docs(sw): hover stability tests, AI rules and evidence for header / footer / home menu

Chromium and Firefox browser tests (hover position, field text, ellipsis and
tooltip), AI docs and guide, report with the open Firefox Continuous 0.6px
issue and the Figma difference of the home menu hover.
This commit is contained in:
shkim4 committed 2026-10-20 17:48:05 +09:00
1 parent 3f7cff5473
commit 032fcdcc03
19 files changed
+67942 -9

No files matched your search

@@ -0,0 +1,123 @@
// 2026-10-20 Firefox counterpart of hover-stability.browser.mjs: the installed Firefox over WebDriver BiDi (no
// Playwright build), real pointer moves. Home Menu tiles and the full-footer commands: icon / text / box are the same
// at rest, on hover and while pressed; header field values are Dotum 400 and not cut; POLYGON / WIPEOUT ellipsis.
// Usage: node packages/sw/tests/hover-stability-firefox.browser.mjs <evidence-dir> [storybook-url]
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/hover-stability-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 VIEW = { width: 1920, height: 1100 }
const sleep = ms => new Promise(r => setTimeout(r, ms))
await mkdir(out, { recursive: true })
const profile = path.join(os.tmpdir(), `sw-ff-hover-${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 context = (await send('browsingContext.getTree', {})).contexts[0].context
// The headless window may ignore MOZ_HEADLESS_WIDTH; ask for the viewport and fall back to what the window gives.
try { await send('browsingContext.setViewport', { context, viewport: VIEW }) } catch (e) { console.log('INFO setViewport:', e.message) }
const ev = async expr => { const r = await send('script.evaluate', { expression: expr, target: { context }, awaitPromise: true }); if (r.type === 'exception') throw new Error(r.exceptionDetails.text + ' :: ' + expr.slice(0, 120)); return r.result.value }
const goto = async story => {
await send('browsingContext.navigate', { context, url: `${base}/iframe.html?id=${story}&viewMode=story`, wait: 'complete' })
for (let i = 0; i < 40; i++) { if (await ev(`!!document.querySelector('#storybook-root > *')`)) break; await sleep(250) }
await ev('document.fonts.ready.then(() => true)'); await sleep(400)
}
const pointer = actions => send('input.performActions', { context, actions: [{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions }] })
const move = (x, y) => pointer([{ type: 'pointerMove', x: Math.round(x), y: Math.round(y), duration: 0 }])
const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : JSON.stringify(d).slice(0, 500)) }
const SNAP = `(sel, i, q) => { const el = [...document.querySelectorAll(sel)].filter(e => e.getClientRects().length)[i]
const vis = e => { for (let n = e; n && n !== el.parentElement; n = n.parentElement) { const c = getComputedStyle(n); if (c.display === 'none' || c.visibility === 'hidden') return false } return true }
const r = e => { const b = e.getBoundingClientRect(); return [b.x, b.y, b.width, b.height].map(v => Math.round(v * 1000) / 1000) }
const art = [...el.querySelectorAll(q.icon)].filter(vis).map(i => i.querySelector('img, svg') ?? i)
const label = el.querySelector(q.label), range = document.createRange(); range.selectNodeContents(label)
const t = range.getBoundingClientRect()
return JSON.stringify({ name: label.textContent.trim(), box: r(el), icon: art.length === 1 ? r(art[0]) : null, n: art.length, text: [t.x, t.y, t.width, t.height].map(v => Math.round(v * 1000) / 1000), color: getComputedStyle(label).color, active: el.dataset.active === 'true' }) }`
const snap = async (sel, i, q) => JSON.parse(await ev(`(${SNAP})(${JSON.stringify(sel)}, ${i}, ${JSON.stringify(q)})`))
// Inside the item (the page may scroll between reads: a scroll moves the whole item, not its content).
const rd = v => Math.round(v * 1000) / 1000
const geo = s => JSON.stringify([s.box[2], s.box[3], s.icon && [rd(s.icon[0] - s.box[0]), rd(s.icon[1] - s.box[1]), s.icon[2], s.icon[3]], s.n, [rd(s.text[0] - s.box[0]), rd(s.text[1] - s.box[1]), s.text[2], s.text[3]]])
async function cycleAll(sel, q, tag) {
const n = await ev(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.getClientRects().length).length`)
const results = []
for (let i = 0; i < n; i++) {
await ev(`[...document.querySelectorAll(${JSON.stringify(sel)})].filter(e => e.getClientRects().length)[${i}].scrollIntoView({ block: 'center', inline: 'center' }), true`); await sleep(60)
await move(1, 1); await sleep(80)
const d0 = await snap(sel, i, q)
const [x, y] = [d0.box[0] + d0.box[2] / 2, d0.box[1] + d0.box[3] / 2]
await move(x, y); await sleep(100); const h1 = await snap(sel, i, q)
await move(1, 1); await sleep(100); const d1 = await snap(sel, i, q)
await move(x, y); await sleep(100); const h2 = await snap(sel, i, q)
await pointer([{ type: 'pointerDown', button: 0 }]); await sleep(80); const a = await snap(sel, i, q)
await pointer([{ type: 'pointerMove', x: 1, y: 1, duration: 0 }, { type: 'pointerUp', button: 0 }]); await sleep(100)
const same = [h1, d1, h2, a].every(s => geo(s) === geo(d0))
results.push({ name: d0.name, same, hovered: d0.active || h1.color !== d0.color, d0, h1, a })
check(`Firefox ${tag} "${d0.name}": icon · text · box unchanged (Default/Hover/Default/Hover/Active)`, same, { d0: geo(d0), h1: geo(h1), a: geo(a) })
}
return results
}
const report = { browser: session.capabilities.browserVersion }
await goto('sw-header--interactive-states')
report.viewport = JSON.parse(await ev('JSON.stringify([innerWidth, innerHeight])'))
console.log('INFO Firefox', report.browser, 'viewport', report.viewport.join(' x '))
report.headerFields = JSON.parse(await ev(`JSON.stringify([...document.querySelectorAll('.hm-sw-header')].filter(h => h.getBoundingClientRect().width >= 1800).flatMap(h => [...h.querySelectorAll('.hm-sw-header__field-value')].filter(e => e.getClientRects().length).map(e => { const c = getComputedStyle(e), g = document.createRange(); g.selectNodeContents(e); const tw = g.getBoundingClientRect().width, bw = e.getBoundingClientRect().width; return { text: e.textContent, field: e.closest('[data-menu]')?.dataset.menu, weight: c.fontWeight, textW: Math.round(tw * 100) / 100, boxW: Math.round(bw * 100) / 100, cut: tw > bw + 0.05 } })))`))
// Fixed 38px 가중치 / 스케일 slots hold short numbers; a longer word there is a data-length case (see the Chromium test).
check('Firefox Header: field values are 400 and not cut (Standard, 0_Layer_Cludmark, Continuous, 1.00, 255,229,0 …)', report.headerFields.length >= 7 && report.headerFields.every(f => f.weight === '400' && (!f.cut || (f.field === 'line-style' && f.boxW <= 38))), report.headerFields.filter(f => f.weight !== '400' || f.cut))
await goto('sw-header--home-menu')
report.home = await cycleAll('.hm-sw-home-menu > .hm-sw-home-menu__sections .hm-sw-home-menu__item', { icon: '.hm-sw-home-menu__icon', label: '.hm-sw-home-menu__label' }, 'Home Menu')
check(`Firefox Home Menu: ${report.home.length} tiles, hover reached every non-active tile`, report.home.length >= 15 && report.home.every(r => r.hovered), report.home.filter(r => !r.hovered).map(r => r.name))
await send('browsingContext.captureScreenshot', { context }).then(r => writeFile(path.join(out, 'home-menu-firefox.png'), Buffer.from(r.data, 'base64')))
await goto('sw-footer--full-footer')
const FOOT = '.sw-footer-story-workspace .hm-sw-footer-tool'
report.footer = []
const tabs = await ev(`document.querySelectorAll('.sw-footer-story-workspace .hm-sw-footer-menu__tab').length`)
for (let k = 0; k < tabs; k++) {
await ev(`document.querySelectorAll('.sw-footer-story-workspace .hm-sw-footer-menu__tab')[${k}].click(), true`); await sleep(120)
report.footer.push(...await cycleAll(FOOT, { icon: '.hm-sw-footer-tool__icon', label: '.hm-sw-footer-tool__label' }, `Footer tab ${k + 1}`))
}
check(`Firefox Footer: ${report.footer.length} commands, all 68 × 47, hover reached every command`, report.footer.length >= 11 && report.footer.every(r => r.d0.box[2] === 68 && r.d0.box[3] === 47 && r.hovered), report.footer.filter(r => !r.hovered).map(r => r.name))
await ev(`document.querySelectorAll('.sw-footer-story-workspace .hm-sw-footer-menu__tab')[0].click(), true`); await sleep(120)
report.ellipsis = {}
for (const name of ['POLYGON', 'WIPEOUT']) {
const box = JSON.parse(await ev(`(() => { const b = [...document.querySelectorAll('${FOOT}')].find(e => e.textContent === '${name}'); b.scrollIntoView({ block: 'center', inline: 'center' }); const r = b.getBoundingClientRect(); return JSON.stringify([r.x + r.width / 2, r.y + r.height / 2]) })()`))
await move(1, 1); await sleep(60); await move(box[0], box[1]); await sleep(150)
report.ellipsis[name] = JSON.parse(await ev(`(() => { const b = [...document.querySelectorAll('${FOOT}')].find(e => e.textContent === '${name}'), l = b.querySelector('.hm-sw-footer-tool__label'); return JSON.stringify({ overflow: getComputedStyle(l).textOverflow, cut: l.scrollWidth > l.clientWidth, title: b.getAttribute('title'), w: b.getBoundingClientRect().width, h: b.getBoundingClientRect().height }) })()`))
const e = report.ellipsis[name]
check(`Firefox Footer: ${name} ends in … (cut) and its hover title is the full name, 68 × 47`, e.overflow === 'ellipsis' && e.cut && e.title === name && e.w === 68 && e.h === 47, e)
}
await move(1, 1)
await send('browsingContext.captureScreenshot', { context }).then(r => writeFile(path.join(out, 'footer-firefox.png'), Buffer.from(r.data, 'base64')))
// The Storybook UI font (sb-common-assets / Nunito Sans) is not part of the SW page; it fails only on this static server.
const swErrors = errors.filter(e => !e.includes('sb-common-assets'))
report.environmentErrors = errors.length - swErrors.length
check('Firefox: no console errors (Storybook UI font requests excluded)', swErrors.length === 0, swErrors)
await writeFile(path.join(out, 'hover-stability-firefox.json'), JSON.stringify({ checks, report }, null, 2))
const failed = checks.filter(c => !c.ok).length
console.log(`${checks.length - failed}/${checks.length} PASS (firefox ${report.browser})`)
try { await send('session.end', {}) } catch {}
ws.close(); proc.kill(); await sleep(500); await rm(profile, { recursive: true, force: true }).catch(() => {})
process.exitCode = failed ? 1 : 0
@@ -0,0 +1,239 @@
// 2026-10-20 SW hover position stability + header field text + footer command ellipsis / tooltip.
// Hover (and Active / Selected) may change only color, background and the hover ring: the icon, the text, their gap
// and the item box stay where they are (Header Home Menu tiles, Footer command slots in every story and category,
// including the ▲ panel). Also: Header field values are Dotum 400 and not cut; long footer commands end in an
// ellipsis inside the unchanged 68 × 47 slot and show their full name on hover.
// Usage: build Storybook first, then node packages/sw/tests/hover-stability.browser.mjs [evidence-dir] [chromium|firefox]
import { createRequire } from 'node:module'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const playwright = require('playwright')
const out = path.resolve(process.argv[2] ?? 'docs/sw/hover-stability-qa'); await mkdir(out, { recursive: true })
const engine = process.argv[3] ?? 'chromium'
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 = engine === 'firefox'
? await playwright.firefox.launch({ headless: true })
: await playwright.chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : JSON.stringify(d).slice(0, 600)) }
const report = { engine }
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.evaluate(() => document.fonts.ready); await page.waitForTimeout(300) }
// Sub-pixel rounding (e.g. a centered odd-width box) is not a layout move: positions are compared to 0.01px.
const EPS = 0.01
// Geometry of one item: the box, the visible icon art (the <img> / <svg> inside the shown icon slot) and the text
// (glyph run through a Range, so a padding or position change of the text box is caught too).
const SNAP = `(el, q) => {
const vis = e => { for (let n = e; n && n !== el.parentElement; n = n.parentElement) { const c = getComputedStyle(n); if (c.display === 'none' || c.visibility === 'hidden') return false } return true }
const r = e => { const b = e.getBoundingClientRect(); return [b.x, b.y, b.width, b.height].map(v => Math.round(v * 1000) / 1000) }
const icons = [...el.querySelectorAll(q.icon)].filter(vis)
const art = icons.map(i => i.querySelector('img, svg') ?? i)
const label = el.querySelector(q.label)
const range = document.createRange(); range.selectNodeContents(label)
const t = range.getBoundingClientRect(), lc = getComputedStyle(label)
const text = [t.x, t.y, t.width, t.height].map(v => Math.round(v * 1000) / 1000)
const icon = art.length === 1 ? r(art[0]) : null
return {
box: r(el), icon, iconCount: art.length, iconSrc: art[0]?.getAttribute?.('src')?.slice(-40) ?? art[0]?.tagName,
text, labelBox: r(label),
// vertical gap between the icon art and the text run (both slots stack vertically)
gap: icon ? Math.round((icon[1] > text[1] ? icon[1] - (text[1] + text[3]) : text[1] - (icon[1] + icon[3])) * 1000) / 1000 : null,
font: [lc.fontFamily.split(',')[0], lc.fontSize, lc.fontWeight, lc.lineHeight, lc.letterSpacing].join('|'),
color: lc.color, iconTransform: icons[0] ? getComputedStyle(icons[0]).transform : null,
}
}`
const snap = (loc, q) => loc.evaluate(new Function('return ' + SNAP)(), q)
const geom = s => JSON.stringify({ box: s.box, icon: s.icon, n: s.iconCount, text: s.text, labelBox: s.labelBox, gap: s.gap, font: s.font })
const delta = (a, b) => {
const d = {}
for (const k of ['box', 'icon', 'text', 'labelBox']) if (a[k] && b[k]) { const v = a[k].map((x, i) => Math.round((b[k][i] - x) * 1000) / 1000); if (v.some(x => Math.abs(x) > EPS)) d[k] = v }
if (a.gap !== null && b.gap !== null && Math.abs(a.gap - b.gap) > EPS) d.gap = [a.gap, b.gap]
if (a.font !== b.font) d.font = [a.font, b.font]
if (a.iconCount !== b.iconCount) d.iconCount = [a.iconCount, b.iconCount]
return d
}
const away = async () => { await page.mouse.move(2, 2); await page.waitForTimeout(90) }
const center = async loc => { const b = await loc.boundingBox(); return [b.x + b.width / 2, b.y + b.height / 2] }
// Default → Hover → Default → Hover → Active (held, released outside so nothing is clicked).
async function cycle(loc, q) {
await loc.scrollIntoViewIfNeeded(); await away(); const d0 = await snap(loc, q)
const [x, y] = await center(loc)
await page.mouse.move(x, y); await page.waitForTimeout(90); const h1 = await snap(loc, q)
await away(); const d1 = await snap(loc, q)
await page.mouse.move(x, y); await page.waitForTimeout(90); const h2 = await snap(loc, q)
await page.mouse.down(); await page.waitForTimeout(60); const a = await snap(loc, q)
await page.mouse.move(2, 2); await page.mouse.up(); await page.waitForTimeout(90)
const d2 = await snap(loc, q)
return { d0, h1, d1, h2, a, d2 }
}
function judge(name, c) {
const moves = { hover: delta(c.d0, c.h1), back: delta(c.d0, c.d1), hover2: delta(c.d0, c.h2), active: delta(c.d0, c.a), end: delta(c.d0, c.d2) }
const moved = Object.fromEntries(Object.entries(moves).filter(([, v]) => Object.keys(v).length))
return { name, ok: Object.keys(moved).length === 0, moved, colorChanged: c.d0.color !== c.h1.color, iconSwap: c.d0.iconSrc !== c.h1.iconSrc, default: c.d0, hover: c.h1, active: c.a }
}
// ---------- 1. Header field values: Dotum 400, nothing cut ----------
// Full-width headers (1800 / 1920px) show every field; narrower ones move the aux fields into More.
const fields = []
for (const id of ['sw-header--interactive-states', 'sw-header--document-open', 'sw-header--responsive-more']) {
await goto(id)
const wide = page.locator('.hm-sw-header').filter({ visible: true })
for (let i = 0, n = await wide.count(); i < n; i++) {
const h = wide.nth(i), w = await h.evaluate(e => e.getBoundingClientRect().width)
if (w < 1800) continue
for (const f of await h.locator('.hm-sw-header__field-value').evaluateAll(list => list.filter(e => e.getClientRects().length).map(e => { const c = getComputedStyle(e), range = document.createRange(); range.selectNodeContents(e); return { text: e.textContent, field: e.closest('[data-menu]')?.dataset.menu, weight: c.fontWeight, size: c.fontSize, spacing: c.letterSpacing, family: c.fontFamily.split(',')[0], textW: Math.round(range.getBoundingClientRect().width * 100) / 100, boxW: Math.round(e.getBoundingClientRect().width * 100) / 100, client: e.clientWidth, scroll: e.scrollWidth, cut: range.getBoundingClientRect().width > e.getBoundingClientRect().width + 0.05 } }))) fields.push({ story: id, header: i, width: w, ...f })
}
}
report.headerFields = fields
check('Header: every field value is Dotum 400 / 14px / 0.56px', fields.length >= 6 && fields.every(f => f.weight === '400' && f.size === '14px' && f.spacing === '0.56px' && /dotum/i.test(f.family)), fields)
for (const want of ['Standard', '0_Layer_Cludmark', '1.00', '255,229,0']) {
const f = fields.filter(x => x.text === want)
check(`Header: "${want}" is shown whole (text width ≤ box)`, f.length > 0 && f.every(x => !x.cut), f)
}
// The 가중치 / 스케일 slots of the line-style field are a fixed 38px (Figma, numbers such as 0.25): a word such as
// ByLayer there is a data-length case (ellipsis + title), reported apart, not a weight / cut regression.
const fixedSlot = f => f.field === 'line-style' && f.client <= 38
check('Header: no field value is cut in a full-width (≥ 1800px) header (except fixed 38px 가중치 / 스케일 slots)', fields.filter(f => !fixedSlot(f)).every(f => !f.cut), fields.filter(f => f.cut && !fixedSlot(f)))
report.fixedSlotCut = [...new Set(fields.filter(f => f.cut && fixedSlot(f)).map(f => `${f.text} (${f.textW} / ${f.client}px)`))]
console.log('INFO fixed 38px slot values longer than the slot:', report.fixedSlotCut.join(', ') || 'none')
await goto('sw-header--interactive-states')
const others = await page.locator('.hm-sw-header :is(.hm-sw-header__menu-text, .hm-sw-header__font-name, .hm-sw-header__document-name)').evaluateAll(l => [...new Set(l.filter(e => e.getClientRects().length).map(e => getComputedStyle(e).fontWeight))])
check('Header: buttons / menu text / font sample keep their own weight (700)', others.every(w => w === '700'), others)
// ---------- 2. Header Home Menu tiles ----------
const HQ = { icon: '.hm-sw-home-menu__icon', label: '.hm-sw-home-menu__label' }
await goto('sw-header--home-menu')
const tiles = page.locator('.hm-sw-home-menu > .hm-sw-home-menu__sections .hm-sw-home-menu__item').filter({ visible: true })
const tileCount = await tiles.count()
const homeResults = []
for (let i = 0; i < tileCount; i++) {
const t = tiles.nth(i)
if (await t.isDisabled()) continue
const name = (await t.locator(HQ.label).textContent()).trim()
// A panel tile (최근파일) stays Active after a click; the cycle releases outside, so it never opens.
const activeAtRest = await t.getAttribute('data-active') === 'true'
homeResults.push({ ...judge(name, await cycle(t, HQ)), activeAtRest })
}
report.homeMenu = homeResults
check(`Home Menu: tiles measured (${homeResults.length})`, homeResults.length >= 15, homeResults.map(r => r.name))
for (const r of homeResults) check(`Home Menu "${r.name}": icon · text · gap · box unchanged (Default/Hover/Default/Hover/Active)`, r.ok, r.moved)
check('Home Menu: hover still shows the accent (#FF5C00 text, hover icon, orange ring)', homeResults.filter(r => !r.activeAtRest).every(r => r.hover.color === 'rgb(255, 92, 0)' && r.default.color !== r.hover.color), homeResults.map(r => [r.name, r.default.color, r.hover.color]))
// Active tiles (shown Active by a Story) have the same icon / text place inside the tile as Default tiles.
const rel = s => [s.icon[0] - s.box[0], s.icon[1] - s.box[1], s.text[0] - s.box[0], s.text[1] - s.box[1]].map(v => Math.round(v * 1000) / 1000).join(',')
const activeRel = homeResults.filter(r => r.activeAtRest).map(r => { const d = homeResults.find(x => !x.activeAtRest && x.name === r.name); return { name: r.name, active: rel(r.default), def: d && rel(d.default) } })
report.homeActiveRelative = activeRel
check(`Home Menu: Active tiles (${activeRel.length}) = Default tiles: same icon / text offset inside the tile`, activeRel.length > 0 && activeRel.every(a => a.def && a.active === a.def), activeRel)
const ring = await tiles.first().evaluate(async e => { const before = getComputedStyle(e, '::after').opacity; return before })
check('Home Menu: ring hidden at rest', ring === '0', ring)
// Keyboard focus / Escape still work on the menu
await tiles.first().focus(); await page.keyboard.press('ArrowRight'); await page.waitForTimeout(60)
const focused = await page.evaluate(() => document.activeElement?.textContent?.trim())
check('Home Menu: ArrowRight moves focus to the next tile', focused === homeResults[1]?.name, focused)
await page.screenshot({ path: path.join(out, `home-menu-${engine}.png`), clip: { x: 0, y: 0, width: 1920, height: 220 } })
// ---------- 3. Footer command slots: every story, every category with commands, ▲ panel ----------
const FQ = { icon: '.hm-sw-footer-tool__icon', label: '.hm-sw-footer-tool__label' }
const footerResults = []
async function footerTools(story, scope, tag) {
const tools = scope.locator('.hm-sw-footer-tool:visible')
const n = await tools.count()
for (let i = 0; i < n; i++) {
const t = tools.nth(i)
if (await t.isDisabled()) continue
const name = (await t.locator(FQ.label).textContent()).trim()
const r = judge(`${story} · ${tag} · ${name}`, await cycle(t, FQ))
// Selected (pressed) — the same geometry as Default
r.label = name
footerResults.push(r)
}
return n
}
const STORIES = ['sw-footer--full-footer', 'sw-footer--tool-groups', 'sw-footer--custom-content']
for (const story of STORIES) {
await goto(story)
const footers = page.locator('.hm-sw-footer:visible')
const fc = await footers.count()
for (let f = 0; f < fc; f++) {
const footer = footers.nth(f)
const tabs = footer.locator('.hm-sw-footer-menu__tab')
const tc = await tabs.count()
if (tc === 0) { await footerTools(story, footer, `footer${f}`); continue }
for (let k = 0; k < tc; k++) {
const tab = tabs.nth(k)
if (!(await tab.isVisible()) || await tab.isDisabled()) continue
await tab.click(); await away()
await footerTools(story, footer, `footer${f} · ${(await tab.textContent()).trim()}`)
}
}
}
// ▲ panel (narrow widths): commands that do not fit open above the slots.
await goto('sw-footer--responsive')
const narrow = page.locator('.sw-footer-story-width .hm-sw-footer').nth(4)
const toggle = narrow.locator('.hm-sw-footer-more')
if (await toggle.count()) {
await toggle.click(); await page.waitForTimeout(150)
const panel = page.locator('.hm-sw-footer__more-panel, [class*="more-panel"]').filter({ has: page.locator('.hm-sw-footer-tool') }).first()
if (await panel.count()) await footerTools('sw-footer--responsive', panel, '▲ panel')
}
report.footer = footerResults
check(`Footer: command slots measured (${footerResults.length})`, footerResults.length >= 20, footerResults.length)
const footerLabels = [...new Set(footerResults.map(r => r.label))]
report.footerLabels = footerLabels
for (const r of footerResults) check(`Footer "${r.name}": icon · text · gap · 68 × 47 unchanged (Default/Hover/Default/Hover/Active)`, r.ok && r.default.box[2] === 68 && r.default.box[3] === 47, { moved: r.moved, box: r.default.box })
check('Footer: hover still colors the command (#FF5C00) and swaps to the orange icon where one is given', footerResults.every(r => r.hover.color === 'rgb(255, 92, 0)'), footerResults.filter(r => r.hover.color !== 'rgb(255, 92, 0)').map(r => r.name))
// Selected (pressed) geometry = Default geometry
await goto('sw-footer--full-footer')
const work = page.locator('.sw-footer-story-workspace .hm-sw-footer')
const poly = work.locator('.hm-sw-footer-tool', { hasText: 'POLYGON' })
await poly.scrollIntoViewIfNeeded(); await away(); const polyDefault = await snap(poly, FQ)
await poly.click(); await away(); const polyPressed = await snap(poly, FQ)
check('Footer: Selected (aria-pressed) POLYGON keeps the Default geometry, accent color', await poly.getAttribute('aria-pressed') === 'true' && Object.keys(delta(polyDefault, polyPressed)).length === 0 && polyPressed.color === 'rgb(255, 92, 0)', delta(polyDefault, polyPressed))
await poly.click(); await away()
// ---------- 4. Footer ellipsis + tooltip ----------
const labelInfo = loc => loc.evaluate(b => { const l = b.querySelector('.hm-sw-footer-tool__label'), c = getComputedStyle(l), r = b.getBoundingClientRect(); return { text: l.textContent, overflow: c.textOverflow, cut: l.scrollWidth > l.clientWidth, client: l.clientWidth, scroll: l.scrollWidth, size: c.fontSize, title: b.getAttribute('title'), w: r.width, h: r.height } })
const ell = {}
for (const name of ['POLYGON', 'WIPEOUT', 'ELLIPSE', 'LINE']) {
const b = work.locator('.hm-sw-footer-tool', { hasText: name }).first()
const rest = await labelInfo(b)
await b.scrollIntoViewIfNeeded(); await b.hover(); await page.waitForTimeout(120)
const hover = await labelInfo(b)
await away()
ell[name] = { rest, hover }
}
report.ellipsis = ell
check('Footer: label ends in an ellipsis (text-overflow: ellipsis), font stays 14px, slot 68 × 47', Object.values(ell).every(e => e.rest.overflow === 'ellipsis' && e.rest.size === '14px' && e.rest.w === 68 && e.rest.h === 47), ell)
for (const name of ['POLYGON', 'WIPEOUT']) {
const e = ell[name]
check(`Footer: ${name} — ${e.rest.cut ? 'cut → ellipsis, hover title = full name' : 'fits → shown whole'}`, e.rest.cut ? e.hover.title === name : e.hover.title === null || e.hover.title === name, e)
}
check('Footer: a command that fits (LINE) gets no automatic tooltip', !ell.LINE.rest.cut && ell.LINE.hover.title === null, ell.LINE)
check('Footer: POLYGON / WIPEOUT accessible names are the full command names', await page.getByRole('button', { name: 'POLYGON', exact: true }).count() >= 1 && await page.getByRole('button', { name: 'WIPEOUT', exact: true }).count() >= 1)
check('Footer: ariaLabel still wins (RECTA... = Rectangle)', await work.getByRole('button', { name: 'Rectangle', exact: true }).count() === 1)
// Keyboard focus on a cut command also exposes the full name
const wipe = work.locator('.hm-sw-footer-tool', { hasText: 'WIPEOUT' }).first()
await wipe.focus(); await page.waitForTimeout(60)
check('Footer: keyboard focus on WIPEOUT exposes the same title', ell.WIPEOUT.rest.cut ? await wipe.getAttribute('title') === 'WIPEOUT' : true, await wipe.getAttribute('title'))
// An explicit SWFooterTool.title is kept as is (no duplicate tooltip)
const explicit = await page.evaluate(() => [...document.querySelectorAll('.hm-sw-footer-tool[title]')].map(b => [b.textContent, b.title]))
report.explicitTitles = explicit
// click still selects
await page.keyboard.press('Enter'); await page.waitForTimeout(80)
check('Footer: Enter on WIPEOUT selects it (aria-pressed)', await wipe.getAttribute('aria-pressed') === 'true')
await page.keyboard.press('Enter'); await page.waitForTimeout(80)
await page.screenshot({ path: path.join(out, `footer-${engine}.png`), clip: { x: 0, y: 0, width: 1920, height: 1100 } })
check('No page errors', errors.length === 0, errors)
await writeFile(path.join(out, `hover-stability-${engine}.json`), JSON.stringify({ checks, report }, null, 2))
const failed = checks.filter(c => !c.ok).length
console.log(`${checks.length - failed}/${checks.length} PASS (${engine})`)
await browser.close(); server.close()
process.exitCode = failed ? 1 : 0