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

@@ -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)