Compare commits

...
5 changed files with 34 additions and 17 deletions

No files matched your search

+1 -1
View File
@@ -4,7 +4,7 @@ Designer Playground는 내부 개발용 `internal` 태그로 기본 검수 메
1. 처음 열면 `themes/default.json` 기반 HM Default 컬러가 표시됩니다. **Load Default JSON**, **Load Service Example JSON**으로 repository JSON을 URL에서 불러오거나 **Theme JSON URL**에 CDN URL을 입력한 뒤 **Load URL**을 누릅니다. 같은 탭에 저장값이 있다면 마지막 유효 Theme를 다시 표시합니다. 다른 Story로 이동해도 편집 데이터는 세션에 보관되지만 해당 Story의 색상은 HM Default이며, 편집기로 돌아오면 보관된 편집값을 다시 적용합니다. **Reset to HM Default**로 기본값을 다시 불러옵니다.
2. Primary / Active / Focus / Accent / Destructive 등 역할의 컬러 피커 또는 HEX 입력을 수정합니다. 불투명 6자리 HEX(`#123abc`)를 사용합니다. 유효한 값은 입력 즉시 적용되며 별도의 Apply 버튼이 필요 없습니다. 미완성 입력은 마지막 유효 색을 유지합니다.
3. 오른쪽 Live Preview에서 여러 컴포넌트를 함께 확인합니다. 화면이 좁으면 편집기 아래에 Preview가 이어집니다. Focus 확인은 Tab 키로 Checkbox/Input 등에 이동합니다. Select, Date Picker, Combobox 및 메뉴를 열면 Global Theme가 팝업에도 적용됩니다.
3. 오른쪽 실시간 미리보기에서 여러 컴포넌트를 함께 확인합니다. 화면이 좁으면 편집기 아래에 미리보기가 이어집니다. Focus 확인은 Tab 키로 Checkbox/Input 등에 이동합니다. Select, Date Picker, Combobox 및 메뉴를 열면 Global Theme가 팝업에도 적용됩니다.
4. 임시 Theme는 Designer Playground 내부에만 적용됩니다. 다른 Component / Pattern Story로 이동하면 override를 해제하고 HM Default로 검수합니다. 편집기 내부 값 변경에는 reload, 파일 저장, Vite HMR, build, Storybook 재시작이 필요 없습니다.
5. **Reset to HM Default**는 현재 override를 해제하고 기본값을 즉시 복원합니다. 컴포넌트 API와 레이아웃은 바꾸지 않습니다.
6. **Show JSON**으로 현재 데이터를 표시하고 **Copy JSON**으로 복사하거나 **Export JSON**으로 `hm-color-theme.json`을 내려받습니다. 클립보드 접근이 막히면 JSON 입력란에서 직접 복사합니다. 수정한 키만 저장하므로 나머지는 HM Default입니다.
+1 -1
View File
@@ -70,7 +70,7 @@ Header·cell·row·selected 면은 `packages/ui/src/theme/table.css`(`--hm-table
## 9. Storybook
- `Patterns/Data/Data Table` → `Data Table`(대표: `Advanced Search` 구역 하나. 추가 조건 목록 검색 + 선택·정렬·열 표시·행 액션·페이지 크기·페이지), 숨김 verification Story `Overview`(기본 표, `patterns-data-data-table--overview`), `Basic Search`(기본 목록 검색, `patterns-data-data-table--basic-search`), `With Filter Bar`(추가 조건 연동 검증). 목록 검색 구역은 흰 Card 하나에 CardTitle(`사용자 목록`) → 검색·열 표시 도구 → 표 순서다. 페이지 제목 PageHeader를 쓰는 화면에서는 PageHeader를 이 Card 밖에 둔다. verification: `WithFilterBar`, `Loading`, `Empty`.
- `Patterns/Data/Data Table` → `Data Table`(대표: `고급 검색` 구역 하나. 추가 조건 목록 검색 + 선택·정렬·열 표시·행 액션·페이지 크기·페이지), 숨김 verification Story `Overview`(기본 표, `patterns-data-data-table--overview`), `Basic Search`(기본 목록 검색, `patterns-data-data-table--basic-search`), `With Filter Bar`(추가 조건 연동 검증). 목록 검색 구역은 흰 Card 하나에 CardTitle(`사용자 목록`) → 검색·열 표시 도구 → 표 순서다. 페이지 제목 PageHeader를 쓰는 화면에서는 PageHeader를 이 Card 밖에 둔다. verification: `WithFilterBar`, `Loading`, `Empty`.
- 샘플 데이터는 `src/patterns/sample-users.ts`(47행, 네트워크 없음).
## 10. Accessibility
+28 -11
View File
@@ -51,11 +51,26 @@ await goto('sw-header--overview')
const docButton = page.locator('.hm-sw-header__document-button').first()
await stableOnHover('Header file name', docButton, docButton.locator('.hm-sw-header__document-name'))
// SWHeader More (narrow): fields moved into the More menu are still SWHeader fields (400).
await goto('sw-header--responsive-more')
const narrow = page.locator('.hm-sw-header').filter({ visible: true }).nth(2)
// Only below 760px does the header move fields into More (sw.css `@container hm-sw-header (max-width: 759.98px)`: 선/문자/치수
// 스타일, 속성Bar / 속성창, 색상; 인쇄 / 팝업 가이드 only below 640). From 760px the More button is hidden and the 화면 너비 Story
// rows stop at 760, so the 760 frame is narrowed to 700px, the way the Viewer More check below narrows its header.
const narrowHeaderMore = async () => {
await goto('sw-header--responsive-more')
const frame = page.locator('[data-frame-width="760"]')
await frame.evaluate(e => { e.style.maxWidth = '700px' }); await page.waitForTimeout(200)
return frame.locator('.hm-sw-header')
}
const narrow = await narrowHeaderMore()
const narrowWidth = await narrow.evaluate(e => Math.round(e.getBoundingClientRect().width))
const moreTrigger = narrow.locator('.hm-sw-header__more-trigger')
if (await moreTrigger.count()) { await moreTrigger.click(); await page.waitForTimeout(200) }
const moreValues = await styles('.hm-sw-header__field-value')
check('Header More: the 700px header shows the More button', narrowWidth === 700 && await moreTrigger.isVisible(), { narrowWidth })
const movedOut = await narrow.locator('.hm-sw-header__fields > :is([data-tier="aux"], [data-menu="color"])').evaluateAll(list => list.map(e => getComputedStyle(e).display))
check('Header More: 선/문자/치수 스타일 and 색상 leave the 700px header', movedOut.length === 4 && movedOut.every(d => d === 'none'), movedOut)
await moreTrigger.click(); await page.waitForTimeout(200)
const moreLabels = (await styles('[data-frame-width="760"] .hm-sw-header__more-item > span:first-child')).map(s => s.text)
const wantMore = ['색상', '선스타일', '문자스타일', '치수스타일', '속성Bar', '속성창']
check('Header More: the menu lists 색상 · 선/문자/치수 스타일 · 속성Bar · 속성창, not 인쇄 / 팝업 가이드 (below 640 only)', wantMore.every(w => moreLabels.includes(w)) && !moreLabels.includes('인쇄') && !moreLabels.includes('팝업 가이드'), moreLabels)
const moreValues = await styles('[data-frame-width="760"] :is(.hm-sw-header__field-value, .hm-sw-header__more-value)')
check('Header More: values shown with the More menu open stay 400 (none turn Bold)', moreValues.length > 0 && moreValues.every(v => v.weight === '400'), moreValues.filter(v => v.weight !== '400'))
await page.keyboard.press('Escape')
// Composed Controls: standalone menu fields (outside SWHeader) are Bold; labels and the font sample unchanged.
@@ -118,9 +133,8 @@ await page.screenshot({ path: path.join(out, 'footer.png'), clip: { x: 0, y: 940
// SWHeader file-name button), 레이어 · 설정 · 속성 and Model / Layout; the two menus follow the SWHeader rules, measured
// on SWHeader itself below and compared row by row.
const typo = s => ({ weight: s.weight, size: s.size, family: s.family, outline: s.outline })
// SWHeader references: the responsive `...` menu rows and the file-name list rows.
await goto('sw-header--responsive-more')
const refTrigger = page.locator('.hm-sw-header__more-trigger').filter({ visible: true }).first()
// SWHeader references: the responsive `...` menu rows (the same 700px header: More shows only below 760) and the file-name list rows.
const refTrigger = (await narrowHeaderMore()).locator('.hm-sw-header__more-trigger')
await refTrigger.click(); await page.waitForTimeout(200)
const refMore = await styles('.hm-sw-header__more-menu .hm-sw-header__more-item > span:first-child')
check('SWHeader ... menu rows (reference): Regular 400 without outline', refMore.length > 0 && refMore.every(r => r.weight === '400' && r.outline === 'none'), refMore)
@@ -172,7 +186,9 @@ await stableOnHover('Viewer 레이어', lab.locator('xpath=ancestor::button[1]')
const tab = vh.locator('.hm-sw-viewer-header__tab', { hasText: 'Layout' }).first()
await stableOnHover('Viewer Layout tab', tab, tab)
// More stays: narrow header shows the More button, it opens and lists the moved items.
await vh.evaluate(e => { e.style.width = '700px' })
// 2026-10-26 (cabd2f6): the viewer header keeps every menu down to 520px (measured steps) and moves items into More only
// below that, so the header is narrowed to 400px as in viewer.browser.mjs (the 520 row narrowed to 400).
await vh.evaluate(e => { e.style.width = '400px' })
const more = vh.getByRole('button', { name: /추가 도구/ })
// 2026-10-20: the header re-measures through a ResizeObserver; wait for More instead of a fixed 200ms (the check below
// still fails when it never appears). The fixed wait failed intermittently on the same build.
@@ -203,10 +219,11 @@ const rowBefore = await rowGeo()
await stableOnHover('Recent file name', row, row.locator('.hm-sw-recent-files__name'))
await row.hover(); await page.waitForTimeout(250)
const preview = await page.locator('.hm-sw-recent-files__preview').evaluate(p => { const t = p.querySelector('.hm-sw-recent-files__preview-title'), c = getComputedStyle(t), b = p.getBoundingClientRect(), body = p.querySelector('.hm-sw-recent-files__preview-body').getBoundingClientRect()
return { title: t.textContent, weight: c.fontWeight, size: c.fontSize, color: c.color, outline: c.textShadow.split(', rgb')[0], titleW: t.getBoundingClientRect().width, overflow: c.textOverflow, w: b.width, h: b.height, bodyH: body.height, bodyEmpty: p.querySelector('.hm-sw-recent-files__preview-body').childElementCount === 0 && !p.querySelector('.hm-sw-recent-files__preview-body').textContent } })
return { title: t.textContent, weight: c.fontWeight, size: c.fontSize, color: c.color, outline: c.textShadow.split(', rgb')[0], titleW: t.getBoundingClientRect().width, overflow: c.textOverflow, w: b.width, h: b.height, bodyH: body.height, bodyContent: [...p.querySelector('.hm-sw-recent-files__preview-body').children].map(e => `${e.tagName.toLowerCase()}.${e.getAttribute('class')}`), bodyImages: p.querySelectorAll('.hm-sw-recent-files__preview-body img').length } })
report.preview = preview
// 2026-10-21 (user decision): the preview frame stays on hover; its 243px body is empty (no image / thumbnail).
check('Recent preview: title 14px 700 + 1.3px outline, width 294 with ellipsis, empty body 243px', preview.bodyEmpty && preview.weight === '700' && preview.size === '14px' && preview.outline === OUTLINE_14 && preview.titleW === 294 && preview.overflow === 'ellipsis' && preview.bodyH === 243, preview)
// 2026-10-21 (user decision): the preview frame stays on hover; its 243px body shows only the red no-preview sign
// (DESIGN_RULES 최근파일 패널), never an image / thumbnail.
check('Recent preview: title 14px 700 + 1.3px outline, width 294 with ellipsis, body 243px with only the no-preview sign', JSON.stringify(preview.bodyContent) === '["svg.hm-sw-recent-files__preview-none"]' && preview.bodyImages === 0 && preview.weight === '700' && preview.size === '14px' && preview.outline === OUTLINE_14 && preview.titleW === 294 && preview.overflow === 'ellipsis' && preview.bodyH === 243, preview)
await page.screenshot({ path: path.join(out, 'recent-files.png'), clip: { x: 0, y: 0, width: 1100, height: 900 } })
check('Recent files: rows keep 50px and their text widths', rowBefore === await rowGeo() && rowBefore.startsWith('50,'), rowBefore)
await row.press('Enter').catch(() => {})
+1 -1
View File
@@ -155,7 +155,7 @@ export const All: Story = {
render: () => (
<StoryPage>
<StoryPageHeader title="Data Table" />
<StorySection title="Advanced Search"><WithFilterBar /></StorySection>
<StorySection title="고급 검색"><WithFilterBar /></StorySection>
</StoryPage>
),
}
+3 -3
View File
@@ -112,13 +112,13 @@ function ThemeEditor() {
return <StoryPage><main className="hm-designer">
<header className="hm-designer-header">
<div><p>HM DESIGN SYSTEM / COLOR THEME</p><h1>디자인팀 Theme Playground</h1>
<p>임시 컬러는 이 편집기와 Live Preview에만 적용됩니다. 편집값은 현재 탭의 세션 동안 보관하며 다른 Story는 HM Default로 검수합니다.</p></div>
<p>임시 컬러는 이 편집기와 실시간 미리보기에만 적용됩니다. 편집값은 현재 탭의 세션 동안 보관하며 다른 Story는 HM Default로 검수합니다.</p></div>
<button type="button" onClick={reset}>Reset to HM Default</button>
</header>
<div className="hm-designer-layout">
<section className="hm-designer-editor" aria-label="Theme color editor">
<section className="hm-designer-source" aria-label="Theme JSON source">
<h2>Theme JSON Preview</h2><p>원본은 themes/*.json입니다. 파일을 Import하거나 서비스의 CDN URL을 입력하세요.</p>
<h2>Theme JSON 미리보기</h2><p>원본은 themes/*.json입니다. 파일을 Import하거나 서비스의 CDN URL을 입력하세요.</p>
<div className="hm-designer-actions"><button type="button" disabled={loading} onClick={() => void loadUrl("/themes/default.json")}>Load Default JSON</button>
<button type="button" disabled={loading} onClick={() => void loadUrl("/themes/service-example.json")}>Load Service Example JSON</button></div>
<label htmlFor="hm-theme-url">Theme JSON URL</label><input id="hm-theme-url" value={themeUrl} onChange={event => setThemeUrl(event.target.value)} />
@@ -146,7 +146,7 @@ function ThemeEditor() {
<output aria-live="polite">{status}</output>
</section>
</section>
<section className="hm-designer-preview" aria-label="Live component preview"><h2>Live Preview</h2>
<section className="hm-designer-preview" aria-label="Live component preview"><h2>실시간 미리보기</h2>
<p>Primary · Active · Focus · Accent · Destructive의 영향을 여러 컴포넌트에서 함께 확인하세요.</p>
<label htmlFor="hm-preview-navigation-mode">Navigation Mode</label>
<select id="hm-preview-navigation-mode" value={navigationMode} onChange={event => setNavigationMode(event.target.value === "LNB" ? "LNB" : "GNB")}>