fix(sw): 2026-10-21 SW design review and responsive header without More from 760px

User-approved design review (2026-10-21):
- SW Header: from 760px up nothing moves into More; the five menus stay with their
  titles (titles and values ellipsize), file name shrinks to 96px. Open next to the
  logo no longer blocks field shrinking above 760px (760/768 overflow fixed, also for
  configured `controls` headers). Dropdown yellow ring inside the button, black seam.
- Property Bar values as read-only red text; property popup "=" labels, read-only
  black values, per-entry hover with pointer.
- Home Menu recent files (3 rows, path + name caption, empty preview), Footer + slot
  guide off by default, Viewer file list without icons, Storybook widths 1920-760.
- Tests: old More expectations (integrated-improvement, keyboard-dismiss,
  footer-popup-viewer-refinement, storybook-cleanup-stage1) moved to the new rule;
  functional checks kept.
This commit is contained in:
shkim4 committed 2026-10-21 17:29:25 +09:00
1 parent a25d2b31c5
commit 2fc1b3e108
37 files changed
+447 -211

No files matched your search

@@ -39,7 +39,10 @@ export interface SWFooterProps extends Omit<HTMLAttributes<HTMLElement>, "childr
slotCount?: number
/** An empty + slot was clicked (register a command). `slotIndex` is 0-based in the row. Omitted: the empty slots are inert. */
onSlotAdd?: (slotIndex: number, categoryId: string) => void
/** Guide over a hovered / focused empty slot. Default: the Figma guide (`명령어 등록` / `빠른사용`). `false`: none. */
/**
* Guide over a hovered / focused empty slot (opt-in). Default `false` (2026-10-21: no preview over the + slot). `{}`:
* the Figma guide (`명령어 등록` / `빠른사용`), or pass its title / caption / media.
*/
slotGuide?: SWFooterGuideContent | false
/** Shows the + after the categories. */
onCategoryAdd?: () => void
@@ -76,7 +79,7 @@ const SLOT = 68
*/
export const SWFooter = forwardRef<HTMLElement, SWFooterProps>(function SWFooter({
categories, categoryId, defaultCategoryId, onCategoryChange, activeToolId = null, onToolSelect,
slotCount, onSlotAdd, slotGuide = {}, onCategoryAdd,
slotCount, onSlotAdd, slotGuide = false, onCategoryAdd,
auxItems, onAuxPressedChange, onAuxItemClick, auxAll = {}, commandAll = {},
command, status, layoutTabs, trailing, label = "명령 도구", className, ...rest
}, ref) {
@@ -153,8 +153,8 @@ export const SWFooterGuide = forwardRef<HTMLDivElement, SWFooterGuideProps>(func
export interface SWFooterAddSlotProps extends Omit<ButtonHTMLAttributes<HTMLButtonElement>, "children"> {
/**
* Guide shown above the slot while it is hovered or focused. Default: the Figma guide (`명령어 등록` / `빠른사용`,
* empty preview). `false`: no guide.
* Guide shown above the slot while it is hovered or focused (opt-in). Default `false` (2026-10-21: no preview over
* the + slot). `{}`: the Figma guide (`명령어 등록` / `빠른사용`, empty preview), or pass its title / caption / media.
*/
guide?: SWFooterGuideContent | false
/** Shows the guide (controlled). Default: while hovered or keyboard focused. */
@@ -165,7 +165,7 @@ export interface SWFooterAddSlotProps extends Omit<ButtonHTMLAttributes<HTMLButt
/** 68 × 46 empty command slot (Figma btn_add_menu 26:9971): the + well; hover draws the orange dashed ring. */
export const SWFooterAddSlot = forwardRef<HTMLButtonElement, SWFooterAddSlotProps>(function SWFooterAddSlot(
{ guide = {}, guideOpen, onGuideOpenChange, className, type = "button", onMouseEnter, onMouseLeave, onFocus, onBlur, ...rest }, ref,
{ guide = false, guideOpen, onGuideOpenChange, className, type = "button", onMouseEnter, onMouseLeave, onFocus, onBlur, ...rest }, ref,
) {
const [hovered, setOpen] = useState(false)
const open = guideOpen ?? hovered
@@ -41,6 +41,8 @@ const defaultControls: SWHeaderControlConfig[] = (["document", "drawingMode", ..
const frameControls: readonly SWHeaderFrameControlId[] = ["home", "windowMove", "minimize", "close"]
const subControls: readonly SWHeaderSubControlId[] = ["documentNew", "documentOpen", "layerFreeze", "layerLock"]
const isMenu = (id: string): id is SWHeaderMenuId => id in menuSlot
/** Header width from which nothing moves into More (2026-10-21): the header compresses instead. Below it, as before. */
const MORE_BELOW = 760
/**
* Order in which visible parts move into More when the measured object area has no room left (on top of the
@@ -318,7 +320,10 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
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
const breakpointOverflowAt = (id: SWHeaderControlId, width: number) => id === "color" ? width < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? width < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? width < 1080 : ["print", "popupGuide"].includes(id) ? width < 640 : id === "drawingMode" ? width < 1180 : false
// 2026-10-21 (user decision): no responsive More from 760px up. Nothing moves out of the header there; narrower headers
// hide the field titles, then shrink the file name, then the field values (ellipsis, sw.css "Responsive"). Below 760px
// (phones, e.g. 390) the former breakpoints and More are unchanged.
const breakpointOverflowAt = (id: SWHeaderControlId, width: number) => width < MORE_BELOW && (id === "color" ? width < 900 : ["lineStyle", "textStyle", "dimensionStyle"].includes(id) ? width < 1800 : ["propertyBar", "propertyPanel", "details"].includes(id) ? width < 1080 : ["print", "popupGuide"].includes(id) ? width < 640 : id === "drawingMode" ? width < 1180 : false)
const breakpointOverflow = (id: SWHeaderControlId) => breakpointOverflowAt(id, headerWidth)
// Measured More: the parts that can move (the default layout moves the property block as one part; print / K sit in
// the window area there and keep the breakpoint). Keys of the moved parts are kept in move order.
@@ -373,6 +378,13 @@ export const SWHeader = forwardRef<HTMLElement, SWHeaderProps>(function SWHeader
collapseRef.current = null
if (openCollapsed) return setOpenCollapsed(false)
}
// No responsive More from 760px up (2026-10-21): the header compresses instead. Below it the measured More runs as
// before; parts it moved leave silently once the header is that wide again.
if (headerWidth >= MORE_BELOW) {
if (squeezed.length) { squeezeRef.current.clear(); return setSqueezed([]) }
if (openCollapsed) { collapseRef.current = null; return setOpenCollapsed(false) }
return
}
if (widthRef.current.current !== headerWidth) widthRef.current = { previous: widthRef.current.current, current: headerWidth }
const { previous } = widthRef.current
if (releasedRef.current.width !== headerWidth) releasedRef.current = { width: headerWidth, keys: new Set() }
@@ -24,13 +24,15 @@ function BarField({ property, onPropertyChange, readOnly, disabled }: Pick<SWHea
return (
<div className="hm-sw-property-bar__field" data-property-id={property.id}>
<label htmlFor={id}>{property.name}</label>
<SWHeaderInput id={id} value={property.mixed ? "다양함" : property.value}
{/* 2026-10-21: a value that cannot be edited here (read-only, computed, disabled) is plain text: no input, no
focus stop. It still follows the host value. */}
{!editable ? <output id={id} className="hm-sw-property-bar__value" data-disabled={disabled || property.disabled ? "true" : undefined}>{property.mixed ? "다양함" : property.value}</output> : <SWHeaderInput id={id} value={property.mixed ? "다양함" : property.value}
valueType={property.type === "number" || property.type === "slider" ? "number" : "text"}
disabled={disabled || property.disabled} readOnly={!editable} parse={parse} commitOnUnmount={false}
onCommit={editable && (onPropertyChange || property.onCommit) ? next => {
property.onCommit?.(next, property)
onPropertyChange?.(property, next)
} : undefined} />
} : undefined} />}
{property.unit && <span>{property.unit}</span>}
</div>
)
@@ -116,8 +116,6 @@ export function SWHeaderPropertyPickerField({ picker, property, id, editable, me
if (picker === "lineStyle") {
const lineTypes = propertyLineTypes(property, menus)
const current = mixed ? undefined : byValue(lineTypes, value)
const weights = menus?.lineWeights
const currentWeight = weights?.find(item => item.id === menus?.activeLineWeightId)
const text = mixed ? "다양함" : current?.name ?? property.options?.find(option => option.id === value)?.name ?? value
return (
<div {...shared}>
@@ -128,17 +126,13 @@ export function SWHeaderPropertyPickerField({ picker, property, id, editable, me
</span>
{property.linePreview !== false && <SWHeaderLinePreview pattern={current?.pattern ?? property.linePattern} />}
</button>
{open && drop("", "선스타일", (
{/* 2026-10-21 design review: the property window opens the line type list only (선꼴: preview + name); weight
and scale are their own rows. The SWHeader line style field keeps its 3 columns. */}
{open && drop("", property.name, (
<SWHeaderLineStyleColumns
lineTypes={lineTypes}
lineWeights={weights}
currentType={current}
currentWeight={currentWeight}
scale={menus?.lineScale}
onLineTypeSelect={item => { commit(item.id); close(true) }}
onLineWeightSelect={menus?.onLineWeightSelect}
onLineWeightValueChange={menus?.onLineWeightValueChange}
onScaleChange={menus?.onLineScaleChange}
onDetail={menus?.onLineStyleDetail && (() => { menus.onLineStyleDetail?.(); close(true) })}
/>
))}
@@ -217,40 +217,47 @@ export function PropertyEditor({ property, onChange, menus }: { property: SWHead
if (event.key === "Enter") { event.preventDefault(); commit(draft) }
if (event.key === "Escape" && draft !== value) { event.stopPropagation(); cancelDraft() }
}
// Figma 26:12649: a number without unit / annotation (선가중치) and the slider value are the 41px hug field;
// every other value is the 200px box (26:12314 / 26:12590 / 26:12583 / 26:12574). Figma has an editing On
// (#0F0D0A) only for numbers; text and computed values keep Default / Hover (no invented On).
const compact = type === "slider" || (type === "number" && !unit && !property.annotation)
const field = compact ? undefined : "box"
// Every value is the 200px box (26:12314 / 26:12590 / 26:12583 / 26:12574). 2026-10-21 design review: a bare
// number (선 가중치) is no longer the 41px hug field; its editing box fills the value area like 선 축척. The slider
// value is its own box that fills the room before the gauge (투명도). Figma has an editing On (#0F0D0A) only for
// numbers; text and computed values keep Default / Hover (no invented On).
const field = type === "slider" ? undefined : "box"
const focusInput = (event: React.MouseEvent<HTMLElement>) => {
// A click on the unit / empty part of the box edits the value (the box is the Figma field).
if (!editable || event.target === inputRef.current || type === "select") return
event.preventDefault()
inputRef.current?.focus()
}
const input = (
<SWHeaderInput
ref={inputRef}
id={inputId} aria-label={property.name} value={mixed ? "다양함" : value}
valueType={type === "number" || type === "slider" ? "number" : "text"}
disabled={disabled} readOnly={!editable} parse={parse} commitOnUnmount={false}
onCommit={editable ? commit : undefined}
/>
)
return (
<div className="hm-sw-property__row" data-property-id={property.id} data-type={type} data-disabled={disabled || undefined}>
<label htmlFor={inputId} title={property.name}>{property.name}</label>
{picker ? <SWHeaderPropertyPickerField picker={picker} property={property} id={inputId} editable={editable} menus={menus} commit={commit} parse={parse} /> : (
<div className="hm-sw-property__value" data-field={field} data-on={field && editable && type === "number" ? "edit" : undefined}
data-readonly={editable ? undefined : "true"}
onMouseDown={event => {
// A click on the unit / empty part of the box edits the value (the box is the Figma field).
if (!field || !editable || event.target === inputRef.current || type === "select") return
event.preventDefault()
inputRef.current?.focus()
}}>
onMouseDown={field ? focusInput : undefined}>
{type === "select" ? (
<select id={inputId} aria-label={property.name} value={mixed && draft === value ? "__mixed" : draft} disabled={!editable} onChange={event => setDraft(event.target.value)} onBlur={() => commit(draft)} onKeyDown={nativeKeys}>
{mixed && <option value="__mixed" disabled>다양함</option>}
{!mixed && !property.options?.some(option => option.id === value) && <option value={value}>{value}</option>}
{property.options?.map(option => <option key={option.id} value={option.id} disabled={option.disabled}>{option.name}</option>)}
</select>
) : (
<SWHeaderInput
ref={inputRef}
id={inputId} aria-label={property.name} value={mixed ? "다양함" : value}
className={compact ? "hm-sw-property__compact" : undefined}
valueType={type === "number" || type === "slider" ? "number" : "text"}
disabled={disabled} readOnly={!editable} parse={parse} commitOnUnmount={false}
onCommit={editable ? commit : undefined}
/>
)}
{property.annotation && <span className="hm-sw-property__unit">{property.annotation}</span>}
) : type === "slider" ? (
// Slider value box: the value and its annotation ((ByL)) are one editing field; the gauge and unit follow.
<span className="hm-sw-property__slider-value" data-on={editable ? "edit" : undefined} data-readonly={editable ? undefined : "true"} onMouseDown={focusInput}>
{input}
{property.annotation && <span className="hm-sw-property__unit">{property.annotation}</span>}
</span>
) : input}
{type !== "slider" && property.annotation && <span className="hm-sw-property__unit">{property.annotation}</span>}
{unit && type !== "slider" && <span className="hm-sw-property__unit">{unit}</span>}
{type === "color" && <span className="hm-sw-property__swatch-area" aria-hidden="true"><span className="hm-sw-property__swatch"><i style={{ backgroundColor: property.swatch ?? parseSWHeaderColor(value)?.hex ?? "transparent" }} /></span></span>}
{property.linePreview && <SWHeaderLinePreview pattern={property.linePattern} />}
@@ -4,6 +4,17 @@ import { useSWHomeMenuClose } from "./SWHomeMenu"
import type { SWFileEntry } from "./types"
type PreviewSide = "right" | "left" | "below" | "above"
const MAX_RECENT_FILES = 3
/** Preview caption (2026-10-21): the folder `path` + the file name; a path that already ends with the name is kept as is. */
function previewCaption(file: SWFileEntry) {
if (file.previewTitle) return file.previewTitle
const path = file.path?.replace(/[\\/]+$/, "")
if (!path) return file.name
const rest = path.slice(0, path.length - file.name.length)
if (path.endsWith(file.name) && (rest === "" || /[\\/]$/.test(rest))) return path
return `${path}${path.includes("/") && !path.includes("\\") ? "/" : "\\"}${file.name}`
}
export interface SWRecentFilesProps extends Omit<HTMLAttributes<HTMLDivElement>, "children"> {
/** Host-owned list (most recent first). Nothing is read from disk here. */
@@ -24,7 +35,8 @@ export interface SWRecentFilesProps extends Omit<HTMLAttributes<HTMLDivElement>,
/**
* 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.
* capped (`--hm-sw-recent-files-max-height`, Figma 750 = 15 rows); longer lists scroll inside. Only the first 3
* files are shown (2026-10-21).
*/
export function SWRecentFiles({
files, onFileOpen, onFileHover, renderPreview, closeOnOpen = true, emptyText = "최근 파일이 없습니다",
@@ -35,8 +47,12 @@ export function SWRecentFiles({
const listRef = useRef<HTMLDivElement>(null)
const previewRef = useRef<HTMLDivElement>(null)
const [hovered, setHovered] = useState<{ id: string; top: number; rowHeight: number; side: PreviewSide } | null>(null)
const current = hovered ? files.find(file => file.id === hovered.id) : undefined
const preview = current ? current.preview ?? renderPreview?.(current) : undefined
// 2026-10-21 (user decision): only the 3 most recent files are shown; `files` itself is not trimmed.
const shown = files.slice(0, MAX_RECENT_FILES)
const current = hovered ? shown.find(file => file.id === hovered.id) : undefined
// 2026-10-21 (user decision): the 2-depth preview keeps its frame, size and position on hover, but its body stays
// empty — no image / thumbnail is rendered. `preview` / `renderPreview` stay accepted (public types unchanged).
void renderPreview
const show = (file: SWFileEntry | null, row?: HTMLElement) => {
const list = listRef.current
@@ -84,7 +100,7 @@ export function SWRecentFiles({
<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 => (
{shown.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}
@@ -100,12 +116,18 @@ export function SWRecentFiles({
</div>
))}
</div>
{current && preview != null && (
{current && (
<div ref={previewRef} className="hm-sw-recent-files__preview" data-side={hovered?.side} aria-hidden="true"
style={{ top: hovered?.side === "below" ? hovered.top - 1 + hovered.rowHeight
: hovered?.side === "above" ? hovered.top - 1 : hovered?.top }}>
<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>
<span className="hm-sw-recent-files__preview-title"><span dir="ltr">{previewCaption(current)}</span></span>
<div className="hm-sw-recent-files__preview-body">
{/* No preview (2026-10-21 design): the red prohibition sign centered on the preview panel. */}
<svg className="hm-sw-recent-files__preview-none" viewBox="0 0 100 100" aria-hidden="true">
<circle cx="50" cy="50" r="44.5" />
<path d="M18.5 18.5 81.5 81.5" />
</svg>
</div>
</div>
)}
</div>
@@ -1,10 +1,13 @@
<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">
<!-- 2026-10-21: Figma 26:10591 tit_* draws the badge narrow on top and flaring out onto the tile group below
(the earlier export was upside down). Shape and gradients are mirrored vertically (y -> 31 - y); the
-2 r4 shadow stays upward like the Figma effect. -->
<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)"/>
<path d="M27 25H4C9.02879 24.13139 12.6686 19.3315 16.1248 14.774C19.5413 10.2686 22.7783 6 27 6H28H104H105C109.222 6 112.459 10.2686 115.875 14.774C119.331 19.3315 122.971 24.13139 128 25H105H104H28H27Z" 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">
@@ -17,15 +20,15 @@
<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">
<linearGradient id="paint0_linear_0_457" x1="66.3862" y1="25" x2="66.3862" y2="6" 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">
<linearGradient id="paint1_linear_0_457" x1="66.3862" y1="25" x2="66.3862" y2="3" 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"/>
<path id="stroke0_0_457" d="M4 25H27H28H104H105H128C122.971 24.13139 119.331 19.3315 115.875 14.774C112.459 10.2686 109.222 6 105 6H104H28H27C22.7783 6 19.5413 10.2686 16.1248 14.774C12.6686 19.3315 9.02879 24.13139 4 25Z" fill-rule="evenodd" clip-rule="evenodd"/>
</defs>
</svg>

Before

Width:  |  Height:  |  Size: 2.1 KiB

After

Width:  |  Height:  |  Size: 2.3 KiB

@@ -109,7 +109,7 @@ export interface SWFileEntry {
badge?: ReactNode
/** 2-depth preview shown beside the hovered / focused row (Figma 파일출력 2뎁스 662:67471). */
preview?: ReactNode
/** Preview caption; defaults to `path`. */
/** Preview caption; defaults to `path` + `name` (2026-10-21; a `path` already ending with the name is not doubled). */
previewTitle?: string
disabled?: boolean
title?: string