diff --git a/packages/sw/tests/ai-template-consumer.browser.mjs b/packages/sw/tests/ai-template-consumer.browser.mjs index ad917cc..b4b1abf 100644 --- a/packages/sw/tests/ai-template-consumer.browser.mjs +++ b/packages/sw/tests/ai-template-consumer.browser.mjs @@ -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.startsWith("Dotum"), tokens) +check("1 styles.css loaded; SWTheme provides tokens", tokens.height === "49px" && tokens.font.includes("Noto Sans KR"), tokens) // 2. Example A: default header unchanged (Figma 1920 coordinates) const a = section("a") diff --git a/packages/sw/tests/footer-design-review.browser.mjs b/packages/sw/tests/footer-design-review.browser.mjs index ef5105c..f4caa68 100644 --- a/packages/sw/tests/footer-design-review.browser.mjs +++ b/packages/sw/tests/footer-design-review.browser.mjs @@ -1,4 +1,4 @@ -// SW Footer design review (2026-10-20, Dotum default): the four open design items and the Model / Layout text rule, +// SW Footer design review (2026-10-20; SW default font Noto Sans KR since the Dotum default was withdrawn): 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. @@ -10,7 +10,7 @@ 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 [storybook = 'http://127.0.0.1:6024', outArg = 'docs/sw/sw-noto-bold-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)}` : ''}`) } @@ -51,7 +51,7 @@ for (const [i, label] of [[0, 'aux'], [1, 'commands']]) { 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) + check(`전체보기(${label}) text = Noto Sans KR 14px`, d.title.font === '"Noto Sans KR"' && d.title.size === '14px', d.title.font) } await away() @@ -106,7 +106,7 @@ for (let i = 1; i < layoutCount; i++) { 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}`) + check(`Layout ${i} default/hover text: #161410 12px 400, same box (Noto Sans KR)`, [d, h].every(s => s.text.color === 'rgb(22, 20, 16)' && s.text.size === '12px' && s.text.weight === '400' && s.text.font === '"Noto Sans KR"') && ['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() @@ -132,7 +132,7 @@ for (let i = 0; i < catNames.length; i++) { 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('Categories: tab text Noto Sans KR', (await catTabs.first().evaluate(e => getComputedStyle(e).fontFamily)).startsWith('"Noto Sans KR"')) 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') diff --git a/packages/sw/tests/footer-popup-viewer-refinement.browser.mjs b/packages/sw/tests/footer-popup-viewer-refinement.browser.mjs index 731f3f4..17c8ab4 100644 --- a/packages/sw/tests/footer-popup-viewer-refinement.browser.mjs +++ b/packages/sw/tests/footer-popup-viewer-refinement.browser.mjs @@ -93,7 +93,7 @@ check('Footer: 화면 폭 변화 keeps the 6 widths, 참고 좁은 화면 + Figm // 2. Popup layer / color = the property window pickers (SWPopupPropertyRow); SWHeader field dropdowns stay header-only. await goto('sw-header--overview') const headerField = await page.locator('.hm-sw-header .hm-sw-header__field--layer').first().evaluate(e => { const c = getComputedStyle(e), v = getComputedStyle(e.querySelector('.hm-sw-header__field-value')); return [c.color, c.height, v.color, v.fontWeight].join('|') }) -// 2026-10-20: field values are Dotum Regular (400), Figma text property (hover-stability.browser.mjs). +// 2026-10-20: SWHeader field values are Regular (400) (user decision, kept with Noto Sans KR; hover-stability.browser.mjs). check('Header: layer field keeps the header design (#F6F4F2, 38px, value 400)', headerField === 'rgb(246, 244, 242)|38px|rgb(246, 244, 242)|400', headerField) await goto('sw-popup--custom-content') const rowSig = loc => loc.evaluate(row => { const s = e => { const c = getComputedStyle(e); return [c.color, c.backgroundColor, c.backgroundImage.slice(0, 30), c.borderTopColor, c.height, c.fontSize, c.fontWeight, c.textShadow].join(',') }; return ['.hm-sw-property__value', '.hm-sw-property__trigger, .hm-sw-header__input', 'label'].map(q => row.querySelector(q) ? s(row.querySelector(q)) : '-').join(' / ') + ' / ' + row.querySelector('.hm-sw-property__value')?.dataset.picker }) diff --git a/packages/sw/tests/hover-stability-firefox.browser.mjs b/packages/sw/tests/hover-stability-firefox.browser.mjs index 7b789d7..4171bf6 100644 --- a/packages/sw/tests/hover-stability-firefox.browser.mjs +++ b/packages/sw/tests/hover-stability-firefox.browser.mjs @@ -1,6 +1,6 @@ // 2026-10-20 Firefox counterpart of hover-stability.browser.mjs: the installed Firefox over WebDriver BiDi (no // Playwright build), real pointer moves. Home Menu tiles and the full-footer commands: icon / text / box are the same -// at rest, on hover and while pressed; header field values are Dotum 400 and not cut; POLYGON / WIPEOUT ellipsis. +// at rest, on hover and while pressed; SWHeader field values are 400 and not cut; POLYGON / WIPEOUT ellipsis. // Usage: node packages/sw/tests/hover-stability-firefox.browser.mjs [storybook-url] import { mkdir, writeFile, rm } from 'node:fs/promises' import { spawn } from 'node:child_process' diff --git a/packages/sw/tests/hover-stability.browser.mjs b/packages/sw/tests/hover-stability.browser.mjs index 8dfadc5..8b0671e 100644 --- a/packages/sw/tests/hover-stability.browser.mjs +++ b/packages/sw/tests/hover-stability.browser.mjs @@ -1,7 +1,7 @@ // 2026-10-20 SW hover position stability + header field text + footer command ellipsis / tooltip. // Hover (and Active / Selected) may change only color, background and the hover ring: the icon, the text, their gap // and the item box stay where they are (Header Home Menu tiles, Footer command slots in every story and category, -// including the ▲ panel). Also: Header field values are Dotum 400 and not cut; long footer commands end in an +// including the ▲ panel). Also: SWHeader field values are Noto Sans KR 400 and not cut; long footer commands end in an // ellipsis inside the unchanged 68 × 47 slot and show their full name on hover. // Usage: build Storybook first, then node packages/sw/tests/hover-stability.browser.mjs [evidence-dir] [chromium|firefox] import { createRequire } from 'node:module' @@ -79,7 +79,7 @@ function judge(name, c) { return { name, ok: Object.keys(moved).length === 0, moved, colorChanged: c.d0.color !== c.h1.color, iconSwap: c.d0.iconSrc !== c.h1.iconSrc, default: c.d0, hover: c.h1, active: c.a } } -// ---------- 1. Header field values: Dotum 400, nothing cut ---------- +// ---------- 1. Header field values: Noto Sans KR 400, nothing cut ---------- // Full-width headers (1800 / 1920px) show every field; narrower ones move the aux fields into More. const fields = [] for (const id of ['sw-header--interactive-states', 'sw-header--document-open', 'sw-header--responsive-more']) { @@ -92,7 +92,7 @@ for (const id of ['sw-header--interactive-states', 'sw-header--document-open', ' } } report.headerFields = fields -check('Header: every field value is Dotum 400 / 14px / 0.56px', fields.length >= 6 && fields.every(f => f.weight === '400' && f.size === '14px' && f.spacing === '0.56px' && /dotum/i.test(f.family)), fields) +check('Header: every field value is Noto Sans KR 400 / 14px / 0.56px', fields.length >= 6 && fields.every(f => f.weight === '400' && f.size === '14px' && f.spacing === '0.56px' && /noto sans kr/i.test(f.family)), fields) for (const want of ['Standard', '0_Layer_Cludmark', '1.00', '255,229,0']) { const f = fields.filter(x => x.text === want) check(`Header: "${want}" is shown whole (text width ≤ box)`, f.length > 0 && f.every(x => !x.cut), f) diff --git a/packages/sw/tests/text-weight.browser.mjs b/packages/sw/tests/text-weight.browser.mjs new file mode 100644 index 0000000..e64cec7 --- /dev/null +++ b/packages/sw/tests/text-weight.browser.mjs @@ -0,0 +1,166 @@ +// 2026-10-20 SW text weight request: Bold only on the named texts, with the black text outline where asked. +// Header file name (700) · Composed Controls field values (700) vs SWHeader field values (400) · Footer 보조기능 / 명령어 +// titles and category tabs (700, 전체보기 / Model · Layout / commands unchanged) · Viewer header file name, labels and +// Model / Layout tabs (700, More kept) · recent files name / path / preview title (700 + outline). Hover keeps the +// text in place. Usage: build Storybook first, then node packages/sw/tests/text-weight.browser.mjs [evidence-dir] +import { createRequire } from 'node:module' +import { readFile, writeFile, mkdir } from 'node:fs/promises' +import path from 'node:path' +import http from 'node:http' +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 out = path.resolve(process.argv[2] ?? 'docs/sw/text-weight-qa'); await mkdir(out, { recursive: true }) +const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' } +const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } }) +await new Promise(r => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}` +const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' }) +const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : JSON.stringify(d).slice(0, 500)) } +const report = {} +let errors = [] +const page = await browser.newPage({ viewport: { width: 1920, height: 1100 } }) +page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) }) +const goto = async id => { errors = []; await page.goto(`${base}/iframe.html?id=${id}&viewMode=story`); await page.locator('#storybook-root > *').first().waitFor(); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(300) } +const away = async () => { await page.mouse.move(2, 2); await page.waitForTimeout(90) } +const OUTLINE_14 = 'rgb(0, 0, 0) 1.3px 0px 0px', OUTLINE_12 = 'rgb(0, 0, 0) 1px 0px 0px' +// Every shown element of a selector: weight, size, color, outline start, text rect, box rect, cut. +const styles = sel => page.locator(sel).evaluateAll(list => list.filter(e => e.getClientRects().length && e.textContent.trim()).map(e => { + const c = getComputedStyle(e), g = document.createRange(); g.selectNodeContents(e); const t = g.getBoundingClientRect(), b = e.getBoundingClientRect() + return { text: e.textContent.trim(), weight: c.fontWeight, size: c.fontSize, family: c.fontFamily.split(',')[0], color: c.color, outline: c.textShadow.split(', rgb')[0], textW: Math.round(t.width * 100) / 100, boxW: Math.round(b.width * 100) / 100, cut: t.width > b.width + 0.05 } +})) +const rect = loc => loc.evaluate(e => { const g = document.createRange(); g.selectNodeContents(e); const t = g.getBoundingClientRect(), b = e.getBoundingClientRect(); return [t.x, t.y, t.width, t.height, b.x, b.y, b.width, b.height].map(v => Math.round(v * 100) / 100).join(',') }) +async function stableOnHover(name, target, text) { + await target.scrollIntoViewIfNeeded(); await away() + const d = await rect(text), w0 = await text.evaluate(e => getComputedStyle(e).fontWeight) + await target.hover(); await page.waitForTimeout(110) + const h = await rect(text), w1 = await text.evaluate(e => getComputedStyle(e).fontWeight) + await away(); const d2 = await rect(text) + check(`${name}: hover keeps the text place, size and weight (${w0})`, d === h && d === d2 && w0 === w1, { d, h, d2, w0, w1 }) +} + +// ---------- 1. Header ---------- +for (const id of ['sw-header--overview', 'sw-header--document-open', 'sw-header--interactive-states']) { + await goto(id) + const names = await styles('.hm-sw-header .hm-sw-header__document-name') + check(`Header ${id}: file name Noto Sans KR 14px 700 + 1.3px outline`, names.length > 0 && names.every(n => n.weight === '700' && n.size === '14px' && n.family === '"Noto Sans KR"' && n.outline === OUTLINE_14), names.slice(0, 3)) + const values = await styles('.hm-sw-header .hm-sw-header__field-value') + report[`headerValues:${id}`] = values + check(`Header ${id}: SWHeader field values stay Regular (400)`, values.length > 0 && values.every(v => v.weight === '400'), values.filter(v => v.weight !== '400')) +} +await goto('sw-header--overview') +const docButton = page.locator('.hm-sw-header__document-button').first() +await stableOnHover('Header file name', docButton, docButton.locator('.hm-sw-header__document-name')) +// SWHeader More (narrow): fields moved into the More menu are still SWHeader fields (400). +await goto('sw-header--responsive-more') +const narrow = page.locator('.hm-sw-header').filter({ visible: true }).nth(2) +const moreTrigger = narrow.locator('.hm-sw-header__more-trigger') +if (await moreTrigger.count()) { await moreTrigger.click(); await page.waitForTimeout(200) } +const moreValues = await styles('.hm-sw-header__field-value') +check('Header More: values shown with the More menu open stay 400 (none turn Bold)', moreValues.length > 0 && moreValues.every(v => v.weight === '400'), moreValues.filter(v => v.weight !== '400')) +await page.keyboard.press('Escape') +// Composed Controls: standalone menu fields (outside SWHeader) are Bold; labels and the font sample unchanged. +await goto('sw-header--composed-controls') +const composed = await styles('[data-composed-controls] .hm-sw-header__field-value') +report.composedValues = composed +check('Composed Controls: every field value Noto Sans KR 14px 700 + 1.3px outline', composed.length >= 7 && composed.every(v => v.weight === '700' && v.size === '14px' && v.outline === OUTLINE_14), composed) +const composedLabels = await styles('[data-composed-controls] .hm-sw-header__field-label') +check('Composed Controls: field labels stay 12px 400', composedLabels.every(l => l.weight === '400' && l.size === '12px'), composedLabels) +report.composedCut = composed.filter(v => v.cut).map(v => `${v.text} (${v.textW} / ${v.boxW}px)`) +console.log('INFO Composed Controls values wider than their (unchanged) box at 700:', report.composedCut.join(', ') || 'none') +const fields = page.locator('[data-composed-controls] .hm-sw-header__field') +for (let i = 0, n = await fields.count(); i < n; i++) { + const f = fields.nth(i), v = f.locator('.hm-sw-header__field-value').first() + await stableOnHover(`Composed field ${await f.getAttribute('data-menu')}`, f, v) +} + +// ---------- 2. Footer ---------- +await goto('sw-footer--full-footer') +const work = page.locator('.sw-footer-story-workspace .hm-sw-footer') +for (const side of ['start', 'end']) { + const btn = work.locator(side === 'end' ? '.hm-sw-footer-side[data-side="end"]' : '.hm-sw-footer-side:not([data-side="end"])') + const read = () => btn.evaluate(e => [e.querySelector('.hm-sw-footer-side__title'), e.querySelector('.hm-sw-footer-side__subtitle')].map(t => { const c = getComputedStyle(t); return [t.textContent, c.fontWeight, c.fontSize, c.color, c.textShadow.split(', rgb')[0]].join('|') })) + await away(); const d = await read() + await btn.hover(); await page.waitForTimeout(110); const h = await read(); await away() + check(`Footer ${side === 'end' ? '명령어' : '보조기능'}: title 700 (Default + Hover), 전체보기 keeps 400 + 1px outline`, d[0].split('|')[1] === '700' && h[0].split('|')[1] === '700' && d[1].split('|')[1] === '400' && h[1].split('|')[1] === '400' && d[1].endsWith(OUTLINE_12) && h[1].split('|')[3] === 'rgb(255, 92, 0)', { d, h }) + await stableOnHover(`Footer side ${side} title`, btn, btn.locator('.hm-sw-footer-side__title')) + await stableOnHover(`Footer side ${side} 전체보기`, btn, btn.locator('.hm-sw-footer-side__subtitle')) +} +const tabs = await styles('.sw-footer-story-workspace .hm-sw-footer-menu__tab') +report.footerTabs = tabs +check(`Footer: every category tab (${tabs.length}) 14px 700 + 1.3px outline`, tabs.length >= 10 && tabs.every(t => t.weight === '700' && t.size === '14px' && t.outline === OUTLINE_14), tabs.filter(t => t.weight !== '700')) +const tabLoc = work.locator('.hm-sw-footer-menu__tab') +await stableOnHover('Footer tab 수정 (Default → Hover)', tabLoc.nth(1), tabLoc.nth(1)) +// Selected: the tab's text sits at the same place inside its own box, weight unchanged. +const inBox = loc => loc.evaluate(e => { const g = document.createRange(); g.selectNodeContents(e); const t = g.getBoundingClientRect(), b = e.getBoundingClientRect(); return [t.x - b.x, t.y - b.y, t.width, b.width, b.height, getComputedStyle(e).fontWeight].map(v => typeof v === 'number' ? Math.round(v * 100) / 100 : v).join(',') }) +const before = await inBox(tabLoc.nth(1)); await tabLoc.nth(1).click(); await away(); const after = await inBox(tabLoc.nth(1)) +check('Footer tab 수정 selected: text place / size / weight inside the tab unchanged', before === after && await tabLoc.nth(1).getAttribute('aria-selected') === 'true', { before, after }) +const pocket = await work.locator('.hm-sw-footer-menu').evaluate(m => { const t = m.querySelector('[aria-selected="true"]').getBoundingClientRect(), y = t.y + 4 + const under = x => document.elementsFromPoint(x, y).find(e => e.matches('.hm-sw-footer__vacant, .hm-sw-footer__group'))?.className + return [under(t.x + 3), under(t.x + t.width / 2), under(t.right - 3)] }) +check('Footer: 수정 pocket surface stays continuous under the Bold tab', pocket.every(p => p === 'hm-sw-footer__group'), pocket) +await tabLoc.nth(0).click(); await away() +const keep = await styles('.sw-footer-story-workspace :is(.hm-sw-footer-tab__select, .hm-sw-footer-tool__label, .hm-sw-footer-aux__label)') +check('Footer: Model / Layout, command and auxiliary labels keep 400', keep.length > 0 && keep.every(k => k.weight === '400'), keep.filter(k => k.weight !== '400')) +const area = await work.locator('.hm-sw-footer-menu__viewport').evaluate(e => ({ w: Math.round(e.getBoundingClientRect().width * 10) / 10, h: Math.round(e.getBoundingClientRect().height), content: e.scrollWidth, steps: [...e.parentElement.querySelectorAll('.hm-sw-footer-menu__step')].filter(s => s.getClientRects().length).length })) +report.footerCategoryArea = area +console.log('INFO Footer category area at 1920:', JSON.stringify(area)) +// The approved category row is 24px high (measured before and after the Bold change, 2026-10-20). +check('Footer: category row height unchanged (24px)', area.h === 24, area) +check('Footer: ◀ ▶ only when the categories overflow their area', (area.content > area.w + 0.5) === (area.steps === 2), area) +await page.screenshot({ path: path.join(out, 'footer.png'), clip: { x: 0, y: 940, width: 1920, height: 160 } }) + +// ---------- 3. Viewer header ---------- +await goto('sw-viewer--viewer-header') +const vh = page.locator('.hm-sw-viewer-header').first() +const vNames = await styles('.hm-sw-viewer-header >> nth=0 >> :is(.hm-sw-viewer-header__document-name, .hm-sw-viewer-header__label, .hm-sw-viewer-header__tab)') +report.viewer = vNames +const want = ['파일 열기', '샘플', '전체 보기', '레이어', '설정', '속성'] +const labels = vNames.map(v => v.text) +check('Viewer: file name, 파일열기 · 샘플 · 전체보기 · 레이어 · 설정 · 속성, Model · Layout are 14px 700 + 1.3px outline', want.every(w => labels.includes(w)) && labels.some(t => t.startsWith('Model')) && labels.some(t => t.startsWith('Layout')) && vNames.every(v => v.weight === '700' && v.size === '14px' && v.outline === OUTLINE_14), vNames) +const counts = await styles('.hm-sw-viewer-header >> nth=0 >> .hm-sw-viewer-header__count') +check('Viewer: tab counts keep 11px 400', counts.length > 0 && counts.every(c => c.weight === '400' && c.size === '11px'), counts) +for (const t of ['파일 열기', '레이어']) { + const lab = vh.locator('.hm-sw-viewer-header__label', { hasText: t }).first() + await stableOnHover(`Viewer ${t}`, lab.locator('xpath=ancestor::button[1]'), lab) +} +const tab = vh.locator('.hm-sw-viewer-header__tab', { hasText: 'Layout' }).first() +await stableOnHover('Viewer Layout tab', tab, tab) +// More stays: narrow header shows the More button, it opens and lists the moved items. +await vh.evaluate(e => { e.style.width = '700px' }); await page.waitForTimeout(200) +const more = vh.getByRole('button', { name: /추가 도구/ }) +check('Viewer: More button shown at a narrow width', await more.count() === 1 && await more.isVisible()) +await more.click(); await page.waitForTimeout(200) +const moreItems = await page.locator('.hm-sw-viewer-menu [role="menuitem"], .hm-sw-viewer-menu [role="menuitemradio"], .hm-sw-viewer-menu [role="menuitemcheckbox"]').count() +check('Viewer: More opens with its entries', moreItems > 0, moreItems) +await page.keyboard.press('Escape'); await page.waitForTimeout(100) +check('Viewer: Escape closes More', await page.locator('.hm-sw-viewer-menu').filter({ visible: true }).count() === 0) +await vh.evaluate(e => { e.style.width = '' }) +check('Viewer: no page errors', errors.length === 0, errors) + +// ---------- 4. Recent files + preview ---------- +await goto('sw-header--home-menu') +const recentTile = page.locator('.hm-sw-home-menu > .hm-sw-home-menu__sections .hm-sw-home-menu__item', { hasText: '최근파일' }).filter({ visible: true }).first() +if (await recentTile.getAttribute('data-active') !== 'true') { await recentTile.click(); await page.waitForTimeout(300) } +const rNames = await styles('.hm-sw-recent-files__name'), rPaths = await styles('.hm-sw-recent-files__path') +report.recent = { names: rNames.slice(0, 3), paths: rPaths.slice(0, 3) } +check(`Recent files: names (${rNames.length}) 14px 700 + 1.3px outline`, rNames.length >= 10 && rNames.every(n => n.weight === '700' && n.size === '14px' && n.outline === OUTLINE_14), rNames.slice(0, 2)) +check(`Recent files: paths (${rPaths.length}) 12px 700 + 1px outline, ellipsis kept`, rPaths.length >= 10 && rPaths.every(p => p.weight === '700' && p.size === '12px' && p.outline === OUTLINE_12), rPaths.slice(0, 2)) +const row = page.locator('.hm-sw-recent-files__row').first() +const rowGeo = () => row.evaluate(r => [r.getBoundingClientRect().height, ...[...r.querySelectorAll('.hm-sw-recent-files__name, .hm-sw-recent-files__path')].map(e => Math.round(e.getBoundingClientRect().width))].join(',')) +const rowBefore = await rowGeo() +await stableOnHover('Recent file name', row, row.locator('.hm-sw-recent-files__name')) +await row.hover(); await page.waitForTimeout(250) +const preview = await page.locator('.hm-sw-recent-files__preview').evaluate(p => { const t = p.querySelector('.hm-sw-recent-files__preview-title'), c = getComputedStyle(t), b = p.getBoundingClientRect(), body = p.querySelector('.hm-sw-recent-files__preview-body').getBoundingClientRect() + return { title: t.textContent, weight: c.fontWeight, size: c.fontSize, color: c.color, outline: c.textShadow.split(', rgb')[0], titleW: t.getBoundingClientRect().width, overflow: c.textOverflow, w: b.width, h: b.height, bodyH: body.height } }) +report.preview = preview +check('Recent preview: title 14px 700 + 1.3px outline, width 294 with ellipsis, body 243px', preview.weight === '700' && preview.size === '14px' && preview.outline === OUTLINE_14 && preview.titleW === 294 && preview.overflow === 'ellipsis' && preview.bodyH === 243, preview) +await page.screenshot({ path: path.join(out, 'recent-files.png'), clip: { x: 0, y: 0, width: 1100, height: 900 } }) +check('Recent files: rows keep 50px and their text widths', rowBefore === await rowGeo() && rowBefore.startsWith('50,'), rowBefore) +await row.press('Enter').catch(() => {}) +check('Recent files / preview: no page errors', errors.length === 0, errors) + +await writeFile(path.join(out, 'text-weight.json'), JSON.stringify({ checks, report }, null, 2)) +const failed = checks.filter(c => !c.ok).length +console.log(`${checks.length - failed}/${checks.length} PASS`) +await browser.close(); server.close() +process.exitCode = failed ? 1 : 0 diff --git a/packages/sw/tests/theme-inheritance.browser.mjs b/packages/sw/tests/theme-inheritance.browser.mjs index 1e6ebab..007855e 100644 --- a/packages/sw/tests/theme-inheritance.browser.mjs +++ b/packages/sw/tests/theme-inheritance.browser.mjs @@ -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 ; areas do -// not mix; unthemed components keep the SW default Dotum (2026-10-20). Noto Sans KR is a verification value only. +// 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, @@ -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 = '"Noto Sans KR", sans-serif' +const VERIFY = 'Dotum, 돋움, sans-serif' const NESTED = '"Malgun Gothic", sans-serif' -const DEFAULT = 'Dotum, 돋움, "Malgun Gothic", "Apple SD Gothic Neo", 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 = [] @@ -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 = 기본 Dotum', only(await fonts(page, `${def} .hm-sw-viewer-header`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-header`))) + 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 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`))) + check('기본: Dropdown = 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 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`))) + 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') // 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`))) + check('뷰어 테마: SWViewerHeader 전체 = Dotum', 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`))) + check('뷰어 테마: Dropdown = 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 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`))) + 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 서브메뉴 = 검증 글꼴', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`))) + 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') 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`))}`) + 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 = 검증 글꼴 (창 이동 Tooltip 별도)', only(headerFonts, VERIFY), fmt(headerFonts)) + 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 = 검증 글꼴', 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`))) + 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) = 검증 글꼴', (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) = 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(() => { @@ -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 === DEFAULT, plain) + 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(검증 글꼴)', only(await area('area-a'), VERIFY), fmt(await area('area-a'))) + 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 바깥(검증 글꼴)', outer === VERIFY, outer) + 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() @@ -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·명령창이 기본 글꼴로 되돌아감(결함 재현)', only(more, DEFAULT) && only(command, DEFAULT), `More ${fmt(more)} · 명령창 ${fmt(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') diff --git a/packages/sw/tests/viewer.browser.mjs b/packages/sw/tests/viewer.browser.mjs index 9986a2b..c3db727 100644 --- a/packages/sw/tests/viewer.browser.mjs +++ b/packages/sw/tests/viewer.browser.mjs @@ -376,7 +376,8 @@ if (comparison.reference) { check('text outline 1.3px black on 14px text (label / file name / tab)', [outline.label, outline.document, outline.tab].every(value => value.includes('rgb(0, 0, 0) 1.3px 0px 0px') && blacks(value) === 16), outline.label.slice(0, 60)) check('label outline 1px black on 11px captions / counts', [outline.caption, outline.count].every(value => value.includes('rgb(0, 0, 0) 1px 0px 0px') && blacks(value) === 16), outline.caption.slice(0, 60)) check('icon outline: SVG stroke copy, no CSS filter', outline.icon.filter === 'none' && outline.icon.copyStroke === 'rgb(0, 0, 0)' && outline.icon.copyWidth === outline.icon.glyphWidth + 3 && outline.icon.glyphStroke !== 'rgb(0, 0, 0)', JSON.stringify(outline.icon)) - check('fill / size / weight / spacing kept', outline.labelColor === 'rgb(255, 255, 255)' && outline.labelFont === '14px 500 normal', outline.labelColor + ' ' + outline.labelFont) + // 2026-10-20 user request: header labels (파일열기 · 샘플 · 전체보기 · 레이어 · 설정 · 속성), file name and Model / Layout are Bold (700). + check('fill / size / weight / spacing kept (labels Bold 700 since 2026-10-20)', outline.labelColor === 'rgb(255, 255, 255)' && outline.labelFont === '14px 700 normal', outline.labelColor + ' ' + outline.labelFont) await page.close() } { diff --git a/src/sw/sw-theme.stories.tsx b/src/sw/sw-theme.stories.tsx index bc4c2d8..3ee3a8e 100644 --- a/src/sw/sw-theme.stories.tsx +++ b/src/sw/sw-theme.stories.tsx @@ -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 — 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. -// The SW default font is Dotum (2026-10-20). Noto Sans KR is used here ONLY to make the inheritance visible. +// Dotum is used here ONLY to make the inheritance visible: the SW default font is Noto Sans KR (2026-10-20 decision). const KICKER = "HM DESIGN SYSTEM / SW" -const VERIFY_FONT = '"Noto Sans KR", sans-serif' +const VERIFY_FONT = 'Dotum, "돋움", 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: () => ( SW 토큰은 가장 바깥 SW 범위(<SWTheme> 또는 단독 컴포넌트 루트)에서 한 번 선언되고, 안쪽 Dropdown·More·패널·명령창은 그 값을 상속합니다. 토큰은 가장 바깥 범위의 style로만 바꿉니다. SW 기본 글꼴은 Dotum이며, 아래 Noto Sans KR은 상속 확인용 검증 값입니다.} /> - + description={<>SW 토큰은 가장 바깥 SW 범위(<SWTheme> 또는 단독 컴포넌트 루트)에서 한 번 선언되고, 안쪽 Dropdown·More·패널·명령창은 그 값을 상속합니다. 토큰은 가장 바깥 범위의 style로만 바꿉니다. 아래 Dotum은 상속 확인용 검증 값이며 SW 기본 글꼴(Noto Sans KR)은 바뀌지 않습니다(Dotum 기본 적용은 디자인 승인 대기).} /> + @@ -105,10 +105,10 @@ export const ThemeInheritance: Story = {
- 영역 A — 검증 글꼴(Noto Sans KR) - 영역 B — 기본(Dotum) + 영역 A — 검증 글꼴(Dotum) + 영역 B — 기본(Noto Sans KR) - 영역 C 바깥 — Noto Sans KR + 영역 C 바깥 — Dotum 영역 C 안쪽 — Malgun Gothic