feat(sw): prepare reusable AI-friendly component package

- Ship an AI guide in the package (ai/: usage rules, component catalog,
  design rules, composition patterns) via package.json files.
- Add examples A-D that import only public exports: default header,
  viewer header, feature header, and a consumer-side search extension
  that reuses SWHeaderInput and the public SW tokens.
- Add SW-only checks: docs/API consistency, tarball consumer project
  (React 19 + TS + Vite typecheck/build) and a browser check (27/27).
- No runtime source or public API change; README gains a pointer section.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shkim4andClaude Opus 5.5 committed 2026-10-19 11:22:07 +09:00
1 parent 50bf5bda8e
commit e27385e0f0
26 files changed
+1349 -1

No files matched your search

@@ -0,0 +1,40 @@
// Example A — reuse (1st priority): the complete SW Header, unchanged. Existing projects keep this code.
import { useState } from "react"
import { SWHeader, type SWHeaderColorMode, type SWHeaderColorValue } from "@hm-design/sw"
import { colors, dimensionStyles, documents, layers, lineTypes, lineWeights, textStyles } from "./sample-data"
export function ExampleADefaultHeader({ onEvent }: { onEvent?: (name: string) => void }) {
const [activeDocumentId, setActiveDocumentId] = useState(documents[0].id)
const [activeLayerId, setActiveLayerId] = useState("0")
const [color, setColor] = useState<SWHeaderColorValue>({ value: "ByLayer", swatch: "#ffffff" })
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>("byLayer")
const log = (name: string) => () => onEvent?.(name)
return (
<SWHeader
documentTitle={documents.find(item => item.id === activeDocumentId)?.name ?? ""}
logoAlt="예제 SW"
documents={documents}
activeDocumentId={activeDocumentId}
onDocumentSelect={document => { setActiveDocumentId(document.id); onEvent?.(`document:${document.id}`) }}
onDocumentNew={log("document:new")}
onDocumentOpen={log("document:open")}
layers={layers}
activeLayerId={activeLayerId}
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }}
color={color}
colors={colors}
colorMode={colorMode}
onColorSelect={option => { setColor({ value: option.label ?? option.color, swatch: option.color }); setColorMode(null); onEvent?.(`color:${option.id}`) }}
onColorModeChange={mode => { setColorMode(mode); onEvent?.(`colorMode:${mode}`) }}
lineTypes={lineTypes}
lineWeights={lineWeights}
textStyles={textStyles}
dimensionStyles={dimensionStyles}
onHomeClick={log("home")}
onPrint={log("print")}
onPopupGuide={log("popupGuide")}
onMinimize={log("minimize")}
onClose={log("close")}
/>
)
}
@@ -0,0 +1,50 @@
// Example B — composition (2nd priority): a simplified viewer header
// Logo → File → Layer → Color → K
// Only public components; the layout is the consumer's (example-layout.css) and <SWTheme> provides tokens.
import { useState } from "react"
import {
SWHeaderActions, SWHeaderColorMenu, SWHeaderDocumentTitle, SWHeaderHome, SWHeaderLayerMenu, SWTheme,
type SWHeaderColorMode, type SWHeaderColorValue,
} from "@hm-design/sw"
import { colors, documents, layers } from "./sample-data"
import "./example-layout.css"
export function ExampleBViewerHeader({ onEvent }: { onEvent?: (name: string) => void }) {
const [activeDocumentId, setActiveDocumentId] = useState(documents[0].id)
const [activeLayerId, setActiveLayerId] = useState("0")
const [color, setColor] = useState<SWHeaderColorValue>({ value: "ByLayer", swatch: "#ffffff" })
const [colorMode, setColorMode] = useState<SWHeaderColorMode | null>("byLayer")
return (
<SWTheme className="app-sw-bar" role="banner" aria-label="뷰어 헤더">
<SWHeaderHome logoAlt="뷰어" onClick={() => onEvent?.("home")} />
<SWHeaderDocumentTitle
title={documents.find(item => item.id === activeDocumentId)?.name ?? ""}
documents={documents}
activeDocumentId={activeDocumentId}
onDocumentSelect={document => { setActiveDocumentId(document.id); onEvent?.(`document:${document.id}`) }}
onNew={() => onEvent?.("document:new")}
onOpen={() => onEvent?.("document:open")}
/>
<div className="app-sw-surface" role="toolbar" aria-label="보기 설정">
<SWHeaderLayerMenu
value={{ name: "0" }}
layers={layers}
activeLayerId={activeLayerId}
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }}
onVisibilityChange={(layer, next) => onEvent?.(`layer-visible:${layer.id}:${next}`)}
/>
<SWHeaderColorMenu
value={color}
colors={colors}
mode={colorMode}
onColorSelect={option => { setColor({ value: option.label ?? option.color, swatch: option.color }); setColorMode(null); onEvent?.(`color:${option.id}`) }}
onModeChange={mode => { setColorMode(mode); onEvent?.(`colorMode:${mode}`) }}
/>
</div>
<div className="app-sw-window">
{/* K only: the existing actions pill with print removed. */}
<SWHeaderActions showPrint={false} onPopupGuide={() => onEvent?.("popupGuide")} />
</div>
</SWTheme>
)
}
@@ -0,0 +1,46 @@
// Example C — composition (2nd priority): a feature-focused header in a different order
// File → Line style → Text style → Dimension style → Print
import { useState } from "react"
import {
SWHeaderActions, SWHeaderDimensionStyleMenu, SWHeaderDocumentTitle, SWHeaderLineStyleMenu, SWHeaderTextStyleMenu, SWTheme,
type SWHeaderDimensionStyleValue, type SWHeaderLineStyleValue, type SWHeaderTextStyleValue,
} from "@hm-design/sw"
import { dimensionStyles, lineTypes, lineWeights, textStyles } from "./sample-data"
import "./example-layout.css"
export function ExampleCFeatureHeader({ onEvent }: { onEvent?: (name: string) => void }) {
const [lineStyle, setLineStyle] = useState<SWHeaderLineStyleValue>({ name: "ByLayer", weight: "ByLayer", scale: "1.00" })
const [textStyle, setTextStyle] = useState<SWHeaderTextStyleValue>({ font: "Arial", name: "Standard" })
const [dimensionStyle, setDimensionStyle] = useState<SWHeaderDimensionStyleValue>({ name: "Standard" })
return (
<SWTheme className="app-sw-bar" role="banner" aria-label="스타일 헤더">
{/* No logo before the file well, so the consumer layout gives it its own left edge. */}
<div className="app-sw-file-edge">
<SWHeaderDocumentTitle title="C0020101-006-평면및종단면도(06)" onClick={() => onEvent?.("document")} />
</div>
<div className="app-sw-surface" role="toolbar" aria-label="스타일">
<SWHeaderLineStyleMenu
value={lineStyle}
lineTypes={lineTypes}
lineWeights={lineWeights}
onLineTypeSelect={type => { setLineStyle(current => ({ ...current, name: type.name })); onEvent?.(`lineType:${type.id}`) }}
onLineWeightSelect={weight => { setLineStyle(current => ({ ...current, weight: weight.name })); onEvent?.(`lineWeight:${weight.id}`) }}
onScaleChange={scale => { setLineStyle(current => ({ ...current, scale })); onEvent?.(`lineScale:${scale}`) }}
/>
<SWHeaderTextStyleMenu
value={textStyle}
textStyles={textStyles}
onTextStyleSelect={style => { setTextStyle({ font: style.font, name: style.name }); onEvent?.(`textStyle:${style.id}`) }}
/>
<SWHeaderDimensionStyleMenu
value={dimensionStyle}
dimensionStyles={dimensionStyles}
onDimensionStyleSelect={style => { setDimensionStyle({ name: style.name }); onEvent?.(`dimensionStyle:${style.id}`) }}
/>
</div>
<div className="app-sw-window">
<SWHeaderActions showPopupGuide={false} onPrint={() => onEvent?.("print")} />
</div>
</SWTheme>
)
}
@@ -0,0 +1,30 @@
// Example D — existing Layer / Color components next to a NEW consumer control (SWSearchControl).
import { useState } from "react"
import { SWHeaderColorMenu, SWHeaderLayerMenu, SWTheme, type SWHeaderColorValue } from "@hm-design/sw"
import { colors, layers } from "./sample-data"
import { SWSearchControl } from "./SWSearchControl"
import "./example-layout.css"
export function ExampleDSearchExtension({ onEvent }: { onEvent?: (name: string) => void }) {
const [activeLayerId, setActiveLayerId] = useState("0")
const [color, setColor] = useState<SWHeaderColorValue>({ value: "ByLayer", swatch: "#ffffff" })
const [query, setQuery] = useState("")
return (
<SWTheme className="app-sw-bar" role="banner" aria-label="검색 헤더">
<div className="app-sw-surface" role="toolbar" aria-label="객체 찾기">
<SWHeaderLayerMenu
value={{ name: "0" }}
layers={layers}
activeLayerId={activeLayerId}
onLayerSelect={layer => { setActiveLayerId(layer.id); onEvent?.(`layer:${layer.id}`) }}
/>
<SWHeaderColorMenu
value={color}
colors={colors}
onColorSelect={option => { setColor({ value: option.label ?? option.color, swatch: option.color }); onEvent?.(`color:${option.id}`) }}
/>
<SWSearchControl value={query} onValueChange={setQuery} onSearch={text => onEvent?.(`search:${text}`)} />
</div>
</SWTheme>
)
}
@@ -0,0 +1,41 @@
// Example D extension — a NEW control written in the consuming project (3rd priority).
// Decision record: the package has no search component (MISSING). Its editable SWHeaderInput covers the
// text entry (Enter / blur commit, Escape cancel, IME), so it is reused; only the field surface and the
// search icon button are new. They read the public SW tokens (sw-search-control.css) — no copied
// package code, no hm-sw-* class, no new design values. Not registered as a package component.
import { SWHeaderInput } from "@hm-design/sw"
import "./sw-search-control.css"
export interface SWSearchControlProps {
/** Committed query (host state). */
value: string
/** A new query was committed with Enter or by leaving the input. */
onValueChange?: (value: string) => void
/** Search with the current query (icon button or a committed query). */
onSearch?: (query: string) => void
label?: string
disabled?: boolean
}
export function SWSearchControl({ value, onValueChange, onSearch, label = "검색", disabled }: SWSearchControlProps) {
return (
<div className="app-sw-search" role="search" data-disabled={disabled || undefined}>
<span className="app-sw-search__text">
<span className="app-sw-search__label" aria-hidden="true">{label}</span>
<SWHeaderInput
aria-label={`${label}어`}
value={value}
placeholder="객체 이름"
disabled={disabled}
onCommit={next => { onValueChange?.(next); onSearch?.(next) }}
/>
</span>
<button type="button" className="app-sw-search__button" aria-label={label} disabled={disabled} onClick={() => onSearch?.(value)}>
<svg width="16" height="16" viewBox="0 0 16 16" aria-hidden="true" focusable="false">
<circle cx="7" cy="7" r="4.75" fill="none" stroke="currentColor" strokeWidth="1.5" />
<path d="M10.5 10.5 14 14" stroke="currentColor" strokeWidth="1.5" strokeLinecap="round" />
</svg>
</button>
</div>
)
}
@@ -0,0 +1,36 @@
/* Consumer layout for the composed examples (B, C, D). It belongs to the consuming project: it only
arranges public SW components and reads the public --hm-sw-header-* tokens provided by <SWTheme>.
It never styles hm-sw-* classes and adds no new design values. */
.app-sw-bar {
display: flex;
align-items: stretch;
gap: 2px;
box-sizing: border-box;
height: var(--hm-sw-header-height);
padding-bottom: 1px; /* 48px surfaces in the 49px header, like SWHeader */
background: var(--hm-sw-header-background);
}
/* Object (editing) surface, the same tokens as the SWHeader object area. */
.app-sw-surface {
display: flex;
align-items: center;
gap: var(--hm-sw-header-gap);
flex: 1 1 0;
min-width: 0;
padding: 4px;
border: 1px solid var(--hm-sw-header-object-border-color);
border-radius: var(--hm-sw-header-object-radius);
background: var(--hm-sw-header-object-background);
}
/* Window-area surface for print / K / window buttons. */
.app-sw-window {
display: flex;
flex: 0 0 auto;
background: var(--hm-sw-header-document-background);
box-shadow: inset 0 0 0 1px var(--hm-sw-header-document-border-color); /* 1px edge without moving the parts */
}
/* File well placed first (no home overlap): its own left edge. */
.app-sw-file-edge {
display: flex;
border-left: 1px solid var(--hm-sw-header-document-border-color);
}
+52
View File
@@ -0,0 +1,52 @@
// Sample host data for the examples. Every type comes from the public package API.
import {
swHeaderLinePatterns,
type SWHeaderColorOption, type SWHeaderDimensionStyleOption, type SWHeaderDocument, type SWHeaderLayer,
type SWHeaderLineType, type SWHeaderLineWeight, type SWHeaderTextStyleOption,
} from "@hm-design/sw"
export const documents: SWHeaderDocument[] = [
{ id: "doc-1", name: "C0020101-006-평면및종단면도(06)" },
{ id: "doc-2", name: "C0020101-007-횡단면도(07)" },
]
export const layers: SWHeaderLayer[] = [
{ id: "0", name: "0", color: "#ffffff" },
{ id: "center", name: "CENTER", color: "#ff0000" },
{ id: "hidden", name: "HIDDEN", color: "#ffe500", visible: false },
{ id: "dim", name: "DIM", color: "#00ff00", locked: true },
]
export const colors: SWHeaderColorOption[] = [
{ id: "white", color: "#ffffff", label: "흰색" },
{ id: "red", color: "#ff0000", label: "빨강" },
{ id: "green", color: "#00ff00", label: "초록" },
{ id: "yellow", color: "#ffff00", label: "노랑" },
{ id: "blue", color: "#0000ff", label: "파랑" },
{ id: "magenta", color: "#ff00ff", label: "자홍" },
{ id: "cyan", color: "#00ffff", label: "청록" },
]
export const lineTypes: SWHeaderLineType[] = [
{ id: "bylayer", name: "ByLayer" },
{ id: "continuous", name: "Continuous" },
{ id: "center", name: "CENTER", pattern: swHeaderLinePatterns.center },
{ id: "hidden", name: "HIDDEN", pattern: swHeaderLinePatterns.hidden },
]
export const lineWeights: SWHeaderLineWeight[] = [
{ id: "bylayer", name: "ByLayer" },
{ id: "byblock", name: "ByBlock" },
{ id: "default", name: "ByDefault", value: "0.25" },
{ id: "user", name: "User", value: "0.50", editable: true },
]
export const textStyles: SWHeaderTextStyleOption[] = [
{ id: "standard", name: "Standard", font: "Arial" },
{ id: "dotum", name: "Dotum", font: "돋움" },
]
export const dimensionStyles: SWHeaderDimensionStyleOption[] = [
{ id: "standard", name: "Standard" },
{ id: "iso", name: "ISO-25" },
]
@@ -0,0 +1,48 @@
/* Consumer extension styles. Every value is a public SW token (see ../DESIGN_RULES.md); the
38px field height, 12px label / 14px bold value and 1px gradient hover ring follow the SW field rules. */
.app-sw-search {
position: relative;
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
height: var(--hm-sw-header-field-height);
padding: 0 6px 0 11px;
border: 1px solid var(--hm-sw-header-property-border-color);
border-radius: var(--hm-sw-header-property-radius);
background: var(--hm-sw-header-property-background);
box-shadow: var(--hm-sw-header-property-shadow);
}
/* Hover: the bordered-control rule — the 1px border becomes the yellow gradient; nothing moves. */
.app-sw-search::after {
content: "";
position: absolute;
inset: calc(-1 * var(--hm-sw-header-hover-border-width));
padding: var(--hm-sw-header-hover-border-width);
border-radius: inherit;
background: var(--hm-sw-header-hover-border-gradient);
/* Ring only: the content box is cut out (prefixed first, the standard declaration last wins). */
-webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
-webkit-mask-composite: xor;
mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
opacity: 0;
pointer-events: none;
}
.app-sw-search:hover:not([data-disabled])::after { opacity: 1; }
.app-sw-search__text { display: flex; flex-direction: column; min-width: 0; }
.app-sw-search__label {
height: 14px;
font-size: var(--hm-sw-header-label-size);
line-height: 14px;
letter-spacing: 0.02em;
text-shadow: var(--hm-sw-header-label-outline);
}
.app-sw-search__button {
display: grid;
place-items: center;
width: 24px;
height: 24px;
border-radius: var(--hm-sw-header-property-radius);
}
/* Text-only hover / selected color, like the SW toggles. */
.app-sw-search__button:hover:not(:disabled) { color: var(--hm-sw-header-selected-color); }