Files
HMDesign/packages/sw/tests/footer-popup-viewer-refinement.browser.mjs
T
shkim4 2fc1b3e108 fix(sw): 2026-10-21 SW design review and responsive header without More from 760px
User-approved design review (2026-10-21):
- SW Header: from 760px up nothing moves into More; the five menus stay with their
  titles (titles and values ellipsize), file name shrinks to 96px. Open next to the
  logo no longer blocks field shrinking above 760px (760/768 overflow fixed, also for
  configured `controls` headers). Dropdown yellow ring inside the button, black seam.
- Property Bar values as read-only red text; property popup "=" labels, read-only
  black values, per-entry hover with pointer.
- Home Menu recent files (3 rows, path + name caption, empty preview), Footer + slot
  guide off by default, Viewer file list without icons, Storybook widths 1920-760.
- Tests: old More expectations (integrated-improvement, keyboard-dismiss,
  footer-popup-viewer-refinement, storybook-cleanup-stage1) moved to the new rule;
  functional checks kept.
2026-10-21 17:29:25 +09:00

220 lines
26 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')
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)