import { useEffect, useId, useRef, useState, type ReactNode } from "react" import type { Meta, StoryObj } from "@storybook/react-vite" import { SWHeader, SWHeaderColorMenu, SWHeaderLineStyleMenu, SWHeaderTextStyleMenu, SWHeaderDimensionStyleMenu, SWHeaderProperties, SWHeaderWindowControls, SWTheme, SWHeaderLayerMenu, parseSWHeaderColor, swHeaderLinePatterns, SWHeaderPropertyPanel, SWHeaderPropertyBar, type SWHeaderColorMode, type SWHeaderColorOption, type SWHeaderColorValue, type SWHeaderDimensionStyleOption, type SWHeaderDocument, type SWHeaderLayer, type SWHeaderLineStyleValue, type SWHeaderLineType, type SWHeaderLineWeight, type SWHeaderMenuId, type SWHeaderProps, type SWHeaderTextStyleOption, type SWHeaderProperty, type SWHeaderPropertyGroup, type SWHeaderPropertySelection, type SWHeaderPropertyMenus, type SWHeaderPropertyShapeContent, SWRecentFiles, } from "@hm-design/sw" import "@hm-design/sw/styles.css" import { SWHeaderMenuDetail } from "../../packages/sw/src/components/sw-header/components/SWHeaderDropdown" import { StoryPage, StoryPageHeader, StorySection } from "@/components/story-page" import "./sw-stories.css" import { SWHeaderOverview } from "./sw-header-overview" import { figmaHomeMenuSections, figmaRecentFiles, withRecentFiles } from "./sw-home-menu-fixture" import placeholderLogo from "./assets/sw-header-logo-placeholder.svg" // Owner: docs/sw/sw-header-design-spec.md (Figma rj6wpsfk24lOWvWiVRAANP, node 26:8944 "header_green"). // The package is independent of @hm-design/ui: this file imports only @hm-design/sw for the specimen. const KICKER = "HM DESIGN SYSTEM / SW" /** * Figma sample values of node 26:8944. The logo is a TEMPORARY placeholder: the logo slot is * common design, the logo image belongs to each SW program (docs/sw/sw-header-design-spec.md §9). */ const figmaArgs: SWHeaderProps = { logoSrc: placeholderLogo, logoAlt: "임시 로고", documentTitle: "C0020101-006-평면및종단면도(06)", drawingModeLabel: "그리기모드", layer: { name: "0_Layer_Cludmark" }, color: { value: "255,229,0", swatch: "#ffe500" }, lineStyle: { name: "Continuous", weight: "0.25", scale: "1.00" }, textStyle: { font: "Arial", name: "Standard" }, dimensionStyle: { name: "Standard" }, // 2026-10-16: the default example opens a document list with the one current document (host data). documents: [{ id: "doc-current", name: "C0020101-006-평면및종단면도(06)" }], activeDocumentId: "doc-current", activeMenu: null, propertyBarOpen: false, propertyPanelOpen: false, disabled: false, } const callbacks = { onHomeClick: () => console.info("[SWHeader] home"), onDocumentClick: () => console.info("[SWHeader] document"), onDocumentSelect: (document: SWHeaderDocument) => console.info("[SWHeader] documentSelect", document.id), onDocumentClose: (document: SWHeaderDocument) => console.info("[SWHeader] documentClose", document.id), onDocumentNew: () => console.info("[SWHeader] documentNew"), onDocumentOpen: () => console.info("[SWHeader] documentOpen"), onLayerOpen: () => console.info("[SWHeader] layer"), onColorOpen: () => console.info("[SWHeader] color"), onLineStyleOpen: () => console.info("[SWHeader] lineStyle"), onTextStyleOpen: () => console.info("[SWHeader] textStyle"), onDimensionStyleOpen: () => console.info("[SWHeader] dimensionStyle"), onPropertyBarToggle: () => console.info("[SWHeader] propertyBar"), onPropertyPanelToggle: () => console.info("[SWHeader] propertyPanel"), onPrint: () => console.info("[SWHeader] print"), onPopupGuide: () => console.info("[SWHeader] popupGuide"), onMinimize: () => console.info("[SWHeader] minimize"), onClose: () => console.info("[SWHeader] close"), } const meta = { title: "SW/Header", excludeStories: ["ComposedDemo", "PropertyPanelComparePage"], component: SWHeader, parameters: { layout: "fullscreen" }, args: { ...figmaArgs, ...callbacks }, argTypes: { activeMenu: { control: "select", options: [null, "layer", "color", "lineStyle", "textStyle", "dimensionStyle"] }, tools: { control: false }, logo: { control: false }, logoSrc: { control: "text" }, logoAlt: { control: "text" }, }, } satisfies Meta export default meta type Story = StoryObj function DetailButtonsComparison() { const labels = ["레이어 상세", "색상 상세", "선스타일 상세", "문자스타일 상세", "치수스타일 상세"] const [counts, setCounts] = useState>({}) return
버튼Default · 클릭 기록Pointer HoverDisabledCallback 없음
{labels.map(label =>
{label} {["default", "hover", "disabled", "no-callback"].map(state =>
setCounts(values => ({ ...values, [label]: (values[label] ?? 0) + 1 }))} /> {state === "default" && {counts[label] ?? 0}}
)}
)}
} export const DetailButtons: Story = { name: "Detail Buttons (Shared States)", tags: ["verification"], parameters: { controls: { disable: true } }, render: () => , } /** Fixed-width frame. The outer scroller belongs to Storybook, never to the header. */ function Frame({ width, children }: { width: number; children: ReactNode }) { return (
{children}
) } /** Hover targets (docs/sw/sw-header-design-spec.md §11). */ const HOVER_TARGETS = [ ".hm-sw-header__home", ".hm-sw-header__document-button", ".hm-sw-header__field", ".hm-sw-header__toggle", ".hm-sw-header__action", ".hm-sw-header__window-button", ] /** 인쇄 · 팝업 가이드 · 최소화 · 종료 (§11.2). */ const WINDOW_HOVER_TARGETS = [".hm-sw-header__action", ".hm-sw-header__window-button"] /** * Pins :hover on the given header parts with the Storybook-only data-sb-hover attribute * (.storybook/pseudo-states.ts mirrors every :hover rule), so Hover can be compared statically. */ function PinHover({ selectors, children }: { selectors: string[]; children: ReactNode }) { const ref = useRef(null) useEffect(() => { ref.current?.querySelectorAll(selectors.join(",")).forEach(element => element.setAttribute("data-sb-hover", "")) }, [selectors]) return
{children}
} /** Visible callback log for print / popup guide / minimize / close (each click calls only its own callback). */ function WindowCallbackDemo() { const [log, setLog] = useState([]) const add = (name: string, callback: () => void) => () => { callback(); setLog(lines => [...lines.slice(-7), name]) } return ( <>

Callback: {log.length ? log.join(" → ") : "없음"}

) } /** Host-owned state: the header itself never keeps program state. */ function InteractiveHeader(props: Partial) { const [activeMenu, setActiveMenu] = useState(null) const [bar, setBar] = useState(false) const [panel, setPanel] = useState(true) const toggle = (id: SWHeaderMenuId) => () => setActiveMenu(current => (current === id ? null : id)) return ( setBar(value => !value)} onPropertyPanelToggle={() => setPanel(value => !value)} {...props} /> ) } // Logo menu of the Overview (Figma 26:10591 sample, same fixture as the Home Menu Story). const OVERVIEW_HOME_MENU = withRecentFiles(figmaHomeMenuSections, ) export const Overview: Story = { name: "SW Header", parameters: { controls: { disable: true } }, render: () => , } export const InteractiveStates: Story = { name: "Interactive States", // 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks. tags: ["verification"], parameters: { controls: { disable: true } }, render: () => (
), } /** Preserve GIF phase ratios/positions, with the user-requested 1640 -> 1400ms speed change. */ const motionTime = (gifTime: number) => gifTime * 1400 / 1640 const MOTION_STAGES = [ { time: undefined, title: "Default (Hover 전)", note: "Figma default 26:10221. 아이콘 32 × 30 (28, 13)." }, { time: 0, title: "Hover 시작 · 0ms", note: "기본 아이콘과 같은 아래쪽 커서에서 출발. 창은 제자리, 문구는 아직 숨김." }, { time: motionTime(240), title: "커서 상승 · 약 205ms", note: "GIF 6번 프레임과 같은 위치. 커서만 오른쪽 아래에서 위로 이동 중." }, { time: motionTime(480), title: "상승 완료 직전 · 약 410ms", note: "창은 제자리이며 문구는 아직 숨김. 커서는 약 12.25px 상승." }, { time: motionTime(520), title: "문구 등장 · 약 444ms", note: "GIF 13번 프레임과 같은 위치. 창 오른쪽에 창 이동 문구 표시." }, { time: motionTime(800), title: "오른쪽 위로 이동 · 약 683ms", note: "창은 시작점 대비 +15.5 / −2.5px. 커서와 문구도 함께 이동." }, { time: motionTime(1040), title: "이동 완료 · 약 888ms", note: "창 +28 / −4.5px. 이 자세로 약 512ms 유지하며 overshoot 없음." }, { time: motionTime(1600), title: "끝 자세 유지 · 약 1366ms", note: "GIF 마지막 프레임과 같은 위치. 창·커서·문구를 유지." }, { time: motionTime(1640), title: "다음 사이클 · 1400ms", note: "1.4초마다 처음 자세와 아래쪽 커서로 재시작. Hover 이탈 시 기본 SVG로 즉시 복귀." }, ] /** * Freezes the hover motion at `time` ms for comparison: pins :hover with the Storybook-only * data-sb-hover attribute, then pauses the CSS animations there (Web Animations API). * Without `time` the area stays at rest. Nothing here styles the header. */ function MotionAt({ time, children }: { time?: number; children: ReactNode }) { const ref = useRef(null) useEffect(() => { const area = ref.current?.querySelector(".hm-sw-header__motion") if (!area || time === undefined) return area.setAttribute("data-sb-hover", "") // getAnimations() flushes style, so the hover animations exist right away. area.getAnimations({ subtree: true }).forEach(animation => { animation.pause(); animation.currentTime = time }) }, [time]) return
{children}
} /** Window controls alone (public sub-component), magnified 3x for the Figma comparison. */ function MotionSpecimen({ time }: { time?: number }) { return (
) } export const WindowMoveMotion: Story = { name: "Window Move Motion", // 2026-10-20 sidebar simplification: GIF frame verification; ID and direct URL kept. tags: ["verification"], parameters: { controls: { disable: true } }, render: () => (
{MOTION_STAGES.map(stage => (
{stage.title} {stage.note}
))}
), } // ComposedDemo is exported for the Dropdown CSF file; exclude it from this file's Story index. export function ComposedDemo() { const panelId = useId() const detailRef = useRef(null) const [activeMenu, setActiveMenu] = useState(null) const [scenario, setScenario] = useState("normal") const [disabled, setDisabled] = useState(false) const [layerReadOnly, setLayerReadOnly] = useState(false) const [detailCallbacks, setDetailCallbacks] = useState(true) const [layers, setLayers] = useState(SAMPLE_LAYERS) const [weights, setWeights] = useState(FIGMA_LINE_WEIGHTS) const [weightId, setWeightId] = useState("w-default") const [colorMode, setColorMode] = useState(null) const [textStyle, setTextStyle] = useState(figmaArgs.textStyle!) const [dimensionStyle, setDimensionStyle] = useState(figmaArgs.dimensionStyle!) const [properties, setProperties] = useState(() => propertyFixture("circle").map(item => item.id === "layer" ? { ...item, value: "0" } : item)) const [bar, setBar] = useState(false) const [panel, setPanel] = useState(false) const [log, setLog] = useState([]) // The overlay positions itself on mount; focus after that initial hidden layout is resolved. useEffect(() => { if (!panel) return const frame = requestAnimationFrame(() => document.getElementById(panelId)?.focus()) return () => cancelAnimationFrame(frame) }, [panel, panelId]) const record = (line: string) => { setLog(lines => [...lines.slice(-11), line]); console.info(`[SWHeader Composed] ${line}`) } const value = (id: string) => properties.find(item => item.id === id)?.value ?? "" const change = (id: string, next: string, event: string) => { setProperties(items => items.map(item => item.id === id ? { ...item, value: next, mixed: false } : item)) if (id === "color") setColorMode(null) record(`${event}(${id}, ${next})`) } const availableLayers = scenario === "empty" ? [] : scenario === "long" ? LONG_LAYERS : scenario === "many" ? MANY_LAYERS : layers const currentLayer = availableLayers.find(item => item.name === value("layer")) const currentWeight = weights.find(item => item.id === weightId) const color = parseSWHeaderColor(value("color")) const menuProps = (id: SWHeaderMenuId) => ({ open: activeMenu === id, disabled, onClick: () => record(`on${id[0].toUpperCase() + id.slice(1)}Open`), onOpenChange: (open: boolean) => { setActiveMenu(current => open ? id : current === id ? null : current) record(`${id}Open(${open})`) }, }) const detail = (name: string) => detailCallbacks ? () => record(`on${name}Detail`) : undefined const toggleLayer = (key: "visible" | "frozen" | "locked", event: string) => layerReadOnly ? undefined : (layer: SWHeaderLayer, next: boolean) => { setLayers(items => items.map(item => item.id === layer.id || item.name === layer.id ? { ...item, [key]: next } : item)) record(`${event}(${layer.id}, ${next})`) } const chooseWeight = (weight: SWHeaderLineWeight) => { setWeightId(weight.id) if (weight.value !== undefined) change("weight", weight.value, "onLineWeightSelect") else record(`onLineWeightSelect(${weight.id})`) } const changeWeight = (weight: SWHeaderLineWeight, next: string) => { setWeights(items => items.map(item => item.id === weight.id ? { ...item, value: next } : item)) if (weightId === weight.id) change("weight", next, "onLineWeightValueChange") else record(`onLineWeightValueChange(${weight.id}, ${next})`) } const closePanel = () => { setPanel(false); record("onPropertyPanelOpenChange(false)") detailRef.current?.querySelector("[data-property-panel-trigger]")?.focus() } const menus: SWHeaderPropertyMenus = { layers: availableLayers, lineTypes: PROPERTY_LINE_TYPES, lineWeights: weights, activeLineWeightId: weightId, lineScale: value("scale"), colors: FIGMA_COLORS, userColors: scenario === "many" ? MANY_USER_COLORS : FIGMA_USER_COLORS, colorMode, onLayerVisibilityChange: toggleLayer("visible", "onLayerVisibilityChange"), onLayerFreezeChange: toggleLayer("frozen", "onLayerFreezeChange"), onLayerLockChange: toggleLayer("locked", "onLayerLockChange"), onLayerDetail: detail("Layer"), onLineStyleDetail: detail("LineStyle"), onColorDetail: detail("Color"), onLineWeightSelect: chooseWeight, onLineWeightValueChange: changeWeight, onLineScaleChange: next => change("scale", next, "onLineScaleChange"), onColorModeChange: mode => { setColorMode(mode); record(`onColorModeChange(${mode})`) }, onColorPick: () => record("onColorPick"), } const panelProperties = properties.map(item => ({ ...item, disabled, ...(item.id === "layer" ? { options: availableLayers.map(layer => ({ ...layer, id: layer.name })) } : {}) })) return
시연 설정
change("layer", layer.name, "onLayerSelect")} onDetail={detail("Layer")} onVisibilityChange={scenario === "normal" ? menus.onLayerVisibilityChange : undefined} onFreezeChange={scenario === "normal" ? menus.onLayerFreezeChange : undefined} onLockChange={scenario === "normal" ? menus.onLayerLockChange : undefined} /> change("color", item.value ?? parseSWHeaderColor(item.color)?.value ?? item.color, "onColorSelect")} onColorInput={next => change("color", next.value, "onColorInput")} onModeChange={mode => { setColorMode(mode); record(`onColorModeChange(${mode})`) }} onPick={() => record("onColorPick")} onDetail={detail("Color")} /> change("line", item.name, "onLineTypeSelect")} onLineWeightSelect={chooseWeight} onLineWeightValueChange={changeWeight} onScaleChange={next => change("scale", next, "onLineScaleChange")} onDetail={detail("LineStyle")} /> { setTextStyle({ font: item.font, name: item.name }); record(`onTextStyleSelect(${item.id})`) }} onDetail={detail("TextStyle")} /> { setDimensionStyle({ name: item.name }); record(`onDimensionStyleSelect(${item.id})`) }} onDetail={detail("DimensionStyle")} />
{ setActiveMenu(null); setBar(next => !next); record(`onPropertyBarOpenChange(${!bar})`) }} onPanelToggle={() => { setActiveMenu(null); setPanel(next => !next); record(`onPropertyPanelOpenChange(${!panel})`) }} />
{bar && panelProperties.filter(item => item.id === barItem.id)).map(item => ({ ...item, readOnly: true }))} onPropertyChange={(property, next) => change(property.id, next, "onPropertyChange")} />} {panel && ({ ...layer, id: layer.name })) }} onPropertyChange={(property, next) => change(property.id, next, "onPropertyChange")} onClose={closePanel} />}
레이어: {value("layer")} · 색상: {colorMode ?? value("color")} · 선: {value("line")} / {value("weight")} / {value("scale")} · 문자: {textStyle.name} · 치수: {dimensionStyle.name} Callback: {log.length ? log.join(" → ") : "없음"}
} /** Neutral sample logos with different ratios (no product or brand). */ const WIDE_SAMPLE_LOGO = "data:image/svg+xml," + encodeURIComponent( '', ) const TALL_SAMPLE_LOGO = ( ) export const LogoSlot: Story = { name: "Logo Slot", parameters: { controls: { disable: true } }, render: () => ( ), } /** Test data only. The package never ships document names; each SW program passes its own list. */ const SAMPLE_DOCUMENTS: SWHeaderDocument[] = [ { id: "doc-1", name: "Sample-Drawing-01.dwg" }, { id: "doc-2", name: "Sample-Drawing-02.dwg" }, { id: "doc-3", name: "Sample-Plan-03.dxf" }, { id: "doc-4", name: "Sample-Section-04.dxf" }, ] /** The one document the default example has open (same name as the Figma file name). */ const CURRENT_DOCUMENT: SWHeaderDocument[] = [{ id: "doc-current", name: "C0020101-006-평면및종단면도(06)" }] const MANY_DOCUMENTS: SWHeaderDocument[] =Array.from({ length: 18 }, (_, index) => ({ id: `many-${index + 1}`, name: `Sample-Drawing-${String(index + 1).padStart(2, "0")}.dwg`, })) const LONG_DOCUMENTS: SWHeaderDocument[] = [ { id: "long-1", name: "C0020101-006-평면및종단면도(06)-수정본-최종-검토용-2026-10-16.dwg" }, { id: "long-2", name: "Sample-매우-긴-문서명-테스트-Ellipsis-확인용-파일-이름.dxf" }, ] const documentCallbacks = { onDocumentSelect: (document: SWHeaderDocument) => console.info("[SWHeader] documentSelect", document.id), onDocumentNew: () => console.info("[SWHeader] documentNew"), onDocumentOpen: () => console.info("[SWHeader] documentOpen"), } /** Frame with room below the header so the overlay dropdown is not clipped by the story scroller. */ function MenuFrame({ width, children, fixedDimension = false }: { width: number; children: ReactNode; fixedDimension?: boolean }) { return (
{children}
) } type DocumentHostProps = Partial & { onLog?: (line: string) => void } /** * Host-owned documents: the SW program keeps the list and the current document. Selecting makes * a document current; Close Document removes that document and, if it was current, leaves no * current document (this example's choice). * The header itself never reorders the data or picks the next document. */ function DocumentHeader({ documents: initialDocuments = SAMPLE_DOCUMENTS, activeDocumentId: initialActiveId, onLog, ...props }: DocumentHostProps) { const [documents, setDocuments] = useState(initialDocuments) const [activeId, setActiveId] = useState(initialActiveId) const active = documents.find(item => item.id === activeId) const title = active?.name ?? (initialActiveId === undefined && activeId === undefined ? figmaArgs.documentTitle : "열린 문서 없음") return ( { documentCallbacks.onDocumentSelect(document) onLog?.(`onDocumentSelect(${document.id})`) setActiveId(document.id) }} onDocumentClose={document => { console.info("[SWHeader] documentClose", document.id) onLog?.(`onDocumentClose(${document.id})`) setDocuments(list => list.filter(item => item.id !== document.id)) // Closing the current document leaves none selected; closing another keeps the selection. if (document.id === activeId) setActiveId(undefined) }} onDocumentNew={() => { documentCallbacks.onDocumentNew(); onLog?.("onDocumentNew") }} onDocumentOpen={() => { documentCallbacks.onDocumentOpen(); onLog?.("onDocumentOpen") }} {...props} /> ) } /** Interactive demo with a visible callback log, to check that select and close never double-fire. */ function DocumentDemo({ width, ...props }: DocumentHostProps & { width: number }) { const [log, setLog] = useState([]) return ( <>

Callback: {log.length ? log.join(" → ") : "없음"}

setLog(lines => [...lines.slice(-7), line])} /> ) } export const DocumentDropdown: Story = { name: "Document Dropdown", // 2026-10-21 SW Storybook menu cleanup: hidden from the sidebar; ID and direct URL kept for the existing checks. tags: ["verification"], parameters: { controls: { disable: true } }, render: () => ( ), } /** Test data only. The package never ships layer names; each SW program passes its own list. */ const SAMPLE_LAYERS: SWHeaderLayer[] = [ { id: "layer-0", name: "0" }, { id: "layer-1", name: "Sample-Center", color: "#ff0000" }, { id: "layer-2", name: "Sample-Wall", color: "#ffe500", visible: false }, { id: "layer-3", name: "Sample-Door", color: "#00ff00", frozen: true }, { id: "layer-4", name: "Sample-Text", color: "#00ffff", locked: true }, { id: "layer-5", name: "Sample-Dimension", color: "#0000ff", visible: false, frozen: true, locked: true }, { id: "layer-6", name: "Sample-Hatch", color: "#ff00ff" }, { id: "layer-7", name: "Sample-Defpoints", color: "#808080", disabled: true }, ] const LONG_LAYERS: SWHeaderLayer[] = [ { id: "long-1", name: "Sample-매우-긴-레이어-이름-Ellipsis-확인용-Layer-Name-0123456789", color: "#ffe500" }, { id: "long-2", name: "Sample_Layer_With_A_Very_Long_Name_For_Truncation_Check", color: "#00ffff", frozen: true }, { id: "long-3", name: "0" }, ] const MANY_LAYERS: SWHeaderLayer[] = Array.from({ length: 20 }, (_, index) => ({ id: `many-${index + 1}`, name: `Sample-Layer-${String(index + 1).padStart(2, "0")}`, color: ["#ffffff", "#ff0000", "#ffe500", "#00ff00", "#00ffff"][index % 5], visible: index % 4 !== 1, frozen: index % 5 === 2, locked: index % 6 === 3, })) /* ------------------------------------------------------------------------------------------------ Field dropdowns (2026-10-16): color 26:10986 / 26:11036, ByLayer 26:12666, ByBlock 26:12659, line style 26:11104 (rows 26:10702), text style 26:11181, dimension style 26:11215. All names and values below are Figma sample data for the Storybook only; the package ships none. ------------------------------------------------------------------------------------------------ */ const FIGMA_COLORS: SWHeaderColorOption[] = [ { id: "white", color: "#ffffff", label: "흰색" }, { id: "red", color: "#f10404", label: "빨강" }, { id: "green", color: "#48ff08", label: "초록" }, { id: "yellow", color: "#ffff00", label: "노랑" }, { id: "blue", color: "#0012ff", label: "파랑" }, { id: "magenta", color: "#ff00f6", label: "자홍" }, { id: "cyan", color: "#00f6ff", label: "청록" }, ] const FIGMA_USER_COLORS: SWHeaderColorOption[] = [ { id: "user-1", color: "#00bd79", label: "사용자 색상 1" }, { id: "user-2", color: "#ffafaf", label: "사용자 색상 2" }, ] const MANY_USER_COLORS: SWHeaderColorOption[] = Array.from({ length: 40 }, (_, index) => ({ id: `user-many-${index + 1}`, color: `hsl(${(index * 37) % 360} 70% 55%)`, label: `사용자 색상 ${index + 1}`, })) const FIGMA_LINE_TYPES: SWHeaderLineType[] = [ { id: "byblock", name: "ByBlock" }, { id: "bylayer", name: "ByLayer" }, { id: "continuous", name: "Continuous" }, { id: "center", name: "CENTER", pattern: swHeaderLinePatterns.center }, { id: "hidden", name: "HIDDEN", pattern: swHeaderLinePatterns.hidden }, ] const FIGMA_LINE_WEIGHTS: SWHeaderLineWeight[] = [ { id: "w-bylayer", name: "ByLayer" }, { id: "w-byblock", name: "ByBlock" }, { id: "w-default", name: "ByDefault", value: "0.25" }, { id: "w-user", name: "User", value: "0.25", editable: true }, ] const FIGMA_TEXT_STYLES: SWHeaderTextStyleOption[] = [ { id: "standard", font: "Arial", name: "Standard" }, { id: "dotum", font: "돋움", name: "Dotum" }, ] const FIGMA_DIMENSION_STYLES: SWHeaderDimensionStyleOption[] = [ { id: "standard", name: "Standard" }, { id: "iso-25", name: "ISO - 25" }, ] type StyleHostProps = Partial & { onLog?: (line: string) => void; showState?: boolean } /** * Host-owned field data: the SW program keeps color, line / text / dimension style and the lists. The * header only reports; this example writes each report back (an explicit color clears ByLayer / ByBlock, * which is this host's choice) so the fields follow. Each property changes alone. */ function StyleHeader({ onLog, showState, ...props }: StyleHostProps) { const [color, setColor] = useState(figmaArgs.color!) const [colorMode, setColorMode] = useState(null) const [lineStyle, setLineStyle] = useState(figmaArgs.lineStyle!) const [lineWeights, setLineWeights] = useState(FIGMA_LINE_WEIGHTS) const [weightId, setWeightId] = useState("w-default") const [textStyle, setTextStyle] = useState(figmaArgs.textStyle!) const [dimensionStyle, setDimensionStyle] = useState(figmaArgs.dimensionStyle!) const [layers, setLayers] = useState(SAMPLE_LAYERS) const [layerId, setLayerId] = useState("layer-1") const log = (line: string) => onLog?.(line) const header = ( { log(`onLayerSelect(${layer.id})`); setLayerId(layer.id) }} onLayerVisibilityChange={(layer, next) => { log(`onLayerVisibilityChange(${layer.id}, ${next})`); setLayers(list => list.map(item => (item.id === layer.id ? { ...item, visible: next } : item))) }} onLayerOpen={() => log("onLayerOpen")} onLayerMenuOpenChange={open => log(`onLayerMenuOpenChange(${open})`)} color={color} colors={FIGMA_COLORS} userColors={FIGMA_USER_COLORS} colorMode={colorMode} onColorOpen={() => log("onColorOpen")} onColorMenuOpenChange={open => log(`onColorMenuOpenChange(${open})`)} onColorSelect={option => { log(`onColorSelect(${option.id})`); setColor({ value: option.value ?? parseSWHeaderColor(option.color)?.value ?? option.color, swatch: option.color }); setColorMode(null) }} onColorModeChange={mode => { log(`onColorModeChange(${mode})`); setColorMode(mode); setColor(current => ({ ...current, value: mode === "byLayer" ? "ByLayer" : "ByBlock" })) }} onColorInput={value => { log(`onColorInput(${value.value})`); setColor({ value: value.value, swatch: value.hex }); setColorMode(null) }} onColorPick={() => log("onColorPick")} onColorDetail={() => log("onColorDetail")} lineStyle={lineStyle} lineTypes={FIGMA_LINE_TYPES} lineWeights={lineWeights} activeLineWeightId={weightId} onLineStyleOpen={() => log("onLineStyleOpen")} onLineStyleMenuOpenChange={open => log(`onLineStyleMenuOpenChange(${open})`)} onLineTypeSelect={type => { log(`onLineTypeSelect(${type.id})`); setLineStyle(current => ({ ...current, name: type.name })) }} onLineWeightSelect={weight => { log(`onLineWeightSelect(${weight.id})`); setWeightId(weight.id); setLineStyle(current => ({ ...current, weight: weight.value ?? weight.name })) }} onLineWeightValueChange={(weight, value) => { log(`onLineWeightValueChange(${weight.id}, ${value})`) setLineWeights(list => list.map(item => (item.id === weight.id ? { ...item, value } : item))) if (weight.id === weightId) setLineStyle(current => ({ ...current, weight: value })) }} onLineScaleChange={value => { log(`onLineScaleChange(${value})`); setLineStyle(current => ({ ...current, scale: value })) }} onLineStyleDetail={() => log("onLineStyleDetail")} textStyle={textStyle} textStyles={FIGMA_TEXT_STYLES} onTextStyleOpen={() => log("onTextStyleOpen")} onTextStyleMenuOpenChange={open => log(`onTextStyleMenuOpenChange(${open})`)} onTextStyleSelect={style => { log(`onTextStyleSelect(${style.id})`); setTextStyle({ font: style.font, name: style.name }) }} onTextStyleDetail={() => log("onTextStyleDetail")} dimensionStyle={dimensionStyle} dimensionStyles={FIGMA_DIMENSION_STYLES} onDimensionStyleOpen={() => log("onDimensionStyleOpen")} onDimensionStyleMenuOpenChange={open => log(`onDimensionStyleMenuOpenChange(${open})`)} onDimensionStyleSelect={style => { log(`onDimensionStyleSelect(${style.id})`); setDimensionStyle({ name: style.name }) }} onDimensionStyleDetail={() => log("onDimensionStyleDetail")} {...props} /> ) if (!showState) return header return ( <>
        {`layer: ${layers.find(item => item.id === layerId)?.name} · color: ${color.value} (${color.swatch}) · colorMode: ${colorMode ?? "null"}\n`}
        {`lineStyle: ${lineStyle.name} / weight ${lineStyle.weight} (${weightId ?? "-"}) / scale ${lineStyle.scale} · textStyle: ${textStyle.font} ${textStyle.name} · dimensionStyle: ${dimensionStyle.name}`}
      
{header} ) } // 2026-10-21: same widths as the SW Footer Responsive More. From 760px no More; the five menus stay in the header // (그리기모드 text hides, then the file name and values shorten). const MORE_WIDTHS = [1920, 1440, 1280, 950, 860, 760] export const ResponsiveMore: Story = { name: "Responsive More", parameters: { controls: { disable: true } }, render: () => ( {MORE_WIDTHS.map(width => ( ))} ), } /* Additional review 2026-10-16: fixture values are host data, never package defaults. */ const PROPERTY_GROUPS: SWHeaderPropertyGroup[] = [ { id: "common", collapsedCount: 6 }, { id: "shape", name: "형상", layout: "circle" }, { id: "position", name: "위치", layout: "position", positionLabel: "중간점" }, ] const PROPERTY_FIXTURE: SWHeaderProperty[] = [ { id: "layer", name: "레이어", value: "0_Layer_Cludmark", type: "text", groupId: "common", picker: "layer", validate: draft => draft.length <= 40 }, { id: "color", name: "색상", value: "255,229,0", type: "color", groupId: "common" }, { id: "line", name: "선꼴", value: "Continuous", type: "select", groupId: "common", unit: "(ByL)", linePreview: true, options: [{ id: "Continuous", name: "Continuous" }, { id: "CENTER", name: "CENTER" }, { id: "HIDDEN", name: "HIDDEN" }] }, { id: "weight", name: "선가중치", value: "0.25", type: "number", groupId: "common", min: 0, annotation: "(ByD, ByL)" }, { id: "scale", name: "선축적", value: "1.0000", type: "number", groupId: "common", min: .0001, decimals: 4, unit: "(LTscale:1)" }, { id: "opacity", name: "투명도", value: "0.25", type: "slider", groupId: "common", min: 0, max: 1, step: .01, annotation: "(ByL)", unit: "%" }, { id: "link", name: "하이퍼링크", value: "--", type: "text", groupId: "common" }, { id: "thickness", name: "두께", value: "0.25", type: "number", groupId: "common", unit: "mm", min: 0 }, { id: "material", name: "재료", value: "-", type: "computed", groupId: "common", unit: "(ByL)" }, { id: "circumference", name: "원주", value: "0.25", type: "number", groupId: "shape", graphicSlot: "circumference", min: 0 }, { id: "diameter", name: "지름", value: "8.1502", type: "number", groupId: "shape", graphicSlot: "diameter", min: 0 }, { id: "radius", name: "반지름", value: "4.0526", type: "number", groupId: "shape", graphicSlot: "radius", min: 0 }, { id: "area", name: "면적", value: "51.5962", type: "number", groupId: "shape", graphicSlot: "area", min: 0 }, { id: "x", name: "X", value: "48.0808", type: "number", groupId: "position" }, { id: "y", name: "Y", value: "14.9160", type: "number", groupId: "position" }, { id: "z", name: "Z", value: "0.0000", type: "number", groupId: "position" }, ] const PROPERTY_COUNTS = [ { id: "shape", name: "도형", count: 10, row: 1, column: 1 }, { id: "line", name: "직선", count: 10, row: 1, column: 2 }, { id: "circle", name: "원", count: 1, row: 1, column: 3 }, { id: "ray", name: "광선", count: 1, row: 1, column: 4 }, { id: "curve", name: "곡선", count: 10, row: 2, column: 3 }, { id: "arc", name: "호", count: 1, row: 2, column: 4 }, { id: "polyline", name: "폴리선", count: 10, row: 3, column: 3 }, { id: "polyline2", name: "폴리선", count: 2, row: 3, column: 4 }, { id: "text1", name: "문자", count: 1, row: 4, column: 1 }, { id: "text2", name: "문자", count: 1, row: 4, column: 2 }, { id: "text3", name: "문자", count: 1, row: 4, column: 3 }, ] type PropertyExample = "none" | "all" | "circle" | "expanded" function propertySelection(example: PropertyExample): SWHeaderPropertySelection { return example === "none" ? { id: "none", title: "선택된 객체 - 없음", kind: "none" } : example === "all" ? { id: "all", title: "선택된 객체 - 전체 11", kind: "all", counts: PROPERTY_COUNTS } : { id: "circle", title: "선택된 객체 - 원1", kind: "single", overviewTitle: "선택된 객체 - 전체 11" } } function propertyFixture(example: PropertyExample) { return PROPERTY_FIXTURE.filter(item => (example === "none" || example === "all") ? item.groupId === "common" : true).map(item => ({ ...item, ...(example === "none" && item.id === "layer" ? { value: "0" } : {}), ...(example === "none" && item.id === "color" ? { value: "138,138,138" } : {}), ...(example === "all" && item.id === "layer" ? { mixed: true } : {}), ...((example === "none" || example === "all") && item.id === "opacity" ? { value: "0" } : {}), })) } /* Property field dropdowns (2026-10-19): host data, same records as the header dropdowns. Ids are the names because the property value is the layer / line type name. */ const PROPERTY_LAYERS: SWHeaderLayer[] = [ { id: "0", name: "0" }, { id: "0_Layer_Cludmark", name: "0_Layer_Cludmark", color: "#ff0000" }, { id: "CC-D GNL", name: "CC-D GNL" }, { id: "Sample-Wall", name: "Sample-Wall", color: "#ffe500", visible: false }, { id: "Sample-Door", name: "Sample-Door", color: "#00ff00", frozen: true }, { id: "Sample-Text", name: "Sample-Text", color: "#00ffff", locked: true }, { id: "Sample-Defpoints", name: "Sample-Defpoints", color: "#808080", disabled: true }, ] const PROPERTY_LINE_TYPES: SWHeaderLineType[] = FIGMA_LINE_TYPES.map(item => ({ ...item, id: item.name })) const linePatternOf = (value: string) => PROPERTY_LINE_TYPES.find(item => item.id === value)?.pattern function usePropertyMenus(record: (line: string) => void, setProperties: (update: (items: SWHeaderProperty[]) => SWHeaderProperty[]) => void): SWHeaderPropertyMenus { const [layers, setLayers] = useState(PROPERTY_LAYERS) const [weights, setWeights] = useState(FIGMA_LINE_WEIGHTS) const [weightId, setWeightId] = useState("w-default") const [colorMode, setColorMode] = useState(null) const setValue = (id: string, value: string) => setProperties(items => items.map(item => item.id === id ? { ...item, value, mixed: false } : item)) const scale = "1.0000" const toggle = (key: "visible" | "frozen" | "locked") => (layer: SWHeaderLayer, next: boolean) => { record(`layer.${key}(${layer.id}, ${next})`) setLayers(items => items.map(item => item.id === layer.id ? { ...item, [key]: next } : item)) } return { layers, onLayerVisibilityChange: toggle("visible"), onLayerFreezeChange: toggle("frozen"), onLayerLockChange: toggle("locked"), onLayerDetail: () => record("layerDetail"), lineTypes: PROPERTY_LINE_TYPES, lineWeights: weights, activeLineWeightId: weightId, lineScale: scale, onLineWeightSelect: weight => { record(`lineWeight(${weight.id})`); setWeightId(weight.id); if (weight.value) setValue("weight", weight.value) }, onLineWeightValueChange: (weight, value) => { record(`lineWeightValue(${weight.id}, ${value})`); setWeights(items => items.map(item => item.id === weight.id ? { ...item, value } : item)) }, onLineScaleChange: value => { record(`lineScale(${value})`); setValue("scale", Number(value).toFixed(4)) }, onLineStyleDetail: () => record("lineStyleDetail"), colors: FIGMA_COLORS, colorMode, onColorModeChange: mode => { record(`colorMode(${mode})`); setColorMode(mode) }, onColorPick: property => record(`colorPick(${property.id})`), onColorDetail: () => record("colorDetail"), } } export const KGuide: Story = { name: "K Guide", // 2026-10-20 sidebar simplification: the same Header as Overview (K guide on hover); ID and direct URL kept. tags: ["verification"], parameters: { controls: { disable: true } }, render: () =>
, } /* 2026-10-21: the property Bar shows the object's calculated values in this order; they are not edited there (computed, plain text without focus). The Bar's object text is the object type only (원). */ const BAR_TITLE = "원" const BAR_FIXTURE: SWHeaderProperty[] = [ { id: "radius", name: "반지름", value: "4.0526", type: "computed", groupId: "shape" }, { id: "diameter", name: "지름", value: "8.1052", type: "computed", groupId: "shape" }, { id: "circumference", name: "원주", value: "8.1052", type: "computed", groupId: "shape" }, { id: "area", name: "면적", value: "51.5962", type: "computed", groupId: "shape" }, ] /* Property panel: the four Figma states with the same component and host data. */ const PROPERTY_STATES: { example: PropertyExample; title: string }[] = [ { example: "none", title: "선택된 객체 없음" }, { example: "all", title: "전체 객체 선택" }, { example: "circle", title: "원 1개 선택" }, { example: "expanded", title: "모든 항목 펼침" }, ] function PropertyState({ example, shapeContent, onLog }: { example: PropertyExample; shapeContent?: SWHeaderPropertyShapeContent; onLog?: (line: string) => void }) { const [properties, setProperties] = useState(() => propertyFixture(example)) const [expanded, setExpanded] = useState(example === "expanded" ? ["common"] : []) const menus = usePropertyMenus(line => onLog?.(line), setProperties) return { onLog?.(`property(${property.id}, ${value})`); setProperties(items => items.map(item => item.id === property.id ? { ...item, value, mixed: false, ...(item.id === "line" ? { linePattern: linePatternOf(value) } : {}) } : item)) }} onClose={() => onLog?.("close")} /> } /** Design check only: a real header shows one panel for the current selection. 2 columns × 2 rows at the panels' own size; the Inner scrolls instead of shrinking or wrapping them. */ function PropertyCompare() { const [log, setLog] = useState([]) return <>
{PROPERTY_STATES.map(state =>
{state.title}
setLog(lines => [...lines.slice(-7), `${state.example}: ${line}`])} />
)}
} /** Rendered by SW/Popup › Property Panel · 4개 비교 (sw-property-panel.stories.tsx, ID sw-header--property-panel-compare). */ export function PropertyPanelComparePage() { return }