fix(sw): 2026-10-21 SW design review and responsive header without More from 760px

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.
This commit is contained in:
shkim4 committed 2026-10-21 17:29:25 +09:00
1 parent a25d2b31c5
commit 2fc1b3e108
37 files changed
+447 -211

No files matched your search

@@ -227,8 +227,9 @@ try {
await page.screenshot({ path: path.join(out, 'composed-property-panel.png'), fullPage: true })
const radius = page.getByRole('dialog').getByRole('textbox', { name: '반지름', exact: true })
await radius.fill('12.5'); await radius.press('Enter')
await page.waitForFunction(() => document.querySelector('.hm-sw-property-bar [data-property-id="radius"] input')?.value === '12.5')
const barRadius = await page.getByRole('region', { name: '속성Bar', exact: true }).getByRole('textbox', { name: '반지름', exact: true }).inputValue()
// 2026-10-21 (user decision): the Bar shows the value as read-only text (<output>).
await page.waitForFunction(() => document.querySelector('.hm-sw-property-bar [data-property-id="radius"] output')?.textContent === '12.5')
const barRadius = await page.getByRole('region', { name: '속성Bar', exact: true }).locator('[data-property-id="radius"] output').textContent()
check('property Bar and panel share edited state', Number(barRadius) === 12.5, barRadius)
const panelWeight = page.getByRole('dialog').getByRole('textbox', { name: '선가중치', exact: true })
await panelWeight.fill('0.75'); await panelWeight.press('Enter')
@@ -147,7 +147,10 @@ const viewerSel = page.locator('.hm-sw-viewer-panel [data-sw-select="unit"]')
const vs = await selStates(viewerSel)
const viewerOptions = await viewerSel.evaluate(e => [...e.options].map(o => o.value))
await viewerSel.selectOption('m'); await page.waitForTimeout(80)
check('Select: Popup 도면 설정 단위 = Viewer 축척 지정 단위 (default · hover · focus computed look)', ps.d === vs.d && ps.h === vs.h && ps.f === vs.f && ps.d.includes('class=hm-sw-select'), { ps, vs })
// 2026-10-21 (user decision): the chosen value of an editable Popup select is red (#E60403) like every editable value;
// the Viewer panel keeps its own text color. Everything else of the look stays the same.
const noColor = look => look.replace(/(^|;)color=[^;]*/, '$1')
check('Select: Popup 도면 설정 단위 = Viewer 축척 지정 단위 (default · hover · focus computed look, text color apart); Popup value red', noColor(ps.d) === noColor(vs.d) && noColor(ps.h) === noColor(vs.h) && noColor(ps.f) === noColor(vs.f) && ps.d.includes('class=hm-sw-select') && ps.d.includes('color=rgb(230, 4, 3)'), { ps, vs })
check('Select: options and selection kept (Popup mm/cm/m, Viewer mm/m), change events', JSON.stringify(popupOptions) === '["mm","cm","m"]' && JSON.stringify(viewerOptions) === '["mm","m"]' && popupPicked[0] && popupPicked[1] === 'm' && await viewerSel.inputValue() === 'm', { popupOptions, viewerOptions, popupPicked })
await page.locator('.hm-sw-viewer-panel', { hasText: '축척 지정' }).screenshot({ path: path.join(out, 'viewer-scale-select.png') })
@@ -160,8 +163,10 @@ const flips = grow.slice(1).filter((s, i) => s[1] !== grow[i][1]).length
check('Header: Open returns once while growing 520 → 1080 and stays', grow[0][1] === 0 && grow.at(-1)[1] === 1 && flips === 1, grow)
const wide = await walk([1440, 1920])
check('Header: at 1920 every part is back and Open is shown', wide.at(-1)[1] === 1 && wide.at(-1)[2] === '', wide)
const shrink = await walk([1800, 1440, 1080, 900, 640, 520])
check('Header: shrinking, Open moves into More first (1800) and stays there', shrink[0][1] === 0 && shrink.every(s => s[1] === 0), shrink)
// 2026-10-21 (user decision): from 760px up nothing moves into More (the fields shrink instead), so Open stays shown
// down to 760; below it the measured More takes Open as before.
const shrink = await walk([1800, 1440, 1080, 900, 760, 640, 520])
check('Header: shrinking, Open stays next to the logo down to 760 (no More), then moves into More and stays there', shrink.every(s => s[0] < 760 || (s[1] === 1 && s[2] === '')) && shrink.at(-1)[1] === 0 && shrink.filter(s => s[0] < 760).every((s, i, a) => i === 0 || s[1] <= a[i - 1][1]), shrink)
const regrow = await walk([560, 600, 640, 680, 720, 900, 1080, 1920])
const reflips = regrow.slice(1).filter((s, i) => s[1] !== regrow[i][1]).length
check('Header: regrowing after a shrink, Open returns once and stays', regrow.at(-1)[1] === 1 && reflips <= 1 && regrow.at(-1)[2] === '', regrow)
@@ -82,6 +82,8 @@ if (sections.includes('options')) {
headerRight: header.getBoundingClientRect().right, barRight: Math.max(...parts.map(r => r.right)), overlap,
open: open ? open.getBoundingClientRect().width : 0, doc: doc ? doc.getBoundingClientRect().width : 0, layer: layer ? layer.getBoundingClientRect().width : 0,
more: !!header.querySelector('.hm-sw-header__more') && getComputedStyle(header.querySelector('.hm-sw-header__more')).display !== 'none',
menus: [...header.querySelectorAll('.hm-sw-header__fields .hm-sw-header__field[data-menu]')].filter(e => e.getClientRects().length && getComputedStyle(e).display !== 'none').map(e => e.dataset.menu),
titles: [...header.querySelectorAll('.hm-sw-header__fields .hm-sw-header__field[data-menu]')].filter(e => e.getClientRects().length && getComputedStyle(e).display !== 'none' && [...e.querySelectorAll('.hm-sw-header__field-label')].some(l => l.getClientRects().length && getComputedStyle(l).display !== 'none')).length,
}
})
let inMore = null
@@ -94,7 +96,11 @@ if (sections.includes('options')) {
widths.push({ width, ...state, inMore })
check(`responsive ${width}: 49px, no overflow or overlap, nothing past the edge`, state.height === 49 && state.barOverflow <= 1 && state.areaOverflow <= 1 && !state.overlap && state.barRight <= state.headerRight + 0.5, state)
check(`responsive ${width}: Open ${state.open ? 'shown next to the logo' : 'moved into More and works there'}`, state.open > 0 || inMore === true, { open: state.open, inMore })
if (width >= 520) check(`responsive ${width}: layer field keeps its Figma width (234) while Open is shown`, !state.open || Math.abs(state.layer - 234) < 0.6 || state.layer === 0, state.layer)
// 2026-10-21 (user decision): from 760px up nothing moves into More; the fields shrink beside Open (fluid widths,
// titles and values ellipsize). The Figma layer width 234 holds where there is room (1920); narrower it is fluid.
if (width >= 760) check(`responsive ${width}: Open shown, no More, the five menus stay in the header with their titles`, state.open > 0 && !state.more && JSON.stringify(state.menus) === '["layer","color","line-style","text-style","dimension-style"]' && state.titles === 5, state)
if (width >= 1920) check(`responsive ${width}: layer field keeps its Figma width (234)`, Math.abs(state.layer - 234) < 0.6, state.layer)
else if (width >= 760) check(`responsive ${width}: layer field shrinks fluidly (0 < width <= 234)`, state.layer > 0 && state.layer <= 234.5, state.layer)
}
report.data.responsive = widths
await page.locator('[data-responsive-open]').screenshot({ path: path.join(out, 'open-placement-responsive.png') })
@@ -149,6 +155,7 @@ if (sections.includes('options') || sections.includes('composed')) {
})
rows.push({ width, ...state })
check(`composed responsive ${width}: 49px, no overflow, nothing past the edge`, state.height === 49 && state.barOverflow <= 1 && state.areaOverflow <= 1 && state.right <= 0.5, state)
if (width >= 760) check(`composed responsive ${width}: 파일 열기 shown, no More (from 760px)`, state.open > 0 && !state.more, state)
if (!state.open) {
await h.locator('.hm-sw-header__more-trigger').click()
const entry = h.locator('.hm-sw-header__more-item', { hasText: '파일 열기' })
@@ -74,10 +74,13 @@ export async function popupChecks({ page, goto, check, log, rect, out, report })
await page.locator('[data-stack]').screenshot({ path: path.join(out, 'popup-short-dropdown.png') })
await page.keyboard.press('Escape')
check('several popups: Escape in a dropdown closes only the dropdown', await bMenu.count() === 0 && await popup('a').count() === 1 && await b.count() === 1 && await popup('c').count() === 1)
const aInput = popup('a').getByLabel('A 값')
await aInput.click(); await aInput.fill('A2'); await aInput.press('Escape')
check('several popups: Escape on an edited input cancels the edit only', await aInput.inputValue() === 'A' && await popup('a').count() === 1)
await aInput.press('Escape')
// 2026-10-21 (user decision): Popup A's value is read-only plain text (no input, no red, no focus ring). The Story has
// no editable input any more (A's was the only one): Escape on an edited input is covered by property-fields /
// overview; here Escape inside A is pressed on its close button.
const aValue = await popup('a').locator('[data-readonly-value="a"]').evaluate(e => ({ tag: e.tagName, tab: e.tabIndex, inputs: e.closest('[data-popup]').querySelectorAll('.hm-sw-property__body input').length, color: getComputedStyle(e).color }))
check('several popups: Popup A shows its read-only value as plain text (not focusable, not red)', aValue.tag === 'SPAN' && aValue.tab === -1 && aValue.inputs === 0 && aValue.color !== 'rgb(255, 0, 0)', aValue)
await popup('a').locator('button').first().focus()
await page.keyboard.press('Escape')
check('several popups: Escape inside a popup closes that popup only', (await log()).endsWith('close(a)') && await b.count() === 1 && await popup('c').count() === 1, await log())
await page.locator('body').click({ position: { x: 5, y: 5 } })
await page.keyboard.press('Escape')
@@ -101,6 +101,11 @@ try {
await pointerdown('body')
check('Document title: outside pointerdown closes the list, focus kept', documentOpen && await documentButton.getAttribute('aria-expanded') === 'false' && await documentButton.evaluate(e => e === document.activeElement), { documentOpen })
await story('sw-header--responsive-more')
// 2026-10-21 (user decision): the Story widths are 1920 … 760 and from 760px up the header shows no More. More stays
// the narrow-header behavior (< 760), so one frame is narrowed to 640px to dismiss it.
check('More: not shown at the Story widths (760px and up)', await page.locator('.hm-sw-header__more-trigger:visible').count() === 0)
await page.locator('.sw-story-frame').first().evaluate(e => { e.style.width = '640px' })
await page.waitForTimeout(250)
const more = page.locator('.hm-sw-header__more-trigger:visible').first()
await more.click()
const moreOpen = await more.getAttribute('aria-expanded') === 'true'
+12 -11
View File
@@ -132,7 +132,9 @@ try {
if(await b.isVisible())await b.click()
else{await header.getByRole('button',{name:'더보기',exact:true}).click();await header.getByRole('menuitemcheckbox',{name:'속성창',exact:true}).click()}
}
const barInput=id=>page.locator(`.hm-sw-property-bar [data-property-id="${id}"] input`)
// 2026-10-21 (user decision): the Bar values (반지름 / 지름 / 원주 / 면적) are read-only red text (<output>, edited in the panel), never focused.
const barValue=id=>page.locator(`.hm-sw-property-bar [data-property-id="${id}"] output`)
const waitText=(id,value)=>page.waitForFunction(([id,value])=>document.querySelector(`.hm-sw-property-bar [data-property-id="${id}"] output`)?.textContent===value,[id,value])
const panelInput=id=>page.locator(`.hm-sw-property [data-property-id="${id}"] input`).first()
const waitValue=async(locator,value)=>page.waitForFunction(([element,value])=>element.value===value,[await locator.elementHandle(),value])
check('one Header and no duplicate fixed Figma specimen',await header.count()===1&&await page.getByText('Figma 기준 1920 × 49',{exact:true}).count()===0)
@@ -161,17 +163,16 @@ try {
await page.keyboard.press('Escape');check('Escape closes Header dropdown',await header.locator('.hm-sw-header__layer-panel').count()===0)
await open('layer','레이어');await page.locator('.sb-page-title').click();check('outside click closes Header dropdown',await header.locator('.hm-sw-header__layer-panel').count()===0)
await barInput('radius').fill('6.25');await barInput('radius').press('Enter')
check('Bar Enter commits state',await barInput('radius').inputValue()==='6.25'&&(await callback()).includes('onPropertyChange(radius, 6.25)'))
await barInput('radius').fill('-1');await barInput('radius').press('Enter');check('Bar validates min=0',await barInput('radius').inputValue()==='6.25')
await barInput('radius').fill('9');await barInput('radius').press('Escape');check('Bar Escape cancels edit',await barInput('radius').inputValue()==='6.25')
const barState=await page.locator('.hm-sw-property-bar').evaluate(bar=>({object:bar.querySelector('.hm-sw-property-bar__object').textContent,order:[...bar.querySelectorAll('[data-property-id]')].map(e=>e.dataset.propertyId),inputs:bar.querySelectorAll('input').length,focusable:[...bar.querySelectorAll('output')].filter(e=>e.tabIndex>=0).length,colors:[...new Set([...bar.querySelectorAll('output')].map(e=>getComputedStyle(e).color))]}))
check('Bar: object text 원, order 반지름 / 지름 / 원주 / 면적, read-only red text (edited in the panel) without input or focus stop',barState.object==='원'&&barState.order.join()==='radius,diameter,circumference,area'&&barState.inputs===0&&barState.focusable===0&&barState.colors.join()==='rgb(230, 4, 3)',barState)
await barValue('radius').click();check('Bar value click never focuses the value',await page.evaluate(()=>document.activeElement?.tagName!=='OUTPUT'&&document.activeElement?.tagName!=='INPUT'))
await panelButton();await page.locator('.hm-sw-property').waitFor()
check('Header opens panel with same Bar value',await panelInput('radius').inputValue()==='6.25')
check('Header opens panel with same Bar value',await panelInput('radius').inputValue()===await barValue('radius').textContent())
check('numeric weight property remains numeric after ByLayer mode',await panelInput('weight').inputValue()==='0.25')
check('Header layer value shared with panel',(await page.locator('.hm-sw-property [data-property-id="layer"]').textContent()).includes('CC-D GNL'))
await panelInput('radius').fill('7.5');await panelInput('radius').press('Enter')
await waitValue(barInput('radius'),'7.5')
check('panel edit updates the Bar',await barInput('radius').inputValue()==='7.5')
await waitText('radius','7.5')
check('panel edit updates the Bar',await barValue('radius').textContent()==='7.5'&&(await callback()).includes('onPropertyChange(radius, 7.5)'))
await panelInput('radius').fill('-2');await panelInput('radius').press('Enter');check('panel number validation rejects negative radius',await panelInput('radius').inputValue()==='7.5')
await page.locator('.hm-sw-property [data-property-id="layer"] button').click()
const drop=page.locator('.hm-sw-property__drop')
@@ -195,10 +196,10 @@ try {
await panelInput('radius').press('Escape');check('next Escape closes panel and restores Header focus',await page.locator('.hm-sw-property').count()===0&&await header.locator('[data-property-panel-trigger]').evaluate(e=>document.activeElement===e))
await panelButton();await page.getByRole('button',{name:'속성창 닫기',exact:true}).click();check('panel Close button restores focus',await header.locator('[data-property-panel-trigger]').evaluate(e=>document.activeElement===e))
await page.getByText('시연 데이터',{exact:true}).click()
await page.getByRole('button',{name:'외부 값 12.5',exact:true}).click();await waitValue(barInput('radius'),'12.5');check('external Props update Bar',await barInput('radius').inputValue()==='12.5')
await page.getByRole('checkbox',{name:'속성 ReadOnly',exact:true}).check();check('Bar ReadOnly',await barInput('radius').evaluate(e=>e.readOnly))
await page.getByRole('button',{name:'외부 값 12.5',exact:true}).click();await waitText('radius','12.5');check('external Props update Bar',await barValue('radius').textContent()==='12.5')
await page.getByRole('checkbox',{name:'속성 ReadOnly',exact:true}).check();check('Bar ReadOnly: text, no input',await barValue('radius').count()===1&&await page.locator('.hm-sw-property-bar input').count()===0)
await page.getByRole('checkbox',{name:'속성 ReadOnly',exact:true}).uncheck()
await page.getByRole('checkbox',{name:'속성 Disabled',exact:true}).check();check('Bar Disabled',await barInput('radius').isDisabled())
await page.getByRole('checkbox',{name:'속성 Disabled',exact:true}).check();check('Bar Disabled: dimmed text',await barValue('radius').getAttribute('data-disabled')==='true')
await page.getByRole('checkbox',{name:'속성 Disabled',exact:true}).uncheck()
await page.getByRole('button',{name:'필드 추가',exact:true}).click();check('dynamic Bar field added',await page.locator('.hm-sw-property-bar [data-property-id^="preview-extra-"]').count()===1)
await panelButton();check('dynamic field shared with panel',await page.locator('.hm-sw-property [data-property-id^="preview-extra-"]').count()===1)
@@ -127,7 +127,13 @@ try {
check('Hover leave resets live specimen', await motion.evaluate(e => e.getAnimations({ subtree: true }).length === 0))
await goto('responsive-more')
const header = page.locator('[data-frame-width="1440"] header.hm-sw-header').first()
// 2026-10-21 (user decision): no More from 760px up (Story widths 1920 … 760); More stays the narrow-header behavior,
// so the 1440 frame is narrowed to 640px to switch the representative field there.
const frame1440 = page.locator('[data-frame-width="1440"]').first()
check('Responsive More: no More at 1440 (760px and up)', await frame1440.locator('.hm-sw-header__more-trigger:visible').count() === 0)
await frame1440.evaluate(e => { e.style.width = '640px' })
await page.waitForTimeout(250)
const header = frame1440.locator('header.hm-sw-header').first()
await header.getByRole('button', { name: '더보기', exact: true }).click()
await header.locator('[role="menuitem"][data-menu="line-style"]').click()
check('Responsive More still switches the representative field and opens its dropdown', await header.locator('.hm-sw-header__line-menu').count() > 0 && await header.locator('.hm-sw-header__line-menu').isVisible())
@@ -140,14 +140,18 @@ try {
const pickBox = await pick.boundingBox()
await pick.click()
check('Overview: btn_colorize 24 × 24 calls onColorPick and closes (former Colorize page)', pickBox.width === 24 && pickBox.height === 24 && (await callback()).includes('onColorPick') && await header.locator('.hm-sw-header__color-menu').count() === 0, pickBox)
const bar = page.locator('.hm-sw-property-bar [data-property-id="radius"] input')
await bar.fill('6.25'); await bar.press('Enter')
check('Overview: property Bar Enter commits through callback', await bar.inputValue() === '6.25' && (await callback()).includes('onPropertyChange(radius, 6.25)'))
await bar.fill('-1'); await bar.press('Enter')
check('Overview: property Bar validation keeps min=0', await bar.inputValue() === '6.25')
// 2026-10-21 (user decision): the Bar values are read-only red text (<output>), never focused; the panel edits them.
const bar = page.locator('.hm-sw-property-bar [data-property-id="radius"] output')
check('Overview: property Bar value is read-only text (no input, no focus stop)', await bar.count() === 1 && await page.locator('.hm-sw-property-bar input').count() === 0 && await bar.evaluate(e => e.tabIndex < 0))
const trigger = header.locator('[data-property-panel-trigger]')
await trigger.click(); await page.locator('.hm-sw-property').waitFor()
check('Overview: panel opens with the shared Bar value', await page.locator('.hm-sw-property [data-property-id="radius"] input').first().inputValue() === '6.25')
const panelRadius = page.locator('.hm-sw-property [data-property-id="radius"] input').first()
check('Overview: panel opens with the shared Bar value', await panelRadius.inputValue() === await bar.textContent())
await panelRadius.fill('6.25'); await panelRadius.press('Enter')
await page.waitForFunction(() => document.querySelector('.hm-sw-property-bar [data-property-id="radius"] output')?.textContent === '6.25')
check('Overview: panel edit commits through callback and updates the Bar', await bar.textContent() === '6.25' && (await callback()).includes('onPropertyChange(radius, 6.25)'))
await panelRadius.fill('-1'); await panelRadius.press('Enter')
check('Overview: panel validation keeps min=0', await panelRadius.inputValue() === '6.25' && await bar.textContent() === '6.25')
await page.keyboard.press('Escape'); await page.keyboard.press('Escape')
check('Overview: Escape closes panel and returns focus to its trigger', await page.locator('.hm-sw-property').count() === 0 && await trigger.evaluate(e => e === document.activeElement))
}
+8 -4
View File
@@ -188,17 +188,21 @@ const recentTile = page.locator('.hm-sw-home-menu > .hm-sw-home-menu__sections .
if (await recentTile.getAttribute('data-active') !== 'true') { await recentTile.click(); await page.waitForTimeout(300) }
const rNames = await styles('.hm-sw-recent-files__name'), rPaths = await styles('.hm-sw-recent-files__path')
report.recent = { names: rNames.slice(0, 3), paths: rPaths.slice(0, 3) }
check(`Recent files: names (${rNames.length}) 14px 700 + 1.3px outline`, rNames.length >= 10 && rNames.every(n => n.weight === '700' && n.size === '14px' && n.outline === OUTLINE_14), rNames.slice(0, 2))
check(`Recent files: paths (${rPaths.length}) 12px 700 + 1px outline, ellipsis kept`, rPaths.length >= 10 && rPaths.every(p => p.weight === '700' && p.size === '12px' && p.outline === OUTLINE_12), rPaths.slice(0, 2))
check(`Recent files: names (${rNames.length}) 14px 700 + 1.3px outline`, rNames.length >= 3 && rNames.every(n => n.weight === '700' && n.size === '14px' && n.outline === OUTLINE_14), rNames.slice(0, 2))
check(`Recent files: paths (${rPaths.length}) 12px 700 + 1px outline, ellipsis kept`, rPaths.length >= 3 && rPaths.every(p => p.weight === '700' && p.size === '12px' && p.outline === OUTLINE_12), rPaths.slice(0, 2))
// 2026-10-21 (user decision): each recent-file list shows only the 3 most recent files (15 in the Story data).
const perList = await page.locator('.hm-sw-recent-files__list').evaluateAll(lists => lists.map(l => l.querySelectorAll('.hm-sw-recent-files__row').length))
check('Recent files: each list shows 3 rows', perList.length > 0 && perList.every(n => n === 3), perList)
const row = page.locator('.hm-sw-recent-files__row').first()
const rowGeo = () => row.evaluate(r => [r.getBoundingClientRect().height, ...[...r.querySelectorAll('.hm-sw-recent-files__name, .hm-sw-recent-files__path')].map(e => Math.round(e.getBoundingClientRect().width))].join(','))
const rowBefore = await rowGeo()
await stableOnHover('Recent file name', row, row.locator('.hm-sw-recent-files__name'))
await row.hover(); await page.waitForTimeout(250)
const preview = await page.locator('.hm-sw-recent-files__preview').evaluate(p => { const t = p.querySelector('.hm-sw-recent-files__preview-title'), c = getComputedStyle(t), b = p.getBoundingClientRect(), body = p.querySelector('.hm-sw-recent-files__preview-body').getBoundingClientRect()
return { title: t.textContent, weight: c.fontWeight, size: c.fontSize, color: c.color, outline: c.textShadow.split(', rgb')[0], titleW: t.getBoundingClientRect().width, overflow: c.textOverflow, w: b.width, h: b.height, bodyH: body.height } })
return { title: t.textContent, weight: c.fontWeight, size: c.fontSize, color: c.color, outline: c.textShadow.split(', rgb')[0], titleW: t.getBoundingClientRect().width, overflow: c.textOverflow, w: b.width, h: b.height, bodyH: body.height, bodyEmpty: p.querySelector('.hm-sw-recent-files__preview-body').childElementCount === 0 && !p.querySelector('.hm-sw-recent-files__preview-body').textContent } })
report.preview = preview
check('Recent preview: title 14px 700 + 1.3px outline, width 294 with ellipsis, body 243px', preview.weight === '700' && preview.size === '14px' && preview.outline === OUTLINE_14 && preview.titleW === 294 && preview.overflow === 'ellipsis' && preview.bodyH === 243, preview)
// 2026-10-21 (user decision): the preview frame stays on hover; its 243px body is empty (no image / thumbnail).
check('Recent preview: title 14px 700 + 1.3px outline, width 294 with ellipsis, empty body 243px', preview.bodyEmpty && preview.weight === '700' && preview.size === '14px' && preview.outline === OUTLINE_14 && preview.titleW === 294 && preview.overflow === 'ellipsis' && preview.bodyH === 243, preview)
await page.screenshot({ path: path.join(out, 'recent-files.png'), clip: { x: 0, y: 0, width: 1100, height: 900 } })
check('Recent files: rows keep 50px and their text widths', rowBefore === await rowGeo() && rowBefore.startsWith('50,'), rowBefore)
await row.press('Enter').catch(() => {})
@@ -94,6 +94,9 @@ try {
console.log('firefox', (await ev('navigator.userAgent')))
// ---------- SWHeader `...` menu reference ----------
await goto('sw-viewer--compare-with-sw-header')
// 2026-10-21: SWHeader shows More only below 760px: the first header's 1440px Story wrapper is narrowed to 700px for
// this reference measurement (face colors and metrics do not depend on the width).
await ev(`(() => { document.querySelector('.hm-sw-header').parentElement.style.width = '700px'; return true })()`); await sleep(300)
await click('.hm-sw-header .hm-sw-header__more-trigger')
const reference = await faceOf('.hm-sw-header .hm-sw-header__more-menu', '.hm-sw-header__more-item')
const BOX = `element => { const s = getComputedStyle(element); return [s.borderTopWidth, s.borderTopStyle, s.borderTopColor, s.borderTopLeftRadius, s.backgroundImage !== 'none' ? s.backgroundImage : s.backgroundColor, s.boxShadow].join(' | ') }`
@@ -172,11 +175,15 @@ try {
fit: [...object.querySelectorAll('button')].some(b => (b.getAttribute('aria-label') ?? b.textContent.trim()) === '전체 보기'), more: !!h.querySelector('.hm-sw-viewer-header__more'),
open: [!!open.querySelector('.hm-sw-viewer-header__open-label'), open.title, open.querySelector('.hm-sw-viewer-header__open-icon').getBoundingClientRect().width],
order: [rect('.hm-sw-viewer-header__brand').right, open.getBoundingClientRect().left, open.getBoundingClientRect().right, rect('.hm-sw-viewer-header__document').left, rect('.hm-sw-viewer-header__document').right, rect('.hm-sw-viewer-header__object').left] } }))`))
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.length === 4 && report.every(r => r.overflow <= 0 && r.objectOverflow <= 1), report.map(r => [r.width, r.overflow, r.objectOverflow]))
check('no horizontal overflow at 1920 / 1440 / 1280 / 950 / 860 / 760 / 520', report.length === 7 && report.every(r => r.overflow <= 0 && r.objectOverflow <= 1), report.map(r => [r.width, r.overflow, r.objectOverflow]))
check('layer / settings / properties always in the header with tooltips', report.every(r => r.toggles.map(t => t[0]).join() === '레이어,설정,속성' && r.toggles.every(t => t[2])), report.map(r => [r.width, r.toggles]))
// 2026-10-21 measured steps: with the file open button this Story fits fully at 1440, as icons at 1100, More at 860 / 520.
check('1440 full (파일 열기 + 전체 보기 labels, no More); 1100 icons only, no More; 860 / 520 icons + More', report[0].fit && !report[0].more && report[0].open[0] && report[0].toggles.every(t => t[1])
&& !report[1].more && report[1].toggles.every(t => !t[1]) && report.slice(1).every(r => !r.open[0] && r.open[1] === '파일 열기' && Math.round(r.open[2]) === 34) && report[2].more && report[3].more,
// 2026-10-21 measured steps (widths of SW Footer / SW Header Responsive More + 520): full at 1920 / 1440, icons at 950,
// More at 860 / 760 / 520. 1280 is at the measured full / icons edge (Firefox fits the labels, Chromium does not): either
// whole state, never More.
const full = r => r.fit && !r.more && r.open[0] && r.toggles.every(t => t[1])
const icons = r => !r.more && r.toggles.every(t => !t[1]) && !r.open[0] && r.open[1] === '파일 열기' && Math.round(r.open[2]) === 34
check('1920 / 1440 full (파일 열기 + 전체 보기 labels, no More); 1280 full or icons, no More; 950 icons only, no More; 860 / 760 / 520 icons + More', report.slice(0, 2).every(full)
&& (full(report[2]) || icons(report[2])) && icons(report[3]) && report.slice(4).every(r => r.more && r.toggles.every(t => !t[1]) && !r.open[0] && r.open[1] === '파일 열기' && Math.round(r.open[2]) === 34),
report.map(r => [r.width, r.fit, r.more, r.open, r.toggles.map(t => t[1])]))
check('logo, file open, file name and toolbar never overlap', report.every(r => r.order[0] <= r.order[1] + 2.5 && r.order[2] <= r.order[3] + 0.5 && r.order[4] <= r.order[5] + 0.5), report.map(r => [r.width, r.order]))
await click('[data-width="520"] .hm-sw-viewer-header__document .hm-sw-header__document-button')
@@ -80,6 +80,11 @@ let reference, viewerMenu, moreMenu, refTrigger
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: '추가 도구' })
@@ -223,7 +228,8 @@ let reference, viewerMenu, moreMenu, refTrigger
await open(page, 'sw-viewer--responsive')
const headers = page.locator('[data-width] .hm-sw-viewer-header')
const report = []
for (const [index, width] of [1440, 1100, 860, 520].entries()) {
// 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()
@@ -239,24 +245,24 @@ let reference, viewerMenu, moreMenu, refTrigger
}
})
report.push({ width, ...info })
const b = await box(header)
await page.screenshot({ path: path.join(out, `responsive-${width}.png`), clip: { x: b.x, y: b.y, width: b.width, height: b.height } })
// 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 1440 / 1100 / 860 / 520', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1), report.map(item => [item.width, item.overflow, item.objectOverflow]))
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 1440, as icons at 1100, and needs More at 860 / 520.
check('1440: full items with labels (전체 보기 shown), no More; 1100: icons only, no More', report.slice(0, 1).every(item => item.fitShown && !item.more && item.toggles.every(toggle => toggle.label))
&& !report[1].more && report[1].fitShown && report[1].toggles.every(toggle => !toggle.label && toggle.title), report.map(item => [item.width, item.fitShown, item.more]))
check('860: icons only (tooltips kept) + More', report[2].more && report[2].toggles.every(toggle => !toggle.label && toggle.title), report[2])
check('520: icons only, then More for what still does not fit', report[3].more && report[3].toggles.every(toggle => !toggle.label && toggle.title), report[3])
// 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(3).getByRole('button', { name: '추가 도구' })
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(3).evaluate(element => [...element.querySelectorAll('.hm-sw-viewer-header__object :is(.hm-sw-viewer-header__button:not(.hm-sw-viewer-header__more), [role=radio], .hm-sw-viewer-header__switch-label)')]
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 })
+27 -24
View File
@@ -33,14 +33,14 @@ const box = locator => locator.boundingBox()
const geometry = async (page, selectors) => page.evaluate(selectors => {
const header = document.querySelector(selectors.header)
const base = header.getBoundingClientRect()
return Object.fromEntries(Object.entries(selectors.parts).map(([name, selector]) => [name, [...document.querySelectorAll(selector)].map(element => {
return Object.fromEntries(Object.entries(selectors.parts).map(([name, selector]) => [name, [...(selectors.scoped ? header : document).querySelectorAll(selector)].map(element => {
const rect = element.getBoundingClientRect()
return { x: Math.round(rect.x - base.x), y: Math.round(rect.y - base.y), w: Math.round(rect.width), h: Math.round(rect.height), text: element.textContent.trim().slice(0, 12) }
})]))
}, selectors)
const parts = {
reference: { header: 'header', parts: { document: '.cad-toolbar-document', object: '.cad-toolbar-object-area', field: '.cad-toolbar-property-field', button: '.cad-toolbar [data-slot=button]', toggle: '.cad-toolbar [data-slot=toggle]', tab: '.cad-toolbar [data-slot=tabs-trigger]', switchField: '.cad-toolbar-switch-field' } },
package: { header: '.hm-sw-viewer-header', parts: { document: '.hm-sw-viewer-header__document', object: '.hm-sw-viewer-header__object', field: '.hm-sw-viewer-header__group[data-surface=field]', button: '.hm-sw-viewer-header__button:not([data-kind=toggle])', toggle: '.hm-sw-viewer-header__button[data-kind=toggle]', tab: '.hm-sw-viewer-header__tab', switchField: '.hm-sw-viewer-header__switch-field' } },
package: { header: '.hm-sw-viewer-header', scoped: true, parts: { document: '.hm-sw-viewer-header__document', object: '.hm-sw-viewer-header__object', field: '.hm-sw-viewer-header__group[data-surface=field]', button: '.hm-sw-viewer-header__button:not([data-kind=toggle])', toggle: '.hm-sw-viewer-header__button[data-kind=toggle]', tab: '.hm-sw-viewer-header__tab', switchField: '.hm-sw-viewer-header__switch-field' } },
}
const comparison = {}
if (reference && reference !== '-') {
@@ -65,16 +65,17 @@ if (reference && reference !== '-') {
{
const page = await newPage(1920, 1000)
await open(page, 'viewer-header')
const frame = page.locator('[data-viewer-frame]')
// 2026-10-21: the Story also shows the minimize / close version below; the checks use the first (basic) frame.
const frame = page.locator('[data-viewer-frame]').first()
const origin = await box(frame)
comparison.package = await geometry(page, parts.package)
await page.screenshot({ path: path.join(out, 'package-1440-loaded.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 56 } })
// 2026-10-21: the samples are the file-name list (SWHeaderDocumentTitle), see viewer-logo-document-menu.browser.mjs.
await page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').click()
await frame.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').click()
await page.screenshot({ path: path.join(out, 'package-1440-sample-menu.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 200 } })
await page.keyboard.press('Escape')
await page.getByRole('button', { name: '레이어' }).click()
await page.getByRole('button', { name: '설정' }).click()
await frame.getByRole('button', { name: '레이어' }).click()
await frame.getByRole('button', { name: '설정' }).click()
await page.waitForTimeout(200)
await page.screenshot({ path: path.join(out, 'package-1440-panels.png'), clip: { x: origin.x, y: origin.y, width: 1440, height: 620 } })
await page.close()
@@ -97,11 +98,13 @@ if (comparison.reference) {
// Wide enough for the 1440px story frame plus the story page padding (panels at the frame right edge stay on screen).
const page = await newPage(1920, 1000)
await open(page, 'viewer-header')
// 2026-10-21: the Story also shows the minimize / close version below; these checks use the first (basic) frame.
const root = page.locator('[data-viewer-frame]').first()
// Samples (2026-10-21): the file-name list (SWHeaderDocumentTitle) replaced the 샘플 menu button. Click opens, rows listed,
// Escape closes and returns focus; the logo menu (Open) is checked in viewer-logo-document-menu.browser.mjs.
const samples = page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button')
const samples = page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-button').first()
await samples.click()
const menu = page.getByRole('group', { name: '문서 목록' })
const menu = root.getByRole('group', { name: '문서 목록' })
check('document list opens on click', await menu.isVisible())
check('document list has the 3 samples', (await menu.locator('.hm-sw-header__document-item').count()) === 3)
const menuBox = await box(menu), triggerBox = await box(samples)
@@ -119,34 +122,34 @@ if (comparison.reference) {
// 2026-10-21: no Open below the rows (file open is the header button); ArrowUp wraps to the last sample.
await page.keyboard.press('ArrowUp')
await page.keyboard.press('Enter')
check('Enter selects a sample (document changes)', (await page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-name').innerText()) === 'simple.dxf')
check('Enter selects a sample (document changes)', (await page.locator('.hm-sw-viewer-header .hm-sw-viewer-header__document .hm-sw-header__document-name').first().innerText()) === 'simple.dxf')
check('onDocumentSelect reported', (await log(page)).includes('samples/sample-dxf'), await log(page))
// Outside click closes.
await samples.click()
await page.mouse.click(700, 600)
check('outside click closes the list', (await page.getByRole('group', { name: '문서 목록' }).count()) === 0)
check('outside click closes the list', (await root.getByRole('group', { name: '문서 목록' }).count()) === 0)
// Tabs: arrow keys select.
const model = page.getByRole('radio', { name: /Model/ })
const model = root.getByRole('radio', { name: /Model/ })
await model.focus()
await page.keyboard.press('ArrowRight')
check('tabs ArrowRight selects Layout', (await page.getByRole('radio', { name: /Layout/ }).getAttribute('aria-checked')) === 'true')
check('tabs ArrowRight selects Layout', (await root.getByRole('radio', { name: /Layout/ }).getAttribute('aria-checked')) === 'true')
// Switch
const depth = page.getByRole('switch', { name: '2.5D' })
const depth = root.getByRole('switch', { name: '2.5D' })
await depth.click()
check('header switch toggles', (await depth.getAttribute('aria-checked')) === 'true' && (await log(page)).includes('2.5d=true'))
// Hover face (2026-10-21: 전체 보기 left the header row for More, so the panel toggle is hovered).
const hovered = page.getByRole('button', { name: '레이어', exact: true })
const hovered = root.getByRole('button', { name: '레이어', exact: true })
await hovered.hover()
check('hover face #266651', (await hovered.evaluate(element => getComputedStyle(element).backgroundColor)) === 'rgb(38, 102, 81)')
// Toggle opens the panel; Escape inside closes it and focus goes back.
const layers = page.getByRole('button', { name: '레이어', exact: true })
const layers = root.getByRole('button', { name: '레이어', exact: true })
await layers.click()
const layerPanel = page.getByRole('dialog', { name: /레이어 \(/ })
const layerPanel = root.getByRole('dialog', { name: /레이어 \(/ })
check('toggle opens panel', await layerPanel.isVisible())
check('toggle pressed state + #FFC600', (await layers.getAttribute('aria-pressed')) === 'true' && (await layers.evaluate(element => getComputedStyle(element).color)) === 'rgb(255, 198, 0)')
check('panel focused on open', await layerPanel.evaluate(element => element.contains(document.activeElement)))
await page.keyboard.press('Escape')
check('Escape closes panel', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
check('Escape closes panel', (await root.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
check('focus returns to toggle', await layers.evaluate(element => element === document.activeElement))
// Resize by the native corner (right after opening, top-end placement).
await layers.click()
@@ -171,7 +174,7 @@ if (comparison.reference) {
await page.mouse.down()
await page.mouse.move(after.x + 3000, after.y + 3000, { steps: 6 })
await page.mouse.up()
const frame = await box(page.locator('.sw-viewer-story-canvas'))
const frame = await box(root.locator('.sw-viewer-story-canvas'))
const clamped = await box(layerPanel)
check('drag stays inside bounds (10px edge)', Math.round(frame.x + frame.width - (clamped.x + clamped.width)) === 10 && Math.round(frame.y + frame.height - (clamped.y + clamped.height)) === 10,
`right gap ${Math.round(frame.x + frame.width - (clamped.x + clamped.width))}, bottom gap ${Math.round(frame.y + frame.height - (clamped.y + clamped.height))}`)
@@ -201,18 +204,18 @@ if (comparison.reference) {
await close.hover()
check('close hover = SWPopup hover asset', await close.evaluate(element => getComputedStyle(element.querySelector('.hm-sw-property__close-hover')).visibility === 'visible' && getComputedStyle(element.querySelector('.hm-sw-property__close-default')).visibility === 'hidden'))
await close.click()
check('close button closes', (await page.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
check('close button closes', (await root.getByRole('dialog', { name: /레이어 \(/ }).count()) === 0)
// Two panels: the last touched comes to the front.
await layers.click()
await page.getByRole('button', { name: '설정', exact: true }).click()
const options = page.getByRole('dialog', { name: '옵션' })
await root.getByRole('button', { name: '설정', exact: true }).click()
const options = root.getByRole('dialog', { name: '옵션' })
const z = async () => Promise.all([layerPanel, options].map(item => item.evaluate(element => Number(getComputedStyle(element).zIndex))))
await layerPanel.locator('.hm-sw-property__titlebar').click({ position: { x: 30, y: 15 } })
const [zl, zo] = await z()
check('last touched panel on top', zl > zo, `layer z ${zl}, options z ${zo}`)
// Modal popup: backdrop, focus trap, Escape.
await page.getByRole('button', { name: '정보 및 라이선스' }).click()
const about = page.getByRole('dialog', { name: /라이선스/ })
await root.getByRole('button', { name: '정보 및 라이선스' }).click()
const about = root.getByRole('dialog', { name: /라이선스/ })
const confirm = about.getByRole('button', { name: '확인' })
await confirm.hover()
const confirmHover = await face(confirm)
@@ -222,7 +225,7 @@ if (comparison.reference) {
for (let index = 0; index < 4; index++) await page.keyboard.press('Tab')
check('modal focus stays inside', await about.evaluate(element => element.contains(document.activeElement)))
await page.keyboard.press('Escape')
check('modal Escape closes', (await page.getByRole('dialog', { name: /라이선스/ }).count()) === 0)
check('modal Escape closes', (await root.getByRole('dialog', { name: /라이선스/ }).count()) === 0)
await page.close()
}
{