feat(sw): viewer header logo menu and file list; simplify SW Storybook

SW Viewer Header (approved optional props, public export names unchanged at 170):
- SWViewerHeader: logoMenu, documents, activeDocumentId, onDocumentSelect,
  onDocumentOpen, documentOpenLabel; measured responsive toggles (full ->
  icons -> More) with the SWHeader More / file-list styling.
- SW AI guides, ExampleH, viewer/text-weight/theme-inheritance tests and the
  logo-document-menu tests with their QA record.

SW Storybook cleanup (Story IDs and test fixtures kept):
- Sidebar: Overview -> "SW Header" (first), 전체 Footer -> "SW Footer",
  Footer/Viewer responsive pages -> "Responsive More", Viewer content-variants
  -> "Settings Popup" (merges the settings popup page).
- Hidden by the verification tag (direct URL kept): Header Dropdown,
  Document Open, Interactive States, Document Dropdown, Hidden Sub Controls,
  Home Menu standalone; Footer tool groups / custom content / parts;
  SW/Theme; Viewer menu groups / item states / variable size / settings
  panel / Header + Dropdown.
- Removed demo UI: Overview size and callback output (demo data and
  selection only with &verification), Home Menu Figma node table and width
  demo, Header Responsive More reduced to 1440 / 768 / 390, Popup reopen
  button, property-panel comparison captions, Viewer context-menu demo
  (&verification only).
- Tests: storybook-inventory records the hidden and renamed Stories;
  overview / range-cross-browser / viewer open the demo controls with
  &verification; integrated-popup reloads the Story instead of the reopen
  button; dropdown-consolidation asserts Dropdown is hidden from the sidebar
  and still opens by its direct URL.
This commit is contained in:
shkim4 committed 2026-10-21 14:52:46 +09:00
1 parent 46119eacb5
commit 23d522c136
57 files changed
+2623 -333

No files matched your search

+46 -35
View File
@@ -17,7 +17,7 @@ const browser = await chromium.launch({ headless: true, executablePath: process.
const results = []
const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${detail}` : ''}`) }
const errors = []
const story = id => `${storybook}/iframe.html?id=sw-viewer--${id}&viewMode=story`
const story = id => `${storybook}/iframe.html?id=sw-viewer--${id}&viewMode=story${id === 'dropdowns' ? '&verification' : ''}`
const newPage = async (width = 1440, height = 900) => {
const page = await browser.newPage({ viewport: { width, height }, reducedMotion: 'reduce' })
page.on('pageerror', error => errors.push(`${page.url()}: ${error.message}`))
@@ -69,7 +69,8 @@ if (reference && reference !== '-') {
const origin = await box(frame)
comparison.package = await geometry(page, parts.package)
await page.screenshot({ path: path.join(out, 'package-1440-loaded.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 56 } })
await page.getByRole('button', { name: '샘플' }).click()
// 2026-10-21: the samples are the file-name list (SWHeaderDocumentTitle), see viewer-logo-document-menu.browser.mjs.
await page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').click()
await page.screenshot({ path: path.join(out, 'package-1440-sample-menu.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 200 } })
await page.keyboard.press('Escape')
await page.getByRole('button', { name: '레이어' }).click()
@@ -96,32 +97,34 @@ if (comparison.reference) {
// Wide enough for the 1440px story frame plus the story page padding (panels at the frame right edge stay on screen).
const page = await newPage(1920, 1000)
await open(page, 'viewer-header')
// Dropdown: click opens, entries listed, Escape closes and returns focus.
const samples = page.getByRole('button', { name: '샘플' })
// Samples (2026-10-21): the file-name list (SWHeaderDocumentTitle) replaced the 샘플 menu button. Click opens, rows listed,
// Escape closes and returns focus; the logo menu (Open) is checked in viewer-logo-document-menu.browser.mjs.
const samples = page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button')
await samples.click()
const menu = page.getByRole('menu', { name: '샘플' })
check('menu opens on click', await menu.isVisible())
check('menu has 3 entries', (await menu.getByRole('menuitem').count()) === 3)
const menu = page.getByRole('group', { name: '문서 목록' })
check('document list opens on click', await menu.isVisible())
check('document list has the 3 samples', (await menu.locator('.hm-sw-header__document-item').count()) === 3)
const menuBox = await box(menu), triggerBox = await box(samples)
check('menu placed under its button', Math.abs(menuBox.x - triggerBox.x) <= 1 && menuBox.y >= triggerBox.y + triggerBox.height, `menu ${Math.round(menuBox.x)},${Math.round(menuBox.y)} button ${Math.round(triggerBox.x)},${Math.round(triggerBox.y + triggerBox.height)}`)
check('trigger shows open state', (await samples.getAttribute('aria-expanded')) === 'true' && (await samples.getAttribute('data-state')) === 'open')
check('list placed under the file name', menuBox.y >= triggerBox.y + triggerBox.height - 1, `list ${Math.round(menuBox.x)},${Math.round(menuBox.y)} name ${Math.round(triggerBox.x)},${Math.round(triggerBox.y + triggerBox.height)}`)
check('trigger shows open state', (await samples.getAttribute('aria-expanded')) === 'true' && (await samples.getAttribute('data-selected')) === 'true')
await page.keyboard.press('ArrowDown')
await page.keyboard.press('ArrowDown')
check('ArrowDown moves focus in menu', (await page.evaluate(() => document.activeElement?.textContent)).includes('평면'))
check('ArrowDown moves focus in the list', (await page.evaluate(() => document.activeElement?.textContent)).includes('평면'))
await page.keyboard.press('Escape')
check('Escape closes menu', !(await menu.isVisible().catch(() => false)))
check('Escape returns focus to trigger', await samples.evaluate(element => element === document.activeElement))
// Keyboard open + Enter select → file name changes, command logged.
await samples.press('Enter')
check('Enter opens with first entry focused', (await page.evaluate(() => document.activeElement?.getAttribute('role'))) === 'menuitem')
await page.keyboard.press('End')
check('Escape closes the list', !(await menu.isVisible().catch(() => false)))
check('Escape returns focus to the file name', await samples.evaluate(element => element === document.activeElement))
// Keyboard open + Enter select → file name changes, selection logged by the host.
await samples.press('ArrowDown')
check('ArrowDown opens with the first row focused', (await page.evaluate(() => document.activeElement?.classList.contains('hm-sw-header__document-item'))))
// 2026-10-21: no Open below the rows (file open is the header button); ArrowUp wraps to the last sample.
await page.keyboard.press('ArrowUp')
await page.keyboard.press('Enter')
check('Enter selects entry (document changes)', (await page.locator('.hm-sw-viewer-header__document-name').innerText()) === 'simple.dxf')
check('onCommand reports menu entry', (await log(page)).includes('samples/sample-dxf'), await log(page))
check('Enter selects a sample (document changes)', (await page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-name').innerText()) === 'simple.dxf')
check('onDocumentSelect reported', (await log(page)).includes('samples/sample-dxf'), await log(page))
// Outside click closes.
await samples.click()
await page.mouse.click(700, 600)
check('outside click closes menu', (await page.getByRole('menu').count()) === 0)
check('outside click closes the list', (await page.getByRole('group', { name: '문서 목록' }).count()) === 0)
// Tabs: arrow keys select.
const model = page.getByRole('radio', { name: /Model/ })
await model.focus()
@@ -131,10 +134,10 @@ if (comparison.reference) {
const depth = page.getByRole('switch', { name: '2.5D' })
await depth.click()
check('header switch toggles', (await depth.getAttribute('aria-checked')) === 'true' && (await log(page)).includes('2.5d=true'))
// Disabled fit button is enabled once a document is open; hover face.
const fit = page.getByRole('button', { name: '전체 보기' })
await fit.hover()
check('hover face #266651', (await fit.evaluate(element => getComputedStyle(element).backgroundColor)) === 'rgb(38, 102, 81)')
// Hover face (2026-10-21: 전체 보기 left the header row for More, so the panel toggle is hovered).
const hovered = page.getByRole('button', { name: '레이어', exact: true })
await hovered.hover()
check('hover face #266651', (await hovered.evaluate(element => getComputedStyle(element).backgroundColor)) === 'rgb(38, 102, 81)')
// Toggle opens the panel; Escape inside closes it and focus goes back.
const layers = page.getByRole('button', { name: '레이어', exact: true })
await layers.click()
@@ -272,7 +275,7 @@ if (comparison.reference) {
await page.close()
}
{
// Responsive: More collects the collapsed items, no horizontal overflow, icon-only below 560.
// Responsive: measured steps (full → icons only → More), no horizontal overflow; More collects the collapsed items.
const page = await newPage(1920, 1200)
await open(page, 'responsive')
const headers = page.locator('[data-width] .hm-sw-viewer-header')
@@ -290,18 +293,24 @@ if (comparison.reference) {
await page.screenshot({ path: path.join(out, `package-responsive-${width}.png`), clip: { x: box2.x, y: box2.y, width: box2.width, height: box2.height } })
}
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1), JSON.stringify(report.map(item => [item.width, item.overflow, item.objectOverflow])))
check('1440: no More', !report[0].more)
check('1100: More, samples/tabs/switches moved', report[1].more && !report[1].labels.includes('샘플') && !report[1].labels.includes('2.5D') && report[1].labels.includes('레이어'))
check('860: panel toggles moved too', report[2].more && !report[2].labels.includes('레이어'))
check('520: 파일 열기 / 전체 보기 icon only', report[3].labels.includes('파일 열기') && !(await headers.nth(3).locator('.hm-sw-viewer-header__label', { hasText: '파일 열기' }).count()))
const more = headers.nth(2).getByRole('button', { name: '추가 도구' })
// 2026-10-21: 파일 열기 / 샘플 left the header (logo menu, file-name list). Measured steps instead of fixed widths:
// full items while they fit, then icons only, then More; the panel toggles never move into More. With this Story's
// content: 1440 / 1100 full, 860 icons only, 520 icons + More.
// 2026-10-21: with the file open button after the logo, 1100 fits as icons (still no More) and 860 needs More.
check('1440 / 1100: every item in the header (전체 보기 included), no More', [report[0], report[1]].every(item => !item.more && ['전체 보기', '2.5D', '어두운 배경', '레이어'].every(name => item.labels.includes(name))))
check('860: icons only + More', report[2].more && !(await headers.nth(2).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
check('520: icons only + More; panel toggles kept as icons (label → aria-label)', report[3].more && ['레이어', '설정', '속성'].every(name => report[3].labels.includes(name))
&& !(await headers.nth(3).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
check('no 파일 열기 / 샘플 header buttons at any width', report.every(item => !item.labels.includes('파일 열기') && !item.labels.includes('샘플')))
const more = headers.nth(3).getByRole('button', { name: '추가 도구' })
await more.click()
const moreMenu = page.getByRole('menu', { name: '추가 도구' })
const items = await moreMenu.locator('.hm-sw-viewer-menu__label, [role^=menuitem]').allInnerTexts()
check('More sections like the reference', ['샘플 도면', '도면 공간', '화면 보기', '패널'].every(label => items.includes(label)), items.join(' | '))
check('More face beige 260px', (await moreMenu.evaluate(element => `${getComputedStyle(element).backgroundColor} ${element.offsetWidth}`)) === 'rgb(245, 240, 229) 260')
await moreMenu.getByRole('menuitemcheckbox', { name: '레이어' }).click()
check('More checkbox reports command', (await log(page)).includes('layers=true'), await log(page))
check('More sections: 도면 공간, 화면 보기 (with 전체 보기); no 패널', ['도면 공간', '화면 보기', '전체 보기'].every(label => items.includes(label)) && !items.includes('패널'), items.join(' | '))
// 2026-10-21: the header dropdowns (More included) take the SWHeader `...` menu face (#0e3d2f); width unchanged.
check('More face (SWHeader ... menu #0e3d2f) 260px', (await moreMenu.evaluate(element => `${getComputedStyle(element).backgroundColor} ${element.offsetWidth}`)) === 'rgb(14, 61, 47) 260')
await moreMenu.getByRole('menuitemcheckbox', { name: '2.5D' }).click()
check('More checkbox reports command', (await log(page)).includes('2.5d=true'), await log(page))
await more.click()
await page.getByRole('menuitemradio', { name: /Layout/ }).click()
check('More radio selects tab value', (await log(page)).includes('space=layout1'))
@@ -358,11 +367,13 @@ if (comparison.reference) {
const header = document.querySelector('[data-logo-case] .hm-sw-viewer-header')
const style = selector => getComputedStyle(header.querySelector(selector))
return {
label: style('.hm-sw-viewer-header__label').textShadow, document: style('.hm-sw-viewer-header__document-name').textShadow,
// 2026-10-21: the file name is the SWHeader document button when `documents` is given (same outline token).
label: style('.hm-sw-viewer-header__label').textShadow, document: style('.hm-sw-viewer-header__document-name, .hm-sw-header__document-name').textShadow,
tab: style('.hm-sw-viewer-header__tab').textShadow, caption: style('.hm-sw-viewer-header__switch-label').textShadow,
count: style('.hm-sw-viewer-header__count').textShadow,
icon: (() => {
const icon = header.querySelector('.hm-sw-viewer-header__icon')
// A lucide (stroke) icon: since 2026-10-21 the first header icon is the More dots (fill), so take the 레이어 toggle.
const icon = header.querySelector('.hm-sw-viewer-header__button[data-kind="toggle"] .hm-sw-viewer-header__icon')
const copy = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__outline svg *')), glyph = getComputedStyle(icon.querySelector('.hm-sw-viewer-icon__glyph svg *'))
// lucide 24-unit icon drawn at 16px: 1px each side = 1.5 units, so the copy is 2 + 3 units.
return { filter: getComputedStyle(icon).filter, copyStroke: copy.stroke, copyWidth: parseFloat(copy.strokeWidth), glyphStroke: glyph.stroke, glyphWidth: parseFloat(glyph.strokeWidth) }