Files
HMDesign/packages/sw/tests/footer-popup-viewer-refinement.browser.mjs
T
shkim4 6d46289fc5
Deploy Storybook / deploy (push) Successful in 1m12s
feat(sw): measured header compaction, gauge recolor, popup menu and story cleanups
- SWHeader (760-1799px): pictures hide one at a time by measured room (치수 아이콘, 선 그림,
  글꼴명, 색상 칩, 레이어 상태 아이콘), then the file name narrows 310 -> 96px, then values
  ellipsize; re-fit after web fonts load. 가중치/스케일 columns grow for longer values.
- Gauge style B / property window range: Figma controll_bar_new 633:77827 colors, ring and
  shadow on the previous size (6px track, 14px thumb).
- Property window: editable text values (e.g. 하이퍼링크) get the editing face like numbers.
- SW Header › Logo: same cases, order and layout as SW Viewer › Logo (ID kept).
- Storybook inner: 80vw from 1920px. Responsive More 1920px rows use the page's usable width
  (container sw-story-page) and span 1920px when there is room.
- SW/Popup sidebar: Property Panel only; Custom Content / Multiple Popups tagged verification.
- SW Viewer Settings Popup: 축척 지정 demo popup removed, frame 620px.
- Tests: inventory / simplification sidebar expectations, Popup-only unit select check.

Verified: check:ai-docs PASS, changed test syntax, headless Chrome checks of header compaction,
Responsive 1920 rows (1920 hidden / 2560, 3840 shown), Popup sidebar, Settings Popup.
Not verified: integrated-visual baselines (not updated), full regression, changed tests not run.
2026-10-22 20:50:39 +09:00

213 lines
25 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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')
// 2026-10-22: the composition section has no heading; its name is the section aria-label.
const sections = [...await page.locator('h2').allTextContents(), ...await page.locator('section[aria-label]').evaluateAll(list => list.map(e => e.getAttribute('aria-label')))]
// 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 (2026-10-22: Custom Content shows only style B, appearance="property"; no panel gauge on the page)
const sliders = await page.locator('[data-sw-slider]').evaluateAll(list => list.map(e => ({ look: e.dataset.appearance, h: Math.round(e.getBoundingClientRect().height), paint: getComputedStyle(e).getPropertyValue('--hm-sw-range-track-paint').trim().length > 0 })))
const panelGauges = await page.locator('[data-popups] .hm-sw-slider[data-appearance="panel"], [data-slider-popups] .hm-sw-slider[data-appearance="panel"]').count()
check('Slider: B = property (16px, property paint); no panel (A) gauge left', sliders.length === 1 && sliders[0].look === 'property' && sliders[0].h === 16 && sliders[0].paint && panelGauges === 0, { sliders, panelGauges })
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: keyboard change on B commits through onValueCommit', /opacity\(property\)=50/.test(await log()) && afterB.every(v => v === '50'), { log: await log(), afterB })
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 도면 설정 (SWSelect). 2026-10-22 (user request): the Viewer 축척 지정 demo popup was removed,
// so the Viewer side of this comparison is gone; the Popup select keeps its look and behaviour checks.
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()]
// 2026-10-21 (user decision): the chosen value of an editable Popup select is red (#E60403) like every editable value.
check('Select: Popup 도면 설정 단위 is SWSelect in default · hover · focus; value red', [ps.d, ps.h, ps.f].every(look => look.includes('class=hm-sw-select')) && ps.d.includes('color=rgb(230, 4, 3)'), { ps })
check('Select: options and selection kept (Popup mm/cm/m), change events', JSON.stringify(popupOptions) === '["mm","cm","m"]' && popupPicked[0] && popupPicked[1] === 'm', { popupOptions, popupPicked })
// 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')
// 2026-10-22: the viewer Story gauges are SWSlider style B (appearance="property"), no green panel gauge.
const sl = await page.locator('.hm-sw-viewer-panel .hm-sw-slider').evaluateAll(list => list.map(e => ({ look: e.dataset.appearance, h: Math.round(e.getBoundingClientRect().height), viewer: e.classList.contains('hm-sw-viewer-slider') })))
check('Viewer: gauges = SWSlider style B (property, 16px), no SWViewerSlider panel gauge', sl.length > 0 && sl.every(e => e.look === 'property' && e.h === 16 && !e.viewer), 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)