From 11d377d9e0da2ea5f2f549b3acf57891be3a0a7d Mon Sep 17 00:00:00 2001 From: umsangdon Date: Thu, 1 Oct 2026 16:05:15 +0900 Subject: [PATCH] =?UTF-8?q?feat(m02-cf):=20=EB=8B=A8=EA=B0=80=EC=82=B0?= =?UTF-8?q?=EC=B6=9C=20=EB=8B=A4=EC=9D=B4=EC=96=B4=EA=B7=B8=EB=9E=A8=20?= =?UTF-8?q?=EC=B6=9C=EB=A0=A5=20=EC=83=81=EC=9E=90=20=ED=8C=90(OutputPanel?= =?UTF-8?q?)=20=C2=B7=20=EC=9B=90=EA=B0=80=20=ED=95=A9=EA=B3=84=20?= =?UTF-8?q?=EC=B9=B8(Summary)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 출력 상자의 받은 값 · 비용 종류 · 단위 · 이름 판, 원가 합계(노무비 · 재료비 · 경비 · 계) · 출력 상자별 줄 · 값 출력 · 빈 받는 자리 · 오류 · 예시값. Co-Authored-By: Claude Sonnet 5.5 Claude-Session: https://claude.ai/code/session_01EauA2ATmqNRbeV48s9CcBr --- .../CostFlow/M02_CF_OutputPanel.css | 85 ++++++ .../CostFlow/M02_CF_OutputPanel.ts | 195 ++++++++++++ .../CostFlow/M02_CF_Summary.css | 132 +++++++++ M02_MasterTemplete/CostFlow/M02_CF_Summary.ts | 278 ++++++++++++++++++ 4 files changed, 690 insertions(+) create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.css create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.ts create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_Summary.css create mode 100644 M02_MasterTemplete/CostFlow/M02_CF_Summary.ts diff --git a/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.css b/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.css new file mode 100644 index 000000000..0ce7fa6d2 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.css @@ -0,0 +1,85 @@ +.m01-cfout__title { + color: var(--color-text-body); + font-size: var(--text-body); +} + +.m01-cfout__body { + display: flex; + flex-wrap: wrap; + align-items: flex-start; + gap: var(--spacing-16); + margin: var(--spacing-8) 0; +} + +.m01-cfout__got { + min-width: 220px; + color: var(--color-text-body); +} + +.m01-cfout__got dl { + display: grid; + grid-template-columns: auto 1fr; + gap: var(--spacing-4) var(--spacing-8); + margin: var(--spacing-4) 0 0; + font-size: var(--text-body-sm); +} + +.m01-cfout__got dt { + color: var(--color-text-secondary); +} + +.m01-cfout__got dd { + margin: 0; +} + +.m01-cfout__num { + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.m01-cfout__fields { + display: flex; + flex-wrap: wrap; + align-items: flex-end; + gap: var(--spacing-8) var(--spacing-16); +} + +.m01-cfout__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-cfout__input { + box-sizing: border-box; + width: 160px; + max-width: 100%; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); +} + +.m01-cfout__warn { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-body-sm); +} + +.m01-cfout__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-cfout__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.ts b/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.ts new file mode 100644 index 000000000..ffb358c12 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_OutputPanel.ts @@ -0,0 +1,195 @@ +/* ============================================================================= + * M02_CF_OutputPanel.ts + * 단가산출 다이어그램 — 출력 상자 판. 출력 상자를 고르면 아래에 뜸 + * 받은 값(어디서 왔나 · 값 · 단위) · 비용 종류 고르기(노무비 · 재료비 · 경비 · 값(비용 아님)) · 단위 · 이름 + * 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 「상자 · 선 약속」 · Model 타입 그대로. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSelectField, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { evalLogic, type CfEvalResult, type CfValue } from "./M02_CF_Eval"; +import { portsOut, type CfBox, type CfCost, type CfLogic } from "./M02_CF_Model"; +import "./M02_CF_OutputPanel.css"; + +const TEXT = { + Title: ["출력", "Output"], + Name: ["이름", "Name"], + Cost: ["비용 종류", "Cost kind"], + Labor: ["노무비", "Labor cost"], + Material: ["재료비", "Material cost"], + Expense: ["경비", "Expense"], + Plain: ["값(비용 아님)", "Value (not a cost)"], + Unit: ["단위", "Unit"], + UnitHint: ["예: 원 · ㎥ · 시간 · -", "e.g. KRW · ㎥ · h · -"], + Got: ["받은 값", "Received value"], + From: ["어디서", "From"], + Value: ["값", "Value"], + NoLink: [ + "아직 이어진 선이 없어요 — 입력이나 함수 상자에서 이 상자로 선을 이어 주세요", + "Nothing is linked yet — draw a line from an input or function box", + ], + Table: ["표", "Table"], + Calc: ["계산 중이 아니에요", "Not calculated"], + Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], + PlainHint: [ + "원가 합계에 안 넣고 값으로만 보여요", + "Shown as a value, not added to the cost total", + ], +} as const; + +type TextKey = keyof typeof TEXT; +const to = (key: TextKey): string => TEXT[key][currentLanguageIndex === 1 ? 1 : 0]; + +const COSTS: [CfCost, TextKey][] = [ + ["노무비", "Labor"], + ["재료비", "Material"], + ["경비", "Expense"], + ["값", "Plain"], +]; + +/** 값 한 칸의 글 — 수 · 글 · 참거짓 · 표 */ +export function showValue(v: CfValue | undefined, money = false): string { + if (v === undefined || v === null) return "—"; + if (Array.isArray(v)) return to("Table"); + if (typeof v === "number") + return money ? formatMoney(v) : v.toLocaleString("ko-KR", { maximumFractionDigits: 6 }); + return String(v); +} + +const withBox = (lg: CfLogic, id: string, edit: (b: CfBox) => CfBox): CfLogic => ({ + ...lg, + boxes: lg.boxes.map((b) => (b.id === id ? edit(b) : b)), +}); + +/** 출력 상자 판 — `opts.result` 가 있으면 그 값으로 「받은 값」을 보임(없으면 지금 계산을 한 번 해 봄) */ +export function renderOutputPanel( + host: HTMLElement, + lg: CfLogic, + boxId: string, + on: { change(lg: CfLogic): void }, + opts: { result?: CfEvalResult } = {}, +): void { + const box = lg.boxes.find((b) => b.id === boxId); + if (!box || box.kind !== "output") { + host.replaceChildren(); + return; + } + const msg = el("p", { className: "m01-cfout__msg" }); + const emit = (make: () => CfLogic): void => { + msg.textContent = ""; + try { + on.change(make()); + } catch { + msg.textContent = to("Cant"); + } + }; + const cost = box.output?.cost ?? "노무비"; + + /* 받은 값 — 이 상자로 들어오는 선 하나(받는 자리는 하나) */ + const link = lg.links.find((l) => l.to.box === box.id); + const from = link && lg.boxes.find((b) => b.id === link.from.box); + let result = opts.result; + if (!result && link) { + try { + result = evalLogic(lg); + } catch { + result = undefined; + } + } + let unit = ""; + if (from && link) { + try { + unit = portsOut(from).find((p) => p.name === link.from.name)?.unit ?? from.unit; + } catch { + unit = from.unit; + } + } + const got = el("div", { className: "m01-cfout__got" }); + if (link && from) { + const value = result?.ports[from.id]?.[link.from.name] ?? result?.outputs[box.id]; + got.replaceChildren( + el("strong", { text: to("Got") }), + el("dl", { + children: [ + el("dt", { text: to("From") }), + el("dd", { text: `${from.name} · ${link.from.name}` }), + el("dt", { text: to("Value") }), + el("dd", { + className: "m01-cfout__num", + text: `${showValue(value, cost !== "값")}${unit && unit !== "-" ? ` ${unit}` : ""}`, + }), + ], + }), + ); + } else { + got.replaceChildren( + el("strong", { text: to("Got") }), + el("p", { className: "m01-cfout__warn", text: to("NoLink") }), + ); + } + + /* 이름 · 비용 종류 · 단위 */ + const nameBox = el("input", { + className: "m01-cfout__input", + attrs: { type: "text", "aria-label": to("Name") }, + }); + nameBox.value = box.name; + nameBox.addEventListener("change", () => { + const next = nameBox.value.trim(); + if (!next || next === box.name) { + nameBox.value = box.name; + return; + } + emit(() => withBox(lg, box.id, (b) => ({ ...b, name: next }))); + }); + const costField = createSelectField({ + label: to("Cost"), + options: COSTS.map(([value, key]) => ({ value, text: to(key) })), + value: cost, + compact: true, + onChange: (v) => + emit(() => + withBox(lg, box.id, (b) => ({ + ...b, + output: { unit: b.output?.unit ?? b.unit, cost: v as CfCost }, + })), + ), + }); + const unitBox = el("input", { + className: "m01-cfout__input", + attrs: { type: "text", placeholder: to("UnitHint"), "aria-label": to("Unit") }, + }); + unitBox.value = box.output?.unit ?? box.unit; + unitBox.addEventListener("change", () => { + const next = unitBox.value.trim() || "-"; + emit(() => + withBox(lg, box.id, (b) => ({ + ...b, + unit: next, + output: { cost: b.output?.cost ?? "노무비", unit: next }, + })), + ); + }); + const field = (label: string, control: HTMLElement): HTMLElement => + el("label", { + className: "m01-cfout__field", + children: [el("span", { text: label }), control], + }); + + host.replaceChildren( + el("strong", { className: "m01-cfout__title", text: `${to("Title")} · ${box.name}` }), + el("div", { + className: "m01-cfout__body", + children: [ + got, + el("div", { + className: "m01-cfout__fields", + children: [field(to("Name"), nameBox), costField.root, field(to("Unit"), unitBox)], + }), + ], + }), + ...(cost === "값" ? [el("p", { className: "m01-cfout__muted", text: to("PlainHint") })] : []), + msg, + ); +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Summary.css b/M02_MasterTemplete/CostFlow/M02_CF_Summary.css new file mode 100644 index 000000000..f8982bf37 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Summary.css @@ -0,0 +1,132 @@ +.m01-cfsum__big { + display: flex; + flex-wrap: wrap; + align-items: baseline; + gap: var(--spacing-8); + margin: 0 0 var(--spacing-8); + color: var(--color-text-secondary); +} + +.m01-cfsum__big strong { + color: var(--color-text-body); + font-size: 28px; + font-variant-numeric: tabular-nums; +} + +.m01-cfsum__costs { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-4) var(--spacing-16); + margin: 0 0 var(--spacing-12); + font-size: var(--text-body-sm); +} + +.m01-cfsum__costs dt { + display: inline; + color: var(--color-text-secondary); +} + +.m01-cfsum__costs dd { + display: inline; + margin: 0 0 0 var(--spacing-4); + color: var(--color-text-body); + font-weight: 600; + font-variant-numeric: tabular-nums; +} + +.m01-cfsum__block { + margin: 0 0 var(--spacing-12); + color: var(--color-text-body); +} + +.m01-cfsum__scroll { + max-width: 100%; + overflow-x: auto; +} + +.m01-cfsum__table { + width: 100%; + min-width: 360px; + margin-top: var(--spacing-4); + border-collapse: collapse; + font-size: var(--text-body-sm); +} + +.m01-cfsum__table th, +.m01-cfsum__table td { + padding: var(--spacing-4) var(--spacing-8); + border: 1px solid var(--color-border); + text-align: left; + vertical-align: middle; +} + +.m01-cfsum__table th { + background: var(--color-surface); + color: var(--color-text-secondary); + font-weight: 600; + white-space: nowrap; +} + +.m01-cfsum__table td.m01-cfsum__num { + text-align: right; + font-variant-numeric: tabular-nums; +} + +.m01-cfsum__strip { + margin: 0 0 var(--spacing-12); + color: var(--color-text-body); +} + +.m01-cfsum__fields { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8) var(--spacing-16); + margin: var(--spacing-8) 0; +} + +.m01-cfsum__field { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + color: var(--color-text-secondary); + font-size: var(--text-body-sm); +} + +.m01-cfsum__input { + box-sizing: border-box; + width: 140px; + height: var(--control-height-sm); + padding: 3px 6px; + border: 1px solid var(--color-border); + border-radius: var(--radius-sm); + background: var(--color-canvas); + color: var(--color-text-body); + font: inherit; + font-size: var(--text-body-sm); +} + +.m01-cfsum__muted { + margin: 0; + color: var(--color-text-muted); + font-size: var(--text-caption); +} + +.m01-cfsum__msg { + margin: var(--spacing-4) 0 0; + color: var(--color-danger); + font-size: var(--text-caption); +} + +.m01-cfsum__problems { + margin: 0 0 var(--spacing-12); + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-danger); + background: var(--color-surface); + color: var(--color-text-body); + font-size: var(--text-body-sm); +} + +.m01-cfsum__problems ul { + margin: var(--spacing-4) 0 0; + padding-left: var(--spacing-16); +} diff --git a/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts b/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts new file mode 100644 index 000000000..f0cfb9284 --- /dev/null +++ b/M02_MasterTemplete/CostFlow/M02_CF_Summary.ts @@ -0,0 +1,278 @@ +/* ============================================================================= + * M02_CF_Summary.ts + * 단가산출 다이어그램 — 원가 합계 칸: 큰 합계 「1㎡ 당 ○원」 · 노무비 · 재료비 · 경비 · 출력 상자별 줄 + * · 값(비용 아님) 출력 · 빈 받는 자리 · 오류 · 예시값(변수 상자의 예시값을 여기서도 바꿈) + * 값은 화면 계산(Eval `evalLogic`) 결과를 받음 — 이 파일은 셈하지 않고 읽어서 보여 줌. + * 모양은 docs/raw/M01_쉬운단가/원가다이어그램_정함.md 점검 5 · 6. 이 폴더째 지워도 됨. + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createSelectField, el } from "@ui/ui_template_elements"; +import { formatMoney } from "../../M01_MasterData/M01_MasterData_UI_Logic_Money"; +import { checkLogic, type CfEvalResult } from "./M02_CF_Eval"; +import type { CfBox, CfLogic, CfVariable } from "./M02_CF_Model"; +import { showValue } from "./M02_CF_OutputPanel"; +import "./M02_CF_Summary.css"; + +const TEXT = { + Per: ["{per} 당", "per {per}"], + Total: ["원가 합계", "Cost total"], + Won: ["원", "KRW"], + Labor: ["노무비", "Labor cost"], + Material: ["재료비", "Material cost"], + Expense: ["경비", "Expense"], + Lines: ["출력 상자별", "By output box"], + HName: ["이름", "Name"], + HCost: ["비용 종류", "Cost kind"], + HValue: ["값", "Value"], + HUnit: ["단위", "Unit"], + Plain: ["값(비용 아님)", "Values (not costs)"], + Sample: ["예시값", "Sample values"], + SampleHint: [ + "여기서 바꾸면 변수 상자의 예시값도 바뀌어요", + "Changing here changes the variable box too", + ], + Problems: ["고칠 것", "To fix"], + NoLink: ["「{box}」 출력 상자에 이어진 선이 없어요", "Output box “{box}” has no line yet"], + NoOutput: ["출력 상자가 아직 없어요", "There is no output box yet"], + Bad: ["숫자를 넣어 주세요", "Enter a number"], + Range: ["{a} ∼ {b} 사이", "between {a} and {b}"], + Pick: ["고르세요", "Choose"], + Cant: ["지금은 바꿀 수 없어요", "Cannot change this now"], + Plain3: ["값", "Value"], +} as const; + +type TextKey = keyof typeof TEXT; +const ts = (key: TextKey, fill: Record = {}): string => + TEXT[key][currentLanguageIndex === 1 ? 1 : 0].replace( + /\{(\w+)\}/g, + (m, k: string) => fill[k] ?? m, + ); + +const COSTS = ["노무비", "재료비", "경비"] as const; +const COST_KEY = { 노무비: "Labor", 재료비: "Material", 경비: "Expense" } as const; + +const costText = (c: string): string => (c === "값" ? ts("Plain3") : c); + +/** 빈 받는 자리 · 출력 상자 없음 — 쉬운 말 */ +function emptyPlaces(lg: CfLogic): string[] { + const outs = lg.boxes.filter((b) => b.kind === "output"); + if (!outs.length) return [ts("NoOutput")]; + return outs + .filter((o) => !lg.links.some((l) => l.to.box === o.id)) + .map((o) => ts("NoLink", { box: o.name })); +} + +/** 변수 상자의 예시값 칸 하나 */ +function sampleField( + v: CfVariable, + put: (value: number | string) => void, + msg: HTMLElement, +): HTMLElement { + const label = el("span", { text: v.unit && v.unit !== "-" ? `${v.name} (${v.unit})` : v.name }); + let control: HTMLElement; + if (v.type === "choice" && v.choices?.length) { + control = createSelectField({ + options: [ + { value: "", text: ts("Pick") }, + ...v.choices.map((c) => ({ value: String(c), text: String(c) })), + ], + value: v.example === null ? "" : String(v.example), + compact: true, + ariaLabel: v.name, + onChange: (s) => { + const hit = v.choices?.find((c) => String(c) === s); + if (hit !== undefined) put(hit); + }, + }).root; + } else { + const box = el("input", { + className: "m01-cfsum__input", + attrs: { + type: "text", + inputmode: v.type === "number" ? "decimal" : "text", + "aria-label": v.name, + }, + }); + if (v.range) box.placeholder = ts("Range", { a: String(v.range[0]), b: String(v.range[1]) }); + box.value = v.example === null ? "" : String(v.example); + box.addEventListener("change", () => { + const raw = box.value.trim(); + if (raw === "") return; + if (v.type !== "number") return put(raw); + const n = Number(raw); + if (Number.isNaN(n)) msg.textContent = ts("Bad"); + else put(n); + }); + control = box; + } + return el("label", { className: "m01-cfsum__field", children: [label, control] }); +} + +/** 원가 합계 칸 — result = 화면 계산 결과 · on 이 있으면 예시값을 바꿀 수 있음 */ +export function renderSummary( + host: HTMLElement, + lg: CfLogic, + result: CfEvalResult, + on?: { change(lg: CfLogic): void }, +): void { + const money = !lg.resultUnit || lg.resultUnit.startsWith("원"); + const perUnit = lg.resultUnit.split("/").slice(1).join("/"); + const perText = !perUnit + ? ts("Total") + : ts("Per", { per: /^\d/.test(perUnit) ? perUnit : `1${perUnit}` }); + const { cost } = result; + const big = el("p", { + className: "m01-cfsum__big", + children: [ + el("span", { text: money ? perText : ts("Total") }), + el("strong", { text: `${formatMoney(cost.계)} ${ts("Won")}` }), + ], + }); + const costs = el("dl", { + className: "m01-cfsum__costs", + children: COSTS.flatMap((c) => [ + el("dt", { text: ts(COST_KEY[c]) }), + el("dd", { text: formatMoney(cost[c]) }), + ]), + }); + + /* 출력 상자별 줄 — 비용 줄 · 값(비용 아님)은 따로 */ + const outs: CfBox[] = lg.boxes.filter((b) => b.kind === "output"); + const costed = outs.filter((b) => b.output?.cost !== "값"); + const plain = outs.filter((b) => b.output?.cost === "값"); + const line = (b: CfBox, isCost: boolean): HTMLElement => + el("tr", { + children: [ + el("td", { text: b.name }), + ...(isCost ? [el("td", { text: costText(b.output?.cost ?? "") })] : []), + el("td", { + className: "m01-cfsum__num", + text: showValue(result.outputs[b.id], isCost), + }), + el("td", { text: b.output?.unit ?? b.unit }), + ], + }); + const table = (list: CfBox[], isCost: boolean, title: string): HTMLElement => + el("div", { + className: "m01-cfsum__block", + children: [ + el("strong", { text: title }), + el("div", { + className: "m01-cfsum__scroll", + children: [ + el("table", { + className: "m01-cfsum__table", + children: [ + el("thead", { + children: [ + el("tr", { + children: [ + el("th", { text: ts("HName") }), + ...(isCost ? [el("th", { text: ts("HCost") })] : []), + el("th", { text: ts("HValue") }), + el("th", { text: ts("HUnit") }), + ], + }), + ], + }), + el("tbody", { children: list.map((b) => line(b, isCost)) }), + ], + }), + ], + }), + ], + }); + + /* 고칠 것 — 빈 받는 자리 · 검사 · 계산 오류 */ + let checked: string[] = []; + try { + checked = checkLogic(lg); + } catch { + checked = []; + } + const problems = [...new Set([...emptyPlaces(lg), ...checked, ...result.errors])]; + + /* 예시값 — 변수 상자의 변수마다 */ + const msg = el("p", { className: "m01-cfsum__msg" }); + const fields = lg.boxes + .filter((b) => b.kind === "input" && b.input?.mode === "variable") + .flatMap((b) => + (b.input?.variables ?? []).map((v, i) => + sampleField( + v, + (value) => { + msg.textContent = ""; + if (!on) return; + try { + on.change({ + ...lg, + boxes: lg.boxes.map((x) => + x.id === b.id && x.input + ? { + ...x, + input: { + ...x.input, + variables: x.input.variables.map((y, j) => + j === i ? { ...y, example: value } : y, + ), + }, + } + : x, + ), + }); + } catch { + msg.textContent = ts("Cant"); + } + }, + msg, + ), + ), + ); + + const plainValues = Object.entries(cost.값들); + host.replaceChildren( + big, + costs, + ...(costed.length ? [table(costed, true, ts("Lines"))] : []), + ...(plain.length + ? [table(plain, false, ts("Plain"))] + : plainValues.length + ? [ + el("div", { + className: "m01-cfsum__block", + children: [ + el("strong", { text: ts("Plain") }), + ...plainValues.map(([k, v]) => + el("p", { className: "m01-cfsum__muted", text: `${k} · ${showValue(v)}` }), + ), + ], + }), + ] + : []), + ...(fields.length + ? [ + el("div", { + className: "m01-cfsum__strip", + children: [ + el("strong", { text: ts("Sample") }), + el("div", { className: "m01-cfsum__fields", children: fields }), + el("p", { className: "m01-cfsum__muted", text: ts("SampleHint") }), + msg, + ], + }), + ] + : []), + ...(problems.length + ? [ + el("div", { + className: "m01-cfsum__problems", + children: [ + el("strong", { text: ts("Problems") }), + el("ul", { children: problems.map((t) => el("li", { text: t })) }), + ], + }), + ] + : []), + ); +}