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
+21 -7
View File
@@ -53,6 +53,9 @@
--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;
/* No preview (2026-10-21 design): the panel brown behind a red prohibition sign. */
--hm-sw-recent-files-preview-body-background: var(--hm-sw-recent-files-preview-background);
--hm-sw-recent-files-preview-none-color: #ff0000;
position: relative;
isolation: isolate;
@@ -241,7 +244,7 @@
padding: 1px 23px 0;
color: var(--hm-sw-home-menu-title-color);
font-size: var(--hm-sw-header-label-size);
font-weight: 400;
font-weight: 700; /* 2026-10-21 user request: group title Bold (was Figma 400). */
line-height: 14px;
letter-spacing: 0.02em;
white-space: nowrap;
@@ -319,10 +322,11 @@
pointer-events: none;
}
/* 18 x 18 icon slot at (31, 6); larger art is centered on it and may overflow (Figma assets carry shadows). */
/* 18 x 18 icon slot at (31, 4); larger art is centered on it and may overflow (Figma assets carry shadows).
2026-10-21 user decision: top 4px (was Figma 6px) so the icon no longer touches the bold label below it. */
.hm-sw-home-menu__icon {
position: absolute;
top: 6px;
top: 4px;
left: 31px;
display: grid;
place-items: center;
@@ -406,15 +410,17 @@
.hm-sw-recent-files__name,
.hm-sw-recent-files__path {
display: block;
overflow: hidden;
/* clip (not hidden): overflow-clip-margin only applies to clip, so the outline of the first glyph is not cut at the
left edge (2026-10-21 hover clipping report); the ellipsis and the text box are unchanged. */
overflow: clip;
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__name { color: var(--hm-sw-header-text-color); font-size: var(--hm-sw-header-font-size); font-weight: 700; line-height: 20px; text-shadow: var(--hm-sw-header-text-outline); }
/* Path Bold with its 12px black outline (2026-10-20 user request), like the file name above (700 + 14px outline). */
.hm-sw-recent-files__path { color: var(--hm-sw-recent-files-path-color); font-size: var(--hm-sw-header-label-size); font-weight: 700; line-height: 14px; opacity: 0.6; text-shadow: var(--hm-sw-header-label-outline); }
.hm-sw-recent-files__path { color: var(--hm-sw-recent-files-path-color); font-size: var(--hm-sw-header-label-size); font-weight: 700; line-height: 16px; 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; }
@@ -471,6 +477,14 @@
font-size: var(--hm-sw-header-font-size);
line-height: 18px;
letter-spacing: 0.02em;
/* 2026-10-21: path + file name on one line; a long path is cut at its start (…) so the file name stays visible.
Right-to-left only for the ellipsis side; the text is an isolated left-to-right span (dir="ltr"), centered. */
direction: rtl;
text-align: center;
}
.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 { position: relative; width: 100%; height: 243px; overflow: hidden; border-radius: 0 0 4px 0; background: var(--hm-sw-recent-files-preview-body-background); }
/* 2026-10-21 design: no image in the preview; the body shows the "no preview" prohibition sign, centered and sized to
the 243px body (no image file: inline SVG drawn with CSS strokes). */
.hm-sw-recent-files__preview-body { display: grid; place-items: center; }
.hm-sw-recent-files__preview-body > .hm-sw-recent-files__preview-none { display: block; width: 200px; height: 200px; fill: none; stroke: var(--hm-sw-recent-files-preview-none-color); stroke-width: 11; }
.hm-sw-recent-files__preview-body > :is(img, svg, canvas) { display: block; width: 100%; height: 100%; object-fit: cover; }
+6
View File
@@ -210,6 +210,11 @@ button.hm-sw-viewer-header__brand { text-align: left; }
calc(100% + 2 * var(--hm-sw-header-document-menu-border-width))
);
}
/* 2026-10-21 user request: viewer file rows show the name only (no icon, no icon gap); rows, hover and the current
file color are unchanged. The SWHeader document list keeps its icons. */
.hm-sw-viewer-header__document[data-list] .hm-sw-header__document-icon { display: none; }
/* SWHeader minimize / close (SWHeaderWindowControls) at the end of `trailing`: right edge as in SWHeader, no inset. */
.hm-sw-viewer-header__object:has(> .hm-sw-viewer-header__actions > .hm-sw-header__window-buttons:last-child) { padding-right: 0; }
/* Viewer header dropdown colors (2026-10-21): the menus of the header — menu buttons, More and their submenus — use the
colors of the SWHeader `...` menu (sw.css .hm-sw-header__more-menu): its face and shadow (fixed values there), the
object border token, the header text, secondary text at 80% (as its values) and the white 8% row hover with the text
@@ -569,6 +574,7 @@ button.hm-sw-viewer-header__brand { text-align: left; }
}
/* SWSelect inside viewer panels reads the viewer panel tokens (same values; viewer theme overrides follow). */
.hm-sw-viewer-panel { --hm-sw-select-border-color: var(--hm-sw-viewer-panel-inner-border); --hm-sw-select-background: var(--hm-sw-viewer-panel-field-background); --hm-sw-select-text: var(--hm-sw-viewer-panel-text); }
.hm-sw-property.hm-sw-viewer-panel .hm-sw-select:not(:disabled) { color: var(--hm-sw-select-text); }
/* Viewer rows dim a disabled field as a whole (SWViewerField 50%); the slider keeps its own opacity as before. */
.hm-sw-theme .hm-sw-viewer-slider:disabled { opacity: 1; }
+134 -32
View File
@@ -240,6 +240,10 @@
.hm-sw-property-bar__field { display: flex; align-items: center; gap: 6px; white-space: nowrap; }
.hm-sw-property-bar__field label { color: var(--hm-sw-header-text-color); letter-spacing: .04em; }
.hm-sw-property-bar__field .hm-sw-header__input { flex-shrink: 0; }
/* 2026-10-21 (user decision): the bar shows its values (반지름 · 지름 · 원주 · 면적 …) as plain text (an <output>, never
focused) in the input's place, font and outline. They stay red: the property window edits them. */
.hm-sw-property-bar__value { flex-shrink: 0; padding: 2px 4px 2px 6px; color: var(--hm-sw-header-input-color); font-size: var(--hm-sw-header-font-size); font-weight: 700; line-height: 16px; letter-spacing: 0.02em; white-space: nowrap; text-shadow: var(--hm-sw-header-text-outline); }
.hm-sw-property-bar__value[data-disabled="true"] { opacity: 0.4; }
.hm-sw-property-bar:focus-visible { outline: 2px solid var(--hm-sw-header-focus-color); outline-offset: -2px; }
/* Configured controls use the same components in an ordered list; no empty removed slots.
@@ -1602,6 +1606,10 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
/* SWSelect: native select, panel field look; focus = the SW popup focus ring. */
.hm-sw-select { flex: 0 0 auto; height: 28px; padding: 0 8px; border: 1px solid var(--hm-sw-select-border-color); border-radius: 4px; background: var(--hm-sw-select-background); color: var(--hm-sw-select-text); font: inherit; font-size: 12px; }
.hm-sw-select:disabled { opacity: 0.4; cursor: default; }
/* 2026-10-21: in an SW popup the chosen value of an enabled select is editable, so it is red like every editable value;
a disabled select and the option list keep --hm-sw-select-text. The viewer panels keep their own text color (sw-viewer.css). */
.hm-sw-property .hm-sw-select:not(:disabled) { color: var(--hm-sw-header-input-color); }
.hm-sw-property .hm-sw-select option { color: var(--hm-sw-select-text); }
.hm-sw-property__unit { flex: 0 0 auto; font-size: 12px; white-space: nowrap; }
/* Color preview (26:12175): 70 x 16 black tile (1px padding, radius 2), 68 x 14 swatch with a white 1px border. */
.hm-sw-property__swatch-area { display: flex; align-items: center; flex: 0 0 auto; }
@@ -1654,6 +1662,12 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-property__expand[aria-expanded="true"] > img { transform: rotate(180deg); }
.hm-sw-property__counts { display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); }
.hm-sw-property__counts > span { min-height: 28px; padding: 5px 0; border-bottom: 1px solid rgba(0,0,0,.5); font-weight: 700; }
/* 2026-10-21 design review: each object type entry (원 1, 폴리선 10 …) changes its own text color on Hover, with the
SW row accent #FF5C00 (the header list row hover color). Display only: no click, selection or filter. */
.hm-sw-property__counts > span:hover { color: var(--hm-sw-header-layer-row-hover-color); }
/* 2026-10-21 user request: Popup items whose color changes on Hover show the pointer (display-only entries too: no
click is added). Inputs and read-only values keep their own cursor. */
:is(.hm-sw-property__tool, .hm-sw-property__close, .hm-sw-property__expand, .hm-sw-property__counts > span) { cursor: pointer; }
.hm-sw-property__group--circle .hm-sw-property__group-content { position: relative; min-height: 260px; }
.hm-sw-property__group--circle .hm-sw-property__editors { position: relative; min-height: 260px; padding-top: 260px; }
/* Shape slot (Figma 26:11932 / 26:12035): 208 x 208 at (39, 26) of the 260px shape group. Default content is
@@ -1664,6 +1678,8 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-property__group--circle .hm-sw-property__editor { position: absolute; }
.hm-sw-property__group--circle .hm-sw-property__row { padding: 0; min-height: 24px; gap: 4px; }
.hm-sw-property__group--circle .hm-sw-property__row > label { flex-basis: auto; }
/* 2026-10-21 user request: 원주 / 지름 / 반지름 / 면적 = [Input] — a text "=" after the name, outside the input. */
.hm-sw-property__editor[data-slot] .hm-sw-property__row > label::after { content: " =" / ""; }
.hm-sw-property__group--circle .hm-sw-property__value { flex: 0 1 auto; }
.hm-sw-property__group--circle .hm-sw-header__input { width: calc(var(--hm-sw-input-chars) * 1ch + 8px); flex: 0 1 auto; }
.hm-sw-property__editor[data-slot="circumference"] { left: 113px; top: 0; }
@@ -1749,6 +1765,46 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
text-shadow: var(--hm-sw-header-text-outline);
}
/* Slider value box (2026-10-21, 투명도): value + annotation in one field that fills the room before the gauge; the
same Default / Hover / On (#0F0D0A, white annotation) as the number box. */
.hm-sw-property__slider-value {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 4px;
flex: 1 1 auto;
min-width: 0;
height: 28px;
padding: 0 7px;
border: 1px solid transparent;
border-radius: 2px;
}
.hm-sw-property__row:not([data-disabled]) .hm-sw-property__slider-value:is(:hover, :not([data-readonly]):focus-within) { border-color: var(--hm-sw-property-field-hover-border-color); }
.hm-sw-property__slider-value > .hm-sw-header__input,
.hm-sw-property__slider-value > .hm-sw-header__input:is(:hover, :focus) {
flex: 1 1 auto;
align-self: stretch;
width: auto;
min-width: 0;
height: auto;
padding: 0;
border: 0;
border-radius: 0;
background: transparent;
font-weight: 500;
letter-spacing: 0.02em;
text-shadow: none;
}
.hm-sw-property__slider-value > .hm-sw-header__input:not(:read-only):focus-visible { outline: none; }
.hm-sw-property__slider-value[data-on="edit"]:focus-within { background: var(--hm-sw-property-field-editing-background); }
.hm-sw-property__slider-value[data-on="edit"]:focus-within > .hm-sw-header__input { text-shadow: var(--hm-sw-header-text-outline); }
.hm-sw-property__slider-value[data-on="edit"]:focus-within > .hm-sw-property__unit { color: var(--hm-sw-property-field-editing-unit-color); }
/* Value color rule (2026-10-21): red (#E60403) marks an editable value only. A read-only, computed or disabled value
is the black label color of the light popup; a disabled row also keeps its 40%. */
:is(.hm-sw-property__value, .hm-sw-property__slider-value)[data-readonly] :is(.hm-sw-header__input, .hm-sw-property__trigger, select:not(.hm-sw-select)) { color: var(--hm-sw-property-label); }
/* Dropdown fields: layer (pl/pr 8), line style (pl 8, pr 6: value + (ByL) + pattern), color (RGB | swatch). */
.hm-sw-property__trigger {
display: flex;
@@ -1808,40 +1864,72 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
}
/* Responsive — header width (container query); the header never scrolls horizontally.
Fields keep their Figma widths at every tier (measured Noto Sans KR content widths are
231 / 168 / 278 / 142 / 163px, so shrinking would only truncate values). Tools move into
More instead, so every Prop and Callback stays reachable. Breakpoints come from the
measured widths: fixed parts (home 63 + gaps 6 + window 240) + object area + file name.
From 760px up (2026-10-21 user decision; the SW Footer story widths) nothing moves into More: the five menus 레이어 · 색상 · 선스타일 ·
문자스타일 · 치수스타일 always stay in the header with their titles, and the More button is not shown. As it narrows:
>= 1800 Figma layout. 1920 -> 1800 only the file name shrinks (310 -> 190).
< 1800 선/문자/치수 스타일 -> More (object area 1301 -> 719)
< 1180 그리기모드 label hides (719 -> 633)
< 1080 속성Bar/속성창 -> More, window move 88 -> 24 (633 -> 480, window 240 -> 176)
< 900 색상 -> More (480 -> 284)
< 640 인쇄/팝업 가이드 -> More, window move hides (window 176 -> 64)
Only below ~520px (phones) does the layer field shrink: its state icons hide and the
layer name ellipsizes.
The file name always takes the remaining width (ellipsis). cqw = header width.
Hiding targets the flex item of each field ([data-menu] / [data-tier] on the field button, or on
the wrapper of a field with a dropdown), so a dropdown field hides like a plain one. */
< 1800 the 그리기모드 text hides, the line-type / dimension pictures hide and the color chip is 24px (fields take
their content width); the file name gives way first (down to 96px, ellipsis), then the field values
shrink with an ellipsis, each by its own width.
< 1080 window move area 88 -> 24 (window 240 -> 176); layer-state icons, the color chip and the 문자스타일
font name hide, field insets 6px.
<= 900 the 선스타일 field shows 선스타일 only (가중치 / 스케일 hide).
Below 760px (phones, e.g. 390) the former tiers are unchanged: parts move into More (선/문자/치수 스타일, 그리기모드,
속성Bar/속성창, 색상, then 인쇄/팝업 가이드 below 640), and below 520 the layer-state icons hide.
Each field keeps its button, title, value, dropdown and hover / focus box. cqw = header width.
Hiding targets the flex item of each field ([data-menu] / [data-tier] on the field button, or on the wrapper of a
field with a dropdown), so a dropdown field hides like a plain one. */
.hm-sw-header__document { flex: 0 0 clamp(190px, calc(100cqw - 1610px - var(--hm-sw-header-leading-width, 0px)), 310px); min-width: 0; }
.hm-sw-header__field--layer { flex-shrink: 1; }
.hm-sw-header__field:not(.hm-sw-header__field--layer) { flex-shrink: 0; }
@container hm-sw-header (max-width: 1799.98px) {
.hm-sw-header__fields > [data-tier="aux"] { display: none; }
.hm-sw-header__more { display: block; }
.hm-sw-header__more-item[data-tier="aux"] { display: flex; }
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 1030px - var(--hm-sw-header-leading-width, 0px)), 310px); }
}
@container hm-sw-header (max-width: 1179.98px) {
@container hm-sw-header (min-width: 760px) and (max-width: 1799.98px) {
.hm-sw-header__mode { display: none; }
.hm-sw-header__fields { padding-left: 4px; }
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 944px - var(--hm-sw-header-leading-width, 0px)), 310px); }
/* Content width (no Figma box) that may shrink: the free room is taken from each value by its own width. */
.hm-sw-header__fields > :is(.hm-sw-header__layer, .hm-sw-header__menu),
.hm-sw-header__fields > .hm-sw-header__control > :is(.hm-sw-header__layer, .hm-sw-header__menu) { flex: 0 1 auto; width: auto; min-width: 0; }
.hm-sw-header__fields .hm-sw-header__field { flex: 0 1 auto; width: auto; min-width: 0; }
/* Fixed-size buttons in a configured bar (인쇄 · K) keep their box: only the fields give way. */
.hm-sw-header__fields > .hm-sw-header__control:is([data-control="print"], [data-control="popupGuide"]) { flex-shrink: 0; }
.hm-sw-header__layer > .hm-sw-header__field--layer { width: 100%; }
.hm-sw-header__field :is(.hm-sw-header__line-values, .hm-sw-header__font-name) { min-width: 0; }
/* Tighter fields: smaller insets, a 24px color chip, no line-type / dimension pictures (the value names stay). */
.hm-sw-header__fields .hm-sw-header__field { padding: 0 8px; gap: 8px; }
.hm-sw-header__fields .hm-sw-header__chip { flex-basis: 24px; width: 24px; }
.hm-sw-header__fields :is(.hm-sw-header__linetype, .hm-sw-header__dimension-icon) { display: none; }
.hm-sw-header__fields .hm-sw-header__line-values { gap: 8px; }
/* Open dropdown (2026-10-21 user request): the fields are narrower than their lists here, so the yellow ring stays
inside each button and the list gets its own ring, as a shifted list (치수스타일) already does. */
.hm-sw-header__fields :is(.hm-sw-header__layer-menu, .hm-sw-header__menu-panel:not(.hm-sw-header__line-menu))::before { top: -1px; }
.hm-sw-header__fields .hm-sw-header__field[data-expanded="true"]::after { opacity: 1; }
/* Where the list meets the button the seam is black across the button width (as the 선스타일 seam). */
.hm-sw-header__fields :is(.hm-sw-header__layer, .hm-sw-header__menu:not(.hm-sw-header__menu--line-style)):has(> .hm-sw-header__field[data-expanded="true"])::after {
content: "";
position: absolute;
z-index: 31;
top: calc(100% - 1px);
left: 0;
right: 0;
height: 2px;
background: var(--hm-sw-header-layer-menu-border-color);
pointer-events: none;
}
/* 1610 - 1301 + the tighter object area with titles, without 그리기모드 (891, measured) = 1200. */
.hm-sw-header__document { flex-basis: clamp(96px, calc(100cqw - 1200px - var(--hm-sw-header-leading-width, 0px)), 310px); }
}
@container hm-sw-header (min-width: 760px) and (max-width: 1079.98px) {
/* Values need the room (1024px, 2026-10-21 user request): the layer-state icons, the color chip and the font name
beside 문자스타일 hide, insets 6px, every value part may shrink (ellipsis). */
.hm-sw-header__fields .hm-sw-header__field { padding: 0 6px; gap: 6px; }
.hm-sw-header__fields :is(.hm-sw-header__field--layer .hm-sw-header__layer-icons, .hm-sw-header__chip, .hm-sw-header__font-name) { display: none; }
.hm-sw-header__fields .hm-sw-header__line-values { grid-template-columns: minmax(0, auto) minmax(0, 38px) minmax(0, 38px); gap: 6px; }
}
@container hm-sw-header (min-width: 760px) and (max-width: 900px) {
/* 900px and narrower (2026-10-21 user request): the 선스타일 field keeps only 선스타일 (가중치 / 스케일 hide). */
.hm-sw-header__fields .hm-sw-header__line-values { grid-template-columns: minmax(0, auto); }
.hm-sw-header__fields .hm-sw-header__line-values > :not(:first-child) { display: none; }
}
@container hm-sw-header (max-width: 1079.98px) {
.hm-sw-header__properties { display: none; }
.hm-sw-header__more { margin-left: auto; margin-right: 4px; }
.hm-sw-header__more-item[data-tier="panel"] { display: flex; }
.hm-sw-header__motion { flex-basis: 24px; background-size: 18px 37px; }
/* 24px move area: no room for the icon motion, so hover shows only the tooltip, right of the
area like Figma hover2 (x = area width + 1, y 11). */
@@ -1851,9 +1939,17 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
.hm-sw-header__motion-tooltip { left: calc(100% + 1px); top: 11px; }
.hm-sw-header__motion:hover > .hm-sw-header__motion-tooltip { animation: none; opacity: 1; }
.hm-sw-header__window { flex-basis: 176px; width: 176px; }
.hm-sw-header__document { flex-basis: clamp(120px, calc(100cqw - 725px - var(--hm-sw-header-leading-width, 0px)), 310px); }
}
@container hm-sw-header (max-width: 899.98px) {
/* Below 760px: the former tiers (선/문자/치수 스타일 < 1800, 그리기모드 < 1180, 속성Bar/속성창 < 1080, 색상 < 900). */
@container hm-sw-header (max-width: 759.98px) {
.hm-sw-header__fields > [data-tier="aux"] { display: none; }
.hm-sw-header__more { display: block; }
.hm-sw-header__more-item[data-tier="aux"] { display: flex; }
.hm-sw-header__mode { display: none; }
.hm-sw-header__fields { padding-left: 4px; }
.hm-sw-header__properties { display: none; }
.hm-sw-header__more { margin-left: auto; margin-right: 4px; }
.hm-sw-header__more-item[data-tier="panel"] { display: flex; }
.hm-sw-header__fields > [data-menu="color"] { display: none; }
.hm-sw-header__more-item[data-tier="narrow"] { display: flex; }
.hm-sw-header__document { flex-basis: clamp(96px, calc(100cqw - 529px - var(--hm-sw-header-leading-width, 0px)), 310px); }
@@ -1960,17 +2056,23 @@ div.hm-sw-header__menu-row { justify-content: space-between; gap: 8px; padding-l
:is(.hm-sw-header__leading, .hm-sw-header__document[data-tool]) + .hm-sw-header__document > .hm-sw-header__document-button { padding-left: 12px; }
/* While the header Open is shown, the default fields keep their width: a lack of room becomes a measured
overflow that moves Open into More instead of narrowing the layer field. Custom leading content alone keeps
the existing behavior (file name first, then the layer field below 520px). */
.hm-sw-header[data-leading="open"] .hm-sw-header__fields:not([data-configured]) > :is(.hm-sw-header__layer, .hm-sw-header__field--layer, .hm-sw-header__control[data-control="layer"]) { flex-shrink: 0; }
the existing behavior (file name first, then the layer field below 520px). Below 760px only (2026-10-21): from
760px up nothing moves into More, so the fields shrink beside Open like everywhere else. */
@container hm-sw-header (max-width: 759.98px) {
.hm-sw-header[data-leading="open"] .hm-sw-header__fields:not([data-configured]) > :is(.hm-sw-header__layer, .hm-sw-header__field--layer, .hm-sw-header__control[data-control="layer"]) { flex-shrink: 0; }
}
.hm-sw-header__more[data-forced] { display: block; }
/* Tool groups reuse the 상세보기 / print-K surfaces but are host tools: the Header breakpoints that move those
built-in parts into More never hide them (they have no More entry). */
.hm-sw-header__properties.hm-sw-tool__group { display: flex; }
.hm-sw-header__actions.hm-sw-tool__pill { display: flex; }
/* Configured layouts too: while the header Open is shown, controls other than the file name keep their width,
so a lack of room is a measured overflow that moves Open into More (the file name still gives way first). */
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control:not([data-control="document"]),
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control:not([data-control="document"]) > * { flex-shrink: 0; }
so a lack of room is a measured overflow that moves Open into More (the file name still gives way first).
Below 760px only (2026-10-21), as above. */
@container hm-sw-header (max-width: 759.98px) {
.hm-sw-header[data-leading="open"] .hm-sw-header__fields[data-configured] > .hm-sw-header__control:not([data-control="document"]),
.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; }