Deploy Storybook / deploy (push) Successful in 1m12s
- 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.
213 lines
25 KiB
JavaScript
213 lines
25 KiB
JavaScript
// 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)
|