docs(sw): AI rules, example E, tests and evidence for the popup / footer / header changes

AI documents (packages/sw/ai)
- SWPopupPropertyRow, SWSelect, SWSlider in the catalog, usage table,
  composition patterns and design rules; SWViewerSlider noted as SWSlider panel.
- Rule: the SWHeader layer / color dropdowns are header-only; popup layer /
  color / line style use SWPopupPropertyRow (property window pickers).
- Footer rules: gra-border-orange surfaces, Model / Layout text unchanged on
  hover, side button hover, vacant surface limited to the slot row.
- Header rules: measured More growing behavior (hysteresis fix A).
- Example E (custom popup) uses SWPopupPropertyRow, SWSelect and SWSlider
  instead of SWHeaderLayerMenu in the popup.

Tests (packages/sw/tests)
- New footer-popup-viewer-refinement.browser.mjs (47 checks: footer stories,
  hover / gradient / tab text / pocket, popup pickers vs property window rows,
  SWSlider, unit SWSelect parity, Popup B, header trace, viewer title art).
- integrated-popup / integrated-consumer: popup pickers and SWSelect instead of
  the header dropdown and the HM UI Select (same overlay / callback / Escape
  requirements).
- integrated-improvement: default sections skip the retired SW/Tool story
  ('tool' still runs by name); storybook-inventory / simplification record the
  retired footer IDs; theme-inheritance widths use sw-footer--full-footer.

Docs (docs/sw)
- sw-footer-popup-viewer-refinement-2026-10-20.md: causes, decisions (the
  first-pass "header dropdown in popup" change is marked withdrawn),
  verification and baseline failures; evidence footer-popup-viewer-refinement-qa
  (47/47). Footer mapping document updated for the removed sections.

Baseline failures (unchanged by this work): home-menu-integration 1,
storybook-unused-removal 5 (10-19 snapshot comparisons), range-cross-browser and
property-panel-comparison open stories retired on 2026-10-19.
This commit is contained in:
shkim4 committed 2026-10-20 15:20:27 +09:00
1 parent 126a35d60d
commit 454472d344
24 files changed
+1211 -52

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 161 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 45 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 23 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 55 KiB

@@ -0,0 +1,782 @@
{
"date": "2026-10-20T05:51:47.090Z",
"checks": [
{
"n": "Footer: 기본 Footer / States removed, full-footer + figma-variants kept",
"ok": true,
"d": [
"sw-footer--full-footer",
"sw-footer--tool-groups",
"sw-footer--figma-variants",
"sw-footer--custom-content",
"sw-footer--responsive",
"sw-footer--parts"
]
},
{
"n": "Footer: full-footer keeps only 실제 Footer 조합 (3 sections removed)",
"ok": true,
"d": {
"sections": [
"실제 Footer 조합 (동작)"
],
"errors": []
}
},
{
"n": "Footer: one live footer, no Figma / reference image",
"ok": true,
"d": 1
},
{
"n": "Footer: category change keeps the command slot position",
"ok": true,
"d": {
"before": [
1389,
817,
12
],
"after": [
1389,
817,
12
]
}
},
{
"n": "Footer: 보조기능 전체보기 hover = both lines accent + orange ring, 74 × 71",
"ok": true,
"d": {
"title": "rgb(255, 92, 0)",
"sub": "rgb(255, 92, 0)",
"ring": "1",
"w": 74,
"h": 71
}
},
{
"n": "Footer: 명령어 전체보기 hover = both lines accent + orange ring, 74 × 71",
"ok": true,
"d": {
"title": "rgb(255, 92, 0)",
"sub": "rgb(255, 92, 0)",
"ring": "1",
"w": 74,
"h": 71
}
},
{
"n": "Footer: side button default unchanged (subtitle #F6F4F2, no ring)",
"ok": true,
"d": [
"rgb(246, 244, 242)",
"0"
]
},
{
"n": "Footer: category + hover = orange + (menu-add-hover, #FF5C00), 28px",
"ok": true,
"d": "url(\"data:image/svg+xml,%3csvg%20preserveAspectRatio='none'%20overflow='visible'"
},
{
"n": "Footer: Layout + hover = gra-border-orange surface with the +, 24px",
"ok": true,
"d": "url(\"data:image/svg+xml,%3csvg%20preserveAspectRatio='none'%20overflow='visible'%20style='display:%20block;'%20width='28'%20height='12'%20viewBox='0%200%2028%20"
},
{
"n": "Footer: Model (selected) = gra-border-orange surface, black #161410 text without outline, 73 × 22",
"ok": true,
"d": {
"bg": "none",
"before": "linear-gradient(rgb(218, 104, 19) 8.52%, rgb(255, 187, 137) 37.93%, rgb(229, 105, 13) 71.85%, rgb(174, 54, 7) 92.77%)",
"color": "rgb(22, 20, 16)",
"size": "12px",
"weight": "400",
"shadow": "none",
"text": "220,40",
"w": 73,
"h": 22
}
},
{
"n": "Footer: Layout 02 hover = Model surface; text identical to Default (black, size, weight, place, box)",
"ok": true,
"d": {
"l02": {
"bg": "linear-gradient(rgb(218, 104, 19) 8.52%, rgb(255, 187, 137) 37.93%, rgb(229, 105, 13) 71.85%, rgb(174, 54, 7) 92.77%)",
"before": "none",
"color": "rgb(22, 20, 16)",
"size": "12px",
"weight": "400",
"shadow": "none",
"text": "80,40",
"w": 85,
"h": 22
},
"l02d": {
"bg": "none",
"before": "none",
"color": "rgb(22, 20, 16)",
"size": "12px",
"weight": "400",
"shadow": "none",
"text": "80,40",
"w": 85,
"h": 22
}
}
},
{
"n": "Footer: Layout 01 hover = Model surface (slanted); text identical to Default",
"ok": true,
"d": {
"l01": {
"bg": "none",
"before": "linear-gradient(rgb(218, 104, 19) 8.52%, rgb(255, 187, 137) 37.93%, rgb(229, 105, 13) 71.85%, rgb(174, 54, 7) 92.77%)",
"color": "rgb(22, 20, 16)",
"size": "12px",
"weight": "400",
"shadow": "none",
"text": "80,40",
"w": 97,
"h": 22
},
"l01d": {
"bg": "none",
"before": "url(\"data:image/svg+xml,%3csvg%20preserveAspectRatio='none'%20overflow='visible'%20style='display:%20block;'%20width='97'%20height='22'%20viewBox='0%200%2097%2022'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cg%20id='bg'%3e%3cg%20filter='url(%23filter0_i_0_83)'%3e%3cpath%20d='M13.6039%203.29463C15.1093%201.22467%2017.5143%200%2020.0738%200H93C95.2091%200%2097%201.79086%2097%204V22H0L13.6039%203.29463Z'%20fill='%23C3B79E'/%3e%3c/g%3e%3cpath%20d='M20.0742%200.5H93C94.933%200.5%2096.5%202.067%2096.5%204V21.5H0.981445L14.0078%203.58887C15.4191%201.64829%2017.6747%200.5%2020.0742%200.5Z'%20stroke='url(%23paint0_linear_0_83)'/%3e%3c/g%3e%3cdefs%3e%3cfilter%20id='filter0_i_0_83'%20x='0'%20y='-3'%20width='97'%20height='25'%20filterUnits='userSpaceOnUse'%20color-interpolation-filters='sRGB'%3e%3cfeFlood%20flood-opacity='0'%20result='BackgroundImageFix'/%3e%3cfeBlend%20mode='normal'%20in='SourceGraphic'%20in2='BackgroundImageFix'%20result='shape'/%3e%3cfeColorMatrix%20in='SourceAlpha'%20type='matrix'%20values='0%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%20127%200'%20result='hardAlpha'/%3e%3cfeOffset%20dy='-3'/%3e%3cfeGaussianBlur%20stdDeviation='2'/%3e%3cfeComposite%20in2='hardAlpha'%20operator='arithmetic'%20k2='-1'%20k3='1'/%3e%3cfeColorMatrix%20type='matrix'%20values='0%200%200%200%200.219608%200%200%200%200%200.188235%200%200%200%200%200.121569%200%200%200%200.25%200'/%3e%3cfeBlend%20mode='multiply'%20in2='shape'%20result='effect1_innerShadow_0_83'/%3e%3c/filter%3e%3clinearGradient%20id='paint0_linear_0_83'%20x1='56'%20y1='0'%20x2='56'%20y2='22'%20gradientUnits='userSpaceOnUse'%3e%3cstop%20stop-opacity='0.05'/%3e%3cstop%20offset='1'%20stop-opacity='0.3'/%3e%3c/linearGradient%3e%3c/defs%3e%3c/svg%3e\")",
"color": "rgb(22, 20, 16)",
"size": "12px",
"weight": "400",
"shadow": "none",
"text": "80,40",
"w": 97,
"h": 22
}
}
},
{
"n": "Footer: Layout tabs return to their default look after hover",
"ok": true,
"d": {
"bg": "none",
"before": "url(\"data:image/svg+xml,%3csvg%20preserveAspectRatio='none'%20overflow='visible'%20style='display:%20block;'%20width='97'%20height='22'%20viewBox='0%200%2097%2022'%20fill='none'%20xmlns='http://www.w3.org/2000/svg'%3e%3cg%20id='bg'%3e%3cg%20filter='url(%23filter0_i_0_83)'%3e%3cpath%20d='M13.6039%203.29463C15.1093%201.22467%2017.5143%200%2020.0738%200H93C95.2091%200%2097%201.79086%2097%204V22H0L13.6039%203.29463Z'%20fill='%23C3B79E'/%3e%3c/g%3e%3cpath%20d='M20.0742%200.5H93C94.933%200.5%2096.5%202.067%2096.5%204V21.5H0.981445L14.0078%203.58887C15.4191%201.64829%2017.6747%200.5%2020.0742%200.5Z'%20stroke='url(%23paint0_linear_0_83)'/%3e%3c/g%3e%3cdefs%3e%3cfilter%20id='filter0_i_0_83'%20x='0'%20y='-3'%20width='97'%20height='25'%20filterUnits='userSpaceOnUse'%20color-interpolation-filters='sRGB'%3e%3cfeFlood%20flood-opacity='0'%20result='BackgroundImageFix'/%3e%3cfeBlend%20mode='normal'%20in='SourceGraphic'%20in2='BackgroundImageFix'%20result='shape'/%3e%3cfeColorMatrix%20in='SourceAlpha'%20type='matrix'%20values='0%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%200%20127%200'%20result='hardAlpha'/%3e%3cfeOffset%20dy='-3'/%3e%3cfeGaussianBlur%20stdDeviation='2'/%3e%3cfeComposite%20in2='hardAlpha'%20operator='arithmetic'%20k2='-1'%20k3='1'/%3e%3cfeColorMatrix%20type='matrix'%20values='0%200%200%200%200.219608%200%200%200%200%200.188235%200%200%200%200%200.121569%200%200%200%200.25%200'/%3e%3cfeBlend%20mode='multiply'%20in2='shape'%20result='effect1_innerShadow_0_83'/%3e%3c/filter%3e%3clinearGradient%20id='paint0_linear_0_83'%20x1='56'%20y1='0'%20x2='56'%20y2='22'%20gradientUnits='userSpaceOnUse'%3e%3cstop%20stop-opacity='0.05'/%3e%3cstop%20offset='1'%20stop-opacity='0.3'/%3e%3c/linearGradient%3e%3c/defs%3e%3c/svg%3e\")",
"color": "rgb(22, 20, 16)",
"size": "12px",
"weight": "400",
"shadow": "none",
"text": "80,40",
"w": 97,
"h": 22
}
},
{
"n": "Footer: category pocket surface is the same across the whole tab for 1st · 2nd · 3rd · 4th tab",
"ok": true,
"d": [
[
"도형",
"hm-sw-footer__group",
"hm-sw-footer__group",
"hm-sw-footer__group"
],
[
"수정",
"hm-sw-footer__group",
"hm-sw-footer__group",
"hm-sw-footer__group"
],
[
"편집",
"hm-sw-footer__group",
"hm-sw-footer__group",
"hm-sw-footer__group"
],
[
"문자",
"hm-sw-footer__group",
"hm-sw-footer__group",
"hm-sw-footer__group"
]
]
},
{
"n": "Footer: vacant surface covers the slot row only (46px)",
"ok": true,
"d": 46
},
{
"n": "Footer: a selected Layout keeps its own (brown) selected surface, distinct from hover",
"ok": true,
"d": [
"on",
"linear-gradient(0deg, rgb(124, 108, 76) 0.48%, rgb(124, 108, 76) 85.07%, rgb(166, 148, 111) 100%)"
]
},
{
"n": "Footer: aux Figma comparison lives in figma-variants (6 rows)",
"ok": true,
"d": []
},
{
"n": "Footer: responsive 860px row renders with an overflow ▲ control",
"ok": true,
"d": {
"panelOrder": "명령 더보기 (7개)",
"errors": []
}
},
{
"n": "Footer: 화면 폭 변화 keeps the 6 widths, 참고 좁은 화면 + Figma 기본 구성 removed",
"ok": true,
"d": [
"실제 조합 (바 안 명령창, command)"
]
},
{
"n": "Header: layer field keeps the header design (#F6F4F2, 38px, value 700)",
"ok": true,
"d": "rgb(246, 244, 242)|38px|rgb(246, 244, 242)|700"
},
{
"n": "Popup: 도면 설정 layer = 속성창 layer picker (value, trigger, label computed look)",
"ok": true,
"d": {
"a": "rgb(0, 0, 0),rgba(0, 0, 0, 0),none,rgba(0, 0, 0, 0),28px,14px,400,none / rgb(230, 4, 3),rgba(0, 0, 0, 0),none,rgb(230, 4, 3),26px,14px,400,none / rgb(0, 0, 0),rgba(0, 0, 0, 0),none,rgb(154, 146, 132),16px,14px,700,none / layer",
"b": "rgb(0, 0, 0),rgba(0, 0, 0, 0),none,rgba(0, 0, 0, 0),28px,14px,400,none / rgb(230, 4, 3),rgba(0, 0, 0, 0),none,rgb(230, 4, 3),26px,14px,400,none / rgb(0, 0, 0),rgba(0, 0, 0, 0),none,rgb(154, 146, 132),16px,14px,700,none / layer"
}
},
{
"n": "Popup: 도면 설정 color = 속성창 color picker (value, trigger, label computed look)",
"ok": true,
"d": {
"a": "rgb(0, 0, 0),rgba(0, 0, 0, 0),none,rgba(0, 0, 0, 0),28px,14px,400,none / rgb(230, 4, 3),rgba(0, 0, 0, 0),none,rgb(230, 4, 3),26px,14px,500,none / rgb(0, 0, 0),rgba(0, 0, 0, 0),none,rgb(154, 146, 132),16px,14px,700,none / color",
"b": "rgb(0, 0, 0),rgba(0, 0, 0, 0),none,rgba(0, 0, 0, 0),28px,14px,400,none / rgb(230, 4, 3),rgba(0, 0, 0, 0),none,rgb(230, 4, 3),26px,14px,500,none / rgb(0, 0, 0),rgba(0, 0, 0, 0),none,rgb(154, 146, 132),16px,14px,700,none / color"
}
},
{
"n": "Popup: no SWHeader field dropdown inside any popup",
"ok": true
},
{
"n": "Popup: layer picker opens (fixed overlay), selects and closes",
"ok": true,
"d": "Callback: layer=hatch"
},
{
"n": "Popup: color picker chip commits the RGB value",
"ok": true,
"d": "Callback: layer=hatch → color=0,0,255"
},
{
"n": "Popup: frame / title / close unchanged (3 popups in the main section)",
"ok": true
},
{
"n": "Slider: A = panel (18px, no property paint), B = property (16px, property paint), same fill",
"ok": true,
"d": [
{
"look": "panel",
"h": 18,
"fill": "0.4",
"paint": false,
"range": "#266651"
},
{
"look": "property",
"h": 16,
"fill": "0.4",
"paint": true,
"range": "#266651"
},
{
"look": "panel",
"h": 18,
"fill": "0.4",
"paint": false,
"range": "#266651"
}
]
},
{
"n": "Slider: one shared value; keyboard change on B commits through onValueCommit",
"ok": true,
"d": {
"log": "Callback: layer=hatch → color=0,0,255 → opacity(property)=50",
"afterB": [
"50",
"50",
"50"
]
}
},
{
"n": "Slider: appearance selection switches the look only",
"ok": true,
"d": "property"
},
{
"n": "Slider: disabled 40%",
"ok": true,
"d": {
"d": true,
"o": "0.4"
}
},
{
"n": "Slider: property window gauge keeps 86 × 16 and its paint",
"ok": true,
"d": [
86,
16,
true
]
},
{
"n": "Select: Popup 도면 설정 단위 = Viewer 축척 지정 단위 (default · hover · focus computed look)",
"ok": true,
"d": {
"ps": {
"d": "appearance=auto;height=28px;paddingLeft=8px;paddingRight=8px;borderTopWidth=1px;borderTopColor=rgb(148, 136, 113);borderTopStyle=solid;borderRadius=4px;backgroundColor=rgb(241, 236, 225);color=rgb(0, 0, 0);fontSize=12px;fontWeight=400;lineHeight=normal;outlineStyle=none;outlineColor=oklab(0.462622 -0.0726663 0.0150061 / 0.5);outlineWidth=3px;opacity=1;cursor=default;class=hm-sw-select;tag=SELECT",
"h": "appearance=auto;height=28px;paddingLeft=8px;paddingRight=8px;borderTopWidth=1px;borderTopColor=rgb(148, 136, 113);borderTopStyle=solid;borderRadius=4px;backgroundColor=rgb(241, 236, 225);color=rgb(0, 0, 0);fontSize=12px;fontWeight=400;lineHeight=normal;outlineStyle=none;outlineColor=oklab(0.462622 -0.0726663 0.0150061 / 0.5);outlineWidth=3px;opacity=1;cursor=default;class=hm-sw-select;tag=SELECT",
"f": "appearance=auto;height=28px;paddingLeft=8px;paddingRight=8px;borderTopWidth=1px;borderTopColor=rgb(148, 136, 113);borderTopStyle=solid;borderRadius=4px;backgroundColor=rgb(241, 236, 225);color=rgb(0, 0, 0);fontSize=12px;fontWeight=400;lineHeight=normal;outlineStyle=solid;outlineColor=rgb(166, 125, 3);outlineWidth=2px;opacity=1;cursor=default;class=hm-sw-select;tag=SELECT"
},
"vs": {
"d": "appearance=auto;height=28px;paddingLeft=8px;paddingRight=8px;borderTopWidth=1px;borderTopColor=rgb(148, 136, 113);borderTopStyle=solid;borderRadius=4px;backgroundColor=rgb(241, 236, 225);color=rgb(0, 0, 0);fontSize=12px;fontWeight=400;lineHeight=normal;outlineStyle=none;outlineColor=oklab(0.462622 -0.0726663 0.0150061 / 0.5);outlineWidth=3px;opacity=1;cursor=default;class=hm-sw-select;tag=SELECT",
"h": "appearance=auto;height=28px;paddingLeft=8px;paddingRight=8px;borderTopWidth=1px;borderTopColor=rgb(148, 136, 113);borderTopStyle=solid;borderRadius=4px;backgroundColor=rgb(241, 236, 225);color=rgb(0, 0, 0);fontSize=12px;fontWeight=400;lineHeight=normal;outlineStyle=none;outlineColor=oklab(0.462622 -0.0726663 0.0150061 / 0.5);outlineWidth=3px;opacity=1;cursor=default;class=hm-sw-select;tag=SELECT",
"f": "appearance=auto;height=28px;paddingLeft=8px;paddingRight=8px;borderTopWidth=1px;borderTopColor=rgb(148, 136, 113);borderTopStyle=solid;borderRadius=4px;backgroundColor=rgb(241, 236, 225);color=rgb(0, 0, 0);fontSize=12px;fontWeight=400;lineHeight=normal;outlineStyle=solid;outlineColor=rgb(166, 125, 3);outlineWidth=2px;opacity=1;cursor=default;class=hm-sw-select;tag=SELECT"
}
}
},
{
"n": "Select: options and selection kept (Popup mm/cm/m, Viewer mm/m), change events",
"ok": true,
"d": {
"popupOptions": [
"mm",
"cm",
"m"
],
"viewerOptions": [
"mm",
"m"
],
"popupPicked": [
true,
"m"
]
}
},
{
"n": "Header: Open returns once while growing 520 → 1080 and stays",
"ok": true,
"d": [
[
520,
0,
""
],
[
560,
0,
""
],
[
600,
1,
""
],
[
640,
1,
"actions"
],
[
680,
1,
"actions"
],
[
720,
1,
""
],
[
760,
1,
""
],
[
820,
1,
""
],
[
900,
1,
"color"
],
[
1080,
1,
"color"
]
]
},
{
"n": "Header: at 1920 every part is back and Open is shown",
"ok": true,
"d": [
[
1440,
1,
""
],
[
1920,
1,
""
]
]
},
{
"n": "Header: shrinking, Open moves into More first (1800) and stays there",
"ok": true,
"d": [
[
1800,
0,
"dimensionStyle"
],
[
1440,
0,
""
],
[
1080,
0,
""
],
[
900,
0,
""
],
[
640,
0,
""
],
[
520,
0,
""
]
]
},
{
"n": "Header: regrowing after a shrink, Open returns once and stays",
"ok": true,
"d": [
[
560,
0,
""
],
[
600,
1,
""
],
[
640,
1,
"actions"
],
[
680,
1,
"actions"
],
[
720,
1,
""
],
[
900,
1,
"color"
],
[
1080,
1,
"color"
],
[
1920,
1,
""
]
]
},
{
"n": "Popup B: same frame as Popup A",
"ok": true,
"d": [
"314|rgb(228, 219, 201)|rgb(166, 125, 3)|rgb(255, 198, 0)|700",
"314|rgb(228, 219, 201)|rgb(166, 125, 3)|rgb(255, 198, 0)|700"
]
},
{
"n": "Popup B: popup layer picker (no SWHeader field)",
"ok": true
},
{
"n": "Popup B: layer selection callback and field value",
"ok": true,
"d": "Callback: b.layer=dim"
},
{
"n": "Popup B: 375px, no page overflow and the popup inside the viewport",
"ok": true,
"d": {
"sw": 375,
"vw": 375,
"b": [
32,
343
]
}
},
{
"n": "Viewer: 250 × auto title art keeps 308px and paints from the left edge (no gap)",
"ok": true,
"d": {
"w": 250,
"h": 142,
"artW": 308,
"gap": 0
}
},
{
"n": "Viewer: 320 / 480 popups unchanged (no gap)",
"ok": true,
"d": [
{
"w": 250,
"h": 142,
"artW": 308,
"gap": 0
},
{
"w": 320,
"h": 420,
"artW": 308,
"gap": 0
},
{
"w": 480,
"h": 300,
"artW": 308,
"gap": 0
}
]
},
{
"n": "Viewer: 250 × auto collapses to the title bar and expands back to auto height",
"ok": true,
"d": {
"h0": 142,
"h1": 32
}
},
{
"n": "Viewer: 250 × auto drags by its title bar",
"ok": true,
"d": {
"dx": 60,
"dy": 30
}
},
{
"n": "Viewer: content variants (incl. 300px) title art has no gap",
"ok": true,
"d": [
{
"w": 314,
"h": 420,
"artW": 308,
"gap": 0
},
{
"w": 314,
"h": 420,
"artW": 308,
"gap": 0
},
{
"w": 300,
"h": 239,
"artW": 308,
"gap": 0
}
]
},
{
"n": "Viewer: SWViewerSlider = SWSlider panel with viewer tokens (#266651 / #e4dbc9)",
"ok": true,
"d": {
"cls": "hm-sw-slider hm-sw-viewer-slider",
"look": "panel",
"h": 18,
"range": "#266651",
"track": "#e4dbc9"
}
}
],
"report": {
"header": {
"grow": [
[
520,
0,
""
],
[
560,
0,
""
],
[
600,
1,
""
],
[
640,
1,
"actions"
],
[
680,
1,
"actions"
],
[
720,
1,
""
],
[
760,
1,
""
],
[
820,
1,
""
],
[
900,
1,
"color"
],
[
1080,
1,
"color"
]
],
"wide": [
[
1440,
1,
""
],
[
1920,
1,
""
]
],
"shrink": [
[
1800,
0,
"dimensionStyle"
],
[
1440,
0,
""
],
[
1080,
0,
""
],
[
900,
0,
""
],
[
640,
0,
""
],
[
520,
0,
""
]
],
"regrow": [
[
560,
0,
""
],
[
600,
1,
""
],
[
640,
1,
"actions"
],
[
680,
1,
"actions"
],
[
720,
1,
""
],
[
900,
1,
"color"
],
[
1080,
1,
"color"
],
[
1920,
1,
""
]
]
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 7.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 10 KiB

@@ -28,23 +28,23 @@
| 32 | 26:10023 | ico_arrow | 명령창·▲ | `SWFooterCommandLine` 펼치기, `SWFooter` ▲ 칸 | Default ↑ · hover · on ↓ · Variant4(on hover) → `expanded` / ▲ 패널 열림 | Figma Variant · 명령창, 화면 폭 |
| 33 | 26:10048 | command (접힘) | 명령창 | `SWFooterCommandLine` | 71px, 마지막 3줄 | Figma Variant · 명령창 |
| 34 | 26:10056 | command (펼침) | 명령창 | `SWFooterCommandLine expanded` | 270px | 〃 |
| 35 | 26:10097 | 레이아웃 분리 | 전체 | `SWFooter`(+ `SWFooterLayoutTabs`) | 하단 72px 전체 | 기본 Footer, Figma Variant · Footer 전체 |
| 35 | 26:10097 | 레이아웃 분리 | 전체 | `SWFooter`(+ `SWFooterLayoutTabs`) | 하단 72px 전체 | 전체 Footer · 실제 조합(Figma 기본 구성 섹션), Figma Variant · Footer 전체 |
보조 참고(35개 목록 밖, 2026-10-20 상태 수정 때 확인): 26:10104의 보조기능 버튼은 Component Set btn_객체스냅 26:9248 · btn_선택효과 26:9267 · btn_직각교차 26:9298 · btn_격자스냅 26:9320 · btn_격자배경 26:9381 · btn_선가중치 26:9412(Default / Variant2 / Variant3)의 인스턴스입니다. 기준 이미지 `src/sw/assets/figma-reference/footer/aux-buttons/`, Story = Hover / Active / Selected / Disabled.
보조 참고(35개 목록 밖, 2026-10-20 상태 수정 때 확인): 26:10104의 보조기능 버튼은 Component Set btn_객체스냅 26:9248 · btn_선택효과 26:9267 · btn_직각교차 26:9298 · btn_격자스냅 26:9320 · btn_격자배경 26:9381 · btn_선가중치 26:9412(Default / Variant2 / Variant3)의 인스턴스입니다. 기준 이미지 `src/sw/assets/figma-reference/footer/aux-buttons/`, Story = Figma 디자인별 Variant · 보조기능 버튼 섹션(2026-10-20까지는 Hover / Active / Selected / Disabled Story).
26:10097 안의 보조기능 버튼(btn_객체스냅 …, 26:10104)·측면 버튼(26:10189 / 26:10192)·구분선·bg_vacant·menu_bg도 같은 노드에서 읽어 `SWFooterAuxGroup`·`SWFooterSideButton`·`SWFooterMenu` 면으로 구현했습니다.
## 2. Storybook `SW/Footer`
2026-10-20 정리: `기본 Footer`(`sw-footer--default`)와 `Hover / Active / Selected / Disabled`(`sw-footer--states`) Story를 삭제했습니다. 두 Story의 화면은 `전체 Footer · 실제 조합`의 섹션(Figma 기본 구성 · 상태)과 숨김 `Figma 디자인별 Variant`의 보조기능 버튼 섹션으로 옮겼습니다. 같은 날 2차 정리(사용자 요청)로 `전체 Footer · 실제 조합`의 `전체 Footer — Figma 기본 구성`·`상태`·`참고: 실제 화면` 섹션과 `화면 폭 변화`의 `참고: 실제 좁은 화면`·`Figma 기본 구성 (명령창 없음)` 섹션을 삭제했습니다(Story ID 유지, 기준 이미지 파일은 보존). Figma 기본 구성 비교는 숨김 `Figma 디자인별 Variant`의 Footer 전체 노드(26:10097)에 남아 있습니다.
| Story | 내용 |
|---|---|
| 기본 Footer | Figma 데이터만 넣은 `<SWFooter />` 1920 + Figma 26:10097 하단 렌더 |
| 전체 Footer · 실제 조합 | 실제 화면 구성(SWHeader + 도면 + 좌표 + 바 안 명령창 + 명령 10개 + 분류 12개 + Layout 탭), 모든 Callback 동작, 실제 화면 이미지 |
| 전체 Footer · 실제 조합 | 실제 Footer 조합(SWHeader + 도면 + 좌표 + 바 안 명령창 + 명령 10개 + 분류 12개 + Layout 탭, 모든 Callback 동작, 각 부품의 Hover · Active · Selected · Disabled) |
| 도구 그룹별 표시 | 도형(9) · 수정(NCopy) · 빈 분류, 보조기능·측면 버튼 없는 구성 |
| Figma 디자인별 Variant (35 노드) | 노드마다 Figma 렌더 ↔ 구현 상태 |
| Hover / Active / Selected / Disabled | 명령·빈 칸·분류·보조기능·측면 버튼 상태 |
| Figma 디자인별 Variant (35 노드, verification) | 노드마다 Figma 렌더 ↔ 구현 상태, 보조기능 버튼 6종 Figma ↔ 구현 4상태 |
| 도구 및 메뉴 변경 예제 | 사용자 분류·lucide 아이콘·slotCount 10·빈 칸으로 명령 추가·분류 추가·문구 변경·trailing·잠긴 Layout |
| 화면 폭 변화 | 실제 조합 1920 / 1440 / 1280 / 950, 좁은 실제 화면 이미지, Figma 구성 1920 / 1280 / 1024 / 820 |
| 화면 폭 변화 | 실제 조합 1920 / 1440 / 1280 / 950 / 860 / 760(▲ 확장 목록 포함) |
| 개별 부품 (조합용) | `SWFooterAuxGroup`(3·2열), `SWFooterMenu` + `SWFooterToolButton`, `SWFooterCommandLine` |
## 3. Figma·실제 화면과 구현의 차이
@@ -0,0 +1,121 @@
# SW Footer · Popup · Viewer 디자인 및 Storybook 정리 (2026-10-20)
범위: `feature/sw-package`, 기준 HEAD `2b0e1a2`. SW 패키지(`packages/sw`)와 SW Story(`src/sw`)만 변경했습니다. Web(UI)·OrgChart·CAD 렌더링·SW 기본 폰트 정책은 변경하지 않았습니다. Commit·Push 전(승인 대기). 같은 날 2차(Header·Footer·SWSelect)·3차(Footer 탭·Popup 컨트롤) 수정과 최종 통합 검증을 §8~§12에 이어 적었습니다.
## 1. Footer Story 정리
| Story (ID) | 처리 |
|---|---|
| `기본 Footer` (`sw-footer--default`) | 삭제. 화면(Figma 데이터만 넣은 `<SWFooter />` 1920 + Figma 26:10097 하단 렌더)은 `전체 Footer · 실제 조합`의 "전체 Footer — Figma 기본 구성" 섹션으로 이동 |
| `Hover / Active / Selected / Disabled` (`sw-footer--states`) | 삭제. 상태 셀(명령·빈 칸·분류·보조기능·측면 버튼의 Default / Hover·Active / Selected / Selected+Hover / Disabled)은 `전체 Footer · 실제 조합`의 "상태" 섹션으로, 보조기능 6종 Figma 비교(`aux-buttons/*.png`)는 숨김 `Figma 디자인별 Variant`로 이동. "하나만 선택" 데모(`AuxToggleDemo`)만 제거(보조기능 토글은 실제 조합에서 동작) |
| `전체 Footer · 실제 조합` (`sw-footer--full-footer`) | 메인. 섹션: 실제 Footer 조합(동작) → 전체 Footer(Figma 기본 구성) → 상태 → 실제 화면 참고. ID·이름 유지 |
| 나머지 (`tool-groups`, `figma-variants`, `custom-content`, `responsive`, `parts`) | 유지 |
- 삭제 Story만 쓰던 코드: `AuxToggleDemo`, Story CSS `.sw-footer-story-aux-demo` 제거. `FigmaFooter`·`StateCell`·`AUX_SETS`·`aux-buttons` 이미지는 이동한 섹션이 계속 사용합니다(Figma 기준 이미지 보존).
- Footer 컴포넌트 코드는 변경하지 않았습니다(Hover·Active·Selected·Disabled, 칸 영역 고정, ▲ 왼쪽부터 배치, 반응형, 공개 API 그대로).
- 참조 갱신: `docs/sw/sw-footer-figma-mapping-2026-10-20.md` §1·§2, `packages/sw/tests/theme-inheritance.browser.mjs`(폭별 검사 대상 `footer--default` → `footer--full-footer`), `storybook-inventory.mjs`(`retiredFooterIds`), `storybook-simplification.browser.mjs`(삭제 ID 검사 추가). 과거 증거 `docs/sw/storybook-simplification-qa/sidebar.json`은 당시 기록이라 수정하지 않았습니다.
## 2. Popup 안 SW Dropdown (레이어·색상) — **3차에서 철회, §10 참고**
> 2026-10-20 3차 사용자 확정: SWHeader 레이어·색상 Dropdown은 Header 전용입니다. 아래 1차 변경(필드 typography 규칙)은 제거했고, Popup의 레이어·색상은 속성창 컨트롤 `SWPopupPropertyRow`로 바꿨습니다.
- 원인: `.hm-sw-property`(Popup 프레임)가 `color: #000`(`--hm-sw-property-label`)·`line-height: 16px`·`letter-spacing: .02em`을 지정하고, Header 필드(`button.hm-sw-header__field`)는 SW 버튼 초기화(`color/font/letter-spacing: inherit`)로 이를 상속했습니다. Header 안에서는 `.hm-sw-header`의 `#F6F4F2`를 상속해서 문제가 없었고, Popup 안에서는 검정 글자 + 검정 외곽선(text-shadow)이 되어 글자가 뭉개졌습니다. 목록 패널은 이미 자기 색을 지정하고 있었습니다.
- 수정(`sw.css`): `:is(.hm-sw-header__field, .hm-sw-header__layer, .hm-sw-header__menu)`가 Header 글자색(`--hm-sw-header-text-color`)·글꼴 크기·400·`line-height/letter-spacing: normal`을 직접 지정. Header 안의 계산값은 이전과 같고(같은 값을 상속하던 것을 직접 지정), Hover·Selected·Expanded 규칙은 특이도가 더 높아 그대로 적용됩니다.
- 별도 컴포넌트·Story 전용 CSS 없음. `SWHeaderLayerMenu`·`SWHeaderColorMenu` 그대로 재사용, Popup 프레임·제목·닫기·고정 Overlay 동작 변경 없음.
- `레이어 속성 + 추가 콘텐츠`(SWHeaderPropertyPanel + children)의 레이어·색상은 속성창 자체 `picker` 디자인(Figma 속성창)이며 Header Dropdown이 아닙니다. 변경하지 않았습니다.
## 3. 투명도 게이지바 — `SWSlider`
| | 스타일 A `appearance="panel"`(기본) | 스타일 B `appearance="property"` |
|---|---|---|
| 출처 | 지금까지 SW Popup에서 쓰던 게이지바(`@hm-design/ui` Slider 실측) = `SWViewerSlider`와 같은 모양 | 속성창 4개 상태 비교의 게이지바(`SWHeaderPropertyPanel` slider) |
| 모양 | 6px 트랙 `#E0DACE`, Range `#266651`, 18px 흰 Thumb + 2px `#266651` | 6px 베이지 트랙 + 빨강 채움(Thumb 중심까지), Figma Thumb 14px + 그림자 |
| 토큰 | `--hm-sw-slider-panel-track` / `-range` / `-thumb` (기존 값만 사용) | `--hm-sw-property-range-fill` / `-fill-edge` / `-track` / `-highlight` (기존) |
- API: `value`(필수), `min`(0)·`max`(100)·`step`(1), `onValueChange(value)`, `onValueCommit(value)`(native `change`), `appearance`, `disabled`(40%), `input` 속성. 값·이벤트·키보드는 두 모양이 같습니다.
- 우선순위: ① `appearance` Prop → ② 기본 `"panel"`. 테마 연동은 토큰 범위(SWTheme `style`·Runtime 토큰 Override로 두 모양의 색 변경). 테마 전체의 기본 모양을 바꾸는 설정은 만들지 않았습니다.
- 중복 제거: 속성창 게이지바 CSS를 `:is(속성창 range, .hm-sw-slider[data-appearance="property"])`로 공유(속성창 86 × 16 그대로). `SWViewerSlider`는 `SWSlider appearance="panel"`을 렌더링하고 뷰어 토큰(`--hm-sw-viewer-panel-switch-on`, `--hm-sw-viewer-panel-background`)을 Slider 토큰에 연결합니다(모양·뷰어 테마 Override 동일, 내부 CSS 변수 `--hm-sw-viewer-slider-percent`는 제거).
- 기존 호환: 새 Export만 추가(`SWSlider`, `SWSliderProps`, `SWSliderAppearance`). `SWViewerSliderProps`·속성창 Props 변경 없음. Popup Story의 HM UI Slider 예제는 콘텐츠 자유 예시로 유지.
- Storybook: `SW/Popup → Custom Content`에 "투명도 게이지바 스타일" 섹션(A·B 비교 Popup, `appearance` 선택 Popup + Disabled).
## 4. Popup B (`SW/Popup → Multiple Popups`)
- 불일치 원인은 2절과 같습니다(레이어 필드 글자가 검정으로 뭉개짐). 2절 수정으로 해결.
- Popup B의 레이어 Dropdown이 고정값이라 선택해도 바뀌지 않던 것을 Popup A처럼 호스트 상태 + Callback(`b.layer=…`)으로 연결했습니다.
- 프레임(314, 면 `#E4DBC9`, 테두리 `#A67D03`, 제목 `#FFC600` 700, 제목줄 배경)·z-index(40, 목록 고정 Overlay 30)·Escape 스택은 A·C와 같습니다. 375px에서 페이지 가로 넘침 없음.
- `SWPopup`은 외형 고정 Non-modal 프레임이며 드래그·크기 조절·접기 기능이 없습니다(그 기능은 뷰어 `SWViewerPanel`). Popup B에 해당 기능을 추가하지 않았습니다.
## 5. Viewer 가변 크기 Popup 제목 배경
- 원인: 뷰어 패널은 Figma 제목 아트(`<img>`, 308px)를 오른쪽 정렬하고 왼쪽 20px을 `clip-path`로 숨긴 뒤, 남는 왼쪽을 `::before`(폭 `100% − 292px`)로 채웁니다. 호스트 전역 `img { max-width: 100% }`(Tailwind preflight, Storybook·소비 프로젝트)가 312px 미만 패널에서 아트를 패널 폭으로 줄였고, 그 결과 숨긴 20px이 패널 안으로 들어왔는데 `::before` 폭은 0이라 제목 앞 약 17px에 제목줄 녹색 배경이 드러났습니다.
- 수정(`sw-viewer.css`): 뷰어 패널 제목 아트에 `max-width: none`. Story 전용 보정 없음. 250 × auto: 아트 308px, -43px부터 표시(틈 0). 같은 문제가 `Popup Content Variants`의 300px 패널(약 10px)에도 있었고 같이 해결됩니다. 314px 이상 패널은 계산값 변화 없음.
- 최소 폭 250·자동 높이·접기/펼치기·드래그는 그대로입니다.
## 6. 검증
결과는 [footer-popup-viewer-refinement-qa/](./footer-popup-viewer-refinement-qa/) 참고. (아래 표는 2026-10-20 실제 실행 결과)
| 항목 | 결과 |
|---|---|
| `npm run typecheck`(전체 workspace, 패키지 build 포함) | PASS |
| `npm run build:sw` · `npm run build-storybook` | PASS |
| `npm run check:ai-docs` · `packages/sw/tests/ai-docs.check.mjs` | PASS · 37/37 |
| `footer-popup-viewer-refinement.browser.mjs`(신규) | **26/26 PASS**: Footer 삭제 ID·섹션·상태·칸 위치 고정·▲, Popup 필드 = Header 필드(색·면·테두리·높이·글자·외곽선·Hover·목록), SWSlider A/B·공통 값·Commit·선택·Disabled, 속성창 게이지 86 × 16, Popup B 프레임·선택·375px, Viewer 250 × auto 아트·접기·드래그, 300px 패널, SWViewerSlider 토큰 |
| `storybook-simplification.browser.mjs` | 9/9 PASS(삭제 Footer ID 검사 추가) |
| `theme-inheritance.browser.mjs` | 23/23 PASS(폭별 검사 대상 `footer--full-footer`) |
| `viewer.browser.mjs` | 70/70 PASS |
| `integrated-improvement.browser.mjs` | 61/62. FAIL 1 `resize: collapsed Open returns once…`(Header 반응형 Open 히스테리시스) — 이번 Dropdown 규칙을 뺀 빌드에서도 같은 trace로 실패해 **이번 변경과 무관**. 10-19 기록에서는 PASS였으므로 그 이후 변경에서 생긴 기존 문제로 별도 확인 필요 |
| `storybook-unused-removal.browser.mjs` | 93개 중 FAIL 5 — 10-20 간소화 보고와 같은 **기존** 스냅샷 비교 실패 |
| `range-cross-browser`, `property-panel-comparison` | 실행 불가(**기존**): 10-19 삭제된 Story(`sw-header--property-panel` 등)를 엶 |
| `viewer-regression.browser.mjs`(기준선 대비 픽셀) | 미실행 — 기준선 Storybook 빌드 필요. SWViewerSlider는 신규 검사(토큰·높이)와 viewer.browser 70/70으로 대신함 |
| Firefox 렌더링 | 미검증(Chrome만) |
## 7. 승인 필요
- `SWSlider` 신규 공개 API 이름·기본값(`appearance="panel"`)·`onValueCommit` 의미.
- 토큰 `--hm-sw-slider-panel-track/-range/-thumb` 추가(값은 기존 `#E0DACE`·`#266651`·`#FFFFFF`). 일반 모드 A 트랙은 HM UI Slider 실측 `#E0DACE`, 뷰어는 기존대로 `#E4DBC9`(뷰어 패널 면) — 의도된 차이 유지.
- 테마 전체 기본 `appearance` 설정은 만들지 않음(필요 시 별도 결정).
- `SWPopup`은 화면 폭 322px 미만에서 314px보다 좁아지면 제목 아트가 줄어듭니다(기존 동작, 이번 범위 밖).
## 8. 2차 — Header Open 히스테리시스 수정안 A (`SWHeader.tsx`, `sw.css`)
- 원인(별도 조사 세션, 첫 실패 Commit `170a5c2`): 넓어질 때 Print/K(640)·Color(900)·속성 블록(1080)이 고정 Breakpoint로 복귀하며 남은 공간을 음수로 만들고, 그때마다 Open이 먼저 More로 밀려남.
- 수정: ① 넓어지는 중 Breakpoint로 막 돌아온 부분은 측정 공간이 없으면 More에 hold(측정 공간이 생기면 복귀). 기본 레이아웃의 Print/K는 창 영역에 있어 hold 동안 창 영역만 640 미만 상태(`data-actions-held`). ② 넓어지는 중 복귀한 Open은 다른 부분 때문에 다시 밀리지 않음(후보가 없을 때만). ③ 좁아질 때는 Open 우선 이동 유지, 접힌 동안 더 좁아지면 복귀 기준 폭도 낮춤. ④ Open 복귀는 More 스택을 기다리지 않음(파일명 폭에서 가져감). ⑤ 마지막 항목 복귀 시 More 버튼이 사라지는 몫은 More를 잠시 빼고 실측. ⑥ 같은 폭에서 한 번 복귀한 항목은 다시 hold하지 않음(무한 업데이트 방지 — 중간 검증에서 `sw-header--responsive-more` React #185를 발견해 수정).
- Trace(테스트 조건 그대로 `[data-width="520"]` 프레임 520→1080): 수정 전 `[[520,0],[560,0],[600,1],[640,0],[680,0],[720,1],[760,1],[820,1],[900,0],[1080,0]]` → 수정 후 `[[520,0],[560,0],[600,1],[640,1],[680,1],[720,1],[760,1],[820,1],[900,1],[1080,1]]`(held: 640·680 Print/K, 900·1080 Color). 1920에서 모두 복귀, 1800으로 줄이면 Open 먼저 이동, 다시 넓히면 600에서 1회 복귀.
## 9. 2차 — Footer·SWSelect
- 토큰 `--hm-sw-footer-gra-border-orange`(Figma gra-border-orange, 기존 `--hm-sw-footer-aux-pressed-border` 값과 동일하며 그 토큰이 이를 참조). 전체보기 버튼 Hover·Pressed = 두 줄 `#FF5C00` + 주황 그라데이션 테두리. 분류 행 + Hover = 주황 + (`menu-add-hover.svg`). Layout + Hover = 그라데이션 면 + +. Model 선택·Hover, Layout(선택 아님) Hover = 그라데이션 면.
- Story 내부 5개 삭제: 전체 Footer — Figma 기본 구성 / 상태 / 참고: 실제 화면, 화면 폭 변화의 참고: 실제 좁은 화면 / Figma 기본 구성(명령창 없음). 기준 이미지 파일(`assets/footer-reference/*.png`)은 매핑 문서 기준이라 보존, Story import만 제거.
- `SWSelect`(신규): native select, 28px · 좌우 8 · 1px `#948871` · radius 4 · `#F1ECE1` · 12px 검정. Popup 도면 설정 단위(이전 HM UI Select)와 Viewer 축척 지정 단위(이전 Story 전용 CSS `.sw-viewer-story-select`, 제거)에 적용 — Default·Hover·Focus 계산값 차이 0.
- 미사용 자산(참조 0, Figma 원본 상태라 보존): `tab-model-on.svg`, `tab-slant-hover.svg`, `tab-add-hover.svg`.
## 10. 3차 — Footer 탭·Popup 컨트롤
- Footer 두 번째 분류 탭 배경 갈라짐: 원인은 탭 위치가 아니라 `bg_vacant`(마지막 명령 뒤 어두운 면)가 분류 행 아래까지 내려와 있던 것. 선택 탭의 Pocket은 위가 열린 모양이라 뒤가 비치고, `수정`(명령 1개)의 Pocket이 68px 칸 경계를 가로질러 왼쪽은 그룹 면·오른쪽은 vacant 면으로 갈라짐. vacant를 명령 칸 높이(46px)까지만 그려 모든 탭의 Pocket이 같은 그룹 면을 보임(도형·수정·편집·문자 검증). **영향:** 명령이 적은 분류(편집 등)의 Pocket 면이 이전의 어두운 vacant에서 그룹 면으로 바뀜 — 디자인 확인 권장.
- Model·Layout 텍스트: Model 선택·Hover와 Layout Hover의 글자를 Default와 같게(`#161410`, 12px, 400, 외곽선 없음, 위치·크기 불변). 이전 2차의 흰 글자·외곽선 규칙 대체. 선택된 Layout(갈색 면, 흰 글자 + 외곽선)과 이름 편집 상태는 그대로.
- Popup의 Header 전용 Dropdown 제거: 원인은 1차에서 Popup 안 `SWHeaderLayerMenu`·`SWHeaderColorMenu`를 Header처럼 보이게 한 `:is(.hm-sw-header__field, …__layer, …__menu)` typography 규칙(제거). Popup 전용 기준은 속성창 picker(`SWHeaderPropertyPickerField`, Figma 26:12250 / 26:12170)이며, 이를 SWPopup 안에서 쓰도록 공개 행 `SWPopupPropertyRow`(속성창 `PropertyEditor` 그대로)를 추가. 도면 설정·Popup B·AI Example E에 적용. 속성창 행과 계산값 동일, Popup 안 `.hm-sw-header__field` 0개, Header 필드는 변경 없음.
## 11. 최종 검증 (2026-10-20 15:00, Storybook static 재빌드 기준)
| 항목 | 결과 |
|---|---|
| `npm run typecheck`(workspace 전체) · `build-storybook` · `build:sw` | PASS |
| `check:ai-docs` · `packages/sw/tests/ai-docs.check.mjs` | PASS · 37/37 |
| `footer-popup-viewer-refinement.browser.mjs` | **47/47 PASS** (증거 [footer-popup-viewer-refinement-qa/](./footer-popup-viewer-refinement-qa/)) |
| `storybook-simplification` | 9/9 PASS |
| `integrated-improvement` (options) / (popup) | 61/61 / 26/26 PASS. 기본 섹션의 `tool`은 10-19 삭제된 `sw-tool--tool-buttons`를 열어 중단되던 **기준선 문제** → 기본 섹션에서 제외(이름으로는 계속 실행 가능) |
| `theme-inheritance` · `viewer.browser` | 23/23 · 70/70 PASS |
| `integrated-consumer`(npm pack → React 19 + Vite + Tailwind 4 새 프로젝트, tsc + vite build) + 브라우저 | build PASS, 46/46 PASS |
| `home-menu-integration` | 22/23, FAIL 1(`without homeMenu: logo click…`) = **기준선**(47972c5에서도 동일) |
| `storybook-unused-removal` | 93 중 FAIL 5 = **기준선**(10-19 inventory·source 스냅샷 비교). 중간 7건 중 추가 2건은 위 무한 업데이트였고 수정 후 5건으로 복귀 |
| `range-cross-browser`, `property-panel-comparison`, `followup-four`, `property-fields` | 실행 불가 = **기준선**(10-19 삭제 Story를 엶) |
| 실제 소비 프로젝트 `D:디자인시스템HMDesignSwTest-viewer`(Submodule `68fbcf6` + Workspace) | 사용 중인 SW API 14개 모두 현재 패키지에 존재(소스 호환). 해당 폴더에 미커밋 작업 20개·detached HEAD가 있어 Submodule 갱신·실행 검증은 하지 않음 — **미검증, 승인 필요** |
| Firefox, `viewer-regression`(기준선 픽셀 비교) | 미실행 |
## 12. 남은 항목 · 승인 필요
- SW 기본 글꼴: 현재 `--hm-sw-header-font-family: "Noto Sans KR", …`(`sw.css` 1곳), AI 문서는 "Dotum 기본 적용 승인 대기". 사용자 기준(SW = 돋움)과 다름. 바꾸면 SW 전체 글자 폭이 달라져 Header More 측정·Footer 칸·Popup 행 폭에 영향 → 별도 승인 후 토큰 1곳 변경 + 문서 4곳 + 회귀 검증 계획.
- 그라데이션 중복: Home Menu `--hm-sw-home-menu-item-hover-border`는 같은 색의 반올림 값(8.5/37.9/71.9/92.8%). 다른 컴포넌트의 승인 값이라 통합하지 않음(검토 필요).
- 디자인 확인: 전체보기 Hover 테두리, 분류 + Hover, Model 기본(선택 아님) 베이지 유지, 명령이 적은 분류의 Pocket 면 변화.
+2
View File
@@ -1,6 +1,8 @@
# @hm-design/sw
> **0.2.0 통합 개선(2026-10-19):** 패키지에 포함된 AI 안내 [ai/AI_USAGE.md](./ai/AI_USAGE.md)·[카탈로그](./ai/COMPONENT_CATALOG.md)가 공개 API의 기준입니다. 추가: `SWPopup` / `SWPopupSection` / `SWPopupRow`(속성창 외형 + `children` 자유 콘텐츠, 여러 Popup Escape 스택, Popup 안 Dropdown 고정 Overlay), `SWHeaderPropertyPanel`의 `children`·`title`·`autoFocus`와 선택형 `selection`/`properties`/`groups`, `SWToolButton` / `SWToolGroup`(기존 글자 토글·pill·파일명 면 재사용), `SWHeader`의 `documentOpenPlacement`(로고 오른쪽 Open, 실측 기반 More 이동)·`leading`, `hiddenControls`의 `documentNew`·`documentOpen`·`layerFreeze`·`layerLock`, `showNew`/`showOpen`/`showFreeze`/`showLock`. 모두 선택 Props이며 기존 화면은 그대로입니다. 빌드는 줄바꿈 정규화로 재현 가능하고 `dist/build-info.json`에 출처 Commit·도구 버전을 남깁니다. 검증 기록: 저장소 `docs/sw/sw-integrated-improvement-2026-10-19.md`.
>
> **2026-10-20:** `SWSlider` 추가(게이지바, `appearance` `"panel"` 기본 / `"property"` 속성창 모양, 값·이벤트 공통). `SWSelect`(native 선택 목록, Popup·Viewer 공통), `SWPopupPropertyRow`(Popup 안 레이어·색상 = 속성창 picker, SWHeader 필드 Dropdown은 Header 전용) 추가. SWHeader 넓어질 때 More 복귀 히스테리시스 수정(Open 재이동 없음). Footer Model·Layout 탭 주황 그라데이션·전체보기 Hover, 선택 분류 Pocket 면 통일. 뷰어 패널 312px 미만 폭의 제목 배경 누락 수정. 기록: 저장소 `docs/sw/sw-footer-popup-viewer-refinement-2026-10-20.md`.
> **로고 클릭 메뉴(2026-10-19):** `SWHomeMenu` / `SWHomeMenuSection` / `SWHomeMenuItem` / `SWRecentFiles`(Figma 26:10591 2단 Header 메뉴, 최근파일 하위 패널)와 `SWHeader`의 `homeMenu`·`homeMenuOpen`·`defaultHomeMenuOpen`·`onHomeMenuOpenChange`. 기본 숨김 → 로고 클릭 열기 → 다시 클릭 닫기, 위치는 Header DOM 측정. `homeMenu`가 있으면 로고 클릭은 `onHomeClick`을 호출하지 않습니다. 모두 선택 Props이며 주지 않으면 기존 화면 그대로입니다([카탈로그 5절](./ai/COMPONENT_CATALOG.md), [예제 G](./ai/examples/ExampleG-HomeMenu.tsx)). 검증 기록: 저장소 `docs/sw/sw-home-menu-integration-2026-10-19.md`.
+1 -1
View File
@@ -41,7 +41,7 @@ SW 패키지에는 Header·Popup 계열이 두 벌 있습니다. 화면마다 **
| 기준 | Figma 이지빔 SW `26:8944`, 49px | 참고 DWG/DXF 뷰어 실측(1440px), 52px |
| Header | `SWHeader`, 또는 `<SWTheme>` 안의 개별 컨트롤(`SWHeaderHome`, `SWHeaderDocumentTitle`, `SWHeaderLayerMenu`, `SWHeaderColorMenu`, `SWHeaderLineStyleMenu`, `SWHeaderTextStyleMenu`, `SWHeaderDimensionStyleMenu`, `SWHeaderActions`, `SWHeaderWindowControls`, `SWHeaderProperties` …) | `SWViewerHeader`(`groups` 데이터 + `trailing`), 오른쪽 끝·`custom` 버튼 `SWViewerHeaderButton`, 상태 `SWViewerStatus` |
| Dropdown | 각 필드 Dropdown(`SWHeaderLayerMenu` …) | `type: "menu"` 항목, 단독·우클릭은 `SWViewerMenu` |
| 팝업·설정창 | `SWPopup`(`titleActions`, `footer`) + `SWPopupSection` / `SWPopupRow` + `SWHeaderInput`, 속성창 `SWHeaderPropertyPanel`, 속성Bar `SWHeaderPropertyBar` | `SWViewerPanel`(이동·크기 조절·접기·`modal`, `headerActions`, `footer`) + `SWViewerSection` / `SWViewerField` / `SWViewerSwitch` / `SWViewerSlider` / `SWViewerInput` / `SWViewerButton` / `SWViewerBadge` / `SWViewerHint` |
| 팝업·설정창 | `SWPopup`(`titleActions`, `footer`) + `SWPopupSection` / `SWPopupRow` + `SWHeaderInput`, 레이어·색상 행 `SWPopupPropertyRow`(Header 필드 Dropdown은 Header 전용), 게이지바 `SWSlider`(`appearance`), 선택 목록 `SWSelect`, 속성창 `SWHeaderPropertyPanel`, 속성Bar `SWHeaderPropertyBar` | `SWViewerPanel`(이동·크기 조절·접기·`modal`, `headerActions`, `footer`) + `SWViewerSection` / `SWViewerField` / `SWViewerSwitch` / `SWViewerSlider` / `SWViewerInput` / `SWViewerButton` / `SWViewerBadge` / `SWViewerHint`, 선택 목록 `SWSelect`(뷰어 패널 토큰으로 표시) |
| 로고(홈) 버튼 메뉴 | `SWHeader`의 `homeMenu`(`SWHomeMenu` 데이터, 최근파일 `SWRecentFiles`, Figma 26:10591). 기본 숨김 → 로고 클릭 열기 → 다시 클릭 닫기 | **사용하지 않음.** `SWViewerHeader`에는 홈 메뉴 Props가 없고 로고 클릭은 `onHomeClick`만 호출합니다. 파일 열기·샘플은 Header `groups`의 `button`·`menu` 항목 |
| 하단 Footer | `SWFooter`(+ 바 안 명령창 `command={<SWFooterCommandLine />}`, 좌표 `status`, `SWFooterLayoutTabs`, 부품 `SWFooter*`), [FOOTER.md](./FOOTER.md) | **사용하지 않음.** 뷰어 화면에는 하단 명령 막대가 없습니다. 도면 공간 탭은 `SWViewerHeader`의 `tabs` 항목 |
| 버튼·탭·토글 | `SWToolButton` / `SWToolGroup` | Header 항목 `type: "button" \| "toggle" \| "tabs" \| "switch"`, 패널 안 `SWViewerButton` |
+6 -3
View File
@@ -8,7 +8,7 @@
- **공통 키보드:** Click·Enter·Space 열기/닫기(Hover로는 열리지 않음), ArrowDown 열고 첫(현재) 항목 Focus, Arrow 이동, Escape·바깥 클릭·Tab 이탈 닫기(Escape는 Trigger로 Focus 복귀).
- **검증 표시:** ✅ = 2026-10-19 독립 소비 프로젝트(Tarball 설치)에서 브라우저 검증, ◻ = 소비 검증 범위 밖(Storybook 검증 기록만 있음).
- **먼저 모드:** SW 일반 모드(편집) / SW 뷰어 모드(도면 보기)를 [AI_USAGE.md](./AI_USAGE.md) §0으로 정합니다. **뷰어 모드는 §9(`SWViewer*`)만** 쓰고, 아래 §1~8은 일반 모드용입니다. 두 모드를 한 화면에 섞지 않습니다.
- **무엇을 쓸까(일반 모드):** Header 전체 → `SWHeader`(1). Header 부품 조합 → 개별 컨트롤(2). 팝업·설정창·정보창 → `SWPopup`(3). 도구 막대·탭·토글 → `SWToolButton` / `SWToolGroup`(4). 로고 클릭 2단 메뉴·최근파일 → `SWHeader homeMenu` / `SWHomeMenu`(5). Slider·Switch·Badge·Progress·Dialog(모달)·Label·FilterBar는 SW에 없습니다 → `@hm-design/ui`를 그대로 씁니다(필요하면 `SWPopup` children 안에).
- **무엇을 쓸까(일반 모드):** Header 전체 → `SWHeader`(1). Header 부품 조합 → 개별 컨트롤(2). 팝업·설정창·정보창 → `SWPopup`(3). 도구 막대·탭·토글 → `SWToolButton` / `SWToolGroup`(4). 로고 클릭 2단 메뉴·최근파일 → `SWHeader homeMenu` / `SWHomeMenu`(5). 게이지바(투명도 등)는 `SWSlider`, 선택 목록(단위 등)은 `SWSelect`(3). Switch·Badge·Progress·Dialog(모달)·Label·FilterBar는 SW에 없습니다 → `@hm-design/ui`를 그대로 씁니다(필요하면 `SWPopup` children 안에).
- **무엇을 쓸까(일반 모드 하단):** 하단 명령 막대(보조기능·명령 칸·분류 탭·명령어 전체보기) → `SWFooter`, 도면 공간 탭(Model/Layout) → `SWFooterLayoutTabs`, 명령창 → `SWFooterCommandLine`(10, [FOOTER.md](./FOOTER.md)).
- **무엇을 쓸까(뷰어 모드):** Header → `SWViewerHeader`(`groups` + `trailing`), Dropdown·우클릭 → `SWViewerMenu`, 팝업·설정창·모달 → `SWViewerPanel` + `SWViewerSection`/`SWViewerField`/`SWViewerSwitch`/`SWViewerSlider`/`SWViewerInput`/`SWViewerButton`, 상태 → `SWViewerStatus`(9).
@@ -72,6 +72,9 @@ SW 공식 기준 Header(Figma 26:8944, 1920 × 49). 로고 · 파일 · 객체
| `SWPopup` | 설정·정보·사용자 정의 Popup 프레임 | `title`(필수), `children`, `onClose`(닫기 버튼·Escape), `closeLabel`(기본 `닫기`), `titleActions`(제목 막대 닫기 버튼 앞 추가 버튼), `footer`(본문 뒤 고정 영역, 본문이 스크롤해도 보임), `autoFocus`(열릴 때 Focus, 안에서 닫히면 원래 위치로 복귀. 기본: `anchorRef`가 있으면 true, 없으면 false), `anchorRef`(기준 요소 아래 고정 Overlay), `style`·`className`·`div` 속성 | 폭 314, 제목줄 30, 높이는 콘텐츠(최대 화면 − 8px, `style.maxHeight`로 더 줄일 수 있음) → 본문 스크롤. Non-modal(Dim·Portal·Focus trap 없음) | ✅ |
| `SWPopupSection` | 속성창 그룹 면(제목 + 내용) | `title`, `children`, `section` 속성 | 그룹 제목 700·70% 검정, 내용 면 `--hm-sw-property-content` | ✅ |
| `SWPopupRow` | 속성창 한 줄(라벨 + 값) | `label`(필수), `htmlFor`, `children`(값 컨트롤, SW 필드는 `SWHeaderInput`), `unit`, `field`(`box` 기본 · `compact` · `none`), `numeric`(숫자 편집 On), `readOnly`, `disabled` | 행 최소 32, 라벨 72, 값 상자 28(Default·Hover 테두리, 숫자 편집 시 #0F0D0A). `compact` = 41px hug 숫자 필드 | ✅ |
| `SWSlider` | 게이지바(native range, 2026-10-20). `SWPopupRow field="none"` 안이나 `SWTheme` 안 어디든 | `value`(필수), `min`(0)·`max`(100)·`step`(1), `onValueChange`(드래그·키보드마다), `onValueCommit`(값 확정 = native `change`), `appearance`(`"panel"` 기본 · `"property"`), `disabled`, `aria-label`, `input` 속성 | 모양만 다르고 값·이벤트·키보드는 같음. `panel` = 지금까지 Popup에서 쓰던 게이지바(6px 트랙 · 18px 흰 Thumb + 2px 녹색 테두리, `SWViewerSlider`와 같은 모양), `property` = 속성창 게이지바(베이지 트랙 · 빨강 채움 · Figma Thumb 14px). 폭은 행을 채움(호스트가 `style.width`로 지정 가능). Disabled 40% | ◻ |
| `SWPopupPropertyRow` | Popup 안 레이어·색상·선스타일 등 속성창 한 줄(2026-10-20) | `property`(`SWHeaderProperty`: `name`·`value`·`type`, 레이어는 `picker: "layer"` + `options`, 색상은 `type: "color"`), `menus`(`SWHeaderPropertyMenus`: `layers`·`colors`·…), `onPropertyChange(property, value)`, `className` | `SWHeaderPropertyPanel` 행과 같은 DOM·모양·동작(속성창 `picker`, 고정 Overlay 목록). **SWHeader 필드 Dropdown(`SWHeaderLayerMenu` 등)을 Popup에 넣지 않습니다** | ◻ |
| `SWSelect` | 선택 목록(native select, 2026-10-20). `SWPopupRow field="none"`, `SWViewerField`, `SWTheme` 안 어디든 | `value`/`defaultValue`, `options`(`SWSelectOption[]` = `{ value, label?, disabled? }`) 또는 `<option>` children, `onValueChange(value)`, `disabled`, `id`·`aria-label`, `select` 속성 | 28px, 좌우 8, 1px `#948871`, radius 4, 면 `#F1ECE1`, 12px 검정, 플랫폼 화살표·옵션 목록. Popup 도면 설정 단위 = Viewer 축척 지정 단위. Disabled 40% | ◻ |
| `SWHeaderPropertyPanel` | 데이터 기반 속성창(2절). 같은 프레임이며 `children`도 받음 | 2절 | 같은 DOM·class | ✅ |
- **Escape·Focus:** Popup 안의 Escape는 그 Popup만 닫습니다. 바깥의 Escape는 가장 최근에 열리거나 사용한(Focus·클릭) Popup 하나만 닫습니다. Dropdown·편집 중인 Input처럼 Escape를 이미 처리한 요소(`preventDefault` / 전파 중단)가 있으면 Popup은 닫히지 않습니다.
@@ -111,7 +114,7 @@ Figma 26:10591(home on), 타일 15종 26:11355 … 26:11631, 섹션 제목 26:12
## 6. 공개 타입
`SWHeaderProps`, 각 컴포넌트 Props(`SWHeaderHomeProps`, `SWHeaderDocumentTitleProps`, `SWHeaderLayerControlProps`, `SWHeaderLayerMenuProps`, `SWHeaderColorMenuProps`, `SWHeaderLineStyleMenuProps`, `SWHeaderTextStyleMenuProps`, `SWHeaderDimensionStyleMenuProps`, `SWHeaderInputProps`, `SWHeaderPropertiesProps`, `SWHeaderPropertyPanelProps`, `SWHeaderPropertyBarProps`, `SWHeaderActionsProps`, `SWHeaderWindowControlsProps`), `SWThemeProps`, `SWPopupProps`, `SWPopupSectionProps`, `SWPopupRowProps`, `SWToolButtonProps`, `SWToolButtonAppearance`, `SWToolGroupProps`, `SWHeaderMenuId`, `SWHeaderControlId`, `SWHeaderControlConfig`, `SWHeaderFrameControlId`, `SWHeaderSubControlId`, `SWHeaderHideableControlId`, `SWHeaderDocument`, `SWHeaderLayer`, `SWHeaderLayerValue`, `SWHeaderColorValue`, `SWHeaderColorOption`, `SWHeaderColorMode`, `SWHeaderColorInputValue`, `SWHeaderLineStyleValue`, `SWHeaderLineType`, `SWHeaderLineWeight`, `SWHeaderTextStyleValue`, `SWHeaderTextStyleOption`, `SWHeaderDimensionStyleValue`, `SWHeaderDimensionStyleOption`, `SWHeaderInputValueType`, `SWHeaderProperty`, `SWHeaderPropertyType`, `SWHeaderPropertyPicker`, `SWHeaderPropertyMenus`, `SWHeaderPropertyGroup`, `SWHeaderPropertySelection`, `SWHeaderPropertyShapeContent`, `SWHeaderPropertyShapeContext`, `SWHeaderHomeMenu`, `SWHeaderHomeMenuChangeReason`, `SWHomeMenuProps`, `SWHomeMenuSectionProps`, `SWHomeMenuItemProps`, `SWHomeMenuItemData`, `SWHomeMenuSectionData`, `SWHomeMenuSelectEvent`, `SWHomeMenuCloseReason`, `SWHomeMenuAccent`, `SWRecentFilesProps`, `SWFileEntry`. 뷰어 모드 타입은 §9.
`SWHeaderProps`, 각 컴포넌트 Props(`SWHeaderHomeProps`, `SWHeaderDocumentTitleProps`, `SWHeaderLayerControlProps`, `SWHeaderLayerMenuProps`, `SWHeaderColorMenuProps`, `SWHeaderLineStyleMenuProps`, `SWHeaderTextStyleMenuProps`, `SWHeaderDimensionStyleMenuProps`, `SWHeaderInputProps`, `SWHeaderPropertiesProps`, `SWHeaderPropertyPanelProps`, `SWHeaderPropertyBarProps`, `SWHeaderActionsProps`, `SWHeaderWindowControlsProps`), `SWThemeProps`, `SWPopupProps`, `SWPopupSectionProps`, `SWPopupRowProps`, `SWPopupPropertyRowProps`, `SWSliderProps`, `SWSliderAppearance`(`"panel"` / `"property"`), `SWSelectProps`, `SWSelectOption`, `SWToolButtonProps`, `SWToolButtonAppearance`, `SWToolGroupProps`, `SWHeaderMenuId`, `SWHeaderControlId`, `SWHeaderControlConfig`, `SWHeaderFrameControlId`, `SWHeaderSubControlId`, `SWHeaderHideableControlId`, `SWHeaderDocument`, `SWHeaderLayer`, `SWHeaderLayerValue`, `SWHeaderColorValue`, `SWHeaderColorOption`, `SWHeaderColorMode`, `SWHeaderColorInputValue`, `SWHeaderLineStyleValue`, `SWHeaderLineType`, `SWHeaderLineWeight`, `SWHeaderTextStyleValue`, `SWHeaderTextStyleOption`, `SWHeaderDimensionStyleValue`, `SWHeaderDimensionStyleOption`, `SWHeaderInputValueType`, `SWHeaderProperty`, `SWHeaderPropertyType`, `SWHeaderPropertyPicker`, `SWHeaderPropertyMenus`, `SWHeaderPropertyGroup`, `SWHeaderPropertySelection`, `SWHeaderPropertyShapeContent`, `SWHeaderPropertyShapeContext`, `SWHeaderHomeMenu`, `SWHeaderHomeMenuChangeReason`, `SWHomeMenuProps`, `SWHomeMenuSectionProps`, `SWHomeMenuItemProps`, `SWHomeMenuItemData`, `SWHomeMenuSectionData`, `SWHomeMenuSelectEvent`, `SWHomeMenuCloseReason`, `SWHomeMenuAccent`, `SWRecentFilesProps`, `SWFileEntry`. 뷰어 모드 타입은 §9.
## 7. 공개하지 않은 것 (사용 금지)
@@ -142,7 +145,7 @@ SW **뷰어 모드** 전용 Export입니다(2026-10-19 추가, 참고 DWG/DXF
| `SWViewerSection` | 패널 콘텐츠 묶음(12px 700 제목) | `title`, `surface`(`card` 기본 / `plain`), `children`, `section` 속성 | ◻ |
| `SWViewerField` | 라벨 + 컨트롤 행 | `label`(필수), `children`(노드 또는 `(id) => 노드`), `htmlFor`, `layout`(`inline` 기본 / `stacked`), `unit`, `below`, `disabled` | ◻ |
| `SWViewerSwitch` | 스위치 | `checked`(필수), `onCheckedChange`, `button` 속성 | ◻ |
| `SWViewerSlider` | 슬라이더(native range) | `value`·`min`·`max`(필수), `step`, `onValueChange`, `input` 속성 | ◻ |
| `SWViewerSlider` | 슬라이더(native range). 내부는 `SWSlider appearance="panel"` + 뷰어 패널 토큰(2026-10-20, 모양 동일) | `value`·`min`·`max`(필수), `step`, `onValueChange`, `input` 속성 | ◻ |
| `SWViewerInput` | 28px 패널 Input(native input) | `input` 속성 전체 | ◻ |
| `SWViewerButton` | 패널 버튼 | `variant`(`outline` / `primary` / `ghost`), `tone`(`"green"`), `icon`, `button` 속성 | ◻ |
| `SWViewerBadge` | 개수 Badge | `children`(필수), `span` 속성 | ◻ |
+6 -1
View File
@@ -87,7 +87,7 @@
1. 카탈로그 3절에서 `SWPopup`을 찾는다(프레임 = 속성창 외형·닫기·Escape·스크롤).
2. 내용은 `children`으로 구성한다. 행 모양은 `SWPopupSection` + `SWPopupRow`, 값 입력은 `SWHeaderInput`.
3. SW에 없는 컨트롤(Switch, Slider, Select, Button 등)은 `@hm-design/ui`를 `children` 안에 그대로 둔다. 구조 제한은 없다.
3. 레이어·색상·선스타일은 `SWPopupPropertyRow`(속성창 `picker`)로 둔다. SWHeader 필드 Dropdown(`SWHeaderLayerMenu`·`SWHeaderColorMenu`)은 Header 전용이라 Popup에 넣지 않는다. 게이지바는 `SWSlider`(`appearance` `"panel"` 기본 / `"property"` 속성창 모양), 선택 목록은 `SWSelect`를 `SWPopupRow field="none"`에 둔다. SW에 없는 컨트롤(Switch, Button 등)은 `@hm-design/ui`를 `children` 안에 그대로 둔다. 구조 제한은 없다.
4. 프레임(폭, 제목줄, 닫기, 표면색)을 다시 만들지 않고, `hm-sw-property*` class를 쓰지 않는다.
```tsx
@@ -97,6 +97,11 @@
<SWPopupRow label="이름" htmlFor={id}><SWHeaderInput id={id} value={name} onCommit={setName} /></SWPopupRow>
<SWPopupRow label="축척" unit="%" numeric><SWHeaderInput aria-label="축척" valueType="number" value={scale} onCommit={setScale} /></SWPopupRow>
</SWPopupSection>
<SWPopupSection title="표시">
<SWPopupRow label="단위" htmlFor={unitId} field="none"><SWSelect id={unitId} value={unit} options={[{ value: "mm" }, { value: "m" }]} onValueChange={setUnit} /></SWPopupRow>
<SWPopupPropertyRow property={{ id: "layer", name: "레이어", value: layerId, type: "select", groupId: "g", picker: "layer", options }} menus={{ layers }} onPropertyChange={(_, id) => setLayerId(id)} />
<SWPopupRow label="투명도" field="none" unit="%"><SWSlider aria-label="투명도" value={opacity} onValueChange={setOpacity} onValueCommit={applyOpacity} /></SWPopupRow>
</SWPopupSection>
<MyProjectFields />
</SWPopup>
</SWTheme>
+12 -8
View File
@@ -84,7 +84,7 @@
Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타일 → More, **< 1180** 그리기모드 숨김, **< 1080** 상세보기 → More·창 이동 24px(창 영역 176), **< 900** 색상 → More, **< 640** 인쇄·K → More·창 이동 숨김(창 영역 64), **< 520** 레이어 상태 아이콘 숨김. More에서 고른 필드는 첫 칸(레이어 자리)에 표시되고 Dropdown이 바로 열립니다(UI 상태만, 데이터 Callback 없음).
실측 More(2026-10-19): Breakpoint 위에서 객체 수정 영역의 실제 남은 공간을 측정해, `leading`·`tools`·긴 라벨 등으로 표시할 수 없는 메뉴만 같은 단계 순서(선/문자/치수 → 그리기모드 → 속성 블록 → 색상 → 구성형의 인쇄·K, 같은 단계는 오른쪽부터)로 More에 옮기고 공간이 생기면 역순으로 돌려놓습니다. 로고 옆 Open이 가장 먼저 옮겨집니다. 메뉴 열기·닫기는 Header 요소의 위치·폭을 바꾸지 않습니다. 호스트 요소(`leading`·`tools`)는 More로 옮기지 않으므로 폭이 좁은 화면에는 많이 넣지 않습니다.
실측 More(2026-10-19): Breakpoint 위에서 객체 수정 영역의 실제 남은 공간을 측정해, `leading`·`tools`·긴 라벨 등으로 표시할 수 없는 메뉴만 같은 단계 순서(선/문자/치수 → 그리기모드 → 속성 블록 → 색상 → 구성형의 인쇄·K, 같은 단계는 오른쪽부터)로 More에 옮기고 공간이 생기면 역순으로 돌려놓습니다. 로고 옆 Open이 가장 먼저 옮겨집니다(좁아질 때). 넓어질 때(2026-10-20 수정안 A): Breakpoint로 막 돌아온 부분(인쇄·K, 색상, 속성 블록, 선/문자/치수, 그리기모드)은 실제 남은 공간이 있을 때만 돌아오고 없으면 More에 남으며, 다시 나타난 Open은 넓어지는 동안 다른 부분 때문에 More로 돌아가지 않습니다. 충분히 넓으면 모두 돌아옵니다. 메뉴 열기·닫기는 Header 요소의 위치·폭을 바꾸지 않습니다. 호스트 요소(`leading`·`tools`)는 More로 옮기지 않으므로 폭이 좁은 화면에는 많이 넣지 않습니다.
로고 클릭 메뉴는 좁은 폭에서는 가로 스크롤 없이 들어가는 섹션만 앞에서부터 보여 주고, 나머지는 끝의 더보기(⋯) 섹션으로 옮깁니다(2026-10-19 사용자 확정, Figma에 좁은 폭 디자인 없음). 더보기를 누르면 옮긴 섹션이 텍스트 메뉴(섹션 제목 + 기능 텍스트 목록, 그룹 사이 구분선)로 아래 Dropdown에 열리고, 폭이 넓어지면 원래 자리로 돌아갑니다. 화면에 남은 섹션은 Figma 타일 그대로입니다(§12).
@@ -102,11 +102,14 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
| 본문 | padding 7px, 그룹 사이 8px | — |
| 본문 스크롤바 | `thin`, Thumb `#9A9284` / Track `#E0DACE`(DWG/DXF Viewer 패널과 동일). 본문과 그 안의 스크롤 영역에 적용, 페이지 스크롤바는 그대로 | `--hm-sw-popup-scrollbar-thumb`, `--hm-sw-popup-scrollbar-track` |
| 그룹 면(`SWPopupSection`) | 제목 700 · 검정 70%, 내용 면 `#F1ECE1`, 1px `#585349`, radius 4px | `--hm-sw-property-content` |
| 속성 행(`SWPopupPropertyRow`) | 속성창 행 그대로(라벨 72px 700, 값 상자 28px, `picker` 필드). 데이터 = `SWHeaderProperty` + `menus` | `--hm-sw-property-*`, `--hm-sw-property-field-*` |
| 행(`SWPopupRow`) | 최소 32px, 라벨 72px 700 검정, 값 글자 `#E60403` 500, 단위 12px | `--hm-sw-property-label`, `--hm-sw-header-input-color` |
| 값 상자 | 28px, Hover 1px `rgba(33,29,19,.4)`, 숫자 편집 On `#0F0D0A`(단위 흰색), Dropdown On `#211D13` | `--hm-sw-property-field-*` |
| 동작 | Non-modal(Dim·Portal·Focus trap 없음). Escape는 Popup 안이면 그 Popup, 밖이면 최근 사용 Popup 하나. `autoFocus`면 열릴 때 Focus, 안에서 닫히면 원래 위치로 | — |
- 밝은 Popup 면에는 SW Header의 어두운 컨트롤(필드·`SWToolButton`)이 디자인상 맞지 않습니다. 쓸 수는 있지만(Dropdown은 고정 Overlay로 잘리지 않음) 행 모양이 필요하면 `SWPopupRow` + `SWHeaderInput` 또는 속성창 `picker`를 씁니다.
- **Header 필드 Dropdown(`SWHeaderLayerMenu`·`SWHeaderColorMenu`·선/문자/치수 스타일)은 SWHeader 전용 디자인입니다.** Popup 안에 넣지 않습니다(2026-10-20 사용자 확정). Popup의 레이어·색상·선스타일은 속성창 컨트롤 `SWPopupPropertyRow`(속성창과 같은 `picker` 필드: 레이어 26:12250, 색상 26:12170, 선스타일 26:12388 — 값 `#E60403`, Hover 1px `rgba(33,29,19,.4)`, On `#211D13`, 목록은 고정 Overlay)를 씁니다. 그 밖의 행은 `SWPopupRow` + `SWHeaderInput`.
- 선택 목록(단위 등)은 `SWSelect`입니다(native select, 28px · 좌우 8 · 1px `#948871` · radius 4 · 면 `#F1ECE1` · 12px 검정, 플랫폼 화살표·옵션 목록). 뷰어 축척 지정 팝업과 같은 모양이고 뷰어 패널 안에서는 `--hm-sw-viewer-panel-*` 값을 따릅니다. 토큰 `--hm-sw-select-border-color`·`-background`·`-text`.
- 게이지바는 `SWSlider`입니다. 모양 선택 우선순위: ① `appearance` Prop(명시) → ② 기본 `"panel"`. 테마 연동은 토큰 범위입니다: `panel` = `--hm-sw-slider-panel-track`(`#E0DACE`)·`-range`(`#266651`)·`-thumb`(`#FFFFFF`), `property` = `--hm-sw-property-range-fill`·`-fill-edge`·`-track`·`-highlight`(속성창과 같은 토큰). `SWTheme style`·Runtime 토큰 Override로 두 모양의 색이 함께 바뀌며, 테마 전체에서 기본 `appearance`를 바꾸는 설정은 없습니다. 새 색을 만들지 않습니다.
- Popup 하단 버튼의 SW 디자인은 없습니다. 임의 색·크기로 만들지 말고 `@hm-design/ui` `Button` 등 기존 컴포넌트를 쓰거나 사용자에게 디자인을 확인합니다.
## 9. 도구 버튼 (`SWToolButton` / `SWToolGroup`)
@@ -183,21 +186,22 @@ Container `hm-sw-header` 기준 Breakpoint: **< 1800** 선/문자/치수스타
|---|---|---|
| 바 | 높이 72, 배경 `#060606` | `--hm-sw-footer-height`, `-background` |
| 배치(1920, Figma) | 보조기능 버튼 0~74 → 2 → 보조기능 격자 76~392(위 2) → 4 → 명령 그룹 396~(위 1) → 명령어 전체보기(명령 칸 바로 뒤, 1552~1626) → 나머지 bg_vacant | — |
| 측면 버튼 | 74 × 71, 자산 면(그라데이션 + 그라데이션 테두리), 그림자 8px 60% 검정(왼쪽 버튼 → 오른쪽 5px, 오른쪽 버튼 → 왼쪽 5px). 제목 14px 1.3px 외곽선, 부제 14px 1px 외곽선, 위 18 · 줄 간격 2 | `--hm-sw-footer-side-*` |
| 측면 버튼 | 74 × 71, 자산 면(그라데이션 + 그라데이션 테두리), 그림자 8px 60% 검정(왼쪽 버튼 → 오른쪽 5px, 오른쪽 버튼 → 왼쪽 5px). 제목 14px 1.3px 외곽선, 부제 14px 1px 외곽선, 위 18 · 줄 간격 2. Hover·Pressed·Expanded = 제목·부제 `#FF5C00` + 1px 주황 그라데이션 테두리(`--hm-sw-footer-gra-border-orange`), Disabled 기본 모양 | `--hm-sw-footer-side-*` |
| 보조기능 | 104 × 33, gap 2, radius 3, 면 `linear-gradient(0deg, #262218 8%, #4A3C2B 34%, #594C31 55%, #312A1B 90%)`, 아이콘 중심 (19.5, 16.5), 라벨 13px 왼쪽 36 폭 57 가운데. 테두리 1px 그라데이션(위 → 아래): Default `#886D35 0%, #423625 8.85%, #F3DBA8 26.17%, #0E0B06 83.79%, #574B30 100%`(Figma btn_* 26:9248 …) | `--hm-sw-footer-aux-*`, `--hm-sw-footer-aux-border` |
| 보조기능 상태 | Hover(선택 아님) = 테두리만 노랑 `--hm-sw-header-hover-border-gradient`(`#A67D03 → #F2CC0F → #FADD4F → #F2CC0F → #926E03`, SW Header Figma Hover 재사용 — 버튼 세트에는 Hover 없음, 사용자 확정 2026-10-20). Selected(`aria-pressed="true"`, Figma Variant2 = Variant3) = 테두리 `#DA6813 8.52%, #FFBB89 37.93%, #E5690D 71.85%, #AE3607 92.77%` + 아이콘 채움·글자 `#FF5C00`, Hover보다 우선. 아이콘 외곽선 검정 90% 1px는 모든 상태 동일 | `--hm-sw-footer-aux-hover-border`, `--hm-sw-footer-aux-pressed-border`, `--hm-sw-footer-aux-icon-color`(`#F1EDE5`) |
| 명령 그룹 면 | 테두리 위·왼쪽·아래 1px `#886D35`, 왼쪽 위 radius 4, 면 `linear-gradient(0deg, #2F281A 0%, #312A1B 5%, #332B1C 36%, #463C29 58%, #38301F 79%, #19160E 98%)`. 마지막 명령 뒤(빈 칸·나머지)는 bg_vacant `linear-gradient(0deg, #18150E 28%, #262015 41%, #332B1C 58%, #38301F 81%, #18150E 100%)` | `--hm-sw-footer-group-*`, `--hm-sw-footer-vacant-background` |
| 보조기능 상태 | Hover(선택 아님) = 테두리만 노랑 `--hm-sw-header-hover-border-gradient`(`#A67D03 → #F2CC0F → #FADD4F → #F2CC0F → #926E03`, SW Header Figma Hover 재사용 — 버튼 세트에는 Hover 없음, 사용자 확정 2026-10-20). Selected(`aria-pressed="true"`, Figma Variant2 = Variant3) = 테두리 `#DA6813 8.52%, #FFBB89 37.93%, #E5690D 71.85%, #AE3607 92.77%`(Figma `gra-border-orange` = `--hm-sw-footer-gra-border-orange`) + 아이콘 채움·글자 `#FF5C00`, Hover보다 우선. 아이콘 외곽선 검정 90% 1px는 모든 상태 동일 | `--hm-sw-footer-aux-hover-border`, `--hm-sw-footer-aux-pressed-border`, `--hm-sw-footer-aux-icon-color`(`#F1EDE5`) |
| 명령 그룹 면 | 테두리 위·왼쪽·아래 1px `#886D35`, 왼쪽 위 radius 4, 면 `linear-gradient(0deg, #2F281A 0%, #312A1B 5%, #332B1C 36%, #463C29 58%, #38301F 79%, #19160E 98%)`. 마지막 명령 뒤(빈 칸·나머지)는 bg_vacant `linear-gradient(0deg, #18150E 28%, #262015 41%, #332B1C 58%, #38301F 81%, #18150E 100%)`, 명령 칸 높이까지만(2026-10-20: 분류 행 아래로 내려가지 않아 선택 탭 Pocket 면이 어느 탭에서나 같음) | `--hm-sw-footer-group-*`, `--hm-sw-footer-vacant-background` |
| 명령 칸 | 68 × 47, 라벨 14px `#F6F4F2` 위 6, 아이콘 18 × 18 위 26 가운데. 칸 사이 구분선 1 × 37(위 5) `linear-gradient(0deg, #20190E, #8A7A5B 45%, #F3DBA8 70%, #423625)` + `-1px 0 1px #000` | `--hm-sw-footer-slot-*`, `--hm-sw-footer-divider*` |
| 명령 상태 | Hover = 선택(Figma Variant2 = Variant3): 글자 `#FF5C00` + 주황 아이콘. Disabled 40%(Figma 없음) | `--hm-sw-footer-accent` |
| 빈 칸 | 68 × 46(위 1), + `#BEB5A6`. Hover: 주황 + 와 주황 그라데이션 점선 링(자산) | — |
| 가이드 | 208 × 149, 빈 칸 위 4px 가운데, 1px `#DA6813` radius 2, 면 `#0E3C2E`, 미리보기 194 × 111(5, 5, 검정 1px), 제목 14px 외곽선, 캡션 12px `#B6D0C9` 1px 외곽선 | `--hm-sw-footer-guide-*` |
| 분류 행 | 높이 24, 면 `#282216` → 위 14% `#574B30`, 안쪽 1px 테두리 그라데이션, 현재 탭 Pocket(좌우 7px 곡선, 아래 radius 2, 가장자리 `#675A3B`). 탭 14px `#D4CBBD`, 자간 10%, 위 4, 좌우 padding 8, 최소 43. 구분선 1 × 10 검정(위 7). Hover·현재 `#FF5C00` | `--hm-sw-footer-menu-*` |
| Layout 탭 | 높이 22, 12px 자간 2%. Default 면 `#C3B79E` 글자 `#161410`. On: `linear-gradient(0deg, #7C6C4C, #7C6C4C 85%, #A6946F)` 흰 글자. 이름 편집: `#0F0D0A` 빨강 `#E60403` 주황 테두리. Hover·On·편집 글자 아래 70% 검정 1.3px 외곽선. 둥근 탭: 1px `rgba(0,0,0,.05)`(Hover·편집 `#DA6813`), 위 radius 4, 안쪽 그림자 `inset 0 -3px 4px rgba(56,48,31,.25)`, ×(16) Hover 노랑. 사선 탭(마지막): 97 × 22 자산, Hover = 주황 면 + 검정 글자, On hover = 베이지 면 + 주황 글자, ×(28, 빨강 사선) | `--hm-sw-footer-tab-*` |
| Model 탭 | 73 × 22 자산(왼쪽 사선), 글자 왼쪽 22, Hover 주황, On 갈색. 앞 사선 탭과 8px 겹침 | — |
| Layout 탭 | 높이 22, 12px 자간 2%. Default 면 `#C3B79E` 글자 `#161410`. On: `linear-gradient(0deg, #7C6C4C, #7C6C4C 85%, #A6946F)` 흰 글자. 이름 편집: `#0F0D0A` 빨강 `#E60403` 주황 테두리. Hover·On·편집 글자 아래 70% 검정 1.3px 외곽선. 둥근 탭: 1px `rgba(0,0,0,.05)`(Hover·편집 `#DA6813`), 위 radius 4, 안쪽 그림자 `inset 0 -3px 4px rgba(56,48,31,.25)`, ×(16) Hover 노랑. 사선 탭(마지막): 97 × 22 자산, On hover = 베이지 면 + 주황 글자, ×(28, 빨강 사선). Hover(선택 아님, 두 모양 모두, 2026-10-20) = Model과 같은 `gra-border-orange` 면(사선 탭은 자산 모양 안)만 바뀌고 글자는 Default 그대로(`#161410` 12px 400, 외곽선 없음, 위치·크기 불변) | `--hm-sw-footer-tab-*`, `--hm-sw-footer-gra-border-orange` |
| `+` 버튼 | Layout 옆 `+`(24 × 22) Hover = `gra-border-orange` 면 + `+`(분류 행 `+` 자산). 분류 행 `+`(28 × 12) Hover = `+` 채움 `#FF5C00`(검정 외곽선 그대로) | `--hm-sw-footer-gra-border-orange`, `--hm-sw-footer-accent` |
| Model 탭 | 73 × 22 자산(왼쪽 사선), 글자 왼쪽 22. Hover·On = 자산 모양 안 `gra-border-orange` 면 + 검정 `#161410` 글자(12px 400, 외곽선 없음)(2026-10-20 사용자 확정, 이전 Figma On 갈색·흰 글자 대체). 앞 사선 탭과 8px 겹침 | `--hm-sw-footer-gra-border-orange` |
| 명령창 | 566 × 71 / 270, 위 radius 4, 위 71px `linear-gradient(180deg, #635537, #262218 5%, #281F16 10%, #282216)` + 아래 `#282216`. 12px 자간 10% 줄 14, 1px 외곽선. 입력 칸 23(좌우 4) `#16130D` + `0 -1px 2px rgba(255,255,255,.25)`, `inset 0 1px 4px #000`. 화살표 18 × 18 검정 radius 2(4, 4 오른쪽 위) | `--hm-sw-footer-command-*` |
| 바 안 명령창(실제 화면) | 보조기능 뒤 4px, 바 아래에 붙어 펼치면 위로 270. 남는 폭(바 − 보조기능 영역 − 명령어 전체보기)을 칸 영역 0.57 : 명령창 나머지(최소 280)로 나눔 — 칸 수는 바 폭으로만 계산, 분류의 명령 수와 무관(1920 → 12칸) | `--hm-sw-footer-command-min-width`, `--hm-sw-footer-slot-share` |
| 좌표(실제 화면) | 바 위 왼쪽, 12px `#F6F4F2` 1px 외곽선, 아래 3px | — |
| 좁은 폭(실제 화면) | 빈 칸이 먼저 줄고, 넘치는 명령은 마지막 칸 ▲(ico_arrow 18 × 18, 오른쪽 위 4) → 명령 칸 위 패널(같은 칸, 오른쪽 맞춤, 테두리 1px `#886D35`·아래 없음·위 radius 4, 명령 칸 면). 분류 탭 넘침 = 탭 영역 잘림 + ◀ ▶(13 × 16, 6 × 8 삼각형 `#F6F4F2`, Hover `#FF5C00`) + +. 바 1279px 이하 보조기능 아이콘만 45 × 33 | `--hm-sw-footer-aux-compact-width` |
- Figma에 없는 값: 실제 화면에서 가져온 값(바 안 명령창 배치, 좌표, ▲ 패널, 분류 ◀ ▶, 보조기능 아이콘만)과 AI 구성 값 — 디자인 확인 필요(측면 버튼 Hover 글자 `#FF5C00`, Disabled 40%, 명령창 최소 280px, 칸 영역 비율 0.57(두 실제 화면 재현 값), 보조기능 아이콘 전환 1279px, Layout 탭 이름 편집 입력의 동작).
- Figma에 없는 값: 실제 화면에서 가져온 값(바 안 명령창 배치, 좌표, ▲ 패널, 분류 ◀ ▶, 보조기능 아이콘만)과 AI 구성 값 — 디자인 확인 필요(측면 버튼 Hover 글자 `#FF5C00`·주황 테두리, 분류 행 `+` Hover, Disabled 40%, 명령창 최소 280px, 칸 영역 비율 0.57(두 실제 화면 재현 값), 보조기능 아이콘 전환 1279px, Layout 탭 이름 편집 입력의 동작).
- 새 하단 UI는 위 토큰을 `var(--…)`로 읽고 `hm-sw-footer*` class를 쓰거나 덮어쓰지 않습니다.
+5 -4
View File
@@ -1,6 +1,6 @@
# SW Footer — `SWFooter` 계열 (SW 일반 모드)
SW 프로그램 하단 명령 막대입니다. 기준은 Figma 이지빔-SW `레이아웃 분리`(26:10097) 하단 72px과 부품 노드 34개(Plugin API로 읽은 값, 2026-10-20)이고, 바 안 명령창·좌표·좁은 폭 동작은 사용자 제공 실제 화면(2026-10-20)을 따릅니다. 두 기준이 다르면 Figma가 우선입니다(예: 선택된 Model 탭은 Figma On 갈색). **SW 일반 모드(편집 화면) 전용**이며 뷰어 모드(`SWViewer*`) 화면에는 쓰지 않습니다([AI_USAGE.md](./AI_USAGE.md) §0).
SW 프로그램 하단 명령 막대입니다. 기준은 Figma 이지빔-SW `레이아웃 분리`(26:10097) 하단 72px과 부품 노드 34개(Plugin API로 읽은 값, 2026-10-20)이고, 바 안 명령창·좌표·좁은 폭 동작은 사용자 제공 실제 화면(2026-10-20)을 따릅니다. 두 기준이 다르면 Figma가 우선입니다. 단, 2026-10-20 사용자 확정으로 선택된 Model 탭과 Layout 탭 Hover는 주황 그라데이션(Figma `gra-border-orange`)입니다. **SW 일반 모드(편집 화면) 전용**이며 뷰어 모드(`SWViewer*`) 화면에는 쓰지 않습니다([AI_USAGE.md](./AI_USAGE.md) §0).
컴포넌트는 **표시와 사용자 입력 전달**만 합니다. CAD 명령 실행, 명령 등록, 레이아웃 생성·삭제, 명령 해석은 소비 프로그램이 Callback을 받아 처리하고 Props를 갱신합니다.
@@ -80,12 +80,13 @@ import "@hm-design/sw/styles.css"
| 명령 | Default `#F6F4F2` / Variant2 Hover = Variant3 선택: `#FF5C00` + 주황 아이콘 | Hover는 CSS, 선택은 `activeToolId`·`pressed` |
| 분류 탭 | Default `#D4CBBD` / Hover·현재 `#FF5C00`, 현재 탭은 Pocket | `categoryId`·`value` |
| 빈 칸 | Default + / Hover 주황 점선 링 + 주황 + | Hover·Focus 시 `slotGuide` 표시 |
| Layout 탭 | Default 베이지 `#C3B79E` / Hover(×) / On 갈색 그라데이션·흰 글자 / On hover / Variant5 이름 편집(`#0F0D0A`, 빨강 `#E60403`) / Variant6 편집 + Hover | `value`, 편집 = 더블클릭·F2 또는 `editingId` |
| Model 탭 | Default / Hover 주황 / On 갈색 | `value === SW_FOOTER_MODEL` |
| Layout 탭 | Default 베이지 `#C3B79E` / Hover(선택 아님, ×) = Model과 같은 주황 그라데이션 면(글자는 Default와 동일: 검정 `#161410`, 크기·굵기·위치 불변) / On 갈색 그라데이션·흰 글자 / On hover / Variant5 이름 편집(`#0F0D0A`, 빨강 `#E60403`) / Variant6 편집 + Hover | `value`, 편집 = 더블클릭·F2 또는 `editingId` |
| Model 탭 | Default 베이지 / Hover·On = 주황 그라데이션 면(`gra-border-orange`) + 검정 `#161410` 글자 | `value === SW_FOOTER_MODEL` |
| `+` 버튼 | Layout 옆 `+` Hover = 같은 주황 그라데이션 면 + `+`. 분류 행 `+` Hover = `+` `#FF5C00` | `onAdd` / `onCategoryAdd` |
| 명령창 화살표 | Default ↑ / Hover 주황 ↑ / On ↓ / On hover 주황 ↓ | `expanded` |
| Disabled | Figma 없음 | SW 공통 40%(명령·빈 칸·탭·보조기능) |
| 보조기능 | Figma btn_객체스냅 26:9248 … btn_선가중치 26:9412: Default 갈색 그라데이션 테두리·아이콘 `#F1EDE5`·글자 `#F6F4F2` / Variant2 = Variant3 주황 그라데이션 테두리·아이콘·글자 `#FF5C00` | Default 그대로. **Hover = 노랑 테두리만**(SW Header Figma Hover 토큰 `--hm-sw-header-hover-border-gradient`, 1px, 아이콘·글자 기본색). **Selected(`pressed`) = 주황 테두리 + 주황 아이콘 + 주황 글자 동시**, Hover 중에도·마우스가 벗어나도 유지. 크기·위치 변화 없음 |
| 측면 버튼 Hover | Figma 없음 | Footer Hover 규칙 재사용: 글자 `#FF5C00` (디자인 확인 필요) |
| 측면 버튼 Hover·Pressed | Figma 없음 | Footer 규칙 재사용(2026-10-20): 제목·부제 모두 `#FF5C00` + 테두리 주황 그라데이션(보조기능 Selected 테두리). 크기·면·그림자 그대로, Disabled는 기본 모양 (디자인 확인 필요) |
### 4.1 좁은 폭 (Figma 없음, 실제 화면 2026-10-20)
+1 -1
View File
@@ -71,7 +71,7 @@
| `autoFocus?` | 열 때 패널로 Focus, 닫을 때 원래 위치로 복귀(기본 true) |
- Escape는 Focus가 패널 안에 있을 때 그 패널만 닫습니다. 비모달 패널은 바깥 클릭으로 닫히지 않습니다(참고 뷰어와 동일, 도구 창). 마지막으로 누른 패널이 맨 앞으로 옵니다.
- **콘텐츠 우선순위:** `children`·`footer`는 타입상 `ReactNode`이고 소스 주석도 자유 콘텐츠(“any component”)라고 적습니다. 그래도 기본은 `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`·`SWViewerBadge`·`SWViewerHint` 우선입니다. 이것으로 안 되는 컨트롤(예: Select·Checkbox·표)만 `@hm-design/ui` 컴포넌트나 소비 프로젝트 요소를 넣을 수 있습니다. `@hm-design/ui`는 SW 패키지 dependency가 아니어서 별도 설치(사용 유형 혼합)가 필요하고, `SWPopup`과 달리 뷰어 패널 안 `@hm-design/ui` 외형 정합성은 문서화·검증 기록이 없으므로 결과 보고에 “디자인 확인 필요”로 적습니다. Header 안에는 `@hm-design/ui` DropdownMenu 등을 직접 두지 않습니다(§1 표).
- **콘텐츠 우선순위:** `children`·`footer`는 타입상 `ReactNode`이고 소스 주석도 자유 콘텐츠(“any component”)라고 적습니다. 그래도 기본은 `SWViewerSection`·`SWViewerField`·`SWViewerSwitch`·`SWViewerSlider`·`SWViewerInput`·`SWViewerButton`·`SWViewerBadge`·`SWViewerHint`와 선택 목록 `SWSelect`(축척 지정 단위, 뷰어 패널 토큰으로 표시) 우선입니다. 이것으로 안 되는 컨트롤(예: Select·Checkbox·표)만 `@hm-design/ui` 컴포넌트나 소비 프로젝트 요소를 넣을 수 있습니다. `@hm-design/ui`는 SW 패키지 dependency가 아니어서 별도 설치(사용 유형 혼합)가 필요하고, `SWPopup`과 달리 뷰어 패널 안 `@hm-design/ui` 외형 정합성은 문서화·검증 기록이 없으므로 결과 보고에 “디자인 확인 필요”로 적습니다. Header 안에는 `@hm-design/ui` DropdownMenu 등을 직접 두지 않습니다(§1 표).
- **콘텐츠 교체:** 같은 프레임에 다른 `children`을 넣습니다(레이어 목록 / 옵션 / 속성 / 입력 폼 — Storybook `Popup Content Variants`). 프레임을 복사하거나 `hm-sw-viewer-*` class로 새 팝업을 만들지 않습니다.
## 5. 올바른 사용
@@ -1,8 +1,9 @@
// Example E — the SW popup frame with project content (no Header). SWPopup keeps the property window design;
// children are free: SWPopupSection / SWPopupRow rows with SWHeaderInput, and an existing SW dropdown.
// children are free: SWPopupSection / SWPopupRow rows with SWHeaderInput, the unit SWSelect, the SWSlider gauge and the
// popup layer / color pickers (SWPopupPropertyRow). The SWHeader field dropdowns are header-only: never in a popup.
import { useId, useState } from "react"
import { SWHeaderLayerMenu, SWHeaderInput, SWPopup, SWPopupRow, SWPopupSection, SWTheme } from "@hm-design/sw"
import { layers } from "./sample-data"
import { SWHeaderInput, SWPopup, SWPopupPropertyRow, SWPopupRow, SWPopupSection, SWSelect, SWSlider, SWTheme } from "@hm-design/sw"
import { colors, layers } from "./sample-data"
export function ExampleECustomPopup({ onEvent }: { onEvent?: (name: string) => void }) {
const [open, setOpen] = useState(true)
@@ -10,7 +11,11 @@ export function ExampleECustomPopup({ onEvent }: { onEvent?: (name: string) => v
const [name, setName] = useState("평면도-01")
const [scale, setScale] = useState("100")
const [x, setX] = useState("1200.5")
const unitId = useId()
const [unit, setUnit] = useState("mm")
const [opacity, setOpacity] = useState(60)
const [activeLayerId, setActiveLayerId] = useState("0")
const [color, setColor] = useState("255,0,0")
if (!open) return <button type="button" onClick={() => setOpen(true)}>설정 열기</button>
return (
<SWTheme>
@@ -22,6 +27,12 @@ export function ExampleECustomPopup({ onEvent }: { onEvent?: (name: string) => v
<SWPopupRow label="축척" unit="%" numeric>
<SWHeaderInput aria-label="축척" valueType="number" value={scale} onCommit={next => { setScale(next); onEvent?.(`scale:${next}`) }} />
</SWPopupRow>
<SWPopupRow label="단위" htmlFor={unitId} field="none">
<SWSelect id={unitId} value={unit} options={[{ value: "mm" }, { value: "cm" }, { value: "m" }]} onValueChange={next => { setUnit(next); onEvent?.(`unit:${next}`) }} />
</SWPopupRow>
<SWPopupRow label="투명도" field="none" unit="%">
<SWSlider aria-label="투명도" value={opacity} step={10} onValueChange={setOpacity} onValueCommit={next => onEvent?.(`opacity:${next}`)} />
</SWPopupRow>
</SWPopupSection>
<SWPopupSection title="객체 정보">
<SWPopupRow label="타입" readOnly><SWHeaderInput aria-label="타입" value="Line" readOnly /></SWPopupRow>
@@ -29,10 +40,12 @@ export function ExampleECustomPopup({ onEvent }: { onEvent?: (name: string) => v
<SWHeaderInput aria-label="X" valueType="number" value={x} onCommit={next => { setX(next); onEvent?.(`x:${next}`) }} />
</SWPopupRow>
</SWPopupSection>
<SWPopupSection title="레이어">
{/* Inside a popup the dropdown opens as a fixed overlay, so the scrolling body never clips it. */}
<SWHeaderLayerMenu value={{ name: "0" }} layers={layers} activeLayerId={activeLayerId}
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }} />
<SWPopupSection title="레이어 · 색상">
{/* The property window pickers; their lists open as a fixed overlay, so the scrolling body never clips them. */}
<SWPopupPropertyRow property={{ id: "layer", name: "레이어", value: activeLayerId, type: "select", groupId: "popup", picker: "layer", options: layers.map(({ id, name }) => ({ id, name })) }}
menus={{ layers }} onPropertyChange={(_, value) => { setActiveLayerId(value); onEvent?.(`layer:${value}`) }} />
<SWPopupPropertyRow property={{ id: "color", name: "색상", value: color, type: "color", groupId: "popup" }}
menus={{ colors }} onPropertyChange={(_, value) => { setColor(value); onEvent?.(`color:${value}`) }} />
</SWPopupSection>
</SWPopup>
</SWTheme>
@@ -0,0 +1,214 @@
// 2026-10-20 SW Footer · Popup · Viewer refinement: Footer Story cleanup, Header field dropdowns inside SWPopup,
// SWSlider appearances (panel / property), Popup B, viewer title art at narrow widths.
// Usage: build Storybook first, then node packages/sw/tests/footer-popup-viewer-refinement.browser.mjs [evidence-dir]
import { createRequire } from 'node:module'
import { readFile, writeFile, mkdir } from 'node:fs/promises'
import path from 'node:path'
import http from 'node:http'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const { chromium } = require('playwright')
const out = path.resolve(process.argv[2] ?? 'docs/sw/footer-popup-viewer-refinement-qa'); await mkdir(out, { recursive: true })
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
await new Promise(r => server.listen(0, '127.0.0.1', r)); const base = `http://127.0.0.1:${server.address().port}`
const browser = await chromium.launch({ headless: true, executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const report = {}
const checks = []; const check = (n, ok, d) => { checks.push({ n, ok, d }); console.log(ok ? 'PASS' : 'FAIL', n, ok ? '' : JSON.stringify(d)) }
const index = JSON.parse(await readFile(path.join(root, 'index.json'), 'utf8'))
let errors = []
const page = await browser.newPage({ viewport: { width: 1920, height: 1100 } })
page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
const goto = async id => { errors = []; await page.goto(`${base}/iframe.html?id=${id}&viewMode=story`); await page.locator('#storybook-root > *').first().waitFor(); await page.waitForTimeout(300) }
const log = () => page.locator('[data-callback-log]').first().textContent()
// 1. Footer Stories
const footer = Object.values(index.entries).filter(e => e.type === 'story' && e.title === 'SW/Footer').map(e => e.id)
check('Footer: 기본 Footer / States removed, full-footer + figma-variants kept', !footer.includes('sw-footer--default') && !footer.includes('sw-footer--states') && footer.includes('sw-footer--full-footer') && footer.includes('sw-footer--figma-variants'), footer)
await goto('sw-footer--full-footer')
const sections = await page.locator('h2').allTextContents()
// 2026-10-20 (second pass): 전체 Footer — Figma 기본 구성, 상태, 참고: 실제 화면 were removed; the live composition stays.
check('Footer: full-footer keeps only 실제 Footer 조합 (3 sections removed)', sections.some(s => s.startsWith('실제 Footer 조합')) && !['전체 Footer — Figma', '상태 —', '참고: 실제 화면'].some(t => sections.some(s => s.startsWith(t))) && errors.length === 0, { sections, errors })
check('Footer: one live footer, no Figma / reference image', await page.locator('.hm-sw-footer').count() === 1 && await page.locator('img[src*="composition-2026"], img[src*="narrow-2026"], [aria-label^="Figma 26:10097"]').count() === 0, await page.locator('.hm-sw-footer').count())
// command area does not move when the category changes (workspace footer)
const work = page.locator('.sw-footer-story-workspace .hm-sw-footer')
const slotBox = () => work.locator('.hm-sw-footer__slots').evaluate(e => { const r = e.getBoundingClientRect(); return [Math.round(r.x), Math.round(r.width), e.querySelectorAll('.hm-sw-footer-slot').length] })
const before = await slotBox()
await work.locator('.hm-sw-footer-menu__tab').nth(1).click(); await page.waitForTimeout(150)
check('Footer: category change keeps the command slot position', JSON.stringify(await slotBox()) === JSON.stringify(before), { before, after: await slotBox() })
// Hover · gradient (2026-10-20): side buttons light up whole (both lines + orange ring), + buttons, Model / Layout tabs.
const ORANGE = 'linear-gradient(rgb(218, 104, 19) 8.52%, rgb(255, 187, 137) 37.93%, rgb(229, 105, 13) 71.85%, rgb(174, 54, 7) 92.77%)'
const hoverRead = async (sel, fn) => { const el = work.locator(sel).first(); await el.hover(); await page.waitForTimeout(120); const r = await el.evaluate(fn); await page.mouse.move(2, 2); await page.waitForTimeout(80); return r }
for (const side of ['start', 'end']) {
const sel = side === 'end' ? '.hm-sw-footer-side[data-side="end"]' : '.hm-sw-footer-side:not([data-side="end"])'
const r = await hoverRead(sel, e => ({ title: getComputedStyle(e.querySelector('.hm-sw-footer-side__title')).color, sub: getComputedStyle(e.querySelector('.hm-sw-footer-side__subtitle')).color, ring: getComputedStyle(e, '::after').opacity, w: e.getBoundingClientRect().width, h: e.getBoundingClientRect().height }))
check(`Footer: ${side === 'end' ? '명령어' : '보조기능'} 전체보기 hover = both lines accent + orange ring, 74 × 71`, r.title === 'rgb(255, 92, 0)' && r.sub === 'rgb(255, 92, 0)' && r.ring === '1' && r.w === 74 && r.h === 71, r)
}
const idle = await work.locator('.hm-sw-footer-side[data-side="end"]').evaluate(e => [getComputedStyle(e.querySelector('.hm-sw-footer-side__subtitle')).color, getComputedStyle(e, '::after').opacity])
check('Footer: side button default unchanged (subtitle #F6F4F2, no ring)', idle[0] === 'rgb(246, 244, 242)' && idle[1] === '0', idle)
const menuAdd = await hoverRead('.hm-sw-footer-menu__add', e => ({ bg: getComputedStyle(e).backgroundImage, w: e.getBoundingClientRect().width }))
check('Footer: category + hover = orange + (menu-add-hover, #FF5C00), 28px', /FF5C00/i.test(decodeURIComponent(menuAdd.bg)) && menuAdd.w === 28, menuAdd.bg.slice(0, 80))
const tabsAdd = await hoverRead('.hm-sw-footer-tabs__add', e => ({ bg: getComputedStyle(e, '::before').backgroundImage, w: e.getBoundingClientRect().width }))
check('Footer: Layout + hover = gra-border-orange surface with the +, 24px', tabsAdd.bg.includes(ORANGE) && tabsAdd.bg.startsWith('url(') && tabsAdd.w === 24, tabsAdd.bg.slice(0, 160))
const tabInfo = e => { const t = e.querySelector('.hm-sw-footer-tab__select'), l = e.querySelector('.hm-sw-footer-tab__label') ?? t, c = getComputedStyle(t), r = e.getBoundingClientRect(), lr = l.getBoundingClientRect(); return { bg: getComputedStyle(e).backgroundImage, before: getComputedStyle(e, '::before').backgroundImage, color: c.color, size: c.fontSize, weight: c.fontWeight, shadow: c.textShadow === 'none' ? 'none' : 'outline', text: [Math.round((lr.x - r.x) * 10), Math.round((lr.y - r.y) * 10)].join(','), w: Math.round(r.width), h: Math.round(r.height) } }
const textOf = t => [t.color, t.size, t.weight, t.shadow, t.text, t.w, t.h].join('|')
const l02d = await work.locator('.hm-sw-footer-tab[data-shape="rounded"][data-state="off"]').evaluate(tabInfo)
const l01d = await work.locator('.hm-sw-footer-tab[data-shape="slant"][data-state="off"]').evaluate(tabInfo)
const model = await work.locator('.hm-sw-footer-tab--model').evaluate(tabInfo)
check('Footer: Model (selected) = gra-border-orange surface, black #161410 text without outline, 73 × 22', model.before === ORANGE && model.color === 'rgb(22, 20, 16)' && model.shadow === 'none' && model.w === 73 && model.h === 22, model)
const l02 = await hoverRead('.hm-sw-footer-tab[data-shape="rounded"][data-state="off"]', tabInfo)
const l01 = await hoverRead('.hm-sw-footer-tab[data-shape="slant"][data-state="off"]', tabInfo)
check('Footer: Layout 02 hover = Model surface; text identical to Default (black, size, weight, place, box)', l02.bg === ORANGE && textOf(l02) === textOf(l02d) && l02.color === 'rgb(22, 20, 16)' && l02.weight === model.weight && l02.size === model.size && l02.w === 85, { l02, l02d })
check('Footer: Layout 01 hover = Model surface (slanted); text identical to Default', l01.before === ORANGE && textOf(l01) === textOf(l01d) && l01.color === 'rgb(22, 20, 16)' && l01.weight === model.weight && l01.size === model.size && l01.w === 97, { l01, l01d })
const l01idle = await work.locator('.hm-sw-footer-tab[data-shape="slant"][data-state="off"]').evaluate(tabInfo)
check('Footer: Layout tabs return to their default look after hover', l01idle.before !== ORANGE && textOf(l01idle) === textOf(l01d), l01idle)
// Category pocket (2026-10-20): every selected tab shows one continuous pocket surface (no vacant surface under the row).
const pockets = []
for (let i = 0; i < 4; i++) {
await work.locator('.hm-sw-footer-menu__tab').nth(i).click(); await page.mouse.move(2, 2); await page.waitForTimeout(120)
pockets.push(await work.locator('.hm-sw-footer-menu').evaluate(m => { const t = m.querySelector('[aria-selected="true"]').getBoundingClientRect(), y = t.y + 4
const under = x => document.elementsFromPoint(x, y).find(e => e.matches('.hm-sw-footer__vacant, .hm-sw-footer__group'))?.className
return [m.querySelector('[aria-selected="true"]').textContent, under(t.x + 3), under(t.x + t.width / 2), under(t.right - 3)] }))
}
check('Footer: category pocket surface is the same across the whole tab for 1st · 2nd · 3rd · 4th tab', pockets.every(p => p[1] === p[2] && p[2] === p[3] && p[1] === 'hm-sw-footer__group'), pockets)
const vacantH = await work.locator('.hm-sw-footer__vacant').evaluate(e => Math.round(e.getBoundingClientRect().height))
check('Footer: vacant surface covers the slot row only (46px)', vacantH === 46, vacantH)
await work.locator('.hm-sw-footer-menu__tab').nth(0).click(); await page.mouse.move(2, 2)
await work.locator('.hm-sw-footer-tab[data-shape="rounded"] .hm-sw-footer-tab__select').click(); await page.waitForTimeout(120)
const l02on = await work.locator('.hm-sw-footer-tab[data-shape="rounded"]').evaluate(e => [e.dataset.state, getComputedStyle(e).backgroundImage])
check('Footer: a selected Layout keeps its own (brown) selected surface, distinct from hover', l02on[0] === 'on' && l02on[1] !== ORANGE && l02on[1].includes('linear-gradient'), l02on)
await page.screenshot({ path: path.join(out, 'footer-full.png'), fullPage: true })
await goto('sw-footer--figma-variants')
check('Footer: aux Figma comparison lives in figma-variants (6 rows)', await page.locator('.sw-footer-story-aux-row').count() === 6 && errors.length === 0, errors)
await goto('sw-footer--responsive')
await page.setViewportSize({ width: 1920, height: 1100 })
const panelOrder = await page.locator('.sw-footer-story-width').nth(4).evaluate(async w => {
const toggle = [...w.querySelectorAll('button')].find(b => b.getAttribute('aria-expanded') !== null && b.closest('.hm-sw-footer') && !b.closest('.hm-sw-footer-command'))
return toggle ? toggle.getAttribute('aria-label') : null
})
check('Footer: responsive 860px row renders with an overflow ▲ control', panelOrder !== null && errors.length === 0, { panelOrder, errors })
const respSections = await page.locator('h2').allTextContents()
check('Footer: 화면 폭 변화 keeps the 6 widths, 참고 좁은 화면 + Figma 기본 구성 removed', await page.locator('.sw-footer-story-width').count() === 6 && !respSections.some(t => t.startsWith('참고: 실제 좁은 화면') || t.startsWith('Figma 기본 구성')) && await page.locator('img[src*="narrow-2026"]').count() === 0, respSections)
// 2. Popup layer / color = the property window pickers (SWPopupPropertyRow); SWHeader field dropdowns stay header-only.
await goto('sw-header--overview')
const headerField = await page.locator('.hm-sw-header .hm-sw-header__field--layer').first().evaluate(e => { const c = getComputedStyle(e), v = getComputedStyle(e.querySelector('.hm-sw-header__field-value')); return [c.color, c.height, v.color, v.fontWeight].join('|') })
check('Header: layer field keeps the header design (#F6F4F2, 38px, value 700)', headerField === 'rgb(246, 244, 242)|38px|rgb(246, 244, 242)|700', headerField)
await goto('sw-popup--custom-content')
const rowSig = loc => loc.evaluate(row => { const s = e => { const c = getComputedStyle(e); return [c.color, c.backgroundColor, c.backgroundImage.slice(0, 30), c.borderTopColor, c.height, c.fontSize, c.fontWeight, c.textShadow].join(',') }; return ['.hm-sw-property__value', '.hm-sw-property__trigger, .hm-sw-header__input', 'label'].map(q => row.querySelector(q) ? s(row.querySelector(q)) : '-').join(' / ') + ' / ' + row.querySelector('.hm-sw-property__value')?.dataset.picker })
const panelRows = page.locator('[data-popups] .hm-sw-property').nth(2)
const settingsPopup = page.locator('[data-popup="settings"]')
for (const id of ['layer', 'color']) {
const a = await rowSig(settingsPopup.locator(`[data-property-id="${id}"]`)), b = await rowSig(panelRows.locator(`[data-property-id="${id}"]`))
check(`Popup: 도면 설정 ${id} = 속성창 ${id} picker (value, trigger, label computed look)`, a === b && a.endsWith(id), { a, b })
}
check('Popup: no SWHeader field dropdown inside any popup', await page.locator('.hm-sw-property .hm-sw-header__field').count() === 0)
await settingsPopup.locator('[data-property-id="layer"] .hm-sw-property__trigger').click(); await page.waitForTimeout(120)
const drop = await page.locator('.hm-sw-property__drop[data-picker="layer"]').evaluate(e => getComputedStyle(e).position)
await page.locator('.hm-sw-property__drop[data-picker="layer"] .hm-sw-header__layer-item', { hasText: 'HATCH' }).click(); await page.waitForTimeout(100)
check('Popup: layer picker opens (fixed overlay), selects and closes', drop === 'fixed' && (await log()).includes('layer=hatch') && await page.locator('.hm-sw-property__drop').count() === 0 && (await settingsPopup.locator('[data-property-id="layer"] .hm-sw-property__text').textContent()) === 'HATCH', await log())
await settingsPopup.locator('[data-property-id="color"] .hm-sw-property__swatch-area').click(); await page.waitForTimeout(120)
await page.locator('.hm-sw-property__drop[data-picker="color"]').getByLabel('파랑').click(); await page.waitForTimeout(100)
check('Popup: color picker chip commits the RGB value', (await log()).includes('color=0,0,255'), await log())
if (await page.locator('.hm-sw-property__drop').count()) await page.keyboard.press('Escape')
const frames = await page.locator('[data-popups] .hm-sw-property').count()
check('Popup: frame / title / close unchanged (3 popups in the main section)', frames === 3)
// 3. SWSlider appearances
const sliders = await page.locator('[data-sw-slider]').evaluateAll(list => list.map(e => ({ look: e.dataset.appearance, h: Math.round(e.getBoundingClientRect().height), fill: e.style.getPropertyValue('--hm-sw-range-fill'), paint: getComputedStyle(e).getPropertyValue('--hm-sw-range-track-paint').trim().length > 0, range: getComputedStyle(e).getPropertyValue('--hm-sw-slider-panel-range').trim() })))
check('Slider: A = panel (18px, no property paint), B = property (16px, property paint), same fill', sliders[0].look === 'panel' && sliders[0].h === 18 && !sliders[0].paint && sliders[1].look === 'property' && sliders[1].h === 16 && sliders[1].paint && sliders[0].fill === sliders[1].fill && sliders[0].range === '#266651', sliders)
await page.getByLabel('투명도 B').focus(); await page.keyboard.press('ArrowRight'); await page.waitForTimeout(100)
const afterB = await page.locator('[data-sw-slider]').evaluateAll(list => list.map(e => e.value))
check('Slider: one shared value; keyboard change on B commits through onValueCommit', /opacity\(property\)=50/.test(await log()) && afterB.every(v => v === '50'), { log: await log(), afterB })
await page.getByRole('combobox', { name: '게이지바 스타일' }).selectOption('property'); await page.waitForTimeout(100)
const chosen = await page.getByLabel('투명도 (선택한 스타일)').evaluate(e => e.dataset.appearance)
check('Slider: appearance selection switches the look only', chosen === 'property' && (await log()).includes('appearance=property'), chosen)
const disabledSlider = await page.getByLabel('투명도 (비활성)').evaluate(e => ({ d: e.disabled, o: getComputedStyle(e).opacity }))
check('Slider: disabled 40%', disabledSlider.d && disabledSlider.o === '0.4', disabledSlider)
await page.locator('[data-slider-popups]').screenshot({ path: path.join(out, 'popup-slider-styles.png') })
await page.locator('[data-popups]').screenshot({ path: path.join(out, 'popup-custom-dropdowns.png') })
// The property window gauge itself is unchanged (property-panel-compare)
await goto('sw-header--property-panel-compare')
const prop = await page.locator('.hm-sw-property__value > input[type="range"]').first().evaluate(e => { const r = e.getBoundingClientRect(); return [Math.round(r.width), Math.round(r.height), getComputedStyle(e).getPropertyValue('--hm-sw-range-track-paint').trim().length > 0] })
check('Slider: property window gauge keeps 86 × 16 and its paint', prop[0] === 86 && prop[1] === 16 && prop[2], prop)
// 3b. Unit select: Popup 도면 설정 = Viewer 축척 지정 (same SWSelect, same computed look in every state)
const selProps = ['appearance', 'height', 'paddingLeft', 'paddingRight', 'borderTopWidth', 'borderTopColor', 'borderTopStyle', 'borderRadius', 'backgroundColor', 'color', 'fontSize', 'fontWeight', 'lineHeight', 'outlineStyle', 'outlineColor', 'outlineWidth', 'opacity', 'cursor']
const selRead = loc => loc.evaluate((e, p) => { const s = getComputedStyle(e); return p.map(k => `${k}=${s[k]}`).join(';') + `;class=${e.className};tag=${e.tagName}` }, selProps)
const selStates = async loc => { const d = await selRead(loc); await loc.hover(); await page.waitForTimeout(80); const h = await selRead(loc); await loc.focus(); await page.keyboard.press('Shift+Tab'); await page.keyboard.press('Tab'); await page.waitForTimeout(80); const f = await selRead(loc); await page.mouse.move(2, 2); return { d, h, f } }
await goto('sw-popup--custom-content')
const popupSel = page.locator('[data-popup="settings"] [data-sw-select="unit"]')
const ps = await selStates(popupSel)
const popupOptions = await popupSel.evaluate(e => [...e.options].map(o => o.value))
await popupSel.selectOption('m'); await page.waitForTimeout(80)
const popupPicked = [(await log()).includes('unit=m'), await popupSel.inputValue()]
await goto('sw-viewer--content-variants')
const viewerSel = page.locator('.hm-sw-viewer-panel [data-sw-select="unit"]')
const vs = await selStates(viewerSel)
const viewerOptions = await viewerSel.evaluate(e => [...e.options].map(o => o.value))
await viewerSel.selectOption('m'); await page.waitForTimeout(80)
check('Select: Popup 도면 설정 단위 = Viewer 축척 지정 단위 (default · hover · focus computed look)', ps.d === vs.d && ps.h === vs.h && ps.f === vs.f && ps.d.includes('class=hm-sw-select'), { ps, vs })
check('Select: options and selection kept (Popup mm/cm/m, Viewer mm/m), change events', JSON.stringify(popupOptions) === '["mm","cm","m"]' && JSON.stringify(viewerOptions) === '["mm","m"]' && popupPicked[0] && popupPicked[1] === 'm' && await viewerSel.inputValue() === 'm', { popupOptions, viewerOptions, popupPicked })
await page.locator('.hm-sw-viewer-panel', { hasText: '축척 지정' }).screenshot({ path: path.join(out, 'viewer-scale-select.png') })
// 3c. Header Open hysteresis (fix A): grow 520 → 1080, Open returns once and stays; shrink: Open moves first.
await goto('sw-header--document-open')
const probe = page.locator('[data-responsive-open] > [data-width="520"] .sw-story-frame')
const walk = async widths => { const t = []; for (const w of widths) { await probe.evaluate((e, w) => { e.style.width = `${w}px` }, w); await page.waitForTimeout(150); t.push([w, await probe.locator('[data-document-open]').count(), await probe.evaluate(e => e.querySelector('.hm-sw-header__object-area').dataset.squeezed ?? '')]) } return t }
const grow = await walk([520, 560, 600, 640, 680, 720, 760, 820, 900, 1080])
const flips = grow.slice(1).filter((s, i) => s[1] !== grow[i][1]).length
check('Header: Open returns once while growing 520 → 1080 and stays', grow[0][1] === 0 && grow.at(-1)[1] === 1 && flips === 1, grow)
const wide = await walk([1440, 1920])
check('Header: at 1920 every part is back and Open is shown', wide.at(-1)[1] === 1 && wide.at(-1)[2] === '', wide)
const shrink = await walk([1800, 1440, 1080, 900, 640, 520])
check('Header: shrinking, Open moves into More first (1800) and stays there', shrink[0][1] === 0 && shrink.every(s => s[1] === 0), shrink)
const regrow = await walk([560, 600, 640, 680, 720, 900, 1080, 1920])
const reflips = regrow.slice(1).filter((s, i) => s[1] !== regrow[i][1]).length
check('Header: regrowing after a shrink, Open returns once and stays', regrow.at(-1)[1] === 1 && reflips <= 1 && regrow.at(-1)[2] === '', regrow)
report.header = { grow, wide, shrink, regrow }
// 4. Popup B
await goto('sw-popup--multiple-popups')
const frame = id => page.locator(`[data-popup="${id}"]`).evaluate(e => { const s = getComputedStyle(e), t = getComputedStyle(e.querySelector('.hm-sw-property__title')); return [Math.round(e.getBoundingClientRect().width), s.backgroundColor, s.borderTopColor, t.color, t.fontWeight].join('|') })
check('Popup B: same frame as Popup A', await frame('a') === await frame('b'), [await frame('a'), await frame('b')])
check('Popup B: popup layer picker (no SWHeader field)', await page.locator('[data-popup="b"] .hm-sw-header__field').count() === 0 && await page.locator('[data-popup="b"] [data-property-id="layer"] [data-picker="layer"]').count() === 1)
await page.locator('[data-popup="b"] [data-property-id="layer"] .hm-sw-property__trigger').click()
await page.locator('.hm-sw-property__drop[data-picker="layer"] .hm-sw-header__layer-item', { hasText: 'DIM' }).click()
check('Popup B: layer selection callback and field value', (await log()).includes('b.layer=dim') && (await page.locator('[data-popup="b"] [data-property-id="layer"] .hm-sw-property__text').textContent()) === 'DIM', await log())
await page.setViewportSize({ width: 375, height: 812 })
await page.waitForTimeout(200)
const narrow = await page.evaluate(() => ({ sw: document.documentElement.scrollWidth, vw: document.documentElement.clientWidth, b: (r => [Math.round(r.left), Math.round(r.right)])(document.querySelector('[data-popup="b"]').getBoundingClientRect()) }))
check('Popup B: 375px, no page overflow and the popup inside the viewport', narrow.sw <= narrow.vw && narrow.b[0] >= 0 && narrow.b[1] <= narrow.vw, narrow)
await page.screenshot({ path: path.join(out, 'popup-stack-375.png'), fullPage: true })
await page.setViewportSize({ width: 1920, height: 1100 })
// 5. Viewer title art
const art = () => page.locator('.hm-sw-viewer-panel').evaluateAll(list => list.map(p => { const r = p.getBoundingClientRect(), a = p.querySelector('.hm-sw-property__title-art').getBoundingClientRect(), t = p.querySelector('.hm-sw-property__titlebar').getBoundingClientRect(); const before = parseFloat(getComputedStyle(p.querySelector('.hm-sw-property__titlebar'), '::before').width) || 0; return { w: Math.round(r.width), h: Math.round(r.height), artW: Math.round(a.width), gap: Math.max(0, (a.left + 20) - (t.left + before)) } }))
await goto('sw-viewer--variable-size')
const v = await art()
check('Viewer: 250 × auto title art keeps 308px and paints from the left edge (no gap)', v[0].w === 250 && v[0].artW === 308 && v[0].gap < 0.5, v[0])
check('Viewer: 320 / 480 popups unchanged (no gap)', v.slice(1).every(p => p.artW === 308 && p.gap < 0.5) && v[1].w === 320 && v[2].w === 480, v)
await page.locator('.hm-sw-viewer-panel').first().screenshot({ path: path.join(out, 'viewer-250-auto.png') })
// collapse + expand, drag
const first = page.locator('.hm-sw-viewer-panel').first()
const h0 = (await first.boundingBox()).height
const collapse = first.locator('button[aria-expanded]').first()
await collapse.click(); await page.waitForTimeout(100)
const h1 = (await first.boundingBox()).height
await collapse.click(); await page.waitForTimeout(100)
check('Viewer: 250 × auto collapses to the title bar and expands back to auto height', h1 < 40 && Math.abs((await first.boundingBox()).height - h0) < 1, { h0, h1 })
const box = await first.locator('.hm-sw-property__titlebar').boundingBox()
await page.mouse.move(box.x + 60, box.y + 15); await page.mouse.down(); await page.mouse.move(box.x + 120, box.y + 45, { steps: 5 }); await page.mouse.up()
const moved = await first.locator('.hm-sw-property__titlebar').boundingBox()
check('Viewer: 250 × auto drags by its title bar', Math.round(moved.x - box.x) === 60 && Math.round(moved.y - box.y) === 30, { dx: moved.x - box.x, dy: moved.y - box.y })
await goto('sw-viewer--content-variants')
const cv = await art()
check('Viewer: content variants (incl. 300px) title art has no gap', cv.every(p => p.artW === 308 && p.gap < 0.5), cv)
await goto('sw-viewer--settings-panel')
const sl = await page.locator('.hm-sw-viewer-slider').first().evaluate(e => ({ cls: e.className, look: e.dataset.appearance, h: Math.round(e.getBoundingClientRect().height), range: getComputedStyle(e).getPropertyValue('--hm-sw-slider-panel-range').trim(), track: getComputedStyle(e).getPropertyValue('--hm-sw-slider-panel-track').trim() }))
check('Viewer: SWViewerSlider = SWSlider panel with viewer tokens (#266651 / #e4dbc9)', sl.cls.includes('hm-sw-slider') && sl.look === 'panel' && sl.h === 18 && sl.range === '#266651' && sl.track === '#e4dbc9', sl)
await writeFile(path.join(out, 'result.json'), JSON.stringify({ date: new Date().toISOString(), checks, report }, null, 1))
console.log(`${checks.filter(c => c.ok).length}/${checks.length} PASS`)
await browser.close(); server.close()
process.exit(checks.every(c => c.ok) ? 0 : 1)
@@ -103,11 +103,15 @@ for (const mode of ["sw-first", "ui-first"]) {
const name = e.getByLabel("이름")
await name.click(); await name.fill("평면도-02"); await name.press("Enter")
check(`${mode} E: row input commits`, await last() === "name:평면도-02", await last())
await e.locator(".hm-sw-header__field--layer").click()
const menu = e.locator(".hm-sw-header__layer-menu")
check(`${mode} E: layer dropdown inside the popup is a fixed overlay`, await menu.evaluate(el => getComputedStyle(el).position) === "fixed")
// 2026-10-20: popup layer = the property window picker (SWPopupPropertyRow); no SWHeader field dropdown in a popup.
check(`${mode} E: no SWHeader field dropdown inside the popup`, await e.locator(".hm-sw-header__field").count() === 0)
await e.locator('[data-property-id="layer"] .hm-sw-property__trigger').click()
const menu = page.locator('.hm-sw-property__drop[data-picker="layer"]')
check(`${mode} E: layer picker inside the popup is a fixed overlay`, await menu.evaluate(el => getComputedStyle(el).position) === "fixed")
await menu.locator(".hm-sw-header__layer-item", { hasText: "DIM" }).click()
check(`${mode} E: layer selection callback`, await last() === "layer:dim", await last())
await e.getByLabel("단위").selectOption("cm")
check(`${mode} E: SWSelect unit callback`, await last() === "unit:cm", await last())
// HM UI inside an SW popup.
const ui = section("ui")
@@ -9,7 +9,9 @@ import http from 'node:http'
const runtime = process.env.SW_RUNTIME_MODULES ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules'
const require = createRequire(path.join(runtime, 'index.js'))
const out = path.resolve(process.argv[2] ?? 'docs/sw/integrated-improvement-qa/verify')
const sections = process.argv.slice(3).length ? process.argv.slice(3) : ['options', 'tool', 'popup']
// 'tool' opens sw-tool--tool-buttons, retired 2026-10-19 (storybook-inventory retiredToolIds): historical, run it only
// by name against an old build. The default runs the sections whose Stories exist (2026-10-20).
const sections = process.argv.slice(3).length ? process.argv.slice(3) : ['options', 'popup']
await mkdir(out, { recursive: true })
const root = path.resolve('storybook-static'), mime = { '.html': 'text/html', '.js': 'text/javascript', '.css': 'text/css', '.json': 'application/json', '.svg': 'image/svg+xml', '.png': 'image/png', '.ttf': 'font/ttf', '.woff2': 'font/woff2' }
const server = http.createServer(async (req, res) => { try { const p = new URL(req.url, 'http://localhost').pathname, file = path.resolve(root, '.' + decodeURIComponent(p === '/' ? '/index.html' : p)); if (path.relative(root, file).startsWith('..')) return res.writeHead(403).end(); res.setHeader('Content-Type', mime[path.extname(file)] ?? 'application/octet-stream'); res.end(await readFile(file)) } catch { res.writeHead(404).end() } })
+18 -15
View File
@@ -18,31 +18,34 @@ export async function popupChecks({ page, goto, check, log, rect, out, report })
check('popup content: SWPopupRow is the property row (72px label, 32px row, 28px box)', rowStyle.label === '72px' && rowStyle.minH === '32px' && rowStyle.box === '28px', rowStyle)
await settings.locator('[data-ui-switch]').click()
check('popup content: HM UI Switch works inside', (await log()).includes('snap=false'))
await settings.locator('[data-ui-select]').click()
await page.getByRole('option', { name: 'cm' }).click()
check('popup content: HM UI Select (portal) works inside', (await log()).includes('unit=cm'))
// 2026-10-20: the unit select is SWSelect (the Viewer 축척 지정 select), no longer the HM UI Select.
await settings.locator('[data-sw-select="unit"]').selectOption('cm')
check('popup content: SWSelect (unit) works inside', (await log()).includes('unit=cm') && await settings.getByLabel('단위').inputValue() === 'cm')
await settings.locator('[data-ui-slider] [role="slider"]').focus()
await page.keyboard.press('ArrowRight')
await page.waitForTimeout(100)
check('popup content: HM UI Slider works inside', /opacity=70/.test(await log()), await log())
await settings.locator('[data-ui-apply]').click()
check('popup content: HM UI Button works inside', (await log()).includes('apply'))
const layerField = settings.locator('[data-popup-layer] button.hm-sw-header__field')
// 2026-10-20: popup layer / color are the property window pickers (SWPopupPropertyRow); SWHeader field dropdowns are
// header-only. The same clipping / overlay / callback / Escape requirements apply to the popup pickers.
check('popup dropdown: no SWHeader field dropdown inside the popups', await page.locator('[data-popups] .hm-sw-header__field').count() === 0)
const layerField = settings.locator('[data-property-id="layer"] .hm-sw-property__trigger')
await layerField.click()
const fieldBox = await rect(layerField), menu = settings.locator('.hm-sw-header__layer-menu')
const fieldBox = await rect(settings.locator('[data-property-id="layer"] .hm-sw-property__value')), menu = page.locator('.hm-sw-property__drop[data-picker="layer"]')
const menuBox = await rect(menu)
const hit = await menu.locator('.hm-sw-header__layer-row').last().evaluate(row => { const r = row.getBoundingClientRect(); const e = document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2); return row.contains(e) })
check('popup dropdown: SW layer dropdown opens right under its field (fixed overlay) and is not clipped', await menu.evaluate(e => getComputedStyle(e).position) === 'fixed' && Math.abs(menuBox.y - (fieldBox.y + fieldBox.h)) < 1 && Math.abs(menuBox.x - fieldBox.x) < 1 && hit, { fieldBox, menuBox, hit })
check('popup dropdown: popup layer picker opens right under its field (fixed overlay, right edge) and is not clipped', await menu.evaluate(e => getComputedStyle(e).position) === 'fixed' && Math.abs(menuBox.y - (fieldBox.y + fieldBox.h)) < 1 && Math.abs((menuBox.x + menuBox.w) - (fieldBox.x + fieldBox.w)) < 1 && hit, { fieldBox, menuBox, hit })
await page.locator('[data-popups]').screenshot({ path: path.join(out, 'popup-custom-dropdown.png') })
await menu.locator('.hm-sw-header__layer-item', { hasText: 'DIM' }).click()
check('popup dropdown: selection callback, list closes, popup stays', (await log()).includes('layer=dim') && await settings.locator('.hm-sw-header__layer-menu').count() === 0 && await settings.isVisible())
await settings.locator('[data-popup-color] button.hm-sw-header__field').click()
const colorPanel = settings.locator('.hm-sw-header__menu-panel')
check('popup dropdown: SW color dropdown becomes a fixed overlay', await colorPanel.evaluate(e => getComputedStyle(e).position) === 'fixed')
check('popup dropdown: selection callback, list closes, popup stays', (await log()).includes('layer=dim') && await menu.count() === 0 && await settings.isVisible())
await settings.locator('[data-property-id="color"] .hm-sw-property__swatch-area').click()
const colorPanel = page.locator('.hm-sw-property__drop[data-picker="color"]')
check('popup dropdown: popup color picker becomes a fixed overlay', await colorPanel.evaluate(e => getComputedStyle(e).position) === 'fixed')
await colorPanel.getByLabel('초록').click()
check('popup dropdown: color chip callback', (await log()).includes('color=green'))
await page.keyboard.press('Escape')
check('popup dropdown: Escape closes the dropdown only', await settings.locator('.hm-sw-header__menu-panel').count() === 0 && await settings.isVisible())
check('popup dropdown: color chip callback', (await log()).includes('color=0,255,0'), await log())
if (await colorPanel.count()) { await colorPanel.getByLabel('초록').focus(); await page.keyboard.press('Escape') }
check('popup dropdown: Escape closes the dropdown only', await colorPanel.count() === 0 && await settings.isVisible())
const object = page.locator('[data-popup="object"]')
check('object popup: read-only rows', await object.getByLabel('이름').evaluate(e => e.readOnly) && await object.getByLabel('길이').evaluate(e => e.readOnly))
@@ -62,8 +65,8 @@ export async function popupChecks({ page, goto, check, log, rect, out, report })
await goto('sw-popup--multiple-popups')
const popup = id => page.locator(`[data-popup="${id}"]`)
const b = popup('b')
await b.locator('[data-popup-layer] button.hm-sw-header__field').click()
const bMenu = b.locator('.hm-sw-header__layer-menu'), bHit = await bMenu.locator('.hm-sw-header__layer-footer').evaluate(row => { const r = row.getBoundingClientRect(); return row.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) })
await b.locator('[data-property-id="layer"] .hm-sw-property__trigger').click()
const bMenu = page.locator('.hm-sw-property__drop[data-picker="layer"]'), bHit = await bMenu.locator('.hm-sw-header__layer-footer').evaluate(row => { const r = row.getBoundingClientRect(); return row.contains(document.elementFromPoint(r.x + r.width / 2, r.y + r.height / 2)) })
const bBox = await rect(b), menuB = await rect(bMenu)
check('short popup: dropdown extends past the popup and stays fully visible (no clipping)', menuB.y + menuB.h > bBox.y + bBox.h && bHit, { bBox, menuB, bHit })
await page.locator('[data-stack]').screenshot({ path: path.join(out, 'popup-short-dropdown.png') })
@@ -26,6 +26,9 @@ export const movedHomeMenuIds = Object.fromEntries(["figma-home-on:figma", "capt
export const retiredHomeMenuCompareIds = ["figma", "capture-active", "capture-outline", "capture-recent", "variants", "recent-files"].map(id => `sw-header--home-menu-${id}`)
// 2026-10-20: Property Panel · 4개 비교 moved to SW/Popup in the sidebar; its ID is unchanged (meta id "sw-header").
export const movedToPopupIds = ["sw-header--property-panel-compare"]
// 2026-10-20 (Footer Story cleanup, user request): 기본 Footer and the States page were removed. Their screens are sections
// of sw-footer--full-footer (Figma 기본 구성, 상태); the aux Figma comparison moved to sw-footer--figma-variants.
export const retiredFooterIds = ["default", "states"].map(id => `sw-footer--${id}`)
export const integratedChangedFiles = [
'packages/sw/README.md', 'packages/sw/package.json', 'packages/sw/build.mjs',
'packages/sw/ai/AI_USAGE.md', 'packages/sw/ai/COMPONENT_CATALOG.md', 'packages/sw/ai/COMPOSITION_PATTERNS.md', 'packages/sw/ai/DESIGN_RULES.md',
@@ -32,6 +32,8 @@ check(`direct URL render: ${sw.length} SW Stories, no error display / page error
// 2. removed IDs are gone
const removed = ['figma', 'capture-active', 'capture-outline', 'capture-recent', 'variants', 'recent-files'].map(id => `sw-header--home-menu-${id}`)
check('removed Home Menu IDs absent from index', removed.every(id => !index.entries[id]))
// 2026-10-20 Footer cleanup: retired Footer IDs are gone, the two main Footer Stories stay.
check('removed Footer IDs absent, full-footer / figma-variants kept', ['sw-footer--default', 'sw-footer--states'].every(id => !index.entries[id]) && ['sw-footer--full-footer', 'sw-footer--figma-variants'].every(id => index.entries[id]))
// 3. Property panel in Popup: four states
await page.goto(`${base}/iframe.html?id=sw-header--property-panel-compare&viewMode=story`)
await page.locator('[data-compare]').first().waitFor()
@@ -159,7 +159,7 @@ const snapshot = page => page.evaluate(() => [...document.querySelectorAll('.hm-
}))
const regression = []
for (const width of [1920, 1440, 1180, 900, 560]) for (const id of ids) {
if (width !== 1440 && !/responsive|viewer--viewer-header|footer--default|overview/.test(id)) continue
if (width !== 1440 && !/responsive|viewer--viewer-header|footer--full-footer|overview/.test(id)) continue
const page = await newPage(width)
await open(page, id)
const a = await snapshot(page)