From f941ddc3f565781b00231eb19f5eae253a9d1e8f Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 21:28:17 +0900 Subject: [PATCH] =?UTF-8?q?feat(ui-kit):=20=ED=8B=80=20=EB=B6=80=ED=92=88?= =?UTF-8?q?=20=EC=97=AC=EC=84=AF=20=E2=80=94=20=EC=9E=91=EC=97=85=20?= =?UTF-8?q?=ED=99=94=EB=A9=B4=20=ED=8B=80=20=EA=B2=89=20=C2=B7=20=EC=9D=BC?= =?UTF-8?q?=EB=B0=98=20=ED=99=94=EB=A9=B4=20=ED=8B=80=20=C2=B7=20=EA=B3=B5?= =?UTF-8?q?=EA=B0=9C=20=ED=99=94=EB=A9=B4=20=ED=8B=80=20=C2=B7=20=EB=8B=A8?= =?UTF-8?q?=EA=B3=84=20=EC=A4=84=20=C2=B7=20=ED=8C=A8=EB=84=90=20=EB=A8=B8?= =?UTF-8?q?=EB=A6=AC=20=C2=B7=20=EB=AA=A9=EB=A1=9D=20=EC=A4=84=20(PLAN=205?= =?UTF-8?q?52-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 작업 화면 틀(overlay): 왼쪽 패널 = 흰 판 + 1 px 선 · 그림자 없음 · 덮개 = --color-scrim · 손잡이 강조색 그대로 · 자리 · 폭 그대로 - 일반 화면 틀 createPageFrame(대시보드 세부 꼴) · 공개 화면 틀 createPublicFrame(A01 hero · section 꼴) - 단계 줄 data-size 28 · 40 · 상태 = 공통 값 · 패널 머리 createPanelHead · 목록 줄 createListRow(옛 .ui-sidebar-row 겉 함께) - 부품 모음: 떠 있는 판 · 서랍 · 도구줄 · 단계 줄 · 패널 머리 · 목록 줄 여섯 줄을 frame 묶음으로 옮김 · 견본 = ui_template_ui_kit_frames.ts - 지킴 시험 test_552_4_frames.py · B02~B09 · D · E · M02 전후 자리 수치 같음 Co-Authored-By: Claude Opus 5.5 --- resources/tester/test_552_4_frames.py | 97 ++++++++++++ ui_template/ui_template_general_layout.css | 42 ++++- ui_template/ui_template_general_layout.ts | 50 ++++++ ui_template/ui_template_list_row.css | 116 ++++++++++++++ ui_template/ui_template_list_row.ts | 76 +++++++++ ui_template/ui_template_overlay.css | 45 ++---- ui_template/ui_template_overlay.ts | 14 ++ ui_template/ui_template_panel_head.css | 49 ++++++ ui_template/ui_template_panel_head.ts | 53 +++++++ ui_template/ui_template_public_layout.css | 81 ++++++++++ ui_template/ui_template_public_layout.ts | 63 ++++++++ ui_template/ui_template_ui_kit_frames.ts | 161 ++++++++++++++++++++ ui_template/ui_template_ui_kit_parts.ts | 25 +-- ui_template/ui_template_workflow_layout.css | 15 +- ui_template/ui_template_workflow_layout.ts | 13 ++ 15 files changed, 852 insertions(+), 48 deletions(-) create mode 100644 resources/tester/test_552_4_frames.py create mode 100644 ui_template/ui_template_list_row.css create mode 100644 ui_template/ui_template_list_row.ts create mode 100644 ui_template/ui_template_panel_head.css create mode 100644 ui_template/ui_template_panel_head.ts create mode 100644 ui_template/ui_template_public_layout.css create mode 100644 ui_template/ui_template_public_layout.ts create mode 100644 ui_template/ui_template_ui_kit_frames.ts diff --git a/resources/tester/test_552_4_frames.py b/resources/tester/test_552_4_frames.py new file mode 100644 index 000000000..70770bfd8 --- /dev/null +++ b/resources/tester/test_552_4_frames.py @@ -0,0 +1,97 @@ +"""552-4 틀 부품 아홉 지킴 시험 — 화면 확인은 부품 모음(`#/b01-account?view=ui-kit`) · ORCA. + +부품마다(이름 · data-size · 상태 규칙 · 날 색 없음): +· 작업 화면 틀(overlay) · 일반 화면 틀(general_layout) · 공개 화면 틀(public_layout) +· 떠 있는 판 · 오른쪽 서랍(side_panels) · 도구줄 · 단계 줄(workflow_layout) · 패널 머리 · 목록 줄 +· 부품 모음 목록 = 아홉 모두 `frame` 묶음 · 견본(demo) 있음 · 파일 이름 = 552-1 목록 그대로 +· 옛 공용 이름(`ui-sidebar-actions` · `ui-sidebar-row` · `ui-workflow-layout__step` …)은 그대로 냄 +""" + +from __future__ import annotations + +import re +from pathlib import Path + +import pytest + +ROOT = Path(__file__).resolve().parents[2] +UI = ROOT / "ui_template" + +# 부품 열쇠 → (ts 파일, css 파일, 만들기 함수, 낼 클래스, 높이 단) +PARTS = { + "work-frame": ("ui_template_overlay", "createWorkflowOverlays", "ui-workflow-overlay", ()), + "page-frame": ("ui_template_general_layout", "createPageFrame", "ui-page-frame", ()), + "public-frame": ("ui_template_public_layout", "createPublicFrame", "ui-public-frame", ()), + "steps": ("ui_template_workflow_layout", "createStepBar", "ui-workflow-layout__step", (28, 40)), + "panel-head": ("ui_template_panel_head", "createPanelHead", "ui-panel-head", (32,)), + "list-row": ("ui_template_list_row", "createListRow", "ui-list-row", (28, 32, 36)), +} + +RAW_COLOR = re.compile(r"#[0-9a-fA-F]{3,8}\b|rgba?\(|hsla?\(|var\(--(?:b01|draw)-") + + +def _text(path: Path) -> str: + return path.read_text(encoding="utf-8") + + +def _strip_comments(css: str) -> str: + return re.sub(r"/\*[\s\S]*?\*/", "", css) + + +@pytest.mark.parametrize("key", PARTS) +def test_머리_이름_크기(key): + stem, maker, cls, sizes = PARTS[key] + ts = _text(UI / f"{stem}.ts") + head = ts.split("*/", 1)[0] + # 규칙 8 — 머리에 무엇 · 꼴 · 크기 · 상태 · 쓰는 법 · 대신하는 옛 변종 + for word in ("꼴", "크기", "상태", "쓰는 법", "대신하는 옛 변종"): + assert word in head, f"{stem} 머리에 {word} 없음" + assert re.search(rf"export function {maker}\b", ts), maker + assert cls in ts + css = _strip_comments(_text(UI / f"{stem}.css")) + for size in sizes: + assert f'[data-size="{size}"]' in css or f"min-height: {size}px" in css, (stem, size) + assert len(ts.splitlines()) <= 700 and len(css.splitlines()) <= 700 + + +@pytest.mark.parametrize("key", PARTS) +def test_날_색_없음(key): + stem = PARTS[key][0] + css = _strip_comments(_text(UI / f"{stem}.css")) + assert not RAW_COLOR.search(css), (stem, RAW_COLOR.search(css)) + assert "font-family" not in css # 글꼴 = --font-ui 상속 + + +def test_상태_규칙(): + # 마우스 · 눌림을 data-state 로 굳혀 보일 수 있음 + for stem in ("ui_template_list_row", "ui_template_workflow_layout"): + css = _text(UI / f"{stem}.css") + assert ':is(:hover, [data-state="hover"])' in css, stem + row = _text(UI / "ui_template_list_row.css") + assert ".ui-sidebar-row.is-active" in row and '[aria-disabled="true"]' in row + + +def test_작업_화면_틀_겉(): + css = _strip_comments(_text(UI / "ui_template_overlay.css")) + title = re.search(r"\.ui-workflow-overlay__panel--title \{([^}]*)\}", css).group(1) + # 흰 판 + 1 px 선 · 그림자 없음(요구 375 정함 7) + assert "box-shadow: none" in title and "border-right-color: var(--color-border)" in title + # 손잡이는 강조색 그대로(2026-08-04 사용자 지시) + assert "border: 1px solid var(--panel-handle-border" in css + assert "background: var(--color-scrim)" in css + # 옛 이름 그대로 — 아래 단추 줄 · [?][<][>] + for name in (".ui-sidebar-actions", ".ui-workflow-overlay__handle--side"): + assert name in css + ts = _text(UI / "ui_template_overlay.ts") + assert 'import "./ui_template_list_row.css"' in ts # 옛 목록 줄 겉도 함께 + + +def test_부품_모음_목록(): + src = _text(UI / "ui_template_ui_kit_parts.ts") + rows = dict(re.findall(r'\{ key: "([^"]+)", name: "[^"]+", group: "(\w+)"', src)) + lines = src.splitlines() + for key, (stem, *_rest) in PARTS.items(): + assert rows.get(key) == "frame", key + i = next(i for i, x in enumerate(lines) if f'key: "{key}"' in x) + assert f'file: "{stem}.ts"' in lines[i], key + assert "demo:" in lines[i] + lines[i + 1], key diff --git a/ui_template/ui_template_general_layout.css b/ui_template/ui_template_general_layout.css index 1cdc97764..37bd43589 100644 --- a/ui_template/ui_template_general_layout.css +++ b/ui_template/ui_template_general_layout.css @@ -19,7 +19,6 @@ .ui-general-layout__title { color: var(--color-text); - font-family: var(--font-display); font-size: var(--text-heading-sm); } @@ -34,6 +33,47 @@ gap: var(--spacing-24); } +/* 일반 화면 틀(552-4) — 대시보드 세부 페이지 꼴(제목 20 · 설명 13 = 대시보드 값) */ +.ui-page-frame { + display: flex; + flex-direction: column; + gap: var(--spacing-16); + max-width: 1120px; + color: var(--color-text); +} + +.ui-page-frame__head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: var(--spacing-16); +} + +.ui-page-frame__title { + margin: 0; + color: var(--color-text); + font-size: 20px; + font-weight: var(--font-weight-bold); +} + +.ui-page-frame__desc { + margin: var(--spacing-4) 0 0; + color: var(--color-text-muted); + font-size: 13px; +} + +.ui-page-frame__action { + display: flex; + flex: none; + gap: var(--spacing-8); +} + +.ui-page-frame__body { + display: flex; + flex-direction: column; + gap: 12px; +} + .ui-general-block__section-header { display: flex; align-items: center; diff --git a/ui_template/ui_template_general_layout.ts b/ui_template/ui_template_general_layout.ts index 01bd2e755..6cf887e0a 100644 --- a/ui_template/ui_template_general_layout.ts +++ b/ui_template/ui_template_general_layout.ts @@ -1,3 +1,14 @@ +/* ============================================================================= + * ui_template_general_layout.ts + * 일반 화면 틀 `page-frame`(552-4 틀) — 프로젝트 밖 화면(대시보드 세부 · 설정 · B02 · 결제) 몸통: 페이지 머리 + 몸. + * 꼴 = createPageFrame — 머리(제목 20 · 설명 13 · 오른쪽 주 단추 자리) + 몸(세로 쌓음) · 대시보드 세부 페이지 꼴 + * createGeneralLayout — 바탕 · 가운데 폭(--page-max-width) · 큰 제목(옛 꼴 그대로) + * 크기 = 폭 1120(대시보드 세부) · 머리 · 몸 사이 16 · 몸 안 사이 12 + * 상태 = 없음 + * 쓰는 법 = const f = createPageFrame(host, "구성원", "회사 사람 · 역할"); f.setAction(addBtn); f.body.append(table) + * 대신하는 옛 변종 = B01 `pageFrame`(`.b01-pg__head`) · `.b01-proj-head` · M01 `m01-head` · `.ui-wf`(안 씀) + * ========================================================================== */ + import "./ui_template_general_layout.css"; export interface GeneralLayoutOptions { @@ -43,3 +54,42 @@ export function createGeneralLayout(options: GeneralLayoutOptions): GeneralLayou root.append(inner); return { root, content }; } + +export interface PageFrame { + root: HTMLElement; + body: HTMLElement; + /** 머리 오른쪽 주 단추 — null = 비움 */ + setAction: (action: HTMLElement | null) => void; +} + +/** 일반 화면 틀 — host 에 붙임(null = 안 붙임) */ +export function createPageFrame(host: HTMLElement | null, title: string, desc = ""): PageFrame { + const root = document.createElement("div"); + root.className = "ui-page-frame"; + const head = document.createElement("header"); + head.className = "ui-page-frame__head"; + const text = document.createElement("div"); + text.className = "ui-page-frame__title-wrap"; + const h = document.createElement("h2"); + h.className = "ui-page-frame__title"; + h.textContent = title; + text.append(h); + if (desc) { + const p = document.createElement("p"); + p.className = "ui-page-frame__desc"; + p.textContent = desc; + text.append(p); + } + const slot = document.createElement("div"); + slot.className = "ui-page-frame__action"; + head.append(text, slot); + const body = document.createElement("div"); + body.className = "ui-page-frame__body"; + root.append(head, body); + host?.append(root); + return { + root, + body, + setAction: (action) => slot.replaceChildren(...(action ? [action] : [])), + }; +} diff --git a/ui_template/ui_template_list_row.css b/ui_template/ui_template_list_row.css new file mode 100644 index 000000000..c0aef34a3 --- /dev/null +++ b/ui_template/ui_template_list_row.css @@ -0,0 +1,116 @@ +/* 목록 줄(552-4) — `.ui-sidebar-row` = 옛 이름(M01 · M02 좌측 목록) · 자리 · 크기는 그대로 · 겉만 공통 값 + 새 줄(`.ui-list-row`)은 data-size 높이 단 · 상태 = :is(:hover, [data-state]) 로 부품 모음이 굳혀 보임 */ +.ui-sidebar-row { + display: flex; + justify-content: space-between; + gap: var(--spacing-8); + padding: var(--spacing-4) var(--spacing-8); + border: 0; + border-radius: var(--radius-buttons); + background: transparent; + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); + text-align: left; + cursor: pointer; +} + +.ui-sidebar-row:is(:hover, [data-state="hover"]) { + background: var(--color-hover); +} + +.ui-sidebar-row:is(:active, [data-state="active"]) { + background: var(--color-border); +} + +.ui-sidebar-row.is-active { + background: var(--color-accent-soft); + color: var(--color-accent); +} + +.ui-sidebar-row:is(:disabled, [aria-disabled="true"]) { + background: transparent; + color: var(--color-text-muted); + opacity: 0.5; + cursor: not-allowed; +} + +/* 새 줄 — 높이 단 · 글 · 덧붙임 */ +.ui-list-row { + box-sizing: border-box; + align-items: center; + width: 100%; + padding-block: 0; +} + +.ui-list-row[data-size="28"] { + min-height: 28px; +} + +.ui-list-row[data-size="32"] { + min-height: 32px; +} + +.ui-list-row[data-size="36"] { + min-height: 36px; +} + +.ui-list-row__text { + display: flex; + flex: 1 1 auto; + flex-direction: column; + min-width: 0; +} + +.ui-list-row__label, +.ui-list-row__sub { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.ui-list-row__sub { + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.ui-list-row__trailing { + display: inline-flex; + flex: none; + align-items: center; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.ui-list-row__no { + display: inline-grid; + flex: none; + place-items: center; + width: 18px; + height: 18px; + border-radius: var(--radius-pills); + background: var(--color-surface); + color: var(--color-text-secondary); + font-size: var(--text-caption); +} + +.ui-list-row.is-active .ui-list-row__no { + background: var(--color-accent); + color: var(--color-primary-text); +} + +/* 끌기 손잡이 — 점 여섯(2 × 3) */ +.ui-list-row__grip { + flex: none; + width: 8px; + height: 14px; + background: radial-gradient(circle, var(--color-text-muted) 1px, transparent 1.5px) 0 0 / 4px + 4.6px; + cursor: grab; +} + +.ui-list-row--stripe { + box-shadow: inset 3px 0 0 var(--ui-list-row-stripe, var(--color-border)); + border-radius: 0 var(--radius-buttons) var(--radius-buttons) 0; +} diff --git a/ui_template/ui_template_list_row.ts b/ui_template/ui_template_list_row.ts new file mode 100644 index 000000000..7b421b28d --- /dev/null +++ b/ui_template/ui_template_list_row.ts @@ -0,0 +1,76 @@ +/* ============================================================================= + * ui_template_list_row.ts + * 목록 줄 `list-row`(552-4 틀) — 패널 · 목록 안 고르는 줄 하나 · 왼쪽 글 · 오른쪽 덧붙임. + * 꼴 = 고르는 줄 · 두 줄(sub) · 끌기 손잡이(grip) · 왼쪽 색 띠(stripe) · 번호 원(no) + * 크기 = data-size 28 · 32 · 36 (기본 32) + * 상태 = 마우스 · 눌림 · 고름(`is-active` + aria-selected) · 막힘(disabled) + * 쓰는 법 = list.append(createListRow({ label: "B1 배수관", active: true, onClick: pick })) + * 대신하는 옛 변종 = `.ui-sidebar-row`(이 이름도 그대로 냄) · `.ui-tree__row` · `.ui-find__row` · + * `b07-plan-manage__row` + `__grip` · `b05-drainage__basin` · `d02-points__row` · `m02-pick__item` … + * ========================================================================== */ + +import "./ui_template_list_row.css"; + +export interface ListRowOptions { + label: string; + /** 둘째 줄(흐린 글) */ + sub?: string; + /** 오른쪽 끝 덧붙임(값 · 배지 · 단추) */ + trailing?: HTMLElement | string; + /** 왼쪽 번호 원 */ + no?: number | string; + /** 왼쪽 색 띠 — 공통 값 이름(예: "var(--color-success)") */ + stripe?: string; + /** 끌기 손잡이 — 끌기 동작은 부르는 쪽 몫 */ + grip?: boolean; + active?: boolean; + disabled?: boolean; + size?: 28 | 32 | 36; + title?: string; + onClick?: (event: Event) => void; +} + +function span(className: string, text?: string): HTMLSpanElement { + const n = document.createElement("span"); + n.className = className; + if (text !== undefined) n.textContent = text; + return n; +} + +/** 줄 하나 — onClick 이 있으면