Files
HMDesign/packages/sw/tests/text-weight.browser.mjs
T
shkim4 2fc1b3e108 fix(sw): 2026-10-21 SW design review and responsive header without More from 760px
User-approved design review (2026-10-21):
- SW Header: from 760px up nothing moves into More; the five menus stay with their
  titles (titles and values ellipsize), file name shrinks to 96px. Open next to the
  logo no longer blocks field shrinking above 760px (760/768 overflow fixed, also for
  configured `controls` headers). Dropdown yellow ring inside the button, black seam.
- Property Bar values as read-only red text; property popup "=" labels, read-only
  black values, per-entry hover with pointer.
- Home Menu recent files (3 rows, path + name caption, empty preview), Footer + slot
  guide off by default, Viewer file list without icons, Storybook widths 1920-760.
- Tests: old More expectations (integrated-improvement, keyboard-dismiss,
  footer-popup-viewer-refinement, storybook-cleanup-stage1) moved to the new rule;
  functional checks kept.
2026-10-21 17:29:25 +09:00

216 lines
21 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; 2026-10-21 logo menu and sample list follow the SWHeader `...` menu / file-name
// list rules) · 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 ? '' : String(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 ----------
// 2026-10-21 (approved restructure): 파일 열기 moved into the logo menu (the SWHeader `...` menu, SWHeaderMore), 샘플 into
// the file-name list (SWHeaderDocumentTitle) and 전체 보기 into More. The header keeps the Bold file name (now the
// SWHeader file-name button), 레이어 · 설정 · 속성 and Model / Layout; the two menus follow the SWHeader rules, measured
// on SWHeader itself below and compared row by row.
const typo = s => ({ weight: s.weight, size: s.size, family: s.family, outline: s.outline })
// SWHeader references: the responsive `...` menu rows and the file-name list rows.
await goto('sw-header--responsive-more')
const refTrigger = page.locator('.hm-sw-header__more-trigger').filter({ visible: true }).first()
await refTrigger.click(); await page.waitForTimeout(200)
const refMore = await styles('.hm-sw-header__more-menu .hm-sw-header__more-item > span:first-child')
check('SWHeader ... menu rows (reference): Regular 400 without outline', refMore.length > 0 && refMore.every(r => r.weight === '400' && r.outline === 'none'), refMore)
await page.keyboard.press('Escape')
await goto('sw-header--document-dropdown')
await page.locator('.hm-sw-header__document-button').first().click(); await page.waitForTimeout(200)
const refDocs = await styles('.hm-sw-header__document-menu .hm-sw-header__document-item-name')
check('SWHeader file-name list rows (reference): 14px + 1.3px outline, one weight', refDocs.length > 0 && refDocs.every(r => r.size === '14px' && r.outline === OUTLINE_14 && r.weight === refDocs[0].weight), refDocs)
// 2026-10-21: the logo menu takes the viewer dropdown type (SWViewerMenu, "상단 메뉴 + Dropdown"); its rows and label are the reference.
await goto('sw-viewer--dropdowns')
await page.getByRole('button', { name: '도면' }).click(); await page.waitForTimeout(200)
const refViewerItem = await styles('.hm-sw-viewer-menu .hm-sw-viewer-menu__text')
const refViewerLabel = await styles('.hm-sw-viewer-menu .hm-sw-viewer-menu__label')
check('SWViewerMenu rows / label (reference): 14px 400 and 12px 500, no outline', refViewerItem.length > 0 && refViewerItem.every(r => r.size === '14px' && r.weight === '400' && r.outline === 'none')
&& refViewerLabel.length > 0 && refViewerLabel.every(r => r.size === '12px' && r.weight === '500' && r.outline === 'none'), { refViewerItem: refViewerItem.slice(0, 2), refViewerLabel })
await page.keyboard.press('Escape')
report.reference = { more: refMore[0], documents: refDocs[0], viewerItem: refViewerItem[0], viewerLabel: refViewerLabel[0] }
await goto('sw-viewer--viewer-header')
// The 1440px Story frame at full width (as the viewer suites): the header has room for every label.
await page.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' }); await page.waitForTimeout(200)
const vh = page.locator('.hm-sw-viewer-header').first()
const vNames = await styles('.hm-sw-viewer-header >> nth=0 >> :is(.hm-sw-header__document-name, .hm-sw-viewer-header__document-name, .hm-sw-viewer-header__label, .hm-sw-viewer-header__tab)')
report.viewer = vNames
// 2026-10-21 measured steps: at 1440 the header has room, so 전체 보기 is a header label again (Bold like the others), and
// the file open button after the logo shows 파일 열기 on the SWHeader file-name surface (Bold + outline like the file name).
const want = ['C0060202-001-평면및종단면도.dwg', '파일 열기', '전체 보기', '레이어', '설정', '속성']
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)
check('Viewer: 샘플 is no longer a header label', !labels.includes('샘플'), labels)
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)
// File open button (파일 열기, the logo menu is gone): hover keeps its label in place, size and weight.
const openButton = vh.locator('[data-document-open]')
await stableOnHover('Viewer 파일 열기', openButton, openButton.locator('.hm-sw-viewer-header__open-label'))
// File-name list (샘플): rows exactly as the SWHeader file-name list rows.
const docButton2 = vh.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button')
await stableOnHover('Viewer file name', docButton2, docButton2.locator('.hm-sw-header__document-name'))
await docButton2.click(); await page.waitForTimeout(150)
const sampleRows = await styles('.hm-sw-viewer-header >> nth=0 >> .hm-sw-header__document-item-name')
report.viewerSamples = sampleRows
check('Viewer sample list: 3 rows = SWHeader file-name list rows (weight / size / family / outline)', sampleRows.length === 3 && sampleRows.every(r => JSON.stringify(typo(r)) === JSON.stringify(typo(refDocs[0])) && !r.cut), { sampleRows, ref: refDocs[0] })
const sampleRow = vh.locator('.hm-sw-header__document-item').first()
await stableOnHover('Viewer sample row', sampleRow, sampleRow.locator('.hm-sw-header__document-item-name'))
await page.keyboard.press('Escape'); await page.waitForTimeout(100)
const lab = vh.locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).first()
await stableOnHover('Viewer 레이어', 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' })
const more = vh.getByRole('button', { name: /추가 도구/ })
// 2026-10-20: the header re-measures through a ResizeObserver; wait for More instead of a fixed 200ms (the check below
// still fails when it never appears). The fixed wait failed intermittently on the same build.
await more.waitFor({ state: 'visible', timeout: 3000 }).catch(() => {})
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 >= 3 && 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 >= 3 && rPaths.every(p => p.weight === '700' && p.size === '12px' && p.outline === OUTLINE_12), rPaths.slice(0, 2))
// 2026-10-21 (user decision): each recent-file list shows only the 3 most recent files (15 in the Story data).
const perList = await page.locator('.hm-sw-recent-files__list').evaluateAll(lists => lists.map(l => l.querySelectorAll('.hm-sw-recent-files__row').length))
check('Recent files: each list shows 3 rows', perList.length > 0 && perList.every(n => n === 3), perList)
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, bodyEmpty: p.querySelector('.hm-sw-recent-files__preview-body').childElementCount === 0 && !p.querySelector('.hm-sw-recent-files__preview-body').textContent } })
report.preview = preview
// 2026-10-21 (user decision): the preview frame stays on hover; its 243px body is empty (no image / thumbnail).
check('Recent preview: title 14px 700 + 1.3px outline, width 294 with ellipsis, empty body 243px', preview.bodyEmpty && 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