User-approved design review (2026-10-21): - SW Header: from 760px up nothing moves into More; the five menus stay with their titles (titles and values ellipsize), file name shrinks to 96px. Open next to the logo no longer blocks field shrinking above 760px (760/768 overflow fixed, also for configured `controls` headers). Dropdown yellow ring inside the button, black seam. - Property Bar values as read-only red text; property popup "=" labels, read-only black values, per-entry hover with pointer. - Home Menu recent files (3 rows, path + name caption, empty preview), Footer + slot guide off by default, Viewer file list without icons, Storybook widths 1920-760. - Tests: old More expectations (integrated-improvement, keyboard-dismiss, footer-popup-viewer-refinement, storybook-cleanup-stage1) moved to the new rule; functional checks kept.
400 lines
34 KiB
JavaScript
400 lines
34 KiB
JavaScript
// 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()
|
||
// 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)
|