// SW Footer design review (2026-10-20; SW default font Noto Sans KR since the Dotum default was withdrawn): the four open design items and the Model / Layout text rule, // measured in the real Storybook (`SW/Footer › 전체 Footer · 실제 조합`). Computed values + element screenshots for // each state; design judgement (PASS / REVIEW) is written in the report, this script only records facts and checks // the approved, unambiguous rules. // Usage: node packages/sw/tests/footer-design-review.browser.mjs [storybook-url] [evidence-dir] import { mkdir, writeFile } from 'node:fs/promises' import path from 'node:path' import { runtimeRequire } from './playwright-runtime.mjs' const require = runtimeRequire() const { chromium } = require('playwright') const [storybook = 'http://127.0.0.1:6024', outArg = 'docs/sw/sw-noto-bold-qa/footer-design'] = process.argv.slice(2) const out = path.resolve(outArg); await mkdir(out, { recursive: true }) const results = [], facts = {}, errors = [] const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail !== '' ? ` — ${JSON.stringify(detail)}` : ''}`) } const GRADIENT = '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 browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' }) const page = await browser.newPage({ viewport: { width: 2400, height: 1400 }, reducedMotion: 'reduce' }) page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error' && !/favicon|404/.test(m.text())) errors.push(m.text()) }) await page.goto(`${storybook}/iframe.html?id=sw-footer--full-footer&viewMode=story`) await page.waitForSelector('.hm-sw-footer'); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(400) const footer = page.locator('.hm-sw-footer').first() await footer.scrollIntoViewIfNeeded() const style = (loc, pseudo = null) => loc.evaluate((e, pseudo) => { const c = getComputedStyle(e, pseudo), r = e.getBoundingClientRect() return { x: +r.x.toFixed(1), y: +r.y.toFixed(1), w: +r.width.toFixed(1), h: +r.height.toFixed(1), color: c.color, bg: c.backgroundImage === 'none' ? c.backgroundColor : c.backgroundImage.replace(/url("data:[^"]{0,4000}")/g, 'url(data)'), opacity: c.opacity, font: c.fontFamily.split(',')[0], size: c.fontSize, weight: c.fontWeight, shadow: c.textShadow === 'none' ? 'none' : 'outline', mask: (c.maskImage || c.webkitMaskImage || '').slice(0, 60) } }, pseudo) // Element shot (scrolls into view; the hover state is kept because the pointer stays over the element). const shot = (loc, name) => loc.screenshot({ path: path.join(out, `${name}.png`) }) const away = () => page.mouse.move(5, 5) // 0. Token value const token = await footer.evaluate(e => getComputedStyle(e).getPropertyValue('--hm-sw-footer-gra-border-orange').trim()) facts.token = token check('Token --hm-sw-footer-gra-border-orange = user gradient (#DA6813 8.52% / #FFBB89 37.93% / #E5690D 71.85% / #AE3607 92.77%)', token.replace(/\s+/g, ' ').toLowerCase() === 'linear-gradient(180deg, #da6813 8.52%, #ffbb89 37.93%, #e5690d 71.85%, #ae3607 92.77%)', token) // 1. 전체보기 side buttons hover (보조기능 / 명령어) facts.side = [] for (const [i, label] of [[0, 'aux'], [1, 'commands']]) { const side = footer.locator('.hm-sw-footer-side').nth(i) await away(); await page.waitForTimeout(100) const d = { box: await style(side), ring: await style(side, '::after'), title: await style(side.locator('.hm-sw-footer-side__title')), sub: await style(side.locator('.hm-sw-footer-side__subtitle')) } await shot(side, `side-${label}-default`) await side.hover(); await page.waitForTimeout(150) const h = { box: await style(side), ring: await style(side, '::after'), title: await style(side.locator('.hm-sw-footer-side__title')), sub: await style(side.locator('.hm-sw-footer-side__subtitle')) } await shot(side, `side-${label}-hover`) facts.side.push({ label, default: d, hover: h }) check(`전체보기(${label}) hover: ring over the whole button (opacity 0 → 1, gra-border-orange, same box)`, d.ring.opacity === '0' && h.ring.opacity === '1' && h.ring.bg === GRADIENT && h.ring.w === h.box.w && h.ring.h === h.box.h, { ring: h.ring.w + '×' + h.ring.h, box: h.box.w + '×' + h.box.h }) check(`전체보기(${label}) hover: both text lines #FF5C00`, h.title.color === 'rgb(255, 92, 0)' && h.sub.color === 'rgb(255, 92, 0)', [h.title.color, h.sub.color]) const rel = s => ({ w: s.box.w, h: s.box.h, tx: +(s.title.x - s.box.x).toFixed(1), ty: +(s.title.y - s.box.y).toFixed(1), tw: s.title.w, sx: +(s.sub.x - s.box.x).toFixed(1), sy: +(s.sub.y - s.box.y).toFixed(1), sw: s.sub.w }) check(`전체보기(${label}) hover: size and text boxes (relative to the button) unchanged`, JSON.stringify(rel(d)) === JSON.stringify(rel(h)), [rel(d), rel(h)]) check(`전체보기(${label}) text = Noto Sans KR 14px`, d.title.font === '"Noto Sans KR"' && d.title.size === '14px', d.title.font) } await away() // 2. + buttons: category row add (below 명령어 전체보기 side) and Layout add facts.add = {} for (const [sel, name] of [['.hm-sw-footer-menu__add', 'category-add'], ['.hm-sw-footer-tabs__add', 'layout-add']]) { const btn = footer.locator(sel).first() if (!(await btn.count())) { check(`${name}: present`, false); continue } const d = { box: await style(btn), before: await style(btn, '::before') } await shot(btn, `${name}-default`, 4) await btn.hover(); await page.waitForTimeout(150) const h = { box: await style(btn), before: await style(btn, '::before') } await shot(btn, `${name}-hover`, 4) facts.add[name] = { default: d, hover: h } check(`${name} hover: same size`, d.box.w === h.box.w && d.box.h === h.box.h, `${h.box.w}×${h.box.h}`) await away() } const cat = facts.add['category-add'].hover check('category + hover: icon swaps to the orange + (menu-add-hover), no extra surface', facts.add['category-add'].default.box.bg !== cat.box.bg && cat.box.bg.startsWith('url(') && !cat.box.bg.includes('linear-gradient'), 'background image changes on hover') const lay = facts.add['layout-add'].hover check('Layout + hover: gra-border-orange surface masked to the button shape, + icon on top', lay.before.bg.startsWith('url(') && lay.before.bg.includes('rgb(218, 104, 19)') && lay.before.mask.startsWith('url('), 'url(+) , gra-border-orange ; mask=button shape') // click keeps working const addCalls = await page.evaluate(() => document.body.innerText.match(/onSlotAdd|onCategoryAdd|onLayoutAdd|onAdd/g)?.length ?? 0) facts.addCallsBefore = addCalls // 3. Model tab: selected vs not selected, Layout text rule const tabs = footer.locator('.hm-sw-footer-tab') const tabInfo = async loc => ({ tab: await style(loc), before: await style(loc, '::before'), text: await style(loc.locator('.hm-sw-footer-tab__label, .hm-sw-footer-tab__select').first()) }) const model = footer.locator('.hm-sw-footer-tab--model').first() const modelState = await model.getAttribute('data-state') facts.model = { initialState: modelState, selected: null, unselected: null } // Make a Layout selected so the Model is unselected, then reselect the Model. const layoutTabs = footer.locator('.hm-sw-footer-tab:not(.hm-sw-footer-tab--model)') const layoutCount = await layoutTabs.count() if (modelState === 'on') { facts.model.selected = await tabInfo(model); await shot(model, 'model-selected', 4) } await layoutTabs.first().locator('.hm-sw-footer-tab__select').click(); await away(); await page.waitForTimeout(150) facts.model.unselected = await tabInfo(model); await shot(model, 'model-unselected', 4) facts.layoutSelected = await tabInfo(layoutTabs.first()); await shot(layoutTabs.first(), 'layout-selected', 4) await model.hover(); await page.waitForTimeout(150); facts.model.unselectedHover = await tabInfo(model); await shot(model, 'model-unselected-hover', 4); await away() await model.locator('.hm-sw-footer-tab__select').click(); await away(); await page.waitForTimeout(150) facts.model.selected = await tabInfo(model); await shot(model, 'model-selected', 4) const mu = facts.model.unselected, ms = facts.model.selected check('Model selected: gra-border-orange surface (::before) masked to the model shape', ms.before.bg === GRADIENT && ms.before.mask.startsWith('url('), ms.before.bg.slice(0, 60)) check('Model not selected: its own tab image surface (tab-model.svg, beige), no orange ::before', mu.tab.bg.startsWith('url(') && !mu.before.bg.includes('rgb(218, 104, 19)'), { tab: mu.tab.bg, before: mu.before.bg }) check('Model text #161410 12px 400 no outline — selected, unselected and hover identical', [ms, mu, facts.model.unselectedHover].every(s => s.text.color === 'rgb(22, 20, 16)' && s.text.size === '12px' && s.text.weight === '400' && s.text.shadow === 'none') && ['x', 'y', 'w', 'h'].every(k => ms.text[k] === mu.text[k] && mu.text[k] === facts.model.unselectedHover.text[k]), [ms.text, mu.text].map(t => `${t.color} ${t.size} ${t.weight} ${t.font}`)) // Layout default vs hover text (non-selected layouts) facts.layoutHover = [] for (let i = 1; i < layoutCount; i++) { const t = layoutTabs.nth(i) if ((await t.getAttribute('data-state')) === 'on') continue const d = await tabInfo(t); await shot(t, `layout-${i}-default`, 4) await t.hover(); await page.waitForTimeout(150) const h = await tabInfo(t); await shot(t, `layout-${i}-hover`, 4); await away() facts.layoutHover.push({ i, d, h }) check(`Layout ${i} default/hover text: #161410 12px 400, same box (Noto Sans KR)`, [d, h].every(s => s.text.color === 'rgb(22, 20, 16)' && s.text.size === '12px' && s.text.weight === '400' && s.text.font === '"Noto Sans KR"') && ['x', 'y', 'w', 'h'].every(k => d.text[k] === h.text[k]), `${d.text.color} → ${h.text.color}, ${d.text.w}×${d.text.h}`) } // restore the initial selection if (modelState !== 'on') await layoutTabs.first().locator('.hm-sw-footer-tab__select').click() // 4. Category pocket surface for 도형 / 수정 / 편집 / 문자 (and every other category) const catTabs = footer.locator('.hm-sw-footer-menu__tab') const catNames = await catTabs.allTextContents() facts.categories = [] for (let i = 0; i < catNames.length; i++) { await catTabs.nth(i).click(); await away(); await page.waitForTimeout(150) const info = await footer.evaluate(f => { const tab = f.querySelector('.hm-sw-footer-menu__tab[aria-selected="true"]'), tr = tab.getBoundingClientRect() const vacant = f.querySelector('.hm-sw-footer__vacant'), vr = vacant?.getBoundingClientRect() const slots = f.querySelectorAll('.hm-sw-footer__slots > *, .hm-sw-footer-slot').length // What is painted right behind the open pocket (just above the tab's top edge, across its width)? const xs = [0.1, 0.5, 0.9].map(p => tr.x + tr.width * p) const behind = xs.map(x => { const els = document.elementsFromPoint(x, tr.y - 2); const e = els.find(el => el !== tab && !tab.contains(el)); return e ? (e.className.baseVal ?? e.className).toString().split(' ')[0] : '' }) return { name: tab.textContent.trim(), tab: [Math.round(tr.x), Math.round(tr.y), Math.round(tr.width), Math.round(tr.height)], vacantBottom: vr ? Math.round(vr.bottom) : null, vacantLeft: vr ? Math.round(vr.left) : null, tabTop: Math.round(tr.top), slots, behind } }) facts.categories.push(info) await shot(footer.locator('.hm-sw-footer__group').first(), `category-${i}-${info.name.replace(/[\\/]/g, '_')}`, 2) } const pocketOk = facts.categories.every(c => c.vacantBottom === null || c.vacantBottom <= c.tabTop + 1) check('Categories: vacant surface never reaches the selected tab pocket (no split surface) — every category', pocketOk, facts.categories.map(c => `${c.name}: vacant↓${c.vacantBottom} tab↑${c.tabTop} behind=${[...new Set(c.behind)].join('/')}`)) check('Categories: one surface behind the whole pocket (left / middle / right) — every category', facts.categories.every(c => new Set(c.behind).size === 1), facts.categories.map(c => `${c.name}:${[...new Set(c.behind)].join('/')}`)) check('Categories: tab text Noto Sans KR', (await catTabs.first().evaluate(e => getComputedStyle(e).fontFamily)).startsWith('"Noto Sans KR"')) check('Console / page error 없음', errors.length === 0, errors.slice(0, 3)) await browser.close() await writeFile(path.join(out, 'footer-design-review.json'), JSON.stringify({ storybook, results, facts, errors }, null, 2) + '\n') console.log(`${results.filter(r => r.result === 'PASS').length}/${results.length} PASS`)