// 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 the SW default Dotum (2026-10-20). Noto Sans KR is a verification value only. // 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 { createRequire } from 'node:module' import { mkdir, readFile, writeFile } from 'node:fs/promises' import path from 'node:path' import { fileURLToPath } from 'node:url' const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules' const require = createRequire(path.join(runtime, 'index.js')) 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 = '"Noto Sans KR", sans-serif' const NESTED = '"Malgun Gothic", sans-serif' const DEFAULT = 'Dotum, 돋움, "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 = [] const browser = await chromium.launch({ headless: true, 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' // ---------- 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 sample dropdown and More. const def = scope('default') check('기본: 단독 SWViewerHeader = 기본 Dotum', only(await fonts(page, `${def} .hm-sw-viewer-header`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-header`))) await page.locator(`${def} .hm-sw-viewer-header [aria-haspopup="menu"]`).first().click() await page.waitForSelector(`${def} .hm-sw-viewer-menu`) check('기본: Dropdown = 기본 Dotum', 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 page.locator(`${def} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click() await page.waitForSelector(`${def} .hm-sw-viewer-menu[data-surface="beige"]`) check('기본: More = 기본 Dotum', only(await fonts(page, `${def} .hm-sw-viewer-menu`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-menu`))) await page.keyboard.press('Escape') // Viewer, themed through . const viewer = scope('viewer-themed') check('뷰어 테마: SWViewerHeader 전체 = 검증 글꼴', only(await fonts(page, `${viewer} .hm-sw-viewer-header`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-header`))) await page.locator(`${viewer} .hm-sw-viewer-header [aria-haspopup="menu"]`).first().click() await page.waitForSelector(`${viewer} .hm-sw-viewer-menu`) check('뷰어 테마: Dropdown = 검증 글꼴', 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 page.locator(`${viewer} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click() await page.waitForSelector(`${viewer} .hm-sw-viewer-menu[data-surface="beige"]`) check('뷰어 테마: More = 검증 글꼴', 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 서브메뉴 = 검증 글꼴', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`))) } await page.keyboard.press('Escape') const panels = await page.locator(`${viewer} .hm-sw-viewer-panel`).count() check('뷰어 테마: SWViewerPanel(제목·입력·버튼·스위치·힌트) = 검증 글꼴', 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 = 검증 글꼴 (창 이동 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 = 검증 글꼴', only(await fonts(page, `${general} .hm-sw-footer`), VERIFY), fmt(await fonts(page, `${general} .hm-sw-footer`))) check('일반 테마: 명령창(SWFooterCommandLine, 중첩 hm-sw-theme) = 검증 글꼴', 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) = 검증 글꼴', (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 === DEFAULT, plain) check('Portal: 같은 theme style의 SWTheme으로 감싸면 테마 적용', themed === VERIFY, themed) // Areas. const area = name => fonts(page, `${scope(name)} .hm-sw-viewer-hint`) check('영역 A(검증 글꼴)', 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 바깥(검증 글꼴)', 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 page.locator(`${viewer} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click() 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·명령창이 기본 글꼴로 되돌아감(결함 재현)', 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)