// 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.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' }) 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 (as text-weight does), then restored. async function openMore(page, area) { const header = page.locator(`${area} .hm-sw-viewer-header`) await header.evaluate(element => { element.style.width = "700px" }) 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() } // ---------- 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) *)' const tokenBlocks = (await Promise.all(['sw.css', 'sw-viewer.css', 'sw-footer.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)/.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)