Author SHA1 Message Date
shkim4 d159ad58af fix(sw): restore dropdown focus after position calculation 2026-11-10 15:33:30 +09:00
shkim4 4fc37c7dd3 Merge pull request 'test(sw): align regression tests with current components' (#31) from fix/sw-tests-alignment into main
Deploy Storybook / deploy (push) Successful in 1m20s
Reviewed-on: #31
2026-11-10 14:54:29 +09:00
shkim4 b01ccaca01 test(sw): align regression tests with current components 2026-11-10 14:53:06 +09:00
shkim4 3cbe879910 Merge pull request 'feat(sw): add independent popup and more design tokens' (#30) from feat/sw-popup-more-tokens into main
Deploy Storybook / deploy (push) Successful in 1m21s
Reviewed-on: #30
2026-11-10 13:36:42 +09:00
shkim4 4c6fff5dea feat(sw): add independent popup and more design tokens 2026-11-10 13:33:27 +09:00
shkim4 39a089ad0e Merge pull request 'Feat/chart package' (#29) from feat/chart-package into main
Deploy Storybook / deploy (push) Successful in 1m21s
Reviewed-on: #29
2026-11-10 11:53:04 +09:00
12 changed files with 351 additions and 69 deletions

No files matched your search

+3 -2
View File
@@ -556,7 +556,8 @@ Composed Controls의 기존 레이어·색상·상세보기 예제는 보존하
| `--hm-sw-header-selected-color` | `#FFC600` | 선택 |
| `--hm-sw-header-focus-color` | `#F0F7F5` | Focus |
| `--hm-sw-header-arrow-color` | `#47AF91` | 파일명 화살표 |
| `--hm-sw-header-hover` | `rgba(255,255,255,.08)` | 더보기 메뉴 항목 Hover 배경 |
| `--hm-sw-header-hover` | `rgba(255,255,255,.08)` | 더보기 메뉴 항목 Hover 배경(`--hm-sw-header-more-item-hover-background` 기본값) |
| `--hm-sw-header-more-menu-background` / `-more-menu-shadow` / `-more-menu-border-color` / `-more-item-hover-background` | `#0E3D2F` / `0 4px 12px rgba(0,0,0,.5)` / `var(--hm-sw-header-object-border-color)` / `var(--hm-sw-header-hover)` | 더보기(`...`) 메뉴 면·그림자·테두리·항목 Hover(2026-11-10 개별 토큰, 값 변경 없음). 뷰어 Header Dropdown·로고 메뉴도 사용 |
| `--hm-sw-header-hover-border-gradient` / `-hover-border-width` | Figma 홈 hover gradient / 1px | Hover 테두리(§11.1) |
| `--hm-sw-header-home-on-line-color` / `-home-on-line-width` | `#5F4B0F` / 2px | Home On(로고 메뉴 열림, Figma 662:67267): Header 전폭 상·하단 선. 로고는 Hover 테두리 유지 |
| `--hm-sw-header-document-menu-width` / `-document-menu-background` / `-document-menu-border-color` / `-document-menu-border-width` | 310px / `#103127` / `#041611` / 1px | 문서 Dropdown(§5.1) |
@@ -575,7 +576,7 @@ Composed Controls의 기존 레이어·색상·상세보기 예제는 보존하
| `--hm-sw-header-layer-footer-border-color` / `-layer-detail-background` / `-layer-detail-color` / `-layer-detail-text-opacity` | `rgba(197,177,158,.15)` / `rgba(197,177,158,.15)` / `#FFF` / .35 | 레이어 상세 영역·버튼 |
| `--hm-sw-header-document-background` / `-document-border-color` | §10 B / `#0E4C3A` | 파일명 면, 홈 뒤, 창 영역, 인쇄 버튼(단색 선: 홈 뒤·창 영역) |
| `--hm-sw-header-document-border-gradient` | §7 | 파일명 면 테두리(SW Header·Viewer, Open 면 포함) |
| `--hm-sw-header-object-background` / `-object-border-color` / `-object-radius` | §10 A / `#135643` / 6px | 객체 수정 면, 더보기 메뉴 테두리(단색) |
| `--hm-sw-header-object-background` / `-object-border-color` / `-object-radius` | §10 A / `#135643` / 6px | 객체 수정 면, 더보기 메뉴 테두리(단색, `--hm-sw-header-more-menu-border-color` 기본값) |
| `--hm-sw-header-object-border-gradient` | §7 | 객체 수정 면 테두리(SW Header·Viewer, Viewer 끝 둥근 면 포함) |
| `--hm-sw-header-property-background` / `-property-border-color` / `-property-radius` / `-property-shadow` | §10 C / `#08251C` / 3px / §10 C | 5개 필드, 더보기 버튼 |
| `--hm-sw-header-pill-background` / `-pill-border-color` | `rgba(0,0,0,.8)` / `#092920` | 인쇄·가이드 pill |
+1 -1
View File
@@ -121,7 +121,7 @@ import "@hm-design/sw/styles.css" // 앱 진입점에서 한 번
### 3.1 테마(토큰) 설정 — 공식 방법 (2026-10-20)
SW 토큰(`--hm-sw-header-*`, `--hm-sw-property-*`, `--hm-sw-viewer-*`, `--hm-sw-footer-*`)의 기본값은 **가장 바깥 SW 범위**(`<SWTheme>`, 또는 단독으로 쓴 `SWHeader`·`SWViewerHeader`·`SWViewerPanel`·`SWViewerMenu`·`SWFooter`의 루트)에서 한 번만 선언됩니다. 그 안의 SW 요소 — Header의 Dropdown·More, `SWViewerMenu`, `SWViewerPanel`과 안쪽 컨트롤, `SWFooterCommandLine`, Tooltip·안내 — 는 자기 루트에 `hm-sw-theme`이 있어도 값을 다시 정하지 않고 **상속**합니다.
SW 토큰(`--hm-sw-header-*`, `--hm-sw-property-*`, `--hm-sw-popup-*`, `--hm-sw-viewer-*`, `--hm-sw-footer-*`)의 기본값은 **가장 바깥 SW 범위**(`<SWTheme>`, 또는 단독으로 쓴 `SWHeader`·`SWViewerHeader`·`SWViewerPanel`·`SWViewerMenu`·`SWFooter`의 루트)에서 한 번만 선언됩니다. 그 안의 SW 요소 — Header의 Dropdown·More, `SWViewerMenu`, `SWViewerPanel`과 안쪽 컨트롤, `SWFooterCommandLine`, Tooltip·안내 — 는 자기 루트에 `hm-sw-theme`이 있어도 값을 다시 정하지 않고 **상속**합니다.
1. 화면 전체(Header·Panel·Menu가 같은 값)를 바꾸려면 화면을 `<SWTheme style={theme}>` 하나로 감싸고 거기서만 토큰을 정합니다. 프로그램 class(`<SWTheme className="app-sw-theme">` + `.app-sw-theme { --hm-sw-header-font-family: … }`)도 됩니다.
```tsx
+7 -4
View File
@@ -71,6 +71,7 @@
- 목록 최대 높이 240px(8행), 넘치면 내부 스크롤. 색상 팔레트 118px.
- 속성창·K 가이드는 `position: fixed` Overlay(뷰포트 안 보정, Portal·Dim 없음).
- 한 번에 하나의 Dropdown(SWHeader). 단독 사용 시 각 컴포넌트가 바깥 클릭으로 스스로 닫힙니다.
- More(`...`) 메뉴 색(2026-11-10 개별 토큰, 값 변경 없음): 면 `--hm-sw-header-more-menu-background`(`#0E3D2F`), 그림자 `--hm-sw-header-more-menu-shadow`(`0 4px 12px rgba(0,0,0,.5)`), 테두리 `--hm-sw-header-more-menu-border-color`(기본 `var(--hm-sw-header-object-border-color)`), 항목 Hover `--hm-sw-header-more-item-hover-background`(기본 `var(--hm-sw-header-hover)`). 뷰어 Header의 Dropdown·로고 메뉴도 같은 토큰을 읽습니다([VIEWER.md](./VIEWER.md)).
## 6. Motion
@@ -102,11 +103,13 @@
| 항목 | 값 | 토큰 |
|---|---|---|
| 폭 / 높이 | 314px / 콘텐츠 높이(최대 화면 − 8px), 넘치면 본문 세로 스크롤 | — |
| 표면 / 테두리 / radius | `#E4DBC9` / 1px `#A67D03` / 4px, 안쪽 1px 검정 그림자 | `--hm-sw-property-surface`, `--hm-sw-property-border` |
| 제목줄 | 30px, Figma 제목 아트, 제목 14px 700 `#FFC600` + 검정 외곽선, 닫기 44 × 30(Hover 자산) | `--hm-sw-header-selected-color` |
| 표면 / 테두리 / radius | `#E4DBC9` / 1px `#A67D03` / 4px, 안쪽 1px 검정 그림자 | `--hm-sw-property-surface`, `--hm-sw-property-border`, `--hm-sw-popup-frame-shadow` |
| 제목줄 | 30px, Figma 제목 아트, 면 = 파일명 면 gradient, 제목 14px 700 `#FFC600` + 검정 외곽선, 닫기 44 × 30(Hover 자산) | `--hm-sw-popup-titlebar-background`(기본 `var(--hm-sw-header-document-background)`), `--hm-sw-popup-title-color`(기본 `var(--hm-sw-header-selected-color)`), `--hm-sw-popup-title-outline`(기본 `var(--hm-sw-header-label-outline)`) |
| 본문 | padding 7px, 그룹 사이 8px | — |
| 본문 스크롤바 | `thin`, Thumb `#9A9284` / Track `#E0DACE`(DWG/DXF Viewer 패널과 동일). 본문과 그 안의 스크롤 영역에 적용, 페이지 스크롤바는 그대로 | `--hm-sw-popup-scrollbar-thumb`, `--hm-sw-popup-scrollbar-track` |
| 그룹 면(`SWPopupSection`) | 제목 700 · 검정 70%, 내용 면 `#F1ECE1`, 1px `#585349`, radius 4px | `--hm-sw-property-content` |
| 그룹 면(`SWPopupSection`) | 제목 700 · 검정 70%, 내용 면 `#F1ECE1`, 1px `#585349`, radius 4px, 안쪽 입체 그림자 | `--hm-sw-property-content`, `--hm-sw-popup-group-heading-color`, `--hm-sw-popup-group-border-color`, `--hm-sw-popup-group-shadow` |
| 구분선 | 개수 목록 아래선 검정 50%, 위치 표 선 검정 40% | `--hm-sw-popup-divider-color`, `--hm-sw-popup-table-line-color` |
| Switch 그림자 | Off·On 안쪽 그림자 + 테두리 링 + 1px `#FCF0D8` 하이라이트, Thumb `0 .5px 1px rgba(0,0,0,.6)` | `--hm-sw-switch-off-shadow`, `--hm-sw-switch-on-shadow`, `--hm-sw-switch-thumb-shadow` |
| 속성 행(`SWPopupPropertyRow`) | 속성창 행 그대로(라벨 72px 700, 값 상자 28px, `picker` 필드). 데이터 = `SWHeaderProperty` + `menus` | `--hm-sw-property-*`, `--hm-sw-property-field-*` |
| 행(`SWPopupRow`) | 최소 32px, 라벨 72px 700 검정, 값 글자 `#E60403` 500, 단위 12px | `--hm-sw-property-label`, `--hm-sw-header-input-color` |
| 값 상자 | 28px, Hover 1px `rgba(33,29,19,.4)`, 숫자 편집 On `#0F0D0A`(단위 흰색), Dropdown On `#211D13` | `--hm-sw-property-field-*` |
@@ -139,7 +142,7 @@
5. 값은 Props, 동작은 Callback. CAD 데이터를 내부에서 바꾸지 않는다.
6. `hm-sw-*` class를 새로 쓰거나 덮어쓰지 않는다(자기 접두사 사용, 예: `app-`).
7. 버튼·탭·토글이면 먼저 `SWToolButton`의 세 모양(§9)으로 해결되는지 본다. 팝업·설정창이면 `SWPopup` + `SWPopupSection` / `SWPopupRow`(§8).
8. 팝업 안의 새 콘텐츠는 §8의 Popup 토큰(`--hm-sw-property-*`)만 읽는다. Header용 어두운 토큰을 밝은 Popup 면에 섞지 않는다.
8. 팝업 안의 새 콘텐츠는 §8의 Popup 토큰(`--hm-sw-property-*`, `--hm-sw-popup-*`)만 읽는다. Header용 어두운 토큰을 밝은 Popup 면에 섞지 않는다. Popup 제목줄 토큰의 기본값은 Header 토큰을 따르므로 Header 토큰을 바꾼 테마는 그대로 적용되고, Popup만 바꿀 때는 `--hm-sw-popup-*`를 지정한다(2026-11-10).
## 11. 뷰어 모드 토큰·상태 요약 (`SWViewer*` 전용)
+1 -1
View File
@@ -45,7 +45,7 @@
- **반응형(2026-10-21, 실제 가용 폭 기준 3단계):** 고정 Breakpoint를 쓰지 않고 Header 행의 실제 남는 폭(로고·파일명·메뉴·Gap·Padding·More 버튼 포함)을 측정합니다. 파일명 면은 글자 수가 아니라 Header 폭으로 정한 기본 폭으로 계산하므로, 같은 폭에서는 짧은 파일명과 긴 파일명의 단계·파일 열기 버튼·파일명 면 너비가 같고 긴 이름은 `…`입니다(2026-10-21). ① 모두 들어가면 기본 형태(아이콘+텍스트), ② 부족하면 `icon`과 `iconOnlyBelow`가 있는 항목을 모두 아이콘만(라벨은 Tooltip·접근성 이름), ③ 그래도 부족하면 `collapseBelow` 항목을 하나씩 More(⋯, 베이지 260px Dropdown)로 옮깁니다(값이 큰 것부터, 같으면 오른쪽 먼저). ③의 판단(2026-10-26)은 tabs를 최소 폭(`--hm-sw-viewer-tabs-min-width`)으로 세므로, tabs가 `…`로 좁아져서 들어가면 More로 옮기지 않습니다. 파일명 면 기본 폭은 `clamp(80px, min(45cqw - 256px, 100cqw - 780px), 360px)`(1280px 이상은 이전과 같음), Header 560px 미만에서는 56px입니다. 넓어지면 More → 아이콘 → 기본 순서로 복귀하고, 상단과 More에 같은 항목이 동시에 나오지 않습니다. `collapseBelow`·`iconOnlyBelow`의 숫자는 px Breakpoint가 아니며 순서만 정합니다(`iconOnlyBelow`는 지정 여부만, `collapseBelow`는 값이 큰 것부터; Props 형식 동일). 로고 옆 파일 열기 버튼(`onDocumentOpen`)은 ② 단계에서 아이콘만으로 줄고 More로 옮기지 않으며, 샘플은 `documents`. 레이어·설정·속성은 `collapseBelow` 없이 아이콘까지만. 1180px 미만 간격 2px, 560px 미만 상태 글자 숨김(CSS container)은 그대로이고, 560px 미만에서는 tabs 개수 숨김·switch `shortLabel` 캡션·switch 좌우 여백 1px도 적용됩니다(2026-10-26). 가로 스크롤은 만들지 않습니다.
- More는 그룹/항목 단위 구획으로 자동 구성됩니다: menu·tabs는 자기 구획(`moreLabel`), button·toggle·switch는 그룹 구획(`group.moreLabel ?? group.label`). toggle·switch는 체크 항목, tabs는 라디오 항목이 됩니다.
- **Dropdown 색(2026-10-21):** Header 안의 Dropdown — 메뉴 버튼(`type: "menu"`), More(⋯), 로고 메뉴 — 은 크기·간격·글자는 `SWViewerMenu` 그대로, 색은 일반 `SWHeader`의 `...` 메뉴와 같습니다(면 #0e3d2f, Border `--hm-sw-header-object-border-color`, 그림자 0 4px 12px rgba(0,0,0,.5), 글자 `--hm-sw-header-text-color`, 보조 글자는 같은 색 80%, Hover `--hm-sw-header-hover`, 구분선 `--hm-sw-header-document-menu-divider-color`). 파일명 목록(`documents`)은 `SWHeader` 파일명 목록 모양을 유지하고, 단독 `SWViewerMenu`(우클릭 메뉴 등)는 기존 색 그대로입니다.
- **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`의 직접 그린 아이콘은 `<SWViewerIcon>`으로 감쌉니다. 색·크기·굵기·간격은 그대로이고 Hover/Active는 채움 색만 바뀝니다. Dropdown·패널 안에는 외곽선이 없습니다(SWHeader More 메뉴와 같음). 프로그램 아이콘은 `currentColor`로 그리면 상태 색과 외곽선이 자동 적용됩니다.
@@ -109,11 +109,16 @@ export function useSWHeaderDropdown({ open: openProp, onOpenChange, disabled = f
/** Focusable entries of the panel in DOM order (rows, inputs, footer). */
const entries = () => Array.from(panelRef.current?.querySelectorAll<HTMLElement>("button:not(:disabled), input:not(:disabled)") ?? [])
// ArrowDown on the closed trigger asks for the entry focus. A fixed overlay (property window, SW popup) stays
// visibility: hidden until its position is measured, and focus() on it is a no-op: the request stays pending and is
// retried on the render that shows the list (the position update), then cleared once focus moved; closing drops it.
useEffect(() => {
if (open && focusEntryRef.current) {
focusEntryRef.current = false
if (!open) { focusEntryRef.current = false; return }
if (focusEntryRef.current) {
const current = panelRef.current?.querySelector<HTMLElement>('[aria-current="true"]:not(:disabled), [aria-pressed="true"]:not(:disabled)')
;(current ?? entries()[0])?.focus()
const target = current ?? entries()[0]
target?.focus()
if (!target || document.activeElement === target) focusEntryRef.current = false
}
})
+9 -9
View File
@@ -221,7 +221,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
padding: 0 12px;
color: inherit;
}
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item:is(:hover, :focus):not(:disabled) { background: var(--hm-sw-header-hover); outline: none; }
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item:is(:hover, :focus):not(:disabled) { background: var(--hm-sw-header-more-item-hover-background); outline: none; }
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-item:disabled { opacity: 0.5; }
/* Title = SWViewerMenu label, directly above its items (one group, no divider); icons = SWViewerMenu icon slot. */
.hm-sw-viewer-header__logo-menu .hm-sw-header__more-title { min-height: 0; padding: 6px 12px; font-size: 12px; font-weight: 500; line-height: 18px; }
@@ -297,18 +297,18 @@ button.hm-sw-viewer-header__brand { text-align: left; }
a segment (none around the license button), no gap, top and bottom on the object surface border. The strip ends at the object surface edge, as in SWHeader. Status stays display only. */
.hm-sw-viewer-header__object:has(> .hm-sw-viewer-header__actions > :is(.hm-sw-viewer-status, .hm-sw-viewer-header__button, .hm-sw-header__window-buttons):last-child) { padding-right: 0; }
/* Viewer header dropdown colors (2026-10-21): the menus of the header — menu buttons, More and their submenus — use the
colors of the SWHeader `...` menu (sw.css .hm-sw-header__more-menu): its face and shadow (fixed values there), the
object border token, the header text, secondary text at 80% (as its values) and the white 8% row hover with the text
unchanged. Size, spacing and type stay SWViewerMenu. A standalone SWViewerMenu (context menu) and the document list
keep their own colors. */
colors of the SWHeader `...` menu (sw.css .hm-sw-header__more-menu): its More tokens for face, shadow, border and
row hover (2026-11-10, same values as before), the header text and secondary text at 80% (as its values), the text
unchanged on hover. Size, spacing and type stay SWViewerMenu. A standalone SWViewerMenu (context menu) and the
document list keep their own colors. */
.hm-sw-viewer-header .hm-sw-viewer-menu[data-surface] {
border-color: var(--hm-sw-header-object-border-color);
background: #0e3d2f;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
border-color: var(--hm-sw-header-more-menu-border-color);
background: var(--hm-sw-header-more-menu-background);
box-shadow: var(--hm-sw-header-more-menu-shadow);
color: var(--hm-sw-header-text-color);
}
.hm-sw-viewer-header .hm-sw-viewer-menu .hm-sw-viewer-menu__item:is(:focus, :hover, [data-state="open"]):not([aria-disabled]) {
background: var(--hm-sw-header-hover);
background: var(--hm-sw-header-more-item-hover-background);
color: inherit;
}
.hm-sw-viewer-header .hm-sw-viewer-menu :is(.hm-sw-viewer-menu__label, .hm-sw-viewer-menu__shortcut, .hm-sw-viewer-menu__chevron) { color: inherit; opacity: 0.8; }
+40 -18
View File
@@ -96,6 +96,28 @@
--hm-sw-switch-on-border: #d2570b;
--hm-sw-switch-thumb-off: linear-gradient(180deg, #fbedd4 0%, #eee3da 100%);
--hm-sw-switch-thumb-on: linear-gradient(180deg, #e8741e 0%, #c0520d 100%);
/* Switch depth (2026-11-10 individual tokens, values as before): inner shadows + ring + 1px highlight, thumb drop. */
--hm-sw-switch-off-shadow: inset 1px 0 2px rgba(0, 0, 0, .3), inset 0 2px 2px rgba(0, 0, 0, .3), inset 0 0 0 1px var(--hm-sw-switch-off-border), 1px 1px 1px #fcf0d8;
--hm-sw-switch-on-shadow: inset 1px 0 1px 1px rgba(0, 0, 0, .2), inset 0 1px 1px 1px rgba(0, 0, 0, .2), inset 0 0 0 .8px var(--hm-sw-switch-on-border), 1px 1px 1px #fcf0d8;
--hm-sw-switch-thumb-shadow: 0 .5px 1px rgba(0, 0, 0, .6);
/* Popup frame (SWPopupFrame: SWPopup, property window, SWViewerPanel; 2026-11-10 individual tokens). The title bar
defaults follow the header tokens it used so far, so a theme that sets those header tokens is unchanged; set the
popup token to change the popup alone. */
--hm-sw-popup-titlebar-background: var(--hm-sw-header-document-background);
--hm-sw-popup-title-color: var(--hm-sw-header-selected-color);
--hm-sw-popup-title-outline: var(--hm-sw-header-label-outline);
--hm-sw-popup-frame-shadow: inset 0 0 0 1px #000;
--hm-sw-popup-group-heading-color: rgba(0, 0, 0, .7);
--hm-sw-popup-group-border-color: #585349;
--hm-sw-popup-group-shadow: 0 2px 2px rgba(255, 255, 255, .6), inset 0 2px 4px #e1cfab, inset 0 -2px 2px rgba(104, 95, 76, .8);
--hm-sw-popup-divider-color: rgba(0, 0, 0, .5);
--hm-sw-popup-table-line-color: rgba(0, 0, 0, .4);
/* SWHeader `...` More menu (2026-11-10 individual tokens, values as before). The viewer header dropdowns and the
viewer logo menu take the same face and shadow. Border and row hover default to the header tokens they used. */
--hm-sw-header-more-menu-background: #0e3d2f;
--hm-sw-header-more-menu-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
--hm-sw-header-more-menu-border-color: var(--hm-sw-header-object-border-color);
--hm-sw-header-more-item-hover-background: var(--hm-sw-header-hover);
/* Popup scrollbar (every SW popup body: SWPopup, SWViewerPanel, property window). Same as the DWG/DXF viewer
panels (thin, HM warm-neutral-mid thumb on warm-neutral-light track). */
--hm-sw-popup-scrollbar-thumb: #9a9284;
@@ -1353,10 +1375,10 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
min-width: 200px;
max-width: calc(100vw - 16px);
padding: 4px;
border: 1px solid var(--hm-sw-header-object-border-color);
border: 1px solid var(--hm-sw-header-more-menu-border-color);
border-radius: var(--hm-sw-header-property-radius);
background: #0e3d2f;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.5);
background: var(--hm-sw-header-more-menu-background);
box-shadow: var(--hm-sw-header-more-menu-shadow);
}
.hm-sw-header__more-item {
display: none;
@@ -1368,7 +1390,7 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
border-radius: 2px;
white-space: nowrap;
}
.hm-sw-header__more-item:hover:not(:disabled) { background: var(--hm-sw-header-hover); }
.hm-sw-header__more-item:hover:not(:disabled) { background: var(--hm-sw-header-more-item-hover-background); }
.hm-sw-header__more-item[role="menuitemcheckbox"]::after { content: ""; width: 14px; text-align: center; }
.hm-sw-header__more-item[aria-checked="true"]::after { content: "✓"; }
.hm-sw-header__more-value { font-size: var(--hm-sw-header-label-size); opacity: 0.8; }
@@ -1619,13 +1641,13 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
overflow: hidden; border: 1px solid var(--hm-sw-property-border); border-radius: 4px;
background: var(--hm-sw-property-surface); color: var(--hm-sw-property-label);
font-size: 14px; font-weight: 400; line-height: 16px; letter-spacing: .02em;
box-shadow: inset 0 0 0 1px #000;
box-shadow: var(--hm-sw-popup-frame-shadow);
}
.hm-sw-property__titlebar { position: relative; display: flex; align-items: center; flex: 0 0 30px; height: 30px; background: var(--hm-sw-header-document-background); }
.hm-sw-property__titlebar { position: relative; display: flex; align-items: center; flex: 0 0 30px; height: 30px; background: var(--hm-sw-popup-titlebar-background); }
/* Figma paints btn_close first, then Union over its left side. Keep Union's diagonal and shadow
above the close background; the title and tool remain interactive on top of it. */
.hm-sw-property__title-art { position: absolute; z-index: 1; width: 308px; height: 38px; left: 0; top: -2px; transform: scaleY(-1); pointer-events: none; }
.hm-sw-property__title { position: relative; z-index: 2; flex: 1; min-width: 0; margin-left: 7px; padding-right: 4px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: 700; letter-spacing: .04em; color: var(--hm-sw-header-selected-color); text-shadow: var(--hm-sw-header-label-outline); }
.hm-sw-property__title { position: relative; z-index: 2; flex: 1; min-width: 0; margin-left: 7px; padding-right: 4px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: 700; letter-spacing: .04em; color: var(--hm-sw-popup-title-color); text-shadow: var(--hm-sw-popup-title-outline); }
.hm-sw-property__tool { position: absolute; z-index: 2; right: 43px; top: 1px; width: 29px; height: 27.13px; }
.hm-sw-property:not([data-selection-kind="single"]) .hm-sw-property__title { margin-right: 29px; }
.hm-sw-property__tool > img { position: absolute; top: 0; left: 0; display: block; width: 29px; height: 27.13px; }
@@ -1649,17 +1671,17 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
box-sizing: border-box; display: inline-flex; align-items: center; flex-shrink: 0;
width: 37px; height: 16px; padding: 0; border: 0; border-radius: 100px;
background: var(--hm-sw-switch-off-background);
box-shadow: inset 1px 0 2px rgba(0, 0, 0, .3), inset 0 2px 2px rgba(0, 0, 0, .3), inset 0 0 0 1px var(--hm-sw-switch-off-border), 1px 1px 1px #fcf0d8;
box-shadow: var(--hm-sw-switch-off-shadow);
}
.hm-sw-property [role="switch"]:is([data-slot="switch"], .hm-sw-viewer-switch)[data-state="checked"] {
background: var(--hm-sw-switch-on-background);
box-shadow: inset 1px 0 1px 1px rgba(0, 0, 0, .2), inset 0 1px 1px 1px rgba(0, 0, 0, .2), inset 0 0 0 .8px var(--hm-sw-switch-on-border), 1px 1px 1px #fcf0d8;
box-shadow: var(--hm-sw-switch-on-shadow);
}
.hm-sw-property [role="switch"]:is([data-slot="switch"], .hm-sw-viewer-switch):hover:not(:disabled) { background-image: none; }
.hm-sw-property [role="switch"]:is([data-slot="switch"], .hm-sw-viewer-switch):focus-visible { outline: 2px solid var(--hm-sw-switch-on-border); outline-offset: 1px; }
.hm-sw-property [role="switch"] > :is([data-slot="switch-thumb"], .hm-sw-viewer-switch__thumb) {
width: 12px; height: 12px; border-radius: 6.5px;
background: var(--hm-sw-switch-thumb-off); box-shadow: 0 .5px 1px rgba(0, 0, 0, .6);
background: var(--hm-sw-switch-thumb-off); box-shadow: var(--hm-sw-switch-thumb-shadow);
transform: translateX(2px);
}
.hm-sw-property [role="switch"][data-state="checked"] > :is([data-slot="switch-thumb"], .hm-sw-viewer-switch__thumb) {
@@ -1668,8 +1690,8 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-property[data-selection-kind="all"] .hm-sw-property__body { padding-bottom: 0; }
.hm-sw-property[data-selection-kind="all"] .hm-sw-property__counts { margin-top: -8px; }
.hm-sw-property__group { flex: 0 0 auto; }
.hm-sw-property__group > h3 { margin: 0 0 4px; font: inherit; font-weight: 700; color: rgba(0,0,0,.7); }
.hm-sw-property__group--rows > .hm-sw-property__group-content { border: 1px solid #585349; border-radius: 4px; padding: 2px; background: var(--hm-sw-property-content); box-shadow: 0 2px 2px rgba(255,255,255,.6), inset 0 2px 4px #e1cfab, inset 0 -2px 2px rgba(104,95,76,.8); }
.hm-sw-property__group > h3 { margin: 0 0 4px; font: inherit; font-weight: 700; color: var(--hm-sw-popup-group-heading-color); }
.hm-sw-property__group--rows > .hm-sw-property__group-content { border: 1px solid var(--hm-sw-popup-group-border-color); border-radius: 4px; padding: 2px; background: var(--hm-sw-property-content); box-shadow: var(--hm-sw-popup-group-shadow); }
.hm-sw-property__row { display: flex; align-items: center; gap: 8px; min-height: 32px; padding: 0 6px; }
.hm-sw-property__row > label { flex: 0 0 72px; min-width: 0; font-weight: 700; white-space: nowrap; overflow: hidden; text-overflow: ellipsis; }
.hm-sw-property__value { display: flex; align-items: center; gap: 4px; flex: 1; min-width: 0; }
@@ -1738,7 +1760,7 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-property__expand > img { display: block; width: 296px; height: 12px; margin-left: calc((100% - 296px) / 2); }
.hm-sw-property__expand[aria-expanded="true"] > img { transform: rotate(180deg); }
.hm-sw-property__counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.hm-sw-property__counts > span { min-height: 28px; padding: 5px 0; border-bottom: 1px solid rgba(0,0,0,.5); font-weight: 700; }
.hm-sw-property__counts > span { min-height: 28px; padding: 5px 0; border-bottom: 1px solid var(--hm-sw-popup-divider-color); font-weight: 700; }
/* 2026-10-21 design review: each object type entry (원 1, 폴리선 10 …) changes its own text color on Hover, with the
SW row accent #FF5C00 (the header list row hover color). Display only: no click, selection or filter. */
/* Only the object list (columns 3-4); the summary entries of columns 1-2 (data-count-summary) stay plain text. */
@@ -1768,17 +1790,17 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-property__group--position { margin-top: 8px; }
.hm-sw-property__group--position .hm-sw-property__group-content { margin: 0 4px; }
.hm-sw-property__position-head, .hm-sw-property__group--position .hm-sw-property__editors { display: grid; grid-auto-flow: column; grid-auto-columns: minmax(0, 1fr); }
.hm-sw-property__position-head { background: var(--hm-sw-property-table); border-top: 1px solid rgba(0,0,0,.4); }
.hm-sw-property__position-head > span, .hm-sw-property__position-label { min-height: 24px; padding: 3px 0; text-align: center; font-weight: 700; border-bottom: 1px solid rgba(0,0,0,.4); }
.hm-sw-property__position-head > span + span, .hm-sw-property__group--position .hm-sw-property__editor { border-left: 1px solid rgba(0,0,0,.4); }
.hm-sw-property__position-head { background: var(--hm-sw-property-table); border-top: 1px solid var(--hm-sw-popup-table-line-color); }
.hm-sw-property__position-head > span, .hm-sw-property__position-label { min-height: 24px; padding: 3px 0; text-align: center; font-weight: 700; border-bottom: 1px solid var(--hm-sw-popup-table-line-color); }
.hm-sw-property__position-head > span + span, .hm-sw-property__group--position .hm-sw-property__editor { border-left: 1px solid var(--hm-sw-popup-table-line-color); }
.hm-sw-property__position-label { background: var(--hm-sw-property-table); }
.hm-sw-property__group--position .hm-sw-property__row { padding: 0; gap: 0; min-height: 24px; }
.hm-sw-property__group--position label { position: absolute; width: 1px; height: 1px; overflow: hidden; clip-path: inset(50%); }
.hm-sw-property__group--position .hm-sw-header__input { padding: 0; width: 100%; height: 24px; text-align: center; }
.hm-sw-property__group--position .hm-sw-property__editor { border-bottom: 1px solid rgba(0,0,0,.4); }
.hm-sw-property__group--position .hm-sw-property__editor { border-bottom: 1px solid var(--hm-sw-popup-table-line-color); }
.hm-sw-property__overview { position: relative; flex: 0 0 28px; height: 28px; text-align: left; overflow: hidden; }
.hm-sw-property__overview > img { position: absolute; left: -3px; top: -2px; width: 317px; height: 36.0847px; transform: scaleY(-1); }
.hm-sw-property__overview > span { position: relative; display: block; padding: 6px 7px; color: var(--hm-sw-header-selected-color); text-shadow: var(--hm-sw-header-label-outline); font-weight: 700; }
.hm-sw-property__overview > span { position: relative; display: block; padding: 6px 7px; color: var(--hm-sw-popup-title-color); text-shadow: var(--hm-sw-popup-title-outline); font-weight: 700; }
.hm-sw-property__overview:disabled { opacity: 1; }
.hm-sw-property__row[data-disabled="true"] { opacity: .4; }
.hm-sw-property button:focus-visible, .hm-sw-property input:focus-visible, .hm-sw-property select:focus-visible { outline: 2px solid var(--hm-sw-property-border); outline-offset: -2px; }
+198
View File
@@ -0,0 +1,198 @@
// Test-only host for property-fields.browser.mjs (2026-11-10). Not a Storybook Story and not shipped (package "files" is
// dist + ai). It restores the host data and host controls of the two demos removed in 5faa1f1 that the field checks
// need: SW/Header › Property Fields (data-fields live / default / pinned hover) and the Header-hosted Property Panel
// (원 1개 selection). Records and controls are copied from src/sw/sw-header.stories.tsx at 5faa1f1^ without changes.
// Uses only the public @hm-design/sw API, like a consuming project. The pinned Hover column uses the Storybook pseudo-state
// mirror (.storybook/pseudo-states.ts) exactly as the retired Story did.
import { useEffect, useRef, useState, type ReactNode } from "react"
import { createRoot } from "react-dom/client"
import {
SWHeader, SWHeaderPropertyPanel, SWTheme, swHeaderLinePatterns,
type SWHeaderColorMode, type SWHeaderColorOption, type SWHeaderLayer, type SWHeaderLineType, type SWHeaderLineWeight,
type SWHeaderProperty, type SWHeaderPropertyGroup, type SWHeaderPropertyMenus, type SWHeaderPropertySelection,
} from "@hm-design/sw"
import "@hm-design/sw/styles.css"
import { ensurePseudoStates } from "../../../../.storybook/pseudo-states"
const FIGMA_COLORS: SWHeaderColorOption[] = [
{ id: "white", color: "#ffffff", label: "흰색" },
{ id: "red", color: "#f10404", label: "빨강" },
{ id: "green", color: "#48ff08", label: "초록" },
{ id: "yellow", color: "#ffff00", label: "노랑" },
{ id: "blue", color: "#0012ff", label: "파랑" },
{ id: "magenta", color: "#ff00f6", label: "자홍" },
{ id: "cyan", color: "#00f6ff", label: "청록" },
]
const FIGMA_LINE_TYPES: SWHeaderLineType[] = [
{ id: "byblock", name: "ByBlock" },
{ id: "bylayer", name: "ByLayer" },
{ id: "continuous", name: "Continuous" },
{ id: "center", name: "CENTER", pattern: swHeaderLinePatterns.center },
{ id: "hidden", name: "HIDDEN", pattern: swHeaderLinePatterns.hidden },
]
const FIGMA_LINE_WEIGHTS: SWHeaderLineWeight[] = [
{ id: "w-bylayer", name: "ByLayer" },
{ id: "w-byblock", name: "ByBlock" },
{ id: "w-default", name: "ByDefault", value: "0.25" },
{ id: "w-user", name: "User", value: "0.25", editable: true },
]
const PROPERTY_GROUPS: SWHeaderPropertyGroup[] = [
{ id: "common", collapsedCount: 6 },
{ id: "shape", name: "형상", layout: "circle" },
{ id: "position", name: "위치", layout: "position", positionLabel: "중간점" },
]
const PROPERTY_FIXTURE: SWHeaderProperty[] = [
{ id: "layer", name: "레이어", value: "0_Layer_Cludmark", type: "text", groupId: "common", picker: "layer", validate: draft => draft.length <= 40 },
{ id: "color", name: "색상", value: "255,229,0", type: "color", groupId: "common" },
{ id: "line", name: "선스타일", value: "Continuous", type: "select", groupId: "common", unit: "(ByL)", linePreview: true, options: [{ id: "Continuous", name: "Continuous" }, { id: "CENTER", name: "CENTER" }, { id: "HIDDEN", name: "HIDDEN" }] },
{ id: "weight", name: "선가중치", value: "0.25", type: "number", groupId: "common", min: 0 },
{ id: "scale", name: "선축적", value: "1.0000", type: "number", groupId: "common", min: .0001, decimals: 4, unit: "(LTscale:1)" },
{ id: "opacity", name: "투명도", value: "0.25", type: "slider", groupId: "common", min: 0, max: 1, step: .01, annotation: "(ByL)", unit: "%" },
{ id: "link", name: "하이퍼링크", value: "--", type: "text", groupId: "common" },
{ id: "thickness", name: "두께", value: "0.25", type: "number", groupId: "common", unit: "mm", min: 0 },
{ id: "material", name: "재료", value: "-", type: "computed", groupId: "common", unit: "(ByL)" },
{ id: "circumference", name: "원주", value: "0.25", type: "computed", groupId: "shape", graphicSlot: "circumference" },
{ id: "diameter", name: "지름", value: "8.1502", type: "number", groupId: "shape", graphicSlot: "diameter", min: 0 },
{ id: "radius", name: "반지름", value: "4.0526", type: "number", groupId: "shape", graphicSlot: "radius", min: 0 },
{ id: "area", name: "면적", value: "51.5962", type: "computed", groupId: "shape", graphicSlot: "area" },
{ id: "x", name: "X", value: "48.0808", type: "number", groupId: "position" },
{ id: "y", name: "Y", value: "14.9160", type: "number", groupId: "position" },
{ id: "z", name: "Z", value: "0.0000", type: "number", groupId: "position" },
]
const PROPERTY_COUNTS = [
{ id: "shape", name: "도형", count: 10, row: 1, column: 1 }, { id: "line", name: "직선", count: 10, row: 1, column: 2 },
{ id: "circle", name: "원", count: 1, row: 1, column: 3 }, { id: "ray", name: "광선", count: 1, row: 1, column: 4 },
{ id: "curve", name: "곡선", count: 10, row: 2, column: 3 }, { id: "arc", name: "호", count: 1, row: 2, column: 4 },
{ id: "polyline", name: "폴리선", count: 10, row: 3, column: 3 }, { id: "polyline2", name: "폴리선", count: 2, row: 3, column: 4 },
{ id: "text1", name: "문자", count: 1, row: 4, column: 1 }, { id: "text2", name: "문자", count: 1, row: 4, column: 2 },
{ id: "text3", name: "문자", count: 1, row: 4, column: 3 },
]
type PropertyExample = "none" | "all" | "circle" | "expanded"
function propertySelection(example: PropertyExample): SWHeaderPropertySelection {
return example === "none" ? { id: "none", title: "선택된 객체 - 없음", kind: "none" }
: example === "all" ? { id: "all", title: "선택된 객체 - 전체 11", kind: "all", counts: PROPERTY_COUNTS }
: { id: "circle", title: "선택된 객체 - 원1", kind: "single", overviewTitle: "선택된 객체 - 전체 11" }
}
function propertyFixture(example: PropertyExample) {
return PROPERTY_FIXTURE.filter(item => (example === "none" || example === "all") ? item.groupId === "common" : true).map(item => ({
...item, ...(example === "none" && item.id === "layer" ? { value: "0" } : {}),
...(example === "none" && item.id === "color" ? { value: "138,138,138" } : {}),
...(example === "all" && item.id === "layer" ? { mixed: true } : {}),
...((example === "none" || example === "all") && item.id === "opacity" ? { value: "0" } : {}),
}))
}
const PROPERTY_LAYERS: SWHeaderLayer[] = [
{ id: "0", name: "0" },
{ id: "0_Layer_Cludmark", name: "0_Layer_Cludmark", color: "#ff0000" },
{ id: "CC-D GNL", name: "CC-D GNL" },
{ id: "Sample-Wall", name: "Sample-Wall", color: "#ffe500", visible: false },
{ id: "Sample-Door", name: "Sample-Door", color: "#00ff00", frozen: true },
{ id: "Sample-Text", name: "Sample-Text", color: "#00ffff", locked: true },
{ id: "Sample-Defpoints", name: "Sample-Defpoints", color: "#808080", disabled: true },
]
const PROPERTY_LINE_TYPES: SWHeaderLineType[] = FIGMA_LINE_TYPES.map(item => ({ ...item, id: item.name }))
const linePatternOf = (value: string) => PROPERTY_LINE_TYPES.find(item => item.id === value)?.pattern
function usePropertyMenus(record: (line: string) => void, setProperties: (update: (items: SWHeaderProperty[]) => SWHeaderProperty[]) => void): SWHeaderPropertyMenus {
const [layers, setLayers] = useState(PROPERTY_LAYERS)
const [weights, setWeights] = useState(FIGMA_LINE_WEIGHTS)
const [weightId, setWeightId] = useState("w-default")
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>(null)
const setValue = (id: string, value: string) => setProperties(items => items.map(item => item.id === id ? { ...item, value, mixed: false } : item))
const scale = "1.0000"
const toggle = (key: "visible" | "frozen" | "locked") => (layer: SWHeaderLayer, next: boolean) => {
record(`layer.${key}(${layer.id}, ${next})`)
setLayers(items => items.map(item => item.id === layer.id ? { ...item, [key]: next } : item))
}
return {
layers, onLayerVisibilityChange: toggle("visible"), onLayerFreezeChange: toggle("frozen"), onLayerLockChange: toggle("locked"),
onLayerDetail: () => record("layerDetail"),
lineTypes: PROPERTY_LINE_TYPES, lineWeights: weights, activeLineWeightId: weightId, lineScale: scale,
onLineWeightSelect: weight => { record(`lineWeight(${weight.id})`); setWeightId(weight.id); if (weight.value) setValue("weight", weight.value) },
onLineWeightValueChange: (weight, value) => { record(`lineWeightValue(${weight.id}, ${value})`); setWeights(items => items.map(item => item.id === weight.id ? { ...item, value } : item)) },
onLineScaleChange: value => { record(`lineScale(${value})`); setValue("scale", Number(value).toFixed(4)) },
onLineStyleDetail: () => record("lineStyleDetail"),
colors: FIGMA_COLORS, colorMode,
onColorModeChange: mode => { record(`colorMode(${mode})`); setColorMode(mode) },
onColorPick: property => record(`colorPick(${property.id})`),
onColorDetail: () => record("colorDetail"),
}
}
function PinHover({ selectors, children }: { selectors: string[]; children: ReactNode }) {
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
ensurePseudoStates()
ref.current?.querySelectorAll(selectors.join(",")).forEach(element => element.setAttribute("data-sb-hover", ""))
}, [selectors])
return <div ref={ref}>{children}</div>
}
/* SW/Header › Property Fields (retired in 5faa1f1): the common rows of PROPERTY_FIXTURE plus a mixed layer row. Figma
sample text 255,259,0 is out of range: shown as host text only, never accepted as input. */
const FIELD_FIXTURE: SWHeaderProperty[] = PROPERTY_FIXTURE.filter(item => item.groupId === "common").flatMap(item =>
item.id === "layer" ? [{ ...item, id: "layer-mixed", mixed: true }, item]
: item.id === "color" ? [{ ...item, value: "255,259,0", swatch: "#ffe500" }]
: [item])
const FIELD_HOVER = [".hm-sw-property__value[data-field]", ".hm-sw-property__compact"]
function FieldPanel({ title, properties, onChange, menus }: { title: string; properties: SWHeaderProperty[]; onChange?: (property: SWHeaderProperty, value: string) => void; menus?: SWHeaderPropertyMenus }) {
return <SWTheme><SWHeaderPropertyPanel selection={{ id: title, title, kind: "none" }} properties={properties} groups={[{ id: "common" }]} onPropertyChange={onChange} menus={menus} /></SWTheme>
}
function PropertyFieldsHost() {
const [properties, setProperties] = useState(FIELD_FIXTURE)
const [log, setLog] = useState<string[]>([])
const record = (line: string) => setLog(lines => [...lines.slice(-11), line])
const menus = usePropertyMenus(record, setProperties as (update: (items: SWHeaderProperty[]) => SWHeaderProperty[]) => void)
const [noMenus, setNoMenus] = useState(false)
const patch = (id: string, change: Partial<SWHeaderProperty>) => setProperties(items => items.map(item => item.id === id ? { ...item, ...change } : item))
const change = (property: SWHeaderProperty, value: string) => {
record(`property(${property.id}, ${value})`)
patch(property.id, { value, mixed: false, ...(property.id === "line" ? { linePattern: linePatternOf(value) } : {}), ...(property.id === "color" ? { swatch: undefined } : {}) })
}
return <div data-fields-demo="">
<div><strong>외부 데이터</strong>
<button data-action="layer-mixed" onClick={() => patch("layer-mixed", { mixed: !properties.find(item => item.id === "layer-mixed")?.mixed })}>레이어 다양함 ↔ 단일</button>
<button data-action="layer-external" onClick={() => patch("layer", { value: "CC-D GNL" })}>레이어 외부 값 변경</button>
<button data-action="scale-external" onClick={() => patch("scale", { value: "2.5000" })}>선축적 외부 값 변경</button>
<button data-action="thickness-unit" onClick={() => patch("thickness", { unit: properties.find(item => item.id === "thickness")?.unit === "mm" ? "cm" : "mm" })}>두께 단위 변경</button>
<button data-action="line-center" onClick={() => patch("line", { value: "CENTER", linePattern: linePatternOf("CENTER") })}>선스타일 외부 CENTER</button>
<button data-action="readonly" onClick={() => setProperties(items => items.map(item => item.id === "thickness" || item.id === "layer" ? { ...item, readOnly: !item.readOnly } : item))}>두께·레이어 ReadOnly</button>
<button data-action="disabled" onClick={() => setProperties(items => items.map(item => item.id === "scale" || item.id === "color" ? { ...item, disabled: !item.disabled } : item))}>선축적·색상 Disabled</button>
<button data-action="add-row" onClick={() => setProperties(items => [...items, { id: `extra-${items.length}`, name: "추가 속성", value: "호스트 값", type: "text", groupId: "common" }])}>속성 추가</button>
<button data-action="remove-row" onClick={() => setProperties(items => items.filter(item => item.id !== "link"))}>하이퍼링크 삭제</button>
<button data-action="no-menus" onClick={() => setNoMenus(value => !value)}>menus 없음(options 사용): {String(noMenus)}</button>
</div>
<p data-property-log="" aria-live="polite">{log.join(" → ") || "Callback 없음"}</p>
<div style={{ display: "flex", gap: 24, alignItems: "flex-start" }}>
<figure style={{ margin: 0 }} data-fields="live"><FieldPanel title="실제 조작" properties={properties} onChange={change} menus={noMenus ? undefined : menus} /></figure>
<figure style={{ margin: 0 }} data-fields="default"><FieldPanel title="Default" properties={FIELD_FIXTURE} /></figure>
<figure style={{ margin: 0 }} data-fields="hover"><PinHover selectors={FIELD_HOVER}><FieldPanel title="Hover" properties={FIELD_FIXTURE} /></PinHover></figure>
</div>
</div>
}
/* SW/Header › Property Panel (retired in 5faa1f1): SWHeader with the propertyPanel prop and the selection buttons. */
function HeaderPanelHost() {
const [selection, setSelection] = useState(propertySelection("none"))
const [properties, setProperties] = useState(propertyFixture("none"))
const [expanded, setExpanded] = useState<string[]>([])
const menus = usePropertyMenus(() => {}, setProperties)
const changeExample = (next: PropertyExample) => {
setSelection(propertySelection(next)); setProperties(propertyFixture(next))
setExpanded(next === "expanded" ? ["common"] : [])
}
const panel = {
selection, properties, groups: PROPERTY_GROUPS, expandedGroupIds: expanded, menus,
onExpandedGroupsChange: setExpanded,
onPropertyChange: (property: SWHeaderProperty, value: string) => setProperties(items => items.map(item => item.id === property.id ? { ...item, value, mixed: false } : item)),
onOverviewClick: () => changeExample("all"),
}
return <div data-property-demo="">
<div>{(["none", "all", "circle", "expanded"] as const).map((name, index) => <button key={name} data-example-button={name} onClick={() => changeExample(name)}>{["선택 없음", "전체 선택", "원 1개", "모두 펼침"][index]}</button>)}</div>
<div style={{ width: 1920, minHeight: 820 }}>
<SWHeader documentTitle="C0020101-006-평면및종단면도(06)" drawingModeLabel="그리기모드" layer={{ name: "0_Layer_Cludmark" }} color={{ value: "255,229,0", swatch: "#ffe500" }}
lineStyle={{ name: "Continuous", weight: "0.25", scale: "1.00" }} textStyle={{ font: "Arial", name: "Standard" }} dimensionStyle={{ name: "Standard" }} propertyPanel={panel} />
</div>
</div>
}
const view = new URLSearchParams(location.search).get("view")
createRoot(document.getElementById("root")!).render(view === "header" ? <HeaderPanelHost /> : <PropertyFieldsHost />)
+47 -20
View File
@@ -1,17 +1,34 @@
// Historical since 2026-10-19: opens SW Story IDs retired by the unused demo removal (property-fields). Kept as evidence
// for its recorded run; current regression: storybook-unused-removal.browser.mjs.
// Property fields (Figma 26:12388 ~ 26:12649): Default / real pointer Hover / Mouse Leave / On (editing, dropdown),
// selection callbacks, keyboard, Escape, external data, Disabled / ReadOnly, and the four panel states.
// 2026-11-10 (test alignment): the SW/Header › Property Fields and Property Panel demos this suite opened were removed in
// 5faa1f1 (approved). Sections 1–9 and the Header-hosted panel run on the test-only fixture fixtures/property-fields.tsx,
// which restores those demos' host data and host controls with the public API (as header-composition does); the four
// panel states run on the current Story sw-header--property-panel-compare (SW/Popup › 속성창). Every check is unchanged.
// Usage: node packages/sw/tests/property-fields.browser.mjs <evidence-dir> <label> [storybook-url]
// Storybook serves packages/sw/dist: build @hm-design/sw first.
import { mkdir, writeFile } from 'node:fs/promises'
// The fixture and Storybook use packages/sw/dist: build @hm-design/sw first.
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { createRequire } from 'node:module'
import http from 'node:http'
import path from 'node:path'
import { fileURLToPath } from 'node:url'
import { loadPlaywright } from './playwright-runtime.mjs'
const out = path.resolve(process.argv[2] ?? 'docs/sw/property-fields-qa')
const label = process.argv[3] ?? 'run'
const base = process.argv[4] ?? 'http://127.0.0.1:6024'
const { chromium } = loadPlaywright()
await mkdir(out, { recursive: true })
// Test-only fixture page (built into the evidence directory, served only for this run).
const site = path.join(out, 'fixture')
await mkdir(site, { recursive: true })
await createRequire(import.meta.url)('esbuild').build({
entryPoints: [fileURLToPath(new URL('./fixtures/property-fields.tsx', import.meta.url))], bundle: true, outdir: site, format: 'esm', jsx: 'automatic', minify: true,
loader: { '.ttf': 'file', '.woff': 'file', '.woff2': 'file', '.svg': 'file', '.png': 'file' }, define: { 'process.env.NODE_ENV': '"production"' }, logLevel: 'warning',
})
await writeFile(path.join(site, 'index.html'), '<!doctype html><html lang="ko"><head><meta charset="utf-8"><link rel="icon" href="data:,"><link rel="stylesheet" href="property-fields.css"></head><body style="margin:0"><div id="root"></div><script type="module" src="property-fields.js"></script></body></html>')
const mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(site, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(site, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
await new Promise(resolve => server.listen(0, '127.0.0.1', resolve))
const fixture = `http://127.0.0.1:${server.address().port}/`
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, deviceScaleFactor: 2 })
const report = { label, checks: [], errors: [] }
@@ -19,7 +36,7 @@ page.on('pageerror', e => report.errors.push(e.message)); page.on('console', m =
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, JSON.stringify(data)) }
const shot = async (name, locator) => writeFile(path.join(out, `${label}-${name}.png`), await (locator ?? page).screenshot())
await page.goto(`${base}/iframe.html?id=sw-header--property-fields&viewMode=story`)
await page.goto(fixture)
await page.locator('[data-fields="live"] .hm-sw-property').waitFor(); await page.evaluate(() => document.fonts.ready)
const live = page.locator('[data-fields="live"]')
const row = id => live.locator(`.hm-sw-property__row[data-property-id="${id}"]`)
@@ -35,9 +52,13 @@ for (const id of ['layer-mixed', 'layer', 'color', 'line', 'scale', 'link', 'thi
const s = await style(field(id), 'borderTopColor', 'backgroundColor', 'borderTopLeftRadius')
check(`Default ${id}: 200 x 28, radius 2, no border / surface`, s.w === 200 && s.h === 28 && s.borderTopColor === CLEAR && s.backgroundColor === CLEAR && s.borderTopLeftRadius === '2px', s)
}
// 2026-11-10: since the user-approved 2026-10-21 design review (2fc1b3e) 선가중치 is no longer the 41px hug field but the
// 200 x 28 box like 선축적, and the 투명도 value is its own 28px box before the gauge (SWHeaderPropertyPanel.tsx). The
// same properties are checked on the current fields: size, padding (7px incl. no border), radius, no border / surface.
const compactField = id => id === 'weight' ? field('weight') : row(id).locator('.hm-sw-property__slider-value')
for (const id of ['weight', 'opacity']) {
const s = await style(row(id).locator('.hm-sw-property__compact'), 'borderTopColor', 'backgroundColor', 'paddingLeft', 'paddingRight')
check(`Default ${id}: compact 28px hug field (pl 6 / pr 4 incl. border), no border`, s.h === 28 && s.w < 60 && s.borderTopColor === CLEAR && s.paddingLeft === '5px' && s.paddingRight === '3px', s)
const s = await style(compactField(id), 'borderTopColor', 'backgroundColor', 'paddingLeft', 'paddingRight', 'borderTopLeftRadius')
check(`Default ${id}: ${id === 'weight' ? '200 x 28 box' : '28px slider value box'} (pl / pr 7), radius 2, no border / surface`, s.h === 28 && (id === 'weight' ? s.w === 200 : s.w < 200) && s.borderTopColor === CLEAR && s.backgroundColor === CLEAR && s.paddingLeft === '7px' && s.paddingRight === '7px' && s.borderTopLeftRadius === '2px', s)
}
const typo = await row('scale').evaluate(r => { const v = getComputedStyle(r.querySelector('.hm-sw-header__input')), u = getComputedStyle(r.querySelector('.hm-sw-property__unit')); return { value: [v.color, v.fontSize], unit: [u.color, u.fontSize] } })
check('Typography: value #E60403 14px, unit #000 12px', typo.value.join() === 'rgb(230, 4, 3),14px' && typo.unit.join() === 'rgb(0, 0, 0),12px', typo)
@@ -56,9 +77,9 @@ for (const id of ['layer-mixed', 'layer', 'color', 'line', 'scale', 'link', 'thi
check(`Mouse Leave ${id}: back to Default`, left.borderTopColor === CLEAR, left)
}
for (const id of ['weight', 'opacity']) {
const c = row(id).locator('.hm-sw-property__compact')
const c = compactField(id)
await c.hover(); const s = await style(c, 'borderTopColor'); await page.mouse.move(1, 1); const l = await style(c, 'borderTopColor')
check(`Hover / Leave ${id} compact`, s.borderTopColor === HOVER && l.borderTopColor === CLEAR, { hover: s.borderTopColor, leave: l.borderTopColor })
check(`Hover / Leave ${id} ${id === 'weight' ? 'box' : 'slider value box'}`, s.borderTopColor === HOVER && l.borderTopColor === CLEAR, { hover: s.borderTopColor, leave: l.borderTopColor })
}
await field('scale').hover(); await shot('hover-scale', field('scale')); await page.mouse.move(1, 1)
@@ -78,8 +99,9 @@ check('Enter: 선축적 2 → validated, 4 decimals committed', (await log()).in
await scale.fill('7'); await page.keyboard.press('Escape')
check('Escape: draft cancelled (2.0000 restored, no callback)', await scale.inputValue() === '2.0000' && !(await log()).includes('7'), await scale.inputValue())
const weight = row('weight').locator('input')
await weight.click(); const w = await style(weight, 'backgroundColor')
check('On(편집) 선가중치 compact: #0F0D0A', w.backgroundColor === 'rgb(15, 13, 10)', w)
// 2fc1b3e (2026-10-21): the editing face is on the 200px box (as 선축적), no longer on the hug field's input.
await weight.click(); const w = await style(field('weight'), 'backgroundColor')
check('On(편집) 선가중치 box: #0F0D0A', w.backgroundColor === 'rgb(15, 13, 10)', w)
await shot('on-weight', row('weight'))
await weight.fill('-1'); await page.keyboard.press('Enter')
check('Invalid 선가중치 -1 (min 0): restored, no callback', await weight.inputValue() === '0.25' && !(await log()).includes('weight, -1'), await weight.inputValue())
@@ -87,10 +109,11 @@ await weight.fill('0.5'); await page.keyboard.press('Enter')
check('선가중치 0.5 committed', (await log()).includes('property(weight, 0.5)'), await log())
await page.mouse.click(1, 1)
// 4. No invented On: 하이퍼링크 (text) focus keeps the Hover look only; 재료 (computed) is read-only.
// 4. 하이퍼링크 (editable text): since 6d46289 (2026-10-22, approved) its editing face is the numbers' On (#0F0D0A,
// Hover border); 재료 (computed) stays read-only without On.
await row('link').locator('input').click()
const link = await style(field('link'), 'backgroundColor', 'borderTopColor')
check('하이퍼링크 focus: no On surface (Figma has no On), editable', link.backgroundColor === CLEAR && link.borderTopColor === HOVER, link)
check('하이퍼링크 focus: editing face #0F0D0A like numbers (6d46289), Hover border, editable', link.backgroundColor === 'rgb(15, 13, 10)' && link.borderTopColor === HOVER, link)
await row('link').locator('input').fill('http://a'); await page.keyboard.press('Enter')
check('하이퍼링크 commit via host callback', (await log()).includes('property(link, http://a)'), await log())
await row('material').locator('input').click()
@@ -132,16 +155,20 @@ check('Keyboard: ArrowDown opens + focuses list, Escape closes to trigger', focu
await layerTrigger.click(); await page.mouse.click(1300, 900)
check('Outside click closes', await drop.count() === 0, null)
// 6. Line style (26:12388 / 26:12404): 466 = 182 + 142 + 142, right-aligned, preview follows the value.
// 6. Line style (26:12388 / 26:12404): right-aligned, preview follows the value. Since 2fc1b3e (2026-10-21 design
// review, DESIGN_RULES 속성창 입력) the property window list is the line type column only (preview + name; type column
// 182px, growing to 240px only for wider font names); weights / scale stay in the SWHeader line style field.
await field('line').locator('.hm-sw-property__trigger').click()
f = await style(field('line'), 'backgroundColor'); d = await style(drop.locator('.hm-sw-header__line-drop'))
const cols = await drop.locator('.hm-sw-header__line-column').evaluateAll(es => es.map(e => +e.getBoundingClientRect().width.toFixed(2)))
const unitOn = await style(field('line').locator('.hm-sw-property__unit'), 'color')
check('Line style On: #211D13, (ByL) #D4CBBD, 3 columns, right edges meet (or shifted to the 4px screen edge when no room)', f.backgroundColor === 'rgb(33, 29, 19)' && unitOn.color === 'rgb(212, 203, 189)' && cols.length === 3 && (Math.abs(d.x + d.w - (f.x + f.w)) < 0.5 || d.x === 4) && Math.abs(d.y - (f.y + f.h)) < 0.5, { f, d, cols })
check('Line style drop 466 x 190 (type column grows only for wider font names)', d.w >= 466 && d.h === 190, { w: d.w, h: d.h, cols })
const typeOnly = await drop.locator('.hm-sw-header__line-column').evaluateAll(es => es.length === 1 && es[0].classList.contains('hm-sw-header__line-column--type'))
check('Line style On: #211D13, (ByL) #D4CBBD, 1 column (line types), right edges meet (or shifted to the 4px screen edge when no room)', f.backgroundColor === 'rgb(33, 29, 19)' && unitOn.color === 'rgb(212, 203, 189)' && cols.length === 1 && typeOnly && (Math.abs(d.x + d.w - (f.x + f.w)) < 0.5 || d.x === 4) && Math.abs(d.y - (f.y + f.h)) < 0.5, { f, d, cols, typeOnly })
check('Line style drop 182–240 x 190 (type column grows only for wider font names)', d.w >= 182 && d.w <= 240 && Math.abs(d.w - cols[0]) < 0.5 && d.h === 190, { w: d.w, h: d.h, cols })
await shot('on-line', page)
await drop.getByRole('button', { name: 'ByBlock', exact: true }).nth(1).click()
check('Weight column: callback only, stays open', (await log()).includes('lineWeight(w-byblock)') && await drop.count() === 1, await log())
// No weight column: the only ByBlock entry is the line type, and no weight / scale callback was called.
const byBlock = await drop.getByRole('button', { name: 'ByBlock', exact: true }).count()
check('Weight column: absent (ByBlock only as a line type), no lineWeight / lineScale callback, list stays open', byBlock === 1 && !/lineWeight|lineScale/.test(await log()) && await drop.count() === 1, { byBlock, log: await log() })
await drop.getByRole('button', { name: 'CENTER' }).click()
const segs = await field('line').locator('.hm-sw-header__line-preview > i').count()
check('Line type select: property(line, CENTER), closes, preview = CENTER pattern (4 segments)', (await log()).includes('property(line, CENTER)') && await drop.count() === 0 && segs === 4, { segs })
@@ -212,7 +239,7 @@ const want = { none: [314, 256], all: [314, 361], circle: [314, 658], expanded:
check('4 panel states: 314 x 256 / 361 / 658 / 754', sizes.every(([k, w, h]) => w === want[k][0] && h === want[k][1]), sizes)
await shot('compare', page.locator('.sw-story-compare'))
// The header-hosted panel: layer field opens its list over the header page as well.
await page.goto(`${base}/iframe.html?id=sw-header--property-panel&viewMode=story`)
await page.goto(`${fixture}?view=header`)
await page.locator('.hm-sw-header').waitFor()
await page.getByRole('button', { name: '원 1개', exact: true }).click()
await page.locator('.hm-sw-header [data-property-panel-trigger]').click()
@@ -223,7 +250,7 @@ const stillOpen = await page.locator('.hm-sw-property').count()
check('Header-hosted panel: layer list attached; Escape closes list only', hosted && Math.abs(hosted.right) < 0.5 && Math.abs(hosted.top) < 0.5 && stillOpen === 1, { hosted, stillOpen })
check('no console / page errors', report.errors.length === 0, report.errors)
await writeFile(path.join(out, `${label}-result.json`), JSON.stringify(report, null, 2))
await browser.close()
await browser.close(); server.close()
const failed = report.checks.filter(c => c.status === 'FAIL').length
console.log(`${report.checks.length - failed}/${report.checks.length} PASS`)
process.exit(failed ? 1 : 0)
+24 -7
View File
@@ -1,6 +1,6 @@
{
"package": "@hm-design/sw",
"approvedCommit": "8cb98b373f9e5321c8d6c032a6d2031e942dc508",
"approvedCommit": "3cbe87991010f08b131e8b80c9f478c3fa721004",
"manifest": {
"name": "@hm-design/sw",
"exports": {
@@ -137,6 +137,18 @@
"SWPopupRowProps": "type",
"SWPopupSection": "value",
"SWPopupSectionProps": "type",
"SWProcessAppearance": "type",
"SWProcessBar": "value",
"SWProcessBarProps": "type",
"SWProcessGroup": "type",
"SWProcessHeader": "value",
"SWProcessHeaderHideableControlId": "type",
"SWProcessHeaderLayer": "type",
"SWProcessHeaderProps": "type",
"SWProcessSplitButton": "value",
"SWProcessSplitButtonProps": "type",
"SWProcessSplitType": "type",
"SWProcessStep": "type",
"SWRecentFiles": "value",
"SWRecentFilesProps": "type",
"SWSelect": "value",
@@ -207,7 +219,7 @@
"components/sw-header/components/SWHeaderDropdown.d.ts": "06ce5aa914826b6dc85df923a24baded80d16f43aebce39845f5b18d9d996504",
"components/sw-header/components/SWHeaderField.d.ts": "741fdd1b0e7da4cf8f7adeaec021eb4eb103d7312f34e80135d00c3510159901",
"components/sw-header/components/SWHeaderFields.d.ts": "43950332583b4c74231a793e18651b45494165626cc45e3dfcf50169ec189edc",
"components/sw-header/components/SWHeaderHome.d.ts": "aa1e56611e92f309d588fcb12c089bfb0cd336de050d9aca2f043118ba01ae54",
"components/sw-header/components/SWHeaderHome.d.ts": "656f93b57e670cb422946abdf711ebe779ddff41786159cdb6f4091bf366f7ee",
"components/sw-header/components/SWHeaderInput.d.ts": "6b379ee70b9bcbf6e9a0808ca891b86838a9bfa355bfa12f036088b26241dd9c",
"components/sw-header/components/SWHeaderLayerIcons.d.ts": "bb4b955a7850c445a3ff14c0e25034124dca49208aed0fafd922878ff7099ac5",
"components/sw-header/components/SWHeaderLayerMenu.d.ts": "0fb65b14f52cd2abf54e74ea09ed4b783fe1665416dfd910bd6cd7d3e1995711",
@@ -219,23 +231,28 @@
"components/sw-header/components/SWHeaderWindowControls.d.ts": "db8bfbd14340e03c28e407f58fc08cef55b9eef49f406ba53fc50e994f5f0834",
"components/sw-header/index.d.ts": "958a64efd05f7b8ab683ecb342fdcdef488db0be97cf9a2bf5ad3e3ac4632ab7",
"components/sw-header/SWHeader.d.ts": "3adac1db372451d9f86a6f6df1df0e9696cd40c3ed7945531ee378da122136d8",
"components/sw-header/types.d.ts": "fa7602374753bf7b6b48c0fa9f1c30a932ed12ccb4d16488c836e964b76f9122",
"components/sw-header/types.d.ts": "fdc3fafb111331f2edd754029bdc78231b18e563df662b849d8a6a8eaf30ebcd",
"components/sw-home-menu/index.d.ts": "b1f67a4e16c7b3a64b93f348bfb17fcd2ada999527cb93ccb77ca6308564091c",
"components/sw-home-menu/SWHomeMenu.d.ts": "2ce530d253374a8b20a20d8b6ddd5c733abfc8d518162d5a7490d432986a662c",
"components/sw-home-menu/SWRecentFiles.d.ts": "0c3c8ececc537ab6a03ce14a91dd1c878e9539b4022ce486ebe161262fb2c710",
"components/sw-home-menu/types.d.ts": "81499bc8edf2f2acfb2f556d7f397019ba99046a808e0607cb96a29a67b6f315",
"components/sw-popup/SWPopup.d.ts": "9af7b5f34bed9c863e343018ed64dc6960d09f5664fdfc1c26fcc7204acdd041",
"components/sw-process-header/index.d.ts": "1fe66c0854bac5f2fa0eeb2bd0bb16056ab1af08750b9911841d34881ec7abb5",
"components/sw-process-header/SWProcessBar.d.ts": "51e69c5dfeeaecee0db96b3b1511ee32d69667ea85aa67e01210bc9fe70854fb",
"components/sw-process-header/SWProcessHeader.d.ts": "e7740f8a1131963a3e43b271ff1664fdd9f06a5fca60217501d7598f557e6b65",
"components/sw-process-header/SWProcessSplitButton.d.ts": "b865f46609301677f9686a6d6d00e8caacfc94e5fdc776eb87fb8f93184f96ff",
"components/sw-process-header/types.d.ts": "02778af5e73c4b3be04bc8fe939a105fe3588526ce10614cf43aebe1fce68849",
"components/sw-select/SWSelect.d.ts": "9cf523e8a8329a2be7bdfba56c1a2c647b2676480efa3f6dfa26a971ea0a9c3b",
"components/sw-slider/SWSlider.d.ts": "7d9c48299cb5644d859753e1bc56ae43dc7ab282e64432d57d3e29b2960aaad5",
"components/sw-theme/SWTheme.d.ts": "cdcc2e7c4b80241afa4d0b7c3da671b7086c16f9b9c231c1927c931757e4244c",
"components/sw-tool/SWTool.d.ts": "23b1eb156578cd3f1dee325afbd81988d88c4a3f421f7350a0e8c47c652e804c",
"components/sw-viewer/index.d.ts": "e57a9ec0b91ae576b4b09b4851cd8e19b6fb4ea4bbdf62a578b4b94d7da6ffe2",
"components/sw-viewer/SWViewerControls.d.ts": "ca32e8a2a7de2cfefec36273ffb473271ad438cfb663f44a25871ee6aed9222d",
"components/sw-viewer/SWViewerHeader.d.ts": "586d07e74f5c2673fbe1d4849ed72cdcf80e7f8a391393168f101dc613490d24",
"components/sw-viewer/SWViewerControls.d.ts": "a75bc7d54db126d1bd9999cf12dc3b7edfb24cb16f75ba44b2e9dda422623aa2",
"components/sw-viewer/SWViewerHeader.d.ts": "0a46e07d7f6b33dc389cf4739e563be045cad12d84585384b1cc4d861c4acdbe",
"components/sw-viewer/SWViewerIcon.d.ts": "3c81582860e481b037b395b3584ff93af73500e4040d8423c9da15c2b4dd108e",
"components/sw-viewer/SWViewerMenu.d.ts": "15fa615c3472d84037780f3c93387acbc6b4d05f5fb56731b0a99262b3f7f109",
"components/sw-viewer/SWViewerPanel.d.ts": "49b040d3f3d00de30e3fc0493fd2ce78e309616ec97a7ce226dfef300b640f92",
"components/sw-viewer/types.d.ts": "e46b4605a3fcab78325bff526a374a891fdcf0256dc7824a9414d2336b2f85d7",
"index.d.ts": "34f0607f63df53d5a1186a4ad763a8bf7b433e20645d868f6f7368ed0853828c"
"components/sw-viewer/types.d.ts": "13b928b6871b9865e30929b37a17e6c820353f18477d96c1cc09a7d69e40cd18",
"index.d.ts": "1e156f688534c117f880c7bd32f06110240caff3d02157db0265edb8e3eb41f5"
}
}
+10 -2
View File
@@ -16,7 +16,15 @@ import { existsSync } from "node:fs"
import { readFile, writeFile } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath, pathToFileURL } from "node:url"
import { approvedMainCommit } from "./storybook-inventory.mjs"
// Reviewed main merge the approved surface must belong to (2026-11-10: own baseline, split from approvedMainCommit in
// storybook-inventory.mjs, which also anchors the Story inventory / source regressions of overview, storybook-unused-
// removal, dropdown-consolidation and storybook-cleanup-stage1; moving that one would accept their unrecorded Story and
// source changes). Update it only in a reviewed test-only change that re-snapshots public-api.approved.json from a clean
// build of that merge after its .d.ts changes were compared with the previous approved build.
// History: approvedMainCommit fc65b2e (snapshot 8cb98b3) -> 3cbe879 (2026-11-10, Merge PR #30; snapshot from the clean
// 3cbe879 build: SWProcess* exports of the user-approved Step Header adbb034 / e700825, optional shortLabel? of the SWViewerHeaderItem switch item in
// cabd2f6, the internal SWHeaderLogo declaration of 205864b (not exported from the entry) and JSDoc-only changes).
const approvedPublicApiMainCommit = "3cbe87991010f08b131e8b80c9f478c3fa721004"
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
const snapshotFile = fileURLToPath(new URL("./public-api.approved.json", import.meta.url))
@@ -82,7 +90,7 @@ if (process.argv[2] === "--snapshot") {
const results = []
const check = (name, ok, data) => { results.push(ok); console.log(ok ? "PASS" : "FAIL", name, data === undefined ? "" : JSON.stringify(data)) }
const ancestor = (a, b) => { try { execFileSync("git", ["merge-base", "--is-ancestor", a, b], { cwd: packageRoot, stdio: "ignore" }); return true } catch { return false } }
check("approved surface comes from a reviewed commit (ancestor of approvedMainCommit and HEAD)", ancestor(approved.approvedCommit, approvedMainCommit) && ancestor(approved.approvedCommit, "HEAD"), { approvedCommit: approved.approvedCommit, approvedMainCommit })
check("approved surface comes from a reviewed commit (ancestor of approvedPublicApiMainCommit and HEAD)", ancestor(approved.approvedCommit, approvedPublicApiMainCommit) && ancestor(approved.approvedCommit, "HEAD"), { approvedCommit: approved.approvedCommit, approvedPublicApiMainCommit })
const manifest = await manifestOf(manifestFile)
const changedFields = ["name", ...manifestFields].filter(field => JSON.stringify(manifest[field]) !== JSON.stringify(approved.manifest[field]))
@@ -60,10 +60,11 @@ const only = (map, expected) => Object.keys(map).length > 0 && Object.keys(map).
const fmt = map => Object.entries(map).map(([family, count]) => `${count}× ${family}`).join(' | ') || 'no text'
// More (2026-10-21): the header shows More only when its measured room runs out (full → icons → More). The Story's
// 1000px header fits as icons, so the header is narrowed for this check (as text-weight does), then restored.
// 1000px header fits as icons, so the header is narrowed for this check, then restored. 2026-11-10: 480px, since
// cabd2f6 (2026-10-26) the viewer header keeps every menu down to about 520px and shows More only below that.
async function openMore(page, area) {
const header = page.locator(`${area} .hm-sw-viewer-header`)
await header.evaluate(element => { element.style.width = "700px" })
await header.evaluate(element => { element.style.width = "480px" })
await page.locator(`${area} .hm-sw-viewer-header button[aria-label="추가 도구"]`).click()
}
const restoreWidth = (page, area) => page.locator(`${area} .hm-sw-viewer-header`).evaluate(element => { element.style.width = "" })