From 7a5236f3ad22094355621a68a201486a11ff4210 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EA=B9=80=EC=88=98=ED=98=84?= Date: Fri, 23 Oct 2026 13:50:33 +0900 Subject: [PATCH] fix(sw): expand viewer model layout tabs and strengthen responsive tests --- packages/sw/ai/DESIGN_RULES.md | 2 +- packages/sw/ai/VIEWER.md | 2 +- packages/sw/src/theme/sw-viewer.css | 13 +- packages/sw/tests/viewer.browser.mjs | 172 ++++++++++++++++++++------- src/sw/sw-viewer.stories.tsx | 8 +- 5 files changed, 150 insertions(+), 47 deletions(-) diff --git a/packages/sw/ai/DESIGN_RULES.md b/packages/sw/ai/DESIGN_RULES.md index c8dc5ca..b88969c 100644 --- a/packages/sw/ai/DESIGN_RULES.md +++ b/packages/sw/ai/DESIGN_RULES.md @@ -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 | diff --git a/packages/sw/ai/VIEWER.md b/packages/sw/ai/VIEWER.md index c941874..96f6cd7 100644 --- a/packages/sw/ai/VIEWER.md +++ b/packages/sw/ai/VIEWER.md @@ -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 다음에 호출 | diff --git a/packages/sw/src/theme/sw-viewer.css b/packages/sw/src/theme/sw-viewer.css index 2f2f84d..7742daf 100644 --- a/packages/sw/src/theme/sw-viewer.css +++ b/packages/sw/src/theme/sw-viewer.css @@ -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; diff --git a/packages/sw/tests/viewer.browser.mjs b/packages/sw/tests/viewer.browser.mjs index e261f6c..d840924 100644 --- a/packages/sw/tests/viewer.browser.mjs +++ b/packages/sw/tests/viewer.browser.mjs @@ -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 } }) diff --git a/src/sw/sw-viewer.stories.tsx b/src/sw/sw-viewer.stories.tsx index 531b6db..b88333f 100644 --- a/src/sw/sw-viewer.stories.tsx +++ b/src/sw/sw-viewer.stories.tsx @@ -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 },