style(storybook): tidy CENTER, chart, org chart and SW story pages

- CENTER plain page layout and shorter reference demo notes (spec values
  moved to the CENTER README); chart specimen descriptions trimmed
- Org chart page names and previews; SW width rows follow the 30px frame
  margin (1980 / 1065 / 727px) and the SW tests match the current layout
- Story inventory regenerated and WORKING_CONTEXT updated
This commit is contained in:
shkim4 committed 2026-11-13 17:58:01 +09:00
1 parent bad2acfc49
commit ed7e8790ec
40 files changed
+1525 -333

No files matched your search

@@ -353,7 +353,6 @@ export function CenterInformationDisplayReferenceDemo() {
<div className="cid-demo">
<Block
title="박스형 · 정의 목록 (박스형 설명1)"
note="배지 + 그라데이션 레이블 칩 + 값 블록; 하위 불릿은 값 열 유지"
>
<CenterInformationBox
layout="definition"
@@ -370,7 +369,7 @@ export function CenterInformationDisplayReferenceDemo() {
/>
</Block>
<Block title="박스형 · 목록 — Source 팔레트 4종" note="지오메트리 동일; 팔레트만 다름">
<Block title="박스형 · 목록 — 팔레트 4종" note="지오메트리 동일; 팔레트만 다름">
{['navy', 'teal', 'orange', 'violet'].map((palette) => (
<CenterInformationBox
key={palette}
@@ -382,7 +381,7 @@ export function CenterInformationDisplayReferenceDemo() {
))}
</Block>
<Block title="테이블형" note="행 높이 = 기본값 + 추가 행마다 26.116 u + 추가 항목마다 34.364 u">
<Block title="테이블형">
<CenterInformationTable
title="사업효과"
width="643px"
@@ -401,7 +400,7 @@ export function CenterInformationDisplayReferenceDemo() {
/>
</Block>
<Block title="강조형 · 화살표 표시 (강조형 1)" note="패널은 카드 뒤에 4 u 겹침으로 배치되며 투명하게 사라짐">
<Block title="강조형 · 화살표 표시 (강조형 1)">
<CenterInformationEmphasis
title="사업목적"
arrow
@@ -429,7 +428,7 @@ export function CenterInformationDisplayReferenceDemo() {
/>
</Block>
<Block title="동적 콘텐츠 — 짧은 / 기준 / 긴 / 매우 긴" note="소유 단일행은 말줄임으로 클리핑; 명시적 줄바꿈 유지; 장문이 지오메트리를 늘리지 않음">
<Block title="동적 콘텐츠 — 짧은 / 기준 / 긴 / 매우 긴" note="한 줄 항목은 말줄임, 명시적 줄바꿈 유지">
{CONTENT_STATES.map((state) => (
<div className="cid-demo__state" key={state.key} data-content-test={state.key}>
<span className="cid-demo__tag">{state.label}</span>
@@ -452,11 +452,6 @@ export function CenterSpatialInformationReferenceDemo() {
<div className="csi csi-demo">
<Panel
id="csi-area-route" title="1. 노선 — 노선, 상태, 윤곽"
legend={
"중심선 18u, 윤곽은 중심선의 20% (3.6u), 그림자 X0 Y0 Blur 6u Opacity 70%. "
+ "지방도 · 구도 · 면도 · 이도는 Dark Outline (#000 / 30%), 나머지 분류는 White Outline 적용. "
+ "Source SVG를 로드하지 않음: 외피는 spread box-shadow, 공사중은 반복 파선 그라데이션, 계획은 45° 빗금 그라데이션."
}
note={
"KNOWN_DEVIATION — 구도,면도, 이도.svg는 흰색 윤곽으로 내보내며 명시된 Source 규칙과 시안 색상 견본에 모순"
+ "여기서는 명시된 규칙 구현"
@@ -479,7 +474,6 @@ export function CenterSpatialInformationReferenceDemo() {
<Panel
id="csi-area-project-route" title="1b. 사업 노선 — 사업노선과 끝 표시"
legend={"사업 노선 #D21203. 원형 표시 = 선의 150%인 원 (18u 선에서 27u), 노선 윤곽과 그림자 공유."}
>
<div className="csi-scale-50" style={{ padding: '10px 4px 4px' }}>
<ProjectRoute />
@@ -489,9 +483,7 @@ export function CenterSpatialInformationReferenceDemo() {
<Panel
id="csi-area-route-information" title="2. 노선 정보 표기 — 노선번호와 노선명 (동적 텍스트)"
legend={
'노선 식별에 필요한 경우 노선번호 또는 노선명을 선택하여 표기한다. '
+ "아래 문자열은 모두 실제 DOM 텍스트다. Source SVG에는 윤곽 글리프 경로로 기록되어 있지만 해당 지오메트리는 런타임에서 사용하지 않음. "
+ "노선명 너비 = padding 9u + 고유 텍스트 너비 (높이 54u 고정). 노선번호 기호는 배지 기본 64u로 고정."
'노선 식별에 필요한 경우 노선번호 또는 노선명을 선택하여 표기한다.'
}
note={
"KNOWN_DEVIATION — 고속도로 노선명.svg 높이는 48u이며 고속도로/특별시도 이름 표면 (#2E619A / #9729E5)은 선 색상과 다름"
@@ -530,8 +522,7 @@ export function CenterSpatialInformationReferenceDemo() {
<Panel
id="csi-area-direction" title="3. 방면 표기 — 방면 배지"
legend={
'노선의 진행 방향을 안내할 경우 화살표와 방면명을 조합한 배지형으로 표기한다. 노선 유형과 관계없이 동일한 형태로 적용한다. '
+ "완전히 둥근 50u 캡슐, 흰색 80%의 안쪽 윤곽 2u, 7점 화살표 다각형, 콘텐츠 기반 너비."
'노선의 진행 방향을 안내할 경우 화살표와 방면명을 조합한 배지형으로 표기한다. 노선 유형과 관계없이 동일한 형태로 적용한다.'
}
>
<div className="csi-scale-50" style={{ position: 'relative', padding: '4px 0 18px' }}>
@@ -422,8 +422,8 @@ function FacilityReferenceDemo({ config, alternateName }: { config: FacilityConf
return (
<div className="facility-reference" data-reference-facility={config.id}>
<div className="facility-reference__variant-grid">
<Sample title="Position Map" note="로컬 SVG · 위치도"><LocalAssetFacility config={config} /></Sample>
<Sample title="Actual / Detail" note="로컬 SVG · 실사화"><LocalAssetFacility config={config} variant="actualDetail" /></Sample>
<Sample title="Position Map" note="위치도"><LocalAssetFacility config={config} /></Sample>
<Sample title="Actual / Detail" note="실사화"><LocalAssetFacility config={config} variant="actualDetail" /></Sample>
</div>
{alternateName ? (
@@ -435,14 +435,14 @@ function FacilityReferenceDemo({ config, alternateName }: { config: FacilityConf
</Sample>
) : null}
<Sample title="Separated" note="Source 작성 부분 간격">
<Sample title="Separated">
<div className="facility-reference__row">
<LocalAssetFacility config={config} separated />
<LocalAssetFacility config={config} variant="actualDetail" separated />
</div>
</Sample>
<Sample title="너비 동작" note="Source 정의 가변 영역만 확장">
<Sample title="너비 동작" note="가변 영역만 확장">
<div className="facility-reference__width-tests">
{widthNames.map((name, index) => (
<div key={name}><span>{['SHORT', 'MEDIUM', 'LONG'][index]}</span><LocalAssetFacility config={config} name={name} /></div>
@@ -473,7 +473,7 @@ export function RoadsideReferenceDemo() {
return (
<div className="facility-reference roadside-reference" data-reference-facility="roadside">
<Sample title="Position Map" note="재구성 Primitive · 콘텐츠 기반 너비">
<Sample title="Position Map" note="콘텐츠 기반 너비">
<div className="roadside-tag-tests">
{tests.map((test) => (
<div key={test.size} className="roadside-tag-test" data-roadside-test={test.size.toLowerCase()}>
@@ -484,7 +484,7 @@ export function RoadsideReferenceDemo() {
</div>
</Sample>
<Sample title="Actual / Detail" note="Source 작성 그라데이션/filter 그래픽 · 고정 양 끝 + 가변 중앙">
<Sample title="Actual / Detail" note="고정 양 끝 + 가변 중앙">
<div className="roadside-tag-tests">
{tests.map((test) => (
<div key={test.size} className="roadside-tag-test" data-roadside-actual-test={test.size.toLowerCase()}>
@@ -495,11 +495,11 @@ export function RoadsideReferenceDemo() {
</div>
</Sample>
<Sample title="Actual / Detail · Separated" note="Source 작성 13 / 90 / 13 구조, 예제 간격 19 / 14">
<Sample title="Actual / Detail · Separated">
<RoadsideFacilityTag entity="drowsy-shelter" variant="actualDetail" separated />
</Sample>
<Sample title="연계 / 노선 맥락" note="노선 + 앵커 + 연결선 + 같은 시설물 태그 계약">
<Sample title="연계 / 노선 맥락" note="노선 + 앵커 + 연결선 + 시설물 태그">
<RoadsideAppliedExample />
</Sample>
</div>
@@ -178,15 +178,15 @@ export function TunnelReferenceDemo() {
return (
<div className="tunnel-reference">
<div className="tunnel-reference__variant-grid">
<TunnelSample title="Position Map" note="로컬 SVG · 위치도">
<TunnelSample title="Position Map" note="위치도">
<TunnelFacility variant="positionMap" />
</TunnelSample>
<TunnelSample title="Actual / Detail" note="로컬 SVG · 실사화">
<TunnelSample title="Actual / Detail" note="실사화">
<TunnelFacility variant="actualDetail" />
</TunnelSample>
</div>
<TunnelSample title="Separated" note="같은 개체 · Source 작성 부분 간격">
<TunnelSample title="Separated">
<div className="tunnel-reference__row">
<TunnelFacility variant="positionMap" separated />
<TunnelFacility variant="actualDetail" separated />
+1 -1
View File
@@ -61,7 +61,7 @@ type HMDonutDatum = { id: string; label: string; value: number; color?: string }
- 색상을 지정하지 않으면 `--hm-chart-1`~`5`(HM 그린·브라운·웜 뉴트럴 팔레트 별칭)를 순서대로 사용합니다. 다른 색은 `color`에 CSS 색상/토큰을 전달합니다.
- 범례 라벨은 짧게 유지합니다. Bar·Line·Radar·Area의 범례는 한 줄로 두며, 직접 `flex-wrap`으로 줄바꿈시키면 Recharts가 범례 높이를 과대 측정해 차트 아래에 빈 공간이 생길 수 있습니다. Donut·Pie·Stacked Pie는 컴포넌트 내부에서 줄바꿈 범례를 쓰도록 구성되어 있고, Radial 계열은 범례를 차트 박스 밖 아래에 따로 놓습니다.
- 모든 차트의 `label`은 필수 접근성 이름(`role="group"`의 `aria-label`)입니다.
- 차트는 부모 폭을 따라 줄어듭니다(`w-full`, 높이 `h-64` → `sm:h-80`). `className`으로 높이를 바꿀 수 있습니다. 부모에 `min-width: 0`이 없으면 flex·grid 안에서 넘칠 수 있습니다.
- 차트는 부모 폭을 따라 줄어듭니다(`w-full`, 높이 `h-64` → `sm:h-80`; Donut·Pie·Radar는 `h-72` → `sm:h-80`, Radial은 `300px`). `className`으로 높이를 바꿀 수 있습니다. 부모에 `min-width: 0`이 없으면 flex·grid 안에서 넘칠 수 있습니다.
## 4. 렌더링
@@ -6,7 +6,7 @@ import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import http from 'node:http'
import { swStoryEntries, retiredPropertyPanelIds, retiredUnusedDemoIds, approvedAddedStoryIds, headerInventoryIssues } from './storybook-inventory.mjs'
import { swStoryEntries, retiredPropertyPanelIds, headerInventoryIssues, swScopeIssues } from './storybook-inventory.mjs'
import { loadPlaywright } from './playwright-runtime.mjs'
// Property panel comparison consolidation (2026-10-19).
@@ -39,7 +39,7 @@ const geometry = () => page.evaluate(() => {
const r = e => { const b = e.getBoundingClientRect(); return { x: b.x, y: b.y, w: b.width, h: b.height, right: b.right, bottom: b.bottom } }
const scroller = document.querySelector('[data-compare-scroll]')
return {
cells: [...document.querySelectorAll('[data-compare]')].map(c => ({ id: c.dataset.compare, cell: r(c), panel: r(c.querySelector('.hm-sw-property')), caption: c.querySelector('figcaption strong')?.textContent })),
cells: [...document.querySelectorAll('[data-compare]')].map(c => ({ id: c.dataset.compare, cell: r(c), panel: r(c.querySelector('.hm-sw-property')), caption: c.querySelector('figcaption strong')?.textContent ?? c.getAttribute('aria-label') })),
scroller: scroller && { ...r(scroller), scrollW: scroller.scrollWidth, clientW: scroller.clientWidth, scrollH: scroller.scrollHeight, clientH: scroller.clientHeight },
doc: { scrollW: document.documentElement.scrollWidth, clientW: document.documentElement.clientWidth, scrollH: document.documentElement.scrollHeight, clientH: document.documentElement.clientHeight },
}
@@ -70,12 +70,15 @@ try {
// approved additions, the verification tags and the Popup move).
const recorded = JSON.parse(execFileSync('git', ['show', 'c8e5fbeebc26fba92998fc5ddcd28c6c110e452d:docs/sw/property-panel-comparison-qa/verify/result.json'], { encoding: 'utf8', maxBuffer: 1 << 26 })).data.inventory
const now = headerInventoryIssues(before.sw, index)
// 2026-11-13 (user request): the former whole-Storybook count (338 - retired + approved SW additions) failed whenever a
// Story outside SW was added; the SW Story set is checked instead (swScopeIssues: no unapproved SW addition, deletion or ID change).
const swScope = swScopeIssues(before.sw, index)
report.data.inventory.recorded = { total: recorded.total, sw: recorded.sw }
check('inventory: 24 -> 19 SW Stories, 343 -> 338 total, only 5 approved retirements (recorded at c8e5fbe); now only approved later changes', before.sw.length === 24 && recorded.sw === 19 && before.total === 343 && recorded.total === 338 &&
JSON.stringify(recorded.ids.map(([id]) => id).sort()) === JSON.stringify(expected.map(e => e.id).sort()) && retiredPropertyPanelIds.every(id => !index.entries[id]) &&
stories.length === 338 - retiredUnusedDemoIds.length + approvedAddedStoryIds.length && now.unexpected.length === 0 && now.missing.length === 0 && now.retiredPresent.length === 0, { before: [before.total, before.sw.length], recorded: [recorded.total, recorded.sw], after: [stories.length, sw.length], ...now })
swScope.ok && now.unexpected.length === 0 && now.missing.length === 0 && now.retiredPresent.length === 0, { before: [before.total, before.sw.length], recorded: [recorded.total, recorded.sw], after: [stories.length, sw.length], ...now, swScope })
check('inventory: retained SW Story IDs, names, titles, tags unchanged (recorded at c8e5fbe); now only the approved tags and Popup move', JSON.stringify(recorded.ids) === JSON.stringify(expected.map(e => [e.id, e.name])) && now.changed.length === 0, { retained: sw.length, changed: now.changed })
check('inventory: compare Story keeps its ID and name', index.entries['sw-header--property-panel-compare']?.name === 'Property Panel')
check('inventory: compare Story keeps its ID and name', index.entries['sw-header--property-panel-compare']?.name === '속성창')
check('inventory: Dropdown Story keeps ID / name / title', index.entries['sw-header--composed-controls']?.name === 'Dropdown' && index.entries['sw-header--composed-controls']?.title === 'SW/Header')
// ---- Layout at four widths
@@ -93,7 +96,8 @@ try {
// 2026-10-26 (user request): from 1080px, 3 columns — none/all stacked in column 1, circle and expanded beside them.
// 2026-11-05: the Story page Inner keeps 60px on each side (story-page.css), so the steps moved to 787px / 1125px
// (sw-stories.css .sw-story-compare--grid). It keeps 16px up to 480px (growing to 60px at 768px), so 390px has 358px.
const oneColumn = width < 787, threeColumns = width >= 1125
// 2026-11-13: the side margin maximum is 30px (story-page.css), so sw-stories.css moved the steps to 727px / 1065px.
const oneColumn = width < 727, threeColumns = width >= 1065
if (threeColumns) {
const gaps = { stack: b.cell.y - a.cell.bottom, col1: c.cell.x - Math.max(a.cell.right, b.cell.right), col2: d.cell.x - c.cell.right }
report.data[`gaps${width}`] = gaps
@@ -141,21 +145,31 @@ try {
report.data[`drop-${id}`] = info
check(`Dropdown ${id}: opens, has items, fully inside the viewport`, info.items > 0 && info.x >= 0 && info.y >= 0 && info.right <= info.vw + 0.5 && info.bottom <= info.vh + 0.5, info)
await page.screenshot({ path: path.join(out, `dropdown-${id}.png`) })
if (id === 'expanded') {
// Scroll the page while open (2026-10-22: no Inner scroller): the fixed overlay must stay attached to its field.
const moved = await page.evaluate(() => { const t = scrollY; scrollBy(0, 120); return scrollY - t })
await page.waitForTimeout(150)
const after = await page.evaluate(() => {
const d = document.querySelector('.hm-sw-property__drop')?.getBoundingClientRect()
const f = document.querySelector('[data-compare="expanded"] [data-property-id="layer"] [aria-expanded="true"]')?.getBoundingClientRect()
return d && f ? { dTop: d.top, dBottom: d.bottom, fTop: f.top, fBottom: f.bottom } : null
})
report.data.dropScroll = { moved, before: info, after }
check('Dropdown follows the page scroll (stays attached to its field)', moved > 0 && after && (Math.abs(after.dTop - after.fBottom) < 1.5 || Math.abs(after.dBottom - after.fTop) < 1.5), report.data.dropScroll)
}
await page.keyboard.press('Escape')
check(`Dropdown ${id}: Escape closes and returns focus to its field`, await page.locator('.hm-sw-property__drop').count() === 0 && await trigger.evaluate(e => document.activeElement === e || e.contains(document.activeElement)))
}
// Scroll the page while open (2026-10-22: no Inner scroller): the fixed overlay must stay attached to its field.
// 2026-11-13: at 1440 × 900 the page (3-column layout since 2026-10-26) fits the viewport and cannot scroll, and the Story
// preview sets `html { scroll-behavior: smooth }` (src/hm-theme.css), so scrollY read right after scrollBy stayed 0. The
// check runs at 1440 × 600, where the page scrolls, with an instant scroll; the viewport and scroll position are restored.
{
const size = page.viewportSize()
await page.setViewportSize({ width: size.width, height: 600 })
await page.evaluate(() => scrollTo({ top: 0, behavior: 'instant' }))
const { trigger, info } = await openPicker('expanded')
const moved = await page.evaluate(() => { const t = scrollY; scrollBy({ top: 120, behavior: 'instant' }); return scrollY - t })
await page.waitForTimeout(150)
const after = await page.evaluate(() => {
const d = document.querySelector('.hm-sw-property__drop')?.getBoundingClientRect()
const f = document.querySelector('[data-compare="expanded"] [data-property-id="layer"] [aria-expanded="true"]')?.getBoundingClientRect()
return d && f ? { dTop: d.top, dBottom: d.bottom, fTop: f.top, fBottom: f.bottom } : null
})
report.data.dropScroll = { viewport: { width: size.width, height: 600 }, moved, before: info, after }
check('Dropdown follows the page scroll (stays attached to its field)', moved > 0 && after && (Math.abs(after.dTop - after.fBottom) < 1.5 || Math.abs(after.dBottom - after.fTop) < 1.5), report.data.dropScroll)
await page.keyboard.press('Escape')
await trigger.evaluate(() => scrollTo({ top: 0, behavior: 'instant' }))
await page.setViewportSize(size)
}
// Select a layer value: callback updates only that panel.
{
const { drop } = await openPicker('all')
+25 -5
View File
@@ -106,11 +106,16 @@ export const menuCleanupHiddenIds = ['sw-header--interactive-states', 'sw-header
'sw-viewer--compare-with-sw-header',
// 2026-10-22 (user request): SW/Popup keeps only Property Panel in the sidebar.
'sw-popup--custom-content', 'sw-popup--multiple-popups']
export const renamedEntries = { 'sw-header--overview': 'SW Header', 'sw-footer--full-footer': 'SW Footer',
'sw-header--logo-slot': 'Logo', // 2026-10-22 (user request): sidebar "Logo", page title "SW Header — 로고"
'sw-header--property-panel-compare': 'Property Panel', // 2026-10-22 (user request): was "Property Panel · 4개 비교"
'sw-footer--responsive': 'Responsive More', 'sw-viewer--content-variants': 'Settings Popup',
'sw-viewer--responsive': 'Responsive More' }
// 2026-11-13 (user request): the expected names follow the current approved Story names (Korean menu names); IDs unchanged.
export const renamedEntries = { 'sw-header--overview': 'SW Header', 'sw-footer--full-footer': 'SW 명령어바',
'sw-header--logo-slot': '로고', // 2026-10-22 (user request): sidebar "Logo", page title "SW Header — 로고"; now 로고
'sw-header--property-panel-compare': '속성창', // 2026-10-22 (user request): was "Property Panel · 4개 비교", then Property Panel
'sw-header--responsive-more': '화면 너비',
'sw-footer--responsive': '화면 너비', 'sw-viewer--content-variants': '설정창',
'sw-viewer--responsive': '화면 너비' }
// Approved commit 2378663 (2026-11-09, "localize sidebar menus and hide unused stories"): Logo Slot left the default sidebar
// with the `internal` tag (Story ID and direct URL kept). Only that tag is added to the recorded entry.
export const internalTaggedIds = ['sw-header--logo-slot']
// 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]
@@ -120,6 +125,7 @@ export const withApprovedOrder = entries => [...entries.filter(entry => entry.id
// 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 (internalTaggedIds.includes(entry.id)) entry = { ...entry, tags: [...entry.tags, 'internal'] }
if (verificationTaggedIds.includes(entry.id)) return { ...entry, tags: [...entry.tags, 'verification'] }
if (!movedToPopupIds.includes(entry.id)) return entry
const { componentPath, ...rest } = entry
@@ -145,6 +151,20 @@ export const movedHeaderDeclarations = { PropertyPanelCompare: ['src/sw/sw-prope
export const laterRetiredHeaderIds = [...retiredPropertyPanelIds, ...retiredUnusedDemoIds]
// The approved additions that sit under SW/Header.
export const approvedAddedHeaderIds = approvedAddedStoryIds.filter(id => id.startsWith('sw-header--'))
// User-approved SW/Step Header Stories (adbb034, 2026-11-03; navigation labels b67a3ec), a SW group added after the lists above.
export const stepHeaderStoryIds = ['process-header', 'responsive', 'split-open', 'multi-step', 'fixed-widths', 'process-bar',
'multi-step-many-groups', 'multi-step-many-steps', 'states', 'theme-override'].map(id => `sw-step-header--${id}`)
// 2026-11-13 (user request): the SW Story set, separate from the whole Storybook count. Every Story whose title starts with
// "SW/" must be either a retained SW/Header Story of the earlier list (approved changes applied) or an approved addition, and
// every one of those must exist: `unexpected` catches an unapproved SW addition or an ID change, `missing` a deletion or an
// ID change. Stories outside SW may be added or removed without affecting it.
export const swScopeIssues = (entries, index) => {
const retained = retainedInventory(entries).filter(entry => !laterRetiredHeaderIds.includes(entry.id)).map(withApprovedEntryChanges).map(entry => entry.id)
const approved = [...new Set([...retained, ...approvedAddedStoryIds, ...approvedAddedHeaderIds, ...stepHeaderStoryIds])]
const current = Object.values(index.entries).filter(entry => entry.type === 'story' && entry.title.startsWith('SW/')).map(entry => entry.id)
const issues = { actual: current.length, expected: approved.length, unexpected: current.filter(id => !approved.includes(id)), missing: approved.filter(id => !current.includes(id)) }
return { ...issues, ok: issues.actual === issues.expected && issues.unexpected.length === 0 && issues.missing.length === 0 }
}
// An earlier SW/Header entry list against today's index: `changed` (ID, name, title or tags differ from the earlier entry
// with the approved changes applied), `unexpected` (SW/Header Stories neither retained nor approved), `missing` (approved
// additions not under SW/Header) and `retiredPresent` (an approved retirement back in the index).
@@ -44,7 +44,7 @@ const keys = Object.keys(afterIndex.entries), firstSW = keys.findIndex(id => id.
const swOrder = { previous: keys[firstSW - 1], afterCenterGraphic: afterIndex.entries[keys[firstSW - 1]]?.title.startsWith('Center Graphic/') && !keys.slice(firstSW + 1).some(id => afterIndex.entries[id].title.startsWith('Center Graphic/')), contiguous: keys.filter(id => id.startsWith('sw-')).every((id, i) => keys[firstSW + i] === id) }
check('inventory: every non-removed index entry identical (docs and other packages included) except the approved SW changes', outsideSW(others(beforeIndex)) === outsideSW(others(afterIndex)) && JSON.stringify(others(beforeIndex).filter(e => e.id.startsWith('sw-')).map(withApprovedEntryChanges)) === JSON.stringify(others(beforeIndex).filter(e => e.id.startsWith('sw-')).map(e => afterIndex.entries[e.id])) && swOrder.afterCenterGraphic && swOrder.contiguous, swOrder)
check('inventory: Dropdown keeps sw-header--composed-controls / Dropdown / SW/Header', afterIndex.entries['sw-header--composed-controls']?.name === 'Dropdown' && afterIndex.entries['sw-header--composed-controls']?.title === 'SW/Header')
check('inventory: comparison keeps its ID and name', afterIndex.entries['sw-header--property-panel-compare']?.name === 'Property Panel')
check('inventory: comparison keeps its ID and name', afterIndex.entries['sw-header--property-panel-compare']?.name === '속성창')
// ---- Source: retained Story declarations byte-identical; product, AI template and other packages untouched.
const sourcePath = 'src/sw/sw-header.stories.tsx'
@@ -89,8 +89,12 @@ let reference, viewerMenu, moreMenu, refTrigger
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)
// 2026-11-13: 2500px wide (like viewer.browser.mjs), so the 1920 row, shown only on a page of 1980px or more, is visible too.
const responsive = await newPage(2500, 1500)
await open(responsive, 'sw-viewer--responsive')
// 2026-11-13: since 2026-10-26 More shows only once even the narrowest tabs do not fit (below about 520px), so the 520 row
// is narrowed in place to 400px first (as viewer.browser.mjs does) to open the same More menu.
await responsive.evaluate(async () => { document.querySelector('[data-width="520"]').style.maxWidth = '400px'; await new Promise(resolve => setTimeout(resolve, 400)) })
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')
@@ -129,12 +133,17 @@ let reference, viewerMenu, moreMenu, refTrigger
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 }
beforeName: wrapper.nextElementSibling?.classList.contains('hm-sw-viewer-header__document'), label: button.querySelector('.hm-sw-viewer-header__open-label')?.textContent,
// 2026-11-13: the folder icon carries the viewer icon outline (SWViewerIcon, caf4c32 2026-10-21): one icon (glyph) SVG and
// one outline copy behind it, nothing else.
icons: button.querySelectorAll('.hm-sw-viewer-header__open-icon svg').length, glyphs: button.querySelectorAll('.hm-sw-viewer-header__open-icon .hm-sw-viewer-icon__glyph svg').length,
outlines: button.querySelectorAll('.hm-sw-viewer-header__open-icon .hm-sw-viewer-icon__outline 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)
check('file open button after the logo: SWHeader document surface, icon (1 glyph + 1 outline SVG) + 파일 열기', layout.surface && layout.afterLogo && layout.beforeName && layout.label === '파일 열기' && layout.glyphs === 1 && layout.outlines === 1 && layout.icons === 2, layout)
const iconBox = await openButton.locator('.hm-sw-viewer-header__open-icon').evaluate(element => { const s = getComputedStyle(element), r = element.getBoundingClientRect(), b = element.closest('[data-document-open]').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)], button: [Math.round(b.width), Math.round(b.height)] } })
// 2026-10-21 user request: the icon has no border and no face (transparent). 2026-11-13: the icon area is the 16 × 16 icon
// itself with no margin (0840d76, DESIGN_RULES --hm-sw-viewer-icon-size); the click area is the button, at least 34px each way.
check('file open icon: no border, transparent, no shadow; 16 × 16 icon, button click area ≥ 34px', iconBox.style.startsWith('0px none') && iconBox.style.includes('rgba(0, 0, 0, 0)') && iconBox.style.endsWith('none') && iconBox.size.join() === '16,16' && iconBox.button.every(side => side >= 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)] })
@@ -179,18 +188,21 @@ let reference, viewerMenu, moreMenu, refTrigger
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 }
const button = element.querySelector('[data-document-open]'), icon = button.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect(), area = button.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)], button: [Math.round(area.width), Math.round(area.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 })
// 2026-11-13: at every width the icon stays the 16 × 16 icon area (0840d76) and the button keeps a click area of at least 34px.
check('1440: icon + 파일 열기; narrower: icon only (tooltip / name 파일 열기); 16 × 16 icon and ≥ 34px click area at every width', sweep[0].label && firstIcon
&& sweep.filter(item => !item.label).every(item => item.aria === '파일 열기' && item.title === '파일 열기')
&& [...sweep, ...back].every(item => item.icon.join() === '16,16' && item.button.every(side => side >= 34)), { first: sweep[0], firstIcon, icons: [...new Set([...sweep, ...back].map(item => item.icon.join()))], minButton: [Math.min(...sweep.map(item => item.button[0])), Math.min(...sweep.map(item => item.button[1]))] })
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' })
// 2026-11-13: More shows only below about 520px since 2026-10-26 (viewer.browser.mjs), so the header is set to 400px.
await header.evaluate(element => { element.style.width = '400px' })
await page.waitForTimeout(60)
const more = header.getByRole('button', { name: '추가 도구' })
await more.click()
@@ -223,7 +235,8 @@ let reference, viewerMenu, moreMenu, refTrigger
// ---------- 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)
// 2026-11-13: 2500px wide so the 1920 row (shown only on a page of 1980px or more) is measured with the others.
const page = await newPage(2500, 1500)
await open(page, 'sw-viewer--responsive')
const headers = page.locator('[data-width] .hm-sw-viewer-header')
const report = []
@@ -251,12 +264,21 @@ let reference, viewerMenu, moreMenu, refTrigger
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.
// 2026-11-13: since 2026-10-26 (user request; viewer.browser.mjs) the file name and the Model / Layout tabs give way first,
// so 860 / 760 / 520 keep every item as icons without More; More shows only below about 520px (the 520 row narrowed to 400).
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('860 / 760: icons only (tooltips kept), no 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 (tooltips kept), no More', !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.
// Every item is either in the header or in More, never both and never lost: checked on the 520 row narrowed to 400px.
const narrowed = await page.evaluate(async () => {
const wrapper = document.querySelector('[data-width="520"]'); wrapper.style.maxWidth = '400px'
await new Promise(resolve => setTimeout(resolve, 400))
const header = wrapper.querySelector('.hm-sw-viewer-header')
return { width: Math.round(header.getBoundingClientRect().width), overflow: header.scrollWidth - header.clientWidth, more: !!header.querySelector('.hm-sw-viewer-header__more') }
})
check('520 row narrowed to 400: More shown, no overflow', narrowed.more && narrowed.overflow <= 0, narrowed)
const more = headers.nth(6).getByRole('button', { name: '추가 도구' })
await more.click()
const moreMenu = page.getByRole('menu', { name: '추가 도구' })
@@ -264,8 +286,9 @@ let reference, viewerMenu, moreMenu, refTrigger
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 })
check('520 row at 400: 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')
await page.evaluate(async () => { document.querySelector('[data-width="520"]').style.maxWidth = ''; await new Promise(resolve => setTimeout(resolve, 400)) })
// Icon toggle at 520 still reports pressed / commands.
const icon = headers.nth(3).getByRole('button', { name: '레이어', exact: true })
await icon.click()
@@ -289,14 +312,15 @@ let reference, viewerMenu, moreMenu, refTrigger
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)
// 2026-11-13: Viewer Header hover (caf4c32, 2026-10-21 user request): no face (transparent), text / icon #FFC600.
const hover = await toggle.evaluate(element => `${getComputedStyle(element).backgroundColor} / ${getComputedStyle(element).color}`)
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)
check(`narrow header (${width}px): icon toggles with tooltip, hover (no face, #FFC600), pressed #FFC600, panel open / close`, width < 900 && panels.every(item => item.iconOnly && item.title && item.hover === 'rgba(0, 0, 0, 0) / rgb(255, 198, 0)' && 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()
}
@@ -353,6 +377,11 @@ let reference, viewerMenu, moreMenu, refTrigger
// 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.
// 2026-11-13 (current approved rules): the panel toggles turn into icons before 전체 보기 (2026-10-23, user-approved) and a
// button that became an icon stays one when narrower; below 560px the file name gives way to 56px (2026-10-26), so items
// may come back from More into the header — instead of a falling item count, every width that shows More opens it and every
// command must be in the header or in More exactly once. The steps use a header-width basis, not the file name width
// (caf4c32, 2026-10-21), so a shorter file name keeps the same boundaries.
const page = await newPage(1920, 900)
await open(page, 'sw-viewer--viewer-header')
const header = page.locator('.hm-sw-viewer-header').first()
@@ -365,8 +394,32 @@ let reference, viewerMenu, moreMenu, refTrigger
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) }
const nameOf = button => button.getAttribute('aria-label') ?? button.textContent.trim()
const iconNames = icons.filter(button => !button.querySelector('.hm-sw-viewer-header__label')).map(nameOf)
const labelNames = icons.filter(button => button.getClientRects().length && button.querySelector('.hm-sw-viewer-header__label')).map(nameOf)
return { width, shown, iconOnly, anyIcon, iconNames, labelNames, more, stage: more ? 2 : iconOnly ? 1 : 0, overflow: Math.max(element.scrollWidth - element.clientWidth, object.scrollWidth - object.clientWidth - 1) }
}, width)
// Commands shown in the header (visible only; a switch is named by its full label) and in the opened More menu.
const COMMANDS = ['Model', 'Layout', '전체 보기', '2.5D', '어두운 배경', '레이어', '설정', '속성']
const PANEL_TOGGLES = ['레이어', '설정', '속성']
const commandsAt = async width => {
const inHeader = await header.evaluate(async (element, width) => {
element.style.width = `${width}px`
await new Promise(resolve => requestAnimationFrame(() => requestAnimationFrame(resolve)))
return [...element.querySelectorAll('.hm-sw-viewer-header__object :is(button:not(.hm-sw-viewer-header__more), [role=radio], [role=switch])')]
.filter(item => item.getClientRects().length)
.map(item => item.getAttribute('role') === 'switch' ? element.querySelector(`label[for="${item.id}"] .hm-sw-viewer-header__switch-label-full`)?.textContent.trim()
: (item.getAttribute('aria-label') ?? item.textContent.trim()).replace(/[\d,]+$/, ''))
}, width)
await header.getByRole('button', { name: '추가 도구' }).click()
const menu = page.getByRole('menu', { name: '추가 도구' })
await menu.waitFor({ timeout: 2000 })
const inMore = (await menu.locator('[role^=menuitem]').allInnerTexts()).map(text => text.split(' · ')[0].trim())
const menuBox = await menu.evaluate(element => { const r = element.getBoundingClientRect(); return { left: r.left, top: r.top, right: r.right, bottom: r.bottom, vw: innerWidth, vh: innerHeight } })
await page.keyboard.press('Escape')
const closed = (await page.getByRole('menu', { name: '추가 도구' }).count()) === 0
return { width, inHeader: inHeader.filter(name => COMMANDS.includes(name)), inMore, menuBox, closed, pageOverflow: await page.evaluate(() => document.documentElement.scrollWidth - document.documentElement.clientWidth) }
}
const sweep = async () => {
const down = [], up = []
for (let width = 1440; width >= 400; width -= 10) down.push(await state(width))
@@ -376,17 +429,33 @@ let reference, viewerMenu, moreMenu, refTrigger
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: icons always before More; panel toggles turn into icons before 전체 보기', [...long.down, ...long.up].every(item => (!item.more || item.iconOnly)
&& (!item.iconNames.includes('전체 보기') || PANEL_TOGGLES.every(name => item.iconNames.includes(name)))),
[...long.down, ...long.up].filter(item => (item.more && !item.iconOnly) || (item.iconNames.includes('전체 보기') && !PANEL_TOGGLES.every(name => item.iconNames.includes(name)))).map(item => [item.width, item.iconNames]))
// A button that became an icon never shows its label again at a narrower width (it may move on into More).
check('sweep: a button that became an icon never gets its label back when narrower', long.down.every((item, i) => i === 0 || !long.down[i - 1].iconNames.some(name => item.labelNames.includes(name))),
long.down.filter((item, i) => i && long.down[i - 1].iconNames.some(name => item.labelNames.includes(name))).map(item => [item.width, item.labelNames]))
// Every width (10px steps) that shows More: More opened for real; each command exactly once in the header or in More, the panel
// toggles never in More, the menu inside the viewport and closed by Escape, no page overflow. Items moving back into the header
// when the file name gives way (below 560px) are allowed.
const moreWidths = long.down.filter(item => item.more).map(item => item.width)
const commands = []
for (const width of moreWidths) commands.push(await commandsAt(width))
const commandIssues = commands.filter(item => !COMMANDS.every(name => item.inHeader.filter(other => other === name).length + item.inMore.filter(other => other === name).length === 1)
|| item.inMore.some(name => PANEL_TOGGLES.includes(name)) || item.inHeader.length + item.inMore.length !== COMMANDS.length
|| !(item.menuBox.left >= 0 && item.menuBox.top >= 0 && item.menuBox.right <= item.menuBox.vw + 0.5 && item.menuBox.bottom <= item.menuBox.vh + 0.5) || !item.closed || item.pageOverflow > 0)
check(`sweep: every command exactly once in the header or More at each More width (${moreWidths.length} widths), menu inside the viewport, Escape closes`, moreWidths.length > 0 && commandIssues.length === 0,
commandIssues.length ? commandIssues.slice(0, 3) : { widths: [moreWidths.at(0), moreWidths.at(-1)], sample: commands.at(-1) })
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.
// A short file name: the steps use the header-width basis of the file name area (caf4c32, 2026-10-21: short and long names
// give the same result), so every boundary stays where it is with a long name.
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 })
check('sweep: file name length does not move the steps (same boundaries for a short name)', a.icons !== undefined && a.more !== undefined && b.icons === a.icons && b.more === a.more, { long: a, short: b })
await page.close()
}