From 361ce516591dc60464ede17c858f9aacf471676a Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 22:33:35 +0900 Subject: [PATCH 1/6] =?UTF-8?q?refactor(B08=C2=B7B09):=20=EC=9E=90?= =?UTF-8?q?=EC=B2=B4=20=EB=B3=80=EC=A2=85=20=E2=86=92=20=EB=B6=80=ED=92=88?= =?UTF-8?q?=20=EC=83=81=EC=9E=90=20=E2=80=94=20=ED=83=AD=20=EC=A4=84=20?= =?UTF-8?q?=C2=B7=20=EB=B6=84=ED=95=A0=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20?= =?UTF-8?q?=C2=B7=20=EB=B0=B0=EC=A7=80=20=EC=B9=A9=20=C2=B7=20=EB=8F=84?= =?UTF-8?q?=EA=B5=AC=EC=A4=84=20=C2=B7=20=EB=96=A0=20=EC=9E=88=EB=8A=94=20?= =?UTF-8?q?=ED=8C=90=20=C2=B7=20=EB=B3=B4=EC=A1=B0=20=EA=B8=80=20(PLAN=205?= =?UTF-8?q?53-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 위 · 보조 탭 줄 = 부품 탭(fillTabs · 밑줄 · 알약) · B09 좌측 스위치 = 분할 고르기 - 연결 칩 · 검사 탭 이름 · 끌기 태그 = 배지 · 이정표 도구 · 머리 줄 · 저장 줄 = 도구줄 · 구간 고침 · 행 고름 창 = 떠 있는 판 - 안내 · 빈 줄 · 상태 글 = 보조 글 · 운반 칸 = 카드 · 인쇄 미리보기 좌측 줄 = B09 dock 한 벌 · 쪽 체크 = 라디오 · 체크 부품 - 시험 흉내 틀 · 문구 맞춤 Co-Authored-By: Claude Sonnet 5.5 --- B08_Quantity/B08_Quantity_UI_Drawing.css | 39 +-------- B08_Quantity/B08_Quantity_UI_Drawing.ts | 6 +- B08_Quantity/B08_Quantity_UI_Earthwork.ts | 33 +++----- B08_Quantity/B08_Quantity_UI_Foot.ts | 6 +- B08_Quantity/B08_Quantity_UI_Hydro.css | 9 +- B08_Quantity/B08_Quantity_UI_Hydro.ts | 39 ++++----- B08_Quantity/B08_Quantity_UI_Link.ts | 29 +++++-- B08_Quantity/B08_Quantity_UI_Material.ts | 12 ++- B08_Quantity/B08_Quantity_UI_Milestone.css | 24 +----- B08_Quantity/B08_Quantity_UI_Milestone.ts | 50 +++++------ .../B08_Quantity_UI_Milestone_Diagram.ts | 10 ++- .../B08_Quantity_UI_Milestone_LegModal.ts | 3 +- .../B08_Quantity_UI_Milestone_Route.ts | 26 ++++-- B08_Quantity/B08_Quantity_UI_Options.ts | 6 +- B08_Quantity/B08_Quantity_UI_Page.css | 27 +++--- B08_Quantity/B08_Quantity_UI_Page.ts | 72 +++++++--------- B08_Quantity/B08_Quantity_UI_Summary.ts | 6 +- B09_Estimation/B09_Estimation_UI_Check.ts | 17 ++-- B09_Estimation/B09_Estimation_UI_Dock.ts | 36 ++------ .../B09_Estimation_UI_Haul_Steps.css | 6 +- .../B09_Estimation_UI_Haul_Steps.ts | 2 +- B09_Estimation/B09_Estimation_UI_Page.css | 83 ++++++++----------- B09_Estimation/B09_Estimation_UI_Popup.ts | 30 +++---- B09_Estimation/B09_Estimation_UI_Shell.ts | 6 +- B09_Estimation/B09_Estimation_UI_TabBar.ts | 78 ++++++++--------- .../B09_Estimation_UI_Tabs_Bill_Drag.ts | 5 +- .../B09_Estimation_UI_Tabs_Bill_Level.ts | 6 +- B09_Estimation/draft/B09_Draft_Print_Page.css | 49 +---------- B09_Estimation/draft/B09_Draft_Print_Page.ts | 58 ++++++------- .../tester/helper_298_2_b08_b09_buttons.cjs | 13 +++ resources/tester/helper_481_2_save_move.cjs | 13 +++ resources/tester/helper_b09_group_cards.py | 46 +++++++++- .../tester/test_200_2_b09_bill_level_bar.py | 1 + .../tester/test_202_1_table_hint_bottom.py | 6 +- resources/tester/test_244_1_b08_milestone.py | 2 +- .../test_250_1_b08_milestone_roads_width.py | 4 +- resources/tester/test_302_1_print_frame.py | 10 ++- resources/tester/test_304_b09_view_setting.py | 2 +- 38 files changed, 392 insertions(+), 478 deletions(-) diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.css b/B08_Quantity/B08_Quantity_UI_Drawing.css index 1b54d1c94..3e517bed6 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.css +++ b/B08_Quantity/B08_Quantity_UI_Drawing.css @@ -33,7 +33,7 @@ } /* M02 에 없는 조합 안내 — 빨간 글 */ -.b08-sheets__combo > .ui-general-block__empty { +.b08-sheets__combo > .ui-text { color: var(--color-danger); } @@ -75,31 +75,10 @@ color: var(--color-text); } +/* 칩 = 부품 배지(알약) · 색은 상태마다 부품 색 · 없어진 원본 = 빨강 점선 */ .b08-link__chip { - display: inline-flex; - align-items: center; gap: var(--spacing-8); max-width: 100%; - padding: 2px var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-pills); -} - -.b08-link__chip.is-missing { - border-color: var(--color-danger); -} - -/* 칩 테두리 상태 색(170-3) — 연결됨 · 마스터가 바뀜 · 고침 있음 · 원본 없어짐(빨강 점선) */ -.b08-link__chip.is-linked { - border-color: var(--color-success); -} - -.b08-link__chip.is-changed { - border-color: var(--color-warning); -} - -.b08-link__chip.is-edited { - border-color: var(--color-info); } .b08-link__chip.is-gone { @@ -128,17 +107,3 @@ .b08-link__badges:empty { display: none; } - -/* 배지(163-4) — 고침 있음 · 마스터가 바뀜(경고) */ -.b08-link__badge { - padding: 0 var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-md); - font-size: var(--text-caption); - color: var(--color-text); -} - -.b08-link__badge.is-warn { - border-color: var(--color-warning); - font-weight: var(--font-weight-semibold); -} diff --git a/B08_Quantity/B08_Quantity_UI_Drawing.ts b/B08_Quantity/B08_Quantity_UI_Drawing.ts index 785ef79c4..536571efd 100644 --- a/B08_Quantity/B08_Quantity_UI_Drawing.ts +++ b/B08_Quantity/B08_Quantity_UI_Drawing.ts @@ -15,6 +15,7 @@ import "./B08_Quantity_UI_Drawing.css"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; import { openModal } from "@ui/ui_template_modal"; import { showToast } from "@ui/ui_template_elements"; import { PART_OF, projectItems, withCopyValues } from "@ui/ui_template_item_picker_project"; @@ -37,10 +38,7 @@ function L(key: keyof typeof ui_locales): string { } function note(text: string): HTMLElement { - const node = document.createElement("p"); - node.className = "ui-general-block__empty"; - node.textContent = text; - return node; + return createText(text, "muted", 14); } export interface SheetsRequest { diff --git a/B08_Quantity/B08_Quantity_UI_Earthwork.ts b/B08_Quantity/B08_Quantity_UI_Earthwork.ts index 071d23d3c..8c33fbac4 100644 --- a/B08_Quantity/B08_Quantity_UI_Earthwork.ts +++ b/B08_Quantity/B08_Quantity_UI_Earthwork.ts @@ -12,8 +12,10 @@ * 자료 읽기 · 표 짜기 = `B08_Quantity_Earthwork_Tables`(화면 없음 · 인쇄 미리보기도 씀 · 302-3) * ========================================================================== */ -import { createButton, createCard } from "@ui/ui_template_elements"; +import { createCard } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; import { createDataTable } from "@ui/ui_template_data_table"; +import { fillTabs } from "../A00_Common/b_page_tabs"; import { footNote } from "./B08_Quantity_UI_Foot"; import { EARTHWORK_SHEETS, @@ -29,10 +31,7 @@ import { import { onOptionsChange } from "./B08_Quantity_UI_Options"; export function note(text: string): HTMLElement { - const p = document.createElement("p"); - p.className = "ui-general-block__empty"; - p.textContent = text; - return p; + return createText(text, "muted", 14); } /** 표 한 장 — 공용 컨테이너(카드) 안에 공용 표 · 아래 줄 · 맨 아래 안내 · 경고 줄(202-1) */ @@ -78,26 +77,18 @@ export async function renderEarthwork(host: HTMLElement, projectId: string): Pro const { table, interval, applyRock } = loaded; const tabs = document.createElement("div"); - tabs.className = "b08-quantity__tabs ui-subtabs b08-earthwork__tabs"; - tabs.setAttribute("role", "tablist"); + tabs.className = "b08-quantity__tabs b08-earthwork__tabs"; const view = document.createElement("div"); let current: SheetId = "summary"; const select = (picked: SheetId): void => { current = picked; - tabs.replaceChildren( - ...SHEETS.map(({ id, label }) => { - const button = createButton({ - label, - variant: id === picked ? "filled" : "ghost", - size: "sm", - onClick: () => id !== picked && select(id), - }); - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", String(id === picked)); - button.dataset.b08EarthworkSheet = id; - return button; - }), - ); + fillTabs(tabs, { + tabs: SHEETS.map(({ id, label }) => ({ key: id, label })), + value: picked, + kind: "pill", + data: "b08EarthworkSheet", + onChange: (id) => select(id as SheetId), + }); if (picked === "sheet") renderSheet(view, table, interval); else if (picked === "summary") renderEarthworkSummary(view, table); else if (picked === "haul") renderHaulDistance(view, table); diff --git a/B08_Quantity/B08_Quantity_UI_Foot.ts b/B08_Quantity/B08_Quantity_UI_Foot.ts index 1376cc3ad..4b6e12b6b 100644 --- a/B08_Quantity/B08_Quantity_UI_Foot.ts +++ b/B08_Quantity/B08_Quantity_UI_Foot.ts @@ -5,11 +5,11 @@ * ========================================================================== */ import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { createText } from "@ui/ui_template_text"; export function footNote(text: string): HTMLElement { - const p = document.createElement("p"); - p.className = "ui-general-block__empty b08-quantity__foot"; - p.textContent = text; + const p = createText(text, "muted"); + p.classList.add("b08-quantity__foot"); attachHoverInfo(p, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); return p; } diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.css b/B08_Quantity/B08_Quantity_UI_Hydro.css index 1454806e7..ea3ab52e7 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.css +++ b/B08_Quantity/B08_Quantity_UI_Hydro.css @@ -2,11 +2,8 @@ 색은 테마 토큰만(밝음 · 어두움 같이) · 테두리는 입력 · 선택 칸(공용 ui-input · ui-select)만 · 굵게 + 붉은 계열은 중요 결과(.is-key)만. */ +/* 보조 탭 + [저장] 한 줄 = 부품 도구줄 · 여기는 아래 틈만 */ .b08-hydro__toolbar { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: var(--spacing-16); margin-bottom: var(--spacing-16); } @@ -16,10 +13,6 @@ min-width: 0; } -.b08-hydro__toolbar > .ui-btn { - flex: 0 0 auto; -} - .b08-hydro__sheet { container-type: inline-size; /* 2열 ↔ 1열 = 창이 아닌 이 칸 폭 기준 */ color: var(--color-text-body); diff --git a/B08_Quantity/B08_Quantity_UI_Hydro.ts b/B08_Quantity/B08_Quantity_UI_Hydro.ts index 251ffbc22..f8f6ef64a 100644 --- a/B08_Quantity/B08_Quantity_UI_Hydro.ts +++ b/B08_Quantity/B08_Quantity_UI_Hydro.ts @@ -18,7 +18,10 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; import { clearState, writeState } from "../A00_Common/b_page_state"; +import { createToolbar } from "@ui/ui_template_toolbar"; +import { fillTabs } from "../A00_Common/b_page_tabs"; import { stationLabel } from "../common_util/common_util_station"; import { hydroCalc, @@ -36,10 +39,7 @@ import { buildHydroSheet, type HydroInputs, type HydroResult } from "./B08_Quant export { request }; function note(text: string): HTMLElement { - const p = document.createElement("p"); - p.className = "ui-general-block__empty"; - p.textContent = text; - return p; + return createText(text, "muted", 14); } /** `onSave` = 페이지 이동 전 저장 등록(481-2) — 고친 측점 없으면 요청 없이 참. */ @@ -68,17 +68,15 @@ export async function renderHydro( const inputsOf = (station: HydroStation): HydroInputs => hydroInputs(sheet, station, drafts); const tabs = document.createElement("div"); - tabs.className = "b08-quantity__tabs ui-subtabs b08-hydro__tabs"; - tabs.setAttribute("role", "tablist"); + tabs.className = "b08-quantity__tabs b08-hydro__tabs"; const save = createButton({ label: "저장", variant: "filled", onClick: () => void saveAll(), }); save.dataset.b08HydroSave = ""; - const toolbar = document.createElement("div"); - toolbar.className = "b08-hydro__toolbar"; - toolbar.append(tabs, save); + const toolbar = createToolbar({ items: [tabs, save], size: 32 }); + toolbar.classList.add("b08-hydro__toolbar"); const page = document.createElement("div"); host.replaceChildren(toolbar, page); let seq = 0; @@ -87,22 +85,17 @@ export async function renderHydro( const open = (index: number): void => { openIndex = index; const station = sheet.stations[index]; - tabs.replaceChildren( - ...sheet.stations.map((s, i) => { + fillTabs(tabs, { + tabs: sheet.stations.map((s, i) => { const label = stationLabel(s.chainage_m, interval); - const button = createButton({ - label: s.kind ? `${label} ${s.kind}` : label, - variant: i === index ? "filled" : "ghost", - size: "sm", - onClick: () => i !== index && open(i), - }); - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", String(i === index)); - button.dataset.b08HydroStation = String(s.chainage_m); - button.title = s.kind || s.facility; - return button; + return { key: String(i), label: s.kind ? `${label} ${s.kind}` : label }; }), - ); + value: String(index), + kind: "pill", + data: "b08HydroStation", + title: (key) => sheet.stations[Number(key)].kind || sheet.stations[Number(key)].facility, + onChange: (key) => open(Number(key)), + }); const draw = (inputs: HydroInputs): void => { const view = buildHydroSheet( stationLabel(station.chainage_m, interval), diff --git a/B08_Quantity/B08_Quantity_UI_Link.ts b/B08_Quantity/B08_Quantity_UI_Link.ts index 0fa90c214..546c70d47 100644 --- a/B08_Quantity/B08_Quantity_UI_Link.ts +++ b/B08_Quantity/B08_Quantity_UI_Link.ts @@ -15,6 +15,7 @@ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { createButton, showConfirmDialog, showToast } from "@ui/ui_template_elements"; import { attachCollapsible } from "@ui/ui_template_collapsible"; +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; import { projectItems, structureFolder, @@ -102,6 +103,22 @@ async function update(projectId: string, info: ProjectItemInfo): Promise = { + "is-linked": "success", + "is-missing": "danger", + "is-changed": "warning", + "is-edited": "info", + "is-gone": "danger", +}; + +/** 칩 = 부품 배지(알약 20) 꼴 · 색 = 상태(연결 초록 · 미연결 빨강 · 바뀜 노랑 · 고침 파랑 · 없어짐 빨강 점선) */ +function toneChip(chip: HTMLElement, state: ChipState): void { + const look = createBadge("", CHIP_TONE[state], { size: 20 }); + chip.className = `${look.className} b08-link__chip ${state}`; + chip.dataset.size = look.dataset.size ?? "20"; +} + /** 칩 배지 · 테두리 색 · [업데이트] — 사본 목록(원본판 대조)을 뒤에서 받아 칠함 · 못 받으면 단추 꺼진 채. */ async function paintCopyState( projectId: string, @@ -116,12 +133,8 @@ async function paintCopyState( .then((rows) => rows.find((row) => row.폴더 === folder) ?? null) .catch(() => null); if (!info) return null; - const badge = (key: keyof typeof ui_locales, warn: boolean): HTMLElement => { - const node = document.createElement("span"); - node.className = warn ? "b08-link__badge is-warn" : "b08-link__badge"; - node.textContent = L(key); - return node; - }; + const badge = (key: keyof typeof ui_locales, warn: boolean): HTMLElement => + createBadge(L(key), warn ? "warning" : "neutral", { size: 18 }); if (info.고침) badges.append(badge("B08_Quantity_Link_Edited", false)); if (info.원본없음) badges.append(badge("B08_Quantity_Link_Gone", true)); else if (info.마스터바뀜) badges.append(badge("B08_Quantity_Link_Changed", true)); @@ -133,7 +146,7 @@ async function paintCopyState( : info.고침 ? "is-edited" : null; - if (tone) chip.classList.replace("is-linked", tone); + if (tone) toneChip(chip, tone); button.disabled = !info.마스터바뀜 || !!info.원본없음; button.title = button.disabled ? L("B08_Quantity_Link_UpdateSame") @@ -173,7 +186,7 @@ export function linkSection( line.append(head); for (const slot of facility.slots) { const chip = document.createElement("span"); - chip.className = slot.linked ? "b08-link__chip is-linked" : "b08-link__chip is-missing"; + toneChip(chip, slot.linked ? "is-linked" : "is-missing"); chip.dataset.b08LinkSlot = slot.copy; const name = document.createElement("span"); name.className = "b08-link__name"; diff --git a/B08_Quantity/B08_Quantity_UI_Material.ts b/B08_Quantity/B08_Quantity_UI_Material.ts index b1e1f50a5..1293b25d8 100644 --- a/B08_Quantity/B08_Quantity_UI_Material.ts +++ b/B08_Quantity/B08_Quantity_UI_Material.ts @@ -21,7 +21,9 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; import { createDataTable } from "@ui/ui_template_data_table"; +import { createToolbar } from "@ui/ui_template_toolbar"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { blankUnitRows, @@ -53,10 +55,7 @@ const HINTS: Record = { const HIDDEN = "값 없는 줄은 숨김"; function note(text: string): HTMLElement { - const p = document.createElement("p"); - p.className = "ui-general-block__empty"; - p.textContent = text; - return p; + return createText(text, "muted", 14); } /** 글 → 값 · 빈칸 = null(일부러 비움) · 수가 아니면 undefined(무시) */ @@ -138,8 +137,8 @@ export async function renderMaterialSheet( const save = createButton({ label: "저장", variant: "filled", onClick: () => void saveAll() }); save.dataset.b08MaterialSave = ""; - const toolbar = document.createElement("div"); - toolbar.className = "b08-material__toolbar"; + const toolbar = createToolbar({ items: ["spacer", save], size: 32 }); + toolbar.classList.add("b08-material__toolbar"); const notes = document.createElement("div"); notes.append(footNote(sheet === "concrete" ? HINTS[sheet] : `${HINTS[sheet]} · ${HIDDEN}`)); if (sheet !== "quantity") { @@ -158,7 +157,6 @@ export async function renderMaterialSheet( const concreteNote = footNote(""); // 콘크리트 전용 빈 까닭(163-5) — 조작마다 다시 concreteNote.dataset.b08MaterialConcreteWhy = ""; if (sheet === "concrete") notes.append(concreteNote); - toolbar.append(save); let built = materialTable(sheet, sheets, overrides); const table = createDataTable({ diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.css b/B08_Quantity/B08_Quantity_UI_Milestone.css index bb1727f37..dd39f1f52 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.css +++ b/B08_Quantity/B08_Quantity_UI_Milestone.css @@ -41,12 +41,6 @@ min-width: 160px; } -.b08-milestone__bar { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-8); -} - .b08-milestone__image { max-width: 100%; max-height: 70vh; @@ -61,8 +55,6 @@ position: absolute; bottom: 8px; left: 8px; - display: flex; - gap: 6px; } .b08-milestone__diagram { @@ -157,14 +149,13 @@ .b08-milestone__leg-edit { position: fixed; z-index: 1000; +} + +.b08-milestone__leg-edit > .ui-float-panel__body { display: flex; align-items: flex-end; gap: 6px; padding: 6px; - border: 1px solid var(--color-border); - border-radius: 6px; - background: var(--color-surface); - box-shadow: 0 2px 8px rgba(0, 0, 0, 0.15); } .b08-milestone__leg-edit .ui-input { @@ -251,14 +242,7 @@ stroke-dasharray: none; } -/* 157 경로 탭 머리 줄 — 거래처 이름 · 주소(왼) + [그림 변경] · [초기화](오른) · 글자 = 메인 탭 크기 */ -.b08-milestone__route-head { - display: flex; - align-items: center; - justify-content: space-between; - gap: var(--spacing-16); -} - +/* 157 경로 탭 머리 줄 = 부품 도구줄(이름 · 주소 왼 + 단추 오른) */ .b08-milestone__route-title { display: flex; flex-wrap: wrap; diff --git a/B08_Quantity/B08_Quantity_UI_Milestone.ts b/B08_Quantity/B08_Quantity_UI_Milestone.ts index 1db32d83e..fcc744443 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone.ts @@ -17,8 +17,10 @@ import "./B08_Quantity_UI_Milestone.css"; import { createDataTable, type DataColumn } from "@ui/ui_template_data_table"; -import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { el, showToast } from "@ui/ui_template_elements"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { createText } from "@ui/ui_template_text"; +import { fillTabs } from "../A00_Common/b_page_tabs"; import type { Row } from "../B09_Estimation/B09_Estimation_Calc_Base"; import { openHaulModal } from "../B09_Estimation/B09_Estimation_UI_Haul_Modal"; import { openPriceLink } from "../B09_Estimation/B09_Estimation_UI_Tabs_Bill_Link"; @@ -146,10 +148,11 @@ function tableView(projectId: string): MilestoneView { }; let mode = "false:false"; let table = makeTable(milestoneColumns(false, false)); - const none = el("p", { - className: "ui-general-block__empty", - text: "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.", - }); + const none = createText( + "경로가 없습니다 — 다이어그램 공사지 · 기준점 [+]로 항목을 넣으세요.", + "muted", + 14, + ); const refresh = (): void => { const data = liveMilestone(projectId); const { options, rows, mode: next } = milestoneTable(data, siteInfo()?.mid_default_m ?? null); @@ -179,8 +182,7 @@ function tableView(projectId: string): MilestoneView { export function renderMilestone(host: HTMLElement, projectId: string): void { const tabs = el("div", { - className: "b08-quantity__tabs ui-subtabs b08-structure__tabs", - attrs: { role: "tablist" }, + className: "b08-quantity__tabs b08-structure__tabs", }); const view = el("div"); let current = "table"; @@ -188,22 +190,19 @@ export function renderMilestone(host: HTMLElement, projectId: string): void { const drawTabs = (): void => { const routes = liveMilestone(projectId).routes; - const tab = (id: string, label: string): HTMLButtonElement => { - const button = createButton({ - label, - variant: id === current ? "filled" : "ghost", - size: "sm", - onClick: () => id !== current && open(id), - }); - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", String(id === current)); - button.dataset.b08MilestoneTab = id; - return button; - }; - tabs.replaceChildren( - tab("table", "거리이정표"), - ...routes.map((r, i) => tab(r.id, r.name.trim() || r.rows[0]?.item || `거래처 ${i + 1}`)), - ); + fillTabs(tabs, { + tabs: [ + { key: "table", label: "거리이정표" }, + ...routes.map((r, i) => ({ + key: r.id, + label: r.name.trim() || r.rows[0]?.item || `거래처 ${i + 1}`, + })), + ], + value: current, + kind: "pill", + data: "b08MilestoneTab", + onChange: open, + }); }; function open(id: string): void { @@ -213,10 +212,7 @@ export function renderMilestone(host: HTMLElement, projectId: string): void { view.replaceChildren(body.root); } - host.replaceChildren( - tabs, - el("p", { className: "ui-general-block__empty", text: "거리이정표를 불러오는 중…" }), - ); + host.replaceChildren(tabs, createText("거리이정표를 불러오는 중…", "muted", 14)); loadMilestone(projectId).then( () => { host.replaceChildren(tabs, view); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts index 620055f02..c2d933641 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Diagram.ts @@ -19,6 +19,8 @@ * ========================================================================== */ import { createButton, createSelectField, el, showToast } from "@ui/ui_template_elements"; +import { createFloatPanel } from "@ui/ui_template_float_panel"; +import { createToolbar } from "@ui/ui_template_toolbar"; import { attachSvgZoomPan, type ZoomPanState } from "../A00_Common/b_svg_zoom_pan"; import { midKm, @@ -94,7 +96,10 @@ function editLegAt( km.placeholder = fmtKm(leg.auto_km); // 빈칸 = 자동 값 km.title = `자동 값 ${fmtKm(leg.auto_km)} km`; } - const box = el("div", { className: "b08-milestone__leg-edit", children: [road.root, km] }); + const pop = createFloatPanel({ popover: true }); + pop.body.append(road.root, km); + const box = pop.root; + box.classList.add("b08-milestone__leg-edit"); box.dataset.b08MilestoneLegEdit = `${from}:${i}`; const at = anchor.getBoundingClientRect(); // 팬 · 줌 뒤 글 자리 그대로 box.style.left = `${at.left}px`; @@ -300,7 +305,8 @@ export function createDiagram( onClick: () => openPlaceModal(projectId, { add: true }, addRoute), }); addElem.dataset.b08MilestoneAdd = "open"; - const tools = el("div", { className: "b08-milestone__tools", children: [arrange, addElem] }); + const tools = createToolbar({ items: [arrange, addElem], size: 32, glass: true }); + tools.classList.add("b08-milestone__tools"); /** 장소 그림(254-3 · 256-1) blob 주소 — 상자마다 키(이름#시각)가 바뀔 때만 다시 받음 */ const picUrls = new Map }>(); diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts index b306bf393..82aa61c66 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_LegModal.ts @@ -14,6 +14,7 @@ import { createCheckbox } from "@ui/ui_template_checkbox"; import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; import { openModal } from "@ui/ui_template_modal"; +import { createText } from "@ui/ui_template_text"; import { ROADS, roadOf, @@ -113,7 +114,7 @@ export function legsEditor( }; } -const hint = (text: string): HTMLElement => el("p", { className: "b08-options__hint", text }); +const hint = (text: string): HTMLElement => createText(text, "muted"); /** 시점 → 중점 — 도로 구분 · 거리 늘 손 입력(빈칸 = 노선 ÷ 2) */ function openMidLeg(projectId: string): void { diff --git a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts index 8e628dee3..4abd3d1a8 100644 --- a/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts +++ b/B08_Quantity/B08_Quantity_UI_Milestone_Route.ts @@ -17,6 +17,8 @@ import { type MapContextMenuItem, } from "@ui/ui_template_context_menu"; import { createButton, el, showToast } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; +import { createToolbar } from "@ui/ui_template_toolbar"; import type { MilestoneData, MilestoneRoute } from "./B08_Quantity_Milestone_Calc"; import { chainOf, ownerOf, reachesSite } from "./B08_Quantity_Milestone_Layout"; import { DEST_TEXT } from "./B08_Quantity_Milestone_Table"; @@ -76,6 +78,13 @@ export interface MilestoneView { refresh: () => void; } +/** 머리 줄 — 이름 · 주소(왼) + 단추(오른) = 부품 도구줄 */ +function routeHead(title: HTMLElement, buttons: HTMLElement[]): HTMLElement { + const bar = createToolbar({ items: [title, "spacer", ...buttons], size: 32 }); + bar.classList.add("b08-milestone__route-head"); + return bar; +} + export function routeView(projectId: string, routeId: string): MilestoneView { // 157 머리 줄 = 거래처 이름 · 주소(왼) + [그림 변경] · [초기화](오른) · 글자 = 메인 탭 크기 const name = el("strong", { className: "b08-milestone__route-name" }); @@ -155,11 +164,11 @@ export function routeView(projectId: string, routeId: string): MilestoneView { return [routeId, ...chain].map((id) => segmentPath(projectId, id)).filter((p) => p.length >= 2); }; // 255-2 길찾기 실패 = 안내 한 줄(까닭 줄 = 「 — 」) - const status = el("p", { className: "b08-options__hint" }); + const status = createText("", "muted"); status.dataset.b08MilestoneRouteStatus = ""; status.hidden = true; // 373 안 이은 기준점에 이음 = 시점까지 안 닿음 안내(이은 거래처만) - const open = el("p", { className: "b08-options__hint" }); + const open = createText("", "muted"); open.dataset.b08MilestoneRouteOpen = ""; open.hidden = true; /** 길 선 여럿 다시 길찾기(그림 캡처 없음 · 캡처는 [저장] 때) → 선 다시 · 실패 안내 */ @@ -207,13 +216,12 @@ export function routeView(projectId: string, routeId: string): MilestoneView { className: "b08-milestone__route ui-card", attrs: { "data-b08-milestone-route": routeId }, children: [ - el("div", { - className: "b08-milestone__route-head", - children: [ - el("div", { className: "b08-milestone__route-title", children: [name, address] }), - el("div", { className: "b08-milestone__bar", children: [place, change, reset, file] }), - ], - }), + routeHead(el("div", { className: "b08-milestone__route-title", children: [name, address] }), [ + place, + change, + reset, + file, + ]), status, open, img, diff --git a/B08_Quantity/B08_Quantity_UI_Options.ts b/B08_Quantity/B08_Quantity_UI_Options.ts index aee840a18..f163403b8 100644 --- a/B08_Quantity/B08_Quantity_UI_Options.ts +++ b/B08_Quantity/B08_Quantity_UI_Options.ts @@ -26,6 +26,7 @@ import { } from "@ui/ui_template_elements"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; import { clearState, readState, writeState } from "../A00_Common/b_page_state"; import { request } from "./B08_Quantity_UI_Hydro"; import { clampPct } from "./B08_Quantity_Options_Calc"; @@ -97,10 +98,7 @@ function card(id: string, title: keyof typeof ui_locales, ...body: HTMLElement[] } function hint(text: string): HTMLElement { - const p = document.createElement("p"); - p.className = "b08-options__hint"; - p.textContent = text; - return p; + return createText(text, "muted"); } /** 좌측 패널 — 맨 끝 `.ui-sidebar-actions` 줄([저장] · [?] 자리). `onSaved` = 열린 탭 다시 그림. diff --git a/B08_Quantity/B08_Quantity_UI_Page.css b/B08_Quantity/B08_Quantity_UI_Page.css index 164bda6da..e9efd3410 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.css +++ b/B08_Quantity/B08_Quantity_UI_Page.css @@ -7,16 +7,22 @@ padding: var(--spacing-16) var(--spacing-24); /* M02 본문(.m02-main)과 같은 값 */ } -.b08-quantity .ui-general-block__empty { - color: var(--color-text-secondary); /* muted 는 밝은 테마에서 대비 3:1 — 4.5:1 로 */ +/* 부품 보조 글은 display:flex 라 hidden 을 이김 — 숨김 켜고 끄는 글이 많아 한 번에 */ +.b08-quantity .ui-text[hidden] { + display: none; } +/* 탭 줄 모양 = 부품 탭(밑줄 · 보조 알약) · 여기는 줄바꿈 · 아래 틈만 */ .b08-quantity__tabs { - display: flex; flex-wrap: wrap; - gap: var(--spacing-8); +} + +.b08-quantity > .b08-quantity__tabs { + margin-bottom: var(--spacing-8); +} + +.b08-quantity__tabs.ui-subtabs { padding-bottom: var(--spacing-8); - border-bottom: 1px solid var(--color-border); } /* ☰ 들여쓰기(7-2) */ @@ -31,6 +37,7 @@ html.is-bm-route .b08-quantity > .b08-quantity__tabs:first-child { /* 236-1 표 아래 안내 · 경고 줄 — 늘 한 줄(줄임표 · 잘리면 공용 호버로 전문) */ .b08-quantity__foot { + display: block; /* 부품 보조 글은 flex — 줄임표는 블록에서 */ min-width: 0; max-width: 100%; overflow: hidden; @@ -39,19 +46,11 @@ html.is-bm-route .b08-quantity > .b08-quantity__tabs:first-child { font-size: var(--text-caption); } -/* 자재 집계표 셋(121) — 안내 글 · [저장] 한 줄 */ +/* 자재 집계표 셋(121) — [저장] 한 줄 = 부품 도구줄 · 아래 틈만 */ .b08-material__toolbar { - display: flex; - align-items: flex-start; - justify-content: flex-end; - gap: var(--spacing-16); margin-bottom: var(--spacing-8); } -.b08-material__toolbar > .ui-btn { - flex: 0 0 auto; -} - /* 구조물 · 자재 집계표 머리 — 좁은 열(44~100)에서 말줄임 대신 줄바꿈(120-2 · 131 · 공용 표 CSS 는 그대로) */ [data-b08-summary="table"] thead th, [data-b08-material] thead th { diff --git a/B08_Quantity/B08_Quantity_UI_Page.ts b/B08_Quantity/B08_Quantity_UI_Page.ts index 40c520763..9a011718b 100644 --- a/B08_Quantity/B08_Quantity_UI_Page.ts +++ b/B08_Quantity/B08_Quantity_UI_Page.ts @@ -13,11 +13,12 @@ import "@ui/ui_template_general_layout.css"; import "./B08_Quantity_UI_Page.css"; +import { createText } from "@ui/ui_template_text"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; -import { createButton } from "@ui/ui_template_elements"; import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; import { CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { workflowSteps } from "../A00_Common/b_page_scaffold"; +import { fillTabs } from "../A00_Common/b_page_tabs"; import { moveStage, WORKFLOW_STEP_ROUTES, type StageStep } from "../A00_Common/b_workflow_nav"; import { renderStructureSummary } from "./B08_Quantity_UI_Summary"; import { renderMaterialSheet } from "./B08_Quantity_UI_Material"; @@ -53,33 +54,22 @@ type SheetId = (typeof SHEETS)[number]["id"]; type TabSave = (host: HTMLElement, save: () => Promise) => void; function pendingNote(): HTMLElement { - const pending = document.createElement("p"); - pending.className = "ui-general-block__empty"; - pending.textContent = L("B08_Quantity_Tab_Pending"); - return pending; + return createText(L("B08_Quantity_Tab_Pending"), "muted", 14); } /** 자재 탭 — 보조 탭 줄 + 몸통(보조 탭마다 새 몸통 · 늦은 응답이 다른 보조 탭을 덮지 않게). */ function renderMaterials(host: HTMLElement, projectId: string | null, onSave: TabSave): void { const tabs = document.createElement("div"); - tabs.className = "b08-quantity__tabs ui-subtabs b08-structure__tabs"; - tabs.setAttribute("role", "tablist"); + tabs.className = "b08-quantity__tabs b08-structure__tabs"; const view = document.createElement("div"); const select = (picked: SheetId): void => { - tabs.replaceChildren( - ...SHEETS.map(({ id, label }) => { - const button = createButton({ - label: L(label), - variant: id === picked ? "filled" : "ghost", - size: "sm", - onClick: () => id !== picked && select(id), - }); - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", String(id === picked)); - button.dataset.b08StructureSheet = id; - return button; - }), - ); + fillTabs(tabs, { + tabs: SHEETS.map(({ id, label }) => ({ key: id, label: L(label) })), + value: picked, + kind: "pill", + data: "b08StructureSheet", + onChange: (id) => select(id as SheetId), + }); const body = document.createElement("div"); view.replaceChildren(body); if (!projectId) body.append(pendingNote()); @@ -95,13 +85,21 @@ function renderTab(host: HTMLElement, tab: TabId, projectId: string | null, onSa host.replaceChildren(view); if (tab === "material") return renderMaterials(view, projectId, onSave); if (tab === "summary" && projectId) { - // 보조 탭 줄 자리(170-4) — 보이지 않는 sm 단추 한 줄 · 자재 탭과 같은 높이라 탭 바꿀 때 안 튐 + // 보조 탭 줄 자리(170-4) — 보이지 않는 보조 탭 한 칸 · 자재 탭과 같은 높이라 탭 바꿀 때 안 튐 const blank = document.createElement("div"); - blank.className = "b08-quantity__tabs ui-subtabs b08-quantity__tabs--blank"; + blank.className = "b08-quantity__tabs b08-quantity__tabs--blank"; + fillTabs(blank, { + tabs: [{ key: SHEETS[0].id, label: L(SHEETS[0].label) }], + value: SHEETS[0].id, + kind: "pill", + data: "b08StructureSheet", + onChange: () => undefined, + }).forEach((button) => { + button.tabIndex = -1; + delete button.dataset.b08StructureSheet; + }); blank.setAttribute("aria-hidden", "true"); - const ghost = createButton({ label: L(SHEETS[0].label), variant: "ghost", size: "sm" }); - ghost.tabIndex = -1; - blank.append(ghost); + blank.setAttribute("role", "presentation"); const body = document.createElement("div"); view.append(blank, body); return void renderStructureSummary(body, projectId); @@ -118,7 +116,6 @@ export function renderB08Quantity(root: HTMLElement): void { const tabBar = document.createElement("div"); tabBar.className = "b08-quantity__tabs"; - tabBar.setAttribute("role", "tablist"); const panel = document.createElement("div"); panel.className = "b08-quantity__panel"; panel.setAttribute("role", "tabpanel"); @@ -128,19 +125,14 @@ export function renderB08Quantity(root: HTMLElement): void { const onTabSave: TabSave = (host, save) => (tabSave = { host, save }); const select = (tab: TabId): void => { current = tab; - tabBar.replaceChildren( - ...TABS.map(({ id, label }) => { - const button = createButton({ - label: L(label), - variant: id === tab ? "filled" : "ghost", - onClick: () => id !== tab && select(id), - }); - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", String(id === tab)); - button.dataset.b08Tab = id; - return button; - }), - ); + fillTabs(tabBar, { + tabs: TABS.map(({ id, label }) => ({ key: id, label: L(label) })), + value: tab, + kind: "line", + size: 32, + data: "b08Tab", + onChange: (id) => select(id as TabId), + }); renderTab(panel, tab, projectId, onTabSave); }; diff --git a/B08_Quantity/B08_Quantity_UI_Summary.ts b/B08_Quantity/B08_Quantity_UI_Summary.ts index 34b70dce8..9276ad27a 100644 --- a/B08_Quantity/B08_Quantity_UI_Summary.ts +++ b/B08_Quantity/B08_Quantity_UI_Summary.ts @@ -20,6 +20,7 @@ * ========================================================================== */ import { ui_locales, currentLanguageIndex } from "@ui/ui_template_locale"; +import { createText } from "@ui/ui_template_text"; import { createCard, showToast } from "@ui/ui_template_elements"; import { createDataTable } from "@ui/ui_template_data_table"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; @@ -39,10 +40,7 @@ function L(key: keyof typeof ui_locales): string { } function note(text: string): HTMLElement { - const node = document.createElement("p"); - node.className = "ui-general-block__empty"; - node.textContent = text; - return node; + return createText(text, "muted", 14); } function buildTable( diff --git a/B09_Estimation/B09_Estimation_UI_Check.ts b/B09_Estimation/B09_Estimation_UI_Check.ts index 5bb00b5e9..f53652d09 100644 --- a/B09_Estimation/B09_Estimation_UI_Check.ts +++ b/B09_Estimation/B09_Estimation_UI_Check.ts @@ -8,6 +8,8 @@ * ========================================================================== */ import { attachHoverInfo } from "@ui/ui_template_hover_info"; +import { createBadge } from "@ui/ui_template_badge"; +import { createText } from "@ui/ui_template_text"; import type { Row } from "./B09_Estimation_Calc_Base"; import { tabName, type Link, type TabId } from "./B09_Estimation_UI_Tabs"; @@ -60,9 +62,8 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi refresh.classList.toggle("is-pending", Boolean(next.stale)); const made = checkItems(sheets, next); items = made.items; - const head = document.createElement("p"); - head.className = "b09-check__head"; - head.textContent = `연결 없음 ${items.length}곳`; + const head = createText(`연결 없음 ${items.length}곳`, "muted"); + head.classList.add("b09-check__head"); const rows = document.createElement("div"); rows.className = "ui-sidebar-list"; rows.append( @@ -73,18 +74,16 @@ export function createCheckPanel(refresh: HTMLElement, goto: (link: Link) => voi row.dataset.i = String(i); const name = document.createElement("span"); name.textContent = item.name; - const tab = document.createElement("span"); - tab.className = "b09-check__tab"; - tab.textContent = item.tab; + const tab = createBadge(item.tab, "neutral", { size: 18 }); + tab.classList.add("b09-check__tab"); row.append(name, tab); row.addEventListener("click", () => goto(item.link)); return row; }), ); const notes = made.notes.map((text) => { - const p = document.createElement("p"); - p.className = "b09-check__note"; - p.textContent = text; + const p = createText(text, "muted"); + p.classList.add("b09-check__note"); return p; }); list.replaceChildren(head, ...(items.length ? [rows] : []), ...notes); diff --git a/B09_Estimation/B09_Estimation_UI_Dock.ts b/B09_Estimation/B09_Estimation_UI_Dock.ts index 09a608005..7d7b19796 100644 --- a/B09_Estimation/B09_Estimation_UI_Dock.ts +++ b/B09_Estimation/B09_Estimation_UI_Dock.ts @@ -9,7 +9,7 @@ * 스위치 값 = pref global(계정 공용 · [저장] 필요 없음) · 바닥 고정 = 공용 `.ui-sidebar-actions` 한 벌 * ========================================================================== */ -import { createButton } from "@ui/ui_template_elements"; +import { createSegment } from "@ui/ui_template_segment"; import { readState, writeState } from "../A00_Common/b_page_state"; import { DRAFT } from "./B09_Estimation_UI_Tabs"; import { @@ -40,7 +40,7 @@ function setLinkMode(mode: LinkMode): void { } } -/** 단추 둘 스위치 — 켜진 쪽 filled · aria-pressed */ +/** 단추 둘 스위치 — 부품 「분할 고르기」(32) · 고른 쪽 aria-checked */ function switchRow( name: string, values: T[], @@ -48,32 +48,14 @@ function switchRow( current: () => T, pick: (value: T) => void, ): HTMLElement { - const row = document.createElement("div"); - row.className = "ui-btn-group b09-dock__row"; - row.dataset.b09Switch = name; - const buttons = values.map((value) => { - const button = createButton({ - label: label(value), - variant: "ghost", - onClick: () => { - if (value === current()) return; - pick(value); - sync(); - }, - }); - button.dataset.value = value; - return button; + const segment = createSegment({ + options: values.map((value) => ({ value, label: label(value) })), + value: current(), + size: 32, + onChange: (value) => pick(value as T), }); - const sync = (): void => - buttons.forEach((button, k) => { - const on = values[k] === current(); - button.classList.toggle("ui-btn--filled", on); - button.classList.toggle("ui-btn--ghost", !on); - button.setAttribute("aria-pressed", String(on)); - }); - sync(); - row.append(...buttons); - return row; + segment.root.dataset.b09Switch = name; + return segment.root; } /** 하단 고정 줄 — onView = 보기 바뀜(묶음 탭이면 다시 그림) */ diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Steps.css b/B09_Estimation/B09_Estimation_UI_Haul_Steps.css index cf8826d2c..14879f89d 100644 --- a/B09_Estimation/B09_Estimation_UI_Haul_Steps.css +++ b/B09_Estimation/B09_Estimation_UI_Haul_Steps.css @@ -4,10 +4,8 @@ flex-direction: column; gap: var(--spacing-12); } -.b09-haulstep { - border: 1px solid var(--color-border); - border-radius: var(--radius-cards); - background-color: var(--color-surface-raised); +.b09-haulstep.ui-card { + padding: 0; /* 부품 카드 · 머리 · 바닥 줄이 가장자리까지 */ color: var(--color-text-body); font-size: var(--text-body-sm); } diff --git a/B09_Estimation/B09_Estimation_UI_Haul_Steps.ts b/B09_Estimation/B09_Estimation_UI_Haul_Steps.ts index 559d539af..f00674ecf 100644 --- a/B09_Estimation/B09_Estimation_UI_Haul_Steps.ts +++ b/B09_Estimation/B09_Estimation_UI_Haul_Steps.ts @@ -83,7 +83,7 @@ const group = (label: string, rows: StepRow[], ats?: number[]): HTMLElement[] => /** 칸 하나 — 제목 줄(제목 · 곁글) · 몸 · 바닥(굵게) */ function box(title: string, sub: string, body: HTMLElement[], foot: HTMLElement[]): HTMLElement { return el("section", { - className: "b09-haulstep", + className: "b09-haulstep ui-card", children: [ el("div", { className: "b09-haulstep__head", diff --git a/B09_Estimation/B09_Estimation_UI_Page.css b/B09_Estimation/B09_Estimation_UI_Page.css index c0323f839..0eb47440e 100644 --- a/B09_Estimation/B09_Estimation_UI_Page.css +++ b/B09_Estimation/B09_Estimation_UI_Page.css @@ -8,22 +8,17 @@ padding: var(--spacing-16) var(--spacing-24); /* B08 본문과 같은 값 */ } -.b09-estimation .ui-general-block__empty, -.b09-estimation__hint { - color: var(--color-text-secondary); -} - -.b09-estimation__hint { - margin: 0 0 var(--spacing-8); - font-size: var(--text-caption); -} - +/* 탭 줄 모양 = 부품 탭(밑줄 · 보조 알약) · 여기는 줄바꿈 · 아래 틈만(B08 과 같은 값) */ .b09-estimation__tabs { - display: flex; flex-wrap: wrap; - gap: var(--spacing-8); /* B08 위 · 보조 탭 줄과 같은 값(181-1) */ +} + +.b09-estimation > .b09-estimation__tabs:first-child { + margin-bottom: var(--spacing-8); +} + +.b09-estimation__tabs.ui-subtabs { padding-bottom: var(--spacing-8); - border-bottom: 1px solid var(--color-border); } /* ☰ 들여쓰기(7-2) */ @@ -59,12 +54,13 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child { } /* 202-1 안내 줄 = 표 · 카드 판 아래 · 203 줄 수 변수만큼 보임 · 넘치면 영역 안 스크롤 */ -.b09-estimation .ui-data-table + .ui-general-block__empty, -.b09-estimation .ui-group-cards + .ui-general-block__empty { +.b09-estimation .ui-data-table + .ui-text, +.b09-estimation .ui-group-cards + .ui-text { margin: var(--spacing-8) 0 0; } .b09-estimation__line-hint { + display: block; /* 부품 보조 글은 flex — 줄 수 · 스크롤은 블록에서 */ min-width: 0; max-height: calc(var(--b09-hint-lines) * 1lh); overflow-y: auto; @@ -192,15 +188,11 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child { /* 연결 점검 목록 — 이름 · 탭(작게) · 알림 글 */ .b09-check__head, .b09-check__note { - margin: var(--spacing-8) 0 0; - color: var(--color-text-secondary); - font-size: var(--text-caption); + margin-top: var(--spacing-8); } .b09-check__tab { flex: none; - color: var(--color-text-secondary); - font-size: var(--text-caption); } /* 줄 끌기(204-1) — 놓을 자리 · 레벨 미리 보기 선 · 못 놓는 자리 = 빨간 점선 */ @@ -232,15 +224,11 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child { background: var(--color-canvas); } -.b09-estimation__drop-tag { +.b09-estimation__drop > .b09-estimation__drop-tag.ui-badge { position: absolute; top: -11px; - padding: 1px var(--spacing-8); - border: 1px solid; - border-radius: var(--radius-pills); + border: 1px solid; /* 놓을 자리 색(--drop-c)을 따름 */ background: var(--color-surface-raised); - font-size: var(--text-caption); - white-space: nowrap; } .b09-estimation [data-b09-table="bill"] tbody tr.is-drop-into td { @@ -266,44 +254,43 @@ html.is-bm-route .b09-estimation > .b09-estimation__tabs:first-child { .b09-dock .b09-dock__row { display: flex; gap: var(--btn-group-gap); + min-width: 0; } +/* 보기 · 행 누름 스위치 = 부품 분할 고르기 · 줄 폭을 나눠 가짐 */ +.b09-dock > .ui-segment { + display: flex; +} + +.b09-dock > .ui-segment > .ui-segment__item { + flex: 1 1 0; + min-width: 0; +} + +/* 줄 폭을 나눠 가짐 · 가로 넘침 없음(min-width 0 = 공용 단추 줄의 auto 보다 앞섬 · 글 줄임표) — 인쇄 미리보기 좌측 줄도 같은 규칙 */ .b09-dock .b09-dock__row > .ui-btn:not(.ui-help-btn, .ui-step-nav-btn) { flex: 1 1 0; - min-width: auto; + min-width: 0; padding-inline: var(--spacing-4); + overflow: hidden; + text-overflow: ellipsis; white-space: nowrap; } -/* 303-2 행 고름 팝업 — 누른 줄 곁 표 조각 창(겹쳐 쌓임 · 창 안 b09 표 꼴 그대로 · 빨강 테두리) */ +.b09-dock .b09-dock__row > .ui-btn[hidden] { + display: none; +} + +/* 303-2 행 고름 팝업 — 누른 줄 곁 표 조각 창(겹쳐 쌓임) = 부품 떠 있는 판 · 여기는 자리 · 크기만 */ .b09-estimation.b09-popup { position: fixed; z-index: var(--z-modal); - gap: var(--spacing-4); width: max-content; max-width: 90vw; max-height: 80vh; - padding: var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-lg); - background: var(--color-surface-raised); - box-shadow: var(--shadow-lg); -} - -.b09-popup__head { - display: flex; - flex: none; - align-items: center; - gap: var(--spacing-4); -} - -.b09-popup__title { - margin-right: auto; - font-weight: var(--font-weight-bold); + padding: 0; } .b09-popup .ui-data-table { - flex: 1 1 auto; - min-height: 0; max-height: none; } diff --git a/B09_Estimation/B09_Estimation_UI_Popup.ts b/B09_Estimation/B09_Estimation_UI_Popup.ts index 23a9e791c..a25730a86 100644 --- a/B09_Estimation/B09_Estimation_UI_Popup.ts +++ b/B09_Estimation/B09_Estimation_UI_Popup.ts @@ -12,6 +12,7 @@ import { createDataTable, type DataTableOptions } from "@ui/ui_template_data_table"; import { createButton } from "@ui/ui_template_elements"; +import { createFloatPanel } from "@ui/ui_template_float_panel"; import { placeHoverInfo } from "@ui/ui_template_hover_info"; import { tableOptionsOf } from "./B09_Estimation_UI_Builders"; import { rowIndex, tabName, type Link, type TabId, type TabTable } from "./B09_Estimation_UI_Tabs"; @@ -135,35 +136,28 @@ export function openRowPopup(o: PopupOpen): void { if (!spec || !range) return o.go(o.link); // 보일 줄 없음 = 옮김 listen(); const level = stack.length; - const box = document.createElement("div"); - box.className = "b09-estimation b09-popup ui-popup"; + // 떠 있는 판(부품) — 머리 = 제목 + [탭 이동] + 닫기 ✕ · 몸 = 표 조각 + const panel = createFloatPanel({ title: tabName(tab), onClose: () => closePopups(level) }); + const box = panel.root; + box.classList.add("b09-estimation", "b09-popup", "ui-popup"); box.setAttribute("role", "dialog"); box.setAttribute("aria-label", tabName(tab)); box.dataset.b09Popup = tab; - const title = document.createElement("span"); - title.className = "b09-popup__title"; - title.textContent = tabName(tab); + const head = box.querySelector(".ui-float-panel__head") as HTMLElement; + head.classList.add("b09-popup__head"); + const closeButton = head.querySelector(".ui-icon-btn") as HTMLElement; + closeButton.dataset.b09PopupClose = ""; const goButton = createButton({ label: "탭 이동", variant: "ghost", - size: "sm", + size: 24, onClick: () => { closePopups(); o.go(o.link); }, }); goButton.dataset.b09PopupGo = ""; - const closeButton = createButton({ - label: "×", - variant: "ghost", - size: "sm", - onClick: () => closePopups(level), - }); - closeButton.dataset.b09PopupClose = ""; - closeButton.setAttribute("aria-label", "닫기"); - const head = document.createElement("div"); - head.className = "b09-popup__head"; - head.append(title, goButton, closeButton); + closeButton.before(goButton); const from = range[0]; const table = createDataTable({ ...popupOptions(spec, tab, range), @@ -181,7 +175,7 @@ export function openRowPopup(o: PopupOpen): void { }, }); table.root.dataset.b09Table = tab; - box.append(head, table.root); + panel.body.append(table.root); document.body.append(box); stack.push(box); const view = { width: window.innerWidth, height: window.innerHeight }; diff --git a/B09_Estimation/B09_Estimation_UI_Shell.ts b/B09_Estimation/B09_Estimation_UI_Shell.ts index e35d42b3d..ff8f4a13c 100644 --- a/B09_Estimation/B09_Estimation_UI_Shell.ts +++ b/B09_Estimation/B09_Estimation_UI_Shell.ts @@ -14,6 +14,7 @@ import "@ui/ui_template_general_layout.css"; import "./B09_Estimation_UI_Page.css"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY } from "@config/config_frontend"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; +import { createText } from "@ui/ui_template_text"; import { createButton, hideLoadingOverlay, @@ -79,10 +80,7 @@ async function request(path: string, init: RequestInit = {}): Promise { } function note(textValue: string): HTMLElement { - const p = document.createElement("p"); - p.className = "ui-general-block__empty"; - p.textContent = textValue; - return p; + return createText(textValue, "muted", 14); } /** 산근(145-1) = 서버 단독 — 받은 그대로 두는 칸(+ sg_hand = 손 줄 로직 → 산근 키 · 166-2) */ diff --git a/B09_Estimation/B09_Estimation_UI_TabBar.ts b/B09_Estimation/B09_Estimation_UI_TabBar.ts index 183fc5f76..108fa8995 100644 --- a/B09_Estimation/B09_Estimation_UI_TabBar.ts +++ b/B09_Estimation/B09_Estimation_UI_TabBar.ts @@ -1,14 +1,15 @@ /* ============================================================================= * B09_Estimation_UI_TabBar.ts - * B09 탭 줄 두 층(PLAN 181-1) — 위 탭(md) · 아래 탭(sm) · B08 수량산출 페이지 꼴. + * B09 탭 줄 두 층(PLAN 181-1) — 위 탭(밑줄 · 32) · 아래 탭(보조 알약) · B08 수량산출 페이지 꼴(부품 「탭」). * - * 위 탭 = `TAB_GROUPS`(`…_UI_Tabs`) · 아래 탭 하나뿐인 위 탭 = 보이지 않는 sm 단추 한 줄(높이만 채움) + * 위 탭 = `TAB_GROUPS`(`…_UI_Tabs`) · 아래 탭 하나뿐인 위 탭 = 보이지 않는 보조 탭 한 칸(높이만 채움) * 위 탭 누름 = 그 묶음에서 마지막으로 본 아래 탭(이 화면 안 Map · 저장 안 함) · 없으면 첫 아래 탭 * 탭 이동은 `draw(탭)` — 이동이 묶음을 넘어도 위 탭이 따라 켜짐 * 보기 단추 · 가로 열 수(202-1) = 아래 줄 오른쪽 맞춤 자리(`setTools`) · 빈 줄에서도 보임 * ========================================================================== */ -import { createButton } from "@ui/ui_template_elements"; +import { createToolbar } from "@ui/ui_template_toolbar"; +import { fillTabs } from "../A00_Common/b_page_tabs"; import { groupOf, tabName, TAB_GROUPS, type TabId } from "./B09_Estimation_UI_Tabs"; export interface TabBars { @@ -20,58 +21,49 @@ export interface TabBars { setTools: (tools: HTMLElement[]) => void; } -function tabButton(label: string, on: boolean, size: "sm" | undefined, pick: () => void) { - const button = createButton({ - label, - variant: on ? "filled" : "ghost", - ...(size ? { size } : {}), - onClick: () => !on && pick(), - }); - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", String(on)); - return button; -} - export function createTabBars(onPick: (tab: TabId) => void): TabBars { const top = document.createElement("div"); top.className = "b09-estimation__tabs"; - top.setAttribute("role", "tablist"); const sub = document.createElement("div"); - sub.className = "b09-estimation__tabs ui-subtabs"; - const tools = document.createElement("div"); - tools.className = "b09-estimation__tools ui-subtabs__tools"; + sub.className = "b09-estimation__tabs"; + const tools = createToolbar({ items: [], size: 24 }); + tools.classList.add("b09-estimation__tools", "ui-subtabs__tools"); const lastSeen = new Map(); const draw = (tab: TabId): void => { const [, picked] = groupOf(tab); lastSeen.set(picked[0], tab); - top.replaceChildren( - ...TAB_GROUPS.map(([label, tabs]) => { - const button = tabButton(label, tabs === picked, undefined, () => - onPick(lastSeen.get(tabs[0]) ?? tabs[0]), - ); - button.dataset.b09Group = tabs[0]; - if (tabs.length === 1) button.dataset.b09Tab = tabs[0]; - return button; - }), - ); + const groupTab = new Map(TAB_GROUPS.map(([, tabs]) => [tabs[0], tabs])); + fillTabs(top, { + tabs: TAB_GROUPS.map(([label, tabs]) => ({ key: tabs[0], label })), + value: picked[0], + kind: "line", + size: 32, + data: "b09Group", + onChange: (key) => onPick(lastSeen.get(key as TabId) ?? (key as TabId)), + }).forEach((button) => { + const tabs = groupTab.get(button.dataset.tab as TabId); + if (tabs?.length === 1) button.dataset.b09Tab = tabs[0]; + }); const single = picked.length === 1; sub.classList.toggle("b09-estimation__tabs--blank", single); + const buttons = fillTabs(sub, { + tabs: single + ? [{ key: picked[0], label: tabName(picked[0]) }] + : picked.map((id) => ({ key: id, label: tabName(id) })), + value: single ? picked[0] : tab, + kind: "pill", + data: "b09Tab", + onChange: (id) => onPick(id as TabId), + }); sub.setAttribute("role", single ? "presentation" : "tablist"); - if (single) { - const ghost = createButton({ label: tabName(picked[0]), variant: "ghost", size: "sm" }); - ghost.tabIndex = -1; - ghost.setAttribute("aria-hidden", "true"); // 도구는 보이는 줄이라 줄 전체를 숨기지 않음 - return void sub.replaceChildren(ghost, tools); - } - sub.replaceChildren( - ...picked.map((id) => { - const button = tabButton(tabName(id), id === tab, "sm", () => onPick(id)); - button.dataset.b09Tab = id; - return button; - }), - tools, - ); + if (single) + for (const button of buttons) { + button.tabIndex = -1; + button.setAttribute("aria-hidden", "true"); // 도구는 보이는 줄이라 줄 전체를 숨기지 않음 + delete button.dataset.b09Tab; + } + sub.append(tools); }; const setTools = (items: HTMLElement[]): void => tools.replaceChildren(...items); return { top, sub, draw, setTools }; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts index 951c43559..f8624ab82 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Drag.ts @@ -9,6 +9,7 @@ * 잡기 = 글 칸 · 단추 밖 아무 칸 · 6px 넘게 움직여야 끌기(그냥 누름 = 줄 고름) * ========================================================================== */ +import { createBadge } from "@ui/ui_template_badge"; import type { DataRow } from "@ui/ui_template_data_table"; import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; @@ -68,8 +69,8 @@ export function attachDrag(root: HTMLElement, table: BillTable, done: (key: stri const step = parseFloat(getComputedStyle(tr).getPropertyValue("--dt-indent")) || 14; const line = document.createElement("div"); line.className = "b09-estimation__drop"; - const tag = document.createElement("span"); - tag.className = "b09-estimation__drop-tag"; + const tag = createBadge("", "neutral", { size: 18 }); + tag.classList.add("b09-estimation__drop-tag"); line.append(tag); const rowEl = (i: number): HTMLElement | null => root.querySelector(`tr[data-i="${i}"]`); let into: HTMLElement | null = null; diff --git a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts index f70f0cb37..f0b333bd9 100644 --- a/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts +++ b/B09_Estimation/B09_Estimation_UI_Tabs_Bill_Level.ts @@ -16,6 +16,7 @@ import type { DataRow } from "@ui/ui_template_data_table"; import { createButton } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; import { attachHoverInfo } from "@ui/ui_template_hover_info"; import { attachDrag } from "./B09_Estimation_UI_Tabs_Bill_Drag"; import type { BillTable } from "./B09_Estimation_UI_Tabs_Bill_Edit"; @@ -149,9 +150,8 @@ export function levelBar( button.dataset.b09Level = label; return button; }); - const guide = document.createElement("p"); - guide.className = "ui-general-block__empty b09-estimation__line-hint"; - guide.textContent = GUIDE; + const guide = createText(GUIDE, "muted"); + guide.classList.add("b09-estimation__line-hint"); attachHoverInfo(guide, (el) => (el.scrollWidth > el.clientWidth ? el.textContent : null)); // 236-2 잘릴 때만 const sync = (): void => { const acts = menuActs(table, index()); diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.css b/B09_Estimation/draft/B09_Draft_Print_Page.css index 7bf7b3265..c383c1f46 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Page.css +++ b/B09_Estimation/draft/B09_Draft_Print_Page.css @@ -10,14 +10,7 @@ } .b09-print__status { - margin: 0; - min-height: 1.4em; - font-size: var(--text-caption); - color: var(--color-text-secondary); -} - -.b09-print__status.is-error { - color: var(--color-danger); + min-height: 1.4em; /* 글이 비어도 줄 높이 유지 */ } .b09-print__frame { @@ -30,9 +23,7 @@ } .b09-print__areas { - display: flex; - flex-wrap: wrap; - gap: var(--spacing-4); + flex-wrap: wrap; /* 부품 보조 탭 줄 */ } .b09-print__list { @@ -42,39 +33,3 @@ max-height: 50vh; overflow: auto; } - -.b09-print__item { - display: flex; - align-items: center; - gap: var(--spacing-8); - font-size: var(--text-body-sm); - cursor: pointer; -} - -/* 좌측 하단 두 줄(302-5 · B09 Dock 꼴) — 단추는 줄 폭을 나눠 가짐 · 가로 넘침 없음 */ -.b09-print__actions.ui-sidebar-actions { - flex-direction: column; -} - -.b09-print__actions.ui-sidebar-actions > * { - flex: none; -} - -.b09-print__actions .b09-print__row { - display: flex; - gap: var(--btn-group-gap); - min-width: 0; -} - -.ui-sidebar-actions.b09-print__actions .b09-print__row > .ui-btn:not(.ui-help-btn) { - flex: 1 1 0; - min-width: 0; - padding-inline: var(--spacing-4); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.b09-print__actions .b09-print__row > .ui-btn[hidden] { - display: none; -} diff --git a/B09_Estimation/draft/B09_Draft_Print_Page.ts b/B09_Estimation/draft/B09_Draft_Print_Page.ts index 1a818890f..6024f699d 100644 --- a/B09_Estimation/draft/B09_Draft_Print_Page.ts +++ b/B09_Estimation/draft/B09_Draft_Print_Page.ts @@ -11,14 +11,18 @@ * ========================================================================== */ import "@ui/ui_template_general_layout.css"; +import "../B09_Estimation_UI_Page.css"; // 좌측 하단 단추 줄(b09-dock) 규칙 한 벌 import "./B09_Draft_Print_Page.css"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; import { attachCollapsible, createPanelCard } from "@ui/ui_template_collapsible"; +import { createCheckbox } from "@ui/ui_template_checkbox"; import { createButton } from "@ui/ui_template_elements"; +import { createText } from "@ui/ui_template_text"; import { createWorkflowLayout } from "@ui/ui_template_workflow_layout"; import type { MountedPage } from "../../A00_Common/router"; import { navigateTo } from "../../A00_Common/router"; import { workflowSteps } from "../../A00_Common/b_page_scaffold"; +import { fillTabs } from "../../A00_Common/b_page_tabs"; import { goToWorkflowStage, WORKFLOW_STEP_ROUTES } from "../../A00_Common/b_workflow_nav"; import { inlineImages, pdfBody, xlsxBody } from "./B09_Draft_Print_Table"; import type { @@ -114,8 +118,8 @@ function save(blob: Blob, name: string): void { export function renderB09Print(root: HTMLElement): MountedPage { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); - const status = document.createElement("p"); - status.className = "b09-print__status"; + const status = createText("", "muted"); + status.className += " b09-print__status"; status.dataset.b09PrintStatus = ""; const frame = document.createElement("iframe"); frame.className = "b09-print__frame"; @@ -144,6 +148,8 @@ export function renderB09Print(root: HTMLElement): MountedPage { const say = (text: string, error = false) => { status.textContent = text; status.classList.toggle("is-error", error); + status.classList.toggle("ui-text--error", error); + status.classList.toggle("ui-text--muted", !error); }; const picked = () => (area?.items ?? []).map((it) => it.id).filter((id) => !off.has(id)); const clearFrame = () => { @@ -213,21 +219,16 @@ export function renderB09Print(root: HTMLElement): MountedPage { const drawList = () => { list.replaceChildren( ...(area?.items ?? []).map((it) => { - const row = document.createElement("label"); - row.className = "b09-print__item"; - const box = document.createElement("input"); - box.type = "checkbox"; - box.checked = !off.has(it.id); - box.dataset.b09PrintItem = it.id; - box.addEventListener("change", () => { - if (box.checked) off.delete(it.id); - else off.add(it.id); - later(); + const row = createCheckbox(it.name, { + checked: !off.has(it.id), + onChange: (on) => { + if (on) off.delete(it.id); + else off.add(it.id); + later(); + }, }); - const name = document.createElement("span"); - name.textContent = it.name; - row.append(box, name); - return row; + row.input.dataset.b09PrintItem = it.id; + return row.root; }), ); }; @@ -238,18 +239,13 @@ export function renderB09Print(root: HTMLElement): MountedPage { off = new Set(); const my = ++seq; clearFrame(); - areaRow.replaceChildren( - ...AREAS.map((a) => { - const b = createButton({ - label: a.label, - size: "sm", - variant: a.id === id ? "filled" : "ghost", - onClick: () => a.id !== areaId && pickArea(a.id), - }); - b.dataset.b09PrintArea = a.id; - return b; - }), - ); + fillTabs(areaRow, { + tabs: AREAS.map((a) => ({ key: a.id, label: a.label })), + value: id, + kind: "pill", + data: "b09PrintArea", + onChange: (key) => pickArea(key as AreaId), + }); xlsxBtn.hidden = id === "drawings"; drawList(); if (!projectId) return say("현재 프로젝트가 없습니다.", true); @@ -270,14 +266,14 @@ export function renderB09Print(root: HTMLElement): MountedPage { // 두 줄(302-5) — 위 = 내려받기 · 아래 = [?](도움말 줄) · 돌아가기 — 패널 폭 넘침 없음 const files = document.createElement("div"); - files.className = "b09-print__row"; + files.className = "b09-dock__row b09-print__row"; files.append(pdfBtn, xlsxBtn); const nav = document.createElement("div"); - nav.className = "b09-print__row"; + nav.className = "b09-dock__row b09-print__row"; nav.dataset.helpRow = ""; nav.append(back); const actions = document.createElement("div"); - actions.className = "ui-sidebar-actions b09-print__actions"; + actions.className = "ui-sidebar-actions b09-dock b09-print__actions"; actions.append(files, nav); const left = document.createElement("div"); left.className = "ui-sidebar-panel b09-print__panel"; diff --git a/resources/tester/helper_298_2_b08_b09_buttons.cjs b/resources/tester/helper_298_2_b08_b09_buttons.cjs index 5c1c6056e..4f7106425 100644 --- a/resources/tester/helper_298_2_b08_b09_buttons.cjs +++ b/resources/tester/helper_298_2_b08_b09_buttons.cjs @@ -87,6 +87,19 @@ let layoutOpts = null; let confirmAnswer = false; const store = new Map(); // 가짜 세션 캐시 const common = { + "../A00_Common/b_page_tabs": { + // 탭 줄 부품 흉내 — 칸마다 dataset[data] = 열쇠 · 지금 칸 누름은 안 부름 + fillTabs: (host, o) => { + const kids = o.tabs.map((t) => + Object.assign(new Node("button"), { + onClick: () => t.key !== o.value && o.onChange(t.key), + dataset: { [o.data]: t.key }, + }), + ); + host.replaceChildren(...kids); + return kids; + }, + }, "@ui/ui_template_workflow_layout": { createWorkflowLayout: (opts) => { layoutOpts = opts; diff --git a/resources/tester/helper_481_2_save_move.cjs b/resources/tester/helper_481_2_save_move.cjs index 5e35bef7c..393d25590 100644 --- a/resources/tester/helper_481_2_save_move.cjs +++ b/resources/tester/helper_481_2_save_move.cjs @@ -81,6 +81,19 @@ const calls = []; let layoutOpts = null; const store = new Map(); // 가짜 세션 캐시 — 이름 → 값 const common = { + "../A00_Common/b_page_tabs": { + // 탭 줄 부품 흉내 — 칸마다 dataset[data] = 열쇠 · 지금 칸 누름은 안 부름 + fillTabs: (host, o) => { + const kids = o.tabs.map((t) => + Object.assign(new Node("button"), { + onClick: () => t.key !== o.value && o.onChange(t.key), + dataset: { [o.data]: t.key }, + }), + ); + host.replaceChildren(...kids); + return kids; + }, + }, "@ui/ui_template_workflow_layout": { createWorkflowLayout: (opts) => { layoutOpts = opts; diff --git a/resources/tester/helper_b09_group_cards.py b/resources/tester/helper_b09_group_cards.py index 5fb08a813..532b80751 100644 --- a/resources/tester/helper_b09_group_cards.py +++ b/resources/tester/helper_b09_group_cards.py @@ -15,7 +15,7 @@ COMMON = "ui_template/ui_template_group_cards.ts" TABS = "B09_Estimation/B09_Estimation_UI_Tabs.ts" GROUPS = "B09_Estimation/B09_Estimation_UI_Tabs_Groups.ts" -# 기억(b_page_state) 대신 Map · 단추(createButton) 대신 가짜 button · 호버(attachHoverInfo) = 부른 자리 기록 · 157-3 원문번호 풍선(raw) 짝 +# 기억(b_page_state) 대신 Map · 탭 줄(fillTabs) 대신 가짜 button 줄 · 단추(createButton) 대신 가짜 button · 호버(attachHoverInfo) = 부른 자리 기록 · 157-3 원문번호 풍선(raw) 짝 STUBS = r""" const kept = new Map(); const readState = (name) => (kept.has(name) ? JSON.parse(kept.get(name)) : null); @@ -27,6 +27,50 @@ const createButton = (o) => { if (o.onClick) b.addEventListener("click", o.onClick); return b; }; +const fillTabs = (host, o) => { + const kids = o.tabs.map((t) => { + const b = document.createElement("button"); + b.textContent = t.label; + b.dataset[o.data] = t.key; + b.addEventListener("click", () => t.key !== o.value && o.onChange(t.key)); + return b; + }); + host.replaceChildren(...kids); + return kids; +}; +const createSegment = (o) => { + const root = document.createElement("div"); + let current = o.value; + const items = o.options.map((x) => { + const b = document.createElement("button"); + b.textContent = x.label; + b.dataset.value = x.value; + b.addEventListener("click", () => { + if (current === x.value) return; + current = x.value; + mark(); + o.onChange(x.value); + }); + return b; + }); + const mark = () => items.forEach((b) => b.setAttribute("aria-checked", String(b.dataset.value === current))); + mark(); + root.append(...items); + return { root, get: () => current, set: (v) => { current = v; mark(); } }; +}; +const createText = (text, kind) => { + const p = document.createElement("p"); + p.className = `ui-text ui-text--${kind}`; + p.textContent = text; + return p; +}; +const createBadge = (text, tone) => { + const span = document.createElement("span"); + span.className = `ui-badge ui-badge--${tone}`; + span.textContent = text; + return span; +}; +const createToolbar = () => document.createElement("div"); const hoverCalls = []; const attachHoverInfo = (root, text, selector) => { hoverCalls.push({ root, text, selector }); return () => {}; }; """ diff --git a/resources/tester/test_200_2_b09_bill_level_bar.py b/resources/tester/test_200_2_b09_bill_level_bar.py index 2aab73e04..48a6f0f3c 100644 --- a/resources/tester/test_200_2_b09_bill_level_bar.py +++ b/resources/tester/test_200_2_b09_bill_level_bar.py @@ -48,6 +48,7 @@ function createButton(o) { b.addEventListener("click", o.onClick); return b; } +function createText(text) { const p = new FakeEl("p"); p.textContent = text; return p; } function attachHoverInfo(el, text) { hovers.push([el.textContent, text]); } let user = {}; diff --git a/resources/tester/test_202_1_table_hint_bottom.py b/resources/tester/test_202_1_table_hint_bottom.py index ff5509be6..8b75244ec 100644 --- a/resources/tester/test_202_1_table_hint_bottom.py +++ b/resources/tester/test_202_1_table_hint_bottom.py @@ -150,11 +150,13 @@ def test_B08_표_카드_차례_표_아래줄_안내(): def test_B08_자재_구조물_안내_줄_표_아래(): material = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Material.ts").read_text("utf-8") assert "createCard({ body: [toolbar, table.root, excel, notes] })" in material - assert "toolbar.append(save);" in material # 저장 단추만 위 줄(안내는 아래) + assert ( + 'createToolbar({ items: ["spacer", save], size: 32 })' in material + ) # 저장 단추만 위 줄(안내는 아래) summary = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Summary.ts").read_text("utf-8") assert "createCard({ body: [table, ...parts] })" in summary css = (ROOT / "B08_Quantity" / "B08_Quantity_UI_Page.css").read_text("utf-8") - assert "justify-content: flex-end" in _block(css, ".b08-material__toolbar") + assert "margin-bottom" in _block(css, ".b08-material__toolbar") # 줄 모양 = 부품 도구줄(spacer) # ── 창 끝까지 셈(199-1)에 아래 줄 몫 ──────────────────────────────────────── diff --git a/resources/tester/test_244_1_b08_milestone.py b/resources/tester/test_244_1_b08_milestone.py index 6ee6d9d0f..98d07eaee 100644 --- a/resources/tester/test_244_1_b08_milestone.py +++ b/resources/tester/test_244_1_b08_milestone.py @@ -161,7 +161,7 @@ def test_메인_탭_하위_탭_추가_삭제(): "ui_template/ui_template_locale_b4.ts" ) ui = _read("B08_Quantity/B08_Quantity_UI_Milestone.ts") - assert 'tab("table", "거리이정표")' in ui and "routes.map((r, i) => tab(r.id," in ui + assert '{ key: "table", label: "거리이정표" }' in ui and "...routes.map((r, i) => ({" in ui # 244-4 — 하위 탭 줄 [+ 추가] · [삭제] 없음(탭은 [+] 등록 · 항목 정보 [삭제]로만) assert 'label: "+ 추가"' not in ui and "removeCurrent" not in ui modal = _read("B08_Quantity/B08_Quantity_UI_Milestone_Modal.ts") diff --git a/resources/tester/test_250_1_b08_milestone_roads_width.py b/resources/tester/test_250_1_b08_milestone_roads_width.py index f0ea6d058..3c8338aba 100644 --- a/resources/tester/test_250_1_b08_milestone_roads_width.py +++ b/resources/tester/test_250_1_b08_milestone_roads_width.py @@ -172,6 +172,8 @@ def test_157_도구_왼쪽_아래_경로_탭_지도_그림_변경_초기화(): assert 'label: "그림 변경"' in route and 'label: "초기화"' in route assert "그림 지우기" not in route and "자동 그림으로 되돌리기" not in route.split("*/", 1)[1] assert "map.root.hidden = user;" in route and "editMap({" in route # 그림 없으면 길찾기 지도 - assert ".b08-milestone__route-head {" in css and "justify-content: space-between;" in css + assert ( + 'createToolbar({ items: [title, "spacer", ...buttons], size: 32 })' in route + ) # 머리 줄 = 부품 도구줄 state = _read("B08_Quantity/B08_Quantity_UI_Milestone_State.ts") assert "export const hasUserImage" in state and "canRevertImage" not in state diff --git a/resources/tester/test_302_1_print_frame.py b/resources/tester/test_302_1_print_frame.py index 5c7b89347..9dd193563 100644 --- a/resources/tester/test_302_1_print_frame.py +++ b/resources/tester/test_302_1_print_frame.py @@ -434,9 +434,13 @@ def test_좌측_하단_두_줄_가로_넘침_없음(): assert "files.append(pdfBtn, xlsxBtn)" in page assert "nav.dataset.helpRow" in page and "nav.append(back)" in page assert "actions.append(files, nav)" in page - css = re.sub(r"\s+", " ", _text(DRAFT / "B09_Draft_Print_Page.css")) - assert ".b09-print__actions.ui-sidebar-actions { flex-direction: column; }" in css - btn = css[css.index(".ui-sidebar-actions.b09-print__actions .b09-print__row > .ui-btn") :] + # 553-3 — 좌측 하단 줄 = B09 dock 한 벌(복사 없음) · 줄 폭을 나눠 가짐 · 글 줄임표 + assert 'actions.className = "ui-sidebar-actions b09-dock b09-print__actions"' in page + assert 'className = "b09-dock__row b09-print__row"' in page + css = re.sub(r"\s+", " ", _text(ROOT / "B09_Estimation/B09_Estimation_UI_Page.css")) + assert ".b09-dock.ui-sidebar-actions { flex-direction: column; }" in css + btn = css[css.index(".b09-dock .b09-dock__row > .ui-btn:not(") :] btn = btn[: btn.index("}")] # 공용 단추 줄(min-width: auto = 글보다 안 좁아짐)보다 앞서 이기는 규칙 · 글 줄임표 assert "flex: 1 1 0" in btn and "min-width: 0" in btn and "text-overflow: ellipsis" in btn + assert ".b09-print__actions" not in _text(DRAFT / "B09_Draft_Print_Page.css") diff --git a/resources/tester/test_304_b09_view_setting.py b/resources/tester/test_304_b09_view_setting.py index 7379d6055..53266f234 100644 --- a/resources/tester/test_304_b09_view_setting.py +++ b/resources/tester/test_304_b09_view_setting.py @@ -78,7 +78,7 @@ def test_좌측_스위치_누름(): "\nlet redraws = 0;" "\nconst dock = createDock([document.createElement('button')], () => redraws++);" "\nconst row = dock.children[0];" - "\nconst pressed = () => row.children.map((b) => b.attrs['aria-pressed']);" + "\nconst pressed = () => row.children.map((b) => b.attrs['aria-checked']);" "\nconst labels = row.children.map((b) => b.textContent);" "\nconst a = pressed();" "\nrow.children[0].click();" # 이미 박스 = 그대로 From 97fc00fafb3298a7e222cada45ccce1e92557b88 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 22:34:04 +0900 Subject: [PATCH 2/6] =?UTF-8?q?refactor(A00):=20=E2=98=B0=20=EC=84=A4?= =?UTF-8?q?=EC=A0=95=20=EC=B0=BD=20=C2=B7=20=EC=84=9C=EB=9E=8D=20=ED=84=B0?= =?UTF-8?q?=EC=B9=98=20=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=E2=86=92=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=20=EC=83=81=EC=9E=90=20=E2=80=94=20=EB=B3=B4=EC=A1=B0?= =?UTF-8?q?=20=ED=83=AD=20=C2=B7=20=EB=9D=BC=EB=94=94=EC=98=A4=20=C2=B7=20?= =?UTF-8?q?=ED=82=A4=20=EC=B9=A9(=EB=B0=B0=EC=A7=80)=20=C2=B7=20CAD=20?= =?UTF-8?q?=EB=B3=84=EC=B9=AD=20=ED=91=9C=20=C2=B7=20=EB=B6=84=ED=95=A0=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20(PLAN=20553-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- A00_Common/app_settings.css | 52 ++++++---------------------------- A00_Common/app_settings.ts | 45 +++++++++++++---------------- A00_Common/app_settings_cad.ts | 32 +++++++++++---------- A00_Common/app_shell.ts | 42 +++++++++------------------ 4 files changed, 60 insertions(+), 111 deletions(-) diff --git a/A00_Common/app_settings.css b/A00_Common/app_settings.css index c7d32a4d9..a1cdc7da4 100644 --- a/A00_Common/app_settings.css +++ b/A00_Common/app_settings.css @@ -1,10 +1,11 @@ -/* ☰ [설정] 창(PLAN 332-2) — 탭 줄 · 휠 방향 고름 · 표 */ +/* ☰ [설정] 창(PLAN 332-2) — 탭 줄 = 부품 보조 탭 · 휠 방향 = 부품 라디오 · CAD 별칭 표 = 부품 표 */ +.app-settings .ui-text[hidden] { + display: none; /* 부품 보조 글은 display:flex 라 hidden 을 이김 */ +} + .app-settings__tabs { - display: flex; flex-wrap: wrap; - gap: var(--spacing-8); padding-bottom: var(--spacing-8); - border-bottom: 1px solid var(--color-border); } .app-settings__radios { @@ -13,25 +14,13 @@ gap: var(--spacing-16); } -.app-settings__radios > label { - display: inline-flex; - align-items: center; - gap: var(--spacing-8); +.app-settings__radios > .ui-checkbox { min-height: 32px; - cursor: pointer; } -.app-settings__table { - width: 100%; - border-collapse: collapse; -} - -.app-settings__table th, -.app-settings__table td { - padding: var(--spacing-4) var(--spacing-8); - border-bottom: 1px solid var(--color-border); - text-align: left; - vertical-align: middle; +/* 표 머리는 창 몸통을 따라 붙음 — 부품 표 겉틀의 가로 스크롤은 풂 */ +.app-settings .ui-table-wrap { + overflow: visible; } .app-settings__table thead th { @@ -40,18 +29,6 @@ background: var(--color-surface-raised); } -.app-settings__table input[aria-invalid="true"] { - border-color: var(--color-danger); -} - -.app-settings__error { - color: var(--color-danger); -} - -.app-settings__error:empty { - display: none; -} - .app-settings__rules { margin: 0 0 var(--spacing-8); padding: 0; @@ -77,14 +54,3 @@ .app-settings__keys:only-child { grid-column: 1 / -1; } - -.app-settings__keys > kbd { - padding: 0 var(--spacing-8); - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface-raised); - color: var(--color-text); - font-family: var(--font-body); - font-size: 0.85em; - line-height: 1.7; -} diff --git a/A00_Common/app_settings.ts b/A00_Common/app_settings.ts index 8e8efc4d5..17e6cbb27 100644 --- a/A00_Common/app_settings.ts +++ b/A00_Common/app_settings.ts @@ -7,12 +7,15 @@ * - 로그인 전엔 이 브라우저에만 남음(계정 올리기는 `b_page_state` 가 로그인 뒤에) * ========================================================================== */ +import { createBadge } from "@ui/ui_template_badge"; +import { createCheckbox } from "@ui/ui_template_checkbox"; import { createButton, el, showToast } from "@ui/ui_template_elements"; import { askLeave } from "@ui/ui_template_leave_dialog"; import { t as L } from "@ui/ui_template_locale"; import { openModal } from "@ui/ui_template_modal"; import { writeState } from "./b_page_state"; import { inputPrefs, type InputPrefs, type WheelZoom } from "./input_prefs"; +import { fillTabs } from "./b_page_tabs"; import { buildCadAliasTab, checkCadAliases } from "./app_settings_cad"; import "./app_settings.css"; @@ -66,7 +69,7 @@ function ruleList(keys: LocaleKey[]): HTMLElement { children: [ el("span", { className: "app-settings__keys", - children: input.split(" · ").map((text) => el("kbd", { text })), + children: input.split(" · ").map((text) => createBadge(text, "neutral")), }), ...(at < 0 ? [] : [el("span", { text: line.slice(at + 3) })]), ], @@ -81,12 +84,14 @@ function mouseTab(draft: InputPrefs): HTMLElement { attrs: { role: "radiogroup", "aria-label": L("App_Settings_Wheel") }, }); for (const [mode, key] of WHEEL) { - const input = el("input", { - attrs: { type: "radio", name: "app-settings-wheel", value: mode }, - }); - input.checked = draft.wheelZoom === mode; - input.addEventListener("change", () => (draft.wheelZoom = mode)); - group.append(el("label", { children: [input, L(key)] })); + group.append( + createCheckbox(L(key), { + radio: "app-settings-wheel", + value: mode, + checked: draft.wheelZoom === mode, + onChange: () => (draft.wheelZoom = mode), + }).root, + ); } return el("div", { children: [ @@ -114,25 +119,15 @@ export function openAppSettings(): void { cad: cad.root, keys: keysTab(), }; - const tabs = el("div", { - className: "app-settings__tabs ui-subtabs", - attrs: { role: "tablist" }, - }); + const tabs = el("div", { className: "app-settings__tabs" }); const show = (id: TabId): void => { - tabs.replaceChildren( - ...TABS.map(([tab, key]) => { - const button = createButton({ - label: L(key), - variant: tab === id ? "filled" : "ghost", - size: "sm", - onClick: () => show(tab), - }); - button.setAttribute("role", "tab"); - button.setAttribute("aria-selected", String(tab === id)); - button.dataset.settingsTab = tab; - return button; - }), - ); + fillTabs(tabs, { + tabs: TABS.map(([tab, key]) => ({ key: tab, label: L(key) })), + value: id, + kind: "pill", + data: "settingsTab", + onChange: (tab) => show(tab as TabId), + }); for (const [tab, panel] of Object.entries(panels)) panel.hidden = tab !== id; }; diff --git a/A00_Common/app_settings_cad.ts b/A00_Common/app_settings_cad.ts index 217623f03..b32884f86 100644 --- a/A00_Common/app_settings_cad.ts +++ b/A00_Common/app_settings_cad.ts @@ -8,7 +8,10 @@ * ========================================================================== */ import catalog from "../B07_DesignDetail/openwebcad/src/commands/command-catalog.json"; +import { setUiSize } from "@ui/ui_template_button"; import { el } from "@ui/ui_template_elements"; +import { createTable } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; import { t as L } from "@ui/ui_template_locale"; export interface CadCommandRow { @@ -67,13 +70,15 @@ export function buildCadAliasTab(aliases: Record): { root: HTMLElement; mark: (errors: Record) => void; } { - const message = el("p", { className: "app-settings__error", attrs: { role: "alert" } }); + const message = createText("", "error"); + message.hidden = true; const inputs = new Map(); const rows = CAD_COMMANDS.map((command) => { const input = el("input", { - className: "ui-input ui-input--sm", + className: "ui-input", attrs: { type: "text", "aria-label": `${command.id} ${L("App_Settings_Cad_Mine")}` }, }); + setUiSize(input, 24); input.value = (aliases[command.id] ?? []).join(", "); input.dataset.cadCommand = command.id; input.addEventListener("input", () => { @@ -82,25 +87,21 @@ export function buildCadAliasTab(aliases: Record): { else delete aliases[command.id]; }); inputs.set(command.id, input); - const cells = [command.id, command.label, command.aliases.join(", ")].map((text) => - el("td", { text }), - ); - return el("tr", { children: [...cells, el("td", { children: [input] })] }); + return [command.id, command.label, command.aliases.join(", "), input]; }); - const head = el("tr", { - children: ( + const table = createTable( + ( [ "App_Settings_Cad_Command", "App_Settings_Cad_Label", "App_Settings_Cad_Default", "App_Settings_Cad_Mine", ] as const - ).map((key) => el("th", { text: L(key) })), - }); - const table = el("table", { - className: "app-settings__table", - children: [el("thead", { children: [head] }), el("tbody", { children: rows })], - }); + ).map((key) => ({ label: L(key) })), + rows, + { density: "data" }, + ); + table.classList.add("app-settings__table"); const mark = (errors: Record): void => { for (const [id, input] of inputs) { const error = errors[id]; @@ -109,7 +110,8 @@ export function buildCadAliasTab(aliases: Record): { input.title = error ? L(ERROR_TEXT[error]) : ""; } const kinds = [...new Set(Object.values(errors))]; - message.textContent = kinds.map((k) => L(ERROR_TEXT[k])).join(" · "); + message.lastChild!.textContent = kinds.map((k) => L(ERROR_TEXT[k])).join(" · "); + message.hidden = !kinds.length; }; const root = el("div", { children: [el("p", { text: L("App_Settings_Cad_Help") }), message, table], diff --git a/A00_Common/app_shell.ts b/A00_Common/app_shell.ts index 018c26a4e..b79578b37 100644 --- a/A00_Common/app_shell.ts +++ b/A00_Common/app_shell.ts @@ -9,6 +9,7 @@ import { ui_locales, currentLanguageIndex, setLanguage } from "@ui/ui_template_locale"; import { createButton } from "@ui/ui_template_elements"; +import { createSegment } from "@ui/ui_template_segment"; import { THEME_STORAGE_KEY, LANG_STORAGE_KEY, ROUTES } from "@config/config_frontend"; import { navigateTo, isAuthenticated, setLeaveGuard, takeLeaveGuard } from "./router"; import { readTouchMode, setTouchMode, type TouchMode } from "./device_context"; @@ -246,35 +247,23 @@ function buildTouchModeSwitch(): HTMLElement { box.className = "app-drawer__touch"; const label = document.createElement("span"); label.textContent = L("App_TouchMode"); - const group = document.createElement("div"); - group.className = "app-drawer__row"; - group.setAttribute("role", "radiogroup"); - group.setAttribute("aria-label", L("App_TouchMode")); const modes: [TouchMode, keyof typeof ui_locales][] = [ ["auto", "App_TouchMode_Auto"], ["on", "App_TouchMode_On"], ["off", "App_TouchMode_Off"], ]; - const buttons = modes.map(([mode, key]) => { - const b = createButton({ - label: L(key), - variant: "pill", - onClick: () => { - setTouchMode(mode); - mark(); - }, - }); - b.setAttribute("role", "radio"); - b.dataset.touchMode = mode; - return b; + // 부품 「분할 고르기」(36) — 칸마다 data-touch-mode + const group = createSegment({ + options: modes.map(([mode, key]) => ({ value: mode, label: L(key) })), + value: readTouchMode(), + size: 36, + ariaLabel: L("App_TouchMode"), + onChange: (mode) => setTouchMode(mode as TouchMode), }); - const mark = (): void => { - const now = readTouchMode(); - for (const b of buttons) b.setAttribute("aria-checked", String(b.dataset.touchMode === now)); - }; - mark(); - group.append(...buttons); - box.append(label, group); + group.root.querySelectorAll("[data-value]").forEach((b) => { + b.dataset.touchMode = b.dataset.value; + }); + box.append(label, group.root); return box; } @@ -522,11 +511,8 @@ html[data-touch="on"] :is(.app-drawer__nav > *, .app-drawer__site > summary, .ap color: var(--color-text-secondary); font-size: var(--text-caption); } -.app-drawer__touch .app-drawer__row { padding-inline: 0; } -.app-drawer__touch [aria-checked="true"] { - background: var(--color-mist-violet); - color: var(--color-accent); -} +.app-drawer__touch .ui-segment { display: flex; } +.app-drawer__touch .ui-segment__item { flex: 1 1 0; min-width: 0; } /* 휴대폰 폭 — 오른쪽 단추(언어 · 테마 · 로그인)는 서랍 안으로 */ html[data-size="compact"] .app-header { padding: 0 var(--spacing-16); gap: var(--spacing-8); } html[data-size="compact"] .app-actions { display: none; } From bb6c67deab53144259069b7383424bf7357a3cf0 Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 22:34:36 +0900 Subject: [PATCH 3/6] =?UTF-8?q?refactor(E06):=20=EC=A0=88=EB=A6=AC=20?= =?UTF-8?q?=ED=91=9C=20=C2=B7=20=EC=B8=A1=EC=A0=90=20=EB=AA=A9=EB=A1=9D=20?= =?UTF-8?q?=C2=B7=20=EB=AA=A8=EB=93=9C=20=ED=91=9C=20=C2=B7=20=ED=8C=90?= =?UTF-8?q?=EC=A0=95=20=EB=9E=A8=ED=94=84=20=C2=B7=20=EB=B3=B4=EA=B8=B0=20?= =?UTF-8?q?=EA=B3=A0=EB=A5=B4=EA=B8=B0=20=C2=B7=20=EB=B2=94=EB=A1=80=20?= =?UTF-8?q?=C2=B7=20=EA=B0=B1=EC=8B=A0=20=EB=9D=A0=EB=A5=BC=20=EB=B6=80?= =?UTF-8?q?=ED=92=88=EC=9C=BC=EB=A1=9C=20(PLAN=20553-4)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 5.5 --- E06_StabRock/E06_StabRock_UI_Joints.ts | 106 +++++++------ E06_StabRock/E06_StabRock_UI_Page.ts | 57 +++---- E06_StabRock/E06_StabRock_UI_Parts.ts | 80 ++++++++++ E06_StabRock/E06_StabRock_UI_Result.ts | 137 +++++++---------- E06_StabRock/E06_StabRock_UI_Stereonet.ts | 31 ++-- E06_StabRock/E06_StabRock_UI_Style.css | 179 ++-------------------- resources/tester/test_553_4_de_parts.py | 29 ++++ 7 files changed, 277 insertions(+), 342 deletions(-) create mode 100644 E06_StabRock/E06_StabRock_UI_Parts.ts diff --git a/E06_StabRock/E06_StabRock_UI_Joints.ts b/E06_StabRock/E06_StabRock_UI_Joints.ts index d64d4fecb..447dfe8ab 100644 --- a/E06_StabRock/E06_StabRock_UI_Joints.ts +++ b/E06_StabRock/E06_StabRock_UI_Joints.ts @@ -6,8 +6,9 @@ * ========================================================================== */ import { createPanelCard } from "@ui/ui_template_collapsible"; -import { createButton, el } from "@ui/ui_template_elements"; +import { createButton, createInputField, createSelectField, el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { e06Table, note } from "./E06_StabRock_UI_Parts"; export interface JointSet { id: string; @@ -55,7 +56,14 @@ export interface JointsView { rebuild: () => void; // 줄 수가 바뀜 } -/** 숫자 칸 — 범위 밖 · 빈 칸(필수)이면 is-invalid · 바꾸면 set 부르고 change */ +/** 틀린 칸 표시 — 부품 오류 상태(`is-error` · aria-invalid) */ +function markInvalid(input: HTMLElement, bad: boolean): void { + input.classList.toggle("is-error", bad); + if (bad) input.setAttribute("aria-invalid", "true"); + else input.removeAttribute("aria-invalid"); +} + +/** 숫자 칸 — 범위 밖 · 빈 칸(필수)이면 오류 표시 · 바꾸면 set 부르고 change */ function numberInput( view: JointsView, value: number | null, @@ -64,17 +72,17 @@ function numberInput( opts: { optional?: boolean; step?: string; path: string }, ): HTMLInputElement { const [lo, hi] = view.defaults.limits[limit] ?? [-Infinity, Infinity]; - const input = el("input", { - className: "e06-input", - attrs: { type: "number", step: opts.step ?? "any", "data-path": opts.path }, - }) as HTMLInputElement; + const input = createInputField({ type: "number", size: 24 }).input; + input.classList.add("e06-input"); + input.step = opts.step ?? "any"; + input.dataset.path = opts.path; input.value = value === null ? "" : String(value); input.disabled = !view.editable; const check = (): boolean => { const raw = input.value.trim(); const num = Number(raw); const ok = raw === "" ? Boolean(opts.optional) : Number.isFinite(num) && num >= lo && num <= hi; - input.classList.toggle("is-invalid", !ok); + markInvalid(input, !ok); input.title = ok ? "" : `${t("E06_Err_Range")} (${lo} ~ ${hi})`; return ok; }; @@ -107,64 +115,61 @@ const COLUMNS: LocaleKey[] = [ function jointTable(view: JointsView): HTMLElement { const wrap = el("div", { className: "e06-table-wrap" }); - const table = el("table", { className: "e06-table" }); - const head = el("tr"); - for (const key of COLUMNS) head.append(el("th", { text: t(key) })); - head.append(el("th")); - table.append(el("thead"), el("tbody")); - table.tHead!.append(head); - view.doc.sets.forEach((row, k) => { - const tr = el("tr", { attrs: { "data-id": row.id } }); - const name = el("input", { - className: "e06-input e06-input--name", - attrs: { type: "text", "data-path": `sets.${k}.name` }, - }) as HTMLInputElement; + const sets = view.doc.sets; + const fields: [keyof JointSet, string, boolean][] = [ + ["from_m", "chainage_m", false], + ["to_m", "chainage_m", false], + ["dip_direction", "dip_direction", false], + ["dip", "dip", false], + ["phi", "phi", false], + ["c", "c", false], + ["persistence_m", "persistence_m", true], + ]; + const rows = sets.map((row, k) => { + const name = createInputField({ size: 24 }).input; + name.classList.add("e06-input", "e06-input--name"); + name.dataset.path = `sets.${k}.name`; name.value = row.name; name.disabled = !view.editable; name.addEventListener("input", () => { row.name = name.value; - name.classList.toggle("is-invalid", !name.value.trim()); + markInvalid(name, !name.value.trim()); view.change(); }); - tr.append(el("td", { children: [name] })); - const fields: [keyof JointSet, string, boolean][] = [ - ["from_m", "chainage_m", false], - ["to_m", "chainage_m", false], - ["dip_direction", "dip_direction", false], - ["dip", "dip", false], - ["phi", "phi", false], - ["c", "c", false], - ["persistence_m", "persistence_m", true], - ]; + const cells: HTMLElement[] = [name]; for (const [key, limit, optional] of fields) { - const input = numberInput( - view, - row[key] as number | null, - limit, - (v) => ((row as unknown as Record)[key] = v), - { optional, path: `sets.${k}.${key}` }, + cells.push( + numberInput( + view, + row[key] as number | null, + limit, + (v) => ((row as unknown as Record)[key] = v), + { optional, path: `sets.${k}.${key}` }, + ), ); - tr.append(el("td", { children: [input] })); } const remove = createButton({ label: "×", variant: "danger-ghost", size: "sm", onClick: () => { - view.doc.sets.splice(k, 1); + sets.splice(k, 1); view.rebuild(); }, }); remove.title = t("E06_Remove"); remove.disabled = !view.editable; - tr.append(el("td", { children: [remove] })); - table.tBodies[0].append(tr); + cells.push(remove); + return cells; }); - wrap.append(table); - if (!view.doc.sets.length) wrap.append(el("p", { className: "e06-hint", text: t("E06_NoSets") })); + const list = e06Table([...COLUMNS.map((key) => t(key)), ""], rows, "e06-table"); + list.rows.forEach((tr, k) => (tr.dataset.id = sets[k].id)); + wrap.append(list.root); + if (!sets.length) wrap.append(note(t("E06_NoSets"))); return wrap; } +/** 좁은 패널 설정 줄 — 설정 줄 부품(줄 52 · 큰 글)은 이 폭에 안 맞아 옛 꼴 그대로 */ function settingRow(label: LocaleKey, control: HTMLElement): HTMLElement { const row = el("label", { className: "e06-setting" }); row.append(el("span", { text: t(label) }), control); @@ -178,11 +183,12 @@ function select( set: (v: T) => void, path: string, ): HTMLSelectElement { - const box = el("select", { - className: "e06-input", - attrs: { "data-path": path }, - }) as HTMLSelectElement; - for (const [v, key] of options) box.append(el("option", { text: t(key), attrs: { value: v } })); + const box = createSelectField({ + options: options.map(([v, key]) => ({ value: v, text: t(key) })), + size: 24, + }).select; + box.classList.add("e06-input"); + box.dataset.path = path; box.value = value; box.disabled = !view.editable; box.addEventListener("change", () => { @@ -241,7 +247,7 @@ export function buildJointCards(view: JointsView): HTMLElement[] { }); add.disabled = !view.editable; setsCard.append( - el("p", { className: "e06-hint", text: t("E06_JointsHint") }), + note(t("E06_JointsHint")), jointTable(view), el("div", { className: "e06-row-actions", children: [add] }), ); @@ -324,8 +330,8 @@ export function buildJointCards(view: JointsView): HTMLElement[] { path: "settings.bolt.plunge", }), ), - el("p", { className: "e06-hint", text: t("E06_SettingsHint") }), - el("p", { className: "e06-hint", text: t("E06_MinBlockHint") }), + note(t("E06_SettingsHint")), + note(t("E06_MinBlockHint")), ); return [setsCard, setCard]; } diff --git a/E06_StabRock/E06_StabRock_UI_Page.ts b/E06_StabRock/E06_StabRock_UI_Page.ts index f96156bd7..fa66bf777 100644 --- a/E06_StabRock/E06_StabRock_UI_Page.ts +++ b/E06_StabRock/E06_StabRock_UI_Page.ts @@ -10,6 +10,7 @@ import "./E06_StabRock_UI_Style.css"; import { API_BASE_URL, CURRENT_PROJECT_ID_KEY, ROUTES } from "@config/config_frontend"; +import { createBanner } from "@ui/ui_template_banner"; import { attachCollapsible } from "@ui/ui_template_collapsible"; import { createButton, @@ -19,7 +20,7 @@ import { showLoadingOverlay, showToast, } from "@ui/ui_template_elements"; -import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { t } from "@ui/ui_template_locale"; import { createSidePanels } from "@ui/ui_template_side_panels"; import { mountGroupStep } from "../A00_Common/group_workflow"; import { @@ -38,6 +39,7 @@ import { type StationEntry, } from "./E06_StabRock_UI_Result"; import { drawStereonet, netLegend, type NetData, type NetMode } from "./E06_StabRock_UI_Stereonet"; +import { emptyBox, note, toggle } from "./E06_StabRock_UI_Parts"; interface RockResponse { joints: JointsDoc; @@ -76,33 +78,16 @@ async function api(path: string, init?: RequestInit): Promise { return body; } -function toggle(options: [LocaleKey, T][], value: T, onPick: (v: T) => void) { - const group = el("div", { className: "e06-toggle" }); - const buttons = options.map(([key, v]) => { - const button = createButton({ label: t(key), variant: "ghost", size: "sm" }); - button.dataset.value = v; - button.classList.toggle("is-on", v === value); - button.addEventListener("click", () => { - buttons.forEach((b) => b.classList.toggle("is-on", b === button)); - onPick(v); - }); - return button; - }); - group.append(...buttons); - return group; -} - export function renderE06StabRock(root: HTMLElement): void { const projectId = localStorage.getItem(CURRENT_PROJECT_ID_KEY); const panel = el("div", { className: "ui-sidebar-panel e06-panel" }); const main = el("div", { className: "e06-page" }); mountGroupStep(root, ROUTES.E06_STAB_ROCK, { mainContent: main, leftPanel: panel }); - if (!projectId) - return void main.append(el("p", { className: "e06-empty", text: t("E06_NoProject") })); + if (!projectId) return void main.append(emptyBox(t("E06_NoProject"))); // ---- 좌 ---- const cards = el("div", { className: "e06-cards" }); - const status = el("p", { className: "e06-status" }); + const status = el("div", { className: "e06-status" }); const saveButton = createButton({ label: t("Common_Btn_Save"), variant: "ghost", @@ -121,11 +106,12 @@ export function renderE06StabRock(root: HTMLElement): void { saveButton.disabled = resetButton.disabled = analyzeButton.disabled = true; const actions = el("div", { className: "ui-sidebar-actions" }); actions.append(resetButton, saveButton, analyzeButton); - panel.append(el("p", { className: "e06-hint", text: t("E06_Note") }), cards, status, actions); + panel.append(note(t("E06_Note")), cards, status, actions); attachCollapsible(cards); // ---- 가운데 · 우 ---- - const staleBar = el("div", { className: "e06-stale", text: t("E06_Stale") }); + const staleBar = createBanner({ tone: "warning", text: t("E06_Stale") }); + staleBar.classList.add("e06-stale"); staleBar.hidden = true; const bandSlot = el("div"); const title = el("strong", { className: "e06-title" }); @@ -140,10 +126,10 @@ export function renderE06StabRock(root: HTMLElement): void { projectionToggle, toggle( [ - ["E06_View_All", "all"], - ["E06_Mode_planar", "planar"], - ["E06_Mode_wedge", "wedge"], - ["E06_Mode_toppling", "toppling"], + [t("E06_View_All"), "all"], + [t("E06_Mode_planar"), "planar"], + [t("E06_Mode_wedge"), "wedge"], + [t("E06_Mode_toppling"), "toppling"], ], netMode, (v) => { @@ -178,14 +164,14 @@ export function renderE06StabRock(root: HTMLElement): void { let active: string | null = null; let shown: StationResponse | null = null; - const invalidCount = (): number => cards.querySelectorAll(".is-invalid").length; + const invalidCount = (): number => cards.querySelectorAll('[aria-invalid="true"]').length; const paintStatus = (): void => { const lines: string[] = []; if (view && !view.editable) lines.push(t("E06_ReadOnly")); if (dirty) lines.push(t("E06_Unsaved")); const bad = invalidCount(); if (bad) lines.push(`${t("E06_Invalid")} ${bad}`); - status.textContent = lines.join("\n"); + status.replaceChildren(...(lines.length ? [note(lines.join("\n"), "e06-status__text")] : [])); status.classList.toggle("is-error", bad > 0); }; @@ -211,8 +197,8 @@ export function renderE06StabRock(root: HTMLElement): void { projectionToggle.replaceChildren( toggle( [ - ["E06_Proj_Angle", "equal_angle"], - ["E06_Proj_Area", "equal_area"], + [t("E06_Proj_Angle"), "equal_angle"], + [t("E06_Proj_Area"), "equal_area"], ], projection, (v) => { @@ -227,7 +213,7 @@ export function renderE06StabRock(root: HTMLElement): void { legendSlot.replaceChildren(netLegend(netMode)); if (!shown) { title.textContent = ""; - netSlot.replaceChildren(el("p", { className: "e06-empty", text: t("E06_PickStation") })); + netSlot.replaceChildren(emptyBox(t("E06_PickStation"))); return; } const e = shown.entry; @@ -247,7 +233,7 @@ export function renderE06StabRock(root: HTMLElement): void { listSlot.replaceChildren( stations.length ? buildList(stations, active, (id) => void select(id)) - : el("p", { className: "e06-hint", text: t(result ? "E06_NoRock" : "E06_NoResult") }), + : note(t(result ? "E06_NoRock" : "E06_NoResult")), ); detailSlot.replaceChildren(shown ? buildDetail(shown.entry) : el("span")); paintNet(); @@ -346,7 +332,7 @@ export function renderE06StabRock(root: HTMLElement): void { async function analyze(): Promise { if (!view?.editable) return; analyzeButton.disabled = true; - status.textContent = t("E06_Running"); + status.replaceChildren(note(t("E06_Running"), "e06-status__text")); try { if (dirty && !(await putJoints())) return; const body = await api<{ status: string; summary?: RockResult["summary"] }>( @@ -387,10 +373,7 @@ export function renderE06StabRock(root: HTMLElement): void { await loadResult(); } catch (error) { main.replaceChildren( - el("p", { - className: "e06-empty", - text: error instanceof Error ? error.message : t("Common_Status_Error"), - }), + emptyBox(error instanceof Error ? error.message : t("Common_Status_Error")), ); } })(); diff --git a/E06_StabRock/E06_StabRock_UI_Parts.ts b/E06_StabRock/E06_StabRock_UI_Parts.ts new file mode 100644 index 000000000..60c656784 --- /dev/null +++ b/E06_StabRock/E06_StabRock_UI_Parts.ts @@ -0,0 +1,80 @@ +/* ============================================================================= + * E06_StabRock_UI_Parts.ts + * E06 안 부품 걸이(553-4) — 부품(보조 글 · 빈 자리 · 표 · 배지 · 분할 고르기)을 부르고 + * 촬영 장면 · 시험이 잡는 이름(`e06-hint` · `e06-empty` · `e06-table` · `e06-list` · `e06-cases` · `e06-verdict` · `e06-toggle`)만 얹음. + * 모양은 부품 · 이 파일에 CSS 없음. + * ========================================================================== */ + +import { createBadge, type BadgeTone } from "@ui/ui_template_badge"; +import { createEmpty } from "@ui/ui_template_empty"; +import { createSegment } from "@ui/ui_template_segment"; +import { createTable, type Cell } from "@ui/ui_template_table"; +import { createText } from "@ui/ui_template_text"; + +/** 보조 글 — 걸이 이름(`e06-hint` · `e06-kin` · `e06-status` …) */ +export function note(text: string, hook = "e06-hint"): HTMLElement { + const node = createText(text, "muted"); + node.classList.add(hook); + return node; +} + +export function emptyBox(text: string): HTMLElement { + const node = createEmpty({ text, small: true }); + node.classList.add("e06-empty"); + return node; +} + +export interface E06Table { + root: HTMLElement; + table: HTMLTableElement; + rows: HTMLTableRowElement[]; +} + +/** 표 부품(데이터표) — `className` = 표에 얹는 걸이 이름 · `role` = data-role */ +export function e06Table( + headers: string[], + rows: Cell[][], + className: string, + role?: string, +): E06Table { + const root = createTable( + headers.map((label) => ({ label })), + rows, + { density: "data" }, + ); + const table = root.querySelector("table") as HTMLTableElement; + table.classList.add(className); + if (role) table.dataset.role = role; + return { root, table, rows: [...table.tBodies[0].rows] }; +} + +const TONE: Record = { + ng: "danger", + check: "warning", + ok: "success", + none: "neutral", +}; + +/** 판정 램프 — verdict = NG · check · OK · none */ +export function verdictLamp(verdict: string, text: string): HTMLElement { + const key = verdict.toLowerCase(); + const lamp = createBadge(text, TONE[key] ?? "neutral", { kind: "lamp" }); + lamp.classList.add("e06-verdict", `e06-verdict--${key}`); + return lamp; +} + +/** 보기 고르기 — 단추 모양은 분할 고르기 부품 · `data-value` 는 그대로 */ +export function toggle( + options: [string, T][], + value: T, + onPick: (v: T) => void, +): HTMLElement { + const group = createSegment({ + options: options.map(([label, v]) => ({ value: v, label })), + value, + size: 24, + onChange: (v) => onPick(v as T), + }); + group.root.classList.add("e06-toggle"); + return group.root; +} diff --git a/E06_StabRock/E06_StabRock_UI_Result.ts b/E06_StabRock/E06_StabRock_UI_Result.ts index b0ce05257..5619c350c 100644 --- a/E06_StabRock/E06_StabRock_UI_Result.ts +++ b/E06_StabRock/E06_StabRock_UI_Result.ts @@ -6,6 +6,7 @@ import { el } from "@ui/ui_template_elements"; import { t, type LocaleKey } from "@ui/ui_template_locale"; +import { e06Table, note, verdictLamp } from "./E06_StabRock_UI_Parts"; export type Verdict = "NG" | "check" | "OK" | "none"; @@ -66,10 +67,7 @@ const fmt = (v: number | null | undefined, d = 2): string => v === null || v === undefined ? "–" : v.toFixed(d); function badge(verdict: Verdict): HTMLElement { - return el("span", { - className: `e06-verdict e06-verdict--${verdict.toLowerCase()}`, - text: t(`E06_V_${verdict}` as LocaleKey), - }); + return verdictLamp(verdict, t(`E06_V_${verdict}` as LocaleKey)); } /** 위험 띠 — 측점마다 가장 나쁜 판정 · 이웃 측점 가운데서 끊음 */ @@ -119,7 +117,7 @@ export function buildBand( el("span", { text: `${hi.toFixed(0)} m` }), ); box.append(legend, strip, ticks); - } else box.append(legend, el("p", { className: "e06-hint", text: t("E06_NoRock") })); + } else box.append(legend, note(t("E06_NoRock"))); return box; } @@ -128,34 +126,28 @@ export function buildList( active: string | null, onSelect: (id: string) => void, ): HTMLElement { - const table = el("table", { className: "e06-list", attrs: { "data-role": "station-list" } }); - const head = el("tr"); - for (const key of [ - "E06_L_Station", - "E06_L_Side", - "E06_L_Modes", - "E06_L_MinF", - "E06_L_Verdict", - ] as LocaleKey[]) - head.append(el("th", { text: t(key) })); - table.append(el("thead", { children: [head] }), el("tbody")); - for (const e of stations) { + const heads = ["E06_L_Station", "E06_L_Side", "E06_L_Modes", "E06_L_MinF", "E06_L_Verdict"]; + const list = e06Table( + heads.map((key) => t(key as LocaleKey)), + stations.map((e) => [ + e.label ?? e.key, + t(e.slope.side === "left" ? "E06_Left" : "E06_Right"), + `${e.counts.planar} · ${e.counts.wedge} · ${e.counts.toppling}`, + fmt(e.min_F), + badge(e.verdict), + ]), + "e06-list", + "station-list", + ); + list.rows.forEach((tr, i) => { + const e = stations[i]; const id = entryId(e); - const tr = el("tr", { - className: id === active ? "is-active" : "", - attrs: { "data-id": id, "data-verdict": e.verdict }, - }); - tr.append( - el("td", { text: e.label ?? e.key }), - el("td", { text: t(e.slope.side === "left" ? "E06_Left" : "E06_Right") }), - el("td", { text: `${e.counts.planar} · ${e.counts.wedge} · ${e.counts.toppling}` }), - el("td", { text: fmt(e.min_F) }), - el("td", { children: [badge(e.verdict)] }), - ); + tr.dataset.id = id; + tr.dataset.verdict = e.verdict; + tr.classList.toggle("is-active", id === active); tr.addEventListener("click", () => onSelect(id)); - table.tBodies[0].append(tr); - } - return table; + }); + return list.root; } export function buildDetail(e: StationEntry): HTMLElement { @@ -163,15 +155,14 @@ export function buildDetail(e: StationEntry): HTMLElement { const s = e.slope; box.append( el("h4", { text: `${e.label ?? e.key} · ${t(s.side === "left" ? "E06_Left" : "E06_Right")}` }), - el("p", { - className: "e06-hint", - text: t("E06_SlopeLine") + note( + t("E06_SlopeLine") .replace("{dd}", s.dip_direction.toFixed(0)) .replace("{dip}", s.dip.toFixed(1)) .replace("{h}", s.height_m.toFixed(2)) .replace("{up}", s.upper_dip.toFixed(1)) .replace("{g}", e.gamma_r.toFixed(1)), - }), + ), ); const kin = e.kinematic; const kinText = [ @@ -179,57 +170,47 @@ export function buildDetail(e: StationEntry): HTMLElement { `${t("E06_Mode_wedge")} ${kin.wedge.map((w) => `${w.ids.join("·")}(${w.trend.toFixed(0)}/${w.plunge.toFixed(0)})`).join(", ") || "–"}`, `${t("E06_Mode_toppling")} ${kin.toppling.join(", ") || "–"}`, ]; - box.append(el("p", { className: "e06-kin", text: kinText.join("\n") })); + box.append(note(kinText.join("\n"), "e06-kin")); if (!e.cases.length) { - box.append( - el("p", { - className: "e06-hint", - text: t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere"), - }), - ); + box.append(note(t(e.joints.length ? "E06_NoRisk" : "E06_NoJointsHere"))); return box; } - const table = el("table", { className: "e06-cases" }); - const head = el("tr"); - for (const key of [ + const heads = [ "E06_C_Mode", "E06_C_Joints", "E06_C_Cond", "E06_C_F", "E06_C_Req", "E06_L_Verdict", - ] as LocaleKey[]) - head.append(el("th", { text: t(key) })); - table.append(el("thead", { children: [head] }), el("tbody")); - for (const c of e.cases) { - const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK"; - const note = !c.formed - ? t("E06_NotFormed") - : c.small - ? t("E06_Small") - : c.persistence_short - ? t("E06_Persist") - : ""; - const tr = el("tr", { attrs: { "data-mode": c.mode, "data-cond": c.condition } }); - tr.append( - el("td", { text: t(`E06_Mode_${c.mode}` as LocaleKey) }), - el("td", { text: c.ids.join("·") }), - el("td", { text: t(`E06_Cond_${c.condition}` as LocaleKey) }), - el("td", { text: `${fmt(c.F, 3)}${note ? ` ${note}` : ""}` }), - el("td", { text: fmt(c.required) }), - el("td", { children: [c.small ? el("span", { text: "–" }) : badge(verdict)] }), - ); - table.tBodies[0].append(tr); - } - box.append(table); - if (e.small_blocks) - box.append( - el("p", { - className: "e06-hint", - text: t("E06_SmallCount").replace("{n}", String(e.small_blocks)), - }), - ); - if (kin.toppling.length) - box.append(el("p", { className: "e06-hint", text: t("E06_ToppleNote") })); + ]; + const cases = e06Table( + heads.map((key) => t(key as LocaleKey)), + e.cases.map((c) => { + const verdict: Verdict = c.F === null ? "check" : c.ok === false ? "NG" : "OK"; + const text = !c.formed + ? t("E06_NotFormed") + : c.small + ? t("E06_Small") + : c.persistence_short + ? t("E06_Persist") + : ""; + return [ + t(`E06_Mode_${c.mode}` as LocaleKey), + c.ids.join("·"), + t(`E06_Cond_${c.condition}` as LocaleKey), + `${fmt(c.F, 3)}${text ? ` ${text}` : ""}`, + fmt(c.required), + c.small ? "–" : badge(verdict), + ]; + }), + "e06-cases", + ); + cases.rows.forEach((tr, i) => { + tr.dataset.mode = e.cases[i].mode; + tr.dataset.cond = e.cases[i].condition; + }); + box.append(cases.root); + if (e.small_blocks) box.append(note(t("E06_SmallCount").replace("{n}", String(e.small_blocks)))); + if (kin.toppling.length) box.append(note(t("E06_ToppleNote"))); return box; } diff --git a/E06_StabRock/E06_StabRock_UI_Stereonet.ts b/E06_StabRock/E06_StabRock_UI_Stereonet.ts index 73e2b9e21..4331c41f5 100644 --- a/E06_StabRock/E06_StabRock_UI_Stereonet.ts +++ b/E06_StabRock/E06_StabRock_UI_Stereonet.ts @@ -6,7 +6,7 @@ * 일광 엔벨로프(평면) · 사면 대원 = 굵은 선. 위험 판정된 절리 · 교선 = 테두리 강조. * ========================================================================== */ -import { el } from "@ui/ui_template_elements"; +import { createLegend } from "@ui/ui_template_flow_legend"; import { t, type LocaleKey } from "@ui/ui_template_locale"; const SVG_NS = "http://www.w3.org/2000/svg"; @@ -169,8 +169,19 @@ export function drawStereonet(net: NetData, mode: NetMode, marks: NetMarks): SVG return root; } +/** 범례 — 범례 부품(선 · 점선 · 칸 · 점) · 삼각(교선)만 걸이 모양 */ +const LEGEND_MARK: Record = { + slope: { color: "var(--color-text)", kind: "line" }, + friction: { color: "var(--color-text-secondary)", kind: "dash" }, + planar: { color: "color-mix(in srgb, var(--e06-planar) 35%, transparent)", kind: "chip" }, + daylight: { color: "var(--e06-planar)", kind: "dash" }, + wedge: { color: "color-mix(in srgb, var(--e06-wedge) 35%, transparent)", kind: "chip" }, + line: { color: "var(--e06-wedge)", kind: "chip" }, + toppling: { color: "color-mix(in srgb, var(--e06-toppling) 35%, transparent)", kind: "chip" }, + pole: { color: "var(--e06-pole)", kind: "dot" }, +}; + export function netLegend(mode: NetMode): HTMLElement { - const box = el("div", { className: "e06-legend" }); const items: [string, LocaleKey][] = [ ["slope", "E06_Lg_Slope"], ["friction", "E06_Lg_Friction"], @@ -181,13 +192,13 @@ export function netLegend(mode: NetMode): HTMLElement { items.push(["wedge", "E06_Lg_Wedge"], ["line", "E06_Lg_Line"]); if (mode === "all" || mode === "toppling") items.push(["toppling", "E06_Lg_Toppling"]); if (mode !== "wedge") items.push(["pole", "E06_Lg_Pole"]); - for (const [cls, key] of items) { - const row = el("span", { className: "e06-legend__row" }); - row.append( - el("i", { className: `e06-legend__mark e06-legend__mark--${cls}` }), - el("span", { text: t(key) }), - ); - box.append(row); - } + const box = createLegend( + items.map(([cls, key]) => ({ label: t(key), ...LEGEND_MARK[cls] })), + { vertical: true }, + ); + box.classList.add("e06-legend"); + box.querySelectorAll(".ui-legend__mark").forEach((mark, i) => { + if (items[i][0] === "line") mark.classList.add("e06-legend__mark--line"); + }); return box; } diff --git a/E06_StabRock/E06_StabRock_UI_Style.css b/E06_StabRock/E06_StabRock_UI_Style.css index eb347f792..46c9f7516 100644 --- a/E06_StabRock/E06_StabRock_UI_Style.css +++ b/E06_StabRock/E06_StabRock_UI_Style.css @@ -12,6 +12,7 @@ --e06-planar: #d03b3b; --e06-wedge: #f1963b; --e06-toppling: #9b6dde; + --e06-pole: #2f6fd6; height: 100dvh; box-sizing: border-box; } @@ -31,39 +32,21 @@ overflow: auto; } -.e06-empty, +/* 보조 글 · 빈 자리 · 띠 알림 · 표 · 설정 줄 · 판정 램프는 부품 — 여기는 자리만 */ +.e06-status__text, .e06-hint, -.e06-status, .e06-kin { - margin: 0; white-space: pre-line; - color: var(--color-text-muted); - font-size: 12px; } .e06-empty { - padding: var(--spacing-16); -} - -.e06-status.is-error { - color: var(--color-danger); -} - -.e06-kin { - color: var(--color-text); + margin: var(--spacing-16); } .e06-panel .ui-sidebar-actions { flex-wrap: wrap; } -.e06-stale { - padding: var(--spacing-8) var(--spacing-12); - border-radius: var(--radius-sm); - background: color-mix(in srgb, var(--color-warning) 18%, var(--color-surface)); - color: var(--color-text); -} - .e06-stale[hidden] { display: none; } @@ -73,32 +56,8 @@ overflow-x: auto; } -.e06-table { - border-collapse: collapse; - font-size: 12px; -} - -.e06-table th { - padding: 2px 3px; - font-weight: var(--font-weight-medium); - color: var(--color-text-secondary); - white-space: nowrap; -} - -.e06-table td { - padding: 1px 2px; -} - .e06-input { width: 4.2em; - padding: 2px 4px; - border: 1px solid var(--color-border); - border-radius: var(--radius-sm); - background: var(--color-surface); - color: var(--color-text); - font: inherit; - font-size: 12px; - box-sizing: border-box; } .e06-input--name { @@ -109,11 +68,6 @@ select.e06-input { width: auto; } -.e06-input.is-invalid { - border-color: var(--color-danger); - background: color-mix(in srgb, var(--color-danger) 12%, var(--color-surface)); -} - .e06-row-actions { display: flex; justify-content: flex-end; @@ -152,17 +106,6 @@ select.e06-input { color: var(--color-text); } -.e06-toggle { - display: inline-flex; - gap: var(--spacing-4); -} - -.e06-toggle .ui-btn.is-on { - border-color: var(--color-primary); - background: var(--color-primary); - color: var(--color-primary-text); -} - .e06-stage { display: flex; gap: var(--spacing-12); @@ -289,58 +232,9 @@ select.e06-input { color: #0e7490; } -.e06-legend { - display: flex; - flex-direction: column; - gap: 4px; - font-size: 12px; - color: var(--color-text); -} - -.e06-legend__row { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.e06-legend__mark { - display: inline-block; - width: 16px; - height: 10px; - border-radius: 2px; -} - -.e06-legend__mark--slope { - height: 3px; - background: var(--color-text); -} -.e06-legend__mark--friction { - height: 0; - border-top: 1px dashed var(--color-text-secondary); -} -.e06-legend__mark--daylight { - height: 0; - border-top: 1px dashed var(--e06-planar); -} -.e06-legend__mark--planar { - background: color-mix(in srgb, var(--e06-planar) 35%, transparent); -} -.e06-legend__mark--wedge { - background: color-mix(in srgb, var(--e06-wedge) 35%, transparent); -} -.e06-legend__mark--toppling { - background: color-mix(in srgb, var(--e06-toppling) 35%, transparent); -} .e06-legend__mark--line { - width: 10px; - background: var(--e06-wedge); clip-path: polygon(50% 0, 100% 100%, 0 100%); } -.e06-legend__mark--pole { - width: 10px; - border-radius: 50%; - background: #2f6fd6; -} /* ---- 위험 띠 ---- */ .e06-band { @@ -418,74 +312,25 @@ select.e06-input { overflow: auto; } -.e06-list, -.e06-cases { - width: 100%; - border-collapse: collapse; - font-size: 12px; - color: var(--color-text); +.e06-list-slot .ui-table-wrap { + overflow: visible; + border: 0; + border-radius: 0; + background: none; } -.e06-list th, -.e06-cases th { +.e06-list-slot th { position: sticky; top: 0; - padding: 3px 4px; - background: var(--color-surface); - color: var(--color-text-secondary); - font-weight: var(--font-weight-medium); - text-align: left; -} - -.e06-list td, -.e06-cases td { - padding: 3px 4px; - border-top: 1px solid var(--color-border); + z-index: 1; + background: var(--color-surface-raised); } .e06-list tbody tr { cursor: pointer; } -.e06-list tbody tr:hover { - background: var(--color-surface-raised); -} - -.e06-list tr.is-active { - background: color-mix(in srgb, var(--color-primary) 16%, transparent); -} - .e06-detail h4 { margin: 0 0 4px; color: var(--color-text); } - -.e06-verdict { - display: inline-flex; - align-items: center; - gap: 4px; - font-weight: var(--font-weight-medium); - white-space: nowrap; -} - -.e06-verdict::before { - content: ""; - width: 9px; - height: 9px; - border-radius: 50%; - background: var(--e06-none); -} - -.e06-verdict--ng::before { - background: var(--e06-ng); - border-radius: 2px; - transform: rotate(45deg); -} -.e06-verdict--ok::before { - background: var(--e06-ok); -} -.e06-verdict--check::before { - background: var(--e06-check); - border-radius: 0; - clip-path: polygon(50% 0, 100% 100%, 0 100%); -} diff --git a/resources/tester/test_553_4_de_parts.py b/resources/tester/test_553_4_de_parts.py index cc714073b..9b1af02f5 100644 --- a/resources/tester/test_553_4_de_parts.py +++ b/resources/tester/test_553_4_de_parts.py @@ -145,3 +145,32 @@ def test_e05_parts(): assert ".e05-stale[hidden]" in css # 띠 알림은 flex 라 hidden 을 덮음 for dead in (".e05-preview {", ".e05-preview__head", ".e05-status.is-error"): assert dead not in css, dead + + +def test_e06_parts(): + names = ["Joints", "Page", "Result", "Stereonet"] + ts = {n: _src(f"E06_StabRock/E06_StabRock_UI_{n}.ts") for n in names} + parts = _src("E06_StabRock/E06_StabRock_UI_Parts.ts") + css = _src("E06_StabRock/E06_StabRock_UI_Style.css") + for n, src in ts.items(): + assert 'el("table"' not in src and 'el("input"' not in src, n + assert 'el("select"' not in src and 'el("p"' not in src, n + for part in ("createTable(", "createBadge(", "createSegment(", "createText(", "createEmpty("): + assert part in parts, part + assert "createLegend(" in ts["Stereonet"] and "createBanner(" in ts["Page"] + assert "createInputField(" in ts["Joints"] and "createSelectField(" in ts["Joints"] + assert ( + 'classList.add("e06-toggle")' in parts + ) # 촬영 장면이 `.e06-toggle button[data-value]` 로 잡음 + assert '"station-list"' in ts["Result"] # 촬영 장면 · 시험이 잡는 data-role + for dead in ( + ".e06-verdict", + ".e06-list th", + ".e06-cases", + ".e06-table th", + ".e06-legend__row", + ".e06-toggle .ui-btn", + "is-invalid", + ): + assert dead not in css, dead + assert "is-invalid" not in ts["Joints"] + ts["Page"] From de250ab7598ccf1c0fb226ebffca6d23d1c022ad Mon Sep 17 00:00:00 2001 From: umsangdon Date: Mon, 26 Oct 2026 22:35:43 +0900 Subject: [PATCH 4/6] =?UTF-8?q?refactor(cad):=20CAD=20=EC=95=B1=20?= =?UTF-8?q?=EA=B2=89=20=EC=9E=90=EC=B2=B4=20=EB=B3=80=EC=A2=85=EC=9D=84=20?= =?UTF-8?q?=EB=B6=80=ED=92=88=20=EC=83=81=EC=9E=90=20=EB=B6=80=ED=92=88?= =?UTF-8?q?=EC=9C=BC=EB=A1=9C=20=E2=80=94=20=EC=83=81=ED=83=9C=EB=A7=89?= =?UTF-8?q?=EB=8C=80=20=EC=BC=9C=EA=B8=B0=20=EC=B9=A9=20=C2=B7=20=ED=8C=A8?= =?UTF-8?q?=EB=84=90=20=ED=83=AD=20=C2=B7=20=EB=96=A0=20=EC=9E=88=EB=8A=94?= =?UTF-8?q?=20=ED=8C=90=20=C2=B7=20=EB=A9=94=EB=89=B4=20=C2=B7=20=EC=95=84?= =?UTF-8?q?=EC=9D=B4=EC=BD=98=20=EB=8B=A8=EC=B6=94=20=C2=B7=20=ED=99=95?= =?UTF-8?q?=EC=9D=B8=EC=B0=BD=20(PLAN=20553-6)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 부품 상자 CSS · 단추 기본 꼴을 CAD iframe 에 실음(integration/ui-kit.ts) · 자리 · 폭은 cad-* 그대로 - 상태막대 = ui-toggle--chip 22 · 특성/레이어 = ui-tabs 36 · ↶↷ · 리본 접기 · 탐색 막대 = ui-icon-btn 20 · 32 - 빠른 특성 · 블록 라이브러리 · 자리표 = ui-float-panel · 리본 넘침 · 스냅 · 명령 자동완성 = ui-ctx-menu - window.confirm · window.prompt → askConfirm · askText · 빈 안내 = ui-empty 작은 꼴 - 높이 단: 리본 칸 19→18 · 명령 칸 26→28 · 레이어 줄 40→42 · 문서 탭 25→24 · 도각 편집 단추 24 - 마우스 바탕 = --color-hover · 켜짐 = --color-accent-soft · 도면 그림 색(--draw-*) 안 건드림 - 안 쓰는 DropdownButton 지움(쓰는 곳 0 · grep) Co-Authored-By: Claude Opus 5.5 --- B07_DesignDetail/openwebcad/src/App.css | 84 ++----- B07_DesignDetail/openwebcad/src/App.types.ts | 1 - .../openwebcad/src/App_Layers.css | 215 +++--------------- .../src/commands/commands.insert.ts | 11 +- .../src/components/BlockLibraryPanel.tsx | 123 ++++++---- .../openwebcad/src/components/Button.tsx | 4 +- .../openwebcad/src/components/CommandLine.tsx | 8 +- .../src/components/DropdownButton.tsx | 69 ------ .../src/components/FramePlaceholderPanel.tsx | 125 +++++----- .../src/components/InspectorPanel.tsx | 13 +- .../src/components/PropertiesEditor.tsx | 9 +- .../src/components/QuickAccessBar.tsx | 5 +- .../src/components/QuickProperties.tsx | 21 +- .../openwebcad/src/components/StatusBar.tsx | 26 ++- .../src/components/ViewControls.tsx | 45 +++- .../src/inputController/snap-menu.css | 26 +-- .../src/inputController/snap-menu.ts | 3 +- .../openwebcad/src/integration/ui-kit.ts | 18 ++ B07_DesignDetail/openwebcad/src/main.tsx | 2 + .../openwebcad/src/ribbon/Ribbon.tsx | 10 +- B07_DesignDetail/openwebcad/tsconfig.app.json | 11 +- ui_template/cad_host/cad_host.css | 9 +- ui_template/cad_host/cad_host_frame_edit.ts | 4 + ui_template/cad_host/cad_host_tabs.css | 18 +- 24 files changed, 364 insertions(+), 496 deletions(-) delete mode 100644 B07_DesignDetail/openwebcad/src/components/DropdownButton.tsx create mode 100644 B07_DesignDetail/openwebcad/src/integration/ui-kit.ts diff --git a/B07_DesignDetail/openwebcad/src/App.css b/B07_DesignDetail/openwebcad/src/App.css index 9c4c7197a..994e1d59c 100644 --- a/B07_DesignDetail/openwebcad/src/App.css +++ b/B07_DesignDetail/openwebcad/src/App.css @@ -27,7 +27,8 @@ --cad-text-dim: var(--color-text-secondary); --cad-text-muted: var(--color-text-muted); --cad-accent: var(--color-accent); - --cad-hover: var(--color-mist-violet); + /* 마우스 바탕 = 부품 상자와 같은 값 · 켜짐 바탕은 --color-accent-soft */ + --cad-hover: var(--color-hover); --cad-canvas: var(--draw-canvas); } @@ -111,21 +112,12 @@ body > canvas[data-id="canvas"] { flex: 1; min-width: 0; } +/* 접기 단추 = 아이콘 단추 부품(단 20) · 여기는 자리만 */ .cad-ribbon-tabs__collapse { - width: 24px; - height: 20px; align-self: center; - padding: 0; - border: 1px solid transparent; - border-radius: 4px; - background: transparent; - color: var(--cad-text-dim); - font-size: 11px; } -.cad-ribbon-tabs__collapse:hover { - background: var(--cad-hover); -} -.cad-ribbon-tabs button { +/* 리본 탭 = CAD 고유 꼴 그대로(토큰만) */ +.cad-ribbon-tabs > button:not(.ui-icon-btn) { padding: 0 14px; border: 1px solid transparent; border-bottom: 0; @@ -134,10 +126,10 @@ body > canvas[data-id="canvas"] { color: var(--cad-text-dim); font-size: 12px; } -.cad-ribbon-tabs button:hover { +.cad-ribbon-tabs > button:not(.ui-icon-btn):hover { background: var(--cad-hover); } -.cad-ribbon-tabs button[data-active="true"] { +.cad-ribbon-tabs > button[data-active="true"] { background: var(--cad-chrome); border-color: var(--cad-line); color: var(--color-text); @@ -185,53 +177,25 @@ body > canvas[data-id="canvas"] { .cad-ribbon-group__label[data-expandable="true"]:hover { color: var(--cad-accent); } +/* 꼴 = 메뉴 부품(`ui-ctx-menu`) · 여기는 자리만 */ .cad-ribbon-overflow { /* 리본 패널 줄이 가로 스크롤 상자라 그 안에서는 잘린다 — 화면 좌표로 띄운다 */ position: fixed; z-index: 20; - display: flex; - flex-direction: column; min-width: 170px; max-height: 72vh; - padding: 4px; - overflow-y: auto; - background: var(--cad-chrome-raised); - border: 1px solid var(--cad-line); - border-radius: 4px; - box-shadow: var(--shadow-lg); } -.cad-ribbon-overflow button { +.cad-ribbon-overflow > .ui-ctx-menu__item { display: flex; gap: 8px; align-items: center; - padding: 5px 8px; - border: 0; - border-radius: 3px; - background: transparent; - font-size: 11px; - text-align: left; -} -.cad-ribbon-overflow button:hover { - background: var(--cad-hover); } +/* ↶ ↷ = 아이콘 단추 부품(단 20) · 여기는 자리만 */ .cad-quick-access { display: flex; gap: 2px; align-self: center; } -.cad-ribbon-tabs .cad-quick-access button { - width: 24px; - height: 20px; - padding: 0; - border: 1px solid transparent; - border-radius: 4px; - line-height: 1; - font-size: 13px; -} -.cad-quick-access button:hover { - border-color: var(--cad-line); - background: var(--cad-hover); -} .cad-ribbon-props { display: flex; flex-direction: column; @@ -252,15 +216,16 @@ body > canvas[data-id="canvas"] { width: 52px; text-align: left; } +/* 리본 안 칸 = CAD 고유 꼴(토큰만) · 높이 = 크기 단 18 · 둥글기 4 */ .cad-prop select, .cad-prop input[type="number"] { - height: 19px; + height: 18px; min-width: 78px; padding: 0 3px; color: var(--cad-text); background: var(--cad-chrome-sunken); border: 1px solid var(--cad-line); - border-radius: 3px; + border-radius: 4px; font-size: 11px; } .cad-prop input[type="number"] { @@ -268,13 +233,13 @@ body > canvas[data-id="canvas"] { width: 52px; } .cad-prop input[type="color"] { - height: 19px; + height: 18px; width: 44px; padding: 1px; background: var(--cad-chrome-sunken); /* 흰색을 골라도 스와치 자리가 보이게 테두리를 진하게 준다 (2026-09-01) */ border: 1px solid var(--cad-text-muted); - border-radius: 3px; + border-radius: 4px; cursor: pointer; } /* 고른 것들 색이 다름(488) — 빈 견본(투명 · 점선 테두리) */ @@ -328,7 +293,7 @@ input[type="color"][data-mixed="true"]::-webkit-color-swatch { background: trans } .cad-tool[data-active="true"] { border-color: var(--cad-accent); - background: var(--cad-hover); + background: var(--color-accent-soft); box-shadow: inset 0 -2px var(--cad-accent); } .cad-tool:disabled { @@ -373,23 +338,14 @@ input[type="color"][data-mixed="true"]::-webkit-color-swatch { background: trans background: var(--cad-chrome-raised); color: var(--cad-text-dim); } +/* 꼴 = 탭 부품(`ui-tabs`) · 여기는 칸 둘이 폭을 나누는 배치만 */ .cad-inspector-tabs { display: grid; grid-template-columns: 1fr 1fr; - height: 38px; - border-bottom: 1px solid var(--cad-line); + gap: 0; } -.cad-inspector-tabs button { - border: 0; - border-bottom: 2px solid transparent; - background: var(--cad-chrome-sunken); - color: var(--cad-text-dim); - font-size: 12px; -} -.cad-inspector-tabs button[data-active="true"] { - border-bottom-color: var(--cad-accent); - background: var(--cad-chrome-raised); - color: var(--color-text); +.cad-inspector-tabs > .ui-tabs__tab { + justify-content: center; } .cad-properties { padding: 14px 12px; diff --git a/B07_DesignDetail/openwebcad/src/App.types.ts b/B07_DesignDetail/openwebcad/src/App.types.ts index db75fc6de..6a1d0346e 100644 --- a/B07_DesignDetail/openwebcad/src/App.types.ts +++ b/B07_DesignDetail/openwebcad/src/App.types.ts @@ -74,7 +74,6 @@ export interface Layer { export enum LOCAL_STORAGE_KEY { DRAWING = 'OPEN_WEB_CAD__DRAWING', - DROPDOWN = 'OPEN_WEB_CAD__DROPDOWN', } export interface StartAndEndpointEntity { diff --git a/B07_DesignDetail/openwebcad/src/App_Layers.css b/B07_DesignDetail/openwebcad/src/App_Layers.css index c46a6d7d1..040234e55 100644 --- a/B07_DesignDetail/openwebcad/src/App_Layers.css +++ b/B07_DesignDetail/openwebcad/src/App_Layers.css @@ -14,22 +14,22 @@ padding-right: 2px; min-width: 0; } -/* 도면층 버튼 — 색은 토큰으로만, 상태는 Button이 붙이는 data-* 속성으로 가른다. */ +/* 도면층 버튼 — 목록 줄 부품(`ui-sidebar-row`) 꼴 · 상태는 Button이 붙이는 data-* 속성으로 가른다. */ .cad-layer-manager button[data-type="regular"] { - background: var(--cad-chrome-sunken); - color: var(--cad-accent); + background: transparent; + color: var(--color-text-body); } .cad-layer-manager button[data-type="transparent"] { background: transparent; - color: var(--cad-accent); + color: var(--color-text-secondary); } .cad-layer-manager button:hover { - background: var(--cad-hover); + background: var(--color-hover); color: var(--color-text); } .cad-layer-manager button[data-active="true"] { - background: var(--color-primary); - color: var(--color-primary-text); + background: var(--color-accent-soft); + color: var(--color-accent); } .cad-view-controls { @@ -41,7 +41,7 @@ align-items: center; overflow: hidden; border: 1px solid var(--cad-line); - border-radius: 4px; + border-radius: var(--radius-buttons); background: var(--cad-chrome-raised); box-shadow: var(--shadow-lg); } @@ -87,21 +87,7 @@ body.cad-file-dragging::after { pointer-events: none; } -.cad-view-controls button { - width: 34px; - height: 32px; - border: 0; - border-right: 1px solid var(--cad-line); - background: transparent; - font-size: 17px; -} -.cad-view-controls button:hover { - background: var(--cad-hover); -} -.cad-view-controls__fit { - color: var(--cad-accent); - font-size: 15px; -} +/* 단추 = 아이콘 단추 부품(단 32) · 여기는 배율 글만 */ .cad-view-controls span { min-width: 48px; color: var(--cad-text-dim); @@ -133,30 +119,16 @@ body.cad-file-dragging::after { color: var(--cad-text-muted); font: 11px Consolas, monospace; } +/* 꼴 = 메뉴 부품(`ui-ctx-menu`) · 여기는 자리와 명령 글꼴만 */ .cad-command-suggestions { position: absolute; z-index: 6; bottom: 100%; left: 10px; - display: flex; - flex-direction: column; min-width: 220px; - padding: 4px; - background: var(--cad-chrome-raised); - border: 1px solid var(--cad-line); - border-radius: 4px; - box-shadow: var(--shadow-lg); } -.cad-command-suggestions button { - padding: 4px 8px; - border: 0; - border-radius: 3px; - background: transparent; - font: 11px Consolas, monospace; - text-align: left; -} -.cad-command-suggestions button:hover { - background: var(--cad-hover); +.cad-command-suggestions > .ui-ctx-menu__item { + font: 12px Consolas, monospace; } .cad-prop--wide select { min-width: 132px; @@ -180,12 +152,13 @@ body.cad-file-dragging::after { font-size: 11px; font-weight: 600; } +/* 명령 칸 = CAD 고유 꼴(토큰만) · 높이 = 크기 단 28 */ .cad-command-area input { flex: 1; - height: 26px; + height: 28px; padding: 0 8px; border: 1px solid var(--cad-line); - border-radius: 2px; + border-radius: var(--radius-buttons); outline: none; background: var(--cad-chrome-sunken); color: var(--cad-text); @@ -210,23 +183,7 @@ body.cad-file-dragging::after { background: var(--cad-chrome); border-top: 1px solid var(--cad-line); } -.cad-statusbar button { - height: 21px; - padding: 0 8px; - border: 1px solid transparent; - border-radius: 2px; - background: transparent; - color: var(--cad-text-dim); - font-size: 10px; -} -.cad-statusbar button:hover { - background: var(--cad-hover); -} -.cad-statusbar button[data-active="true"] { - border-color: var(--cad-accent); - background: var(--color-primary); - color: var(--color-primary-text); -} +/* 켜기 = 켜기 칩 부품(`ui-toggle--chip` · 단 22) */ .cad-statusbar__hint { margin-left: auto; color: var(--cad-text-muted); @@ -261,13 +218,14 @@ body.cad-file-dragging::after { font-size: 11px; color: var(--cad-text-dim); } +/* 칸 = 크기 단 24(둥글기 6) · 토큰만 */ .cad-properties-editor select, .cad-properties-editor input { height: 24px; min-width: 0; padding: 0 4px; border: 1px solid var(--cad-line); - border-radius: 3px; + border-radius: 6px; background: var(--cad-chrome-sunken); color: var(--cad-text); font-size: 11px; @@ -292,59 +250,19 @@ body.cad-file-dragging::after { color: var(--cad-text); text-overflow: ellipsis; } -.cad-properties__empty { - margin: 0; - color: var(--cad-text-muted); - font-size: 11px; -} -/* 빠른 특성 — 화면 위에 뜨는 작은 상자 */ -.cad-quick-properties { - position: fixed; - z-index: 5; - top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px); - right: 14px; - width: 240px; - padding: 8px 10px 10px; - background: var(--cad-chrome-raised); - border: 1px solid var(--cad-line); - border-radius: 5px; - box-shadow: var(--shadow-lg); -} -.cad-quick-properties__header { - display: flex; - justify-content: space-between; - align-items: center; - margin-bottom: 6px; - color: var(--cad-text-dim); - font-size: 11px; -} -.cad-quick-properties__header button { - border: 0; - background: transparent; - color: var(--cad-text-muted); - font-size: 12px; -} -/* 블록 라이브러리 팔레트 (INSERT/BLOCK) */ +/* 빠른 특성 · 블록 라이브러리 — 꼴 = 떠 있는 판 부품(`ui-float-panel`) · 여기는 자리 · 폭만 */ +.cad-quick-properties, .cad-block-library { position: fixed; z-index: 5; top: calc(var(--cad-title-height) + var(--cad-ribbon-height) + 12px); right: 14px; width: 240px; - padding: 8px 10px 10px; - background: var(--cad-chrome-raised); - border: 1px solid var(--cad-line); - border-radius: 5px; - box-shadow: var(--shadow-lg); -} -.cad-block-library__empty { - margin: 0; - color: var(--cad-text-muted); - font-size: 11px; - line-height: 1.5; } + +/* 블록 라이브러리 팔레트 (INSERT/BLOCK) */ .cad-block-library__list { max-height: 220px; margin: 0; @@ -357,40 +275,26 @@ body.cad-file-dragging::after { align-items: center; gap: 4px; } -.cad-block-library__list li button { - border: 0; - background: transparent; - color: var(--cad-text); - font-size: 12px; -} +/* 블록 줄 = 목록 줄 부품(`ui-sidebar-row`) · 지우기 = 아이콘 단추 부품(단 20) · 여기는 줄 폭만 */ .cad-block-library__name { flex: 1; + min-width: 0; overflow: hidden; - padding: 3px 4px; - text-align: left; text-overflow: ellipsis; white-space: nowrap; } -.cad-block-library__name:hover { - background: var(--cad-chrome-sunken); -} +/* 단추 = 단추 부품(보조 · 단 24) · 여기는 둘이 폭을 나눔만 */ .cad-block-library__actions { display: flex; gap: 6px; margin-top: 8px; } -.cad-block-library__actions button { +.cad-block-library__actions > .ui-btn { flex: 1; - padding: 4px 0; - border: 1px solid var(--cad-line); - border-radius: 3px; - background: transparent; - color: var(--cad-text-dim); - font-size: 11px; } /* 도각 자리표 패널 — 도각 편집으로 도면을 실었을 때만 뜬다 (2026-09-06 사용자 지시로 - 부모 사이드바에서 캐드 안으로 옮김). 도면 오른쪽 위, 리본 아래에 붙는다. */ + 부모 사이드바에서 캐드 안으로 옮김). 꼴 = 떠 있는 판 부품(`ui-float-panel`) · 여기는 자리 · 폭 · 줄 배치만. */ .cad-frame-tokens { position: fixed; /* 오른쪽 위는 진행단계 패널이 쓴다 — 아래쪽(상태막대·명령행 위)에 붙인다. */ @@ -398,36 +302,13 @@ body.cad-file-dragging::after { bottom: calc(var(--cad-status-height) + var(--cad-command-height) + 8px); z-index: 3; max-height: 46vh; - overflow-y: auto; + width: 236px; +} + +.cad-frame-tokens__body { display: flex; flex-direction: column; gap: 6px; - width: 236px; - padding: 8px; - border: 1px solid var(--cad-line); - border-radius: 6px; - background: var(--cad-chrome-raised); - box-shadow: var(--shadow-lg); - color: var(--cad-text); -} - -.cad-frame-tokens__header { - display: flex; - justify-content: space-between; - align-items: flex-start; - gap: 6px; - color: var(--cad-text-dim); - font-size: 11px; - line-height: 1.4; -} - -.cad-frame-tokens__fold { - flex: none; - border: 1px solid var(--cad-line); - border-radius: 4px; - background: transparent; - color: var(--cad-text); - cursor: pointer; } .cad-frame-tokens__group { @@ -439,39 +320,9 @@ body.cad-file-dragging::after { .cad-frame-tokens__title { width: 100%; - color: var(--cad-text-dim); - font-size: 11px; } -.cad-frame-tokens__button { - padding: 3px 7px; - border: 1px solid var(--cad-line); - border-radius: 4px; - background: var(--cad-chrome); - color: var(--cad-text); - font-size: 11px; - cursor: pointer; -} - -.cad-frame-tokens__button:hover { - background: var(--cad-accent-soft, var(--cad-chrome-raised)); -} - -/* 자리표 칸 크기 입력 (2026-09-06) */ -.cad-frame-tokens__size { - display: flex; - align-items: center; - gap: 4px; - color: var(--cad-text-dim); - font-size: 11px; -} - -.cad-frame-tokens__size input { +/* 자리표 칸 크기 입력 (2026-09-06) — 칸 = 입력 부품(단 24) */ +.cad-frame-tokens__size > .ui-input { width: 64px; - padding: 2px 4px; - border: 1px solid var(--cad-line); - border-radius: 4px; - background: var(--cad-chrome); - color: var(--cad-text); - font-size: 11px; } diff --git a/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts b/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts index 6cb2b8d73..d5d377d45 100644 --- a/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts +++ b/B07_DesignDetail/openwebcad/src/commands/commands.insert.ts @@ -5,6 +5,7 @@ import { setBlockLibraryVisible } from '../components/ui-state'; import { importImageFromFile } from '../helpers/import-export-handlers/import-image-from-file'; import { pickFile } from '../helpers/pick-file'; import { importDrawingFile } from '../integration/dxf-clip-paste'; +import { askText } from '../integration/ui-kit'; import { getActiveToolActor, getSelectedEntities } from '../state'; import { Tool } from '../tools'; import { imageImportToolStateMachine } from '../tools/image-import-tool'; @@ -39,13 +40,15 @@ export const INSERT_COMMANDS: CadCommand[] = [ needsSelection: true, run: () => { const entities = getSelectedEntities(); - const name = window.prompt('블록 이름', '')?.trim(); - if (!name) return '블록 작성 취소'; - void saveBlockFromEntities(name, entities).then(() => { + // 글 묻기 = 확인창 부품(window.prompt 대신 · 553-6) + void askText({ message: '블록 이름', okLabel: '저장' }).then(async (text) => { + const name = text?.trim(); + if (!name) return; + await saveBlockFromEntities(name, entities); toast.success(`블록 '${name}' 저장 (객체 ${entities.length}개)`); setBlockLibraryVisible(true); }); - return `블록 '${name}' 저장`; + return '블록 이름 입력'; }, }, { diff --git a/B07_DesignDetail/openwebcad/src/components/BlockLibraryPanel.tsx b/B07_DesignDetail/openwebcad/src/components/BlockLibraryPanel.tsx index 3fc99300a..f53c3ffa7 100644 --- a/B07_DesignDetail/openwebcad/src/components/BlockLibraryPanel.tsx +++ b/B07_DesignDetail/openwebcad/src/components/BlockLibraryPanel.tsx @@ -1,4 +1,4 @@ -/** 블록 라이브러리 팔레트 — 저장한 블록을 고르면 삽입 도구가 켜진다 (INSERT/BLOCK) */ +/** 블록 라이브러리 팔레트 — 저장한 블록을 고르면 삽입 도구가 켜진다 (INSERT/BLOCK) · 떠 있는 판 부품 */ import type { FC } from 'react'; import { deleteBlock, @@ -7,6 +7,7 @@ import { importBlocksFromFile, } from '../blocks/block-library'; import { pickFile } from '../helpers/pick-file'; +import { askConfirm } from '../integration/ui-kit'; import { startBlockInsert } from '../tools/insert-block-tool'; import { isBlockLibraryVisible, setBlockLibraryVisible } from './ui-state'; @@ -16,57 +17,83 @@ export const BlockLibraryPanel: FC = () => { const blocks = getBlocks(); return ( -
-
- 블록 라이브러리 -
+
+ {blocks.length === 0 ? ( +
+

객체를 선택하고 BLOCK 명령으로 저장하십시오.

+
+ ) : ( +
    + {blocks.map((block) => ( +
  • + + +
  • + ))} +
+ )} - {blocks.length === 0 ? ( -

객체를 선택하고 BLOCK 명령으로 저장하십시오.

- ) : ( -
    - {blocks.map((block) => ( -
  • - - -
  • - ))} -
- )} - -
- - +
+ + +
); diff --git a/B07_DesignDetail/openwebcad/src/components/Button.tsx b/B07_DesignDetail/openwebcad/src/components/Button.tsx index f95ddc2c4..4f499c0de 100644 --- a/B07_DesignDetail/openwebcad/src/components/Button.tsx +++ b/B07_DesignDetail/openwebcad/src/components/Button.tsx @@ -37,9 +37,9 @@ export const Button: FC = ({ left = null, right = null, }) => { - // 색은 App.css의 테마 토큰이 data-type/data-active로 준다 — 여기서는 배치만. + // 색은 App.css의 테마 토큰이 data-type/data-active로 준다 — 여기서는 배치만 · 높이 = 크기 단 42(글 14 · 둥글기 8) const classParts = [ - 'font-semibold py-4 h-10 flex flex-row justify-start w-full items-center', + 'font-semibold h-[42px] text-sm rounded-lg flex flex-row justify-start w-full items-center', size === 'regular' ? 'pl-2 pr-2 gap-2' : '', size === 'small' ? 'pl-2 pr-2 gap-0' : '', className || '', diff --git a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx index 5d878f70d..c3721829a 100644 --- a/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx +++ b/B07_DesignDetail/openwebcad/src/components/CommandLine.tsx @@ -16,7 +16,10 @@ export const CommandLine: FC = () => { const history = getCommandHistory(); const instruction = currentPrompt().line || '명령을 입력하거나 도구를 선택하십시오.'; - const suggestions = useMemo(() => matchCommandPrefixes(pick?.base ?? text).slice(0, 8), [text, pick]); + const suggestions = useMemo( + () => matchCommandPrefixes(pick?.base ?? text).slice(0, 8), + [text, pick] + ); const submit = (event: FormEvent) => { event.preventDefault(); @@ -102,10 +105,11 @@ export const CommandLine: FC = () => { {instruction} {text.trim() && suggestions.length > 0 && ( -
+
{suggestions.map((command) => (
- ); -}; diff --git a/B07_DesignDetail/openwebcad/src/components/FramePlaceholderPanel.tsx b/B07_DesignDetail/openwebcad/src/components/FramePlaceholderPanel.tsx index 706adf570..732331f58 100644 --- a/B07_DesignDetail/openwebcad/src/components/FramePlaceholderPanel.tsx +++ b/B07_DesignDetail/openwebcad/src/components/FramePlaceholderPanel.tsx @@ -139,15 +139,15 @@ export const FramePlaceholderPanel: FC = () => { if (!visible) return null; return ( -
-
- - {folded ? '자리표 놓기' : '자리표 놓기 — 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다'} - +
+
+ 자리표 놓기
{!folded && ( - <> -
- 글자 - {TEXT_TOKENS.map((token) => ( - - ))} -
-
- 그림 (칸에 비율 그대로 들어감) - {IMAGE_TOKENS.map((token) => ( - - ))} -
- {picked && ( -
- 고른 자리표 칸 크기 (mm) - - +
+

+ 누르면 화면 가운데에 서고, 끌어서 자리를 잡습니다 +

+
+ 글자 + {TEXT_TOKENS.map((token) => ( + + ))} +
+
+ + 그림 (칸에 비율 그대로 들어감) + + {IMAGE_TOKENS.map((token) => ( + + ))} +
+ {picked && ( +
+ + 고른 자리표 칸 크기 (mm) + + + +
+ )}
)} - - )}
); }; diff --git a/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx b/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx index 3807a356a..b94064002 100644 --- a/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx +++ b/B07_DesignDetail/openwebcad/src/components/InspectorPanel.tsx @@ -27,17 +27,24 @@ export const InspectorPanel: FC = ({ collapsed, onToggleCol {!collapsed && ( <> -
+ {/* 탭 부품(밑줄 탭 · 단 36) — 칸 둘이 폭을 나눠 가짐 */} +
- +
+ +
); }; diff --git a/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx b/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx index b34eef6e3..cf8188167 100644 --- a/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx +++ b/B07_DesignDetail/openwebcad/src/components/StatusBar.tsx @@ -1,4 +1,4 @@ -/** 상태막대 — AutoCAD 하단 제도 보조 토글 */ +/** 상태막대 — AutoCAD 하단 제도 보조 토글 · 켜기 칩 부품(`ui-toggle--chip` · 단 22) */ import type { FC } from 'react'; import { getAngleStep, @@ -27,7 +27,9 @@ export const StatusBar: FC = ({ commandLineVisible, onToggleComm
- )} - {Math.round(zoom * 100)}% -
diff --git a/B07_DesignDetail/openwebcad/src/inputController/snap-menu.css b/B07_DesignDetail/openwebcad/src/inputController/snap-menu.css index 8d313cc24..7cb29eeff 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/snap-menu.css +++ b/B07_DesignDetail/openwebcad/src/inputController/snap-menu.css @@ -1,29 +1,5 @@ -/* Shift+우클릭 한 번 스냅 메뉴(`snap-menu.ts`) — 커서 자리에 세로 목록 */ +/* Shift+우클릭 한 번 스냅 메뉴(`snap-menu.ts`) — 꼴 = 메뉴 부품(`ui-ctx-menu`) · 여기는 커서 자리 · 위 겹침만 */ .cad-snap-menu { position: fixed; z-index: 60; - display: flex; - flex-direction: column; - min-width: 160px; - padding: 4px; - border: 1px solid var(--color-border); - border-radius: 6px; - background: var(--color-surface-raised); - box-shadow: var(--shadow-sm); -} - -.cad-snap-menu > button { - padding: 4px 10px; - border: 0; - border-radius: 4px; - background: transparent; - color: var(--color-text); - font-size: 13px; - text-align: left; - cursor: pointer; -} - -.cad-snap-menu > button:hover, -.cad-snap-menu > button:focus-visible { - background: var(--color-surface-hover, rgba(127, 127, 127, 0.15)); } diff --git a/B07_DesignDetail/openwebcad/src/inputController/snap-menu.ts b/B07_DesignDetail/openwebcad/src/inputController/snap-menu.ts index bfb33b789..81533c097 100644 --- a/B07_DesignDetail/openwebcad/src/inputController/snap-menu.ts +++ b/B07_DesignDetail/openwebcad/src/inputController/snap-menu.ts @@ -55,11 +55,12 @@ export function openSnapMenu(x: number, y: number, onPick: (name: string) => voi current = { onPick, element: null }; if (typeof document === 'undefined') return; const menu = document.createElement('div'); - menu.className = 'cad-snap-menu controls'; + menu.className = 'ui-ctx-menu cad-snap-menu controls'; // 꼴 = 메뉴 부품 menu.setAttribute('role', 'menu'); for (const [name, label] of SNAP_MENU) { const item = document.createElement('button'); item.type = 'button'; + item.className = 'ui-ctx-menu__item'; item.setAttribute('role', 'menuitem'); item.textContent = `${label} (${name})`; item.addEventListener('click', () => pickSnapMenu(name)); diff --git a/B07_DesignDetail/openwebcad/src/integration/ui-kit.ts b/B07_DesignDetail/openwebcad/src/integration/ui-kit.ts new file mode 100644 index 000000000..1cdc55074 --- /dev/null +++ b/B07_DesignDetail/openwebcad/src/integration/ui-kit.ts @@ -0,0 +1,18 @@ +/** + * 부품 상자(`ui_template/`) CAD 쪽 한 벌(553-6) — CAD 겉도 앱 화면과 같은 부품 꼴을 씀. + * 부품 CSS 를 싣고 단추 · 확인창 기본 꼴(`injectBaseStyles`)을 한 번 넣음 · 확인창 · 글 묻기를 다시 냄. + * React 화면은 부품 클래스(`ui-btn` · `ui-icon-btn` · `ui-toggle--chip` · `ui-tabs` · `ui-float-panel` · `ui-ctx-menu` · `ui-empty` · `ui-text` · `ui-input`)와 + * `data-size` 단을 직접 붙임 · 자리 · 폭은 App.css 의 `cad-*` 가 맡음 · 도면 그림 색(`--draw-*`)은 안 건드림. + */ +import '../../../../ui_template/ui_template_tabs.css'; +import '../../../../ui_template/ui_template_toggle.css'; +import '../../../../ui_template/ui_template_float_panel.css'; +import '../../../../ui_template/ui_template_context_menu.css'; +import '../../../../ui_template/ui_template_empty.css'; +import '../../../../ui_template/ui_template_input.css'; +import '../../../../ui_template/ui_template_text.css'; +import { injectBaseStyles } from '../../../../ui_template/ui_template_elements_styles.ts'; + +export { askConfirm, askText } from '../../../../ui_template/ui_template_modal.ts'; + +injectBaseStyles(); diff --git a/B07_DesignDetail/openwebcad/src/main.tsx b/B07_DesignDetail/openwebcad/src/main.tsx index 38280e159..d5c4c0e7f 100644 --- a/B07_DesignDetail/openwebcad/src/main.tsx +++ b/B07_DesignDetail/openwebcad/src/main.tsx @@ -3,6 +3,8 @@ import { Point } from '@flatten-js/core'; import React from 'react'; import ReactDOM from 'react-dom/client'; import { Actor, type MachineSnapshot } from 'xstate'; +// 부품 상자 CSS 를 앱 CSS 보다 먼저 실음 — 자리 · 폭은 뒤에 오는 App.css 의 `cad-*` 가 덮음 +import './integration/ui-kit.ts'; import { SNAP_POINT_DISTANCE } from './App.consts'; import App from './App.tsx'; import { TOOL_STATE_MACHINES } from './commands/registry'; diff --git a/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx b/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx index d3ec81f83..10a17d975 100644 --- a/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx +++ b/B07_DesignDetail/openwebcad/src/ribbon/Ribbon.tsx @@ -129,10 +129,14 @@ const Panel: FC<{ {overflowCommands.length ? ' ▾' : ''} {overflowAt && overflowCommands.length > 0 && ( -
+
{overflowCommands.map((command) => (