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>
)
@@ -15,13 +15,16 @@ export interface SWHeaderHomeProps {
* SWHeader always shows the logo; only the home action can be removed. Default true.
*/
interactive?: boolean
/** Logo menu state (SWHeader `homeMenu`). No visual change: Figma has no open look for the logo. */
"aria-expanded"?: boolean
"aria-controls"?: string
}
/**
* Home button. The slanted background and the logo slot position are the common SW design;
* the logo itself belongs to each SW program, so no logo is rendered unless one is passed.
*/
export function SWHeaderHome({ onClick, logoSrc, logo, logoAlt, interactive = true }: SWHeaderHomeProps) {
export function SWHeaderHome({ onClick, logoSrc, logo, logoAlt, interactive = true, "aria-expanded": expanded, "aria-controls": controls }: SWHeaderHomeProps) {
const content = logo ?? (logoSrc ? <img src={logoSrc} alt="" /> : null)
if (!interactive) return (
<div className="hm-sw-header__home" data-static="" role={logoAlt ? "img" : undefined} aria-label={logoAlt}>
@@ -30,7 +33,7 @@ export function SWHeaderHome({ onClick, logoSrc, logo, logoAlt, interactive = tr
</div>
)
return (
<button type="button" className="hm-sw-header__home" aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} onClick={onClick}>
<button type="button" className="hm-sw-header__home" aria-label={logoAlt ? `${logoAlt} 홈` : "홈"} aria-expanded={expanded} aria-controls={controls} onClick={onClick}>
<img className="hm-sw-header__home-bg" src={homeBackground} alt="" aria-hidden="true" />
<img className="hm-sw-header__home-bg-hover" src={homeBackgroundHover} alt="" aria-hidden="true" />
{content && <span className="hm-sw-header__logo" aria-hidden="true">{content}</span>}
@@ -272,7 +272,8 @@ function onDocumentEscape(event: KeyboardEvent) {
if (event.key !== "Escape" || event.defaultPrevented || event.isComposing) return
popupStack.at(-1)?.close()
}
function useSWPopupStack(onClose?: () => void) {
/** Internal (not exported by the package): also used by the open SWHomeMenu, which closes like a popup. */
export function useSWPopupStack(onClose?: () => void) {
const entry = useRef({ close: () => {} })
entry.current.close = () => onClose?.()
useEffect(() => {
@@ -31,3 +31,4 @@ export type {
export { SWHeaderPropertyBar, type SWHeaderPropertyBarProps } from "./components/SWHeaderPropertyBar"
export type { SWHeaderControlId, SWHeaderControlConfig, SWHeaderFrameControlId, SWHeaderSubControlId, SWHeaderHideableControlId } from "./types"
export type { SWHeaderHomeMenu, SWHeaderHomeMenuChangeReason } from "./types"
@@ -1,6 +1,7 @@
import type { HTMLAttributes, PointerEventHandler, ReactNode } from "react"
import type { SWHeaderPropertyBarProps } from "./components/SWHeaderPropertyBar"
import type { SWHeaderPropertyPanelProps } from "./components/SWHeaderPropertyPanel"
import type { SWHomeMenuCloseReason, SWHomeMenuProps } from "../sw-home-menu/types"
/** Header controls that can be shown as selected (text and arrow turn #FFC600). */
export type SWHeaderMenuId = "layer" | "color" | "lineStyle" | "textStyle" | "dimensionStyle"
@@ -146,6 +147,18 @@ export type SWHeaderFrameControlId = "home" | "windowMove" | "minimize" | "close
export type SWHeaderSubControlId = "documentNew" | "documentOpen" | "layerFreeze" | "layerLock"
/** Every Header part except the logo, which is always shown. */
export type SWHeaderHideableControlId = SWHeaderControlId | SWHeaderFrameControlId | SWHeaderSubControlId
/**
* Logo (home) menu of SWHeader: the SWHomeMenu props without the open state, which SWHeader owns
* (`homeMenuOpen` / `defaultHomeMenuOpen` / `onHomeMenuOpenChange`), and without the focus target (the logo).
*/
export type SWHeaderHomeMenu = Omit<SWHomeMenuProps, "open" | "defaultOpen" | "onOpenChange" | "returnFocusRef">
/**
* Why the logo menu opened or closed: the SWHomeMenu reasons, `home` (logo click / re-click) or `document`
* (the document dropdown opened, which closes the menu).
*/
export type SWHeaderHomeMenuChangeReason = SWHomeMenuCloseReason | "home" | "document"
export interface SWHeaderControlConfig {
id: SWHeaderControlId
visible?: boolean
@@ -323,6 +336,17 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
disabled?: boolean
/** Extra tools rendered inside the object area, after the menus. */
tools?: ReactNode
/**
* Logo (home) menu (Figma 26:10591): hidden by default, a logo click opens it and a second click closes it.
* It is placed from the measured header (object area − 2px up to the window area). While it is set, a logo
* click only toggles the menu and never calls `onHomeClick` (no unintended home navigation).
*/
homeMenu?: SWHeaderHomeMenu
/** Controlled open state of `homeMenu`. */
homeMenuOpen?: boolean
/** Uncontrolled initial state of `homeMenu`. Default false. */
defaultHomeMenuOpen?: boolean
onHomeMenuOpenChange?: (open: boolean, reason: SWHeaderHomeMenuChangeReason) => void
/**
* Logo image URL of the SW program, contained in the 32 x 17 logo slot.
* The logo is decided by each program: none is rendered when omitted.
@@ -333,6 +357,7 @@ export interface SWHeaderProps extends Omit<HTMLAttributes<HTMLElement>, "title"
/** Accessible product name; the home button is announced as "<logoAlt> 홈" (default "홈"). */
logoAlt?: string
/** Logo click. Not called while `homeMenu` is set (the click toggles the menu instead). */
onHomeClick?: () => void
onDocumentClick?: () => void
onLayerOpen?: () => void
@@ -0,0 +1,341 @@
import {
createContext, forwardRef, useCallback, useContext, useEffect, useId, useImperativeHandle, useLayoutEffect, useMemo, useRef, useState,
type ButtonHTMLAttributes, type CSSProperties, type KeyboardEvent, type MouseEvent, type ReactNode,
} from "react"
import { useSWPopupStack } from "../sw-header/components/SWHeaderPropertyPanel"
import titleBadge from "./assets/title-badge.svg"
// Imported where the component is used (not only in index.ts) so bundlers keep it with sideEffects: ["**/*.css"].
import "../../theme/sw-home-menu.css"
import type {
SWHomeMenuCloseReason, SWHomeMenuItemData, SWHomeMenuProps, SWHomeMenuSectionData, SWHomeMenuSelectEvent,
} from "./types"
interface WindowContextValue {
isActive: (id: string) => boolean
select: (item: SWHomeMenuItemData, section?: SWHomeMenuSectionData) => boolean
registerPanel: (id: string, panel: ReactNode | undefined, label: string) => void
panelId: string
close: (reason: SWHomeMenuCloseReason) => void
}
const WindowContext = createContext<WindowContextValue | null>(null)
const SectionContext = createContext<SWHomeMenuSectionData | undefined>(undefined)
/** For content inside a panel (e.g. SWRecentFiles): close the whole menu after opening a file. */
export function useSWHomeMenuClose() {
return useContext(WindowContext)?.close
}
const ITEM_SELECTOR = ".hm-sw-home-menu__item:not(:disabled)"
/**
* While open, the menu is on the SW popup Escape stack (SWPopup / property window): Escape with the focus outside
* every popup and the menu closes only the most recently used one (opened, focused or pressed last).
*/
function SWHomeMenuEscapeEntry({ onEscape, raiseRef }: { onEscape: () => void; raiseRef: { current: (() => void) | null } }) {
raiseRef.current = useSWPopupStack(onEscape)
return null
}
/**
* Figma 26:10591 "헤더/꾸러미 ver2/home on": the 2-tier header menu opened by the SW logo (home) button. Hidden by
* default; the host toggles `open` from SWHeader `onHomeClick` (logo click / re-click). Sections of 80 x 46 tiles;
* a tile may open an optional sub panel under the menu (Figma 최근파일 662:67331 → <SWRecentFiles>). Every text,
* icon, item and action is host data. Only the menu is drawn: the home button and window area stay those of
* <SWHeader>. Render it inside <SWTheme> (or next to SWHeader).
*/
export const SWHomeMenu = forwardRef<HTMLDivElement, SWHomeMenuProps>(function SWHomeMenu({
sections, children, open: openProp, defaultOpen = false, onOpenChange, activeIds: activeProp, defaultActiveIds,
onActiveChange, selection = "multiple", onItemSelect, closeOnBackgroundClick = true, closeOnSelect = false,
closeOnEscape = true, closeOnOutsideClick = false, autoFocus = true, returnFocusRef, accent = "auto",
className, style, onClick, onKeyDown, ...rest
}, ref) {
const [internalOpen, setInternalOpen] = useState(defaultOpen)
const open = openProp ?? internalOpen
const [internalActive, setInternalActive] = useState<readonly string[]>(defaultActiveIds ?? [])
const activeIds = activeProp ?? internalActive
const rootRef = useRef<HTMLDivElement>(null)
useImperativeHandle(ref, () => rootRef.current as HTMLDivElement)
const previousFocus = useRef<HTMLElement | null>(null)
const panelId = useId()
// Panels of the rendered tiles (id → content); the open one is the Active tile that has a panel.
const [panels, setPanels] = useState<Record<string, { panel: ReactNode; label: string }>>({})
const registerPanel = useCallback((id: string, panel: ReactNode | undefined, label: string) => {
setPanels(current => {
if (panel == null) {
if (!(id in current)) return current
const { [id]: _removed, ...next } = current
return next
}
if (current[id]?.panel === panel && current[id]?.label === label) return current
return { ...current, [id]: { panel, label } }
})
}, [])
const openPanelId = activeIds.find(id => id in panels)
const latest = useRef({ activeIds, onActiveChange, onItemSelect, selection, closeOnSelect, panels })
latest.current = { activeIds, onActiveChange, onItemSelect, selection, closeOnSelect, panels }
const setActive = useCallback((next: string[], event: SWHomeMenuSelectEvent) => {
if (activeProp === undefined) setInternalActive(next)
latest.current.onActiveChange?.(next, event)
}, [activeProp])
const close = useCallback((reason: SWHomeMenuCloseReason) => {
const root = rootRef.current
// Focus inside the menu would be lost when it unmounts: return it to the trigger / previous element.
if (root && root.contains(document.activeElement)) (returnFocusRef?.current ?? previousFocus.current)?.focus()
// A sub panel closes with the menu: its tile returns to Default (the next open starts without it).
const { activeIds: current, panels: known } = latest.current
const openPanel = current.find(id => id in known)
if (openPanel) setActive(current.filter(id => id !== openPanel), { item: { id: openPanel, label: known[openPanel].label }, active: false })
if (openProp === undefined) setInternalOpen(false)
onOpenChange?.(false, reason)
}, [openProp, onOpenChange, returnFocusRef, setActive])
// Opening (not the first render): remember the focus to return to, then focus the first tile.
const wasOpen = useRef(open)
useLayoutEffect(() => {
if (open && !wasOpen.current) {
const active = document.activeElement
previousFocus.current = active instanceof HTMLElement && !rootRef.current?.contains(active) ? active : null
if (autoFocus) rootRef.current?.querySelector<HTMLElement>(ITEM_SELECTOR)?.focus()
}
wasOpen.current = open
}, [open, autoFocus])
useEffect(() => {
if (!open || !closeOnOutsideClick) return
const onPointerDown = (event: PointerEvent) => {
const target = event.target as Node
if (rootRef.current?.contains(target) || returnFocusRef?.current?.contains(target)) return
close("outside")
}
document.addEventListener("pointerdown", onPointerDown)
return () => document.removeEventListener("pointerdown", onPointerDown)
}, [open, closeOnOutsideClick, close, returnFocusRef])
// Panel position: under the menu, 1px right of its tile's left edge (Figma 최근파일 808 vs tile 807).
const [panelLeft, setPanelLeft] = useState(0)
useLayoutEffect(() => {
if (!openPanelId || !rootRef.current) return
const root = rootRef.current
const measure = () => {
const tile = root.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`)
if (tile) setPanelLeft(tile.getBoundingClientRect().left - root.getBoundingClientRect().left + 1)
}
measure()
const scroller = root.querySelector(".hm-sw-home-menu__sections")
scroller?.addEventListener("scroll", measure)
const observer = new ResizeObserver(measure)
observer.observe(root)
return () => { scroller?.removeEventListener("scroll", measure); observer.disconnect() }
}, [openPanelId])
const context = useMemo<WindowContextValue>(() => ({
isActive: id => activeIds.includes(id),
registerPanel,
panelId,
close,
select: (item, section) => {
const { activeIds: current, onItemSelect: notify, selection: mode, closeOnSelect: closeAfter, panels: known } = latest.current
const toggle = item.toggle !== false
const active = toggle && !current.includes(item.id)
const event: SWHomeMenuSelectEvent = { item, section, active }
if (toggle) {
let next = active ? (mode === "single" ? [item.id] : [...current, item.id]) : current.filter(id => id !== item.id)
// One sub panel at a time: opening a panel tile closes the other panel tile.
if (active && item.panel != null) next = next.filter(id => id === item.id || !(id in known))
setActive(next, event)
}
item.onSelect?.(event)
notify?.(event)
if (closeAfter && item.panel == null) close("select")
return active
},
}), [activeIds, registerPanel, panelId, close, setActive])
const raiseRef = useRef<(() => void) | null>(null)
if (!open) return null
/** Escape: an open sub panel closes first (focus back on its tile when it was inside), then the menu. */
const escape = () => {
if (openPanelId) {
const item = { id: openPanelId, label: panels[openPanelId]?.label ?? openPanelId }
const focusInside = rootRef.current?.contains(document.activeElement)
setActive(activeIds.filter(id => id !== openPanelId), { item, active: false })
if (focusInside) rootRef.current?.querySelector<HTMLElement>(`[data-item-id="${CSS.escape(openPanelId)}"]`)?.focus()
return
}
close("escape")
}
const handleClick = (event: MouseEvent<HTMLDivElement>) => {
onClick?.(event)
if (event.defaultPrevented || !closeOnBackgroundClick) return
const target = event.target as Element
if (!target.closest("button, a, input, [role='listbox'], .hm-sw-home-menu__panel")) close("background")
}
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented) return
if (event.key === "Escape" && closeOnEscape) {
// preventDefault: the document-level popup Escape handler ignores it (one Escape closes one layer).
event.preventDefault()
escape()
return
}
// ArrowDown on the tile of the open sub panel moves into the panel (keyboard access, not in Figma).
if (event.key === "ArrowDown" && openPanelId && (document.activeElement as HTMLElement | null)?.dataset.itemId === openPanelId) {
const first = rootRef.current?.querySelector<HTMLElement>(".hm-sw-home-menu__panel :is(button, [href], input, [tabindex]):not(:disabled, [tabindex='-1'])")
if (first) { event.preventDefault(); first.focus() }
return
}
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) return
const items = Array.from(rootRef.current?.querySelectorAll<HTMLElement>(ITEM_SELECTOR) ?? [])
const index = items.indexOf(document.activeElement as HTMLElement)
if (index < 0) return
event.preventDefault()
const next = event.key === "Home" ? 0 : event.key === "End" ? items.length - 1
: (index + (event.key === "ArrowRight" ? 1 : -1) + items.length) % items.length
items[next].focus()
}
const resolvedAccent = accent === "auto" ? (openPanelId ? "outline" : "sections") : accent
const openPanel = openPanelId ? panels[openPanelId] : undefined
return (
<WindowContext.Provider value={context}>
<div
role="region"
aria-label={rest["aria-label"] ?? "홈 메뉴"}
{...rest}
ref={rootRef}
className={`hm-sw-home-menu${className ? ` ${className}` : ""}`}
data-accent={resolvedAccent}
data-panel-open={openPanel ? "true" : undefined}
style={{ "--hm-sw-home-menu-title-art": `url("${titleBadge}")`, ...style } as CSSProperties}
onClick={handleClick}
onKeyDown={handleKeyDown}
onFocusCapture={event => { raiseRef.current?.(); rest.onFocusCapture?.(event) }}
onPointerDownCapture={event => { raiseRef.current?.(); rest.onPointerDownCapture?.(event) }}
>
{closeOnEscape && <SWHomeMenuEscapeEntry onEscape={escape} raiseRef={raiseRef} />}
<div className="hm-sw-home-menu__sections">
{sections
? sections.filter(section => !section.hidden).map(section => (
<SWHomeMenuSection key={section.id} section={section} title={section.title}>
{section.items.filter(item => !item.hidden).map(item => <SWHomeMenuItem key={item.id} item={item} />)}
</SWHomeMenuSection>
))
: children}
</div>
{openPanel && (
<div className="hm-sw-home-menu__panel" id={panelId} role="group" aria-label={openPanel.label} style={{ left: panelLeft }}>
{openPanel.panel}
</div>
)}
</div>
</WindowContext.Provider>
)
})
export interface SWHomeMenuSectionProps {
title: string
children: ReactNode
/** Data record passed back in select events (set automatically in data mode). */
section?: SWHomeMenuSectionData
className?: string
}
/** Figma section (e.g. Frame 2408): title badge + tile group. Its width follows the number of tiles. */
export function SWHomeMenuSection({ title, children, section, className }: SWHomeMenuSectionProps) {
const titleId = useId()
return (
<SectionContext.Provider value={section}>
<div className={`hm-sw-home-menu__section${className ? ` ${className}` : ""}`} role="group" aria-labelledby={titleId}>
<span className="hm-sw-home-menu__title" id={titleId}>{title}</span>
<div className="hm-sw-home-menu__group">{children}</div>
</div>
</SectionContext.Provider>
)
}
export interface SWHomeMenuItemProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "onSelect" | "children"> {
/** Data mode: the whole record. Otherwise give `itemId` + `label` (+ `icon`, `panel`, …). */
item?: SWHomeMenuItemData
itemId?: string
label?: string
icon?: ReactNode
hoverIcon?: ReactNode
toggle?: boolean
panel?: ReactNode
panelLabel?: string
/** Outside <SWHomeMenu> only: controlled / initial Active and its change. Inside, the menu owns Active. */
active?: boolean
defaultActive?: boolean
onActiveChange?: (active: boolean) => void
onSelect?: (event: SWHomeMenuSelectEvent) => void
}
/** Figma tile 80 x 46 with Default / Variant2 (Hover) / Variant3 (Active, same look as Variant2). */
export function SWHomeMenuItem({
item: itemProp, itemId, label, icon, hoverIcon, toggle, panel, panelLabel, active: activeProp, defaultActive = false,
onActiveChange, onSelect, disabled, title, className, onClick, ...rest
}: SWHomeMenuItemProps) {
const host = useContext(WindowContext)
const section = useContext(SectionContext)
const generatedId = useId()
const item: SWHomeMenuItemData = itemProp ?? {
id: itemId ?? generatedId, label: label ?? "", icon, hoverIcon, toggle, panel, panelLabel, disabled, title, onSelect,
"aria-label": rest["aria-label"],
}
const [ownActive, setOwnActive] = useState(defaultActive)
const toggles = item.toggle !== false
const active = toggles && (host ? host.isActive(item.id) : activeProp ?? ownActive)
const isDisabled = item.disabled ?? disabled
const hasPanel = host != null && item.panel != null
const register = host?.registerPanel
useLayoutEffect(() => {
if (!register) return
register(item.id, item.panel ?? undefined, item.panelLabel ?? item.label)
return () => register(item.id, undefined, "")
}, [register, item.id, item.panel, item.panelLabel, item.label])
const handleClick = (event: MouseEvent<HTMLButtonElement>) => {
onClick?.(event)
if (event.defaultPrevented) return
if (host) {
host.select(item, section)
return
}
const next = toggles && !active
if (toggles) {
if (activeProp === undefined) setOwnActive(next)
onActiveChange?.(next)
}
item.onSelect?.({ item, active: next })
}
return (
<button
type="button"
{...rest}
className={`hm-sw-home-menu__item${className ? ` ${className}` : ""}`}
data-item-id={item.id}
data-active={active ? "true" : undefined}
data-has-hover-icon={item.hoverIcon ? "" : undefined}
aria-pressed={toggles && !hasPanel ? active : undefined}
aria-expanded={hasPanel ? active : undefined}
aria-controls={hasPanel && active ? host.panelId : undefined}
aria-label={item["aria-label"]}
title={item.title ?? item.label}
disabled={isDisabled}
onClick={handleClick}
>
{item.icon != null && <span className="hm-sw-home-menu__icon" aria-hidden="true">{item.icon}</span>}
{item.hoverIcon != null && <span className="hm-sw-home-menu__icon hm-sw-home-menu__icon--hover" aria-hidden="true">{item.hoverIcon}</span>}
<span className="hm-sw-home-menu__label">{item.label}</span>
</button>
)
}
@@ -0,0 +1,92 @@
import { useId, useRef, useState, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import "../../theme/sw-home-menu.css"
import { useSWHomeMenuClose } from "./SWHomeMenu"
import type { SWFileEntry } from "./types"
export interface SWRecentFilesProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
/** Host-owned list (most recent first). Nothing is read from disk here. */
files: readonly SWFileEntry[]
/** A row was chosen (click / Enter). The host opens the file. */
onFileOpen?: (file: SWFileEntry) => void
/** Hover / focus change of the previewed row (null when none). */
onFileHover?: (file: SWFileEntry | null) => void
/** Preview content when a file has no `preview` (e.g. a thumbnail loaded by the host). */
renderPreview?: (file: SWFileEntry) => ReactNode
/** Inside <SWHomeMenu>: close the menu after `onFileOpen`. Default true. */
closeOnOpen?: boolean
/** Shown when `files` is empty (not in Figma). */
emptyText?: ReactNode
"aria-label"?: string
}
/**
* Figma 최근파일 662:67331: 354px recent-file list (rows 50px: optional 29 x 28 badge, name 14px, path 12px)
* with the 2-depth preview 662:67471 beside the hovered / focused row. The list height follows the rows and is
* capped (`--hm-sw-recent-files-max-height`, Figma 750 = 15 rows); longer lists scroll inside.
*/
export function SWRecentFiles({
files, onFileOpen, onFileHover, renderPreview, closeOnOpen = true, emptyText = "최근 파일이 없습니다",
className, onKeyDown, ...rest
}: SWRecentFilesProps) {
const closeWindow = useSWHomeMenuClose()
const listId = useId()
const listRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState<{ id: string; top: number } | null>(null)
const current = hovered ? files.find(file => file.id === hovered.id) : undefined
const preview = current ? current.preview ?? renderPreview?.(current) : undefined
const show = (file: SWFileEntry | null, row?: HTMLElement) => {
const list = listRef.current
const top = row && list ? row.offsetTop - list.scrollTop + 1 : 0
setHovered(file ? { id: file.id, top } : null)
if ((file?.id ?? null) !== (hovered?.id ?? null)) onFileHover?.(file)
}
const open = (file: SWFileEntry) => {
if (file.disabled) return
onFileOpen?.(file)
if (closeOnOpen) closeWindow?.("file")
}
const handleKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented || !["ArrowDown", "ArrowUp", "Home", "End"].includes(event.key)) return
const rows = Array.from(listRef.current?.querySelectorAll<HTMLElement>(".hm-sw-recent-files__row:not(:disabled)") ?? [])
const index = rows.indexOf(document.activeElement as HTMLElement)
event.preventDefault()
event.stopPropagation()
const next = event.key === "Home" ? 0 : event.key === "End" ? rows.length - 1
: index < 0 ? 0 : Math.min(rows.length - 1, Math.max(0, index + (event.key === "ArrowDown" ? 1 : -1)))
rows[next]?.focus()
}
return (
<div {...rest} className={`hm-sw-recent-files${className ? ` ${className}` : ""}`} onKeyDown={handleKeyDown}
onMouseLeave={() => show(null)}>
<div ref={listRef} className="hm-sw-recent-files__list" id={listId} role="list" aria-label={rest["aria-label"] ?? "최근 파일"}
onScroll={() => hovered && setHovered(null)}>
{files.length === 0 && <div className="hm-sw-recent-files__empty" role="listitem">{emptyText}</div>}
{files.map(file => (
<div key={file.id} role="listitem" className="hm-sw-recent-files__item">
<button type="button" className="hm-sw-recent-files__row" data-file-id={file.id}
data-hovered={hovered?.id === file.id ? "true" : undefined} disabled={file.disabled}
title={file.title ?? (file.path ? `${file.name}\n${file.path}` : file.name)}
onMouseEnter={event => show(file, event.currentTarget)} onFocus={event => show(file, event.currentTarget)}
onClick={() => open(file)}>
{file.badge != null && <span className="hm-sw-recent-files__badge" aria-hidden="true">{file.badge}</span>}
<span className="hm-sw-recent-files__text">
<span className="hm-sw-recent-files__name">{file.name}</span>
{file.path && <span className="hm-sw-recent-files__path">{file.path}</span>}
</span>
</button>
</div>
))}
</div>
{current && preview != null && (
<div className="hm-sw-recent-files__preview" style={{ top: hovered?.top }} aria-hidden="true">
<span className="hm-sw-recent-files__preview-title">{current.previewTitle ?? current.path ?? current.name}</span>
<div className="hm-sw-recent-files__preview-body">{preview}</div>
</div>
)}
</div>
)
}
@@ -0,0 +1,31 @@
<svg preserveAspectRatio="none" overflow="visible" style="display: block;" width="132" height="27" viewBox="0 0 132 27" fill="none" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink">
<g id="Union" filter="url(#filter0_d_0_457)">
<use xlink:href="#stroke0_0_457" fill="url(#paint0_linear_0_457)"/>
<mask id="stroke1_0_457" fill="white">
<use xlink:href="#stroke0_0_457"/>
</mask>
<path d="M27 6H4C9.02879 6.86861 12.6686 11.6685 16.1248 16.226C19.5413 20.7314 22.7783 25 27 25H28H104H105C109.222 25 112.459 20.7314 115.875 16.226C119.331 11.6685 122.971 6.86861 128 6H105H104H28H27Z" stroke-width="2" stroke="url(#paint1_linear_0_457)" mask="url(#stroke1_0_457)"/>
</g>
<defs>
<filter id="filter0_d_0_457" x="0" y="0" width="132" height="27" filterUnits="userSpaceOnUse" color-interpolation-filters="sRGB">
<feFlood flood-opacity="0" result="BackgroundImageFix"/>
<feColorMatrix in="SourceAlpha" type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 127 0" result="hardAlpha"/>
<feOffset dy="-2"/>
<feGaussianBlur stdDeviation="2"/>
<feComposite in2="hardAlpha" operator="out"/>
<feColorMatrix type="matrix" values="0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 0.4 0"/>
<feBlend mode="normal" in2="BackgroundImageFix" result="effect1_dropShadow_0_457"/>
<feBlend mode="normal" in="SourceGraphic" in2="effect1_dropShadow_0_457" result="shape"/>
</filter>
<linearGradient id="paint0_linear_0_457" x1="66.3862" y1="6" x2="66.3862" y2="25" gradientUnits="userSpaceOnUse">
<stop stop-color="#887757"/>
<stop offset="0.755507" stop-color="#A8997C"/>
<stop offset="1" stop-color="#C3B79E"/>
</linearGradient>
<linearGradient id="paint1_linear_0_457" x1="66.3862" y1="6" x2="66.3862" y2="28" gradientUnits="userSpaceOnUse">
<stop stop-color="#7B6332"/>
<stop offset="1" stop-color="#9F8D68"/>
</linearGradient>
<path id="stroke0_0_457" d="M4 6H27H28H104H105H128C122.971 6.86861 119.331 11.6685 115.875 16.226C112.459 20.7314 109.222 25 105 25H104H28H27C22.7783 25 19.5413 20.7314 16.1248 16.226C12.6686 11.6685 9.02879 6.86861 4 6Z" fill-rule="evenodd" clip-rule="evenodd"/>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.1 KiB

@@ -0,0 +1,9 @@
export {
SWHomeMenu, SWHomeMenuSection, SWHomeMenuItem, useSWHomeMenuClose,
type SWHomeMenuSectionProps, type SWHomeMenuItemProps,
} from "./SWHomeMenu"
export { SWRecentFiles, type SWRecentFilesProps } from "./SWRecentFiles"
export type {
SWHomeMenuProps, SWHomeMenuItemData, SWHomeMenuSectionData, SWHomeMenuSelectEvent, SWHomeMenuCloseReason, SWHomeMenuAccent,
SWFileEntry,
} from "./types"
@@ -0,0 +1,108 @@
import type { HTMLAttributes, ReactNode, RefObject } from "react"
/** One 80 x 46 tile of the logo (home) menu (Figma 26:11355 … 26:11631). Text, icons and actions belong to the host. */
export interface SWHomeMenuItemData {
id: string
/** One line. Longer text ends with an ellipsis and the full text is shown as `title`. */
label: string
/**
* Icon in an 18 x 18 slot (centered; larger art may overflow it like the Figma assets). A `currentColor`
* icon follows the state color (Default #B6B4AF, Hover / Active #FF5C00); an image needs `hoverIcon`.
*/
icon?: ReactNode
/** Replaces `icon` on Hover and Active, for fixed-color image pairs (Figma default / hover SVG). Same name as SWToolButton. */
hoverIcon?: ReactNode
/**
* true (default): a click toggles Active (Figma Default → Variant2 on hover → Variant3 on click → Default).
* false: a plain action button without Active state.
*/
toggle?: boolean
/**
* Optional sub panel opened under the tile while the tile is Active, e.g. <SWRecentFiles> (Figma 최근파일 662:67331).
* Only one tile with a panel is Active at a time; a second click closes it.
*/
panel?: ReactNode
/** Accessible name of the panel (defaults to the label). */
panelLabel?: string
disabled?: boolean
/** Not rendered; the remaining tiles close the gap (no empty slot). */
hidden?: boolean
/** Tooltip; defaults to `label`. */
title?: string
"aria-label"?: string
onSelect?: (event: SWHomeMenuSelectEvent) => void
}
export interface SWHomeMenuSectionData {
id: string
/** Section badge text (Figma tit_* 26:12604 …). The badge width follows the text. */
title: string
items: readonly SWHomeMenuItemData[]
hidden?: boolean
}
export interface SWHomeMenuSelectEvent {
item: SWHomeMenuItemData
section?: SWHomeMenuSectionData
/** Active state after this click (always false for `toggle: false` items). */
active: boolean
}
export type SWHomeMenuCloseReason = "escape" | "background" | "outside" | "select" | "file"
/**
* Bottom accent under the sections. `sections` = Figma 26:10591 (one orange strip per section);
* `outline` = Figma 662:67267 (2px orange outline around the whole menu, shown with an open sub panel).
* Default `auto`: `outline` while a panel is open, otherwise `sections`.
*/
export type SWHomeMenuAccent = "auto" | "sections" | "outline"
export interface SWHomeMenuProps extends Omit<HTMLAttributes<HTMLDivElement>, "onSelect" | "title" | "children"> {
/** Data mode. Use either `sections` or `children` (<SWHomeMenuSection>). */
sections?: readonly SWHomeMenuSectionData[]
children?: ReactNode
/** Controlled open state (e.g. toggled by SWHeader `onHomeClick`, the logo). Closed renders nothing. */
open?: boolean
/** Uncontrolled initial state. Default false: the menu is hidden until the logo is clicked. */
defaultOpen?: boolean
onOpenChange?: (open: boolean, reason: SWHomeMenuCloseReason) => void
/** Controlled Active (Figma Variant3) tile ids. */
activeIds?: readonly string[]
defaultActiveIds?: readonly string[]
onActiveChange?: (activeIds: string[], event: SWHomeMenuSelectEvent) => void
/** `multiple` (default, each tile toggles on its own as in Figma) or `single` (one Active tile at a time). */
selection?: "multiple" | "single"
/** Every tile click, after the item's own `onSelect`. */
onItemSelect?: (event: SWHomeMenuSelectEvent) => void
/** Figma: a click on the menu outside a tile closes it (Group 2882 ON_CLICK → CLOSE). Default true. */
closeOnBackgroundClick?: boolean
/** Close after a tile click. Default false (Figma: a tile click changes the tile to Variant3). */
closeOnSelect?: boolean
/** Not in Figma (keyboard access). Escape closes an open panel first, then the menu. Default true. */
closeOnEscape?: boolean
/** Not in Figma (its overlay has no outside interaction). Default false. */
closeOnOutsideClick?: boolean
/** Focus the first tile when the menu opens. Default true (keyboard access). */
autoFocus?: boolean
/** Focus target after Escape. Default: the element that had focus when the menu opened. */
returnFocusRef?: RefObject<HTMLElement | null>
accent?: SWHomeMenuAccent
"aria-label"?: string
}
/** One row of <SWRecentFiles> (Figma header/최근파일/list, 354 x 50). */
export interface SWFileEntry {
id: string
/** File name (14px, one line, ellipsis). */
name: string
/** Location (12px, 60%, one line, ellipsis). */
path?: string
/** 29 x 28 slot before the text (Figma icon_dualmonitor: the file is open on monitor 1 / 2). Rows without it keep the text column. */
badge?: ReactNode
/** 2-depth preview shown beside the hovered / focused row (Figma 파일출력 2뎁스 662:67471). */
preview?: ReactNode
/** Preview caption; defaults to `path`. */
previewTitle?: string
disabled?: boolean
title?: string
}
+1
View File
@@ -10,6 +10,7 @@ export {
SWPopup, SWPopupSection, SWPopupRow,
type SWPopupProps, type SWPopupSectionProps, type SWPopupRowProps,
} from "./components/sw-popup/SWPopup"
export * from "./components/sw-home-menu"
// SW viewer mode (2026-10-19): viewer header, dropdown, popup panel and panel controls.
export * from "./components/sw-viewer"
+335
View File
@@ -0,0 +1,335 @@
/* @hm-design/sw — Home menu (Figma rj6wpsfk24lOWvWiVRAANP 26:10591 "헤더/꾸러미 ver2/home on", tiles 26:11355 …).
Scoped to .hm-sw-home-menu; reads the shared SW tokens of .hm-sw-header / .hm-sw-theme (font, text outline,
focus). New values are Figma values (Plugin API, 2026-10-19), never global selectors. */
.hm-sw-home-menu {
/* Section surface (Frame 2409–2412; Frame 2408 has slightly different stops): bottom → top + edge shade. */
--hm-sw-home-menu-section-background:
linear-gradient(0deg, #16120a 0%, rgba(22, 18, 10, 0) 54.458%, #16120a 100%),
linear-gradient(0deg, #18150e 28.102%, #262015 41.084%, #332b1c 58.06%, #38301f 81.027%, #18150e 100%);
--hm-sw-home-menu-section-border-color: #081813;
--hm-sw-home-menu-section-inner-border-color: #20190e;
/* Tile group g_* : fill bottom → top, 1px stroke top → bottom, drop shadow 0 2 r4 60%. */
--hm-sw-home-menu-group-background: linear-gradient(0deg, #19160e 2%, #38301f 43.678%, #463c29 67.668%, #2f281a 100%);
--hm-sw-home-menu-group-border: linear-gradient(180deg, #886d35 0%, #423625 8.846%, #f3dba8 26.169%, #0e0b06 83.792%, #574b30 100%);
--hm-sw-home-menu-group-shadow: 0 2px 4px rgba(0, 0, 0, 0.6);
/* Tile divider 1 x 44 (same gradient as the group stroke) with -1 0 r2 black shadow. */
--hm-sw-home-menu-divider: var(--hm-sw-home-menu-group-border);
--hm-sw-home-menu-divider-shadow: -1px 0 2px #000;
/* Tile states: Variant2 / Variant3 stroke (INSIDE 1px, top → bottom), icon and label color. */
--hm-sw-home-menu-item-hover-border: linear-gradient(180deg, #da6813 8.5%, #ffbb89 37.9%, #e5690d 71.9%, #ae3607 92.8%);
--hm-sw-home-menu-item-color: var(--hm-sw-header-text-color);
--hm-sw-home-menu-item-accent-color: #ff5c00;
--hm-sw-home-menu-icon-color: #b6b4af;
/* Title badge (tit_* Union, 9-slice of the Figma SVG) */
--hm-sw-home-menu-title-color: #000;
/* Bottom accent: 26:10591 per-section strip (top → bottom) / 662:67267 2px outline (bottom → top). */
--hm-sw-home-menu-accent-strip: linear-gradient(180deg, #5f4b0f -6.4%, #ff5100 96.8%);
--hm-sw-home-menu-accent-outline: linear-gradient(0deg, #ff5100 0%, #5f4b0f 76.7%);
/* Optional sub panel (Figma 최근파일 662:67331 / 파일출력 2뎁스 662:67471). */
--hm-sw-recent-files-width: 354px;
--hm-sw-recent-files-max-height: 750px;
--hm-sw-recent-files-background: #27241d;
--hm-sw-recent-files-border-color: #da6813;
--hm-sw-recent-files-path-color: #d4cbbd;
--hm-sw-recent-files-row-hover-background: var(--hm-sw-header-layer-row-hover-background);
--hm-sw-recent-files-row-hover-color: var(--hm-sw-header-layer-row-hover-color);
--hm-sw-recent-files-preview-width: 310px;
--hm-sw-recent-files-preview-background: #62533d;
position: relative;
isolation: isolate;
width: max-content;
max-width: 100%;
}
.hm-sw-home-menu__sections {
display: flex;
align-items: flex-start;
max-width: 100%;
/* 2px of the accent strip shows left, right and under the sections (menu 1305 x 77 in Figma). */
padding: 0 2px 2px;
/* Not in Figma (1920px only): narrower hosts scroll the menu horizontally, like the property bar. */
overflow-x: auto;
overflow-y: hidden;
scrollbar-width: thin;
}
/* Sub panel under the menu, at its tile (position set from the DOM). */
.hm-sw-home-menu__panel { position: absolute; z-index: 1; top: 100%; }
.hm-sw-home-menu__section {
position: relative;
display: flex;
flex: 0 0 auto;
flex-direction: column;
align-items: center;
height: 75px;
/* Figma: content 5px from the top and 10px from the sides, the 1px stroke included. */
padding: 4px 9px 0;
border: 1px solid var(--hm-sw-home-menu-section-border-color);
border-radius: 0 0 3px 3px;
background: var(--hm-sw-home-menu-section-background);
}
/* Frame 2412 (last): 261px, 11px left. Frame 2408 keeps 340px with its group centered. */
.hm-sw-home-menu__section:last-child { padding-left: 10px; }
/* Inner 1px #20190E (Rectangle 6977 / border, inset 1px). */
.hm-sw-home-menu__section::before {
content: "";
position: absolute;
inset: 0;
border: 1px solid var(--hm-sw-home-menu-section-inner-border-color);
border-radius: 0 0 2px 2px;
pointer-events: none;
}
/* 26:10591 Rectangle 6991–6995: 30px strip from y 47 behind the section, 2px visible under it. */
.hm-sw-home-menu[data-accent="sections"] .hm-sw-home-menu__section::after {
content: "";
position: absolute;
z-index: -1;
top: 46px;
right: -2px;
bottom: -3px;
left: -2px;
border-radius: 0 0 3px 3px;
background: var(--hm-sw-home-menu-accent-strip);
pointer-events: none;
}
.hm-sw-home-menu[data-accent="sections"] .hm-sw-home-menu__section:first-child::after { left: -3px; }
.hm-sw-home-menu[data-accent="sections"] .hm-sw-home-menu__section:last-child::after { right: -3px; }
/* 662:67267 Rectangle 6991: 1305 x 30 from y 47, 2px INSIDE gradient stroke. */
.hm-sw-home-menu[data-accent="outline"]::after {
content: "";
position: absolute;
z-index: -1;
top: 47px;
right: 0;
left: 0;
height: 30px;
padding: 2px;
border-radius: 0 0 3px 3px;
background: var(--hm-sw-home-menu-accent-outline);
-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);
pointer-events: none;
}
/* tit_* : text + 23px each side; the Figma Union SVG (132 x 27 incl. its -2 r4 shadow) is 9-sliced. */
.hm-sw-home-menu__title {
position: relative;
z-index: 1;
display: flex;
flex: 0 0 auto;
align-items: center;
justify-content: center;
height: 19px;
margin-bottom: -1px;
padding: 1px 23px 0;
color: var(--hm-sw-home-menu-title-color);
font-size: var(--hm-sw-header-label-size);
font-weight: 400;
line-height: 14px;
letter-spacing: 0.02em;
white-space: nowrap;
}
.hm-sw-home-menu__title::before {
content: "";
position: absolute;
z-index: -1;
inset: -6px -4px -2px;
border-style: solid;
border-width: 0 30px;
border-image: var(--hm-sw-home-menu-title-art) 0 30 fill / 0 30px / 0 stretch;
pointer-events: none;
}
.hm-sw-home-menu__group {
position: relative;
display: flex;
flex: 0 0 auto;
height: 46px;
border-radius: 3px;
background: var(--hm-sw-home-menu-group-background);
box-shadow: var(--hm-sw-home-menu-group-shadow);
}
/* INSIDE stroke drawn over the edge: tiles keep their full 80 x 46 like Figma. */
.hm-sw-home-menu__group::before {
content: "";
position: absolute;
inset: 0;
padding: 1px;
border-radius: inherit;
background: var(--hm-sw-home-menu-group-border);
-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);
pointer-events: none;
}
.hm-sw-home-menu__item {
position: relative;
flex: 0 0 80px;
width: 80px;
height: 46px;
color: var(--hm-sw-home-menu-item-color);
}
.hm-sw-home-menu__item:first-child { border-radius: 3px 0 0 3px; }
.hm-sw-home-menu__item:last-child { border-radius: 0 3px 3px 0; }
.hm-sw-home-menu__item:only-child { border-radius: 3px; }
/* Divider at the right edge of every tile but the last. */
.hm-sw-home-menu__item:not(:last-child)::before {
content: "";
position: absolute;
z-index: 2;
top: 1px;
right: -1px;
width: 1px;
height: 44px;
background: var(--hm-sw-home-menu-divider);
box-shadow: var(--hm-sw-home-menu-divider-shadow);
pointer-events: none;
}
/* Variant2 / Variant3 stroke. */
.hm-sw-home-menu__item::after {
content: "";
position: absolute;
z-index: 3;
inset: 0;
padding: 1px;
border-radius: inherit;
background: var(--hm-sw-home-menu-item-hover-border);
-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;
}
/* 18 x 18 icon slot at (31, 6); larger art is centered on it and may overflow (Figma assets carry shadows). */
.hm-sw-home-menu__icon {
position: absolute;
top: 6px;
left: 31px;
display: grid;
place-items: center;
width: 18px;
height: 18px;
color: var(--hm-sw-home-menu-icon-color);
}
.hm-sw-home-menu__icon > * { flex: none; max-width: none; }
.hm-sw-home-menu__icon > :is(svg, img) { display: block; }
.hm-sw-home-menu__icon--hover { visibility: hidden; }
.hm-sw-home-menu__label {
position: absolute;
top: 25px;
left: 50%;
max-width: 76px;
height: 16px;
padding: 0 var(--hm-sw-header-text-outline-room);
overflow: hidden;
transform: translateX(-50%);
font-size: var(--hm-sw-header-font-size);
font-weight: 700;
line-height: 16px;
letter-spacing: 0.04em;
white-space: nowrap;
text-overflow: ellipsis;
text-shadow: var(--hm-sw-header-text-outline);
}
/* Variant2 (hover) = Variant3 (active): orange stroke, #FF5C00 icon and label, art 1px up-left. */
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"])::after { opacity: 1; }
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) {
--hm-sw-home-menu-icon-color: var(--hm-sw-home-menu-item-accent-color);
color: var(--hm-sw-home-menu-item-accent-color);
}
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon { transform: translate(-1px, -1px); }
.hm-sw-home-menu__item:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__label { top: 24px; }
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon:not(.hm-sw-home-menu__icon--hover) { visibility: hidden; }
.hm-sw-home-menu__item[data-has-hover-icon]:is(:hover:not(:disabled), [data-active="true"]) .hm-sw-home-menu__icon--hover { visibility: visible; }
/* SWRecentFiles — Figma 최근파일 662:67331 (354 x 750: rows 50px) + 2-depth preview 662:67471 (310 x 270). */
.hm-sw-recent-files { position: relative; width: var(--hm-sw-recent-files-width); }
/* Frame stroke: 1px #DA6813 left / right / bottom over the list edge (joins the menu's outline accent). */
.hm-sw-recent-files::after {
content: "";
position: absolute;
z-index: 2;
inset: 0;
border: 1px solid var(--hm-sw-recent-files-border-color);
border-top: 0;
border-radius: 0 0 3px 3px;
pointer-events: none;
}
.hm-sw-recent-files__list {
box-sizing: content-box; /* max-height = rows (15 x 50 = Figma 750) */
max-height: var(--hm-sw-recent-files-max-height);
overflow-x: hidden;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
border: 1px solid var(--hm-sw-recent-files-background);
border-radius: 0 0 4px 4px;
background: var(--hm-sw-recent-files-background);
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.25), inset 0 4px 8px rgba(0, 0, 0, 0.4);
}
.hm-sw-recent-files__row {
display: flex;
align-items: center;
gap: 8px;
width: 100%;
height: 50px;
padding: 1px 8px;
text-align: left;
}
/* Rows without a badge keep the text column at 45px (Figma px 45). */
.hm-sw-recent-files__row:not(:has(> .hm-sw-recent-files__badge)) { padding-left: 45px; }
.hm-sw-recent-files__badge { display: grid; flex: 0 0 29px; place-items: center; width: 29px; height: 28px; }
.hm-sw-recent-files__badge > :is(svg, img) { display: block; max-width: none; }
.hm-sw-recent-files__text { display: flex; flex-direction: column; gap: 2px; min-width: 0; max-width: 301px; }
.hm-sw-recent-files__name,
.hm-sw-recent-files__path {
display: block;
overflow: hidden;
overflow-clip-margin: var(--hm-sw-header-text-outline-room);
white-space: nowrap;
text-overflow: ellipsis;
letter-spacing: 0.04em;
}
.hm-sw-recent-files__name { color: var(--hm-sw-header-text-color); font-size: var(--hm-sw-header-font-size); font-weight: 700; line-height: 16px; text-shadow: var(--hm-sw-header-text-outline); }
.hm-sw-recent-files__path { color: var(--hm-sw-recent-files-path-color); font-size: var(--hm-sw-header-label-size); line-height: 14px; opacity: 0.6; text-shadow: var(--hm-sw-header-label-outline); }
.hm-sw-recent-files__row:is(:hover, [data-hovered="true"]):not(:disabled) { background: var(--hm-sw-recent-files-row-hover-background); }
.hm-sw-recent-files__row:is(:hover, [data-hovered="true"]):not(:disabled) .hm-sw-recent-files__name { color: var(--hm-sw-recent-files-row-hover-color); }
.hm-sw-recent-files__empty { padding: 16px 45px; color: var(--hm-sw-recent-files-path-color); font-size: var(--hm-sw-header-label-size); opacity: 0.6; }
/* 2-depth preview beside the row (1px gap), top aligned 1px under the row top. */
.hm-sw-recent-files__preview {
position: absolute;
z-index: 3;
left: calc(100% + 1px);
display: flex;
flex-direction: column;
align-items: center;
gap: 4px;
width: var(--hm-sw-recent-files-preview-width);
padding: 3px 0 0; /* + 1px border = Figma py 4; 270px total with the 243px body */
border: 1px solid var(--hm-sw-recent-files-preview-background);
border-left: 0;
border-radius: 0 3px 4px 0;
background: var(--hm-sw-recent-files-preview-background);
box-shadow: inset 2px 0 2.5px rgba(0, 0, 0, 0.5);
filter: drop-shadow(0 2px 1px rgba(0, 0, 0, 0.5));
pointer-events: none;
}
.hm-sw-recent-files__preview-title {
width: 294px;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
color: var(--hm-sw-recent-files-row-hover-color);
font-size: var(--hm-sw-header-font-size);
line-height: 18px;
letter-spacing: 0.02em;
}
.hm-sw-recent-files__preview-body { position: relative; width: 100%; height: 243px; overflow: hidden; border-radius: 0 0 4px 0; }
.hm-sw-recent-files__preview-body > :is(img, svg, canvas) { display: block; width: 100%; height: 100%; object-fit: cover; }
+5
View File
@@ -1876,3 +1876,8 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control:not([data-control="document"]) > * { flex-shrink: 0; }
/* ...and the file name gives way only down to the default design's smallest width (96px, see the clamps above). */
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control[data-control="document"] { min-width: 96px; }
/* Logo (home) menu layer (SWHeader homeMenu, 2026-10-19). Top of the header, left / max-width measured from the
object area (−2px) to the window area. Above the property bar anchor (2), under the dropdowns (30) and the
property window (40). The logo, the file name and the window area stay visible beside it. */
.hm-sw-header__home-menu { position: absolute; top: 0; z-index: 20; }