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:
1 parent
46119eacb5
commit
23d522c136
57 files changed
+2623
-333
No files matched your search
@@ -279,12 +279,17 @@ try {
|
||||
check('Overview keeps one Header at 49px and 16:9 Stage', await page.locator('header.hm-sw-header').count() === 1 && await page.locator('[data-preview-stage]').evaluate(e => Math.abs(e.getBoundingClientRect().width / e.getBoundingClientRect().height - 16 / 9) < 0.001) && await page.locator('header.hm-sw-header').evaluate(e => e.getBoundingClientRect().height === 49))
|
||||
await go('window-move-motion')
|
||||
check('Window Move Motion retains only nine 3x steps', JSON.stringify(await page.locator('section > header h2').allTextContents()) === JSON.stringify(['단계별 진행 (3배 확대)']) && await page.locator('[data-motion-time]').count() === 9 && await page.locator('.sw-story-zoom').first().evaluate(e => getComputedStyle(e).zoom === '3'))
|
||||
// 2026-10-21 (SW Storybook menu cleanup, user request): Dropdown left the sidebar by the verification tag (storybook-inventory
|
||||
// verificationTaggedIds); its ID and direct URL stay. Read the sidebar with the Header group open (SW Header page),
|
||||
// since opening the hidden Story itself leaves the group collapsed and would make an absent link trivially true.
|
||||
await page.goto(`${base}/?path=/story/sw-header--overview`)
|
||||
await page.locator('a[href="/?path=/story/sw-header--overview"]').waitFor()
|
||||
const headerLinks = await page.locator('a[href^="/?path=/story/sw-header--"]').evaluateAll(list => list.map(a => a.getAttribute('href')))
|
||||
check('manager hides the Dropdown link under the open Header group, no Composed Controls submenu', headerLinks.length > 0 && !headerLinks.includes('/?path=/story/sw-header--composed-controls') && await page.getByRole('link', { name: 'Dropdown', exact: true }).count() === 0 && await page.getByRole('link', { name: 'Composed Controls', exact: true }).count() === 0, headerLinks)
|
||||
await page.goto(`${base}/?path=/story/sw-header--composed-controls`)
|
||||
await page.locator('#storybook-preview-iframe').waitFor()
|
||||
const dropdownLink = page.getByRole('link', { name: 'Dropdown', exact: true })
|
||||
await dropdownLink.waitFor()
|
||||
await dropdownLink.scrollIntoViewIfNeeded()
|
||||
check('manager shows a single Dropdown link directly under Header, no Composed Controls submenu', await dropdownLink.count() === 1 && await dropdownLink.getAttribute('href') === '/?path=/story/sw-header--composed-controls' && await page.getByRole('link', { name: 'Composed Controls', exact: true }).count() === 0)
|
||||
await page.frameLocator('#storybook-preview-iframe').locator('[data-composed-controls]').waitFor()
|
||||
check('Dropdown direct URL still opens the Story (ID sw-header--composed-controls)', await page.frameLocator('#storybook-preview-iframe').locator('[data-composed-controls]').count() === 1)
|
||||
check('retired Dropdown links absent in manager', await page.locator(retiredDropdownIds.map(id => `a[href*="${id}"]`).join(',')).count() === 0)
|
||||
await page.screenshot({ path: path.join(out, 'composed-manager.png'), fullPage: true })
|
||||
check('no browser console or page errors', report.errors.length === 0, report.errors)
|
||||
|
||||
@@ -83,7 +83,8 @@ export async function popupChecks({ page, goto, check, log, rect, out, report })
|
||||
await page.keyboard.press('Escape')
|
||||
// B was used last (its dropdown), so B is the top popup, not C (opened last).
|
||||
check('several popups: Escape outside closes only the top popup (last used: B)', (await log()).endsWith('close(b)') && await popup('c').count() === 1, await log())
|
||||
await page.locator('[data-reopen]').click()
|
||||
// Reloading the Story reopens A, B and C (the page has no reopen button since 2026-10-21).
|
||||
await goto('sw-popup--multiple-popups')
|
||||
await popup('a').locator('.hm-sw-property__titlebar').click()
|
||||
await page.locator('body').click({ position: { x: 5, y: 5 } })
|
||||
await page.keyboard.press('Escape')
|
||||
|
||||
@@ -68,7 +68,7 @@ try {
|
||||
page = await browser.newPage({ viewport:{width:1440,height:1000} }); page.setDefaultTimeout(6000)
|
||||
page.on('pageerror', e => report.errors.push(e.message)); page.on('console', m => { if(m.type() === 'error') report.errors.push(m.text()) })
|
||||
const go = async(id='overview') => {
|
||||
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story`)
|
||||
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story${id === 'overview' ? '&verification' : ''}`)
|
||||
await page.locator('.hm-sw-header,.hm-sw-theme').first().waitFor(); await page.evaluate(() => document.fonts.ready)
|
||||
await page.evaluate(()=>new Promise(resolve=>requestAnimationFrame(()=>requestAnimationFrame(resolve))))
|
||||
}
|
||||
|
||||
@@ -21,7 +21,7 @@ const FIREFOX = process.env.SW_FIREFOX_PATH ?? 'C:/Program Files/Mozilla Firefox
|
||||
// The Firefox driver talks WebDriver BiDi over the global WebSocket (Node 22+; Node 20: --experimental-websocket).
|
||||
if (typeof WebSocket === 'undefined') throw new Error('No global WebSocket: run with Node 22+ or `node --experimental-websocket` (Node 20).')
|
||||
const { chromium } = loadPlaywright()
|
||||
const STORY = `${base}/iframe.html?id=sw-header--overview&viewMode=story`
|
||||
const STORY = `${base}/iframe.html?id=sw-header--overview&viewMode=story&verification`
|
||||
const VIEW = { width: 1400, height: 1000 }
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
await mkdir(out, { recursive: true })
|
||||
|
||||
@@ -61,12 +61,23 @@ export const footerStoryIds = ['full-footer', 'tool-groups', 'figma-variants', '
|
||||
export const viewerStoryIds = ['viewer-header', 'menu-groups', 'states', 'dropdowns', 'settings-panel', 'content-variants', 'variable-size', 'responsive', 'compare-with-sw-header', 'logo'].map(id => `sw-viewer--${id}`)
|
||||
export const themeStoryIds = ['sw-theme--theme-inheritance']
|
||||
// 2b0e1a2 sidebar simplification: ID and direct URL kept, hidden from the sidebar by the verification tag.
|
||||
export const verificationTaggedIds = ['sw-header--window-move-motion', 'sw-header--k-guide']
|
||||
// 2026-10-21 (SW Storybook menu cleanup, user request): Interactive States and Document Dropdown left the sidebar the
|
||||
// same way; Overview is named "SW Header" and sorted first (preview storySort). menuCleanupHiddenIds lists every Story the
|
||||
// cleanup hid (also the approved additions hidden-sub-controls / home-menu-standalone and three Footer pages).
|
||||
export const verificationTaggedIds = ['sw-header--window-move-motion', 'sw-header--k-guide', 'sw-header--interactive-states', 'sw-header--document-dropdown', 'sw-header--composed-controls']
|
||||
export const menuCleanupHiddenIds = ['sw-header--interactive-states', 'sw-header--document-dropdown', 'sw-header--hidden-sub-controls', 'sw-header--home-menu-standalone',
|
||||
...['tool-groups', 'custom-content', 'parts'].map(id => `sw-footer--${id}`),
|
||||
// Second pass (same day): SW/Theme hidden as a whole; Viewer settings-panel merged into content-variants.
|
||||
'sw-header--document-open', 'sw-header--composed-controls', ...themeStoryIds, ...['menu-groups', 'states', 'dropdowns', 'variable-size', 'settings-panel'].map(id => `sw-viewer--${id}`)]
|
||||
export const renamedEntries = { 'sw-header--overview': 'SW Header', 'sw-footer--full-footer': 'SW Footer',
|
||||
'sw-footer--responsive': 'Responsive More', 'sw-viewer--content-variants': 'Settings Popup',
|
||||
'sw-viewer--responsive': 'Responsive More' }
|
||||
// Every approved SW Story ID added after the unused-demo removal, as the current index lists them.
|
||||
export const approvedAddedStoryIds = [...integratedStoryIds.filter(id => !retiredToolIds.includes(id)), ...homeMenuSidebarIds,
|
||||
...Object.values(movedHomeMenuIds).filter(id => !retiredHomeMenuCompareIds.includes(id)), ...footerStoryIds, ...viewerStoryIds, ...themeStoryIds]
|
||||
// An earlier index entry with the approved tag / Popup move applied (2b0e1a2; the moved Story has no component path).
|
||||
export const withApprovedEntryChanges = entry => {
|
||||
if (renamedEntries[entry.id]) entry = { ...entry, name: renamedEntries[entry.id] }
|
||||
if (verificationTaggedIds.includes(entry.id)) return { ...entry, tags: [...entry.tags, 'verification'] }
|
||||
if (!movedToPopupIds.includes(entry.id)) return entry
|
||||
const { componentPath, ...rest } = entry
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
// 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
|
||||
// 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'
|
||||
@@ -109,19 +110,61 @@ check('Footer: ◀ ▶ only when the categories overflow their area', (area.cont
|
||||
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-viewer-header__document-name, .hm-sw-viewer-header__label, .hm-sw-viewer-header__tab)')
|
||||
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
|
||||
const want = ['파일 열기', '샘플', '전체 보기', '레이어', '설정', '속성']
|
||||
// 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: 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)
|
||||
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)
|
||||
}
|
||||
// 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.
|
||||
|
||||
@@ -60,32 +60,90 @@ const fonts = (page, selector, exclude = null) => page.evaluate(([selector, excl
|
||||
const only = (map, expected) => Object.keys(map).length > 0 && Object.keys(map).every(family => family === expected)
|
||||
const fmt = map => Object.entries(map).map(([family, count]) => `${count}× ${family}`).join(' | ') || 'no text'
|
||||
|
||||
// More (2026-10-21): the header shows More only when its measured room runs out (full → icons → More). The Story's
|
||||
// 1000px header fits as icons, so the header is narrowed for this check (as text-weight does), then restored.
|
||||
async function openMore(page, area) {
|
||||
const header = page.locator(`${area} .hm-sw-viewer-header`)
|
||||
await header.evaluate(element => { element.style.width = "700px" })
|
||||
await page.locator(`${area} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||||
}
|
||||
const restoreWidth = (page, area) => page.locator(`${area} .hm-sw-viewer-header`).evaluate(element => { element.style.width = "" })
|
||||
|
||||
// Viewer header dropdown checked here (2026-10-21): the file-name list (.hm-sw-header__document-menu); the logo menu is gone.
|
||||
const DOC_LIST = '.hm-sw-viewer-header .hm-sw-header__document-menu'
|
||||
async function dropdownFonts(page, area, name, expected) {
|
||||
const label = expected === DEFAULT ? 'Noto Sans KR' : 'Dotum'
|
||||
await page.locator(`${area} .hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button`).click()
|
||||
await page.waitForSelector(`${area} ${DOC_LIST}`)
|
||||
const list = `${area} :is(${DOC_LIST}, .hm-sw-viewer-header .hm-sw-header__document-button)` // file name + file open surfaces
|
||||
check(`${name}: 파일명 버튼 + 샘플 목록 = ${label}`, only(await fonts(page, list), expected), fmt(await fonts(page, list)))
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
// Token → painted property of each dropdown part. 2026-10-21: the logo menu keeps the SWHeader `...` menu colors (object
|
||||
// border, text, row hover tokens; its face #0e3d2f is a fixed value, checked below); the document list keeps its tokens.
|
||||
const COLOR_TOKENS = [
|
||||
['--hm-sw-header-document-menu-background', DOC_LIST, 'backgroundColor'],
|
||||
['--hm-sw-header-document-menu-border-color', DOC_LIST, 'borderTopColor'],
|
||||
['--hm-sw-header-title-color', `${DOC_LIST} .hm-sw-header__document-row:not([data-active]) .hm-sw-header__document-item-name`, 'color'],
|
||||
['--hm-sw-header-document-item-hover-background', `${DOC_LIST} .hm-sw-header__document-row:not([data-active])`, 'backgroundColor', 'hover'],
|
||||
]
|
||||
async function dropdownColors(page, def, themed) {
|
||||
// Paint of every part with its menu open (hovered first when the token is a hover state), next to the token value
|
||||
// resolved in the same scope (a probe painted with var(token), so both are in the computed rgb() form).
|
||||
const paint = async (area, scopeSelector) => {
|
||||
const result = {}
|
||||
for (const [token, part, property, hover] of COLOR_TOKENS) {
|
||||
await page.locator(`${area} .hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button`).click()
|
||||
const target = page.locator(`${area} ${part}`).first()
|
||||
await target.waitFor()
|
||||
if (hover) { await target.hover(); await page.waitForTimeout(120) }
|
||||
result[token] = await target.evaluate((element, [token, property, scopeSelector]) => {
|
||||
const probe = document.createElement('span'); probe.style.color = `var(${token})`
|
||||
element.closest(scopeSelector).append(probe); const value = getComputedStyle(probe).color; probe.remove()
|
||||
return { painted: getComputedStyle(element)[property], token: value }
|
||||
}, [token, property, scopeSelector])
|
||||
await page.mouse.move(2, 2)
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
return result
|
||||
}
|
||||
const reference = await paint(def, '.hm-sw-viewer-header')
|
||||
check('기본: 샘플 목록(SWHeader 파일명 목록 토큰) 배경·글자·Border·Hover = 기본 토큰 값', Object.values(reference).every(item => item.painted === item.token), JSON.stringify(reference))
|
||||
// Themed: distinct colors set once on the outermost scope reach both menus; removed again afterwards.
|
||||
const values = ['rgb(201, 11, 21)', 'rgb(202, 12, 22)', 'rgb(203, 13, 23)', 'rgb(204, 14, 24)']
|
||||
const tokens = COLOR_TOKENS.map(item => item[0])
|
||||
await page.locator(themed).evaluate((element, [tokens, values]) => tokens.forEach((token, i) => element.style.setProperty(token, values[i])), [tokens, values])
|
||||
const themedPaint = await paint(themed, themed)
|
||||
await page.locator(themed).evaluate((element, tokens) => tokens.forEach(token => element.style.removeProperty(token)), tokens)
|
||||
check('뷰어 테마: 바깥 범위 토큰(배경·글자·Border·Hover)이 샘플 목록까지 상속', tokens.every((token, i) => themedPaint[token].painted === values[i]), JSON.stringify(themedPaint))
|
||||
}
|
||||
|
||||
// ---------- 1. Theme inheritance Story ----------
|
||||
{
|
||||
const page = await newPage()
|
||||
await open(page, 'sw-theme--theme-inheritance')
|
||||
const scope = name => `[data-theme-case="${name}"]`
|
||||
|
||||
// Default (no theme): header, then its sample dropdown and More.
|
||||
// Default (no theme): header, then its dropdowns and More. 2026-10-21: the header's dropdowns are the logo menu
|
||||
// (파일 열기, SWHeader `...` menu) and the file-name sample list (SWHeader file-name list); the 샘플 menu button is gone.
|
||||
const def = scope('default')
|
||||
check('기본: 단독 SWViewerHeader = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-header`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-header`)))
|
||||
await page.locator(`${def} .hm-sw-viewer-header [aria-haspopup="menu"]`).first().click()
|
||||
await page.waitForSelector(`${def} .hm-sw-viewer-menu`)
|
||||
check('기본: Dropdown = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-menu`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-menu`)))
|
||||
await page.keyboard.press('Escape')
|
||||
await page.locator(`${def} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||||
await dropdownFonts(page, def, '기본', DEFAULT)
|
||||
await openMore(page, def)
|
||||
await page.waitForSelector(`${def} .hm-sw-viewer-menu[data-surface="beige"]`)
|
||||
check('기본: More = Noto Sans KR', only(await fonts(page, `${def} .hm-sw-viewer-menu`), DEFAULT), fmt(await fonts(page, `${def} .hm-sw-viewer-menu`)))
|
||||
await page.keyboard.press('Escape')
|
||||
await restoreWidth(page, def)
|
||||
|
||||
// Viewer, themed through <SWTheme style>.
|
||||
const viewer = scope('viewer-themed')
|
||||
check('뷰어 테마: SWViewerHeader 전체 = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-header`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-header`)))
|
||||
await page.locator(`${viewer} .hm-sw-viewer-header [aria-haspopup="menu"]`).first().click()
|
||||
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu`)
|
||||
check('뷰어 테마: Dropdown = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
|
||||
await page.keyboard.press('Escape')
|
||||
await page.locator(`${viewer} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||||
await dropdownFonts(page, viewer, '뷰어 테마', VERIFY)
|
||||
// Colors / border / hover of the two dropdowns: the SWHeader default token values without a theme, and a token set
|
||||
// through `style` on the outermost SW scope (here the themed <SWTheme>) reaches both menus. The colors are set only
|
||||
// for this check and removed again (the Story itself themes the font only).
|
||||
await dropdownColors(page, def, viewer)
|
||||
await openMore(page, viewer)
|
||||
await page.waitForSelector(`${viewer} .hm-sw-viewer-menu[data-surface="beige"]`)
|
||||
check('뷰어 테마: More = Dotum', only(await fonts(page, `${viewer} .hm-sw-viewer-menu`), VERIFY), fmt(await fonts(page, `${viewer} .hm-sw-viewer-menu`)))
|
||||
// A submenu of More, if any, is a nested menu inside a nested menu.
|
||||
@@ -96,6 +154,7 @@ const fmt = map => Object.entries(map).map(([family, count]) => `${count}× ${fa
|
||||
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')
|
||||
await restoreWidth(page, viewer)
|
||||
const panels = await page.locator(`${viewer} .hm-sw-viewer-panel`).count()
|
||||
check('뷰어 테마: SWViewerPanel(제목·입력·버튼·스위치·힌트) = Dotum', panels === 2 && only(await fonts(page, `${viewer} .hm-sw-viewer-panel`), VERIFY), `${panels} panels · ${fmt(await fonts(page, `${viewer} .hm-sw-viewer-panel`))}`)
|
||||
await page.screenshot({ path: path.join(out, 'viewer-themed-more.png'), clip: await page.locator(viewer).boundingBox() })
|
||||
@@ -210,7 +269,7 @@ for (const width of [1920, 1440, 1180, 900, 560]) for (const id of ids) {
|
||||
await page.addStyleTag({ content: legacy })
|
||||
await page.waitForTimeout(150)
|
||||
const viewer = '[data-theme-case="viewer-themed"]'
|
||||
await page.locator(`${viewer} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
|
||||
await openMore(page, viewer)
|
||||
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')
|
||||
|
||||
@@ -0,0 +1,213 @@
|
||||
// 2026-10-21 Firefox counterpart of viewer-logo-document-menu.browser.mjs: the installed Firefox over WebDriver BiDi (no
|
||||
// Playwright build), real pointer clicks and key presses. Logo menu (SWHeaderMore) and file-name list
|
||||
// (SWHeaderDocumentTitle) of SWViewerHeader: open / select / Escape / outside click / focus return / never together,
|
||||
// title + divider rule, same face as the SWHeader `...` menu, responsive icon toggles and panels.
|
||||
// Usage: node packages/sw/tests/viewer-logo-document-menu-firefox.browser.mjs <evidence-dir> [storybook-url]
|
||||
import { mkdir, writeFile, rm } from 'node:fs/promises'
|
||||
import { spawn } from 'node:child_process'
|
||||
import os from 'node:os'
|
||||
import path from 'node:path'
|
||||
import { runtimeRequire } from './playwright-runtime.mjs'
|
||||
|
||||
const out = path.resolve(process.argv[2] ?? 'docs/sw/viewer-logo-document-menu-qa/firefox')
|
||||
const base = process.argv[3] ?? 'http://127.0.0.1:6024'
|
||||
const FIREFOX = process.env.SW_FIREFOX_PATH ?? 'C:/Program Files/Mozilla Firefox/firefox.exe'
|
||||
const VIEW = { width: 1920, height: 1100 }
|
||||
const sleep = ms => new Promise(r => setTimeout(r, ms))
|
||||
await mkdir(out, { recursive: true })
|
||||
|
||||
const profile = path.join(os.tmpdir(), `sw-ff-viewer-menu-${process.pid}`)
|
||||
await mkdir(profile, { recursive: true })
|
||||
const port = 9300 + (process.pid % 500)
|
||||
const proc = spawn(FIREFOX, ['--headless', '--no-remote', '--profile', profile, `--remote-debugging-port=${port}`], { stdio: 'ignore', env: { ...process.env, MOZ_HEADLESS_WIDTH: String(VIEW.width), MOZ_HEADLESS_HEIGHT: String(VIEW.height) } })
|
||||
let ws
|
||||
for (let i = 0; i < 60 && !ws; i++) {
|
||||
await sleep(500)
|
||||
try { ws = await new Promise((res, rej) => { const s = new WebSocket(`ws://127.0.0.1:${port}/session`); s.onopen = () => res(s); s.onerror = rej }) } catch { ws = undefined }
|
||||
}
|
||||
if (!ws) throw new Error('Firefox BiDi not reachable')
|
||||
let id = 0; const pending = new Map(), errors = []
|
||||
ws.onmessage = event => {
|
||||
const m = JSON.parse(event.data)
|
||||
if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.type === 'error' ? rej(new Error(`${m.error}: ${m.message}`)) : res(m.result) }
|
||||
// Storybook's own UI font (sb-common-assets) is served by the dev server only, not by a static build; the Chromium
|
||||
// suites ignore such 404s too.
|
||||
else if (m.method === 'log.entryAdded' && m.params.level === 'error' && !/sb-common-assets/.test(m.params.text)) errors.push(m.params.text)
|
||||
}
|
||||
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej: e => rej(new Error(method + ' -> ' + e.message)) }); ws.send(JSON.stringify({ id: i, method, params })) })
|
||||
const session = await send('session.new', { capabilities: {} })
|
||||
await send('session.subscribe', { events: ['log.entryAdded'] })
|
||||
// A tab of our own: in current Firefox the first tree entry can be a privileged (chrome) context that BiDi scripts
|
||||
// and viewport calls may not touch without -remote-allow-system-access.
|
||||
const context = (await send('browsingContext.create', { type: 'tab' })).context
|
||||
const viewport = async size => { try { await send('browsingContext.setViewport', { context, viewport: size }) } catch (e) { console.log('INFO setViewport:', e.message) } }
|
||||
await viewport(VIEW)
|
||||
const ev = async expr => { const r = await send('script.evaluate', { expression: expr, target: { context }, awaitPromise: true }); if (r.type === 'exception') throw new Error(r.exceptionDetails.text + ' :: ' + expr.slice(0, 160)); return r.result.value }
|
||||
const goto = async story => {
|
||||
await send('browsingContext.navigate', { context, url: `${base}/iframe.html?id=${story}&viewMode=story`, wait: 'complete' })
|
||||
for (let i = 0; i < 40; i++) { if (await ev(`!!document.querySelector('.hm-sw-viewer-header')`)) break; await sleep(250) }
|
||||
// Lift the Story page envelope like the Chromium suites; scroll instantly (the preview sets smooth scrolling).
|
||||
await ev(`(() => { const s = document.createElement('style'); s.textContent = '.sb-page { --sb-page-max-width: 100vw; } html { scroll-behavior: auto !important; }'; document.head.append(s); return true })()`)
|
||||
await ev('document.fonts.ready.then(() => true)'); await sleep(400)
|
||||
}
|
||||
const frames = () => ev('new Promise(r => requestAnimationFrame(() => requestAnimationFrame(() => r(true))))')
|
||||
const pointer = actions => send('input.performActions', { context, actions: [{ type: 'pointer', id: 'mouse', parameters: { pointerType: 'mouse' }, actions }] })
|
||||
// Click the center of the first visible match (scrolled into view first), with a real pointer.
|
||||
const click = async selector => {
|
||||
const point = JSON.parse(await ev(`(() => { const el = [...document.querySelectorAll(${JSON.stringify(selector)})].find(e => e.getClientRects().length)
|
||||
el.scrollIntoView({ block: 'center', inline: 'nearest', behavior: 'instant' }); const r = el.getBoundingClientRect(); return JSON.stringify([r.x + r.width / 2, r.y + r.height / 2]) })()`))
|
||||
await pointer([{ type: 'pointerMove', x: Math.round(point[0]), y: Math.round(point[1]), duration: 0 }, { type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 }])
|
||||
await frames()
|
||||
}
|
||||
const clickAt = async (x, y) => { await pointer([{ type: 'pointerMove', x, y, duration: 0 }, { type: 'pointerDown', button: 0 }, { type: 'pointerUp', button: 0 }]); await frames() }
|
||||
const KEYS = { ArrowDown: '\uE015', Escape: '\uE00C', Enter: '\uE007' }
|
||||
const press = async key => { await send('input.performActions', { context, actions: [{ type: 'key', id: 'kbd', actions: [{ type: 'keyDown', value: KEYS[key] }, { type: 'keyUp', value: KEYS[key] }] }] }); await frames() }
|
||||
const focus = selector => ev(`(document.querySelector(${JSON.stringify(selector)}).focus(), true)`)
|
||||
const shot = async (file, selector, width, height) => {
|
||||
const clip = JSON.parse(await ev(`(() => { const r = document.querySelector(${JSON.stringify(selector)}).getBoundingClientRect(); return JSON.stringify({ x: Math.max(0, r.x - 8), y: Math.max(0, r.y - 8) }) })()`))
|
||||
const result = await send('browsingContext.captureScreenshot', { context, clip: { type: 'box', ...clip, width, height } })
|
||||
await writeFile(path.join(out, file), Buffer.from(result.data, 'base64'))
|
||||
}
|
||||
|
||||
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, 600)) }
|
||||
const state = () => ev(`(() => {
|
||||
const header = document.querySelector('.hm-sw-viewer-header'), logo = header.querySelector('button.hm-sw-viewer-header__brand')
|
||||
const menu = header.querySelector('.hm-sw-header__more-menu'), list = header.querySelector('.hm-sw-header__document-menu')
|
||||
const name = header.querySelector('.hm-sw-viewer-header__document .hm-sw-header__document-button'), active = document.activeElement
|
||||
return JSON.stringify({ logoExpanded: logo?.getAttribute('aria-expanded'), menu: !!menu, list: !!list, nameExpanded: name?.getAttribute('aria-expanded'),
|
||||
name: name?.textContent.trim(), focus: active === logo ? 'logo' : active === name ? 'name' : (active?.textContent ?? '').trim(),
|
||||
log: document.querySelector('[data-callback-log]')?.textContent ?? '' })
|
||||
})()`).then(JSON.parse)
|
||||
// Face of an open menu and its first shown row, split into colors and the other metrics (2026-10-21 style unification:
|
||||
// the logo menu and the document list take the metrics of the viewer dropdown, SWViewerMenu, and keep their colors).
|
||||
const FACE = `(menu, itemSelector) => { const s = getComputedStyle(menu), item = [...menu.querySelectorAll(itemSelector)].find(e => e.offsetParent), i = getComputedStyle(item)
|
||||
const m = menu.getBoundingClientRect(), b = item.getBoundingClientRect()
|
||||
return { colors: { background: s.backgroundColor, border: s.borderTopColor, shadow: s.boxShadow, item: i.color },
|
||||
metrics: { border: s.borderTopWidth + ' ' + s.borderTopStyle, radius: s.borderTopLeftRadius, padding: s.paddingTop + ' ' + s.paddingLeft, minWidth: s.minWidth, font: s.fontSize + '/' + s.lineHeight + ' ' + s.fontWeight,
|
||||
itemHeight: Math.round(b.height), itemInset: Math.round(b.left - m.left), itemPadding: i.paddingLeft + ' ' + i.paddingRight, itemGap: i.columnGap, itemRadius: i.borderTopLeftRadius,
|
||||
itemFont: i.fontFamily.split(',')[0] + ' ' + i.fontSize + '/' + i.lineHeight + ' ' + i.fontWeight } } }`
|
||||
const faceOf = (menuSelector, itemSelector) => ev(`JSON.stringify((${FACE})(document.querySelector(${JSON.stringify(menuSelector)}), ${JSON.stringify(itemSelector)}))`).then(JSON.parse)
|
||||
// Scrolled into view first (the 520 row sits at the bottom of the Story; its list with Open reaches below the fold).
|
||||
const onTop = selector => ev(`(() => { const el = document.querySelector(${JSON.stringify(selector)}); if (!el) return false; el.scrollIntoView({ block: 'nearest', behavior: 'instant' }); const r = el.getBoundingClientRect(); return el.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height - 6)) })()`)
|
||||
|
||||
try {
|
||||
console.log('firefox', (await ev('navigator.userAgent')))
|
||||
// ---------- SWHeader `...` menu reference ----------
|
||||
await goto('sw-viewer--compare-with-sw-header')
|
||||
await click('.hm-sw-header .hm-sw-header__more-trigger')
|
||||
const reference = await faceOf('.hm-sw-header .hm-sw-header__more-menu', '.hm-sw-header__more-item')
|
||||
const BOX = `element => { const s = getComputedStyle(element); return [s.borderTopWidth, s.borderTopStyle, s.borderTopColor, s.borderTopLeftRadius, s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor, s.boxShadow].join(' | ') }`
|
||||
const refTrigger = await ev(`(${BOX})(document.querySelector('.hm-sw-header .hm-sw-header__more-trigger'))`)
|
||||
check('SWHeader ... menu opens (reference colors)', !!reference.colors.background, reference)
|
||||
await press('Escape')
|
||||
check('SWHeader ... menu Escape closes, focus back on ...', await ev(`!document.querySelector('.hm-sw-header .hm-sw-header__more-menu') && document.activeElement?.classList.contains('hm-sw-header__more-trigger')`))
|
||||
|
||||
// ---------- SWViewerMenu reference metrics ("상단 메뉴 + Dropdown") ----------
|
||||
await goto('sw-viewer--dropdowns')
|
||||
await click('.hm-sw-viewer-header [aria-haspopup="menu"]')
|
||||
const viewerMenu = await faceOf('.hm-sw-viewer-menu', '.hm-sw-viewer-menu__item')
|
||||
check('SWViewerMenu reference opens (36px rows, 4px padding)', viewerMenu.metrics.itemHeight === 36 && viewerMenu.metrics.padding === '4px 4px', viewerMenu.metrics)
|
||||
await press('Escape')
|
||||
|
||||
// ---------- File open button (the logo menu is gone) ----------
|
||||
await goto('sw-viewer--viewer-header')
|
||||
await click('.hm-sw-viewer-header .hm-sw-viewer-header__brand')
|
||||
let s = await state()
|
||||
check('logo opens no dropdown', !s.menu && !(await ev(`!!document.querySelector('[role=menu]')`)), s)
|
||||
const open = JSON.parse(await ev(`(() => { const b = document.querySelector('.hm-sw-viewer-header [data-document-open]'), w = b.parentElement
|
||||
return JSON.stringify({ surface: b.classList.contains('hm-sw-header__document-button') && w.matches('.hm-sw-header__document[data-tool]'), afterLogo: w.previousElementSibling?.classList.contains('hm-sw-viewer-header__brand'),
|
||||
label: b.querySelector('.hm-sw-viewer-header__open-label')?.textContent, icon: b.querySelectorAll('.hm-sw-viewer-header__open-icon svg').length }) })()`))
|
||||
check('file open button after the logo: SWHeader document surface, icon + 파일 열기', open.surface && open.afterLogo && open.label === '파일 열기' && open.icon === 1, open)
|
||||
const iconBox = await ev(`(${BOX})(document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__open-icon'))`)
|
||||
// 2026-10-21 user request: no border, transparent, no shadow.
|
||||
check('file open icon: no border, transparent, no shadow', iconBox.startsWith('0px | none') && iconBox.includes('rgba(0, 0, 0, 0)') && iconBox.endsWith('none'), iconBox)
|
||||
await click('.hm-sw-viewer-header [data-document-open]')
|
||||
s = await state()
|
||||
check('file open button runs the existing file open', s.log.endsWith('파일 선택창 (미연결) → document/open'), s)
|
||||
await shot('firefox-header-open.png', '.hm-sw-viewer-header .hm-sw-viewer-header__brand', 640, 60)
|
||||
|
||||
// ---------- Document list ----------
|
||||
// The logo cap (and its slanted tail) has the same pixels with the list open, as in SWHeader. Left out: the bottom
|
||||
// 2px, which the list's own drop shadow (0 2px 4px: blur 4 − offset 2) reaches above the list (Firefox 2px, Chromium 1px).
|
||||
const capClip = JSON.parse(await ev(`(() => { const r = document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__brand').getBoundingClientRect(); return JSON.stringify({ x: r.x, y: r.y, width: r.width + 18, height: r.height - 2 }) })()`))
|
||||
const capShot = async () => (await send('browsingContext.captureScreenshot', { context, clip: { type: 'box', ...capClip } })).data
|
||||
const n = JSON.parse(await ev(`(() => { const r = document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').getBoundingClientRect(); return JSON.stringify([r.x + r.width / 2, r.y + r.height / 2]) })()`))
|
||||
await pointer([{ type: 'pointerMove', x: Math.round(n[0]), y: Math.round(n[1]), duration: 0 }]); await frames()
|
||||
const capClosed = await capShot()
|
||||
await click('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button')
|
||||
check('open list never covers the logo cap (pixels identical)', capClosed === await capShot())
|
||||
s = await state()
|
||||
const list = JSON.parse(await ev(`(() => { const m = document.querySelector('.hm-sw-viewer-header .hm-sw-header__document-menu'), n = document.querySelector('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button')
|
||||
const r = m.getBoundingClientRect(), b = n.getBoundingClientRect(), row = m.querySelector('.hm-sw-header__document-row').getBoundingClientRect()
|
||||
return JSON.stringify({ rows: [...m.querySelectorAll('.hm-sw-header__document-item-name')].map(e => e.textContent.trim()), actions: m.querySelectorAll('.hm-sw-header__document-footer, .hm-sw-header__document-action').length,
|
||||
active: m.querySelector('[data-active=true] .hm-sw-header__document-item-name')?.textContent, width: r.width, button: b.width, left: r.left - b.left, right: r.right - b.right, row: Math.round(row.height), gap: Math.round(r.y - b.bottom), background: getComputedStyle(m).backgroundColor }) })()`))
|
||||
check('file name click opens the 3 samples only (no Open / + New), SWHeader face', s.list && s.nameExpanded === 'true' && list.rows.length === 3 && list.actions === 0 && list.row === 32 && Math.abs(list.gap) <= 1
|
||||
&& list.background === 'rgb(16, 49, 39)' && list.active === 'C0060202-001-평면및종단면도.dwg', list)
|
||||
check('document list >= file-name button (outer) and >= 312px, covers it', list.width + 0.5 >= list.button && list.width >= 311.5 && list.left <= 0.5 && list.right >= -0.5, list)
|
||||
check('list painted on top (not cut)', await onTop('.hm-sw-viewer-header .hm-sw-header__document-menu'))
|
||||
await shot('firefox-document-list.png', '.hm-sw-viewer-header .hm-sw-viewer-header__brand', 640, 200)
|
||||
await click('.hm-sw-viewer-header .hm-sw-header__document-row:last-child .hm-sw-header__document-item')
|
||||
s = await state()
|
||||
check('sample select loads it, renames, closes, focus on the file name', s.name === 'simple.dxf' && s.log.includes('samples/sample-dxf') && !s.list && s.focus === 'name', s)
|
||||
await press('ArrowDown')
|
||||
s = await state()
|
||||
check('ArrowDown opens the list on its first row', s.list && s.focus === 'BasicSample.dwg', s)
|
||||
await press('Escape')
|
||||
s = await state()
|
||||
check('Escape closes the list and refocuses the file name', !s.list && s.focus === 'name', s)
|
||||
|
||||
// ---------- Above open panels ----------
|
||||
await goto('sw-theme--theme-inheritance')
|
||||
const themedArea = '[data-theme-case="viewer-themed"] .hm-sw-viewer-header'
|
||||
await click(`${themedArea} .hm-sw-viewer-header__document .hm-sw-header__document-button`)
|
||||
check('document list above an open SWViewerPanel', await onTop(`${themedArea} .hm-sw-header__document-menu`))
|
||||
await press('Escape')
|
||||
|
||||
// ---------- Responsive ----------
|
||||
await goto('sw-viewer--responsive')
|
||||
const report = JSON.parse(await ev(`JSON.stringify([...document.querySelectorAll('[data-width] .hm-sw-viewer-header')].map(h => { const rect = s => h.querySelector(s).getBoundingClientRect()
|
||||
const toggles = [...h.querySelectorAll('.hm-sw-viewer-header__button[data-kind=toggle]')], object = h.querySelector('.hm-sw-viewer-header__object'), open = h.querySelector('[data-document-open]')
|
||||
return { width: Math.round(h.getBoundingClientRect().width), overflow: h.scrollWidth - h.clientWidth, objectOverflow: object.scrollWidth - object.clientWidth,
|
||||
toggles: toggles.map(t => [t.getAttribute('aria-label') ?? t.textContent.trim(), !!t.querySelector('.hm-sw-viewer-header__label'), !!t.title]),
|
||||
fit: [...object.querySelectorAll('button')].some(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === '전체 보기'), more: !!h.querySelector('.hm-sw-viewer-header__more'),
|
||||
open: [!!open.querySelector('.hm-sw-viewer-header__open-label'), open.title, open.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect().width],
|
||||
order: [rect('.hm-sw-viewer-header__brand').right, open.getBoundingClientRect().left, open.getBoundingClientRect().right, rect('.hm-sw-viewer-header__document').left, rect('.hm-sw-viewer-header__document').right, rect('.hm-sw-viewer-header__object').left] } }))`))
|
||||
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.length === 4 && report.every(r => r.overflow <= 0 && r.objectOverflow <= 1), report.map(r => [r.width, r.overflow, r.objectOverflow]))
|
||||
check('layer / settings / properties always in the header with tooltips', report.every(r => r.toggles.map(t => t[0]).join() === '레이어,설정,속성' && r.toggles.every(t => t[2])), report.map(r => [r.width, r.toggles]))
|
||||
// 2026-10-21 measured steps: with the file open button this Story fits fully at 1440, as icons at 1100, More at 860 / 520.
|
||||
check('1440 full (파일 열기 + 전체 보기 labels, no More); 1100 icons only, no More; 860 / 520 icons + More', report[0].fit && !report[0].more && report[0].open[0] && report[0].toggles.every(t => t[1])
|
||||
&& !report[1].more && report[1].toggles.every(t => !t[1]) && report.slice(1).every(r => !r.open[0] && r.open[1] === '파일 열기' && Math.round(r.open[2]) === 34) && report[2].more && report[3].more,
|
||||
report.map(r => [r.width, r.fit, r.more, r.open, r.toggles.map(t => t[1])]))
|
||||
check('logo, file open, file name and toolbar never overlap', report.every(r => r.order[0] <= r.order[1] + 2.5 && r.order[2] <= r.order[3] + 0.5 && r.order[4] <= r.order[5] + 0.5), report.map(r => [r.width, r.order]))
|
||||
await click('[data-width="520"] .hm-sw-viewer-header__document .hm-sw-header__document-button')
|
||||
check('520: document list shown inside the header width', await onTop('[data-width="520"] .hm-sw-header__document-menu') && await ev(`(() => { const m = document.querySelector('[data-width="520"] .hm-sw-header__document-menu').getBoundingClientRect(), h = document.querySelector('[data-width="520"] .hm-sw-viewer-header').getBoundingClientRect(); return m.x >= h.x - 2 && m.right <= h.right })()`))
|
||||
await press('Escape')
|
||||
await click('[data-width="520"] [data-document-open]')
|
||||
check('520: icon-only file open runs the file open', (await state()).log !== undefined && await ev(`document.querySelector('[data-callback-log]').textContent.endsWith('document/open')`))
|
||||
|
||||
// ---------- Narrow screen: icon toggles open the panels ----------
|
||||
await viewport({ width: 820, height: 900 })
|
||||
await goto('sw-viewer--viewer-header')
|
||||
const panels = []
|
||||
for (const [name, dialog] of [['레이어', '레이어 ('], ['설정', '옵션'], ['속성', '속성']]) {
|
||||
const selector = `.hm-sw-viewer-header .hm-sw-viewer-header__button[aria-label="${name}"]`
|
||||
await click(selector)
|
||||
const after = JSON.parse(await ev(`(() => { const t = document.querySelector(${JSON.stringify(selector)}); return JSON.stringify({ pressed: t.getAttribute('aria-pressed'), color: getComputedStyle(t).color, title: t.title,
|
||||
open: [...document.querySelectorAll('[role=dialog]')].some(d => (d.getAttribute('aria-label') ?? d.textContent).includes(${JSON.stringify(dialog)}) || document.getElementById(d.getAttribute('aria-labelledby'))?.textContent.includes(${JSON.stringify(dialog)})) }) })()`))
|
||||
await click(selector)
|
||||
after.closed = await ev(`document.querySelector(${JSON.stringify(selector)}).getAttribute('aria-pressed') === 'false'`)
|
||||
panels.push({ name, ...after })
|
||||
}
|
||||
const width = await ev(`Math.round(document.querySelector('.hm-sw-viewer-header').getBoundingClientRect().width)`)
|
||||
check(`narrow header (${width}px): icon toggles open / close panels, pressed #FFC600, tooltip`, width < 900 && panels.every(p => p.pressed === 'true' && p.color === 'rgb(255, 198, 0)' && p.title && p.open && p.closed), panels)
|
||||
await shot('firefox-viewer-narrow.png', '.hm-sw-viewer-header', 820, 60)
|
||||
} finally {
|
||||
check('no console errors', errors.length === 0, errors.slice(0, 5))
|
||||
const failed = checks.filter(c => !c.ok).length
|
||||
await writeFile(path.join(out, 'viewer-logo-document-menu-firefox.json'), JSON.stringify({ base, checks, errors }, null, 2))
|
||||
console.log(`${checks.length - failed}/${checks.length} PASS (firefox)`)
|
||||
try { await send('session.end', {}) } catch {}
|
||||
ws.close(); proc.kill()
|
||||
await sleep(500); await rm(profile, { recursive: true, force: true }).catch(() => {})
|
||||
process.exitCode = failed ? 1 : 0
|
||||
}
|
||||
@@ -0,0 +1,393 @@
|
||||
// SW viewer header file open button + document list (2026-10-21).
|
||||
// The logo cap opens the SWHeader `...` menu (SWHeaderMore) with Open; the file name opens the SWHeader document list
|
||||
// (SWHeaderDocumentTitle, no + New / Open) with the samples. Never open together. Panel toggles stay in the header
|
||||
// (icon only below 900px), 전체 보기 lives in More only.
|
||||
// Usage: node packages/sw/tests/viewer-logo-document-menu.browser.mjs <storybook-url> <out-dir>
|
||||
// e.g. node packages/sw/tests/viewer-logo-document-menu.browser.mjs http://127.0.0.1:6024 docs/sw/viewer-logo-menu-qa
|
||||
import { mkdir, writeFile } from 'node:fs/promises'
|
||||
import path from 'node:path'
|
||||
import { runtimeRequire } from './playwright-runtime.mjs'
|
||||
|
||||
const require = runtimeRequire()
|
||||
const { chromium } = require('playwright')
|
||||
const [storybook, outArg] = process.argv.slice(2)
|
||||
if (!storybook) throw new Error('Pass the Storybook URL')
|
||||
const out = path.resolve(outArg ?? 'docs/sw/viewer-logo-menu-qa')
|
||||
await mkdir(out, { recursive: true })
|
||||
|
||||
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
|
||||
const results = []
|
||||
const check = (name, pass, detail = '') => { results.push({ name, result: pass ? 'PASS' : 'FAIL', detail }); console.log(`${pass ? 'PASS' : 'FAIL'} ${name}${detail ? ` — ${typeof detail === 'string' ? detail : JSON.stringify(detail)}` : ''}`) }
|
||||
const errors = []
|
||||
const story = id => `${storybook}/iframe.html?id=${id}&viewMode=story`
|
||||
const newPage = async (width = 1920, height = 1000) => {
|
||||
const page = await browser.newPage({ viewport: { width, height }, reducedMotion: 'reduce' })
|
||||
page.on('pageerror', error => errors.push(`${page.url()}: ${error.message}`))
|
||||
page.on('console', message => { if (message.type() === 'error' && !/favicon|404/.test(message.text())) errors.push(`${page.url()}: ${message.text()}`) })
|
||||
return page
|
||||
}
|
||||
const open = async (page, id) => {
|
||||
await page.goto(story(id))
|
||||
await page.waitForSelector('.hm-sw-viewer-header')
|
||||
await page.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' })
|
||||
await page.evaluate(() => document.fonts.ready)
|
||||
await page.waitForTimeout(200)
|
||||
}
|
||||
const box = async locator => locator.boundingBox()
|
||||
const log = page => page.locator('[data-callback-log]').first().innerText()
|
||||
// Not cut by a scroller or covered: the element at the center of the popup is the popup itself.
|
||||
const onTop = locator => locator.evaluate(element => { element.scrollIntoView({ block: 'nearest' }); const r = element.getBoundingClientRect(); return element.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height - 6)) })
|
||||
const focused = page => page.evaluate(() => document.activeElement?.getAttribute('aria-label') ?? document.activeElement?.textContent?.trim())
|
||||
// Menu face of an open menu, split into colors and the other metrics (2026-10-21 style unification: the logo menu and
|
||||
// the document list take the metrics of the viewer dropdown, SWViewerMenu, and keep their own colors).
|
||||
const face = (locator, itemSelector, labelSelector, separatorSelector, iconSelector, textSelector) => locator.evaluate((menu, [itemSelector, labelSelector, separatorSelector, iconSelector, textSelector]) => {
|
||||
// First item actually shown (SWHeader hides the items of wider tiers with CSS).
|
||||
const s = getComputedStyle(menu), item = [...menu.querySelectorAll(itemSelector)].find(entry => entry.offsetParent !== null), i = getComputedStyle(item)
|
||||
const label = labelSelector && menu.querySelector(labelSelector), separator = separatorSelector && menu.querySelector(separatorSelector)
|
||||
const m = menu.getBoundingClientRect(), b = item.getBoundingClientRect()
|
||||
return {
|
||||
colors: { background: s.backgroundColor, border: s.borderTopColor, shadow: s.boxShadow, item: i.color, label: label ? getComputedStyle(label).color : null },
|
||||
metrics: {
|
||||
border: `${s.borderTopWidth} ${s.borderTopStyle}`, radius: s.borderTopLeftRadius, padding: s.padding, minWidth: s.minWidth, font: `${s.fontSize}/${s.lineHeight} ${s.fontWeight}`,
|
||||
itemHeight: Math.round(b.height), itemInset: Math.round(b.left - m.left), itemPadding: `${i.paddingLeft} ${i.paddingRight}`, itemGap: i.columnGap, itemRadius: i.borderTopLeftRadius,
|
||||
itemFont: `${i.fontFamily.split(',')[0]} ${i.fontSize}/${i.lineHeight} ${i.fontWeight}`, itemOutline: i.textShadow,
|
||||
label: label ? (l => `${l.padding} ${l.fontSize}/${l.lineHeight} ${l.fontWeight}`)(getComputedStyle(label)) : null,
|
||||
separator: separator ? (x => `${x.height} ${x.margin}`)(getComputedStyle(separator)) : null,
|
||||
// Icon slot of the first row (2026-10-21 logo menu icon): size and left offset, then where the text starts.
|
||||
icon: iconSelector && item.querySelector(iconSelector) ? (r => `${Math.round(r.width)}x${Math.round(r.height)} @${Math.round(r.left - b.left)}`)(item.querySelector(iconSelector).getBoundingClientRect()) : null,
|
||||
textLeft: textSelector && item.querySelector(textSelector) ? Math.round(item.querySelector(textSelector).getBoundingClientRect().left - b.left) : null,
|
||||
},
|
||||
}
|
||||
}, [itemSelector, labelSelector, separatorSelector, iconSelector, textSelector])
|
||||
const MORE_ITEM = '.hm-sw-header__more-item', VIEWER_ITEM = '.hm-sw-viewer-menu__item'
|
||||
|
||||
// ---------- References: SWViewerMenu metrics ("상단 메뉴 + Dropdown") and the SWHeader `...` menu (colors, unchanged) ----------
|
||||
let reference, viewerMenu, moreMenu, refTrigger
|
||||
{
|
||||
const page = await newPage()
|
||||
await open(page, 'sw-viewer--dropdowns')
|
||||
const trigger = page.getByRole('button', { name: '도면' })
|
||||
await trigger.click()
|
||||
const dropdown = page.getByRole('menu', { name: '도면', exact: true })
|
||||
viewerMenu = await face(dropdown, VIEWER_ITEM, '.hm-sw-viewer-menu__label', '.hm-sw-viewer-menu__separator', '.hm-sw-viewer-menu__icon', '.hm-sw-viewer-menu__text')
|
||||
const [d, t] = [await box(dropdown), await box(trigger)]
|
||||
viewerMenu.place = { dy: Math.round((d.y - t.y - t.height) * 10) / 10, dx: Math.round((d.x - t.x) * 10) / 10 }
|
||||
check('reference SWViewerMenu: 36px rows, 4px padding / radius, 14px 400, 4px under its trigger', viewerMenu.metrics.itemHeight === 36 && viewerMenu.metrics.padding === '4px' && viewerMenu.metrics.radius === '4px'
|
||||
&& viewerMenu.metrics.font === '14px/20px 400' && viewerMenu.place.dy === 4 && viewerMenu.place.dx === 0, viewerMenu)
|
||||
await page.close()
|
||||
}
|
||||
{
|
||||
const page = await newPage()
|
||||
await open(page, 'sw-viewer--compare-with-sw-header')
|
||||
const header = page.locator('.hm-sw-header').first()
|
||||
const more = header.getByRole('button', { name: '더보기' })
|
||||
await more.click()
|
||||
const menu = header.getByRole('menu', { name: '추가 도구' })
|
||||
reference = await face(menu, MORE_ITEM)
|
||||
refTrigger = await more.evaluate(element => { const s = getComputedStyle(element); return `${s.borderTopWidth} ${s.borderTopStyle} ${s.borderTopColor} | ${s.borderTopLeftRadius} | ${s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor} | ${s.boxShadow}` })
|
||||
const responsive = await newPage(1920, 1500)
|
||||
await open(responsive, 'sw-viewer--responsive')
|
||||
await responsive.locator('[data-width="520"] .hm-sw-viewer-header').getByRole('button', { name: '추가 도구' }).click()
|
||||
const more520 = responsive.getByRole('menu', { name: '추가 도구' })
|
||||
moreMenu = await face(more520, VIEWER_ITEM, '.hm-sw-viewer-menu__label', '.hm-sw-viewer-menu__separator', '.hm-sw-viewer-menu__icon', '.hm-sw-viewer-menu__text')
|
||||
const moreRow = more520.locator(VIEWER_ITEM).first()
|
||||
await moreRow.hover()
|
||||
await responsive.waitForTimeout(120)
|
||||
moreMenu.hover = await moreRow.evaluate(element => `${getComputedStyle(element).backgroundColor} / ${getComputedStyle(element).color}`)
|
||||
// 2026-10-21 final colors: the SWHeader `...` menu (measured above as `reference`, normal SW mode): face, border,
|
||||
// shadow, text; its row hover (white 8%, text unchanged) is checked on the logo menu below.
|
||||
const { label: _refLabel, ...refColors } = reference.colors, { label: moreLabel, ...moreColors } = moreMenu.colors
|
||||
check('More (520px) colors = SWHeader ... menu (face, border, shadow, text); caption = text color', JSON.stringify(moreColors) === JSON.stringify(refColors) && moreLabel === moreColors.item && moreMenu.hover === `rgba(255, 255, 255, 0.08) / ${moreColors.item}`, { more: moreMenu.colors, hover: moreMenu.hover, swHeader: reference.colors })
|
||||
check('header dropdown (상단 메뉴 + Dropdown) = More face and colors', JSON.stringify(viewerMenu.colors) === JSON.stringify(moreMenu.colors), { dropdown: viewerMenu.colors, more: moreMenu.colors })
|
||||
await responsive.close()
|
||||
const [menuBox, triggerBox] = [await box(menu), await box(more)]
|
||||
check('SWHeader ... menu still right-aligned under its trigger (no title, no divider)', Math.abs(menuBox.x + menuBox.width - (triggerBox.x + triggerBox.width)) <= 1 && menuBox.y >= triggerBox.y + triggerBox.height
|
||||
&& (await menu.locator('.hm-sw-header__more-title, .hm-sw-header__more-separator').count()) === 0)
|
||||
await page.keyboard.press('Escape')
|
||||
check('SWHeader ... menu Escape closes and refocuses ...', (await header.getByRole('menu').count()) === 0 && (await focused(page)) === '더보기')
|
||||
// The normal file name keeps + New / Open.
|
||||
await header.locator('.hm-sw-header__document-button').click().catch(() => {})
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ---------- Viewer header: file open button + document list ----------
|
||||
{
|
||||
const page = await newPage()
|
||||
await open(page, 'sw-viewer--viewer-header')
|
||||
const header = page.locator('.hm-sw-viewer-header').first()
|
||||
const logo = header.locator('.hm-sw-viewer-header__brand')
|
||||
const openButton = header.locator('[data-document-open]')
|
||||
const name = header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button')
|
||||
// Logo: no dropdown any more (2026-10-21, the file open moved to the header button).
|
||||
await logo.click()
|
||||
check('logo opens no dropdown (no menu trigger)', (await logo.getAttribute('aria-haspopup')) === null && (await page.getByRole('menu').count()) === 0)
|
||||
// File open button: SWHeader documentOpenPlacement="header" surface right after the logo, bordered icon + label.
|
||||
const layout = await header.evaluate(element => {
|
||||
const button = element.querySelector('[data-document-open]'), wrapper = button.parentElement
|
||||
return { surface: button.classList.contains('hm-sw-header__document-button') && wrapper.matches('.hm-sw-header__document[data-tool]'), afterLogo: wrapper.previousElementSibling?.classList.contains('hm-sw-viewer-header__brand'),
|
||||
beforeName: wrapper.nextElementSibling?.classList.contains('hm-sw-viewer-header__document'), label: button.querySelector('.hm-sw-viewer-header__open-label')?.textContent, icons: button.querySelectorAll('.hm-sw-viewer-header__open-icon svg').length }
|
||||
})
|
||||
check('file open button after the logo: SWHeader document surface, icon + 파일 열기', layout.surface && layout.afterLogo && layout.beforeName && layout.label === '파일 열기' && layout.icons === 1, layout)
|
||||
const iconBox = await openButton.locator('.hm-sw-viewer-header__open-icon').evaluate(element => { const s = getComputedStyle(element), r = element.getBoundingClientRect(); return { style: `${s.borderTopWidth} ${s.borderTopStyle} ${s.borderTopColor} | ${s.borderTopLeftRadius} | ${s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor} | ${s.boxShadow}`, size: [Math.round(r.width), Math.round(r.height)] } })
|
||||
// 2026-10-21 user request: the icon has no border and no face (transparent); 34px slot kept.
|
||||
check('file open icon: no border, transparent, no shadow; 34px slot', iconBox.style.startsWith('0px none') && iconBox.style.includes('rgba(0, 0, 0, 0)') && iconBox.style.endsWith('none') && iconBox.size.join() === '34,34', iconBox)
|
||||
// File-name area (.hm-sw-viewer-header__document): no left padding, right after the file open button; the file-name
|
||||
// button inside keeps its own padding (user decision 2026-10-21).
|
||||
const nameStart = await header.evaluate(element => { const d = element.querySelector('.hm-sw-viewer-header__document'); return [getComputedStyle(d).paddingLeft, Math.round(d.getBoundingClientRect().left - element.querySelector('[data-document-open]').getBoundingClientRect().right)] })
|
||||
check('file name area: no left padding, right after the file open button', nameStart[0] === '0px' && Math.abs(nameStart[1]) <= 2, nameStart)
|
||||
check('no 샘플 header button, no + New', (await header.getByRole('button', { name: '샘플' }).count()) === 0 && (await page.getByText('+ New').count()) === 0)
|
||||
await page.screenshot({ path: path.join(out, 'viewer-1440-idle.png'), clip: { ...(await box(header)), height: 56 } })
|
||||
await openButton.click()
|
||||
check('file open button runs the existing file open (onDocumentOpen + onCommand open/document)', (await log(page)).endsWith('파일 선택창 (미연결) → document/open'), await log(page))
|
||||
|
||||
// ---------- Document list ----------
|
||||
check('file name is the SWHeader document button', (await name.getAttribute('aria-label')) === '도면: C0060202-001-평면및종단면도.dwg' && (await name.getAttribute('aria-expanded')) === 'false')
|
||||
// The logo cap and its slanted tail have the same pixels with the list open, as in SWHeader. The cap's last pixel row
|
||||
// touches the header's bottom border, where the list's own drop shadow (0 2px 4px) starts: that row is left out.
|
||||
const cap = await box(logo), capClip = { x: cap.x, y: cap.y, width: cap.width + 18, height: cap.height - 1 }
|
||||
await name.hover()
|
||||
const capClosed = await page.screenshot({ clip: capClip })
|
||||
await name.click()
|
||||
const list = header.getByRole('group', { name: '문서 목록' })
|
||||
await list.waitFor()
|
||||
check('open list never covers the logo cap (pixels identical)', capClosed.equals(await page.screenshot({ clip: capClip })))
|
||||
const rows = await list.locator('.hm-sw-header__document-item-name').allInnerTexts()
|
||||
check('file name click opens the sample list', await list.isVisible() && await onTop(list) && JSON.stringify(rows) === JSON.stringify(['BasicSample.dwg', 'C0060202-001-평면및종단면도.dwg', 'simple.dxf']), rows)
|
||||
check('no Open and no + New below the samples', (await list.locator('.hm-sw-header__document-footer, .hm-sw-header__document-action').count()) === 0)
|
||||
check('current sample marked active', (await list.locator('[data-active="true"] .hm-sw-header__document-item-name').innerText()) === 'C0060202-001-평면및종단면도.dwg')
|
||||
const listFace = await list.evaluate(element => { const s = getComputedStyle(element); return { background: s.backgroundColor, width: Math.round(element.getBoundingClientRect().width), row: Math.round(element.querySelector('.hm-sw-header__document-row').getBoundingClientRect().height) } })
|
||||
check('SWHeader list face (32px rows, #103127, at least the SWHeader 312px)', listFace.background === 'rgb(16, 49, 39)' && listFace.width >= 312 && listFace.row === 32, listFace)
|
||||
const [listBox, nameBox] = [await box(list), await box(name)]
|
||||
check('list opens directly below the file name', Math.abs(listBox.y - (nameBox.y + nameBox.height)) <= 1, { list: listBox, name: nameBox })
|
||||
await page.screenshot({ path: path.join(out, 'viewer-document-list-open.png'), clip: { x: cap.x - 8, y: cap.y - 8, width: 640, height: 200 } })
|
||||
await list.getByRole('button', { name: 'simple.dxf' }).click()
|
||||
check('sample select loads it and renames the file', (await name.innerText()).includes('simple.dxf') && (await log(page)).includes('samples/sample-dxf'), await log(page))
|
||||
check('list closes after select, focus on the file name', (await list.count()) === 0 && await name.evaluate(element => element === document.activeElement))
|
||||
await page.keyboard.press('ArrowDown')
|
||||
check('ArrowDown opens the list on its first row', await list.isVisible() && (await focused(page)) === 'BasicSample.dwg')
|
||||
await page.keyboard.press('Escape')
|
||||
check('Escape closes the list and refocuses the file name', (await list.count()) === 0 && await name.evaluate(element => element === document.activeElement))
|
||||
await name.click()
|
||||
await page.mouse.click(900, 700)
|
||||
check('outside click closes the list', (await list.count()) === 0)
|
||||
|
||||
// ---------- File open: icon only when the header has no room (measured), never in More ----------
|
||||
const openState = width => header.evaluate(async (element, width) => {
|
||||
element.style.width = `${width}px`
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
const button = element.querySelector('[data-document-open]'), icon = button.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect()
|
||||
return { width, label: !!button.querySelector('.hm-sw-viewer-header__open-label'), aria: button.getAttribute('aria-label'), title: button.title, icon: [Math.round(icon.width), Math.round(icon.height)], overflow: element.scrollWidth - element.clientWidth }
|
||||
}, width)
|
||||
const sweep = []
|
||||
for (let width = 1440; width >= 400; width -= 20) sweep.push(await openState(width))
|
||||
const back = []
|
||||
for (let width = 400; width <= 1440; width += 20) back.push(await openState(width))
|
||||
const firstIcon = sweep.find(item => !item.label)
|
||||
check('1440: icon + 파일 열기; narrower: icon only (tooltip / name 파일 열기, 34px icon slot kept)', sweep[0].label && firstIcon
|
||||
&& sweep.filter(item => !item.label).every(item => item.aria === '파일 열기' && item.title === '파일 열기' && item.icon.join() === '34,34'), { first: sweep[0], firstIcon })
|
||||
check('label comes back at the same widths when the header grows; no overflow', sweep.every(item => back.find(other => other.width === item.width).label === item.label) && [...sweep, ...back].every(item => item.overflow <= 0))
|
||||
await header.evaluate(element => { element.style.width = '520px' })
|
||||
await page.waitForTimeout(60)
|
||||
const more = header.getByRole('button', { name: '추가 도구' })
|
||||
await more.click()
|
||||
const moreItems = await page.getByRole('menu', { name: '추가 도구' }).locator('[role^=menuitem]').allInnerTexts()
|
||||
check('file open never moves into More', !moreItems.some(item => item.includes('파일 열기') || item === 'Open'), moreItems)
|
||||
await page.keyboard.press('Escape')
|
||||
await openButton.click()
|
||||
check('icon-only button runs the same file open', (await log(page)).endsWith('파일 선택창 (미연결) → document/open'), await log(page))
|
||||
await page.screenshot({ path: path.join(out, 'viewer-520-open-icon.png'), clip: { ...(await box(header)), height: 56 } })
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ---------- Above open panels ----------
|
||||
{
|
||||
// A panel at the top-left of the drawing area lies under the document list; the open list must be painted above it
|
||||
// (the header takes the viewer dropdown layer while it is open) and the header has no layer once it is closed.
|
||||
const page = await newPage()
|
||||
await open(page, 'sw-theme--theme-inheritance')
|
||||
const area = page.locator('[data-theme-case="viewer-themed"]')
|
||||
const header = area.locator('.hm-sw-viewer-header')
|
||||
await header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button').click()
|
||||
const list = header.getByRole('group', { name: '문서 목록' })
|
||||
check('document list above an open SWViewerPanel', await onTop(list), await header.evaluate(element => getComputedStyle(element).zIndex))
|
||||
await page.keyboard.press('Escape')
|
||||
check('closed list drops back (no raised layer at rest), cap over the open button and file name', await header.evaluate(element => getComputedStyle(element).zIndex === 'auto'
|
||||
&& getComputedStyle(element.querySelector('.hm-sw-viewer-header__brand')).zIndex === '2' && getComputedStyle(element.querySelector('.hm-sw-header__document')).zIndex === '1'))
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ---------- Responsive ----------
|
||||
{
|
||||
// Tall enough that the 520 row and its popups are inside the viewport (the Story page does not scroll them in).
|
||||
const page = await newPage(1920, 1500)
|
||||
await open(page, 'sw-viewer--responsive')
|
||||
const headers = page.locator('[data-width] .hm-sw-viewer-header')
|
||||
const report = []
|
||||
for (const [index, width] of [1440, 1100, 860, 520].entries()) {
|
||||
const header = headers.nth(index)
|
||||
const info = await header.evaluate(element => {
|
||||
const rect = selector => element.querySelector(selector)?.getBoundingClientRect()
|
||||
const toggles = [...element.querySelectorAll('.hm-sw-viewer-header__button[data-kind=toggle]')]
|
||||
return {
|
||||
overflow: element.scrollWidth - element.clientWidth,
|
||||
objectOverflow: (() => { const object = element.querySelector('.hm-sw-viewer-header__object'); return object.scrollWidth - object.clientWidth })(),
|
||||
toggles: toggles.map(item => ({ name: item.getAttribute('aria-label') ?? item.textContent.trim(), label: !!item.querySelector('.hm-sw-viewer-header__label'), title: item.title })),
|
||||
fitShown: [...element.querySelectorAll('.hm-sw-viewer-header__object button')].some(item => (item.getAttribute('aria-label') ?? item.textContent.trim()) === '전체 보기'),
|
||||
more: !!element.querySelector('.hm-sw-viewer-header__more'),
|
||||
logoRight: rect('.hm-sw-viewer-header__brand')?.right, docLeft: rect('.hm-sw-viewer-header__document')?.left,
|
||||
docRight: rect('.hm-sw-viewer-header__document')?.right, objectLeft: rect('.hm-sw-viewer-header__object')?.left,
|
||||
}
|
||||
})
|
||||
report.push({ width, ...info })
|
||||
const b = await box(header)
|
||||
await page.screenshot({ path: path.join(out, `responsive-${width}.png`), clip: { x: b.x, y: b.y, width: b.width, height: b.height } })
|
||||
}
|
||||
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1), report.map(item => [item.width, item.overflow, item.objectOverflow]))
|
||||
check('layer / settings / properties always in the header', report.every(item => JSON.stringify(item.toggles.map(toggle => toggle.name)) === '["레이어","설정","속성"]'), report.map(item => item.toggles.map(toggle => toggle.name)))
|
||||
// 2026-10-21 measured steps: full → icons only → More, by the measured room (no fixed width). With the file open button
|
||||
// after the logo, this Story's content fits fully at 1440, as icons at 1100, and needs More at 860 / 520.
|
||||
check('1440: full items with labels (전체 보기 shown), no More; 1100: icons only, no More', report.slice(0, 1).every(item => item.fitShown && !item.more && item.toggles.every(toggle => toggle.label))
|
||||
&& !report[1].more && report[1].fitShown && report[1].toggles.every(toggle => !toggle.label && toggle.title), report.map(item => [item.width, item.fitShown, item.more]))
|
||||
check('860: icons only (tooltips kept) + More', report[2].more && report[2].toggles.every(toggle => !toggle.label && toggle.title), report[2])
|
||||
check('520: icons only, then More for what still does not fit', report[3].more && report[3].toggles.every(toggle => !toggle.label && toggle.title), report[3])
|
||||
check('logo, file name and toolbar never overlap', report.every(item => item.logoRight <= item.docLeft + 2 + 0.5 && item.docRight <= item.objectLeft + 0.5), report.map(item => [item.width, item.logoRight, item.docLeft, item.docRight, item.objectLeft]))
|
||||
// Every item is either in the header or in More, never both and never lost.
|
||||
const more = headers.nth(3).getByRole('button', { name: '추가 도구' })
|
||||
await more.click()
|
||||
const moreMenu = page.getByRole('menu', { name: '추가 도구' })
|
||||
const moreItems = (await moreMenu.locator('[role^=menuitem]').allInnerTexts()).map(text => text.split(' · ')[0])
|
||||
const inHeader = await headers.nth(3).evaluate(element => [...element.querySelectorAll('.hm-sw-viewer-header__object :is(.hm-sw-viewer-header__button:not(.hm-sw-viewer-header__more), [role=radio], .hm-sw-viewer-header__switch-label)')]
|
||||
.map(item => item.getAttribute('aria-label') ?? item.textContent.trim().replace(/[\d,]+$/, '')))
|
||||
const all = ['Model', 'Layout', '전체 보기', '2.5D', '어두운 배경', '레이어', '설정', '속성']
|
||||
check('520: header + More hold every item exactly once (panel toggles never in More)', all.every(name => inHeader.includes(name) !== moreItems.includes(name)) && !moreItems.some(item => ['레이어', '설정', '속성'].includes(item)), { inHeader, moreItems })
|
||||
await page.keyboard.press('Escape')
|
||||
// Icon toggle at 520 still reports pressed / commands.
|
||||
const icon = headers.nth(3).getByRole('button', { name: '레이어', exact: true })
|
||||
await icon.click()
|
||||
check('icon toggle pressed + command at 520', (await icon.getAttribute('aria-pressed')) === 'true' && (await log(page)).includes('layers=true'), await log(page))
|
||||
// Narrow: the document list stays inside the header width.
|
||||
await headers.nth(3).locator('.hm-sw-viewer-header__document .hm-sw-header__document-button').click()
|
||||
const narrowList = headers.nth(3).getByRole('group', { name: '문서 목록' })
|
||||
const [listBox, headerBox] = [await box(narrowList), await box(headers.nth(3))]
|
||||
check('520: document list shown, inside the header width', await onTop(narrowList) && listBox.x >= headerBox.x - 2 && listBox.x + listBox.width <= headerBox.x + headerBox.width, { list: listBox, header: headerBox })
|
||||
await page.keyboard.press('Escape')
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ---------- Narrow screen: icon toggles open the panels ----------
|
||||
{
|
||||
const page = await newPage(820, 900)
|
||||
await open(page, 'sw-viewer--viewer-header')
|
||||
const header = page.locator('.hm-sw-viewer-header').first()
|
||||
const width = Math.round((await box(header)).width)
|
||||
const panels = []
|
||||
for (const [name, dialog] of [['레이어', /레이어 \(/], ['설정', '옵션'], ['속성', '속성']]) {
|
||||
const toggle = header.getByRole('button', { name, exact: true })
|
||||
await toggle.hover()
|
||||
const hover = await toggle.evaluate(element => getComputedStyle(element).backgroundColor)
|
||||
await toggle.click()
|
||||
const panel = page.getByRole('dialog', { name: dialog })
|
||||
panels.push({ name, iconOnly: (await toggle.locator('.hm-sw-viewer-header__label').count()) === 0, title: await toggle.getAttribute('title'), hover, open: await panel.isVisible(), pressed: await toggle.getAttribute('aria-pressed'), color: await toggle.evaluate(element => getComputedStyle(element).color) })
|
||||
await toggle.click()
|
||||
panels.at(-1).closed = (await panel.count()) === 0 && (await toggle.getAttribute('aria-pressed')) === 'false'
|
||||
}
|
||||
check(`narrow header (${width}px): icon toggles with tooltip, hover, pressed #FFC600, panel open / close`, width < 900 && panels.every(item => item.iconOnly && item.title && item.hover === 'rgb(38, 102, 81)' && item.open && item.pressed === 'true' && item.color === 'rgb(255, 198, 0)' && item.closed), panels)
|
||||
await page.screenshot({ path: path.join(out, `viewer-narrow-${width}.png`), clip: { ...(await box(header)), height: 56 } })
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ---------- Document list never narrower than the file-name button (2026-10-21) ----------
|
||||
{
|
||||
// Outer width of the open list vs the outer width of the file-name button (padding and border included), for a long
|
||||
// and a short file name at several header widths, after a file change and while resizing with the list open. The list
|
||||
// covers the button from its left edge, is at least the SWHeader 312px, and no row is cut (longer rows widen it).
|
||||
const page = await newPage(1920, 900)
|
||||
await open(page, 'sw-viewer--viewer-header')
|
||||
const header = page.locator('.hm-sw-viewer-header').first()
|
||||
const button = header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button'), list = header.getByRole('group', { name: '문서 목록' })
|
||||
const measure = async label => {
|
||||
const result = await header.evaluate(element => {
|
||||
const b = element.querySelector('.hm-sw-viewer-header__document .hm-sw-header__document-button').getBoundingClientRect(), m = element.querySelector('.hm-sw-header__document-menu').getBoundingClientRect()
|
||||
const cut = [...element.querySelectorAll('.hm-sw-header__document-item-name')].filter(name => name.scrollWidth > name.clientWidth + 0.5).map(name => name.textContent)
|
||||
return { button: Math.round(b.width * 10) / 10, list: Math.round(m.width * 10) / 10, left: Math.round((m.left - b.left) * 10) / 10, right: Math.round((m.right - b.right) * 10) / 10, cut }
|
||||
})
|
||||
return { label, ...result }
|
||||
}
|
||||
const cases = []
|
||||
for (const file of ['long', 'short']) {
|
||||
if (file === 'short') {
|
||||
await header.evaluate(element => { element.style.width = '' })
|
||||
await button.click()
|
||||
await list.getByRole('button', { name: 'simple.dxf' }).click()
|
||||
}
|
||||
for (const width of [1440, 1100, 860, 520, 400]) {
|
||||
await header.evaluate((element, width) => { element.style.width = `${width}px` }, width)
|
||||
await page.waitForTimeout(60)
|
||||
await button.click()
|
||||
cases.push(await measure(`${file}@${width}`))
|
||||
await page.keyboard.press('Escape')
|
||||
}
|
||||
}
|
||||
// Resize with the list open: the minimum follows the button.
|
||||
await header.evaluate(element => { element.style.width = '1440px' })
|
||||
await button.click()
|
||||
for (const width of [1200, 900, 600]) {
|
||||
await header.evaluate((element, width) => { element.style.width = `${width}px` }, width)
|
||||
await page.waitForTimeout(60)
|
||||
cases.push(await measure(`open-resize@${width}`))
|
||||
}
|
||||
await page.keyboard.press('Escape')
|
||||
const bad = cases.filter(item => item.list + 0.5 < item.button || item.left > 0.5 || item.right < -0.5 || item.list < 312 - 0.5 || item.cut.length)
|
||||
check('document list >= file-name button (outer), covers it, >= 312px, no row cut — long / short name, 1440–400, resize while open', bad.length === 0, { bad, cases })
|
||||
console.log('INFO document list widths', JSON.stringify(cases.map(item => [item.label, item.button, item.list])))
|
||||
await page.close()
|
||||
}
|
||||
|
||||
// ---------- Measured steps over a width sweep (2026-10-21) ----------
|
||||
{
|
||||
// The header's own width is swept (10px steps) down and back up. Stage per width: 0 full, 1 icons only, 2+ More with
|
||||
// n items. Icons always come before More; narrower never has fewer steps; the way back gives the same state at
|
||||
// every width (no flicker, no stuck item); no overflow. A shorter file name moves every boundary to a narrower width.
|
||||
const page = await newPage(1920, 900)
|
||||
await open(page, 'sw-viewer--viewer-header')
|
||||
const header = page.locator('.hm-sw-viewer-header').first()
|
||||
const state = width => header.evaluate(async (element, width) => {
|
||||
element.style.width = `${width}px`
|
||||
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
|
||||
const object = element.querySelector('.hm-sw-viewer-header__object')
|
||||
const icons = [...element.querySelectorAll('.hm-sw-viewer-header__button[data-kind]:not(.hm-sw-viewer-header__more)')].filter(button => button.getAttribute('data-icon-only') !== 'always')
|
||||
const shown = [...object.querySelectorAll(':is(.hm-sw-viewer-header__button:not(.hm-sw-viewer-header__more), .hm-sw-viewer-header__tabs, .hm-sw-viewer-header__switch-field)')].length
|
||||
const iconOnly = icons.length > 0 && icons.every(button => !button.querySelector('.hm-sw-viewer-header__label'))
|
||||
const anyIcon = icons.some(button => !button.querySelector('.hm-sw-viewer-header__label'))
|
||||
const more = !!element.querySelector('.hm-sw-viewer-header__more')
|
||||
return { width, shown, iconOnly, anyIcon, more, stage: more ? 2 : iconOnly ? 1 : 0, overflow: Math.max(element.scrollWidth - element.clientWidth, object.scrollWidth - object.clientWidth - 1) }
|
||||
}, width)
|
||||
const sweep = async () => {
|
||||
const down = [], up = []
|
||||
for (let width = 1440; width >= 400; width -= 10) down.push(await state(width))
|
||||
for (let width = 400; width <= 1440; width += 10) up.push(await state(width))
|
||||
return { down, up }
|
||||
}
|
||||
const boundaries = list => ({ icons: list.find(item => item.stage >= 1)?.width, more: list.find(item => item.stage === 2)?.width })
|
||||
const long = await sweep()
|
||||
check('sweep: no overflow at any width (400–1440)', [...long.down, ...long.up].every(item => item.overflow <= 0), [...long.down, ...long.up].filter(item => item.overflow > 0).map(item => item.width))
|
||||
check('sweep: icons switch together and always before More', [...long.down, ...long.up].every(item => item.anyIcon === item.iconOnly && (!item.more || item.iconOnly)))
|
||||
check('sweep: narrower never shows more (monotonic: full → icons → More, fewer items)', long.down.every((item, i) => i === 0 || (item.stage >= long.down[i - 1].stage && item.shown <= long.down[i - 1].shown)))
|
||||
check('sweep: growing back gives the same state at every width (reverse order, no stuck item)', long.down.every(item => { const back = long.up.find(other => other.width === item.width); return back.stage === item.stage && back.shown === item.shown }))
|
||||
check('sweep: 1440 full; each stage reached by measured room only', long.down[0].stage === 0 && long.down.at(-1).stage === 2, boundaries(long.down))
|
||||
// A short file name: the same content needs less room, so each step happens at a narrower width.
|
||||
await header.evaluate(element => { element.style.width = '' })
|
||||
await header.locator('.hm-sw-viewer-header__document .hm-sw-header__document-button').click()
|
||||
await header.getByRole('button', { name: 'simple.dxf' }).click()
|
||||
const short = await sweep()
|
||||
const [a, b] = [boundaries(long.down), boundaries(short.down)]
|
||||
check('sweep: shorter file name moves the steps to narrower widths', b.icons < a.icons && b.more < a.more, { long: a, short: b })
|
||||
await page.close()
|
||||
}
|
||||
|
||||
check('no page errors', errors.length === 0, errors.slice(0, 5).join(' | '))
|
||||
await writeFile(path.join(out, 'viewer-logo-document-menu-results.json'), JSON.stringify({ storybook, results, errors }, null, 2))
|
||||
await browser.close()
|
||||
const failed = results.filter(item => item.result === 'FAIL').length
|
||||
console.log(`${results.length - failed}/${results.length} PASS`)
|
||||
process.exit(failed ? 1 : 0)
|
||||
@@ -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) }
|
||||
|
||||
Reference in new issue
Block a user