@nanobpm/urban 0.55.0 → 0.56.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,2179 @@
1
+ // @ts-check
2
+ // ── Urban browser page runtime (authored source) ──────────────────────────
3
+ // This is the real, type-checked / linted / unit-tested source for the browser
4
+ // page renderer served at /app/runtime.js. It is NOT served from disk directly:
5
+ // the checked-in string artifact `../core/modules/runtime.gen.ts` is *generated*
6
+ // from this file by `scripts/gen-runtime.mjs` — the same "generated derived
7
+ // artifact + `git diff --exit-code` in CI" pattern AGENTS.md uses elsewhere, so
8
+ // a forgotten regeneration fails CI instead of shipping drift. The lone
9
+ // `__MOBILE_MAX_WIDTH__` placeholder is substituted from the shared MOBILE
10
+ // breakpoint (the single source of truth) at generation time, so the JS
11
+ // mode-switch and the shell CSS @media can never drift. Regenerate with
12
+ // `npm run gen:runtime`.
13
+ //
14
+ // The module is import-safe under Node/Deno: it performs NO DOM access at load —
15
+ // every bootstrap side-effect lives in boot(), gated on a real <main id="page">.
16
+ // That lets the renderer functions and the RENDERERS registry be imported and
17
+ // unit-tested with a fake DOM, and lets the string-generation build load it.
18
+
19
+ /** @typedef {import("@nanobpm/nano-app-schema").PageNodeType} PageNodeType */
20
+ /**
21
+ * A page node from a `*.page.json` document. `props` is an open, author-supplied
22
+ * bag whose shape is owned/validated by `@nanobpm/nano-app-schema`, so it is
23
+ * intentionally loose at this (already-validated) rendering boundary.
24
+ * @typedef {{ type: string, id?: string, props?: Record<string, any> }} PageNode
25
+ */
26
+ /** A renderer turns one page node into a DOM element. @typedef {(node: PageNode) => HTMLElement} Renderer */
27
+ /** Action-resolution context: the current form fields and (for grid rows) the row. @typedef {{ form?: Record<string, any>, row?: Record<string, any> }} ActionCtx */
28
+
29
+ /** The page mount root (`<main id="page">`). Assigned in boot(). @type {HTMLElement} */
30
+ let root;
31
+ /** The home page id (from the mount root's `data-home`). @type {string} */
32
+ let HOME = "home";
33
+ /** The single mobile-breakpoint media query. Assigned in boot(). @type {MediaQueryList} */
34
+ let MOBILE_MQ;
35
+ /** True when this app runs inside the Nano console's same-origin iframe. Assigned in boot(). @type {boolean} */
36
+ let NANO_EMBEDDED = false;
37
+
38
+ // Single mobile breakpoint, interpolated from the same MOBILE_MAX_WIDTH constant
39
+ // as the shell CSS @media so the JS mode-switch and the pure-CSS card flip can
40
+ // never drift on where "narrow" begins (#268). Only Tier-2 (an optional
41
+ // page-level 'mobile' layout variant) needs this JS switch — the dataGrid card
42
+ // flip, nav collapse and button/form stacking are all pure CSS + data-label.
43
+ function isNarrow() { return MOBILE_MQ.matches; }
44
+
45
+ // ── Multi-page routing (hash-based, reverse-proxy safe) ──────────────────
46
+ // Pages are selected by the URL fragment (#/<page>) so navigation never hits
47
+ // the server and works identically at the origin root and under the Nano
48
+ // console's /console/app-view/<name>/ path-prefixed proxy (the hash is never
49
+ // sent upstream). An empty/invalid hash falls back to the home page. Only a
50
+ // safe id charset is accepted so the fragment can't smuggle a path/URL.
51
+ const PAGE_ID = /^[A-Za-z0-9_-]+$/;
52
+ /** @param {any} value */
53
+ function safePageId(value) {
54
+ return typeof value === "string" && PAGE_ID.test(value);
55
+ }
56
+ // A route is #/<page> or #/<page>/<url-encoded-param>. We split on the FIRST
57
+ // "/" *before* decoding, so a param that itself contains encoded slashes or
58
+ // hashes (e.g. a plan_key "owner/repo#123" carried as "owner%2Frepo%23123")
59
+ // survives intact instead of the inner "%2F" being decoded into a second
60
+ // segment. The page segment is still restricted to the safe id charset; the
61
+ // param is opaque free text (bound only as a whitelisted '?' SQL value, or
62
+ // interpolated as DOM text — never as HTML/markup).
63
+ function parseRoute() {
64
+ const raw = (location.hash || "").replace(/^#\/?/, "");
65
+ const slash = raw.indexOf("/");
66
+ const pageRaw = slash >= 0 ? raw.slice(0, slash) : raw;
67
+ const paramRaw = slash >= 0 ? raw.slice(slash + 1) : "";
68
+ let page = HOME, param = "", pageOk = false;
69
+ try { const p = decodeURIComponent(pageRaw); if (safePageId(p)) { page = p; pageOk = true; } } catch (e) { /* keep HOME */ }
70
+ // Only carry the param when the page segment resolved to a real (safe-id) page.
71
+ // A malformed/unknown page like #/not-a-page/x falls back to HOME and must NOT
72
+ // inherit x, or that stray param would silently scope every eqParam grid and
73
+ // {{param}} on the home page to an entity the user never selected.
74
+ if (pageOk) { try { param = paramRaw ? decodeURIComponent(paramRaw) : ""; } catch (e) { param = ""; } }
75
+ return { page, param };
76
+ }
77
+ function currentPage() { return parseRoute().page; }
78
+ let CURRENT = "home";
79
+ // The current route param (the "/<param>" tail, or "" when absent). Exposed to a
80
+ // page's datasource filters (a filter with { eqParam: true } binds its value to
81
+ // this) and to {{param}} text interpolation, so one page template can scope
82
+ // every section to a selected entity (e.g. an epic's plan_key).
83
+ let PARAM = "";
84
+ // Monotonic render token. renderPage() is async and fires from both hashchange
85
+ // and the matchMedia viewport-crossing handler, so two invocations can overlap
86
+ // and their fetches resolve out of order. Each render captures the token it
87
+ // bumped to; after its await it only touches the DOM if it is still the latest,
88
+ // so a stale navigation/viewport render can't overwrite a newer one.
89
+ let renderSeq = 0;
90
+
91
+ // Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
92
+ // listener here; navigating to another page runs every disposer before the new
93
+ // page renders, so a switched-away grid stops polling (otherwise every visited
94
+ // page would leave a live setInterval fetching forever).
95
+ /** @type {any[]} */
96
+ const disposers = [];
97
+ function teardown() {
98
+ while (disposers.length) {
99
+ try { disposers.pop()(); } catch (e) { /* best-effort cleanup */ }
100
+ }
101
+ }
102
+
103
+ // Monotonic counter for per-modal element ids. aria-labelledby needs a unique
104
+ // title id: a fixed id would clash if the page already contains that id, and
105
+ // mis-associate the label for assistive tech.
106
+ let modalSeq = 0;
107
+ // At most one modal is open at a time. A double-click (or two rapid button
108
+ // clicks) would otherwise stack multiple overlays + document-level keydown
109
+ // listeners; this guard keeps a single dialog + handler live.
110
+ let modalOpen = false;
111
+
112
+ // Theme bridge for the Nano console embed. The app runs in a sandboxed,
113
+ // same-origin iframe, so the console's --nano-* custom properties and its
114
+ // data-appearance never cascade in. Instead the console postMessages its
115
+ // resolved tokens; we lay them onto this document's :root and mirror the
116
+ // appearance. Standalone (no console parent) nothing is posted, so the CSS
117
+ // defaults stand. We announce readiness so the console can push the theme even
118
+ // if its first message raced our listener install.
119
+ /** @param {any} msg */
120
+ function applyTheme(msg) {
121
+ if (!msg || msg.type !== "nano-theme") return;
122
+ const el = document.documentElement;
123
+ if (msg.appearance === "light" || msg.appearance === "dark") el.dataset.appearance = msg.appearance;
124
+ const vars = msg.vars;
125
+ if (vars && typeof vars === "object") {
126
+ for (const [k, v] of Object.entries(vars)) {
127
+ // Only accept the shared token namespace; ignore anything else.
128
+ if (typeof k === "string" && k.startsWith("--nano-") && typeof v === "string") {
129
+ el.style.setProperty(k, v);
130
+ }
131
+ }
132
+ }
133
+ }
134
+ // True when this app runs inside the Nano console's same-origin iframe (vs a
135
+ // standalone CLI run). Gates the theme bridge below AND in-host link navigation:
136
+ // embedded, a processExplorer link routes the console in place; standalone, the
137
+ // link keeps its native new-window behavior. The same-origin probe matters: a
138
+ // cross-origin framer is also window.parent, but postMessage to our own origin
139
+ // would never reach it and hostNavigate would still preventDefault the link —
140
+ // so a cross-origin embed must fall through to the native _blank anchor. Reading
141
+ // parent.location.origin throws under the same-origin policy for a cross-origin
142
+ // parent, so the try/catch is the origin gate.
143
+ function computeEmbedded() {
144
+ if (!window.parent || window.parent === window) return false;
145
+ try {
146
+ return window.parent.location.origin === window.location.origin;
147
+ } catch {
148
+ return false;
149
+ }
150
+ }
151
+
152
+ // Ask the framing console to navigate in-host. Returns true when a message was
153
+ // posted (embedded) so the caller can suppress the native anchor; false
154
+ // standalone so it falls through to the new-window <a>. The payload is
155
+ // structured (target + params), never a raw href — the console constructs its
156
+ // own route, so app/row data can't smuggle a path or scheme across the boundary.
157
+ /** @param {string} target
158
+ * @param {Record<string, any>} params
159
+ */
160
+ function hostNavigate(target, params) {
161
+ if (!NANO_EMBEDDED) return false;
162
+ window.parent.postMessage({ type: "nano-navigate", target: target, params: params }, window.location.origin);
163
+ return true;
164
+ }
165
+
166
+ function installThemeBridge() {
167
+ if (!NANO_EMBEDDED) return;
168
+ window.addEventListener("message", (ev) => {
169
+ // Same-origin proxy (posture A): only trust a message from the framing
170
+ // parent AND from our own origin. ev.source alone is insufficient — a
171
+ // cross-origin page that frames a standalone run becomes window.parent, so
172
+ // an origin check is what actually pins the trust boundary.
173
+ if (ev.origin === window.location.origin && ev.source === window.parent) applyTheme(ev.data);
174
+ });
175
+ window.parent.postMessage({ type: "nano-app-ready" }, window.location.origin);
176
+ }
177
+
178
+ // Resolve every app endpoint against where THIS module was actually served from,
179
+ // so absolute-looking "/app/…" paths work both at the origin root (direct run,
180
+ // e.g. the CLI on :3000) and under a path-prefixed reverse proxy (the Nano
181
+ // console embeds the app at /console/app-view/<name>/). "../" drops the trailing
182
+ // "app/runtime.js" to land on the app's mount root (always ends in "/").
183
+ const APP_BASE = new URL("../", import.meta.url);
184
+ /** @param {string} u */
185
+ function apiUrl(u) {
186
+ // Rebase root-absolute app paths ("/app/…") onto the mount root. Leave a
187
+ // protocol-relative ("//host/…") or otherwise non-root-relative string alone —
188
+ // only a single leading slash denotes an app path we own.
189
+ return typeof u === "string" && u.startsWith("/") && !u.startsWith("//")
190
+ ? new URL(u.slice(1), APP_BASE).toString()
191
+ : u;
192
+ }
193
+
194
+ /** @param {string} url
195
+ * @param {RequestInit=} opts
196
+ * @returns {Promise<any>}
197
+ */
198
+ async function getJSON(url, opts) {
199
+ const r = await fetch(apiUrl(url), opts);
200
+ const body = await r.json().catch(() => ({}));
201
+ if (!r.ok) throw new Error(body.error || ("HTTP " + r.status));
202
+ return body;
203
+ }
204
+
205
+ // ── route-driven actions ────────────────────────────────────────────────
206
+ // Every page action is a single primitive: POST (or any method) the resolved
207
+ // body to an app route path. There are NO bespoke action kinds — a form/row
208
+ // reaches process start, message publish, cancel, an OpenAPI operation, a
209
+ // webhook, or any app route uniformly by naming its path. Path + body may
210
+ // interpolate the current form fields and (for grid rows/detail forms) the row
211
+ // via {{form.KEY}} / {{row.KEY}} tokens; the whole-string tokens {{form}} and
212
+ // {{row}} splice the object itself (type-preserving), so a body of
213
+ // "variables": "{{form}}" sends the form object as the variable map.
214
+ /** @param {string} path
215
+ * @param {ActionCtx} ctx
216
+ * @returns {any}
217
+ */
218
+ function lookupToken(path, ctx) {
219
+ const segs = String(path).split(".");
220
+ const head = segs[0];
221
+ let base = head === "form" ? (ctx.form || {}) : head === "row" ? (ctx.row || {}) : undefined;
222
+ for (let i = 1; i < segs.length; i++) base = base == null ? undefined : base[segs[i]];
223
+ return base;
224
+ }
225
+ /** @param {any} node
226
+ * @param {ActionCtx} ctx
227
+ * @returns {any}
228
+ */
229
+ function resolveTemplate(node, ctx) {
230
+ if (typeof node === "string") {
231
+ const whole = node.match(/^\{\{\s*([\w.]+)\s*\}\}$/);
232
+ if (whole) return lookupToken(whole[1], ctx);
233
+ return node.replace(/\{\{\s*([\w.]+)\s*\}\}/g, (_, k) => {
234
+ const v = lookupToken(k, ctx);
235
+ return v == null ? "" : String(v);
236
+ });
237
+ }
238
+ if (Array.isArray(node)) return node.map((n) => resolveTemplate(n, ctx));
239
+ if (node && typeof node === "object") {
240
+ // Null-proto target so a template key like "__proto__" can't trigger prototype
241
+ // mutation (prototype-pollution class) when copied into the resolved object.
242
+ const out = Object.create(null);
243
+ for (const [k, v] of Object.entries(node)) out[k] = resolveTemplate(v, ctx);
244
+ return out;
245
+ }
246
+ return node;
247
+ }
248
+ /** @param {any} action
249
+ * @param {ActionCtx} ctx
250
+ * @returns {Promise<any>}
251
+ */
252
+ async function runRoute(action, ctx) {
253
+ if (!action || typeof action.path !== "string" || action.path === "") {
254
+ throw new Error("This action has no route configured (action.path is missing or blank)");
255
+ }
256
+ const path = /** @type {any} */ (action.path).replace(/\{\{\s*([\w.]+)\s*\}\}/g, /** @param {string} _ @param {string} k */ (_, k) => {
257
+ const v = lookupToken(k, ctx);
258
+ return v == null ? "" : encodeURIComponent(String(v));
259
+ });
260
+ const method = String(action.method || "POST").toUpperCase();
261
+ let body;
262
+ if (action.body !== undefined) body = resolveTemplate(action.body, ctx);
263
+ else if (ctx.form) body = { variables: ctx.form };
264
+ else body = {};
265
+ /** @type {RequestInit} */
266
+ const opts = { method, headers: { "content-type": "application/json" } };
267
+ if (method !== "GET" && method !== "HEAD") opts.body = JSON.stringify(body);
268
+ return getJSON(path, opts);
269
+ }
270
+
271
+ /** @param {string} tag
272
+ * @param {Record<string, any>} [attrs]
273
+ * @param {...any} kids
274
+ * @returns {any}
275
+ */
276
+ function el(tag, attrs = {}, ...kids) {
277
+ const n = document.createElement(tag);
278
+ for (const [k, v] of Object.entries(attrs)) {
279
+ if (k === "class") n.className = v;
280
+ else if (k.startsWith("on") && typeof v === "function") n.addEventListener(k.slice(2), v);
281
+ else if (v != null) n.setAttribute(k, v);
282
+ }
283
+ for (const kid of kids) n.append(kid);
284
+ return n;
285
+ }
286
+
287
+ // Interpolate the {{param}} token in a schema-supplied string with the current
288
+ // route param. The result is only ever set as DOM text (textContent), so the
289
+ // substituted value cannot inject markup. Other tokens are left untouched.
290
+ // A function replacement inserts PARAM literally; a plain string replacement would
291
+ // treat replacement patterns inside PARAM (an id like the dollar-one token) as
292
+ // special and render the wrong text.
293
+ /** @param {any} s
294
+ * @returns {any}
295
+ */
296
+ function interpParam(s) {
297
+ return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
298
+ }
299
+
300
+ // Interpolate {{field}} tokens in a column's per-cell template with values from
301
+ // the row, coercing each to a string (null/undefined → ""). Like interpParam the
302
+ // result is only ever set as DOM text (textContent via el()), so a substituted
303
+ // row value cannot inject markup or attributes. A function replacement inserts
304
+ // each value literally, so a "$"-bearing value can't be reinterpreted as a
305
+ // replacement pattern. Unknown tokens (no such field) render empty rather than
306
+ // throwing, matching interpParam. This is a dumb text formatter: no expressions,
307
+ // arithmetic, or conditionals — just field splicing. Only the row's own
308
+ // fields resolve (own-property gate, enumerable or not): an inherited
309
+ // Object.prototype key (toString, constructor, …) counts as unknown and
310
+ // renders "", so a token can't pick up prototype cruft instead of blank.
311
+ /** @param {any} tpl
312
+ * @param {Record<string, any>} row
313
+ * @returns {any}
314
+ */
315
+ function interpTemplate(tpl, row) {
316
+ return typeof tpl === "string"
317
+ ? tpl.replace(/\{\{([^{}]+)\}\}/g, (_m, name) => {
318
+ const key = name.trim();
319
+ const v = Object.prototype.hasOwnProperty.call(row, key) ? row[key] : null;
320
+ return v == null ? "" : String(v);
321
+ })
322
+ : tpl;
323
+ }
324
+ /** @param {any} node */
325
+ function renderText(node) {
326
+ const v = node.props.variant;
327
+ const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
328
+ return el(v === "heading" ? "h1" : "p", { class: cls }, interpParam(node.props.text) || "");
329
+ }
330
+
331
+ // Copy text to the clipboard, resiliently. The async Clipboard API is tried
332
+ // first, but it can reject inside a sandboxed console iframe (no clipboard-write
333
+ // permission) — so fall back to a hidden <textarea> + execCommand("copy"), which
334
+ // works under a user gesture with only allow-scripts. Returns whether a copy
335
+ // succeeded so the caller can nudge the user to ⌘/Ctrl+C when both paths fail.
336
+ /** @param {string} text
337
+ * @returns {Promise<boolean>}
338
+ */
339
+ async function copyToClipboard(text) {
340
+ try {
341
+ if (navigator.clipboard && navigator.clipboard.writeText) {
342
+ await navigator.clipboard.writeText(text);
343
+ return true;
344
+ }
345
+ } catch (_e) { /* fall through to the execCommand path */ }
346
+ const ta = document.createElement("textarea");
347
+ try {
348
+ ta.value = text;
349
+ ta.setAttribute("readonly", "");
350
+ ta.style.position = "fixed";
351
+ ta.style.top = "-1000px";
352
+ ta.style.opacity = "0";
353
+ document.body.append(ta);
354
+ ta.select();
355
+ return document.execCommand("copy");
356
+ } catch (_e) { return false; }
357
+ // Always remove the hidden textarea, even if select()/execCommand threw, so a
358
+ // failed copy never leaves a detached node accreting in <body> on retry.
359
+ finally { ta.remove(); }
360
+ }
361
+
362
+ // Rebase the sole {{appBase}} token in author-supplied copy text onto the app's
363
+ // absolute mount root (always ends in "/"), so a prompt like
364
+ // "read {{appBase}}app/api/agent" resolves to a URL an external agent can fetch —
365
+ // the console proxy path when embedded, the app origin when standalone. No other
366
+ // interpolation happens: copy text is otherwise emitted verbatim.
367
+ /** @param {any} text */
368
+ function resolveCopyText(text) {
369
+ return typeof text === "string" ? text.split("{{appBase}}").join(APP_BASE.toString()) : "";
370
+ }
371
+
372
+ // Open a lightweight modal appended to <body>. Closes on the ✕/Close button, a
373
+ // backdrop click, Escape, or a page switch; the keydown listener is removed on
374
+ // close so it never leaks, and close() is registered with teardown() so
375
+ // navigating away (hashchange → renderPage → teardown) tears down any open modal
376
+ // instead of leaving a stale overlay + document-level listener alive. When it
377
+ // carries copyText it renders a scrollable code block plus a Copy button (see
378
+ // copyToClipboard for the sandbox-safe fallback).
379
+ /** @param {any} m
380
+ * @returns {(() => void)|null}
381
+ */
382
+ function openModal(m) {
383
+ // Only one modal at a time — drop a second (e.g. double-click) open so we
384
+ // never stack overlays or register duplicate document-level keydown handlers.
385
+ // Return null (not a close fn) so a guarded caller (confirmModal) can tell the
386
+ // open was dropped and resolve rather than hang.
387
+ if (modalOpen) return null;
388
+ modalOpen = true;
389
+ const overlay = el("div", { class: "pc-modal-overlay" });
390
+ // role="dialog" + aria-modal need an accessible name: label by the visible
391
+ // title when present, else fall back to a generic aria-label so screen readers
392
+ // don't announce an unnamed dialog. The title id is unique per instance so it
393
+ // can't collide with a pre-existing page id.
394
+ const titleId = m.title ? "pc-modal-title-" + (++modalSeq) : null;
395
+ const dialog = el("div", titleId
396
+ ? { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId }
397
+ : { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-label": "Dialog" });
398
+ if (m.title) dialog.append(el("h2", { class: "pc-modal-title", id: titleId }, m.title));
399
+ if (m.description) dialog.append(el("p", { class: "pc-modal-desc" }, m.description));
400
+ const copyText = resolveCopyText(m.copyText);
401
+ if (copyText) dialog.append(el("pre", { class: "pc-modal-code" }, copyText));
402
+ const actions = el("div", { class: "pc-modal-actions" });
403
+ // Remember what had focus so we can restore it on close — a keyboard/screen
404
+ // reader user returns to where they were instead of being dumped at <body>.
405
+ const prevFocus = document.activeElement;
406
+ // A dismissal (✕/Close button, backdrop, Escape, page switch) resolves with
407
+ // m.dismissValue; an action button overrides it before closing. onResult fires
408
+ // exactly once, from close(), so every exit path (button OR dismiss) reports a
409
+ // result — this is what lets confirmModal() gate on a real user choice inside a
410
+ // sandboxed iframe where native confirm() would silently return false (#276).
411
+ const onResult = typeof m.onResult === "function" ? m.onResult : null;
412
+ let resultValue = m.dismissValue;
413
+ let closed = false;
414
+ function close() {
415
+ if (closed) return;
416
+ closed = true;
417
+ modalOpen = false;
418
+ document.removeEventListener("keydown", onKey);
419
+ overlay.remove();
420
+ // A manual close (button / Escape / backdrop) never pops the disposers
421
+ // stack, so drop our own entry here — otherwise repeated open/close cycles
422
+ // accrete dead close() closures until the next page navigation runs teardown.
423
+ const i = disposers.indexOf(close);
424
+ if (i !== -1) disposers.splice(i, 1);
425
+ // A consumer-provided result callback must never abort teardown: if it throws,
426
+ // swallow it so focus restoration below (and Escape/backdrop close) still runs.
427
+ if (onResult) { try { onResult(resultValue); } catch (_e) {} }
428
+ if (prevFocus instanceof HTMLElement && typeof prevFocus.focus === "function") prevFocus.focus();
429
+ }
430
+ // Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
431
+ /** @returns {any[]} */
432
+ function focusables() {
433
+ return Array.prototype.slice.call(dialog.querySelectorAll(
434
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
435
+ )).filter((n) => !n.disabled);
436
+ }
437
+ /** @param {KeyboardEvent} ev */
438
+ function onKey(ev) {
439
+ if (ev.key === "Escape") { close(); return; }
440
+ if (ev.key !== "Tab") return;
441
+ // Trap Tab within the dialog so focus can't slip behind the overlay onto the
442
+ // inert page underneath — that would break aria-modal semantics.
443
+ const f = focusables();
444
+ if (!f.length) return;
445
+ const first = f[0];
446
+ const last = f[f.length - 1];
447
+ if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
448
+ else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
449
+ }
450
+ if (copyText) {
451
+ const label = m.copyLabel || "Copy";
452
+ const copyBtn = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, label);
453
+ copyBtn.addEventListener("click", async () => {
454
+ const ok = await copyToClipboard(copyText);
455
+ copyBtn.textContent = ok ? "Copied ✓" : "Press ⌘/Ctrl+C";
456
+ setTimeout(() => { copyBtn.textContent = label; }, 2200);
457
+ });
458
+ actions.append(copyBtn);
459
+ }
460
+ const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
461
+ closeBtn.addEventListener("click", close);
462
+ // Custom action buttons (m.actions) turn this into a decision dialog — each
463
+ // sets the result then closes; the plain Close button is the default when none
464
+ // are given. This is the in-DOM replacement for native confirm()/alert(): one
465
+ // overlay + focus-trap + teardown machinery, no drift (AGENTS.md).
466
+ let focusBtn = closeBtn;
467
+ if (Array.isArray(m.actions) && m.actions.length) {
468
+ let firstActionBtn = null;
469
+ let defaultBtn = null;
470
+ for (const a of m.actions) {
471
+ const cls = "pc-btn pc-btn-sm" + (a.variant === "ghost" ? " pc-btn-ghost" : "");
472
+ const ab = el("button", { class: cls, type: "button" }, a.label);
473
+ ab.addEventListener("click", () => { resultValue = a.value; close(); });
474
+ actions.append(ab);
475
+ if (!firstActionBtn) firstActionBtn = ab;
476
+ if (a.default) defaultBtn = ab;
477
+ }
478
+ // Default focus lands on the FIRST action (typically Cancel), never the last:
479
+ // for confirmModal that keeps a stray Enter from accidentally triggering the
480
+ // destructive Confirm. An action can opt into autofocus with default:true.
481
+ focusBtn = defaultBtn || firstActionBtn;
482
+ } else {
483
+ actions.append(closeBtn);
484
+ }
485
+ dialog.append(actions);
486
+ overlay.append(dialog);
487
+ // A click on the backdrop (never the dialog itself) dismisses.
488
+ overlay.addEventListener("click", /** @param {MouseEvent} ev */ (ev) => { if (ev.target === overlay) close(); });
489
+ document.addEventListener("keydown", onKey);
490
+ document.body.append(overlay);
491
+ // A page switch runs teardown() before the next render — dispose the modal
492
+ // there too so navigating away never leaves a stale overlay + keydown listener.
493
+ disposers.push(close);
494
+ focusBtn.focus();
495
+ return close;
496
+ }
497
+
498
+ // In-DOM confirm — an accessible replacement for native confirm() that works in
499
+ // a sandboxed iframe (without allow-modals native confirm() silently returns
500
+ // false, dead-ending every confirm-guarded action; #276). Renders the message
501
+ // with Cancel/Confirm and resolves the user's choice; a dismissal (Escape /
502
+ // backdrop / ✕) resolves false. If a modal is already open the guarded open is
503
+ // dropped — resolve false so the caller doesn't hang.
504
+ /** @param {any} message
505
+ * @returns {Promise<boolean>}
506
+ */
507
+ function confirmModal(message) {
508
+ return new Promise((resolve) => {
509
+ const opened = openModal({
510
+ title: "Confirm",
511
+ description: String(message),
512
+ dismissValue: false,
513
+ actions: [
514
+ { label: "Cancel", value: false, variant: "ghost" },
515
+ { label: "Confirm", value: true },
516
+ ],
517
+ /** @param {any} v */
518
+ onResult: (v) => resolve(!!v),
519
+ });
520
+ if (!opened) resolve(false);
521
+ });
522
+ }
523
+
524
+ // In-DOM error surface — replaces native alert(), which a sandboxed iframe also
525
+ // suppresses, so action failures reported through it were invisible (#276).
526
+ /** @param {any} message */
527
+ function alertModal(message) {
528
+ openModal({ title: "Error", description: String(message) });
529
+ }
530
+
531
+ // A standalone button node. Clicking it opens props.modal — used e.g. to surface
532
+ // a copy-pasteable "point your agent here" prompt. props.variant === "ghost"
533
+ // renders the muted outline style. A button without a modal is inert.
534
+ /** @param {any} node */
535
+ function renderButton(node) {
536
+ const p = node.props || {};
537
+ const btn = el(
538
+ "button",
539
+ { class: "pc-btn" + (p.variant === "ghost" ? " pc-btn-ghost" : ""), type: "button" },
540
+ p.label || "Open",
541
+ );
542
+ const m = p.modal && typeof p.modal === "object" ? p.modal : null;
543
+ if (m) btn.addEventListener("click", () => openModal(m));
544
+ return el("div", { class: "pc-buttonrow" }, btn);
545
+ }
546
+
547
+ // A grid td cell with three column-declared rendering modes, checked in order:
548
+ // 1. badge — a compact status indicator, gated on a non-empty field value
549
+ // (see the detailed note at the check below).
550
+ // 2. linkField — the cell text becomes a link to the URL held in that other
551
+ // field. Only http(s) hrefs are linked (a javascript:/other-scheme URL
552
+ // smuggled through row data falls back to plain text).
553
+ // 3. link: { kind: "processExplorer", keyField } — a structured, engine-aware
554
+ // link: the cell text links to the Nano console's explorer view for the
555
+ // process instance whose key is held in the row's keyField. The console
556
+ // path is constructed HERE (never taken from row data) and the key is
557
+ // URL-encoded, so row data can't smuggle a path or scheme; only a
558
+ // non-empty key (after trimming surrounding whitespace) produces a link.
559
+ // Unknown link kinds fall back to plain text so an unrecognised schema
560
+ // can't render a broken anchor.
561
+ // The external linkField anchor always opens in a new tab (rel=noopener
562
+ // noreferrer). The processExplorer anchor does too when the app runs
563
+ // standalone, but when embedded in the console a plain click instead routes the
564
+ // host's own explorer view in place via the nano-navigate postMessage bridge
565
+ // (a modified/middle click keeps the new-tab behavior). Shared by the top-level
566
+ // grid and child grids.
567
+ //
568
+ // Cell TEXT (the 'text' local below): the visible string a cell renders. By
569
+ // default it is the column's single 'field' value, but an optional
570
+ // 'col.template' string wins: its {{field}} tokens are spliced from the row
571
+ // (interpTemplate) into DOM text. A template lets one cell combine several
572
+ // fields / add surrounding text (e.g. "{{current_wave}}/{{wave_count}}")
573
+ // without denormalising a display column into the datasource. The template
574
+ // drives the rendered text for the plain, linkField and link
575
+ // (page/processExplorer) paths — a link's href still comes from its own
576
+ // keyField/linkField, never the template. The 'badge' presence gate and its
577
+ // tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not
578
+ // the field text), so a template does not change badge behaviour. Sorting,
579
+ // groupBy and orderBy also key off real fields, never the rendered template.
580
+ //
581
+ // Two-line identity + one-line clamp (additive, opt-in, backward-compatible):
582
+ // a column may also declare 'subtitleField'/'subtitleTemplate' to render a muted
583
+ // second line under the primary text (the identity key beneath a title, GitHub/
584
+ // Linear style) and 'truncate: true' to clamp each line to one row with an
585
+ // ellipsis and a full-text title/aria-label tooltip (pairs with a per-column
586
+ // width/weight — see buildColgroup — to give the clip a bounded column). The
587
+ // subtitle is ALWAYS plain DOM text, never an href source. With none of these
588
+ // set the cell is a single inline node exactly as before (see cellTd).
589
+ // Build an in-app hash route to another page, scoped to a row key: #/<page>/<key>.
590
+ // The page id is validated against the safe-id charset and the key must be
591
+ // non-empty (after trimming); either failing yields "" (no link). The key is
592
+ // URL-encoded into the route's param tail, which parseRoute() decodes back
593
+ // verbatim, so row data can't smuggle a path or scheme. SINGLE source of truth
594
+ // for the page-link href so the grid's link cell and the pipeline locus link
595
+ // (issue #265) can't drift on how the route is built.
596
+ /** @param {any} page
597
+ * @param {any} key
598
+ * @returns {string}
599
+ */
600
+ function pageHashHref(page, key) {
601
+ const keyStr = key == null ? "" : String(key).trim();
602
+ if (!safePageId(page) || keyStr === "") return "";
603
+ return "#/" + encodeURIComponent(page) + "/" + encodeURIComponent(keyStr);
604
+ }
605
+
606
+ // Coerce a not-in-path value (issue #265) into a Set of stage keys. Accepts an
607
+ // array of keys or a comma/whitespace-separated string; null/undefined → empty
608
+ // set. Used to mark the stages a given row skips (dashed/omitted).
609
+ /** @param {any} v
610
+ * @returns {Set<string>}
611
+ */
612
+ function toStageSet(v) {
613
+ // Trim + drop-empties on BOTH inputs so array entries with stray whitespace
614
+ // (e.g. from CSV/JSON munging) match stages[].key just like the string path.
615
+ if (Array.isArray(v)) return new Set(v.map((s) => String(s).trim()).filter((s) => s !== ""));
616
+ if (v == null) return new Set();
617
+ return new Set(String(v).split(/[\s,]+/).filter((s) => s !== ""));
618
+ }
619
+
620
+ /** @param {any} col
621
+ * @param {Record<string, any>} row
622
+ * @param {string} role
623
+ * @returns {HTMLTableCellElement}
624
+ */
625
+ function gridCell(col, row, role) {
626
+ // Mobile card attributes stamped on every td so a dataGrid can flip to a
627
+ // stacked card list below the breakpoint with no JS branching (#268):
628
+ // 'data-label' drives the pure-CSS label:value line (the column header, or a
629
+ // mobile:{label} override), and the role class picks the card region (title /
630
+ // status chip / secondary field / hidden). The role is derived by convention
631
+ // (classifyColumns) and refinable per-column via col.mobile.priority.
632
+ const mrole = role || "secondary";
633
+ const mlabel =
634
+ col.mobile && typeof col.mobile.label === "string"
635
+ ? col.mobile.label
636
+ : col.header != null
637
+ ? String(col.header)
638
+ : String(col.field == null ? "" : col.field);
639
+ const mob = { class: "pc-mcell pc-mcell-" + mrole, "data-label": mlabel };
640
+ // Raw single-field value: drives the badge presence gate + tooltip and any
641
+ // field-derived defaults. Kept separate from the (possibly templated) display
642
+ // text so a template never re-lights or blanks a badge.
643
+ const rawText = row[col.field] == null ? "" : String(row[col.field]);
644
+ // Visible text: a col.template (interpolated from the row) wins over the
645
+ // raw field value, else the field value is shown verbatim.
646
+ const text = typeof col.template === "string" ? interpTemplate(col.template, row) : rawText;
647
+ // 1. badge — a compact status indicator. When the row's field value is
648
+ // non-empty (truthy after trimming) render a small circular badge (e.g. a
649
+ // red "1" dot flagging an incident) whose tooltip is the full field text;
650
+ // when empty the cell stays blank so the column is unobtrusive until it
651
+ // matters. tone (danger|warn|ok|info, default danger) picks the color and
652
+ // label the glyph inside (default "1"). Label/tone come from the app
653
+ // schema (trusted); the tooltip is row data set via title/textContent
654
+ // (DOM-escaped by the platform, so no HTML/attribute injection). The full
655
+ // field text is also mirrored into aria-label so assistive tech announces
656
+ // the incident/status text (the visible "1" glyph alone is not meaningful).
657
+ if (col.badge) {
658
+ // Even when blank, stamp data-label (the "every td carries data-label"
659
+ // invariant): the cell still collapses on mobile via the pc-mcell-chip:empty
660
+ // rule (attributes don't defeat :empty), so this keeps empty and non-empty
661
+ // badge cells consistent without re-lighting the label.
662
+ if (rawText.trim() === "") return el("td", { class: "pc-mcell pc-mcell-chip", "data-label": mlabel });
663
+ const t = col.badge.tone;
664
+ const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
665
+ const label = col.badge.label == null ? "1" : String(col.badge.label);
666
+ return el(
667
+ "td",
668
+ mob,
669
+ el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
670
+ );
671
+ }
672
+ // Optional second line (col.subtitleField / subtitleTemplate): a muted key
673
+ // under the primary title. subtitleTemplate wins (interpolated from the row);
674
+ // else the raw subtitleField value. Absent/null → "" → no extra line. It is
675
+ // ALWAYS plain DOM text (see cellTd), never a link/href source — a subtitle
676
+ // cannot smuggle a scheme or path, only linkField/keyField drive hrefs.
677
+ const subRaw =
678
+ col.subtitleField != null && row[col.subtitleField] != null ? String(row[col.subtitleField]) : "";
679
+ const subText = typeof col.subtitleTemplate === "string" ? interpTemplate(col.subtitleTemplate, row) : subRaw;
680
+ const truncate = col.truncate === true;
681
+ // pipeline (issue #265): a dataGrid column declaring kind:"pipeline" renders
682
+ // the ordered stages as a per-row horizontal progress track (upstream-filled
683
+ // → active-lit → downstream-ghosted, not-in-path dashed). It owns the whole
684
+ // cell, so it returns here before the link chain below. Gated purely on the
685
+ // discriminator so every existing column mode is untouched when it is absent;
686
+ // unknown/missing pipeline config degrades to plain cell text (never throws),
687
+ // exactly as an unrecognised link.kind falls back today.
688
+ if (col.kind === "pipeline") return pipelineCell(col, row, text, subText, truncate, mob);
689
+ // Primary inline content: an anchor for a valid link mode (checked in the same
690
+ // badge < linkField < link(page) < link(processExplorer) precedence as before),
691
+ // else the plain display text. Each link mode only claims the cell while
692
+ // 'primary' is still the bare text — an invalid href falls through to the next
693
+ // mode and ultimately to plain text, exactly as the early-return chain did.
694
+ let primary = text;
695
+ if (col.linkField) {
696
+ const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
697
+ if (text !== "" && /^https?:\/\//i.test(href)) {
698
+ primary = el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, text);
699
+ }
700
+ }
701
+ if (primary === text && col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
702
+ // An in-app link to another page, scoped to this row: #/<page>/<key>. Built
703
+ // via pageHashHref (the single source of truth for the route): the page id is
704
+ // validated against the safe-id charset and the key must be non-empty, so a
705
+ // blank/keyless cell stays plain text. Navigation is a pure hash change
706
+ // (no new tab, no server hit) — the SPA router re-renders in place.
707
+ const href = pageHashHref(col.link.page, row[col.link.keyField]);
708
+ if (text !== "" && href !== "") {
709
+ primary = el("a", { class: "pc-link", href }, text);
710
+ }
711
+ }
712
+ if (primary === text && col.link && col.link.kind === "processExplorer" && col.link.keyField) {
713
+ const key = row[col.link.keyField];
714
+ const keyStr = key == null ? "" : String(key).trim();
715
+ if (text !== "" && keyStr !== "") {
716
+ const href = "/console/explorer?instance=" + encodeURIComponent(keyStr);
717
+ primary = el("a", {
718
+ class: "pc-link",
719
+ href,
720
+ target: "_blank",
721
+ rel: "noopener noreferrer",
722
+ // Embedded in the console a plain primary click routes the host's
723
+ // explorer in place (via the nano-navigate bridge) instead of opening
724
+ // a new window. A non-primary button (middle/right = new-tab/context
725
+ // intent), a modified click (⌘/ctrl/shift/alt = new-tab intent) and
726
+ // any standalone run fall through to the native anchor above.
727
+ /** @param {MouseEvent} ev */
728
+ onclick: (ev) => {
729
+ if (ev.button !== 0) return;
730
+ if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;
731
+ if (hostNavigate("processExplorer", { instance: keyStr })) ev.preventDefault();
732
+ },
733
+ }, text);
734
+ }
735
+ }
736
+ return cellTd(primary, text, subText, truncate, mob);
737
+ }
738
+
739
+ // Compose a grid <td> from its primary inline content (a string or an anchor),
740
+ // an optional muted subtitle line, and optional one-line truncation. With
741
+ // NEITHER a subtitle nor truncation the cell is exactly the single inline node
742
+ // as before, so existing grids render byte-for-byte unchanged (backward
743
+ // compatible). When either is set the content is wrapped in a .pc-cell-main
744
+ // line and, if a subtitle is present, a .pc-cell-sub line beneath it. truncate
745
+ // adds .pc-truncate (nowrap + ellipsis) to each line and mirrors the full text
746
+ // into title=/aria-label so the clipped value is still recoverable on hover and
747
+ // to assistive tech. When the primary is an element (a link cell's <a>), title=/
748
+ // aria-label= are ALSO mirrored onto it: HTML does not inherit those to
749
+ // descendants, so hovering the link text or naming the anchor for assistive tech
750
+ // would otherwise miss the full value the wrapper carries. The subtitle is set as
751
+ // DOM text only — never an href. 'tdAttrs' (the mobile card attrs: data-label +
752
+ // role class, see gridCell) is applied to whichever <td> shape is returned so
753
+ // the CSS-only card flip labels every cell identically (#268).
754
+ /** @param {any} primary
755
+ * @param {string} primaryText
756
+ * @param {string} subText
757
+ * @param {boolean} truncate
758
+ * @param {Record<string, any>} tdAttrs
759
+ * @returns {HTMLTableCellElement}
760
+ */
761
+ function cellTd(primary, primaryText, subText, truncate, tdAttrs) {
762
+ const td = tdAttrs || {};
763
+ if (subText === "" && !truncate) return el("td", td, primary);
764
+ /** @type {Record<string, any>} */
765
+ const mainAttrs = { class: "pc-cell-main" + (truncate ? " pc-truncate" : "") };
766
+ if (truncate && primaryText !== "") {
767
+ mainAttrs.title = primaryText; mainAttrs["aria-label"] = primaryText;
768
+ if (primary && typeof primary.setAttribute === "function") {
769
+ primary.setAttribute("title", primaryText); primary.setAttribute("aria-label", primaryText);
770
+ }
771
+ }
772
+ const main = el("div", mainAttrs, primary);
773
+ if (subText === "") return el("td", td, main);
774
+ /** @type {Record<string, any>} */
775
+ const subAttrs = { class: "pc-cell-sub" + (truncate ? " pc-truncate" : "") };
776
+ if (truncate) { subAttrs.title = subText; subAttrs["aria-label"] = subText; }
777
+ return el("td", td, main, el("div", subAttrs, subText));
778
+ }
779
+
780
+ // Classify each grid column into a mobile-card region so a dataGrid flips to a
781
+ // stacked card list below the breakpoint with ZERO page-schema change (#268).
782
+ // Convention (Tier 0): the first non-badge column is the card title (primary), a
783
+ // badge column is a status chip, and everything else is a secondary label:value
784
+ // row. Tier 1 refines it — a column may carry mobile:{priority:"primary" |
785
+ // "secondary" | "hidden"}: an explicit "primary" suppresses the auto title pick
786
+ // so a later column can lead the card, "hidden" drops the column off the card
787
+ // (behind the row's "More" toggle), and "secondary" forces a plain field row.
788
+ /** @param {any[]} cols
789
+ * @returns {string[]}
790
+ */
791
+ function classifyColumns(cols) {
792
+ const list = cols || [];
793
+ const explicitPrimary = list.some((c) => c && c.mobile && c.mobile.priority === "primary");
794
+ let titleTaken = explicitPrimary;
795
+ return list.map((col) => {
796
+ const pr = col && col.mobile ? col.mobile.priority : null;
797
+ if (pr === "primary") return "primary";
798
+ if (pr === "secondary") return "secondary";
799
+ if (pr === "hidden") return "hidden";
800
+ if (col && col.badge) return "chip";
801
+ if (!titleTaken) { titleTaken = true; return "primary"; }
802
+ return "secondary";
803
+ });
804
+ }
805
+
806
+ // Per-row mobile "More" toggle cell: reveals the mobile:{priority:"hidden"} columns
807
+ // a narrow viewport drops off the card. The cell is base-hidden (display:none) so
808
+ // the desktop table is byte-for-byte unchanged; clicking flips .pc-open on the row,
809
+ // swaps the label, and updates aria-expanded so the reveal state is exposed to
810
+ // assistive tech (#268). One implementation shared by the top-level grid and child
811
+ // grids so the affordance — and its accessibility — can't drift between them.
812
+ /** @param {HTMLTableRowElement} tr */
813
+ function mobileMoreCell(tr) {
814
+ const moreBtn = el("button",
815
+ { class: "pc-btn pc-btn-sm pc-btn-ghost pc-more", type: "button", "aria-expanded": "false" }, "More");
816
+ moreBtn.addEventListener("click", () => {
817
+ const open = tr.classList.toggle("pc-open");
818
+ moreBtn.textContent = open ? "Less" : "More";
819
+ moreBtn.setAttribute("aria-expanded", String(open));
820
+ });
821
+ return el("td", { class: "pc-mcard-toggle" }, moreBtn);
822
+ }
823
+
824
+ // Pipeline / stepper cell (issue #265) — a dataGrid column with kind:"pipeline"
825
+ // renders a per-row, multi-stage horizontal progress track. It is RENDER-ONLY:
826
+ // stage state is read straight from the row fields the app supplies; no
827
+ // app-specific stage derivation lives here. Column config (all fields are the
828
+ // NAMES of row fields, mirroring the issue sketch):
829
+ // { kind:"pipeline",
830
+ // stages:[{key,label},...], // ordered stage definitions (required)
831
+ // activeField:"stage", // row field → the current stage's key
832
+ // stateField:"stage_state", // row field → ok | active | failed | blocked
833
+ // badgeField:"attention", // optional row field → badge text shown on the active stage
834
+ // notInPathField:"skipped", // optional row field → stages this row skips
835
+ // notInPath:["converging"], // static fallback used only when no field given
836
+ // locus:{ field:"pr_key", stage?:"<key>",
837
+ // link:{ kind:"page", page:"home" } } } // optional out-link
838
+ // Rendering: stages BEFORE the active one are filled/completed, the active stage
839
+ // is lit (its treatment driven by stateField — success ✓ vs failure ✕ vs blocked
840
+ // ⊘ are made unmistakably distinct), in-path stages AFTER it are ghosted, and any
841
+ // stage in the row's not-in-path set is dashed. Reuses the .pc-badge and .pc-link
842
+ // classes and the shared pageHashHref route builder — no parallel implementations. Unknown or
843
+ // missing config (no stages array) degrades gracefully to plain cell text.
844
+ /** @param {{ stages?: any[], [key: string]: any }} col
845
+ * @param {Record<string, any>} row
846
+ * @param {string} text
847
+ * @param {string} subText
848
+ * @param {boolean} truncate
849
+ * @param {Record<string, any>} tdAttrs
850
+ * @returns {HTMLTableCellElement}
851
+ */
852
+ function pipelineCell(col, row, text, subText, truncate, tdAttrs) {
853
+ // Drop null/undefined entries defensively: a schema with a hole must still
854
+ // render (never throws on s.key) — same graceful-degradation goal as below.
855
+ /** @type {any[]} */
856
+ const stages = Array.isArray(col.stages) ? col.stages.filter((s) => s != null) : [];
857
+ // Graceful fallback: an unrecognised/empty pipeline config renders plain text,
858
+ // exactly like an unknown link.kind — never throws.
859
+ if (stages.length === 0) return cellTd(text, text, subText, truncate, tdAttrs);
860
+ // The current stage's key, matched against stages[].key to find the active index.
861
+ // The row value is trimmed for consistency with the other row-derived keys
862
+ // (pageHashHref, toStageSet), so a whitespace-padded value still matches its stage
863
+ // instead of leaving the whole track rendered as upcoming.
864
+ // Stage keys identify a single stage, so stop at the first match: the result is
865
+ // deterministic even if a config accidentally repeats a key, and we avoid scanning
866
+ // the remaining stages on every row.
867
+ const activeKey = col.activeField != null && row[col.activeField] != null ? String(row[col.activeField]).trim() : "";
868
+ let activeIdx = -1;
869
+ for (let i = 0; i < stages.length; i++) {
870
+ if (String(stages[i].key) === activeKey) {
871
+ activeIdx = i;
872
+ break;
873
+ }
874
+ }
875
+ // The active stage's state drives its lit/failed/blocked treatment. Anything
876
+ // outside the allow-list (or absent) is treated as an in-progress "active".
877
+ const stRaw = col.stateField != null && row[col.stateField] != null ? String(row[col.stateField]) : "";
878
+ const state = stRaw === "ok" || stRaw === "failed" || stRaw === "blocked" ? stRaw : "active";
879
+ // Not-in-path (skipped) stages: driven per-row from notInPathField when given,
880
+ // else the static notInPath config (per the issue: "per row, from a field").
881
+ const skip =
882
+ col.notInPathField != null && row[col.notInPathField] != null
883
+ ? toStageSet(row[col.notInPathField])
884
+ : toStageSet(col.notInPath);
885
+ // Optional active-stage badge (escalation/attention → warn, blocked/failure →
886
+ // danger), reusing .pc-badge tone classes — no new badge styles. The badge text
887
+ // is a single row-level value (row[badgeField]) shown only on the active stage.
888
+ const badgeText = col.badgeField != null && row[col.badgeField] != null ? String(row[col.badgeField]) : "";
889
+ const badgeTone = state === "failed" || state === "blocked" ? "danger" : "warn";
890
+ // Optional locus out-link: the relevant stage (default the active one, or
891
+ // locus.stage by key) becomes an in-app page link when the row's locus key is
892
+ // non-empty. Href comes from the shared pageHashHref builder (safePageId guard,
893
+ // encodeURIComponent) — a blank key yields "" so the stage stays plain.
894
+ const locus = col.locus;
895
+ const locusKey = locus && locus.field != null && row[locus.field] != null ? String(row[locus.field]) : "";
896
+ const locusHref =
897
+ locus && locus.link && locus.link.kind === "page" && locus.link.page ? pageHashHref(locus.link.page, locusKey) : "";
898
+ const track = el("div", {
899
+ class: "pc-pipe",
900
+ role: "list",
901
+ "aria-label": "Progress" + (activeIdx >= 0 ? ": " + labelOf(stages[activeIdx]) : ""),
902
+ });
903
+ for (let i = 0; i < stages.length; i++) {
904
+ const s = stages[i];
905
+ // Active wins over skip: if the active stage's key is also in the not-in-path
906
+ // set, it must still render as the current step (active styling + aria-current),
907
+ // never as skipped. Excluding activeIdx here keeps that precedence consistent
908
+ // across the stage class, the aria-current mark, and the connector fill below.
909
+ const skipped = i !== activeIdx && skip.has(String(s.key));
910
+ // Connector between adjacent stages; filled up to and including the active
911
+ // stage, but never across a not-in-path stage — a skipped predecessor breaks
912
+ // the fill so we don't visually imply it was on the path.
913
+ if (i > 0) {
914
+ const filled =
915
+ activeIdx >= 0 && i <= activeIdx && !skipped && !skip.has(String(stages[i - 1].key)) ? " filled" : "";
916
+ track.append(el("span", { class: "pc-pipe-conn" + filled, "aria-hidden": "true" }));
917
+ }
918
+ let cls;
919
+ let word;
920
+ let glyph = "";
921
+ let current = false;
922
+ if (skipped) {
923
+ cls = "pc-pipe-skip"; word = "skipped";
924
+ } else if (activeIdx >= 0 && i < activeIdx) {
925
+ cls = "pc-pipe-done"; word = "completed";
926
+ } else if (i === activeIdx) {
927
+ current = true;
928
+ if (state === "failed") { cls = "pc-pipe-active pc-pipe-failed"; word = "failed"; glyph = "\u2715"; }
929
+ else if (state === "blocked") { cls = "pc-pipe-active pc-pipe-blocked"; word = "blocked"; glyph = "\u2298"; }
930
+ else if (state === "ok") { cls = "pc-pipe-active pc-pipe-ok"; word = "done"; glyph = "\u2713"; }
931
+ else { cls = "pc-pipe-active"; word = "current"; }
932
+ } else {
933
+ cls = "pc-pipe-upcoming"; word = "upcoming";
934
+ }
935
+ const isLocus = locusHref !== "" && (locus.stage != null ? String(s.key) === String(locus.stage) : i === activeIdx);
936
+ const label = labelOf(s);
937
+ const labelNode = isLocus
938
+ ? el("a", { class: "pc-link pc-pipe-label", href: locusHref }, label)
939
+ : el("span", { class: "pc-pipe-label" }, label);
940
+ /** @type {Record<string, any>} */
941
+ const attrs = { class: "pc-pipe-stage " + cls, role: "listitem", "aria-label": label + " \u2014 " + word };
942
+ if (current) attrs["aria-current"] = "step";
943
+ const stage = el("span", attrs, labelNode);
944
+ if (glyph !== "") stage.append(el("span", { class: "pc-pipe-mark", "aria-hidden": "true" }, glyph));
945
+ if (current && badgeText.trim() !== "") {
946
+ stage.append(el("span", { class: "pc-badge pc-badge-" + badgeTone, title: badgeText, "aria-label": badgeText }, badgeText));
947
+ }
948
+ track.append(stage);
949
+ }
950
+ // The mobile card attrs (data-label + role class, #268) stamped by gridCell are
951
+ // merged onto the pipeline <td> too, alongside its own pc-pipe-cell class, so a
952
+ // pipeline column labels and classifies identically under the CSS-only card flip.
953
+ const cellAttrs = { ...(tdAttrs || {}), class: (tdAttrs && tdAttrs.class ? tdAttrs.class + " " : "") + "pc-pipe-cell" };
954
+ return el("td", cellAttrs, track);
955
+ }
956
+
957
+ // A stage's visible label: its declared label, else its key as a fallback.
958
+ /** @param {any} stage */
959
+ function labelOf(stage) {
960
+ return stage.label == null ? String(stage.key) : String(stage.label);
961
+ }
962
+
963
+ // Parse a "<number>%" width into its numeric percentage, or null if the string
964
+ // is not a bare percentage (e.g. "22rem", "120px") — used to decide whether the
965
+ // remainder left for weighted columns can be computed.
966
+ /** @param {string} width
967
+ * @returns {number|null}
968
+ */
969
+ function colWidthPct(width) {
970
+ const m = /^\s*([0-9]*\.?[0-9]+)%\s*$/.exec(width);
971
+ return m ? parseFloat(m[1]) : null;
972
+ }
973
+
974
+ // A column carries an explicit width only when it is a non-empty string. Single
975
+ // source of truth so colWidthStyle/buildColgroup can't drift on what "explicit"
976
+ // means (which columns win verbatim, consume the remainder, or use their weight).
977
+ /** @param {any} col */
978
+ function hasExplicitWidth(col) {
979
+ return typeof col.width === "string" && col.width !== "";
980
+ }
981
+
982
+ // A column's <col> width for the grid's <colgroup>: an explicit col.width string
983
+ // ("40%", "22rem") wins verbatim; else a positive numeric col.weight is
984
+ // normalised across the weighted columns into a percentage share of the width
985
+ // left over by explicit columns (remainderPct); else null (no width — the column
986
+ // shares the remainder equally under table-layout:fixed).
987
+ /** @param {any} col
988
+ * @param {number} weightTotal
989
+ * @param {number} remainderPct
990
+ * @returns {string|null}
991
+ */
992
+ function colWidthStyle(col, weightTotal, remainderPct) {
993
+ if (hasExplicitWidth(col)) return col.width;
994
+ if (typeof col.weight === "number" && col.weight > 0 && weightTotal > 0) {
995
+ return Math.round((col.weight / weightTotal) * remainderPct * 100) / 100 + "%";
996
+ }
997
+ return null;
998
+ }
999
+
1000
+ // Build a <colgroup> that sizes the grid's columns so table-layout:fixed stops
1001
+ // splitting width equally. Returns null when no column declares width/weight, so
1002
+ // an unsized grid emits no colgroup and lays out exactly as today. 'extraCount'
1003
+ // adds trailing unsized <col>s for the row-actions/detail column so the sized
1004
+ // data columns stay aligned with their headers.
1005
+ /** @param {any[]} cols
1006
+ * @param {number} extraCount
1007
+ * @returns {HTMLTableColElement|null}
1008
+ */
1009
+ function buildColgroup(cols, extraCount) {
1010
+ const hasSizing = cols.some(
1011
+ (c) => hasExplicitWidth(c) || (typeof c.weight === "number" && c.weight > 0),
1012
+ );
1013
+ if (!hasSizing) return null;
1014
+ // Only columns that will actually use their weight (no explicit width — an
1015
+ // explicit width wins verbatim in colWidthStyle and never consumes weight)
1016
+ // contribute to weightTotal; counting a width-bearing column's weight here
1017
+ // would inflate the divisor and shrink the truly-weighted columns' share.
1018
+ let weightTotal = 0;
1019
+ for (const c of cols)
1020
+ if (!hasExplicitWidth(c) && typeof c.weight === "number" && c.weight > 0) weightTotal += c.weight;
1021
+ // Weighted columns share only the width the explicit columns leave behind, so
1022
+ // mixing e.g. width:"40%" with weighted columns can't oversubscribe past 100%.
1023
+ // The remainder is only computable when every explicit width is a percentage;
1024
+ // if any uses an absolute/relative unit (rem/px) we can't subtract it from
1025
+ // 100%, so weighted columns fall back to a share of the full width.
1026
+ let explicitPctTotal = 0;
1027
+ let allExplicitArePct = true;
1028
+ for (const c of cols) {
1029
+ if (hasExplicitWidth(c)) {
1030
+ const pct = colWidthPct(c.width);
1031
+ if (pct === null) allExplicitArePct = false;
1032
+ else explicitPctTotal += pct;
1033
+ }
1034
+ }
1035
+ const remainderPct = allExplicitArePct ? Math.max(0, 100 - explicitPctTotal) : 100;
1036
+ const colEls = cols.map((c) => {
1037
+ const w = colWidthStyle(c, weightTotal, remainderPct);
1038
+ return el("col", w ? { style: "width:" + w } : {});
1039
+ });
1040
+ for (let i = 0; i < extraCount; i++) colEls.push(el("col", {}));
1041
+ return el("colgroup", {}, ...colEls);
1042
+ }
1043
+
1044
+ /** @param {any} node */
1045
+ function renderActionForm(node) {
1046
+ /** @type {any} */
1047
+ const p = node.props;
1048
+ const card = el("section", { class: "pc-card" });
1049
+ if (p.title) card.append(el("h2", {}, p.title));
1050
+ // Map/null-proto stores so a schema field keyed __proto__/constructor can't
1051
+ // pollute Object.prototype or shadow inherited props (prototype-pollution class).
1052
+ /** @type {Map<string, HTMLInputElement>} */
1053
+ const inputs = new Map();
1054
+ /** @type {Map<string, string>} */
1055
+ const fieldTypes = new Map();
1056
+ // Client-side required-field validation state: the set of required keys, each
1057
+ // field's custom message, and its inline error node (populated on a blocked
1058
+ // submit, cleared as soon as the user edits the field).
1059
+ /** @type {Set<string>} */
1060
+ const required = new Set();
1061
+ /** @type {Map<string, string>} */
1062
+ const reqMsg = new Map();
1063
+ /** @type {Map<string, HTMLElement>} */
1064
+ const errs = new Map();
1065
+ // A required field's label carries a danger-toned "*" so the requirement is
1066
+ // visible BEFORE submit; spread into el(...) — an empty array adds no child.
1067
+ /** @param {any} f
1068
+ * @returns {HTMLElement[]}
1069
+ */
1070
+ const reqMark = (f) => (f.required === true ? [el("span", { class: "pc-req", title: "Required" }, " *")] : []);
1071
+ // Register a field's inline error node and, when required, mark the input
1072
+ // (aria-required) and wire a clear-on-edit handler so a hint disappears the
1073
+ // moment the user starts fixing it. Editing a required field also clears the
1074
+ // blocked-submit summary ("Please fill in the required fields.") so it never
1075
+ // lingers as a stale/misleading banner while the user is correcting input.
1076
+ /** @param {any} f
1077
+ * @param {HTMLInputElement} input
1078
+ * @param {HTMLElement} ferr
1079
+ * @param {string} evt
1080
+ */
1081
+ const wireField = (f, input, ferr, evt) => {
1082
+ errs.set(f.key, ferr);
1083
+ if (f.required !== true) return;
1084
+ required.add(f.key);
1085
+ if (typeof f.requiredMessage === "string" && f.requiredMessage !== "") reqMsg.set(f.key, f.requiredMessage);
1086
+ input.setAttribute("aria-required", "true");
1087
+ input.addEventListener(evt, () => {
1088
+ input.classList.remove("pc-invalid");
1089
+ input.removeAttribute("aria-invalid");
1090
+ ferr.textContent = "";
1091
+ msg.textContent = "";
1092
+ msg.className = "pc-msg";
1093
+ });
1094
+ };
1095
+ for (const f of p.fields || []) {
1096
+ const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
1097
+ fieldTypes.set(f.key, kind);
1098
+ const ferr = el("p", { class: "pc-field-err" });
1099
+ if (kind === "checkbox") {
1100
+ // A boolean field renders a real checkbox; its default checked state comes
1101
+ // from default/checked on the field (unset -> unchecked). We record that on
1102
+ // the input's defaultChecked so a post-submit reset can restore it without a
1103
+ // side map that could drift from the actual input.
1104
+ const checked = f.default === true || f.checked === true;
1105
+ const input = el("input", { type: "checkbox" });
1106
+ input.defaultChecked = checked;
1107
+ input.checked = checked;
1108
+ inputs.set(f.key, input);
1109
+ wireField(f, input, ferr, "change");
1110
+ // Input-first, wrapped in the label so the whole row is a click target.
1111
+ card.append(
1112
+ el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key), ...reqMark(f)), ferr),
1113
+ );
1114
+ continue;
1115
+ }
1116
+ /** @type {Record<string, any>} */
1117
+ const attrs = { type: kind, placeholder: f.label || f.key };
1118
+ if (kind === "number") {
1119
+ attrs.inputmode = "numeric";
1120
+ if (f.min != null) attrs.min = String(f.min);
1121
+ if (f.max != null) attrs.max = String(f.max);
1122
+ attrs.step = f.step != null ? String(f.step) : "1";
1123
+ }
1124
+ const input = el("input", attrs);
1125
+ inputs.set(f.key, input);
1126
+ wireField(f, input, ferr, "input");
1127
+ card.append(el("div", { class: "pc-field" }, el("label", {}, f.label || f.key, ...reqMark(f)), input, ferr));
1128
+ }
1129
+ const msg = el("p", { class: "pc-msg" });
1130
+ const btn = el("button", { class: "pc-btn" }, p.submitLabel || "Submit");
1131
+ btn.addEventListener("click", async () => {
1132
+ // Required-field gate: block the submit and surface an inline hint on each
1133
+ // empty required field (a text/number left blank, a required checkbox left
1134
+ // unchecked), focusing the first offender — so a missing value never
1135
+ // round-trips to the server only to bounce back as a generic error.
1136
+ let firstInvalid = null;
1137
+ for (const [k, input] of inputs) {
1138
+ const ferr = errs.get(k);
1139
+ if (ferr) ferr.textContent = "";
1140
+ input.classList.remove("pc-invalid");
1141
+ input.removeAttribute("aria-invalid");
1142
+ if (!required.has(k)) continue;
1143
+ const type = fieldTypes.get(k);
1144
+ const val = String(input.value).trim();
1145
+ const missing = type === "checkbox"
1146
+ ? input.checked !== true
1147
+ : type === "number"
1148
+ ? (val === "" || !Number.isFinite(Number(val)))
1149
+ : val === "";
1150
+ if (missing) {
1151
+ if (ferr) ferr.textContent = reqMsg.get(k) || "Required";
1152
+ input.classList.add("pc-invalid");
1153
+ input.setAttribute("aria-invalid", "true");
1154
+ if (!firstInvalid) firstInvalid = input;
1155
+ }
1156
+ }
1157
+ if (firstInvalid) {
1158
+ msg.className = "pc-msg err";
1159
+ msg.textContent = "Please fill in the required fields.";
1160
+ firstInvalid.focus();
1161
+ return;
1162
+ }
1163
+ const variables = Object.create(null);
1164
+ for (const [k, input] of inputs) {
1165
+ if (fieldTypes.get(k) === "checkbox") {
1166
+ // Always emit a real boolean (checked -> true / false), never a string,
1167
+ // so a strict equality check on the action side sees the intended value.
1168
+ variables[k] = input.checked === true;
1169
+ } else if (fieldTypes.get(k) === "number") {
1170
+ const t = String(input.value).trim();
1171
+ // Blank → omit so the action-side default applies; a non-finite parse
1172
+ // (NaN/Infinity) is also omitted rather than smuggling a raw string
1173
+ // process variable through, which would defeat the numeric coercion.
1174
+ if (t === "") continue;
1175
+ const num = Number(t);
1176
+ if (!Number.isFinite(num)) continue;
1177
+ variables[k] = num;
1178
+ } else {
1179
+ variables[k] = input.value;
1180
+ }
1181
+ }
1182
+ btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Submitting…";
1183
+ try {
1184
+ // Route-driven: POST the form (as { variables } by default, or the action's
1185
+ // own body template) to the action's route path. correlationKey / process /
1186
+ // message are expressed by the path + body template the page author supplies,
1187
+ // e.g. { path: "/app/actions/message", body: { name, correlationKey, variables: "{{form}}" } }.
1188
+ const res = await runRoute(p.action, { form: variables });
1189
+ msg.className = "pc-msg ok";
1190
+ msg.textContent = (p.action && p.action.successLabel) ||
1191
+ (res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
1192
+ for (const [k, input] of inputs) {
1193
+ // Text/number inputs clear; a checkbox resets to its declared default,
1194
+ // read straight off the input's defaultChecked (no side map to drift).
1195
+ if (fieldTypes.get(k) === "checkbox") input.checked = input.defaultChecked;
1196
+ else input.value = "";
1197
+ }
1198
+ document.dispatchEvent(new CustomEvent("pc:refresh"));
1199
+ } catch (e) {
1200
+ msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
1201
+ } finally { btn.disabled = false; }
1202
+ });
1203
+ card.append(btn, msg);
1204
+ return card;
1205
+ }
1206
+
1207
+ // Accessible name for the row-count badge. The visible text is just a number, so an
1208
+ // aria-label would override it with a bare "row count" (the count — the important part —
1209
+ // is dropped). Instead name it "N row(s)" and keep it in sync with the text on refresh.
1210
+ // Single source of truth for both the initial render and every refresh below.
1211
+ /** @param {number} n */
1212
+ function rowCountLabel(n) { return n + (n === 1 ? " row" : " rows"); }
1213
+
1214
+ // Build the /app/data/<source>/<table> URL a data-bound list renderer fetches,
1215
+ // threading the whitelisted where/order query params. Shared by dataGrid and the
1216
+ // prose renderer so the two can't drift on how a filter/orderBy is encoded. A
1217
+ // filter is { field, in:[…] } (IN set), { field, eqParam:true } (bound to the
1218
+ // current route PARAM — "show the selected entity's rows"), or { field, eq } (a
1219
+ // literal equals); orderBy is { field, dir }. The source/table/field names are
1220
+ // server-side whitelisted (the /app/data route rejects unknown columns).
1221
+ /** @param {string} source
1222
+ * @param {string} tbl
1223
+ * @param {any[]} filters
1224
+ * @param {any} order
1225
+ */
1226
+ function dataUrl(source, tbl, filters, order) {
1227
+ let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
1228
+ const qs = [];
1229
+ for (const f of filters || []) {
1230
+ if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1231
+ else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1232
+ else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1233
+ }
1234
+ if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
1235
+ return qs.length ? u + "?" + qs.join("&") : u;
1236
+ }
1237
+
1238
+ // ── Minimal, safe Markdown → DOM (no innerHTML) — used by the prose renderer ──
1239
+ // A dependency-free CommonMark subset. It NEVER sets innerHTML: every block and
1240
+ // inline token is materialised as a known element via el(), with text set through
1241
+ // textContent / createTextNode — so a record's body field may carry markdown
1242
+ // WITHOUT ever becoming an HTML/script injection vector (the same text-only
1243
+ // guarantee interpTemplate gives a grid cell). Supported: ATX headings
1244
+ // (#..######), fenced + inline code, bold (**/__), italic (*/_), links
1245
+ // [text](url) (http/https/mailto only — every other scheme degrades to its plain
1246
+ // label), unordered (-,*,+) and ordered (1. / 1)) lists, blockquotes (>),
1247
+ // thematic breaks (---), and blank-line-separated paragraphs. MD_BACKTICK is the
1248
+ // backtick that delimits code spans/fences.
1249
+ const MD_BACKTICK = "`";
1250
+ /** @param {any} c */
1251
+ function mdIsWordChar(c) { return typeof c === "string" && /[0-9A-Za-z]/.test(c); }
1252
+ // Only http(s)/mailto hrefs become anchors; anything else (javascript:, data:,
1253
+ // vbscript:, a bare relative path, …) returns "" so the caller renders the label
1254
+ // as plain text rather than a clickable, potentially-hostile link.
1255
+ /** @param {any} href */
1256
+ function mdSafeHref(href) {
1257
+ const h = String(href == null ? "" : href).trim();
1258
+ return /^(?:https?:|mailto:)/i.test(h) ? h : "";
1259
+ }
1260
+ // Find the closing run of 'marker' at or after 'from'. For underscore emphasis the
1261
+ // close must not be intra-word (a following alphanumeric means snake_case, not a
1262
+ // delimiter), so keep scanning past such runs; -1 when there is no valid close.
1263
+ /** @param {string} s
1264
+ * @param {string} marker
1265
+ * @param {number} from
1266
+ * @param {boolean} underscore
1267
+ */
1268
+ function mdFindClose(s, marker, from, underscore) {
1269
+ let j = from;
1270
+ while (true) {
1271
+ const k = s.indexOf(marker, j);
1272
+ if (k < 0) return -1;
1273
+ if (!underscore || !mdIsWordChar(s[k + marker.length])) return k;
1274
+ j = k + marker.length;
1275
+ }
1276
+ }
1277
+ /** @param {any} text
1278
+ * @returns {Array<Node|string>}
1279
+ */
1280
+ function mdInline(text) {
1281
+ const s = String(text == null ? "" : text);
1282
+ /** @type {Array<Node|string>} */
1283
+ const nodes = [];
1284
+ let buf = "";
1285
+ const flush = () => { if (buf) { nodes.push(document.createTextNode(buf)); buf = ""; } };
1286
+ let i = 0;
1287
+ while (i < s.length) {
1288
+ const ch = s[i];
1289
+ // Backslash escape: the next char is emitted literally, never as a delimiter.
1290
+ if (ch === "\\" && i + 1 < s.length) { buf += s[i + 1]; i += 2; continue; }
1291
+ // Inline code span — verbatim, no nested inline parsing.
1292
+ if (ch === MD_BACKTICK) {
1293
+ const end = s.indexOf(MD_BACKTICK, i + 1);
1294
+ if (end > i) { flush(); nodes.push(el("code", { class: "pc-md-code" }, s.slice(i + 1, end))); i = end + 1; continue; }
1295
+ }
1296
+ // Strong (** or __). Underscore only when it opens on a word boundary so an
1297
+ // identifier like a__b doesn't get mangled into emphasis.
1298
+ if ((ch === "*" || ch === "_") && s[i + 1] === ch) {
1299
+ const underscore = ch === "_";
1300
+ if (!underscore || !mdIsWordChar(s[i - 1])) {
1301
+ const marker = ch + ch;
1302
+ const end = mdFindClose(s, marker, i + 2, underscore);
1303
+ if (end > i + 1) { flush(); nodes.push(el("strong", {}, ...mdInline(s.slice(i + 2, end)))); i = end + 2; continue; }
1304
+ }
1305
+ }
1306
+ // Emphasis (* or _), same word-boundary guard for underscore.
1307
+ if (ch === "*" || ch === "_") {
1308
+ const underscore = ch === "_";
1309
+ if (!underscore || !mdIsWordChar(s[i - 1])) {
1310
+ const end = mdFindClose(s, ch, i + 1, underscore);
1311
+ if (end > i + 1) { flush(); nodes.push(el("em", {}, ...mdInline(s.slice(i + 1, end)))); i = end + 1; continue; }
1312
+ }
1313
+ }
1314
+ // Link [label](href): href sanitised; an unsafe scheme drops to the label text.
1315
+ if (ch === "[") {
1316
+ const close = s.indexOf("]", i + 1);
1317
+ if (close > i && s[close + 1] === "(") {
1318
+ const paren = s.indexOf(")", close + 2);
1319
+ if (paren > close) {
1320
+ const href = mdSafeHref(s.slice(close + 2, paren));
1321
+ const label = mdInline(s.slice(i + 1, close));
1322
+ flush();
1323
+ if (href) nodes.push(el("a", { href: href, target: "_blank", rel: "noopener noreferrer" }, ...label));
1324
+ else for (const l of label) nodes.push(l);
1325
+ i = paren + 1; continue;
1326
+ }
1327
+ }
1328
+ }
1329
+ buf += ch; i++;
1330
+ }
1331
+ flush();
1332
+ return nodes;
1333
+ }
1334
+ // Regexes that classify a block-opening line; shared by the block loop and the
1335
+ // paragraph terminator so the two can't disagree on where a paragraph ends.
1336
+ const MD_HEADING = /^(#{1,6})\s+(.*)$/;
1337
+ const MD_QUOTE = /^>\s?/;
1338
+ const MD_UL = /^[-*+]\s+/;
1339
+ const MD_OL = /^\d+[.)]\s+/;
1340
+ const MD_HR = /^([-*_])(?:\s*\1){2,}\s*$/;
1341
+ /** @param {string} t */
1342
+ function mdBlockStart(t) {
1343
+ return t.slice(0, 3) === MD_BACKTICK + MD_BACKTICK + MD_BACKTICK
1344
+ || MD_HEADING.test(t) || MD_QUOTE.test(t) || MD_UL.test(t) || MD_OL.test(t) || MD_HR.test(t);
1345
+ }
1346
+ // Parse a markdown string into an array of block-level DOM nodes.
1347
+ /** @param {any} src
1348
+ * @returns {HTMLElement[]}
1349
+ */
1350
+ function mdToNodes(src) {
1351
+ /** @type {HTMLElement[]} */
1352
+ const out = [];
1353
+ const fence = MD_BACKTICK + MD_BACKTICK + MD_BACKTICK;
1354
+ const lines = String(src == null ? "" : src).replace(/\r\n?/g, "\n").split("\n");
1355
+ let i = 0;
1356
+ while (i < lines.length) {
1357
+ const t = lines[i].trim();
1358
+ if (t === "") { i++; continue; }
1359
+ // Fenced code block — content is verbatim (no inline parsing).
1360
+ if (t.slice(0, 3) === fence) {
1361
+ const body = [];
1362
+ i++;
1363
+ while (i < lines.length && lines[i].trim().slice(0, 3) !== fence) { body.push(lines[i]); i++; }
1364
+ if (i < lines.length) i++; // consume the closing fence
1365
+ out.push(el("pre", { class: "pc-md-pre" }, el("code", {}, body.join("\n"))));
1366
+ continue;
1367
+ }
1368
+ // Thematic break (checked before lists so "***"/"---" isn't read as a bullet).
1369
+ if (MD_HR.test(t)) { out.push(el("hr", { class: "pc-md-hr" })); i++; continue; }
1370
+ // ATX heading.
1371
+ const h = MD_HEADING.exec(t);
1372
+ if (h) { out.push(el("h" + h[1].length, { class: "pc-md-h" }, ...mdInline(h[2].trim()))); i++; continue; }
1373
+ // Blockquote — strip one '>' per line and re-parse the inner block(s).
1374
+ if (MD_QUOTE.test(t)) {
1375
+ const inner = [];
1376
+ while (i < lines.length && MD_QUOTE.test(lines[i].trim())) { inner.push(lines[i].trim().replace(MD_QUOTE, "")); i++; }
1377
+ const bq = el("blockquote", { class: "pc-md-quote" });
1378
+ for (const b of mdToNodes(inner.join("\n"))) bq.append(b);
1379
+ out.push(bq); continue;
1380
+ }
1381
+ // Unordered list.
1382
+ if (MD_UL.test(t)) {
1383
+ const ul = el("ul", { class: "pc-md-list" });
1384
+ while (i < lines.length && MD_UL.test(lines[i].trim())) {
1385
+ ul.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_UL, ""))));
1386
+ i++;
1387
+ }
1388
+ out.push(ul); continue;
1389
+ }
1390
+ // Ordered list.
1391
+ if (MD_OL.test(t)) {
1392
+ const ol = el("ol", { class: "pc-md-list" });
1393
+ while (i < lines.length && MD_OL.test(lines[i].trim())) {
1394
+ ol.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_OL, ""))));
1395
+ i++;
1396
+ }
1397
+ out.push(ol); continue;
1398
+ }
1399
+ // Paragraph — soft-wrapped lines joined with a space until a blank line or the
1400
+ // next block opener.
1401
+ const para = [];
1402
+ while (i < lines.length) {
1403
+ const lt = lines[i].trim();
1404
+ if (lt === "" || mdBlockStart(lt)) break;
1405
+ para.push(lt); i++;
1406
+ }
1407
+ out.push(el("p", { class: "pc-md-p" }, ...mdInline(para.join(" "))));
1408
+ }
1409
+ return out;
1410
+ }
1411
+
1412
+ // A data-bound prose/markdown list (#274). Binds a datasource like dataGrid
1413
+ // (props.data = { source, table, orderBy, filter }) but renders each row as a
1414
+ // stacked prose block — a small header template (props.header, e.g.
1415
+ // "Round {{round}} · {{approved}}") over one body field (props.body) rendered as
1416
+ // sanitised markdown at a comfortable measure (props.measure ch, ~66 default).
1417
+ // This is the primitive for narrative records (plan-review findings, coordination
1418
+ // notes, escalation bodies) that read as a document, not a squeezed table cell.
1419
+ // It inherits collapsible/defaultCollapsed for free via makeCollapsible (which
1420
+ // unwraps the <h2> title), and refreshMs polling + pc:refresh like dataGrid.
1421
+ /** @param {any} node */
1422
+ function renderProse(node) {
1423
+ const p = node.props || {};
1424
+ const data = p.data || {};
1425
+ const card = el("section", { class: "pc-card" });
1426
+ if (p.title) card.append(el("h2", {}, p.title));
1427
+ const listEl = el("div", { class: "pc-prose-list" });
1428
+ card.append(listEl);
1429
+ const headerTpl = typeof p.header === "string" ? p.header : null;
1430
+ const bodyField = p.body;
1431
+ // Clamp the reading measure to a sane range so a malformed schema can't set a
1432
+ // 5000ch (full-bleed) or 2ch (one-word-per-line) body. ~66ch is the default.
1433
+ let measure = Number(p.measure);
1434
+ if (!Number.isFinite(measure)) measure = 66;
1435
+ measure = Math.max(40, Math.min(100, Math.round(measure)));
1436
+ /** @type {any[]} */
1437
+ const activeFilter = Array.isArray(data.filter) ? data.filter : [];
1438
+
1439
+ /** @param {Record<string, any>} row */
1440
+ function itemFor(row) {
1441
+ const item = el("article", { class: "pc-prose-item" });
1442
+ if (headerTpl) item.append(el("div", { class: "pc-prose-head" }, interpTemplate(headerTpl, row)));
1443
+ const bodyEl = el("div", { class: "pc-prose-body", style: "max-width:" + measure + "ch" });
1444
+ // Own-property gate (matching interpTemplate) so a body field name that
1445
+ // collides with a prototype key can't pick up prototype cruft.
1446
+ const raw = bodyField != null && Object.prototype.hasOwnProperty.call(row, bodyField) && row[bodyField] != null
1447
+ ? String(row[bodyField])
1448
+ : "";
1449
+ for (const b of mdToNodes(raw)) bodyEl.append(b);
1450
+ item.append(bodyEl);
1451
+ return item;
1452
+ }
1453
+
1454
+ async function refresh() {
1455
+ try {
1456
+ // A param-scoped list ("show the selected entity's records") with no route
1457
+ // param present renders nothing rather than a field=empty-string query.
1458
+ const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
1459
+ /** @type {{ rows: Array<Record<string, any>> }} */
1460
+ /** @type {{ rows: Array<Record<string, any>> }} */
1461
+ const { rows } = paramScoped && PARAM === ""
1462
+ ? { rows: [] }
1463
+ : await getJSON(dataUrl(data.source, data.table, activeFilter, data.orderBy));
1464
+ listEl.replaceChildren();
1465
+ if (!rows.length) { listEl.append(el("p", { class: "pc-prose-empty" }, p.empty || "No records")); return; }
1466
+ for (const row of rows) listEl.append(itemFor(row));
1467
+ } catch (e) {
1468
+ listEl.replaceChildren(el("p", { class: "pc-msg err" }, String((e && e.message) || e)));
1469
+ }
1470
+ }
1471
+ document.addEventListener("pc:refresh", refresh);
1472
+ disposers.push(() => document.removeEventListener("pc:refresh", refresh));
1473
+ if (p.refreshMs && p.refreshMs > 0) {
1474
+ const timer = setInterval(refresh, p.refreshMs);
1475
+ disposers.push(() => clearInterval(timer));
1476
+ }
1477
+ refresh();
1478
+ return card;
1479
+ }
1480
+
1481
+ /** @param {any} node */
1482
+ function renderDataGrid(node) {
1483
+ const p = node.props;
1484
+ const card = el("section", { class: "pc-card" });
1485
+ // Opt-in live row-count badge ("showCount"). Rendered inside the <h2> so a plain
1486
+ // (non-collapsible) grid surfaces it beside the title; makeCollapsible re-homes it
1487
+ // into the collapse header so it stays visible — and refresh-updated — while the
1488
+ // section is collapsed. refresh() below rewrites its text on every poll/pc:refresh.
1489
+ const countBadge = p.showCount ? el("span", { class: "pc-badge pc-count-badge", "aria-label": rowCountLabel(0) }, "0") : null;
1490
+ if (p.title || countBadge) {
1491
+ const h2 = el("h2", {}, p.title || "");
1492
+ if (countBadge) { if (p.title) h2.append(" "); h2.append(countBadge); }
1493
+ card.append(h2);
1494
+ }
1495
+ /** @type {any[]} */
1496
+ const cols = p.columns || [];
1497
+ // Mobile card roles per column (derived by convention, refined by
1498
+ // col.mobile.priority) so the grid can flip to a stacked card list on a phone
1499
+ // (#268). 'hasHidden' gates the per-row "More" toggle that reveals the columns
1500
+ // a mobile:{priority:"hidden"} hint drops off the card.
1501
+ const roles = classifyColumns(cols);
1502
+ const hasHidden = roles.indexOf("hidden") >= 0;
1503
+ /** @type {any[]} */
1504
+ const tabs = p.tabs || [];
1505
+ /** @type {any[]} */
1506
+ const rowActions = p.rowActions || [];
1507
+ const detail = p.detail || null;
1508
+ const hasExtra = rowActions.length > 0 || detail != null;
1509
+ /** @type {any[]} */
1510
+ let activeFilter = p.data.filter || [];
1511
+
1512
+ if (tabs.length) {
1513
+ const bar = el("div", { class: "pc-tabs" });
1514
+ activeFilter = tabs[0].filter || [];
1515
+ tabs.forEach((t, i) => {
1516
+ const b = el("button", { class: "pc-tab" + (i === 0 ? " active" : "") }, t.label);
1517
+ b.addEventListener("click", () => {
1518
+ activeFilter = t.filter || [];
1519
+ for (const c of bar.children) c.classList.remove("active");
1520
+ b.classList.add("active");
1521
+ refresh();
1522
+ });
1523
+ bar.append(b);
1524
+ });
1525
+ card.append(bar);
1526
+ }
1527
+
1528
+ const headCells = cols.map((c) => el("th", {}, c.header || c.field));
1529
+ if (hasExtra) headCells.push(el("th", {}, ""));
1530
+ const thead = el("thead", {}, el("tr", {}, ...headCells));
1531
+ const tbody = el("tbody", {});
1532
+ // A <colgroup> (present only when a column declares width/weight) sizes the
1533
+ // columns so table-layout:fixed stops splitting width equally; it must precede
1534
+ // <thead> in the table.
1535
+ const colgroup = buildColgroup(cols, hasExtra ? 1 : 0);
1536
+ const table = colgroup
1537
+ ? el("table", { class: "pc-grid" }, colgroup, thead, tbody)
1538
+ : el("table", { class: "pc-grid" }, thead, tbody);
1539
+ card.append(table);
1540
+ const span = String((cols.length || 1) + (hasExtra ? 1 : 0));
1541
+
1542
+ // Expansion state has to outlive the poll: refresh() rebuilds the whole tbody
1543
+ // every refreshMs, so without this an open detail row (where you answer an
1544
+ // escalation) would collapse on the next tick. We remember which rowKeys are
1545
+ // open, and reuse the already-built detail <tr> for them across refreshes so a
1546
+ // half-typed answer survives too. Keyed by p.rowKey; grids without one keep the
1547
+ // old (collapse-on-refresh) behavior.
1548
+ const expanded = new Set();
1549
+ const detailNodes = new Map();
1550
+ // A row only participates in expansion/detail-node caching if it has a real
1551
+ // (non-null) rowKey. Coercing a missing key through String() would yield the
1552
+ // literal "undefined"/"null" and collide unrelated rows, so treat it as keyless.
1553
+ /** @param {Record<string, any>} row
1554
+ * @returns {string|null}
1555
+ */
1556
+ const rowKeyOf = (row) => {
1557
+ if (!p.rowKey) return null;
1558
+ const v = row[p.rowKey];
1559
+ return v == null ? null : String(v);
1560
+ };
1561
+
1562
+ /** @param {any} action
1563
+ * @param {Record<string, any>} row
1564
+ */
1565
+ async function fireAction(action, row) {
1566
+ // Route-driven row action: POST the action's body template (default {})
1567
+ // to its route path, with {{row.KEY}} tokens resolved from this row.
1568
+ return runRoute(action, { row });
1569
+ }
1570
+
1571
+ /** @param {Record<string, any>} row
1572
+ * @param {any} ra
1573
+ * @returns {HTMLButtonElement|null}
1574
+ */
1575
+ function rowActionButton(row, ra) {
1576
+ if (ra.showWhenField && !row[ra.showWhenField]) return null;
1577
+ const b = el("button", { class: "pc-btn pc-btn-sm" }, ra.label);
1578
+ b.addEventListener("click", /** @param {MouseEvent} ev */ async (ev) => {
1579
+ ev.stopPropagation();
1580
+ // In-DOM confirm/error (openModal) instead of native confirm()/alert():
1581
+ // a sandboxed iframe without allow-modals makes native confirm() return
1582
+ // false and alert() a no-op, so every confirm-guarded action silently
1583
+ // dead-ended and its errors vanished (#276).
1584
+ if (ra.confirm && !(await confirmModal(ra.confirm))) return;
1585
+ b.disabled = true;
1586
+ try {
1587
+ await fireAction(ra.action, row);
1588
+ document.dispatchEvent(new CustomEvent("pc:refresh"));
1589
+ } catch (e) {
1590
+ b.disabled = false;
1591
+ alertModal(String(e.message || e));
1592
+ }
1593
+ });
1594
+ return b;
1595
+ }
1596
+
1597
+ /** @param {Record<string, any>} row
1598
+ * @returns {HTMLElement|null}
1599
+ */
1600
+ function detailForm(row) {
1601
+ const f = detail.form;
1602
+ if (!f || !row[f.showWhenField]) return null;
1603
+ const box = el("div", { class: "pc-subform" });
1604
+ if (f.title) box.append(el("div", { class: "pc-subform-title" }, f.title));
1605
+ if (f.promptField && row[f.promptField] != null) {
1606
+ box.append(el("div", { class: "pc-prompt" }, String(row[f.promptField])));
1607
+ }
1608
+ const input = el("textarea", { class: "pc-textarea", placeholder: f.inputLabel || f.inputKey });
1609
+ const msg = el("p", { class: "pc-msg" });
1610
+ const btn = el("button", { class: "pc-btn pc-btn-sm" }, f.submitLabel || "Submit");
1611
+ btn.addEventListener("click", async () => {
1612
+ btn.disabled = true; msg.className = "pc-msg"; msg.textContent = "Sending…";
1613
+ try {
1614
+ // The textarea value is the form's single field (f.inputKey); route + body
1615
+ // template come from f.action, e.g. { path: "/app/actions/message",
1616
+ // body: { name, correlationKey: "{{row.pr_key}}", variables: "{{form}}" } }.
1617
+ // Null-proto so an f.inputKey of "__proto__"/"constructor" can't mutate a prototype.
1618
+ const form = Object.create(null); form[f.inputKey] = input.value;
1619
+ await runRoute(f.action, { form, row });
1620
+ msg.className = "pc-msg ok"; msg.textContent = (f.action && f.action.successLabel) || "Sent";
1621
+ document.dispatchEvent(new CustomEvent("pc:refresh"));
1622
+ } catch (e) {
1623
+ btn.disabled = false; msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
1624
+ }
1625
+ });
1626
+ box.append(el("div", { class: "pc-field" }, input), btn, msg);
1627
+ return box;
1628
+ }
1629
+
1630
+ /** @param {any} cg
1631
+ * @param {Record<string, any>} row
1632
+ */
1633
+ async function childGrid(cg, row) {
1634
+ const wrap = el("div", { class: "pc-child" });
1635
+ if (cg.title) wrap.append(el("div", { class: "pc-child-title" }, cg.title));
1636
+ /** @type {any[]} */
1637
+ const ccols = cg.columns || [];
1638
+ const croles = classifyColumns(ccols);
1639
+ const chasHidden = croles.indexOf("hidden") >= 0;
1640
+ const cbody = el("tbody", {});
1641
+ const ccolgroup = buildColgroup(ccols, cg.lazyField ? 1 : 0);
1642
+ const cthead = el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
1643
+ ...(cg.lazyField ? [el("th", {}, "")] : [])));
1644
+ const ctable = ccolgroup
1645
+ ? el("table", { class: "pc-grid" }, ccolgroup, cthead, cbody)
1646
+ : el("table", { class: "pc-grid" }, cthead, cbody);
1647
+ wrap.append(ctable);
1648
+ /** @type {any} */
1649
+ var cspan;
1650
+ try {
1651
+ /** @type {{ rows: Array<Record<string, any>> }} */
1652
+ const { rows } = await getJSON(dataUrl(cg.source || "app", cg.table,
1653
+ [{ field: cg.childField, eq: row[cg.parentField] }], cg.orderBy));
1654
+ cspan = String((ccols.length || 1) + (cg.lazyField ? 1 : 0));
1655
+ if (!rows.length) {
1656
+ cbody.append(el("tr", {}, el("td", { colspan: cspan }, "None")));
1657
+ }
1658
+ for (const cr of rows) {
1659
+ const cells = ccols.map((c, i) => gridCell(c, cr, croles[i]));
1660
+ if (cg.lazyField) {
1661
+ const lf = cg.lazyField;
1662
+ const has = cr[lf.field] != null && String(cr[lf.field]).trim() !== "";
1663
+ const cell = el("td", {});
1664
+ if (has) {
1665
+ const toggle = el("button", { class: "pc-btn pc-btn-sm" }, lf.label || "Show");
1666
+ const pre = el("pre", { class: "pc-transcript", hidden: "" });
1667
+ pre.textContent = String(cr[lf.field]);
1668
+ toggle.addEventListener("click", () => {
1669
+ pre.hidden = !pre.hidden;
1670
+ toggle.textContent = pre.hidden ? (lf.label || "Show") : "Hide";
1671
+ });
1672
+ cell.append(toggle, pre);
1673
+ }
1674
+ cells.push(cell);
1675
+ }
1676
+ // Child grids classify columns into the same mobile roles (including
1677
+ // "hidden") as the top-level grid, so they need the same per-row "More"
1678
+ // toggle — without it a mobile:{priority:"hidden"} child column would be
1679
+ // permanently unreachable on a narrow viewport (#268).
1680
+ const ctr = el("tr", {}, ...cells);
1681
+ if (chasHidden) ctr.append(mobileMoreCell(ctr));
1682
+ cbody.append(ctr);
1683
+ }
1684
+ } catch (e) {
1685
+ cbody.append(el("tr", {}, el("td", { colspan: cspan }, String(e.message || e))));
1686
+ }
1687
+ return wrap;
1688
+ }
1689
+
1690
+ /** @param {Record<string, any>} row */
1691
+ function detailPanel(row) {
1692
+ const box = el("div", { class: "pc-detail" });
1693
+ if (detail.linkField && row[detail.linkField]) {
1694
+ const href = String(row[detail.linkField]);
1695
+ // Render as a link only for http(s); anything else (e.g. a javascript: URL
1696
+ // smuggled through row data) is shown as inert text. External links get
1697
+ // rel="noopener noreferrer" so the opened page can't reach window.opener.
1698
+ if (/^https?:\/\//i.test(href)) {
1699
+ box.append(el("a", { class: "pc-link", href, target: "_blank", rel: "noopener noreferrer" }, href));
1700
+ } else {
1701
+ box.append(el("span", { class: "pc-link" }, href));
1702
+ }
1703
+ }
1704
+ for (const df of detail.fields || []) {
1705
+ box.append(el("div", { class: "pc-detail-field" },
1706
+ el("span", { class: "pc-detail-label" }, df.label || df.field),
1707
+ el("span", {}, row[df.field] == null ? "" : String(row[df.field]))));
1708
+ }
1709
+ for (const cg of detail.children || []) {
1710
+ const holder = el("div", {});
1711
+ box.append(holder);
1712
+ childGrid(cg, row).then((w) => holder.replaceChildren(w));
1713
+ }
1714
+ const form = detailForm(row);
1715
+ if (form) box.append(form);
1716
+ return box;
1717
+ }
1718
+
1719
+ /** @param {Record<string, any>} row
1720
+ * @param {Array<{ tr: HTMLTableRowElement, isDetail: boolean, key: string|null }>|null} sink
1721
+ */
1722
+ function renderRow(row, sink) {
1723
+ const cells = cols.map((c, i) => gridCell(c, row, roles[i]));
1724
+ const key = rowKeyOf(row);
1725
+ let toggle = null;
1726
+ if (hasExtra) {
1727
+ const actionCell = el("td", { class: "pc-row-actions" });
1728
+ if (detail) {
1729
+ toggle = el("button", { class: "pc-btn pc-btn-sm pc-chevron" }, "▸");
1730
+ actionCell.append(toggle);
1731
+ }
1732
+ for (const ra of rowActions) {
1733
+ const b = rowActionButton(row, ra);
1734
+ if (b) actionCell.append(b);
1735
+ }
1736
+ cells.push(actionCell);
1737
+ }
1738
+ // Per-row "More" toggle: only when a mobile:{priority:"hidden"} hint drops
1739
+ // columns off the card. Appended after the <tr> is built (the toggle wires a
1740
+ // click against its own row) and shared with child grids via mobileMoreCell so
1741
+ // the reveal affordance and its aria state can't drift (#268).
1742
+ const tr = el("tr", {}, ...cells);
1743
+ if (hasHidden) tr.append(mobileMoreCell(tr));
1744
+ tbody.append(tr);
1745
+ // When grouping, the caller collects every <tr> this row produced (the data
1746
+ // row plus any detail row) so a group header can hide/show them as one unit.
1747
+ if (sink) sink.push({ tr, isDetail: false, key });
1748
+ if (detail && toggle) {
1749
+ const isOpen = key != null && expanded.has(key);
1750
+ // Reuse an already-built detail row for an open PR across refreshes so its
1751
+ // expanded state — and any half-typed escalation answer in the form — survives
1752
+ // the poll. A closed (or keyless) row always gets a fresh, lazily-built panel.
1753
+ let entry = key != null ? detailNodes.get(key) : null;
1754
+ if (!(isOpen && entry && entry.built)) {
1755
+ const dtr = el("tr", { hidden: isOpen ? null : "" }, el("td", { colspan: span }));
1756
+ entry = { dtr, built: false };
1757
+ if (key != null) detailNodes.set(key, entry);
1758
+ if (isOpen) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
1759
+ }
1760
+ const dtr = entry.dtr;
1761
+ toggle.textContent = dtr.hidden ? "▸" : "▾";
1762
+ toggle.addEventListener("click", /** @param {MouseEvent} ev */ (ev) => {
1763
+ ev.stopPropagation();
1764
+ const open = dtr.hidden;
1765
+ dtr.hidden = !open;
1766
+ toggle.textContent = open ? "▾" : "▸";
1767
+ if (open) {
1768
+ if (key != null) expanded.add(key);
1769
+ if (!entry.built) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }
1770
+ } else if (key != null) {
1771
+ expanded.delete(key);
1772
+ }
1773
+ });
1774
+ tbody.append(dtr);
1775
+ if (sink) sink.push({ tr: dtr, isDetail: true, key });
1776
+ }
1777
+ }
1778
+
1779
+ // Append the fetched rows into the tbody, either flat (default) or partitioned
1780
+ // into collapsible groups when the grid declares 'groupBy: "<field>"'. Each
1781
+ // group renders a clickable header row ("<Label> <value> (<count>)") that
1782
+ // collapses/expands its member rows as one unit; the collapsed state is
1783
+ // persisted (localStorage, keyed by page + node + group value) so it survives
1784
+ // both the refresh poll and a full reload — the same durability the top-level
1785
+ // 'collapsible' node wrapper gives. Group order is numeric-aware ascending
1786
+ // (so waves 1,2,10 sort naturally), falling back to lexical for non-numeric
1787
+ // keys. 'groupDefaultCollapsed' seeds each group collapsed until toggled.
1788
+ /** @param {Array<Record<string, any>>} rows */
1789
+ function appendRows(rows) {
1790
+ const groupBy = p.groupBy;
1791
+ // Treat a non-string groupBy as "no grouping": labelBase calls groupBy.charAt()
1792
+ // below, so a truthy non-string (number/object from a malformed page schema)
1793
+ // would throw at render time and break the whole page instead of one grid.
1794
+ if (!groupBy || typeof groupBy !== "string") { for (const row of rows) renderRow(row, null); return; }
1795
+ const order = [];
1796
+ const groups = new Map();
1797
+ for (const row of rows) {
1798
+ const gvRaw = row[groupBy];
1799
+ const gv = gvRaw == null ? "" : String(gvRaw);
1800
+ if (!groups.has(gv)) { groups.set(gv, []); order.push(gv); }
1801
+ groups.get(gv).push(row);
1802
+ }
1803
+ order.sort((a, b) => {
1804
+ const na = Number(a), nb = Number(b);
1805
+ if (a !== "" && b !== "" && !Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
1806
+ return a < b ? -1 : a > b ? 1 : 0;
1807
+ });
1808
+ const labelBase = p.groupLabel != null
1809
+ ? String(p.groupLabel)
1810
+ : groupBy.charAt(0).toUpperCase() + groupBy.slice(1);
1811
+ for (const gv of order) {
1812
+ const groupRows = groups.get(gv);
1813
+ const gkey = "pc:collapsed:" + CURRENT + ":" + (node.id || p.title || "grid") + ":g:" + gv;
1814
+ let gcollapsed = readCollapsed(gkey, !!p.groupDefaultCollapsed);
1815
+ const chevron = el("span", { class: "pc-chevron-inline" }, gcollapsed ? "▸" : "▾");
1816
+ const label = (gv === "" ? labelBase + " —" : labelBase + " " + gv) + " (" + groupRows.length + ")";
1817
+ const btn = el("button", { class: "pc-group-toggle", type: "button" },
1818
+ chevron, el("span", { class: "pc-group-title" }, label));
1819
+ tbody.append(el("tr", { class: "pc-group-header" }, el("td", { colspan: span }, btn)));
1820
+ /** @type {Array<{ tr: HTMLTableRowElement, isDetail: boolean, key: string|null }>} */
1821
+ const members = [];
1822
+ for (const row of groupRows) renderRow(row, members);
1823
+ const applyGroup = () => {
1824
+ chevron.textContent = gcollapsed ? "▸" : "▾";
1825
+ btn.setAttribute("aria-expanded", String(!gcollapsed));
1826
+ for (const m of members) {
1827
+ m.tr.hidden = gcollapsed
1828
+ ? true
1829
+ : (m.isDetail ? !(m.key != null && expanded.has(m.key)) : false);
1830
+ }
1831
+ };
1832
+ btn.addEventListener("click", () => {
1833
+ gcollapsed = !gcollapsed;
1834
+ writeCollapsed(gkey, gcollapsed);
1835
+ applyGroup();
1836
+ });
1837
+ applyGroup();
1838
+ }
1839
+ }
1840
+
1841
+ // Is the user actively typing inside this grid? tbody.replaceChildren() in refresh()
1842
+ // detaches the subtree, and detaching the node that holds document.activeElement blurs
1843
+ // it — so an open detail form (where you answer an escalation) loses focus every
1844
+ // refreshMs even though #103 preserves its half-typed value. Used to skip the poll.
1845
+ const editingInGrid = () => {
1846
+ const a = /** @type {any} */ (document.activeElement);
1847
+ if (!a || !tbody.contains(a)) return false;
1848
+ return a.tagName === "INPUT" || a.tagName === "TEXTAREA" || a.tagName === "SELECT" || a.isContentEditable;
1849
+ };
1850
+
1851
+ async function refresh() {
1852
+ try {
1853
+ // A grid scoped to a route param (any eqParam filter) means "show the rows
1854
+ // for the selected entity". With no param present, PARAM is empty and emitting
1855
+ // a where clause of field-colon-empty would read server-side as field equals
1856
+ // empty string and surface empty-valued rows. That is not the selected entity's
1857
+ // rows (there is no selection), so short-circuit to zero rows without a request.
1858
+ const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
1859
+ const { rows } = paramScoped && PARAM === ""
1860
+ ? { rows: [] }
1861
+ : await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
1862
+ // Live row-count badge: reflect the currently-fetched, filtered (active-tab)
1863
+ // set — exactly what the body renders — on every refresh (poll + pc:refresh),
1864
+ // whether the section is expanded or collapsed.
1865
+ if (countBadge) { countBadge.textContent = String(rows.length); countBadge.setAttribute("aria-label", rowCountLabel(rows.length)); }
1866
+ // Forget expansion / cached detail nodes for rows no longer present so the maps
1867
+ // don't grow without bound and a stale answer can't resurface on a key reuse.
1868
+ if (p.rowKey) {
1869
+ /** @type {Set<string>} */
1870
+ const live = new Set();
1871
+ for (const r of rows) { const k = rowKeyOf(r); if (k != null) live.add(k); }
1872
+ for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);
1873
+ for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);
1874
+ }
1875
+ // Capture focus + caret before the DOM swap. replaceChildren() detaches (and so blurs)
1876
+ // the reused detail node; re-appending it restores its value but not its focus. If the
1877
+ // very same node is re-attached (an open, still-present row), put the caret back.
1878
+ const active = /** @type {any} */ (document.activeElement);
1879
+ const keepFocus = !!active && tbody.contains(active);
1880
+ let selStart = null, selEnd = null, savedRange = null;
1881
+ if (keepFocus) {
1882
+ if (active.isContentEditable) {
1883
+ // contenteditable has no selectionStart — its caret lives in the document
1884
+ // selection. The reused node is re-attached, so the cloned range still points
1885
+ // at live containers and can be restored after focus.
1886
+ try { const s = document.getSelection(); if (s && s.rangeCount) savedRange = s.getRangeAt(0).cloneRange(); } catch (e) { /* selection unavailable */ }
1887
+ } else {
1888
+ try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch (e) { /* non-text field */ }
1889
+ }
1890
+ }
1891
+ // Capture scroll offsets of scrolled descendants before the swap. An open detail node is
1892
+ // reused (same element instance), but replaceChildren() detaches it, which resets the
1893
+ // scrollTop/scrollLeft of any inner scroll container (e.g. a scrolled-into .pc-transcript,
1894
+ // max-height + overflow:auto) back to 0. Only built (open) detail subtrees are re-attached
1895
+ // by the rebuild, so walk just those — not the whole grid — to keep this O(open panels)
1896
+ // rather than O(grid size). Restoration below skips any node that isn't re-attached.
1897
+ /** @type {Array<[any, number, number]>} */
1898
+ const scrollSaved = [];
1899
+ for (const entry of detailNodes.values()) {
1900
+ if (!entry.built) continue;
1901
+ for (const sc of entry.dtr.querySelectorAll("*")) {
1902
+ if (sc.scrollTop || sc.scrollLeft) scrollSaved.push([sc, sc.scrollTop, sc.scrollLeft]);
1903
+ }
1904
+ }
1905
+ tbody.replaceChildren();
1906
+ appendRows(rows);
1907
+ if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
1908
+ if (keepFocus && active.isConnected) {
1909
+ active.focus();
1910
+ if (savedRange) {
1911
+ try { const s = /** @type {Selection} */ (document.getSelection()); s.removeAllRanges(); s.addRange(savedRange); } catch (e) { /* selection unavailable */ }
1912
+ } else if (selStart != null && typeof active.setSelectionRange === "function") {
1913
+ try { active.setSelectionRange(selStart, selEnd); } catch (e) { /* non-text field */ }
1914
+ }
1915
+ }
1916
+ // Restore scroll depth on the reused nodes that survived the swap (still connected).
1917
+ for (const [sc, top, left] of scrollSaved) {
1918
+ if (sc.isConnected) { sc.scrollTop = top; sc.scrollLeft = left; }
1919
+ }
1920
+ } catch (e) {
1921
+ tbody.replaceChildren(el("tr", {}, el("td", { colspan: span }, String(e.message || e))));
1922
+ }
1923
+ }
1924
+ document.addEventListener("pc:refresh", refresh);
1925
+ disposers.push(() => document.removeEventListener("pc:refresh", refresh));
1926
+ // Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh
1927
+ // (e.g. after submitting an answer) still runs — so rows never shift under an in-progress
1928
+ // answer and the detail input keeps focus across ticks. The interval is registered as a
1929
+ // disposer so a page switch (hashchange → renderPage → teardown) stops it.
1930
+ if (p.refreshMs && p.refreshMs > 0) {
1931
+ const timer = setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);
1932
+ disposers.push(() => clearInterval(timer));
1933
+ }
1934
+ refresh();
1935
+ return card;
1936
+ }
1937
+
1938
+ // A navigation node — a horizontal menu bar (variant "bar", default) or a
1939
+ // vertical side rail (variant "rail"). Each item links either to another page
1940
+ // ({ label, page } -> an in-app #/<page> hash link, highlighted when it's the
1941
+ // current page) or to an external URL ({ label, href } -> a hardened new-tab
1942
+ // link; only http(s) is honoured). With items:"auto" (or omitted) the nav
1943
+ // lists every page from the /app/pages index, using each page's title.
1944
+ /** @param {any} item
1945
+ * @returns {HTMLAnchorElement|null}
1946
+ */
1947
+ function navLink(item) {
1948
+ const isPage = safePageId(item.page);
1949
+ const isExt = !isPage && typeof item.href === "string" && /^https?:\/\//i.test(item.href);
1950
+ if (!isPage && !isExt) return null;
1951
+ /** @type {Record<string, any>} */
1952
+ const attrs = { class: "pc-nav-link" };
1953
+ if (isPage) {
1954
+ attrs.href = "#/" + encodeURIComponent(item.page);
1955
+ if (item.page === CURRENT) { attrs.class += " active"; attrs["aria-current"] = "page"; }
1956
+ } else {
1957
+ attrs.href = item.href; attrs.target = "_blank"; attrs.rel = "noopener noreferrer";
1958
+ }
1959
+ /** @type {Array<Node|string>} */
1960
+ const kids = [];
1961
+ if (item.icon != null) kids.push(el("span", { class: "pc-nav-icon" }, String(item.icon)));
1962
+ kids.push(el("span", { class: "pc-nav-label" }, String(item.label != null ? item.label : (item.page || item.href))));
1963
+ return el("a", attrs, ...kids);
1964
+ }
1965
+ /** @param {HTMLElement} list
1966
+ * @param {any[]} items
1967
+ */
1968
+ function fillNav(list, items) {
1969
+ list.replaceChildren();
1970
+ let n = 0;
1971
+ for (const item of items || []) {
1972
+ if (!item || typeof item !== "object") continue;
1973
+ const a = navLink(item);
1974
+ if (a) { list.append(a); n++; }
1975
+ }
1976
+ if (!n) list.append(el("span", { class: "pc-nav-empty" }, "No pages"));
1977
+ }
1978
+ /** @param {any} node */
1979
+ function renderNav(node) {
1980
+ const p = node.props || {};
1981
+ const rail = p.variant === "rail";
1982
+ // A bar nav can opt into staying pinned to the top of the viewport while the
1983
+ // page scrolls (sticky) — useful on the long, multi-section operator pages so
1984
+ // the page switcher is always reachable. Rails are already full-height, so the
1985
+ // flag only applies to the bar variant.
1986
+ const barSticky = !rail && p.sticky === true;
1987
+ const nav = el("nav", {
1988
+ class: (rail ? "pc-nav pc-rail" : "pc-nav pc-bar") + (barSticky ? " pc-sticky" : ""),
1989
+ });
1990
+ if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
1991
+ const list = el("div", { class: "pc-nav-items" });
1992
+ nav.append(list);
1993
+ const items = p.items;
1994
+ if (Array.isArray(items)) {
1995
+ fillNav(list, items);
1996
+ } else {
1997
+ // "auto" (or unspecified): enumerate every page and link to each.
1998
+ getJSON("/app/pages")
1999
+ .then((res) => fillNav(list, (res.pages || []).map(/** @param {any} pg */ (pg) => ({ label: pg.title || pg.id, page: pg.id }))))
2000
+ .catch(() => list.append(el("span", { class: "pc-nav-empty" }, "No pages")));
2001
+ }
2002
+ return nav;
2003
+ }
2004
+
2005
+ // The browser renderer's dispatch table: the runtime source of truth for which
2006
+ // page-node types can be drawn. `@satisfies Record<PageNodeType, Renderer>` locks
2007
+ // it, at compile time, to `@nanobpm/nano-app-schema`'s PAGE_NODE_TYPES — the same
2008
+ // registry the Console's Page Composer is bound to. Adding a schema node type
2009
+ // without a renderer here (or vice-versa) is now a type error, so the table can
2010
+ // no longer silently drift from the schema (this replaces the #290 regex guard,
2011
+ // which had to string-scrape these keys precisely because this used to be a
2012
+ // string). The drift is also covered by a real-import unit test.
2013
+ /** @satisfies {Record<PageNodeType, Renderer>} */
2014
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, prose: renderProse, nav: renderNav, button: renderButton };
2015
+
2016
+ // Durable per-node UI state. localStorage is keyed by the home page id + node
2017
+ // id so two grids on the same page (or the same grid across pages) don't clash,
2018
+ // and the collapsed state survives a full reload / new session — not just the
2019
+ // refresh poll. Every access is guarded: private-mode or storage-disabled
2020
+ // browsers throw on localStorage, and a UI preference must never break render.
2021
+ /** @param {string} key
2022
+ * @param {boolean} dflt
2023
+ */
2024
+ function readCollapsed(key, dflt) {
2025
+ try {
2026
+ const v = localStorage.getItem(key);
2027
+ return v === null ? dflt : v === "1";
2028
+ } catch (e) {
2029
+ return dflt;
2030
+ }
2031
+ }
2032
+ /** @param {string} key
2033
+ * @param {boolean} val
2034
+ */
2035
+ function writeCollapsed(key, val) {
2036
+ try {
2037
+ localStorage.setItem(key, val ? "1" : "0");
2038
+ } catch (e) {
2039
+ // Preference is best-effort; ignore storage failures.
2040
+ }
2041
+ }
2042
+
2043
+ // Wrap a rendered node so its card can be collapsed from a clickable header.
2044
+ // Opt-in via the node's "collapsible" prop; "defaultCollapsed" seeds the state
2045
+ // the first time (before the user has toggled it). Generic across node types —
2046
+ // the body (everything the renderer produced, minus its own <h2> title, which
2047
+ // becomes the header label) is hidden as one unit.
2048
+ /** @param {any} node
2049
+ * @param {HTMLElement} card
2050
+ * @returns {HTMLElement}
2051
+ */
2052
+ function makeCollapsible(node, card) {
2053
+ const props = node.props || {};
2054
+ if (!props.collapsible) return card;
2055
+ // Normalize to a card container. Card renderers already return
2056
+ // <section class="pc-card"> — we unwrap those in place (lift out the <h2>
2057
+ // title, reparent the rest into the body). A non-card renderer (e.g. text
2058
+ // returning <p>/<h1>) is nested whole inside a fresh section instead:
2059
+ // appending a <button>/<div> directly into a <p> is invalid markup the
2060
+ // browser silently re-parents, breaking layout and click handling.
2061
+ const isCard = card.tagName === "SECTION";
2062
+ const container = isCard ? card : el("section", { class: "pc-card" });
2063
+ const h2 = isCard && card.querySelector ? card.querySelector("h2") : null;
2064
+ // A dataGrid with "showCount" renders a live count badge inside its <h2>. Lift it
2065
+ // out before deriving the title (so the count doesn't leak into the label text),
2066
+ // then re-home it in the header alongside the title so it stays visible — and
2067
+ // refresh-updated by the grid's own refresh() — while the section is collapsed.
2068
+ const countBadge = h2 && h2.querySelector ? h2.querySelector(".pc-count-badge") : null;
2069
+ if (countBadge) countBadge.remove();
2070
+ // Trim the derived label: after the badge is lifted out, the leftover " " whitespace
2071
+ // node from h2.append(" ", countBadge) would otherwise make a title-less section's
2072
+ // header a lone space (and leak whitespace into the storage key). Fall back to "Section".
2073
+ const titleText = props.title || (h2 ? h2.textContent.trim() : "") || "Section";
2074
+ if (h2) h2.remove();
2075
+ const body = el("div", { class: "pc-card-body" });
2076
+ if (isCard) {
2077
+ while (card.firstChild) body.append(card.firstChild);
2078
+ } else {
2079
+ body.append(card);
2080
+ }
2081
+ const chevron = el("span", { class: "pc-chevron-inline" }, "▾");
2082
+ const header = el(
2083
+ "button",
2084
+ { class: "pc-collapse-header", type: "button" },
2085
+ chevron,
2086
+ el("span", { class: "pc-collapse-title" }, titleText),
2087
+ );
2088
+ if (countBadge) header.append(countBadge);
2089
+ const storageKey = "pc:collapsed:" + CURRENT + ":" + (node.id || titleText);
2090
+ let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);
2091
+ function apply() {
2092
+ body.hidden = collapsed;
2093
+ chevron.textContent = collapsed ? "▸" : "▾";
2094
+ header.setAttribute("aria-expanded", String(!collapsed));
2095
+ }
2096
+ header.addEventListener("click", () => {
2097
+ collapsed = !collapsed;
2098
+ writeCollapsed(storageKey, collapsed);
2099
+ apply();
2100
+ });
2101
+ apply();
2102
+ container.append(header, body);
2103
+ return container;
2104
+ }
2105
+
2106
+ // Tier-2 escape hatch (#268): a page may declare a distinct 'mobile' layout
2107
+ // variant — its own node list — for the rare case the derived card flip isn't
2108
+ // enough (reorder/omit sections, swap a dense grid for a summary card). It is
2109
+ // used ONLY on a narrow viewport; the default 'nodes' render everywhere else,
2110
+ // and anything malformed falls back to them, so the variant is purely additive.
2111
+ /** @param {any} doc
2112
+ * @returns {any[]}
2113
+ */
2114
+ function pickNodes(doc) {
2115
+ if (isNarrow() && doc && doc.mobile && Array.isArray(doc.mobile.nodes)) return doc.mobile.nodes;
2116
+ return (doc && doc.nodes) || [];
2117
+ }
2118
+
2119
+ // Render the page named by the current hash route. A rail nav is lifted into a
2120
+ // left <aside> and the rest of the nodes flow into a content column; otherwise
2121
+ // (bar nav or none) nodes render in document order. Re-runs on every hashchange,
2122
+ // tearing down the previous page's grid polls first.
2123
+ async function renderPage() {
2124
+ teardown();
2125
+ const mySeq = ++renderSeq;
2126
+ const route = parseRoute();
2127
+ CURRENT = route.page;
2128
+ PARAM = route.param;
2129
+ try {
2130
+ const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
2131
+ if (mySeq !== renderSeq) return;
2132
+ document.title = (doc && doc.title) || "Urban App";
2133
+ const nodes = pickNodes(doc);
2134
+ const built = nodes.map((n) => ({ n, node: makeCollapsible(n, (/** @type {Record<string, Renderer>} */ (RENDERERS)[n.type] || (() => el("div")))(n)) }));
2135
+ const rail = built.find((b) => b.n.type === "nav" && b.n.props && b.n.props.variant === "rail");
2136
+ if (rail) {
2137
+ const col = el("div", { class: "pc-main-col" });
2138
+ for (const b of built) if (b !== rail) col.append(b.node);
2139
+ root.replaceChildren(el("div", { class: "pc-layout" }, el("aside", { class: "pc-rail-wrap" }, rail.node), col));
2140
+ } else {
2141
+ root.replaceChildren(...built.map((b) => b.node));
2142
+ }
2143
+ } catch (e) {
2144
+ if (mySeq !== renderSeq) return;
2145
+ root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String((e && e.message) || e)));
2146
+ }
2147
+ }
2148
+ // ── Bootstrap ─────────────────────────────────────────────────────────────
2149
+ // Every DOM/window side-effect is gathered here (not at module top level) so
2150
+ // importing this module — for a unit test or the string-generation build — is
2151
+ // inert. boot() runs only in a real browser document (see the guard below).
2152
+ function boot() {
2153
+ root = /** @type {HTMLElement} */ (document.getElementById("page"));
2154
+ HOME = root.dataset.home || "home";
2155
+ MOBILE_MQ = window.matchMedia("(max-width:__MOBILE_MAX_WIDTH__)");
2156
+ CURRENT = currentPage();
2157
+ PARAM = parseRoute().param;
2158
+ NANO_EMBEDDED = computeEmbedded();
2159
+ installThemeBridge();
2160
+ window.addEventListener("hashchange", renderPage);
2161
+ // Re-render when the viewport crosses the mobile breakpoint so a Tier-2 'mobile'
2162
+ // variant swaps in/out on rotation or a window resize (the grid card flip, nav
2163
+ // collapse and button stacking are pure CSS and need no re-render). teardown()
2164
+ // inside renderPage stops the outgoing page's grid polls first. Prefer the modern
2165
+ // addEventListener but fall back to the deprecated addListener so older Safari/iOS
2166
+ // (< 14), where MediaQueryList.addEventListener is unimplemented, still swaps the
2167
+ // Tier-2 variant on rotation/resize instead of silently failing to register.
2168
+ if (typeof MOBILE_MQ.addEventListener === "function") MOBILE_MQ.addEventListener("change", renderPage);
2169
+ else if (typeof MOBILE_MQ.addListener === "function") MOBILE_MQ.addListener(renderPage);
2170
+ renderPage();
2171
+ }
2172
+
2173
+ // Only boot in a real browser document (a served <main id="page"> exists). Under
2174
+ // Node/Deno import — for the unit tests or the string-generation build — this
2175
+ // guard is false, so the module loads with zero DOM side-effects and its
2176
+ // renderer functions / RENDERERS registry can be imported directly.
2177
+ if (typeof document !== "undefined" && document.getElementById("page")) boot();
2178
+
2179
+ export { RENDERERS, renderText, renderButton, renderProse, renderNav };