Files
HMDesign/packages/sw/tests/text-weight.browser.mjs
T

166 lines
16 KiB
JavaScript

// 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 { readFile, writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
import { runtimeRequire } from './playwright-runtime.mjs'
const require = runtimeRequire()
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