test(sw): align theme and consumer tests with the Dotum default

- theme-inheritance: default = Dotum stack, inheritance verification value = Noto Sans KR (same 23 checks).
- SW/Theme Story: verification font and labels follow the new default.
- ai-template-consumer: SWTheme token font now starts with Dotum.
- footer-design-review (new): Footer design items (전체보기 / + hover, Model selected / unselected, category pockets,
  Model / Layout text) measured in Storybook with state screenshots.
This commit is contained in:
shkim4 committed 2026-10-20 16:59:12 +09:00
1 parent 633ea61c32
commit d818d0308e
4 files changed
+166 -27

No files matched your search

@@ -41,7 +41,7 @@ await page.evaluate(() => document.fonts.ready)
const assets = await page.evaluate(() => [...document.images].map(image => ({ src: image.currentSrc.slice(0, 30), ok: image.complete && image.naturalWidth > 0 })))
check("1 all SVG/PNG assets decode (data URI, no external path)", assets.length > 0 && assets.every(item => item.ok && item.src.startsWith("data:")), { count: assets.length, broken: assets.filter(item => !item.ok) })
const tokens = await page.evaluate(() => { const s = getComputedStyle(document.querySelector('[data-example="b"] .hm-sw-theme')); return { height: s.getPropertyValue("--hm-sw-header-height").trim(), font: s.fontFamily } })
check("1 styles.css loaded; SWTheme provides tokens", tokens.height === "49px" && tokens.font.includes("Noto Sans KR"), tokens)
check("1 styles.css loaded; SWTheme provides tokens", tokens.height === "49px" && tokens.font.startsWith("Dotum"), tokens)
// 2. Example A: default header unchanged (Figma 1920 coordinates)
const a = section("a")
@@ -0,0 +1,139 @@
// SW Footer design review (2026-10-20, Dotum default): the four open design items and the Model / Layout text rule,
// measured in the real Storybook (`SW/Footer › 전체 Footer · 실제 조합`). Computed values + element screenshots for
// each state; design judgement (PASS / REVIEW) is written in the report, this script only records facts and checks
// the approved, unambiguous rules.
// Usage: node packages/sw/tests/footer-design-review.browser.mjs [storybook-url] [evidence-dir]
import { createRequire } from 'node:module'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
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 [storybook = 'http://127.0.0.1:6024', outArg = 'docs/sw/sw-dotum-font-qa/footer-design'] = process.argv.slice(2)
const out = path.resolve(outArg); await mkdir(out, { recursive: true })
const results = [], facts = {}, errors = []
const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail !== '' ? ` — ${JSON.stringify(detail)}` : ''}`) }
const GRADIENT = 'linear-gradient(rgb(218, 104, 19) 8.52%, rgb(255, 187, 137) 37.93%, rgb(229, 105, 13) 71.85%, rgb(174, 54, 7) 92.77%)'
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: { width: 2400, height: 1400 }, reducedMotion: 'reduce' })
page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error' && !/favicon|404/.test(m.text())) errors.push(m.text()) })
await page.goto(`${storybook}/iframe.html?id=sw-footer--full-footer&viewMode=story`)
await page.waitForSelector('.hm-sw-footer'); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(400)
const footer = page.locator('.hm-sw-footer').first()
await footer.scrollIntoViewIfNeeded()
const style = (loc, pseudo = null) => loc.evaluate((e, pseudo) => {
const c = getComputedStyle(e, pseudo), r = e.getBoundingClientRect()
return { x: +r.x.toFixed(1), y: +r.y.toFixed(1), w: +r.width.toFixed(1), h: +r.height.toFixed(1), color: c.color, bg: c.backgroundImage === 'none' ? c.backgroundColor : c.backgroundImage.replace(/url("data:[^"]{0,4000}")/g, 'url(data)'), opacity: c.opacity, font: c.fontFamily.split(',')[0], size: c.fontSize, weight: c.fontWeight, shadow: c.textShadow === 'none' ? 'none' : 'outline', mask: (c.maskImage || c.webkitMaskImage || '').slice(0, 60) }
}, pseudo)
// Element shot (scrolls into view; the hover state is kept because the pointer stays over the element).
const shot = (loc, name) => loc.screenshot({ path: path.join(out, `${name}.png`) })
const away = () => page.mouse.move(5, 5)
// 0. Token value
const token = await footer.evaluate(e => getComputedStyle(e).getPropertyValue('--hm-sw-footer-gra-border-orange').trim())
facts.token = token
check('Token --hm-sw-footer-gra-border-orange = user gradient (#DA6813 8.52% / #FFBB89 37.93% / #E5690D 71.85% / #AE3607 92.77%)', token.replace(/\s+/g, ' ').toLowerCase() === 'linear-gradient(180deg, #da6813 8.52%, #ffbb89 37.93%, #e5690d 71.85%, #ae3607 92.77%)', token)
// 1. 전체보기 side buttons hover (보조기능 / 명령어)
facts.side = []
for (const [i, label] of [[0, 'aux'], [1, 'commands']]) {
const side = footer.locator('.hm-sw-footer-side').nth(i)
await away(); await page.waitForTimeout(100)
const d = { box: await style(side), ring: await style(side, '::after'), title: await style(side.locator('.hm-sw-footer-side__title')), sub: await style(side.locator('.hm-sw-footer-side__subtitle')) }
await shot(side, `side-${label}-default`)
await side.hover(); await page.waitForTimeout(150)
const h = { box: await style(side), ring: await style(side, '::after'), title: await style(side.locator('.hm-sw-footer-side__title')), sub: await style(side.locator('.hm-sw-footer-side__subtitle')) }
await shot(side, `side-${label}-hover`)
facts.side.push({ label, default: d, hover: h })
check(`전체보기(${label}) hover: ring over the whole button (opacity 0 → 1, gra-border-orange, same box)`, d.ring.opacity === '0' && h.ring.opacity === '1' && h.ring.bg === GRADIENT && h.ring.w === h.box.w && h.ring.h === h.box.h, { ring: h.ring.w + '×' + h.ring.h, box: h.box.w + '×' + h.box.h })
check(`전체보기(${label}) hover: both text lines #FF5C00`, h.title.color === 'rgb(255, 92, 0)' && h.sub.color === 'rgb(255, 92, 0)', [h.title.color, h.sub.color])
const rel = s => ({ w: s.box.w, h: s.box.h, tx: +(s.title.x - s.box.x).toFixed(1), ty: +(s.title.y - s.box.y).toFixed(1), tw: s.title.w, sx: +(s.sub.x - s.box.x).toFixed(1), sy: +(s.sub.y - s.box.y).toFixed(1), sw: s.sub.w })
check(`전체보기(${label}) hover: size and text boxes (relative to the button) unchanged`, JSON.stringify(rel(d)) === JSON.stringify(rel(h)), [rel(d), rel(h)])
check(`전체보기(${label}) text = Dotum 14px`, d.title.font === 'Dotum' && d.title.size === '14px', d.title.font)
}
await away()
// 2. + buttons: category row add (below 명령어 전체보기 side) and Layout add
facts.add = {}
for (const [sel, name] of [['.hm-sw-footer-menu__add', 'category-add'], ['.hm-sw-footer-tabs__add', 'layout-add']]) {
const btn = footer.locator(sel).first()
if (!(await btn.count())) { check(`${name}: present`, false); continue }
const d = { box: await style(btn), before: await style(btn, '::before') }
await shot(btn, `${name}-default`, 4)
await btn.hover(); await page.waitForTimeout(150)
const h = { box: await style(btn), before: await style(btn, '::before') }
await shot(btn, `${name}-hover`, 4)
facts.add[name] = { default: d, hover: h }
check(`${name} hover: same size`, d.box.w === h.box.w && d.box.h === h.box.h, `${h.box.w}×${h.box.h}`)
await away()
}
const cat = facts.add['category-add'].hover
check('category + hover: icon swaps to the orange + (menu-add-hover), no extra surface', facts.add['category-add'].default.box.bg !== cat.box.bg && cat.box.bg.startsWith('url(') && !cat.box.bg.includes('linear-gradient'), 'background image changes on hover')
const lay = facts.add['layout-add'].hover
check('Layout + hover: gra-border-orange surface masked to the button shape, + icon on top', lay.before.bg.startsWith('url(') && lay.before.bg.includes('rgb(218, 104, 19)') && lay.before.mask.startsWith('url('), 'url(+) , gra-border-orange ; mask=button shape')
// click keeps working
const addCalls = await page.evaluate(() => document.body.innerText.match(/onSlotAdd|onCategoryAdd|onLayoutAdd|onAdd/g)?.length ?? 0)
facts.addCallsBefore = addCalls
// 3. Model tab: selected vs not selected, Layout text rule
const tabs = footer.locator('.hm-sw-footer-tab')
const tabInfo = async loc => ({ tab: await style(loc), before: await style(loc, '::before'), text: await style(loc.locator('.hm-sw-footer-tab__label, .hm-sw-footer-tab__select').first()) })
const model = footer.locator('.hm-sw-footer-tab--model').first()
const modelState = await model.getAttribute('data-state')
facts.model = { initialState: modelState, selected: null, unselected: null }
// Make a Layout selected so the Model is unselected, then reselect the Model.
const layoutTabs = footer.locator('.hm-sw-footer-tab:not(.hm-sw-footer-tab--model)')
const layoutCount = await layoutTabs.count()
if (modelState === 'on') { facts.model.selected = await tabInfo(model); await shot(model, 'model-selected', 4) }
await layoutTabs.first().locator('.hm-sw-footer-tab__select').click(); await away(); await page.waitForTimeout(150)
facts.model.unselected = await tabInfo(model); await shot(model, 'model-unselected', 4)
facts.layoutSelected = await tabInfo(layoutTabs.first()); await shot(layoutTabs.first(), 'layout-selected', 4)
await model.hover(); await page.waitForTimeout(150); facts.model.unselectedHover = await tabInfo(model); await shot(model, 'model-unselected-hover', 4); await away()
await model.locator('.hm-sw-footer-tab__select').click(); await away(); await page.waitForTimeout(150)
facts.model.selected = await tabInfo(model); await shot(model, 'model-selected', 4)
const mu = facts.model.unselected, ms = facts.model.selected
check('Model selected: gra-border-orange surface (::before) masked to the model shape', ms.before.bg === GRADIENT && ms.before.mask.startsWith('url('), ms.before.bg.slice(0, 60))
check('Model not selected: its own tab image surface (tab-model.svg, beige), no orange ::before', mu.tab.bg.startsWith('url(') && !mu.before.bg.includes('rgb(218, 104, 19)'), { tab: mu.tab.bg, before: mu.before.bg })
check('Model text #161410 12px 400 no outline — selected, unselected and hover identical', [ms, mu, facts.model.unselectedHover].every(s => s.text.color === 'rgb(22, 20, 16)' && s.text.size === '12px' && s.text.weight === '400' && s.text.shadow === 'none') && ['x', 'y', 'w', 'h'].every(k => ms.text[k] === mu.text[k] && mu.text[k] === facts.model.unselectedHover.text[k]), [ms.text, mu.text].map(t => `${t.color} ${t.size} ${t.weight} ${t.font}`))
// Layout default vs hover text (non-selected layouts)
facts.layoutHover = []
for (let i = 1; i < layoutCount; i++) {
const t = layoutTabs.nth(i)
if ((await t.getAttribute('data-state')) === 'on') continue
const d = await tabInfo(t); await shot(t, `layout-${i}-default`, 4)
await t.hover(); await page.waitForTimeout(150)
const h = await tabInfo(t); await shot(t, `layout-${i}-hover`, 4); await away()
facts.layoutHover.push({ i, d, h })
check(`Layout ${i} default/hover text: #161410 12px 400, same box (Dotum)`, [d, h].every(s => s.text.color === 'rgb(22, 20, 16)' && s.text.size === '12px' && s.text.weight === '400' && s.text.font === 'Dotum') && ['x', 'y', 'w', 'h'].every(k => d.text[k] === h.text[k]), `${d.text.color} → ${h.text.color}, ${d.text.w}×${d.text.h}`)
}
// restore the initial selection
if (modelState !== 'on') await layoutTabs.first().locator('.hm-sw-footer-tab__select').click()
// 4. Category pocket surface for 도형 / 수정 / 편집 / 문자 (and every other category)
const catTabs = footer.locator('.hm-sw-footer-menu__tab')
const catNames = await catTabs.allTextContents()
facts.categories = []
for (let i = 0; i < catNames.length; i++) {
await catTabs.nth(i).click(); await away(); await page.waitForTimeout(150)
const info = await footer.evaluate(f => {
const tab = f.querySelector('.hm-sw-footer-menu__tab[aria-selected="true"]'), tr = tab.getBoundingClientRect()
const vacant = f.querySelector('.hm-sw-footer__vacant'), vr = vacant?.getBoundingClientRect()
const slots = f.querySelectorAll('.hm-sw-footer__slots > *, .hm-sw-footer-slot').length
// What is painted right behind the open pocket (just above the tab's top edge, across its width)?
const xs = [0.1, 0.5, 0.9].map(p => tr.x + tr.width * p)
const behind = xs.map(x => { const els = document.elementsFromPoint(x, tr.y - 2); const e = els.find(el => el !== tab && !tab.contains(el)); return e ? (e.className.baseVal ?? e.className).toString().split(' ')[0] : '' })
return { name: tab.textContent.trim(), tab: [Math.round(tr.x), Math.round(tr.y), Math.round(tr.width), Math.round(tr.height)], vacantBottom: vr ? Math.round(vr.bottom) : null, vacantLeft: vr ? Math.round(vr.left) : null, tabTop: Math.round(tr.top), slots, behind }
})
facts.categories.push(info)
await shot(footer.locator('.hm-sw-footer__group').first(), `category-${i}-${info.name.replace(/[\\/]/g, '_')}`, 2)
}
const pocketOk = facts.categories.every(c => c.vacantBottom === null || c.vacantBottom <= c.tabTop + 1)
check('Categories: vacant surface never reaches the selected tab pocket (no split surface) — every category', pocketOk, facts.categories.map(c => `${c.name}: vacant↓${c.vacantBottom} tab↑${c.tabTop} behind=${[...new Set(c.behind)].join('/')}`))
check('Categories: one surface behind the whole pocket (left / middle / right) — every category', facts.categories.every(c => new Set(c.behind).size === 1), facts.categories.map(c => `${c.name}:${[...new Set(c.behind)].join('/')}`))
check('Categories: tab text Dotum', (await catTabs.first().evaluate(e => getComputedStyle(e).fontFamily)).startsWith('Dotum'))
check('Console / page error 없음', errors.length === 0, errors.slice(0, 3))
await browser.close()
await writeFile(path.join(out, 'footer-design-review.json'), JSON.stringify({ storybook, results, facts, errors }, null, 2) + '\n')
console.log(`${results.filter(r => r.result === 'PASS').length}/${results.length} PASS`)
+19 -19
View File
@@ -1,7 +1,7 @@
// 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).
// 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,
@@ -22,9 +22,9 @@ 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 VERIFY = '"Noto Sans KR", sans-serif'
const NESTED = '"Malgun Gothic", sans-serif'
const DEFAULT = '"Noto Sans KR", "Malgun Gothic", "Apple SD Gothic Neo", 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 = []
@@ -67,52 +67,52 @@ const fmt = map => Object.entries(map).map(([family, count]) => `${count}× ${fa
// Default (no theme): header, then its sample dropdown and More.
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`)))
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 = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-menu`), DEFAULT), fmt(await fonts(page, `${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 = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-menu`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-menu`)))
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 <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`)))
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 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${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 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
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 서브메뉴 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
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(제목·입력·버튼·스위치·힌트) = Dotum', panels === 2 && only(await fonts(page, `${viewer} .hm-sw-viewer-panel`), VERIFY), `${panels} panels · ${fmt(await fonts(page, `${viewer} .hm-sw-viewer-panel`))}`)
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 = Dotum (창 이동 Tooltip 별도)', only(headerFonts, VERIFY), fmt(headerFonts))
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 = 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`)))
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) = 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"]`)))
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(() => {
@@ -122,15 +122,15 @@ const fmt = map => Object.entries(map).map(([family, count]) => `${count}× ${fa
!!document.querySelector('[data-theme-case="portal-source"] [data-portal]')]
})
check('Portal: DOM이 테마 영역 밖에 렌더링됨', !insideSource)
check('Portal: SWTheme만 감싸면 기본 글꼴(테마 미전달)', plain.startsWith('"Noto Sans KR"'), plain)
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(Dotum)', only(await area('area-a'), VERIFY), fmt(await area('area-a')))
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 바깥(Dotum)', outer === VERIFY, outer)
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()
@@ -213,7 +213,7 @@ for (const width of [1920, 1440, 1180, 900, 560]) for (const id of ids) {
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)}`)
check('대조: 기존 구조 주입 시 More·명령창이 기본 글꼴로 되돌아감(결함 재현)', only(more, DEFAULT) && only(command, DEFAULT), `More ${fmt(more)} · 명령창 ${fmt(command)}`)
await page.close()
}
const failedRegression = regression.filter(item => item.result !== 'PASS')
+7 -7
View File
@@ -14,9 +14,9 @@ import "./sw-viewer-stories.css"
// SW theme token inheritance (2026-10-20). The official way to change SW tokens: set them through `style` (or the
// consumer's own class) on the OUTERMOST SW scope — <SWTheme> around the screen, or the component root when it is
// used alone. Nested SW roots (viewer menus, More, panels, footer command line) inherit the value.
// Dotum is used here ONLY to make the inheritance visible: it is NOT the SW default font (design approval pending).
// The SW default font is Dotum (2026-10-20). Noto Sans KR is used here ONLY to make the inheritance visible.
const KICKER = "HM DESIGN SYSTEM / SW"
const VERIFY_FONT = 'Dotum, "돋움", sans-serif'
const VERIFY_FONT = '"Noto Sans KR", sans-serif'
const VERIFY_THEME = { "--hm-sw-header-font-family": VERIFY_FONT } as CSSProperties
const NESTED_THEME = { "--hm-sw-header-font-family": '"Malgun Gothic", sans-serif' } as CSSProperties
@@ -77,8 +77,8 @@ export const ThemeInheritance: Story = {
render: () => (
<StoryPage>
<StoryPageHeader kicker={KICKER} title="SW — 테마 토큰 상속"
description={<>SW 토큰은 가장 바깥 SW 범위(<code>&lt;SWTheme&gt;</code> 또는 단독 컴포넌트 루트)에서 한 번 선언되고, 안쪽 Dropdown·More·패널·명령창은 그 값을 상속합니다. 토큰은 가장 바깥 범위의 <code>style</code>로만 바꿉니다. 아래 Dotum은 <strong>상속 확인용 검증 값</strong>이며 SW 기본 글꼴(Noto Sans KR)은 바뀌지 않습니다(Dotum 기본 적용은 디자인 승인 대기).</>} />
<StorySection title="1. 기본 (테마 지정 없음)" description="단독 SWViewerHeader. 기본 글꼴 Noto Sans KR. 1000px 폭이라 일부 항목이 More(⋯)로 이동합니다.">
description={<>SW 토큰은 가장 바깥 SW 범위(<code>&lt;SWTheme&gt;</code> 또는 단독 컴포넌트 루트)에서 한 번 선언되고, 안쪽 Dropdown·More·패널·명령창은 그 값을 상속합니다. 토큰은 가장 바깥 범위의 <code>style</code>로만 바꿉니다. SW 기본 글꼴은 Dotum이며, 아래 Noto Sans KR은 <strong>상속 확인용 검증 값</strong>입니다.</>} />
<StorySection title="1. 기본 (테마 지정 없음)" description="단독 SWViewerHeader. 기본 글꼴 Dotum. 1000px 폭이라 일부 항목이 More(⋯)로 이동합니다.">
<ViewerArea width={1000} data-theme-case="default" />
</StorySection>
<StorySection title="2. 뷰어 모드 — SWTheme style로 글꼴 지정" description="<SWTheme style={{ '--hm-sw-header-font-family': … }}> 안의 Header·Dropdown·More·패널(내부 컨트롤 포함)이 같은 글꼴을 씁니다.">
@@ -105,10 +105,10 @@ export const ThemeInheritance: Story = {
</StorySection>
<StorySection title="5. 여러 테마 영역" description="옆 영역끼리 값이 섞이지 않고, 안쪽 SWTheme의 style은 그 안에서만 적용됩니다.">
<div style={{ display: "grid", gridTemplateColumns: "repeat(3, minmax(0, 1fr))", gap: 12 }}>
<SWTheme style={VERIFY_THEME} data-theme-case="area-a"><SWViewerHint>영역 A — 검증 글꼴(Dotum)</SWViewerHint></SWTheme>
<SWTheme data-theme-case="area-b"><SWViewerHint>영역 B — 기본(Noto Sans KR)</SWViewerHint></SWTheme>
<SWTheme style={VERIFY_THEME} data-theme-case="area-a"><SWViewerHint>영역 A — 검증 글꼴(Noto Sans KR)</SWViewerHint></SWTheme>
<SWTheme data-theme-case="area-b"><SWViewerHint>영역 B — 기본(Dotum)</SWViewerHint></SWTheme>
<SWTheme style={VERIFY_THEME} data-theme-case="area-c">
<SWViewerHint>영역 C 바깥 — Dotum</SWViewerHint>
<SWViewerHint>영역 C 바깥 — Noto Sans KR</SWViewerHint>
<SWTheme style={NESTED_THEME} data-theme-case="area-c-nested"><SWViewerHint>영역 C 안쪽 — Malgun Gothic</SWViewerHint></SWTheme>
</SWTheme>
</div>