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.
140 lines
13 KiB
JavaScript
140 lines
13 KiB
JavaScript
// 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`)
|