fix(sw): refine property panel and Firefox range styles

- Property bar ends after its last field (content width, scrolls when longer)
- Remove Figma canvas layers from property tool and close SVGs
- Suppress header field hover while the field's own dropdown is open
- Gauge: Figma red fill pill, beige track, Figma thumb SVG; shared
  paint for WebKit and Gecko (::-moz-range-*) so Firefox matches Chrome
- Story buttons for gauge external value / disabled / read-only
- Browser checks: follow-up fixes, Chrome vs Firefox range, close button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shkim4andClaude Opus 5.5 committed 2026-10-19 09:10:13 +09:00
1 parent fc26afd440
commit 724c51732b
66 files changed
+3823 -39

No files matched your search

+195
View File
@@ -0,0 +1,195 @@
// Usage: node packages/sw/tests/followup-four.browser.mjs <evidence-dir> [storybook-url] (Storybook uses packages/sw/dist: build first)
import { createRequire } from 'node:module'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
const out = path.resolve(process.argv[2])
const base = process.argv[3] ?? 'http://127.0.0.1:6024'
const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
await mkdir(out, { recursive: true })
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 } })
const report = { checks: [], errors: [], data: {} }
page.on('pageerror', e => report.errors.push(e.message))
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, JSON.stringify(data)) }
const goto = async (id, w = 1920) => {
await page.setViewportSize({ width: w, height: 1000 })
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story`)
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
await page.evaluate(() => document.fonts.ready)
await page.mouse.move(1, 1)
}
// Pixel sampler on a screenshot buffer (decoded in page canvas).
const pixels = async (buf, pts) => page.evaluate(async ([b64, pts]) => {
const i = new Image(); i.src = 'data:image/png;base64,' + b64; await i.decode()
const c = document.createElement('canvas'); c.width = i.width; c.height = i.height; const x = c.getContext('2d'); x.drawImage(i, 0, 0)
return pts.map(([px, py]) => [...x.getImageData(px, py, 1, 1).data].slice(0, 3))
}, [buf.toString('base64'), pts])
const near = (a, b, t = 12) => a.every((v, k) => Math.abs(v - b[k]) <= t)
// ---- A. Property bar
await goto('property-bar')
const toggleBar = page.locator('.hm-sw-header [data-property-bar-trigger], .hm-sw-header .hm-sw-header__toggle').filter({ hasText: '속성Bar' }).first()
await toggleBar.click()
const barGeo = () => page.evaluate(() => {
const bar = document.querySelector('.hm-sw-header .hm-sw-property-bar'), r = bar.getBoundingClientRect()
const fields = [...bar.querySelectorAll('.hm-sw-property-bar__field')], last = fields.at(-1).getBoundingClientRect()
const s = getComputedStyle(bar), header = document.querySelector('.hm-sw-header').getBoundingClientRect()
return { left: r.left, right: r.right, width: r.width, height: r.height, lastFieldRight: last.right, scrollW: bar.scrollWidth, clientW: bar.clientWidth,
borderBottom: s.borderBottomWidth + ' ' + s.borderBottomColor, radius: s.borderBottomLeftRadius + '/' + s.borderBottomRightRadius, bg: s.backgroundColor,
headerH: header.height, headerRight: header.right, fieldCount: fields.length }
})
let g = await barGeo()
report.data.barDefault = g
check('A1 bar ends after last field (padding 6 + border 1)', Math.abs(g.right - g.lastFieldRight - 7) <= 1 && g.width < 1222, g)
check('A2 height 28 / header 49 / radius & border kept', g.height === 28 && g.headerH === 49 && g.radius === '6px/6px' && g.borderBottom.startsWith('1px'), g)
await page.locator('.hm-sw-header .hm-sw-property-bar').screenshot({ path: path.join(out, 'A-bar-1920.png') })
// Background and bottom line end at the same x: sample 2px inside and 2px outside the right edge at the bottom border row.
const barTop = await page.evaluate(() => document.querySelector('.hm-sw-header .hm-sw-property-bar').getBoundingClientRect().top)
const shot = await page.screenshot({ clip: { x: Math.floor(g.right) - 20, y: Math.floor(barTop), width: 40, height: 28 } })
await page.screenshot({ path: path.join(out, 'A-bar-right-end.png'), clip: { x: Math.floor(g.right) - 60, y: Math.floor(barTop) - 10, width: 120, height: 40 } })
const ex = g.right - (Math.floor(g.right) - 20)
const [inBg, outBg, inLine, outLine] = await pixels(shot, [[Math.floor(ex) - 4, 22], [Math.ceil(ex) + 3, 22], [Math.floor(ex) - 8, 27], [Math.ceil(ex) + 3, 27]])
check('A3 background and bottom line end together; nothing brown beyond', near(inBg, [0x27, 0x1f, 0x13], 10) && !near(outBg, [0x27, 0x1f, 0x13], 10) && !near(outLine, inLine, 10), { inBg, outBg, inLine, outLine })
for (let n = 0; n < 6; n++) await page.getByRole('button', { name: 'Bar 필드 추가' }).click()
g = await barGeo(); report.data.barGrown = g
check('A4 bar grows with added fields, none clipped', g.fieldCount === 10 && Math.abs(g.right - g.lastFieldRight - 7) <= 1 && g.scrollW <= g.clientW + 1, g)
for (let n = 0; n < 14; n++) await page.getByRole('button', { name: 'Bar 필드 추가' }).click()
g = await barGeo(); report.data.barOverflow = g
const lastVisible = await page.evaluate(() => { const bar = document.querySelector('.hm-sw-header .hm-sw-property-bar'); bar.scrollLeft = bar.scrollWidth; const f = [...bar.querySelectorAll('.hm-sw-property-bar__field')].at(-1).getBoundingClientRect(), r = bar.getBoundingClientRect(); return f.right <= r.right + 0.5 })
check('A5 longer than space: capped to header edge, scroll reaches last field', g.right <= g.headerRight + 0.5 && g.scrollW > g.clientW && lastVisible, { ...g, lastVisible })
await page.getByRole('button', { name: 'Bar 객체 교체' }).click()
g = await barGeo(); report.data.barReplaced = g
check('A6 bar shrinks back for a 1-field object', g.fieldCount === 1 && Math.abs(g.right - g.lastFieldRight - 7) <= 1, g)
for (const w of [1440, 1080, 768]) {
await goto('property-bar', w)
const t = page.locator('.hm-sw-header .hm-sw-header__toggle').filter({ hasText: '속성Bar' }).first()
if (await t.isVisible()) await t.click(); else { await page.locator('.hm-sw-header__more-trigger').click(); await page.locator('.hm-sw-header__more-item').filter({ hasText: '속성Bar' }).click() }
const gg = await barGeo(); report.data[`bar${w}`] = gg
const overflowX = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)
check(`A7 width ${w}: bar within header, height 28, no page scroll`, gg.right <= gg.headerRight + 0.5 && gg.height === 28 && gg.headerH === 49 && !overflowX, { ...gg, overflowX })
}
// ---- B. Tool icon
await goto('property-panel-all')
const tool = page.locator('.hm-sw-property__tool').first()
const toolInfo = await tool.evaluate(e => { const s = getComputedStyle(e), imgs = [...e.querySelectorAll('img')].map(i => ({ cls: i.className, w: i.getBoundingClientRect().width, h: i.getBoundingClientRect().height, src: i.getAttribute('src').slice(0, 60) })); return { border: s.borderTopWidth + ' ' + s.borderTopColor, outline: s.outlineStyle, bg: s.backgroundColor, w: e.getBoundingClientRect().width, h: e.getBoundingClientRect().height, imgs } })
report.data.tool = toolInfo
check('B1 no CSS border/outline/background on tool button', toolInfo.border.startsWith('0px') && toolInfo.outline === 'none' && toolInfo.bg === 'rgba(0, 0, 0, 0)', toolInfo)
check('B2 images 29 x 27.13 equal the button', toolInfo.imgs.every(i => i.w === 29 && Math.abs(i.h - 27.13) < 0.02), toolInfo.imgs)
const toolCorners = async name => {
const box = await tool.boundingBox()
const clip = { x: Math.floor(box.x) - 3, y: Math.floor(box.y) - 3, width: 36, height: 34 }
const b = await page.screenshot({ clip, scale: 'css' })
await page.screenshot({ path: path.join(out, `B-tool-${name}.png`), clip })
// Any near-white pixel within the 29x27 button area means a light frame.
const pts = []; for (let y = 3; y < 30; y++) for (let x = 3; x < 32; x++) pts.push([x, y])
const px = await pixels(b, pts)
const white = px.filter(p => p.every(v => v >= 235)).length
const outer = [px[0], px[28], px[pts.length - 29], px[pts.length - 1]] // four corner pixels
return { white, outer }
}
const def = await toolCorners('default')
await tool.hover(); const hov = await toolCorners('hover')
await tool.click(); await page.mouse.move(1, 1); const act = await toolCorners('active')
const pressed = await tool.getAttribute('aria-pressed')
report.data.toolPixels = { def, hov, act, pressed }
check('B3 no white frame pixels (default/hover/active)', def.white === 0 && hov.white === 0 && act.white === 0, { def: def.white, hov: hov.white, act: act.white })
check('B4 active toggles (aria-pressed)', pressed === 'true', { pressed })
// ---- C/D. Dropdown open: no trigger hover, inner hover kept
await goto('additional-hover')
const ringOpacity = sel => page.evaluate(s => { const e = document.querySelector(s); return { after: getComputedStyle(e, '::after').opacity, color: getComputedStyle(e).color, expanded: e.dataset.expanded ?? null } }, sel)
for (const menu of ['layer', 'color', 'text-style', 'dimension-style', 'line-style']) {
const sel = `.hm-sw-header .hm-sw-header__field[data-menu="${menu}"]`
const field = page.locator(sel).first()
await page.mouse.move(1, 1)
await field.hover(); const closedHover = await ringOpacity(sel)
await field.click(); await page.mouse.move(1, 1)
const openIdle = await ringOpacity(sel)
await field.hover(); const openHover = await ringOpacity(sel)
const shotIdle = null
await page.screenshot({ path: path.join(out, `C-${menu}-open-hover.png`), clip: { ...(await field.boundingBox()), x: (await field.boundingBox()).x - 4, y: 0, width: (await field.boundingBox()).width + 8, height: 60 } })
// Inner list row + detail hover
const panel = page.locator('.hm-sw-header [data-open="true"]').first()
const rows = panel.locator('.hm-sw-header__menu-row:not([data-active="true"]):not([data-disabled]), .hm-sw-header__layer-row:not([data-active="true"]), .hm-sw-header__color-mode:not(:disabled)')
let rowChanged = null
for (let k = 0; k < Math.min(2, await rows.count()); k++) {
const row = rows.nth(k); await page.mouse.move(1, 1)
const st = e => { const t = e.querySelector('.hm-sw-header__menu-text') ?? e; return getComputedStyle(e).backgroundColor + '|' + getComputedStyle(t).color }
const b0 = await row.evaluate(st)
await row.hover(); const b1 = await row.evaluate(st)
rowChanged = rowChanged || b0 !== b1
}
const detail = panel.locator('.hm-sw-header__menu-detail').first()
let detailChanged = null
if (await detail.count()) {
await page.mouse.move(1, 1)
const d0 = await detail.evaluate(e => getComputedStyle(e).backgroundColor + '|' + getComputedStyle(e).boxShadow + '|' + getComputedStyle(e).color)
await detail.hover(); const d1 = await detail.evaluate(e => getComputedStyle(e).backgroundColor + '|' + getComputedStyle(e).boxShadow + '|' + getComputedStyle(e).color)
detailChanged = d0 !== d1
}
await page.keyboard.press('Escape'); await page.mouse.move(1, 1)
const closedAgain = await ringOpacity(sel)
await field.hover(); const restored = await ringOpacity(sel)
await page.mouse.move(1, 1)
const res = { closedHover, openIdle, openHover, closedAgain, restored, rowChanged, detailChanged }
report.data[`dropdown-${menu}`] = res
check(`C ${menu}: open+hover == open idle`, openIdle.expanded === 'true' && openHover.after === openIdle.after && openHover.color === openIdle.color, res)
check(`C ${menu}: hover restored after close`, closedAgain.expanded === null && restored.after === '1' && closedHover.after === '1' && restored.color === closedHover.color, res)
check(`D ${menu}: inner row hover kept / detail hover kept`, rowChanged !== false && detailChanged !== false, { rowChanged, detailChanged })
}
// Focus-visible on an open trigger still shows the outline
{
const sel = '.hm-sw-header .hm-sw-header__field[data-menu="color"]'
await page.locator(sel).first().focus(); await page.keyboard.press('Enter'); await page.keyboard.press('Shift+Tab').catch(() => {})
}
// ---- E. Gauge
await goto('property-panel-circle')
const range = page.locator('.hm-sw-property input[type="range"]').first()
const gaugeState = () => range.evaluate(e => ({ value: e.value, fill: e.style.getPropertyValue('--hm-sw-range-fill'), track: getComputedStyle(e).getPropertyValue('--hm-sw-property-range-track').trim() }))
const gaugePixels = async name => {
const b = await range.boundingBox()
const clip = { x: Math.floor(b.x), y: Math.floor(b.y), width: Math.ceil(b.width), height: Math.ceil(b.height) }
const buf = await page.screenshot({ clip })
await page.screenshot({ path: path.join(out, `E-gauge-${name}.png`), clip })
const st = await gaugeState(), f = Number(st.fill), cx = 7 + (clip.width - 14) * f, mid = Math.floor(clip.height / 2)
const [left, right] = await pixels(buf, [[Math.max(2, Math.floor(cx - 10)), mid], [Math.min(clip.width - 3, Math.ceil(cx + 10)), mid]])
return { ...st, left, right, width: clip.width }
}
let gs = await gaugePixels('initial'); report.data.gaugeInitial = gs
const isRed = p => p[0] > 150 && p[1] < 90 && p[2] < 90, isBeige = p => p[0] > 190 && p[1] > 180 && p[2] > 160 && p[0] - p[2] > 15
check('E1 initial 0.25: red left of thumb, beige right', gs.value === '0.25' && Math.abs(Number(gs.fill) - 0.25) < 1e-9 && isRed(gs.left) && isBeige(gs.right), gs)
await range.focus(); for (let n = 0; n < 40; n++) await page.keyboard.press('ArrowRight')
gs = await gaugePixels('keyboard'); report.data.gaugeKeyboard = gs
check('E2 keyboard ArrowRight x40 -> 0.65, fill follows', gs.value === '0.65' && Math.abs(Number(gs.fill) - 0.65) < 1e-9 && isRed(gs.left) && isBeige(gs.right), gs)
await page.keyboard.press('Enter')
await page.waitForFunction(() => document.querySelector('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input')?.value === '0.65', null, { timeout: 2000 }).catch(() => {})
const committed = await page.locator('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input').first().inputValue().catch(() => null)
report.data.gaugeCommitted = committed
check('E3 commit updates the number input (callback path)', committed === '0.65', { committed })
await page.keyboard.press('End'); gs = await gaugePixels('max'); report.data.gaugeMax = gs
check('E4 max -> fill 1', gs.value === '1' && Number(gs.fill) === 1 && isRed(gs.left), gs)
await page.keyboard.press('Home'); gs = await gaugePixels('min'); report.data.gaugeMin = gs
check('E5 min -> fill 0, beige right', gs.value === '0' && Number(gs.fill) === 0 && isBeige(gs.right), gs)
// External value: type into the numeric input and commit
const num = page.locator('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input').first()
await num.click(); await num.fill('0.5'); await num.press('Enter'); await page.mouse.move(1, 1)
gs = await gaugeState(); report.data.gaugeExternal = gs
check('E6 value changed elsewhere syncs gauge', gs.value === '0.5' && Number(gs.fill) === 0.5, gs)
await page.locator('.hm-sw-property').first().screenshot({ path: path.join(out, 'E-panel-circle.png') })
// ---- F. Panel four states render
for (const id of ['property-panel-none', 'property-panel-all', 'property-panel-circle', 'property-panel-expanded']) {
await goto(id)
const sz = await page.locator('.hm-sw-property').first().evaluate(e => { const r = e.getBoundingClientRect(); return [r.width, r.height] })
report.data[id] = sz
await page.locator('.hm-sw-property').first().screenshot({ path: path.join(out, `F-${id}.png`) })
check(`F ${id} renders`, sz[0] === 314 && sz[1] > 200, sz)
}
check('G no page/console errors', report.errors.length === 0, report.errors)
await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2))
await browser.close()
console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length)
@@ -0,0 +1,62 @@
// Property panel close button (Figma 26:12366): canvas bleed, Default / Hover, close by click and Escape.
// Usage: node packages/sw/tests/property-close.browser.mjs <evidence-dir> <label> [storybook-url]
// Storybook serves packages/sw/dist: build @hm-design/sw first.
import { createRequire } from 'node:module'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
const out = path.resolve(process.argv[2] ?? 'docs/sw/property-close-qa')
const label = process.argv[3] ?? 'run'
const base = process.argv[4] ?? 'http://127.0.0.1:6024'
const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
await mkdir(out, { recursive: true })
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, deviceScaleFactor: 4 })
const report = { label, checks: [], errors: [] }
page.on('pageerror', e => report.errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, JSON.stringify(data)) }
await page.goto(`${base}/iframe.html?id=sw-header--property-panel&viewMode=story`)
await page.locator('.hm-sw-header').waitFor(); await page.evaluate(() => document.fonts.ready)
await page.getByRole('button', { name: '원 1개', exact: true }).click()
const trigger = page.locator('.hm-sw-header [data-property-panel-trigger]')
await trigger.click(); await page.mouse.move(1, 1)
const close = page.locator('.hm-sw-property__close').first()
const box = await close.boundingBox()
const geo = await close.evaluate(e => { const p = e.closest('.hm-sw-property').getBoundingClientRect(), r = e.getBoundingClientRect(); return { w: r.width, h: r.height, right: p.right - r.right, top: r.top - p.top, imgs: [...e.querySelectorAll('img')].map(i => [i.className, i.getBoundingClientRect().width, i.getBoundingClientRect().height]) } })
check('size 44 x 30, top-right of the panel', geo.w === 44 && geo.h === 30, geo)
// Light pixels (all channels >= 235) inside the 44 x 30 button: the Figma canvas is #F5F5F5 / white.
const bright = async name => {
const clip = { x: box.x - 2, y: box.y - 2, width: box.width + 4, height: box.height + 4 }
const buf = await page.screenshot({ clip })
await writeFile(path.join(out, `${label}-${name}.png`), buf)
return page.evaluate(async ([b64, w, h]) => {
const i = new Image(); i.src = 'data:image/png;base64,' + b64; await i.decode()
const c = document.createElement('canvas'); c.width = i.width; c.height = i.height; const x = c.getContext('2d'); x.drawImage(i, 0, 0)
const s = i.width / (w + 4), d = x.getImageData(0, 0, i.width, i.height).data; let n = 0; const where = []
for (let yy = Math.round(2 * s); yy < Math.round((h + 2) * s); yy++) for (let xx = Math.round(2 * s); xx < Math.round((w + 2) * s); xx++) {
const bx = xx / s - 2, by = yy / s - 2, edge = bx < 1.5 || bx > w - 1.5 || by < 1.5 || by > h - 1.5 || (bx > w - 4 && by < 4)
if (!edge) continue // the white X of Hover is intended; the canvas can only show at the edge / rounded corner
const k = (yy * i.width + xx) * 4; if (d[k] >= 235 && d[k + 1] >= 235 && d[k + 2] >= 235) { n++; if (where.length < 6) where.push([+(xx / s - 2).toFixed(2), +(yy / s - 2).toFixed(2)]) } }
return { bright: n, where }
}, [buf.toString('base64'), box.width, box.height])
}
const def = await bright('default')
await close.hover()
const hov = await bright('hover')
const vis = await close.evaluate(e => [...e.querySelectorAll('img')].map(i => getComputedStyle(i).visibility))
report.pixels = { def, hov }
check('Default: no canvas-light pixels on the button edge / corner', def.bright === 0, def)
check('Hover: hover asset shown, no canvas-light pixels on the edge / corner', vis.join() === 'hidden,visible' && hov.bright === 0, { vis, ...hov })
// Click closes; focus returns to the trigger.
await close.click()
let state = await page.evaluate(() => ({ open: !!document.querySelector('.hm-sw-property'), focusTrigger: document.activeElement?.hasAttribute('data-property-panel-trigger'), log: document.querySelector('[data-property-log]').textContent.slice(-60) }))
check('click closes the panel, focus back on 속성창 trigger', !state.open && state.focusTrigger, state)
// Escape closes from inside the panel; focus returns.
await trigger.click(); await page.locator('.hm-sw-property').waitFor()
await page.locator('.hm-sw-property__close').first().focus()
await page.keyboard.press('Escape')
state = await page.evaluate(() => ({ open: !!document.querySelector('.hm-sw-property'), focusTrigger: document.activeElement?.hasAttribute('data-property-panel-trigger'), log: document.querySelector('[data-property-log]').textContent.slice(-60) }))
check('Escape closes the panel, focus back on 속성창 trigger', !state.open && state.focusTrigger, state)
check('no console / page errors', report.errors.length === 0, report.errors)
await writeFile(path.join(out, `${label}-result.json`), JSON.stringify(report, null, 2))
await browser.close()
console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length)
@@ -0,0 +1,195 @@
// Chrome vs Firefox property-panel gauge (range) check on the running Storybook.
// Usage: node packages/sw/tests/range-cross-browser.browser.mjs <evidence-dir> [storybook-url]
// Chrome via Playwright; Firefox = the installed browser driven over WebDriver BiDi (no Playwright build needed).
// Storybook serves packages/sw/dist: build @hm-design/sw first.
import { createRequire } from 'node:module'
import { mkdir, writeFile, rm } from 'node:fs/promises'
import { spawn } from 'node:child_process'
import os from 'node:os'
import path from 'node:path'
const out = path.resolve(process.argv[2] ?? 'docs/sw/firefox-range-qa')
const base = process.argv[3] ?? 'http://127.0.0.1:6024'
const FIREFOX = process.env.SW_FIREFOX_PATH ?? 'C:/Program Files/Mozilla Firefox/firefox.exe'
const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
const STORY = `${base}/iframe.html?id=sw-header--property-panel&viewMode=story`
const VIEW = { width: 1400, height: 1000 }
const sleep = ms => new Promise(r => setTimeout(r, ms))
await mkdir(out, { recursive: true })
// Page-side helpers shared by both browsers (strings evaluated in the page).
const SETUP = `(async () => {
const click = t => [...document.querySelectorAll('button')].find(b => b.textContent.trim() === t).click()
click('원 1개'); await new Promise(r => setTimeout(r, 100))
document.querySelector('.hm-sw-header [data-property-panel-trigger]').click(); await new Promise(r => setTimeout(r, 200))
await document.fonts.ready
// Screenshots compare the gauge itself: headless Firefox driven over BiDi never reports keyboard modality
// (:focus-visible stays false even for plain buttons), so the shared focus ring is hidden in both browsers.
const st = document.createElement('style'); st.textContent = '.hm-sw-property input[type=range]{outline:none!important}'; document.head.append(st)
return 'ok' })()`
const STATE = `(() => { const r = document.querySelector('.hm-sw-property [data-property-id="opacity"] input[type="range"]'), b = r.getBoundingClientRect()
const n = document.querySelector('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input')
return JSON.stringify({ value: r.value, fill: r.style.getPropertyValue('--hm-sw-range-fill'), disabled: r.disabled, number: n.value, x: b.x, y: b.y, w: b.width, h: b.height,
log: document.querySelector('[data-property-log]').textContent.slice(-80), focused: document.activeElement === r, focusVisible: r.matches(':focus-visible'), outline: getComputedStyle(r).outlineStyle + ' ' + getComputedStyle(r).outlineColor }) })()`
// Keyboard focus (same :focus-visible rule in both engines): focus the value input, then a real Tab.
const FOCUS_PREV = `(() => { document.querySelector('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input').focus(); return 'ok' })()`
const CLICK = t => `(() => { [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(t)}).click(); return 'ok' })()`
// ---------- drivers ----------
async function chromeDriver() {
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: VIEW, deviceScaleFactor: 1 })
const errors = []
page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
const keyMap = { ArrowRight: 'ArrowRight', Home: 'Home', End: 'End', Enter: 'Enter', Tab: 'Tab' }
return {
name: 'chrome', version: browser.version(), errors,
goto: async url => { await page.goto(url); await page.locator('.hm-sw-header').waitFor() },
eval: async expr => page.evaluate(expr),
key: async (k, n = 1) => { for (let i = 0; i < n; i++) await page.keyboard.press(keyMap[k]) },
shot: async clip => (await page.screenshot({ clip })).toString('base64'),
close: () => browser.close(),
}
}
async function firefoxDriver() {
const profile = path.join(os.tmpdir(), `sw-ff-profile-${process.pid}`)
await mkdir(profile, { recursive: true })
const port = 9300 + (process.pid % 500)
const proc = spawn(FIREFOX, ['--headless', '--no-remote', '--profile', profile, `--remote-debugging-port=${port}`], { stdio: 'ignore', env: { ...process.env, MOZ_HEADLESS_WIDTH: String(VIEW.width), MOZ_HEADLESS_HEIGHT: String(VIEW.height) } })
let ws
for (let i = 0; i < 60 && !ws; i++) {
await sleep(500)
try { ws = await new Promise((res, rej) => { const s = new WebSocket(`ws://127.0.0.1:${port}/session`); s.onopen = () => res(s); s.onerror = rej }) } catch { ws = undefined }
}
if (!ws) throw new Error('Firefox BiDi not reachable')
let id = 0; const pending = new Map(), errors = []
ws.onmessage = ev => {
const m = JSON.parse(ev.data)
if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.type === 'error' ? rej(new Error(`${m.error}: ${m.message}`)) : res(m.result) }
else if (m.method === 'log.entryAdded' && m.params.level === 'error') errors.push(m.params.text)
}
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej: e => rej(new Error(method + " -> " + e.message)) }); ws.send(JSON.stringify({ id: i, method, params })) })
const session = await send('session.new', { capabilities: {} })
await send('session.subscribe', { events: ['log.entryAdded'] })
const tree = await send('browsingContext.getTree', {})
const context = tree.contexts[0].context
// setViewport needs system access in Firefox; the headless window size above gives the same 1400 x 1000.
const KEYS = { ArrowRight: '\uE014', Home: '\uE011', End: '\uE010', Enter: '\uE007', Tab: '\uE004' }
return {
name: 'firefox', version: session.capabilities.browserVersion, errors,
goto: async url => {
await send('browsingContext.navigate', { context, url, wait: 'complete' })
for (let i = 0; i < 40; i++) { const r = await send('script.evaluate', { expression: `!!document.querySelector('.hm-sw-header')`, target: { context }, awaitPromise: false }); if (r.result.value) return; await sleep(250) }
throw new Error('story did not render in Firefox')
},
eval: async expr => { const r = await send('script.evaluate', { expression: expr, target: { context }, awaitPromise: true }); if (r.type === 'exception') throw new Error(r.exceptionDetails.text); return r.result.value },
key: async (k, n = 1) => { const a = []; for (let i = 0; i < n; i++) a.push({ type: 'keyDown', value: KEYS[k] }, { type: 'keyUp', value: KEYS[k] }); await send('input.performActions', { context, actions: [{ type: 'key', id: 'kb', actions: a }] }) },
shot: async clip => (await send('browsingContext.captureScreenshot', { context, clip: { type: 'box', ...clip } })).data,
close: async () => { try { await send('session.end', {}) } catch {} ws.close(); proc.kill(); await sleep(500); await rm(profile, { recursive: true, force: true }).catch(() => {}) },
}
}
// ---------- scenario ----------
const STEPS = [
{ name: 'initial', run: async d => {} },
{ name: 'keyboard-0.65', run: async d => { await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('ArrowRight', 40) } },
{ name: 'enter-commit', run: async d => { await d.key('Enter') } },
{ name: 'max', run: async d => { await d.key('End') } },
{ name: 'min', run: async d => { await d.key('Home') } },
{ name: 'external-0.8', run: async d => { await d.eval(CLICK('투명도 외부 값 변경')) } },
{ name: 'disabled', run: async d => { await d.eval(CLICK('투명도 Disabled')); await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('ArrowRight', 5) } },
{ name: 'readonly', run: async d => { await d.eval(CLICK('투명도 Disabled')); await d.eval(CLICK('투명도 ReadOnly')); await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('ArrowRight', 5) } },
{ name: 'restored', run: async d => { await d.eval(CLICK('투명도 ReadOnly')); await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('Home') } },
]
async function run(driver) {
await driver.goto(STORY)
await driver.eval(SETUP)
const states = []
for (const step of STEPS) {
await step.run(driver); await sleep(250)
const st = JSON.parse(await driver.eval(STATE))
// Clip at whole pixels around the input (range is 86 x 16): 2px margin each side.
const clip = { x: Math.round(st.x) - 2, y: Math.round(st.y) - 2, width: 90, height: 20 }
const png = await driver.shot(clip)
await writeFile(path.join(out, `${driver.name}-${step.name}.png`), Buffer.from(png, 'base64'))
states.push({ step: step.name, ...st, offset: { x: st.x - clip.x, y: st.y - clip.y }, png })
}
return states
}
const report = { story: STORY, viewport: VIEW, browsers: {}, steps: [], checks: [] }
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, JSON.stringify(data)) }
const results = {}
for (const make of [chromeDriver, firefoxDriver]) {
const d = await make()
try { results[d.name] = await run(d); report.browsers[d.name] = { version: d.version, errors: d.errors } } finally { await d.close() }
}
// ---------- analysis (pixels decoded in a Chrome page) ----------
const ab = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const ap = await ab.newPage()
const analyse = (png, ox, oy) => ap.evaluate(async ([png, ox, oy]) => {
const i = new Image(); i.src = 'data:image/png;base64,' + png; await i.decode()
const c = document.createElement('canvas'); c.width = i.width; c.height = i.height; const x = c.getContext('2d'); x.drawImage(i, 0, 0)
const d = x.getImageData(0, 0, i.width, i.height).data, px = (cx, cy) => { const k = (cy * i.width + cx) * 4; return [d[k], d[k + 1], d[k + 2]] }
const bg = px(0, 0), same = (a, b, t = 6) => a.every((v, k) => Math.abs(v - b[k]) <= t)
const top = Math.round(oy), left = Math.round(ox)
// Thumb bbox: red-dominant pixels outside the 6 track rows (rows 5-10 of the 16px input).
let tx0 = 1e9, tx1 = -1, ty0 = 1e9, ty1 = -1
for (let yy = 0; yy < i.height; yy++) { const r = yy - top; if (r >= 5 && r <= 10) continue
for (let xx = 0; xx < i.width; xx++) { const p = px(xx, yy); if (p[0] > 90 && p[0] - p[1] > 50 && p[0] - p[2] > 50) { tx0 = Math.min(tx0, xx); tx1 = Math.max(tx1, xx); ty0 = Math.min(ty0, yy); ty1 = Math.max(ty1, yy) } } }
const thumb = tx1 < 0 ? null : { left: tx0 - left, right: tx1 - left, top: ty0 - top, bottom: ty1 - top, cx: (tx0 + tx1 + 1) / 2 - left, cy: (ty0 + ty1 + 1) / 2 - top }
// Track column profiles: fill column (x=3 of input) and beige column (x=82 of input).
const column = cx => { const rows = []; for (let r = 3; r <= 12; r++) rows.push(px(left + cx, top + r)); return rows }
const extent = col => { const idx = col.map((p, k) => same(p, bg, 10) ? -1 : k + 3).filter(k => k >= 0); return idx.length ? { from: idx[0], to: idx.at(-1), rows: idx.length } : null }
const hex = p => p.map(v => v.toString(16).padStart(2, '0')).join('')
const fillCol = column(3), trackCol = column(82)
return { thumb, fillCol: fillCol.map(hex), trackCol: trackCol.map(hex), fillExtent: extent(fillCol), trackExtent: extent(trackCol) }
}, [png, ox, oy])
const diff = (a, b) => ap.evaluate(async ([a, b]) => {
const load = async s => { const i = new Image(); i.src = 'data:image/png;base64,' + s; await i.decode(); const c = document.createElement('canvas'); c.width = i.width; c.height = i.height; const x = c.getContext('2d'); x.drawImage(i, 0, 0); return x.getImageData(0, 0, i.width, i.height).data }
const A = await load(a), B = await load(b); let max = 0, over16 = 0, over48 = 0, sum = 0
for (let k = 0; k < A.length; k += 4) { const m = Math.max(Math.abs(A[k] - B[k]), Math.abs(A[k + 1] - B[k + 1]), Math.abs(A[k + 2] - B[k + 2])); max = Math.max(max, m); sum += m; if (m > 16) over16++; if (m > 48) over48++ }
return { pixels: A.length / 4, max, mean: +(sum / (A.length / 4)).toFixed(2), over16, over48 }
}, [a, b])
for (let k = 0; k < STEPS.length; k++) {
const c = results.chrome[k], f = results.firefox[k]
const ca = await analyse(c.png, c.offset.x, c.offset.y), fa = await analyse(f.png, f.offset.x, f.offset.y)
const dd = await diff(c.png, f.png)
const strip = s => { const { png, ...rest } = s; return rest }
report.steps.push({ step: STEPS[k].name, chrome: { ...strip(c), pixels: ca }, firefox: { ...strip(f), pixels: fa }, diff: dd })
}
await ab.close()
// ---------- checks ----------
const S = Object.fromEntries(report.steps.map(s => [s.step, s]))
const isRed = h => { const v = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); return v[0] > 100 && v[0] - v[1] > 60 }
const isBeige = h => { const v = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); return v[0] > 160 && v[1] > 150 && v[2] > 120 && v[0] - v[2] > 15 && v[0] - v[2] < 45 }
for (const b of ['chrome', 'firefox']) {
const st = n => S[n][b]
check(`${b} 1-3 red fill / beige track / black-edged 6px`, isRed(st('initial').pixels.fillCol[4]) && isBeige(st('initial').pixels.trackCol[4]) && st('initial').pixels.fillCol[2] === '000000' && st('initial').pixels.fillCol[7] === '000000' && st('initial').pixels.fillExtent.from === 5 && st('initial').pixels.fillExtent.to >= 10, { fill: st('initial').pixels.fillCol, track: st('initial').pixels.trackCol, extent: st('initial').pixels.fillExtent })
const t = st('initial').pixels.thumb
check(`${b} 4-5 thumb 14px, vertically centred on the track`, t && t.right - t.left + 1 >= 11 && t.right - t.left + 1 <= 15 && Math.abs(t.cy - 8) <= 0.5, t)
for (const [n, v] of [['min', 0], ['initial', 0.25], ['keyboard-0.65', 0.65], ['max', 1], ['external-0.8', 0.8]]) {
const s = st(n), expect = 7 + 72 * v
check(`${b} 6-8/10 ${n}: value ${s.value}, thumb centre ${s.pixels.thumb?.cx} ≈ ${expect}`, Number(s.value) === v && Number(s.fill) === v && Math.abs(s.pixels.thumb.cx - expect) <= 1, { value: s.value, fill: s.fill, cx: s.pixels.thumb?.cx })
}
check(`${b} 9 keyboard + Enter commit`, st('keyboard-0.65').value === '0.65' && st('enter-commit').number === '0.65' && st('enter-commit').log.includes('property(opacity, 0.65)'), { value: st('keyboard-0.65').value, number: st('enter-commit').number, log: st('enter-commit').log })
check(`${b} 11 disabled / readOnly block keys, keep design`, st('disabled').disabled && st('disabled').value === '0.8' && st('readonly').disabled && st('readonly').value === '0.8' && isRed(st('disabled').pixels.fillCol[4]) && isBeige(st('readonly').pixels.trackCol[4]) && !st('restored').disabled && st('restored').value === '0', { disabled: [st('disabled').disabled, st('disabled').value], readonly: [st('readonly').disabled, st('readonly').value], restored: [st('restored').disabled, st('restored').value] })
check(`${b} 12 no console / page errors`, report.browsers[b].errors.filter(e => !e.includes('/sb-common-assets/')).length === 0, report.browsers[b].errors)
}
const near = (a, b, t) => a.every((h, k) => [0, 2, 4].every(i => Math.abs(parseInt(h.slice(i, i + 2), 16) - parseInt(b[k].slice(i, i + 2), 16)) <= t))
const noHighlight = col => col.filter((_, k) => k !== 8) // 1px highlight row under the track, reported separately
for (const s of report.steps) {
const ct = s.chrome.pixels.thumb, ft = s.firefox.pixels.thumb
const fillSame = s.step === 'min' || s.step === 'restored' || near(noHighlight(s.chrome.pixels.fillCol), noHighlight(s.firefox.pixels.fillCol), 3)
check(`compare ${s.step}: columns ±3, thumb ±1px, ≤12 px differ > 48`,
fillSame && near(noHighlight(s.chrome.pixels.trackCol), noHighlight(s.firefox.pixels.trackCol), 3)
&& Math.abs(ct.cx - ft.cx) <= 1 && Math.abs(ct.cy - ft.cy) <= 1 && s.diff.over48 <= 12,
{ diff: s.diff, focus: [s.chrome.focusVisible, s.firefox.focusVisible], chromeThumb: ct, firefoxThumb: ft, highlight: [s.chrome.pixels.fillCol[8], s.firefox.pixels.fillCol[8]] })
}
await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2))
console.log('BROWSERS', JSON.stringify(Object.fromEntries(Object.entries(report.browsers).map(([k, v]) => [k, v.version]))))
console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length)