Files
HMDesign/packages/sw/tests/viewer-logo-document-menu.browser.mjs
shkim4 6c61edadc1 fix(storybook): refine responsive page inner width
- Story page Inner side margin: 16px up to a 480px viewport, linear to 60px at 768px, 60px beyond (no width jump)
- Follow the 60px margins: Responsive 1920 row from a 2040px page (was 2000px); property compare 3 columns from 1125px, 2 columns from 787px (was 1080 / 720)
- SW browser tests: drop the removed --sb-page-max-width override, compare thresholds 787 / 1125
2026-11-05 15:59:41 +09:00

399 lines
34 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// 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.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()
// 2026-10-21: SWHeader shows More only below 760px (wider headers compress instead): the reference `...` menu is
// measured with the first header's 1440px Story wrapper narrowed to 700px (measurement only; face colors and metrics
// do not depend on the width).
await header.evaluate(element => { element.parentElement.style.width = '700px' })
await page.waitForTimeout(300)
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 = []
// 2026-10-21: the Story widths follow SW Footer / SW Header Responsive More (+ 520).
for (const [index, width] of [1920, 1440, 1280, 950, 860, 760, 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 })
// Element screenshot: scrolls the row in (7 rows since 2026-10-21 do not fit one viewport).
await header.screenshot({ path: path.join(out, `responsive-${width}.png`) })
}
check('no horizontal overflow at 1920 / 1440 / 1280 / 950 / 860 / 760 / 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 1920 / 1440, as icons at 1280 / 950, and needs More at 860 / 760 / 520.
check('1920 / 1440: full items with labels (전체 보기 shown), no More; 1280 / 950: icons only, no More', report.slice(0, 2).every(item => item.fitShown && !item.more && item.toggles.every(toggle => toggle.label))
&& report.slice(2, 4).every(item => !item.more && item.fitShown && item.toggles.every(toggle => !toggle.label && toggle.title)), report.map(item => [item.width, item.fitShown, item.more]))
check('860 / 760: icons only (tooltips kept) + More', report.slice(4, 6).every(item => item.more && item.toggles.every(toggle => !toggle.label && toggle.title)), report.slice(4, 6))
check('520: icons only, then More for what still does not fit', report[6].more && report[6].toggles.every(toggle => !toggle.label && toggle.title), report[6])
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(6).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(6).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)