Merge branch 'integration/sw-home-menu' into feature/sw-home-menu

Integrate the SW home (logo) menu into SWHeader for SW general mode only.
SW viewer mode code and stories are unchanged.

- Resolve conflicts: keep both sw-home-menu and sw-viewer exports;
  renumber AI docs (catalog viewer 8->9, patterns home menu 7->8,
  design rules home menu 11->12)
- SWHeader: start the home menu after the file name when it sits
  inside controls, so the file name stays visible
- AI guides: mark home menu as general-mode only; forbid it in viewer
  mode (AI_USAGE, catalog, design rules, patterns, VIEWER.md, AI_GUIDE)
- Tests: ignore wildcard identifiers in ai-docs check; update expected
  Storybook SW titles (SW/Tool removed, SW/Viewer added)
- Add merge QA report docs/sw/sw-home-menu-merge-2026-10-19.md
This commit is contained in:
shkim4 committed 2026-10-19 18:21:19 +09:00
commit 3a5e29f2fa
122 files changed
+6547 -41

No files matched your search

@@ -20,7 +20,8 @@ import { SWHeaderPropertyPanel } from "./components/SWHeaderPropertyPanel"
import { SWHeaderDimensionStyleMenu, SWHeaderTextStyleMenu } from "./components/SWHeaderStyleListMenus"
import { SWHeaderWindowControls } from "./components/SWHeaderWindowControls"
import { SWToolButton } from "../sw-tool/SWTool"
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types"
import { SWHomeMenu } from "../sw-home-menu/SWHomeMenu"
import type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderHideableControlId, SWHeaderHomeMenuChangeReason, SWHeaderMenuId, SWHeaderProps, SWHeaderSubControlId } from "./types"
const defaults = {
layer: { name: "0" },
@@ -135,6 +136,10 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
onPropertyPanelOpenChange,
disabled = false,
tools,
homeMenu,
homeMenuOpen,
defaultHomeMenuOpen = false,
onHomeMenuOpenChange,
logo,
logoSrc,
logoAlt,
@@ -260,6 +265,41 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
setOpenCollapsed(false)
}
})
// Logo (home) menu: SWHeader owns its open state; a logo click toggles it instead of calling onHomeClick.
const homeMenuId = useId()
const homeButtonRef = useRef<HTMLElement | null>(null)
const [internalHomeOpen, setInternalHomeOpen] = useState(defaultHomeMenuOpen)
const homeOpen = !!homeMenu && shown("home") && (homeMenuOpen ?? internalHomeOpen)
const [internalDocumentOpen, setInternalDocumentOpen] = useState(false)
const documentOpenNow = documentMenuOpen ?? internalDocumentOpen
const setDocumentOpen = (next: boolean) => {
if (documentMenuOpen === undefined) setInternalDocumentOpen(next)
onDocumentMenuOpenChange?.(next)
}
const setHomeOpen = (next: boolean, reason: SWHeaderHomeMenuChangeReason) => {
if (homeMenuOpen === undefined) setInternalHomeOpen(next)
if (next !== homeOpen) onHomeMenuOpenChange?.(next, reason)
if (!next) return
// One header layer at a time: the menu covers the fields, so their dropdown and the document menu close.
if (openMenu) openers[openMenu].onOpenChange(false)
if (documentOpenNow) setDocumentOpen(false)
}
// Placement from the DOM (never a fixed x): 2px before the object area (Figma 375 vs 377 at 1920) up to the
// window area, so it follows the breakpoints, `leading`, the header Open and a narrowed file name.
const [homeBox, setHomeBox] = useState({ left: 0, width: 0 })
useLayoutEffect(() => {
const header = fieldsRef.current?.closest<HTMLElement>(".hm-sw-header")
homeButtonRef.current = header?.querySelector<HTMLElement>(":scope > .hm-sw-header__bar > .hm-sw-header__home") ?? null
if (!homeOpen || !header || !panelAnchorRef.current) return
const origin = header.getBoundingClientRect()
// With `controls`, the file name sits inside the object area: start after it so it stays visible.
const documentRight = fieldsRef.current?.querySelector<HTMLElement>(':scope > [data-control="document"]:not([data-hidden])')?.getBoundingClientRect().right
const start = Math.max(panelAnchorRef.current.getBoundingClientRect().left, documentRight ?? -Infinity)
const end = header.querySelector<HTMLElement>(":scope > .hm-sw-header__bar > .hm-sw-header__window")?.getBoundingClientRect().left ?? origin.right
const next = { left: start - origin.left - 2, width: end - start + 2 }
if (Math.abs(next.left - homeBox.left) > 0.1 || Math.abs(next.width - homeBox.width) > 0.1) setHomeBox(next)
})
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
@@ -440,8 +480,9 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
title={documentTitle}
onClick={onDocumentClick}
documents={documents}
menuOpen={documentMenuOpen}
onMenuOpenChange={onDocumentMenuOpenChange}
// With a logo menu SWHeader holds the document menu state, so the two never stay open together.
menuOpen={homeMenu ? documentOpenNow : documentMenuOpen}
onMenuOpenChange={homeMenu ? next => { setDocumentOpen(next); if (next && homeOpen) setHomeOpen(false, "document") } : onDocumentMenuOpenChange}
onDocumentSelect={onDocumentSelect}
activeDocumentId={activeDocumentId}
onDocumentClose={onDocumentClose}
@@ -479,7 +520,8 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
return (
<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} />
<SWHeaderHome onClick={!shown("home") ? undefined : homeMenu ? () => setHomeOpen(!homeOpen, "home") : onHomeClick} interactive={shown("home")} logo={logo} logoSrc={logoSrc} logoAlt={logoAlt}
aria-expanded={homeMenu && shown("home") ? homeOpen : undefined} aria-controls={homeOpen ? homeMenu?.id ?? homeMenuId : undefined} />
{hasLeading && <div ref={leadingRef} className="hm-sw-header__leading">
{openShown && <SWToolButton appearance="document" data-document-open="" disabled={disabled} onClick={onDocumentOpen}>{documentOpenLabel ?? "Open"}</SWToolButton>}
{leading}
@@ -505,6 +547,12 @@ 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>}
{homeMenu && shown("home") && (
<div className="hm-sw-header__home-menu" style={{ left: homeBox.left, maxWidth: homeBox.width }}>
<SWHomeMenu {...homeMenu} id={homeMenu.id ?? homeMenuId} open={homeOpen} returnFocusRef={homeButtonRef}
onOpenChange={(next, reason) => setHomeOpen(next, reason)} />
</div>
)}
{panelOpen && propertyPanel && <SWHeaderPropertyPanel {...propertyPanel} menus={{ showLayerFreeze: layerStates.showFreeze, showLayerLock: layerStates.showLock, ...propertyPanel.menus }} id={panelId} anchorRef={panelAnchorRef} onClose={closePanel} />}
</header>
)