merge(sw): integrate AI reusable component template

Merge feature/sw-ai-template (e27385e) into feature/sw-package (bf16578).
No conflicts; no runtime source, Storybook source or public API change.
Adds the integration record docs/sw/sw-ai-template-integration-2026-10-19.md
and its evidence (Overview 53/53, overlays 19/19, AI consumer 27/27).
The unverified wip/sw-header-composition-unverified branch is not included.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shkim4andClaude Opus 5.5 committed 2026-10-19 11:33:10 +09:00
commit c165753bbe
31 files changed
+2483 -1

No files matched your search

@@ -0,0 +1,276 @@
{
"checks": [
{
"name": "1 all SVG/PNG assets decode (data URI, no external path)",
"status": "PASS",
"data": {
"count": 33,
"broken": []
}
},
{
"name": "1 styles.css loaded; SWTheme provides tokens",
"status": "PASS",
"data": {
"height": "49px",
"font": "\"Noto Sans KR\", \"Malgun Gothic\", \"Apple SD Gothic Neo\", sans-serif"
}
},
{
"name": "2 A default header 1920 geometry = Figma (49 / 0·80 / 65·310 / 377·1301 / 1680·240)",
"status": "PASS",
"data": {
"header": {
"x": 0,
"y": 0,
"w": 1920,
"h": 49
},
"home": {
"x": 0,
"y": 0,
"w": 80,
"h": 48
},
"document": {
"x": 65,
"y": 0,
"w": 310,
"h": 48
},
"object": {
"x": 377,
"y": 0,
"w": 1301,
"h": 48
},
"window": {
"x": 1680,
"y": 0,
"w": 240,
"h": 48
}
}
},
{
"name": "2 A layer dropdown select callback",
"status": "PASS",
"data": "layer:center"
},
{
"name": "2 A close callback",
"status": "PASS",
"data": "close"
},
{
"name": "3 B order Logo → File → Layer → Color → K (DOM)",
"status": "PASS",
"data": [
"뷰어 홈",
"hm-sw-header__document",
"레이어",
"색상",
"팝업 가이드"
]
},
{
"name": "3 B sizes keep SW rules (bar 49, home 80 overlapping file 15px, fields 234/192 x 38)",
"status": "PASS",
"data": {
"bar": {
"x": 0,
"y": 409,
"w": 1920,
"h": 49
},
"home": {
"x": 0,
"y": 409,
"w": 80,
"h": 48
},
"document": {
"x": 65,
"y": 409,
"w": 310,
"h": 48
},
"layer": {
"x": 382,
"y": 414,
"w": 234,
"h": 38
},
"color": {
"x": 620,
"y": 414,
"w": 192,
"h": 38
}
}
},
{
"name": "3 B document dropdown New callback",
"status": "PASS",
"data": "document:new"
},
{
"name": "3 B layer dropdown opens under the field",
"status": "PASS",
"data": "true"
},
{
"name": "3 B Escape closes and returns focus to the field",
"status": "PASS",
"data": null
},
{
"name": "3 B color chip callback (dropdown stays open)",
"status": "PASS",
"data": "color:red"
},
{
"name": "3 B K guide preview on hover (animation frame advances)",
"status": "PASS",
"data": {
"guide": true
}
},
{
"name": "3 B K click callback",
"status": "PASS",
"data": "popupGuide"
},
{
"name": "4 C order File → 선 → 문자 → 치수 → 인쇄",
"status": "PASS",
"data": [
"file",
"선스타일",
"문자스타일",
"치수스타일",
"인쇄"
]
},
{
"name": "4 C first file well has a left edge (consumer wrapper)",
"status": "PASS",
"data": "1px"
},
{
"name": "4 C line type callback + field value",
"status": "PASS",
"data": "lineType:center"
},
{
"name": "4 C text style callback + field value",
"status": "PASS",
"data": "textStyle:dotum"
},
{
"name": "4 C dimension style callback",
"status": "PASS",
"data": "dimensionStyle:iso"
},
{
"name": "4 C print callback",
"status": "PASS",
"data": "print"
},
{
"name": "5 D new search field matches the SW field surface (height/top/border/radius/background/shadow/label)",
"status": "PASS",
"data": {
"layer": {
"h": 38,
"top": 1232,
"border": "1px rgb(8, 37, 28)",
"radius": "3px",
"bg": "linear-gradient(0deg, rgba(0, 0, 0, 0.45",
"shadow": "rgba(9, 39, 30, 0.7) 0px 1px 4px 0px"
},
"search": {
"h": 38,
"top": 1232,
"border": "1px rgb(8, 37, 28)",
"radius": "3px",
"bg": "linear-gradient(0deg, rgba(0, 0, 0, 0.45",
"shadow": "rgba(9, 39, 30, 0.7) 0px 1px 4px 0px"
},
"label": "12px",
"fieldLabel": "12px"
}
},
{
"name": "5 D hover ring like existing fields (edge turns yellow, inside unchanged)",
"status": "PASS",
"data": {
"ring": "1",
"before": {
"inside": [
18,
50,
39
],
"edge": [
8,
37,
28
]
},
"after": {
"inside": [
18,
50,
39
],
"edge": [
172,
131,
3
]
}
}
},
{
"name": "5 D Enter commits through SWHeaderInput → search callback",
"status": "PASS",
"data": "search:WALL-01"
},
{
"name": "5 D search button callback uses committed value",
"status": "PASS",
"data": "search:WALL-01"
},
{
"name": "5 D Escape restores the committed value",
"status": "PASS",
"data": "WALL-01"
},
{
"name": "5 D existing color dropdown in the same bar",
"status": "PASS",
"data": "color:blue"
},
{
"name": "6 B Tab order = display order",
"status": "PASS",
"data": [
"문서: C0020101-006-평면및종단면도(06)",
"레이어",
"색상",
"팝업 가이드"
]
},
{
"name": "7 no page/console errors",
"status": "PASS",
"data": []
}
],
"errors": [],
"warnings": [
"Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently",
"Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently"
]
}
@@ -0,0 +1,104 @@
{
"baseCommit": "50bf5bda8e956a4973b923d64131f2f2285bd38c",
"checks": [
{
"name": "all 28 Story IDs, names, order and tags retained",
"status": "PASS"
},
{
"name": "other 27 Story declarations byte-identical",
"status": "PASS"
},
{
"name": "package production source untouched",
"status": "PASS"
},
{
"name": "document first row is above the property Bar and clickable",
"status": "PASS"
},
{
"name": "existing 레이어 상세 reports Callback",
"status": "PASS"
},
{
"name": "existing 색상 상세 reports Callback",
"status": "PASS"
},
{
"name": "existing 선스타일 상세 reports Callback",
"status": "PASS"
},
{
"name": "existing 문자스타일 상세 reports Callback",
"status": "PASS"
},
{
"name": "existing 치수스타일 상세 reports Callback",
"status": "PASS"
},
{
"name": "ByBlock mode immediately appears in Header without changing numeric RGB property",
"status": "PASS"
},
{
"name": "direct color selection clears Header mode label",
"status": "PASS"
},
{
"name": "small Inner layer dropdown stays in browser width",
"status": "PASS",
"data": {
"x": 141,
"y": 345.0625,
"width": 234,
"height": 250
}
},
{
"name": "small Inner lower layer row hit-test is unobstructed",
"status": "PASS"
},
{
"name": "small Inner layer selection or Escape closes only nested dropdown",
"status": "PASS"
},
{
"name": "small Inner line dropdown stays in browser width",
"status": "PASS",
"data": {
"x": 186,
"y": 39.0625,
"width": 189.1875,
"height": 342
}
},
{
"name": "small Inner line selection or Escape closes only nested dropdown",
"status": "PASS"
},
{
"name": "small Inner color dropdown stays in browser width",
"status": "PASS",
"data": {
"x": 175,
"y": 377.0625,
"width": 192,
"height": 182
}
},
{
"name": "small Inner color selection or Escape closes only nested dropdown",
"status": "PASS"
},
{
"name": "no overlay browser errors",
"status": "PASS",
"data": []
}
],
"errors": [],
"sizes": [],
"browser": "155.0.8059.80",
"status": "PASS"
}
@@ -0,0 +1,670 @@
{
"baseCommit": "50bf5bda8e956a4973b923d64131f2f2285bd38c",
"checks": [
{
"name": "all 28 Story IDs, names, order and tags retained",
"status": "PASS"
},
{
"name": "other 27 Story declarations byte-identical",
"status": "PASS"
},
{
"name": "package production source untouched",
"status": "PASS"
},
{
"name": "16:9 fitted to both Inner dimensions at 1920x1080",
"status": "PASS",
"data": {
"stage": {
"x": 360,
"y": 264.25,
"w": 1200,
"h": 675,
"right": 1560,
"bottom": 939.25
},
"slot": {
"x": 360,
"y": 169.5,
"w": 1200,
"h": 864.5,
"right": 1560,
"bottom": 1034
},
"header": {
"x": 360,
"y": 264.25,
"w": 1200,
"h": 49,
"right": 1560,
"bottom": 313.25
},
"viewport": {
"w": 1920,
"h": 1080
},
"overflow": 0
}
},
{
"name": "16:9 fitted to both Inner dimensions at 1440x900",
"status": "PASS",
"data": {
"stage": {
"x": 120,
"y": 174.25,
"w": 1200,
"h": 675,
"right": 1320,
"bottom": 849.25
},
"slot": {
"x": 120,
"y": 169.5,
"w": 1200,
"h": 684.5,
"right": 1320,
"bottom": 854
},
"header": {
"x": 120,
"y": 174.25,
"w": 1200,
"h": 49,
"right": 1320,
"bottom": 223.25
},
"viewport": {
"w": 1440,
"h": 900
},
"overflow": 0
}
},
{
"name": "16:9 fitted to both Inner dimensions at 1280x720",
"status": "PASS",
"data": {
"stage": {
"x": 191.5625,
"y": 169.5,
"w": 896.875,
"h": 504.5,
"right": 1088.4375,
"bottom": 674
},
"slot": {
"x": 38.40625,
"y": 169.5,
"w": 1203.1875,
"h": 504.5,
"right": 1241.59375,
"bottom": 674
},
"header": {
"x": 191.5625,
"y": 169.5,
"w": 896.875,
"h": 49,
"right": 1088.4375,
"bottom": 218.5
},
"viewport": {
"w": 1280,
"h": 720
},
"overflow": 0
}
},
{
"name": "16:9 fitted to both Inner dimensions at 768x600",
"status": "PASS",
"data": {
"stage": {
"x": 42.21875,
"y": 169.5,
"w": 683.546875,
"h": 384.5,
"right": 725.765625,
"bottom": 554
},
"slot": {
"x": 23.046875,
"y": 169.5,
"w": 721.90625,
"h": 384.5,
"right": 744.953125,
"bottom": 554
},
"header": {
"x": 42.21875,
"y": 169.5,
"w": 683.546875,
"h": 49,
"right": 725.765625,
"bottom": 218.5
},
"viewport": {
"w": 768,
"h": 600
},
"overflow": 0
}
},
{
"name": "16:9 fitted to both Inner dimensions at 390x844",
"status": "PASS",
"data": {
"stage": {
"x": 16,
"y": 383.0625,
"w": 358,
"h": 201.375,
"right": 374,
"bottom": 584.4375
},
"slot": {
"x": 16,
"y": 169.5,
"w": 358,
"h": 628.5,
"right": 374,
"bottom": 798
},
"header": {
"x": 16,
"y": 383.0625,
"w": 390,
"h": 49,
"right": 406,
"bottom": 432.0625
},
"viewport": {
"w": 390,
"h": 844
},
"overflow": 0
}
},
{
"name": "16:9 fitted to both Inner dimensions at 1000x500",
"status": "PASS",
"data": {
"stage": {
"x": 247.109375,
"y": 169.5,
"w": 505.765625,
"h": 284.5,
"right": 752.875,
"bottom": 454
},
"slot": {
"x": 30,
"y": 169.5,
"w": 940,
"h": 284.5,
"right": 970,
"bottom": 454
},
"header": {
"x": 247.109375,
"y": 169.5,
"w": 505.765625,
"h": 49,
"right": 752.875,
"bottom": 218.5
},
"viewport": {
"w": 1000,
"h": 500
},
"overflow": 0
}
},
{
"name": "one Header and no duplicate fixed Figma specimen",
"status": "PASS"
},
{
"name": "document selection updates Header and Callback",
"status": "PASS",
"data": "onDocumentClick → documentOpen(true) → onDocumentSelect(doc-2) → documentOpen(false)"
},
{
"name": "layer selection updates shared data",
"status": "PASS",
"data": "onDocumentClick → documentOpen(true) → onDocumentSelect(doc-2) → documentOpen(false) → onLayerOpen → onLayerSelect(layer, CC-D GNL)"
},
{
"name": "existing layer Detail callback closes list",
"status": "PASS"
},
{
"name": "color selection invokes host callback",
"status": "PASS",
"data": "onLayerOpen → onLayerSelect(layer, CC-D GNL) → onLayerOpen → onLayerDetail → onColorOpen → onColorSelect(color, 255,255,255)"
},
{
"name": "line selection updates Header",
"status": "PASS",
"data": "onLayerOpen → onLayerDetail → onColorOpen → onColorSelect(color, 255,255,255) → onLineStyleOpen → onLineTypeSelect(line, CENTER)"
},
{
"name": "ByLayer weight mode does not turn numeric property into a mode string",
"status": "PASS"
},
{
"name": "text style selection updates Header",
"status": "PASS",
"data": "onColorSelect(color, 255,255,255) → onLineStyleOpen → onLineTypeSelect(line, CENTER) → onLineWeightSelect(w-bylayer) → onTextStyleOpen → onTextStyleSelect(dotum)"
},
{
"name": "dimension style selection updates Header",
"status": "PASS",
"data": "onLineTypeSelect(line, CENTER) → onLineWeightSelect(w-bylayer) → onTextStyleOpen → onTextStyleSelect(dotum) → onDimensionStyleOpen → onDimensionStyleSelect(iso-25)"
},
{
"name": "opening another dropdown closes the previous one",
"status": "PASS"
},
{
"name": "Escape closes Header dropdown",
"status": "PASS"
},
{
"name": "outside click closes Header dropdown",
"status": "PASS"
},
{
"name": "Bar Enter commits state",
"status": "PASS"
},
{
"name": "Bar validates min=0",
"status": "PASS"
},
{
"name": "Bar Escape cancels edit",
"status": "PASS"
},
{
"name": "Header opens panel with same Bar value",
"status": "PASS"
},
{
"name": "numeric weight property remains numeric after ByLayer mode",
"status": "PASS"
},
{
"name": "Header layer value shared with panel",
"status": "PASS"
},
{
"name": "panel edit updates the Bar",
"status": "PASS"
},
{
"name": "panel number validation rejects negative radius",
"status": "PASS"
},
{
"name": "panel Layer dropdown updates Header shared value",
"status": "PASS"
},
{
"name": "panel line dropdown updates Header shared value",
"status": "PASS"
},
{
"name": "panel color dropdown updates Header shared value",
"status": "PASS"
},
{
"name": "panel RGB validation rejects out-of-range value",
"status": "PASS"
},
{
"name": "gauge keyboard editing commits host state",
"status": "PASS"
},
{
"name": "first Escape cancels draft and retains panel",
"status": "PASS"
},
{
"name": "next Escape closes panel and restores Header focus",
"status": "PASS"
},
{
"name": "panel Close button restores focus",
"status": "PASS"
},
{
"name": "external Props update Bar",
"status": "PASS"
},
{
"name": "Bar ReadOnly",
"status": "PASS"
},
{
"name": "Bar Disabled",
"status": "PASS"
},
{
"name": "dynamic Bar field added",
"status": "PASS"
},
{
"name": "dynamic field shared with panel",
"status": "PASS"
},
{
"name": "dynamic field removed from both",
"status": "PASS"
},
{
"name": "shape Slot can be empty without changing panel structure",
"status": "PASS"
},
{
"name": "original panel size none:314x256",
"status": "PASS",
"data": {
"x": 1000,
"y": 301.25,
"width": 314,
"height": 256
}
},
{
"name": "original panel size all:314x361",
"status": "PASS",
"data": {
"x": 1000,
"y": 301.25,
"width": 314,
"height": 361
}
},
{
"name": "original panel size circle:314x658",
"status": "PASS",
"data": {
"x": 1000,
"y": 301.25,
"width": 314,
"height": 658
}
},
{
"name": "original panel size expanded:314x754",
"status": "PASS",
"data": {
"x": 1000,
"y": 301.25,
"width": 314,
"height": 754
}
},
{
"name": "expanded popup bottom accessible through internal scroll",
"status": "PASS"
},
{
"name": "small Inner reaches full popup bottom with native Header and popup dimensions",
"status": "PASS",
"data": {
"top": 630,
"left": 16,
"height": 201,
"scrollHeight": 831,
"width": 358,
"scrollWidth": 390
}
},
{
"name": "narrow popup Escape restores More focus",
"status": "PASS"
},
{
"name": "actual Storybook manager iframe fits its own Inner",
"status": "PASS",
"data": {
"width": 1071.59375,
"height": 602.765625,
"slotWidth": 1071.59375,
"slotHeight": 744.5,
"viewportWidth": 1140,
"viewportHeight": 960
}
},
{
"name": "no browser errors",
"status": "PASS",
"data": []
}
],
"errors": [],
"sizes": [
{
"stage": {
"x": 360,
"y": 264.25,
"w": 1200,
"h": 675,
"right": 1560,
"bottom": 939.25
},
"slot": {
"x": 360,
"y": 169.5,
"w": 1200,
"h": 864.5,
"right": 1560,
"bottom": 1034
},
"header": {
"x": 360,
"y": 264.25,
"w": 1200,
"h": 49,
"right": 1560,
"bottom": 313.25
},
"viewport": {
"w": 1920,
"h": 1080
},
"overflow": 0
},
{
"stage": {
"x": 120,
"y": 174.25,
"w": 1200,
"h": 675,
"right": 1320,
"bottom": 849.25
},
"slot": {
"x": 120,
"y": 169.5,
"w": 1200,
"h": 684.5,
"right": 1320,
"bottom": 854
},
"header": {
"x": 120,
"y": 174.25,
"w": 1200,
"h": 49,
"right": 1320,
"bottom": 223.25
},
"viewport": {
"w": 1440,
"h": 900
},
"overflow": 0
},
{
"stage": {
"x": 191.5625,
"y": 169.5,
"w": 896.875,
"h": 504.5,
"right": 1088.4375,
"bottom": 674
},
"slot": {
"x": 38.40625,
"y": 169.5,
"w": 1203.1875,
"h": 504.5,
"right": 1241.59375,
"bottom": 674
},
"header": {
"x": 191.5625,
"y": 169.5,
"w": 896.875,
"h": 49,
"right": 1088.4375,
"bottom": 218.5
},
"viewport": {
"w": 1280,
"h": 720
},
"overflow": 0
},
{
"stage": {
"x": 42.21875,
"y": 169.5,
"w": 683.546875,
"h": 384.5,
"right": 725.765625,
"bottom": 554
},
"slot": {
"x": 23.046875,
"y": 169.5,
"w": 721.90625,
"h": 384.5,
"right": 744.953125,
"bottom": 554
},
"header": {
"x": 42.21875,
"y": 169.5,
"w": 683.546875,
"h": 49,
"right": 725.765625,
"bottom": 218.5
},
"viewport": {
"w": 768,
"h": 600
},
"overflow": 0
},
{
"stage": {
"x": 16,
"y": 383.0625,
"w": 358,
"h": 201.375,
"right": 374,
"bottom": 584.4375
},
"slot": {
"x": 16,
"y": 169.5,
"w": 358,
"h": 628.5,
"right": 374,
"bottom": 798
},
"header": {
"x": 16,
"y": 383.0625,
"w": 390,
"h": 49,
"right": 406,
"bottom": 432.0625
},
"viewport": {
"w": 390,
"h": 844
},
"overflow": 0
},
{
"stage": {
"x": 247.109375,
"y": 169.5,
"w": 505.765625,
"h": 284.5,
"right": 752.875,
"bottom": 454
},
"slot": {
"x": 30,
"y": 169.5,
"w": 940,
"h": 284.5,
"right": 970,
"bottom": 454
},
"header": {
"x": 247.109375,
"y": 169.5,
"w": 505.765625,
"h": 49,
"right": 752.875,
"bottom": 218.5
},
"viewport": {
"w": 1000,
"h": 500
},
"overflow": 0
}
],
"browser": "155.0.8059.80",
"managerOrder": [
"sw-header--overview",
"sw-header--interactive-states",
"sw-header--window-move-motion",
"sw-header--composed-controls",
"sw-header--logo-slot",
"sw-header--document-dropdown",
"sw-header--layer-dropdown",
"sw-header--color-dropdown",
"sw-header--line-style-dropdown",
"sw-header--text-dimension-style-dropdown",
"sw-header--responsive-more",
"sw-header--layer-detail-input",
"sw-header--property-panel",
"sw-header--property-panel-variants",
"sw-header--k-guide",
"sw-header--additional-hover",
"sw-header--property-bar",
"sw-header--dynamic-controls",
"sw-header--colorize",
"sw-header--composition",
"sw-header--property-panel-none",
"sw-header--property-panel-all",
"sw-header--property-panel-circle",
"sw-header--property-panel-expanded",
"sw-header--property-panel-compare",
"sw-header--property-shape-slot",
"sw-header--property-fields"
],
"status": "PASS"
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 134 KiB

+116
View File
@@ -0,0 +1,116 @@
# SW AI 재사용 템플릿 — 조사·구현·검증 기록 (2026-10-19)
> 범위: `@hm-design/sw`만. `@hm-design/ui`·조직도·Center Graphic·DWG/DXF Viewer는 변경하지 않았습니다. 작업 Worktree `D:\디자인시스템\HMDesign-SW-AI-Template`, 브랜치 `feature/sw-ai-template`, 시작 HEAD `50bf5bda8e956a4973b923d64131f2f2285bd38c`(`feature/sw-package`).
>
> AI용 사용 규칙·카탈로그·디자인 규칙·조합 패턴의 **원본은 패키지 안 [`packages/sw/ai/`](../../../packages/sw/ai/AI_USAGE.md)** 이며 npm 패키지에 포함됩니다. 이 문서는 조사 결과와 검증 기록만 담고 원본 내용을 반복하지 않습니다.
## 1. 결론
- 기존 공개 Export만으로 재사용(예제 A)·조합(예제 B·C)·확장(예제 D)이 모두 가능했습니다. **런타임 소스(`packages/sw/src`)와 공개 API는 변경하지 않았습니다.** 그래서 기존 SW Header의 디자인·기능·Props·Callback은 그대로입니다.
- 변경은 패키지 배포 범위(`files`에 `ai` 추가), AI 가이드 문서 4종, 공개 API만 쓰는 예제, README 안내 1절, SW 전용 검증 스크립트 3개입니다.
- 공개 API가 부족한 부분(More, 범용 필드 면, Header 면 레이아웃 등)은 내부 Import로 우회하지 않았습니다. §3 EXPORT CANDIDATE / MISSING으로 보고합니다.
## 2. 기존 구성 분석 요약
- 진입점: `src/index.ts` → `components/sw-header/index.ts`, `SWTheme`. 배포는 `exports` `"."`(ESM `dist/index.js` + `dist/index.d.ts`)와 `"./styles.css"` 두 개입니다. `main`·`types`가 있고 `module` 필드는 없습니다(ESM 전용 `"type": "module"`, `exports`가 우선). Peer는 `react`/`react-dom` `^19.0.0`, 런타임 dependency는 없습니다.
- 자산: SVG·PNG 48개는 Vite library build에서 data URI로 JS에 포함됩니다(소비 프로젝트 33개 이미지 디코드 확인). 글꼴은 포함하지 않습니다.
- CSS: 토큰은 `.hm-sw-header`와 `.hm-sw-theme`에만 정의됩니다. 컴포넌트 규칙은 대부분 `hm-sw-header__*` class 단위라서 `SWTheme` 안에서 단독 렌더링됩니다. 반응형은 `@container hm-sw-header`(12개 규칙)라 `SWHeader` 안에서만 적용됩니다. 전역 선택자는 없습니다.
- Overlay: 필드 Dropdown은 absolute(필드 아래, `z-index` 30)이고 `closest(".hm-sw-header") ?? documentElement` 기준으로 좌우를 보정하므로 Header 밖에서도 동작합니다. 속성창·K 가이드는 `position: fixed`(`useSWHeaderOverlay`)이며 Portal·Dim은 없습니다.
- `dist`에는 내부 모듈 `.d.ts`도 생성되지만 `exports`가 deep import를 막습니다.
## 3. 분류
### PUBLIC READY (현재 외부 재사용 가능)
| 컴포넌트 | 근거 |
|---|---|
| `SWHeader` | 공식 기준 템플릿. 예제 A에서 1920 좌표가 Figma와 일치(0·80 / 65·310 / 377·1301 / 1680·240, 49px) |
| `SWTheme` | 단독 사용 토큰 범위. 소비 프로젝트에서 토큰·글꼴 확인 |
| `SWHeaderHome`, `SWHeaderDocumentTitle` | 예제 B·C 렌더링, 문서 Dropdown `+ New` Callback |
| `SWHeaderLayerMenu`, `SWHeaderColorMenu`, `SWHeaderLineStyleMenu`, `SWHeaderTextStyleMenu`, `SWHeaderDimensionStyleMenu` | 예제 B·C·D에서 열기·선택 Callback·Escape Focus 복귀 |
| `SWHeaderActions` | `showPrint` / `showPopupGuide`로 K만·인쇄만. K 가이드 Hover·Click |
| `SWHeaderInput`, `parseSWHeaderColor`, `parseSWHeaderNumber` | Input은 예제 D에서 Enter 확정·Escape 복원 확인. 파서는 이번 미검증 |
| `SWHeaderLayerControl` 외 필드 버튼 4종, `SWHeaderWindowControls`, `SWHeaderProperties`, `SWHeaderPropertyBar`, `SWHeaderPropertyPanel`, `SWHeaderLinePreview`, `swHeaderLinePatterns`, 공개 타입 | 공개·문서화 상태(기존 사용 가이드 §4·§8). 이번 소비 검증 범위 밖 |
### EXPORT CANDIDATE (내부 구현 있음, 공개 API 정비 필요)
| 내부 구현 | 재사용 가치 | 공개 전 필요한 정비 |
|---|---|---|
| `SWHeaderMore` | 조합 Header의 Overflow | tier·`data-visible`·container 규칙이 `SWHeader` 구조에 결합. 항목 visibility API 설계 필요 |
| `SWHeaderField` / `SWHeaderFieldText` | 새 필드형 컨트롤이 같은 면을 재사용(예제 D 표면 CSS 대체) | `variant`가 5개 고정 폭. 범용 폭·`children` 계약 필요 |
| `SWHeaderKGuide` | pill 없는 K 단독 버튼 | 현재 `SWHeaderActions showPrint={false}`로 대체 가능. 수요 확인 후 |
| `useSWHeaderDropdown`, `SWHeaderMenuDetail`, `SWHeaderColorChip` | 새 Dropdown 동작·`… 상세` pill·색상 칩 | 반환값 계약(ref·style·shifted)과 CSS 의존 정리 |
| Header 면(객체 수정 / 창 영역 / 막대) | 조합 Header 배치 | 현재 `SWHeader` 내부 DOM. 예제는 소비 CSS + 공개 토큰으로 구성 |
### INTERNAL ONLY
`SWHeaderLayerRows`, `SWHeaderColorPanelBody`, `SWHeaderLineStyleColumns`(헤더 Dropdown과 속성창 Picker의 공유 본문), `SWHeaderPropertyPickerField`, `propertyPicker`, `propertyLineTypes`, `SWHeaderLayerIconArt`, `SWHeaderLayerToggle`, `layerIconOn`, `SWHeaderCommitPresence`, `useSWHeaderOverlay`, `useSWHeaderColorMode`, `SWHeaderKGuidePreview`. 상위 공개 컴포넌트의 상태·Focus 계약에 묶여 있어 단독 공개 시 중복 상태 위험이 있습니다.
### MISSING (SW 패키지에 없음)
검색 컨트롤(예제 D에서 소비 프로젝트 확장으로 작성), 범용 SW Button / IconButton, 범용 Tooltip 컴포넌트(창 이동 Tooltip은 CSS 전용), 조합 Header용 반응형 More, Header 면 레이아웃 컴포넌트.
### 발견한 재사용 제약 (코드 미변경, 문서화)
1. `SWHeaderDocumentTitle`은 앞의 로고와 겹치는 전제로 왼쪽 테두리가 없습니다 → 첫 칸이면 소비 wrapper에 테두리(예제 C).
2. `SWHeaderDocumentTitle` 폭은 `100cqw` 기반이라 Header 밖에서는 뷰포트 폭을 기준으로 계산합니다.
3. 창 영역 배경·테두리는 `SWHeader` DOM에 있어 `SWHeaderActions`/`SWHeaderWindowControls` 단독 사용 시 소비 면이 필요합니다.
4. `body` margin 등 페이지 기본값은 소비 앱 책임입니다(첫 소비 실행에서 8px 오프셋으로 확인 → 테스트 앱에서 지정).
## 4. 변경 파일
| 파일 | 내용 |
|---|---|
| `packages/sw/package.json` | `files`: `["dist"]` → `["dist", "ai"]` |
| `packages/sw/README.md` | 끝에 "AI 코딩 도구용 가이드" 절 추가(기존 본문 무변경) |
| `packages/sw/ai/AI_USAGE.md`, `COMPONENT_CATALOG.md`, `DESIGN_RULES.md`, `COMPOSITION_PATTERNS.md` | AI 가이드(배포 포함) |
| `packages/sw/ai/examples/*` | 예제 A~D, `SWSearchControl`(소비 확장), 소비 배치 CSS, 샘플 데이터 |
| `packages/sw/tests/ai-docs.check.mjs` | 문서·예제와 공개 Export·링크 일치 검사 |
| `packages/sw/tests/ai-template-consumer.mjs` | `npm pack` → 저장소 밖 독립 React 19 + TS 7 + Vite 8 프로젝트 생성, Tarball 설치, 설치된 `ai/examples` 복사, `tsc`·`vite build` |
| `packages/sw/tests/ai-template-consumer.browser.mjs` | 소비 빌드 브라우저 검증(Chrome, Playwright) |
| `docs/sw/ai-template/` | 이 기록과 `evidence/`(스크린샷 7장, `report.json`) |
## 5. 이번에 실행한 검증 (2026-10-19)
| 항목 | 결과 |
|---|---|
| `npm run typecheck -w @hm-design/sw` | PASS |
| `npm run build -w @hm-design/sw` | PASS(`dist/index.js` 712.25 kB, `styles.css` 102.25 kB) |
| `npm pack`(실제 tarball, Publish 없음) | PASS — 40개 파일, `ai/` 문서 4 + 예제 7, `dist`, `README.md`, `package.json` |
| `node packages/sw/tests/ai-docs.check.mjs` | 19/19 PASS — 문서에 쓴 SW 이름 전부 공개 Export, "공개하지 않은 것" 목록에 Export 없음, 예제 Import 전부 공개·deep import 없음, 패키지 상대 링크 정상 |
| 독립 소비 프로젝트 설치(`react@19.3.0`, `typescript@7.0.2`, `vite@8.3.0`, `@vitejs/plugin-react@6.1.1`) | PASS |
| 소비 프로젝트 `tsc`(strict, `noUnusedLocals`, `skipLibCheck: false`) | PASS — 설치된 패키지의 예제를 그대로 컴파일 |
| 소비 프로젝트 Production Build | PASS(번들 500 kB 경고만, 오류 없음) |
| 소비 빌드 브라우저(Chrome 1920 × 1080) | 27/27 PASS([report.json](./evidence/report.json)) |
브라우저 27개 항목: 자산 33개 data URI 디코드 · CSS/토큰 로딩 · A 기본 Header 좌표 = Figma · A 레이어 선택 / 종료 Callback · B 순서(DOM) · B 크기(49 / 80 / 파일 x65 / 234·192 × 38) · B 문서 `+ New` · B 레이어 열림 · Escape 닫힘 + Focus 복귀 · B 색상 선택(열림 유지) · B K 가이드 Hover · B K Click · C 순서 · C 파일 왼쪽 테두리 · C 선·문자·치수 선택과 필드 값 반영 · C 인쇄 · D 검색 필드 표면 = 레이어 필드(높이·위치·테두리·radius·배경·그림자·라벨 12px) · D Hover 링(테두리만 노랑, 내부 픽셀 불변) · D Enter → 검색 · D 버튼 검색 · D Escape 복원 · D 색상 선택 · B Tab 순서 = 표시 순서 · 페이지/콘솔 오류 0.
중간 실패와 수정: 첫 실행 24/27(소비 앱 `body` 8px margin, 예제 막대 `box-sizing`, favicon 404) → 소비 앱·예제 CSS 수정. 육안 확인 중 D의 Hover 링이 필드 전체를 덮는 문제(표준 `mask` 뒤에 `-webkit-mask` 선언) 발견 → 선언 순서 수정, 테스트에 픽셀 검사 추가 후 재실행 PASS.
증거: [A](./evidence/A-default-1920.png) · [B](./evidence/B-viewer-1920.png) · [B 레이어](./evidence/B-layer-open.png) · [B K](./evidence/B-k-guide.png) · [C](./evidence/C-feature-1920.png) · [D](./evidence/D-search-1920.png) · [D Hover](./evidence/D-search-hover.png).
재현:
```bash
npm run build -w @hm-design/sw
node packages/sw/tests/ai-docs.check.mjs
node packages/sw/tests/ai-template-consumer.mjs <저장소 밖 빈 폴더>
node packages/sw/tests/ai-template-consumer.browser.mjs <같은 폴더> <증거 폴더>
```
## 6. NOT VERIFIED
- Firefox·Safari에서 소비 빌드(이번에는 Chrome만). 예제 D의 mask는 표준 + prefix 둘 다 선언했으나 Firefox 미실행.
- 이번 소비 검증에서 직접 렌더링하지 않은 공개 컴포넌트: 필드 버튼 5종 단독, `SWHeaderWindowControls` 단독(창 이동 Motion), `SWHeaderProperties`, `SWHeaderPropertyBar`, `SWHeaderPropertyPanel`(13개 필드·4상태), `SWHeaderLinePreview`, 파서 함수. 기존 Storybook 검증 기록만 있습니다.
- `SWHeader`의 반응형 More·10개 폭, Colorize, 게이지바, 속성창 연결: 런타임 코드를 바꾸지 않았으므로 이번에 다시 실행하지 않았습니다(과거 PASS 기록은 기존 문서).
- Noto Sans KR 실제 로드(소비 테스트는 시스템 대체 글꼴).
- 저장소 루트 전체 `npm run typecheck`·Storybook build(SW 패키지 단위만 실행).
- `README.md`의 기존 `../../docs/...` 링크는 Tarball 안에서 열리지 않습니다(기존 상태, `ai/` 문서는 패키지 내부 링크만 사용).
## 7. 추가 개선 후보
1. `SWHeaderField` 범용 폭 공개 → 새 필드형 컨트롤이 표면 CSS를 다시 쓰지 않도록.
2. 조합 Header용 More(또는 `SWHeaderMore` 공개 API) 설계.
3. `SWHeaderDocumentTitle`에 단독 첫 칸용 왼쪽 테두리 옵션 검토(기본 Header 픽셀 영향 확인 필요).
4. Header 면(막대·객체 수정·창 영역) 레이아웃 컴포넌트 검토.
5. 소비 검증을 Firefox와 속성창 단독 사용까지 확장.
Binary file not shown.

After

Width:  |  Height:  |  Size: 40 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 31 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 32 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 19 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 25 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 11 KiB

+276
View File
@@ -0,0 +1,276 @@
{
"checks": [
{
"name": "1 all SVG/PNG assets decode (data URI, no external path)",
"status": "PASS",
"data": {
"count": 33,
"broken": []
}
},
{
"name": "1 styles.css loaded; SWTheme provides tokens",
"status": "PASS",
"data": {
"height": "49px",
"font": "\"Noto Sans KR\", \"Malgun Gothic\", \"Apple SD Gothic Neo\", sans-serif"
}
},
{
"name": "2 A default header 1920 geometry = Figma (49 / 0·80 / 65·310 / 377·1301 / 1680·240)",
"status": "PASS",
"data": {
"header": {
"x": 0,
"y": 0,
"w": 1920,
"h": 49
},
"home": {
"x": 0,
"y": 0,
"w": 80,
"h": 48
},
"document": {
"x": 65,
"y": 0,
"w": 310,
"h": 48
},
"object": {
"x": 377,
"y": 0,
"w": 1301,
"h": 48
},
"window": {
"x": 1680,
"y": 0,
"w": 240,
"h": 48
}
}
},
{
"name": "2 A layer dropdown select callback",
"status": "PASS",
"data": "layer:center"
},
{
"name": "2 A close callback",
"status": "PASS",
"data": "close"
},
{
"name": "3 B order Logo → File → Layer → Color → K (DOM)",
"status": "PASS",
"data": [
"뷰어 홈",
"hm-sw-header__document",
"레이어",
"색상",
"팝업 가이드"
]
},
{
"name": "3 B sizes keep SW rules (bar 49, home 80 overlapping file 15px, fields 234/192 x 38)",
"status": "PASS",
"data": {
"bar": {
"x": 0,
"y": 409,
"w": 1920,
"h": 49
},
"home": {
"x": 0,
"y": 409,
"w": 80,
"h": 48
},
"document": {
"x": 65,
"y": 409,
"w": 310,
"h": 48
},
"layer": {
"x": 382,
"y": 414,
"w": 234,
"h": 38
},
"color": {
"x": 620,
"y": 414,
"w": 192,
"h": 38
}
}
},
{
"name": "3 B document dropdown New callback",
"status": "PASS",
"data": "document:new"
},
{
"name": "3 B layer dropdown opens under the field",
"status": "PASS",
"data": "true"
},
{
"name": "3 B Escape closes and returns focus to the field",
"status": "PASS",
"data": null
},
{
"name": "3 B color chip callback (dropdown stays open)",
"status": "PASS",
"data": "color:red"
},
{
"name": "3 B K guide preview on hover (animation frame advances)",
"status": "PASS",
"data": {
"guide": true
}
},
{
"name": "3 B K click callback",
"status": "PASS",
"data": "popupGuide"
},
{
"name": "4 C order File → 선 → 문자 → 치수 → 인쇄",
"status": "PASS",
"data": [
"file",
"선스타일",
"문자스타일",
"치수스타일",
"인쇄"
]
},
{
"name": "4 C first file well has a left edge (consumer wrapper)",
"status": "PASS",
"data": "1px"
},
{
"name": "4 C line type callback + field value",
"status": "PASS",
"data": "lineType:center"
},
{
"name": "4 C text style callback + field value",
"status": "PASS",
"data": "textStyle:dotum"
},
{
"name": "4 C dimension style callback",
"status": "PASS",
"data": "dimensionStyle:iso"
},
{
"name": "4 C print callback",
"status": "PASS",
"data": "print"
},
{
"name": "5 D new search field matches the SW field surface (height/top/border/radius/background/shadow/label)",
"status": "PASS",
"data": {
"layer": {
"h": 38,
"top": 1232,
"border": "1px rgb(8, 37, 28)",
"radius": "3px",
"bg": "linear-gradient(0deg, rgba(0, 0, 0, 0.45",
"shadow": "rgba(9, 39, 30, 0.7) 0px 1px 4px 0px"
},
"search": {
"h": 38,
"top": 1232,
"border": "1px rgb(8, 37, 28)",
"radius": "3px",
"bg": "linear-gradient(0deg, rgba(0, 0, 0, 0.45",
"shadow": "rgba(9, 39, 30, 0.7) 0px 1px 4px 0px"
},
"label": "12px",
"fieldLabel": "12px"
}
},
{
"name": "5 D hover ring like existing fields (edge turns yellow, inside unchanged)",
"status": "PASS",
"data": {
"ring": "1",
"before": {
"inside": [
18,
50,
39
],
"edge": [
8,
37,
28
]
},
"after": {
"inside": [
18,
50,
39
],
"edge": [
172,
131,
3
]
}
}
},
{
"name": "5 D Enter commits through SWHeaderInput → search callback",
"status": "PASS",
"data": "search:WALL-01"
},
{
"name": "5 D search button callback uses committed value",
"status": "PASS",
"data": "search:WALL-01"
},
{
"name": "5 D Escape restores the committed value",
"status": "PASS",
"data": "WALL-01"
},
{
"name": "5 D existing color dropdown in the same bar",
"status": "PASS",
"data": "color:blue"
},
{
"name": "6 B Tab order = display order",
"status": "PASS",
"data": [
"문서: C0020101-006-평면및종단면도(06)",
"레이어",
"색상",
"팝업 가이드"
]
},
{
"name": "7 no page/console errors",
"status": "PASS",
"data": []
}
],
"errors": [],
"warnings": [
"Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently",
"Canvas2D: Multiple readback operations using getImageData are faster with the willReadFrequently attribute set to true. See: https://html.spec.whatwg.org/multipage/canvas.html#concept-canvas-will-read-frequently"
]
}
@@ -0,0 +1,84 @@
# SW AI 재사용 템플릿 로컬 통합 기록 (2026-10-19)
> 범위: `@hm-design/sw`. `feature/sw-ai-template`을 `feature/sw-package`에 로컬 Merge만 했습니다. Push·PR·Publish·배포는 하지 않았습니다. 기존 [AI 템플릿 검증 기록](./ai-template/README.md)과 [Storybook Overview 검증 자료](./storybook-overview-qa/)는 수정하지 않았습니다. 이번 실행 결과는 [ai-template-integration-qa/](./ai-template-integration-qa/)에 따로 두었습니다.
## 1. 통합 전 상태 (실제 로컬 Git)
| 항목 | 값 |
|---|---|
| `feature/sw-package` HEAD | `bf16578226fe89e5d9a6ba904b7979bebb602661` (chore(sw): improve interactive header story preview) |
| `feature/sw-ai-template` HEAD | `e27385e0f07e6ad5d58d5ba81cfcbef800997ec6` (feat(sw): prepare reusable AI-friendly component package) |
| Merge Base | `50bf5bda8e956a4973b923d64131f2f2285bd38c` |
| 원본 Working Tree | Clean(Staged·Unstaged·Untracked 없음) |
| 원격 대비 미Push Commit(`origin/feature/sw-package` = `724c517`) | `0029cef`, `92830fe`, `50bf5bd`, `bf16578` — 4개 모두 보존 |
| Worktree | `HMDesign-SW`(`feature/sw-package`), `HMDesign-SW-AI-Template`(`feature/sw-ai-template`), `HMDesign-SW-Composition`(`wip/sw-header-composition-unverified`) 외 기존 OrgChart·main |
`left-right` 비교: `feature/sw-package`에만 `bf16578`, `feature/sw-ai-template`에만 `e27385e`.
## 2. 영향 분석
- 양쪽 브랜치가 함께 수정한 파일: **없음**. 삭제·이동: 없음.
- AI 브랜치 변경 26개: 신규 24(`packages/sw/ai/` 12, `packages/sw/tests/ai-*.mjs` 3, `docs/sw/ai-template/` 9), 수정 2(`packages/sw/package.json`의 `files`에 `ai` 추가, `packages/sw/README.md` 끝에 안내 1절).
- Storybook 브랜치의 런타임 외 변경(`src/sw/sw-header.stories.tsx`, `src/sw/sw-stories.css`, `src/sw/sw-header-overview.tsx`, Overview 테스트, QA 자료)과 겹치지 않습니다.
- `packages/sw/src`, `src/`, `.storybook/`, `packages/ui`, OrgChart 패키지, Center Graphic, 루트 `package.json`·`package-lock.json`: Merge 결과 diff 없음.
- 공개 API: 변경 없음(`exports` 그대로). 배포 설정: `files`만 `["dist", "ai"]`로 확장.
## 3. Merge
- 방식: `git merge --no-ff --no-commit feature/sw-ai-template` → 결과 검토 → 이 보고서와 이번 실행 증거를 추가 → Merge Commit.
- 충돌: 없음("Automatic merge went well"). 스테이징 26개 = AI 브랜치 변경 목록과 같음. `git diff --cached --check` 통과.
- 제외: `wip/sw-header-composition-unverified`(`1b2c3917376841da46a1e2bd4cc4913e41eeb77f`)는 Merge·Cherry-pick하지 않았습니다. 브랜치·Commit·`D:\디자인시스템\HMDesign-SW-Composition` 폴더 그대로이며, 그 Worktree의 Git Status는 Clean입니다.
## 4. 통합된 AI 템플릿 (실제 파일)
| 구분 | 파일 |
|---|---|
| AI 사용 가이드 | `packages/sw/ai/AI_USAGE.md` |
| 컴포넌트 카탈로그 | `packages/sw/ai/COMPONENT_CATALOG.md` |
| SW 디자인 규칙 | `packages/sw/ai/DESIGN_RULES.md` |
| 조합 규칙 | `packages/sw/ai/COMPOSITION_PATTERNS.md` |
| 재사용 예제 | `ai/examples/ExampleA-DefaultHeader.tsx`(완성형 `SWHeader`) |
| 새 Header 구성 예제 | `ExampleB-ViewerHeader.tsx`, `ExampleC-FeatureHeader.tsx`, `example-layout.css`, `sample-data.ts` |
| 없는 컴포넌트를 SW 스타일로 구현 | `SWSearchControl.tsx`, `sw-search-control.css`, `ExampleD-SearchExtension.tsx` |
| 검증 도구 | `packages/sw/tests/ai-docs.check.mjs`, `ai-template-consumer.mjs`, `ai-template-consumer.browser.mjs` |
| 독립 소비 검증 자료 | `docs/sw/ai-template/README.md`, `docs/sw/ai-template/evidence/` |
가이드는 "검색 → 재사용 → 조합 → 없을 때만 소비 프로젝트에서 SW 규칙으로 신규 작성" 순서를 지시합니다. 패키지가 AI 기능을 실행한다고 쓰지 않습니다. 기존 `SWHeader`를 공식 기본 템플릿으로 두며, 예제 Header는 제품 Header를 대체하지 않습니다.
## 5. 이번에 실행한 검증 (통합 Working Tree, Merge Commit 직전 상태)
| 항목 | 결과 |
|---|---|
| `npm run typecheck:sw` | PASS |
| `npm exec -- tsc -p tsconfig.app.json --noEmit`(Storybook·Demo 소스) | PASS |
| `npm run build:sw` | PASS — `dist/index.js`·`styles.css`·`index.d.ts` SHA-256이 Merge 전 산출물과 같음 |
| `npm exec -- storybook build -o storybook-static --disable-telemetry` | PASS |
| Story ID 비교 | 28개. ID·이름·importPath·순서가 [after-index.json](./storybook-overview-qa/after-index.json)과 같음. 삭제된 Default·Controls·Responsive·Text Stroke·Dynamic Data는 없음. AI 예제는 Story로 수집되지 않음 |
| Storybook 소스 해시 | `sw-header.stories.tsx`·`sw-header-overview.tsx`·`sw-stories.css`가 Overview 검증 기록의 `sourceSha256`과 같음 |
| Overview Chrome(`overview.browser.mjs`, 통합 Storybook 빌드) | 53/53 PASS — 16:9 Stage, 49px, Dropdown, 속성Bar 동적 편집·확정, 속성창 열기·닫기·Focus 복귀, 입력 검증, Enter/Escape, Disabled/ReadOnly 포함([결과](./ai-template-integration-qa/chrome-result.json)) |
| Overview Overlay(`overview.browser.mjs overlays`) | 19/19 PASS([결과](./ai-template-integration-qa/chrome-overlays-result.json)) |
| Window Move Motion Story | "단계별 진행 (3배 확대)" 한 구성만 존재, 이동 영역 9개, 이미지 디코드, 창 이동 애니메이션 24개 실행, 오류 0([화면](./ai-template-integration-qa/window-move-motion.png)) |
| `ai-docs.check.mjs` | 19/19 PASS — 문서·예제 Import와 공개 Export 일치, 패키지 내부 링크 정상 |
| Tarball(`npm pack`, 통합 브랜치) | 40개 파일, `ai/` 12개 포함, `src`·`tests` 미포함 |
| 독립 소비 프로젝트(기존 프로젝트 재사용, 통합 Tarball 재설치) | 설치된 `ai/examples` = 저장소 예제, `tsc` PASS, `vite build` PASS, 브라우저 27/27 PASS([결과](./ai-template-integration-qa/ai-consumer-report.json)) |
Overview 스크립트는 결과를 출력 폴더에 쓰므로 기존 `storybook-overview-qa/`가 아니라 임시 폴더에서 실행한 뒤 결과 JSON만 `ai-template-integration-qa/`로 복사했습니다.
## 6. 기존 PASS 재사용 근거
런타임 소스(`packages/sw/src`)와 Storybook 소스(`src/`, `.storybook/`)가 Merge로 바뀌지 않았고 SW 산출물 해시도 같습니다. 그래서 아래 기존 결과는 다시 실행하지 않고 이전 기록으로 남깁니다(이번에 새 PASS로 기록하지 않음): Overview Firefox 9/9, 기존 필드 77/77, Header 관련 257 checks, Header 10개 폭·반응형 More 전체·Colorize·게이지바(Chrome/Firefox)·속성창 4상태·속성 필드 13개 개별 검증.
## 7. NOT VERIFIED
- Firefox: 이번 통합 후 실행하지 않음(기존 9/9 기록만).
- Overview 외 27개 Story의 전체 인터랙션 회귀(Story 선언·소스 해시 동일만 확인).
- 저장소 루트 전체 `npm run typecheck`(`tsc -b` + 모든 workspace)와 `npm run build-storybook`의 `prebuild`(전체 패키지 build). SW 범위 명령과 직접 `storybook build`만 실행했습니다.
- AI 소비 예제의 Firefox·Safari, 소비 검증 범위 밖 공개 컴포넌트(기존 AI 기록 §6 그대로).
- CAD·파일·운영체제 연동, 접근성 인증.
## 8. 로컬 Merge Commit
- 메시지: `merge(sw): integrate AI reusable component template`
- Parents: `bf16578`(feature/sw-package) + `e27385e`(feature/sw-ai-template)
- Push·Force Push·원격 갱신·PR·Publish·배포: 진행하지 않음.
- Commit SHA·Parents·Status는 Commit 직후 확인 결과를 최종 보고에 적습니다(이 문서는 Commit 안에 포함되므로 자기 SHA를 담을 수 없음).
+4
View File
@@ -87,3 +87,7 @@ npm run typecheck -w @hm-design/sw
- 속성창 공통 행은 Figma 26:12388 ~ 26:12649의 Default / Hover / On을 따릅니다. 숫자는 편집 On(`#0F0D0A`), 레이어·선스타일·색상은 Dropdown On(`#211D13`)이며 하이퍼링크·재료는 Default·Hover만 있습니다.
- 추가 API(모두 선택): `SWHeaderProperty.picker`(`"layer" | "lineStyle" | "color" | false`), `SWHeaderProperty.decimals`, `SWHeaderPropertyPanelProps.menus`(`SWHeaderPropertyMenus`). 필드 Dropdown은 Header Dropdown과 같은 목록 컴포넌트를 씁니다. [사용법](../../docs/sw/sw-header-usage.md#속성창k-가이드-추가-연결-2026-10-16), [검증 기록](../../docs/sw/sw-property-fields-verification-2026-10-19.md).
- 투명도 게이지는 빨강 채움 + 베이지 Track이며 Chrome·Firefox가 같은 그림을 씁니다([Firefox 게이지 기록](../../docs/sw/sw-header-firefox-range-close-verification-2026-10-19.md)).
## AI 코딩 도구용 가이드 (2026-10-19)
다른 프로젝트에서 AI 코딩 도구가 이 패키지를 재사용·조합·확장할 때는 설치된 패키지의 `ai/` 폴더를 먼저 읽습니다: [AI_USAGE.md](./ai/AI_USAGE.md)(작업 순서·Import 규칙) → [COMPONENT_CATALOG.md](./ai/COMPONENT_CATALOG.md)(공개 API) → [DESIGN_RULES.md](./ai/DESIGN_RULES.md)(토큰·상태·Motion) → [COMPOSITION_PATTERNS.md](./ai/COMPOSITION_PATTERNS.md)(조합 방법) → [examples/](./ai/examples)(공개 API만 쓰는 예제 A~D). `ai/`는 npm 패키지(`files`)에 포함됩니다. 런타임 API는 바뀌지 않았습니다.
+54
View File
@@ -0,0 +1,54 @@
# @hm-design/sw — AI 코딩 도구 사용 안내
이 폴더(`node_modules/@hm-design/sw/ai/`)는 다른 React 프로젝트에서 AI 코딩 도구가 SW 패키지를 **재사용·조합·확장**할 때 먼저 읽는 안내서입니다. 패키지 자체에는 AI 기능이 없습니다. 아래 문서는 실제 공개 API와 CSS에서 확인한 값만 적습니다.
| 문서 | 내용 |
|---|---|
| [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md) | 공식 공개 컴포넌트·Props·Callback·크기·CSS 의존성 |
| [DESIGN_RULES.md](./DESIGN_RULES.md) | SW 전용 토큰·Typography·간격·Hover/Focus/Selected·Overlay·Motion·반응형 |
| [COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md) | 완성형·간소화·기능 중심·확장 Header 조합 방법 |
| [examples/](./examples) | 공개 API만 쓰는 예제 A~D(독립 소비 프로젝트에서 Typecheck·Build·브라우저 검증) |
## 1. 작업 순서 (필수)
1. **먼저 검색한다.** 요청 기능을 [COMPONENT_CATALOG.md](./COMPONENT_CATALOG.md)의 공개 컴포넌트에서 찾는다.
2. **있으면 재사용한다(1순위).** 완성형 Header가 필요하면 `SWHeader` 하나만 쓴다. 기존 Props(`hiddenControls`, `menuOrder`, `controls`, 각 데이터 배열·Callback)로 해결되는지 먼저 본다.
3. **없으면 조합한다(2순위).** 개별 공개 컴포넌트(`SWHeaderHome`, `SWHeaderDocumentTitle`, `SWHeaderLayerMenu` …)를 `<SWTheme>` 안에 배치한다. 배치용 CSS만 소비 프로젝트에 작성한다([COMPOSITION_PATTERNS.md](./COMPOSITION_PATTERNS.md)).
4. **조합으로도 안 될 때만 새로 만든다(3순위).** 새 컴포넌트는 **소비 프로젝트**에 작성하고, 공개 컴포넌트(예: `SWHeaderInput`)를 내부에 재사용하며, 외형은 공개 토큰 `--hm-sw-header-*`만 읽는다([DESIGN_RULES.md](./DESIGN_RULES.md), 예제 D).
5. **새 디자인 값을 만들지 않는다.** 크기·색·간격은 토큰과 DESIGN_RULES의 확인값만 쓴다. 값이 없으면 추측하지 말고 사용자에게 확인한다.
6. **존재하지 않는 API를 추측하지 않는다.** 카탈로그와 `dist/index.d.ts`에 없는 Export·Props는 없는 것이다.
7. **패키지 코드를 복사하지 않는다.** `node_modules/@hm-design/sw/dist`의 구현이나 `hm-sw-*` CSS 블록을 복사해 같은 컴포넌트를 다시 만들지 않는다.
8. **실제로 검증한다.** 소비 프로젝트에서 `tsc`, Production Build, 브라우저 렌더링(Dropdown 열기·Callback·Focus)을 확인한 뒤 완료로 보고한다. 실행하지 않은 검증은 PASS로 적지 않는다.
## 2. 설치·Import 규칙
```tsx
import { SWHeader, SWTheme, SWHeaderLayerMenu } from "@hm-design/sw"
import "@hm-design/sw/styles.css" // 앱 진입점에서 한 번
```
- 공식 진입점은 `@hm-design/sw`(JS·타입)와 `@hm-design/sw/styles.css` 두 개뿐이다. `package.json`의 `exports`가 다른 경로를 막는다.
- **금지:** `@hm-design/sw/src/...`, `@hm-design/sw/dist/components/...` 같은 내부 경로 Import. `dist`에 내부 모듈의 `.d.ts`가 보여도 공개 API가 아니다.
- Peer: `react`, `react-dom` `^19.0.0`. 런타임 dependency 없음. `@hm-design/ui`·조직도 패키지와 무관하다.
- SVG·PNG 자산은 JS에 data URI로 포함되어 별도 설정이 없다.
- 글꼴 파일은 포함하지 않는다. 앱에서 Noto Sans KR(400/500/700)을 로드한다(없으면 Malgun Gothic → Apple SD Gothic Neo).
- CSS는 전역(`html`, `body`, `*`)을 건드리지 않는다. `body` margin 등 페이지 기본값은 소비 앱이 정한다.
- TypeScript에서 CSS side-effect import 오류가 나면 Vite의 `vite/client` 타입을 포함한다.
## 3. 스타일 규칙
- `SWHeader` 밖에서 하위 컴포넌트를 쓰면 반드시 `<SWTheme>`로 감싼다. 토큰·글꼴·버튼 초기화가 이 범위에서만 적용된다.
- 소비 코드는 `hm-sw-*` class를 붙이거나 재정의하지 않는다. 배치는 자기 class(`className`)와 `style`로 한다.
- 외형 변경은 `--hm-sw-header-*` 토큰을 `SWTheme`/`SWHeader`의 `style`로 바꾸는 방법만 쓴다. 사용자 확정 면(파일명·객체 수정·속성 입력)은 기본값 유지를 권장한다.
- Light/Dark 분기는 없다. SW 디자인은 하나다.
## 4. 판단 기록 남기기
새 UI를 만들 때 결과 보고에 다음을 남긴다.
| 요청 항목 | 판단 | 근거 |
|---|---|---|
| 예: 레이어 | 재사용 | `SWHeaderLayerMenu` |
| 예: 검색 | 신규(소비 프로젝트) | 카탈로그 MISSING. `SWHeaderInput` 재사용 + 토큰 기반 표면 |
새 컴포넌트를 SW 패키지 공식 컴포넌트로 승격할지는 별도 검토 대상이다. 임의로 패키지에 추가하지 않는다.
+75
View File
@@ -0,0 +1,75 @@
# @hm-design/sw 컴포넌트 카탈로그
기준: `@hm-design/sw` 0.1.0, `dist/index.d.ts` 공개 Export(2026-10-19). 모든 Import는 `from "@hm-design/sw"`이며 CSS는 `@hm-design/sw/styles.css` 한 개입니다. 이 표에 없는 이름은 공개 API가 아닙니다.
- **단독 사용:** `SWHeader` 밖에서는 `<SWTheme>` 안에 둡니다(토큰·글꼴·버튼 초기화).
- **상태 소유:** 값은 Props, 사용자 행동은 Callback입니다. 컴포넌트는 CAD 데이터를 바꾸지 않습니다. 호스트가 Callback을 받아 Props를 갱신합니다.
- **열림 상태:** Dropdown은 `open` / `onOpenChange`(Controlled)를 주지 않으면 내부 상태로 동작합니다.
- **공통 키보드:** Click·Enter·Space 열기/닫기(Hover로는 열리지 않음), ArrowDown 열고 첫(현재) 항목 Focus, Arrow 이동, Escape·바깥 클릭·Tab 이탈 닫기(Escape는 Trigger로 Focus 복귀).
- **검증 표시:** ✅ = 2026-10-19 독립 소비 프로젝트(Tarball 설치)에서 브라우저 검증, ◻ = 이번 소비 검증 범위 밖(기존 Storybook 검증 기록만 있음).
## 1. 완성형 템플릿
### `SWHeader` ✅
SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체 수정 영역(그리기모드, 레이어/색상/선/문자/치수스타일, 상세보기) · 창 영역(인쇄·K, 창 이동, 최소화, 종료)과 반응형 More, 속성Bar, 속성창을 포함합니다.
| 구분 | Props / Callback |
|---|---|
| 필수 | `documentTitle: string` |
| 구성 | `hiddenControls?: SWHeaderHideableControlId[]`(로고 외 부품 제거, `home` 제거 시 정적 로고), `menuOrder?: SWHeaderMenuId[]`(5개 메뉴끼리 순서), `controls?: SWHeaderControlConfig[]`(객체 영역 전체 allowlist·순서·label) |
| 로고 | `logo?: ReactNode`, `logoSrc?: string`, `logoAlt?: string`, `onHomeClick` |
| 파일 | `documents`, `activeDocumentId`, `documentMenuOpen`, `onDocumentMenuOpenChange`, `onDocumentSelect`, `onDocumentClose`, `onDocumentNew`, `onDocumentOpen`, `onDocumentClick`, `documentIcon`, `documentNewLabel`, `documentOpenLabel` |
| 레이어 | `layer`, `layers`, `activeLayerId`, `layerMenuOpen`, `onLayerMenuOpenChange`, `onLayerSelect`, `onLayerVisibilityChange`, `onLayerFreezeChange`, `onLayerLockChange`, `onLayerDetail`, `onLayerOpen` |
| 색상 | `color`, `colors`, `userColors`, `colorMode`, `colorMenuOpen`, `onColorMenuOpenChange`, `onColorSelect`, `onColorModeChange`, `onColorInput`, `onColorPick`, `onColorDetail`, `onColorOpen` |
| 선스타일 | `lineStyle`, `lineTypes`, `lineWeights`, `activeLineTypeId`, `activeLineWeightId`, `lineStyleMenuOpen`, `onLineStyleMenuOpenChange`, `onLineTypeSelect`, `onLineWeightSelect`, `onLineWeightValueChange`, `onLineScaleChange`, `onLineStyleDetail`, `onLineStyleOpen` |
| 문자/치수 | `textStyle`, `textStyles`, `activeTextStyleId`, `textStyleMenuOpen`, `onTextStyleMenuOpenChange`, `onTextStyleSelect`, `onTextStyleDetail`, `onTextStyleOpen` / `dimensionStyle`, `dimensionStyles`, `activeDimensionStyleId`, `dimensionStyleMenuOpen`, `onDimensionStyleMenuOpenChange`, `onDimensionStyleSelect`, `onDimensionStyleDetail`, `onDimensionStyleOpen` |
| 상세 | `propertyBar`, `propertyBarOpen`, `defaultPropertyBarOpen`, `onPropertyBarOpenChange`, `onPropertyBarToggle`, `propertyPanel`, `propertyPanelOpen`, `onPropertyPanelOpenChange`, `onPropertyPanelToggle` |
| 창 | `onPrint`, `onPopupGuide`, `onWindowMoveStart(PointerEvent)`, `onMinimize`, `onClose` |
| 기타 | `activeMenu`, `disabled`(창 버튼 제외), `drawingModeLabel`, `tools?: ReactNode`(객체 영역 메뉴 뒤), `<header>` HTML 속성·`ref` |
- 데이터 배열(`layers`, `colors`, `lineTypes`/`lineWeights`, `textStyles`, `dimensionStyles`, `documents`)을 주지 않으면 해당 필드는 Dropdown 없는 버튼입니다.
- 반응형(More·대표 필드 전환·창 영역 축소)은 **`SWHeader` 안에서만** 동작합니다(CSS container `hm-sw-header`).
```tsx
<SWHeader documentTitle={name} logoSrc={logo} logoAlt="제품명" layers={layers} activeLayerId={id} onLayerSelect={l => setId(l.id)} onClose={closeWindow} />
```
## 2. 개별 컨트롤 (조합용)
| 컴포넌트 | 용도 | 주요 Props / Callback | 크기(Figma) | 상태 | 검증 |
|---|---|---|---|---|---|
| `SWTheme` | 단독 사용 범위(토큰·글꼴·초기화). 배치는 정하지 않음 | `div` HTML 속성, `className`, `style`(토큰 override) | — | — | ✅ |
| `SWHeaderHome` | 로고 슬롯 + 홈 버튼 | `logo`, `logoSrc`, `logoAlt`, `onClick`, `interactive`(false = 정적 로고) | 80 × 48, 로고 슬롯 32 × 17, 오른쪽 −17px로 다음 파일 면과 겹침 | Hover 배경 교체, Focus | ✅ |
| `SWHeaderDocumentTitle` | 파일명 + 문서 Dropdown(+ New / Open) | `title`(필수), `documents`, `activeDocumentId`, `menuOpen`, `onMenuOpenChange`, `onDocumentSelect`, `onDocumentClose`, `onNew`, `onOpen`, `onClick`, `documentIcon`, `newLabel`, `openLabel`, `label` | 높이 48, 폭 190–310(반응형 clamp), 메뉴 310 | 텍스트만 Hover #FFC600, 열림 화살표 회전 | ✅ |
| `SWHeaderLayerMenu` | 레이어 필드 + Dropdown | `value`(필수), `layers`(필수), `activeLayerId`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onLayerSelect`, `onVisibilityChange`, `onFreezeChange`, `onLockChange`, `onDetail`, `label` | 필드 234 × 38, 메뉴 234 | Hover 링, Selected, Expanded, 행 Hover | ✅ |
| `SWHeaderColorMenu` | 색상 필드 + Dropdown(ByLayer/ByBlock, 팔레트, 사용자 색, 추출, RGB 입력) | `value`, `colors`(필수), `userColors`, `mode`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onColorSelect`, `onModeChange`, `onColorInput`, `onPick`, `onDetail`, `label` | 필드 192 × 38 | 칩 선택 시 열림 유지 | ✅ |
| `SWHeaderLineStyleMenu` | 선스타일 필드 + 3열 Dropdown | `value`, `lineTypes`, `lineWeights`, `activeLineTypeId`, `activeLineWeightId`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onLineTypeSelect`, `onLineWeightSelect`, `onLineWeightValueChange`, `onScaleChange`, `onDetail`, `label` | 필드 284 × 38, 열 182·142·142(필드 중앙 정렬) | 열별 선택, 열림 유지 | ✅ |
| `SWHeaderTextStyleMenu` | 문자스타일 필드 + 목록 | `value`, `textStyles`(필수), `activeTextStyleId`, `onTextStyleSelect`, `open`, `onOpenChange`, `onClick`, `selected`, `disabled`, `onDetail`, `label` | 필드 164 × 38 | 선택 시 닫힘 | ✅ |
| `SWHeaderDimensionStyleMenu` | 치수스타일 필드 + 목록 | `value`, `dimensionStyles`(필수), `activeDimensionStyleId`, `onDimensionStyleSelect`, 열림·`onDetail`·`label` 동일 | 필드 164 × 38 | 선택 시 닫힘 | ✅ |
| `SWHeaderLayerControl` · `SWHeaderColorControl` · `SWHeaderLineStyle` · `SWHeaderTextStyle` · `SWHeaderDimensionStyle` | Dropdown 없는 필드 버튼(호스트가 자체 팝업을 열 때) | `value`(필수), `label`, `selected`, `disabled`, `onClick`, `expanded`, `controls`, `buttonRef` / `SWHeaderLayerControl`은 `state`(표시·동결·잠금) | 위 필드 크기와 같음 | Hover 링, Selected | ◻ |
| `SWHeaderActions` | 인쇄·K 버튼 pill | `onPrint`, `onPopupGuide`, `disabled`, `showPrint`, `showPopupGuide`(하나만 남기면 pill이 축소) | pill 85 × 42(버튼 40 × 38) | 아이콘 Hover, Hover 링, K 가이드 미리보기(반복 Motion, Reduced Motion 정적) | ✅ |
| `SWHeaderWindowControls` | 창 이동·최소화·종료 | `onWindowMoveStart`, `onMinimize`, `onClose`, `showWindowMove`, `showMinimize`, `showClose` | 이동 88 × 48, 버튼 32 × 48 | 창 이동 Hover Motion 1.4s, 종료 Hover 자산 | ◻ |
| `SWHeaderProperties` | 상세보기 / 속성Bar · 속성창 토글 | `barOpen`, `panelOpen`, `panelId`, `disabled`, `onBarToggle`, `onPanelToggle`, `showCaption`, `showBar`, `showPanel` | 157 × 38 | 텍스트 Hover #FFC600 | ◻ |
| `SWHeaderPropertyBar` | 28px 속성 편집 띠 | `selection`, `properties`, `onPropertyChange`, `readOnly`, `disabled`, `div` 속성 | 높이 28, 콘텐츠 폭 | Input 상태 | ◻ |
| `SWHeaderPropertyPanel` | 속성창(13개 필드 타입, 그룹, 형상 Slot) | `selection`, `properties`, `groups`, `onPropertyChange`, `menus`, `expandedGroupIds`/`defaultExpandedGroupIds`/`onExpandedGroupsChange`, `toolActive`/`onToolActiveChange`, `toolLabel`, `onOverviewClick`, `shapeContent`, `onClose`, `id`, `anchorRef`, `style` | 고정 위치 Overlay(Portal·Dim 없음) | 4가지 선택 상태 | ◻ |
| `SWHeaderInput` | 수정 가능한 SW Input | `value`(필수), `onValueChange`, `onCommit`(Enter·Blur, 유효 + 변경 시), `onCancel`(Escape 복원), `valueType`(`text`/`number`), `parse`, `readOnly`, `disabled`, `commitOnUnmount`, `input` 속성 | 58 × 20 기준, Hover/편집 24px | Default/Hover/편집/ReadOnly/Disabled, IME 조합 중 Enter 무시 | ✅ |
| `parseSWHeaderColor` · `parseSWHeaderNumber` | Input 검증 도우미 | `r,g,b` / `#rrggbb`, 숫자(`min`, `max`) | — | — | ◻ |
| `SWHeaderLinePreview` · `swHeaderLinePatterns` | 70 × 2 선 미리보기, CENTER/HIDDEN 패턴 | `pattern?: number[]` | 70 × 2 | — | ◻(패턴 상수만 예제 데이터에서 사용) |
## 3. 공개 타입
`SWHeaderProps`, 각 컴포넌트 `*Props`, `SWThemeProps`, `SWHeaderMenuId`, `SWHeaderControlId`, `SWHeaderControlConfig`, `SWHeaderFrameControlId`, `SWHeaderHideableControlId`, `SWHeaderDocument`, `SWHeaderLayer`, `SWHeaderLayerValue`, `SWHeaderColorValue`, `SWHeaderColorOption`, `SWHeaderColorMode`, `SWHeaderColorInputValue`, `SWHeaderLineStyleValue`, `SWHeaderLineType`, `SWHeaderLineWeight`, `SWHeaderTextStyleValue`, `SWHeaderTextStyleOption`, `SWHeaderDimensionStyleValue`, `SWHeaderDimensionStyleOption`, `SWHeaderInputValueType`, `SWHeaderProperty`, `SWHeaderPropertyType`, `SWHeaderPropertyPicker`, `SWHeaderPropertyMenus`, `SWHeaderPropertyGroup`, `SWHeaderPropertySelection`, `SWHeaderPropertyShapeContent`, `SWHeaderPropertyShapeContext`.
## 4. 공개하지 않은 것 (사용 금지)
`SWHeaderMore`, `SWHeaderField`/`SWHeaderFieldText`, `SWHeaderKGuide`, `useSWHeaderDropdown`, `SWHeaderMenuDetail`, `SWHeaderColorChip`, `SWHeaderLayerRows`, `SWHeaderColorPanelBody`, `SWHeaderLineStyleColumns`, 속성창 내부 Picker, `useSWHeaderOverlay` 등은 내부 구현입니다. 필요하면 공개 컴포넌트로 대체하거나, 부족한 API로 보고합니다.
## 5. 알려진 단독 사용 제약
| 항목 | 내용 | 대응 |
|---|---|---|
| 반응형 More | `SWHeader` 전용. 조합 Header에서는 More·필드 이동이 없음 | 조합 Header의 폭은 소비 앱이 정함(필요 없는 컨트롤을 빼거나 가로 공간 확보) |
| `SWHeaderDocumentTitle` 왼쪽 테두리 | 앞의 로고와 겹치는 전제로 왼쪽 테두리가 없음 | 로고 없이 첫 칸이면 소비 레이아웃 wrapper에 1px 테두리(예제 C) |
| `SWHeaderDocumentTitle` 폭 | `clamp(190px, 100cqw − 1610px, 310px)`; Header 밖에서는 cqw가 뷰포트 기준 | flex 부모 안에 둔다. 1920 뷰포트에서 310px |
| 창 영역 배경 | 배경·테두리는 `SWHeader`의 창 영역에 있음 | 소비 레이아웃이 토큰으로 표면 작성(예제 B·C) |
| Dropdown 위치 | `SWHeader` 안이면 헤더, 밖이면 뷰포트 안으로 좌우 보정 | 부모에 `overflow: hidden`을 두지 않는다 |
+82
View File
@@ -0,0 +1,82 @@
# SW 조합 패턴
모든 패턴은 공개 Export만 사용합니다. 전체 코드는 [examples/](./examples)에 있으며, 설치된 패키지에서 꺼낸 그대로 독립 React 19 + TypeScript + Vite 프로젝트의 `tsc`·Production Build·브라우저 검증을 통과했습니다(2026-10-19).
## 0. 어떤 패턴을 고를까
| 요청 | 패턴 |
|---|---|
| 기존 SW Header 전체(일부 부품 제거·메뉴 순서 변경 포함) | 1. 완성형 |
| 기존 컨트롤 일부만, 다른 배치·순서 | 2. 조합 Header |
| 기존 Dropdown을 툴바·패널 등 다른 곳에 | 3. 개별 컨트롤 |
| 패키지에 없는 기능 | 4. 확장(소비 프로젝트 신규 컴포넌트) |
## 1. 완성형 SW Header — [ExampleA-DefaultHeader.tsx](./examples/ExampleA-DefaultHeader.tsx)
```tsx
<SWHeader documentTitle={name} documents={documents} layers={layers} colors={colors} onClose={close} />
```
- 부품 제거: `hiddenControls={["print", "popupGuide"]}`(로고는 항상 표시, `"home"`은 정적 로고).
- 메뉴 순서: `menuOrder={["color", "layer"]}`(5개 메뉴끼리, 누락분은 기본 순서로 뒤에).
- 객체 영역 전체를 직접 나열: `controls={[{ id: "document" }, { id: "layer" }, { id: "print" }]}`.
- 추가 도구: `tools={<MyTool />}`(객체 영역 메뉴 뒤). 새 도구는 아래 4의 규칙을 따른다.
- 이 경우에만 반응형 More가 동작합니다.
## 2. 조합 Header (간소화·기능 중심)
`<SWTheme>`을 Header 막대로 쓰고, 막대·면 배치 CSS는 소비 프로젝트가 작성합니다([example-layout.css](./examples/example-layout.css)). 이 CSS는 `--hm-sw-header-*` 토큰만 읽고 `hm-sw-*` class를 건드리지 않습니다.
```tsx
<SWTheme className="app-sw-bar"> {/* 49px, #060606, flex, gap 2px */}
<SWHeaderHome logoAlt="뷰어" onClick={goHome} /> {/* 80px, 다음 파일 면과 겹침 */}
<SWHeaderDocumentTitle title={name} documents={documents} onDocumentSelect={open} />
<div className="app-sw-surface"> {/* 객체 수정 면 토큰 */}
<SWHeaderLayerMenu value={layer} layers={layers} onLayerSelect={selectLayer} />
<SWHeaderColorMenu value={color} colors={colors} onColorSelect={selectColor} />
</div>
<div className="app-sw-window"> {/* 창 영역 면 토큰 */}
<SWHeaderActions showPrint={false} onPopupGuide={showGuide} />
</div>
</SWTheme>
```
- 간소화 Viewer: [ExampleB-ViewerHeader.tsx](./examples/ExampleB-ViewerHeader.tsx) — 로고 → 파일 → 레이어 → 색상 → K.
- 기능 중심: [ExampleC-FeatureHeader.tsx](./examples/ExampleC-FeatureHeader.tsx) — 파일 → 선 → 문자 → 치수스타일 → 인쇄.
- 순서는 JSX 순서입니다(DOM·Tab 순서 동일). CSS `order`로 바꾸지 않습니다.
- 로고 없이 파일 면이 처음이면 왼쪽 테두리가 없으므로 wrapper에 1px 테두리를 둡니다(예제 C `app-sw-file-edge`).
- K만/인쇄만: `SWHeaderActions`의 `showPrint={false}` / `showPopupGuide={false}`.
- 창 제어: `<SWHeaderWindowControls onWindowMoveStart={startDrag} onMinimize={min} onClose={close} />`(창 영역 면 안).
- 조합 Header에는 반응형 More가 없습니다. 좁은 화면이 필요하면 완성형을 쓰거나 표시할 컨트롤을 줄입니다.
## 3. 기존 Dropdown을 다른 레이아웃에서
```tsx
<SWTheme className="app-toolbar">
<SWHeaderLineStyleMenu value={lineStyle} lineTypes={lineTypes} lineWeights={lineWeights} onLineTypeSelect={setType} />
</SWTheme>
```
- Dropdown은 필드 바로 아래에 열리고 뷰포트 안으로 좌우 보정됩니다. 부모에 `overflow: hidden`을 두지 않습니다.
- Controlled가 필요하면 `open` / `onOpenChange`를 함께 줍니다.
- Dropdown이 필요 없고 호스트가 자체 팝업을 열면 `SWHeaderLayerControl` 등 필드 버튼만 씁니다.
## 4. 확장 — 없는 컨트롤 새로 만들기 — [SWSearchControl.tsx](./examples/SWSearchControl.tsx), [ExampleD-SearchExtension.tsx](./examples/ExampleD-SearchExtension.tsx)
요청 예: "레이어와 색상은 기존 SW 컴포넌트, 검색 버튼은 SW 디자인으로 새로."
1. 카탈로그 검색: 검색 컴포넌트 없음(MISSING).
2. 조합 검토: 텍스트 입력·확정·취소·IME는 `SWHeaderInput`으로 충족 → 재사용. 필드 면과 검색 아이콘 버튼은 공개 컴포넌트가 없음 → 신규.
3. 신규 부분은 **소비 프로젝트**에 작성: 38px 속성 필드 면, 12px 라벨, gradient Hover 링, 아이콘 버튼은 글자색 Hover `#FFC600`. 모든 값은 토큰([sw-search-control.css](./examples/sw-search-control.css)).
4. 기존 컨트롤과 같은 면(`app-sw-surface`)에 나란히 배치.
5. 검증: 레이어 필드와 높이·위치·테두리·radius·배경·그림자·라벨 크기 일치, Hover 링, Enter 확정 → `onSearch`, Escape 복원.
새 컨트롤은 패키지 공식 컴포넌트로 자동 등록하지 않습니다. 여러 프로젝트에서 반복되면 SW 패키지 승격을 별도로 검토합니다.
## 5. 하지 말 것
- `hm-sw-*` class를 소비 코드에 쓰거나 CSS로 덮어쓰기
- `dist` 구현·`styles.css` 블록 복사로 같은 컴포넌트 재작성
- `@hm-design/sw/dist/...`·`src/...` 내부 경로 Import
- 문서에 없는 Props 추측, 새 색·크기 값 도입
- 같은 화면에 `SWHeader`와 조합 Header를 겹쳐 두 개의 Header 만들기
+94
View File
@@ -0,0 +1,94 @@
# SW 디자인 규칙
출처: `@hm-design/sw` `styles.css`(원본 `src/theme/sw.css`)의 토큰과 규칙, Figma 이지빔 SW `rj6wpsfk24lOWvWiVRAANP` node `26:8944`와 사용자 확정값. 이 문서에 없는 값은 확정값이 아닙니다. 새 컴포넌트에 임의 값을 만들지 말고 아래 토큰을 `var(--…)`로 읽으세요.
- 토큰은 `.hm-sw-header`와 `.hm-sw-theme`(`<SWTheme>`)에만 정의됩니다. 새 컴포넌트는 그 안에 렌더링합니다.
- SW는 `@hm-design/ui` 토큰을 쓰지 않습니다. UI 패키지 토큰을 복제하거나 섞지 않습니다.
- Light/Dark 분기 없음. 전역 선택자(`html`, `body`, `*`) 없음.
## 1. 크기·간격
| 항목 | 값 | 토큰 |
|---|---|---|
| Header 높이 / 면 높이 | 49px / 48px | `--hm-sw-header-height` / `--hm-sw-header-bar-height` |
| 면 사이 간격 | 2px | (Header bar `gap`) |
| 필드 높이 | 38px(면 안 위아래 5px) | `--hm-sw-header-field-height` |
| 필드 사이 간격 | 4px | `--hm-sw-header-gap` |
| 로고 슬롯 | 32 × 17 (홈 80 × 48 안 12, 16) | `--hm-sw-header-logo-width` / `-height` |
| 필드 폭 | 레이어 234, 색상 192, 선스타일 284, 문자·치수스타일 164 | — |
| 상세보기 / 창 영역 | 157 × 38 / 240 × 48(pill 85 × 42, 창 이동 88, 버튼 32 × 2) | — |
| 필드 내부 | padding 0 10px 0 11px, 텍스트 사이 12px | — |
## 2. 색·표면
| 역할 | 값 | 토큰 |
|---|---|---|
| Header 배경 | `#060606` | `--hm-sw-header-background` |
| 기본 글자 / 제목 | `#F6F4F2` / `#F0F7F5` | `--hm-sw-header-text-color` / `--hm-sw-header-title-color` |
| Selected·On·Hover 글자 | `#FFC600` | `--hm-sw-header-selected-color` |
| 화살표 | `#47AF91` | `--hm-sw-header-arrow-color` |
| 파일명 면 | 세로 gradient, 테두리 `#0E4C3A` | `--hm-sw-header-document-background`, `--hm-sw-header-document-border-color` |
| 객체 수정 면 | gradient, 테두리 `#135643`, radius 6px | `--hm-sw-header-object-background`, `-object-border-color`, `-object-radius` |
| 속성 필드 면 | 검정 45–40% gradient, 테두리 `#08251C`, radius 3px, shadow `0 1px 4px rgba(9,39,30,.7)` | `--hm-sw-header-property-background`, `-property-border-color`, `-property-radius`, `-property-shadow` |
| 인쇄·K pill | `rgba(0,0,0,.8)`, 테두리 `#092920` | `--hm-sw-header-pill-background`, `-pill-border-color` |
| 문서 Dropdown | `#103127`, 테두리 `#041611` | `--hm-sw-header-document-menu-*` |
| 필드 Dropdown(레이어·색상·선·문자·치수) | `#27241D`, 테두리 `#000`, radius 3px, 행 Hover `#62533D` + 글자 `#FF5C00` | `--hm-sw-header-layer-menu-*`, `--hm-sw-header-layer-row-hover-*` |
| Input | 글자 `#E60403`, 편집 `#0F0D0A` | `--hm-sw-header-input-*` |
| Tooltip | `rgba(0,0,0,.8)` / `#FFF` | `--hm-sw-header-tooltip-*` |
사용자 확정 면 3개(파일명·객체 수정·속성 필드)는 기본값 유지를 권장합니다.
## 3. Typography
| 항목 | 값 |
|---|---|
| 글꼴 | Noto Sans KR → Malgun Gothic → Apple SD Gothic Neo (Figma Dotum 대체, 의도된 결정). 폰트 파일은 앱이 로드 |
| 기본 / 라벨 | 14px / 12px (`--hm-sw-header-font-size` / `--hm-sw-header-label-size`) |
| Weight | 400, 500, 700만 사용(600 없음). 값·제목·토글 700 |
| 자간 | 값 0.04em, 라벨 0.02em, 파일명 0.06em |
| Text Stroke | Figma Stroke가 있는 텍스트만 검정 외곽선: 14·16px `--hm-sw-header-text-outline`(1.3px), 12px `--hm-sw-header-label-outline`(1px). 자기 색 Stroke 없음. More·Tooltip·`+ New`/`Open` 제외 |
| 말줄임 | 값은 한 줄 `ellipsis`, `title`로 전체 표시 |
## 4. 상태
| 상태 | 규칙 |
|---|---|
| Hover(테두리 있는 버튼·필드·More·인쇄·K) | 1px 테두리가 노란 세로 gradient로 바뀜(`--hm-sw-header-hover-border-gradient`, 폭 `--hm-sw-header-hover-border-width`). 크기·배경·그림자 변화 없음, 자기 Dropdown이 열린 필드는 Hover 없음 |
| Hover(파일명, 속성Bar/속성창 토글) | 글자만 `#FFC600`. 링·배경 없음 |
| Hover(필드 미리보기) | 텍스트·선/폰트/치수 미리보기 `#FFC600` |
| Selected / On | 글자·단색 아이콘 `#FFC600`(`data-selected="true"`), 배경·테두리 변화 없음 |
| Expanded(Dropdown 열림) | 필드 테두리 `#081813`, 아래 모서리 직각, 필드+목록을 감싸는 1px 노란 링 |
| Focus | 2px `#F0F7F5` outline, offset −2px(`:focus-visible`) |
| Disabled | opacity 0.4, 커서 기본. 창 버튼은 `disabled` 영향 없음 |
| 목록 행 | Hover와 Selected가 같은 강조(`#62533D` + `#FF5C00`) |
## 5. Overlay·Dropdown
- 필드 Dropdown은 필드 바로 아래(`top: 100%`)에 겹쳐 열립니다. Portal·Dim 없음, `z-index` 30. 화면(또는 Header) 밖으로 나가면 좌우만 보정합니다. 부모에 `overflow: hidden`을 두지 않습니다.
- 목록 최대 높이 240px(8행), 넘치면 내부 스크롤. 색상 팔레트 118px.
- 속성창·K 가이드는 `position: fixed` Overlay(뷰포트 안 보정, Portal·Dim 없음).
- 한 번에 하나의 Dropdown(SWHeader). 단독 사용 시 각 컴포넌트가 바깥 클릭으로 스스로 닫힙니다.
## 6. Motion
| 대상 | 규칙 |
|---|---|
| 창 이동 Hover | 1.4s 반복(커서 상승 → 약 444ms 문구 → 오른쪽 위 이동 → 약 888ms부터 유지). Drag(`:active`) 중 정지. Reduced Motion: 원본 아이콘과 정적 Tooltip |
| K 가이드 | 원본 이미지 Pan/Zoom 반복(400ms 대기 후 1022·1022·511·511ms 전환). Reduced Motion 정적 |
| 레이어 토글 | 배경·아이콘 300ms ease-out |
| 기타 | Hover·Selected는 즉시 전환(Transition 없음) |
## 7. 반응형 (`SWHeader` 전용)
Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타일 → More, **< 1180** 그리기모드 숨김, **< 1080** 상세보기 → More·창 이동 24px(창 영역 176), **< 900** 색상 → More, **< 640** 인쇄·K → More·창 이동 숨김(창 영역 64), **< 520** 레이어 상태 아이콘 숨김. More에서 고른 필드는 첫 칸(레이어 자리)에 표시되고 Dropdown이 바로 열립니다(UI 상태만, 데이터 Callback 없음).
조합 Header(개별 컴포넌트)에는 이 규칙이 적용되지 않습니다. 필요한 컨트롤만 배치하고 폭 정책은 소비 앱이 정합니다.
## 8. 새 컴포넌트 체크리스트
1. `<SWTheme>`(또는 `SWHeader`의 `tools`) 안에서 렌더링한다.
2. 표면·크기·색은 위 토큰만 쓴다. 필드형이면 38px·속성 필드 면·12px 라벨 + 14px 700 값.
3. Hover/Focus/Disabled/Selected를 §4 규칙대로 구현한다(테두리형은 gradient 링, 텍스트형은 글자만 `#FFC600`).
4. 입력이 필요하면 `SWHeaderInput`을 재사용한다.
5. 값은 Props, 동작은 Callback. CAD 데이터를 내부에서 바꾸지 않는다.
6. `hm-sw-*` class를 새로 쓰거나 덮어쓰지 않는다(자기 접두사 사용, 예: `app-`).
@@ -0,0 +1,40 @@
// Example A — reuse (1st priority): the complete SW Header, unchanged. Existing projects keep this code.
import { useState } from "react"
import { SWHeader, type SWHeaderColorMode, type SWHeaderColorValue } from "@hm-design/sw"
import { colors, dimensionStyles, documents, layers, lineTypes, lineWeights, textStyles } from "./sample-data"
export function ExampleADefaultHeader({ onEvent }: { onEvent?: (name: string) => void }) {
const [activeDocumentId, setActiveDocumentId] = useState(documents[0].id)
const [activeLayerId, setActiveLayerId] = useState("0")
const [color, setColor] = useState<SWHeaderColorValue>({ value: "ByLayer", swatch: "#ffffff" })
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>("byLayer")
const log = (name: string) => () => onEvent?.(name)
return (
<SWHeader
documentTitle={documents.find(item => item.id === activeDocumentId)?.name ?? ""}
logoAlt="예제 SW"
documents={documents}
activeDocumentId={activeDocumentId}
onDocumentSelect={document => { setActiveDocumentId(document.id); onEvent?.(`document:${document.id}`) }}
onDocumentNew={log("document:new")}
onDocumentOpen={log("document:open")}
layers={layers}
activeLayerId={activeLayerId}
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }}
color={color}
colors={colors}
colorMode={colorMode}
onColorSelect={option => { setColor({ value: option.label ?? option.color, swatch: option.color }); setColorMode(null); onEvent?.(`color:${option.id}`) }}
onColorModeChange={mode => { setColorMode(mode); onEvent?.(`colorMode:${mode}`) }}
lineTypes={lineTypes}
lineWeights={lineWeights}
textStyles={textStyles}
dimensionStyles={dimensionStyles}
onHomeClick={log("home")}
onPrint={log("print")}
onPopupGuide={log("popupGuide")}
onMinimize={log("minimize")}
onClose={log("close")}
/>
)
}
@@ -0,0 +1,50 @@
// Example B — composition (2nd priority): a simplified viewer header
// Logo → File → Layer → Color → K
// Only public components; the layout is the consumer's (example-layout.css) and <SWTheme> provides tokens.
import { useState } from "react"
import {
SWHeaderActions, SWHeaderColorMenu, SWHeaderDocumentTitle, SWHeaderHome, SWHeaderLayerMenu, SWTheme,
type SWHeaderColorMode, type SWHeaderColorValue,
} from "@hm-design/sw"
import { colors, documents, layers } from "./sample-data"
import "./example-layout.css"
export function ExampleBViewerHeader({ onEvent }: { onEvent?: (name: string) => void }) {
const [activeDocumentId, setActiveDocumentId] = useState(documents[0].id)
const [activeLayerId, setActiveLayerId] = useState("0")
const [color, setColor] = useState<SWHeaderColorValue>({ value: "ByLayer", swatch: "#ffffff" })
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>("byLayer")
return (
<SWTheme className="app-sw-bar" role="banner" aria-label="뷰어 헤더">
<SWHeaderHome logoAlt="뷰어" onClick={() => onEvent?.("home")} />
<SWHeaderDocumentTitle
title={documents.find(item => item.id === activeDocumentId)?.name ?? ""}
documents={documents}
activeDocumentId={activeDocumentId}
onDocumentSelect={document => { setActiveDocumentId(document.id); onEvent?.(`document:${document.id}`) }}
onNew={() => onEvent?.("document:new")}
onOpen={() => onEvent?.("document:open")}
/>
<div className="app-sw-surface" role="toolbar" aria-label="보기 설정">
<SWHeaderLayerMenu
value={{ name: "0" }}
layers={layers}
activeLayerId={activeLayerId}
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }}
onVisibilityChange={(layer, next) => onEvent?.(`layer-visible:${layer.id}:${next}`)}
/>
<SWHeaderColorMenu
value={color}
colors={colors}
mode={colorMode}
onColorSelect={option => { setColor({ value: option.label ?? option.color, swatch: option.color }); setColorMode(null); onEvent?.(`color:${option.id}`) }}
onModeChange={mode => { setColorMode(mode); onEvent?.(`colorMode:${mode}`) }}
/>
</div>
<div className="app-sw-window">
{/* K only: the existing actions pill with print removed. */}
<SWHeaderActions showPrint={false} onPopupGuide={() => onEvent?.("popupGuide")} />
</div>
</SWTheme>
)
}
@@ -0,0 +1,46 @@
// Example C — composition (2nd priority): a feature-focused header in a different order
// File → Line style → Text style → Dimension style → Print
import { useState } from "react"
import {
SWHeaderActions, SWHeaderDimensionStyleMenu, SWHeaderDocumentTitle, SWHeaderLineStyleMenu, SWHeaderTextStyleMenu, SWTheme,
type SWHeaderDimensionStyleValue, type SWHeaderLineStyleValue, type SWHeaderTextStyleValue,
} from "@hm-design/sw"
import { dimensionStyles, lineTypes, lineWeights, textStyles } from "./sample-data"
import "./example-layout.css"
export function ExampleCFeatureHeader({ onEvent }: { onEvent?: (name: string) => void }) {
const [lineStyle, setLineStyle] = useState<SWHeaderLineStyleValue>({ name: "ByLayer", weight: "ByLayer", scale: "1.00" })
const [textStyle, setTextStyle] = useState<SWHeaderTextStyleValue>({ font: "Arial", name: "Standard" })
const [dimensionStyle, setDimensionStyle] = useState<SWHeaderDimensionStyleValue>({ name: "Standard" })
return (
<SWTheme className="app-sw-bar" role="banner" aria-label="스타일 헤더">
{/* No logo before the file well, so the consumer layout gives it its own left edge. */}
<div className="app-sw-file-edge">
<SWHeaderDocumentTitle title="C0020101-006-평면및종단면도(06)" onClick={() => onEvent?.("document")} />
</div>
<div className="app-sw-surface" role="toolbar" aria-label="스타일">
<SWHeaderLineStyleMenu
value={lineStyle}
lineTypes={lineTypes}
lineWeights={lineWeights}
onLineTypeSelect={type => { setLineStyle(current => ({ ...current, name: type.name })); onEvent?.(`lineType:${type.id}`) }}
onLineWeightSelect={weight => { setLineStyle(current => ({ ...current, weight: weight.name })); onEvent?.(`lineWeight:${weight.id}`) }}
onScaleChange={scale => { setLineStyle(current => ({ ...current, scale })); onEvent?.(`lineScale:${scale}`) }}
/>
<SWHeaderTextStyleMenu
value={textStyle}
textStyles={textStyles}
onTextStyleSelect={style => { setTextStyle({ font: style.font, name: style.name }); onEvent?.(`textStyle:${style.id}`) }}
/>
<SWHeaderDimensionStyleMenu
value={dimensionStyle}
dimensionStyles={dimensionStyles}
onDimensionStyleSelect={style => { setDimensionStyle({ name: style.name }); onEvent?.(`dimensionStyle:${style.id}`) }}
/>
</div>
<div className="app-sw-window">
<SWHeaderActions showPopupGuide={false} onPrint={() => onEvent?.("print")} />
</div>
</SWTheme>
)
}
@@ -0,0 +1,30 @@
// Example D — existing Layer / Color components next to a NEW consumer control (SWSearchControl).
import { useState } from "react"
import { SWHeaderColorMenu, SWHeaderLayerMenu, SWTheme, type SWHeaderColorValue } from "@hm-design/sw"
import { colors, layers } from "./sample-data"
import { SWSearchControl } from "./SWSearchControl"
import "./example-layout.css"
export function ExampleDSearchExtension({ onEvent }: { onEvent?: (name: string) => void }) {
const [activeLayerId, setActiveLayerId] = useState("0")
const [color, setColor] = useState<SWHeaderColorValue>({ value: "ByLayer", swatch: "#ffffff" })
const [query, setQuery] = useState("")
return (
<SWTheme className="app-sw-bar" role="banner" aria-label="검색 헤더">
<div className="app-sw-surface" role="toolbar" aria-label="객체 찾기">
<SWHeaderLayerMenu
value={{ name: "0" }}
layers={layers}
activeLayerId={activeLayerId}
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }}
/>
<SWHeaderColorMenu
value={color}
colors={colors}
onColorSelect={option => { setColor({ value: option.label ?? option.color, swatch: option.color }); onEvent?.(`color:${option.id}`) }}
/>
<SWSearchControl value={query} onValueChange={setQuery} onSearch={text => onEvent?.(`search:${text}`)} />
</div>
</SWTheme>
)
}
@@ -0,0 +1,41 @@
// Example D extension — a NEW control written in the consuming project (3rd priority).
// Decision record: the package has no search component (MISSING). Its editable SWHeaderInput covers the
// text entry (Enter / blur commit, Escape cancel, IME), so it is reused; only the field surface and the
// search icon button are new. They read the public SW tokens (sw-search-control.css) — no copied
// package code, no hm-sw-* class, no new design values. Not registered as a package component.
import { SWHeaderInput } from "@hm-design/sw"
import "./sw-search-control.css"
export interface SWSearchControlProps {
/** Committed query (host state). */
value: string
/** A new query was committed with Enter or by leaving the input. */
onValueChange?: (value: string) => void
/** Search with the current query (icon button or a committed query). */
onSearch?: (query: string) => void
label?: string
disabled?: boolean
}
export function SWSearchControl({ value, onValueChange, onSearch, label = "검색", disabled }: SWSearchControlProps) {
return (
<div className="app-sw-search" role="search" data-disabled={disabled || undefined}>
<span className="app-sw-search__text">
<span className="app-sw-search__label" aria-hidden="true">{label}</span>
<SWHeaderInput
aria-label={`${label}어`}
value={value}
placeholder="객체 이름"
disabled={disabled}
onCommit={next => { onValueChange?.(next); onSearch?.(next) }}
/>
</span>
<button type="button" className="app-sw-search__button" aria-label={label} disabled={disabled} onClick={() => onSearch?.(value)}>
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<circle cx="7" cy="7" r="4.75" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.5 10.5 14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
)
}
@@ -0,0 +1,36 @@
/* Consumer layout for the composed examples (B, C, D). It belongs to the consuming project: it only
arranges public SW components and reads the public --hm-sw-header-* tokens provided by <SWTheme>.
It never styles hm-sw-* classes and adds no new design values. */
.app-sw-bar {
display: flex;
align-items: stretch;
gap: 2px;
box-sizing: border-box;
height: var(--hm-sw-header-height);
padding-bottom: 1px; /* 48px surfaces in the 49px header, like SWHeader */
background: var(--hm-sw-header-background);
}
/* Object (editing) surface, the same tokens as the SWHeader object area. */
.app-sw-surface {
display: flex;
align-items: center;
gap: var(--hm-sw-header-gap);
flex: 1 1 0;
min-width: 0;
padding: 4px;
border: 1px solid var(--hm-sw-header-object-border-color);
border-radius: var(--hm-sw-header-object-radius);
background: var(--hm-sw-header-object-background);
}
/* Window-area surface for print / K / window buttons. */
.app-sw-window {
display: flex;
flex: 0 0 auto;
background: var(--hm-sw-header-document-background);
box-shadow: inset 0 0 0 1px var(--hm-sw-header-document-border-color); /* 1px edge without moving the parts */
}
/* File well placed first (no home overlap): its own left edge. */
.app-sw-file-edge {
display: flex;
border-left: 1px solid var(--hm-sw-header-document-border-color);
}
+52
View File
@@ -0,0 +1,52 @@
// Sample host data for the examples. Every type comes from the public package API.
import {
swHeaderLinePatterns,
type SWHeaderColorOption, type SWHeaderDimensionStyleOption, type SWHeaderDocument, type SWHeaderLayer,
type SWHeaderLineType, type SWHeaderLineWeight, type SWHeaderTextStyleOption,
} from "@hm-design/sw"
export const documents: SWHeaderDocument[] = [
{ id: "doc-1", name: "C0020101-006-평면및종단면도(06)" },
{ id: "doc-2", name: "C0020101-007-횡단면도(07)" },
]
export const layers: SWHeaderLayer[] = [
{ id: "0", name: "0", color: "#ffffff" },
{ id: "center", name: "CENTER", color: "#ff0000" },
{ id: "hidden", name: "HIDDEN", color: "#ffe500", visible: false },
{ id: "dim", name: "DIM", color: "#00ff00", locked: true },
]
export const colors: SWHeaderColorOption[] = [
{ id: "white", color: "#ffffff", label: "흰색" },
{ id: "red", color: "#ff0000", label: "빨강" },
{ id: "green", color: "#00ff00", label: "초록" },
{ id: "yellow", color: "#ffff00", label: "노랑" },
{ id: "blue", color: "#0000ff", label: "파랑" },
{ id: "magenta", color: "#ff00ff", label: "자홍" },
{ id: "cyan", color: "#00ffff", label: "청록" },
]
export const lineTypes: SWHeaderLineType[] = [
{ id: "bylayer", name: "ByLayer" },
{ id: "continuous", name: "Continuous" },
{ id: "center", name: "CENTER", pattern: swHeaderLinePatterns.center },
{ id: "hidden", name: "HIDDEN", pattern: swHeaderLinePatterns.hidden },
]
export const lineWeights: SWHeaderLineWeight[] = [
{ id: "bylayer", name: "ByLayer" },
{ id: "byblock", name: "ByBlock" },
{ id: "default", name: "ByDefault", value: "0.25" },
{ id: "user", name: "User", value: "0.50", editable: true },
]
export const textStyles: SWHeaderTextStyleOption[] = [
{ id: "standard", name: "Standard", font: "Arial" },
{ id: "dotum", name: "Dotum", font: "돋움" },
]
export const dimensionStyles: SWHeaderDimensionStyleOption[] = [
{ id: "standard", name: "Standard" },
{ id: "iso", name: "ISO-25" },
]
@@ -0,0 +1,48 @@
/* Consumer extension styles. Every value is a public SW token (see ../DESIGN_RULES.md); the
38px field height, 12px label / 14px bold value and 1px gradient hover ring follow the SW field rules. */
.app-sw-search {
position: relative;
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
height: var(--hm-sw-header-field-height);
padding: 0 6px 0 11px;
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);
}
/* Hover: the bordered-control rule — the 1px border becomes the yellow gradient; nothing moves. */
.app-sw-search::after {
content: "";
position: absolute;
inset: calc(-1 * var(--hm-sw-header-hover-border-width));
padding: var(--hm-sw-header-hover-border-width);
border-radius: inherit;
background: var(--hm-sw-header-hover-border-gradient);
/* Ring only: the content box is cut out (prefixed first, the standard declaration last wins). */
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
opacity: 0;
pointer-events: none;
}
.app-sw-search:hover:not([data-disabled])::after { opacity: 1; }
.app-sw-search__text { display: flex; flex-direction: column; min-width: 0; }
.app-sw-search__label {
height: 14px;
font-size: var(--hm-sw-header-label-size);
line-height: 14px;
letter-spacing: 0.02em;
text-shadow: var(--hm-sw-header-label-outline);
}
.app-sw-search__button {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: var(--hm-sw-header-property-radius);
}
/* Text-only hover / selected color, like the SW toggles. */
.app-sw-search__button:hover:not(:disabled) { color: var(--hm-sw-header-selected-color); }
+2 -1
View File
@@ -15,7 +15,8 @@
"./styles.css": "./dist/styles.css"
},
"files": [
"dist"
"dist",
"ai"
],
"scripts": {
"typecheck": "tsc -p tsconfig.json",
+53
View File
@@ -0,0 +1,53 @@
// Checks that the shipped AI guide matches the built public API (2026-10-19).
// Usage: node packages/sw/tests/ai-docs.check.mjs (build first: npm run build -w @hm-design/sw)
// 1. Every SW identifier named in ai/*.md is a real export, unless the doc lists it as not public.
// 2. Every import from "@hm-design/sw" in ai/examples is a real export; no deep package import.
// 3. Every relative link in ai/*.md and README.md resolves inside the package (tarball-safe).
import { readdir, readFile } from "node:fs/promises"
import { existsSync } from "node:fs"
import path from "node:path"
import { fileURLToPath } from "node:url"
const root = fileURLToPath(new URL("../", import.meta.url))
const declarations = await readFile(path.join(root, "dist/index.d.ts"), "utf8")
const nested = [...declarations.matchAll(/from\s+["'](\.[^"']+)["']/g)].map(match => match[1])
let exported = declarations
for (const file of nested) exported += await readFile(path.join(root, "dist", file.replace(/\.js$/, ".d.ts")), "utf8")
const names = new Set()
for (const block of exported.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}/g)) for (const part of block[1].split(",")) { const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop(); if (name) names.add(name) }
for (const match of exported.matchAll(/export\s+(?:declare\s+)?(?:const|function|interface|type)\s+(\w+)/g)) names.add(match[1])
// Only names re-exported by the entry are public: collect the entry's re-export lists.
const publicNames = new Set()
for (const block of declarations.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}/g)) for (const part of block[1].split(",")) { const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop(); if (name) publicNames.add(name) }
for (const match of declarations.matchAll(/export\s+\*\s+from\s+["']([^"']+)["']/g)) {
const text = await readFile(path.join(root, "dist", match[1].replace(/\.js$/, ".d.ts")), "utf8")
for (const block of text.matchAll(/export\s*(?:type\s*)?\{([^}]*)\}/g)) for (const part of block[1].split(",")) { const name = part.trim().replace(/^type\s+/, "").split(/\s+as\s+/).pop(); if (name) publicNames.add(name) }
}
void names
const results = []
const check = (name, ok, data) => { results.push(ok); console.log(ok ? "PASS" : "FAIL", name, data === undefined ? "" : JSON.stringify(data)) }
const docs = (await readdir(path.join(root, "ai"))).filter(name => name.endsWith(".md"))
for (const doc of docs) {
const text = await readFile(path.join(root, "ai", doc), "utf8")
const notPublic = new Set([...(text.split(/^## .*공개하지 않은 것.*$/m)[1]?.split(/^## /m)[0] ?? "").matchAll(/`(\w+)`/g)].map(match => match[1]))
const mentioned = [...new Set([...text.matchAll(/`((?:SW|parseSW|swHeader|useSW)\w+)/g)].map(match => match[1]))]
const unknown = mentioned.filter(name => !publicNames.has(name) && !notPublic.has(name) && !/^SWSearchControl/.test(name))
check(`${doc}: named SW identifiers are public exports`, unknown.length === 0, { mentioned: mentioned.length, unknown })
const leaked = [...notPublic].filter(name => publicNames.has(name))
check(`${doc}: "not public" list contains no export`, leaked.length === 0, leaked)
}
for (const file of (await readdir(path.join(root, "ai/examples"))).filter(name => /\.tsx?$/.test(name))) {
const text = await readFile(path.join(root, "ai/examples", file), "utf8")
const imports = [...text.matchAll(/import\s*\{([^}]*)\}\s*from\s*"@hm-design\/sw"/g)].flatMap(match => match[1].split(",").map(part => part.trim().replace(/^type\s+/, "")).filter(Boolean))
const deep = /from\s*"@hm-design\/sw\/(?!styles\.css)/.test(text)
check(`examples/${file}: imports only public exports`, !deep && imports.every(name => publicNames.has(name)), { imports, missing: imports.filter(name => !publicNames.has(name)), deep })
}
for (const doc of [...docs.map(name => path.join("ai", name)), "README.md"]) {
const text = await readFile(path.join(root, doc), "utf8")
const links = [...text.matchAll(/\]\((\.\/[^)#\s]+)/g)].map(match => match[1])
const broken = links.filter(link => !existsSync(path.resolve(root, path.dirname(doc), link)))
check(`${doc}: package-relative links resolve`, broken.length === 0, { links: links.length, broken })
}
console.log(`${results.filter(Boolean).length}/${results.length} PASS`)
process.exitCode = results.every(Boolean) ? 0 : 1
@@ -0,0 +1,180 @@
// Browser check of the independent consumer built by ai-template-consumer.mjs.
// Usage: node packages/sw/tests/ai-template-consumer.browser.mjs <consumer-dir> <evidence-dir>
// Serves <consumer-dir>/dist and checks examples A–D (rendering, assets, dropdowns, callbacks, focus, styles).
import { createRequire } from "node:module"
import { createServer } from "node:http"
import { mkdir, readFile, writeFile } from "node:fs/promises"
import path from "node:path"
const dist = path.resolve(process.argv[2], "dist")
const out = path.resolve(process.argv[3])
const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? "C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright")
await mkdir(out, { recursive: true })
const types = { ".html": "text/html", ".js": "text/javascript", ".css": "text/css" }
const server = createServer(async (request, response) => {
const pathname = decodeURIComponent(new URL(request.url, "http://x").pathname)
const file = path.join(dist, pathname === "/" ? "/index.html" : pathname)
let body
try { body = await readFile(file) } catch { response.writeHead(404); response.end(); return }
response.writeHead(200, { "content-type": types[path.extname(file)] ?? "application/octet-stream" })
response.end(body)
}).listen(0, "127.0.0.1")
await new Promise(resolve => server.once("listening", resolve))
const base = `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: 1920, height: 1080 } })
const report = { checks: [], errors: [], warnings: [] }
page.on("pageerror", error => report.errors.push(error.message))
page.on("console", message => { if (message.type() === "error") report.errors.push(message.text()); if (message.type() === "warning") report.warnings.push(message.text()) })
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? "PASS" : "FAIL", data }); console.log(ok ? "PASS" : "FAIL", name, JSON.stringify(data ?? "")) }
const events = async () => (await page.locator("#events").textContent()).split("\n").filter(Boolean)
const lastEvent = async () => (await events()).at(-1)
const section = id => page.locator(`[data-example="${id}"]`)
const rect = (locator) => locator.evaluate(node => { const r = node.getBoundingClientRect(); return { x: Math.round(r.left), y: Math.round(r.top), w: Math.round(r.width), h: Math.round(r.height) } })
await page.goto(base)
await page.locator('[data-example="d"] .app-sw-search').waitFor()
await page.evaluate(() => document.fonts.ready)
// 1. Assets and CSS
const assets = await page.evaluate(() => [...document.images].map(image => ({ src: image.currentSrc.slice(0, 30), ok: image.complete && image.naturalWidth > 0 })))
check("1 all SVG/PNG assets decode (data URI, no external path)", assets.length > 0 && assets.every(item => item.ok && item.src.startsWith("data:")), { count: assets.length, broken: assets.filter(item => !item.ok) })
const tokens = await page.evaluate(() => { const s = getComputedStyle(document.querySelector('[data-example="b"] .hm-sw-theme')); return { height: s.getPropertyValue("--hm-sw-header-height").trim(), font: s.fontFamily } })
check("1 styles.css loaded; SWTheme provides tokens", tokens.height === "49px" && tokens.font.includes("Noto Sans KR"), tokens)
// 2. Example A: default header unchanged (Figma 1920 coordinates)
const a = section("a")
const geometry = {
header: await rect(a.locator(".hm-sw-header")), home: await rect(a.locator(".hm-sw-header__home")),
document: await rect(a.locator(".hm-sw-header__document")), object: await rect(a.locator(".hm-sw-header__object-area")),
window: await rect(a.locator(".hm-sw-header__window")),
}
check("2 A default header 1920 geometry = Figma (49 / 0·80 / 65·310 / 377·1301 / 1680·240)",
geometry.header.h === 49 && geometry.home.x === 0 && geometry.home.w === 80 && geometry.document.x === 65 && geometry.document.w === 310
&& geometry.object.x === 377 && geometry.object.w === 1301 && geometry.window.x === 1680 && geometry.window.w === 240, geometry)
await a.locator(".hm-sw-header").screenshot({ path: path.join(out, "A-default-1920.png") })
await a.locator(".hm-sw-header__field--layer").click()
await a.getByRole("button", { name: "CENTER" }).first().click()
check("2 A layer dropdown select callback", (await lastEvent()) === "layer:center", await lastEvent())
await a.getByRole("button", { name: "종료" }).click()
check("2 A close callback", (await lastEvent()) === "close", await lastEvent())
// 3. Example B: Logo → File → Layer → Color → K
const b = section("b")
const order = await b.locator(".hm-sw-theme").evaluate(bar => [...bar.querySelectorAll(".hm-sw-header__home, .hm-sw-header__document, .hm-sw-header__field, .hm-sw-header__action")].map(node => node.getAttribute("aria-label") ?? node.className.split(" ")[0]))
check("3 B order Logo → File → Layer → Color → K (DOM)", JSON.stringify(order) === JSON.stringify(["뷰어 홈", "hm-sw-header__document", "레이어", "색상", "팝업 가이드"]), order)
const bGeo = { bar: await rect(b.locator(".hm-sw-theme")), home: await rect(b.locator(".hm-sw-header__home")), document: await rect(b.locator(".hm-sw-header__document")), layer: await rect(b.locator(".hm-sw-header__field--layer")), color: await rect(b.locator(".hm-sw-header__field--color")) }
check("3 B sizes keep SW rules (bar 49, home 80 overlapping file 15px, fields 234/192 x 38)", bGeo.bar.h === 49 && bGeo.home.w === 80 && bGeo.document.x - bGeo.home.x === 65 && bGeo.layer.w === 234 && bGeo.layer.h === 38 && bGeo.color.w === 192, bGeo)
await b.locator(".hm-sw-header__document-button").click()
await b.getByRole("menuitem", { name: /New/ }).or(b.getByRole("button", { name: /New/ })).first().click()
check("3 B document dropdown New callback", (await lastEvent()) === "document:new", await lastEvent())
const layerField = b.locator(".hm-sw-header__field--layer")
await layerField.click()
const layerPanel = b.locator(".hm-sw-header__layer [id], .hm-sw-header__layer-menu").first()
check("3 B layer dropdown opens under the field", await layerField.getAttribute("aria-expanded") === "true", await layerField.getAttribute("aria-expanded"))
await page.screenshot({ path: path.join(out, "B-layer-open.png"), clip: { x: 0, y: (await rect(b)).y, width: 1000, height: 360 } })
await page.keyboard.press("Escape")
check("3 B Escape closes and returns focus to the field", await layerField.getAttribute("aria-expanded") === "false" && await layerField.evaluate(node => node === document.activeElement), null)
void layerPanel
await b.locator(".hm-sw-header__field--color").click()
await b.getByRole("button", { name: "빨강" }).click()
check("3 B color chip callback (dropdown stays open)", (await lastEvent()) === "color:red" && await b.locator(".hm-sw-header__field--color").getAttribute("aria-expanded") === "true", await lastEvent())
await page.mouse.click(1800, 900)
const k = b.getByRole("button", { name: "팝업 가이드" })
await k.hover()
await page.waitForTimeout(500)
const guide = await page.locator(".hm-sw-k-guide__panel").first().isVisible()
check("3 B K guide preview on hover (animation frame advances)", guide && Number(await page.locator(".hm-sw-k-guide__panel").first().getAttribute("data-frame")) >= 1, { guide })
await page.waitForTimeout(1000)
await page.screenshot({ path: path.join(out, "B-k-guide.png"), clip: { x: 1300, y: (await rect(b)).y, width: 620, height: 340 } })
await k.click()
check("3 B K click callback", (await lastEvent()) === "popupGuide", await lastEvent())
await page.mouse.move(1, 1)
await b.locator(".hm-sw-theme").screenshot({ path: path.join(out, "B-viewer-1920.png") })
// 4. Example C: File → Line → Text → Dimension → Print
const c = section("c")
const cOrder = await c.locator(".hm-sw-theme").evaluate(bar => [...bar.querySelectorAll(".hm-sw-header__document, .hm-sw-header__field, .hm-sw-header__action")].map(node => node.getAttribute("aria-label") ?? "file"))
check("4 C order File → 선 → 문자 → 치수 → 인쇄", JSON.stringify(cOrder) === JSON.stringify(["file", "선스타일", "문자스타일", "치수스타일", "인쇄"]), cOrder)
const fileEdge = await c.locator(".app-sw-file-edge").evaluate(node => getComputedStyle(node).borderLeftWidth)
check("4 C first file well has a left edge (consumer wrapper)", fileEdge === "1px", fileEdge)
await c.locator(".hm-sw-header__field--line-style").click()
await c.getByRole("button", { name: "CENTER" }).first().click()
check("4 C line type callback + field value", (await lastEvent()) === "lineType:center" && (await c.locator(".hm-sw-header__field--line-style").textContent()).includes("CENTER"), await lastEvent())
await page.keyboard.press("Escape")
await c.locator(".hm-sw-header__field--text-style").click()
await c.getByRole("button", { name: "Dotum" }).click()
check("4 C text style callback + field value", (await lastEvent()) === "textStyle:dotum" && (await c.locator(".hm-sw-header__field--text-style").textContent()).includes("Dotum"), await lastEvent())
await c.locator(".hm-sw-header__field--dimension-style").click()
await c.getByRole("button", { name: "ISO-25" }).click()
check("4 C dimension style callback", (await lastEvent()) === "dimensionStyle:iso", await lastEvent())
await c.getByRole("button", { name: "인쇄" }).click()
check("4 C print callback", (await lastEvent()) === "print", await lastEvent())
await page.mouse.move(1, 1)
await c.locator(".hm-sw-theme").screenshot({ path: path.join(out, "C-feature-1920.png") })
// 5. Example D: existing layer / color + new SWSearchControl
const d = section("d")
const style = await d.locator(".hm-sw-theme").evaluate(bar => {
const pick = node => { const s = getComputedStyle(node), r = node.getBoundingClientRect(); return { h: Math.round(r.height), top: Math.round(r.top), border: `${s.borderTopWidth} ${s.borderTopColor}`, radius: s.borderTopLeftRadius, bg: s.backgroundImage.slice(0, 40), shadow: s.boxShadow } }
return { layer: pick(bar.querySelector(".hm-sw-header__field--layer")), search: pick(bar.querySelector(".app-sw-search")),
label: getComputedStyle(bar.querySelector(".app-sw-search__label")).fontSize, fieldLabel: getComputedStyle(bar.querySelector(".hm-sw-header__field-label")).fontSize }
})
const same = ["h", "top", "border", "radius", "bg", "shadow"].every(key => style.layer[key] === style.search[key])
check("5 D new search field matches the SW field surface (height/top/border/radius/background/shadow/label)", same && style.label === style.fieldLabel, style)
// Pixels of an element screenshot: the inside must not change on hover, only the 1px edge turns yellow.
const samplePixels = async (locator) => {
const buffer = await locator.screenshot()
return page.evaluate(async b64 => {
const image = new Image(); image.src = `data:image/png;base64,${b64}`; await image.decode()
const canvas = document.createElement("canvas"); canvas.width = image.width; canvas.height = image.height
const context = canvas.getContext("2d"); context.drawImage(image, 0, 0)
const at = (x, y) => [...context.getImageData(x, y, 1, 1).data].slice(0, 3)
return { inside: at(Math.round(image.width * 0.85), Math.round(image.height / 2)), edge: at(Math.round(image.width / 2), 0) }
}, buffer.toString("base64"))
}
const searchField = d.locator(".app-sw-search")
await page.mouse.move(1, 1)
const before = await samplePixels(searchField)
await searchField.hover()
const ring = await searchField.evaluate(node => getComputedStyle(node, "::after").opacity)
const after = await samplePixels(searchField)
const near = (p, q, tolerance = 12) => p.every((value, index) => Math.abs(value - q[index]) <= tolerance)
const yellow = rgb => rgb[0] > 150 && rgb[1] > 100 && rgb[2] < 90
const layerRing = async () => { await d.locator(".hm-sw-header__field--layer").hover(); return d.locator(".hm-sw-header__field--layer").evaluate(node => getComputedStyle(node, "::after").opacity) }
check("5 D hover ring like existing fields (edge turns yellow, inside unchanged)", ring === "1" && near(before.inside, after.inside) && yellow(after.edge) && !yellow(before.edge) && (await layerRing()) === "1", { ring, before, after })
await d.locator(".app-sw-search").hover()
await page.screenshot({ path: path.join(out, "D-search-hover.png"), clip: { x: 0, y: (await rect(d)).y, width: 760, height: 60 } })
const input = d.getByRole("textbox", { name: "검색어" })
await input.click()
await input.fill("WALL-01")
await page.keyboard.press("Enter")
check("5 D Enter commits through SWHeaderInput → search callback", (await lastEvent()) === "search:WALL-01", await lastEvent())
await d.getByRole("button", { name: "검색", exact: true }).click()
check("5 D search button callback uses committed value", (await lastEvent()) === "search:WALL-01", await lastEvent())
await input.click()
await input.fill("TMP")
await page.keyboard.press("Escape")
check("5 D Escape restores the committed value", await input.inputValue() === "WALL-01", await input.inputValue())
await page.keyboard.press("Tab")
await d.locator(".hm-sw-header__field--color").click()
await d.getByRole("button", { name: "파랑" }).click()
check("5 D existing color dropdown in the same bar", (await lastEvent()) === "color:blue", await lastEvent())
await page.mouse.click(1800, 900)
await page.mouse.move(1, 1)
await d.locator(".hm-sw-theme").screenshot({ path: path.join(out, "D-search-1920.png") })
// 6. Keyboard order follows DOM order in B
await b.locator(".hm-sw-header__home").focus()
const tabs = []
for (let index = 0; index < 4; index++) { await page.keyboard.press("Tab"); tabs.push(await page.evaluate(() => document.activeElement?.getAttribute("aria-label") ?? document.activeElement?.textContent?.trim().slice(0, 12))) }
check("6 B Tab order = display order", tabs.includes("레이어") && tabs.indexOf("레이어") < tabs.indexOf("색상") && tabs.indexOf("색상") < tabs.indexOf("팝업 가이드"), tabs)
check("7 no page/console errors", report.errors.length === 0, report.errors)
await writeFile(path.join(out, "report.json"), JSON.stringify(report, null, 2))
await browser.close()
server.close()
const failed = report.checks.filter(item => item.status === "FAIL").length
console.log(`${report.checks.length - failed}/${report.checks.length} PASS`)
process.exitCode = failed ? 1 : 0
@@ -0,0 +1,70 @@
// Independent consumer check for @hm-design/sw (AI template, 2026-10-19).
// Usage: node packages/sw/tests/ai-template-consumer.mjs <empty-dir-outside-the-repo>
// Packs the built package (npm pack), creates a React 19 + TypeScript + Vite project in <dir>, installs the
// tarball, copies the shipped ai/examples from node_modules (not from the repo) and runs tsc + vite build.
// Build the package first: npm run build -w @hm-design/sw
import { execFileSync } from "node:child_process"
import { cp, mkdir, readdir, writeFile } from "node:fs/promises"
import path from "node:path"
import { fileURLToPath } from "node:url"
const packageRoot = fileURLToPath(new URL("../", import.meta.url))
const out = path.resolve(process.argv[2] ?? "")
if (!process.argv[2] || out.startsWith(path.resolve(packageRoot, "../.."))) throw new Error("Pass an empty directory outside the repository")
await mkdir(out, { recursive: true })
if ((await readdir(out)).length) throw new Error(`${out} is not empty`)
const npm = (args, cwd) => execFileSync(process.platform === "win32" ? "npm.cmd" : "npm", args, { cwd, stdio: "inherit", shell: process.platform === "win32" })
npm(["pack", "--ignore-scripts", "--pack-destination", out], packageRoot)
const tarball = (await readdir(out)).find(name => name.endsWith(".tgz"))
const files = {
"package.json": JSON.stringify({
name: "sw-ai-template-consumer", private: true, type: "module",
scripts: { typecheck: "tsc -p tsconfig.json", build: "vite build" },
}, null, 2),
"tsconfig.json": JSON.stringify({
compilerOptions: {
target: "ES2022", lib: ["ES2022", "DOM", "DOM.Iterable"], module: "ESNext", moduleResolution: "Bundler",
jsx: "react-jsx", strict: true, noEmit: true, skipLibCheck: false, isolatedModules: true,
noUnusedLocals: true, noUnusedParameters: true, types: ["vite/client"],
},
include: ["src"],
}, null, 2),
"vite.config.ts": `import { defineConfig } from "vite"\nimport react from "@vitejs/plugin-react"\nexport default defineConfig({ plugins: [react()] })\n`,
"index.html": `<!doctype html><html lang="ko"><head><meta charset="utf-8" /><meta name="viewport" content="width=device-width, initial-scale=1" /><title>SW consumer</title><link rel="icon" href="data:," /><style>body { margin: 0; }</style></head><body><div id="root"></div><script type="module" src="/src/main.tsx"></script></body></html>\n`,
"src/main.tsx": `import { StrictMode, useState } from "react"
import { createRoot } from "react-dom/client"
import "@hm-design/sw/styles.css"
import { ExampleADefaultHeader } from "./examples/ExampleA-DefaultHeader"
import { ExampleBViewerHeader } from "./examples/ExampleB-ViewerHeader"
import { ExampleCFeatureHeader } from "./examples/ExampleC-FeatureHeader"
import { ExampleDSearchExtension } from "./examples/ExampleD-SearchExtension"
const examples = { a: ExampleADefaultHeader, b: ExampleBViewerHeader, c: ExampleCFeatureHeader, d: ExampleDSearchExtension }
function App() {
const [events, setEvents] = useState<string[]>([])
const onEvent = (name: string) => setEvents(list => [...list, name])
return (
<>
{Object.entries(examples).map(([id, Example]) => (
<section key={id} data-example={id} style={{ marginBottom: 360 }}><Example onEvent={onEvent} /></section>
))}
<pre id="events">{events.join("\\n")}</pre>
</>
)
}
createRoot(document.getElementById("root")!).render(<StrictMode><App /></StrictMode>)
`,
}
for (const [name, text] of Object.entries(files)) {
await mkdir(path.dirname(path.join(out, name)), { recursive: true })
await writeFile(path.join(out, name), text)
}
npm(["install", "--no-audit", "--no-fund", `./${tarball}`, "react@19.3.0", "react-dom@19.3.0"], out)
npm(["install", "--no-audit", "--no-fund", "-D", "typescript@7.0.2", "vite@8.3.0", "@vitejs/plugin-react@6.1.1", "@types/react@19.3.0", "@types/react-dom@19.3.0"], out)
// The examples are taken from the installed package: what an AI tool in another project would read.
await cp(path.join(out, "node_modules/@hm-design/sw/ai/examples"), path.join(out, "src/examples"), { recursive: true })
npm(["run", "typecheck"], out)
npm(["run", "build"], out)
console.log(`Consumer ready: ${out} (dist/ built). Next: node packages/sw/tests/ai-template-consumer.browser.mjs ${out} <evidence-dir>`)