fix(sw): refine property panel and Firefox range styles

- Property bar ends after its last field (content width, scrolls when longer)
- Remove Figma canvas layers from property tool and close SVGs
- Suppress header field hover while the field's own dropdown is open
- Gauge: Figma red fill pill, beige track, Figma thumb SVG; shared
  paint for WebKit and Gecko (::-moz-range-*) so Firefox matches Chrome
- Story buttons for gauge external value / disabled / read-only
- Browser checks: follow-up fixes, Chrome vs Firefox range, close button

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shkim4andClaude Opus 5.5 committed 2026-10-19 09:10:13 +09:00
1 parent fc26afd440
commit 724c51732b
66 files changed
+3823 -39

No files matched your search

Binary file not shown.

After

Width:  |  Height:  |  Size: 1008 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 39 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.1 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 6.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 248 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 255 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 211 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.0 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 901 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 719 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 739 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 30 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 49 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 28 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 48 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 52 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 21 KiB

@@ -0,0 +1,29 @@
<svg width="20" height="21" viewBox="0 0 20 21" fill="none" xmlns="http://www.w3.org/2000/svg">
<g filter="url(#filter0_dd_26_12081)">
<g clip-path="url(#paint0_angular_26_12081_clip_path)" data-figma-skip-parse="true"><g transform="matrix(0 0.007 -0.007 0 10 10)"><foreignObject x="-1285.71" y="-1285.71" width="2571.43" height="2571.43"><div xmlns="http://www.w3.org/1999/xhtml" style="background:conic-gradient(from 90deg,rgba(81, 0, 0, 1) 0deg,rgba(81, 0, 0, 1) 12.8082deg,rgba(172, 29, 29, 1) 48.0519deg,rgba(121, 22, 22, 1) 92.222deg,rgba(255, 177, 177, 1) 127.031deg,rgba(228, 0, 0, 1) 157.271deg,rgba(255, 166, 166, 1) 206.171deg,rgba(190, 23, 23, 1) 252.461deg,rgba(81, 0, 0, 1) 292.192deg,rgba(81, 0, 0, 1) 327.01deg,rgba(81, 0, 0, 1) 360deg);height:100%;width:100%;opacity:1"></div></foreignObject></g></g><rect x="3" y="3" width="14" height="14" rx="7" data-figma-gradient-fill="{&#34;type&#34;:&#34;GRADIENT_ANGULAR&#34;,&#34;stops&#34;:[{&#34;color&#34;:{&#34;r&#34;:0.32083332538604736,&#34;g&#34;:0.0,&#34;b&#34;:0.0,&#34;a&#34;:1.0},&#34;position&#34;:0.035578250885009766},{&#34;color&#34;:{&#34;r&#34;:0.67500001192092896,&#34;g&#34;:0.11531250178813934,&#34;b&#34;:0.11531250178813934,&#34;a&#34;:1.0},&#34;position&#34;:0.13347755372524261},{&#34;color&#34;:{&#34;r&#34;:0.47499999403953552,&#34;g&#34;:0.087083332240581512,&#34;b&#34;:0.087083332240581512,&#34;a&#34;:1.0},&#34;position&#34;:0.25617215037345886},{&#34;color&#34;:{&#34;r&#34;:1.0,&#34;g&#34;:0.69583332538604736,&#34;b&#34;:0.69583332538604736,&#34;a&#34;:1.0},&#34;position&#34;:0.35286325216293335},{&#34;color&#34;:{&#34;r&#34;:0.89411765336990356,&#34;g&#34;:0.0,&#34;b&#34;:0.0,&#34;a&#34;:1.0},&#34;position&#34;:0.43686389923095703},{&#34;color&#34;:{&#34;r&#34;:1.0,&#34;g&#34;:0.65416663885116577,&#34;b&#34;:0.65416663885116577,&#34;a&#34;:1.0},&#34;position&#34;:0.57269597053527832},{&#34;color&#34;:{&#34;r&#34;:0.74583333730697632,&#34;g&#34;:0.093229167163372040,&#34;b&#34;:0.093229167163372040,&#34;a&#34;:1.0},&#34;position&#34;:0.70128107070922852},{&#34;color&#34;:{&#34;r&#34;:0.32083332538604736,&#34;g&#34;:0.0,&#34;b&#34;:0.0,&#34;a&#34;:1.0},&#34;position&#34;:0.81164407730102539},{&#34;color&#34;:{&#34;r&#34;:0.32083332538604736,&#34;g&#34;:0.0,&#34;b&#34;:0.0,&#34;a&#34;:1.0},&#34;position&#34;:0.90836137533187866}],&#34;transform&#34;:{&#34;m00&#34;:8.5725282327939710e-16,&#34;m01&#34;:-14.0,&#34;m02&#34;:17.0,&#34;m10&#34;:14.0,&#34;m11&#34;:8.5725282327939710e-16,&#34;m12&#34;:3.0},&#34;opacity&#34;:1.0,&#34;blendMode&#34;:&#34;NORMAL&#34;,&#34;stopsVar&#34;:[{&#34;color&#34;:{&#34;r&#34;:0.32083332538604736,&#34;g&#34;:0.0,&#34;b&#34;:0.0,&#34;a&#34;:1.0},&#34;position&#34;:0.035578250885009766},{&#34;color&#34;:{&#34;r&#34;:0.67500001192092896,&#34;g&#34;:0.11531250178813934,&#34;b&#34;:0.11531250178813934,&#34;a&#34;:1.0},&#34;position&#34;:0.13347755372524261},{&#34;color&#34;:{&#34;r&#34;:0.47499999403953552,&#34;g&#34;:0.087083332240581512,&#34;b&#34;:0.087083332240581512,&#34;a&#34;:1.0},&#34;position&#34;:0.25617215037345886},{&#34;color&#34;:{&#34;r&#34;:1.0,&#34;g&#34;:0.69583332538604736,&#34;b&#34;:0.69583332538604736,&#34;a&#34;:1.0},&#34;position&#34;:0.35286325216293335},{&#34;color&#34;:{&#34;r&#34;:0.89411765336990356,&#34;g&#34;:0.0,&#34;b&#34;:0.0,&#34;a&#34;:1.0},&#34;position&#34;:0.43686389923095703},{&#34;color&#34;:{&#34;r&#34;:1.0,&#34;g&#34;:0.65416663885116577,&#34;b&#34;:0.65416663885116577,&#34;a&#34;:1.0},&#34;position&#34;:0.57269597053527832},{&#34;color&#34;:{&#34;r&#34;:0.74583333730697632,&#34;g&#34;:0.093229167163372040,&#34;b&#34;:0.093229167163372040,&#34;a&#34;:1.0},&#34;position&#34;:0.70128107070922852},{&#34;color&#34;:{&#34;r&#34;:0.32083332538604736,&#34;g&#34;:0.0,&#34;b&#34;:0.0,&#34;a&#34;:1.0},&#34;position&#34;:0.81164407730102539},{&#34;color&#34;:{&#34;r&#34;:0.32083332538604736,&#34;g&#34;:0.0,&#34;b&#34;:0.0,&#34;a&#34;:1.0},&#34;position&#34;:0.90836137533187866}],&#34;visible&#34;:true}"/>
<rect x="4" y="4" width="12" height="12" rx="6" stroke="url(#paint1_linear_26_12081)" stroke-opacity="0.4" stroke-width="2"/>
</g>
<defs>
<filter id="filter0_dd_26_12081" x="0" y="0" width="20" height="21" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset/>
<feGaussianBlur stdDeviation="1.5"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.108333 0 0 0 0 0.100706 0 0 0 0 0.0862153 0 0 0 1 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_26_12081"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="2"/>
<feGaussianBlur stdDeviation="1"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0.108333 0 0 0 0 0.100706 0 0 0 0 0.0862153 0 0 0 0.4 0"/>
<feBlend mode="normal" in2="effect1_dropShadow_26_12081" result="effect2_dropShadow_26_12081"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect2_dropShadow_26_12081" result="shape"/>
</filter>
<clipPath id="paint0_angular_26_12081_clip_path"><rect x="3" y="3" width="14" height="14" rx="7"/></clipPath><linearGradient id="paint1_linear_26_12081" x1="5.625" y1="4.3125" x2="10" y2="17" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF5656"/>
<stop offset="0.681666" stop-color="#E40000"/>
<stop offset="1" stop-color="#550000"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 5.5 KiB

@@ -0,0 +1,11 @@
<svg width="60" height="6" viewBox="0 0 60 6" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M2.96798 0.5H57.032C58.395 0.5 59.5 1.60495 59.5 2.96798C59.5 4.331 58.395 5.43595 57.032 5.43595H2.96798C1.60495 5.43595 0.5 4.331 0.5 2.96798C0.5 1.60495 1.60495 0.5 2.96798 0.5Z" fill="url(#paint0_linear_26_12080)" stroke="black"/>
<defs>
<linearGradient id="paint0_linear_26_12080" x1="30" y1="1" x2="30" y2="4.93595" gradientUnits="userSpaceOnUse">
<stop stop-color="#8E0000"/>
<stop offset="0.370561" stop-color="#FF4242"/>
<stop offset="0.76" stop-color="#E40000"/>
<stop offset="1" stop-color="#1E0000"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 KiB

File diff suppressed because it is too large. Load diff
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,81 @@
{
"label": "after",
"checks": [
{
"name": "size 44 x 30, top-right of the panel",
"status": "PASS",
"data": {
"w": 44,
"h": 30,
"right": 1,
"top": 1,
"imgs": [
[
"hm-sw-property__close-default",
44,
30
],
[
"hm-sw-property__close-hover",
44,
30
]
]
}
},
{
"name": "Default: no canvas-light pixels on the button edge / corner",
"status": "PASS",
"data": {
"bright": 0,
"where": []
}
},
{
"name": "Hover: hover asset shown, no canvas-light pixels on the edge / corner",
"status": "PASS",
"data": {
"vis": [
"hidden",
"visible"
],
"bright": 0,
"where": []
}
},
{
"name": "click closes the panel, focus back on 속성창 trigger",
"status": "PASS",
"data": {
"open": false,
"focusTrigger": true,
"log": "legacyToggle → open(true) → open(false)"
}
},
{
"name": "Escape closes the panel, focus back on 속성창 trigger",
"status": "PASS",
"data": {
"open": false,
"focusTrigger": true,
"log": "rue) → open(false) → legacyToggle → open(true) → open(false)"
}
},
{
"name": "no console / page errors",
"status": "PASS",
"data": []
}
],
"errors": [],
"pixels": {
"def": {
"bright": 0,
"where": []
},
"hov": {
"bright": 0,
"where": []
}
}
}
Binary file not shown.

After

Width:  |  Height:  |  Size: 9.3 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 6.1 KiB

@@ -0,0 +1,81 @@
{
"label": "before",
"checks": [
{
"name": "size 44 x 30, top-right of the panel",
"status": "PASS",
"data": {
"w": 44,
"h": 30,
"right": 1,
"top": 1,
"imgs": [
[
"hm-sw-property__close-default",
44,
30
],
[
"hm-sw-property__close-hover",
44,
30
]
]
}
},
{
"name": "Default: no canvas-light pixels on the button edge / corner",
"status": "PASS",
"data": {
"bright": 0,
"where": []
}
},
{
"name": "Hover: hover asset shown, no canvas-light pixels on the edge / corner",
"status": "PASS",
"data": {
"vis": [
"hidden",
"visible"
],
"bright": 0,
"where": []
}
},
{
"name": "click closes the panel, focus back on 속성창 trigger",
"status": "PASS",
"data": {
"open": false,
"focusTrigger": true,
"log": "legacyToggle → open(true) → open(false)"
}
},
{
"name": "Escape closes the panel, focus back on 속성창 trigger",
"status": "PASS",
"data": {
"open": false,
"focusTrigger": true,
"log": "rue) → open(false) → legacyToggle → open(true) → open(false)"
}
},
{
"name": "no console / page errors",
"status": "PASS",
"data": []
}
],
"errors": [],
"pixels": {
"def": {
"bright": 0,
"where": []
},
"hov": {
"bright": 0,
"where": []
}
}
}
+2
View File
@@ -1,5 +1,7 @@
# SW Header 디자인 명세
> **후속 디자인 수정 4건(2026-10-19):** 속성Bar는 Figma 1222px 고정 폭 대신 마지막 필드에서 끝나는 콘텐츠 폭(공간 부족 시 내부 스크롤)을 사용합니다. 속성창 도구 아이콘 SVG의 Figma 캔버스 배경을 제거했고, 레이어·색상·문자·치수스타일 필드는 자기 Dropdown이 열린 동안 Hover를 적용하지 않으며, 게이지는 빨강 채움 + 베이지 Track입니다. 아래 26:10492의 "최대 1222" 설명보다 우선합니다. [검증 기록](./sw-header-followup-four-design-fixes-verification-2026-10-19.md).
> **상세 버튼 공통화 기준(2026-10-16, 이번 요청):** 상세 5개는 기존 `SWHeaderMenuDetail`을 공유하며 **현재 문자스타일 상세**를 우선합니다. Default는 배경 `rgba(197,177,158,.15)`·radius 24·글자 35%·아이콘 SVG 20%, Hover는 배경 .25·radius 25·글자 35%·아이콘 SVG 35%입니다. Disabled는 Default 외형과 클릭 차단을 사용합니다. Callback이 없어도 Hover되며 클릭은 무동작입니다. 치수 상세의 글자 100% Hover와 Callback 부재에 따른 비활성 설명은 이 기준으로 대체합니다. 기존 폭 113/99/128/142/142px·높이 24px, 제거된 Text Stroke 선언을 유지합니다. 과거 검증 문서는 당시 기록으로 보존합니다. [이번 검증 기록](./sw-header-detail-buttons-verification-2026-10-16.md).
> **3차 기준(2026-10-16):** 문서 끝 [3차 기준](#3차-기준--구성요소-표시메뉴-순서속성창-상태형상-slot-2026-10-16)이 구성요소 표시·메뉴 순서·속성창 상태·형상 Slot에 우선합니다. [3차 검증 기록](./sw-header-composition-shape-verification-2026-10-16.md).
@@ -0,0 +1,55 @@
# SW 속성창 게이지 Firefox 지원 · 닫기 SVG · Storybook Build 검증 (2026-10-19)
기준: `feature/sw-package` @ `fc26afd4401d5b3bb74c8818cacb76f669fcd24b`. 직전 후속 4건([검증 기록](./sw-header-followup-four-design-fixes-verification-2026-10-19.md))의 미커밋 변경 위에서 진행했다.
## A. Firefox 게이지
- **원인:** 게이지 디자인이 `::-webkit-slider-runnable-track` / `::-webkit-slider-thumb`에만 정의돼 있었다. Gecko는 이 의사 요소를 쓰지 않아 기본 range 외형이 남았다.
- **방식:** Track 그림(채움 왼쪽 검정 선 + Figma 6926 채움 + 베이지 Track), Thumb 그림(`property-range-thumb.svg`), Thumb 그림자를 input의 custom property(`--hm-sw-range-track-paint`, `--hm-sw-range-thumb-paint`, `--hm-sw-range-thumb-shadow`)로 한 번만 정의했다. 엔진별 규칙이 이 값을 공유한다.
- WebKit/Blink: 기존 track + thumb(`margin-top: -4px`).
- Gecko: `::-moz-range-track`(6px, radius 3) + `::-moz-range-thumb`(14px, 자동 수직 중앙). `::-moz-range-progress`는 높이 0·투명으로 둬서 채움이 두 번 그려지지 않는다.
- 색·자산은 Chrome PASS 값 그대로이고, 새 색은 없다. 각 엔진은 자기 의사 요소만 인식하므로 다른 엔진에 영향이 없다.
- **검증:** `node packages/sw/tests/range-cross-browser.browser.mjs docs/sw/firefox-range-qa` → **29/29 PASS** ([result.json](./firefox-range-qa/result.json), [비교 이미지](./firefox-range-qa/compare-chrome-firefox.png)).
- 브라우저: Chrome 155.0.8059.80(Playwright), 설치된 **Firefox 158.0 실브라우저**(WebDriver BiDi로 직접 제어, Playwright Firefox 빌드 미사용).
- 조건: 같은 Story(`SW/Header/Property Panel` → 원 1개 → 속성창), 1400×1000, DPR 1, 같은 순서의 실제 키 입력.
- 두 브라우저 공통 확인: 빨강 채움, 베이지 Track, 위·아래 검정 1px의 6px 높이, Thumb 12px 검출 폭(14px 그림), Thumb 세로 중심이 Track 중심(8.0).
- 값 0 / 0.25 / 0.65(→ 40회) / 1(End) / 0(Home) / 외부 Props 0.8에서 Thumb 중심이 기대값 ±1px.
- Enter 확정 시 숫자 입력 0.65와 Callback `property(opacity, 0.65)` 확인.
- Disabled·ReadOnly: range가 비활성화되어 키 입력으로 값이 바뀌지 않고, 해제하면 복원된다.
- Chrome 대비 차이: 1,800픽셀 중 48 초과 차이는 최대 1px, 평균 채널 차이 1.2–2.1. 채움·Track 열 색은 ±1 이내.
- 남은 차이는 Track 아래 1px 하이라이트 행 하나다. Chrome #EADEC8, Firefox #EDE5D4로 Gecko의 box-shadow 안티앨리어싱 차이다.
- 최종 실행의 Thumb 중심(px)은 9개 상태 모두 두 엔진이 같다: 0 → 7, 0.25 → 25, 0.65 → 53, 0.8 → 64, 1 → 79. 이전 실행에서는 Chrome Focus 테두리가 포함돼 극값이 7.5 / 78.5로 측정됐다.
- **Focus-visible:** 기존 `.hm-sw-property input:focus-visible`(2px #A67D03) 규칙은 엔진 공통이다.
- headless Firefox는 BiDi 합성 키 입력에서 버튼·텍스트 입력까지 포함해 `:focus-visible`을 켜지 않는다(별도 대조 페이지에서 확인).
- 그래서 비교 스크린샷은 두 브라우저 모두 테스트 전용으로 outline만 숨기고 찍었다. Firefox Focus 링의 시각 확인은 NOT VERIFIED다.
- 콘솔: 두 브라우저 모두 컴포넌트 오류 0. Firefox에는 Storybook manager 글꼴(`/sb-common-assets/nunito-sans-regular.woff2`) 다운로드 실패 1건이 있으며, Storybook 인프라 항목이라 제외했다.
- Story: `PropertyPanelDemo`에 `투명도 외부 값 변경` / `투명도 Disabled` / `투명도 ReadOnly` 버튼 3개만 추가했다(공개 API 변경 없음).
- Chrome 회귀: 기존 `followup-four.browser.mjs` **39/39 PASS** 재실행.
## B. 닫기 버튼 SVG (Figma 26:12366)
- `property-close.svg` / `property-close-hover.svg` 모두 도구 아이콘과 같은 Figma 캔버스 4요소를 포함하고 있었다: `#F5F5F5` 44×30 배경, 흰 페이지 path, 10% 검정 페이지 테두리, `#9747FF` 점선 프레임.
- **실제 노출:** 버튼 몸체가 44×30을 덮고 패널의 둥근 모서리·overflow가 대부분을 가린다. 그래서 흰 테두리처럼 보이지는 않았다(가장자리 밝은 픽셀 0). 다만 오른쪽 위 radius 3 곡선의 안티앨리어싱 픽셀(4배율 기준 19 / 15개, 최대 채널 차이 67 / 35)에 밝은 캔버스가 섞여 있었다.
- **수정:** 두 파일에서 위 4요소만 제거했다. viewBox 44×30(버튼과 동일), 버튼 path·Gradient·Hover 빨강 #E40000·X 아이콘·Default/Hover 분리는 그대로다.
- **검증:** `node packages/sw/tests/property-close.browser.mjs docs/sw/property-close-qa after` → **6/6 PASS** ([before](./property-close-qa/before-result.json) / [after](./property-close-qa/after-result.json)).
- 크기 44×30, 패널 오른쪽·위 1px.
- Default/Hover 가장자리 밝은 픽셀 0, Hover 자산 표시 확인.
- 클릭 닫힘 + 속성창 버튼으로 Focus 복귀, Escape 닫힘 + Focus 복귀, 오류 0.
- 전후 차이는 위 모서리 곡선 픽셀뿐이다.
- `.hm-sw-property__tool` 관련 SVG는 이번에 수정하지 않았다.
## C. Storybook Build
- `npm run build-storybook`(prebuild: theme-defaults 검사 + 패키지 build 포함) → **exit 0, "Storybook build completed successfully"**.
- 경고 4종은 모두 기존 경고로 SW 변경과 무관하다: Tailwind `bg-[var(...)]` CSS 최적화, lightningcss `host-context`, 500kB 초과 chunk, PLUGIN_TIMINGS. 앞의 3종은 `docs/org-chart/consistency-review-p11-6.md` 등에 이미 기록돼 있다.
- 결과물 `storybook-static/assets/sw-header-*.css`에 `::-moz-range-track/progress/thumb` 각 1회, Thumb SVG data URI가 포함됐다. Story bundle의 SVG에는 `#F5F5F5` / `#9747FF`가 0건이다.
## 재사용한 기존 PASS (재실행 안 함)
Storybook 32개 렌더, 구성 77/77, 브라우저 271건, 기본 Header 10폭 차이 0. 공통 Header CSS·팝업 구조는 바꾸지 않았다(range 의사 요소와 닫기 SVG만 변경).
## NOT VERIFIED
- Firefox에서 키보드 Focus 링(`:focus-visible`)의 시각 표시. 자동화 입력 한계로 확인하지 못했고, 규칙은 엔진 공통 CSS다.
- 실제 Windows Firefox 창(비 headless)과 고배율(DPR 2) 렌더링.
- Safari(WebKit) 실브라우저.
@@ -0,0 +1,32 @@
# SW Header 후속 디자인 수정 4건 검증 (2026-10-19)
기준: `feature/sw-package` @ `fc26afd4401d5b3bb74c8818cacb76f669fcd24b`(시작 시 Working Tree clean). Commit·Push 없음.
증거: [followup-four-qa/](./followup-four-qa/) · 결과 [result.json](./followup-four-qa/result.json) · 재실행 `node packages/sw/tests/followup-four.browser.mjs docs/sw/followup-four-qa` (Storybook 6024, `packages/sw/dist` 사용 → SW build 후 실행).
## 변경 요약
| 작업 | 원인 / 결정 | 변경 |
|---|---|---|
| A 속성Bar 길이 | 바가 `width:100%`(최대 1222px)로 앵커 폭을 채워 마지막 필드 뒤가 빈 갈색 영역 | 바 `width: max-content; max-width: 100%`, 앵커는 고정 `width` 대신 `maxWidth: calc(100% - 왼쪽 오프셋)`. 마지막 필드 + 기존 오른쪽 padding 6px + border 1px에서 끝남. 공간보다 길면 Header 오른쪽 끝까지 늘어나고 내부 가로 스크롤. Figma 1222px 고정 폭은 사용자 최신 지시로 대체. **2026-10-19 사용자 확정**: 마지막 필드에서 끝나는 콘텐츠 폭 정책 |
| B 도구 아이콘 흰 테두리 | CSS 원인 아님(border 0·outline none·배경 투명). **SVG 자체**에 Figma 페이지 캔버스가 함께 export됨: `#F5F5F5` 29×28 사각형, 흰색 페이지 path, 10% 검정 페이지 테두리, `#9747FF` 점선 컴포넌트 세트 테두리. 버튼 사각형은 29×27.129·rx 3이므로 네 모서리와 아래 0.87px에 밝은 캔버스가 비침 | 3개 SVG(Default/Hover/Active)에서 위 캔버스 요소 4개만 제거, viewBox/높이 29×27.129, img 높이 28 → 27.13px. 버튼 사각형·`#092920` 외곽·`#08251C` / Active `#A67D03` 내부 Gradient·inner shadow·아이콘 path는 그대로 |
| C Dropdown 열림 Hover | 필드 Hover(글자·미리보기·노란 ring)가 열린 상태에서도 적용 | 기존 필드 Hover selector 5개에 `:not([data-expanded="true"])` 추가. 열림 표현(`data-expanded` 스타일, 선스타일 고정 ring)·내부 행·상세 버튼 Hover·Focus-visible 미변경, `pointer-events` 미사용 |
| D 게이지바 | 기존 Track 전체가 빨강, 채움/미채움 구분 없음 | 입력값에서 0–1 비율을 계산해 `--hm-sw-range-fill`로 전달. **사용자 예시 이미지**([reference/user-gauge-example.png](./followup-four-qa/reference/user-gauge-example.png))를 행 단위로 측정해 적용: 채움은 사용자 제공 Figma 원본 [Rectangle 6926.svg](./followup-four-qa/reference/Rectangle%206926.svg): 6px 알약, 검정 1px 테두리, 내부 #8E0000 / #FF4242 37% / #E40000 76% / #1E0000 세로 Gradient. 왼쪽 끝은 radius 3과 왼쪽 검정 1px로 둥근 테두리, 오른쪽 끝은 항상 Thumb 아래. 미채움 베이지는 예시 이미지 측정값 #AEA28B / #C3B9A4 / #C7BDA9 ×2 / #B7AC96 / #AC9E84, 아래 1px 하이라이트 #EADEC8. 채움 끝은 Thumb 중심. Thumb는 사용자 제공 Figma 원본 [Rectangle 6915.svg](./followup-four-qa/reference/Rectangle%206915.svg) 14px(conic + 2px Gradient 테두리 40%)를 `assets/property-range-thumb.svg`로 저장해 사용(viewBox만 14px 본체로 자르고 Figma 편집용 속성 제거). 원본 그림자 필터 2개는 같은 값의 CSS `box-shadow`(0 0 3px #1C1A16, 0 2px 2px 40%). 토큰 `--hm-sw-property-range-fill / -fill-edge / -track / -highlight` |
## 실행 결과 (Chrome headless, 2회 연속 39/39 PASS)
- **A** 1920: 바 x 377–906.7(529.7px), 마지막 필드 끝 899.7 → 차이 7px(padding 6 + border 1). 높이 28, Header 49, 하단 radius 6/6, 하단 border 1px #08251C. 끝 안쪽은 #271F13, 바깥은 배경색이며 하단 라인도 같은 x에서 끝남. 필드 4 → 10개: 1169px로 증가, 잘림 0. 24개: Header 끝(1920)에서 멈추고 스크롤로 마지막 필드 접근. 1개 객체로 교체: 158px로 축소. 1440 / 1080 / 768: Header 안, 28px, 페이지 가로 스크롤 0(768은 내부 스크롤).
- **B** 버튼·이미지 29 × 27.13, CSS border/outline/background 없음. Default / Hover / Active 각 버튼 영역에서 밝은(≥235) 픽셀 0. Active 클릭 시 `aria-pressed=true`.
- **C** 레이어·색상·문자·치수: 열림 상태 Hover 전후 ring opacity·글자색 동일(ring 0). 닫힌 뒤 Hover ring 1·글자 #FFC600 복원. 선스타일: 기존대로 열림 ring 1 유지, Hover로 변하지 않음.
- **D(Hover 유지)** 5개 Dropdown 모두 비선택 내부 행(색상은 ByLayer/ByBlock) Hover 변화 있음, 하단 상세 버튼 Hover 변화 있음.
- **E** 렌더링 미채움 6행은 예시 측정값과, 채움 행은 Figma 6926(검정 / #B51717 / #FE4040 / #ED1515 / #790000 / 검정, 1x)과 일치(1x 픽셀 측정, [x8 확대](./followup-four-qa/E-gauge-zoom-x8.png)). 0.25: Thumb 왼쪽 빨강 / 오른쪽 베이지. 키보드 →×40 = 0.65, 채움 동기화, Enter commit 후 숫자 입력 0.65(Callback 경로). End = 1, Home = 0. 숫자 입력에 0.5 입력 시 게이지 0.5 동기화.
- **F** 속성창 4상태 314×256 / 361 / 658 / 754 렌더.
- **G** page / console 오류 0.
- SW `typecheck`, SW `build`, 루트 `tsc -b` PASS.
## 미확정 / 미검증
- 예시 이미지의 미채움 Track은 Figma 26:11978의 어두운 빨강 대신 베이지로 대체했습니다(사용자 지시).
- 예시 이미지 아래의 최소/최대 라벨(`0`, `90 %`)은 현재 속성창 구현과 Figma에 없는 요소라 추가하지 않았습니다(기존 단위 `%` 표시는 유지).
- `property-close*.svg`에도 같은 Figma 캔버스 요소가 있으나 이번 범위가 아니라 수정하지 않았습니다(44×30 전체가 덮이는지 미확인).
- Firefox `::-moz-range-*`는 기존 코드에도 없으며 이번에도 추가하지 않았습니다.
- Storybook Build·기존 77/77 구성·271건 브라우저·10폭 회귀는 재실행하지 않았습니다(기존 PASS 재사용).
@@ -1,10 +1,6 @@
<svg width="44" height="30" viewBox="0 0 44 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="44" height="30" fill="#F5F5F5"/>
<g id="2. &#237;&#151;&#164;&#235;&#141;&#148;">
<path d="M-2243 -4131C-2243 -4132.1 -2242.1 -4133 -2241 -4133H3016C3017.1 -4133 3018 -4132.1 3018 -4131V8497C3018 8498.1 3017.1 8499 3016 8499H-2241C-2242.1 8499 -2243 8498.1 -2243 8497V-4131Z" fill="white"/>
<path d="M-2241 -4132H3016V-4134H-2241V-4132ZM3017 -4131V8497H3019V-4131H3017ZM3016 8498H-2241V8500H3016V8498ZM-2242 8497V-4131H-2244V8497H-2242ZM-2241 8498C-2241.55 8498 -2242 8497.55 -2242 8497H-2244C-2244 8498.66 -2242.66 8500 -2241 8500V8498ZM3017 8497C3017 8497.55 3016.55 8498 3016 8498V8500C3017.66 8500 3019 8498.66 3019 8497H3017ZM3016 -4132C3016.55 -4132 3017 -4131.55 3017 -4131H3019C3019 -4132.66 3017.66 -4134 3016 -4134V-4132ZM-2241 -4134C-2242.66 -4134 -2244 -4132.66 -2244 -4131H-2242C-2242 -4131.55 -2241.55 -4132 -2241 -4132V-4134Z" fill="black" fill-opacity="0.1"/>
<g id="btn_close popup">
<rect x="-19.5" y="-69.5" width="83" height="119" rx="4.5" stroke="#9747FF" stroke-dasharray="10 5"/>
<g id="Property 1=Variant2">
<g id="Rectangle 7347">
<path d="M0 0H41C42.6569 0 44 1.34315 44 3V30H0V0Z" fill="url(#paint0_linear_25_48)"/>

Before

Width:  |  Height:  |  Size: 1.9 KiB

After

Width:  |  Height:  |  Size: 958 B

@@ -1,10 +1,6 @@
<svg width="44" height="30" viewBox="0 0 44 30" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="44" height="30" fill="#F5F5F5"/>
<g id="2. &#237;&#151;&#164;&#235;&#141;&#148;">
<path d="M-2243 -4081C-2243 -4082.1 -2242.1 -4083 -2241 -4083H3016C3017.1 -4083 3018 -4082.1 3018 -4081V8547C3018 8548.1 3017.1 8549 3016 8549H-2241C-2242.1 8549 -2243 8548.1 -2243 8547V-4081Z" fill="white"/>
<path d="M-2241 -4082H3016V-4084H-2241V-4082ZM3017 -4081V8547H3019V-4081H3017ZM3016 8548H-2241V8550H3016V8548ZM-2242 8547V-4081H-2244V8547H-2242ZM-2241 8548C-2241.55 8548 -2242 8547.55 -2242 8547H-2244C-2244 8548.66 -2242.66 8550 -2241 8550V8548ZM3017 8547C3017 8547.55 3016.55 8548 3016 8548V8550C3017.66 8550 3019 8548.66 3019 8547H3017ZM3016 -4082C3016.55 -4082 3017 -4081.55 3017 -4081H3019C3019 -4082.66 3017.66 -4084 3016 -4084V-4082ZM-2241 -4084C-2242.66 -4084 -2244 -4082.66 -2244 -4081H-2242C-2242 -4081.55 -2241.55 -4082 -2241 -4082V-4084Z" fill="black" fill-opacity="0.1"/>
<g id="btn_close popup">
<rect x="-19.5" y="-19.5" width="83" height="119" rx="4.5" stroke="#9747FF" stroke-dasharray="10 5"/>
<g id="Property 1=Default">
<g id="Rectangle 7347">
<path d="M41 0.5C42.3807 0.5 43.5 1.61929 43.5 3V29.5H0.5V0.5H41Z" fill="url(#paint0_linear_25_48)"/>

Before

Width:  |  Height:  |  Size: 2.9 KiB

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -0,0 +1,11 @@
<svg width="14" height="14" viewBox="3 3 14 14" fill="none" xmlns="http://www.w3.org/2000/svg">
<g clip-path="url(#paint0_angular_26_12081_clip_path)"><g transform="matrix(0 0.007 -0.007 0 10 10)"><foreignObject x="-1285.71" y="-1285.71" width="2571.43" height="2571.43"><div xmlns="http://www.w3.org/1999/xhtml" style="background:conic-gradient(from 90deg,rgba(81, 0, 0, 1) 0deg,rgba(81, 0, 0, 1) 12.8082deg,rgba(172, 29, 29, 1) 48.0519deg,rgba(121, 22, 22, 1) 92.222deg,rgba(255, 177, 177, 1) 127.031deg,rgba(228, 0, 0, 1) 157.271deg,rgba(255, 166, 166, 1) 206.171deg,rgba(190, 23, 23, 1) 252.461deg,rgba(81, 0, 0, 1) 292.192deg,rgba(81, 0, 0, 1) 327.01deg,rgba(81, 0, 0, 1) 360deg);height:100%;width:100%;opacity:1"></div></foreignObject></g></g><rect x="3" y="3" width="14" height="14" rx="7"/>
<rect x="4" y="4" width="12" height="12" rx="6" stroke="url(#paint1_linear_26_12081)" stroke-opacity="0.4" stroke-width="2"/>
<defs>
<clipPath id="paint0_angular_26_12081_clip_path"><rect x="3" y="3" width="14" height="14" rx="7"/></clipPath><linearGradient id="paint1_linear_26_12081" x1="5.625" y1="4.3125" x2="10" y2="17" gradientUnits="userSpaceOnUse">
<stop stop-color="#FF5656"/>
<stop offset="0.681666" stop-color="#E40000"/>
<stop offset="1" stop-color="#550000"/>
</linearGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 1.3 KiB

@@ -1,10 +1,6 @@
<svg width="29" height="28" viewBox="0 0 29 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="29" height="28" fill="#F5F5F5"/>
<svg width="29" height="27.129" viewBox="0 0 29 27.129" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="2. &#237;&#151;&#164;&#235;&#141;&#148;">
<path d="M-2585 -4174.26C-2585 -4175.36 -2584.1 -4176.26 -2583 -4176.26H2674C2675.1 -4176.26 2676 -4175.36 2676 -4174.26V8453.74C2676 8454.85 2675.1 8455.74 2674 8455.74H-2583C-2584.1 8455.74 -2585 8454.85 -2585 8453.74V-4174.26Z" fill="white"/>
<path d="M-2583 -4175.26H2674V-4177.26H-2583V-4175.26ZM2675 -4174.26V8453.74H2677V-4174.26H2675ZM2674 8454.74H-2583V8456.74H2674V8454.74ZM-2584 8453.74V-4174.26H-2586V8453.74H-2584ZM-2583 8454.74C-2583.55 8454.74 -2584 8454.29 -2584 8453.74H-2586C-2586 8455.4 -2584.66 8456.74 -2583 8456.74V8454.74ZM2675 8453.74C2675 8454.29 2674.55 8454.74 2674 8454.74V8456.74C2675.66 8456.74 2677 8455.4 2677 8453.74H2675ZM2674 -4175.26C2674.55 -4175.26 2675 -4174.81 2675 -4174.26H2677C2677 -4175.91 2675.66 -4177.26 2674 -4177.26V-4175.26ZM-2583 -4177.26C-2584.66 -4177.26 -2586 -4175.92 -2586 -4174.26H-2584C-2584 -4174.81 -2583.55 -4175.26 -2583 -4175.26V-4177.26Z" fill="black" fill-opacity="0.1"/>
<g id="Group 427322294">
<rect x="-19.5" y="-113.758" width="68" height="160.387" rx="4.5" stroke="#9747FF" stroke-dasharray="10 5"/>
<g id="Property 1=Variant3">
<g id="Group 3815">
<g id="Group 3789">

Before

Width:  |  Height:  |  Size: 5.1 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -1,10 +1,6 @@
<svg width="29" height="28" viewBox="0 0 29 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="29" height="28" fill="#F5F5F5"/>
<svg width="29" height="27.129" viewBox="0 0 29 27.129" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="2. &#237;&#151;&#164;&#235;&#141;&#148;">
<path d="M-2585 -4127.13C-2585 -4128.24 -2584.1 -4129.13 -2583 -4129.13H2674C2675.1 -4129.13 2676 -4128.24 2676 -4127.13V8500.87C2676 8501.97 2675.1 8502.87 2674 8502.87H-2583C-2584.1 8502.87 -2585 8501.97 -2585 8500.87V-4127.13Z" fill="white"/>
<path d="M-2583 -4128.13H2674V-4130.13H-2583V-4128.13ZM2675 -4127.13V8500.87H2677V-4127.13H2675ZM2674 8501.87H-2583V8503.87H2674V8501.87ZM-2584 8500.87V-4127.13H-2586V8500.87H-2584ZM-2583 8501.87C-2583.55 8501.87 -2584 8501.42 -2584 8500.87H-2586C-2586 8502.52 -2584.66 8503.87 -2583 8503.87V8501.87ZM2675 8500.87C2675 8501.42 2674.55 8501.87 2674 8501.87V8503.87C2675.66 8503.87 2677 8502.52 2677 8500.87H2675ZM2674 -4128.13C2674.55 -4128.13 2675 -4127.69 2675 -4127.13H2677C2677 -4128.79 2675.66 -4130.13 2674 -4130.13V-4128.13ZM-2583 -4130.13C-2584.66 -4130.13 -2586 -4128.79 -2586 -4127.13H-2584C-2584 -4127.69 -2583.55 -4128.13 -2583 -4128.13V-4130.13Z" fill="black" fill-opacity="0.1"/>
<g id="Group 427322294">
<rect x="-19.5" y="-66.6328" width="68" height="160.387" rx="4.5" stroke="#9747FF" stroke-dasharray="10 5"/>
<g id="Property 1=Variant2">
<g id="Group 3815">
<g id="Group 3789">

Before

Width:  |  Height:  |  Size: 5.0 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

+1 -5
View File
@@ -1,10 +1,6 @@
<svg width="29" height="28" viewBox="0 0 29 28" fill="none" xmlns="http://www.w3.org/2000/svg">
<rect width="29" height="28" fill="#F5F5F5"/>
<svg width="29" height="27.129" viewBox="0 0 29 27.129" fill="none" xmlns="http://www.w3.org/2000/svg">
<g id="2. &#237;&#151;&#164;&#235;&#141;&#148;">
<path d="M-2585 -4080C-2585 -4081.1 -2584.1 -4082 -2583 -4082H2674C2675.1 -4082 2676 -4081.1 2676 -4080V8548C2676 8549.1 2675.1 8550 2674 8550H-2583C-2584.1 8550 -2585 8549.1 -2585 8548V-4080Z" fill="white"/>
<path d="M-2583 -4081H2674V-4083H-2583V-4081ZM2675 -4080V8548H2677V-4080H2675ZM2674 8549H-2583V8551H2674V8549ZM-2584 8548V-4080H-2586V8548H-2584ZM-2583 8549C-2583.55 8549 -2584 8548.55 -2584 8548H-2586C-2586 8549.66 -2584.66 8551 -2583 8551V8549ZM2675 8548C2675 8548.55 2674.55 8549 2674 8549V8551C2675.66 8551 2677 8549.66 2677 8548H2675ZM2674 -4081C2674.55 -4081 2675 -4080.55 2675 -4080H2677C2677 -4081.66 2675.66 -4083 2674 -4083V-4081ZM-2583 -4083C-2584.66 -4083 -2586 -4081.66 -2586 -4080H-2584C-2584 -4080.55 -2583.55 -4081 -2583 -4081V-4083Z" fill="black" fill-opacity="0.1"/>
<g id="Group 427322294">
<rect x="-19.5" y="-19.5" width="68" height="160.387" rx="4.5" stroke="#9747FF" stroke-dasharray="10 5"/>
<g id="Property 1=Default">
<g id="Group 3815">
<g id="Group 3789">

Before

Width:  |  Height:  |  Size: 4.9 KiB

After

Width:  |  Height:  |  Size: 4.0 KiB

@@ -454,7 +454,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
<SWHeaderWindowControls onWindowMoveStart={onWindowMoveStart} onMinimize={onMinimize} onClose={onClose} showWindowMove={shown("windowMove")} showMinimize={shown("minimize")} showClose={shown("close")} />
</div>}
</div>
{barOpen && barData && <div className="hm-sw-header__bar-anchor" style={{ left: barOffset, width: `calc(100% - ${barOffset}px)` }}><SWHeaderPropertyBar {...barData} disabled={disabled || propertyBar?.disabled} id={barId} /></div>}
{barOpen && barData && <div className="hm-sw-header__bar-anchor" style={{ left: barOffset, maxWidth: `calc(100% - ${barOffset}px)` }}><SWHeaderPropertyBar {...barData} disabled={disabled || propertyBar?.disabled} id={barId} /></div>}
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
</header>
)
@@ -143,6 +143,8 @@ function PropertyEditor({ property, onChange }: { property: SWHeaderProperty; on
onChange?.(property, next)
}
const cancelDraft = () => setDraft(value)
const rangeMin = property.min ?? 0, rangeMax = property.max ?? 100, rangeValue = mixed ? rangeMin : Number(draft)
const rangeFill = rangeMax > rangeMin && Number.isFinite(rangeValue) ? Math.min(1, Math.max(0, (rangeValue - rangeMin) / (rangeMax - rangeMin))) : 0
const nativeKeys = (event: React.KeyboardEvent<HTMLElement>) => {
if (event.nativeEvent.isComposing) return
if (event.key === "Enter") { event.preventDefault(); commit(draft) }
@@ -170,7 +172,7 @@ function PropertyEditor({ property, onChange }: { property: SWHeaderProperty; on
{unit && type !== "slider" && <span className="hm-sw-property__unit">{unit}</span>}
{type === "color" && <span className="hm-sw-property__swatch" aria-hidden="true"><i style={{ backgroundColor: property.swatch ?? parseSWHeaderColor(value)?.hex ?? "transparent" }} /></span>}
{property.linePreview && <SWHeaderLinePreview pattern={property.linePattern} />}
{type === "slider" && <input type="range" aria-label={`${property.name} 슬라이더`} min={property.min ?? 0} max={property.max ?? 100} step={property.step ?? 1} value={mixed ? property.min ?? 0 : draft} disabled={!editable} onChange={event => setDraft(event.target.value)} onBlur={() => commit(draft)} onKeyDown={nativeKeys} />}
{type === "slider" && <input type="range" aria-label={`${property.name} 슬라이더`} min={property.min ?? 0} max={property.max ?? 100} step={property.step ?? 1} value={mixed ? rangeMin : draft} style={{ "--hm-sw-range-fill": rangeFill } as CSSProperties} disabled={!editable} onChange={event => setDraft(event.target.value)} onBlur={() => commit(draft)} onKeyDown={nativeKeys} />}
{unit && type === "slider" && <span className="hm-sw-property__unit">{unit}</span>}
</div>
</div>
+42 -13
View File
@@ -75,6 +75,14 @@
--hm-sw-property-label: #000;
--hm-sw-property-table: #d2c8b4;
--hm-sw-property-inner-border: #948871;
/* Gauge (user-supplied Figma SVGs, 2026-10-19). Fill = Rectangle 6926: 6px pill, 1px black stroke,
vertical #8E0000 / #FF4242 37% / #E40000 76% / #1E0000 inside the stroke; its right end sits under
the thumb. Unfilled track = beige measured row by row from the user's reference image.
Thumb = Rectangle 6915: 14px conic + 2px 40% ring; its two drop shadows are CSS. */
--hm-sw-property-range-fill: linear-gradient(#000 0 1px, #8e0000 1px, #ff4242 2.46px, #e40000 3.99px, #1e0000 4.94px, #000 4.94px);
--hm-sw-property-range-fill-edge: #000;
--hm-sw-property-range-track: linear-gradient(#aea28b 0 1px, #c3b9a4 1px 2px, #c7bda9 2px 4px, #b7ac96 4px 5px, #ac9e84 5px);
--hm-sw-property-range-highlight: #eadec8;
/* User-confirmed: document (file name) surface */
--hm-sw-header-document-background: linear-gradient(180deg, #081813 0%, #09271e 8.69%, #0b3227 25.43%, #0e3d2f 51.25%, #0b3227 80.03%, #0a2b21 89.76%, #081813 100%);
@@ -168,13 +176,16 @@
background: var(--hm-sw-header-background);
}
/* Figma 26:10492. Header stays 49px; host content can reserve the extra 28px when open. */
.hm-sw-header__bar-anchor { position: absolute; top: 49px; z-index: 2; max-width: 1222px; }
/* Figma 26:10492. Header stays 49px; host content can reserve the extra 28px when open.
The strip ends after its last field (user correction 2026-10-19, overrides the fixed 1222px frame):
it shrinks to its content, so no empty brown area, and is capped by the space right of its left
edge (inline max-width); longer property lists scroll inside. */
.hm-sw-header__bar-anchor { position: absolute; top: 49px; z-index: 2; }
.hm-sw-property-bar {
--hm-sw-property-bar-background: #271f13;
--hm-sw-property-bar-border: #08251c;
width: 100%;
max-width: 1222px;
width: max-content;
max-width: 100%;
height: 28px;
overflow-x: auto;
overflow-y: hidden;
@@ -600,11 +611,13 @@
/* Group-field hover (user request): all labels/values and the font sample inherit yellow.
Swatches and layer-state graphics retain their data colors. Line borders and the dimension
SVG's black mask outline stay black; only the white foreground changes. Disabled is excluded. */
.hm-sw-header__field:hover:not(:disabled) { color: var(--hm-sw-header-selected-color); }
.hm-sw-header__field:hover:not(:disabled) .hm-sw-header__linetype > i { background: var(--hm-sw-header-selected-color); }
.hm-sw-header__field:hover:not(:disabled) .hm-sw-header__dimension-icon-default { visibility: hidden; }
.hm-sw-header__field:hover:not(:disabled) .hm-sw-header__dimension-icon-hover { visibility: visible; }
SVG's black mask outline stay black; only the white foreground changes. Disabled is excluded.
While a field's own dropdown is open (data-expanded) hover adds nothing: the expanded styling
stays as is and hover returns once the list closes. Focus-visible is unaffected. */
.hm-sw-header__field:hover:not(:disabled, [data-expanded="true"]) { color: var(--hm-sw-header-selected-color); }
.hm-sw-header__field:hover:not(:disabled, [data-expanded="true"]) .hm-sw-header__linetype > i { background: var(--hm-sw-header-selected-color); }
.hm-sw-header__field:hover:not(:disabled, [data-expanded="true"]) .hm-sw-header__dimension-icon-default { visibility: hidden; }
.hm-sw-header__field:hover:not(:disabled, [data-expanded="true"]) .hm-sw-header__dimension-icon-hover { visibility: visible; }
/* Hover — Figma header/btn_home "hover": the 1px border turns into a yellow vertical gradient.
Same treatment on every bordered SW Header button except the file name and 속성Bar / 속성창
@@ -635,7 +648,7 @@
/* Popup guide: the 1px outline is inside its SVG (rect rx 1.5 + 0.5 stroke), so the ring overlays it. */
.hm-sw-header__action--guide { border-radius: 2px; }
.hm-sw-header__action--guide::after { inset: 0; }
:is(.hm-sw-header__field, .hm-sw-header__more-trigger, .hm-sw-header__action):hover:not(:disabled)::after { opacity: 1; }
:is(.hm-sw-header__field, .hm-sw-header__more-trigger, .hm-sw-header__action):hover:not(:disabled, [data-expanded="true"])::after { opacity: 1; }
/* Selected: text and mono icons turn yellow; no selected background, border or shadow. */
:is(.hm-sw-header, .hm-sw-theme) [data-selected="true"] { color: var(--hm-sw-header-selected-color); }
@@ -1483,7 +1496,7 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-property__title { position: relative; z-index: 2; flex: 1; min-width: 0; margin-left: 7px; padding-right: 4px; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; font-weight: 700; letter-spacing: .04em; color: var(--hm-sw-header-selected-color); text-shadow: var(--hm-sw-header-label-outline); }
.hm-sw-property__tool { position: absolute; z-index: 2; right: 43px; top: 1px; width: 29px; height: 27.13px; }
.hm-sw-property:not([data-selection-kind="single"]) .hm-sw-property__title { margin-right: 29px; }
.hm-sw-property__tool > img { position: absolute; top: 0; left: 0; display: block; width: 29px; height: 28px; }
.hm-sw-property__tool > img { position: absolute; top: 0; left: 0; display: block; width: 29px; height: 27.13px; }
.hm-sw-property__tool-hover { visibility: hidden; }
.hm-sw-property__tool:hover > .hm-sw-property__tool-default { visibility: hidden; }
.hm-sw-property__tool:hover > .hm-sw-property__tool-hover { visibility: visible; }
@@ -1510,8 +1523,24 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-property__swatch { display: block; flex: 0 0 70px; height: 16px; padding: 1px; border: 1px solid #000; border-radius: 2px; }
.hm-sw-property__swatch > i { display: block; height: 12px; border: 1px solid #fff; }
.hm-sw-property__value > input[type="range"] { appearance: none; flex: 0 0 86px; width: 86px; height: 16px; margin: 0; background: transparent; cursor: pointer; }
.hm-sw-property__value > input[type="range"]::-webkit-slider-runnable-track { height: 3px; border: 1px solid rgba(28,26,22,.4); border-radius: 8px; background: linear-gradient(#8e0000, #ff4242 37%, #e40000 76%, #1e0000); }
.hm-sw-property__value > input[type="range"]::-webkit-slider-thumb { appearance: none; width: 14px; height: 14px; margin-top: -6px; border: 2px solid #ff5656; border-radius: 12px; background: conic-gradient(#520000, #ffb1b1 35%, #e40000 44%, #ffa7a7 57%, #520000 81%); box-shadow: 0 2px 2px rgba(28,26,22,.4), 0 0 3px #1c1a16; }
/* --hm-sw-range-fill (0-1) is set from the value; the red ends under the thumb centre (14px thumb).
The 3px radius rounds the fill's left cap; the first layer is its left stroke. Track and thumb are
shared values, painted once per engine: WebKit/Blink track + thumb, Gecko track + thumb (Gecko's
::-moz-range-progress is kept empty so the fill is never painted twice). */
.hm-sw-property__value > input[type="range"] {
--hm-sw-range-fill-width: calc(7px + (100% - 14px) * var(--hm-sw-range-fill, 0));
--hm-sw-range-track-paint:
linear-gradient(90deg, var(--hm-sw-property-range-fill-edge) 0 1px, transparent 1px) 0 0 / var(--hm-sw-range-fill-width) 100% no-repeat,
var(--hm-sw-property-range-fill) 0 0 / var(--hm-sw-range-fill-width) 100% no-repeat,
var(--hm-sw-property-range-track);
--hm-sw-range-thumb-paint: url("../assets/property-range-thumb.svg") center / 14px 14px no-repeat;
--hm-sw-range-thumb-shadow: 0 0 3px #1c1a16, 0 2px 2px rgba(28, 26, 22, .4);
}
.hm-sw-property__value > input[type="range"]::-webkit-slider-runnable-track { height: 6px; border-radius: 3px; background: var(--hm-sw-range-track-paint); box-shadow: 0 1px 0 var(--hm-sw-property-range-highlight); }
.hm-sw-property__value > input[type="range"]::-webkit-slider-thumb { appearance: none; width: 14px; height: 14px; margin-top: -4px; border: 0; border-radius: 50%; background: var(--hm-sw-range-thumb-paint); box-shadow: var(--hm-sw-range-thumb-shadow); }
.hm-sw-property__value > input[type="range"]::-moz-range-track { box-sizing: border-box; height: 6px; border: 0; border-radius: 3px; background: var(--hm-sw-range-track-paint); box-shadow: 0 1px 0 var(--hm-sw-property-range-highlight); }
.hm-sw-property__value > input[type="range"]::-moz-range-progress { height: 0; background: transparent; }
.hm-sw-property__value > input[type="range"]::-moz-range-thumb { box-sizing: border-box; width: 14px; height: 14px; border: 0; border-radius: 50%; background: var(--hm-sw-range-thumb-paint); box-shadow: var(--hm-sw-range-thumb-shadow); }
.hm-sw-property__value > .hm-sw-header__line-preview { flex-basis: 68px; width: 68px; }
.hm-sw-property__expand { display: block; width: 100%; height: 12px; overflow: hidden; }
.hm-sw-property__expand > img { display: block; width: 296px; height: 12px; margin-left: calc((100% - 296px) / 2); }
+195
View File
@@ -0,0 +1,195 @@
// Usage: node packages/sw/tests/followup-four.browser.mjs <evidence-dir> [storybook-url] (Storybook uses packages/sw/dist: build first)
import { createRequire } from 'node:module'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
const out = path.resolve(process.argv[2])
const base = process.argv[3] ?? 'http://127.0.0.1:6024'
const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
await mkdir(out, { recursive: true })
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: { width: 1920, height: 1000 } })
const report = { checks: [], errors: [], data: {} }
page.on('pageerror', e => report.errors.push(e.message))
page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, JSON.stringify(data)) }
const goto = async (id, w = 1920) => {
await page.setViewportSize({ width: w, height: 1000 })
await page.goto(`${base}/iframe.html?id=sw-header--${id}&viewMode=story`)
await page.locator('.hm-sw-header, .hm-sw-theme').first().waitFor()
await page.evaluate(() => document.fonts.ready)
await page.mouse.move(1, 1)
}
// Pixel sampler on a screenshot buffer (decoded in page canvas).
const pixels = async (buf, pts) => page.evaluate(async ([b64, pts]) => {
const i = new Image(); i.src = 'data:image/png;base64,' + b64; await i.decode()
const c = document.createElement('canvas'); c.width = i.width; c.height = i.height; const x = c.getContext('2d'); x.drawImage(i, 0, 0)
return pts.map(([px, py]) => [...x.getImageData(px, py, 1, 1).data].slice(0, 3))
}, [buf.toString('base64'), pts])
const near = (a, b, t = 12) => a.every((v, k) => Math.abs(v - b[k]) <= t)
// ---- A. Property bar
await goto('property-bar')
const toggleBar = page.locator('.hm-sw-header [data-property-bar-trigger], .hm-sw-header .hm-sw-header__toggle').filter({ hasText: '속성Bar' }).first()
await toggleBar.click()
const barGeo = () => page.evaluate(() => {
const bar = document.querySelector('.hm-sw-header .hm-sw-property-bar'), r = bar.getBoundingClientRect()
const fields = [...bar.querySelectorAll('.hm-sw-property-bar__field')], last = fields.at(-1).getBoundingClientRect()
const s = getComputedStyle(bar), header = document.querySelector('.hm-sw-header').getBoundingClientRect()
return { left: r.left, right: r.right, width: r.width, height: r.height, lastFieldRight: last.right, scrollW: bar.scrollWidth, clientW: bar.clientWidth,
borderBottom: s.borderBottomWidth + ' ' + s.borderBottomColor, radius: s.borderBottomLeftRadius + '/' + s.borderBottomRightRadius, bg: s.backgroundColor,
headerH: header.height, headerRight: header.right, fieldCount: fields.length }
})
let g = await barGeo()
report.data.barDefault = g
check('A1 bar ends after last field (padding 6 + border 1)', Math.abs(g.right - g.lastFieldRight - 7) <= 1 && g.width < 1222, g)
check('A2 height 28 / header 49 / radius & border kept', g.height === 28 && g.headerH === 49 && g.radius === '6px/6px' && g.borderBottom.startsWith('1px'), g)
await page.locator('.hm-sw-header .hm-sw-property-bar').screenshot({ path: path.join(out, 'A-bar-1920.png') })
// Background and bottom line end at the same x: sample 2px inside and 2px outside the right edge at the bottom border row.
const barTop = await page.evaluate(() => document.querySelector('.hm-sw-header .hm-sw-property-bar').getBoundingClientRect().top)
const shot = await page.screenshot({ clip: { x: Math.floor(g.right) - 20, y: Math.floor(barTop), width: 40, height: 28 } })
await page.screenshot({ path: path.join(out, 'A-bar-right-end.png'), clip: { x: Math.floor(g.right) - 60, y: Math.floor(barTop) - 10, width: 120, height: 40 } })
const ex = g.right - (Math.floor(g.right) - 20)
const [inBg, outBg, inLine, outLine] = await pixels(shot, [[Math.floor(ex) - 4, 22], [Math.ceil(ex) + 3, 22], [Math.floor(ex) - 8, 27], [Math.ceil(ex) + 3, 27]])
check('A3 background and bottom line end together; nothing brown beyond', near(inBg, [0x27, 0x1f, 0x13], 10) && !near(outBg, [0x27, 0x1f, 0x13], 10) && !near(outLine, inLine, 10), { inBg, outBg, inLine, outLine })
for (let n = 0; n < 6; n++) await page.getByRole('button', { name: 'Bar 필드 추가' }).click()
g = await barGeo(); report.data.barGrown = g
check('A4 bar grows with added fields, none clipped', g.fieldCount === 10 && Math.abs(g.right - g.lastFieldRight - 7) <= 1 && g.scrollW <= g.clientW + 1, g)
for (let n = 0; n < 14; n++) await page.getByRole('button', { name: 'Bar 필드 추가' }).click()
g = await barGeo(); report.data.barOverflow = g
const lastVisible = await page.evaluate(() => { const bar = document.querySelector('.hm-sw-header .hm-sw-property-bar'); bar.scrollLeft = bar.scrollWidth; const f = [...bar.querySelectorAll('.hm-sw-property-bar__field')].at(-1).getBoundingClientRect(), r = bar.getBoundingClientRect(); return f.right <= r.right + 0.5 })
check('A5 longer than space: capped to header edge, scroll reaches last field', g.right <= g.headerRight + 0.5 && g.scrollW > g.clientW && lastVisible, { ...g, lastVisible })
await page.getByRole('button', { name: 'Bar 객체 교체' }).click()
g = await barGeo(); report.data.barReplaced = g
check('A6 bar shrinks back for a 1-field object', g.fieldCount === 1 && Math.abs(g.right - g.lastFieldRight - 7) <= 1, g)
for (const w of [1440, 1080, 768]) {
await goto('property-bar', w)
const t = page.locator('.hm-sw-header .hm-sw-header__toggle').filter({ hasText: '속성Bar' }).first()
if (await t.isVisible()) await t.click(); else { await page.locator('.hm-sw-header__more-trigger').click(); await page.locator('.hm-sw-header__more-item').filter({ hasText: '속성Bar' }).click() }
const gg = await barGeo(); report.data[`bar${w}`] = gg
const overflowX = await page.evaluate(() => document.documentElement.scrollWidth > document.documentElement.clientWidth)
check(`A7 width ${w}: bar within header, height 28, no page scroll`, gg.right <= gg.headerRight + 0.5 && gg.height === 28 && gg.headerH === 49 && !overflowX, { ...gg, overflowX })
}
// ---- B. Tool icon
await goto('property-panel-all')
const tool = page.locator('.hm-sw-property__tool').first()
const toolInfo = await tool.evaluate(e => { const s = getComputedStyle(e), imgs = [...e.querySelectorAll('img')].map(i => ({ cls: i.className, w: i.getBoundingClientRect().width, h: i.getBoundingClientRect().height, src: i.getAttribute('src').slice(0, 60) })); return { border: s.borderTopWidth + ' ' + s.borderTopColor, outline: s.outlineStyle, bg: s.backgroundColor, w: e.getBoundingClientRect().width, h: e.getBoundingClientRect().height, imgs } })
report.data.tool = toolInfo
check('B1 no CSS border/outline/background on tool button', toolInfo.border.startsWith('0px') && toolInfo.outline === 'none' && toolInfo.bg === 'rgba(0, 0, 0, 0)', toolInfo)
check('B2 images 29 x 27.13 equal the button', toolInfo.imgs.every(i => i.w === 29 && Math.abs(i.h - 27.13) < 0.02), toolInfo.imgs)
const toolCorners = async name => {
const box = await tool.boundingBox()
const clip = { x: Math.floor(box.x) - 3, y: Math.floor(box.y) - 3, width: 36, height: 34 }
const b = await page.screenshot({ clip, scale: 'css' })
await page.screenshot({ path: path.join(out, `B-tool-${name}.png`), clip })
// Any near-white pixel within the 29x27 button area means a light frame.
const pts = []; for (let y = 3; y < 30; y++) for (let x = 3; x < 32; x++) pts.push([x, y])
const px = await pixels(b, pts)
const white = px.filter(p => p.every(v => v >= 235)).length
const outer = [px[0], px[28], px[pts.length - 29], px[pts.length - 1]] // four corner pixels
return { white, outer }
}
const def = await toolCorners('default')
await tool.hover(); const hov = await toolCorners('hover')
await tool.click(); await page.mouse.move(1, 1); const act = await toolCorners('active')
const pressed = await tool.getAttribute('aria-pressed')
report.data.toolPixels = { def, hov, act, pressed }
check('B3 no white frame pixels (default/hover/active)', def.white === 0 && hov.white === 0 && act.white === 0, { def: def.white, hov: hov.white, act: act.white })
check('B4 active toggles (aria-pressed)', pressed === 'true', { pressed })
// ---- C/D. Dropdown open: no trigger hover, inner hover kept
await goto('additional-hover')
const ringOpacity = sel => page.evaluate(s => { const e = document.querySelector(s); return { after: getComputedStyle(e, '::after').opacity, color: getComputedStyle(e).color, expanded: e.dataset.expanded ?? null } }, sel)
for (const menu of ['layer', 'color', 'text-style', 'dimension-style', 'line-style']) {
const sel = `.hm-sw-header .hm-sw-header__field[data-menu="${menu}"]`
const field = page.locator(sel).first()
await page.mouse.move(1, 1)
await field.hover(); const closedHover = await ringOpacity(sel)
await field.click(); await page.mouse.move(1, 1)
const openIdle = await ringOpacity(sel)
await field.hover(); const openHover = await ringOpacity(sel)
const shotIdle = null
await page.screenshot({ path: path.join(out, `C-${menu}-open-hover.png`), clip: { ...(await field.boundingBox()), x: (await field.boundingBox()).x - 4, y: 0, width: (await field.boundingBox()).width + 8, height: 60 } })
// Inner list row + detail hover
const panel = page.locator('.hm-sw-header [data-open="true"]').first()
const rows = panel.locator('.hm-sw-header__menu-row:not([data-active="true"]):not([data-disabled]), .hm-sw-header__layer-row:not([data-active="true"]), .hm-sw-header__color-mode:not(:disabled)')
let rowChanged = null
for (let k = 0; k < Math.min(2, await rows.count()); k++) {
const row = rows.nth(k); await page.mouse.move(1, 1)
const st = e => { const t = e.querySelector('.hm-sw-header__menu-text') ?? e; return getComputedStyle(e).backgroundColor + '|' + getComputedStyle(t).color }
const b0 = await row.evaluate(st)
await row.hover(); const b1 = await row.evaluate(st)
rowChanged = rowChanged || b0 !== b1
}
const detail = panel.locator('.hm-sw-header__menu-detail').first()
let detailChanged = null
if (await detail.count()) {
await page.mouse.move(1, 1)
const d0 = await detail.evaluate(e => getComputedStyle(e).backgroundColor + '|' + getComputedStyle(e).boxShadow + '|' + getComputedStyle(e).color)
await detail.hover(); const d1 = await detail.evaluate(e => getComputedStyle(e).backgroundColor + '|' + getComputedStyle(e).boxShadow + '|' + getComputedStyle(e).color)
detailChanged = d0 !== d1
}
await page.keyboard.press('Escape'); await page.mouse.move(1, 1)
const closedAgain = await ringOpacity(sel)
await field.hover(); const restored = await ringOpacity(sel)
await page.mouse.move(1, 1)
const res = { closedHover, openIdle, openHover, closedAgain, restored, rowChanged, detailChanged }
report.data[`dropdown-${menu}`] = res
check(`C ${menu}: open+hover == open idle`, openIdle.expanded === 'true' && openHover.after === openIdle.after && openHover.color === openIdle.color, res)
check(`C ${menu}: hover restored after close`, closedAgain.expanded === null && restored.after === '1' && closedHover.after === '1' && restored.color === closedHover.color, res)
check(`D ${menu}: inner row hover kept / detail hover kept`, rowChanged !== false && detailChanged !== false, { rowChanged, detailChanged })
}
// Focus-visible on an open trigger still shows the outline
{
const sel = '.hm-sw-header .hm-sw-header__field[data-menu="color"]'
await page.locator(sel).first().focus(); await page.keyboard.press('Enter'); await page.keyboard.press('Shift+Tab').catch(() => {})
}
// ---- E. Gauge
await goto('property-panel-circle')
const range = page.locator('.hm-sw-property input[type="range"]').first()
const gaugeState = () => range.evaluate(e => ({ value: e.value, fill: e.style.getPropertyValue('--hm-sw-range-fill'), track: getComputedStyle(e).getPropertyValue('--hm-sw-property-range-track').trim() }))
const gaugePixels = async name => {
const b = await range.boundingBox()
const clip = { x: Math.floor(b.x), y: Math.floor(b.y), width: Math.ceil(b.width), height: Math.ceil(b.height) }
const buf = await page.screenshot({ clip })
await page.screenshot({ path: path.join(out, `E-gauge-${name}.png`), clip })
const st = await gaugeState(), f = Number(st.fill), cx = 7 + (clip.width - 14) * f, mid = Math.floor(clip.height / 2)
const [left, right] = await pixels(buf, [[Math.max(2, Math.floor(cx - 10)), mid], [Math.min(clip.width - 3, Math.ceil(cx + 10)), mid]])
return { ...st, left, right, width: clip.width }
}
let gs = await gaugePixels('initial'); report.data.gaugeInitial = gs
const isRed = p => p[0] > 150 && p[1] < 90 && p[2] < 90, isBeige = p => p[0] > 190 && p[1] > 180 && p[2] > 160 && p[0] - p[2] > 15
check('E1 initial 0.25: red left of thumb, beige right', gs.value === '0.25' && Math.abs(Number(gs.fill) - 0.25) < 1e-9 && isRed(gs.left) && isBeige(gs.right), gs)
await range.focus(); for (let n = 0; n < 40; n++) await page.keyboard.press('ArrowRight')
gs = await gaugePixels('keyboard'); report.data.gaugeKeyboard = gs
check('E2 keyboard ArrowRight x40 -> 0.65, fill follows', gs.value === '0.65' && Math.abs(Number(gs.fill) - 0.65) < 1e-9 && isRed(gs.left) && isBeige(gs.right), gs)
await page.keyboard.press('Enter')
await page.waitForFunction(() => document.querySelector('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input')?.value === '0.65', null, { timeout: 2000 }).catch(() => {})
const committed = await page.locator('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input').first().inputValue().catch(() => null)
report.data.gaugeCommitted = committed
check('E3 commit updates the number input (callback path)', committed === '0.65', { committed })
await page.keyboard.press('End'); gs = await gaugePixels('max'); report.data.gaugeMax = gs
check('E4 max -> fill 1', gs.value === '1' && Number(gs.fill) === 1 && isRed(gs.left), gs)
await page.keyboard.press('Home'); gs = await gaugePixels('min'); report.data.gaugeMin = gs
check('E5 min -> fill 0, beige right', gs.value === '0' && Number(gs.fill) === 0 && isBeige(gs.right), gs)
// External value: type into the numeric input and commit
const num = page.locator('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input').first()
await num.click(); await num.fill('0.5'); await num.press('Enter'); await page.mouse.move(1, 1)
gs = await gaugeState(); report.data.gaugeExternal = gs
check('E6 value changed elsewhere syncs gauge', gs.value === '0.5' && Number(gs.fill) === 0.5, gs)
await page.locator('.hm-sw-property').first().screenshot({ path: path.join(out, 'E-panel-circle.png') })
// ---- F. Panel four states render
for (const id of ['property-panel-none', 'property-panel-all', 'property-panel-circle', 'property-panel-expanded']) {
await goto(id)
const sz = await page.locator('.hm-sw-property').first().evaluate(e => { const r = e.getBoundingClientRect(); return [r.width, r.height] })
report.data[id] = sz
await page.locator('.hm-sw-property').first().screenshot({ path: path.join(out, `F-${id}.png`) })
check(`F ${id} renders`, sz[0] === 314 && sz[1] > 200, sz)
}
check('G no page/console errors', report.errors.length === 0, report.errors)
await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2))
await browser.close()
console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length)
@@ -0,0 +1,62 @@
// Property panel close button (Figma 26:12366): canvas bleed, Default / Hover, close by click and Escape.
// Usage: node packages/sw/tests/property-close.browser.mjs <evidence-dir> <label> [storybook-url]
// Storybook serves packages/sw/dist: build @hm-design/sw first.
import { createRequire } from 'node:module'
import { mkdir, writeFile } from 'node:fs/promises'
import path from 'node:path'
const out = path.resolve(process.argv[2] ?? 'docs/sw/property-close-qa')
const label = process.argv[3] ?? 'run'
const base = process.argv[4] ?? 'http://127.0.0.1:6024'
const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
await mkdir(out, { recursive: true })
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: { width: 1400, height: 1000 }, deviceScaleFactor: 4 })
const report = { label, checks: [], errors: [] }
page.on('pageerror', e => report.errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') report.errors.push(m.text()) })
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, JSON.stringify(data)) }
await page.goto(`${base}/iframe.html?id=sw-header--property-panel&viewMode=story`)
await page.locator('.hm-sw-header').waitFor(); await page.evaluate(() => document.fonts.ready)
await page.getByRole('button', { name: '원 1개', exact: true }).click()
const trigger = page.locator('.hm-sw-header [data-property-panel-trigger]')
await trigger.click(); await page.mouse.move(1, 1)
const close = page.locator('.hm-sw-property__close').first()
const box = await close.boundingBox()
const geo = await close.evaluate(e => { const p = e.closest('.hm-sw-property').getBoundingClientRect(), r = e.getBoundingClientRect(); return { w: r.width, h: r.height, right: p.right - r.right, top: r.top - p.top, imgs: [...e.querySelectorAll('img')].map(i => [i.className, i.getBoundingClientRect().width, i.getBoundingClientRect().height]) } })
check('size 44 x 30, top-right of the panel', geo.w === 44 && geo.h === 30, geo)
// Light pixels (all channels >= 235) inside the 44 x 30 button: the Figma canvas is #F5F5F5 / white.
const bright = async name => {
const clip = { x: box.x - 2, y: box.y - 2, width: box.width + 4, height: box.height + 4 }
const buf = await page.screenshot({ clip })
await writeFile(path.join(out, `${label}-${name}.png`), buf)
return page.evaluate(async ([b64, w, h]) => {
const i = new Image(); i.src = 'data:image/png;base64,' + b64; await i.decode()
const c = document.createElement('canvas'); c.width = i.width; c.height = i.height; const x = c.getContext('2d'); x.drawImage(i, 0, 0)
const s = i.width / (w + 4), d = x.getImageData(0, 0, i.width, i.height).data; let n = 0; const where = []
for (let yy = Math.round(2 * s); yy < Math.round((h + 2) * s); yy++) for (let xx = Math.round(2 * s); xx < Math.round((w + 2) * s); xx++) {
const bx = xx / s - 2, by = yy / s - 2, edge = bx < 1.5 || bx > w - 1.5 || by < 1.5 || by > h - 1.5 || (bx > w - 4 && by < 4)
if (!edge) continue // the white X of Hover is intended; the canvas can only show at the edge / rounded corner
const k = (yy * i.width + xx) * 4; if (d[k] >= 235 && d[k + 1] >= 235 && d[k + 2] >= 235) { n++; if (where.length < 6) where.push([+(xx / s - 2).toFixed(2), +(yy / s - 2).toFixed(2)]) } }
return { bright: n, where }
}, [buf.toString('base64'), box.width, box.height])
}
const def = await bright('default')
await close.hover()
const hov = await bright('hover')
const vis = await close.evaluate(e => [...e.querySelectorAll('img')].map(i => getComputedStyle(i).visibility))
report.pixels = { def, hov }
check('Default: no canvas-light pixels on the button edge / corner', def.bright === 0, def)
check('Hover: hover asset shown, no canvas-light pixels on the edge / corner', vis.join() === 'hidden,visible' && hov.bright === 0, { vis, ...hov })
// Click closes; focus returns to the trigger.
await close.click()
let state = await page.evaluate(() => ({ open: !!document.querySelector('.hm-sw-property'), focusTrigger: document.activeElement?.hasAttribute('data-property-panel-trigger'), log: document.querySelector('[data-property-log]').textContent.slice(-60) }))
check('click closes the panel, focus back on 속성창 trigger', !state.open && state.focusTrigger, state)
// Escape closes from inside the panel; focus returns.
await trigger.click(); await page.locator('.hm-sw-property').waitFor()
await page.locator('.hm-sw-property__close').first().focus()
await page.keyboard.press('Escape')
state = await page.evaluate(() => ({ open: !!document.querySelector('.hm-sw-property'), focusTrigger: document.activeElement?.hasAttribute('data-property-panel-trigger'), log: document.querySelector('[data-property-log]').textContent.slice(-60) }))
check('Escape closes the panel, focus back on 속성창 trigger', !state.open && state.focusTrigger, state)
check('no console / page errors', report.errors.length === 0, report.errors)
await writeFile(path.join(out, `${label}-result.json`), JSON.stringify(report, null, 2))
await browser.close()
console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length)
@@ -0,0 +1,195 @@
// Chrome vs Firefox property-panel gauge (range) check on the running Storybook.
// Usage: node packages/sw/tests/range-cross-browser.browser.mjs <evidence-dir> [storybook-url]
// Chrome via Playwright; Firefox = the installed browser driven over WebDriver BiDi (no Playwright build needed).
// Storybook serves packages/sw/dist: build @hm-design/sw first.
import { createRequire } from 'node:module'
import { mkdir, writeFile, rm } from 'node:fs/promises'
import { spawn } from 'node:child_process'
import os from 'node:os'
import path from 'node:path'
const out = path.resolve(process.argv[2] ?? 'docs/sw/firefox-range-qa')
const base = process.argv[3] ?? 'http://127.0.0.1:6024'
const FIREFOX = process.env.SW_FIREFOX_PATH ?? 'C:/Program Files/Mozilla Firefox/firefox.exe'
const { chromium } = createRequire(import.meta.url)(process.env.SW_PLAYWRIGHT_PATH ?? 'C:/Users/user/.cache/codex-runtimes/codex-primary-runtime/dependencies/node/node_modules/playwright')
const STORY = `${base}/iframe.html?id=sw-header--property-panel&viewMode=story`
const VIEW = { width: 1400, height: 1000 }
const sleep = ms => new Promise(r => setTimeout(r, ms))
await mkdir(out, { recursive: true })
// Page-side helpers shared by both browsers (strings evaluated in the page).
const SETUP = `(async () => {
const click = t => [...document.querySelectorAll('button')].find(b => b.textContent.trim() === t).click()
click('원 1개'); await new Promise(r => setTimeout(r, 100))
document.querySelector('.hm-sw-header [data-property-panel-trigger]').click(); await new Promise(r => setTimeout(r, 200))
await document.fonts.ready
// Screenshots compare the gauge itself: headless Firefox driven over BiDi never reports keyboard modality
// (:focus-visible stays false even for plain buttons), so the shared focus ring is hidden in both browsers.
const st = document.createElement('style'); st.textContent = '.hm-sw-property input[type=range]{outline:none!important}'; document.head.append(st)
return 'ok' })()`
const STATE = `(() => { const r = document.querySelector('.hm-sw-property [data-property-id="opacity"] input[type="range"]'), b = r.getBoundingClientRect()
const n = document.querySelector('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input')
return JSON.stringify({ value: r.value, fill: r.style.getPropertyValue('--hm-sw-range-fill'), disabled: r.disabled, number: n.value, x: b.x, y: b.y, w: b.width, h: b.height,
log: document.querySelector('[data-property-log]').textContent.slice(-80), focused: document.activeElement === r, focusVisible: r.matches(':focus-visible'), outline: getComputedStyle(r).outlineStyle + ' ' + getComputedStyle(r).outlineColor }) })()`
// Keyboard focus (same :focus-visible rule in both engines): focus the value input, then a real Tab.
const FOCUS_PREV = `(() => { document.querySelector('.hm-sw-property [data-property-id="opacity"] .hm-sw-header__input').focus(); return 'ok' })()`
const CLICK = t => `(() => { [...document.querySelectorAll('button')].find(b => b.textContent.trim() === ${JSON.stringify(t)}).click(); return 'ok' })()`
// ---------- drivers ----------
async function chromeDriver() {
const browser = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const page = await browser.newPage({ viewport: VIEW, deviceScaleFactor: 1 })
const errors = []
page.on('pageerror', e => errors.push(e.message)); page.on('console', m => { if (m.type() === 'error') errors.push(m.text()) })
const keyMap = { ArrowRight: 'ArrowRight', Home: 'Home', End: 'End', Enter: 'Enter', Tab: 'Tab' }
return {
name: 'chrome', version: browser.version(), errors,
goto: async url => { await page.goto(url); await page.locator('.hm-sw-header').waitFor() },
eval: async expr => page.evaluate(expr),
key: async (k, n = 1) => { for (let i = 0; i < n; i++) await page.keyboard.press(keyMap[k]) },
shot: async clip => (await page.screenshot({ clip })).toString('base64'),
close: () => browser.close(),
}
}
async function firefoxDriver() {
const profile = path.join(os.tmpdir(), `sw-ff-profile-${process.pid}`)
await mkdir(profile, { recursive: true })
const port = 9300 + (process.pid % 500)
const proc = spawn(FIREFOX, ['--headless', '--no-remote', '--profile', profile, `--remote-debugging-port=${port}`], { stdio: 'ignore', env: { ...process.env, MOZ_HEADLESS_WIDTH: String(VIEW.width), MOZ_HEADLESS_HEIGHT: String(VIEW.height) } })
let ws
for (let i = 0; i < 60 && !ws; i++) {
await sleep(500)
try { ws = await new Promise((res, rej) => { const s = new WebSocket(`ws://127.0.0.1:${port}/session`); s.onopen = () => res(s); s.onerror = rej }) } catch { ws = undefined }
}
if (!ws) throw new Error('Firefox BiDi not reachable')
let id = 0; const pending = new Map(), errors = []
ws.onmessage = ev => {
const m = JSON.parse(ev.data)
if (m.id && pending.has(m.id)) { const { res, rej } = pending.get(m.id); pending.delete(m.id); m.type === 'error' ? rej(new Error(`${m.error}: ${m.message}`)) : res(m.result) }
else if (m.method === 'log.entryAdded' && m.params.level === 'error') errors.push(m.params.text)
}
const send = (method, params = {}) => new Promise((res, rej) => { const i = ++id; pending.set(i, { res, rej: e => rej(new Error(method + " -> " + e.message)) }); ws.send(JSON.stringify({ id: i, method, params })) })
const session = await send('session.new', { capabilities: {} })
await send('session.subscribe', { events: ['log.entryAdded'] })
const tree = await send('browsingContext.getTree', {})
const context = tree.contexts[0].context
// setViewport needs system access in Firefox; the headless window size above gives the same 1400 x 1000.
const KEYS = { ArrowRight: '\uE014', Home: '\uE011', End: '\uE010', Enter: '\uE007', Tab: '\uE004' }
return {
name: 'firefox', version: session.capabilities.browserVersion, errors,
goto: async url => {
await send('browsingContext.navigate', { context, url, wait: 'complete' })
for (let i = 0; i < 40; i++) { const r = await send('script.evaluate', { expression: `!!document.querySelector('.hm-sw-header')`, target: { context }, awaitPromise: false }); if (r.result.value) return; await sleep(250) }
throw new Error('story did not render in Firefox')
},
eval: async expr => { const r = await send('script.evaluate', { expression: expr, target: { context }, awaitPromise: true }); if (r.type === 'exception') throw new Error(r.exceptionDetails.text); return r.result.value },
key: async (k, n = 1) => { const a = []; for (let i = 0; i < n; i++) a.push({ type: 'keyDown', value: KEYS[k] }, { type: 'keyUp', value: KEYS[k] }); await send('input.performActions', { context, actions: [{ type: 'key', id: 'kb', actions: a }] }) },
shot: async clip => (await send('browsingContext.captureScreenshot', { context, clip: { type: 'box', ...clip } })).data,
close: async () => { try { await send('session.end', {}) } catch {} ws.close(); proc.kill(); await sleep(500); await rm(profile, { recursive: true, force: true }).catch(() => {}) },
}
}
// ---------- scenario ----------
const STEPS = [
{ name: 'initial', run: async d => {} },
{ name: 'keyboard-0.65', run: async d => { await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('ArrowRight', 40) } },
{ name: 'enter-commit', run: async d => { await d.key('Enter') } },
{ name: 'max', run: async d => { await d.key('End') } },
{ name: 'min', run: async d => { await d.key('Home') } },
{ name: 'external-0.8', run: async d => { await d.eval(CLICK('투명도 외부 값 변경')) } },
{ name: 'disabled', run: async d => { await d.eval(CLICK('투명도 Disabled')); await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('ArrowRight', 5) } },
{ name: 'readonly', run: async d => { await d.eval(CLICK('투명도 Disabled')); await d.eval(CLICK('투명도 ReadOnly')); await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('ArrowRight', 5) } },
{ name: 'restored', run: async d => { await d.eval(CLICK('투명도 ReadOnly')); await d.eval(FOCUS_PREV); await d.key('Tab'); await d.key('Home') } },
]
async function run(driver) {
await driver.goto(STORY)
await driver.eval(SETUP)
const states = []
for (const step of STEPS) {
await step.run(driver); await sleep(250)
const st = JSON.parse(await driver.eval(STATE))
// Clip at whole pixels around the input (range is 86 x 16): 2px margin each side.
const clip = { x: Math.round(st.x) - 2, y: Math.round(st.y) - 2, width: 90, height: 20 }
const png = await driver.shot(clip)
await writeFile(path.join(out, `${driver.name}-${step.name}.png`), Buffer.from(png, 'base64'))
states.push({ step: step.name, ...st, offset: { x: st.x - clip.x, y: st.y - clip.y }, png })
}
return states
}
const report = { story: STORY, viewport: VIEW, browsers: {}, steps: [], checks: [] }
const check = (name, ok, data) => { report.checks.push({ name, status: ok ? 'PASS' : 'FAIL', data }); console.log(ok ? 'PASS' : 'FAIL', name, JSON.stringify(data)) }
const results = {}
for (const make of [chromeDriver, firefoxDriver]) {
const d = await make()
try { results[d.name] = await run(d); report.browsers[d.name] = { version: d.version, errors: d.errors } } finally { await d.close() }
}
// ---------- analysis (pixels decoded in a Chrome page) ----------
const ab = await chromium.launch({ executablePath: process.env.SW_CHROME_PATH ?? 'C:/Program Files/Google/Chrome/Application/chrome.exe' })
const ap = await ab.newPage()
const analyse = (png, ox, oy) => ap.evaluate(async ([png, ox, oy]) => {
const i = new Image(); i.src = 'data:image/png;base64,' + png; await i.decode()
const c = document.createElement('canvas'); c.width = i.width; c.height = i.height; const x = c.getContext('2d'); x.drawImage(i, 0, 0)
const d = x.getImageData(0, 0, i.width, i.height).data, px = (cx, cy) => { const k = (cy * i.width + cx) * 4; return [d[k], d[k + 1], d[k + 2]] }
const bg = px(0, 0), same = (a, b, t = 6) => a.every((v, k) => Math.abs(v - b[k]) <= t)
const top = Math.round(oy), left = Math.round(ox)
// Thumb bbox: red-dominant pixels outside the 6 track rows (rows 5-10 of the 16px input).
let tx0 = 1e9, tx1 = -1, ty0 = 1e9, ty1 = -1
for (let yy = 0; yy < i.height; yy++) { const r = yy - top; if (r >= 5 && r <= 10) continue
for (let xx = 0; xx < i.width; xx++) { const p = px(xx, yy); if (p[0] > 90 && p[0] - p[1] > 50 && p[0] - p[2] > 50) { tx0 = Math.min(tx0, xx); tx1 = Math.max(tx1, xx); ty0 = Math.min(ty0, yy); ty1 = Math.max(ty1, yy) } } }
const thumb = tx1 < 0 ? null : { left: tx0 - left, right: tx1 - left, top: ty0 - top, bottom: ty1 - top, cx: (tx0 + tx1 + 1) / 2 - left, cy: (ty0 + ty1 + 1) / 2 - top }
// Track column profiles: fill column (x=3 of input) and beige column (x=82 of input).
const column = cx => { const rows = []; for (let r = 3; r <= 12; r++) rows.push(px(left + cx, top + r)); return rows }
const extent = col => { const idx = col.map((p, k) => same(p, bg, 10) ? -1 : k + 3).filter(k => k >= 0); return idx.length ? { from: idx[0], to: idx.at(-1), rows: idx.length } : null }
const hex = p => p.map(v => v.toString(16).padStart(2, '0')).join('')
const fillCol = column(3), trackCol = column(82)
return { thumb, fillCol: fillCol.map(hex), trackCol: trackCol.map(hex), fillExtent: extent(fillCol), trackExtent: extent(trackCol) }
}, [png, ox, oy])
const diff = (a, b) => ap.evaluate(async ([a, b]) => {
const load = async s => { const i = new Image(); i.src = 'data:image/png;base64,' + s; await i.decode(); const c = document.createElement('canvas'); c.width = i.width; c.height = i.height; const x = c.getContext('2d'); x.drawImage(i, 0, 0); return x.getImageData(0, 0, i.width, i.height).data }
const A = await load(a), B = await load(b); let max = 0, over16 = 0, over48 = 0, sum = 0
for (let k = 0; k < A.length; k += 4) { const m = Math.max(Math.abs(A[k] - B[k]), Math.abs(A[k + 1] - B[k + 1]), Math.abs(A[k + 2] - B[k + 2])); max = Math.max(max, m); sum += m; if (m > 16) over16++; if (m > 48) over48++ }
return { pixels: A.length / 4, max, mean: +(sum / (A.length / 4)).toFixed(2), over16, over48 }
}, [a, b])
for (let k = 0; k < STEPS.length; k++) {
const c = results.chrome[k], f = results.firefox[k]
const ca = await analyse(c.png, c.offset.x, c.offset.y), fa = await analyse(f.png, f.offset.x, f.offset.y)
const dd = await diff(c.png, f.png)
const strip = s => { const { png, ...rest } = s; return rest }
report.steps.push({ step: STEPS[k].name, chrome: { ...strip(c), pixels: ca }, firefox: { ...strip(f), pixels: fa }, diff: dd })
}
await ab.close()
// ---------- checks ----------
const S = Object.fromEntries(report.steps.map(s => [s.step, s]))
const isRed = h => { const v = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); return v[0] > 100 && v[0] - v[1] > 60 }
const isBeige = h => { const v = [0, 2, 4].map(i => parseInt(h.slice(i, i + 2), 16)); return v[0] > 160 && v[1] > 150 && v[2] > 120 && v[0] - v[2] > 15 && v[0] - v[2] < 45 }
for (const b of ['chrome', 'firefox']) {
const st = n => S[n][b]
check(`${b} 1-3 red fill / beige track / black-edged 6px`, isRed(st('initial').pixels.fillCol[4]) && isBeige(st('initial').pixels.trackCol[4]) && st('initial').pixels.fillCol[2] === '000000' && st('initial').pixels.fillCol[7] === '000000' && st('initial').pixels.fillExtent.from === 5 && st('initial').pixels.fillExtent.to >= 10, { fill: st('initial').pixels.fillCol, track: st('initial').pixels.trackCol, extent: st('initial').pixels.fillExtent })
const t = st('initial').pixels.thumb
check(`${b} 4-5 thumb 14px, vertically centred on the track`, t && t.right - t.left + 1 >= 11 && t.right - t.left + 1 <= 15 && Math.abs(t.cy - 8) <= 0.5, t)
for (const [n, v] of [['min', 0], ['initial', 0.25], ['keyboard-0.65', 0.65], ['max', 1], ['external-0.8', 0.8]]) {
const s = st(n), expect = 7 + 72 * v
check(`${b} 6-8/10 ${n}: value ${s.value}, thumb centre ${s.pixels.thumb?.cx} ≈ ${expect}`, Number(s.value) === v && Number(s.fill) === v && Math.abs(s.pixels.thumb.cx - expect) <= 1, { value: s.value, fill: s.fill, cx: s.pixels.thumb?.cx })
}
check(`${b} 9 keyboard + Enter commit`, st('keyboard-0.65').value === '0.65' && st('enter-commit').number === '0.65' && st('enter-commit').log.includes('property(opacity, 0.65)'), { value: st('keyboard-0.65').value, number: st('enter-commit').number, log: st('enter-commit').log })
check(`${b} 11 disabled / readOnly block keys, keep design`, st('disabled').disabled && st('disabled').value === '0.8' && st('readonly').disabled && st('readonly').value === '0.8' && isRed(st('disabled').pixels.fillCol[4]) && isBeige(st('readonly').pixels.trackCol[4]) && !st('restored').disabled && st('restored').value === '0', { disabled: [st('disabled').disabled, st('disabled').value], readonly: [st('readonly').disabled, st('readonly').value], restored: [st('restored').disabled, st('restored').value] })
check(`${b} 12 no console / page errors`, report.browsers[b].errors.filter(e => !e.includes('/sb-common-assets/')).length === 0, report.browsers[b].errors)
}
const near = (a, b, t) => a.every((h, k) => [0, 2, 4].every(i => Math.abs(parseInt(h.slice(i, i + 2), 16) - parseInt(b[k].slice(i, i + 2), 16)) <= t))
const noHighlight = col => col.filter((_, k) => k !== 8) // 1px highlight row under the track, reported separately
for (const s of report.steps) {
const ct = s.chrome.pixels.thumb, ft = s.firefox.pixels.thumb
const fillSame = s.step === 'min' || s.step === 'restored' || near(noHighlight(s.chrome.pixels.fillCol), noHighlight(s.firefox.pixels.fillCol), 3)
check(`compare ${s.step}: columns ±3, thumb ±1px, ≤12 px differ > 48`,
fillSame && near(noHighlight(s.chrome.pixels.trackCol), noHighlight(s.firefox.pixels.trackCol), 3)
&& Math.abs(ct.cx - ft.cx) <= 1 && Math.abs(ct.cy - ft.cy) <= 1 && s.diff.over48 <= 12,
{ diff: s.diff, focus: [s.chrome.focusVisible, s.firefox.focusVisible], chromeThumb: ct, firefoxThumb: ft, highlight: [s.chrome.pixels.fillCol[8], s.firefox.pixels.fillCol[8]] })
}
await writeFile(path.join(out, 'result.json'), JSON.stringify(report, null, 2))
console.log('BROWSERS', JSON.stringify(Object.fromEntries(Object.entries(report.browsers).map(([k, v]) => [k, v.version]))))
console.log('TOTAL', report.checks.length, 'FAIL', report.checks.filter(c => c.status === 'FAIL').length)
+4 -1
View File
@@ -1454,6 +1454,9 @@ function PropertyPanelDemo({ standalone = false }: { standalone?: boolean }) {
<button onClick={() => setProperties(items => [...items].reverse())}>속성 순서 뒤집기</button>
<button onClick={() => setProperties(items => items.map(item => item.id === "weight" ? { ...item, disabled: !item.disabled } : item))}>가중치 Disabled</button>
<button onClick={() => setProperties(items => items.map(item => item.id === "layer" ? { ...item, readOnly: !item.readOnly } : item))}>레이어 ReadOnly</button>
<button onClick={() => setProperties(items => items.map(item => item.id === "opacity" ? { ...item, value: "0.8" } : item))}>투명도 외부 값 변경</button>
<button onClick={() => setProperties(items => items.map(item => item.id === "opacity" ? { ...item, disabled: !item.disabled } : item))}>투명도 Disabled</button>
<button onClick={() => setProperties(items => items.map(item => item.id === "opacity" ? { ...item, readOnly: !item.readOnly } : item))}>투명도 ReadOnly</button>
</div>
<p className="sw-story-log" data-property-log="" aria-live="polite">{log.join(" → ") || "Callback 없음"}</p>
<pre className="sw-story-layer-state" data-property-state="">{JSON.stringify({ selection: selection.id, values: properties.map(item => [item.id, item.value]), expanded, open })}</pre>
@@ -1538,7 +1541,7 @@ export const PropertyBar: Story = {
name: "Property Bar",
parameters: { controls: { disable: true } },
render: () => <StoryPage className="sw-story-followup"><StoryPageHeader kicker={KICKER} title="속성Bar · 동적 편집" description="Figma 26:10492. 속성창과 독립 열림, 동일 호스트 데이터. 수치는 Figma 예시이며 CAD 계산은 수행하지 않습니다." /><PropertyBarDemo />
<StorySection title="1222 × 28 기준"><div className="sw-story-scroller"><SWTheme><div style={{ width: 1222 }}><SWHeaderPropertyBar selection={{ id: "reference", title: "원", kind: "single" }} properties={BAR_FIXTURE} /></div></SWTheme></div></StorySection>
<StorySection title="콘텐츠 폭 기준 (부모 1222px, 높이 28)"><div className="sw-story-scroller"><SWTheme><div style={{ width: 1222 }}><SWHeaderPropertyBar selection={{ id: "reference", title: "원", kind: "single" }} properties={BAR_FIXTURE} /></div></SWTheme></div></StorySection>
</StoryPage>,
}