// 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 ; 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 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 . 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 ) 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 = '
' 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 = '
'; 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)