diff --git a/packages/sw/ai/AI_USAGE.md b/packages/sw/ai/AI_USAGE.md index 9f93bb9..e48f607 100644 --- a/packages/sw/ai/AI_USAGE.md +++ b/packages/sw/ai/AI_USAGE.md @@ -137,6 +137,7 @@ SW 토큰(`--hm-sw-header-*`, `--hm-sw-property-*`, `--hm-sw-popup-*`, `--hm-sw- 5. **하지 않음:** `hm-sw-*` class를 선택자로 쓴 CSS(`.hm-sw-header, .hm-sw-theme { --… }` 등), `!important`, 안쪽 컴포넌트마다 같은 값을 반복 지정, SW 범위가 아닌 조상(`body`, 앱 루트 `div`)에만 토큰 지정(SW 범위가 기본값을 다시 선언하므로 적용되지 않음). - 다른 토큰을 참조하는 토큰(예: `--hm-sw-viewer-panel-background` = `var(--hm-sw-property-surface)`)은 가장 바깥 범위에서 계산됩니다. 참조되는 토큰을 바꿀 때도 가장 바깥 범위에서 바꿉니다. - Step Header(`SWProcessHeader`, 2026-11-10): 칠하는 SWHeader 토큰 18개를 `--hm-sw-process-*` alias(기본값 `var(--hm-sw-header-…)`)로 읽습니다. 아무것도 정하지 않으면 SWHeader 테마를 따르고, `--hm-sw-process-selected-color` 등을 가장 바깥 범위에 정하면 Step Header만 바뀝니다. 위 규칙대로 안쪽 요소에서 바꾼 `--hm-sw-header-*`는 이 alias에 전달되지 않습니다. 목록·공유 유지 토큰은 [DESIGN_RULES.md](./DESIGN_RULES.md) §14. +- Viewer Header(`SWViewerHeader`, 2026-11-10): 파일명·객체 수정·속성 면의 SWHeader 토큰 10개를 `--hm-sw-viewer-document-*`·`--hm-sw-viewer-object-*`·`--hm-sw-viewer-property-*` alias(기본값 `var(--hm-sw-header-…)`)로 읽습니다. 기본은 SWHeader 테마 공유 그대로이고, 가장 바깥 범위에 `--hm-sw-viewer-*`를 정하면 Viewer Header만 바뀝니다. 안쪽 요소에서 바꾼 `--hm-sw-header-*`는 이 alias에 전달되지 않습니다. 목록·적용 범위는 [DESIGN_RULES.md](./DESIGN_RULES.md) §11. ## 4. 판단 기록 남기기 diff --git a/packages/sw/ai/DESIGN_RULES.md b/packages/sw/ai/DESIGN_RULES.md index 3a24b5e..0307e85 100644 --- a/packages/sw/ai/DESIGN_RULES.md +++ b/packages/sw/ai/DESIGN_RULES.md @@ -160,6 +160,7 @@ - **Model / Layout 탭 토큰:** 위 `--hm-sw-viewer-tabs-*`·`--hm-sw-viewer-tab-*`은 탭 컨테이너·버튼에만 연결되며 다른 Header 부품·SWHeader·Footer·패널에 쓰이지 않습니다. 탭 색만 바꾸려면 가장 바깥 SW 범위의 `style`로 덮습니다: 화면을 ``로 감싸거나 단독 `SWViewerHeader`의 루트에 지정합니다(토큰이 그 범위에 선언되므로 일반 `div` 래퍼의 값은 가려짐). 예: `style={{ "--hm-sw-viewer-tab-active-border": "linear-gradient(#e07b00, #e07b00)", "--hm-sw-viewer-tab-inactive-border": "linear-gradient(#3d6bb3, #3d6bb3)" }}`. `var(...)` 기본값(`active-text`·`hover-*`·`focus-color`)은 SW 범위에서 계산되므로 공통 토큰(`--hm-sw-viewer-accent` 등)을 바꾸면 같은 범위에서 함께 따라가고, 탭 토큰을 직접 지정하면 공통 토큰과 독립합니다. 글자 외곽선(`text-shadow`)은 탭에서도 기존 그대로(이름 `--hm-sw-header-text-outline` 1.3px, 개수 `--hm-sw-header-label-outline` 1px)이며 탭 토큰이 아닙니다. 탭 class로 색을 덮지 않습니다. - **공유 토큰(모드 혼합 아님):** 뷰어 Header의 파일명·객체 수정·속성 면은 `--hm-sw-header-document-*`, `--hm-sw-header-object-*`, `--hm-sw-header-property-*`를, 글자 외곽선은 `--hm-sw-header-text-outline`·`--hm-sw-header-label-outline`을 그대로 읽습니다(§2·§3). 패널 제목 막대 추가 버튼(`ghost`)의 Hover·Focus 색은 `--hm-sw-header-selected-color`입니다. +- **Viewer Header 전용 덮어쓰기(2026-11-10, 공유 유지):** 위 면 토큰 10개는 같은 이름의 `--hm-sw-viewer-*` alias(가장 바깥 SW 범위, 기본값 `var(--hm-sw-header-…)`)를 거쳐 읽습니다 — `--hm-sw-viewer-document-background`·`-document-border-gradient`·`-document-border-color`, `--hm-sw-viewer-object-background`·`-object-border-gradient`·`-object-radius`, `--hm-sw-viewer-property-background`·`-property-border-color`·`-property-radius`·`-property-shadow`. 적용 범위: 파일명 면, 객체 수정 면, 속성 입력 그룹(`data-surface="field"`), 오른쪽 상태·버튼 띠와 그 시작 조각. 기본 동작은 그대로 공유입니다(정하지 않으면 SWHeader 테마를 따름). Viewer Header만 바꾸려면 가장 바깥 SW 범위(`` 또는 단독 `SWViewerHeader`의 `style`)에 `--hm-sw-viewer-*`를 지정합니다(SWHeader·Step Header 영향 없음). 제약: alias는 가장 바깥 범위에서 계산되므로 안쪽 요소에서만 바꾼 `--hm-sw-header-*`는 이 10개에 전달되지 않습니다. 파일명 버튼 등 재사용 SWHeader 부품의 `sw.css` 규칙(로고가 있을 때 로고 칸 바탕 — SWHeader 홈과 같은 공용 규칙 `.hm-sw-header__home::before, .hm-sw-viewer-header__brand[data-logo]::before` — 포함), Dropdown·More·파일 목록, 패널(`selected-color`·`input-color`), 글자 외곽선은 계속 `--hm-sw-header-*`를 읽습니다. | 상태 | Header 항목(button·toggle·menu·tabs·switch) | 패널 콘텐츠 | |---|---|---| diff --git a/packages/sw/ai/VIEWER.md b/packages/sw/ai/VIEWER.md index d3df82d..c71361b 100644 --- a/packages/sw/ai/VIEWER.md +++ b/packages/sw/ai/VIEWER.md @@ -48,6 +48,7 @@ - **Dropdown 색(2026-10-21):** Header 안의 Dropdown — 메뉴 버튼(`type: "menu"`), More(⋯), 로고 메뉴 — 은 크기·간격·글자는 `SWViewerMenu` 그대로, 색은 일반 `SWHeader`의 `...` 메뉴와 같은 More 토큰을 읽습니다(2026-11-10: 면 `--hm-sw-header-more-menu-background` #0e3d2f, Border `--hm-sw-header-more-menu-border-color` = `var(--hm-sw-header-object-border-color)`, 그림자 `--hm-sw-header-more-menu-shadow` 0 4px 12px rgba(0,0,0,.5), 글자 `--hm-sw-header-text-color`, 보조 글자는 같은 색 80%, Hover `--hm-sw-header-more-item-hover-background` = `var(--hm-sw-header-hover)`, 구분선 `--hm-sw-header-document-menu-divider-color`). 파일명 목록(`documents`)은 `SWHeader` 파일명 목록 모양을 유지하고, 단독 `SWViewerMenu`(우클릭 메뉴 등)는 기존 색 그대로입니다. - **상태 표현:** Hover·Active·Pressed·열림·선택 탭 = 글자·아이콘 #FFC600(면·선택 박스 없음, Hover 전후 크기·위치 동일 — 2026-10-21 Hover 면 #266651 제거), Disabled = 60% 불투명, Focus = 2px #FFC600 외곽선. - **글자·아이콘 외곽선(SWHeader와 동일):** 14px 글자 `--hm-sw-header-text-outline`(검정 1.3px), 11px 캡션·개수·상태 `--hm-sw-header-label-outline`(검정 1px), 아이콘 `SWViewerIcon`(SWHeader 아이콘 자산의 1px 검정 바깥 Stroke를 **SVG stroke**로 재현: 아이콘 뒤 복사본의 모든 도형을 검정, 원래 선 두께 + 화면 기준 양쪽 1px(`--hm-sw-viewer-icon-outline-width`)로 그림. 위 아이콘은 그대로라 색·모양·선 두께 유지. CSS `filter` 미사용, glow는 생략). Header 항목·`SWViewerHeaderButton`의 `icon`은 자동 적용, `custom` / `trailing`의 직접 그린 아이콘은 ``으로 감쌉니다. 색·크기·굵기·간격은 그대로이고 Hover/Active는 채움 색만 바뀝니다. Dropdown·패널 안에는 외곽선이 없습니다(SWHeader More 메뉴와 같음). 프로그램 아이콘은 `currentColor`로 그리면 상태 색과 외곽선이 자동 적용됩니다. +- **면 색(SWHeader와 공유, 2026-11-10 Viewer 전용 덮어쓰기 추가):** 파일명·객체 수정·속성 입력 면과 오른쪽 상태·버튼 띠는 기본으로 SWHeader 토큰(`--hm-sw-header-document-*`·`-object-*`·`-property-*`)을 따릅니다. Viewer Header만 바꾸려면 같은 이름의 `--hm-sw-viewer-document-*`·`--hm-sw-viewer-object-*`·`--hm-sw-viewer-property-*`(10개)를 가장 바깥 SW 범위(`` 또는 단독 `SWViewerHeader`의 `style`)에 지정합니다. 안쪽 요소에서 바꾼 `--hm-sw-header-*`는 이 값에 전달되지 않습니다. 목록은 [DESIGN_RULES.md](./DESIGN_RULES.md) §11. ### 2.1 두 가지 구성 (2026-10-21) diff --git a/packages/sw/src/theme/sw-viewer.css b/packages/sw/src/theme/sw-viewer.css index 5ad4e47..e2bca6d 100644 --- a/packages/sw/src/theme/sw-viewer.css +++ b/packages/sw/src/theme/sw-viewer.css @@ -111,6 +111,22 @@ /* Title-bar icon buttons of viewer panels (collapse, `ghost` header actions; 2026-10-23). Popup-only: the header status text and switch captions keep --hm-sw-viewer-muted. SWPopup's own title icons are images. */ --hm-sw-viewer-panel-icon-color: #c7bda9; + + /* Viewer Header faces (2026-11-10): the file name, object and property surfaces, the trailing status / button strip and + its start piece read these copies of the SWHeader tokens. Each defaults to the SWHeader token, so the shared look + stays (a --hm-sw-header-* theme on the outermost SW scope still reaches the viewer); set --hm-sw-viewer-* there to + change the Viewer Header alone. Resolved on the outermost scope like every alias here: a --hm-sw-header-* value set + on an inner element no longer reaches these. Reused SWHeader parts keep reading --hm-sw-header-* in sw.css. */ + --hm-sw-viewer-document-background: var(--hm-sw-header-document-background); + --hm-sw-viewer-document-border-gradient: var(--hm-sw-header-document-border-gradient); + --hm-sw-viewer-document-border-color: var(--hm-sw-header-document-border-color); + --hm-sw-viewer-object-background: var(--hm-sw-header-object-background); + --hm-sw-viewer-object-border-gradient: var(--hm-sw-header-object-border-gradient); + --hm-sw-viewer-object-radius: var(--hm-sw-header-object-radius); + --hm-sw-viewer-property-background: var(--hm-sw-header-property-background); + --hm-sw-viewer-property-border-color: var(--hm-sw-header-property-border-color); + --hm-sw-viewer-property-radius: var(--hm-sw-header-property-radius); + --hm-sw-viewer-property-shadow: var(--hm-sw-header-property-shadow); } .hm-sw-viewer-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; } @@ -179,7 +195,7 @@ button.hm-sw-viewer-header__brand { text-align: left; } /* SWHeader file-name surface: the Figma gradient stroke through the transparent top / right / bottom 1px. */ border: 1px solid transparent; border-left: 0; - background: var(--hm-sw-header-document-background) padding-box, var(--hm-sw-header-document-border-gradient) border-box; + background: var(--hm-sw-viewer-document-background) padding-box, var(--hm-sw-viewer-document-border-gradient) border-box; } .hm-sw-viewer-header__document-name { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; } @@ -339,8 +355,8 @@ button.hm-sw-viewer-header__brand { text-align: left; } padding-inline: var(--hm-sw-viewer-gap); /* SWHeader object surface: the Figma gradient stroke through the transparent 1px border. */ border: 1px solid transparent; - border-radius: var(--hm-sw-header-object-radius); - background: var(--hm-sw-header-object-background) padding-box, var(--hm-sw-header-object-border-gradient) border-box; + border-radius: var(--hm-sw-viewer-object-radius); + background: var(--hm-sw-viewer-object-background) padding-box, var(--hm-sw-viewer-object-border-gradient) border-box; } .hm-sw-viewer-header__group { @@ -351,10 +367,10 @@ button.hm-sw-viewer-header__brand { text-align: left; } padding-inline: var(--hm-sw-viewer-gap); } .hm-sw-viewer-header__group[data-surface="field"] { - border: 1px solid var(--hm-sw-header-property-border-color); - border-radius: var(--hm-sw-header-property-radius); - background: var(--hm-sw-header-property-background); - box-shadow: var(--hm-sw-header-property-shadow); + border: 1px solid var(--hm-sw-viewer-property-border-color); + border-radius: var(--hm-sw-viewer-property-radius); + background: var(--hm-sw-viewer-property-background); + box-shadow: var(--hm-sw-viewer-property-shadow); } .hm-sw-viewer-header__actions { display: flex; @@ -589,10 +605,10 @@ button.hm-sw-viewer-header__brand { text-align: left; } align-self: stretch; height: auto; border-radius: 0; - background: var(--hm-sw-header-document-background); + background: var(--hm-sw-viewer-document-background); /* Only the 1px line before a segment: top, bottom and right are the object surface border itself, so every segment (and the license button between them) has the same top and bottom edge (2026-10-21; no doubled lines). */ - box-shadow: inset 1px 0 0 var(--hm-sw-header-document-border-color); + box-shadow: inset 1px 0 0 var(--hm-sw-viewer-document-border-color); position: relative; } /* Strip outline (2026-10-23): the file-name gradient stroke (--hm-sw-header-document-border-gradient) over the object @@ -606,7 +622,7 @@ button.hm-sw-viewer-header__brand { text-align: left; } inset: 0; border: 0 solid transparent; border-block-width: 1px; - border-image: var(--hm-sw-header-document-border-gradient) 1 / 1 / 1 0; + border-image: var(--hm-sw-viewer-document-border-gradient) 1 / 1 / 1 0; pointer-events: none; } .hm-sw-viewer-header .hm-sw-viewer-header__actions > :is(.hm-sw-viewer-status, .hm-sw-viewer-header__button, .hm-sw-header__window-buttons):last-child::after { @@ -632,7 +648,7 @@ button.hm-sw-viewer-header__brand { text-align: left; } /* 2026-10-23: the first segment's left side is the strip's outer gradient line, painted inside it (a 1px layer in place of the flat divider, so its width is unchanged). */ .hm-sw-viewer-header .hm-sw-viewer-header__actions > :is(.hm-sw-viewer-status, .hm-sw-viewer-header__button, .hm-sw-header__window-buttons):not(:is(.hm-sw-viewer-status, .hm-sw-viewer-header__button, .hm-sw-header__window-buttons) + *) { - background: var(--hm-sw-header-document-border-gradient) left top / 1px 100% no-repeat border-box, var(--hm-sw-header-document-background); + background: var(--hm-sw-viewer-document-border-gradient) left top / 1px 100% no-repeat border-box, var(--hm-sw-viewer-document-background); box-shadow: none; } /* The empty strip-start mark (SWViewerHeader, right before `trailing`) carries the surface end, so the first segment @@ -646,21 +662,21 @@ button.hm-sw-viewer-header__brand { text-align: left; } position: absolute; top: calc(-1 * var(--hm-sw-header-object-border-width, 1px)); bottom: calc(-1 * var(--hm-sw-header-object-border-width, 1px)); - left: calc(-1 * (var(--hm-sw-header-object-radius) + 2px)); + left: calc(-1 * (var(--hm-sw-viewer-object-radius) + 2px)); pointer-events: none; } .hm-sw-viewer-header__strip-start:has(+ :is(.hm-sw-viewer-status, .hm-sw-viewer-header__button, .hm-sw-header__window-buttons))::before { - width: calc(var(--hm-sw-header-object-radius) + 2px); + width: calc(var(--hm-sw-viewer-object-radius) + 2px); background: var(--hm-sw-viewer-background); } .hm-sw-viewer-header__strip-start:has(+ :is(.hm-sw-viewer-status, .hm-sw-viewer-header__button, .hm-sw-header__window-buttons))::after { box-sizing: border-box; - width: var(--hm-sw-header-object-radius); + width: var(--hm-sw-viewer-object-radius); /* The object surface end: its face and gradient stroke, spanning the same 1px-outset height so both line up. */ border: 1px solid transparent; border-left: 0; - border-radius: 0 var(--hm-sw-header-object-radius) var(--hm-sw-header-object-radius) 0; - background: var(--hm-sw-header-object-background) padding-box, var(--hm-sw-header-object-border-gradient) border-box; + border-radius: 0 var(--hm-sw-viewer-object-radius) var(--hm-sw-viewer-object-radius) 0; + background: var(--hm-sw-viewer-object-background) padding-box, var(--hm-sw-viewer-object-border-gradient) border-box; } /* No line around the license / info button: the window buttons right after it drop their line before it. */ .hm-sw-viewer-header .hm-sw-viewer-header__actions > .hm-sw-viewer-header__button + .hm-sw-header__window-buttons { box-shadow: none; } diff --git a/packages/sw/tests/theme-inheritance.browser.mjs b/packages/sw/tests/theme-inheritance.browser.mjs index e0045b7..8ac16e0 100644 --- a/packages/sw/tests/theme-inheritance.browser.mjs +++ b/packages/sw/tests/theme-inheritance.browser.mjs @@ -230,6 +230,50 @@ async function dropdownColors(page, def, themed) { await header.close() } +// ---------- 1c. Viewer Header (2026-11-10): --hm-sw-viewer-document-* / -object-* / -property-* face aliases ---------- +// Shared by default (an SWHeader theme on the outermost SW scope reaches the viewer faces); a --hm-sw-viewer-* value there +// changes the Viewer Header faces alone and leaves SWHeader and the Step Header as they are. +{ + const setOn = (page, root, values) => page.locator(root).first().evaluate((element, values) => { + let outer = element + for (let e = element; e; e = e.parentElement) if (e.matches?.('.hm-sw-header, .hm-sw-theme')) outer = e + Object.entries(values).forEach(([token, value]) => value === null ? outer.style.removeProperty(token) : outer.style.setProperty(token, value)) + }, values) + const page = await newPage() + await open(page, 'sw-viewer--viewer-header') + const read = () => page.evaluate(() => { + // Every match (the Story has more than one header; some file-name areas carry no face of their own). + const paint = selector => { const all = [...document.querySelectorAll(selector)]; if (!all.length) return null; return all.map(e => { const s = getComputedStyle(e); return `${s.backgroundColor} ${s.backgroundImage}` }).join(' || ') } + // The document face token paints the trailing status / button strip segments in this Story. + return { document: paint('.hm-sw-viewer-header__actions > :is(.hm-sw-viewer-status, .hm-sw-viewer-header__button, .hm-sw-header__window-buttons)'), property: paint('.hm-sw-viewer-header__group[data-surface="field"]') } + }) + const A = 'linear-gradient(rgb(44, 55, 66), rgb(44, 55, 66))', B = 'linear-gradient(rgb(1, 2, 3), rgb(1, 2, 3))' + const base = await read() + await setOn(page, '.hm-sw-viewer-header', { '--hm-sw-header-document-background': A, '--hm-sw-header-property-background': A }) + const inherited = await read() + check('Viewer Header: 바깥 범위의 SWHeader 토큰(파일명·속성 면)이 그대로 전달', base.document !== null && base.property !== null && inherited.document.includes('rgb(44, 55, 66)') && inherited.property.includes('rgb(44, 55, 66)'), JSON.stringify({ base, inherited })) + await setOn(page, '.hm-sw-viewer-header', { '--hm-sw-viewer-document-background': B, '--hm-sw-viewer-property-background': B }) + const own = await read() + check('Viewer Header: --hm-sw-viewer-* 지정이 SWHeader 토큰보다 우선', own.document.includes('rgb(1, 2, 3)') && own.property.includes('rgb(1, 2, 3)'), JSON.stringify(own)) + await setOn(page, '.hm-sw-viewer-header', { '--hm-sw-header-document-background': null, '--hm-sw-header-property-background': null, '--hm-sw-viewer-document-background': null, '--hm-sw-viewer-property-background': null }) + check('Viewer Header: 지정을 지우면 기본값으로 복귀', JSON.stringify(await read()) === JSON.stringify(base), JSON.stringify(base)) + await page.close() + // The Viewer Header face tokens on SWHeader and Step Header pages change nothing there. + const names = ['document-background', 'document-border-gradient', 'document-border-color', 'object-background', 'object-border-gradient', 'object-radius', 'property-background', 'property-border-color', 'property-radius', 'property-shadow'] + const values = Object.fromEntries(names.map(name => [`--hm-sw-viewer-${name}`, name.endsWith('radius') ? '9px' : name.endsWith('shadow') ? '0 0 0 3px rgb(1, 2, 3)' : name.endsWith('color') ? 'rgb(1, 2, 3)' : B])) + const untouched = [] + for (const [id, root] of [['sw-header--overview', '.hm-sw-header'], ['sw-step-header--process-header', '.hm-sw-process-header']]) { + const other = await newPage() + await open(other, id) + const paint = () => other.evaluate(root => [...document.querySelectorAll(`${root}, ${root} *`)].map(element => { const s = getComputedStyle(element); return `${s.color}|${s.backgroundColor}|${s.backgroundImage}|${s.boxShadow}|${s.borderTopColor}|${s.borderTopLeftRadius}` }).join('\n'), root) + const before = await paint() + await setOn(other, root, values) + untouched.push(`${id} ${before === await paint() ? 'same' : 'CHANGED'} ${before.split('\n').length}`) + await other.close() + } + check('Viewer Header 토큰은 SWHeader·Step Header 화면에 영향 없음', untouched.every(item => item.includes(' same ')), untouched.join(', ')) +} + // ---------- 2. Regression: new cascade vs legacy cascade on every existing SW Story ---------- const here = path.dirname(fileURLToPath(import.meta.url)) const outermost = ':is(.hm-sw-header, .hm-sw-theme):not(:where(.hm-sw-header, .hm-sw-theme) *)'