Files
HMDesign/packages/sw/tests/theme-inheritance.browser.mjs
T
shkim4 228d8872fc feat(sw): add remaining component theme tokens
Add nine component tokens declared on the outermost SW scope. Each
defaults to the token or literal it replaces, so the default look and
header theme inheritance are unchanged; setting one changes its
component alone.

- Viewer Header menus (logo menu, header dropdowns, More):
  --hm-sw-viewer-header-menu-background / -border-color / -shadow /
  -item-hover-background (default: --hm-sw-header-more-*)
- SWHeader K guide: --hm-sw-header-k-guide-border-color
  (default var(--hm-sw-property-border)) / -background (#0e3c2e)
- Popup value color: --hm-sw-popup-value-color
  (default var(--hm-sw-header-input-color)); the property bar keeps
  the header token
- Footer command history scrollbar:
  --hm-sw-footer-command-scrollbar-thumb (#574b30) / -track (transparent)

Footer focus ring, side shadow, shared text color and dividers stay
shared. Docs (DESIGN_RULES, AI_USAGE, VIEWER, header design spec) and
theme-inheritance section 1d updated.
2026-11-10 18:21:32 +09:00

480 lines
37 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 theme token inheritance (2026-10-20, A-1).
// 1) SW/Theme › Theme Inheritance: a token set through `style` on the outermost SW scope reaches the header, dropdowns,
// More, panels, footer command line and tooltips; portals get it only when wrapped in a themed <SWTheme>; areas do
// not mix; unthemed components keep Noto Sans KR. Dotum is a verification value only (not the SW default).
// 2) Regression: every existing SW Story renders identically with the new cascade and with the legacy cascade
// (token blocks re-declared on every `.hm-sw-header, .hm-sw-theme`, i.e. the code before this change): computed
// font / color / geometry of every SW element and an element screenshot comparison. No Story sets an override,
// so any difference would be a regression. Pixels are compared with a control shot taken after the CURRENT token
// blocks are injected once more (2026-10-20): re-declaring them alone re-rasterizes a few gradient pixels.
// Usage: node packages/sw/tests/theme-inheritance.browser.mjs <storybook-url> <out-dir>
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { runtimeRequire } from './playwright-runtime.mjs'
const require = runtimeRequire()
const { chromium } = require('playwright')
const { PNG } = require('pngjs')
const pixelmatch = require('pixelmatch').default ?? require('pixelmatch')
const [storybook = 'http://127.0.0.1:6024', outArg] = process.argv.slice(2)
const out = path.resolve(outArg ?? 'docs/sw/theme-inheritance-qa')
await mkdir(out, { recursive: true })
const VERIFY = 'Dotum, 돋움, sans-serif'
const NESTED = '"Malgun Gothic", sans-serif'
const DEFAULT = '"Noto Sans KR", "Malgun Gothic", "Apple SD Gothic Neo", sans-serif'
const results = []
const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) }
const errors = []
// 2026-10-20: software raster without font hinting / subpixel positioning, as integrated-visual / viewer-regression: the
// regression pass failed on varying Stories (Home Menu, Footer, Responsive More) on the same build, before and after the change.
const browser = await chromium.launch({ headless: true, args: ['--disable-gpu', '--font-render-hinting=none', '--disable-font-subpixel-positioning', '--disable-lcd-text', '--force-color-profile=srgb'], executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const newPage = async (width = 1440, height = 1000) => {
const page = await browser.newPage({ viewport: { width, height }, reducedMotion: 'reduce' })
page.on('pageerror', error => errors.push(`${page.url()}: ${error.message}`))
page.on('console', message => { if (message.type() === 'error' && !/favicon|404/.test(message.text())) errors.push(`${page.url()}: ${message.text()}`) })
return page
}
const open = async (page, id) => {
await page.goto(`${storybook}/iframe.html?id=${id}&viewMode=story`)
await page.waitForSelector('#storybook-root > *')
await page.evaluate(() => document.fonts.ready)
await page.waitForTimeout(300)
}
// Font of every element with its own text under `selector` (header items, menu rows, panel controls …).
const fonts = (page, selector, exclude = null) => page.evaluate(([selector, exclude]) => {
const map = {}
for (const root of document.querySelectorAll(selector)) for (const element of [root, ...root.querySelectorAll('*')]) {
// Story content (fixture classes) and text-less range inputs are not SW typography.
if (element.closest('svg') || element.matches('[class^="sw-"], input[type="range"]') || (exclude && element.matches(exclude))) continue
const own = [...element.childNodes].some(node => node.nodeType === 3 && node.textContent.trim()) || /^(INPUT|TEXTAREA|BUTTON)$/.test(element.tagName)
if (!own) continue
const family = getComputedStyle(element).fontFamily
map[family] = (map[family] ?? 0) + 1
}
return map
}, [selector, exclude])
const only = (map, expected) => Object.keys(map).length > 0 && Object.keys(map).every(family => family === expected)
const fmt = map => Object.entries(map).map(([family, count]) => `${count}× ${family}`).join(' | ') || 'no text'
// More (2026-10-21): the header shows More only when its measured room runs out (full → icons → More). The Story's
// 1000px header fits as icons, so the header is narrowed for this check, then restored. 2026-11-10: 480px, since
// cabd2f6 (2026-10-26) the viewer header keeps every menu down to about 520px and shows More only below that.
async function openMore(page, area) {
const header = page.locator(`${area} .hm-sw-viewer-header`)
await header.evaluate(element => { element.style.width = "480px" })
await page.locator(`${area} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
}
const restoreWidth = (page, area) => page.locator(`${area} .hm-sw-viewer-header`).evaluate(element => { element.style.width = "" })
// Viewer header dropdown checked here (2026-10-21): the file-name list (.hm-sw-header__document-menu); the logo menu is gone.
const DOC_LIST = '.hm-sw-viewer-header .hm-sw-header__document-menu'
async function dropdownFonts(page, area, name, expected) {
const label = expected === DEFAULT ? 'Noto Sans KR' : 'Dotum'
await page.locator(`${area} .hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button`).click()
await page.waitForSelector(`${area} ${DOC_LIST}`)
const list = `${area} :is(${DOC_LIST}, .hm-sw-viewer-header .hm-sw-header__document-button)` // file name + file open surfaces
check(`${name}: 파일명 버튼 + 샘플 목록 = ${label}`, only(await fonts(page, list), expected), fmt(await fonts(page, list)))
await page.keyboard.press('Escape')
}
// Token → painted property of each dropdown part. 2026-10-21: the logo menu keeps the SWHeader `...` menu colors (object
// border, text, row hover tokens; its face #0e3d2f is a fixed value, checked below); the document list keeps its tokens.
const COLOR_TOKENS = [
['--hm-sw-header-document-menu-background', DOC_LIST, 'backgroundColor'],
['--hm-sw-header-document-menu-border-color', DOC_LIST, 'borderTopColor'],
['--hm-sw-header-title-color', `${DOC_LIST} .hm-sw-header__document-row:not([data-active]) .hm-sw-header__document-item-name`, 'color'],
['--hm-sw-header-document-item-hover-background', `${DOC_LIST} .hm-sw-header__document-row:not([data-active])`, 'backgroundColor', 'hover'],
]
async function dropdownColors(page, def, themed) {
// Paint of every part with its menu open (hovered first when the token is a hover state), next to the token value
// resolved in the same scope (a probe painted with var(token), so both are in the computed rgb() form).
const paint = async (area, scopeSelector) => {
const result = {}
for (const [token, part, property, hover] of COLOR_TOKENS) {
await page.locator(`${area} .hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button`).click()
const target = page.locator(`${area} ${part}`).first()
await target.waitFor()
if (hover) { await target.hover(); await page.waitForTimeout(120) }
result[token] = await target.evaluate((element, [token, property, scopeSelector]) => {
const probe = document.createElement('span'); probe.style.color = `var(${token})`
element.closest(scopeSelector).append(probe); const value = getComputedStyle(probe).color; probe.remove()
return { painted: getComputedStyle(element)[property], token: value }
}, [token, property, scopeSelector])
await page.mouse.move(2, 2)
await page.keyboard.press('Escape')
}
return result
}
const reference = await paint(def, '.hm-sw-viewer-header')
check('기본: 샘플 목록(SWHeader 파일명 목록 토큰) 배경·글자·Border·Hover = 기본 토큰 값', Object.values(reference).every(item => item.painted === item.token), JSON.stringify(reference))
// Themed: distinct colors set once on the outermost scope reach both menus; removed again afterwards.
const values = ['rgb(201, 11, 21)', 'rgb(202, 12, 22)', 'rgb(203, 13, 23)', 'rgb(204, 14, 24)']
const tokens = COLOR_TOKENS.map(item => item[0])
await page.locator(themed).evaluate((element, [tokens, values]) => tokens.forEach((token, i) => element.style.setProperty(token, values[i])), [tokens, values])
const themedPaint = await paint(themed, themed)
await page.locator(themed).evaluate((element, tokens) => tokens.forEach(token => element.style.removeProperty(token)), tokens)
check('뷰어 테마: 바깥 범위 토큰(배경·글자·Border·Hover)이 샘플 목록까지 상속', tokens.every((token, i) => themedPaint[token].painted === values[i]), JSON.stringify(themedPaint))
}
// ---------- 1. Theme inheritance Story ----------
{
const page = await newPage()
await open(page, 'sw-theme--theme-inheritance')
const scope = name => `[data-theme-case="${name}"]`
// Default (no theme): header, then its dropdowns and More. 2026-10-21: the header's dropdowns are the logo menu
// (파일 열기, SWHeader `...` menu) and the file-name sample list (SWHeader file-name list); the 샘플 menu button is gone.
const def = scope('default')
check('기본: 단독 SWViewerHeader = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-header`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-header`)))
await dropdownFonts(page, def, '기본', DEFAULT)
await openMore(page, def)
await page.waitForSelector(`${def} .hm-sw-viewer-menu[data-surface="beige"]`)
check('기본: More = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-menu`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-menu`)))
await page.keyboard.press('Escape')
await restoreWidth(page, def)
// Viewer, themed through <SWTheme style>.
const viewer = scope('viewer-themed')
check('뷰어 테마: SWViewerHeader 전체 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-header`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-header`)))
await dropdownFonts(page, viewer, '뷰어 테마', VERIFY)
// Colors / border / hover of the two dropdowns: the SWHeader default token values without a theme, and a token set
// through `style` on the outermost SW scope (here the themed <SWTheme>) reaches both menus. The colors are set only
// for this check and removed again (the Story itself themes the font only).
await dropdownColors(page, def, viewer)
await openMore(page, viewer)
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu[data-surface="beige"]`)
check('뷰어 테마: More = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
// A submenu of More, if any, is a nested menu inside a nested menu.
const sub = page.locator(`${viewer} .hm-sw-viewer-menu[data-surface="beige"] [aria-haspopup="menu"]`).first()
if (await sub.count()) {
await sub.hover()
await page.waitForTimeout(150)
check('뷰어 테마: More 서브메뉴 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
}
await page.keyboard.press('Escape')
await restoreWidth(page, viewer)
const panels = await page.locator(`${viewer} .hm-sw-viewer-panel`).count()
check('뷰어 테마: SWViewerPanel(제목·입력·버튼·스위치·힌트) = Dotum', panels === 2 && only(await fonts(page, `${viewer} .hm-sw-viewer-panel`), VERIFY), `${panels} panels · ${fmt(await fonts(page, `${viewer} .hm-sw-viewer-panel`))}`)
await page.screenshot({ path: path.join(out, 'viewer-themed-more.png'), clip: await page.locator(viewer).boundingBox() })
// General mode: SWHeader (+ open layer dropdown, window-move tooltip), SWFooter (+ command line, guide tooltip).
const general = scope('general-themed')
const headerFonts = await fonts(page, `${general} .hm-sw-header`, '.hm-sw-header__motion-tooltip')
check('일반 테마: SWHeader + 열린 레이어 Dropdown = Dotum (창 이동 Tooltip 별도)', only(headerFonts, VERIFY), fmt(headerFonts))
const tooltip = await page.locator(`${general} .hm-sw-header__motion-tooltip`).evaluate(element => getComputedStyle(element).fontFamily)
check('일반 테마: 창 이동 Tooltip = Inter → 테마 글꼴', tooltip === `Inter, ${VERIFY}`, tooltip)
const layerMenu = await page.locator(`${general} .hm-sw-header`).getByText('A-WALL').count()
check('일반 테마: 레이어 Dropdown이 열린 상태(목록 행 A-WALL 표시)', layerMenu > 0, `${layerMenu} row`)
check('일반 테마: SWFooter = Dotum', only(await fonts(page, `${general} .hm-sw-footer`), VERIFY), fmt(await fonts(page, `${general} .hm-sw-footer`)))
check('일반 테마: 명령창(SWFooterCommandLine, 중첩 hm-sw-theme) = Dotum', only(await fonts(page, `${general} .hm-sw-footer-command`), VERIFY), fmt(await fonts(page, `${general} .hm-sw-footer-command`)))
await page.locator(`${general} .hm-sw-footer-add`).first().focus()
await page.waitForTimeout(150)
const guide = page.locator(`${general} .hm-sw-footer [role="tooltip"]`)
check('일반 테마: 명령 등록 안내(Tooltip) = Dotum', (await guide.count()) > 0 && only(await fonts(page, `${general} .hm-sw-footer [role="tooltip"]`), VERIFY), fmt(await fonts(page, `${general} .hm-sw-footer [role="tooltip"]`)))
// Portals.
const [plain, themed, insideSource] = await page.evaluate(() => {
const target = document.querySelector('[data-portal-target]')
const plain = target.querySelector('[data-portal="unthemed"]'), themed = target.querySelector('[data-portal="themed"]')
return [getComputedStyle(plain.querySelector('.hm-sw-viewer-button')).fontFamily, getComputedStyle(themed.querySelector('.hm-sw-viewer-button')).fontFamily,
!!document.querySelector('[data-theme-case="portal-source"] [data-portal]')]
})
check('Portal: DOM이 테마 영역 밖에 렌더링됨', !insideSource)
check('Portal: SWTheme만 감싸면 기본 글꼴(테마 미전달)', plain.startsWith('"Noto Sans KR"'), plain)
check('Portal: 같은 theme style의 SWTheme으로 감싸면 테마 적용', themed === VERIFY, themed)
// Areas.
const area = name => fonts(page, `${scope(name)} .hm-sw-viewer-hint`)
check('영역 A(Dotum)', only(await area('area-a'), VERIFY), fmt(await area('area-a')))
check('영역 B(기본) — 옆 영역 값이 섞이지 않음', only(await area('area-b'), DEFAULT), fmt(await area('area-b')))
const outer = await page.locator(`${scope('area-c')} > .hm-sw-viewer-hint`).evaluate(element => getComputedStyle(element).fontFamily)
check('영역 C 바깥(Dotum)', outer === VERIFY, outer)
check('영역 C 안쪽 SWTheme style(Malgun Gothic)만 안쪽에 적용', only(await area('area-c-nested'), NESTED), fmt(await area('area-c-nested')))
await page.screenshot({ path: path.join(out, 'theme-inheritance-full.png'), fullPage: true })
await page.close()
}
// ---------- 1b. Step Header (2026-11-10): --hm-sw-process-* copies of the SWHeader tokens it paints with ----------
// Without a Step Header value an SWHeader theme on the outermost SW scope still reaches the Step Header; a
// --hm-sw-process-* value there changes the Step Header alone (it wins over the SWHeader token) and leaves SWHeader as is.
{
const page = await newPage()
await open(page, 'sw-step-header--process-header')
const outerOf = selector => page.locator(selector).first().evaluateHandle(element => { let outer = element; for (let e = element; e; e = e.parentElement) if (e.matches?.('.hm-sw-header, .hm-sw-theme')) outer = e; return outer })
const scope = await outerOf('.hm-sw-process-header')
const set = async (values) => scope.evaluate((outer, values) => Object.entries(values).forEach(([token, value]) => value === null ? outer.style.removeProperty(token) : outer.style.setProperty(token, value)), values)
const read = () => page.evaluate(() => {
const header = getComputedStyle(document.querySelector('.hm-sw-process-header'))
const step = document.querySelector('.hm-sw-process-bar__step[aria-checked="true"]')
return { background: `${header.backgroundColor} ${header.backgroundImage}`, selected: step ? getComputedStyle(step).color : null }
})
const base = await read()
await set({ '--hm-sw-header-background': 'rgb(44, 55, 66)', '--hm-sw-header-selected-color': 'rgb(11, 22, 33)' })
const inherited = await read()
check('Step Header: 바깥 범위의 SWHeader 토큰(배경·선택 색)이 그대로 전달', base.selected !== null && inherited.background.includes('rgb(44, 55, 66)') && inherited.selected === 'rgb(11, 22, 33)', JSON.stringify({ base, inherited }))
await set({ '--hm-sw-process-background': 'rgb(1, 2, 3)', '--hm-sw-process-selected-color': 'rgb(77, 88, 99)' })
const own = await read()
check('Step Header: --hm-sw-process-* 지정이 SWHeader 토큰보다 우선', own.background.includes('rgb(1, 2, 3)') && own.selected === 'rgb(77, 88, 99)', JSON.stringify(own))
await set({ '--hm-sw-header-background': null, '--hm-sw-header-selected-color': null, '--hm-sw-process-background': null, '--hm-sw-process-selected-color': null })
check('Step Header: 지정을 지우면 기본값으로 복귀', JSON.stringify(await read()) === JSON.stringify(base), JSON.stringify(base))
await page.close()
// The same Step Header tokens on an SWHeader page change nothing there.
const header = await newPage()
await open(header, 'sw-header--overview')
const paint = () => header.evaluate(() => [...document.querySelectorAll('.hm-sw-header, .hm-sw-header *')].map(element => { const s = getComputedStyle(element); return `${s.color}|${s.backgroundColor}|${s.backgroundImage}|${s.boxShadow}|${s.borderTopColor}` }).join('\n'))
const before = await paint()
await header.locator('.hm-sw-header').first().evaluate(element => { let outer = element; for (let e = element; e; e = e.parentElement) if (e.matches?.('.hm-sw-header, .hm-sw-theme')) outer = e; ['background', 'height', 'text-color', 'selected-color', 'hover-border-gradient', 'document-background', 'object-background', 'property-background', 'window-icon-color'].forEach(name => outer.style.setProperty(`--hm-sw-process-${name}`, 'rgb(1, 2, 3)')) })
check('Step Header 토큰은 SWHeader 화면에 영향 없음', before === await paint(), `${before.split('\n').length} elements`)
await header.close()
}
// ---------- 1c. Viewer Header (2026-11-10): --hm-sw-viewer-document-* / -object-* / -property-* face aliases ----------
// Shared by default (an SWHeader theme on the outermost SW scope reaches the viewer faces); a --hm-sw-viewer-* value there
// changes the Viewer Header faces alone and leaves SWHeader and the Step Header as they are.
{
const setOn = (page, root, values) => page.locator(root).first().evaluate((element, values) => {
let outer = element
for (let e = element; e; e = e.parentElement) if (e.matches?.('.hm-sw-header, .hm-sw-theme')) outer = e
Object.entries(values).forEach(([token, value]) => value === null ? outer.style.removeProperty(token) : outer.style.setProperty(token, value))
}, values)
const page = await newPage()
await open(page, 'sw-viewer--viewer-header')
const read = () => page.evaluate(() => {
// Every match (the Story has more than one header; some file-name areas carry no face of their own).
const paint = selector => { const all = [...document.querySelectorAll(selector)]; if (!all.length) return null; return all.map(e => { const s = getComputedStyle(e); return `${s.backgroundColor} ${s.backgroundImage}` }).join(' || ') }
// The document face token paints the trailing status / button strip segments in this Story.
return { document: paint('.hm-sw-viewer-header__actions > :is(.hm-sw-viewer-status, .hm-sw-viewer-header__button, .hm-sw-header__window-buttons)'), property: paint('.hm-sw-viewer-header__group[data-surface="field"]') }
})
const A = 'linear-gradient(rgb(44, 55, 66), rgb(44, 55, 66))', B = 'linear-gradient(rgb(1, 2, 3), rgb(1, 2, 3))'
const base = await read()
await setOn(page, '.hm-sw-viewer-header', { '--hm-sw-header-document-background': A, '--hm-sw-header-property-background': A })
const inherited = await read()
check('Viewer Header: 바깥 범위의 SWHeader 토큰(파일명·속성 면)이 그대로 전달', base.document !== null && base.property !== null && inherited.document.includes('rgb(44, 55, 66)') && inherited.property.includes('rgb(44, 55, 66)'), JSON.stringify({ base, inherited }))
await setOn(page, '.hm-sw-viewer-header', { '--hm-sw-viewer-document-background': B, '--hm-sw-viewer-property-background': B })
const own = await read()
check('Viewer Header: --hm-sw-viewer-* 지정이 SWHeader 토큰보다 우선', own.document.includes('rgb(1, 2, 3)') && own.property.includes('rgb(1, 2, 3)'), JSON.stringify(own))
await setOn(page, '.hm-sw-viewer-header', { '--hm-sw-header-document-background': null, '--hm-sw-header-property-background': null, '--hm-sw-viewer-document-background': null, '--hm-sw-viewer-property-background': null })
check('Viewer Header: 지정을 지우면 기본값으로 복귀', JSON.stringify(await read()) === JSON.stringify(base), JSON.stringify(base))
await page.close()
// The Viewer Header face tokens on SWHeader and Step Header pages change nothing there.
const names = ['document-background', 'document-border-gradient', 'document-border-color', 'object-background', 'object-border-gradient', 'object-radius', 'property-background', 'property-border-color', 'property-radius', 'property-shadow']
const values = Object.fromEntries(names.map(name => [`--hm-sw-viewer-${name}`, name.endsWith('radius') ? '9px' : name.endsWith('shadow') ? '0 0 0 3px rgb(1, 2, 3)' : name.endsWith('color') ? 'rgb(1, 2, 3)' : B]))
const untouched = []
for (const [id, root] of [['sw-header--overview', '.hm-sw-header'], ['sw-step-header--process-header', '.hm-sw-process-header']]) {
const other = await newPage()
await open(other, id)
const paint = () => other.evaluate(root => [...document.querySelectorAll(`${root}, ${root} *`)].map(element => { const s = getComputedStyle(element); return `${s.color}|${s.backgroundColor}|${s.backgroundImage}|${s.boxShadow}|${s.borderTopColor}|${s.borderTopLeftRadius}` }).join('\n'), root)
const before = await paint()
await setOn(other, root, values)
untouched.push(`${id} ${before === await paint() ? 'same' : 'CHANGED'} ${before.split('\n').length}`)
await other.close()
}
check('Viewer Header 토큰은 SWHeader·Step Header 화면에 영향 없음', untouched.every(item => item.includes(' same ')), untouched.join(', '))
}
// ---------- 1d. Component tokens (2026-11-10): viewer header menus, K guide, popup value, footer command scrollbar ----------
// Each defaults to the token / literal it replaced (same value), follows a shared token set on the outermost SW scope,
// and when set itself changes its component alone.
{
const setOn = (page, root, values) => page.locator(root).first().evaluate((element, values) => {
let outer = element
for (let e = element; e; e = e.parentElement) if (e.matches?.('.hm-sw-header, .hm-sw-theme')) outer = e
Object.entries(values).forEach(([token, value]) => value === null ? outer.style.removeProperty(token) : outer.style.setProperty(token, value))
}, values)
const C1 = 'rgb(44, 55, 66)', C2 = 'rgb(1, 2, 3)'
const flow = async (label, page, root, read, shared, own, expect) => {
const base = await read()
await setOn(page, root, Object.fromEntries(shared.map(token => [token, C1])))
const inherited = await read()
await setOn(page, root, Object.fromEntries(own.map(token => [token, C2])))
const mine = await read()
await setOn(page, root, Object.fromEntries([...shared, ...own].map(token => [token, null])))
const back = await read()
check(`${label}: 기본값 = 기존 값`, JSON.stringify(base) === JSON.stringify(expect), JSON.stringify(base))
check(`${label}: 바깥 범위의 공유 토큰을 따름`, Object.values(inherited).every(value => value.includes(C1)), JSON.stringify(inherited))
check(`${label}: 전용 토큰 지정이 우선, 지우면 복귀`, Object.values(mine).every(value => value.includes(C2)) && JSON.stringify(back) === JSON.stringify(base), JSON.stringify(mine))
}
// Viewer Header menus: a header dropdown (도면) and the logo menu (no Story renders `logoMenu`: the same class
// structure is appended to the header, so the real rules apply).
const viewer = await newPage()
await open(viewer, 'sw-viewer--dropdowns')
await viewer.locator('.hm-sw-viewer-header').first().evaluate(header => {
const wrap = document.createElement('div'); wrap.className = 'hm-sw-viewer-header__logo-menu hm-sw-header__more'
wrap.innerHTML = '<div class="hm-sw-header__more-menu" data-probe=""><button type="button" class="hm-sw-header__more-item" data-visible="true">probe</button></div>'
header.append(wrap)
})
await viewer.getByRole('button', { name: '도면' }).first().click()
const menu = viewer.locator('.hm-sw-viewer-header .hm-sw-viewer-menu[data-surface]').first()
await menu.locator('.hm-sw-viewer-menu__item:not([aria-disabled])').first().hover()
const readMenu = () => viewer.evaluate(() => {
const m = getComputedStyle(document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-menu[data-surface]'))
const hovered = [...document.querySelectorAll('.hm-sw-viewer-header .hm-sw-viewer-menu .hm-sw-viewer-menu__item')].find(item => item.matches(':hover, :focus'))
const logo = getComputedStyle(document.querySelector('[data-probe]'))
return { face: `${m.backgroundColor} | ${m.borderTopColor} | ${m.boxShadow}`, hover: hovered ? getComputedStyle(hovered).backgroundColor : 'none', logo: `${logo.backgroundColor} | ${logo.borderTopColor} | ${logo.boxShadow}` }
})
const header = await newPage()
await open(header, 'sw-header--overview')
// Expected defaults: the SWHeader More menu face (same tokens) — computed on a probe More menu inside SWHeader.
const moreFace = await header.locator('.hm-sw-header').first().evaluate(root => {
const wrap = document.createElement('div'); wrap.className = 'hm-sw-header__more'
wrap.innerHTML = '<div class="hm-sw-header__more-menu"></div>'; root.append(wrap)
const s = getComputedStyle(wrap.firstChild); const value = `${s.backgroundColor} | ${s.borderTopColor} | ${s.boxShadow}`; wrap.remove(); return value
})
const hoverDefault = 'rgba(255, 255, 255, 0.08)'
await flow('Viewer Header 메뉴', viewer, '.hm-sw-viewer-header', readMenu,
['--hm-sw-header-more-menu-background', '--hm-sw-header-more-menu-border-color', '--hm-sw-header-more-menu-shadow', '--hm-sw-header-more-item-hover-background'],
['--hm-sw-viewer-header-menu-background', '--hm-sw-viewer-header-menu-border-color', '--hm-sw-viewer-header-menu-shadow', '--hm-sw-viewer-header-menu-item-hover-background'],
{ face: moreFace, hover: hoverDefault, logo: moreFace })
// The shadow token is a full shadow: the C1 / C2 color strings above are not valid shadows, so the shadow keeps its
// value there; check it once with a real shadow.
await setOn(viewer, '.hm-sw-viewer-header', { '--hm-sw-viewer-header-menu-shadow': `0 0 0 3px ${C2}` })
const shadow = await readMenu()
await setOn(viewer, '.hm-sw-viewer-header', { '--hm-sw-viewer-header-menu-shadow': null })
check('Viewer Header 메뉴: 그림자 전용 토큰', shadow.face.endsWith(`${C2} 0px 0px 0px 3px`) && shadow.logo.endsWith(`${C2} 0px 0px 0px 3px`), JSON.stringify(shadow))
await viewer.close()
// K guide panel (hover opens it).
const guide = await newPage()
await open(guide, 'sw-header--k-guide')
await guide.locator('.hm-sw-k-guide button').first().hover()
await guide.locator('.hm-sw-k-guide__panel').first().waitFor()
const readGuide = () => guide.evaluate(() => { const s = getComputedStyle(document.querySelector('.hm-sw-k-guide__panel')); return { border: s.borderTopColor, face: s.backgroundColor } })
// Shared token: the border follows --hm-sw-property-border; the face was a literal (own token only).
const guideBase = await readGuide()
await setOn(guide, '.hm-sw-header', { '--hm-sw-property-border': C1 })
const guideShared = await readGuide()
await setOn(guide, '.hm-sw-header', { '--hm-sw-header-k-guide-border-color': C2, '--hm-sw-header-k-guide-background': C2 })
const guideOwn = await readGuide()
await setOn(guide, '.hm-sw-header', { '--hm-sw-property-border': null, '--hm-sw-header-k-guide-border-color': null, '--hm-sw-header-k-guide-background': null })
check('K 가이드: 기본값 = 기존 값(#A67D03 / #0E3C2E)', guideBase.border === 'rgb(166, 125, 3)' && guideBase.face === 'rgb(14, 60, 46)', JSON.stringify(guideBase))
check('K 가이드: 바깥 범위의 --hm-sw-property-border를 따름', guideShared.border === C1 && guideShared.face === guideBase.face, JSON.stringify(guideShared))
check('K 가이드: 전용 토큰 지정이 우선, 지우면 복귀', guideOwn.border === C2 && guideOwn.face === C2 && JSON.stringify(await readGuide()) === JSON.stringify(guideBase), JSON.stringify(guideOwn))
await guide.close()
// Popup value color: property window inputs (value, gauge value), dropdown triggers, and the SWHeader 속성Bar untouched.
const popup = await newPage()
await open(popup, 'sw-header--property-panel-compare')
const readValue = () => popup.evaluate(() => {
const color = selector => [...new Set([...document.querySelectorAll(selector)].map(e => getComputedStyle(e).color))].join(',')
return {
input: color('.hm-sw-property__value:not([data-readonly]) > .hm-sw-header__input:not(:read-only)'),
gauge: color('.hm-sw-property__slider-value:not([data-readonly]) > .hm-sw-header__input:not(:read-only)'),
trigger: color('.hm-sw-property__value:not([data-readonly]) .hm-sw-property__trigger'),
}
})
await flow('Popup 값 글자색', popup, '.hm-sw-property', readValue, ['--hm-sw-header-input-color'], ['--hm-sw-popup-value-color'],
{ input: 'rgb(230, 4, 3)', gauge: 'rgb(230, 4, 3)', trigger: 'rgb(230, 4, 3)' })
await popup.close()
const bar = await newPage()
await open(bar, 'sw-header--overview')
const barColor = () => bar.evaluate(() => [...document.querySelectorAll('.hm-sw-property-bar .hm-sw-header__input, .hm-sw-property-bar__value')].map(e => getComputedStyle(e).color).join(','))
const barBefore = await barColor()
await setOn(bar, '.hm-sw-header', { '--hm-sw-popup-value-color': C2 })
check('Popup 값 토큰은 SWHeader 속성Bar 값에 영향 없음', barBefore.length > 0 && barBefore === await barColor(), barBefore)
await bar.close()
// Footer command history scrollbar.
const footer = await newPage()
await open(footer, 'sw-footer--full-footer')
const readBar = () => footer.evaluate(() => ({ scrollbar: getComputedStyle(document.querySelector('.hm-sw-footer-command__history')).scrollbarColor }))
const footerBase = await readBar()
await setOn(footer, '.hm-sw-footer-command', { '--hm-sw-footer-command-scrollbar-thumb': C2, '--hm-sw-footer-command-scrollbar-track': C1 })
const footerOwn = await readBar()
await setOn(footer, '.hm-sw-footer-command', { '--hm-sw-footer-command-scrollbar-thumb': null, '--hm-sw-footer-command-scrollbar-track': null })
check('Footer 명령창 스크롤바: 기본값 = 기존 값(#574B30 / transparent)', footerBase.scrollbar === 'rgb(87, 75, 48) rgba(0, 0, 0, 0)', JSON.stringify(footerBase))
check('Footer 명령창 스크롤바: 전용 토큰 지정이 우선, 지우면 복귀', footerOwn.scrollbar === `${C2} ${C1}` && JSON.stringify(await readBar()) === JSON.stringify(footerBase), JSON.stringify(footerOwn))
await footer.close()
await header.close()
}
// ---------- 2. Regression: new cascade vs legacy cascade on every existing SW Story ----------
const here = path.dirname(fileURLToPath(import.meta.url))
const outermost = ':is(.hm-sw-header, .hm-sw-theme):not(:where(.hm-sw-header, .hm-sw-theme) *)'
// 2026-11-10: sw-process-header.css (Step Header aliases) and the Step Header Stories joined the comparison.
const tokenBlocks = (await Promise.all(['sw.css', 'sw-viewer.css', 'sw-footer.css', 'sw-process-header.css'].map(file => readFile(path.join(here, '../src/theme', file), 'utf8'))))
.map(text => {
const start = text.indexOf(`${outermost} {`)
const end = text.indexOf('\n}', start)
if (start < 0 || end < 0) throw new Error('token block not found')
return text.slice(text.indexOf('{', start), end + 2)
})
const legacy = tokenBlocks.map(block => `.hm-sw-header,\n.hm-sw-theme ${block}`).join('\n')
// Control (2026-10-20): the SAME token blocks on the SAME selector, i.e. the current cascade declared once more.
// Re-declaring the token blocks re-rasterizes a few gradient pixels (±1–2 per channel at the end of the Footer
// command row) even though no value changes, so the legacy shot is also compared with this control shot.
const control = tokenBlocks.map(block => `${outermost} ${block}`).join('\n')
const index = await (await fetch(`${storybook}/index.json`)).json()
const ids = Object.values(index.entries).filter(entry => entry.type === 'story' && /^sw-(header|popup|viewer|footer|home-menu|step-header)/.test(entry.id)).map(entry => entry.id)
const snapshot = page => page.evaluate(() => [...document.querySelectorAll('.hm-sw-header, .hm-sw-theme')].flatMap(root => [root, ...root.querySelectorAll('*')]).map(element => {
const style = getComputedStyle(element), rect = element.getBoundingClientRect()
return `${style.fontFamily}|${style.fontSize}|${style.color}|${style.backgroundImage.slice(0, 80)}|${Math.round(rect.x * 10)},${Math.round(rect.y * 10)},${Math.round(rect.width * 10)},${Math.round(rect.height * 10)}`
}))
const regression = []
for (const width of [1920, 1440, 1180, 900, 560]) for (const id of ids) {
if (width !== 1440 && !/responsive|viewer--viewer-header|footer--full-footer|overview/.test(id)) continue
const page = await newPage(width)
await open(page, id)
const a = await snapshot(page)
const shot = async () => PNG.sync.read(await page.screenshot({ fullPage: true, animations: 'disabled' }))
const shotA = await shot()
await page.waitForTimeout(150)
// Pixels that change between two shots of the SAME CSS (live Story content) are masked out as noise.
const shotA2 = await shot()
// Control: the current cascade declared again. Its computed values must equal the first state.
await page.addStyleTag({ content: control })
await page.waitForTimeout(150)
const c = await snapshot(page)
const shotC = await shot()
await page.waitForTimeout(150)
const shotC2 = await shot()
await page.addStyleTag({ content: legacy })
await page.waitForTimeout(150)
const b = await snapshot(page)
const shotB = await shot()
const changedValues = (x, y) => x.length === y.length ? x.filter((value, i) => value !== y[i]).length : -1
const changed = changedValues(a, b), controlChanged = changedValues(a, c)
let pixels = -1, noise = 0, controlPixels = -1
if ([shotA2, shotC, shotC2, shotB].every(item => item.width === shotA.width && item.height === shotA.height)) {
pixels = 0
controlPixels = 0
for (let i = 0; i < shotA.data.length; i += 4) {
const same = (x, y) => x[i] === y[i] && x[i + 1] === y[i + 1] && x[i + 2] === y[i + 2] && x[i + 3] === y[i + 3]
if (!same(shotA.data, shotC.data)) controlPixels++
// Live Story content changing at rest (before or after the control) is masked out as noise.
if (!same(shotA.data, shotA2.data) || !same(shotC.data, shotC2.data)) noise++
// First shot and control shot both render the unchanged cascade (re-raster artifacts appear only in the
// control; a transient relayout may appear in either one). The legacy cascade fails when its pixel matches
// neither of them exactly.
else if (!same(shotC.data, shotB.data) && !same(shotA.data, shotB.data)) pixels++
}
}
// Computed values: legacy vs first state (and control vs first state). Pixels: legacy vs first state and control.
regression.push({ id, width, elements: a.length, changedElements: changed, controlChangedElements: controlChanged, diffPixels: pixels,
controlRasterPixels: controlPixels, maskedNoisePixels: noise, result: changed === 0 && controlChanged === 0 && pixels === 0 ? 'PASS' : 'FAIL' })
await page.close()
}
// Negative control: the legacy cascade must reproduce the reported defect on the themed Story (the comparison above is
// only meaningful if injecting it really restores the old behavior).
{
const page = await newPage()
await open(page, 'sw-theme--theme-inheritance')
await page.addStyleTag({ content: legacy })
await page.waitForTimeout(150)
const viewer = '[data-theme-case="viewer-themed"]'
await openMore(page, viewer)
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu[data-surface="beige"]`)
const more = await fonts(page, `${viewer} .hm-sw-viewer-menu`)
const command = await fonts(page, '[data-theme-case="general-themed"] .hm-sw-footer-command')
check('대조: 기존 구조 주입 시 More·명령창이 Noto로 되돌아감(결함 재현)', only(more, DEFAULT) && only(command, DEFAULT), `More ${fmt(more)} · 명령창 ${fmt(command)}`)
await page.close()
}
const failedRegression = regression.filter(item => item.result !== 'PASS')
check(`회귀: 기존 SW Story ${regression.length}개 화면 — 새 상속 구조 = 기존 구조(계산값·위치·픽셀)`, failedRegression.length === 0,
`${regression.length - failedRegression.length}/${regression.length}${failedRegression.length ? ` FAIL ${failedRegression.map(item => `${item.id}@${item.width}(${item.changedElements}/${item.diffPixels})`).join(', ')}` : ''}`)
check('Console / page error 없음', errors.length === 0, errors.slice(0, 5).join(' | '))
await browser.close()
await writeFile(path.join(out, 'theme-inheritance.json'), JSON.stringify({ storybook, results, regression, errors }, null, 2) + '\n')
const failed = results.filter(item => item.result !== 'PASS').length
console.log(`${results.length - failed}/${results.length} PASS`)
process.exit(failed ? 1 : 0)