// 2026-10-20 SW Footer · Popup · Viewer refinement: Footer Story cleanup, Header field dropdowns inside SWPopup, // SWSlider appearances (panel / property), Popup B, viewer title art at narrow widths. // Usage: build Storybook first, then node packages/sw/tests/footer-popup-viewer-refinement.browser.mjs [evidence-dir] import { readFile, writeFile, mkdir } from 'node:fs/promises' import path from 'node:path' import http from 'node:http' import { runtimeRequire } from './playwright-runtime.mjs' const require = runtimeRequire() const { chromium } = require('playwright') const out = path.resolve(process.argv[2] ?? 'docs/sw/footer-popup-viewer-refinement-qa'); await mkdir(out, { recursive: true }) const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' } const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } }) await new Promise(r => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}` const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' }) const report = {} const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : JSON.stringify(d)) } const index = JSON.parse(await readFile(path.join(root, 'index.json'), 'utf8')) let errors = [] const page = await browser.newPage({ viewport: { width: 1920, height: 1100 } }) page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) }) const goto = async id => { errors = []; await page.goto(`${base}/iframe.html?id=${id}&viewMode=story`); await page.locator('#storybook-root > *').first().waitFor(); await page.waitForTimeout(300) } const log = () => page.locator('[data-callback-log]').first().textContent() // 1. Footer Stories const footer = Object.values(index.entries).filter(e => e.type === 'story' && e.title === 'SW/Footer').map(e => e.id) check('Footer: 기본 Footer / States removed, full-footer + figma-variants kept', !footer.includes('sw-footer--default') && !footer.includes('sw-footer--states') && footer.includes('sw-footer--full-footer') && footer.includes('sw-footer--figma-variants'), footer) await goto('sw-footer--full-footer') const sections = await page.locator('h2').allTextContents() // 2026-10-20 (second pass): 전체 Footer — Figma 기본 구성, 상태, 참고: 실제 화면 were removed; the live composition stays. check('Footer: full-footer keeps only 실제 Footer 조합 (3 sections removed)', sections.some(s => s.startsWith('실제 Footer 조합')) && !['전체 Footer — Figma', '상태 —', '참고: 실제 화면'].some(t => sections.some(s => s.startsWith(t))) && errors.length === 0, { sections, errors }) check('Footer: one live footer, no Figma / reference image', await page.locator('.hm-sw-footer').count() === 1 && await page.locator('img[src*="composition-2026"], img[src*="narrow-2026"], [aria-label^="Figma 26:10097"]').count() === 0, await page.locator('.hm-sw-footer').count()) // command area does not move when the category changes (workspace footer) const work = page.locator('.sw-footer-story-workspace .hm-sw-footer') const slotBox = () => work.locator('.hm-sw-footer__slots').evaluate(e => { const r = e.getBoundingClientRect(); return [Math.round(r.x), Math.round(r.width), e.querySelectorAll('.hm-sw-footer-slot').length] }) const before = await slotBox() await work.locator('.hm-sw-footer-menu__tab').nth(1).click(); await page.waitForTimeout(150) check('Footer: category change keeps the command slot position', JSON.stringify(await slotBox()) === JSON.stringify(before), { before, after: await slotBox() }) // Hover · gradient (2026-10-20): side buttons light up whole (both lines + orange ring), + buttons, Model / Layout tabs. const ORANGE = 'linear-gradient(rgb(218, 104, 19) 8.52%, rgb(255, 187, 137) 37.93%, rgb(229, 105, 13) 71.85%, rgb(174, 54, 7) 92.77%)' const hoverRead = async (sel, fn) => { const el = work.locator(sel).first(); await el.hover(); await page.waitForTimeout(120); const r = await el.evaluate(fn); await page.mouse.move(2, 2); await page.waitForTimeout(80); return r } for (const side of ['start', 'end']) { const sel = side === 'end' ? '.hm-sw-footer-side[data-side="end"]' : '.hm-sw-footer-side:not([data-side="end"])' const r = await hoverRead(sel, e => ({ title: getComputedStyle(e.querySelector('.hm-sw-footer-side__title')).color, sub: getComputedStyle(e.querySelector('.hm-sw-footer-side__subtitle')).color, ring: getComputedStyle(e, '::after').opacity, w: e.getBoundingClientRect().width, h: e.getBoundingClientRect().height })) check(`Footer: ${side === 'end' ? '명령어' : '보조기능'} 전체보기 hover = both lines accent + orange ring, 74 × 71`, r.title === 'rgb(255, 92, 0)' && r.sub === 'rgb(255, 92, 0)' && r.ring === '1' && r.w === 74 && r.h === 71, r) } const idle = await work.locator('.hm-sw-footer-side[data-side="end"]').evaluate(e => [getComputedStyle(e.querySelector('.hm-sw-footer-side__subtitle')).color, getComputedStyle(e, '::after').opacity]) check('Footer: side button default unchanged (subtitle #F6F4F2, no ring)', idle[0] === 'rgb(246, 244, 242)' && idle[1] === '0', idle) const menuAdd = await hoverRead('.hm-sw-footer-menu__add', e => ({ bg: getComputedStyle(e).backgroundImage, w: e.getBoundingClientRect().width })) check('Footer: category + hover = orange + (menu-add-hover, #FF5C00), 28px', /FF5C00/i.test(decodeURIComponent(menuAdd.bg)) && menuAdd.w === 28, menuAdd.bg.slice(0, 80)) const tabsAdd = await hoverRead('.hm-sw-footer-tabs__add', e => ({ bg: getComputedStyle(e, '::before').backgroundImage, w: e.getBoundingClientRect().width })) check('Footer: Layout + hover = gra-border-orange surface with the +, 24px', tabsAdd.bg.includes(ORANGE) && tabsAdd.bg.startsWith('url(') && tabsAdd.w === 24, tabsAdd.bg.slice(0, 160)) const tabInfo = e => { const t = e.querySelector('.hm-sw-footer-tab__select'), l = e.querySelector('.hm-sw-footer-tab__label') ?? t, c = getComputedStyle(t), r = e.getBoundingClientRect(), lr = l.getBoundingClientRect(); return { bg: getComputedStyle(e).backgroundImage, before: getComputedStyle(e, '::before').backgroundImage, color: c.color, size: c.fontSize, weight: c.fontWeight, shadow: c.textShadow === 'none' ? 'none' : 'outline', text: [Math.round((lr.x - r.x) * 10), Math.round((lr.y - r.y) * 10)].join(','), w: Math.round(r.width), h: Math.round(r.height) } } const textOf = t => [t.color, t.size, t.weight, t.shadow, t.text, t.w, t.h].join('|') const l02d = await work.locator('.hm-sw-footer-tab[data-shape="rounded"][data-state="off"]').evaluate(tabInfo) const l01d = await work.locator('.hm-sw-footer-tab[data-shape="slant"][data-state="off"]').evaluate(tabInfo) const model = await work.locator('.hm-sw-footer-tab--model').evaluate(tabInfo) check('Footer: Model (selected) = gra-border-orange surface, black #161410 text without outline, 73 × 22', model.before === ORANGE && model.color === 'rgb(22, 20, 16)' && model.shadow === 'none' && model.w === 73 && model.h === 22, model) const l02 = await hoverRead('.hm-sw-footer-tab[data-shape="rounded"][data-state="off"]', tabInfo) const l01 = await hoverRead('.hm-sw-footer-tab[data-shape="slant"][data-state="off"]', tabInfo) check('Footer: Layout 02 hover = Model surface; text identical to Default (black, size, weight, place, box)', l02.bg === ORANGE && textOf(l02) === textOf(l02d) && l02.color === 'rgb(22, 20, 16)' && l02.weight === model.weight && l02.size === model.size && l02.w === 85, { l02, l02d }) check('Footer: Layout 01 hover = Model surface (slanted); text identical to Default', l01.before === ORANGE && textOf(l01) === textOf(l01d) && l01.color === 'rgb(22, 20, 16)' && l01.weight === model.weight && l01.size === model.size && l01.w === 97, { l01, l01d }) const l01idle = await work.locator('.hm-sw-footer-tab[data-shape="slant"][data-state="off"]').evaluate(tabInfo) check('Footer: Layout tabs return to their default look after hover', l01idle.before !== ORANGE && textOf(l01idle) === textOf(l01d), l01idle) // Category pocket (2026-10-20): every selected tab shows one continuous pocket surface (no vacant surface under the row). const pockets = [] for (let i = 0; i < 4; i++) { await work.locator('.hm-sw-footer-menu__tab').nth(i).click(); await page.mouse.move(2, 2); await page.waitForTimeout(120) pockets.push(await work.locator('.hm-sw-footer-menu').evaluate(m => { const t = m.querySelector('[aria-selected="true"]').getBoundingClientRect(), y = t.y + 4 const under = x => document.elementsFromPoint(x, y).find(e => e.matches('.hm-sw-footer__vacant, .hm-sw-footer__group'))?.className return [m.querySelector('[aria-selected="true"]').textContent, under(t.x + 3), under(t.x + t.width / 2), under(t.right - 3)] })) } check('Footer: category pocket surface is the same across the whole tab for 1st · 2nd · 3rd · 4th tab', pockets.every(p => p[1] === p[2] && p[2] === p[3] && p[1] === 'hm-sw-footer__group'), pockets) const vacantH = await work.locator('.hm-sw-footer__vacant').evaluate(e => Math.round(e.getBoundingClientRect().height)) check('Footer: vacant surface covers the slot row only (46px)', vacantH === 46, vacantH) await work.locator('.hm-sw-footer-menu__tab').nth(0).click(); await page.mouse.move(2, 2) await work.locator('.hm-sw-footer-tab[data-shape="rounded"] .hm-sw-footer-tab__select').click(); await page.waitForTimeout(120) const l02on = await work.locator('.hm-sw-footer-tab[data-shape="rounded"]').evaluate(e => [e.dataset.state, getComputedStyle(e).backgroundImage]) check('Footer: a selected Layout keeps its own (brown) selected surface, distinct from hover', l02on[0] === 'on' && l02on[1] !== ORANGE && l02on[1].includes('linear-gradient'), l02on) await page.screenshot({ path: path.join(out, 'footer-full.png'), fullPage: true }) await goto('sw-footer--figma-variants') check('Footer: aux Figma comparison lives in figma-variants (6 rows)', await page.locator('.sw-footer-story-aux-row').count() === 6 && errors.length === 0, errors) await goto('sw-footer--responsive') await page.setViewportSize({ width: 1920, height: 1100 }) const panelOrder = await page.locator('.sw-footer-story-width').nth(4).evaluate(async w => { const toggle = [...w.querySelectorAll('button')].find(b => b.getAttribute('aria-expanded') !== null && b.closest('.hm-sw-footer') && !b.closest('.hm-sw-footer-command')) return toggle ? toggle.getAttribute('aria-label') : null }) check('Footer: responsive 860px row renders with an overflow ▲ control', panelOrder !== null && errors.length === 0, { panelOrder, errors }) const respSections = await page.locator('h2').allTextContents() check('Footer: 화면 폭 변화 keeps the 6 widths, 참고 좁은 화면 + Figma 기본 구성 removed', await page.locator('.sw-footer-story-width').count() === 6 && !respSections.some(t => t.startsWith('참고: 실제 좁은 화면') || t.startsWith('Figma 기본 구성')) && await page.locator('img[src*="narrow-2026"]').count() === 0, respSections) // 2. Popup layer / color = the property window pickers (SWPopupPropertyRow); SWHeader field dropdowns stay header-only. await goto('sw-header--overview') const headerField = await page.locator('.hm-sw-header .hm-sw-header__field--layer').first().evaluate(e => { const c = getComputedStyle(e), v = getComputedStyle(e.querySelector('.hm-sw-header__field-value')); return [c.color, c.height, v.color, v.fontWeight].join('|') }) // 2026-10-20: SWHeader field values are Regular (400) (user decision, kept with Noto Sans KR; hover-stability.browser.mjs). check('Header: layer field keeps the header design (#F6F4F2, 38px, value 400)', headerField === 'rgb(246, 244, 242)|38px|rgb(246, 244, 242)|400', headerField) await goto('sw-popup--custom-content') const rowSig = loc => loc.evaluate(row => { const s = e => { const c = getComputedStyle(e); return [c.color, c.backgroundColor, c.backgroundImage.slice(0, 30), c.borderTopColor, c.height, c.fontSize, c.fontWeight, c.textShadow].join(',') }; return ['.hm-sw-property__value', '.hm-sw-property__trigger, .hm-sw-header__input', 'label'].map(q => row.querySelector(q) ? s(row.querySelector(q)) : '-').join(' / ') + ' / ' + row.querySelector('.hm-sw-property__value')?.dataset.picker }) const panelRows = page.locator('[data-popups] .hm-sw-property').nth(2) const settingsPopup = page.locator('[data-popup="settings"]') for (const id of ['layer', 'color']) { const a = await rowSig(settingsPopup.locator(`[data-property-id="${id}"]`)), b = await rowSig(panelRows.locator(`[data-property-id="${id}"]`)) check(`Popup: 도면 설정 ${id} = 속성창 ${id} picker (value, trigger, label computed look)`, a === b && a.endsWith(id), { a, b }) } check('Popup: no SWHeader field dropdown inside any popup', await page.locator('.hm-sw-property .hm-sw-header__field').count() === 0) await settingsPopup.locator('[data-property-id="layer"] .hm-sw-property__trigger').click(); await page.waitForTimeout(120) const drop = await page.locator('.hm-sw-property__drop[data-picker="layer"]').evaluate(e => getComputedStyle(e).position) await page.locator('.hm-sw-property__drop[data-picker="layer"] .hm-sw-header__layer-item', { hasText: 'HATCH' }).click(); await page.waitForTimeout(100) check('Popup: layer picker opens (fixed overlay), selects and closes', drop === 'fixed' && (await log()).includes('layer=hatch') && await page.locator('.hm-sw-property__drop').count() === 0 && (await settingsPopup.locator('[data-property-id="layer"] .hm-sw-property__text').textContent()) === 'HATCH', await log()) await settingsPopup.locator('[data-property-id="color"] .hm-sw-property__swatch-area').click(); await page.waitForTimeout(120) await page.locator('.hm-sw-property__drop[data-picker="color"]').getByLabel('파랑').click(); await page.waitForTimeout(100) check('Popup: color picker chip commits the RGB value', (await log()).includes('color=0,0,255'), await log()) if (await page.locator('.hm-sw-property__drop').count()) await page.keyboard.press('Escape') const frames = await page.locator('[data-popups] .hm-sw-property').count() check('Popup: frame / title / close unchanged (3 popups in the main section)', frames === 3) // 3. SWSlider appearances const sliders = await page.locator('[data-sw-slider]').evaluateAll(list => list.map(e => ({ look: e.dataset.appearance, h: Math.round(e.getBoundingClientRect().height), fill: e.style.getPropertyValue('--hm-sw-range-fill'), paint: getComputedStyle(e).getPropertyValue('--hm-sw-range-track-paint').trim().length > 0, range: getComputedStyle(e).getPropertyValue('--hm-sw-slider-panel-range').trim() }))) check('Slider: A = panel (18px, no property paint), B = property (16px, property paint), same fill', sliders[0].look === 'panel' && sliders[0].h === 18 && !sliders[0].paint && sliders[1].look === 'property' && sliders[1].h === 16 && sliders[1].paint && sliders[0].fill === sliders[1].fill && sliders[0].range === '#266651', sliders) await page.getByLabel('투명도 B').focus(); await page.keyboard.press('ArrowRight'); await page.waitForTimeout(100) const afterB = await page.locator('[data-sw-slider]').evaluateAll(list => list.map(e => e.value)) check('Slider: one shared value; keyboard change on B commits through onValueCommit', /opacity\(property\)=50/.test(await log()) && afterB.every(v => v === '50'), { log: await log(), afterB }) await page.getByRole('combobox', { name: '게이지바 스타일' }).selectOption('property'); await page.waitForTimeout(100) const chosen = await page.getByLabel('투명도 (선택한 스타일)').evaluate(e => e.dataset.appearance) check('Slider: appearance selection switches the look only', chosen === 'property' && (await log()).includes('appearance=property'), chosen) const disabledSlider = await page.getByLabel('투명도 (비활성)').evaluate(e => ({ d: e.disabled, o: getComputedStyle(e).opacity })) check('Slider: disabled 40%', disabledSlider.d && disabledSlider.o === '0.4', disabledSlider) await page.locator('[data-slider-popups]').screenshot({ path: path.join(out, 'popup-slider-styles.png') }) await page.locator('[data-popups]').screenshot({ path: path.join(out, 'popup-custom-dropdowns.png') }) // The property window gauge itself is unchanged (property-panel-compare) await goto('sw-header--property-panel-compare') const prop = await page.locator('.hm-sw-property__value > input[type="range"]').first().evaluate(e => { const r = e.getBoundingClientRect(); return [Math.round(r.width), Math.round(r.height), getComputedStyle(e).getPropertyValue('--hm-sw-range-track-paint').trim().length > 0] }) check('Slider: property window gauge keeps 86 × 16 and its paint', prop[0] === 86 && prop[1] === 16 && prop[2], prop) // 3b. Unit select: Popup 도면 설정 = Viewer 축척 지정 (same SWSelect, same computed look in every state) const selProps = ['appearance', 'height', 'paddingLeft', 'paddingRight', 'borderTopWidth', 'borderTopColor', 'borderTopStyle', 'borderRadius', 'backgroundColor', 'color', 'fontSize', 'fontWeight', 'lineHeight', 'outlineStyle', 'outlineColor', 'outlineWidth', 'opacity', 'cursor'] const selRead = loc => loc.evaluate((e, p) => { const s = getComputedStyle(e); return p.map(k => `${k}=${s[k]}`).join(';') + `;class=${e.className};tag=${e.tagName}` }, selProps) const selStates = async loc => { const d = await selRead(loc); await loc.hover(); await page.waitForTimeout(80); const h = await selRead(loc); await loc.focus(); await page.keyboard.press('Shift+Tab'); await page.keyboard.press('Tab'); await page.waitForTimeout(80); const f = await selRead(loc); await page.mouse.move(2, 2); return { d, h, f } } await goto('sw-popup--custom-content') const popupSel = page.locator('[data-popup="settings"] [data-sw-select="unit"]') const ps = await selStates(popupSel) const popupOptions = await popupSel.evaluate(e => [...e.options].map(o => o.value)) await popupSel.selectOption('m'); await page.waitForTimeout(80) const popupPicked = [(await log()).includes('unit=m'), await popupSel.inputValue()] await goto('sw-viewer--content-variants') const viewerSel = page.locator('.hm-sw-viewer-panel [data-sw-select="unit"]') const vs = await selStates(viewerSel) const viewerOptions = await viewerSel.evaluate(e => [...e.options].map(o => o.value)) await viewerSel.selectOption('m'); await page.waitForTimeout(80) // 2026-10-21 (user decision): the chosen value of an editable Popup select is red (#E60403) like every editable value; // the Viewer panel keeps its own text color. Everything else of the look stays the same. const noColor = look => look.replace(/(^|;)color=[^;]*/, '$1') check('Select: Popup 도면 설정 단위 = Viewer 축척 지정 단위 (default · hover · focus computed look, text color apart); Popup value red', noColor(ps.d) === noColor(vs.d) && noColor(ps.h) === noColor(vs.h) && noColor(ps.f) === noColor(vs.f) && ps.d.includes('class=hm-sw-select') && ps.d.includes('color=rgb(230, 4, 3)'), { ps, vs }) check('Select: options and selection kept (Popup mm/cm/m, Viewer mm/m), change events', JSON.stringify(popupOptions) === '["mm","cm","m"]' && JSON.stringify(viewerOptions) === '["mm","m"]' && popupPicked[0] && popupPicked[1] === 'm' && await viewerSel.inputValue() === 'm', { popupOptions, viewerOptions, popupPicked }) await page.locator('.hm-sw-viewer-panel', { hasText: '축척 지정' }).screenshot({ path: path.join(out, 'viewer-scale-select.png') }) // 3c. Header Open hysteresis (fix A): grow 520 → 1080, Open returns once and stays; shrink: Open moves first. await goto('sw-header--document-open') const probe = page.locator('[data-responsive-open] > [data-width="520"] .sw-story-frame') const walk = async widths => { const t = []; for (const w of widths) { await probe.evaluate((e, w) => { e.style.width = `${w}px` }, w); await page.waitForTimeout(150); t.push([w, await probe.locator('[data-document-open]').count(), await probe.evaluate(e => e.querySelector('.hm-sw-header__object-area').dataset.squeezed ?? '')]) } return t } const grow = await walk([520, 560, 600, 640, 680, 720, 760, 820, 900, 1080]) const flips = grow.slice(1).filter((s, i) => s[1] !== grow[i][1]).length check('Header: Open returns once while growing 520 → 1080 and stays', grow[0][1] === 0 && grow.at(-1)[1] === 1 && flips === 1, grow) const wide = await walk([1440, 1920]) check('Header: at 1920 every part is back and Open is shown', wide.at(-1)[1] === 1 && wide.at(-1)[2] === '', wide) // 2026-10-21 (user decision): from 760px up nothing moves into More (the fields shrink instead), so Open stays shown // down to 760; below it the measured More takes Open as before. const shrink = await walk([1800, 1440, 1080, 900, 760, 640, 520]) check('Header: shrinking, Open stays next to the logo down to 760 (no More), then moves into More and stays there', shrink.every(s => s[0] < 760 || (s[1] === 1 && s[2] === '')) && shrink.at(-1)[1] === 0 && shrink.filter(s => s[0] < 760).every((s, i, a) => i === 0 || s[1] <= a[i - 1][1]), shrink) const regrow = await walk([560, 600, 640, 680, 720, 900, 1080, 1920]) const reflips = regrow.slice(1).filter((s, i) => s[1] !== regrow[i][1]).length check('Header: regrowing after a shrink, Open returns once and stays', regrow.at(-1)[1] === 1 && reflips <= 1 && regrow.at(-1)[2] === '', regrow) report.header = { grow, wide, shrink, regrow } // 4. Popup B await goto('sw-popup--multiple-popups') const frame = id => page.locator(`[data-popup="${id}"]`).evaluate(e => { const s = getComputedStyle(e), t = getComputedStyle(e.querySelector('.hm-sw-property__title')); return [Math.round(e.getBoundingClientRect().width), s.backgroundColor, s.borderTopColor, t.color, t.fontWeight].join('|') }) check('Popup B: same frame as Popup A', await frame('a') === await frame('b'), [await frame('a'), await frame('b')]) check('Popup B: popup layer picker (no SWHeader field)', await page.locator('[data-popup="b"] .hm-sw-header__field').count() === 0 && await page.locator('[data-popup="b"] [data-property-id="layer"] [data-picker="layer"]').count() === 1) await page.locator('[data-popup="b"] [data-property-id="layer"] .hm-sw-property__trigger').click() await page.locator('.hm-sw-property__drop[data-picker="layer"] .hm-sw-header__layer-item', { hasText: 'DIM' }).click() check('Popup B: layer selection callback and field value', (await log()).includes('b.layer=dim') && (await page.locator('[data-popup="b"] [data-property-id="layer"] .hm-sw-property__text').textContent()) === 'DIM', await log()) await page.setViewportSize({ width: 375, height: 812 }) await page.waitForTimeout(200) const narrow = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: document.documentElement.clientWidth, b: (r => [Math.round(r.left), Math.round(r.right)])(document.querySelector('[data-popup="b"]').getBoundingClientRect()) })) check('Popup B: 375px, no page overflow and the popup inside the viewport', narrow.sw <= narrow.vw && narrow.b[0] >= 0 && narrow.b[1] <= narrow.vw, narrow) await page.screenshot({ path: path.join(out, 'popup-stack-375.png'), fullPage: true }) await page.setViewportSize({ width: 1920, height: 1100 }) // 5. Viewer title art const art = () => page.locator('.hm-sw-viewer-panel').evaluateAll(list => list.map(p => { const r = p.getBoundingClientRect(), a = p.querySelector('.hm-sw-property__title-art').getBoundingClientRect(), t = p.querySelector('.hm-sw-property__titlebar').getBoundingClientRect(); const before = parseFloat(getComputedStyle(p.querySelector('.hm-sw-property__titlebar'), '::before').width) || 0; return { w: Math.round(r.width), h: Math.round(r.height), artW: Math.round(a.width), gap: Math.max(0, (a.left + 20) - (t.left + before)) } })) await goto('sw-viewer--variable-size') const v = await art() check('Viewer: 250 × auto title art keeps 308px and paints from the left edge (no gap)', v[0].w === 250 && v[0].artW === 308 && v[0].gap < 0.5, v[0]) check('Viewer: 320 / 480 popups unchanged (no gap)', v.slice(1).every(p => p.artW === 308 && p.gap < 0.5) && v[1].w === 320 && v[2].w === 480, v) await page.locator('.hm-sw-viewer-panel').first().screenshot({ path: path.join(out, 'viewer-250-auto.png') }) // collapse + expand, drag const first = page.locator('.hm-sw-viewer-panel').first() const h0 = (await first.boundingBox()).height const collapse = first.locator('button[aria-expanded]').first() await collapse.click(); await page.waitForTimeout(100) const h1 = (await first.boundingBox()).height await collapse.click(); await page.waitForTimeout(100) check('Viewer: 250 × auto collapses to the title bar and expands back to auto height', h1 < 40 && Math.abs((await first.boundingBox()).height - h0) < 1, { h0, h1 }) const box = await first.locator('.hm-sw-property__titlebar').boundingBox() await page.mouse.move(box.x + 60, box.y + 15); await page.mouse.down(); await page.mouse.move(box.x + 120, box.y + 45, { steps: 5 }); await page.mouse.up() const moved = await first.locator('.hm-sw-property__titlebar').boundingBox() check('Viewer: 250 × auto drags by its title bar', Math.round(moved.x - box.x) === 60 && Math.round(moved.y - box.y) === 30, { dx: moved.x - box.x, dy: moved.y - box.y }) await goto('sw-viewer--content-variants') const cv = await art() check('Viewer: content variants (incl. 300px) title art has no gap', cv.every(p => p.artW === 308 && p.gap < 0.5), cv) await goto('sw-viewer--settings-panel') const sl = await page.locator('.hm-sw-viewer-slider').first().evaluate(e => ({ cls: e.className, look: e.dataset.appearance, h: Math.round(e.getBoundingClientRect().height), range: getComputedStyle(e).getPropertyValue('--hm-sw-slider-panel-range').trim(), track: getComputedStyle(e).getPropertyValue('--hm-sw-slider-panel-track').trim() })) check('Viewer: SWViewerSlider = SWSlider panel with viewer tokens (#266651 / #e4dbc9)', sl.cls.includes('hm-sw-slider') && sl.look === 'panel' && sl.h === 18 && sl.range === '#266651' && sl.track === '#e4dbc9', sl) await writeFile(path.join(out, 'result.json'), JSON.stringify({ date: new Date().toISOString(), checks, report }, null, 1)) console.log(`${checks.filter(c => c.ok).length}/${checks.length} PASS`) await browser.close(); server.close() process.exit(checks.every(c => c.ok) ? 0 : 1)