feat(sw): viewer follow-up — SVG icon outline, shared SW popup frame, story grid, green layer buttons

- A: SWViewerIcon draws the SWHeader black outline as SVG stroke on a copy behind the icon (no CSS filter)
- B: SWViewerPanel renders the common SWPopup frame (same DOM / CSS) and adds drag, corner resize,
  collapse, bounds and modal; SWPopup gains optional titleActions / footer; title art spans any width
- C: shared Story canvas grid (--sw-story-canvas) behind the SW Header work area and dropdown room
- D: SWViewerButton tone="green" for 전체 켜기 / 전체 끄기: default kept, hover = primary (확인) hover
  token, pressed = primary face
- AI guide, verification scripts (76 checks, element regression) and evidence updated
This commit is contained in:
shkim4 committed 2026-10-19 16:34:50 +09:00
1 parent 7f42923ccf
commit 68fbcf6e15
45 files changed
+1463 -377

No files matched your search

@@ -17,6 +17,10 @@ export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "titl
autoFocus?: boolean
/** Anchor element: the popup is fixed below it and kept inside the viewport (as SWHeader does). */
anchorRef?: RefObject<HTMLElement | null>
/** Extra title-bar buttons, before the close button (e.g. collapse, reset). Default none. */
titleActions?: ReactNode
/** Area after the scrolling body (e.g. buttons). Stays visible while the body scrolls. Default none. */
footer?: ReactNode
}
/**
@@ -24,8 +28,8 @@ export interface SWPopupProps extends Omit<HTMLAttributes<HTMLDivElement>, "titl
* content up to the viewport (the body scrolls). Non-modal (no dim, no portal, no focus trap); the host decides
* when it is shown and where (`style`, or `anchorRef`). Wrap it in <SWTheme> outside SWHeader.
*/
export function SWPopup({ closeLabel = "닫기", ...props }: SWPopupProps) {
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" {...props} />
export function SWPopup({ closeLabel = "닫기", footer, ...props }: SWPopupProps) {
return <SWPopupFrame closeLabel={closeLabel} titleArt="single" kind="single" after={footer} {...props} />
}
export interface SWPopupSectionProps extends Omit<HTMLAttributes<HTMLElement>, "title"> {
@@ -1,4 +1,5 @@
import { forwardRef, useId, type ButtonHTMLAttributes, type HTMLAttributes, type InputHTMLAttributes, type ReactNode } from "react"
import { SWViewerIcon } from "./SWViewerIcon"
import "../../theme/sw-viewer.css"
const cx = (...names: (string | false | undefined)[]) => names.filter(Boolean).join(" ")
@@ -47,7 +48,7 @@ const StatusIcon = ({ state }: { state: SWViewerStatusState }) => state === "idl
export function SWViewerStatus({ state, label, detail, className, ...rest }: SWViewerStatusProps) {
return (
<div role="status" className={cx("hm-sw-viewer-status", className)} data-state={state} title={detail} {...rest}>
<StatusIcon state={state} />
{state !== "idle" && <span className="hm-sw-viewer-status__icon"><SWViewerIcon><StatusIcon state={state} /></SWViewerIcon></span>}
<span className="hm-sw-viewer-status__label">{label ?? statusLabel[state]}</span>
{detail && <span className="hm-sw-viewer-sr-only">: {detail}</span>}
</div>
@@ -60,14 +61,16 @@ export interface SWViewerButtonProps extends ButtonHTMLAttributes<HTMLButtonElem
* `primary`: the same size filled with #123328 (footer confirm). `ghost`: icon button of the panel title bar.
*/
variant?: "outline" | "primary" | "ghost"
/** `green`: the default look stays; hover and press turn the face HM green with white text. Default none. */
tone?: "green"
icon?: ReactNode
}
export const SWViewerButton = forwardRef<HTMLButtonElement, SWViewerButtonProps>(function SWViewerButton(
{ variant = "outline", icon, children, className, type = "button", ...rest }, ref,
{ variant = "outline", tone, icon, children, className, type = "button", ...rest }, ref,
) {
return (
<button ref={ref} type={type} className={cx("hm-sw-viewer-button", className)} data-variant={variant} {...rest}>
<button ref={ref} type={type} className={cx("hm-sw-viewer-button", className)} data-variant={variant} data-tone={tone} {...rest}>
{icon && <span className="hm-sw-viewer-button__icon" aria-hidden="true">{icon}</span>}
{children}
</button>
@@ -1,6 +1,7 @@
import { forwardRef, useId, useLayoutEffect, useRef, useState, type ButtonHTMLAttributes, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react"
import { SWViewerMenu } from "./SWViewerMenu"
import { SWViewerSwitch } from "./SWViewerControls"
import { SWViewerIcon } from "./SWViewerIcon"
import type { SWViewerCommand, SWViewerHeaderGroup, SWViewerHeaderItem, SWViewerMenuEntry } from "./types"
import "../../theme/sw-viewer.css"
@@ -97,9 +98,9 @@ function MenuButton({ item, iconOnly, onCommand }: { item: Extract<SWViewerHeade
aria-haspopup="menu" aria-expanded={open} aria-controls={open ? menuId : undefined} aria-label={iconOnly ? item.label : undefined} title={item.title} disabled={item.disabled}
onClick={() => (open ? setOpen(false) : show("menu"))}
onKeyDown={event => { if (event.key === "ArrowDown" || ((event.key === "Enter" || event.key === " ") && !open)) { event.preventDefault(); show("first") } }}>
{item.icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true">{item.icon}</span>}
{item.icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true"><SWViewerIcon>{item.icon}</SWViewerIcon></span>}
{!iconOnly && <span className="hm-sw-viewer-header__label">{item.label}</span>}
<span className="hm-sw-viewer-header__chevron"><ChevronDown /></span>
<span className="hm-sw-viewer-header__chevron"><SWViewerIcon><ChevronDown /></SWViewerIcon></span>
</button>
<SWViewerMenu id={menuId} entries={item.entries} open={open} onOpenChange={setOpen} anchorRef={ref} align={item.menuAlign} width={item.menuWidth} label={item.label} initialFocus={focus}
onEntrySelect={entry => onCommand({ id: entry.id, itemId: item.id, value: entry.type === "checkbox" ? !entry.checked : undefined })} />
@@ -190,7 +191,7 @@ export const SWViewerHeaderButton = forwardRef<HTMLButtonElement, SWViewerHeader
return (
<button ref={ref} type={type} className={`hm-sw-viewer-header__button${className ? ` ${className}` : ""}`} data-kind={kind} data-icon-only={iconOnly === true ? "always" : iconOnly ? "" : undefined}
data-state={pressed === undefined ? undefined : pressed ? "on" : "off"} aria-pressed={pressed} aria-label={iconOnly ? label : undefined} {...rest}>
{icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true">{icon}</span>}
{icon && <span className="hm-sw-viewer-header__icon" aria-hidden="true"><SWViewerIcon>{icon}</SWViewerIcon></span>}
{!iconOnly && <span className="hm-sw-viewer-header__label">{label}</span>}
</button>
)
@@ -272,7 +273,7 @@ export const SWViewerHeader = forwardRef<HTMLElement, SWViewerHeaderProps>(funct
aria-label={moreLabel} title="더보기" aria-haspopup="menu" aria-expanded={moreOpen}
onClick={() => { setMoreFocus("menu"); setMoreOpen(!moreOpen) }}
onKeyDown={event => { if (event.key === "ArrowDown") { event.preventDefault(); setMoreFocus("first"); setMoreOpen(true) } }}>
<span className="hm-sw-viewer-header__icon"><MoreIcon /></span>
<span className="hm-sw-viewer-header__icon"><SWViewerIcon><MoreIcon /></SWViewerIcon></span>
</button>
<SWViewerMenu entries={more} open={moreOpen} onOpenChange={setMoreOpen} anchorRef={moreRef} align="end" width={260} surface="beige" label={moreLabel} initialFocus={moreFocus}
onEntrySelect={entry => {
@@ -0,0 +1,49 @@
import { useLayoutEffect, useRef, type ReactNode } from "react"
import "../../theme/sw-viewer.css"
const SHAPES = "path, line, polyline, polygon, circle, ellipse, rect"
export interface SWViewerIconProps {
/** The program's SVG icon (any size; stroke and / or fill, currentColor recommended). */
children: ReactNode
className?: string
}
/**
* Icon with the SWHeader black outline drawn as SVG stroke (SWHeader icon assets: 1px black OUTSIDE stroke).
* A copy of the icon sits behind it: every shape of the copy is stroked black with its own stroke width plus
* 2 x `--hm-sw-viewer-icon-outline-width` (1px each side, in screen pixels at the rendered size), and filled
* shapes are filled black. The icon on top is untouched, so its color, shape, size and line width stay the same.
*/
export function SWViewerIcon({ children, className }: SWViewerIconProps) {
const outlineRef = useRef<HTMLSpanElement>(null)
useLayoutEffect(() => {
const root = outlineRef.current
if (!root) return
const outline = parseFloat(getComputedStyle(root).getPropertyValue("--hm-sw-viewer-icon-outline-width")) || 1
for (const svg of Array.from(root.querySelectorAll("svg"))) {
const view = svg.viewBox?.baseVal
const rendered = svg.getBoundingClientRect().width
// User units per screen pixel, so the outline is 1px on screen whatever the viewBox (e.g. 24 drawn at 16px).
const unit = view && view.width && rendered ? view.width / rendered : 1
for (const shape of Array.from(svg.querySelectorAll<SVGElement>(SHAPES))) {
shape.style.removeProperty("stroke-width")
const style = getComputedStyle(shape)
const strokeWidth = style.stroke !== "none" ? parseFloat(style.strokeWidth) || 0 : 0
const filled = style.fill !== "none" && Number(style.fillOpacity) !== 0
if (!strokeWidth && !filled) { shape.style.visibility = "hidden"; continue }
shape.style.stroke = "var(--hm-sw-viewer-icon-outline-color)"
shape.style.strokeWidth = `${strokeWidth + 2 * outline * unit}`
shape.style.strokeLinejoin = "round"
if (!strokeWidth) shape.style.strokeLinecap = "round"
if (filled) shape.style.fill = "var(--hm-sw-viewer-icon-outline-color)"
}
}
})
return (
<span className={`hm-sw-viewer-icon${className ? ` ${className}` : ""}`} aria-hidden="true">
<span ref={outlineRef} className="hm-sw-viewer-icon__outline">{children}</span>
<span className="hm-sw-viewer-icon__glyph">{children}</span>
</span>
)
}
@@ -1,16 +1,15 @@
import { useEffect, useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"
import { useId, useLayoutEffect, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type MouseEvent as ReactMouseEvent, type PointerEvent as ReactPointerEvent, type ReactNode } from "react"
import { SWPopup } from "../sw-popup/SWPopup"
import "../../theme/sw-viewer.css"
export type SWViewerPanelPlacement = "top-end" | "top-start" | "center"
export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>, "title"> {
/** Title (14px 700 #FFC600, ellipsis). Also the accessible name. */
/** Title (SWPopup title: 700, #FFC600, black 1px outline, ellipsis). Also the accessible name. */
title: string
/** 16px title icon supplied by the program. */
icon?: ReactNode
/** Shown when true. Unmounted otherwise (no hidden DOM). */
open: boolean
/** Close button, and Escape while focus is inside the panel. */
/** Close button, and Escape (inside the panel, or anywhere while it is the top SW popup — SWPopup behavior). */
onClose: () => void
closeLabel?: string
/** Extra title-bar buttons before collapse / close (use `SWViewerButton variant="ghost"`). */
@@ -19,7 +18,7 @@ export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>,
children?: ReactNode
/** Bottom button area (e.g. 취소 / 적용). Stays visible while the body scrolls. */
footer?: ReactNode
/** Initial size. Default 320 x 420 (reference panel). `height: "auto"` follows the content. */
/** Initial size. Default 314 (the SWPopup width) x 420. `height: "auto"` follows the content. */
width?: number
height?: number | "auto"
minWidth?: number
@@ -48,7 +47,7 @@ export interface SWViewerPanelProps extends Omit<HTMLAttributes<HTMLDivElement>,
onSizeChange?: (size: { width: number; height: number }) => void
}
let topLayer = 30
let topLayer = 40
const EDGE = 10
const INTERACTIVE = "button, input, select, textarea, a, label, [data-panel-no-drag]"
@@ -56,8 +55,8 @@ const Chevron = ({ down }: { down: boolean }) => (
<svg viewBox="0 0 16 16" width="16" height="16" aria-hidden="true"><path d={down ? "M4 6l4 4 4-4" : "M6 4l4 4-4 4"} fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round" /></svg>
)
function bounds(element: HTMLElement | null, strategy: "fixed" | "absolute") {
const parent = strategy === "absolute" ? element?.offsetParent as HTMLElement | null : null
function bounds(element: HTMLElement, strategy: "fixed" | "absolute") {
const parent = strategy === "absolute" ? element.offsetParent as HTMLElement | null : null
return parent ? { width: parent.clientWidth, height: parent.clientHeight } : { width: document.documentElement.clientWidth, height: window.innerHeight }
}
@@ -70,20 +69,21 @@ function clamp(left: number, top: number, element: HTMLElement, strategy: "fixed
}
/**
* SW viewer popup / settings window (reference DWG/DXF viewer floating panel): #E0DACE body, 2px #897445 border,
* #221D13 title bar with #FFC600 title, collapse and the curved green close (#E40000 on hover). Non-modal by
* default: drag by the title bar, corner resize, collapse, stays inside the bounds, Escape inside closes, the
* last touched panel comes to the front. The frame is fixed; `children` / `footer` are free.
* SW viewer popup / settings window. The frame is the common SW popup (`SWPopup`: title bar, title, close,
* border, radius, shadow — the same component and DOM as every SW popup); this adds the viewer window behavior:
* drag by the title bar, corner resize, collapse, placement inside the bounds, bring to front, optional modal.
* `children` / `footer` are free content.
*/
export function SWViewerPanel({
title, icon, open, onClose, closeLabel = "닫기", headerActions, children, footer,
width = 320, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed",
title, open, onClose, closeLabel = "닫기", headerActions, children, footer,
width = 314, height = 420, minWidth = 250, minHeight = 140, defaultPosition, inset, strategy = "fixed",
draggable = true, resizable = true, collapsible = true, defaultCollapsed = false, collapsed: collapsedProp, onCollapsedChange,
modal = false, autoFocus = true, onPositionChange, onSizeChange, className, style, onKeyDown, onPointerDownCapture, onFocusCapture, ...rest
modal = false, autoFocus = true, onPositionChange, onSizeChange, id: idProp, className, style, onKeyDown,
onPointerDownCapture, onFocusCapture, onPointerDown, onPointerMove, onPointerUp, onDoubleClick, ...rest
}: SWViewerPanelProps) {
const ref = useRef<HTMLDivElement>(null)
const titleId = useId()
const closeGradient = `hm-sw-viewer-close-${useId().replace(/:/g, "")}`
const generatedId = useId()
const id = idProp ?? `hm-sw-viewer-panel-${generatedId.replace(/:/g, "")}`
const element = () => document.getElementById(id) as HTMLDivElement | null
const [position, setPosition] = useState<{ left: number; top: number } | null>(null)
const [layer, setLayer] = useState(() => ++topLayer)
const [internalCollapsed, setInternalCollapsed] = useState(defaultCollapsed)
@@ -96,10 +96,10 @@ export function SWViewerPanel({
positionRef.current = position
const placement = defaultPosition ?? (modal ? "center" : "top-end")
// Initial position, measured once per opening; then kept inside the bounds when they change.
// Initial position, measured once per opening (before paint); then kept inside the bounds when they change.
useLayoutEffect(() => {
if (!open) { setPosition(null); setSize(null); return }
const panel = ref.current
const panel = element()
if (!panel) return
const box = bounds(panel, strategy)
const top = inset?.top ?? 56, side = inset?.side ?? 24
@@ -107,7 +107,7 @@ export function SWViewerPanel({
: placement === "center" ? { left: (box.width - panel.offsetWidth) / 2, top: (box.height - panel.offsetHeight) / 2 }
: { left: placement === "top-end" ? box.width - panel.offsetWidth - side : side, top }
setPosition(clamp(start.left, start.top, panel, strategy))
const keepInside = () => { const current = positionRef.current; if (current && ref.current) setPosition(clamp(current.left, current.top, ref.current, strategy)) }
const keepInside = () => { const current = positionRef.current, node = element(); if (current && node) setPosition(clamp(current.left, current.top, node, strategy)) }
window.addEventListener("resize", keepInside)
const parent = strategy === "absolute" ? panel.offsetParent : null
const observer = parent ? new ResizeObserver(keepInside) : null
@@ -115,46 +115,52 @@ export function SWViewerPanel({
return () => { window.removeEventListener("resize", keepInside); observer?.disconnect() }
}, [open])
// Focus in once positioned (a hidden panel cannot take focus), back to the opener on close.
const placed = position !== null
useEffect(() => {
if (!open || !autoFocus) return
const previous = document.activeElement as HTMLElement | null
return () => { if (previous?.isConnected) previous.focus({ preventScroll: true }) }
}, [open, autoFocus])
useEffect(() => {
if (open && autoFocus && placed && !ref.current?.contains(document.activeElement)) ref.current?.focus({ preventScroll: true })
}, [open, autoFocus, placed])
if (!open) return null
const raise = () => { if (layer !== topLayer) setLayer(++topLayer) }
const setCollapsed = (next: boolean) => { if (collapsedProp === undefined) setInternalCollapsed(next); onCollapsedChange?.(next) }
const onTitlebar = (target: EventTarget) => {
const node = target as HTMLElement
return !!node.closest(".hm-sw-property__titlebar") && !node.closest(INTERACTIVE)
}
const onTitlePointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest(INTERACTIVE) || !ref.current || !position) return
const startDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
onPointerDown?.(event)
if (!draggable || event.button !== 0 || !position || !onTitlebar(event.target)) return
drag.current = { x: event.clientX, y: event.clientY, left: position.left, top: position.top }
event.currentTarget.setPointerCapture(event.pointerId)
event.currentTarget.dataset.dragging = ""
}
const onTitlePointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const start = drag.current
if (!start || !ref.current) return
setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, ref.current, strategy))
const moveDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
onPointerMove?.(event)
const start = drag.current, panel = element()
if (!start || !panel) return
setPosition(clamp(start.left + event.clientX - start.x, start.top + event.clientY - start.y, panel, strategy))
}
const onTitlePointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
const endDrag = (event: ReactPointerEvent<HTMLDivElement>) => {
onPointerUp?.(event)
if (!drag.current) return
drag.current = null
delete event.currentTarget.dataset.dragging
if (positionRef.current) onPositionChange?.(positionRef.current)
}
const toggleByTitle = (event: ReactMouseEvent<HTMLDivElement>) => {
onDoubleClick?.(event)
// Pointer capture (drag) retargets the click to the panel root, so test what is under the pointer.
const under = document.elementFromPoint(event.clientX, event.clientY)
if (collapsible && under && onTitlebar(under)) setCollapsed(!collapsed)
}
const onGripPointerDown = (event: ReactPointerEvent<HTMLDivElement>) => {
if (event.button !== 0 || !ref.current) return
const panel = element()
if (event.button !== 0 || !panel) return
event.preventDefault()
resize.current = { x: event.clientX, y: event.clientY, width: ref.current.offsetWidth, height: ref.current.offsetHeight }
event.stopPropagation()
resize.current = { x: event.clientX, y: event.clientY, width: panel.offsetWidth, height: panel.offsetHeight }
event.currentTarget.setPointerCapture(event.pointerId)
}
const onGripPointerMove = (event: ReactPointerEvent<HTMLDivElement>) => {
const start = resize.current, panel = ref.current
event.stopPropagation()
const start = resize.current, panel = element()
if (!start || !panel || !position) return
const box = bounds(panel, strategy)
// Min size, 90% of the bounds, and never past the bounds edge (10px) from the current corner.
@@ -164,73 +170,59 @@ export function SWViewerPanel({
height: Math.round(Math.max(minHeight, Math.min(maxHeight, start.height + event.clientY - start.y))),
})
}
const onGripPointerUp = () => {
const onGripPointerUp = (event: ReactPointerEvent<HTMLDivElement>) => {
event.stopPropagation()
if (!resize.current) return
resize.current = null
if (ref.current) onSizeChange?.({ width: ref.current.offsetWidth, height: ref.current.offsetHeight })
const panel = element()
if (panel) onSizeChange?.({ width: panel.offsetWidth, height: panel.offsetHeight })
}
const onPanelKeyDown = (event: KeyboardEvent<HTMLDivElement>) => {
const trapFocus = (event: KeyboardEvent<HTMLDivElement>) => {
onKeyDown?.(event)
if (event.defaultPrevented || event.nativeEvent.isComposing) return
if (event.key === "Escape") { event.stopPropagation(); onClose(); return }
if (modal && event.key === "Tab" && ref.current) {
const focusables = Array.from(ref.current.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])'))
if (!focusables.length) { event.preventDefault(); return }
const first = focusables[0], last = focusables[focusables.length - 1]
if (event.shiftKey && (document.activeElement === first || document.activeElement === ref.current)) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
const panel = element()
if (!modal || event.key !== "Tab" || event.defaultPrevented || !panel) return
const focusables = Array.from(panel.querySelectorAll<HTMLElement>('button:not(:disabled), input:not(:disabled), select:not(:disabled), textarea:not(:disabled), a[href], [tabindex]:not([tabindex="-1"])'))
if (!focusables.length) { event.preventDefault(); return }
const first = focusables[0], last = focusables[focusables.length - 1]
if (event.shiftKey && (document.activeElement === first || document.activeElement === panel)) { event.preventDefault(); last.focus() }
else if (!event.shiftKey && document.activeElement === last) { event.preventDefault(); first.focus() }
}
const currentWidth = size?.width ?? width
const frameStyle: CSSProperties = {
position: strategy,
left: position?.left ?? 0,
top: position?.top ?? 0,
zIndex: layer,
width: size?.width ?? width,
width: currentWidth,
height: collapsed || (height === "auto" && !size) ? "auto" : size?.height ?? height,
minWidth,
minHeight: collapsed ? undefined : Math.min(minHeight, typeof height === "number" ? height : minHeight),
visibility: position ? undefined : "hidden",
maxWidth: "90vw",
maxHeight: "90vh",
["--hm-sw-viewer-panel-width" as string]: `${currentWidth}px`,
...style,
}
const panel = (
<div ref={ref} role="dialog" aria-modal={modal} aria-labelledby={titleId} tabIndex={-1}
className={`hm-sw-theme hm-sw-viewer-panel${className ? ` ${className}` : ""}`} data-collapsed={collapsed || undefined} data-modal={modal || undefined}
style={frameStyle} {...rest}
onKeyDown={onPanelKeyDown}
<SWPopup {...rest} id={id} title={title} onClose={onClose} closeLabel={closeLabel} autoFocus={autoFocus}
className={`hm-sw-theme hm-sw-viewer-panel${className ? ` ${className}` : ""}`} style={frameStyle}
aria-modal={modal}
data-collapsed={collapsed || undefined} data-modal={modal || undefined} data-draggable={draggable || undefined}
onKeyDown={trapFocus}
onPointerDownCapture={event => { raise(); onPointerDownCapture?.(event) }}
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}>
<div className="hm-sw-viewer-panel__titlebar" data-draggable={draggable || undefined}
onPointerDown={onTitlePointerDown} onPointerMove={onTitlePointerMove} onPointerUp={onTitlePointerUp} onPointerCancel={onTitlePointerUp}
onDoubleClick={event => { if (collapsible && !(event.target as HTMLElement).closest(INTERACTIVE)) setCollapsed(!collapsed) }}>
<h2 id={titleId} className="hm-sw-viewer-panel__title" title={title}>
{icon && <span className="hm-sw-viewer-panel__title-icon" aria-hidden="true">{icon}</span>}
<span className="hm-sw-viewer-panel__title-text">{title}</span>
</h2>
<div className="hm-sw-viewer-panel__actions">
{headerActions}
{collapsible && <button type="button" className="hm-sw-viewer-panel__icon-button" aria-label={collapsed ? "펼치기" : "접기"} title={collapsed ? "펼치기" : "접기"} aria-expanded={!collapsed} onClick={() => setCollapsed(!collapsed)}><Chevron down={!collapsed} /></button>}
<button type="button" className="hm-sw-viewer-panel__close" aria-label={closeLabel} title={closeLabel} onClick={onClose}>
<svg className="hm-sw-viewer-panel__close-shape" viewBox="0 0 52 34" preserveAspectRatio="none" aria-hidden="true" focusable="false">
<defs>
<radialGradient id={closeGradient} cx="55%" cy="30%" r="85%">
<stop offset="0%" stopColor="var(--hm-sw-viewer-close-glow)" />
<stop offset="100%" stopColor="var(--hm-sw-viewer-close-edge)" />
</radialGradient>
</defs>
<path d="M 0 0 C 12 0 14 34 26 34 H 52 V 0 Z" fill={`url(#${closeGradient})`} />
<path className="hm-sw-viewer-panel__close-curve" d="M 0 0 C 12 0 14 34 26 34" fill="none" vectorEffect="non-scaling-stroke" />
</svg>
<svg className="hm-sw-viewer-panel__close-icon" viewBox="0 0 24 24" width="16" height="16" aria-hidden="true"><path d="M18 6L6 18M6 6l12 12" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" /></svg>
</button>
</div>
</div>
{!collapsed && <div className="hm-sw-viewer-panel__body">{children}</div>}
{!collapsed && footer && <div className="hm-sw-viewer-panel__footer">{footer}</div>}
{resizable && !collapsed && <div className="hm-sw-viewer-panel__resize" aria-hidden="true" data-panel-no-drag=""
onPointerDown={onGripPointerDown} onPointerMove={onGripPointerMove} onPointerUp={onGripPointerUp} onPointerCancel={onGripPointerUp} />}
</div>
onFocusCapture={event => { raise(); onFocusCapture?.(event) }}
onPointerDown={startDrag} onPointerMove={moveDrag} onPointerUp={endDrag} onPointerCancel={endDrag} onDoubleClick={toggleByTitle}
titleActions={(headerActions || collapsible) && <div className="hm-sw-viewer-panel__actions">
{headerActions}
{collapsible && <button type="button" className="hm-sw-viewer-panel__icon-button" aria-label={collapsed ? "펼치기" : "접기"} title={collapsed ? "펼치기" : "접기"} aria-expanded={!collapsed} onClick={() => setCollapsed(!collapsed)}><Chevron down={!collapsed} /></button>}
</div>}
footer={<>
{footer && !collapsed && <div className="hm-sw-viewer-panel__footer">{footer}</div>}
{resizable && !collapsed && <div className="hm-sw-viewer-panel__resize" aria-hidden="true" data-panel-no-drag=""
onPointerDown={onGripPointerDown} onPointerMove={onGripPointerMove} onPointerUp={onGripPointerUp} onPointerCancel={onGripPointerUp} />}
</>}>
{collapsed ? null : children}
</SWPopup>
)
return modal ? <><div className="hm-sw-viewer-panel-backdrop" style={{ position: strategy, zIndex: layer }} aria-hidden="true" />{panel}</> : panel
}
@@ -1,5 +1,6 @@
export { SWViewerHeader, SWViewerHeaderButton, type SWViewerHeaderProps, type SWViewerHeaderButtonProps } from "./SWViewerHeader"
export { SWViewerMenu, type SWViewerMenuProps } from "./SWViewerMenu"
export { SWViewerIcon, type SWViewerIconProps } from "./SWViewerIcon"
export { SWViewerPanel, type SWViewerPanelProps, type SWViewerPanelPlacement } from "./SWViewerPanel"
export {
SWViewerSection, SWViewerField, SWViewerHint, SWViewerSwitch, SWViewerSlider, SWViewerInput, SWViewerButton, SWViewerBadge, SWViewerStatus,
+52 -114
View File
@@ -29,15 +29,9 @@
--hm-sw-viewer-switch-off: #0a221d;
--hm-sw-viewer-switch-bevel: inset 0 1px 0 rgba(255, 255, 255, 0.1), inset 0 -1px 1px rgba(0, 0, 0, 0.25);
/* Icon stroke: the SWHeader icon assets (print / K guide / layer icons) are a fill + a 1px black OUTSIDE stroke
(Figma mask "outside" path) + a 0-offset glow (stdDeviation 1.615, #061A14). Program icons are free SVGs, so the
same stroke is a drop-shadow chain that follows the icon's own contour: the four 1px shadows dilate the shape
by 1px (each one also shadows the previous ones, so corners close), the last one is the glow. */
(Figma mask "outside" path). SWViewerIcon draws it as SVG stroke on a copy behind the icon (see SWViewerIcon). */
--hm-sw-viewer-icon-outline-color: var(--hm-sw-header-text-outline-color);
--hm-sw-viewer-icon-glow: #061a14;
--hm-sw-viewer-icon-outline:
drop-shadow(1px 0 0 var(--hm-sw-viewer-icon-outline-color)) drop-shadow(-1px 0 0 var(--hm-sw-viewer-icon-outline-color))
drop-shadow(0 1px 0 var(--hm-sw-viewer-icon-outline-color)) drop-shadow(0 -1px 0 var(--hm-sw-viewer-icon-outline-color))
drop-shadow(0 0 1.615px var(--hm-sw-viewer-icon-glow));
--hm-sw-viewer-icon-outline-width: 1px;
/* Dropdown (light) and More (beige) */
--hm-sw-viewer-menu-background: #fff;
@@ -51,31 +45,20 @@
--hm-sw-viewer-more-background: #f5f0e5;
--hm-sw-viewer-more-border-color: #897445;
/* Panel */
--hm-sw-viewer-panel-background: #e0dace;
--hm-sw-viewer-panel-field-background: #f5f0e5;
--hm-sw-viewer-panel-border: #897445;
--hm-sw-viewer-panel-inner-border: #9a9284;
--hm-sw-viewer-panel-title-background: #221d13;
--hm-sw-viewer-panel-title-highlight: #a98d53;
--hm-sw-viewer-panel-title-color: #ffc600;
--hm-sw-viewer-panel-title-button-hover: #604f32;
--hm-sw-viewer-panel-text: #1d1810;
/* Panel: the frame is SWPopup (title bar, title, close, border, radius, shadow). Content tokens map to the
SW property window tokens so viewer content sits on the same surface as every SW popup. */
--hm-sw-viewer-panel-background: var(--hm-sw-property-surface);
--hm-sw-viewer-panel-field-background: var(--hm-sw-property-content);
--hm-sw-viewer-panel-inner-border: var(--hm-sw-property-inner-border);
--hm-sw-viewer-panel-text: var(--hm-sw-property-label);
--hm-sw-viewer-panel-heading: #3e3523;
--hm-sw-viewer-panel-label: #6e6759;
--hm-sw-viewer-panel-spacing: 12px;
--hm-sw-viewer-panel-title-height: 34px;
--hm-sw-viewer-panel-shadow: 0 0 12px rgba(0, 0, 0, 0.2);
--hm-sw-viewer-panel-primary: #123328;
--hm-sw-viewer-panel-switch-off: #9a9284;
--hm-sw-viewer-panel-switch-off: var(--hm-sw-property-inner-border);
--hm-sw-viewer-panel-switch-on: #266651;
--hm-sw-viewer-panel-badge-background: #f4f1ea;
--hm-sw-viewer-panel-badge-background: var(--hm-sw-property-table);
--hm-sw-viewer-panel-overlay: rgba(0, 0, 0, 0.1);
/* Close: green curve, #E40000 on hover (user-supplied reference value). */
--hm-sw-viewer-close-glow: #123328;
--hm-sw-viewer-close-edge: #020a07;
--hm-sw-viewer-close-hover: #e40000;
--hm-sw-viewer-close-icon: #999;
--hm-sw-viewer-panel-spacing: 12px;
}
.hm-sw-viewer-sr-only { position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; }
@@ -194,6 +177,13 @@ button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-header__icon, .hm-sw-viewer-header__chevron { display: inline-flex; width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
.hm-sw-viewer-header__icon > svg, .hm-sw-viewer-header__chevron > svg { width: 100%; height: 100%; }
/* SWViewerIcon: the outline copy (black SVG stroke) right behind the untouched icon. */
.hm-sw-viewer-icon { position: relative; display: inline-flex; width: 100%; height: 100%; }
.hm-sw-viewer-icon__outline, .hm-sw-viewer-icon__glyph { display: inline-flex; width: 100%; height: 100%; }
.hm-sw-viewer-icon__outline { position: absolute; inset: 0; overflow: visible; }
.hm-sw-viewer-icon__glyph { position: relative; }
.hm-sw-viewer-icon svg { width: 100%; height: 100%; overflow: visible; }
/* Tabs (Model / Layout): 40px triggers with an 11px count; the active one turns #FFC600. */
.hm-sw-viewer-header__tabs { display: flex; align-items: center; gap: var(--hm-sw-viewer-gap); }
.hm-sw-viewer-header .hm-sw-viewer-header__tab {
@@ -233,7 +223,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
/* Text and icon stroke (2026-10-19 request): the SWHeader black outline on the viewer header, same tokens and
method — an outside outline of the glyph / icon shape, never a box border.
Text: 14px = --hm-sw-header-text-outline (1.3px black), 11px captions / counts / status = --hm-sw-header-label-outline
(1px black), as SWHeader's 12px labels. Icons: --hm-sw-viewer-icon-outline (see the token). Fill color, size,
(1px black), as SWHeader's 12px labels. Icons: SWViewerIcon (SVG stroke, see the token). Fill color, size,
weight and spacing are unchanged; hover / active / open change only the fill, disabled fades the whole button
(opacity) like SWHeader. Dropdown faces (light / beige) and panels get no outline, like SWHeader's More menu. */
.hm-sw-viewer-header :is(
@@ -244,9 +234,6 @@ button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-header :is(.hm-sw-viewer-header__count, .hm-sw-viewer-header__switch-label, .hm-sw-viewer-status__label) {
text-shadow: var(--hm-sw-header-label-outline);
}
.hm-sw-viewer-header :is(.hm-sw-viewer-header__icon, .hm-sw-viewer-header__chevron, .hm-sw-viewer-status > svg) {
filter: var(--hm-sw-viewer-icon-outline);
}
/* Clipped (ellipsis) file name: the outline may paint 2px outside the box instead of being cut. */
.hm-sw-viewer-header .hm-sw-viewer-header__document-name {
overflow: clip;
@@ -264,7 +251,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
font-weight: 400;
white-space: nowrap;
}
.hm-sw-viewer-status > svg { width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
.hm-sw-viewer-status__icon { display: inline-flex; width: var(--hm-sw-viewer-icon-size); height: var(--hm-sw-viewer-icon-size); }
.hm-sw-viewer-status[data-state="error"] { color: var(--hm-sw-viewer-accent); }
.hm-sw-viewer-status__spin { animation: hm-sw-viewer-spin 1s linear infinite; }
@keyframes hm-sw-viewer-spin { to { transform: rotate(360deg); } }
@@ -356,105 +343,52 @@ button.hm-sw-viewer-header__brand { text-align: left; }
.hm-sw-viewer-menu__separator { flex: 0 0 1px; height: 1px; margin: 4px -4px; background: var(--hm-sw-viewer-menu-border-color); }
.hm-sw-viewer-menu[data-surface="beige"] .hm-sw-viewer-menu__separator { background: var(--hm-sw-viewer-panel-inner-border); }
/* ---------- Panel ---------- */
.hm-sw-viewer-panel {
display: flex;
flex-direction: column;
max-width: 90vw;
max-height: 90vh;
overflow: hidden;
border: 2px solid var(--hm-sw-viewer-panel-border);
border-radius: var(--hm-sw-viewer-radius);
background: var(--hm-sw-viewer-panel-background);
box-shadow: var(--hm-sw-viewer-panel-shadow), inset 0 0 0 1px var(--hm-sw-viewer-panel-field-background);
color: var(--hm-sw-viewer-panel-text);
font-size: 14px;
font-weight: 400;
line-height: 20px;
outline: none;
}
/* ---------- Panel (SWPopup frame + viewer window behavior) ---------- */
.hm-sw-viewer-panel-backdrop { inset: 0; background: var(--hm-sw-viewer-panel-overlay); }
.hm-sw-viewer-panel__titlebar {
display: flex;
flex-shrink: 0;
align-items: center;
gap: 4px;
min-height: var(--hm-sw-viewer-panel-title-height);
padding-left: var(--hm-sw-viewer-panel-spacing);
border-bottom: 1px solid var(--hm-sw-viewer-panel-border);
background: var(--hm-sw-viewer-panel-title-background);
box-shadow: inset 0 1px 0 var(--hm-sw-viewer-panel-title-highlight);
user-select: none;
.hm-sw-viewer-panel[data-collapsed] .hm-sw-property__body { display: none; }
.hm-sw-viewer-panel[data-draggable] .hm-sw-property__titlebar { cursor: grab; touch-action: none; user-select: none; }
.hm-sw-viewer-panel[data-dragging] .hm-sw-property__titlebar { cursor: grabbing; }
/* Any width: the Figma title art keeps its 308px curve at the close button (right-aligned; at 314px this is exactly
the SWPopup position), and a stretched copy of the same art fills the flat part on the left of wider panels.
The right one hides its own left 20px (left edge + corner) and the left copy ends exactly there: no seam, no
doubled shadow. */
.hm-sw-viewer-panel .hm-sw-property__title-art { left: auto; right: 4px; clip-path: inset(-4px -4px -4px 20px); }
.hm-sw-viewer-panel .hm-sw-property__titlebar::before {
content: "";
position: absolute;
z-index: 1;
left: 0;
top: -2px;
width: max(0px, calc(100% - 292px));
height: 38px;
background: url("../assets/property-title-single.svg") left top / calc(100% + 288px) 38px no-repeat;
transform: scaleY(-1);
pointer-events: none;
}
.hm-sw-viewer-panel__titlebar[data-draggable] { cursor: grab; touch-action: none; }
.hm-sw-viewer-panel__titlebar[data-dragging] { cursor: grabbing; }
.hm-sw-viewer-panel__title {
display: flex;
align-items: center;
gap: 8px;
flex: 1;
min-width: 0;
margin: 0;
color: var(--hm-sw-viewer-panel-title-color);
font-size: 14px;
font-weight: 700;
line-height: 1.4;
}
.hm-sw-viewer-panel__title-icon { display: inline-flex; flex: 0 0 16px; width: 16px; height: 16px; }
.hm-sw-viewer-panel__title-icon > svg { width: 100%; height: 100%; }
.hm-sw-viewer-panel__title-text { min-width: 0; overflow: hidden; white-space: nowrap; text-overflow: ellipsis; }
.hm-sw-viewer-panel__actions { display: flex; flex-shrink: 0; align-items: center; }
.hm-sw-viewer-panel__actions { position: relative; z-index: 2; display: flex; flex-shrink: 0; align-items: center; height: 30px; }
/* Title-bar buttons: SW text-only look (no face), #FFC600 on hover like the SW title-bar controls. */
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) {
display: inline-flex;
align-items: center;
justify-content: center;
width: var(--hm-sw-viewer-panel-title-height);
height: var(--hm-sw-viewer-panel-title-height);
width: 26px;
height: 26px;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
color: var(--hm-sw-viewer-muted);
}
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):hover:not(:disabled) { background: var(--hm-sw-viewer-panel-title-button-hover); color: var(--hm-sw-viewer-panel-title-color); }
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"], .hm-sw-viewer-panel__close):focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-title-color); outline-offset: -3px; }
.hm-sw-viewer-panel .hm-sw-viewer-panel__close {
position: relative;
isolation: isolate;
display: inline-flex;
align-items: center;
flex-shrink: 0;
width: calc(var(--hm-sw-viewer-panel-title-height) * 1.53);
height: var(--hm-sw-viewer-panel-title-height);
padding: 0 0 0 calc(var(--hm-sw-viewer-panel-title-height) * 0.7);
overflow: hidden;
color: var(--hm-sw-viewer-close-icon);
}
.hm-sw-viewer-panel__close-shape { position: absolute; inset: 0; width: 100%; height: 100%; pointer-events: none; }
.hm-sw-viewer-panel__close-curve { stroke: var(--hm-sw-viewer-panel-border); stroke-width: 1; }
.hm-sw-viewer-panel__close-icon { position: relative; width: 16px; height: 16px; pointer-events: none; }
.hm-sw-viewer-panel .hm-sw-viewer-panel__close:focus-visible { --hm-sw-viewer-close-glow: #266651; color: #fff; }
.hm-sw-viewer-panel .hm-sw-viewer-panel__close:is(:hover, :active) { --hm-sw-viewer-close-glow: var(--hm-sw-viewer-close-hover); --hm-sw-viewer-close-edge: var(--hm-sw-viewer-close-hover); color: #fff; }
.hm-sw-viewer-panel__body {
display: flex;
flex-direction: column;
gap: 20px;
flex: 1;
min-height: 0;
overflow: auto;
overscroll-behavior: contain;
padding: var(--hm-sw-viewer-panel-spacing);
scrollbar-width: thin;
scrollbar-color: var(--hm-sw-viewer-panel-inner-border) var(--hm-sw-viewer-panel-background);
}
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):hover:not(:disabled) { background: transparent; color: var(--hm-sw-header-selected-color); }
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]):focus-visible { outline: 2px solid var(--hm-sw-header-selected-color); outline-offset: -2px; }
.hm-sw-viewer-panel :is(.hm-sw-viewer-panel__icon-button, .hm-sw-viewer-button[data-variant="ghost"]) svg { width: 16px; height: 16px; }
.hm-sw-viewer-panel__footer {
display: flex;
flex-shrink: 0;
justify-content: flex-end;
gap: 8px;
padding: 8px var(--hm-sw-viewer-panel-spacing);
padding: 7px;
border-top: 1px solid var(--hm-sw-viewer-panel-inner-border);
background: var(--hm-sw-viewer-panel-background);
}
/* Panel content */
@@ -525,6 +459,10 @@ button.hm-sw-viewer-header__brand { text-align: left; }
}
.hm-sw-theme .hm-sw-viewer-button[data-variant="outline"]:hover:not(:disabled) { background: var(--hm-sw-viewer-panel-field-background); }
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"] { background: var(--hm-sw-viewer-panel-primary); color: #fff; }
/* tone="green" (e.g. 전체 켜기 / 전체 끄기): default unchanged. Hover = the primary (확인) button hover, the same token;
pressed = the primary button face. White text as on the primary button. Back to default / hover after the press. */
.hm-sw-theme .hm-sw-viewer-button[data-tone="green"]:hover:not(:disabled) { border-color: var(--hm-sw-viewer-hover); background: var(--hm-sw-viewer-hover); color: #fff; }
.hm-sw-theme .hm-sw-viewer-button[data-tone="green"]:active:not(:disabled) { border-color: var(--hm-sw-viewer-panel-primary); background: var(--hm-sw-viewer-panel-primary); color: #fff; }
.hm-sw-theme .hm-sw-viewer-button[data-variant="primary"]:hover:not(:disabled) { background: var(--hm-sw-viewer-hover); border-color: var(--hm-sw-viewer-hover); }
.hm-sw-theme .hm-sw-viewer-button:focus-visible { outline: 2px solid var(--hm-sw-viewer-panel-switch-on); outline-offset: 1px; }
.hm-sw-theme .hm-sw-viewer-button:disabled { opacity: 0.5; cursor: not-allowed; }