feat(sw): viewer header keeps every menu down to 520px
Deploy Storybook / deploy (push) Successful in 1m16s

- File name: clamp(80px, min(45cqw - 256px, 100cqw - 780px), 360px),
  56px below 560px; 1280px and wider unchanged
- Model / Layout tabs narrow (name, then count ellipsized) down to the new
  --hm-sw-viewer-tabs-min-width (48px) before anything moves into More;
  the measurement counts them at that minimum once every icon step applies
- Switch items get an optional shortLabel shown below 560px (어두운 배경 ->
  배경); counts hidden there
- More appears only below about 520px; viewer test and AI guides updated
This commit is contained in:
shkim4 committed 2026-10-26 20:00:50 +09:00
1 parent 7976801034
commit cabd2f67de
8 files changed
+83 -34

No files matched your search

+26 -11
View File
@@ -309,11 +309,13 @@ if (comparison.reference) {
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).
// Name and count of a button never overlap and stay inside it (2026-10-26: narrow tabs ellipsize them, so their
// boxes are compared, not the full text).
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)
const name = tab.querySelector('.hm-sw-viewer-header__tab-name'), count = tab.querySelector('.hm-sw-viewer-header__count'), box = rect(tab)
const shown = [name, count].filter(node => node && rect(node).width > 0)
return shown.every(node => rect(node).left >= box.left - 0.5 && rect(node).right <= box.right + 0.5)
&& (shown.length < 2 || rect(name).right <= rect(count).left + 0.5)
}) : true
return {
width: Math.round(rect(element).width),
@@ -325,7 +327,8 @@ if (comparison.reference) {
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')),
// A switch caption with a shortLabel holds both texts; its full label is the one named here.
labels: [...element.querySelectorAll('.hm-sw-viewer-header__button, .hm-sw-viewer-header__tab, .hm-sw-viewer-header__switch-label')].map(item => (item.querySelector('.hm-sw-viewer-header__switch-label-full') ?? item).textContent.trim() || item.getAttribute('aria-label')),
// Buttons shown as icons only by the measured steps (name = aria-label / title) and labels drawn as text.
iconOnly: [...element.querySelectorAll('.hm-sw-viewer-header__button[data-icon-only=""]')].map(item => item.getAttribute('aria-label') || item.getAttribute('title') || item.textContent.trim()).sort(),
shownLabels: [...element.querySelectorAll('.hm-sw-viewer-header__label')].filter(item => rect(item).width > 0).map(item => item.textContent.trim()),
@@ -352,10 +355,18 @@ if (comparison.reference) {
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()))
// 2026-10-26 (user request): the file name gives way further (80px, below 560px 56px) and the Model / Layout tabs
// narrow with an ellipsis, so 950 / 860 / 760 / 520 keep every item in the header (no More).
check('950 / 860 / 760 / 520: every item in the header, icons only, no More', [950, 860, 760, 520].every(width => !at(width).more && at(width).field && ['Model', 'Layout', '전체 보기', '레이어'].every(name => at(width).labels.some(label => label.startsWith(name))))
&& !(await row(950).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()) && !(await row(520).locator('.hm-sw-viewer-header__label', { hasText: '레이어' }).count()), JSON.stringify([950, 860, 760, 520].map(width => [width, at(width).more, at(width).field])))
check('760: Model / Layout narrower than at 860', at(760).field < at(860).field, JSON.stringify([at(860).field, at(760).field]))
// Below 560px: counts hidden, 어두운 배경 shows its shortLabel 배경 (the full label stays the accessible name).
const narrow = await row(520).evaluate(element => ({
counts: [...element.querySelectorAll('.hm-sw-viewer-header__tab .hm-sw-viewer-header__count')].filter(node => node.getBoundingClientRect().width > 0).length,
captions: [...element.querySelectorAll('.hm-sw-viewer-header__switch-label')].map(label => [...label.children].filter(node => node.getBoundingClientRect().width > 1).map(node => node.textContent).join('')),
darkName: element.querySelector('.hm-sw-viewer-header__switch-label[data-short]')?.control?.labels?.[0]?.textContent,
}))
check('520: counts hidden, switch captions 2.5D / 배경', narrow.counts === 0 && JSON.stringify(narrow.captions) === JSON.stringify(['2.5D', '배경']), narrow)
check('no 파일 열기 / 샘플 header buttons at any width', report.every(item => !item.labels.includes('파일 열기') && !item.labels.includes('샘플')))
// 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.
@@ -368,11 +379,15 @@ if (comparison.reference) {
}, [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) })
for (const max of [1100, 1000, 900, 800, 520, 440]) 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])))
// 2026-10-26: More only once even the narrowest tabs do not fit (below about 520px).
check('in-place shrink: no overflow, no More down to 520, More at 440', shrink.every(item => item.overflow <= 0) && shrink.filter(item => typeof item.max === 'number' && item.max >= 520).every(item => !item.more) && shrink.filter(item => item.max === 440).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')))
// The More menu: the 520 row narrowed in place to 400px, where Model / Layout move into More too (2026-10-26).
const narrowed = await resize(520, 400)
check('520 row narrowed to 400: More shown, no overflow', narrowed.more && narrowed.overflow <= 0, JSON.stringify(narrowed))
const more = row(520).getByRole('button', { name: '추가 도구' })
await more.click()
const moreMenu = page.getByRole('menu', { name: '추가 도구' })