Files
HMDesign/packages/sw/tests/footer-design-review.browser.mjs
T
shkim4 fc65b2e836 feat(sw): bold 9-slice footer tabs and viewer panel bottom edge
User-approved 2026-10-22 SW changes (CSS only, public API unchanged).
- Footer Model / Layout tab labels 700 in every state, letter-spacing -5%;
  the name-edit input keeps 400.
- Slanted Layout tab and Model tab draw their SVG assets as 9-slice
  border-images: 97px / 73px stay the minimum, the slant and corners keep
  their size and only the flat middle grows with a longer label (max 12em,
  then ellipsis, like the rounded tabs). Hover surfaces use a union mask of
  the same asset (no mask-border), so Chromium and Firefox match. The
  default width renders pixel-identical to the previous drawing; the 8px
  slant / Model joint is unchanged.
- Viewer panel body: the 7px bottom padding (inside the scrollport) becomes
  a 2px transparent bottom border, so scrolled rows stop 2px above the
  frame border instead of running into it. SWPopup keeps its 7px.
- AI docs: DESIGN_RULES, AI_USAGE, FOOTER, VIEWER updated.
- Tests: text-weight and footer-design-review expect the 700 labels and read
  the Model surface from the 9-slice ::before.
2026-10-22 15:32:07 +09:00

140 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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')
// 2026-10-22: the label (Bold 700) is the text; the 9-slice tab surface is the ::before border-image.
const tabInfo = async loc => ({ tab: await style(loc), before: await style(loc, '::before'), surface: await loc.evaluate(e => getComputedStyle(e, '::before').borderImageSource), text: await style(loc.locator('.hm-sw-footer-tab__label').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 9-slice on ::before, beige), no orange ::before', mu.surface.startsWith('url(') && !mu.before.bg.includes('rgb(218, 104, 19)'), { surface: mu.surface.slice(0, 40), before: mu.before.bg })
check('Model text #161410 12px 700 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 === '700' && 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 700, same box (Noto Sans KR)`, [d, h].every(s => s.text.color === 'rgb(22, 20, 16)' && s.text.size === '12px' && s.text.weight === '700' && 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`)