feat(ui-kit): 부품 모음 화면 틀 — 시스템 관리자만 · 밝음 / 어두움 나란히 · 상태 다섯 칸 · 크기 여덟 단 (PLAN 552-1)

- 대시보드 세부 페이지 `#/b01-account?view=ui-kit` 등록부 한 줄(시스템 관리 묶음 · SYSTEM_ADMIN 만)
- ui_template_ui_kit.ts — 테마 칸마다 iframe(data-theme)에 지금 CSS 를 옮겨 실제 값으로 그림
- ui_template_ui_kit_parts.ts — 표준 부품 30 줄(552-2 기본 · 552-3 묶음 · 552-4 틀) · 머리 = 부품 규칙(자리 · 이름 · data-size · data-state · 색 · 아이콘)
- 지금 PageKit · 공용 부품 견본(단추 · 입력 · 고르기 · 탭 · 배지 · 표 · ⋯ · 빈 상태 · 설정 줄 · 아이콘) · 나머지 빈 자리
- test_546 시스템 메뉴 기대값에 ui-kit 더함 · 새 지킴 시험 test_552_1_ui_kit

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-26 20:49:09 +09:00
co-authored by Claude Opus 5.5
parent 85ae6319e4
commit aaac17db4c
6 changed files with 480 additions and 1 deletions
+2
View File
@@ -25,6 +25,7 @@ import { mountSysDefaults } from "./B01_Dashboard_UI_View_SysDefaults";
import { mountSysLogs } from "./B01_Dashboard_UI_View_SysLogs";
import { mountSysOverview } from "./B01_Dashboard_UI_View_SysOverview";
import { mountSysUsers } from "./B01_Dashboard_UI_View_SysUsers";
import { mountUiKit } from "@ui/ui_template_ui_kit";
/** 역할 넷 — 산림청 = 세션 is_forest_service 인 일반 사용자(회사 관리자 · 시스템 관리자가 앞섬) */
export type ViewRole = "USER" | "ADMIN" | "FOREST" | "SYSTEM_ADMIN";
@@ -70,6 +71,7 @@ export const VIEWS: View[] = [
{ key: "sys-users", title: "사용자", group: "system", roles: ["SYSTEM_ADMIN"], icon: "user", mount: mountSysUsers },
{ key: "sys-defaults", title: "설계 기본값", group: "system", roles: ["SYSTEM_ADMIN"], icon: "database", mount: mountSysDefaults },
{ key: "sys-logs", title: "시스템 로그", group: "system", roles: ["SYSTEM_ADMIN"], icon: "fileText", mount: mountSysLogs },
{ key: "ui-kit", title: "부품 모음", group: "system", roles: ["SYSTEM_ADMIN"], icon: "adjustments", mount: mountUiKit },
// 도움
{ key: "guide", title: "사용자 가이드", group: "help", roles: ALL, icon: "book", mount: mountGuide },
{ key: "chat", title: "지식 챗봇", group: "help", roles: ALL, icon: "chatbot", mount: mountChat },
+1 -1
View File
@@ -67,7 +67,7 @@ def test_역할별_메뉴():
assert system == [
*work, "company:people", "company:company",
"system:sys-overview", "system:sys-companies", "system:sys-users",
"system:sys-defaults", "system:sys-logs", *help_,
"system:sys-defaults", "system:sys-logs", "system:ui-kit", *help_,
] # fmt: skip
+67
View File
@@ -0,0 +1,67 @@
"""552-1 부품 모음 화면 지킴 시험 — 화면 확인은 ORCA.
· `#/b01-account?view=ui-kit` = 시스템 관리자만(다른 역할은 메뉴에 없고 주소로 와도 홈)
· 부품 목록 = 열쇠 겹침 없음 · 세 묶음(552-2 기본 · 552-3 묶음 · 552-4 틀) · 파일은 ui_template_ 안
· 크기 여덟 단 · 상태 다섯 칸 · 밝음 / 어두움 두 칸(iframe data-theme) · 날 색 없음
"""
from __future__ import annotations
import re
import shutil
from pathlib import Path
import pytest
from test_546_b01_shell import MENU, _run_views
ROOT = Path(__file__).resolve().parents[2]
UI = ROOT / "ui_template"
def _text(path: Path) -> str:
return path.read_text(encoding="utf-8")
@pytest.mark.skipif(shutil.which("node") is None, reason="node 가 없음")
def test_시스템_관리자만():
out = _run_views(
MENU + "console.log(JSON.stringify(['USER','ADMIN','FOREST','SYSTEM_ADMIN'].map("
"(r) => [menu(r).includes('system:ui-kit'), viewFor('ui-kit', r).key])));"
)
assert out == [[False, "home"], [False, "home"], [False, "home"], [True, "ui-kit"]]
def test_부품_목록():
src = _text(UI / "ui_template_ui_kit_parts.ts")
rows = re.findall(
r'\{ key: "([^"]+)", name: "[^"]+", group: "(\w+)",[^\n]*file: "([^"]+)"', src
)
keys = [k for k, _, _ in rows]
assert len(keys) == len(set(keys)) >= 25
assert {g for _, g, _ in rows} == {"basic", "compound", "frame"}
assert all(f.startswith("ui_template_") and f.endswith(".ts") for _, _, f in rows)
# 묶음 차례 그대로 — 창마다 자기 덩어리만 고침
groups = [g for _, g, _ in rows]
assert groups == sorted(groups, key=["basic", "compound", "frame"].index)
assert "KIT_SIZES = [18, 20, 22, 24, 28, 32, 36, 42] as const" in src
assert 'KIT_STATES = ["default", "hover", "active", "disabled", "error"] as const' in src
# 규칙 머리 — 크기 · 상태 · 색 · 아이콘
for word in (
'data-size="18|20|22|24|28|32|36|42"',
'data-state="hover"',
"--draw-*",
"icon(name, size)",
):
assert word in src, word
def test_밝음_어두움_두_칸():
view = _text(UI / "ui_template_ui_kit.ts")
assert 'panes.append(themePane("light"), themePane("dark"))' in view
assert "doc.documentElement.dataset.theme = theme" in view
assert "frame.srcdoc" in view # 표준 모드 문서
css = _text(UI / "ui_template_ui_kit.css")
assert not re.search(r"#[0-9a-fA-F]{3,8}\b|rgba?\(", css) # 색 = 공통 값만
for path in (view, _text(UI / "ui_template_ui_kit_parts.ts"), css):
assert len(path.splitlines()) <= 700
+145
View File
@@ -0,0 +1,145 @@
/* 부품 모음 화면(552-1) — 밝음 · 어두움 두 칸 · 칸 안은 iframe 문서 */
.b01-pg.ui-kit {
max-width: none;
}
.ui-kit__panes {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(520px, 1fr));
gap: 16px;
align-items: start;
}
.ui-kit__pane-title {
margin: 0 0 8px;
font-size: var(--text-body-sm);
font-weight: 600;
}
.ui-kit__frame {
display: block;
width: 100%;
min-height: 240px;
border: 1px solid var(--color-border);
border-radius: var(--radius-cards, 10px);
}
/* iframe 문서 */
.ui-kit__doc {
margin: 0;
background: var(--color-bg);
}
/* 셸 별칭만 받고 셸 배치(100dvh · grid)는 풂 */
.b01-shell.ui-kit__body {
display: flex;
flex-direction: column;
gap: 16px;
min-height: 0;
padding: 16px;
}
.ui-kit__group {
display: flex;
flex-direction: column;
gap: 8px;
}
.ui-kit__group-title {
margin: 0;
font-size: var(--text-body-sm);
font-weight: 700;
color: var(--color-text);
}
.ui-kit__part {
padding: 12px;
background: var(--color-surface-raised);
border: 1px solid var(--color-border);
border-radius: var(--radius-cards, 10px);
}
.ui-kit__part-head {
display: flex;
flex-wrap: wrap;
align-items: baseline;
gap: 4px 8px;
margin-bottom: 8px;
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.ui-kit__part-name {
font-size: var(--text-body-sm);
color: var(--color-text);
}
.ui-kit__part-flag {
padding: 0 6px;
border: 1px solid var(--color-border);
border-radius: 999px;
}
.ui-kit__states {
display: grid;
grid-template-columns: repeat(5, minmax(0, 1fr));
gap: 8px;
align-items: start;
}
.ui-kit__legend {
position: sticky;
top: 0;
z-index: 1;
padding: 4px 12px;
background: var(--color-bg);
font-size: var(--text-caption);
font-weight: 600;
color: var(--color-text-secondary);
}
.ui-kit__state {
min-width: 0;
overflow: hidden;
}
/* 상태가 기본 하나뿐인 견본(표 · 배지 …)은 다섯 칸을 다 씀 */
.ui-kit__state--wide {
grid-column: 1 / -1;
overflow: visible;
}
.ui-kit__slot {
display: flex;
align-items: center;
justify-content: center;
height: 32px;
border: 1px dashed var(--color-border);
border-radius: 8px;
font-size: var(--text-caption);
color: var(--color-text-secondary);
}
.ui-kit__demo-row {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: 8px;
}
.ui-kit__sizes {
display: flex;
align-items: flex-end;
gap: 8px;
}
.ui-kit__size {
display: flex;
align-items: center;
justify-content: center;
min-width: 40px;
border: 1px dashed var(--color-accent);
border-radius: 6px;
font-size: 11px;
color: var(--color-text-secondary);
}
+128
View File
@@ -0,0 +1,128 @@
/* =============================================================================
* ui_template_ui_kit.ts
* 부품 모음 화면(552-1) — 대시보드 세부 페이지 `#/b01-account?view=ui-kit` · 시스템 관리자만.
* 밝음 · 어두움을 나란히 — 칸마다 iframe(`<html data-theme>`)에 지금 페이지 CSS 를 옮겨 실제 테마 값으로 그림.
* 부품 줄 = `ui_template_ui_kit_parts.ts` 목록 · 상태 다섯 칸(기본 · 마우스 · 눌림 · 막힘 · 오류) · 견본이 없으면 빈 자리.
* 크기 자 = 단추 · 입력 높이 여덟 단(18 ~ 42).
* ========================================================================== */
import { pageFrame } from "../B01_Dashboard/B01_Dashboard_UI_PageKit";
import {
KIT_GROUP_LABEL,
KIT_PARTS,
KIT_SIZES,
KIT_STATES,
KIT_STATE_LABEL,
type KitGroup,
type KitPart,
} from "./ui_template_ui_kit_parts";
import "./ui_template_ui_kit.css";
type Theme = "light" | "dark";
function node<K extends keyof HTMLElementTagNameMap>(
tag: K,
className: string,
text?: string,
): HTMLElementTagNameMap[K] {
const n = document.createElement(tag);
n.className = className;
if (text !== undefined) n.textContent = text;
return n;
}
/** 크기 자 — 여덟 단 높이 상자 */
function sizeRuler(): HTMLElement {
const box = node("section", "ui-kit__group");
box.append(node("h3", "ui-kit__group-title", "크기 여덟 단 (높이 px · data-size)"));
const line = node("div", "ui-kit__sizes");
for (const h of KIT_SIZES) {
const cell = node("div", "ui-kit__size", String(h));
cell.style.height = `${h}px`;
cell.dataset.size = String(h);
line.append(cell);
}
box.append(line);
return box;
}
function partRow(part: KitPart): HTMLElement {
const item = node("article", "ui-kit__part");
item.dataset.part = part.key;
const head = node("header", "ui-kit__part-head");
head.append(
node("strong", "ui-kit__part-name", part.name),
node("span", "ui-kit__part-kinds", part.kinds),
node("code", "ui-kit__part-file", part.file),
);
if (part.legacy) head.append(node("span", "ui-kit__part-flag", "지금 꼴"));
item.append(head);
const states = node("div", "ui-kit__states");
const shown = KIT_STATES.map((s) => part.demo?.(s)); // null = 그 상태 없음 · undefined = 견본 없음
const onlyDefault = shown[0] && shown.slice(1).every((x) => x === null);
KIT_STATES.forEach((s, i) => {
if (onlyDefault && i > 0) return;
const cell = node("div", `ui-kit__state${onlyDefault ? " ui-kit__state--wide" : ""}`);
cell.dataset.state = s;
cell.append(shown[i] ?? node("span", "ui-kit__slot", shown[i] === null ? "-" : "빈 자리"));
states.append(cell);
});
item.append(states);
return item;
}
/** 한 테마 칸 내용 — 상태 머리 · 크기 자 · 묶음별 부품 줄 */
export function renderKit(): HTMLElement {
// 셸 별칭(`--b01-*`)을 받으려고 .b01-shell 로 감쌈 — 배치 규칙은 CSS 에서 풂
const root = node("div", "b01-shell ui-kit__body");
const legend = node("div", "ui-kit__states ui-kit__legend");
for (const s of KIT_STATES) legend.append(node("span", "ui-kit__state", KIT_STATE_LABEL[s]));
root.append(legend, sizeRuler());
for (const group of Object.keys(KIT_GROUP_LABEL) as KitGroup[]) {
const box = node("section", "ui-kit__group");
box.dataset.group = group;
box.append(node("h3", "ui-kit__group-title", KIT_GROUP_LABEL[group]));
box.append(...KIT_PARTS.filter((p) => p.group === group).map(partRow));
root.append(box);
}
return root;
}
/** 테마 칸 — iframe 에 지금 문서의 CSS 를 옮기고 `<html data-theme>` 를 박음 */
function themePane(theme: Theme): HTMLElement {
const pane = node("div", "ui-kit__pane");
pane.dataset.theme = theme;
pane.append(node("h3", "ui-kit__pane-title", theme === "light" ? "밝음" : "어두움"));
const frame = node("iframe", "ui-kit__frame");
frame.title = `부품 모음 — ${theme === "light" ? "밝음" : "어두움"}`;
pane.append(frame);
frame.addEventListener("load", () => {
const doc = frame.contentDocument;
if (!doc) return;
doc.documentElement.dataset.theme = theme;
doc.documentElement.lang = "ko";
for (const css of document.head.querySelectorAll('style, link[rel="stylesheet"]'))
doc.head.append(css.cloneNode(true));
doc.body.className = "ui-kit__doc";
doc.body.append(renderKit());
const fit = (): void => {
frame.style.height = `${doc.documentElement.scrollHeight}px`;
};
new ResizeObserver(fit).observe(doc.body);
fit();
});
frame.srcdoc = "<!doctype html><html><head></head><body></body></html>";
return pane;
}
export function mountUiKit(host: HTMLElement): void {
const page = pageFrame(
host,
"부품 모음",
"모든 화면이 다시 쓰는 부품 — 밝음 · 어두움 · 상태별 · 빈 자리는 552-2 ~ 552-4 가 채움",
);
page.root.classList.add("ui-kit");
const panes = node("div", "ui-kit__panes");
panes.append(themePane("light"), themePane("dark"));
page.body.append(panes);
}
+137
View File
@@ -0,0 +1,137 @@
/* =============================================================================
* ui_template_ui_kit_parts.ts
* 부품 모음 화면(552-1)의 부품 목록 — 한 줄 = 표준 부품 하나 · 화면은 `ui_template_ui_kit.ts`.
*
* 부품 규칙(552-2 ~ 552-4 가 따름):
* 자리 = `ui_template/` 한 곳 · 부품 하나 = `ui_template_<이름>.ts` (+ 같은 이름 `.css`) · 700 줄 넘으면 `_<갈래>` 로 나눔
* 이름 = 만들기 `create<부품>(opts)` → 요소 또는 { root, … } 손잡이 · 클래스 `ui-<부품>` · 속 `ui-<부품>__<조각>` · 꼴 `ui-<부품>--<꼴>`
* 크기 = `data-size="18|20|22|24|28|32|36|42"`(높이 px · 여덟 단) — 단추 · 아이콘 단추 · 입력 · 고르기 · 분할 고르기 · 탭 같은 값
* 상태 = 마우스 `:hover` · 눌림 `:active` · 고름 `.is-active` / `aria-selected` · 막힘 `disabled` / `aria-disabled` · 오류 `.is-error` / `aria-invalid`
* → CSS 는 `:is(:hover, [data-state="hover"])` · `:is(:active, [data-state="active"])` 로 써서 이 화면이 상태를 굳혀 보임
* 색 = 공통 값(`--color-*` · `--radius-*` · `--shadow-*`)만 · 날 색 · `--b01-*` · 그림 값(`--draw-*`) 금지
* 글꼴 = `--font-ui` 상속 · 글자 크기 = `--text-*`
* 아이콘 = `ui_template_icons.ts` 의 `icon(name, size)` 만 · 그림 이모지 금지 · 글자 기호(→ ▾ ▲▼ ✓)는 시험이 잡아 그대로
* 옛 이름 = 지금 공용 클래스(`ui-btn--filled` · `ui-collapsible__title` · `ui-sidebar-actions` …)는 부품이 그대로 냄(시험 · 가이드 촬영)
* 머리 = 파일 머리에 무엇 · 꼴 · 크기 · 상태 · 쓰는 법 한 줄씩
* 부품을 만들면 아래 자기 줄의 `file` 을 맞추고 `demo` 를 채움 — 빈 칸 = 「빈 자리」로 보임.
* 몫 = 552-2 기본 · 552-3 묶음 · 552-4 틀 — 세 묶음이 떨어져 있어 서로 줄을 안 건드림.
* ========================================================================== */
import { createButton, createInputField, createSelectField } from "./ui_template_elements";
import { icon } from "./ui_template_icons";
import {
badge,
dataTable,
emptyState,
rowMenu,
settingCard,
settingRow,
tabBar,
valueText,
} from "../B01_Dashboard/B01_Dashboard_UI_PageKit";
/** 상태 줄 다섯 칸 */
export const KIT_STATES = ["default", "hover", "active", "disabled", "error"] as const;
export type KitState = (typeof KIT_STATES)[number];
export const KIT_STATE_LABEL: Record<KitState, string> = {
default: "기본",
hover: "마우스",
active: "눌림",
disabled: "막힘",
error: "오류",
};
/** 단추 · 입력 높이 여덟 단(요구 377 · 378) */
export const KIT_SIZES = [18, 20, 22, 24, 28, 32, 36, 42] as const;
export type KitGroup = "basic" | "compound" | "frame";
export const KIT_GROUP_LABEL: Record<KitGroup, string> = {
basic: "기본 부품 (552-2)",
compound: "묶음 부품 (552-3)",
frame: "틀 (552-4)",
};
export interface KitPart {
key: string;
name: string;
group: KitGroup;
/** 꼴 · 갈래 */
kinds: string;
/** 만들 파일(`ui_template/` 안) */
file: string;
/** 지금 견본 — 상태마다 하나 · null = 그 상태 없음 · 없으면 빈 자리 */
demo?: (state: KitState) => HTMLElement | null;
/** 견본이 옮기기 전 PageKit · 옛 공용 부품인지 */
legacy?: boolean;
}
/** 상태 굳히기 — 마우스 · 눌림 · 오류 = data-state · 막힘 = disabled */
export function markState<T extends HTMLElement>(node: T, state: KitState): T {
if (state === "disabled") {
if ("disabled" in node) (node as unknown as { disabled: boolean }).disabled = true;
node.setAttribute("aria-disabled", "true");
} else if (state !== "default") {
node.dataset.state = state;
if (state === "error") {
node.classList.add("is-error");
node.setAttribute("aria-invalid", "true");
}
}
return node;
}
const row = (...children: HTMLElement[]): HTMLElement => {
const box = document.createElement("div");
box.className = "ui-kit__demo-row";
box.append(...children);
return box;
};
const noop = (): void => {};
// prettier-ignore
export const KIT_PARTS: KitPart[] = [
// ── 552-2 기본 ─────────────────────────────────────────────
{ key: "button", name: "단추", group: "basic", kinds: "주 · 보조 · 고스트 · 위험 · 위험 고스트 · 글 링크 · 여덟 단", file: "ui_template_button.ts", legacy: true,
demo: (s) => row(markState(createButton({ label: "저장", onClick: noop }), s), markState(createButton({ label: "취소", variant: "ghost", onClick: noop }), s), markState(createButton({ label: "삭제", variant: "danger", onClick: noop }), s)) },
{ key: "icon-button", name: "아이콘 단추", group: "basic", kinds: "고스트 · 선 · 채움 · 여덟 단 · title 필수", file: "ui_template_button.ts" },
{ key: "input", name: "입력", group: "basic", kinds: "글 · 수 · 단위 붙음 · 찾기 · 날짜 · 여덟 단", file: "ui_template_input.ts", legacy: true,
demo: (s) => { const f = createInputField({ placeholder: "이름" }); markState(f.input, s); return f.root; } },
{ key: "select", name: "고르기", group: "basic", kinds: "한 개 · 여덟 단", file: "ui_template_input.ts", legacy: true,
demo: (s) => { const f = createSelectField({ options: [{ value: "a", text: "임도" }, { value: "b", text: "예정노선" }] }); markState(f.select, s); return f.root; } },
{ key: "check", name: "체크 · 라디오", group: "basic", kinds: "체크 · 라디오 · 글 붙음", file: "ui_template_checkbox.ts" },
{ key: "toggle", name: "켜기", group: "basic", kinds: "켬 · 끔 · 글 붙음", file: "ui_template_toggle.ts" },
{ key: "segment", name: "분할 고르기", group: "basic", kinds: "글 · 아이콘 · 여덟 단", file: "ui_template_segment.ts" },
{ key: "tabs", name: "탭", group: "basic", kinds: "밑줄 탭(한 꼴) · 수 배지 · 문서 탭은 꼴 그대로", file: "ui_template_tabs.ts", legacy: true,
demo: (s) => (s === "default" ? tabBar([{ key: "a", label: "개요" }, { key: "b", label: "구성원" }, { key: "c", label: "기록" }], "a", noop).root : null) },
{ key: "badge", name: "배지", group: "basic", kinds: "상태 · 역할 · 종류 · 수 · 램프(✓ 같이)", file: "ui_template_badge.ts", legacy: true,
demo: (s) => (s === "default" ? row(badge("진행 중", "accent"), badge("완료", "success"), badge("멈춤", "warning"), badge("오류", "danger"), badge("임시", "neutral")) : null) },
{ key: "tooltip", name: "툴팁", group: "basic", kinds: "마우스 글 · 값 풍선", file: "ui_template_hover_info.ts" },
{ key: "icon", name: "아이콘", group: "basic", kinds: "Tabler 선 · 16 · 18 · 24 · 32", file: "ui_template_icons.ts", legacy: true,
demo: (s) => (s === "default" ? row(icon("home"), icon("folder"), icon("route"), icon("search"), icon("bell"), icon("users")) : null) },
// ── 552-3 묶음 ─────────────────────────────────────────────
{ key: "card", name: "카드", group: "compound", kinds: "선 카드 · 머리 카드 · 고를 수 있는 카드", file: "ui_template_card.ts" },
{ key: "collapsible", name: "접이 카드", group: "compound", kinds: "패널 카드 · 구역 · 캐럿 셰브런", file: "ui_template_collapsible.ts" },
{ key: "panel-head", name: "패널 머리", group: "compound", kinds: "제목 · 부제 · 오른쪽 단추", file: "ui_template_panel_head.ts" },
{ key: "table", name: "표", group: "compound", kinds: "데이터표(28 · 밀도) · 일반표(40) · ⋯ 칸 · 한글 배지 · 빈 줄", file: "ui_template_table.ts", legacy: true,
demo: (s) => (s === "default" ? dataTable([{ label: "이름" }, { label: "상태", width: "90px" }, { label: "연장", width: "80px", align: "end" }, { label: "", width: "44px" }], [["임도 1 노선", badge("진행 중", "accent"), "1,080 m", rowMenu([{ label: "열기", onClick: noop }])], ["임도 2 노선", badge("완료", "success"), "640 m", rowMenu([{ label: "열기", onClick: noop }])]]) : null) },
{ key: "row-menu", name: "⋯ · 오른쪽 메뉴", group: "compound", kinds: "줄 끝 ⋯ · 오른쪽 누름 메뉴 · 위험 항목", file: "ui_template_context_menu.ts", legacy: true,
demo: (s) => (s === "default" ? rowMenu([{ label: "이름 바꾸기", onClick: noop }, { label: "삭제", danger: true, onClick: noop }]) : null) },
{ key: "empty", name: "빈 상태", group: "compound", kinds: "선 아이콘 · 한 줄 · 주 단추 · 사용법", file: "ui_template_empty.ts", legacy: true,
demo: (s) => (s === "default" ? emptyState({ icon: "folder", text: "아직 프로젝트가 없음" }) : null) },
{ key: "modal", name: "모달 · 확인창", group: "compound", kinds: "보통 · 넓음 · 확인 · 위험 확인", file: "ui_template_modal.ts" },
{ key: "drawer", name: "오른쪽 서랍", group: "compound", kinds: "상세 · 설정", file: "ui_template_side_panels.ts" },
{ key: "toast", name: "알림", group: "compound", kinds: "토스트 4꼴 · 띠 알림 · 로딩 가림막", file: "ui_template_toast.ts" },
{ key: "toolbar", name: "도구줄", group: "compound", kinds: "단추 묶음 · 나눔선 · 찾기 · 고르기", file: "ui_template_toolbar.ts" },
{ key: "steps", name: "단계 줄", group: "compound", kinds: "가로 막대 · 세로 목록 · compact", file: "ui_template_workflow_layout.ts" },
{ key: "stat", name: "숫자 카드", group: "compound", kinds: "값 · 단위 · 변화", file: "ui_template_stat.ts" },
{ key: "progress", name: "진행 막대", group: "compound", kinds: "막대 · 원", file: "ui_template_progress.ts" },
{ key: "mini-chart", name: "작은 그래프", group: "compound", kinds: "막대 · 원 · 선", file: "ui_template_elements_chart.ts" },
{ key: "setting-row", name: "설정 줄", group: "compound", kinds: "제목 · 설명 · 오른쪽 값 · 조작", file: "ui_template_setting_row.ts", legacy: true,
demo: (s) => (s === "default" ? settingCard([settingRow("회사 이름", "보고서 머리에 들어감", valueText("산림설계")), settingRow("단위", "", valueText(null))]) : null) },
{ key: "drop", name: "파일 놓기 칸", group: "compound", kinds: "끌어 놓기 · 고르기 단추 · 진행", file: "ui_template_drop.ts" },
// ── 552-4 틀 ───────────────────────────────────────────────
{ key: "work-frame", name: "작업 화면 틀", group: "frame", kinds: "왼쪽 패널 · 본문 · 아래 단추 줄 · [?][<][>] · 머리 줄 없음 · ☰ 서랍", file: "ui_template_overlay.ts" },
{ key: "page-frame", name: "일반 화면 틀", group: "frame", kinds: "페이지 머리(제목 · 설명 · 주 단추) · 몸통", file: "ui_template_general_layout.ts" },
{ key: "public-frame", name: "공개 화면 틀", group: "frame", kinds: "공개 머리 · 본문 · 바닥글", file: "ui_template_public_layout.ts" },
];