// 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: SWHeader field values are Noto Sans KR 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 { 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 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 / 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: Noto Sans KR 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 Noto Sans KR 400 / 14px / 0.56px', fields.length >= 6 && fields.every(f => f.weight === '400' && f.size === '14px' && f.spacing === '0.56px' && /noto sans kr/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