feat(sw): integrated improvement 0.2.0 (popup content, header open, tool buttons)

SW package 0.2.0 integrated improvement (Task A, steps 1-8). All new props are
optional; existing SW Stories are pixel-identical to the previous build (28/28).

- Build reproducibility: dist text normalized to LF, dist/build-info.json
  (commit, dirty, tool versions, no timestamp), packages/sw/.gitattributes
  (eol=lf). LF checkouts give identical dist and tarball.
- SWHeader: documentOpenPlacement ("menu" default | "header": Open right of
  the logo in the existing document-button look, reusing onDocumentOpen and
  documentOpenLabel, no duplicate Open in the dropdown), leading slot,
  hiddenControls sub-items documentNew / documentOpen / layerFreeze /
  layerLock. Open moves into More on measured overflow (with hysteresis).
- showNew / showOpen (SWHeaderDocumentTitle), showFreeze / showLock (layer
  menu, field, property window list).
- SWToolButton (text / pill / document) and SWToolGroup (radio selection),
  reusing the existing toggle, pill and file-name looks.
- SWPopup / SWPopupSection / SWPopupRow on the shared property window frame;
  SWHeaderPropertyPanel gains children, title, autoFocus and optional
  selection / properties / groups. Escape stack for several popups.
- Field dropdowns inside a popup use the shared fixed overlay
  (useSWFixedDropdownPosition), so the scrolling body no longer clips them.
- AI guide, examples E/F, README, Storybook (SW/Header/Options, SW/Tool,
  SW/Popup) and verification record docs/sw/sw-integrated-improvement-2026-10-19.md.
- Regression scripts rescoped for the intended changes (unused-removal 81/81,
  overview 52/54 with the two pre-existing historical assertions).

Known limitation (unresolved): tools / leading content (incl. SWToolGroup)
does not move into More. With a wide tool bar the configured fields shrink
instead; in Example F the document name and layer field reach 0px at 390px.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
shkim4andClaude Opus 5.5 committed 2026-10-19 14:49:19 +09:00
1 parent 5faa1f1ffe
commit 55179b4577
138 files changed
+17194 -162

No files matched your search

@@ -19,7 +19,8 @@ import printHover from "../../assets/print-hover.svg"
import { SWHeaderPropertyPanel } from "./components/SWHeaderPropertyPanel"
import { SWHeaderDimensionStyleMenu, SWHeaderTextStyleMenu } from "./components/SWHeaderStyleListMenus"
import { SWHeaderWindowControls } from "./components/SWHeaderWindowControls"
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps } from "./types"
import { SWToolButton } from "../sw-tool/SWTool"
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types"
const defaults = {
layer: { name: "0" },
@@ -37,6 +38,7 @@ const menuSlot: Record<SWHeaderMenuId, string> = {
const defaultMenuOrder = Object.keys(menuSlot) as SWHeaderMenuId[]
const defaultControls: SWHeaderControlConfig[] = (["document", "drawingMode", ...defaultMenuOrder, "details", "propertyBar", "propertyPanel", "print", "popupGuide"] as const).map(id => ({ id }))
const frameControls: readonly SWHeaderFrameControlId[] = ["home", "windowMove", "minimize", "close"]
const subControls: readonly SWHeaderSubControlId[] = ["documentNew", "documentOpen", "layerFreeze", "layerLock"]
const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot
/**
@@ -73,6 +75,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
documentIcon,
documentNewLabel,
documentOpenLabel,
documentOpenPlacement = "menu",
leading,
drawingModeLabel = "그리기모드",
layer = defaults.layer,
layers,
@@ -161,7 +165,9 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const enabled = (id: SWHeaderControlId) => configuration.some(item => item.id === id)
// Default design with removed object parts: same structure and breakpoints, but the script decides what
// moves into More (the CSS alone assumes every field is present). Untouched defaults stay CSS-only.
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId))
// Sub-items live inside a part and never change the layout mode.
const scripted = controls !== undefined || [...removed].some(id => !frameControls.includes(id as SWHeaderFrameControlId) && !subControls.includes(id as SWHeaderSubControlId))
const layerStates = { showFreeze: shown("layerFreeze"), showLock: shown("layerLock") }
const presentRef = useRef(new Set(configuration.map(item => item.id)))
presentRef.current = new Set(configuration.map(item => item.id))
const label = (id: SWHeaderControlId, fallback: string) => configuration.find(item => item.id === id)?.label ?? fallback
@@ -204,6 +210,13 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const [headerWidth, setHeaderWidth] = useState(1920)
const [barOffset, setBarOffset] = useState(0)
// Open next to the logo (documentOpenPlacement="header") moves into More when the measured header has no room.
const openInHeader = documentOpenPlacement === "header" && shown("documentOpen")
const leadingRef = useRef<HTMLDivElement>(null)
const [openCollapsed, setOpenCollapsed] = useState(false)
const collapseRef = useRef<{ at: number; width: number } | null>(null)
const openShown = openInHeader && !openCollapsed
const hasLeading = openShown || (leading !== undefined && leading !== null && leading !== false)
useLayoutEffect(() => {
const header = fieldsRef.current?.closest(".hm-sw-header")
if (!header) return
@@ -218,6 +231,35 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
if (panelAnchorRef.current) observer.observe(panelAnchorRef.current)
return () => observer.disconnect()
}, [])
// Space for the parts right of the logo, measured: the file name gives way first (its CSS clamp subtracts
// --hm-sw-header-leading-width). Open moves into More only when the bar or object area really overflows,
// and returns once the header is wider than at that moment by the Open width (no flicker at the edge).
useLayoutEffect(() => {
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
if (!header) return
const leadingWidth = leadingRef.current ? leadingRef.current.getBoundingClientRect().width : 0
const value = hasLeading ? `${leadingWidth + 2}px` : ""
if (header.style.getPropertyValue("--hm-sw-header-leading-width") !== value) {
if (value) header.style.setProperty("--hm-sw-header-leading-width", value)
else header.style.removeProperty("--hm-sw-header-leading-width")
}
if (!openInHeader) {
collapseRef.current = null
if (openCollapsed) setOpenCollapsed(false)
return
}
if (!openCollapsed) {
const bar = header.querySelector<HTMLElement>(":scope > .hm-sw-header__bar")
const overflow = [bar, panelAnchorRef.current].some(element => !!element && element.scrollWidth - element.clientWidth > 1)
const open = leadingRef.current?.querySelector<HTMLElement>("[data-document-open]")
if (overflow && open) {
collapseRef.current = { at: header.getBoundingClientRect().width, width: open.getBoundingClientRect().width + 2 }
setOpenCollapsed(true)
}
} else if (collapseRef.current && headerWidth >= collapseRef.current.at + collapseRef.current.width) {
setOpenCollapsed(false)
}
})
const menuIds = configuration.map(item => item.id).filter((id): id is SWHeaderMenuId => id in menuSlot)
const baseMenu = menuIds.includes("layer") ? "layer" : menuIds[0]
const validPromoted = promoted && enabled(promoted) ? promoted : null
@@ -302,7 +344,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
if (!item) return []
const hidden = config.id in menuSlot ? fieldHidden(config.id as SWHeaderMenuId) : isOverflow(config.id)
return [{ ...item, label: config.label ?? item.label, visible: scripted ? hidden : undefined }]
})
}).concat(openInHeader ? [{ id: "documentOpen", label: documentOpenLabel ?? "Open", tier: "action" as const, disabled, visible: openCollapsed, onSelect: onDocumentOpen }] : [])
const fieldNodes: Record<SWHeaderMenuId, ReactNode> = {
layer: (layers ? (
<SWHeaderLayerMenu label={label("layer", "레이어")}
@@ -318,9 +360,10 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
onFreezeChange={onLayerFreezeChange}
onLockChange={onLayerLockChange}
onDetail={onLayerDetail}
{...layerStates}
/>
) : (
<SWHeaderLayerControl label={label("layer", "레이어")} value={layer} selected={activeMenu === "layer"} disabled={disabled} onClick={onLayerOpen} />
<SWHeaderLayerControl label={label("layer", "레이어")} value={layer} selected={activeMenu === "layer"} disabled={disabled} onClick={onLayerOpen} {...layerStates} />
)),
color: (colors ? (
<SWHeaderColorMenu label={label("color", "색상")}
@@ -404,6 +447,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
onDocumentClose={onDocumentClose}
onNew={onDocumentNew}
onOpen={onDocumentOpen}
showNew={shown("documentNew")}
showOpen={shown("documentOpen") && !openInHeader}
documentIcon={documentIcon}
newLabel={documentNewLabel}
openLabel={documentOpenLabel}
@@ -424,16 +469,21 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
const windowReduced = removed.size > 0 && (frameControls.some(id => removed.has(id)) || (controls === undefined && !(enabled("print") && enabled("popupGuide"))))
// CSS-only default layout: a field holding the first slot instead of the removed layer is styled as promoted.
const promotedSlot = validPromoted ?? (!scripted && baseMenu && baseMenu !== "layer" ? baseMenu : null)
const barData = propertyBar ?? (propertyPanel && {
// The bar shares the panel data only when the panel has a selection (a content-only panel has no bar rows).
const barData = propertyBar ?? (propertyPanel?.selection && {
selection: propertyPanel.selection,
properties: propertyPanel.properties,
properties: propertyPanel.properties ?? [],
onPropertyChange: propertyPanel.onPropertyChange,
})
return (
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} {...rest}>
<header ref={ref} className={`hm-sw-header${className ? ` ${className}` : ""}`} data-leading={openShown ? "open" : hasLeading ? "" : undefined} {...rest}>
<div className="hm-sw-header__bar">
<SWHeaderHome onClick={shown("home") ? onHomeClick : undefined} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt} />
{hasLeading && <div ref={leadingRef} className="hm-sw-header__leading">
{openShown && <SWToolButton appearance="document" data-document-open="" disabled={disabled} onClick={onDocumentOpen}>{documentOpenLabel ?? "Open"}</SWToolButton>}
{leading}
</div>}
{controls === undefined && enabled("document") && documentNode}
<div ref={panelAnchorRef} className="hm-sw-header__object-area" role="toolbar" aria-label="객체 수정" data-configured={controls !== undefined || undefined} data-promoted={promotedSlot ? menuSlot[promotedSlot] : undefined}>
{controls === undefined && enabled("drawingMode") && <span className="hm-sw-header__mode">{drawingModeLabel}</span>}
@@ -446,7 +496,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
)) : menuIds.map(id => <Fragment key={id}>{fieldNodes[id]}</Fragment>)}
{tools}
</div>
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} />}
{moreItems.some(item => item.visible !== false) && <SWHeaderMore items={moreItems} forceVisible={openCollapsed} />}
{controls === undefined && <SWHeaderProperties barOpen={barOpen} panelOpen={panelOpen} panelId={propertyPanel ? panelId : undefined} disabled={disabled} onBarToggle={toggleBar} onPanelToggle={togglePanel} showCaption={enabled("details")} showBar={enabled("propertyBar")} showPanel={enabled("propertyPanel")} />}
</div>
{windowShown && <div className="hm-sw-header__window" data-configured={controls !== undefined || undefined} data-reduced={windowReduced || undefined}>
@@ -455,7 +505,7 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
</div>}
</div>
{barOpen && barData && <div className="hm-sw-header__bar-anchor" style={{ left: barOffset, maxWidth: `calc(100% - ${barOffset}px)` }}><SWHeaderPropertyBar {...barData} disabled={disabled || propertyBar?.disabled} id={barId} /></div>}
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} menus={{ showLayerFreeze: layerStates.showFreeze, showLayerLock: layerStates.showLock, ...propertyPanel.menus }} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
</header>
)
})