diff --git a/M01_MasterData/SheetLogic/M01_SL_Panel.css b/M01_MasterData/SheetLogic/M01_SL_Panel.css new file mode 100644 index 000000000..d67df74d1 --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Panel.css @@ -0,0 +1,172 @@ +.m01-slpanel { + display: flex; + flex-direction: column; + gap: var(--spacing-8); + font-size: var(--text-body); + color: var(--color-text-body); +} + +.m01-slpanel-head, +.m01-slpanel-row { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: var(--spacing-8); +} + +.m01-slpanel-bar { + display: flex; + flex-direction: column; + gap: var(--spacing-8); +} + +.m01-slpanel-cell { + padding: 2px var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-pills); + background: var(--color-surface-raised); +} + +.m01-slpanel-note { + color: var(--color-text-muted); + font-size: var(--text-body-sm); +} + +.m01-slpanel .ui-btn, +.m01-slpanel .ui-input { + min-height: 44px; + font-size: var(--text-body); +} + +.m01-slpanel-name { + flex: none; + width: 12em; +} + +.m01-slpanel-msg { + margin: 0; + padding: var(--spacing-8) var(--spacing-12); + border-left: 4px solid var(--color-warning); + background: var(--color-surface-raised); + font-weight: var(--font-weight-medium); +} + +.m01-slpanel-sheet { + position: relative; + min-height: 260px; + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + overflow: hidden; +} + +.m01-slpanel-pills { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.m01-slpanel-pill, +.m01-slpanel-dir { + min-height: 44px; + padding: 0 var(--spacing-12); + border: 2px solid; + border-radius: var(--radius-pills); + background: var(--color-surface); + color: inherit; + font: inherit; + font-weight: var(--font-weight-medium); + cursor: pointer; +} + +.m01-slpanel-dir { + display: inline-flex; + align-items: center; + min-height: 0; + cursor: default; +} + +.m01-slpanel-pill:focus-visible { + outline: 2px solid var(--color-focus-ring); +} + +.m01-slpanel-pill.is-on { + background: var(--color-surface-raised); + font-weight: var(--font-weight-bold); +} + +.is-in { + border-color: #3b82f6; +} + +.is-out { + border-color: #f59e0b; +} + +.m01-slpanel-info { + display: flex; + flex-direction: column; + gap: var(--spacing-4); + padding: var(--spacing-12); + border: 1px solid var(--color-border); + border-radius: var(--radius-cards); + background: var(--color-surface-raised); +} + +.m01-slpanel-infoline { + display: flex; + flex-wrap: wrap; + gap: var(--spacing-8); +} + +.m01-slpanel-infoline > .m01-slpanel-note { + min-width: 6em; +} + +/* 칸 위 알약 표시 — 색 + 글자(입력 · 출력) 둘 다 */ +.m01-slpanel .aislo-cell[data-sl-pill="in"] { + box-shadow: inset 0 0 0 2px #3b82f6; +} + +.m01-slpanel .aislo-cell[data-sl-pill="out"] { + box-shadow: inset 0 0 0 2px #f59e0b; +} + +.m01-slpanel .aislo-cell[data-sl-pill]::after { + content: attr(data-sl-name); + position: absolute; + top: 0; + right: 0; + padding: 0 4px; + border-radius: 0 0 0 6px; + background: var(--color-surface); + color: var(--color-text); + font-size: 10px; + line-height: 14px; + pointer-events: none; +} + +.m01-slpanel .aislo-cell[data-sl-pill="in"]::after { + border: 1px solid #3b82f6; +} + +.m01-slpanel .aislo-cell[data-sl-pill="out"]::after { + border: 1px solid #f59e0b; +} + +.m01-slpanel .aislo-cell[data-sl-lock]::before { + content: "🔒 " attr(data-sl-lock); + position: absolute; + bottom: 0; + left: 0; + padding: 0 4px; + background: var(--color-surface); + color: var(--color-text); + font-size: 10px; + line-height: 14px; + pointer-events: none; +} + +.m01-slpanel .aislo-cell[data-sl-err] { + outline: 2px dashed var(--color-danger); + outline-offset: -2px; +} diff --git a/M01_MasterData/SheetLogic/M01_SL_Panel.ts b/M01_MasterData/SheetLogic/M01_SL_Panel.ts new file mode 100644 index 000000000..f2a4c112f --- /dev/null +++ b/M01_MasterData/SheetLogic/M01_SL_Panel.ts @@ -0,0 +1,544 @@ +/* ============================================================================= + * M01_SL_Panel.ts + * 시트 로직 아래 시트 판 — 고른 상자의 시트를 우리 스프레드시트 편집기로 · 칸 → 입력/출력 알약 · 칸 위 알약 표시 · + * 받는 알약 칸 잠금 · 틀 단추(진짜 엑셀 수식) · 틀린 수식/안 이어진 입력 알약 한 줄 + * 모양은 `docs/raw/M01_쉬운단가/시트로직_정함.md` · M01_SL_Model 타입 그대로 · 편집기는 M02 미리보기가 쓰는 그것(늦게 불러옴) + * 글 [한, 영] 짝은 아래 TEXT + * ========================================================================== */ + +import { currentLanguageIndex } from "@ui/ui_template_locale"; +import { createButton, el, showConfirmDialog, showToast } from "@ui/ui_template_elements"; +import type { + CalcValue, + CalcValues, + SpreadsheetHandle, + Workbook, +} from "../../A00_Common/spreadsheet/spreadsheet"; +import { disconnect, type SheetLogic, type SlEdge, type SlNode, type SlPill } from "./M01_SL_Model"; +import "./M01_SL_Panel.css"; + +const TEXT = { + Cell: ["고른 칸", "Selected cell"], + PillIn: ["입력 알약으로", "Make input pill"], + PillOut: ["출력 알약으로", "Make output pill"], + AskName: ["알약 이름", "Pill name"], + Ok: ["정함", "Set"], + Cancel: ["취소", "Cancel"], + Frames: ["틀", "Templates"], + FrameMore: ["할증", "Markup"], + FrameDiv: ["나누기", "Divide"], + FrameIf: ["만약", "If"], + FrameMin: ["최소", "Min"], + FrameMax: ["최대", "Max"], + FrameUp: ["올림", "Round up"], + FrameLook: ["표에서 찾기", "Look up in table"], + FrameHint: ["칸 주소만 바꾸면 됨", "Just change the cell addresses"], + In: ["입력", "In"], + Out: ["출력", "Out"], + Lock: ["선", "Wire"], + Pills: ["알약", "Pills"], + NoPills: [ + "아직 알약이 없음 — 칸을 골라 알약으로 정함", + "No pills yet — pick a cell and make it a pill", + ], + Value: ["값", "Value"], + Unit: ["단위", "Unit"], + From: ["어디서 오나", "Comes from"], + To: ["어디로 가나", "Goes to"], + NoFrom: ["아직 이어지지 않음", "Not connected yet"], + NoTo: ["아직 어디로도 안 감", "Goes nowhere yet"], + Close: ["닫기", "Close"], + ReadOnly: ["표준은 읽기만", "Read only"], + Loading: ["시트를 불러오는 중", "Loading sheet"], + BreakAsk: [ + "이 칸은 선으로 들어오는 값입니다. 선을 끊고 직접 고칠까요?", + "This cell receives a wire. Cut the wire and edit it yourself?", + ], + BreakOk: ["선을 끊고 고침", "Cut and edit"], + Cannot: ["이 고침은 지금 할 수 없음", "This change cannot be made now"], + Problem: ["⚠ ", "⚠ "], + Unwired: [" 입력 알약이 아직 이어지지 않음", " input pill is not connected yet"], + Err_DIV0: ["0으로 나눔", "divides by zero"], + Err_NAME: ["알 수 없는 이름", "unknown name"], + Err_REF: ["없는 칸을 가리킴", "points to a missing cell"], + Err_VALUE: ["값 종류가 안 맞음", "wrong kind of value"], + Err_CYCLE: ["서로 돌려 가리킴(순환)", "circular reference"], + Err_NA: ["찾는 값이 없음", "value not found"], + Err_OTHER: ["수식이 틀림", "formula is wrong"], +} as const; + +const tp = (key: keyof typeof TEXT): string => TEXT[key][currentLanguageIndex]; + +/** 틀 단추 — 진짜 엑셀 수식을 고른 칸에 넣음 */ +const FRAMES: [keyof typeof TEXT, string][] = [ + ["FrameMore", "=B1*(1+B2/100)"], + ["FrameDiv", "=B1/B2"], + ["FrameIf", '=IF(B3="급",10,0)'], + ["FrameMin", "=MIN(B1,B2)"], + ["FrameMax", "=MAX(B1,B2)"], + ["FrameUp", "=ROUNDUP(B4,3)"], + ["FrameLook", "=XLOOKUP(B1,E1:E10,F1:F10)"], +]; + +const ERR_KEY: Record = { + "#DIV/0!": "Err_DIV0", + "#NAME?": "Err_NAME", + "#REF!": "Err_REF", + "#VALUE!": "Err_VALUE", + "#CYCLE!": "Err_CYCLE", + "#N/A": "Err_NA", +}; + +type Handlers = { + change(sl: SheetLogic): void; + pill(nodeId: string, cell: string, dir: "in" | "out", name: string): void; +}; +type SheetModule = typeof import("../../A00_Common/spreadsheet/spreadsheet"); + +interface State { + nodeId: string; + sl: SheetLogic; + on: Handlers; + readOnly: boolean; + handle: SpreadsheetHandle | null; + /** 수식 칸 계산값 — 바뀐 뒤에만 새로 받음(받으면 격자가 다시 그려져, 표시 쪽에서 부르면 맴돎) */ + snap: CalcValues; + /** 편집기가 마지막으로 낸 문서(활성 빼고) — 같으면 다시 안 끼움 */ + json: string; + cell: string; + info: { dir: "in" | "out"; name: string } | null; + asking: "in" | "out" | null; + busy: boolean; + parts: { + cell: HTMLElement; + bar: HTMLElement; + msg: HTMLElement; + sheet: HTMLElement; + pills: HTMLElement; + info: HTMLElement; + }; + observer: MutationObserver | null; + redraw(): void; +} + +const states = new WeakMap(); +let sheetModule: Promise | null = null; +/** 편집기는 M02 처럼 늦게 불러옴 — 정적 import 금지 */ +const loadSheet = (): Promise => + (sheetModule ??= import("../../A00_Common/spreadsheet/spreadsheet")); + +const norm = (book: Workbook): string => JSON.stringify({ ...book, 활성: null, 계산값: null }); +const nodeOf = (st: State): SlNode | undefined => st.sl.nodes.find((n) => n.id === st.nodeId); +const shown = (st: State): Workbook => ({ + ...st.sl.workbook, + 활성: nodeOf(st)?.sheetId ?? st.sl.workbook.활성, +}); + +/* ------------------------------ 값 · 말 ------------------------------ */ + +function valueText(v: CalcValue | number | string | boolean | undefined | null): string { + if (v === undefined || v === null) return ""; + if (typeof v !== "object") return String(v); + if ("수" in v) return v.수; + if ("글" in v) return v.글; + if ("참" in v) return v.참 ? "TRUE" : "FALSE"; + return v.오류; +} + +function valueAt(st: State, addr: string): string { + const node = nodeOf(st); + const sheet = st.sl.workbook.시트.find((s) => s.id === node?.sheetId); + const cell = sheet?.칸[addr]; + if (!cell) return ""; + if (cell.식 === undefined) return valueText(cell.값); + return valueText(st.snap[sheet!.id]?.[addr]); +} + +const edgesIn = (st: State, pill: string): SlEdge[] => + st.sl.edges.filter((e) => e.to.node === st.nodeId && e.to.pill === pill); +const edgesOut = (st: State, pill: string): SlEdge[] => + st.sl.edges.filter((e) => e.from.node === st.nodeId && e.from.pill === pill); + +/** 선으로 값을 받는 칸 — 직접 고치기 전에 물음 */ +function lockedCells(st: State): Map { + const out = new Map(); + for (const p of nodeOf(st)?.pillsIn ?? []) { + const e = edgesIn(st, p.name)[0]; + if (e) out.set(p.cell, e); + } + return out; +} + +/** 틀린 수식 · 이어지지 않은 입력 알약 — 칸 → 까닭 */ +function problems(st: State): Map { + const out = new Map(); + const node = nodeOf(st); + if (!node || !st.handle) return out; + const sheet = st.sl.workbook.시트.find((s) => s.id === node.sheetId); + const snap = st.snap[node.sheetId] ?? {}; + for (const [addr, v] of Object.entries(snap)) { + if (typeof v === "object" && "오류" in v) { + out.set(addr, `${addr} ${tp(ERR_KEY[v.오류] ?? "Err_OTHER")}`); + } + } + for (const p of node.pillsIn) { + const filled = sheet?.칸[p.cell]?.식 !== undefined; + if (!edgesIn(st, p.name).length && !filled && node.kind !== "input" && node.kind !== "const") { + out.set(p.cell, `${p.name}(${p.cell})${tp("Unwired")}`); + } + } + return out; +} + +/** 계산값 새로 받고 표시 다시 — 편집기가 바뀐 뒤 · 처음 뜬 뒤에만 */ +function refreshSnap(st: State): void { + if (!st.handle) return; + st.snap = st.handle.recalc(); + st.redraw(); +} + +/* ------------------------------ 칸 위 표시 ------------------------------ */ + +function applyMarks(st: State): void { + const node = nodeOf(st); + const grid = st.parts.sheet; + if (!node) return; + const pill = new Map(); + for (const p of node.pillsIn) pill.set(p.cell, { dir: "in", p }); + for (const p of node.pillsOut) pill.set(p.cell, { dir: "out", p }); + const lock = lockedCells(st); + const bad = problems(st); + for (const c of grid.querySelectorAll(".aislo-cell")) { + const addr = c.dataset.addr ?? ""; + const hit = pill.get(addr); + for (const a of ["slPill", "slName", "slLock", "slErr"]) delete c.dataset[a]; + if (hit) { + c.dataset.slPill = hit.dir; + c.dataset.slName = `${tp(hit.dir === "in" ? "In" : "Out")} ${hit.p.name}`; + } + if (lock.has(addr)) c.dataset.slLock = tp("Lock"); + if (bad.has(addr)) c.dataset.slErr = "1"; + } + const first = [...bad.values()][0]; + st.parts.msg.textContent = first + ? `${tp("Problem")}${first}${bad.size > 1 ? ` (+${bad.size - 1})` : ""}` + : ""; + st.parts.msg.hidden = !first; +} + +/* ------------------------------ 위 줄 · 알약 목록 ------------------------------ */ + +function nameDefault(st: State): string { + const row = st.cell.replace(/^[A-Z]+/, ""); + const sheet = st.sl.workbook.시트.find((s) => s.id === nodeOf(st)?.sheetId); + const a = sheet?.칸[`A${row}`]; + return a ? valueText(a.값 ?? a.식) : ""; +} + +function drawBar(st: State): void { + const { bar, cell } = st.parts; + cell.textContent = `${tp("Cell")} ${st.cell}`; + const frag: (HTMLElement | string)[] = []; + if (st.readOnly) { + bar.replaceChildren(el("span", { className: "m01-slpanel-note", text: tp("ReadOnly") })); + return; + } + const ask = (dir: "in" | "out"): void => { + st.asking = dir; + drawBar(st); + }; + const pillRow = el("div", { + className: "m01-slpanel-row", + children: [ + createButton({ label: tp("PillIn"), variant: "ghost", size: "sm", onClick: () => ask("in") }), + createButton({ + label: tp("PillOut"), + variant: "ghost", + size: "sm", + onClick: () => ask("out"), + }), + ], + }); + frag.push(pillRow); + if (st.asking) { + const dir = st.asking; + const input = el("input", { + className: "ui-input m01-slpanel-name", + attrs: { type: "text", "aria-label": tp("AskName") }, + }); + input.value = nameDefault(st); + const done = (): void => { + const name = input.value.trim(); + if (!name) return; + st.asking = null; + st.on.pill(st.nodeId, st.cell, dir, name); + drawBar(st); + }; + input.addEventListener("keydown", (e) => e.key === "Enter" && done()); + frag.push( + el("div", { + className: "m01-slpanel-row", + children: [ + el("span", { + className: `m01-slpanel-dir is-${dir}`, + text: tp(dir === "in" ? "In" : "Out"), + }), + input, + createButton({ label: tp("Ok"), variant: "filled", size: "sm", onClick: done }), + createButton({ + label: tp("Cancel"), + variant: "ghost", + size: "sm", + onClick: () => ((st.asking = null), drawBar(st)), + }), + ], + }), + ); + queueMicrotask(() => input.focus()); + } + frag.push( + el("div", { + className: "m01-slpanel-row", + children: [ + el("span", { className: "m01-slpanel-note", text: tp("Frames") }), + ...FRAMES.map(([key, formula]) => + createButton({ + label: tp(key), + variant: "ghost", + size: "sm", + onClick: () => putFormula(st, formula), + }), + ), + el("span", { className: "m01-slpanel-note", text: tp("FrameHint") }), + ], + }), + ); + bar.replaceChildren(...frag.filter((x): x is HTMLElement => typeof x !== "string")); +} + +function drawPills(st: State): void { + const node = nodeOf(st); + const chips: HTMLElement[] = []; + const add = (dir: "in" | "out", p: SlPill): void => { + const b = el("button", { + className: `m01-slpanel-pill is-${dir}${st.info?.name === p.name && st.info.dir === dir ? " is-on" : ""}`, + text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})`, + attrs: { type: "button" }, + }); + b.addEventListener("click", () => ((st.info = { dir, name: p.name }), drawPills(st))); + chips.push(b); + }; + for (const p of node?.pillsIn ?? []) add("in", p); + for (const p of node?.pillsOut ?? []) add("out", p); + st.parts.pills.replaceChildren( + ...(chips.length + ? chips + : [el("span", { className: "m01-slpanel-note", text: tp("NoPills") })]), + ); + drawInfo(st); +} + +function drawInfo(st: State): void { + const box = st.parts.info; + const node = nodeOf(st); + const p = (st.info?.dir === "in" ? node?.pillsIn : node?.pillsOut)?.find( + (x) => x.name === st.info?.name, + ); + if (!st.info || !p || !node) { + box.hidden = true; + box.replaceChildren(); + return; + } + const dir = st.info.dir; + const nameOf = (id: string): string => st.sl.nodes.find((n) => n.id === id)?.name ?? ""; + const links = (dir === "in" ? edgesIn(st, p.name) : edgesOut(st, p.name)).map((e) => { + const end = dir === "in" ? e.from : e.to; + return `${nameOf(end.node)} · ${end.pill}`; + }); + const rows: [string, string][] = [ + [tp("Value"), valueAt(st, p.cell)], + [tp("Unit"), p.unit ?? ""], + [ + tp(dir === "in" ? "From" : "To"), + links.length ? links.join(", ") : tp(dir === "in" ? "NoFrom" : "NoTo"), + ], + ]; + box.hidden = false; + box.replaceChildren( + el("strong", { text: `${tp(dir === "in" ? "In" : "Out")} · ${p.name} (${p.cell})` }), + ...rows.map(([k, v]) => + el("div", { + className: "m01-slpanel-infoline", + children: [el("span", { className: "m01-slpanel-note", text: k }), v], + }), + ), + createButton({ + label: tp("Close"), + variant: "ghost", + size: "sm", + onClick: () => ((st.info = null), drawPills(st)), + }), + ); +} + +/* ------------------------------ 편집기 ------------------------------ */ + +/** 틀 수식을 고른 칸에 넣음 — 수식 입력줄에 쳐서 확정하는 길(되돌리기 · 검사 그대로) */ +function putFormula(st: State, formula: string): void { + const root = st.parts.sheet; + const bar = root.querySelector(".ss-formula"); + const addr = root.querySelector(".ss-addr"); + if (!bar || !addr || st.readOnly) return; + const at = st.cell; + bar.focus(); + bar.value = formula; + bar.dispatchEvent(new Event("input", { bubbles: true })); + bar.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", ctrlKey: true, bubbles: true, cancelable: true }), + ); + addr.value = at; + addr.dispatchEvent( + new KeyboardEvent("keydown", { key: "Enter", bubbles: true, cancelable: true }), + ); +} + +/** 편집기가 문서를 냄 — 선 받는 칸을 직접 고쳤으면 먼저 물음 */ +async function onBook(st: State, book: Workbook): Promise { + if (st.busy) return; + const prev = st.sl; + const node = nodeOf(st); + const next: SheetLogic = { ...prev, workbook: { ...book, 활성: prev.workbook.활성 } }; + const before = prev.workbook.시트.find((s) => s.id === node?.sheetId); + const after = book.시트.find((s) => s.id === node?.sheetId); + const hit = [...lockedCells(st)].find( + ([addr]) => JSON.stringify(before?.칸[addr]) !== JSON.stringify(after?.칸[addr]), + ); + if (!hit) { + st.json = norm(book); + st.sl = next; + st.on.change(next); + return void setTimeout(() => refreshSnap(st), 0); + } + st.busy = true; + const ok = await showConfirmDialog(tp("BreakAsk"), tp("BreakOk")); + st.busy = false; + if (!ok) { + st.json = norm(shown(st)); + return st.handle?.setDoc(structuredClone(shown(st))); + } + try { + const cut = disconnect(next, hit[1].id); + const sheet = cut.workbook.시트.find((s) => s.id === node?.sheetId); + const mine = after?.칸[hit[0]]; + if (sheet) { + if (mine) sheet.칸[hit[0]] = mine; + else delete sheet.칸[hit[0]]; + } + st.json = norm(cut.workbook); + st.sl = cut; + st.on.change(cut); + setTimeout(() => refreshSnap(st), 0); + } catch { + showToast(tp("Cannot"), "error"); + st.handle?.setDoc(structuredClone(shown(st))); + } +} + +async function mountSheet(st: State): Promise { + const { createSpreadsheet } = await loadSheet(); + if (!st.parts.sheet.isConnected || st.handle) return; + st.parts.sheet.replaceChildren(); + const book = structuredClone(shown(st)); // 편집기가 제자리에서 고치므로 사본을 줌 + st.json = norm(book); + st.handle = createSpreadsheet(st.parts.sheet, book, { + preview: true, + tabs: false, + readOnly: st.readOnly, + onChange: (b) => void onBook(st, b), + onSelect: (s) => { + st.cell = s.칸; + st.parts.cell.textContent = `${tp("Cell")} ${st.cell}`; + if (st.asking) drawBar(st); + }, + }); + st.observer = new MutationObserver(() => requestAnimationFrame(() => applyMarks(st))); + st.observer.observe(st.parts.sheet, { + childList: true, + subtree: true, + attributes: true, + attributeFilter: ["data-addr"], + }); + refreshSnap(st); +} + +/** 아래 시트 판 그리기 — 같은 상자면 편집기를 그대로 두고 알약 · 표시만 다시 */ +export function renderPanel( + host: HTMLElement, + sl: SheetLogic, + nodeId: string, + on: Handlers, + opts: { readOnly?: boolean } = {}, +): void { + const old = states.get(host); + const readOnly = !!opts.readOnly; + if (old && old.nodeId === nodeId && old.readOnly === readOnly) { + old.sl = sl; + old.on = on; + if (old.handle && norm(shown(old)) !== old.json) { + old.json = norm(shown(old)); + old.handle.setDoc(structuredClone(shown(old))); + setTimeout(() => refreshSnap(old), 0); + return; + } + return old.redraw(); + } + if (old) { + old.observer?.disconnect(); + old.handle?.destroy(); + } + const parts: State["parts"] = { + cell: el("span", { className: "m01-slpanel-cell", text: `${tp("Cell")} A1` }), + bar: el("div", { className: "m01-slpanel-bar" }), + msg: el("p", { className: "m01-slpanel-msg", attrs: { role: "status" } }), + sheet: el("div", { className: "m01-slpanel-sheet", text: tp("Loading") }), + pills: el("div", { className: "m01-slpanel-pills" }), + info: el("div", { className: "m01-slpanel-info" }), + }; + const st: State = { + nodeId, + sl, + on, + readOnly, + handle: null, + snap: {}, + json: "", + cell: "A1", + info: null, + asking: null, + busy: false, + parts, + observer: null, + redraw: () => { + drawBar(st); + drawPills(st); + applyMarks(st); + }, + }; + states.set(host, st); + const node = nodeOf(st); + host.classList.add("m01-slpanel"); + host.replaceChildren( + el("div", { + className: "m01-slpanel-head", + children: [el("strong", { text: node?.name ?? "" }), parts.cell], + }), + parts.bar, + parts.msg, + parts.sheet, + parts.pills, + parts.info, + ); + parts.msg.hidden = true; + st.redraw(); + void mountSheet(st); +}