refactor(storybook): hide page headers and preserve accessibility

StoryPageHeader no longer shows the page title, kicker, description or the SW group notice; it renders only a visually hidden H1 with the Story title, so the preview keeps a page heading for screen readers while the first section starts at the top gutter (unchanged position).

- OrgChart verify-p8 / p9 / p9-1 / p9-2-composition wait for the first visible StoryPage content block and read the page H1 instead of .sb-page-title; existing expectations are unchanged.
- SW overview browser test clicks the page gutter as its outside-click target.
- Drop the unused .sb-page-description from the SW Storybook hide rules.
- Update the Storybook page frame rule in the project guideline.
This commit is contained in:
shkim4 committed 2026-11-11 15:50:39 +09:00
1 parent f22428548a
commit 63b3ab010b
11 files changed
+59 -105

No files matched your search

+1 -1
View File
@@ -157,7 +157,7 @@ HM 지정값이 있는 색상, radius, control height, typography 등은 Theme T
- 컴포넌트가 실제 지원하는 default, variant, size, disabled 및 주요 상태를 확인한다. invalid, open 등 해당 컴포넌트에 실제로 있는 상태만 다루고, 없는 상태를 문서화를 위해 만들지 않는다. - 컴포넌트가 실제 지원하는 default, variant, size, disabled 및 주요 상태를 확인한다. invalid, open 등 해당 컴포넌트에 실제로 있는 상태만 다루고, 없는 상태를 문서화를 위해 만들지 않는다.
- Storybook은 구현 Reference Catalog이자 컴포넌트 단위의 검수와 사용법 문서화 도구다. 신규 UI 구현 전 유사 대표/PRIMARY Story를 먼저 확인한다. `Demo/App` 화면은 여러 컴포넌트를 조합한 적용 결과 확인용이며, Demo의 페이지·타일 UI를 Story의 기준으로 쓰지 않는다. - Storybook은 구현 Reference Catalog이자 컴포넌트 단위의 검수와 사용법 문서화 도구다. 신규 UI 구현 전 유사 대표/PRIMARY Story를 먼저 확인한다. `Demo/App` 화면은 여러 컴포넌트를 조합한 적용 결과 확인용이며, Demo의 페이지·타일 UI를 Story의 기준으로 쓰지 않는다.
- 컴포넌트 Story는 현재 HM 대표 Story의 공통 구조를 따른다. 컴포넌트 이름의 대표 Story 하나에 종류·크기·상태를 한 화면으로 모으고, 같은 구역을 `verification` 태그 Story(예: Roles, Sizes, Brand States)로도 둔다. `verification` Story는 메뉴와 검색에서 기본으로 숨기고(`.storybook/main.ts`의 `tags`) 직접 URL과 사이드바 태그 필터로 연다. HM 전용 Story(Playground, Compatibility)도 같은 태그로 숨긴다. 기본 사이드바에는 컴포넌트가 하위 메뉴 없는 1뎁스 항목 하나로만 보이고(대표 Story 하나만 노출되면 Storybook이 컴포넌트 항목에 합친다), 클릭하면 한 페이지에서 전체 검수 내용이 열린다. Story 이름과 순서, 행·열 구성은 현재 HM 유사 대표 Story의 공통 구성을 먼저 따른다. 외부 ui_viz Story는 제공된 경우에만 충돌하지 않는 참고로 사용한다. - 컴포넌트 Story는 현재 HM 대표 Story의 공통 구조를 따른다. 컴포넌트 이름의 대표 Story 하나에 종류·크기·상태를 한 화면으로 모으고, 같은 구역을 `verification` 태그 Story(예: Roles, Sizes, Brand States)로도 둔다. `verification` Story는 메뉴와 검색에서 기본으로 숨기고(`.storybook/main.ts`의 `tags`) 직접 URL과 사이드바 태그 필터로 연다. HM 전용 Story(Playground, Compatibility)도 같은 태그로 숨긴다. 기본 사이드바에는 컴포넌트가 하위 메뉴 없는 1뎁스 항목 하나로만 보이고(대표 Story 하나만 노출되면 Storybook이 컴포넌트 항목에 합친다), 클릭하면 한 페이지에서 전체 검수 내용이 열린다. Story 이름과 순서, 행·열 구성은 현재 HM 유사 대표 Story의 공통 구성을 먼저 따른다. 외부 ui_viz Story는 제공된 경우에만 충돌하지 않는 참고로 사용한다.
- Storybook 페이지 프레임(2026-11-11 확정)은 `src/components/story-page.tsx`·`story-page.css`의 `StoryPage`·`StoryPageHeader`·`StorySection`·`StorySectionGrid`가 공통으로 소유한다. Web·Chart·OrgChart(Admin)의 문서 위계는 H1 페이지 제목 28/36/700 → 설명 14/20/400 보조색(제목 아래 8px) → 24px → H2 섹션 제목 16/24/700과 얇은 구분선 → 12px → 예제이며, 섹션 사이는 24px, `StateBoard`의 H3 라벨은 14/20/500 보조색이고 자체 구분선을 두지 않는다. 위·아래 여백은 16–24px, 좌우 여백은 기존 값(480px까지 16px, 768px부터 60px)을 유지한다. 값은 기존 Component Token을 `--sb-page-*`로 참조하며 새 Design Token이 아니다. 섹션을 나란히 놓을 때는 `StorySectionGrid`를 쓰고, Story별 `!important`·`[&_.sb-page-*]` 덮어쓰기로 간격을 바꾸지 않는다. SW와 노선도(CENTER)는 이 페이지 프레임과 섹션 간격만 공유하고 컴포넌트 크기·글꼴·캔버스·카드 규격은 각자 유지한다. 페이지 제목은 사이드바 이름을 따르며, 한 그룹에 Story가 여럿이면 `그룹 — Story 이름`(예: `SW Header — 화면 너비`)으로 쓴다. 섹션 제목은 페이지 제목과 예제 카드 제목을 반복하지 않는 짧은 변형명(예: Chart `Basic`, `Label`, `Solid`)으로 쓴다. - Storybook 페이지 프레임(2026-11-11 확정)은 `src/components/story-page.tsx`·`story-page.css`의 `StoryPage`·`StoryPageHeader`·`StorySection`·`StorySectionGrid`가 공통으로 소유한다. 2026-11-11 사용자 요청으로 `StoryPageHeader`는 H1 페이지 제목·kicker·설명·SW 공통 안내 문구를 표시하지 않으며(호출과 props는 유지), 페이지는 위 여백(16–24px) 바로 아래 첫 섹션부터 시작한다. 단, Preview 문서 제목이 Story ID라 스크린 리더가 페이지를 식별할 수 있도록 `title`만 시각적으로 숨긴 접근성 전용 H1(`.sb-page-a11y-title`, 1px clip·문서 흐름 밖)로 렌더링한다. 검증 스크립트는 이 H1의 시각 스타일에 의존하지 않고 첫 콘텐츠 블록(`.sb-page-inner > :not(h1)`)으로 렌더링을 기다리며, 페이지 식별은 H1 텍스트로 한다. 제품 `PageHeader` 컴포넌트와는 무관하다. Web·Chart·OrgChart(Admin)의 문서 위계는 H2 섹션 제목 16/24/700과 얇은 구분선 → 12px → 예제이며, 섹션 사이는 24px, `StateBoard`의 H3 라벨은 14/20/500 보조색이고 자체 구분선을 두지 않는다. 위·아래 여백은 16–24px, 좌우 여백은 기존 값(480px까지 16px, 768px부터 60px)을 유지한다. 값은 기존 Component Token을 `--sb-page-*`로 참조하며 새 Design Token이 아니다. 섹션을 나란히 놓을 때는 `StorySectionGrid`를 쓰고, Story별 `!important`·`[&_.sb-page-*]` 덮어쓰기로 간격을 바꾸지 않는다. SW와 노선도(CENTER)는 이 페이지 프레임과 섹션 간격만 공유하고 컴포넌트 크기·글꼴·캔버스·카드 규격은 각자 유지한다. 페이지 제목은 사이드바 이름을 따르며, 한 그룹에 Story가 여럿이면 `그룹 — Story 이름`(예: `SW Header — 화면 너비`)으로 쓴다. 섹션 제목은 페이지 제목과 예제 카드 제목을 반복하지 않는 짧은 변형명(예: Chart `Basic`, `Label`, `Solid`)으로 쓴다.
- 대표 Story의 Focus 구역은 pseudo-state 복제 대신 `play` 함수로 실제 요소에 focus를 준다(ui_viz도 Focus는 정적 specimen을 만들지 않는다). hover 같은 상태가 ui_viz에 없으면 `.storybook/pseudo-states.ts`를 쓰지 않는다. 구역 배치 helper는 `src/components/story-board.tsx`의 `StateBoard`를 쓴다. - 대표 Story의 Focus 구역은 pseudo-state 복제 대신 `play` 함수로 실제 요소에 focus를 준다(ui_viz도 Focus는 정적 specimen을 만들지 않는다). hover 같은 상태가 ui_viz에 없으면 `.storybook/pseudo-states.ts`를 쓰지 않는다. 구역 배치 helper는 `src/components/story-board.tsx`의 `StateBoard`를 쓴다.
- hover, pressed 같은 pseudo state는 Storybook이 `:hover`/`:active` 규칙을 `data-sb-hover`/`data-sb-active` 규칙으로 복제해 고정한다(`.storybook/pseudo-states.ts`). 복제만 하고 값을 새로 정의하지 않으므로 실제 디자인을 보정하는 용도로 쓰지 않는다. Story에서는 `data-sb-*` 속성만 붙인다. - hover, pressed 같은 pseudo state는 Storybook이 `:hover`/`:active` 규칙을 `data-sb-hover`/`data-sb-active` 규칙으로 복제해 고정한다(`.storybook/pseudo-states.ts`). 복제만 하고 값을 새로 정의하지 않으므로 실제 디자인을 보정하는 용도로 쓰지 않는다. Story에서는 `data-sb-*` 속성만 붙인다.
- ui_viz에서 값이 `TBD`인 상태(예: Button Focus)는 별도 specimen을 만들지 않고 키보드로 확인하는 안내만 둔다. ui_viz에 없는 상태(예: Button `aria-invalid`)와 `REFERENCE_ONLY` 항목은 검수 Story에 넣지 않는다. shadcn 호환 API는 `Compatibility` Story로 분리한다. - ui_viz에서 값이 `TBD`인 상태(예: Button Focus)는 별도 specimen을 만들지 않고 키보드로 확인하는 안내만 둔다. ui_viz에 없는 상태(예: Button `aria-invalid`)와 `REFERENCE_ONLY` 항목은 검수 Story에 넣지 않는다. shadcn 호환 API는 `Compatibility` Story로 분리한다.
+12 -11
View File
@@ -46,8 +46,11 @@ page.setDefaultTimeout(20000);
async function settle(target = page) { async function settle(target = page) {
await target.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); await target.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); });
} }
// The Storybook page title is a visually hidden H1 (2026-11-11), so readiness waits for the first visible content block
// of the StoryPage frame, and page identity reads the H1 text (the Story's accessible page heading).
const pageContent = '.sb-page-inner > :not(h1)', pageHeading = '.sb-page-inner > h1';
async function ready(expected) { async function ready(expected) {
await page.locator('.sb-page-title').waitFor(); await page.locator(pageContent).first().waitFor();
await page.waitForFunction(expected => { await page.waitForFunction(expected => {
const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')]; const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')];
if (!hosts.length) return false; if (!hosts.length) return false;
@@ -76,10 +79,8 @@ async function commonStyle() {
const properties = { const properties = {
'.sb-page': ['fontFamily', 'color', 'paddingTop', 'paddingBottom', 'boxSizing', 'width'], '.sb-page': ['fontFamily', 'color', 'paddingTop', 'paddingBottom', 'boxSizing', 'width'],
'.sb-page-inner': ['width', 'boxSizing'], '.sb-page-inner': ['width', 'boxSizing'],
'.sb-page-header': ['paddingBottom', 'borderBottomWidth', 'borderBottomColor', 'fontSize', 'lineHeight'], // The visible header (kicker, title, description) is no longer rendered; the page H1 stays visually hidden.
'.sb-page-kicker': ['fontSize', 'fontWeight', 'color', 'marginBottom'], '.sb-page-inner > h1': ['position', 'width', 'height', 'overflow', 'clipPath'],
'.sb-page-title': ['fontSize', 'fontWeight', 'lineHeight', 'marginTop', 'marginBottom'],
'.sb-page-description': ['fontSize', 'fontWeight', 'lineHeight', 'marginBottom'],
'.sb-page-section': ['marginTop', 'scrollMarginTop'], '.sb-page-section': ['marginTop', 'scrollMarginTop'],
'.sb-page-section-header': ['paddingBottom', 'marginBottom', 'borderBottomWidth', 'borderBottomColor'], '.sb-page-section-header': ['paddingBottom', 'marginBottom', 'borderBottomWidth', 'borderBottomColor'],
'.sb-page-section-title': ['fontSize', 'lineHeight', 'fontWeight', 'marginTop', 'marginBottom'], '.sb-page-section-title': ['fontSize', 'lineHeight', 'fontWeight', 'marginTop', 'marginBottom'],
@@ -105,7 +106,7 @@ async function pageGeometry() {
return page.evaluate(() => { return page.evaluate(() => {
const rect = element => { if (!element) return null; const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; }; const rect = element => { if (!element) return null; const r = element.getBoundingClientRect(); return { x: r.x, y: r.y, width: r.width, height: r.height, right: r.right, bottom: r.bottom }; };
const documentGeometry = { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, innerWidth, scrollHeight: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight }; const documentGeometry = { scrollWidth: document.documentElement.scrollWidth, clientWidth: document.documentElement.clientWidth, innerWidth, scrollHeight: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight };
const frame = { page: rect(document.querySelector('.sb-page')), inner: rect(document.querySelector('.sb-page-inner')), title: document.querySelector('.sb-page-title')?.textContent }; const frame = { page: rect(document.querySelector('.sb-page')), inner: rect(document.querySelector('.sb-page-inner')), title: document.querySelector('.sb-page-inner > h1')?.textContent };
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
const wrapper = shadow?.querySelector('.hm-orgchart-composition'); const wrapper = shadow?.querySelector('.hm-orgchart-composition');
const stage = shadow?.querySelector('.hm-orgchart-stage'); const stage = shadow?.querySelector('.hm-orgchart-stage');
@@ -159,10 +160,10 @@ try {
await manager.waitForFunction(() => { await manager.waitForFunction(() => {
const preview = document.querySelector('#storybook-preview-iframe')?.contentDocument; const preview = document.querySelector('#storybook-preview-iframe')?.contentDocument;
const hosts = [...preview?.querySelectorAll('.hm-orgchart-component-host') ?? []]; const hosts = [...preview?.querySelectorAll('.hm-orgchart-component-host') ?? []];
return preview?.querySelector('.sb-page-title')?.textContent === 'Overview' && hosts.length > 0 return preview?.querySelector('.sb-page-inner > h1')?.textContent === 'Overview' && hosts.length > 0
&& hosts.every(host => host.shadowRoot && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)); && hosts.every(host => host.shadowRoot && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet));
}); });
await manager.frameLocator('#storybook-preview-iframe').locator('.sb-page-title').evaluate(async element => { await manager.frameLocator('#storybook-preview-iframe').locator(pageHeading).evaluate(async element => {
const document = element.ownerDocument, view = document.defaultView; const document = element.ownerDocument, view = document.defaultView;
await document.fonts.ready; await document.fonts.ready;
for (let i = 0; i < 5; i++) await new Promise(resolve => view.requestAnimationFrame(resolve)); for (let i = 0; i < 5; i++) await new Promise(resolve => view.requestAnimationFrame(resolve));
@@ -198,7 +199,7 @@ try {
await gotoStory(story); await gotoStory(story);
const rendered = await page.evaluate(({ selectors }) => { const rendered = await page.evaluate(({ selectors }) => {
const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')]; const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')];
return { title: document.querySelector('.sb-page-title')?.textContent, hosts: hosts.length, return { title: document.querySelector('.sb-page-inner > h1')?.textContent, hosts: hosts.length,
specimens: Object.fromEntries(selectors.map(selector => [selector, hosts.reduce((total, host) => total + host.shadowRoot.querySelectorAll(selector).length, 0)])), specimens: Object.fromEntries(selectors.map(selector => [selector, hosts.reduce((total, host) => total + host.shadowRoot.querySelectorAll(selector).length, 0)])),
employees: hosts.reduce((total, host) => total + host.shadowRoot.querySelectorAll('.hm-orgchart-employee').length, 0), employees: hosts.reduce((total, host) => total + host.shadowRoot.querySelectorAll('.hm-orgchart-employee').length, 0),
autoLayout: hosts.find(host => host.classList.contains('hm-orgchart-host'))?.shadowRoot.querySelector('.hm-orgchart-expanded')?.dataset.autoLayout }; autoLayout: hosts.find(host => host.classList.contains('hm-orgchart-host'))?.shadowRoot.querySelector('.hm-orgchart-expanded')?.dataset.autoLayout };
@@ -214,7 +215,7 @@ try {
for (const id of patterns) { for (const id of patterns) {
await page.setViewportSize({ width: 1440, height: 1100 }); await page.setViewportSize({ width: 1440, height: 1100 });
await page.goto(`${origin}/iframe.html?id=${id}&viewMode=story`); await page.goto(`${origin}/iframe.html?id=${id}&viewMode=story`);
await page.locator('.sb-page-title').waitFor(); await settle(); await page.locator(pageContent).first().waitFor(); await settle();
references.push({ id, style: await commonStyle() }); references.push({ id, style: await commonStyle() });
} }
const reference = references[0].style; const reference = references[0].style;
@@ -268,7 +269,7 @@ try {
await section('full-repair', async () => { await section('full-repair', async () => {
await page.setViewportSize({ width: 1440, height: 1100 }); await page.setViewportSize({ width: 1440, height: 1100 });
await page.goto(`${origin}/iframe.html?id=${patterns[0]}&viewMode=story`); await page.goto(`${origin}/iframe.html?id=${patterns[0]}&viewMode=story`);
await page.locator('.sb-page-title').waitFor(); await settle(); await page.locator(pageContent).first().waitFor(); await settle();
const reference = await commonStyle(); const reference = await commonStyle();
await gotoStory(stories[2]); await gotoStory(stories[2]);
const style = await commonStyle(); const style = await commonStyle();
+4 -3
View File
@@ -39,7 +39,8 @@ async function settle() {
async function goto(name, size) { async function goto(name, size) {
await page.setViewportSize(size); await page.setViewportSize(size);
await page.goto(`${origin}/iframe.html?id=${stories[name]}&viewMode=story`); await page.goto(`${origin}/iframe.html?id=${stories[name]}&viewMode=story`);
await page.locator('.sb-page-title').waitFor(); // The page title is a visually hidden H1 (2026-11-11): wait for the first visible StoryPage content block instead.
await page.locator('.sb-page-inner > :not(h1)').first().waitFor();
await page.waitForFunction(() => { await page.waitForFunction(() => {
const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')]; const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')];
return hosts.length === 1 && hosts.every(host => host.shadowRoot && !host.shadowRoot.querySelector('[aria-busy="true"]') && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet)); return hosts.length === 1 && hosts.every(host => host.shadowRoot && !host.shadowRoot.querySelector('[aria-busy="true"]') && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet));
@@ -86,7 +87,7 @@ async function snapshot() {
const css = getComputedStyle(element); const css = getComputedStyle(element);
return { scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, left: element.scrollLeft, top: element.scrollTop, overflowX: css.overflowX, overflowY: css.overflowY }; return { scrollWidth: element.scrollWidth, clientWidth: element.clientWidth, scrollHeight: element.scrollHeight, clientHeight: element.clientHeight, left: element.scrollLeft, top: element.scrollTop, overflowX: css.overflowX, overflowY: css.overflowY };
}; };
const common = Object.fromEntries(['.sb-page', '.sb-page-inner', '.sb-page-title', '.sb-page-section-title', '.sb-page-section-header', '.sb-page-section-body'].map(selector => [selector, style(document.querySelector(selector))])); const common = Object.fromEntries(['.sb-page', '.sb-page-inner', '.sb-page-inner > h1', '.sb-page-section-title', '.sb-page-section-header', '.sb-page-section-body'].map(selector => [selector, style(document.querySelector(selector))]));
// Page/section height is content-dependent; typography, borders and gutters are invariant. // Page/section height is content-dependent; typography, borders and gutters are invariant.
for (const value of Object.values(common)) if (value) for (const key of ['height', 'minHeight']) delete value[key]; for (const value of Object.values(common)) if (value) for (const key of ['height', 'minHeight']) delete value[key];
const documentScroll = { viewportWidth: innerWidth, viewportHeight: innerHeight, root: scroll(document.documentElement), body: scroll(document.body), scrollingElement: scroll(document.scrollingElement) }; const documentScroll = { viewportWidth: innerWidth, viewportHeight: innerHeight, root: scroll(document.documentElement), body: scroll(document.body), scrollingElement: scroll(document.scrollingElement) };
@@ -145,7 +146,7 @@ async function snapshot() {
}; };
} }
return { return {
title: document.querySelector('.sb-page-title')?.textContent, common, documentScroll, departments, unwanted, title: document.querySelector('.sb-page-inner > h1')?.textContent, common, documentScroll, departments, unwanted,
optionsPresent: [...document.querySelectorAll('button')].filter(visible).some(button => /긴 이름 검증/.test(button.textContent)), optionsPresent: [...document.querySelectorAll('button')].filter(visible).some(button => /긴 이름 검증/.test(button.textContent)),
sectionTitles: [...document.querySelectorAll('.sb-page-section-title')].map(element => element.textContent), fixed, sectionTitles: [...document.querySelectorAll('.sb-page-section-title')].map(element => element.textContent), fixed,
}; };
@@ -33,7 +33,8 @@ async function settle() {
async function open(size) { async function open(size) {
await page.setViewportSize(size); await page.setViewportSize(size);
await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`); await page.goto(`${origin}/iframe.html?id=orgchart--full-composition&viewMode=story`);
await page.locator('.sb-page-title').waitFor(); // The page title is a visually hidden H1 (2026-11-11): wait for the first visible StoryPage content block instead.
await page.locator('.sb-page-inner > :not(h1)').first().waitFor();
await page.waitForFunction(() => { await page.waitForFunction(() => {
const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot; const shadow = document.querySelector('.hm-orgchart-host')?.shadowRoot;
return shadow?.querySelector('.hm-orgchart-stage[data-workdesk-artwork]') && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet); return shadow?.querySelector('.hm-orgchart-stage[data-workdesk-artwork]') && !shadow.querySelector('[aria-busy="true"]') && [...shadow.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet);
+4 -3
View File
@@ -39,7 +39,8 @@ page.setDefaultTimeout(20000);
let expectedHosts = 1; let expectedHosts = 1;
async function settle() { await page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); } async function settle() { await page.evaluate(async () => { await document.fonts.ready; for (let i = 0; i < 5; i++) await new Promise(resolve => requestAnimationFrame(resolve)); }); }
async function ready(requirePage = true) { async function ready(requirePage = true) {
if (requirePage) await page.locator('.sb-page-title').waitFor(); // The page title is a visually hidden H1 (2026-11-11): wait for the first visible StoryPage content block instead.
if (requirePage) await page.locator('.sb-page-inner > :not(h1)').first().waitFor();
await page.waitForFunction(expected => { await page.waitForFunction(expected => {
const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')]; const hosts = [...document.querySelectorAll('.hm-orgchart-host,.hm-orgchart-component-host')];
return hosts.length === expected && hosts.every(host => host.shadowRoot && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) && !host.shadowRoot.querySelector('[aria-busy="true"]') && [...host.shadowRoot.querySelectorAll('img')].every(image => image.complete)); return hosts.length === expected && hosts.every(host => host.shadowRoot && [...host.shadowRoot.querySelectorAll('link[rel="stylesheet"]')].every(link => link.sheet) && !host.shadowRoot.querySelector('[aria-busy="true"]') && [...host.shadowRoot.querySelectorAll('img')].every(image => image.complete));
@@ -111,8 +112,8 @@ async function snapshot() {
cards: [...expanded.querySelectorAll('.hm-orgchart-employee')].map(card => { const css = getComputedStyle(card), direction = card.dataset.connectionDirection; return { company: card.dataset.companyId, direction, width: card.offsetWidth, height: card.offsetHeight, border: hex(css[direction === 'left' ? 'borderLeftColor' : direction === 'right' ? 'borderRightColor' : 'borderTopColor']), image: Boolean(card.querySelector('img')) }; }), cards: [...expanded.querySelectorAll('.hm-orgchart-employee')].map(card => { const css = getComputedStyle(card), direction = card.dataset.connectionDirection; return { company: card.dataset.companyId, direction, width: card.offsetWidth, height: card.offsetHeight, border: hex(css[direction === 'left' ? 'borderLeftColor' : direction === 'right' ? 'borderRightColor' : 'borderTopColor']), image: Boolean(card.querySelector('img')) }; }),
connectors: expanded.querySelectorAll('svg,.edge,.lines').length, connectors: expanded.querySelectorAll('svg,.edge,.lines').length,
} : null; } : null;
const common = Object.fromEntries(['.sb-page-title', '.sb-page-description', '.sb-page-section-title', '.sb-page-section-header'].map(selector => [selector, style(document.querySelector(selector))])); const common = Object.fromEntries(['.sb-page-inner > h1', '.sb-page-section-title', '.sb-page-section-header'].map(selector => [selector, style(document.querySelector(selector))]));
return { title: document.querySelector('.sb-page-title')?.textContent, titles: [...document.querySelectorAll('.sb-page-section-title')].map(element => element.textContent), document: { width: innerWidth, scrollWidth: document.documentElement.scrollWidth, scrollHeight: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight }, common, summary, affiliation, logos, departments, fixed, auto }; return { title: document.querySelector('.sb-page-inner > h1')?.textContent, titles: [...document.querySelectorAll('.sb-page-section-title')].map(element => element.textContent), document: { width: innerWidth, scrollWidth: document.documentElement.scrollWidth, scrollHeight: document.documentElement.scrollHeight, clientHeight: document.documentElement.clientHeight }, common, summary, affiliation, logos, departments, fixed, auto };
}); });
} }
function noHorizontalOverflow(state) { return state.document.scrollWidth <= state.document.width + 1; } function noHorizontalOverflow(state) { return state.document.scrollWidth <= state.document.width + 1; }
+1 -1
View File
@@ -161,7 +161,7 @@ try {
await open('color','색상');await open('layer','레이어') await open('color','색상');await open('layer','레이어')
check('opening another dropdown closes the previous one',await header.locator('.hm-sw-header__color-menu').count()===0&&await header.locator('.hm-sw-header__layer-panel').count()===1) check('opening another dropdown closes the previous one',await header.locator('.hm-sw-header__color-menu').count()===0&&await header.locator('.hm-sw-header__layer-panel').count()===1)
await page.keyboard.press('Escape');check('Escape closes Header dropdown',await header.locator('.hm-sw-header__layer-panel').count()===0) 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 open('layer','레이어');await page.locator('.sb-page').click({position:{x:4,y:4}});check('outside click closes Header dropdown',await header.locator('.hm-sw-header__layer-panel').count()===0)
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))]})) 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) 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)
+17 -50
View File
@@ -1,7 +1,7 @@
/* Storybook presentation only. Common page frame for representative Stories; all selectors stay inside .sb-page. /* Storybook presentation only. Common page frame for representative Stories; all selectors stay inside .sb-page.
2026-11-11 compact document rhythm, shared by Web / Chart / OrgChart (SW and CENTER pages use the same frame only): 2026-11-11 compact document rhythm, shared by Web / Chart / OrgChart (SW and CENTER pages use the same frame only):
H1 page title 28/36/700 (Page Header title) → description 14/20/400 secondary, 8px below (Page Header description) 2026-11-11: the H1 page title / description header is no longer shown (the H1 stays for screen readers only); pages start with the
→ 24px → H2 section title 16/24/700 (body-md) over a thin divider → 12px → specimen; sections 24px apart (Dashboard gap); H2 section title 16/24/700 (body-md) over a thin divider → 12px → specimen; sections 24px apart (Dashboard gap);
H3 specimen labels 14/20/500 secondary, no divider of their own (story-board.tsx). Top / bottom gutter 16–24px. H3 specimen labels 14/20/500 secondary, no divider of their own (story-board.tsx). Top / bottom gutter 16–24px.
Side margins are unchanged (16px up to 480px, growing to 60px at 768px): the SW breakpoints 2040 / 1125 / 787 depend on them. Side margins are unchanged (16px up to 480px, growing to 60px at 768px): the SW breakpoints 2040 / 1125 / 787 depend on them.
No design token is added; --sb-page-* only names existing component token values for this Storybook frame. */ No design token is added; --sb-page-* only names existing component token values for this Storybook frame. */
@@ -27,32 +27,21 @@
margin-inline: auto; margin-inline: auto;
} }
/* No bottom padding: the header's margin collapses with the next block's own top margin (a section's 24px), so the first /* 2026-11-11: no visible page header, so the first block starts right at the top gutter. StoryPageHeader only renders the
content sits 24px below the title whether it is a section, a section grid or a plain preview. */ visually hidden page H1 (screen readers only, out of flow), so the first visible block after it gets the same reset. */
.sb-page .sb-page-header { .sb-page > .sb-page-inner > :first-child,
margin-bottom: var(--sb-page-section-gap); .sb-page > .sb-page-inner > .sb-page-a11y-title:first-child + * { margin-top: 0; }
font-size: var(--hm-label-font-size); .sb-page .sb-page-a11y-title {
line-height: var(--hm-label-line-height); position: absolute;
overflow-wrap: anywhere; width: 1px;
} height: 1px;
.sb-page .sb-page-header .sb-page-kicker { margin: -1px;
margin: 0 0 4px; padding: 0;
color: var(--hm-sem-fg-secondary); overflow: hidden;
font-size: var(--hm-button-font-size-sm); clip: rect(0 0 0 0);
font-weight: var(--hm-font-weight-bold); clip-path: inset(50%);
} white-space: nowrap;
.sb-page .sb-page-header .sb-page-title { border: 0;
margin: 0;
font-size: var(--hm-page-header-title-font-size);
line-height: var(--hm-page-header-title-line-height);
font-weight: var(--hm-page-header-title-font-weight);
}
.sb-page .sb-page-header .sb-page-description {
margin: var(--hm-page-header-description-gap) 0 0;
color: var(--hm-page-header-description-color);
font-size: var(--hm-page-header-description-font-size);
line-height: var(--hm-page-header-description-line-height);
font-weight: var(--hm-input-font-weight);
} }
.sb-page .sb-page-section { margin-top: var(--sb-page-section-gap); scroll-margin-top: 16px; } .sb-page .sb-page-section { margin-top: var(--sb-page-section-gap); scroll-margin-top: 16px; }
@@ -80,25 +69,3 @@
Columns and alignment stay with the Story (className): this unlayered rule would override Tailwind's layered grid-cols-*. */ Columns and alignment stay with the Story (className): this unlayered rule would override Tailwind's layered grid-cols-*. */
.sb-page .sb-page-section-grid { display: grid; gap: var(--sb-page-section-gap) 24px; margin-top: var(--sb-page-section-gap); } .sb-page .sb-page-section-grid { display: grid; gap: var(--sb-page-section-gap) 24px; margin-top: var(--sb-page-section-gap); }
.sb-page .sb-page-section-grid > .sb-page-section { margin-top: 0; } .sb-page .sb-page-section-grid > .sb-page-section { margin-top: 0; }
/* Group notice under the page title (StoryNotice inside StoryPageHeader): 8px below the H1, same type and color as the
Page Header description (one level below the title), no box. The header's own bottom margin keeps the first section 24px below. */
.sb-page .sb-notice {
display: flex;
align-items: flex-start;
gap: 6px;
margin: var(--hm-page-header-description-gap) 0 0;
font-size: var(--hm-page-header-description-font-size);
line-height: var(--hm-page-header-description-line-height);
font-weight: var(--hm-input-font-weight);
color: var(--hm-page-header-description-color);
overflow-wrap: anywhere;
}
.sb-page .sb-notice-icon {
flex: none;
width: 14px;
height: 14px;
/* centers the 14px icon on the first text line (20px line height → 3px above and below) */
margin-block: calc((var(--hm-page-header-description-line-height) - 14px) / 2);
}
.sb-page .sb-notice-text { min-width: 0; }
+13 -31
View File
@@ -1,9 +1,9 @@
import { createContext, useContext, type ReactNode } from "react" import { createContext, type ReactNode } from "react"
import "./story-page.css" import "./story-page.css"
// Storybook presentation only (not part of @hm-design/ui): the common frame of a representative Story page. // Storybook presentation only (not part of @hm-design/ui): the common frame of a representative Story page.
// Page → Header (title, description) → Sections (title, description, specimen). It never styles the component being reviewed. // Page → Sections (title, description, specimen). It never styles the component being reviewed.
// Inner and title follow Foundation / Color; the section header → content split follows Center Graphic / 교량. // Inner follows Foundation / Color; the section header → content split follows Center Graphic / 교량.
// A Story using it sets `parameters.layout: "fullscreen"` so the page owns its inner padding. // A Story using it sets `parameters.layout: "fullscreen"` so the page owns its inner padding.
// Lets StateBoard render its specimen labels one level below the section heading. // Lets StateBoard render its specimen labels one level below the section heading.
@@ -13,36 +13,18 @@ export function StoryPage({ className, children }: { className?: string; childre
return <div className={className ? `sb-page ${className}` : "sb-page"}><div className="sb-page-inner">{children}</div></div> return <div className={className ? `sb-page ${className}` : "sb-page"}><div className="sb-page-inner">{children}</div></div>
} }
// Group notice shown directly under the page title (H1 → notice → description). A Story group opts in through a decorator // Group notice context. Still provided by src/sw/sw-story-notice.tsx, but nothing renders it since the page header was
// that provides this context (e.g. src/sw/sw-story-notice.tsx); without a provider nothing is rendered. // removed from the Storybook frame (2026-11-11).
export const StoryNoticeContext = createContext<ReactNode>(null) export const StoryNoticeContext = createContext<ReactNode>(null)
// Representative (sidebar) Stories omit `kicker`: the sidebar path already names the category. // 2026-11-11 (user request): the Storybook page header (kicker, description and group notice) is no longer shown, so every
// Hidden verification Stories keep passing it so their page structure stays unchanged. // page visually starts with its first section. Only the title stays, as a visually hidden H1 (no box, no space), so the
export function StoryPageHeader({ kicker, title, description }: { kicker?: string; title: string; description?: ReactNode }) { // preview document keeps one page heading above its H2 sections: the preview's own document title is the Story ID and the
const notice = useContext(StoryNoticeContext) // iframe title is generic, so this is the only place a screen reader can identify the page.
return ( // Stories keep their calls and props, so the visible header can be restored here.
<header className="sb-page-header"> // The product Page Header component (@hm-design/ui PageHeader) is unrelated and unchanged.
{kicker ? <p className="sb-page-kicker">{kicker}</p> : null} export function StoryPageHeader({ title }: { kicker?: string; title: string; description?: ReactNode }) {
<h1 className="sb-page-title">{title}</h1> return <h1 className="sb-page-a11y-title">{title}</h1>
{notice ? <StoryNotice>{notice}</StoryNotice> : null}
{description ? <p className="sb-page-description">{description}</p> : null}
</header>
)
}
// Notice row (icon + text) under the page title, one level below it (Page Header description type and color).
// No box, background or border. Icon: Group 1171290812.svg (circle + "!"), colors via currentColor.
export function StoryNotice({ children }: { children: ReactNode }) {
return (
<p className="sb-notice" data-story-notice="">
<svg className="sb-notice-icon" width="14" height="14" viewBox="0 0 29 29" fill="none" aria-hidden="true" focusable="false">
<path d="M15.7415 6L15.5383 17.3984H13.3743L13.1711 6H15.7415ZM14.4602 22.1484C14.0279 22.1484 13.6581 21.9974 13.3508 21.6953C13.0435 21.388 12.8925 21.0182 12.8977 20.5859C12.8925 20.1589 13.0435 19.7943 13.3508 19.4922C13.6581 19.1849 14.0279 19.0312 14.4602 19.0312C14.8821 19.0312 15.2467 19.1849 15.554 19.4922C15.8612 19.7943 16.0175 20.1589 16.0227 20.5859C16.0175 20.8724 15.942 21.1354 15.7961 21.375C15.6555 21.6094 15.468 21.7969 15.2336 21.9375C14.9993 22.0781 14.7415 22.1484 14.4602 22.1484Z" fill="currentColor" />
<circle cx="14.5" cy="14.5" r="13.5" stroke="currentColor" strokeWidth="2" />
</svg>
<span className="sb-notice-text">{children}</span>
</p>
)
} }
// Headings and specimens share StoryPage's single Inner. Wide specimens own their local scroll frame. // Headings and specimens share StoryPage's single Inner. Wide specimens own their local scroll frame.
+1 -1
View File
@@ -12,4 +12,4 @@
/* 2026-10-21 Storybook simplification (SW pages): no event log or long descriptions on screen; the log stays in the DOM /* 2026-10-21 Storybook simplification (SW pages): no event log or long descriptions on screen; the log stays in the DOM
for the browser tests. Same rule as sw-stories.css (this page does not load it). */ for the browser tests. Same rule as sw-stories.css (this page does not load it). */
.sb-page:has([class*="hm-sw-"]) :is(.sw-home-menu-story__log, [data-callback-log], .sb-page-description, .sb-page-section-description) { display: none; } .sb-page:has([class*="hm-sw-"]) :is(.sw-home-menu-story__log, [data-callback-log], .sb-page-section-description) { display: none; }
+1 -1
View File
@@ -101,4 +101,4 @@
/* 2026-10-21 Storybook simplification (SW pages): the components only. Callback logs, state dumps and long page / /* 2026-10-21 Storybook simplification (SW pages): the components only. Callback logs, state dumps and long page /
section descriptions are not shown; the log and state elements stay in the DOM because the browser tests read section descriptions are not shown; the log and state elements stay in the DOM because the browser tests read
their text. Scoped to pages that render SW components, so other Story pages are unaffected. */ their text. Scoped to pages that render SW components, so other Story pages are unaffected. */
.sb-page:has([class*="hm-sw-"]) :is([data-callback-log], [data-property-log], [data-style-state], .sb-page-description, .sb-page-section-description) { display: none; } .sb-page:has([class*="hm-sw-"]) :is([data-callback-log], [data-property-log], [data-style-state], .sb-page-section-description) { display: none; }
+3 -2
View File
@@ -1,8 +1,9 @@
import type { Decorator } from "@storybook/react-vite" import type { Decorator } from "@storybook/react-vite"
import { StoryNoticeContext } from "@/components/story-page" import { StoryNoticeContext } from "@/components/story-page"
// Storybook presentation only. Common notice of every SW Story page (SW/* groups only), shown directly under the page // Storybook presentation only. Common notice of every SW Story page (SW/* groups only). Since 2026-11-11 the page header
// title by StoryPageHeader: each SW stories file opts in with `decorators: [withSWNotice]`, so other menus never render it. // that showed it is not rendered, so the notice is hidden; the decorator and context stay so it can be restored in one place.
// Each SW stories file opts in with `decorators: [withSWNotice]`, so other menus never render it.
// Hidden verification / internal Stories are skipped; `parameters.swNotice: false` opts a Story out // Hidden verification / internal Stories are skipped; `parameters.swNotice: false` opts a Story out
// (use it where the page already carries the same notice, so it is never shown twice). // (use it where the page already carries the same notice, so it is never shown twice).
export const SW_NOTICE_TEXT = export const SW_NOTICE_TEXT =