fix(sw): expand viewer model layout tabs and strengthen responsive tests
Deploy Storybook / deploy (push) Successful in 1m13s

This commit is contained in:
shkim4 committed 2026-10-23 13:50:33 +09:00
1 parent 4551edfd82
commit 7a5236f3ad
5 files changed
+150 -47

No files matched your search

+1 -1
View File
@@ -143,7 +143,7 @@
|---|---|
| Header | `--hm-sw-viewer-height` 52px, `--hm-sw-viewer-background` #0a0a0b(단색, 2026-10-21 — Storybook 뷰어 캔버스 바탕색), `--hm-sw-viewer-border-color` #071e18, `--hm-sw-viewer-text` #fff, `--hm-sw-viewer-muted` #bddfd4, `--hm-sw-viewer-accent` #ffc600, `--hm-sw-viewer-hover` #266651 |
| 로고 면 | 2026-10-23부터 `SWHeader` 홈과 같은 사선 배경(Figma 에셋 9-slice)·같은 폭 계산(왼쪽 12 + 로고 + 36, 다음 부품과 17px 겹침, 예시 로고에서 79px). `--hm-sw-viewer-brand-width` 기본 `auto`. 로고 다음 첫 부품(파일 열기·파일명) 시작 여백 8px. Hover 효과 없음(기존 그대로, `SWHeader` Hover와 다름). 로고가 없으면 로고 면 없음(폭 0, 첫 부품 12px 안쪽). `--hm-sw-viewer-brand-empty-width` 24px·`--hm-sw-viewer-brand-top` #123328·`-middle` #205241·`-bottom` #071e18은 더 쓰지 않으며 호환용으로만 남음 |
| 크기·글자 | `--hm-sw-viewer-gap` 4px, `--hm-sw-viewer-radius` 4px, `--hm-sw-viewer-control-height` 40px, `--hm-sw-viewer-button-height` 34px, `--hm-sw-viewer-font-size` 14px, `--hm-sw-viewer-label-size` 11px, `--hm-sw-viewer-icon-size` 16px, `--hm-sw-viewer-disabled-opacity` 0.6 |
| 크기·글자 | `--hm-sw-viewer-gap` 4px, `--hm-sw-viewer-radius` 4px, `--hm-sw-viewer-control-height` 40px, `--hm-sw-viewer-button-height` 34px, `--hm-sw-viewer-tabs-max-width` 378px(tabs만 있는 field 그룹의 최대 폭, 2026-10-23), `--hm-sw-viewer-font-size` 14px, `--hm-sw-viewer-label-size` 11px, `--hm-sw-viewer-icon-size` 16px, `--hm-sw-viewer-disabled-opacity` 0.6 |
| Header 스위치·아이콘 | `--hm-sw-viewer-switch-off` #020a07(OFF 트랙), `--hm-sw-viewer-switch-thumb-off` #5f7a71(OFF 원형, 흐린 회녹색), `--hm-sw-viewer-switch-on` = `var(--hm-sw-viewer-hover)`(ON 트랙), `--hm-sw-viewer-switch-thumb-on` = `var(--hm-sw-viewer-muted)`(ON 원형, 민트; 노란색·테두리 없음, 2026-10-22), `--hm-sw-viewer-switch-bevel` `inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 1px rgba(0, 0, 0, 0.25)`, `--hm-sw-viewer-icon-outline-color` = `var(--hm-sw-header-text-outline-color)`, `--hm-sw-viewer-icon-outline-width` 1px |
| Dropdown·More | `--hm-sw-viewer-menu-background` #fff, `--hm-sw-viewer-menu-border-color` #dedbd2, `--hm-sw-viewer-menu-text` #0a221d, `--hm-sw-viewer-menu-muted` #6e6759, `--hm-sw-viewer-menu-highlight` `rgba(189, 223, 212, 0.45)`, `--hm-sw-viewer-menu-highlight-text` #123328, `--hm-sw-viewer-menu-shadow` `0 0 12px rgba(0, 0, 0, 0.2)`, `--hm-sw-viewer-menu-item-height` 36px, `--hm-sw-viewer-more-background` #f5f0e5, `--hm-sw-viewer-more-border-color` #897445 |
| 패널 콘텐츠 | `--hm-sw-viewer-panel-background` = `var(--hm-sw-property-surface)`, `--hm-sw-viewer-panel-field-background` = `var(--hm-sw-property-content)`, `--hm-sw-viewer-panel-inner-border` = `var(--hm-sw-property-inner-border)`, `--hm-sw-viewer-panel-text` = `var(--hm-sw-property-label)`, `--hm-sw-viewer-panel-heading` #3e3523, `--hm-sw-viewer-panel-label` #6e6759, `--hm-sw-viewer-panel-primary` #123328, `--hm-sw-viewer-panel-switch-off` = `var(--hm-sw-property-inner-border)`, `--hm-sw-viewer-panel-switch-on` #266651, `--hm-sw-viewer-panel-badge-background` = `var(--hm-sw-property-table)`, `--hm-sw-viewer-panel-overlay` `rgba(0, 0, 0, 0.1)`, `--hm-sw-viewer-panel-spacing` 12px |
+1 -1
View File
@@ -31,7 +31,7 @@
| `type: "button"` | `onSelect` |
| `type: "toggle"` | `pressed`, `onPressedChange` (패널 열기/닫기 등) |
| `type: "menu"` | `entries: SWViewerMenuEntry[]`, `menuWidth?`, `menuAlign?: "start" \| "end"` |
| `type: "tabs"` | `value`, `options: { value, label, count?, title?, disabled? }[]`, `onValueChange` (Model / Layout) |
| `type: "tabs"` | `value`, `options: { value, label, count?, title?, disabled? }[]`, `onValueChange` (Model / Layout). 폭(2026-10-23): 두 버튼은 항상 같은 폭(가장 긴 이름 + 개수 이상이라 겹치지 않음). tabs만 있는 그룹은 Header에 남는 공간만큼 넓어져 field 면 최대 378px(`--hm-sw-viewer-tabs-max-width`), 공간이 적으면 그만큼만 넓어지고 기본 폭보다 부족하면 기존 측정 단계대로 More로 이동합니다. 모양(높이·글자·개수·색·field 면)은 그대로 |
| `type: "switch"` | `checked`, `onCheckedChange` (2.5D 등, 위 11px 캡션) |
| `type: "custom"` | `render()`, `menuEntries?`(More에 넣을 항목) |
| `onCommand(command)` | 모든 명령을 한 곳에서 받습니다: `{ id, itemId, value? }`. 항목 자체 Callback 다음에 호출 |
+11 -2
View File
@@ -26,6 +26,8 @@
--hm-sw-viewer-radius: 4px;
--hm-sw-viewer-control-height: 40px;
--hm-sw-viewer-button-height: 34px;
/* Model / Layout field face: widest size (2026-10-23); narrower when the header has less room. */
--hm-sw-viewer-tabs-max-width: 378px;
--hm-sw-viewer-font-size: 14px;
--hm-sw-viewer-label-size: 11px;
--hm-sw-viewer-icon-size: 16px;
@@ -351,11 +353,18 @@ button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-icon__glyph { position: relative; }
.hm-sw-viewer-icon svg { width: 100%; height: 100%; overflow: visible; }
/* Tabs (Model / Layout): 40px triggers with an 11px count; the active one turns #FFC600. */
.hm-sw-viewer-header__tabs { display: flex; align-items: center; gap: var(--hm-sw-viewer-gap); }
/* Tabs (Model / Layout): 40px triggers with an 11px count; the active one turns #FFC600.
Width (2026-10-23): equal columns (1fr: each at least the widest name + count, so they never overlap). A group
holding only the tabs starts at that natural width and takes the object's free room up to
--hm-sw-viewer-tabs-max-width (outer field face); with less room it stays narrower, and below the natural width the
measured steps move it into More as before (the measurement, `data-measuring`, uses the natural width). */
.hm-sw-viewer-header__tabs { display: grid; grid-auto-flow: column; grid-auto-columns: 1fr; align-items: center; gap: var(--hm-sw-viewer-gap); }
.hm-sw-viewer-header__group:has(> .hm-sw-viewer-header__tabs:only-child) { flex: 1 0 auto; box-sizing: border-box; max-width: var(--hm-sw-viewer-tabs-max-width); }
.hm-sw-viewer-header__group > .hm-sw-viewer-header__tabs:only-child { flex: 1 1 auto; }
.hm-sw-viewer-header .hm-sw-viewer-header__tab {
display: inline-flex;
align-items: center;
justify-content: center;
gap: var(--hm-sw-viewer-gap);
height: var(--hm-sw-viewer-control-height);
padding: 0 8px;
+130 -42
View File
@@ -24,8 +24,9 @@ const newPage = async (width = 1440, height = 900) => {
page.on('console', message => { if (message.type() === 'error' && !/favicon|404/.test(message.text())) errors.push(`${page.url()}: ${message.text()}`) })
return page
}
// 2026-10-23: waits for a visible header (the Responsive Story's first, 1920px row is hidden below a 2000px page).
// Test-only: lift the 1280px Story page envelope so the 1440px frames are measured at full width.
const open = async (page, id) => { await page.goto(story(id)); await page.waitForSelector('.hm-sw-viewer-header'); await page.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' }); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(200) }
const open = async (page, id) => { await page.goto(story(id)); await page.waitForSelector('.hm-sw-viewer-header:visible'); await page.addStyleTag({ content: '.sb-page { --sb-page-max-width: 100vw; }' }); await page.evaluate(() => document.fonts.ready); await page.waitForTimeout(200) }
const log = page => page.locator('[data-callback-log]').first().innerText()
const box = locator => locator.boundingBox()
@@ -80,12 +81,18 @@ if (reference && reference !== '-') {
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()
}
// 2026-10-23: the Model / Layout buttons share one width (their field face widens up to 378px with room), so they and
// their field are left out of the size comparison with the reference; height and look are unchanged.
check('geometry tabs: equal widths, 40px high', comparison.package.tab.length === 2 && comparison.package.tab.every(item => item.h === 40 && Math.abs(item.w - comparison.package.tab[0].w) <= 1), comparison.package.tab.map(item => `${item.w}x${item.h}`).join(' '))
if (comparison.reference) {
// Same item = same size (positions shift with the file-name text width, which differs by sample data).
for (const key of ['button', 'toggle', 'tab', 'switchField', 'field']) {
const a = comparison.reference[key].map(item => `${item.w}x${item.h}`), b = comparison.package[key].map(item => `${item.w}x${item.h}`)
// field: the reference Tabs root is 39px inside its 40px frame, so its tab field is 41px; the package keeps both fields 42px (documented 1px difference).
const within = key === 'field' ? comparison.reference[key].every((item, index) => comparison.package[key][index] && item.w === comparison.package[key][index].w && Math.abs(item.h - comparison.package[key][index].h) <= 1) : JSON.stringify(a) === JSON.stringify(b)
const noTabs = items => items.filter(item => !item.text.startsWith('Model'))
for (const key of ['button', 'toggle', 'switchField', 'field']) {
const pick = key === 'field' ? noTabs : items => items
const a = pick(comparison.reference[key]).map(item => `${item.w}x${item.h}`), b = pick(comparison.package[key]).map(item => `${item.w}x${item.h}`)
// field (without the tabs field): same width, height within 1px.
const [ref, pkg] = [pick(comparison.reference[key]), pick(comparison.package[key])]
const within = key === 'field' ? ref.every((item, index) => pkg[index] && item.w === pkg[index].w && Math.abs(item.h - pkg[index].h) <= 1) : JSON.stringify(a) === JSON.stringify(b)
check(`geometry ${key} sizes equal to reference${key === 'field' ? ' (±1px height, known)' : ''}`, within, `reference ${a.join(' ')} / package ${b.join(' ')}`)
}
const [r, p] = [comparison.reference.document[0], comparison.package.document[0]]
@@ -137,10 +144,15 @@ if (comparison.reference) {
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).
// Hover (2026-10-21 rule, VIEWER.md: the #266651 face was removed): text / icon #FFC600, no face, same size and
// position. 전체 보기 left the header row for More, so the panel toggle is hovered.
const hovered = root.getByRole('button', { name: '레이어', exact: true })
const beforeHover = await box(hovered)
await hovered.hover()
check('hover face #266651', (await hovered.evaluate(element => getComputedStyle(element).backgroundColor)) === 'rgb(38, 102, 81)')
const hoverStyle = await hovered.evaluate(element => { const style = getComputedStyle(element); return `${style.color} ${style.backgroundColor}` })
const afterHover = await box(hovered)
check('hover: #FFC600 text, no face, same box', hoverStyle === 'rgb(255, 198, 0) rgba(0, 0, 0, 0)' && JSON.stringify(beforeHover) === JSON.stringify(afterHover), `${hoverStyle} ${JSON.stringify(afterHover)}`)
await page.mouse.move(0, 0)
// Toggle opens the panel; Escape inside closes it and focus goes back.
const layers = root.getByRole('button', { name: '레이어', exact: true })
await layers.click()
@@ -281,44 +293,100 @@ if (comparison.reference) {
}
{
// Responsive: measured steps (full → icons only → More), no horizontal overflow; More collects the collapsed items.
const page = await newPage(1920, 1200)
// 2026-10-23: rows are picked by their data-width (not by position). The Story's 1920px row is hidden below a 2000px
// page (sw-story-responsive-hidden), so the page is 2500px wide and every row is shown at its own maximum width.
const page = await newPage(2500, 1200)
await open(page, 'responsive')
const headers = page.locator('[data-width] .hm-sw-viewer-header')
const row = width => page.locator(`[data-width="${width}"] .hm-sw-viewer-header`)
const report = []
for (const [index, width] of [1440, 1100, 860, 520].entries()) {
const header = headers.nth(index)
const info = await header.evaluate(element => ({
overflow: element.scrollWidth - element.clientWidth,
objectOverflow: (() => { const object = element.querySelector('.hm-sw-viewer-header__object'); return object.scrollWidth - object.clientWidth })(),
more: !!element.querySelector('.hm-sw-viewer-header__more'),
labels: [...element.querySelectorAll('.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__switch-label')].map(item => item.textContent.trim() || item.getAttribute('aria-label')),
}))
report.push({ width, ...info })
const box2 = await box(header)
await page.screenshot({ path: path.join(out, `package-responsive-${width}.png`), clip: { x: box2.x, y: box2.y, width: box2.width, height: box2.height } })
for (const width of [1920, 1440, 1280, 950, 860, 760, 520]) {
const header = row(width)
check(`responsive ${width}px row shown`, await header.isVisible())
const info = await header.evaluate(element => {
const rect = node => node.getBoundingClientRect()
const parts = [element.querySelector('.hm-sw-viewer-header__document'), ...element.querySelectorAll('.hm-sw-viewer-header__object > [data-group], .hm-sw-viewer-header__more, .hm-sw-viewer-header__actions')].filter(Boolean).map(rect)
const overlaps = parts.some((a, i) => parts.slice(i + 1).some(b => Math.min(a.right, b.right) - Math.max(a.left, b.left) > 0.5))
const tabs = element.querySelector('.hm-sw-viewer-header__tabs')
const field = tabs?.parentElement
const tabWidths = tabs ? [...tabs.querySelectorAll('.hm-sw-viewer-header__tab')].map(tab => rect(tab).width) : []
// Name and count of a button never overlap (and the button never clips them).
const tabsFit = tabs ? [...tabs.querySelectorAll('.hm-sw-viewer-header__tab')].every(tab => {
const count = tab.querySelector('.hm-sw-viewer-header__count'), range = document.createRange()
range.selectNodeContents(tab.firstChild)
return tab.scrollWidth <= tab.clientWidth && (!count || range.getBoundingClientRect().right <= rect(count).left)
}) : true
return {
width: Math.round(rect(element).width),
overflow: element.scrollWidth - element.clientWidth,
objectOverflow: (() => { const object = element.querySelector('.hm-sw-viewer-header__object'); return object.scrollWidth - object.clientWidth })(),
overlaps,
outside: parts.some(part => part.right > rect(element).right + 0.5),
more: !!element.querySelector('.hm-sw-viewer-header__more'),
field: field ? Math.round(rect(field).width * 10) / 10 : null,
tabsEqual: tabWidths.every(width => Math.abs(width - tabWidths[0]) <= 0.5),
tabsFit,
labels: [...element.querySelectorAll('.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__switch-label')].map(item => item.textContent.trim() || item.getAttribute('aria-label')),
}
})
report.push({ rowWidth: width, ...info })
// Element capture: the lower rows are below the 1200px viewport (a page clip would fall outside the image).
await header.screenshot({ path: path.join(out, `package-responsive-${width}.png`) })
}
check('no horizontal overflow at 1440 / 1100 / 860 / 520', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1), JSON.stringify(report.map(item => [item.width, item.overflow, item.objectOverflow])))
// 2026-10-21: 파일 열기 / 샘플 left the header (logo menu, file-name list). Measured steps instead of fixed widths:
// full items while they fit, then icons only, then More; the panel toggles never move into More. With this Story's
// content: 1440 / 1100 full, 860 icons only, 520 icons + More.
// 2026-10-21: with the file open button after the logo, 1100 fits as icons (still no More) and 860 needs More.
check('1440 / 1100: every item in the header (전체 보기 included), no More', [report[0], report[1]].every(item => !item.more && ['전체 보기', '2.5D', '어두운 배경', '레이어'].every(name => item.labels.includes(name))))
check('860: icons only + More', report[2].more && !(await headers.nth(2).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
check('520: icons only + More; panel toggles kept as icons (label → aria-label)', report[3].more && ['레이어', '설정', '속성'].every(name => report[3].labels.includes(name))
&& !(await headers.nth(3).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
const at = width => report.find(item => item.rowWidth === width)
check('rows render at their own width', report.every(item => item.width === item.rowWidth), JSON.stringify(report.map(item => [item.rowWidth, item.width])))
check('no horizontal overflow, overlap or part outside the header at any row', report.every(item => item.overflow <= 0 && item.objectOverflow <= 1 && !item.overlaps && !item.outside), JSON.stringify(report.map(item => [item.rowWidth, item.overflow, item.objectOverflow, item.overlaps, item.outside])))
// 2026-10-23: the Model / Layout field widens with the free room up to 378px (1920: 378), narrower with less room;
// the buttons always share one width and keep name + count apart. With this Story's content 1920 / 1440 / 1280 show
// every item (no More); narrower rows move collapseBelow items (Model / Layout included) into More by the measured steps.
check('Model / Layout field: 378px at 1920, at most 378px and narrower with less room', at(1920).field === 378 && report.every(item => item.field === null || item.field <= 378) && at(1440).field >= at(1280).field, JSON.stringify(report.map(item => [item.rowWidth, item.field])))
check('Model / Layout buttons equal, name and count never overlap', report.every(item => item.tabsEqual && item.tabsFit))
check('1920 / 1440 / 1280: every item in the header, no More', [1920, 1440, 1280].every(width => !at(width).more && at(width).field && ['전체 보기', '2.5D', '어두운 배경', '레이어'].every(name => at(width).labels.includes(name))))
// 950 sits at the edge of the measured steps (icons only or More, depending on the font rendering); both fit.
check('950: icons only or More', at(950).more || !(await row(950).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
check('860 / 760 / 520: More shown', [860, 760, 520].every(width => at(width).more))
check('860: icons only + More', at(860).more && !(await row(860).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
check('520: icons only + More; panel toggles kept as icons (label → aria-label)', at(520).more && ['레이어', '설정', '속성'].every(name => at(520).labels.includes(name))
&& !(await row(520).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()))
check('no 파일 열기 / 샘플 header buttons at any width', report.every(item => !item.labels.includes('파일 열기') && !item.labels.includes('샘플')))
const more = headers.nth(3).getByRole('button', { name: '추가 도구' })
// 2026-10-23: a header first drawn wide and then narrowed in place (window resize) takes the same measured steps
// (icons, then More) without overflow, and gets every item back when widened again.
const resize = async (width, max) => page.evaluate(async ([width, max]) => {
const wrapper = document.querySelector(`[data-width="${width}"]`)
wrapper.style.maxWidth = max ? `${max}px` : ''
await new Promise(resolve => setTimeout(resolve, 400))
const header = wrapper.querySelector('.hm-sw-viewer-header')
return { overflow: header.scrollWidth - header.clientWidth, more: !!header.querySelector('.hm-sw-viewer-header__more'), labels: header.querySelectorAll('.hm-sw-viewer-header__object .hm-sw-viewer-header__label').length }
}, [width, max])
const shrink = []
for (const width of [1920, 1440]) {
for (const max of [1100, 1000, 900, 800]) shrink.push({ width, max, ...await resize(width, max) })
shrink.push({ width, max: 'restored', ...await resize(width, null) })
}
check('in-place shrink: no overflow, More once the room is short', shrink.every(item => item.overflow <= 0) && shrink.filter(item => item.max === 800).every(item => item.more), JSON.stringify(shrink.map(item => [item.width, item.max, item.overflow, item.more])))
check('in-place grow back: every label back, no More', shrink.filter(item => item.max === 'restored').every(item => !item.more && item.labels === at(item.width).labels.filter(label => ['전체 보기', '레이어', '설정', '속성'].includes(label)).length), JSON.stringify(shrink.filter(item => item.max === 'restored')))
const more = row(520).getByRole('button', { name: '추가 도구' })
await more.click()
const moreMenu = page.getByRole('menu', { name: '추가 도구' })
const items = await moreMenu.locator('.hm-sw-viewer-menu__label, [role^=menuitem]').allInnerTexts()
check('More sections: 도면 공간, 화면 보기 (with 전체 보기); no 패널', ['도면 공간', '화면 보기', '전체 보기'].every(label => items.includes(label)) && !items.includes('패널'), items.join(' | '))
// 2026-10-21: the header dropdowns (More included) take the SWHeader `...` menu face (#0e3d2f); width unchanged.
check('More face (SWHeader ... menu #0e3d2f) 260px', (await moreMenu.evaluate(element => `${getComputedStyle(element).backgroundColor} ${element.offsetWidth}`)) === 'rgb(14, 61, 47) 260')
// Model / Layout inside More: radio items with their counts, the current value checked.
const radios = () => moreMenu.getByRole('menuitemradio').evaluateAll(entries => entries.map(entry => `${entry.textContent.trim()}=${entry.getAttribute('aria-checked')}`))
const initial = await radios()
check('More: Model / Layout radios with counts, Model checked', initial.length === 2 && /^Model.*17,377.*=true$/.test(initial[0]) && /^Layout.*237.*=false$/.test(initial[1]), JSON.stringify(initial))
await moreMenu.getByRole('menuitemcheckbox', { name: '2.5D' }).click()
check('More checkbox reports command', (await log(page)).includes('2.5d=true'), await log(page))
await more.click()
await page.getByRole('menuitemradio', { name: /Layout/ }).click()
check('More radio selects tab value', (await log(page)).includes('space=layout1'))
await more.click()
const switched = await radios()
check('More: Layout checked after the switch, counts kept', switched.length === 2 && /^Model.*17,377.*=false$/.test(switched[0]) && /^Layout.*237.*=true$/.test(switched[1]), JSON.stringify(switched))
check('wide rows follow the same value (Layout selected, counts kept)', (await row(1440).getByRole('radio', { name: /Layout/ }).getAttribute('aria-checked')) === 'true'
&& (await row(1440).locator('.hm-sw-viewer-header__count').allInnerTexts()).join('|') === '17,377|237')
await page.getByRole('menuitemradio', { name: /Model/ }).click()
check('More radio back to Model', (await log(page)).includes('space=model') && (await row(1440).getByRole('radio', { name: /Model/ }).getAttribute('aria-checked')) === 'true')
await page.screenshot({ path: path.join(out, 'package-responsive-more-open.png') })
await page.close()
}
@@ -342,27 +410,47 @@ if (comparison.reference) {
await page.close()
}
{
// Logo: nothing drawn without one, image / element when given; layout unchanged.
// Logo (2026-10-23 rule, VIEWER.md): no logo = no logo area at all (no cap, no slant, no width; the first part starts
// at the header edge). With a logo: the SWHeader home shape (shared .hm-sw-header__home-bg slant) around the logo,
// 11px + the 1px header border + logo + 36px wide (79px with the 32px example logo), overlapping the next part by 17px.
const page = await newPage(1920, 1000)
await open(page, 'logo')
const cases = page.locator('[data-logo-case]')
const read = index => cases.nth(index).evaluate(element => {
const brand = element.querySelector('.hm-sw-viewer-header__brand'), doc = element.querySelector('.hm-sw-viewer-header__document')
const base = element.getBoundingClientRect()
const header = element.querySelector('.hm-sw-viewer-header'), base = header.getBoundingClientRect()
const brand = header.querySelector('.hm-sw-viewer-header__brand'), mark = header.querySelector('.hm-sw-viewer-header__brand-mark')
const first = [...header.children].find(child => !child.matches('.hm-sw-viewer-header__brand, .hm-sw-viewer-header__logo-menu'))
return {
text: brand.textContent.trim(), img: !!brand.querySelector('img, .hm-sw-viewer-header__brand-mark svg'),
width: Math.round(brand.getBoundingClientRect().width), docX: Math.round(doc.getBoundingClientRect().x - base.x),
height: Math.round(element.querySelector('.hm-sw-viewer-header').getBoundingClientRect().height),
fileOpenY: Math.round(element.querySelector('.hm-sw-viewer-header__button').getBoundingClientRect().y - base.y),
brand: !!brand, slant: !!brand?.querySelector('.hm-sw-header__home-bg'), text: brand?.textContent.trim() ?? '',
img: !!brand?.querySelector('img, .hm-sw-viewer-header__brand-mark svg'),
width: brand ? Math.round(brand.getBoundingClientRect().width) : 0, logoWidth: mark ? Math.round(mark.getBoundingClientRect().width) : 0,
logoX: mark ? Math.round(mark.getBoundingClientRect().x - base.x) : null,
firstX: Math.round(first.getBoundingClientRect().x - base.x), gap: parseFloat(getComputedStyle(header).columnGap),
height: Math.round(base.height),
fileOpenY: Math.round(header.querySelector('.hm-sw-viewer-header__button').getBoundingClientRect().y - base.y),
}
})
const [none, src, node] = [await read(0), await read(1), await read(2)]
check('logo none: no HM / text / icon', none.text === '' && !none.img, JSON.stringify(none))
check('logo none: cap shrinks to its edge (no wide gap)', none.width === 24, JSON.stringify(none))
const logoWidth = await cases.nth(1).locator('.hm-sw-viewer-header__brand-mark').evaluate(element => Math.round(element.getBoundingClientRect().width))
check('logoSrc shows the image (cap = 24 + logo + 25, min 72)', src.img && src.width === Math.max(72, 24 + logoWidth + 25), JSON.stringify({ ...src, logoWidth }))
check('logo element shown', node.img && node.width >= 72, JSON.stringify(node))
check('logo none: no logo area (no cap, slant or text)', !none.brand && !none.slant && none.text === '' && !none.img, JSON.stringify(none))
check('logo none: first part starts at the header edge (no width left)', none.firstX === 1, JSON.stringify(none))
for (const [name, item] of [['logoSrc', src], ['logo element', node]]) {
check(`${name}: slanted cap (SWHeader home shape) with the logo`, item.brand && item.slant && item.img, JSON.stringify(item))
check(`${name}: cap = 11 + logo + 36 (79px with the 32px logo), logo 12px from the header edge`, item.width === 11 + item.logoWidth + 36 && item.logoX === 12, JSON.stringify(item))
check(`${name}: next part overlapped by 17px`, item.firstX === 1 + item.width - 17 + item.gap, JSON.stringify(item))
}
check('header height and menu row unchanged with / without logo', [none, src, node].every(item => item.height === 52 && item.fileOpenY === none.fileOpenY), [none, src, node].map(item => item.height + '/' + item.fileOpenY).join(' '))
{
// SWHeader keeps its 80px home area and slant without a logo (unlike the viewer).
const sw = await newPage(1920, 1000)
await sw.goto(`${storybook}/iframe.html?id=sw-header--logo-slot&viewMode=story`)
await sw.waitForSelector('[data-logo-case] .hm-sw-header:visible')
const homes = await sw.locator('[data-logo-case] .hm-sw-header').evaluateAll(headers => headers.map(header => {
const home = header.querySelector('.hm-sw-header__home')
return { width: home ? Math.round(home.getBoundingClientRect().width) : 0, slant: !!home?.querySelector('.hm-sw-header__home-bg') }
}))
check('SWHeader: 80px home area and slant with and without a logo', homes.length === 3 && homes.every(home => home.width === 80 && home.slant), JSON.stringify(homes))
await sw.close()
}
for (const [index, name] of [[0, 'none'], [1, 'src'], [2, 'element']]) {
const b = await box(cases.nth(index))
await page.screenshot({ path: path.join(out, 'package-logo-' + name + '.png'), clip: { x: b.x, y: b.y, width: 520, height: 52 } })
+7 -1
View File
@@ -148,9 +148,15 @@ export const States: Story = {
{ type: "menu", id: "menu-disabled", label: "Dropdown (disabled)", disabled: true, entries: [] },
],
},
// 2026-10-23: the Model / Layout tabs get a field group of their own, as in the viewer demo, so the Story shows
// their width rule (equal buttons, the field face up to 378px with room).
{
id: "tabs", label: "탭", surface: "field", items: [
{ type: "tabs", id: "tabs", label: "탭", value: "model", options: [{ value: "model", label: "Model", count: 17377 }, { value: "layout", label: "Layout", count: 237 }, { value: "off", label: "Disabled", disabled: true }] },
],
},
{
id: "field", label: "field 면", surface: "field", items: [
{ type: "tabs", id: "tabs", label: "탭", value: "model", options: [{ value: "model", label: "Model", count: 17377 }, { value: "layout", label: "Layout", count: 237 }, { value: "off", label: "Disabled", disabled: true }] },
{ type: "switch", id: "on", label: "Switch on", checked: true },
{ type: "switch", id: "off", label: "Switch off", checked: false },
{ type: "switch", id: "sw-disabled", label: "Disabled", checked: false, disabled: true },