feat(sw): implement property field interaction variants

- Property panel rows follow Figma 26:12388 ~ 26:12649: 28px fields with
  Default / Hover border / numeric editing On (#0F0D0A) / dropdown On (#211D13)
- Layer, line style and color fields open the shared header dropdown contents
  (SWHeaderLayerRows, SWHeaderLineStyleColumns, SWHeaderColorPanelBody)
- Additive API: property.picker, property.decimals, panel menus
- Property Fields story and browser test (77 checks)

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shkim4andClaude Opus 5.5 committed 2026-10-19 09:38:31 +09:00
1 parent 724c51732b
commit 0029cef60c
23 files changed
+1907 -189

No files matched your search

@@ -53,31 +53,7 @@ export function SWHeaderColorMenu({
onColorSelect, onModeChange, onColorInput, onPick, onDetail,
}: SWHeaderColorMenuProps) {
const { open, setOpen, close, panelRef, triggerRef, menuId, panelStyle, shifted, rootProps } = useSWHeaderDropdown({ open: openProp, onOpenChange, disabled })
const swatch = value.swatch ?? "#ffe500"
const current = parseSWHeaderColor(value.value) ?? parseSWHeaderColor(swatch)
const inputValue = current?.value ?? value.value
const [lastMode, setLastMode] = useState<SWHeaderColorMode>(mode ?? "byLayer")
useEffect(() => { if (mode) setLastMode(mode) }, [mode])
const selectedMode = mode ?? lastMode
const chip = (option: SWHeaderColorOption) => {
const pressed = !mode && current !== null && parseSWHeaderColor(option.color)?.hex === current.hex
const label = option.label ?? optionValue(option)
return (
<button
key={option.id}
type="button"
className="hm-sw-header__color-option"
title={label}
aria-label={label}
aria-pressed={pressed}
disabled={option.disabled}
onClick={() => onColorSelect?.(option)}
>
<SWHeaderColorChip color={option.color} />
</button>
)
}
const [selectedMode, chooseMode] = useSWHeaderColorMode(mode)
return (
<div {...rootProps} className="hm-sw-header__menu hm-sw-header__menu--color" data-menu="color">
@@ -104,63 +80,132 @@ export function SWHeaderColorMenu({
data-shifted={shifted ? "true" : undefined}
style={panelStyle}
>
<div className="hm-sw-header__color-modes" role="group" aria-label="색상 기준">
{modes.map(item => (
<button
key={item.mode}
type="button"
className={`hm-sw-header__color-mode hm-sw-header__color-mode--${item.mode === "byLayer" ? "layer" : "block"}`}
aria-pressed={selectedMode === item.mode}
onClick={() => {
if (selectedMode === item.mode) return
setLastMode(item.mode)
onModeChange?.(item.mode)
}}
>
<span className="hm-sw-header__menu-text">{item.label}</span>
</button>
))}
</div>
<div className="hm-sw-header__color-palette">
{colors.length > 0 && <div className="hm-sw-header__color-row" role="group" aria-label="기본 색상">{colors.map(chip)}</div>}
{userColors && userColors.length > 0 && (
<div className="hm-sw-header__color-row hm-sw-header__color-row--user" role="group" aria-label="사용자 색상">{userColors.map(chip)}</div>
)}
</div>
<div className="hm-sw-header__color-tools">
<button
type="button"
className="hm-sw-header__color-pick"
aria-label="색상 추출"
title="색상 추출"
disabled={!onPick}
onClick={() => {
onPick?.()
close(true)
}}
>
<img className="hm-sw-header__colorize-default" src={colorPickIcon} alt="" />
<img className="hm-sw-header__colorize-hover" src={colorPickHover} alt="" />
</button>
<span className="hm-sw-header__color-input">
<SWHeaderColorChip color={swatch} />
<SWHeaderInput
aria-label="색상값 (R,G,B)"
inputMode="text"
value={inputValue}
parse={draft => parseSWHeaderColor(draft)?.value ?? null}
onCommit={onColorInput ? next => {
const parsed = parseSWHeaderColor(next)
if (parsed) onColorInput(parsed)
} : undefined}
/>
</span>
</div>
<div className="hm-sw-header__menu-footer">
<SWHeaderMenuDetail label="색상 상세" onClick={onDetail && (() => { onDetail(); close(true) })} />
</div>
<SWHeaderColorPanelBody
value={value}
colors={colors}
userColors={userColors}
mode={mode}
selectedMode={selectedMode}
onColorSelect={onColorSelect}
onModeChange={next => {
chooseMode(next)
onModeChange?.(next)
}}
onColorInput={onColorInput}
onPick={onPick && (() => {
onPick()
close(true)
})}
onDetail={onDetail && (() => { onDetail(); close(true) })}
/>
</div>
)}
</div>
)
}
/**
* ByLayer / ByBlock is a required single choice: with `mode` null (explicit color) the indicator keeps the
* last mode (default ByLayer). Returns the shown mode and the setter for a click.
*/
export function useSWHeaderColorMode(mode: SWHeaderColorMode | null | undefined) {
const [lastMode, setLastMode] = useState<SWHeaderColorMode>(mode ?? "byLayer")
useEffect(() => { if (mode) setLastMode(mode) }, [mode])
return [mode ?? lastMode, setLastMode] as const
}
export interface SWHeaderColorPanelBodyProps extends Pick<SWHeaderColorMenuProps, "value" | "colors" | "userColors" | "mode" | "onColorSelect" | "onColorInput"> {
/** Shown mode (see useSWHeaderColorMode). */
selectedMode: SWHeaderColorMode
/** A mode other than the shown one was clicked. */
onModeChange: (mode: SWHeaderColorMode) => void
/** Color pick; the caller closes its dropdown. Without it the button is disabled. */
onPick?: () => void
onDetail?: () => void
}
/**
* Contents of the color dropdown (Figma 26:10986, property field 26:12184): ByLayer / ByBlock, palette, user
* colors, btn_colorize + RGB input (0-255, validated) and "색상 상세". Shared by the header color field and
* the property window color field.
*/
export function SWHeaderColorPanelBody({ value, colors, userColors, mode, selectedMode, onColorSelect, onModeChange, onColorInput, onPick, onDetail }: SWHeaderColorPanelBodyProps) {
const swatch = value.swatch ?? "#ffe500"
const current = parseSWHeaderColor(value.value) ?? parseSWHeaderColor(swatch)
const inputValue = current?.value ?? value.value
const chip = (option: SWHeaderColorOption) => {
const pressed = !mode && current !== null && parseSWHeaderColor(option.color)?.hex === current.hex
const label = option.label ?? optionValue(option)
return (
<button
key={option.id}
type="button"
className="hm-sw-header__color-option"
title={label}
aria-label={label}
aria-pressed={pressed}
disabled={option.disabled}
onClick={() => onColorSelect?.(option)}
>
<SWHeaderColorChip color={option.color} />
</button>
)
}
return (
<>
<div className="hm-sw-header__color-modes" role="group" aria-label="색상 기준">
{modes.map(item => (
<button
key={item.mode}
type="button"
className={`hm-sw-header__color-mode hm-sw-header__color-mode--${item.mode === "byLayer" ? "layer" : "block"}`}
aria-pressed={selectedMode === item.mode}
onClick={() => {
if (selectedMode === item.mode) return
onModeChange(item.mode)
}}
>
<span className="hm-sw-header__menu-text">{item.label}</span>
</button>
))}
</div>
<div className="hm-sw-header__color-palette">
{colors.length > 0 && <div className="hm-sw-header__color-row" role="group" aria-label="기본 색상">{colors.map(chip)}</div>}
{userColors && userColors.length > 0 && (
<div className="hm-sw-header__color-row hm-sw-header__color-row--user" role="group" aria-label="사용자 색상">{userColors.map(chip)}</div>
)}
</div>
<div className="hm-sw-header__color-tools">
<button
type="button"
className="hm-sw-header__color-pick"
aria-label="색상 추출"
title="색상 추출"
disabled={!onPick}
onClick={() => onPick?.()}
>
<img className="hm-sw-header__colorize-default" src={colorPickIcon} alt="" />
<img className="hm-sw-header__colorize-hover" src={colorPickHover} alt="" />
</button>
<span className="hm-sw-header__color-input">
<SWHeaderColorChip color={swatch} />
<SWHeaderInput
aria-label="색상값 (R,G,B)"
inputMode="text"
value={inputValue}
parse={draft => parseSWHeaderColor(draft)?.value ?? null}
onCommit={onColorInput ? next => {
const parsed = parseSWHeaderColor(next)
if (parsed) onColorInput(parsed)
} : undefined}
/>
</span>
</div>
<div className="hm-sw-header__menu-footer">
<SWHeaderMenuDetail label="색상 상세" onClick={onDetail} />
</div>
</>
)
}
@@ -187,46 +187,78 @@ export function SWHeaderLayerMenu({
style={offset !== 0 ? { left: offset } : undefined}
>
<div className="hm-sw-header__layer-panel">
{layers.length > 0 && (
<ul className="hm-sw-header__layer-list">
{layers.map(layer => (
<li
key={layer.id}
className="hm-sw-header__layer-row"
data-active={layer === current ? "true" : undefined}
data-disabled={layer.disabled ? "true" : undefined}
>
<span className="hm-sw-header__layer-toggles">
<SWHeaderLayerToggle kind="visibility" layer={layer} onChange={onVisibilityChange} />
<SWHeaderLayerToggle kind="freeze" layer={layer} onChange={onFreezeChange} />
<SWHeaderLayerToggle kind="lock" layer={layer} onChange={onLockChange} />
</span>
<button
type="button"
className="hm-sw-header__layer-item"
title={layer.name}
aria-current={layer === current ? "true" : undefined}
disabled={layer.disabled}
onClick={() => {
onLayerSelect?.(layer)
close(true)
}}
>
<span className="hm-sw-header__layer-swatch" aria-hidden="true">
<span style={{ background: layer.color ?? "#ffffff" }} />
</span>
<span className="hm-sw-header__layer-name">{layer.name}</span>
</button>
</li>
))}
</ul>
)}
<div className="hm-sw-header__layer-footer">
<SWHeaderMenuDetail label="레이어 상세" className="hm-sw-header__layer-detail" disabled={disabled} onClick={onDetail && (() => { onDetail(); close(true) })} />
</div>
<SWHeaderLayerRows
layers={layers}
current={current}
detailDisabled={disabled}
onSelect={layer => {
onLayerSelect?.(layer)
close(true)
}}
onVisibilityChange={onVisibilityChange}
onFreezeChange={onFreezeChange}
onLockChange={onLockChange}
onDetail={onDetail && (() => { onDetail(); close(true) })}
/>
</div>
</div>
)}
</div>
)
}
export interface SWHeaderLayerRowsProps extends Pick<SWHeaderLayerMenuProps, "onVisibilityChange" | "onFreezeChange" | "onLockChange"> {
layers: SWHeaderLayer[]
/** Row shown as current (#FFC600 name, hover emphasis). None when the selection mixes layers. */
current?: SWHeaderLayer
/** A row (swatch / name) was chosen; the caller closes its dropdown. */
onSelect: (layer: SWHeaderLayer) => void
onDetail?: () => void
detailDisabled?: boolean
}
/**
* Rows + "레이어 상세" footer of the layer dropdown (Figma 26:10940, property field 26:12265 / 26:12291).
* Shared by the header layer field and the property window layer field: same rows, toggles and callbacks.
* The bulb, snowflake and lock are siblings of the row selection, so they never select the row.
*/
export function SWHeaderLayerRows({ layers, current, onSelect, onVisibilityChange, onFreezeChange, onLockChange, onDetail, detailDisabled = false }: SWHeaderLayerRowsProps) {
return (
<>
{layers.length > 0 && (
<ul className="hm-sw-header__layer-list">
{layers.map(layer => (
<li
key={layer.id}
className="hm-sw-header__layer-row"
data-active={layer === current ? "true" : undefined}
data-disabled={layer.disabled ? "true" : undefined}
>
<span className="hm-sw-header__layer-toggles">
<SWHeaderLayerToggle kind="visibility" layer={layer} onChange={onVisibilityChange} />
<SWHeaderLayerToggle kind="freeze" layer={layer} onChange={onFreezeChange} />
<SWHeaderLayerToggle kind="lock" layer={layer} onChange={onLockChange} />
</span>
<button
type="button"
className="hm-sw-header__layer-item"
title={layer.name}
aria-current={layer === current ? "true" : undefined}
disabled={layer.disabled}
onClick={() => onSelect(layer)}
>
<span className="hm-sw-header__layer-swatch" aria-hidden="true">
<span style={{ background: layer.color ?? "#ffffff" }} />
</span>
<span className="hm-sw-header__layer-name">{layer.name}</span>
</button>
</li>
))}
</ul>
)}
<div className="hm-sw-header__layer-footer">
<SWHeaderMenuDetail label="레이어 상세" className="hm-sw-header__layer-detail" disabled={detailDisabled} onClick={onDetail} />
</div>
</>
)
}
@@ -95,57 +95,95 @@ export function SWHeaderLineStyleMenu({
{open && (
<div ref={panelRef} id={menuId} className="hm-sw-header__menu-panel hm-sw-header__line-menu" role="group" aria-label="선스타일"
style={{ ...panelStyle, "--hm-sw-line-seam-left": `${-(panelStyle?.left ?? 0)}px` } as CSSProperties}>
<div className="hm-sw-header__line-drop">
<div className="hm-sw-header__line-column hm-sw-header__line-column--type">
<ul className="hm-sw-header__menu-list" aria-label="선 종류">
{lineTypes.map(item => (
<li key={item.id} className="hm-sw-header__menu-row hm-sw-header__menu-row--line" data-active={item === currentType ? "true" : undefined} data-disabled={item.disabled ? "true" : undefined}>
<button type="button" className="hm-sw-header__menu-item" title={item.name} aria-current={item === currentType ? "true" : undefined} disabled={item.disabled} onClick={() => onLineTypeSelect?.(item)}>
<SWHeaderLinePreview pattern={item.pattern} />
<span className="hm-sw-header__menu-text hm-sw-header__menu-name">{item.name}</span>
</button>
</li>
))}
</ul>
<div className="hm-sw-header__menu-footer">
<SWHeaderMenuDetail label="선스타일 상세" onClick={onDetail && (() => { onDetail(); close(true) })} />
</div>
</div>
<div className="hm-sw-header__line-column">
<ul className="hm-sw-header__menu-list" aria-label="가중치">
{lineWeights.map(item => (
<li key={item.id} className="hm-sw-header__menu-row hm-sw-header__menu-row--weight" data-active={item === currentWeight ? "true" : undefined} data-disabled={item.disabled ? "true" : undefined}>
<button type="button" className="hm-sw-header__menu-item" title={item.name} aria-current={item === currentWeight ? "true" : undefined} disabled={item.disabled} onClick={() => onLineWeightSelect?.(item)}>
<span className="hm-sw-header__menu-text hm-sw-header__menu-name">{item.name}</span>
{item.value !== undefined && !item.editable && <span className="hm-sw-header__menu-text hm-sw-header__menu-value">{item.value}</span>}
</button>
{item.editable && (
// Sibling of the row button (no nested controls): editing never selects the row.
<SWHeaderInput
aria-label={`${item.name} 가중치`}
valueType="number"
value={item.value ?? ""}
disabled={item.disabled}
parse={draft => parseSWHeaderNumber(draft, { min: 0 })}
onCommit={onLineWeightValueChange && (next => onLineWeightValueChange(item, next))}
/>
)}
</li>
))}
</ul>
</div>
<div className="hm-sw-header__line-column">
<div className="hm-sw-header__menu-row hm-sw-header__menu-row--weight">
<span className="hm-sw-header__menu-label hm-sw-header__menu-text hm-sw-header__menu-name">스케일</span>
<SWHeaderInput
aria-label="스케일"
valueType="number"
value={value.scale}
parse={draft => parseSWHeaderNumber(draft, { min: 0, minExclusive: true })}
onCommit={onScaleChange}
/>
</div>
</div>
<SWHeaderLineStyleColumns
lineTypes={lineTypes}
lineWeights={lineWeights}
currentType={currentType}
currentWeight={currentWeight}
scale={value.scale}
onLineTypeSelect={onLineTypeSelect}
onLineWeightSelect={onLineWeightSelect}
onLineWeightValueChange={onLineWeightValueChange}
onScaleChange={onScaleChange}
onDetail={onDetail && (() => { onDetail(); close(true) })}
/>
</div>
)}
</div>
)
}
export interface SWHeaderLineStyleColumnsProps extends Pick<SWHeaderLineStyleMenuProps, "onLineTypeSelect" | "onLineWeightSelect" | "onLineWeightValueChange" | "onScaleChange" | "onDetail"> {
lineTypes: readonly SWHeaderLineType[]
/** Line weight column; omitted => no weight column. */
lineWeights?: readonly SWHeaderLineWeight[]
currentType?: SWHeaderLineType
currentWeight?: SWHeaderLineWeight
/** Scale input; omitted => no scale column. */
scale?: string
}
/**
* The 466px drop of the line style dropdown (Figma 26:11143, property field 26:12404): line type 182 (pattern
* preview + name, "선스타일 상세") · weight 142 · scale 142. Shared by the header line style field and the
* property window line style field; each caller decides whether a selection closes its dropdown.
*/
export function SWHeaderLineStyleColumns({
lineTypes, lineWeights, currentType, currentWeight, scale, onLineTypeSelect, onLineWeightSelect, onLineWeightValueChange, onScaleChange, onDetail,
}: SWHeaderLineStyleColumnsProps) {
return (
<div className="hm-sw-header__line-drop">
<div className="hm-sw-header__line-column hm-sw-header__line-column--type">
<ul className="hm-sw-header__menu-list" aria-label="선 종류">
{lineTypes.map(item => (
<li key={item.id} className="hm-sw-header__menu-row hm-sw-header__menu-row--line" data-active={item === currentType ? "true" : undefined} data-disabled={item.disabled ? "true" : undefined}>
<button type="button" className="hm-sw-header__menu-item" title={item.name} aria-current={item === currentType ? "true" : undefined} disabled={item.disabled} onClick={() => onLineTypeSelect?.(item)}>
<SWHeaderLinePreview pattern={item.pattern} />
<span className="hm-sw-header__menu-text hm-sw-header__menu-name">{item.name}</span>
</button>
</li>
))}
</ul>
<div className="hm-sw-header__menu-footer">
<SWHeaderMenuDetail label="선스타일 상세" onClick={onDetail} />
</div>
</div>
{lineWeights && (
<div className="hm-sw-header__line-column">
<ul className="hm-sw-header__menu-list" aria-label="가중치">
{lineWeights.map(item => (
<li key={item.id} className="hm-sw-header__menu-row hm-sw-header__menu-row--weight" data-active={item === currentWeight ? "true" : undefined} data-disabled={item.disabled ? "true" : undefined}>
<button type="button" className="hm-sw-header__menu-item" title={item.name} aria-current={item === currentWeight ? "true" : undefined} disabled={item.disabled} onClick={() => onLineWeightSelect?.(item)}>
<span className="hm-sw-header__menu-text hm-sw-header__menu-name">{item.name}</span>
{item.value !== undefined && !item.editable && <span className="hm-sw-header__menu-text hm-sw-header__menu-value">{item.value}</span>}
</button>
{item.editable && (
// Sibling of the row button (no nested controls): editing never selects the row.
<SWHeaderInput
aria-label={`${item.name} 가중치`}
valueType="number"
value={item.value ?? ""}
disabled={item.disabled}
parse={draft => parseSWHeaderNumber(draft, { min: 0 })}
onCommit={onLineWeightValueChange && (next => onLineWeightValueChange(item, next))}
/>
)}
</li>
))}
</ul>
</div>
)}
{scale !== undefined && (
<div className="hm-sw-header__line-column">
<div className="hm-sw-header__menu-row hm-sw-header__menu-row--weight">
<span className="hm-sw-header__menu-label hm-sw-header__menu-text hm-sw-header__menu-name">스케일</span>
<SWHeaderInput
aria-label="스케일"
valueType="number"
value={scale}
parse={draft => parseSWHeaderNumber(draft, { min: 0, minExclusive: true })}
onCommit={onScaleChange}
/>
</div>
</div>
)}
@@ -0,0 +1,221 @@
import { useLayoutEffect, useState, type CSSProperties, type ReactNode, type Ref } from "react"
import type { SWHeaderColorOption, SWHeaderLayer, SWHeaderLineType } from "../types"
import { SWHeaderColorPanelBody, useSWHeaderColorMode } from "./SWHeaderColorMenu"
import { useSWHeaderDropdown } from "./SWHeaderDropdown"
import { SWHeaderInput, parseSWHeaderColor } from "./SWHeaderInput"
import { SWHeaderLayerRows } from "./SWHeaderLayerMenu"
import { SWHeaderLinePreview, SWHeaderLineStyleColumns } from "./SWHeaderLineStyleMenu"
import type { SWHeaderProperty, SWHeaderPropertyMenus, SWHeaderPropertyPicker } from "./SWHeaderPropertyPanel"
/** Dropdown of a property row: `picker` when set, else color rows and line style (select + linePreview). */
export function propertyPicker(property: SWHeaderProperty): SWHeaderPropertyPicker | undefined {
if (property.picker === false) return undefined
if (property.picker) return property.picker
if (property.type === "color") return "color"
if (property.type === "select" && property.linePreview) return "lineStyle"
return undefined
}
/** Line type rows: host `lineTypes`, else the property's own options (pattern only known for the current one). */
export function propertyLineTypes(property: SWHeaderProperty, menus?: SWHeaderPropertyMenus): readonly SWHeaderLineType[] {
return menus?.lineTypes ?? (property.options ?? []).map(option => ({
id: option.id, name: option.name, disabled: option.disabled, pattern: option.id === property.value ? property.linePattern : undefined,
}))
}
/** Layer rows: host `layers`, else the property's own options (no toggle state). */
function propertyLayers(property: SWHeaderProperty, menus?: SWHeaderPropertyMenus): readonly SWHeaderLayer[] {
return menus?.layers ?? (property.options ?? []).map(option => ({ id: option.id, name: option.name, disabled: option.disabled }))
}
const byValue = <T extends { id: string; name: string }>(items: readonly T[], value: string) =>
items.find(item => item.id === value) ?? items.find(item => item.name === value)
/**
* Field dropdown inside the property window: the shared header dropdown behavior (click / Enter / Space toggle,
* ArrowDown opens, Escape / outside click / Tab out close) with a fixed overlay, so the list escapes the
* scrolling panel body. Figma 26:12265 / 26:12404 attach at the field's right edge, 26:12184 at its left edge;
* the list flips above the field and shifts sideways only when the screen has no room.
*/
function useFieldDropdown(align: "start" | "end", disabled: boolean) {
const dropdown = useSWHeaderDropdown({ disabled })
const { open, rootRef, panelRef } = dropdown
const [position, setPosition] = useState<CSSProperties>({ visibility: "hidden" })
useLayoutEffect(() => {
if (!open) return
const update = () => {
const field = rootRef.current?.getBoundingClientRect()
const panel = panelRef.current
if (!field || !panel) return
const width = panel.offsetWidth, height = panel.offsetHeight
const viewportWidth = document.documentElement.clientWidth, viewportHeight = window.innerHeight
const left = Math.max(4, Math.min(align === "end" ? field.right - width : field.left, viewportWidth - width - 4))
const below = field.bottom
const top = below + height > viewportHeight - 4 && field.top - height >= 4 ? field.top - height : below
setPosition(current => (current.left === left && current.top === top ? current : { left, top }))
}
update()
window.addEventListener("resize", update)
window.addEventListener("scroll", update, true)
return () => {
window.removeEventListener("resize", update)
window.removeEventListener("scroll", update, true)
}
}, [open, align, rootRef, panelRef])
return { ...dropdown, position }
}
export interface SWHeaderPropertyPickerFieldProps {
picker: SWHeaderPropertyPicker
property: SWHeaderProperty
/** Id of the trigger (the row label points at it). */
id: string
/** Editable: opens the dropdown and commits. Otherwise the field only shows the value (Default / Hover). */
editable: boolean
menus?: SWHeaderPropertyMenus
/** Validated commit of the row (host callbacks). */
commit: (value: string) => void
/** Validation of the row, used by the color input. */
parse: (draft: string) => string | null
}
/**
* Layer (26:12250 / 26:12260 → 26:12265 / 26:12291), line style (26:12388 → 26:12404) and color
* (26:12170 → 26:12184) fields of the property window. Default: value only; Hover: 1px rgba(33,29,19,.4)
* border; On: #211D13 field + the shared header dropdown contents. Values and lists are host data; a
* selection is reported through the row commit (the host updates `properties`).
*/
export function SWHeaderPropertyPickerField({ picker, property, id, editable, menus, commit, parse }: SWHeaderPropertyPickerFieldProps) {
const { open, setOpen, close, panelRef, triggerRef, menuId, position, rootProps } = useFieldDropdown(picker === "color" ? "start" : "end", !editable)
const { mixed, value, unit } = property
const shared = {
...rootProps,
className: "hm-sw-property__value",
"data-field": "picker",
"data-picker": picker,
"data-mixed": mixed ? "true" : undefined,
"data-readonly": editable ? undefined : "true",
}
const trigger = {
id,
"aria-haspopup": "true" as const,
"aria-expanded": open,
"aria-controls": open ? menuId : undefined,
"aria-disabled": !editable && !property.disabled ? true : undefined,
}
const drop = (className: string, label: string, children: ReactNode) => (
<div ref={panelRef} id={menuId} className={`hm-sw-property__drop ${className}`} data-picker={picker} role="group" aria-label={label} style={position}>
{children}
</div>
)
if (picker === "layer") {
const layers = propertyLayers(property, menus)
const current = mixed ? undefined : byValue(layers, value)
return (
<div {...shared}>
<button ref={triggerRef} type="button" className="hm-sw-property__trigger" {...trigger} disabled={property.disabled} onClick={() => editable && setOpen(!open)}>
<span className="hm-sw-property__text" title={mixed ? "다양함" : current?.name ?? value}>{mixed ? "다양함" : current?.name ?? value}</span>
</button>
{open && drop("hm-sw-header__layer-panel", "레이어 목록", (
<SWHeaderLayerRows
layers={[...layers]}
current={current}
onSelect={layer => { commit(layer.id); close(true) }}
onVisibilityChange={menus?.onLayerVisibilityChange}
onFreezeChange={menus?.onLayerFreezeChange}
onLockChange={menus?.onLayerLockChange}
onDetail={menus?.onLayerDetail && (() => { menus.onLayerDetail?.(); close(true) })}
/>
))}
</div>
)
}
if (picker === "lineStyle") {
const lineTypes = propertyLineTypes(property, menus)
const current = mixed ? undefined : byValue(lineTypes, value)
const weights = menus?.lineWeights
const currentWeight = weights?.find(item => item.id === menus?.activeLineWeightId)
const text = mixed ? "다양함" : current?.name ?? property.options?.find(option => option.id === value)?.name ?? value
return (
<div {...shared}>
<button ref={triggerRef} type="button" className="hm-sw-property__trigger" {...trigger} disabled={property.disabled} onClick={() => editable && setOpen(!open)}>
<span className="hm-sw-property__trigger-text">
<span className="hm-sw-property__text" title={text}>{text}</span>
{unit && <span className="hm-sw-property__unit">{unit}</span>}
</span>
{property.linePreview !== false && <SWHeaderLinePreview pattern={current?.pattern ?? property.linePattern} />}
</button>
{open && drop("", "선스타일", (
<SWHeaderLineStyleColumns
lineTypes={lineTypes}
lineWeights={weights}
currentType={current}
currentWeight={currentWeight}
scale={menus?.lineScale}
onLineTypeSelect={item => { commit(item.id); close(true) }}
onLineWeightSelect={menus?.onLineWeightSelect}
onLineWeightValueChange={menus?.onLineWeightValueChange}
onScaleChange={menus?.onLineScaleChange}
onDetail={menus?.onLineStyleDetail && (() => { menus.onLineStyleDetail?.(); close(true) })}
/>
))}
</div>
)
}
return <ColorField {...{ property, editable, menus, commit, parse, shared, trigger, open, setOpen, close, triggerRef, drop }} />
}
const optionValue = (option: SWHeaderColorOption) => option.value ?? parseSWHeaderColor(option.color)?.value ?? option.color
/** Color (26:12170 / 26:12184): RGB input (validated 0-255, Figma sample 255,259,0 is not valid) + swatch. */
function ColorField({ property, editable, menus, commit, parse, shared, trigger, open, setOpen, close, triggerRef, drop }: Pick<SWHeaderPropertyPickerFieldProps, "property" | "editable" | "menus" | "commit" | "parse"> & {
shared: Record<string, unknown>
trigger: Record<string, unknown>
open: boolean
setOpen: (open: boolean) => void
close: (restoreFocus: boolean) => void
triggerRef: Ref<HTMLButtonElement>
drop: (className: string, label: string, children: ReactNode) => ReactNode
}) {
const [selectedMode, chooseMode] = useSWHeaderColorMode(menus?.colorMode)
const { mixed, value } = property
const parsed = parseSWHeaderColor(value)
const swatch = property.swatch ?? parsed?.hex ?? "transparent"
return (
<div {...shared}>
<span className="hm-sw-property__color-value">
<SWHeaderInput
ref={triggerRef as unknown as Ref<HTMLInputElement>}
{...trigger}
value={mixed ? "다양함" : value}
disabled={property.disabled}
readOnly={!editable}
parse={parse}
commitOnUnmount={false}
onCommit={editable ? commit : undefined}
onClick={() => { if (editable && !open) setOpen(true) }}
/>
</span>
<span className="hm-sw-property__swatch-area" aria-hidden="true" onClick={() => editable && setOpen(!open)}>
<span className="hm-sw-property__swatch"><i style={{ backgroundColor: swatch }} /></span>
</span>
{open && drop("hm-sw-header__menu-panel hm-sw-header__color-menu", "색상", (
<SWHeaderColorPanelBody
value={{ value: parsed?.value ?? value, swatch: property.swatch ?? parsed?.hex }}
colors={menus?.colors ?? []}
userColors={menus?.userColors}
mode={menus?.colorMode}
selectedMode={selectedMode}
onColorSelect={option => commit(optionValue(option))}
onModeChange={mode => { chooseMode(mode); menus?.onColorModeChange?.(mode, property) }}
onColorInput={next => commit(next.value)}
onPick={menus?.onColorPick && (() => { menus.onColorPick?.(property); close(true) })}
onDetail={menus?.onColorDetail && (() => { menus.onColorDetail?.(); close(true) })}
/>
))}
</div>
)
}
@@ -9,8 +9,10 @@ import toolHover from "../../../assets/property-tool-hover.svg"
import toolActiveGraphic from "../../../assets/property-tool-active.svg"
import closeDefault from "../../../assets/property-close.svg"
import closeHover from "../../../assets/property-close-hover.svg"
import type { SWHeaderColorMode, SWHeaderColorOption, SWHeaderLayer, SWHeaderLineType, SWHeaderLineWeight } from "../types"
import { SWHeaderInput, parseSWHeaderColor, parseSWHeaderNumber } from "./SWHeaderInput"
import { SWHeaderLinePreview } from "./SWHeaderLineStyleMenu"
import { SWHeaderPropertyPickerField, propertyLineTypes, propertyPicker } from "./SWHeaderPropertyFields"
export type SWHeaderPropertyType = "text" | "number" | "select" | "color" | "slider" | "computed"
export interface SWHeaderProperty {
@@ -28,15 +30,53 @@ export interface SWHeaderProperty {
min?: number
max?: number
step?: number
/** Fixed decimals of a committed number (e.g. 4: `2` → `2.0000`). The host value is shown as given. */
decimals?: number
options?: readonly { id: string; name: string; disabled?: boolean }[]
/** Optional color swatch / original line pattern, supplied by the host. */
swatch?: string
linePattern?: readonly number[]
linePreview?: boolean
graphicSlot?: "circumference" | "diameter" | "radius" | "area"
/**
* Field dropdown (Figma 26:12265 layer, 26:12404 line style, 26:12184 color). Default: `color` rows use the
* color dropdown and `select` rows with `linePreview` the line style dropdown; `layer` must be set. `false`
* keeps the plain field. Lists come from the panel `menus` (else from `options`).
*/
picker?: SWHeaderPropertyPicker | false
validate?: (draft: string) => boolean
onCommit?: (value: string, property: SWHeaderProperty) => void
}
export type SWHeaderPropertyPicker = "layer" | "lineStyle" | "color"
/**
* Host data of the property field dropdowns (same records as the header dropdowns). A row selection is
* committed to its property (`onPropertyChange` / `onCommit`, value = the row id); every other control only
* reports through its callback. Nothing here changes CAD data by itself.
*/
export interface SWHeaderPropertyMenus {
layers?: SWHeaderLayer[]
onLayerVisibilityChange?: (layer: SWHeaderLayer, nextVisible: boolean) => void
onLayerFreezeChange?: (layer: SWHeaderLayer, nextFrozen: boolean) => void
onLayerLockChange?: (layer: SWHeaderLayer, nextLocked: boolean) => void
onLayerDetail?: () => void
lineTypes?: SWHeaderLineType[]
/** Weight column of the line style dropdown; omitted => no column. */
lineWeights?: SWHeaderLineWeight[]
activeLineWeightId?: string
/** Scale column of the line style dropdown; omitted => no column. */
lineScale?: string
onLineWeightSelect?: (weight: SWHeaderLineWeight) => void
onLineWeightValueChange?: (weight: SWHeaderLineWeight, value: string) => void
onLineScaleChange?: (value: string) => void
onLineStyleDetail?: () => void
colors?: SWHeaderColorOption[]
userColors?: SWHeaderColorOption[]
colorMode?: SWHeaderColorMode | null
onColorModeChange?: (mode: SWHeaderColorMode, property: SWHeaderProperty) => void
/** Color pick (btn_colorize); picking from the drawing is the host's job. The dropdown closes. */
onColorPick?: (property: SWHeaderProperty) => void
onColorDetail?: () => void
}
export interface SWHeaderPropertyGroup {
id: string
name?: string
@@ -72,6 +112,8 @@ export interface SWHeaderPropertyPanelProps {
properties: readonly SWHeaderProperty[]
groups: readonly SWHeaderPropertyGroup[]
onPropertyChange?: (property: SWHeaderProperty, value: string) => void
/** Lists and callbacks of the layer / line style / color field dropdowns. */
menus?: SWHeaderPropertyMenus
expandedGroupIds?: readonly string[]
defaultExpandedGroupIds?: readonly string[]
onExpandedGroupsChange?: (ids: string[]) => void
@@ -120,27 +162,37 @@ export function useSWHeaderOverlay(anchorRef: RefObject<HTMLElement | null> | un
return { panelRef, style }
}
function PropertyEditor({ property, onChange }: { property: SWHeaderProperty; onChange?: SWHeaderPropertyPanelProps["onPropertyChange"] }) {
function PropertyEditor({ property, onChange, menus }: { property: SWHeaderProperty; onChange?: SWHeaderPropertyPanelProps["onPropertyChange"]; menus?: SWHeaderPropertyMenus }) {
const inputId = useId()
const inputRef = useRef<HTMLInputElement>(null)
const { type, value, disabled, mixed, readOnly, unit } = property
const [draft, setDraft] = useState(value)
useEffect(() => setDraft(value), [value, property.options, disabled, readOnly])
const pendingRef = useRef<string | null>(null)
useEffect(() => { pendingRef.current = null }, [value])
const editable = !disabled && !readOnly && type !== "computed" && (!!onChange || !!property.onCommit)
const picker = propertyPicker(property)
const parse = (draft: string) => {
if (property.validate && !property.validate(draft)) return null
if (type === "number" || type === "slider") return parseSWHeaderNumber(draft, { min: property.min, max: property.max })
if (type === "number" || type === "slider") {
const number = parseSWHeaderNumber(draft, { min: property.min, max: property.max })
return number !== null && property.decimals !== undefined ? Number(number).toFixed(property.decimals) : number
}
if (type === "color") return parseSWHeaderColor(draft)?.value ?? null
if (type === "select") return property.options?.some(option => option.id === draft && !option.disabled) ? draft : null
if (type === "select") {
const rows = picker === "lineStyle" ? propertyLineTypes(property, menus) : property.options ?? []
return rows.some(option => option.id === draft && !option.disabled) ? draft : null
}
return draft.trim()
}
const commit = (next: string) => {
if (!editable || next === value || next === pendingRef.current) return
if (parse(next) === null) { setDraft(value); return }
pendingRef.current = next
property.onCommit?.(next, property)
onChange?.(property, next)
const parsed = parse(next)
if (parsed === null) { setDraft(value); return }
if (parsed === value) return
pendingRef.current = parsed
property.onCommit?.(parsed, property)
onChange?.(property, parsed)
}
const cancelDraft = () => setDraft(value)
const rangeMin = property.min ?? 0, rangeMax = property.max ?? 100, rangeValue = mixed ? rangeMin : Number(draft)
@@ -150,10 +202,23 @@ function PropertyEditor({ property, onChange }: { property: SWHeaderProperty; on
if (event.key === "Enter") { event.preventDefault(); commit(draft) }
if (event.key === "Escape" && draft !== value) { event.stopPropagation(); cancelDraft() }
}
// Figma 26:12649: a number without unit / annotation (선가중치) and the slider value are the 41px hug field;
// every other value is the 200px box (26:12314 / 26:12590 / 26:12583 / 26:12574). Figma has an editing On
// (#0F0D0A) only for numbers; text and computed values keep Default / Hover (no invented On).
const compact = type === "slider" || (type === "number" && !unit && !property.annotation)
const field = compact ? undefined : "box"
return (
<div className="hm-sw-property__row" data-property-id={property.id} data-type={type} data-disabled={disabled || undefined}>
<label htmlFor={inputId} title={property.name}>{property.name}</label>
<div className="hm-sw-property__value">
{picker ? <SWHeaderPropertyPickerField picker={picker} property={property} id={inputId} editable={editable} menus={menus} commit={commit} parse={parse} /> : (
<div className="hm-sw-property__value" data-field={field} data-on={field && editable && type === "number" ? "edit" : undefined}
data-readonly={editable ? undefined : "true"}
onMouseDown={event => {
// A click on the unit / empty part of the box edits the value (the box is the Figma field).
if (!field || !editable || event.target === inputRef.current || type === "select") return
event.preventDefault()
inputRef.current?.focus()
}}>
{type === "select" ? (
<select id={inputId} aria-label={property.name} value={mixed && draft === value ? "__mixed" : draft} disabled={!editable} onChange={event => setDraft(event.target.value)} onBlur={() => commit(draft)} onKeyDown={nativeKeys}>
{mixed && <option value="__mixed" disabled>다양함</option>}
@@ -162,7 +227,9 @@ function PropertyEditor({ property, onChange }: { property: SWHeaderProperty; on
</select>
) : (
<SWHeaderInput
ref={inputRef}
id={inputId} aria-label={property.name} value={mixed ? "다양함" : value}
className={compact ? "hm-sw-property__compact" : undefined}
valueType={type === "number" || type === "slider" ? "number" : "text"}
disabled={disabled} readOnly={!editable} parse={parse} commitOnUnmount={false}
onCommit={editable ? commit : undefined}
@@ -170,18 +237,19 @@ function PropertyEditor({ property, onChange }: { property: SWHeaderProperty; on
)}
{property.annotation && <span className="hm-sw-property__unit">{property.annotation}</span>}
{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>}
{type === "color" && <span className="hm-sw-property__swatch-area" aria-hidden="true"><span className="hm-sw-property__swatch"><i style={{ backgroundColor: property.swatch ?? parseSWHeaderColor(value)?.hex ?? "transparent" }} /></span></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 ? 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>
)
}
/** A single data-driven, non-modal property tool window (Figma 26:11822). */
export function SWHeaderPropertyPanel({
selection, properties, groups, onPropertyChange, expandedGroupIds, defaultExpandedGroupIds = [],
selection, properties, groups, onPropertyChange, menus, expandedGroupIds, defaultExpandedGroupIds = [],
onExpandedGroupsChange, toolActive: controlledTool, onToolActiveChange, toolLabel = "객체 선택 도구",
onOverviewClick, shapeContent, onClose, id, anchorRef, style: customStyle,
}: SWHeaderPropertyPanelProps) {
@@ -244,7 +312,7 @@ export function SWHeaderPropertyPanel({
{group.layout === "position" && <div className="hm-sw-property__position-head"><span>구분</span>{visible.map(item => <span key={item.id}>{item.name}</span>)}</div>}
<div className="hm-sw-property__editors">
{group.layout === "position" && <span className="hm-sw-property__position-label">{group.positionLabel}</span>}
{visible.map(property => <div key={property.id} className="hm-sw-property__editor" data-slot={property.graphicSlot}><PropertyEditor property={property} onChange={onPropertyChange} /></div>)}
{visible.map(property => <div key={property.id} className="hm-sw-property__editor" data-slot={property.graphicSlot}><PropertyEditor property={property} onChange={onPropertyChange} menus={menus} /></div>)}
</div>
{expandable && <button type="button" className="hm-sw-property__expand" aria-label={`${group.name ?? "공통 속성"} ${isExpanded ? "접기" : "펼치기"}`} aria-expanded={isExpanded} onClick={() => toggleGroup(group.id)}><img src={expandGraphic} alt="" /></button>}
</div>
@@ -19,7 +19,7 @@ export {
type SWHeaderInputProps, type SWHeaderInputValueType,
} from "./components/SWHeaderInput"
export { SWHeaderProperties, type SWHeaderPropertiesProps } from "./components/SWHeaderProperties"
export { SWHeaderPropertyPanel, type SWHeaderPropertyPanelProps, type SWHeaderProperty, type SWHeaderPropertyType, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderPropertyShapeContent, type SWHeaderPropertyShapeContext } from "./components/SWHeaderPropertyPanel"
export { SWHeaderPropertyPanel, type SWHeaderPropertyPanelProps, type SWHeaderProperty, type SWHeaderPropertyType, type SWHeaderPropertyPicker, type SWHeaderPropertyMenus, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderPropertyShapeContent, type SWHeaderPropertyShapeContext } from "./components/SWHeaderPropertyPanel"
export { SWHeaderActions, type SWHeaderActionsProps } from "./components/SWHeaderActions"
export { SWHeaderWindowControls, type SWHeaderWindowControlsProps } from "./components/SWHeaderWindowControls"
export type {
+111 -2
View File
@@ -75,6 +75,12 @@
--hm-sw-property-label: #000;
--hm-sw-property-table: #d2c8b4;
--hm-sw-property-inner-border: #948871;
/* Property fields (Figma 26:12170 ~ 26:12649): Hover border, editing On, dropdown On, unit colors. */
--hm-sw-property-field-hover-border-color: rgba(33, 29, 19, 0.4);
--hm-sw-property-field-editing-background: #0f0d0a;
--hm-sw-property-field-open-background: #211d13;
--hm-sw-property-field-editing-unit-color: #fff;
--hm-sw-property-field-open-unit-color: #d4cbbd;
/* 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.
@@ -1520,8 +1526,10 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-property__value > select:disabled { opacity: 1; appearance: none; color: var(--hm-sw-header-input-color); }
.hm-sw-property__value > select:focus { background: var(--hm-sw-property-content); border-color: var(--hm-sw-property-inner-border); }
.hm-sw-property__unit { flex: 0 0 auto; font-size: 12px; white-space: nowrap; }
.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; }
/* Color preview (26:12175): 70 x 16 black tile (1px padding, radius 2), 68 x 14 swatch with a white 1px border. */
.hm-sw-property__swatch-area { display: flex; align-items: center; flex: 0 0 auto; }
.hm-sw-property__swatch { display: block; flex: 0 0 70px; width: 70px; height: 16px; padding: 1px; border-radius: 2px; background: #000; }
.hm-sw-property__swatch > i { display: block; height: 14px; border: 1px solid #fff; border-radius: 1px; }
.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-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
@@ -1582,6 +1590,107 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-property__row[data-disabled="true"] { opacity: .4; }
.hm-sw-property button:focus-visible, .hm-sw-property input:focus-visible, .hm-sw-property select:focus-visible { outline: 2px solid var(--hm-sw-property-border); outline-offset: -2px; }
/* Property fields (2026-10-19, Figma 26:12170 ~ 26:12649). Common rows only; the circle / position groups keep
their layout. Box: 28px, radius 2, a transparent 1px border so Hover / On never move the value (8px inset as
in Default). Default: value only. Hover: rgba(33,29,19,.4) border, values / units / previews unchanged.
On (editing, numbers 26:12323 / 26:12599 / 26:12656): #0F0D0A, black text outline, unit #FFF, red caret.
On (dropdown 26:12265 / 26:12291 / 26:12404 / 26:12184): #211D13, outlined value, unit #D4CBBD.
Text and computed values (26:12583 / 26:12574) have no Figma On: only Default / Hover. */
.hm-sw-property__group--rows .hm-sw-property__value[data-field] {
position: relative;
height: 28px;
border: 1px solid transparent;
border-radius: 2px;
justify-content: space-between;
}
.hm-sw-property__group--rows .hm-sw-property__value[data-field="box"] { padding: 0 7px; }
.hm-sw-property__group--rows .hm-sw-property__value[data-field="picker"] { padding: 0; }
.hm-sw-property__group--rows .hm-sw-property__row:not([data-disabled]) > .hm-sw-property__value[data-field]:is(:hover, [data-open="true"], :not([data-readonly]):focus-within) {
border-color: var(--hm-sw-property-field-hover-border-color);
}
.hm-sw-property__group--rows .hm-sw-property__value[data-field] .hm-sw-header__input,
.hm-sw-property__group--rows .hm-sw-property__value[data-field] .hm-sw-header__input:is(:hover, :focus) {
flex: 1 1 auto;
align-self: stretch;
width: auto;
min-width: 0;
height: auto;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
font-weight: 500;
letter-spacing: 0.02em;
text-shadow: none;
}
/* The box shows the editing state; read-only values keep the keyboard focus ring. */
.hm-sw-property__group--rows .hm-sw-property__value[data-field] .hm-sw-header__input:not(:read-only):focus-visible { outline: none; }
.hm-sw-property__group--rows .hm-sw-property__value[data-on="edit"]:focus-within { background: var(--hm-sw-property-field-editing-background); }
.hm-sw-property__group--rows .hm-sw-property__value[data-on="edit"]:focus-within > .hm-sw-header__input { text-shadow: var(--hm-sw-header-text-outline); }
.hm-sw-property__group--rows .hm-sw-property__value[data-on="edit"]:focus-within > .hm-sw-property__unit { color: var(--hm-sw-property-field-editing-unit-color); }
/* 41px hug field (26:12649: 선가중치 / 투명도 value): pl 6, pr 4, the input itself is the box. */
.hm-sw-property__group--rows .hm-sw-property__value > .hm-sw-header__input.hm-sw-property__compact,
.hm-sw-property__group--rows .hm-sw-property__value > .hm-sw-header__input.hm-sw-property__compact:is(:hover, :focus) {
flex: 0 0 auto;
width: calc(var(--hm-sw-input-chars, 1) * (1ch + 0.02em) + 10px);
height: 28px;
padding: 0 3px 0 5px;
border: 1px solid transparent;
border-radius: 2px;
background: transparent;
}
@supports (field-sizing: content) {
.hm-sw-property__group--rows .hm-sw-property__value > .hm-sw-header__input.hm-sw-property__compact { width: auto; min-width: calc(1ch + 10px); }
}
.hm-sw-property__group--rows .hm-sw-property__row:not([data-disabled]) .hm-sw-header__input.hm-sw-property__compact:is(:hover, :focus:not(:read-only)) { border-color: var(--hm-sw-property-field-hover-border-color); }
.hm-sw-property__group--rows .hm-sw-property__value > .hm-sw-header__input.hm-sw-property__compact:focus:not(:read-only) {
outline: none;
background: var(--hm-sw-property-field-editing-background);
text-shadow: var(--hm-sw-header-text-outline);
}
/* Dropdown fields: layer (pl/pr 8), line style (pl 8, pr 6: value + (ByL) + pattern), color (RGB | swatch). */
.hm-sw-property__trigger {
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
flex: 1 1 auto;
align-self: stretch;
min-width: 0;
padding: 0 7px;
color: var(--hm-sw-header-input-color);
text-align: left;
letter-spacing: 0.02em;
}
.hm-sw-property__value[data-picker="lineStyle"] > .hm-sw-property__trigger { padding-right: 5px; }
.hm-sw-property__value[data-readonly] > .hm-sw-property__trigger,
.hm-sw-property__trigger:disabled { cursor: default; opacity: 1; }
.hm-sw-property__trigger-text { display: flex; align-items: center; gap: 4px; min-width: 0; }
.hm-sw-property__text { min-width: 0; overflow: clip; overflow-clip-margin: var(--hm-sw-header-text-outline-room); white-space: nowrap; text-overflow: ellipsis; font-weight: 500; }
.hm-sw-property__trigger .hm-sw-property__unit { color: var(--hm-sw-property-label); }
/* Figma 68px (Dotum). Noto Sans KR needs more room for "Continuous (ByL)": the preview gives way first (Figma On draws it at 50px), then the name ellipsizes. */
.hm-sw-property__trigger > .hm-sw-header__line-preview { flex: 0 1 68px; width: 68px; min-width: 40px; }
.hm-sw-property__value[data-picker="lineStyle"] .hm-sw-property__trigger-text { flex-shrink: 0; max-width: calc(100% - 44px); }
.hm-sw-property__value[data-field="picker"][data-open="true"]:not([data-picker="color"]),
.hm-sw-property__value[data-picker="color"][data-open="true"] > .hm-sw-property__color-value { background: var(--hm-sw-property-field-open-background); }
.hm-sw-property__group--rows .hm-sw-property__value[data-field][data-open="true"] :is(.hm-sw-property__text, .hm-sw-header__input) { text-shadow: var(--hm-sw-header-text-outline); }
.hm-sw-property__value[data-open="true"] .hm-sw-property__trigger .hm-sw-property__unit { color: var(--hm-sw-property-field-open-unit-color); }
.hm-sw-property__color-value { display: flex; align-items: center; flex: 1 1 auto; align-self: stretch; min-width: 0; padding: 0 7px; border-right: 1px solid transparent; }
.hm-sw-property__value[data-field] > .hm-sw-property__swatch-area { align-self: stretch; padding: 0 5px; }
.hm-sw-property__value[data-field="box"] > .hm-sw-property__swatch-area { padding-right: 0; }
.hm-sw-property__value[data-picker="color"]:not([data-readonly]) > .hm-sw-property__swatch-area { cursor: pointer; }
.hm-sw-property__group--rows .hm-sw-property__row:not([data-disabled]) > .hm-sw-property__value[data-picker="color"]:is(:hover, [data-open="true"]) > .hm-sw-property__color-value {
border-right-color: var(--hm-sw-property-field-hover-border-color);
}
/* The shared header dropdown contents in a fixed overlay (escapes the scrolling body). No yellow ring:
the property Figma dropdowns have the black stroke and the brown surface only. */
.hm-sw-property__drop { position: fixed; z-index: 50; top: 0; left: 0; color: var(--hm-sw-header-text-color); text-align: left; }
.hm-sw-property__drop[data-picker="layer"] { width: var(--hm-sw-header-layer-menu-width); }
.hm-sw-property__drop.hm-sw-header__menu-panel { position: fixed; max-width: none; }
.hm-sw-property__drop.hm-sw-header__menu-panel::before { display: none; }
/* K guide 26:11698 / 26:11669: original image, fixed mask viewport and continuous pan/zoom. */
.hm-sw-k-guide { position: relative; flex: 0 0 40px; height: 38px; }
.hm-sw-k-guide__panel { z-index: 45; height: 149px; min-height: 149px; overflow: hidden; border: 1px solid var(--hm-sw-property-border); border-radius: 2px; background: #0e3c2e; animation: hm-sw-k-guide-enter 511ms ease-out; }
@@ -0,0 +1,227 @@
// Property fields (Figma 26:12388 ~ 26:12649): Default / real pointer Hover / Mouse Leave / On (editing, dropdown),
// selection callbacks, keyboard, Escape, external data, Disabled / ReadOnly, and the four panel states.
// Usage: node packages/sw/tests/property-fields.browser.mjs <evidence-dir> <label> [storybook-url]
// Storybook serves packages/sw/dist: build @hm-design/sw first.
import { 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-fields-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: 2 })
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)) }
const shot = async (name, locator) => writeFile(path.join(out, `${label}-${name}.png`), await (locator ?? page).screenshot())
await page.goto(`${base}/iframe.html?id=sw-header--property-fields&viewMode=story`)
await page.locator('[data-fields="live"] .hm-sw-property').waitFor(); await page.evaluate(() => document.fonts.ready)
const live = page.locator('[data-fields="live"]')
const row = id => live.locator(`.hm-sw-property__row[data-property-id="${id}"]`)
const field = id => row(id).locator('.hm-sw-property__value').first()
const log = () => page.locator('[data-property-log]').textContent()
const HOVER = 'rgba(33, 29, 19, 0.4)', CLEAR = 'rgba(0, 0, 0, 0)'
const style = (locator, ...props) => locator.evaluate((e, props) => { const s = getComputedStyle(e), r = e.getBoundingClientRect(); return Object.assign({ x: r.x, y: r.y, w: +r.width.toFixed(2), h: +r.height.toFixed(2) }, ...props.map(p => ({ [p]: s[p] }))) }, props)
const textBox = async id => row(id).locator('.hm-sw-header__input, .hm-sw-property__text').first().evaluate(e => { const r = e.getBoundingClientRect(); return [r.x, r.y] })
await page.mouse.move(1, 1)
// 1. Geometry (Default): 200 x 28 boxes, 28px compact (선가중치 / 투명도 value), radius 2, no border, no surface.
for (const id of ['layer-mixed', 'layer', 'color', 'line', 'scale', 'link', 'thickness', 'material']) {
const s = await style(field(id), 'borderTopColor', 'backgroundColor', 'borderTopLeftRadius')
check(`Default ${id}: 200 x 28, radius 2, no border / surface`, s.w === 200 && s.h === 28 && s.borderTopColor === CLEAR && s.backgroundColor === CLEAR && s.borderTopLeftRadius === '2px', s)
}
for (const id of ['weight', 'opacity']) {
const s = await style(row(id).locator('.hm-sw-property__compact'), 'borderTopColor', 'backgroundColor', 'paddingLeft', 'paddingRight')
check(`Default ${id}: compact 28px hug field (pl 6 / pr 4 incl. border), no border`, s.h === 28 && s.w < 60 && s.borderTopColor === CLEAR && s.paddingLeft === '5px' && s.paddingRight === '3px', s)
}
const typo = await row('scale').evaluate(r => { const v = getComputedStyle(r.querySelector('.hm-sw-header__input')), u = getComputedStyle(r.querySelector('.hm-sw-property__unit')); return { value: [v.color, v.fontSize], unit: [u.color, u.fontSize] } })
check('Typography: value #E60403 14px, unit #000 12px', typo.value.join() === 'rgb(230, 4, 3),14px' && typo.unit.join() === 'rgb(0, 0, 0),12px', typo)
await shot('default', live.locator('.hm-sw-property'))
// 2. Real pointer Hover → border only, value does not move; Mouse Leave → Default.
for (const id of ['layer-mixed', 'layer', 'color', 'line', 'scale', 'link', 'thickness', 'material']) {
const before = await textBox(id)
await field(id).hover()
const s = await style(field(id), 'borderTopColor', 'backgroundColor')
const after = await textBox(id)
const extra = id === 'color' ? await style(row(id).locator('.hm-sw-property__color-value'), 'borderRightColor') : {}
check(`Hover ${id}: rgba(33,29,19,.4) border, 28px kept, value not moved${id === 'color' ? ', divider shown' : ''}`, s.borderTopColor === HOVER && s.h === 28 && s.backgroundColor === CLEAR && before.join() === after.join() && (id !== 'color' || extra.borderRightColor === HOVER), { ...s, before, after, ...extra })
await page.mouse.move(1, 1)
const left = await style(field(id), 'borderTopColor')
check(`Mouse Leave ${id}: back to Default`, left.borderTopColor === CLEAR, left)
}
for (const id of ['weight', 'opacity']) {
const c = row(id).locator('.hm-sw-property__compact')
await c.hover(); const s = await style(c, 'borderTopColor'); await page.mouse.move(1, 1); const l = await style(c, 'borderTopColor')
check(`Hover / Leave ${id} compact`, s.borderTopColor === HOVER && l.borderTopColor === CLEAR, { hover: s.borderTopColor, leave: l.borderTopColor })
}
await field('scale').hover(); await shot('hover-scale', field('scale')); await page.mouse.move(1, 1)
// 3. On (editing): numbers only. #0F0D0A, unit white, outlined value, real input focus; Enter commits (4 decimals).
for (const id of ['scale', 'thickness']) {
await row(id).locator('.hm-sw-property__unit').click()
const s = await style(field(id), 'backgroundColor', 'borderTopColor')
const u = await style(row(id).locator('.hm-sw-property__unit'), 'color')
const input = await row(id).locator('input').evaluate(e => ({ focused: document.activeElement === e, caret: getComputedStyle(e).caretColor, shadow: getComputedStyle(e).textShadow !== 'none' }))
check(`On(편집) ${id}: #0F0D0A, border, unit #FFF, focused input with red caret + outline`, s.backgroundColor === 'rgb(15, 13, 10)' && s.borderTopColor === HOVER && u.color === 'rgb(255, 255, 255)' && input.focused && input.caret === 'rgb(230, 4, 3)' && input.shadow && s.h === 28, { ...s, unit: u.color, ...input })
await shot(`on-${id}`, field(id))
await page.keyboard.press('Escape')
}
const scale = row('scale').locator('input')
await scale.click(); await scale.fill('2'); await page.keyboard.press('Enter')
check('Enter: 선축적 2 → validated, 4 decimals committed', (await log()).includes('property(scale, 2.0000)') && await scale.inputValue() === '2.0000', await log())
await scale.fill('7'); await page.keyboard.press('Escape')
check('Escape: draft cancelled (2.0000 restored, no callback)', await scale.inputValue() === '2.0000' && !(await log()).includes('7'), await scale.inputValue())
const weight = row('weight').locator('input')
await weight.click(); const w = await style(weight, 'backgroundColor')
check('On(편집) 선가중치 compact: #0F0D0A', w.backgroundColor === 'rgb(15, 13, 10)', w)
await shot('on-weight', row('weight'))
await weight.fill('-1'); await page.keyboard.press('Enter')
check('Invalid 선가중치 -1 (min 0): restored, no callback', await weight.inputValue() === '0.25' && !(await log()).includes('weight, -1'), await weight.inputValue())
await weight.fill('0.5'); await page.keyboard.press('Enter')
check('선가중치 0.5 committed', (await log()).includes('property(weight, 0.5)'), await log())
await page.mouse.click(1, 1)
// 4. No invented On: 하이퍼링크 (text) focus keeps the Hover look only; 재료 (computed) is read-only.
await row('link').locator('input').click()
const link = await style(field('link'), 'backgroundColor', 'borderTopColor')
check('하이퍼링크 focus: no On surface (Figma has no On), editable', link.backgroundColor === CLEAR && link.borderTopColor === HOVER, link)
await row('link').locator('input').fill('http://a'); await page.keyboard.press('Enter')
check('하이퍼링크 commit via host callback', (await log()).includes('property(link, http://a)'), await log())
await row('material').locator('input').click()
const mat = await style(field('material'), 'backgroundColor')
check('재료: read-only, no On', mat.backgroundColor === CLEAR && await row('material').locator('input').evaluate(e => e.readOnly), mat)
await page.mouse.click(1, 1)
// 5. Layer dropdown (26:12265 / 26:12291): #211D13, 234px list right-aligned under the field, rows 30, footer 40.
const layerTrigger = field('layer').locator('.hm-sw-property__trigger')
await layerTrigger.click()
const drop = page.locator('.hm-sw-property__drop')
let f = await style(field('layer'), 'backgroundColor', 'borderTopColor'), d = await style(drop.first())
const rows = await drop.locator('.hm-sw-header__layer-row').count()
const rowH = await style(drop.locator('.hm-sw-header__layer-row').first()), foot = await style(drop.locator('.hm-sw-header__layer-footer'))
check('Layer On: #211D13 field, 234 wide list, right edges meet, starts at field bottom', f.backgroundColor === 'rgb(33, 29, 19)' && d.w === 234 && Math.abs(d.x + d.w - (f.x + f.w)) < 0.5 && Math.abs(d.y - (f.y + f.h)) < 0.5, { f, d })
check('Layer list rows 30px, footer 40px, height = rows*30+40', rowH.h === 30 && foot.h === 40 && Math.abs(d.h - (rows * 30 + 40)) < 0.5, { rows, row: rowH.h, footer: foot.h, h: d.h })
check('Layer current row marked (0_Layer_Cludmark)', await drop.locator('[aria-current="true"]').textContent() === '0_Layer_Cludmark', null)
await shot('on-layer', page)
await drop.locator('.hm-sw-header__layer-row').nth(3).locator('.hm-sw-header__layer-toggle').first().click()
check('Bulb toggle: callback only, list stays open, row not selected', (await log()).includes('layer.visible(Sample-Wall, true)') && await drop.count() === 1 && !(await log()).includes('property(layer, Sample-Wall)'), await log())
await drop.getByRole('button', { name: 'CC-D GNL', exact: true }).click()
check('Row select: property(layer, CC-D GNL), closes, field shows value', (await log()).includes('property(layer, CC-D GNL)') && await drop.count() === 0 && (await field('layer').textContent()).includes('CC-D GNL'), await log())
check('Close keeps the value and focus returns to the field', await layerTrigger.evaluate(e => document.activeElement === e), null)
// Mixed (다양함): no current row; host switch to single value.
await field('layer-mixed').locator('.hm-sw-property__trigger').click()
check('다양함: text 다양함, no current row in list', (await field('layer-mixed').locator('.hm-sw-property__trigger').textContent()) === '다양함' && await drop.locator('[aria-current="true"]').count() === 0, null)
await shot('on-layer-mixed', page)
await page.keyboard.press('Escape')
check('Escape closes the layer list; panel stays', await drop.count() === 0 && await live.locator('.hm-sw-property').count() === 1, null)
await page.getByRole('button', { name: '레이어 다양함 ↔ 단일' }).click()
check('Host data: 다양함 → 단일 값 (0_Layer_Cludmark)', (await field('layer-mixed').textContent()) === '0_Layer_Cludmark', await field('layer-mixed').textContent())
await page.getByRole('button', { name: '레이어 외부 값 변경' }).click()
check('External value change shown (CC-D GNL)', (await field('layer').textContent()) === 'CC-D GNL', null)
// Keyboard: Enter opens, ArrowDown moves into the list, Escape closes back to the trigger.
await layerTrigger.focus(); await page.keyboard.press('ArrowDown')
const focusedInList = await page.evaluate(() => !!document.activeElement?.closest('.hm-sw-property__drop'))
await page.keyboard.press('Escape')
check('Keyboard: ArrowDown opens + focuses list, Escape closes to trigger', focusedInList && await drop.count() === 0 && await layerTrigger.evaluate(e => document.activeElement === e), null)
await layerTrigger.click(); await page.mouse.click(1300, 900)
check('Outside click closes', await drop.count() === 0, null)
// 6. Line style (26:12388 / 26:12404): 466 = 182 + 142 + 142, right-aligned, preview follows the value.
await field('line').locator('.hm-sw-property__trigger').click()
f = await style(field('line'), 'backgroundColor'); d = await style(drop.locator('.hm-sw-header__line-drop'))
const cols = await drop.locator('.hm-sw-header__line-column').evaluateAll(es => es.map(e => +e.getBoundingClientRect().width.toFixed(2)))
const unitOn = await style(field('line').locator('.hm-sw-property__unit'), 'color')
check('Line style On: #211D13, (ByL) #D4CBBD, 3 columns, right edges meet (or shifted to the 4px screen edge when no room)', f.backgroundColor === 'rgb(33, 29, 19)' && unitOn.color === 'rgb(212, 203, 189)' && cols.length === 3 && (Math.abs(d.x + d.w - (f.x + f.w)) < 0.5 || d.x === 4) && Math.abs(d.y - (f.y + f.h)) < 0.5, { f, d, cols })
check('Line style drop 466 x 190 (type column grows only for wider font names)', d.w >= 466 && d.h === 190, { w: d.w, h: d.h, cols })
await shot('on-line', page)
await drop.getByRole('button', { name: 'ByBlock', exact: true }).nth(1).click()
check('Weight column: callback only, stays open', (await log()).includes('lineWeight(w-byblock)') && await drop.count() === 1, await log())
await drop.getByRole('button', { name: 'CENTER' }).click()
const segs = await field('line').locator('.hm-sw-header__line-preview > i').count()
check('Line type select: property(line, CENTER), closes, preview = CENTER pattern (4 segments)', (await log()).includes('property(line, CENTER)') && await drop.count() === 0 && segs === 4, { segs })
await page.getByRole('button', { name: '선스타일 외부 CENTER' }).click()
// 7. Color (26:12170 / 26:12184): RGB value | swatch, 192px dropdown left-aligned, ByLayer / ByBlock, chips, pick, validation.
const colorInput = field('color').locator('.hm-sw-property__color-value input')
check('Figma sample 255,259,0 shown as host text, swatch from host', await colorInput.inputValue() === '255,259,0', null)
await colorInput.click()
f = await style(field('color')); d = await style(drop.first())
const left = await style(field('color').locator('.hm-sw-property__color-value'), 'backgroundColor', 'borderRightColor')
check('Color On: dropdown 192 wide, left edges meet, RGB area #211D13 + divider', d.w === 192 && Math.abs(d.x - f.x) < 0.5 && Math.abs(d.y - (f.y + f.h)) < 0.5 && left.backgroundColor === 'rgb(33, 29, 19)' && left.borderRightColor === HOVER, { f, d, left })
check('Color dropdown height 158 (Figma)', d.h === 158, d.h)
await shot('on-color', page)
await drop.getByRole('button', { name: 'ByBlock' }).click()
check('ByBlock: callback, required single choice (ByBlock pressed, ByLayer not)', (await log()).includes('colorMode(byBlock)') && await drop.getByRole('button', { name: 'ByBlock' }).getAttribute('aria-pressed') === 'true' && await drop.getByRole('button', { name: 'ByLayer' }).getAttribute('aria-pressed') === 'false', null)
await drop.getByRole('button', { name: 'ByBlock' }).click()
check('Clicking the pressed mode is a no-op', (await log()).split('colorMode').length === 2, null)
await drop.getByRole('button', { name: '빨강' }).click()
check('Chip: property(color, 241,4,4)', (await log()).includes('property(color, 241,4,4)'), await log())
const inner = drop.getByRole('textbox', { name: '색상값 (R,G,B)' })
await inner.fill('255,259,0'); await page.keyboard.press('Enter')
check('Out-of-range 255,259,0 rejected (no callback, restored)', !(await log()).includes('255,259,0') && await inner.inputValue() === '241,4,4', await inner.inputValue())
await inner.fill('1, 2, 3'); await page.keyboard.press('Enter')
check('Valid RGB committed normalized (1,2,3)', (await log()).includes('property(color, 1,2,3)'), await log())
await drop.getByRole('button', { name: '색상 추출' }).click()
check('btn_colorize: host callback, dropdown closes', (await log()).includes('colorPick(color)') && await drop.count() === 0, null)
await colorInput.click(); await colorInput.fill('300,0,0'); await page.keyboard.press('Enter')
check('Field RGB 300,0,0 rejected', !(await log()).includes('300,0,0') && await colorInput.inputValue() === '1,2,3', await colorInput.inputValue())
await colorInput.fill('9,9,9'); await page.keyboard.press('Escape')
check('Escape #1 cancels draft, dropdown still open', await colorInput.inputValue() === '1,2,3' && await drop.count() === 1, null)
await page.keyboard.press('Escape')
check('Escape #2 closes the dropdown, panel stays', await drop.count() === 0 && await live.locator('.hm-sw-property').count() === 1, null)
// 8. Disabled / ReadOnly and external updates.
await page.getByRole('button', { name: '두께·레이어 ReadOnly' }).click()
await page.getByRole('button', { name: '선축적·색상 Disabled' }).click()
await row('thickness').locator('input').click()
const ro = await style(field('thickness'), 'backgroundColor')
await field('layer').locator('.hm-sw-property__trigger').click({ force: true })
check('ReadOnly 두께: no On; ReadOnly 레이어: no dropdown', ro.backgroundColor === CLEAR && await drop.count() === 0, ro)
await field('thickness').hover()
check('ReadOnly keeps Hover border', (await style(field('thickness'), 'borderTopColor')).borderTopColor === HOVER, null)
await colorInput.click({ force: true })
await field('scale').hover()
check('Disabled 색상: no dropdown; Disabled 선축적: no hover border', await drop.count() === 0 && (await style(field('scale'), 'borderTopColor')).borderTopColor === CLEAR, null)
await page.getByRole('button', { name: '두께·레이어 ReadOnly' }).click(); await page.getByRole('button', { name: '선축적·색상 Disabled' }).click()
await page.getByRole('button', { name: '선축적 외부 값 변경' }).click(); await page.getByRole('button', { name: '두께 단위 변경' }).click()
check('External value / unit props: 선축적 2.5000, 두께 cm', await scale.inputValue() === '2.5000' && (await row('thickness').locator('.hm-sw-property__unit').textContent()) === 'cm', null)
await page.getByRole('button', { name: '속성 추가' }).click(); await page.getByRole('button', { name: '하이퍼링크 삭제' }).click()
const ids = await live.locator('.hm-sw-property__row').evaluateAll(es => es.map(e => e.dataset.propertyId))
check('Dynamic rows: added at the end, removed row gone, order kept', ids.at(-1).startsWith('extra-') && !ids.includes('link') && ids[0] === 'layer-mixed', ids)
await page.getByRole('button', { name: /menus 없음/ }).click()
await field('line').locator('.hm-sw-property__trigger').click()
const fallback = await drop.locator('.hm-sw-header__line-column').count()
await page.keyboard.press('Escape')
check('Without menus: line types from options only (1 column), no crash', fallback === 1, fallback)
// 9. Pinned Hover specimen matches the real hover.
const pinned = await page.locator('[data-fields="hover"] .hm-sw-property__value[data-field]').first().evaluate(e => getComputedStyle(e).borderTopColor)
check('Pinned Hover specimen uses the same border', pinned === HOVER, pinned)
// 10. Four property panel states keep their Figma sizes.
await page.goto(`${base}/iframe.html?id=sw-header--property-panel-compare&viewMode=story`)
await page.locator('.hm-sw-property').first().waitFor(); await page.evaluate(() => document.fonts.ready)
const sizes = await page.locator('[data-compare]').evaluateAll(cells => cells.map(c => { const r = c.querySelector('.hm-sw-property').getBoundingClientRect(); return [c.dataset.compare, Math.round(r.width), Math.round(r.height)] }))
const want = { none: [314, 256], all: [314, 361], circle: [314, 658], expanded: [314, 754] }
check('4 panel states: 314 x 256 / 361 / 658 / 754', sizes.every(([k, w, h]) => w === want[k][0] && h === want[k][1]), sizes)
await shot('compare', page.locator('.sw-story-compare'))
// The header-hosted panel: layer field opens its list over the header page as well.
await page.goto(`${base}/iframe.html?id=sw-header--property-panel&viewMode=story`)
await page.locator('.hm-sw-header').waitFor()
await page.getByRole('button', { name: '원 1개', exact: true }).click()
await page.locator('.hm-sw-header [data-property-panel-trigger]').click()
await page.locator('.hm-sw-property .hm-sw-property__row[data-property-id="layer"] .hm-sw-property__trigger').click()
const hosted = await page.evaluate(() => { const d = document.querySelector('.hm-sw-property__drop'), f = document.querySelector('.hm-sw-property__row[data-property-id="layer"] .hm-sw-property__value'); if (!d) return null; const a = d.getBoundingClientRect(), b = f.getBoundingClientRect(); return { right: a.right - b.right, top: a.top - b.bottom, panelOpen: !!document.querySelector('.hm-sw-property') } })
await page.keyboard.press('Escape')
const stillOpen = await page.locator('.hm-sw-property').count()
check('Header-hosted panel: layer list attached; Escape closes list only', hosted && Math.abs(hosted.right) < 0.5 && Math.abs(hosted.top) < 0.5 && stillOpen === 1, { hosted, stillOpen })
check('no console / page errors', report.errors.length === 0, report.errors)
await writeFile(path.join(out, `${label}-result.json`), JSON.stringify(report, null, 2))
await browser.close()
const failed = report.checks.filter(c => c.status === 'FAIL').length
console.log(`${report.checks.length - failed}/${report.checks.length} PASS`)
process.exit(failed ? 1 : 0)