@nanobpm/urban 0.90.2 → 0.91.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.
|
@@ -2,4 +2,4 @@
|
|
|
2
2
|
// Source: src/runtime/browser/runtime.browser.js (the type-checked/linted/tested
|
|
3
3
|
// browser runtime). Regenerate with `npm run gen:runtime`; CI enforces this file
|
|
4
4
|
// is byte-identical to a fresh generation via `git diff --exit-code` (npm run check:runtime).
|
|
5
|
-
export const RENDERER_JS = "// @ts-check\n// ── Urban browser page runtime (authored source) ──────────────────────────\n// This is the real, type-checked / linted / unit-tested source for the browser\n// page renderer served at /app/runtime.js. It is NOT served from disk directly:\n// the checked-in string artifact `../core/modules/runtime.gen.ts` is *generated*\n// from this file by `scripts/gen-runtime.mjs` — the same \"generated derived\n// artifact + `git diff --exit-code` in CI\" pattern AGENTS.md uses elsewhere, so\n// a forgotten regeneration fails CI instead of shipping drift. The lone\n// `640px` placeholder is substituted from the shared MOBILE\n// breakpoint (the single source of truth) at generation time, so the JS\n// mode-switch and the shell CSS @media can never drift. Regenerate with\n// `npm run gen:runtime`.\n//\n// The module is import-safe under Node/Deno: it performs NO DOM access at load —\n// every bootstrap side-effect lives in boot(), gated on a real <main id=\"page\">.\n// That lets the renderer functions and the RENDERERS registry be imported and\n// unit-tested with a fake DOM, and lets the string-generation build load it.\n\n/** @typedef {import(\"../core/page-nodes.ts\").PageNodeType} PageNodeType */\n/**\n * A page node from a `*.page.json` document. `props` is an open, author-supplied\n * bag whose shape is owned/validated by `@nanobpm/nano-app-schema`, so it is\n * intentionally loose at this (already-validated) rendering boundary.\n * @typedef {{ type: string, id?: string, props?: Record<string, any> }} PageNode\n */\n/** A renderer turns one page node into a DOM element. @typedef {(node: PageNode) => HTMLElement} Renderer */\n/** Action-resolution context: the current form fields and (for grid rows) the row. @typedef {{ form?: Record<string, any>, row?: Record<string, any> }} ActionCtx */\n\n/** The page mount root (`<main id=\"page\">`). Assigned in boot(). @type {HTMLElement} */\nlet root;\n/** The home page id (from the mount root's `data-home`). @type {string} */\nlet HOME = \"home\";\n/** The single mobile-breakpoint media query. Assigned in boot(). @type {MediaQueryList} */\nlet MOBILE_MQ;\n/** True when this app runs inside the Nano console's same-origin iframe. Assigned in boot(). @type {boolean} */\nlet NANO_EMBEDDED = false;\n\n// Single mobile breakpoint, interpolated from the same MOBILE_MAX_WIDTH constant\n// as the shell CSS @media so the JS mode-switch and the pure-CSS card flip can\n// never drift on where \"narrow\" begins (#268). Only Tier-2 (an optional\n// page-level 'mobile' layout variant) needs this JS switch — the dataGrid card\n// flip, nav collapse and button/form stacking are all pure CSS + data-label.\nfunction isNarrow() { return MOBILE_MQ.matches; }\n\n// ── Multi-page routing (hash-based, reverse-proxy safe) ──────────────────\n// Pages are selected by the URL fragment (#/<page>) so navigation never hits\n// the server and works identically at the origin root and under the Nano\n// console's /console/app-view/<name>/ path-prefixed proxy (the hash is never\n// sent upstream). An empty/invalid hash falls back to the home page. Only a\n// safe id charset is accepted so the fragment can't smuggle a path/URL.\nconst PAGE_ID = /^[A-Za-z0-9_-]+$/;\n/** @param {any} value */\nfunction safePageId(value) {\n return typeof value === \"string\" && PAGE_ID.test(value);\n}\n// A route is #/<page> or #/<page>/<url-encoded-param>. We split on the FIRST\n// \"/\" *before* decoding, so a param that itself contains encoded slashes or\n// hashes (e.g. a plan_key \"owner/repo#123\" carried as \"owner%2Frepo%23123\")\n// survives intact instead of the inner \"%2F\" being decoded into a second\n// segment. The page segment is still restricted to the safe id charset; the\n// param is opaque free text (bound only as a whitelisted '?' SQL value, or\n// interpolated as DOM text — never as HTML/markup).\nfunction parseRoute() {\n const raw = (location.hash || \"\").replace(/^#\\/?/, \"\");\n const slash = raw.indexOf(\"/\");\n const pageRaw = slash >= 0 ? raw.slice(0, slash) : raw;\n const paramRaw = slash >= 0 ? raw.slice(slash + 1) : \"\";\n let page = HOME, param = \"\", pageOk = false;\n try { const p = decodeURIComponent(pageRaw); if (safePageId(p)) { page = p; pageOk = true; } } catch (e) { /* keep HOME */ }\n // Only carry the param when the page segment resolved to a real (safe-id) page.\n // A malformed/unknown page like #/not-a-page/x falls back to HOME and must NOT\n // inherit x, or that stray param would silently scope every eqParam grid and\n // {{param}} on the home page to an entity the user never selected.\n if (pageOk) { try { param = paramRaw ? decodeURIComponent(paramRaw) : \"\"; } catch (e) { param = \"\"; } }\n return { page, param };\n}\nfunction currentPage() { return parseRoute().page; }\nlet CURRENT = \"home\";\n// The current route param (the \"/<param>\" tail, or \"\" when absent). Exposed to a\n// page's datasource filters (a filter with { eqParam: true } binds its value to\n// this) and to {{param}} text interpolation, so one page template can scope\n// every section to a selected entity (e.g. an epic's plan_key).\nlet PARAM = \"\";\n// Monotonic render token. renderPage() is async and fires from both hashchange\n// and the matchMedia viewport-crossing handler, so two invocations can overlap\n// and their fetches resolve out of order. Each render captures the token it\n// bumped to; after its await it only touches the DOM if it is still the latest,\n// so a stale navigation/viewport render can't overwrite a newer one.\nlet renderSeq = 0;\n\n// Per-render teardown. Each dataGrid registers its poll interval + pc:refresh\n// listener here; navigating to another page runs every disposer before the new\n// page renders, so a switched-away grid stops polling (otherwise every visited\n// page would leave a live setInterval fetching forever).\n/** @type {any[]} */\nconst disposers = [];\nfunction teardown() {\n while (disposers.length) {\n try { disposers.pop()(); } catch (e) { /* best-effort cleanup */ }\n }\n}\n\n// Monotonic counter for per-modal element ids. aria-labelledby needs a unique\n// title id: a fixed id would clash if the page already contains that id, and\n// mis-associate the label for assistive tech.\nlet modalSeq = 0;\n// At most one modal is open at a time. A double-click (or two rapid button\n// clicks) would otherwise stack multiple overlays + document-level keydown\n// listeners; this guard keeps a single dialog + handler live.\nlet modalOpen = false;\n\n// Theme bridge for the Nano console embed. The app runs in a sandboxed,\n// same-origin iframe, so the console's --nano-* custom properties and its\n// data-appearance never cascade in. Instead the console postMessages its\n// resolved tokens; we lay them onto this document's :root and mirror the\n// appearance. Standalone (no console parent) nothing is posted, so the CSS\n// defaults stand. We announce readiness so the console can push the theme even\n// if its first message raced our listener install.\n/** @param {any} msg */\nfunction applyTheme(msg) {\n if (!msg || msg.type !== \"nano-theme\") return;\n const el = document.documentElement;\n if (msg.appearance === \"light\" || msg.appearance === \"dark\") el.dataset.appearance = msg.appearance;\n const vars = msg.vars;\n if (vars && typeof vars === \"object\") {\n for (const [k, v] of Object.entries(vars)) {\n // Only accept the shared token namespace; ignore anything else.\n if (typeof k === \"string\" && k.startsWith(\"--nano-\") && typeof v === \"string\") {\n el.style.setProperty(k, v);\n }\n }\n }\n}\n// True when this app runs inside the Nano console's same-origin iframe (vs a\n// standalone CLI run). Gates the theme bridge below AND in-host link navigation:\n// embedded, a processExplorer link routes the console in place; standalone, the\n// link keeps its native new-window behavior. The same-origin probe matters: a\n// cross-origin framer is also window.parent, but postMessage to our own origin\n// would never reach it and hostNavigate would still preventDefault the link —\n// so a cross-origin embed must fall through to the native _blank anchor. Reading\n// parent.location.origin throws under the same-origin policy for a cross-origin\n// parent, so the try/catch is the origin gate.\nfunction computeEmbedded() {\n if (!window.parent || window.parent === window) return false;\n try {\n return window.parent.location.origin === window.location.origin;\n } catch {\n return false;\n }\n}\n\n// Ask the framing console to navigate in-host. Returns true when a message was\n// posted (embedded) so the caller can suppress the native anchor; false\n// standalone so it falls through to the new-window <a>. The payload is\n// structured (target + params), never a raw href — the console constructs its\n// own route, so app/row data can't smuggle a path or scheme across the boundary.\n/** @param {string} target\n * @param {Record<string, any>} params\n */\nfunction hostNavigate(target, params) {\n if (!NANO_EMBEDDED) return false;\n window.parent.postMessage({ type: \"nano-navigate\", target: target, params: params }, window.location.origin);\n return true;\n}\n\nfunction installThemeBridge() {\n if (!NANO_EMBEDDED) return;\n window.addEventListener(\"message\", (ev) => {\n // Same-origin proxy (posture A): only trust a message from the framing\n // parent AND from our own origin. ev.source alone is insufficient — a\n // cross-origin page that frames a standalone run becomes window.parent, so\n // an origin check is what actually pins the trust boundary.\n if (ev.origin === window.location.origin && ev.source === window.parent) applyTheme(ev.data);\n });\n window.parent.postMessage({ type: \"nano-app-ready\" }, window.location.origin);\n}\n\n// ── App-View message relay (#518) ─────────────────────────────────────────\n// The App-View iframes this page currently mounts (renderAppView adds each one;\n// teardown removes it so a switched-away page's frames leave the relay set). The\n// bridge below uses this set as the trust membership: a message is only relayed\n// if ev.source is one of THESE frames' contentWindows (a nested App-View of this\n// page), never an arbitrary framed window.\n/** @type {Set<HTMLIFrameElement>} */\nconst APP_VIEW_FRAMES = new Set();\n\n// The relay primitive. A nested App-View posts to `window.parent` (this page),\n// but this page installs no bridge, so the message is dropped: it never reaches\n// the console host (forward-UP) nor the page's other App-Views (relay-ACROSS).\n// This forwards a whitelisted `nano-navigate` UP to the host (chaining the same\n// hostNavigate boundary the page's own links use, so a nested App-View can drive\n// the console explorer — Preview DI, instance deep-links) and relays any other\n// App-View message ACROSS to the page's sibling App-Views (e.g. Library \"Reuse\"\n// → Compose fill). Deliberately generic (forward-up + broadcast-across), not a\n// per-message special case, so the whole cross-frame failure class is closed at\n// once rather than patched button-by-button.\n//\n// Trust boundary: the message must be same-origin AND from a known App-View\n// iframe of THIS page. `ev.source` alone is insufficient — the origin pin plus\n// the membership check are what actually gate the boundary, so a message from a\n// non-App-View source or a cross-origin frame is never relayed.\n/**\n * @param {{ origin: string, source: any, data: any }} ev\n * @param {Iterable<{ contentWindow: any }>} frames\n * @param {{ origin: string, parent: any, embedded: boolean }} ctx\n */\nfunction relayAppViewMessage(ev, frames, ctx) {\n if (ev.origin !== ctx.origin) return;\n const list = Array.isArray(frames) ? frames : [...frames];\n let originFrame = null;\n for (const f of list) {\n if (f.contentWindow && f.contentWindow === ev.source) { originFrame = f; break; }\n }\n // Unknown source: not one of this page's App-Views — reject (trust boundary).\n if (!originFrame) return;\n const data = ev.data;\n if (data && data.type === \"nano-navigate\") {\n // Forward upward to the host/console, gated on the same same-origin embed\n // boundary as hostNavigate: standalone (no same-origin console parent) there\n // is nothing to forward to, so the message stays within the page.\n if (ctx.embedded && ctx.parent) ctx.parent.postMessage(data, ctx.origin);\n return;\n }\n // Relay across to the page's OTHER App-Views (never back to the originator).\n for (const f of list) {\n if (f === originFrame) continue;\n const w = f.contentWindow;\n if (w) w.postMessage(data, ctx.origin);\n }\n}\n\n// Install the App-View relay as a single page-level `message` listener. Unlike\n// the theme bridge this is NOT gated on NANO_EMBEDDED: the sibling relay-across\n// works within a standalone page too; only the forward-up leg is embed-gated\n// (via ctx.embedded in relayAppViewMessage).\nfunction installAppViewBridge() {\n window.addEventListener(\"message\", (ev) => {\n relayAppViewMessage(ev, APP_VIEW_FRAMES, {\n origin: window.location.origin,\n parent: window.parent,\n embedded: NANO_EMBEDDED,\n });\n });\n}\n\n// Resolve every app endpoint against where THIS module was actually served from,\n// so absolute-looking \"/app/…\" paths work both at the origin root (direct run,\n// e.g. the CLI on :3000) and under a path-prefixed reverse proxy (the Nano\n// console embeds the app at /console/app-view/<name>/). \"../\" drops the trailing\n// \"app/runtime.js\" to land on the app's mount root (always ends in \"/\").\nconst APP_BASE = new URL(\"../\", import.meta.url);\n/** @param {string} u */\nfunction apiUrl(u) {\n // Rebase root-absolute app paths (\"/app/…\") onto the mount root. Leave a\n // protocol-relative (\"//host/…\") or otherwise non-root-relative string alone —\n // only a single leading slash denotes an app path we own.\n return typeof u === \"string\" && u.startsWith(\"/\") && !u.startsWith(\"//\")\n ? new URL(u.slice(1), APP_BASE).toString()\n : u;\n}\n\n/** @param {string} url\n * @param {RequestInit=} opts\n * @returns {Promise<any>}\n */\nasync function getJSON(url, opts) {\n const r = await fetch(apiUrl(url), opts);\n const body = await r.json().catch(() => ({}));\n if (!r.ok) throw new Error(body.error || (\"HTTP \" + r.status));\n return body;\n}\n\n// ── route-driven actions ────────────────────────────────────────────────\n// Every page action is a single primitive: POST (or any method) the resolved\n// body to an app route path. There are NO bespoke action kinds — a form/row\n// reaches process start, message publish, cancel, an OpenAPI operation, a\n// webhook, or any app route uniformly by naming its path. Path + body may\n// interpolate the current form fields and (for grid rows/detail forms) the row\n// via {{form.KEY}} / {{row.KEY}} tokens; the whole-string tokens {{form}} and\n// {{row}} splice the object itself (type-preserving), so a body of\n// \"variables\": \"{{form}}\" sends the form object as the variable map.\n/** @param {string} path\n * @param {ActionCtx} ctx\n * @returns {any}\n */\nfunction lookupToken(path, ctx) {\n const segs = String(path).split(\".\");\n const head = segs[0];\n let base = head === \"form\" ? (ctx.form || {}) : head === \"row\" ? (ctx.row || {}) : undefined;\n for (let i = 1; i < segs.length; i++) base = base == null ? undefined : base[segs[i]];\n return base;\n}\n/** @param {any} node\n * @param {ActionCtx} ctx\n * @returns {any}\n */\nfunction resolveTemplate(node, ctx) {\n if (typeof node === \"string\") {\n const whole = node.match(/^\\{\\{\\s*([\\w.]+)\\s*\\}\\}$/);\n if (whole) return lookupToken(whole[1], ctx);\n return node.replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (_, k) => {\n const v = lookupToken(k, ctx);\n return v == null ? \"\" : String(v);\n });\n }\n if (Array.isArray(node)) return node.map((n) => resolveTemplate(n, ctx));\n if (node && typeof node === \"object\") {\n // Null-proto target so a template key like \"__proto__\" can't trigger prototype\n // mutation (prototype-pollution class) when copied into the resolved object.\n const out = Object.create(null);\n for (const [k, v] of Object.entries(node)) out[k] = resolveTemplate(v, ctx);\n return out;\n }\n return node;\n}\n/** @param {any} action\n * @param {ActionCtx} ctx\n * @returns {Promise<any>}\n */\nasync function runRoute(action, ctx) {\n if (!action || typeof action.path !== \"string\" || action.path === \"\") {\n throw new Error(\"This action has no route configured (action.path is missing or blank)\");\n }\n const path = /** @type {any} */ (action.path).replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, /** @param {string} _ @param {string} k */ (_, k) => {\n const v = lookupToken(k, ctx);\n return v == null ? \"\" : encodeURIComponent(String(v));\n });\n const method = String(action.method || \"POST\").toUpperCase();\n let body;\n if (action.body !== undefined) body = resolveTemplate(action.body, ctx);\n else if (ctx.form) body = { variables: ctx.form };\n else body = {};\n /** @type {RequestInit} */\n const opts = { method, headers: { \"content-type\": \"application/json\" } };\n if (method !== \"GET\" && method !== \"HEAD\") opts.body = JSON.stringify(body);\n return getJSON(path, opts);\n}\n\n/** @param {string} tag\n * @param {Record<string, any>} [attrs]\n * @param {...any} kids\n * @returns {any}\n */\nfunction el(tag, attrs = {}, ...kids) {\n const n = document.createElement(tag);\n for (const [k, v] of Object.entries(attrs)) {\n if (k === \"class\") n.className = v;\n else if (k.startsWith(\"on\") && typeof v === \"function\") n.addEventListener(k.slice(2), v);\n else if (v != null) n.setAttribute(k, v);\n }\n for (const kid of kids) n.append(kid);\n return n;\n}\n\n// Interpolate the {{param}} token in a schema-supplied string with the current\n// route param. The result is only ever set as DOM text (textContent), so the\n// substituted value cannot inject markup. Other tokens are left untouched.\n// A function replacement inserts PARAM literally; a plain string replacement would\n// treat replacement patterns inside PARAM (an id like the dollar-one token) as\n// special and render the wrong text.\n/** @param {any} s\n * @returns {any}\n */\nfunction interpParam(s) {\n return typeof s === \"string\" ? s.replace(/\\{\\{param\\}\\}/g, () => PARAM) : s;\n}\n\n// Interpolate {{field}} tokens in a column's per-cell template with values from\n// the row, coercing each to a string (null/undefined → \"\"). Like interpParam the\n// result is only ever set as DOM text (textContent via el()), so a substituted\n// row value cannot inject markup or attributes. A function replacement inserts\n// each value literally, so a \"$\"-bearing value can't be reinterpreted as a\n// replacement pattern. Unknown tokens (no such field) render empty rather than\n// throwing, matching interpParam. This is a dumb text formatter: no expressions,\n// arithmetic, or conditionals — just field splicing. Only the row's own\n// fields resolve (own-property gate, enumerable or not): an inherited\n// Object.prototype key (toString, constructor, …) counts as unknown and\n// renders \"\", so a token can't pick up prototype cruft instead of blank.\n/** @param {any} tpl\n * @param {Record<string, any>} row\n * @returns {any}\n */\nfunction interpTemplate(tpl, row) {\n return typeof tpl === \"string\"\n ? tpl.replace(/\\{\\{([^{}]+)\\}\\}/g, (_m, name) => {\n const key = name.trim();\n const v = Object.prototype.hasOwnProperty.call(row, key) ? row[key] : null;\n return v == null ? \"\" : String(v);\n })\n : tpl;\n}\n\n// Format a single-field cell value per a column's opt-in `col.format` (issue\n// #327). This runs in the browser, so it is the ONLY place that can render a\n// stored UTC ISO timestamp in the VIEWER'S local timezone — the server never\n// knows the viewer's zone, so a server-formatted string would always be wrong\n// for someone. Currently the sole format is \"datetime\": a parseable ISO/date\n// value → \"11:42am Aug 19\" (h:mm + lowercase am/pm, then short-month day), both\n// parts in local time. Defensive by construction: an empty value, an unparseable\n// date, or any unknown format returns the raw text verbatim — a mis-set format\n// never blanks a cell or throws, matching how an unrecognised link.kind degrades\n// to plain text. Display-only: the untransformed rawText still drives the badge\n// gate/tooltip and any sort/groupBy (those key off the real field).\n/** @param {any} format\n * @param {string} raw\n * @returns {string} */\nfunction fmtCellValue(format, raw) {\n if (format !== \"datetime\" || raw === \"\") return raw;\n const d = new Date(raw);\n if (Number.isNaN(d.getTime())) return raw;\n // Present in a stable en-US format (\"11:42am Aug 19\") regardless of the\n // viewer's locale ordering (e.g. en-GB would emit \"19 Aug\"), while the ABSENCE\n // of a `timeZone` option keeps both parts in the viewer's LOCAL zone. hour12\n // forces am/pm; the replace lowercases \"AM\"/\"a.m.\"/… to \"am\"/\"pm\" and drops the\n // separating space so the output is exactly \"11:42am\".\n const time = d\n .toLocaleTimeString(\"en-US\", { hour: \"numeric\", minute: \"2-digit\", hour12: true })\n .replace(/\\s*([AaPp])\\.?\\s*[Mm]\\.?$/, (_m, p) => p.toLowerCase() + \"m\");\n const date = d.toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" });\n return time + \" \" + date;\n}\n/** @param {any} node */\nfunction renderText(node) {\n const v = node.props.variant;\n const cls = v === \"heading\" ? \"pc-heading\" : v === \"sub\" ? \"pc-sub\" : \"pc-body\";\n return el(v === \"heading\" ? \"h1\" : \"p\", { class: cls }, interpParam(node.props.text) || \"\");\n}\n\n// Copy text to the clipboard, resiliently. The async Clipboard API is tried\n// first, but it can reject inside a sandboxed console iframe (no clipboard-write\n// permission) — so fall back to a hidden <textarea> + execCommand(\"copy\"), which\n// works under a user gesture with only allow-scripts. Returns whether a copy\n// succeeded so the caller can nudge the user to ⌘/Ctrl+C when both paths fail.\n/** @param {string} text\n * @returns {Promise<boolean>}\n */\nasync function copyToClipboard(text) {\n try {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n await navigator.clipboard.writeText(text);\n return true;\n }\n } catch (_e) { /* fall through to the execCommand path */ }\n const ta = document.createElement(\"textarea\");\n try {\n ta.value = text;\n ta.setAttribute(\"readonly\", \"\");\n ta.style.position = \"fixed\";\n ta.style.top = \"-1000px\";\n ta.style.opacity = \"0\";\n document.body.append(ta);\n ta.select();\n return document.execCommand(\"copy\");\n } catch (_e) { return false; }\n // Always remove the hidden textarea, even if select()/execCommand threw, so a\n // failed copy never leaves a detached node accreting in <body> on retry.\n finally { ta.remove(); }\n}\n\n// Rebase the sole {{appBase}} token in author-supplied copy text onto the app's\n// absolute mount root (always ends in \"/\"), so a prompt like\n// \"read {{appBase}}app/api/agent\" resolves to a URL an external agent can fetch —\n// the console proxy path when embedded, the app origin when standalone. No other\n// interpolation happens: copy text is otherwise emitted verbatim.\n/** @param {any} text */\nfunction resolveCopyText(text) {\n return typeof text === \"string\" ? text.split(\"{{appBase}}\").join(APP_BASE.toString()) : \"\";\n}\n\n// Open a lightweight modal appended to <body>. Closes on the ✕/Close button, a\n// backdrop click, Escape, or a page switch; the keydown listener is removed on\n// close so it never leaks, and close() is registered with teardown() so\n// navigating away (hashchange → renderPage → teardown) tears down any open modal\n// instead of leaving a stale overlay + document-level listener alive. When it\n// carries copyText it renders a scrollable code block plus a Copy button (see\n// copyToClipboard for the sandbox-safe fallback).\n/** @param {any} m\n * @returns {(() => void)|null}\n */\nfunction openModal(m) {\n // Only one modal at a time — drop a second (e.g. double-click) open so we\n // never stack overlays or register duplicate document-level keydown handlers.\n // Return null (not a close fn) so a guarded caller (confirmModal) can tell the\n // open was dropped and resolve rather than hang.\n if (modalOpen) return null;\n modalOpen = true;\n const overlay = el(\"div\", { class: \"pc-modal-overlay\" });\n // role=\"dialog\" + aria-modal need an accessible name: label by the visible\n // title when present, else fall back to a generic aria-label so screen readers\n // don't announce an unnamed dialog. The title id is unique per instance so it\n // can't collide with a pre-existing page id.\n const titleId = m.title ? \"pc-modal-title-\" + (++modalSeq) : null;\n const dialog = el(\"div\", titleId\n ? { class: \"pc-modal\", role: \"dialog\", \"aria-modal\": \"true\", \"aria-labelledby\": titleId }\n : { class: \"pc-modal\", role: \"dialog\", \"aria-modal\": \"true\", \"aria-label\": \"Dialog\" });\n if (m.title) dialog.append(el(\"h2\", { class: \"pc-modal-title\", id: titleId }, m.title));\n if (m.description) dialog.append(el(\"p\", { class: \"pc-modal-desc\" }, m.description));\n const copyText = resolveCopyText(m.copyText);\n if (copyText) dialog.append(el(\"pre\", { class: \"pc-modal-code\" }, copyText));\n const actions = el(\"div\", { class: \"pc-modal-actions\" });\n // Remember what had focus so we can restore it on close — a keyboard/screen\n // reader user returns to where they were instead of being dumped at <body>.\n const prevFocus = document.activeElement;\n // A dismissal (✕/Close button, backdrop, Escape, page switch) resolves with\n // m.dismissValue; an action button overrides it before closing. onResult fires\n // exactly once, from close(), so every exit path (button OR dismiss) reports a\n // result — this is what lets confirmModal() gate on a real user choice inside a\n // sandboxed iframe where native confirm() would silently return false (#276).\n const onResult = typeof m.onResult === \"function\" ? m.onResult : null;\n let resultValue = m.dismissValue;\n let closed = false;\n function close() {\n if (closed) return;\n closed = true;\n modalOpen = false;\n document.removeEventListener(\"keydown\", onKey);\n overlay.remove();\n // A manual close (button / Escape / backdrop) never pops the disposers\n // stack, so drop our own entry here — otherwise repeated open/close cycles\n // accrete dead close() closures until the next page navigation runs teardown.\n const i = disposers.indexOf(close);\n if (i !== -1) disposers.splice(i, 1);\n // A consumer-provided result callback must never abort teardown: if it throws,\n // swallow it so focus restoration below (and Escape/backdrop close) still runs.\n if (onResult) { try { onResult(resultValue); } catch (_e) {} }\n if (prevFocus instanceof HTMLElement && typeof prevFocus.focus === \"function\") prevFocus.focus();\n }\n // Focusable descendants of the dialog, in DOM order, for the Tab focus trap.\n /** @returns {any[]} */\n function focusables() {\n return Array.prototype.slice.call(dialog.querySelectorAll(\n 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n )).filter((n) => !n.disabled);\n }\n /** @param {KeyboardEvent} ev */\n function onKey(ev) {\n if (ev.key === \"Escape\") { close(); return; }\n if (ev.key !== \"Tab\") return;\n // Trap Tab within the dialog so focus can't slip behind the overlay onto the\n // inert page underneath — that would break aria-modal semantics.\n const f = focusables();\n if (!f.length) return;\n const first = f[0];\n const last = f[f.length - 1];\n if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }\n else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }\n }\n if (copyText) {\n const label = m.copyLabel || \"Copy\";\n const copyBtn = el(\"button\", { class: \"pc-btn pc-btn-sm\", type: \"button\" }, label);\n copyBtn.addEventListener(\"click\", async () => {\n const ok = await copyToClipboard(copyText);\n copyBtn.textContent = ok ? \"Copied ✓\" : \"Press ⌘/Ctrl+C\";\n setTimeout(() => { copyBtn.textContent = label; }, 2200);\n });\n actions.append(copyBtn);\n }\n const closeBtn = el(\"button\", { class: \"pc-btn pc-btn-sm pc-btn-ghost\", type: \"button\" }, m.closeLabel || \"Close\");\n closeBtn.addEventListener(\"click\", close);\n // Custom action buttons (m.actions) turn this into a decision dialog — each\n // sets the result then closes; the plain Close button is the default when none\n // are given. This is the in-DOM replacement for native confirm()/alert(): one\n // overlay + focus-trap + teardown machinery, no drift (AGENTS.md).\n let focusBtn = closeBtn;\n if (Array.isArray(m.actions) && m.actions.length) {\n let firstActionBtn = null;\n let defaultBtn = null;\n for (const a of m.actions) {\n const cls = \"pc-btn pc-btn-sm\" + (a.variant === \"ghost\" ? \" pc-btn-ghost\" : \"\");\n const ab = el(\"button\", { class: cls, type: \"button\" }, a.label);\n ab.addEventListener(\"click\", () => { resultValue = a.value; close(); });\n actions.append(ab);\n if (!firstActionBtn) firstActionBtn = ab;\n if (a.default) defaultBtn = ab;\n }\n // Default focus lands on the FIRST action (typically Cancel), never the last:\n // for confirmModal that keeps a stray Enter from accidentally triggering the\n // destructive Confirm. An action can opt into autofocus with default:true.\n focusBtn = defaultBtn || firstActionBtn;\n } else {\n actions.append(closeBtn);\n }\n dialog.append(actions);\n overlay.append(dialog);\n // A click on the backdrop (never the dialog itself) dismisses.\n overlay.addEventListener(\"click\", /** @param {MouseEvent} ev */ (ev) => { if (ev.target === overlay) close(); });\n document.addEventListener(\"keydown\", onKey);\n document.body.append(overlay);\n // A page switch runs teardown() before the next render — dispose the modal\n // there too so navigating away never leaves a stale overlay + keydown listener.\n disposers.push(close);\n focusBtn.focus();\n return close;\n}\n\n// In-DOM confirm — an accessible replacement for native confirm() that works in\n// a sandboxed iframe (without allow-modals native confirm() silently returns\n// false, dead-ending every confirm-guarded action; #276). Renders the message\n// with Cancel/Confirm and resolves the user's choice; a dismissal (Escape /\n// backdrop / ✕) resolves false. If a modal is already open the guarded open is\n// dropped — resolve false so the caller doesn't hang.\n/** @param {any} message\n * @returns {Promise<boolean>}\n */\nfunction confirmModal(message) {\n return new Promise((resolve) => {\n const opened = openModal({\n title: \"Confirm\",\n description: String(message),\n dismissValue: false,\n actions: [\n { label: \"Cancel\", value: false, variant: \"ghost\" },\n { label: \"Confirm\", value: true },\n ],\n /** @param {any} v */\n onResult: (v) => resolve(!!v),\n });\n if (!opened) resolve(false);\n });\n}\n\n// In-DOM error surface — replaces native alert(), which a sandboxed iframe also\n// suppresses, so action failures reported through it were invisible (#276).\n/** @param {any} message */\nfunction alertModal(message) {\n openModal({ title: \"Error\", description: String(message) });\n}\n\n// A standalone button node. Clicking it opens props.modal — used e.g. to surface\n// a copy-pasteable \"point your agent here\" prompt. props.variant === \"ghost\"\n// renders the muted outline style. A button without a modal is inert.\n/** @param {any} node */\nfunction renderButton(node) {\n const p = node.props || {};\n const btn = el(\n \"button\",\n { class: \"pc-btn\" + (p.variant === \"ghost\" ? \" pc-btn-ghost\" : \"\"), type: \"button\" },\n p.label || \"Open\",\n );\n const m = p.modal && typeof p.modal === \"object\" ? p.modal : null;\n if (m) btn.addEventListener(\"click\", () => openModal(m));\n return el(\"div\", { class: \"pc-buttonrow\" }, btn);\n}\n\n// A grid td cell with three column-declared rendering modes, checked in order:\n// 1. badge — a compact status indicator, gated on a non-empty field value\n// (see the detailed note at the check below).\n// 2. linkField — the cell text becomes a link to the URL held in that other\n// field. Only http(s) hrefs are linked (a javascript:/other-scheme URL\n// smuggled through row data falls back to plain text).\n// 3. link: { kind: \"processExplorer\", keyField } — a structured, engine-aware\n// link: the cell text links to the Nano console's explorer view for the\n// process instance whose key is held in the row's keyField. The console\n// path is constructed HERE (never taken from row data) and the key is\n// URL-encoded, so row data can't smuggle a path or scheme; only a\n// non-empty key (after trimming surrounding whitespace) produces a link.\n// Unknown link kinds fall back to plain text so an unrecognised schema\n// can't render a broken anchor.\n// The external linkField anchor always opens in a new tab (rel=noopener\n// noreferrer). The processExplorer anchor does too when the app runs\n// standalone, but when embedded in the console a plain click instead routes the\n// host's own explorer view in place via the nano-navigate postMessage bridge\n// (a modified/middle click keeps the new-tab behavior). Shared by the top-level\n// grid and child grids.\n//\n// Cell TEXT (the 'text' local below): the visible string a cell renders. By\n// default it is the column's single 'field' value, but an optional\n// 'col.template' string wins: its {{field}} tokens are spliced from the row\n// (interpTemplate) into DOM text. A template lets one cell combine several\n// fields / add surrounding text (e.g. \"{{current_wave}}/{{wave_count}}\")\n// without denormalising a display column into the datasource. The template\n// drives the rendered text for the plain, linkField and link\n// (page/processExplorer) paths — a link's href still comes from its own\n// keyField/linkField, never the template. The 'badge' presence gate and its\n// tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not\n// the field text), so a template does not change badge behaviour. Sorting,\n// groupBy and orderBy also key off real fields, never the rendered template.\n//\n// Two-line identity + one-line clamp (additive, opt-in, backward-compatible):\n// a column may also declare 'subtitleField'/'subtitleTemplate' to render a muted\n// second line under the primary text (the identity key beneath a title, GitHub/\n// Linear style) and 'truncate: true' to clamp each line to one row with an\n// ellipsis and a full-text title/aria-label tooltip (pairs with a per-column\n// width/weight — see buildColgroup — to give the clip a bounded column). The\n// subtitle is ALWAYS plain DOM text, never an href source. With none of these\n// set the cell is a single inline node exactly as before (see cellTd).\n// Build an in-app hash route to another page, scoped to a row key: #/<page>/<key>.\n// The page id is validated against the safe-id charset and the key must be\n// non-empty (after trimming); either failing yields \"\" (no link). The key is\n// URL-encoded into the route's param tail, which parseRoute() decodes back\n// verbatim, so row data can't smuggle a path or scheme. SINGLE source of truth\n// for the page-link href so the grid's link cell and the pipeline locus link\n// (issue #265) can't drift on how the route is built.\n/** @param {any} page\n * @param {any} key\n * @returns {string}\n */\nfunction pageHashHref(page, key) {\n const keyStr = key == null ? \"\" : String(key).trim();\n if (!safePageId(page) || keyStr === \"\") return \"\";\n return \"#/\" + encodeURIComponent(page) + \"/\" + encodeURIComponent(keyStr);\n}\n\n// SINGLE source of truth for a console process-explorer link. The grid link cell\n// (link kind \"processExplorer\") and the pipeline locus link (issue #347) both\n// route here so they can't drift on the href or the embedded-vs-standalone\n// navigation. The console path is constructed HERE (never taken from row data)\n// and the trimmed key URL-encoded, so row data can't smuggle a path/scheme.\n// Returns null for a blank key (after trimming) so the caller keeps the node\n// plain. Standalone the anchor opens a new tab (hardened rel); embedded in the\n// console a plain primary click instead routes the host's explorer in place via\n// the nano-navigate bridge (a non-primary/modified click keeps the new-tab\n// behavior).\n/** @param {any} label\n * @param {any} key\n * @param {string} cls\n * @returns {any}\n */\nfunction explorerAnchor(label, key, cls) {\n const keyStr = key == null ? \"\" : String(key).trim();\n if (keyStr === \"\") return null;\n const href = \"/console/explorer?instance=\" + encodeURIComponent(keyStr);\n return el(\"a\", {\n class: cls,\n href,\n target: \"_blank\",\n rel: \"noopener noreferrer\",\n /** @param {MouseEvent} ev */\n onclick: (ev) => {\n if (ev.button !== 0) return;\n if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;\n if (hostNavigate(\"processExplorer\", { instance: keyStr })) ev.preventDefault();\n },\n }, label);\n}\n\n// Coerce a not-in-path value (issue #265) into a Set of stage keys. Accepts an\n// array of keys or a comma/whitespace-separated string; null/undefined → empty\n// set. Used to mark the stages a given row skips (dashed/omitted).\n/** @param {any} v\n * @returns {Set<string>}\n */\nfunction toStageSet(v) {\n // Trim + drop-empties on BOTH inputs so array entries with stray whitespace\n // (e.g. from CSV/JSON munging) match stages[].key just like the string path.\n if (Array.isArray(v)) return new Set(v.map((s) => String(s).trim()).filter((s) => s !== \"\"));\n if (v == null) return new Set();\n return new Set(String(v).split(/[\\s,]+/).filter((s) => s !== \"\"));\n}\n\n/** @param {any} col\n * @param {Record<string, any>} row\n * @param {string} role\n * @returns {HTMLTableCellElement}\n */\nfunction gridCell(col, row, role) {\n // Mobile card attributes stamped on every td so a dataGrid can flip to a\n // stacked card list below the breakpoint with no JS branching (#268):\n // 'data-label' drives the pure-CSS label:value line (the column header, or a\n // mobile:{label} override), and the role class picks the card region (title /\n // status chip / secondary field / hidden). The role is derived by convention\n // (classifyColumns) and refinable per-column via col.mobile.priority.\n const mrole = role || \"secondary\";\n const mlabel =\n col.mobile && typeof col.mobile.label === \"string\"\n ? col.mobile.label\n : col.header != null\n ? String(col.header)\n : String(col.field == null ? \"\" : col.field);\n const mob = { class: \"pc-mcell pc-mcell-\" + mrole, \"data-label\": mlabel };\n // Raw single-field value: drives the badge presence gate + tooltip and any\n // field-derived defaults. Kept separate from the (possibly templated) display\n // text so a template never re-lights or blanks a badge.\n const rawText = row[col.field] == null ? \"\" : String(row[col.field]);\n // Per-column display format (issue #327): an opt-in `col.format` transforms the\n // single-field value for display only (e.g. \"datetime\" → the viewer's local\n // time). rawText above stays untransformed so the badge gate/tooltip and any\n // sort/groupBy still key off the real field. A col.template composes its own\n // {{field}} tokens and therefore wins over the formatted field text below.\n const fieldText = col.format ? fmtCellValue(col.format, rawText) : rawText;\n // Visible text: a col.template (interpolated from the row) wins over the\n // formatted/raw field value, else the field value is shown verbatim.\n const text = typeof col.template === \"string\" ? interpTemplate(col.template, row) : fieldText;\n // 1. badge — a compact status indicator. When the row's field value is\n // non-empty (truthy after trimming) render a small circular badge (e.g. a\n // red \"1\" dot flagging an incident) whose tooltip is the full field text;\n // when empty the cell stays blank so the column is unobtrusive until it\n // matters. tone (danger|warn|ok|info, default danger) picks the color and\n // label the glyph inside (default \"1\"). Label/tone come from the app\n // schema (trusted); the tooltip is row data set via title/textContent\n // (DOM-escaped by the platform, so no HTML/attribute injection). The full\n // field text is also mirrored into aria-label so assistive tech announces\n // the incident/status text (the visible \"1\" glyph alone is not meaningful).\n if (col.badge) {\n // Even when blank, stamp data-label (the \"every td carries data-label\"\n // invariant): the cell still collapses on mobile via the pc-mcell-chip:empty\n // rule (attributes don't defeat :empty), so this keeps empty and non-empty\n // badge cells consistent without re-lighting the label.\n if (rawText.trim() === \"\") return el(\"td\", { class: \"pc-mcell pc-mcell-chip\", \"data-label\": mlabel });\n const t = col.badge.tone;\n const tone = t === \"warn\" || t === \"ok\" || t === \"info\" ? t : \"danger\";\n const label = col.badge.label == null ? \"1\" : String(col.badge.label);\n return el(\n \"td\",\n mob,\n el(\"span\", { class: \"pc-badge pc-badge-\" + tone, title: rawText, \"aria-label\": rawText }, label),\n );\n }\n // Optional second line (col.subtitleField / subtitleTemplate): a muted key\n // under the primary title. subtitleTemplate wins (interpolated from the row);\n // else the raw subtitleField value. Absent/null → \"\" → no extra line. It is\n // ALWAYS plain DOM text (see cellTd), never a link/href source — a subtitle\n // cannot smuggle a scheme or path, only linkField/keyField drive hrefs.\n const subRaw =\n col.subtitleField != null && row[col.subtitleField] != null ? String(row[col.subtitleField]) : \"\";\n const subText = typeof col.subtitleTemplate === \"string\" ? interpTemplate(col.subtitleTemplate, row) : subRaw;\n const truncate = col.truncate === true;\n // pipeline (issue #265): a dataGrid column declaring kind:\"pipeline\" renders\n // the ordered stages as a per-row horizontal progress track (upstream-filled\n // → active-lit → downstream-ghosted, not-in-path dashed). It owns the whole\n // cell, so it returns here before the link chain below. Gated purely on the\n // discriminator so every existing column mode is untouched when it is absent;\n // unknown/missing pipeline config degrades to plain cell text (never throws),\n // exactly as an unrecognised link.kind falls back today.\n if (col.kind === \"pipeline\") return pipelineCell(col, row, text, subText, truncate, mob);\n // Primary inline content: an anchor for a valid link mode (checked in the same\n // badge < linkField < link(page) < link(processExplorer) precedence as before),\n // else the plain display text. Each link mode only claims the cell while\n // 'primary' is still the bare text — an invalid href falls through to the next\n // mode and ultimately to plain text, exactly as the early-return chain did.\n let primary = text;\n if (col.linkField) {\n const href = row[col.linkField] == null ? \"\" : String(row[col.linkField]);\n if (text !== \"\" && /^https?:\\/\\//i.test(href)) {\n primary = el(\"a\", { class: \"pc-link\", href, target: \"_blank\", rel: \"noopener noreferrer\" }, text);\n }\n }\n if (primary === text && col.link && col.link.kind === \"page\" && col.link.page && col.link.keyField) {\n // An in-app link to another page, scoped to this row: #/<page>/<key>. Built\n // via pageHashHref (the single source of truth for the route): the page id is\n // validated against the safe-id charset and the key must be non-empty, so a\n // blank/keyless cell stays plain text. Navigation is a pure hash change\n // (no new tab, no server hit) — the SPA router re-renders in place.\n const href = pageHashHref(col.link.page, row[col.link.keyField]);\n if (text !== \"\" && href !== \"\") {\n primary = el(\"a\", { class: \"pc-link\", href }, text);\n }\n }\n if (primary === text && col.link && col.link.kind === \"processExplorer\" && col.link.keyField) {\n // Route through the shared explorerAnchor builder (single source of truth) so\n // this cell and the pipeline locus link can't drift on the href or the\n // embedded-in-place / standalone-new-tab navigation. A blank key → null →\n // the cell stays plain text.\n const a = explorerAnchor(text, row[col.link.keyField], \"pc-link\");\n if (text !== \"\" && a) {\n primary = a;\n }\n }\n return cellTd(primary, text, subText, truncate, mob);\n}\n\n// Compose a grid <td> from its primary inline content (a string or an anchor),\n// an optional muted subtitle line, and optional one-line truncation. With\n// NEITHER a subtitle nor truncation the cell is exactly the single inline node\n// as before, so existing grids render byte-for-byte unchanged (backward\n// compatible). When either is set the content is wrapped in a .pc-cell-main\n// line and, if a subtitle is present, a .pc-cell-sub line beneath it. truncate\n// adds .pc-truncate (nowrap + ellipsis) to each line and mirrors the full text\n// into title=/aria-label so the clipped value is still recoverable on hover and\n// to assistive tech. When the primary is an element (a link cell's <a>), title=/\n// aria-label= are ALSO mirrored onto it: HTML does not inherit those to\n// descendants, so hovering the link text or naming the anchor for assistive tech\n// would otherwise miss the full value the wrapper carries. The subtitle is set as\n// DOM text only — never an href. 'tdAttrs' (the mobile card attrs: data-label +\n// role class, see gridCell) is applied to whichever <td> shape is returned so\n// the CSS-only card flip labels every cell identically (#268).\n/** @param {any} primary\n * @param {string} primaryText\n * @param {string} subText\n * @param {boolean} truncate\n * @param {Record<string, any>} tdAttrs\n * @returns {HTMLTableCellElement}\n */\nfunction cellTd(primary, primaryText, subText, truncate, tdAttrs) {\n const td = tdAttrs || {};\n if (subText === \"\" && !truncate) return el(\"td\", td, primary);\n /** @type {Record<string, any>} */\n const mainAttrs = { class: \"pc-cell-main\" + (truncate ? \" pc-truncate\" : \"\") };\n if (truncate && primaryText !== \"\") {\n mainAttrs.title = primaryText; mainAttrs[\"aria-label\"] = primaryText;\n if (primary && typeof primary.setAttribute === \"function\") {\n primary.setAttribute(\"title\", primaryText); primary.setAttribute(\"aria-label\", primaryText);\n }\n }\n const main = el(\"div\", mainAttrs, primary);\n if (subText === \"\") return el(\"td\", td, main);\n /** @type {Record<string, any>} */\n const subAttrs = { class: \"pc-cell-sub\" + (truncate ? \" pc-truncate\" : \"\") };\n if (truncate) { subAttrs.title = subText; subAttrs[\"aria-label\"] = subText; }\n return el(\"td\", td, main, el(\"div\", subAttrs, subText));\n}\n\n// Classify each grid column into a mobile-card region so a dataGrid flips to a\n// stacked card list below the breakpoint with ZERO page-schema change (#268).\n// Convention (Tier 0): the first non-badge column is the card title (primary), a\n// badge column is a status chip, and everything else is a secondary label:value\n// row. Tier 1 refines it — a column may carry mobile:{priority:\"primary\" |\n// \"secondary\" | \"hidden\"}: an explicit \"primary\" suppresses the auto title pick\n// so a later column can lead the card, \"hidden\" drops the column off the card\n// (behind the row's \"More\" toggle), and \"secondary\" forces a plain field row.\n/** @param {any[]} cols\n * @returns {string[]}\n */\nfunction classifyColumns(cols) {\n const list = cols || [];\n const explicitPrimary = list.some((c) => c && c.mobile && c.mobile.priority === \"primary\");\n let titleTaken = explicitPrimary;\n return list.map((col) => {\n const pr = col && col.mobile ? col.mobile.priority : null;\n if (pr === \"primary\") return \"primary\";\n if (pr === \"secondary\") return \"secondary\";\n if (pr === \"hidden\") return \"hidden\";\n if (col && col.badge) return \"chip\";\n if (!titleTaken) { titleTaken = true; return \"primary\"; }\n return \"secondary\";\n });\n}\n\n// Per-row mobile \"More\" toggle cell: reveals the mobile:{priority:\"hidden\"} columns\n// a narrow viewport drops off the card. The cell is base-hidden (display:none) so\n// the desktop table is byte-for-byte unchanged; clicking flips .pc-open on the row,\n// swaps the label, and updates aria-expanded so the reveal state is exposed to\n// assistive tech (#268). One implementation shared by the top-level grid and child\n// grids so the affordance — and its accessibility — can't drift between them.\n/** @param {HTMLTableRowElement} tr */\nfunction mobileMoreCell(tr) {\n const moreBtn = el(\"button\",\n { class: \"pc-btn pc-btn-sm pc-btn-ghost pc-more\", type: \"button\", \"aria-expanded\": \"false\" }, \"More\");\n moreBtn.addEventListener(\"click\", () => {\n const open = tr.classList.toggle(\"pc-open\");\n moreBtn.textContent = open ? \"Less\" : \"More\";\n moreBtn.setAttribute(\"aria-expanded\", String(open));\n });\n return el(\"td\", { class: \"pc-mcard-toggle\" }, moreBtn);\n}\n\n// Pipeline / stepper cell (issue #265) — a dataGrid column with kind:\"pipeline\"\n// renders a per-row, multi-stage horizontal progress track. It is RENDER-ONLY:\n// stage state is read straight from the row fields the app supplies; no\n// app-specific stage derivation lives here. Column config (all fields are the\n// NAMES of row fields, mirroring the issue sketch):\n// { kind:\"pipeline\",\n// stages:[{key,label},...], // ordered stage definitions (required)\n// activeField:\"stage\", // row field → the current stage's key\n// stateField:\"stage_state\", // row field → ok | active | failed | blocked\n// badgeField:\"attention\", // optional row field → badge text shown on the active stage\n// notInPathField:\"skipped\", // optional row field → stages this row skips\n// notInPath:[\"converging\"], // static fallback used only when no field given\n// locus:{ field:\"pr_key\", stage?:\"<key>\",\n// link:{ kind:\"page\", page:\"home\" } } } // optional out-link\n// Rendering: stages BEFORE the active one are filled/completed, the active stage\n// is lit (its treatment driven by stateField — success ✓ vs failure ✕ vs blocked\n// ⊘ are made unmistakably distinct), in-path stages AFTER it are ghosted, and any\n// stage in the row's not-in-path set is dashed. Reuses the .pc-badge and .pc-link\n// classes and the shared pageHashHref route builder — no parallel implementations. Unknown or\n// missing config (no stages array) degrades gracefully to plain cell text.\n/** @param {{ stages?: any[], [key: string]: any }} col\n * @param {Record<string, any>} row\n * @param {string} text\n * @param {string} subText\n * @param {boolean} truncate\n * @param {Record<string, any>} tdAttrs\n * @returns {HTMLTableCellElement}\n */\nfunction pipelineCell(col, row, text, subText, truncate, tdAttrs) {\n // Drop null/undefined entries defensively: a schema with a hole must still\n // render (never throws on s.key) — same graceful-degradation goal as below.\n /** @type {any[]} */\n const stages = Array.isArray(col.stages) ? col.stages.filter((s) => s != null) : [];\n // Graceful fallback: an unrecognised/empty pipeline config renders plain text,\n // exactly like an unknown link.kind — never throws.\n if (stages.length === 0) return cellTd(text, text, subText, truncate, tdAttrs);\n // The current stage's key, matched against stages[].key to find the active index.\n // The row value is trimmed for consistency with the other row-derived keys\n // (pageHashHref, toStageSet), so a whitespace-padded value still matches its stage\n // instead of leaving the whole track rendered as upcoming.\n // Stage keys identify a single stage, so stop at the first match: the result is\n // deterministic even if a config accidentally repeats a key, and we avoid scanning\n // the remaining stages on every row.\n const activeKey = col.activeField != null && row[col.activeField] != null ? String(row[col.activeField]).trim() : \"\";\n let activeIdx = -1;\n for (let i = 0; i < stages.length; i++) {\n if (String(stages[i].key) === activeKey) {\n activeIdx = i;\n break;\n }\n }\n // The active stage's state drives its lit/failed/blocked treatment. Anything\n // outside the allow-list (or absent) is treated as an in-progress \"active\".\n const stRaw = col.stateField != null && row[col.stateField] != null ? String(row[col.stateField]) : \"\";\n const state = stRaw === \"ok\" || stRaw === \"failed\" || stRaw === \"blocked\" ? stRaw : \"active\";\n // Not-in-path (skipped) stages: driven per-row from notInPathField when given,\n // else the static notInPath config (per the issue: \"per row, from a field\").\n const skip =\n col.notInPathField != null && row[col.notInPathField] != null\n ? toStageSet(row[col.notInPathField])\n : toStageSet(col.notInPath);\n // Optional active-stage badge (escalation/attention → warn, blocked/failure →\n // danger), reusing .pc-badge tone classes — no new badge styles. The badge text\n // is a single row-level value (row[badgeField]) shown only on the active stage.\n const badgeText = col.badgeField != null && row[col.badgeField] != null ? String(row[col.badgeField]) : \"\";\n const badgeTone = state === \"failed\" || state === \"blocked\" ? \"danger\" : \"warn\";\n // Optional locus out-link: the relevant stage (default the active one, or\n // locus.stage by key) becomes a link when the row's locus key is non-empty.\n // Two link kinds are supported, mirroring the grid link cell and built via the\n // SAME single-source builders so the two can't drift: `page` → an in-app\n // #/<page>/<key> hash route (pageHashHref, safePageId guard + encodeURIComponent);\n // `processExplorer` → the console explorer for the row's process instance\n // (explorerAnchor, embedded-in-place / standalone-new-tab). An unknown kind or\n // blank key yields no link, so the stage stays plain.\n const locus = col.locus;\n const locusKey = locus && locus.field != null && row[locus.field] != null ? String(row[locus.field]) : \"\";\n /** @param {any} label\n * @returns {any} an anchor node for the locus stage, or null (→ plain label). */\n const locusLabelNode = (label) => {\n if (!locus || !locus.link || locusKey === \"\") return null;\n if (locus.link.kind === \"page\" && locus.link.page) {\n const href = pageHashHref(locus.link.page, locusKey);\n return href === \"\" ? null : el(\"a\", { class: \"pc-link pc-pipe-label\", href }, label);\n }\n if (locus.link.kind === \"processExplorer\") {\n return explorerAnchor(label, locusKey, \"pc-link pc-pipe-label\");\n }\n return null;\n };\n const track = el(\"div\", {\n class: \"pc-pipe\",\n role: \"list\",\n \"aria-label\": \"Progress\" + (activeIdx >= 0 ? \": \" + labelOf(stages[activeIdx]) : \"\"),\n });\n for (let i = 0; i < stages.length; i++) {\n const s = stages[i];\n // Active wins over skip: if the active stage's key is also in the not-in-path\n // set, it must still render as the current step (active styling + aria-current),\n // never as skipped. Excluding activeIdx here keeps that precedence consistent\n // across the stage class, the aria-current mark, and the connector fill below.\n const skipped = i !== activeIdx && skip.has(String(s.key));\n // Connector between adjacent stages; filled up to and including the active\n // stage, but never across a not-in-path stage — a skipped predecessor breaks\n // the fill so we don't visually imply it was on the path.\n if (i > 0) {\n const filled =\n activeIdx >= 0 && i <= activeIdx && !skipped && !skip.has(String(stages[i - 1].key)) ? \" filled\" : \"\";\n track.append(el(\"span\", { class: \"pc-pipe-conn\" + filled, \"aria-hidden\": \"true\" }));\n }\n let cls;\n let word;\n let glyph = \"\";\n let current = false;\n if (skipped) {\n cls = \"pc-pipe-skip\"; word = \"skipped\";\n } else if (activeIdx >= 0 && i < activeIdx) {\n cls = \"pc-pipe-done\"; word = \"completed\";\n } else if (i === activeIdx) {\n current = true;\n if (state === \"failed\") { cls = \"pc-pipe-active pc-pipe-failed\"; word = \"failed\"; glyph = \"\\u2715\"; }\n else if (state === \"blocked\") { cls = \"pc-pipe-active pc-pipe-blocked\"; word = \"blocked\"; glyph = \"\\u2298\"; }\n else if (state === \"ok\") { cls = \"pc-pipe-active pc-pipe-ok\"; word = \"done\"; glyph = \"\\u2713\"; }\n else { cls = \"pc-pipe-active\"; word = \"current\"; }\n } else {\n cls = \"pc-pipe-upcoming\"; word = \"upcoming\";\n }\n const atLocus = locus != null && (locus.stage != null ? String(s.key) === String(locus.stage) : i === activeIdx);\n const label = labelOf(s);\n const locusNode = atLocus ? locusLabelNode(label) : null;\n const labelNode = locusNode != null ? locusNode : el(\"span\", { class: \"pc-pipe-label\" }, label);\n /** @type {Record<string, any>} */\n const attrs = { class: \"pc-pipe-stage \" + cls, role: \"listitem\", \"aria-label\": label + \" \\u2014 \" + word };\n if (current) attrs[\"aria-current\"] = \"step\";\n const stage = el(\"span\", attrs, labelNode);\n if (glyph !== \"\") stage.append(el(\"span\", { class: \"pc-pipe-mark\", \"aria-hidden\": \"true\" }, glyph));\n if (current && badgeText.trim() !== \"\") {\n stage.append(el(\"span\", { class: \"pc-badge pc-badge-\" + badgeTone, title: badgeText, \"aria-label\": badgeText }, badgeText));\n }\n track.append(stage);\n }\n // The mobile card attrs (data-label + role class, #268) stamped by gridCell are\n // merged onto the pipeline <td> too, alongside its own pc-pipe-cell class, so a\n // pipeline column labels and classifies identically under the CSS-only card flip.\n const cellAttrs = { ...(tdAttrs || {}), class: (tdAttrs && tdAttrs.class ? tdAttrs.class + \" \" : \"\") + \"pc-pipe-cell\" };\n return el(\"td\", cellAttrs, track);\n}\n\n// A stage's visible label: its declared label, else its key as a fallback.\n/** @param {any} stage */\nfunction labelOf(stage) {\n return stage.label == null ? String(stage.key) : String(stage.label);\n}\n\n// Parse a \"<number>%\" width into its numeric percentage, or null if the string\n// is not a bare percentage (e.g. \"22rem\", \"120px\") — used to decide whether the\n// remainder left for weighted columns can be computed.\n/** @param {string} width\n * @returns {number|null}\n */\nfunction colWidthPct(width) {\n const m = /^\\s*([0-9]*\\.?[0-9]+)%\\s*$/.exec(width);\n return m ? parseFloat(m[1]) : null;\n}\n\n// A column carries an explicit width only when it is a non-empty string. Single\n// source of truth so colWidthStyle/buildColgroup can't drift on what \"explicit\"\n// means (which columns win verbatim, consume the remainder, or use their weight).\n/** @param {any} col */\nfunction hasExplicitWidth(col) {\n return typeof col.width === \"string\" && col.width !== \"\";\n}\n\n// A column's <col> width for the grid's <colgroup>: an explicit col.width string\n// (\"40%\", \"22rem\") wins verbatim; else a positive numeric col.weight is\n// normalised across the weighted columns into a percentage share of the width\n// left over by explicit columns (remainderPct); else null (no width — the column\n// shares the remainder equally under table-layout:fixed).\n/** @param {any} col\n * @param {number} weightTotal\n * @param {number} remainderPct\n * @returns {string|null}\n */\nfunction colWidthStyle(col, weightTotal, remainderPct) {\n if (hasExplicitWidth(col)) return col.width;\n if (typeof col.weight === \"number\" && col.weight > 0 && weightTotal > 0) {\n return Math.round((col.weight / weightTotal) * remainderPct * 100) / 100 + \"%\";\n }\n return null;\n}\n\n// Build a <colgroup> that sizes the grid's columns so table-layout:fixed stops\n// splitting width equally. Returns null when no column declares width/weight, so\n// an unsized grid emits no colgroup and lays out exactly as today. 'extraCount'\n// adds trailing unsized <col>s for the row-actions/detail column so the sized\n// data columns stay aligned with their headers.\n/** @param {any[]} cols\n * @param {number} extraCount\n * @returns {HTMLTableColElement|null}\n */\nfunction buildColgroup(cols, extraCount) {\n const hasSizing = cols.some(\n (c) => hasExplicitWidth(c) || (typeof c.weight === \"number\" && c.weight > 0),\n );\n if (!hasSizing) return null;\n // Only columns that will actually use their weight (no explicit width — an\n // explicit width wins verbatim in colWidthStyle and never consumes weight)\n // contribute to weightTotal; counting a width-bearing column's weight here\n // would inflate the divisor and shrink the truly-weighted columns' share.\n let weightTotal = 0;\n for (const c of cols)\n if (!hasExplicitWidth(c) && typeof c.weight === \"number\" && c.weight > 0) weightTotal += c.weight;\n // Weighted columns share only the width the explicit columns leave behind, so\n // mixing e.g. width:\"40%\" with weighted columns can't oversubscribe past 100%.\n // The remainder is only computable when every explicit width is a percentage;\n // if any uses an absolute/relative unit (rem/px) we can't subtract it from\n // 100%, so weighted columns fall back to a share of the full width.\n let explicitPctTotal = 0;\n let allExplicitArePct = true;\n for (const c of cols) {\n if (hasExplicitWidth(c)) {\n const pct = colWidthPct(c.width);\n if (pct === null) allExplicitArePct = false;\n else explicitPctTotal += pct;\n }\n }\n const remainderPct = allExplicitArePct ? Math.max(0, 100 - explicitPctTotal) : 100;\n const colEls = cols.map((c) => {\n const w = colWidthStyle(c, weightTotal, remainderPct);\n return el(\"col\", w ? { style: \"width:\" + w } : {});\n });\n for (let i = 0; i < extraCount; i++) colEls.push(el(\"col\", {}));\n return el(\"colgroup\", {}, ...colEls);\n}\n\n/** @param {any} node */\nfunction renderActionForm(node) {\n /** @type {any} */\n const p = node.props;\n const card = el(\"section\", { class: \"pc-card\" });\n if (p.title) card.append(el(\"h2\", {}, p.title));\n // Map/null-proto stores so a schema field keyed __proto__/constructor can't\n // pollute Object.prototype or shadow inherited props (prototype-pollution class).\n /** @type {Map<string, HTMLInputElement>} */\n const inputs = new Map();\n /** @type {Map<string, string>} */\n const fieldTypes = new Map();\n // Client-side required-field validation state: the set of required keys, each\n // field's custom message, and its inline error node (populated on a blocked\n // submit, cleared as soon as the user edits the field).\n /** @type {Set<string>} */\n const required = new Set();\n /** @type {Map<string, string>} */\n const reqMsg = new Map();\n /** @type {Map<string, HTMLElement>} */\n const errs = new Map();\n // A required field's label carries a danger-toned \"*\" so the requirement is\n // visible BEFORE submit; spread into el(...) — an empty array adds no child.\n /** @param {any} f\n * @returns {HTMLElement[]}\n */\n const reqMark = (f) => (f.required === true ? [el(\"span\", { class: \"pc-req\", title: \"Required\" }, \" *\")] : []);\n // Register a field's inline error node and, when required, mark the input\n // (aria-required) and wire a clear-on-edit handler so a hint disappears the\n // moment the user starts fixing it. Editing a required field also clears the\n // blocked-submit summary (\"Please fill in the required fields.\") so it never\n // lingers as a stale/misleading banner while the user is correcting input.\n /** @param {any} f\n * @param {HTMLInputElement} input\n * @param {HTMLElement} ferr\n * @param {string} evt\n */\n const wireField = (f, input, ferr, evt) => {\n errs.set(f.key, ferr);\n if (f.required !== true) return;\n required.add(f.key);\n if (typeof f.requiredMessage === \"string\" && f.requiredMessage !== \"\") reqMsg.set(f.key, f.requiredMessage);\n input.setAttribute(\"aria-required\", \"true\");\n input.addEventListener(evt, () => {\n input.classList.remove(\"pc-invalid\");\n input.removeAttribute(\"aria-invalid\");\n ferr.textContent = \"\";\n msg.textContent = \"\";\n msg.className = \"pc-msg\";\n });\n };\n for (const f of p.fields || []) {\n const kind = f.type === \"checkbox\" ? \"checkbox\" : (f.type === \"number\" ? \"number\" : \"text\");\n fieldTypes.set(f.key, kind);\n const ferr = el(\"p\", { class: \"pc-field-err\" });\n if (kind === \"checkbox\") {\n // A boolean field renders a real checkbox; its default checked state comes\n // from default/checked on the field (unset -> unchecked). We record that on\n // the input's defaultChecked so a post-submit reset can restore it without a\n // side map that could drift from the actual input.\n const checked = f.default === true || f.checked === true;\n const input = el(\"input\", { type: \"checkbox\" });\n input.defaultChecked = checked;\n input.checked = checked;\n inputs.set(f.key, input);\n wireField(f, input, ferr, \"change\");\n // Input-first, wrapped in the label so the whole row is a click target.\n card.append(\n el(\"div\", { class: \"pc-field pc-field-check\" }, el(\"label\", {}, input, \" \" + (f.label || f.key), ...reqMark(f)), ferr),\n );\n continue;\n }\n /** @type {Record<string, any>} */\n const attrs = { type: kind, placeholder: f.label || f.key };\n if (kind === \"number\") {\n attrs.inputmode = \"numeric\";\n if (f.min != null) attrs.min = String(f.min);\n if (f.max != null) attrs.max = String(f.max);\n attrs.step = f.step != null ? String(f.step) : \"1\";\n }\n const input = el(\"input\", attrs);\n inputs.set(f.key, input);\n wireField(f, input, ferr, \"input\");\n card.append(el(\"div\", { class: \"pc-field\" }, el(\"label\", {}, f.label || f.key, ...reqMark(f)), input, ferr));\n }\n const msg = el(\"p\", { class: \"pc-msg\" });\n const btn = el(\"button\", { class: \"pc-btn\" }, p.submitLabel || \"Submit\");\n btn.addEventListener(\"click\", async () => {\n // Required-field gate: block the submit and surface an inline hint on each\n // empty required field (a text/number left blank, a required checkbox left\n // unchecked), focusing the first offender — so a missing value never\n // round-trips to the server only to bounce back as a generic error.\n let firstInvalid = null;\n for (const [k, input] of inputs) {\n const ferr = errs.get(k);\n if (ferr) ferr.textContent = \"\";\n input.classList.remove(\"pc-invalid\");\n input.removeAttribute(\"aria-invalid\");\n if (!required.has(k)) continue;\n const type = fieldTypes.get(k);\n const val = String(input.value).trim();\n const missing = type === \"checkbox\"\n ? input.checked !== true\n : type === \"number\"\n ? (val === \"\" || !Number.isFinite(Number(val)))\n : val === \"\";\n if (missing) {\n if (ferr) ferr.textContent = reqMsg.get(k) || \"Required\";\n input.classList.add(\"pc-invalid\");\n input.setAttribute(\"aria-invalid\", \"true\");\n if (!firstInvalid) firstInvalid = input;\n }\n }\n if (firstInvalid) {\n msg.className = \"pc-msg err\";\n msg.textContent = \"Please fill in the required fields.\";\n firstInvalid.focus();\n return;\n }\n const variables = Object.create(null);\n for (const [k, input] of inputs) {\n if (fieldTypes.get(k) === \"checkbox\") {\n // Always emit a real boolean (checked -> true / false), never a string,\n // so a strict equality check on the action side sees the intended value.\n variables[k] = input.checked === true;\n } else if (fieldTypes.get(k) === \"number\") {\n const t = String(input.value).trim();\n // Blank → omit so the action-side default applies; a non-finite parse\n // (NaN/Infinity) is also omitted rather than smuggling a raw string\n // process variable through, which would defeat the numeric coercion.\n if (t === \"\") continue;\n const num = Number(t);\n if (!Number.isFinite(num)) continue;\n variables[k] = num;\n } else {\n variables[k] = input.value;\n }\n }\n btn.disabled = true; msg.className = \"pc-msg\"; msg.textContent = \"Submitting…\";\n try {\n // Route-driven: POST the form (as { variables } by default, or the action's\n // own body template) to the action's route path. correlationKey / process /\n // message are expressed by the path + body template the page author supplies,\n // e.g. { path: \"/app/actions/message\", body: { name, correlationKey, variables: \"{{form}}\" } }.\n const res = await runRoute(p.action, { form: variables });\n msg.className = \"pc-msg ok\";\n msg.textContent = (p.action && p.action.successLabel) ||\n (res && res.processInstanceKey != null ? \"Started (instance \" + res.processInstanceKey + \")\" : \"Done\");\n for (const [k, input] of inputs) {\n // Text/number inputs clear; a checkbox resets to its declared default,\n // read straight off the input's defaultChecked (no side map to drift).\n if (fieldTypes.get(k) === \"checkbox\") input.checked = input.defaultChecked;\n else input.value = \"\";\n }\n document.dispatchEvent(new CustomEvent(\"pc:refresh\"));\n } catch (e) {\n msg.className = \"pc-msg err\"; msg.textContent = String(e.message || e);\n } finally { btn.disabled = false; }\n });\n card.append(btn, msg);\n return card;\n}\n\n// Accessible name for the row-count badge. The visible text is just a number, so an\n// aria-label would override it with a bare \"row count\" (the count — the important part —\n// is dropped). Instead name it \"N row(s)\" and keep it in sync with the text on refresh.\n// Single source of truth for both the initial render and every refresh below.\n/** @param {number} n */\nfunction rowCountLabel(n) { return n + (n === 1 ? \" row\" : \" rows\"); }\n\n// Build the /app/data/<source>/<table> URL a data-bound list renderer fetches,\n// threading the whitelisted where/order query params. Shared by dataGrid and the\n// prose renderer so the two can't drift on how a filter/orderBy is encoded. A\n// filter is { field, in:[…] } (IN set), { field, eqParam:true } (bound to the\n// current route PARAM — \"show the selected entity's rows\"), or { field, eq } (a\n// literal equals); orderBy is { field, dir }. The source/table/field names are\n// server-side whitelisted (the /app/data route rejects unknown columns).\n/** @param {string} source\n * @param {string} tbl\n * @param {any[]} filters\n * @param {any} order\n */\nfunction dataUrl(source, tbl, filters, order) {\n let u = \"/app/data/\" + encodeURIComponent(source) + \"/\" + encodeURIComponent(tbl);\n const qs = [];\n for (const f of filters || []) {\n if (Array.isArray(f.in)) qs.push(\"where=\" + encodeURIComponent(f.field + \":in:\" + f.in.join(\",\")));\n else if (f.eqParam) qs.push(\"where=\" + encodeURIComponent(f.field + \":\" + PARAM));\n else qs.push(\"where=\" + encodeURIComponent(f.field + \":\" + f.eq));\n }\n if (order && order.field) qs.push(\"order=\" + encodeURIComponent(order.field + \":\" + (order.dir || \"asc\")));\n return qs.length ? u + \"?\" + qs.join(\"&\") : u;\n}\n\n// ── Minimal, safe Markdown → DOM (no innerHTML) — used by the prose renderer ──\n// A dependency-free CommonMark subset. It NEVER sets innerHTML: every block and\n// inline token is materialised as a known element via el(), with text set through\n// textContent / createTextNode — so a record's body field may carry markdown\n// WITHOUT ever becoming an HTML/script injection vector (the same text-only\n// guarantee interpTemplate gives a grid cell). Supported: ATX headings\n// (#..######), fenced + inline code, bold (**/__), italic (*/_), links\n// [text](url) (http/https/mailto only — every other scheme degrades to its plain\n// label), unordered (-,*,+) and ordered (1. / 1)) lists, blockquotes (>),\n// thematic breaks (---), and blank-line-separated paragraphs. MD_BACKTICK is the\n// backtick that delimits code spans/fences.\nconst MD_BACKTICK = \"`\";\n/** @param {any} c */\nfunction mdIsWordChar(c) { return typeof c === \"string\" && /[0-9A-Za-z]/.test(c); }\n// Only http(s)/mailto hrefs become anchors; anything else (javascript:, data:,\n// vbscript:, a bare relative path, …) returns \"\" so the caller renders the label\n// as plain text rather than a clickable, potentially-hostile link.\n/** @param {any} href */\nfunction mdSafeHref(href) {\n const h = String(href == null ? \"\" : href).trim();\n return /^(?:https?:|mailto:)/i.test(h) ? h : \"\";\n}\n// Find the closing run of 'marker' at or after 'from'. For underscore emphasis the\n// close must not be intra-word (a following alphanumeric means snake_case, not a\n// delimiter), so keep scanning past such runs; -1 when there is no valid close.\n/** @param {string} s\n * @param {string} marker\n * @param {number} from\n * @param {boolean} underscore\n */\nfunction mdFindClose(s, marker, from, underscore) {\n let j = from;\n while (true) {\n const k = s.indexOf(marker, j);\n if (k < 0) return -1;\n if (!underscore || !mdIsWordChar(s[k + marker.length])) return k;\n j = k + marker.length;\n }\n}\n/** @param {any} text\n * @returns {Array<Node|string>}\n */\nfunction mdInline(text) {\n const s = String(text == null ? \"\" : text);\n /** @type {Array<Node|string>} */\n const nodes = [];\n let buf = \"\";\n const flush = () => { if (buf) { nodes.push(document.createTextNode(buf)); buf = \"\"; } };\n let i = 0;\n while (i < s.length) {\n const ch = s[i];\n // Backslash escape: the next char is emitted literally, never as a delimiter.\n if (ch === \"\\\\\" && i + 1 < s.length) { buf += s[i + 1]; i += 2; continue; }\n // Inline code span — verbatim, no nested inline parsing.\n if (ch === MD_BACKTICK) {\n const end = s.indexOf(MD_BACKTICK, i + 1);\n if (end > i) { flush(); nodes.push(el(\"code\", { class: \"pc-md-code\" }, s.slice(i + 1, end))); i = end + 1; continue; }\n }\n // Strong (** or __). Underscore only when it opens on a word boundary so an\n // identifier like a__b doesn't get mangled into emphasis.\n if ((ch === \"*\" || ch === \"_\") && s[i + 1] === ch) {\n const underscore = ch === \"_\";\n if (!underscore || !mdIsWordChar(s[i - 1])) {\n const marker = ch + ch;\n const end = mdFindClose(s, marker, i + 2, underscore);\n if (end > i + 1) { flush(); nodes.push(el(\"strong\", {}, ...mdInline(s.slice(i + 2, end)))); i = end + 2; continue; }\n }\n }\n // Emphasis (* or _), same word-boundary guard for underscore.\n if (ch === \"*\" || ch === \"_\") {\n const underscore = ch === \"_\";\n if (!underscore || !mdIsWordChar(s[i - 1])) {\n const end = mdFindClose(s, ch, i + 1, underscore);\n if (end > i + 1) { flush(); nodes.push(el(\"em\", {}, ...mdInline(s.slice(i + 1, end)))); i = end + 1; continue; }\n }\n }\n // Link [label](href): href sanitised; an unsafe scheme drops to the label text.\n if (ch === \"[\") {\n const close = s.indexOf(\"]\", i + 1);\n if (close > i && s[close + 1] === \"(\") {\n const paren = s.indexOf(\")\", close + 2);\n if (paren > close) {\n const href = mdSafeHref(s.slice(close + 2, paren));\n const label = mdInline(s.slice(i + 1, close));\n flush();\n if (href) nodes.push(el(\"a\", { href: href, target: \"_blank\", rel: \"noopener noreferrer\" }, ...label));\n else for (const l of label) nodes.push(l);\n i = paren + 1; continue;\n }\n }\n }\n buf += ch; i++;\n }\n flush();\n return nodes;\n}\n// Regexes that classify a block-opening line; shared by the block loop and the\n// paragraph terminator so the two can't disagree on where a paragraph ends.\nconst MD_HEADING = /^(#{1,6})\\s+(.*)$/;\nconst MD_QUOTE = /^>\\s?/;\nconst MD_UL = /^[-*+]\\s+/;\nconst MD_OL = /^\\d+[.)]\\s+/;\nconst MD_HR = /^([-*_])(?:\\s*\\1){2,}\\s*$/;\n/** @param {string} t */\nfunction mdBlockStart(t) {\n return t.slice(0, 3) === MD_BACKTICK + MD_BACKTICK + MD_BACKTICK\n || MD_HEADING.test(t) || MD_QUOTE.test(t) || MD_UL.test(t) || MD_OL.test(t) || MD_HR.test(t);\n}\n// Parse a markdown string into an array of block-level DOM nodes.\n/** @param {any} src\n * @returns {HTMLElement[]}\n */\nfunction mdToNodes(src) {\n /** @type {HTMLElement[]} */\n const out = [];\n const fence = MD_BACKTICK + MD_BACKTICK + MD_BACKTICK;\n const lines = String(src == null ? \"\" : src).replace(/\\r\\n?/g, \"\\n\").split(\"\\n\");\n let i = 0;\n while (i < lines.length) {\n const t = lines[i].trim();\n if (t === \"\") { i++; continue; }\n // Fenced code block — content is verbatim (no inline parsing).\n if (t.slice(0, 3) === fence) {\n const body = [];\n i++;\n while (i < lines.length && lines[i].trim().slice(0, 3) !== fence) { body.push(lines[i]); i++; }\n if (i < lines.length) i++; // consume the closing fence\n out.push(el(\"pre\", { class: \"pc-md-pre\" }, el(\"code\", {}, body.join(\"\\n\"))));\n continue;\n }\n // Thematic break (checked before lists so \"***\"/\"---\" isn't read as a bullet).\n if (MD_HR.test(t)) { out.push(el(\"hr\", { class: \"pc-md-hr\" })); i++; continue; }\n // ATX heading.\n const h = MD_HEADING.exec(t);\n if (h) { out.push(el(\"h\" + h[1].length, { class: \"pc-md-h\" }, ...mdInline(h[2].trim()))); i++; continue; }\n // Blockquote — strip one '>' per line and re-parse the inner block(s).\n if (MD_QUOTE.test(t)) {\n const inner = [];\n while (i < lines.length && MD_QUOTE.test(lines[i].trim())) { inner.push(lines[i].trim().replace(MD_QUOTE, \"\")); i++; }\n const bq = el(\"blockquote\", { class: \"pc-md-quote\" });\n for (const b of mdToNodes(inner.join(\"\\n\"))) bq.append(b);\n out.push(bq); continue;\n }\n // Unordered list.\n if (MD_UL.test(t)) {\n const ul = el(\"ul\", { class: \"pc-md-list\" });\n while (i < lines.length && MD_UL.test(lines[i].trim())) {\n ul.append(el(\"li\", {}, ...mdInline(lines[i].trim().replace(MD_UL, \"\"))));\n i++;\n }\n out.push(ul); continue;\n }\n // Ordered list.\n if (MD_OL.test(t)) {\n const ol = el(\"ol\", { class: \"pc-md-list\" });\n while (i < lines.length && MD_OL.test(lines[i].trim())) {\n ol.append(el(\"li\", {}, ...mdInline(lines[i].trim().replace(MD_OL, \"\"))));\n i++;\n }\n out.push(ol); continue;\n }\n // Paragraph — soft-wrapped lines joined with a space until a blank line or the\n // next block opener.\n const para = [];\n while (i < lines.length) {\n const lt = lines[i].trim();\n if (lt === \"\" || mdBlockStart(lt)) break;\n para.push(lt); i++;\n }\n out.push(el(\"p\", { class: \"pc-md-p\" }, ...mdInline(para.join(\" \"))));\n }\n return out;\n}\n\n// A data-bound prose/markdown list (#274). Binds a datasource like dataGrid\n// (props.data = { source, table, orderBy, filter }) but renders each row as a\n// stacked prose block — a small header template (props.header, e.g.\n// \"Round {{round}} · {{approved}}\") over one body field (props.body) rendered as\n// sanitised markdown at a comfortable measure (props.measure ch, ~66 default).\n// This is the primitive for narrative records (plan-review findings, coordination\n// notes, escalation bodies) that read as a document, not a squeezed table cell.\n// It inherits collapsible/defaultCollapsed for free via makeCollapsible (which\n// unwraps the <h2> title), and refreshMs polling + pc:refresh like dataGrid.\n/** @param {any} node */\nfunction renderProse(node) {\n const p = node.props || {};\n const data = p.data || {};\n const card = el(\"section\", { class: \"pc-card\" });\n if (p.title) card.append(el(\"h2\", {}, p.title));\n const listEl = el(\"div\", { class: \"pc-prose-list\" });\n card.append(listEl);\n const headerTpl = typeof p.header === \"string\" ? p.header : null;\n const bodyField = p.body;\n // Clamp the reading measure to a sane range so a malformed schema can't set a\n // 5000ch (full-bleed) or 2ch (one-word-per-line) body. ~66ch is the default.\n let measure = Number(p.measure);\n if (!Number.isFinite(measure)) measure = 66;\n measure = Math.max(40, Math.min(100, Math.round(measure)));\n /** @type {any[]} */\n const activeFilter = Array.isArray(data.filter) ? data.filter : [];\n\n /** @param {Record<string, any>} row */\n function itemFor(row) {\n const item = el(\"article\", { class: \"pc-prose-item\" });\n if (headerTpl) item.append(el(\"div\", { class: \"pc-prose-head\" }, interpTemplate(headerTpl, row)));\n const bodyEl = el(\"div\", { class: \"pc-prose-body\", style: \"max-width:\" + measure + \"ch\" });\n // Own-property gate (matching interpTemplate) so a body field name that\n // collides with a prototype key can't pick up prototype cruft.\n const raw = bodyField != null && Object.prototype.hasOwnProperty.call(row, bodyField) && row[bodyField] != null\n ? String(row[bodyField])\n : \"\";\n for (const b of mdToNodes(raw)) bodyEl.append(b);\n item.append(bodyEl);\n return item;\n }\n\n async function refresh() {\n try {\n // A param-scoped list (\"show the selected entity's records\") with no route\n // param present renders nothing rather than a field=empty-string query.\n const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);\n /** @type {{ rows: Array<Record<string, any>> }} */\n /** @type {{ rows: Array<Record<string, any>> }} */\n const { rows } = paramScoped && PARAM === \"\"\n ? { rows: [] }\n : await getJSON(dataUrl(data.source, data.table, activeFilter, data.orderBy));\n listEl.replaceChildren();\n if (!rows.length) { listEl.append(el(\"p\", { class: \"pc-prose-empty\" }, p.empty || \"No records\")); return; }\n for (const row of rows) listEl.append(itemFor(row));\n } catch (e) {\n listEl.replaceChildren(el(\"p\", { class: \"pc-msg err\" }, String((e && e.message) || e)));\n }\n }\n document.addEventListener(\"pc:refresh\", refresh);\n disposers.push(() => document.removeEventListener(\"pc:refresh\", refresh));\n if (p.refreshMs && p.refreshMs > 0) {\n const timer = setInterval(refresh, p.refreshMs);\n disposers.push(() => clearInterval(timer));\n }\n refresh();\n return card;\n}\n\n// Build a detail/escalation answer form for a single grid row. This is the ONE\n// implementation of the `detail.form` contract — shared verbatim by the\n// top-level grid's detail panel AND a dataGrid child-grid row (#333) so the two\n// can never drift. The form is SCHEMA-DERIVED (#372): its fields come from the\n// element's `.form` contract, not hand-rolled markup. A form may declare either a\n// single free-text field (legacy: f.inputKey, rendered exactly as before) or an\n// array of field descriptors (f.fields) — each a `select` (enumerated\n// value/label options), `textarea`, or `text` widget bound to its own\n// f.inputKey, optionally gated by a per-field `conditional.hide` FEEL expression\n// measured against the form's other field values. Every visible field is\n// assembled into ONE `{{form}}` body ({ inputKey: value, … }); a hidden field is\n// omitted. There is an optional prompt echoed from the row (f.promptField), and a\n// route-driven submit (f.action) whose path/body interpolate {{form.*}} /\n// {{row.*}} tokens. It\n// renders only when the row's f.showWhenField is truthy (an answerable\n// escalation), returning null otherwise. On a successful submit it invokes\n// onSuccess — the top-level grid re-polls the whole page (pc:refresh) so the\n// answered row drops out on the next tick; a child grid re-fetches just its own\n// rows, matching the Tasks page where the row disappears once its user-task\n// completes.\n// A disclosure chevron for expanding an inline detail row. This is the ONE place\n// a \"▸/▾\" toggle button is built, so the glyph and its `aria-expanded` state can\n// never drift apart (they used to be set independently, leaving assistive tech\n// unaware a row had opened). Every chevron gets an explicit type=\"button\" (so it\n// never submits an enclosing form) and an accessible name.\n/** @param {string} [label] @returns {HTMLElement} */\nfunction chevronToggle(label) {\n return el(\"button\", {\n class: \"pc-btn pc-btn-sm pc-chevron\",\n type: \"button\",\n \"aria-label\": label || \"Toggle details\",\n \"aria-expanded\": \"false\",\n }, \"▸\");\n}\n// Set a chevron's open/closed state, keeping the glyph and aria-expanded in lock-step.\n/** @param {HTMLElement} btn @param {boolean} open */\nfunction setChevronOpen(btn, open) {\n btn.textContent = open ? \"▾\" : \"▸\";\n btn.setAttribute(\"aria-expanded\", String(open));\n}\n\n// Normalise a `detail.form` descriptor to a list of field descriptors so the\n// legacy single-field form (f.inputKey + free-text textarea) and the\n// schema-derived multi-field form (f.fields) share ONE render/submit path (no\n// drift). A descriptor carrying f.fields is used verbatim; otherwise the whole\n// form collapses to a single textarea field bound to f.inputKey — which renders\n// byte-for-byte as the pre-#372 form.\n/** @param {any} f\n * @returns {any[]}\n */\nfunction normalizeDetailFields(f) {\n if (Array.isArray(f.fields) && f.fields.length > 0) return f.fields;\n return [{ inputKey: f.inputKey, type: \"textarea\", inputLabel: f.inputLabel }];\n}\n\n// Normalise a select field's enumerated values (from the `.form` contract) to\n// {value,label} pairs. Accepts either objects ({ value, label } — label\n// defaults to the value) or bare scalars (value === label), so an adopter can\n// author the terse or the labelled form without a bespoke shape.\n/** @param {any} options\n * @returns {Array<{value: string, label: string}>}\n */\nfunction normalizeDetailOptions(options) {\n /** @type {Array<{value: string, label: string}>} */\n const out = [];\n for (const opt of Array.isArray(options) ? options : []) {\n if (opt && typeof opt === \"object\") {\n const value = opt.value != null ? String(opt.value) : \"\";\n const label = opt.label != null ? String(opt.label) : value;\n out.push({ value, label });\n } else {\n out.push({ value: String(opt), label: String(opt) });\n }\n }\n return out;\n}\n\n// ── conditional.hide evaluation (a tiny, SAFE FEEL subset) ──────────────────\n// A `.form` field can carry `conditional.hide` — a FEEL boolean (optionally with\n// the FEEL `=` prefix, e.g. `=resolution != \"answer\"`) that, when true, hides\n// the field from the operator AND drops it from the submitted `{{form}}` body.\n// We evaluate it against the form's CURRENT field values with a purpose-built\n// recursive-descent parser — never `eval`/`new Function` — so a schema-supplied\n// string can't execute arbitrary code. The grammar is deliberately small:\n// or := and ( \"or\" and )*\n// and := cmp ( \"and\" cmp )*\n// cmp := primary ( op primary )? op ∈ = == != < <= > >=\n// primary := ident | string | number | true | false | null | \"(\" or \")\"\n// An identifier resolves to the same-form field's current value. Anything the\n// parser can't understand fails OPEN (returns false → the field stays visible),\n// because erroneously HIDING a field could silently drop a required input.\n/** @param {string} src\n * @returns {any[]}\n */\nfunction tokenizeDetailCondition(src) {\n /** @type {any[]} */\n const toks = [];\n const s = String(src);\n let i = 0;\n while (i < s.length) {\n const ch = s[i];\n if (ch === \" \" || ch === \"\\t\" || ch === \"\\n\" || ch === \"\\r\") { i++; continue; }\n if (ch === '\"' || ch === \"'\") {\n const quote = ch; let j = i + 1; let str = \"\"; let closed = false;\n while (j < s.length) {\n if (s[j] === quote) { closed = true; break; }\n if (s[j] === \"\\\\\" && j + 1 < s.length) { str += s[j + 1]; j += 2; } else { str += s[j]; j++; }\n }\n // An unterminated string literal can't be part of the grammar; abandon\n // parsing so the whole expression fails open (field stays visible) rather\n // than treating a truncated FEEL string as a valid token.\n if (!closed) throw new Error(\"unterminated string in condition\");\n toks.push({ t: \"val\", v: str }); i = j + 1; continue;\n }\n if (ch === \"(\") { toks.push({ t: \"(\" }); i++; continue; }\n if (ch === \")\") { toks.push({ t: \")\" }); i++; continue; }\n if (ch === \"!\" && s[i + 1] === \"=\") { toks.push({ t: \"op\", v: \"!=\" }); i += 2; continue; }\n if (ch === \"=\" && s[i + 1] === \"=\") { toks.push({ t: \"op\", v: \"=\" }); i += 2; continue; }\n if (ch === \"<\" && s[i + 1] === \"=\") { toks.push({ t: \"op\", v: \"<=\" }); i += 2; continue; }\n if (ch === \">\" && s[i + 1] === \"=\") { toks.push({ t: \"op\", v: \">=\" }); i += 2; continue; }\n if (ch === \"=\") { toks.push({ t: \"op\", v: \"=\" }); i++; continue; }\n if (ch === \"<\") { toks.push({ t: \"op\", v: \"<\" }); i++; continue; }\n if (ch === \">\") { toks.push({ t: \"op\", v: \">\" }); i++; continue; }\n if (/[0-9]/.test(ch) || (ch === \"-\" && /[0-9]/.test(s[i + 1] || \"\"))) {\n let j = i + 1;\n while (j < s.length && /[0-9.]/.test(s[j])) j++;\n toks.push({ t: \"val\", v: Number(s.slice(i, j)) }); i = j; continue;\n }\n if (/[A-Za-z_]/.test(ch)) {\n let j = i + 1;\n while (j < s.length && /[A-Za-z0-9_.]/.test(s[j])) j++;\n const word = s.slice(i, j);\n const lower = word.toLowerCase();\n if (lower === \"and\" || lower === \"or\") toks.push({ t: lower });\n else if (lower === \"true\") toks.push({ t: \"val\", v: true });\n else if (lower === \"false\") toks.push({ t: \"val\", v: false });\n else if (lower === \"null\") toks.push({ t: \"val\", v: null });\n else toks.push({ t: \"id\", v: word });\n i = j; continue;\n }\n // An unrecognised character can't be part of the grammar; abandon parsing so\n // the whole expression fails open (field stays visible) rather than silently\n // mis-parsing.\n throw new Error(\"unexpected character in condition\");\n }\n return toks;\n}\n/** @param {any} a @param {any} b @returns {boolean} */\nfunction detailLooseEq(a, b) {\n if (a === b) return true;\n if (a == null || b == null) return a == null && b == null;\n return String(a) === String(b);\n}\n/** @param {string} op @param {any} a @param {any} b @returns {boolean} */\nfunction detailCompare(op, a, b) {\n if (op === \"=\") return detailLooseEq(a, b);\n if (op === \"!=\") return !detailLooseEq(a, b);\n const na = Number(a); const nb = Number(b);\n if (!Number.isFinite(na) || !Number.isFinite(nb)) return false;\n if (op === \"<\") return na < nb;\n if (op === \"<=\") return na <= nb;\n if (op === \">\") return na > nb;\n if (op === \">=\") return na >= nb;\n return false;\n}\n// A bare (op-less) primary used where a boolean is expected — a FEEL flag like\n// `=isUrgent`. Only an explicit boolean true / the string \"true\" reads as true.\n/** @param {any} v @returns {boolean} */\nfunction detailTruthy(v) { return v === true || v === \"true\"; }\n/** @param {any} f\n * @param {Record<string, any>} values\n * @returns {boolean}\n */\nfunction evalDetailCondition(f, values) {\n /** @param {any} p @returns {boolean} */\n const parseOr = (p) => {\n let left = parseAnd(p);\n while (p.i < p.toks.length && p.toks[p.i].t === \"or\") { p.i++; const right = parseAnd(p); left = left || right; }\n return left;\n };\n /** @param {any} p @returns {boolean} */\n function parseAnd(p) {\n let left = parseCmp(p);\n while (p.i < p.toks.length && p.toks[p.i].t === \"and\") { p.i++; const right = parseCmp(p); left = left && right; }\n return left;\n }\n /** @param {any} p @returns {boolean} */\n function parseCmp(p) {\n const left = parsePrimary(p);\n const tok = p.toks[p.i];\n if (tok && tok.t === \"op\") { p.i++; const right = parsePrimary(p); return detailCompare(tok.v, left, right); }\n return detailTruthy(left);\n }\n /** @param {any} p @returns {any} */\n function parsePrimary(p) {\n const tok = p.toks[p.i];\n if (!tok) throw new Error(\"unexpected end of condition\");\n if (tok.t === \"(\") { p.i++; const r = parseOr(p); if (!p.toks[p.i] || p.toks[p.i].t !== \")\") throw new Error(\"missing )\"); p.i++; return r; }\n if (tok.t === \"id\") { p.i++; return values[tok.v]; }\n if (tok.t === \"val\") { p.i++; return tok.v; }\n throw new Error(\"unexpected token in condition\");\n }\n try {\n let src = String(f == null ? \"\" : f).trim();\n if (src === \"\") return false;\n if (src.startsWith(\"=\")) src = src.slice(1);\n const p = { toks: tokenizeDetailCondition(src), i: 0 };\n if (p.toks.length === 0) return false;\n const result = parseOr(p);\n if (p.i !== p.toks.length) return false;\n return result === true;\n } catch (e) {\n return false;\n }\n}\n\n/** @param {any} f\n * @param {Record<string, any>} row\n * @param {() => void | Promise<void>} onSuccess\n * @returns {HTMLElement|null}\n */\nfunction buildDetailForm(f, row, onSuccess) {\n if (!f || !row[f.showWhenField]) return null;\n const box = el(\"div\", { class: \"pc-subform\" });\n if (f.title) box.append(el(\"div\", { class: \"pc-subform-title\" }, f.title));\n if (f.promptField && row[f.promptField] != null) {\n box.append(el(\"div\", { class: \"pc-prompt\" }, String(row[f.promptField])));\n }\n // Multi-field only when the descriptor supplies f.fields; a legacy single-field\n // form renders exactly as before (one bare textarea, no visible <label>).\n const multi = Array.isArray(f.fields) && f.fields.length > 0;\n const fields = normalizeDetailFields(f);\n // role=status + aria-live so the \"Sending…\"/success/error transitions are\n // announced to screen readers instead of silently changing text.\n const msg = el(\"p\", { class: \"pc-msg\", role: \"status\", \"aria-live\": \"polite\" });\n const btn = el(\"button\", { class: \"pc-btn pc-btn-sm\", type: \"button\" }, f.submitLabel || \"Submit\");\n /** @type {Array<{key: string, input: any, wrap: any, hideExpr: any}>} */\n const controls = [];\n for (const fd of fields) {\n const key = fd.inputKey != null ? fd.inputKey : fd.key;\n const label = fd.inputLabel || fd.label || key;\n const type = fd.type === \"select\" ? \"select\" : fd.type === \"text\" ? \"text\" : \"textarea\";\n let input;\n if (type === \"select\") {\n input = el(\"select\", { class: \"pc-select\", \"aria-label\": label });\n for (const opt of normalizeDetailOptions(fd.options)) input.append(el(\"option\", { value: opt.value }, opt.label));\n if (fd.default != null) input.value = String(fd.default);\n } else if (type === \"text\") {\n input = el(\"input\", { type: \"text\", class: \"pc-input\", placeholder: label, \"aria-label\": label });\n if (fd.default != null) input.value = String(fd.default);\n } else {\n input = el(\"textarea\", { class: \"pc-textarea\", placeholder: label, \"aria-label\": label });\n if (fd.default != null) input.value = String(fd.default);\n }\n // A multi-field form gets a visible <label> per widget; the legacy single\n // free-text form keeps its bare textarea (unchanged DOM).\n const wrap = multi\n ? el(\"div\", { class: \"pc-field\" }, el(\"label\", {}, label), input)\n : el(\"div\", { class: \"pc-field\" }, input);\n const hideExpr = fd.conditional && typeof fd.conditional.hide === \"string\" ? fd.conditional.hide : null;\n controls.push({ key, input, wrap, hideExpr });\n box.append(wrap);\n }\n // Snapshot every field's current value, then hide/show each conditional field.\n // A field with no conditional.hide is always visible; the value map is keyed by\n // inputKey so a hide expression can reference a sibling field (e.g. `answer`\n // hidden when `resolution != \"answer\"`).\n const applyConditions = () => {\n /** @type {Record<string, any>} */\n const values = Object.create(null);\n for (const c of controls) values[c.key] = c.input.value;\n for (const c of controls) {\n const hidden = c.hideExpr != null && evalDetailCondition(c.hideExpr, values);\n c.wrap.hidden = hidden;\n }\n };\n // Re-evaluate the conditions whenever any field changes so a select flip\n // reveals/hides its dependent field live.\n if (controls.some((c) => c.hideExpr != null)) {\n for (const c of controls) {\n c.input.addEventListener(\"change\", applyConditions);\n c.input.addEventListener(\"input\", applyConditions);\n }\n applyConditions();\n }\n btn.addEventListener(\"click\", async () => {\n btn.disabled = true; msg.className = \"pc-msg\"; msg.textContent = \"Sending…\";\n try {\n // Assemble every VISIBLE field into one form body, keyed by its inputKey;\n // route + body template come from f.action, e.g. { path: \"/app/actions/message\",\n // body: { name, correlationKey: \"{{row.pr_key}}\", variables: \"{{form}}\" } }.\n // Null-proto so an inputKey of \"__proto__\"/\"constructor\" can't mutate a\n // prototype. A hidden (conditional.hide) field is omitted so a stale value\n // for an inapplicable choice never reaches the action.\n const form = Object.create(null);\n for (const c of controls) { if (c.wrap.hidden) continue; form[c.key] = c.input.value; }\n await runRoute(f.action, { form, row });\n msg.className = \"pc-msg ok\"; msg.textContent = (f.action && f.action.successLabel) || \"Sent\";\n // Await onSuccess so a refresh that rejects (e.g. a child grid's re-fetch\n // failing) is surfaced here and re-enables the button below, instead of the\n // operator being stuck on a permanently-disabled button with a silent\n // failure. On success the refresh detaches/rebuilds this form, so leaving\n // the button disabled is moot; we only re-enable on the error path.\n await onSuccess();\n } catch (e) {\n btn.disabled = false; msg.className = \"pc-msg err\"; msg.textContent = String(e.message || e);\n }\n });\n box.append(btn, msg);\n return box;\n}\n\n// Build a dataGrid detail's ENGINE-declared form (ADR 0026, #457). Where\n// buildDetailForm renders a page-authored `detail.form` (fields duplicated from a\n// deployed `.form`), this resolves the row's engine `formKey` and renders the\n// AUTHORITATIVE deployed form-js schema — so one grid can render the several\n// different typed forms carried by a heterogeneous task inbox, and no page-local\n// copy can drift from the deployed form. It reuses the ONE shared form-js renderer\n// (globalThis.NanoFormJs, embedded by the shell) and the shared `/app/actions/form`\n// resolution gate + `/app/actions/complete` completion — the same seams the\n// taskInbox surface uses, no fork.\n//\n// `cfg.formKeyField` (default \"form_key\") names the row column carrying the engine\n// formKey; `cfg.userTaskKeyField` (default \"user_task_key\") names the column\n// carrying the user-task key to complete. A row with no formKey degrades to a bare\n// \"Complete\" button (empty variables), matching taskInbox's no-form fallback.\n/** @param {any} cfg\n * @param {Record<string, any>} row\n * @param {() => void | Promise<void>} onSuccess\n * @returns {HTMLElement|null}\n */\nfunction buildEngineForm(cfg, row, onSuccess) {\n if (!cfg) return null;\n const userTaskKey = row[cfg.userTaskKeyField || \"user_task_key\"];\n // Without a user-task key there is nothing to complete — render nothing rather\n // than a dead button.\n if (userTaskKey == null || userTaskKey === \"\") return null;\n const formKey = row[cfg.formKeyField || \"form_key\"];\n const box = el(\"div\", { class: \"pc-subform\" });\n const msg = el(\"p\", { class: \"njf-msg\", role: \"status\", \"aria-live\": \"polite\" });\n /** @param {Record<string, any>} variables */\n const complete = (variables) =>\n getJSON(\"/app/actions/complete\", {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ userTaskKey, variables }),\n }).then(() => onSuccess());\n // No renderable form → the bare completion path (parity with taskInbox).\n const renderBare = () => {\n const bar = el(\"div\", { class: \"njf-actions\" });\n const b = el(\"button\", { class: \"pc-btn pc-btn-sm\", type: \"button\" }, \"Complete\");\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n msg.className = \"njf-msg\";\n msg.textContent = \"Sending\\u2026\";\n try {\n await complete(Object.create(null));\n } catch (e) {\n b.disabled = false;\n msg.className = \"njf-msg err\";\n msg.textContent = String(e.message || e);\n }\n });\n bar.append(b);\n box.append(bar, msg);\n };\n if (formKey == null || formKey === \"\") {\n renderBare();\n return box;\n }\n box.append(el(\"p\", { class: \"njf-text\" }, \"loading form\\u2026\"));\n getJSON(\"/app/actions/form?formKey=\" + encodeURIComponent(String(formKey)))\n .then((f) => {\n box.replaceChildren();\n const renderer = /** @type {any} */ (globalThis).NanoFormJs;\n if (!f || !f.schema || !renderer) {\n renderBare();\n return;\n }\n box.append(\n renderer.renderForm(f.schema, {\n submitLabel: \"Complete\",\n /** @param {Record<string, any>} variables */\n onSubmit: (variables) => {\n msg.className = \"njf-msg\";\n msg.textContent = \"Sending\\u2026\";\n // Surface completion failures to the operator (parity with the bare-completion\n // path). Rethrow so renderForm's catch still re-enables the submit button.\n return complete(variables).catch((e) => {\n msg.className = \"njf-msg err\";\n msg.textContent = String(e.message || e);\n throw e;\n });\n },\n }),\n msg,\n );\n })\n .catch((e) => {\n box.replaceChildren();\n msg.className = \"njf-msg err\";\n msg.textContent = \"Failed to load form: \" + String(e.message || e);\n box.append(msg);\n });\n return box;\n}\n\n/** @param {any} node */\nfunction renderDataGrid(node) {\n const p = node.props;\n const card = el(\"section\", { class: \"pc-card\" });\n // Opt-in live row-count badge (\"showCount\"). Rendered inside the <h2> so a plain\n // (non-collapsible) grid surfaces it beside the title; makeCollapsible re-homes it\n // into the collapse header so it stays visible — and refresh-updated — while the\n // section is collapsed. refresh() below rewrites its text on every poll/pc:refresh.\n const countBadge = p.showCount ? el(\"span\", { class: \"pc-badge pc-count-badge\", \"aria-label\": rowCountLabel(0) }, \"0\") : null;\n if (p.title || countBadge) {\n const h2 = el(\"h2\", {}, p.title || \"\");\n if (countBadge) { if (p.title) h2.append(\" \"); h2.append(countBadge); }\n card.append(h2);\n }\n /** @type {any[]} */\n const cols = p.columns || [];\n // Mobile card roles per column (derived by convention, refined by\n // col.mobile.priority) so the grid can flip to a stacked card list on a phone\n // (#268). 'hasHidden' gates the per-row \"More\" toggle that reveals the columns\n // a mobile:{priority:\"hidden\"} hint drops off the card.\n const roles = classifyColumns(cols);\n const hasHidden = roles.indexOf(\"hidden\") >= 0;\n /** @type {any[]} */\n const tabs = p.tabs || [];\n /** @type {any[]} */\n const rowActions = p.rowActions || [];\n const detail = p.detail || null;\n const hasExtra = rowActions.length > 0 || detail != null;\n /** @type {any[]} */\n let activeFilter = p.data.filter || [];\n\n if (tabs.length) {\n const bar = el(\"div\", { class: \"pc-tabs\" });\n activeFilter = tabs[0].filter || [];\n tabs.forEach((t, i) => {\n const b = el(\"button\", { class: \"pc-tab\" + (i === 0 ? \" active\" : \"\") }, t.label);\n b.addEventListener(\"click\", () => {\n activeFilter = t.filter || [];\n for (const c of bar.children) c.classList.remove(\"active\");\n b.classList.add(\"active\");\n refresh();\n });\n bar.append(b);\n });\n card.append(bar);\n }\n\n const headCells = cols.map((c) => el(\"th\", {}, c.header || c.field));\n if (hasExtra) headCells.push(el(\"th\", {}, \"\"));\n const thead = el(\"thead\", {}, el(\"tr\", {}, ...headCells));\n const tbody = el(\"tbody\", {});\n // A <colgroup> (present only when a column declares width/weight) sizes the\n // columns so table-layout:fixed stops splitting width equally; it must precede\n // <thead> in the table.\n const colgroup = buildColgroup(cols, hasExtra ? 1 : 0);\n const table = colgroup\n ? el(\"table\", { class: \"pc-grid\" }, colgroup, thead, tbody)\n : el(\"table\", { class: \"pc-grid\" }, thead, tbody);\n card.append(table);\n const span = String((cols.length || 1) + (hasExtra ? 1 : 0));\n\n // Expansion state has to outlive the poll: refresh() rebuilds the whole tbody\n // every refreshMs, so without this an open detail row (where you answer an\n // escalation) would collapse on the next tick. We remember which rowKeys are\n // open, and reuse the already-built detail <tr> for them across refreshes so a\n // half-typed answer survives too. Keyed by p.rowKey; grids without one keep the\n // old (collapse-on-refresh) behavior.\n const expanded = new Set();\n const detailNodes = new Map();\n // A row only participates in expansion/detail-node caching if it has a real\n // (non-null) rowKey. Coercing a missing key through String() would yield the\n // literal \"undefined\"/\"null\" and collide unrelated rows, so treat it as keyless.\n /** @param {Record<string, any>} row\n * @returns {string|null}\n */\n const rowKeyOf = (row) => {\n if (!p.rowKey) return null;\n const v = row[p.rowKey];\n return v == null ? null : String(v);\n };\n\n /** @param {any} action\n * @param {Record<string, any>} row\n */\n async function fireAction(action, row) {\n // Route-driven row action: POST the action's body template (default {})\n // to its route path, with {{row.KEY}} tokens resolved from this row.\n return runRoute(action, { row });\n }\n\n /** @param {Record<string, any>} row\n * @param {any} ra\n * @returns {HTMLButtonElement|null}\n */\n function rowActionButton(row, ra) {\n if (ra.showWhenField && !row[ra.showWhenField]) return null;\n const b = el(\"button\", { class: \"pc-btn pc-btn-sm\" }, ra.label);\n b.addEventListener(\"click\", /** @param {MouseEvent} ev */ async (ev) => {\n ev.stopPropagation();\n // In-DOM confirm/error (openModal) instead of native confirm()/alert():\n // a sandboxed iframe without allow-modals makes native confirm() return\n // false and alert() a no-op, so every confirm-guarded action silently\n // dead-ended and its errors vanished (#276).\n if (ra.confirm && !(await confirmModal(ra.confirm))) return;\n b.disabled = true;\n try {\n await fireAction(ra.action, row);\n document.dispatchEvent(new CustomEvent(\"pc:refresh\"));\n } catch (e) {\n b.disabled = false;\n alertModal(String(e.message || e));\n }\n });\n return b;\n }\n\n /** @param {any} cg\n * @param {Record<string, any>} row\n */\n async function childGrid(cg, row) {\n const wrap = el(\"div\", { class: \"pc-child\" });\n if (cg.title) wrap.append(el(\"div\", { class: \"pc-child-title\" }, cg.title));\n /** @type {any[]} */\n const ccols = cg.columns || [];\n const croles = classifyColumns(ccols);\n const chasHidden = croles.indexOf(\"hidden\") >= 0;\n // Per-row expansion for child grids: a `detail` (alias `expandable`) block\n // gives each child row a chevron that reveals the same detailPanel() content\n // the top-level grid uses (link + fields + nested child grids + form). Rows are\n // collapsed by default; `detail.defaultCollapsed`/`detail.collapsed` (default\n // true) seeds the initial state, and — when the child grid declares a `rowKey`\n // — the per-row collapse is persisted in localStorage keyed by page + node +\n // parent rowKey + child rowKey, so it survives the top-level grid's refresh\n // poll (which rebuilds this whole subtree) and a full reload, mirroring how\n // groupBy group-collapse is persisted. Absent a `detail` block the child grid\n // is byte-for-byte unchanged (backward compatible). A `detail.form` (#333) is\n // rendered inside that panel by the shared detailPanel/buildDetailForm, and its\n // submit re-fetches only this child grid (see load() below) rather than the\n // whole page — so answering a nested escalation drops the answered row without\n // disturbing the parent grid.\n const cdetail = cg.detail || cg.expandable || null;\n const chasExpand = cdetail != null;\n const cextra = (cg.lazyField ? 1 : 0) + (chasExpand ? 1 : 0);\n const cdefaultCollapsed = chasExpand\n ? (cdetail.defaultCollapsed != null\n ? !!cdetail.defaultCollapsed\n : cdetail.collapsed != null\n ? !!cdetail.collapsed\n : true)\n : true;\n const cnodeId = node.id || p.title || \"grid\";\n const cchildId = cg.node || cg.table || cg.title || \"child\";\n // The parent-row discriminator is the value this child grid is queried by\n // (row[cg.parentField]) — NOT the top-level grid's rowKey. That keeps per-row\n // collapse state unique per parent row even when the parent grid declares no\n // rowKey (rowKeyOf(row) would then be null and every parent row would collide\n // under the literal \"_\"). When no parent discriminator is available there is no\n // safe key, so persistence is disabled (ckeyBase null) instead of colliding.\n const cparentKey = cg.parentField != null && row[cg.parentField] != null\n ? String(row[cg.parentField]) : null;\n const ckeyBase = cparentKey == null ? null\n : \"pc:collapsed:\" + CURRENT + \":\" + cnodeId + \":\" + cchildId + \":\" + cparentKey + \":r:\";\n /** @param {Record<string, any>} cr @returns {string|null} */\n const crowKeyOf = (cr) => {\n if (!cg.rowKey) return null;\n const v = cr[cg.rowKey];\n return v == null ? null : String(v);\n };\n const cbody = el(\"tbody\", {});\n const ccolgroup = buildColgroup(ccols, cextra);\n const cthead = el(\"thead\", {}, el(\"tr\", {}, ...ccols.map((c) => el(\"th\", {}, c.header || c.field)),\n ...(cg.lazyField ? [el(\"th\", {}, \"\")] : []),\n ...(chasExpand ? [el(\"th\", {}, \"\")] : [])));\n const ctable = ccolgroup\n ? el(\"table\", { class: \"pc-grid\" }, ccolgroup, cthead, cbody)\n : el(\"table\", { class: \"pc-grid\" }, cthead, cbody);\n wrap.append(ctable);\n // Compute the colspan eagerly (before the fetch) so BOTH the success path and\n // the catch's error row have a valid, table-spanning colspan — if getJSON()\n // throws, a lazily-assigned cspan would still be undefined here.\n const cspan = String((ccols.length || 1) + cextra);\n // Re-fetch and repaint just this child grid's rows. Wired to the child detail\n // panel's form onSuccess so answering a nested escalation drops the row once\n // its user-task completes — the child-scoped mirror of the top-level grid's\n // pc:refresh re-poll, without disturbing the parent grid or its other detail\n // rows (which a global pc:refresh would leave untouched anyway, since open\n // detail rows are reused across the poll).\n async function load() {\n /** @type {{ rows: Array<Record<string, any>> }} */\n const { rows } = await getJSON(dataUrl(cg.source || \"app\", cg.table,\n [{ field: cg.childField, eq: row[cg.parentField] }], cg.orderBy));\n cbody.replaceChildren();\n if (!rows.length) {\n cbody.append(el(\"tr\", {}, el(\"td\", { colspan: cspan }, \"None\")));\n return;\n }\n for (const cr of rows) {\n const cells = ccols.map((c, i) => gridCell(c, cr, croles[i]));\n if (cg.lazyField) {\n const lf = cg.lazyField;\n const has = cr[lf.field] != null && String(cr[lf.field]).trim() !== \"\";\n const cell = el(\"td\", {});\n if (has) {\n const toggle = el(\"button\", { class: \"pc-btn pc-btn-sm\" }, lf.label || \"Show\");\n const pre = el(\"pre\", { class: \"pc-transcript\", hidden: \"\" });\n pre.textContent = String(cr[lf.field]);\n toggle.addEventListener(\"click\", () => {\n pre.hidden = !pre.hidden;\n toggle.textContent = pre.hidden ? (lf.label || \"Show\") : \"Hide\";\n });\n cell.append(toggle, pre);\n }\n cells.push(cell);\n }\n // Per-row expander cell (trailing, after any lazyField cell): a chevron\n // that reveals this row's detailPanel in a following full-width row. The\n // panel is built lazily on first open (and eagerly when the row starts\n // expanded), matching the top-level grid. The panel's escalation form (if\n // any) is wired to load() so answering it re-fetches only this child grid.\n // The chevron uses the shared chevronToggle()/setChevronOpen() so its glyph\n // and aria-expanded stay in lock-step for assistive tech.\n /** @type {any} */\n let cdtr = null;\n if (chasExpand) {\n const crk = crowKeyOf(cr);\n const cskey = ckeyBase && crk != null ? ckeyBase + crk : null;\n const collapsed = cskey ? readCollapsed(cskey, cdefaultCollapsed) : cdefaultCollapsed;\n const ctoggle = chevronToggle(\"Toggle row details\");\n setChevronOpen(ctoggle, !collapsed);\n cells.push(el(\"td\", { class: \"pc-row-actions\" }, ctoggle));\n cdtr = el(\"tr\", { hidden: collapsed ? \"\" : null }, el(\"td\", { colspan: cspan }));\n let built = false;\n const build = () => {\n // Return the reload promise (don't swallow it) so buildDetailForm can\n // surface a failed re-fetch and re-enable its submit button instead of\n // leaving the operator stuck on a false \"Sent\".\n if (!built) { built = true; cdtr.firstChild.append(detailPanel(cr, cdetail, () => load())); }\n };\n if (!collapsed) build();\n ctoggle.addEventListener(\"click\", /** @param {MouseEvent} ev */ (ev) => {\n ev.stopPropagation();\n const open = cdtr.hidden;\n cdtr.hidden = !open;\n setChevronOpen(ctoggle, open);\n if (open) build();\n if (cskey) writeCollapsed(cskey, !open);\n });\n }\n // Child grids classify columns into the same mobile roles (including\n // \"hidden\") as the top-level grid, so they need the same per-row \"More\"\n // toggle — without it a mobile:{priority:\"hidden\"} child column would be\n // permanently unreachable on a narrow viewport (#268).\n const ctr = el(\"tr\", {}, ...cells);\n if (chasHidden) ctr.append(mobileMoreCell(ctr));\n cbody.append(ctr);\n if (cdtr) cbody.append(cdtr);\n }\n }\n try {\n await load();\n } catch (e) {\n cbody.replaceChildren(el(\"tr\", {}, el(\"td\", { colspan: cspan }, String(e.message || e))));\n }\n return wrap;\n }\n\n /** Build an expandable-row detail panel. Used by the top-level grid (with the\n * closure `detail` config) and by child grids (which pass their own `cg.detail`\n * as `cfg`) so both share one look/behaviour — link, fields, nested child grids\n * and an optional escalation form.\n * @param {Record<string, any>} row\n * @param {any} [cfg] detail config; defaults to the top-level grid's `detail`\n * @param {(() => void)} [onSuccess] invoked after the panel's escalation form\n * submits successfully. Defaults to a whole-page `pc:refresh` re-poll (the\n * top-level grid's behaviour); a child grid passes a child-scoped reload so\n * answering a nested escalation refreshes only that child grid, not the page.\n */\n function detailPanel(row, cfg, onSuccess) {\n const d = cfg || detail;\n const box = el(\"div\", { class: \"pc-detail\" });\n if (d.linkField && row[d.linkField]) {\n const href = String(row[d.linkField]);\n // Render as a link only for http(s); anything else (e.g. a javascript: URL\n // smuggled through row data) is shown as inert text. External links get\n // rel=\"noopener noreferrer\" so the opened page can't reach window.opener.\n if (/^https?:\\/\\//i.test(href)) {\n box.append(el(\"a\", { class: \"pc-link\", href, target: \"_blank\", rel: \"noopener noreferrer\" }, href));\n } else {\n box.append(el(\"span\", { class: \"pc-link\" }, href));\n }\n }\n for (const df of d.fields || []) {\n box.append(el(\"div\", { class: \"pc-detail-field\" },\n el(\"span\", { class: \"pc-detail-label\" }, df.label || df.field),\n el(\"span\", {}, row[df.field] == null ? \"\" : String(row[df.field]))));\n }\n for (const cg of d.children || []) {\n const holder = el(\"div\", {});\n box.append(holder);\n childGrid(cg, row).then((w) => holder.replaceChildren(w));\n }\n const form = buildDetailForm(d.form, row, onSuccess || (() => { document.dispatchEvent(new CustomEvent(\"pc:refresh\")); }));\n if (form) box.append(form);\n // An engine-declared form (ADR 0026/#457) sits ALONGSIDE the page-authored\n // `detail.form`, not replacing it: a grid may render the deployed form-js schema\n // for the row's user task instead of (or in addition to) a hand-authored copy.\n const engineForm = buildEngineForm(d.engineForm, row, onSuccess || (() => { document.dispatchEvent(new CustomEvent(\"pc:refresh\")); }));\n if (engineForm) box.append(engineForm);\n return box;\n }\n\n /** @param {Record<string, any>} row\n * @param {Array<{ tr: HTMLTableRowElement, isDetail: boolean, key: string|null }>|null} sink\n */\n function renderRow(row, sink) {\n const cells = cols.map((c, i) => gridCell(c, row, roles[i]));\n const key = rowKeyOf(row);\n let toggle = null;\n if (hasExtra) {\n const actionCell = el(\"td\", { class: \"pc-row-actions\" });\n if (detail) {\n toggle = chevronToggle(\"Toggle row details\");\n actionCell.append(toggle);\n }\n for (const ra of rowActions) {\n const b = rowActionButton(row, ra);\n if (b) actionCell.append(b);\n }\n cells.push(actionCell);\n }\n // Per-row \"More\" toggle: only when a mobile:{priority:\"hidden\"} hint drops\n // columns off the card. Appended after the <tr> is built (the toggle wires a\n // click against its own row) and shared with child grids via mobileMoreCell so\n // the reveal affordance and its aria state can't drift (#268).\n const tr = el(\"tr\", {}, ...cells);\n if (hasHidden) tr.append(mobileMoreCell(tr));\n tbody.append(tr);\n // When grouping, the caller collects every <tr> this row produced (the data\n // row plus any detail row) so a group header can hide/show them as one unit.\n if (sink) sink.push({ tr, isDetail: false, key });\n if (detail && toggle) {\n const isOpen = key != null && expanded.has(key);\n // Reuse an already-built detail row for an open PR across refreshes so its\n // expanded state — and any half-typed escalation answer in the form — survives\n // the poll. A closed (or keyless) row always gets a fresh, lazily-built panel.\n let entry = key != null ? detailNodes.get(key) : null;\n if (!(isOpen && entry && entry.built)) {\n const dtr = el(\"tr\", { hidden: isOpen ? null : \"\" }, el(\"td\", { colspan: span }));\n entry = { dtr, built: false };\n if (key != null) detailNodes.set(key, entry);\n if (isOpen) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }\n }\n const dtr = entry.dtr;\n setChevronOpen(toggle, !dtr.hidden);\n toggle.addEventListener(\"click\", /** @param {MouseEvent} ev */ (ev) => {\n ev.stopPropagation();\n const open = dtr.hidden;\n dtr.hidden = !open;\n setChevronOpen(toggle, open);\n if (open) {\n if (key != null) expanded.add(key);\n if (!entry.built) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }\n } else if (key != null) {\n expanded.delete(key);\n }\n });\n tbody.append(dtr);\n if (sink) sink.push({ tr: dtr, isDetail: true, key });\n }\n }\n\n // Append the fetched rows into the tbody, either flat (default) or partitioned\n // into collapsible groups when the grid declares 'groupBy: \"<field>\"'. Each\n // group renders a clickable header row (\"<Label> <value> (<count>)\") that\n // collapses/expands its member rows as one unit; the collapsed state is\n // persisted (localStorage, keyed by page + node + group value) so it survives\n // both the refresh poll and a full reload — the same durability the top-level\n // 'collapsible' node wrapper gives. Group order is numeric-aware ascending\n // (so waves 1,2,10 sort naturally), falling back to lexical for non-numeric\n // keys. 'groupDefaultCollapsed' seeds each group collapsed until toggled.\n /** @param {Array<Record<string, any>>} rows */\n function appendRows(rows) {\n const groupBy = p.groupBy;\n // Treat a non-string groupBy as \"no grouping\": labelBase calls groupBy.charAt()\n // below, so a truthy non-string (number/object from a malformed page schema)\n // would throw at render time and break the whole page instead of one grid.\n if (!groupBy || typeof groupBy !== \"string\") { for (const row of rows) renderRow(row, null); return; }\n const order = [];\n const groups = new Map();\n for (const row of rows) {\n const gvRaw = row[groupBy];\n const gv = gvRaw == null ? \"\" : String(gvRaw);\n if (!groups.has(gv)) { groups.set(gv, []); order.push(gv); }\n groups.get(gv).push(row);\n }\n order.sort((a, b) => {\n const na = Number(a), nb = Number(b);\n if (a !== \"\" && b !== \"\" && !Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;\n return a < b ? -1 : a > b ? 1 : 0;\n });\n const labelBase = p.groupLabel != null\n ? String(p.groupLabel)\n : groupBy.charAt(0).toUpperCase() + groupBy.slice(1);\n for (const gv of order) {\n const groupRows = groups.get(gv);\n const gkey = \"pc:collapsed:\" + CURRENT + \":\" + (node.id || p.title || \"grid\") + \":g:\" + gv;\n let gcollapsed = readCollapsed(gkey, !!p.groupDefaultCollapsed);\n const chevron = el(\"span\", { class: \"pc-chevron-inline\" }, gcollapsed ? \"▸\" : \"▾\");\n const label = (gv === \"\" ? labelBase + \" —\" : labelBase + \" \" + gv) + \" (\" + groupRows.length + \")\";\n const btn = el(\"button\", { class: \"pc-group-toggle\", type: \"button\" },\n chevron, el(\"span\", { class: \"pc-group-title\" }, label));\n tbody.append(el(\"tr\", { class: \"pc-group-header\" }, el(\"td\", { colspan: span }, btn)));\n /** @type {Array<{ tr: HTMLTableRowElement, isDetail: boolean, key: string|null }>} */\n const members = [];\n for (const row of groupRows) renderRow(row, members);\n const applyGroup = () => {\n chevron.textContent = gcollapsed ? \"▸\" : \"▾\";\n btn.setAttribute(\"aria-expanded\", String(!gcollapsed));\n for (const m of members) {\n m.tr.hidden = gcollapsed\n ? true\n : (m.isDetail ? !(m.key != null && expanded.has(m.key)) : false);\n }\n };\n btn.addEventListener(\"click\", () => {\n gcollapsed = !gcollapsed;\n writeCollapsed(gkey, gcollapsed);\n applyGroup();\n });\n applyGroup();\n }\n }\n\n // Is the user actively typing inside this grid? tbody.replaceChildren() in refresh()\n // detaches the subtree, and detaching the node that holds document.activeElement blurs\n // it — so an open detail form (where you answer an escalation) loses focus every\n // refreshMs even though #103 preserves its half-typed value. Used to skip the poll.\n const editingInGrid = () => {\n const a = /** @type {any} */ (document.activeElement);\n if (!a || !tbody.contains(a)) return false;\n return a.tagName === \"INPUT\" || a.tagName === \"TEXTAREA\" || a.tagName === \"SELECT\" || a.isContentEditable;\n };\n\n async function refresh() {\n try {\n // A grid scoped to a route param (any eqParam filter) means \"show the rows\n // for the selected entity\". With no param present, PARAM is empty and emitting\n // a where clause of field-colon-empty would read server-side as field equals\n // empty string and surface empty-valued rows. That is not the selected entity's\n // rows (there is no selection), so short-circuit to zero rows without a request.\n const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);\n const { rows } = paramScoped && PARAM === \"\"\n ? { rows: [] }\n : await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));\n // Live row-count badge: reflect the currently-fetched, filtered (active-tab)\n // set — exactly what the body renders — on every refresh (poll + pc:refresh),\n // whether the section is expanded or collapsed.\n if (countBadge) { countBadge.textContent = String(rows.length); countBadge.setAttribute(\"aria-label\", rowCountLabel(rows.length)); }\n // Forget expansion / cached detail nodes for rows no longer present so the maps\n // don't grow without bound and a stale answer can't resurface on a key reuse.\n if (p.rowKey) {\n /** @type {Set<string>} */\n const live = new Set();\n for (const r of rows) { const k = rowKeyOf(r); if (k != null) live.add(k); }\n for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);\n for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);\n }\n // Capture focus + caret before the DOM swap. replaceChildren() detaches (and so blurs)\n // the reused detail node; re-appending it restores its value but not its focus. If the\n // very same node is re-attached (an open, still-present row), put the caret back.\n const active = /** @type {any} */ (document.activeElement);\n const keepFocus = !!active && tbody.contains(active);\n let selStart = null, selEnd = null, savedRange = null;\n if (keepFocus) {\n if (active.isContentEditable) {\n // contenteditable has no selectionStart — its caret lives in the document\n // selection. The reused node is re-attached, so the cloned range still points\n // at live containers and can be restored after focus.\n try { const s = document.getSelection(); if (s && s.rangeCount) savedRange = s.getRangeAt(0).cloneRange(); } catch (e) { /* selection unavailable */ }\n } else {\n try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch (e) { /* non-text field */ }\n }\n }\n // Capture scroll offsets of scrolled descendants before the swap. An open detail node is\n // reused (same element instance), but replaceChildren() detaches it, which resets the\n // scrollTop/scrollLeft of any inner scroll container (e.g. a scrolled-into .pc-transcript,\n // max-height + overflow:auto) back to 0. Only built (open) detail subtrees are re-attached\n // by the rebuild, so walk just those — not the whole grid — to keep this O(open panels)\n // rather than O(grid size). Restoration below skips any node that isn't re-attached.\n /** @type {Array<[any, number, number]>} */\n const scrollSaved = [];\n for (const entry of detailNodes.values()) {\n if (!entry.built) continue;\n for (const sc of entry.dtr.querySelectorAll(\"*\")) {\n if (sc.scrollTop || sc.scrollLeft) scrollSaved.push([sc, sc.scrollTop, sc.scrollLeft]);\n }\n }\n tbody.replaceChildren();\n appendRows(rows);\n if (!rows.length) tbody.append(el(\"tr\", {}, el(\"td\", { colspan: span }, \"No rows\")));\n if (keepFocus && active.isConnected) {\n active.focus();\n if (savedRange) {\n try { const s = /** @type {Selection} */ (document.getSelection()); s.removeAllRanges(); s.addRange(savedRange); } catch (e) { /* selection unavailable */ }\n } else if (selStart != null && typeof active.setSelectionRange === \"function\") {\n try { active.setSelectionRange(selStart, selEnd); } catch (e) { /* non-text field */ }\n }\n }\n // Restore scroll depth on the reused nodes that survived the swap (still connected).\n for (const [sc, top, left] of scrollSaved) {\n if (sc.isConnected) { sc.scrollTop = top; sc.scrollLeft = left; }\n }\n } catch (e) {\n tbody.replaceChildren(el(\"tr\", {}, el(\"td\", { colspan: span }, String(e.message || e))));\n }\n }\n document.addEventListener(\"pc:refresh\", refresh);\n disposers.push(() => document.removeEventListener(\"pc:refresh\", refresh));\n // Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh\n // (e.g. after submitting an answer) still runs — so rows never shift under an in-progress\n // answer and the detail input keeps focus across ticks. The interval is registered as a\n // disposer so a page switch (hashchange → renderPage → teardown) stops it.\n if (p.refreshMs && p.refreshMs > 0) {\n const timer = setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);\n disposers.push(() => clearInterval(timer));\n }\n refresh();\n return card;\n}\n\n// A navigation node — a horizontal menu bar (variant \"bar\", default) or a\n// vertical side rail (variant \"rail\"). Each item links to one of three targets,\n// in precedence order:\n// 1. { label, page } → an in-app #/<page> hash link within THIS (pages)\n// surface, highlighted when it's the current page.\n// 2. { label, path } → a same-origin, same-tab link to another mounted\n// surface by relative path (e.g. \"/tasks\" → the taskInbox surface). The\n// path is resolved against the app's mount root via baseRelativeUrl, so a\n// root-absolute \"/tasks\" survives the Nano console's path-prefixed App-View\n// proxy (/console/app-view/<name>/tasks) instead of escaping to the console\n// origin. Only same-origin values are honoured here — a scheme\n// (http(s)/javascript:/…) or protocol-relative \"//host\" is rejected (\"\"),\n// falling through to the {href} case, so `path` can never smuggle an\n// external or executable-scheme target into a same-tab link. This is the\n// only link form that composes multi-surface apps into a coherent,\n// proxy-safe navigable whole (nanobpm/nano-ide#436).\n// 3. { label, href } → a hardened external new-tab link; only http(s) is\n// honoured.\n// With items:\"auto\" (or omitted) the nav lists every page from the /app/pages\n// index, using each page's title. An item may also carry a `badge` ({ source,\n// table, filter, tone, refreshMs, hideWhenZero }) that renders a live count pill\n// from the /app/data gateway.\n/** @param {any} item\n * @returns {HTMLAnchorElement|null}\n */\nfunction navLink(item) {\n const isPage = safePageId(item.page);\n // A same-origin relative path to another mounted surface (e.g. \"/tasks\"),\n // rebased onto the app mount root so it survives the console proxy prefix.\n const rel = isPage ? \"\" : sameOriginPath(item.path);\n const isRel = !isPage && rel !== \"\";\n const isExt = !isPage && !isRel && typeof item.href === \"string\" && /^https?:\\/\\//i.test(item.href);\n if (!isPage && !isRel && !isExt) return null;\n /** @type {Record<string, any>} */\n const attrs = { class: \"pc-nav-link\" };\n if (isPage) {\n attrs.href = \"#/\" + encodeURIComponent(item.page);\n if (item.page === CURRENT) { attrs.class += \" active\"; attrs[\"aria-current\"] = \"page\"; }\n } else if (isRel) {\n // Same-origin, same-tab: no target=_blank — this is in-app cross-surface\n // navigation, not an external hand-off.\n attrs.href = rel;\n } else {\n attrs.href = item.href; attrs.target = \"_blank\"; attrs.rel = \"noopener noreferrer\";\n }\n /** @type {Array<Node|string>} */\n const kids = [];\n if (item.icon != null) kids.push(el(\"span\", { class: \"pc-nav-icon\" }, String(item.icon)));\n // Default the label from the target actually used, not a fixed page>path>href\n // fallback: when `path` is rejected and we fell through to `href`, the label\n // must reflect the href we navigate to, never the discarded `path`.\n const defaultLabel = isPage ? item.page : (isRel ? item.path : item.href);\n const labelText = String(item.label != null ? item.label : defaultLabel);\n kids.push(el(\"span\", { class: \"pc-nav-label\" }, labelText));\n const link = el(\"a\", attrs, ...kids);\n // A nav item may carry a live count badge sourced from a datasource (e.g. the\n // Tasks tab showing how many escalations await a human). It reuses the same\n // /app/data gateway + .pc-badge tone pills as dataGrid, and polls on refreshMs.\n // Absent/invalid badge → the link is exactly as before (backward compatible).\n wireNavBadge(link, labelText, item.badge);\n return link;\n}\n\n// Attach + drive a nav item's live count badge. Best-effort throughout: a bad\n// config or a failed fetch degrades to no badge, never a broken nav link. The\n// pill is appended hidden and only appears once a fetch yields a count (and, when\n// hideWhenZero, only when that count is > 0). The accessible name of the whole\n// link is kept in sync (\"Tasks (3 open)\") so assistive tech announces the count,\n// which the small \"3\" glyph alone would not convey; the pill itself is aria-hidden\n// to avoid a double announcement.\n/** @param {HTMLElement} link\n * @param {string} baseLabel\n * @param {any} badge\n */\nfunction wireNavBadge(link, baseLabel, badge) {\n if (!badge || typeof badge !== \"object\") return;\n if (typeof badge.table !== \"string\" || badge.table === \"\") return;\n const source = typeof badge.source === \"string\" && badge.source !== \"\" ? badge.source : \"app\";\n const t = badge.tone;\n const tone = t === \"warn\" || t === \"ok\" || t === \"info\" || t === \"danger\" ? t : \"danger\";\n const hideWhenZero = badge.hideWhenZero === true;\n const pill = el(\"span\", { class: \"pc-badge pc-nav-badge pc-badge-\" + tone, \"aria-hidden\": \"true\" });\n pill.hidden = true;\n link.append(pill);\n const filter = Array.isArray(badge.filter) ? badge.filter : [];\n const paramScoped = filter.some(/** @param {any} f */(f) => f && f.eqParam);\n const refresh = () => {\n // A param-scoped badge (\"count for the selected entity\") with no route param\n // present would otherwise build the URL with dataUrl baking in an empty PARAM\n // and query field=\"\", surfacing a wrong count. Match the dataGrid/list pollers:\n // degrade to 0 (hidden when hideWhenZero) without a request instead.\n if (paramScoped && PARAM === \"\") {\n applyNavBadge(link, pill, baseLabel, hideWhenZero, 0);\n return;\n }\n const url = dataUrl(source, badge.table, filter, null);\n const countUrl = url + (url.indexOf(\"?\") >= 0 ? \"&\" : \"?\") + \"count=1\";\n getJSON(countUrl)\n .then(/** @param {any} res */(res) => applyNavBadge(link, pill, baseLabel, hideWhenZero, res && res.count))\n .catch(() => applyNavBadge(link, pill, baseLabel, hideWhenZero, NaN));\n };\n refresh();\n const ms = Number(badge.refreshMs);\n if (Number.isFinite(ms) && ms > 0) {\n const timer = setInterval(refresh, ms);\n disposers.push(() => clearInterval(timer));\n }\n}\n\n// Reflect a fetched count into the pill + the link's accessible name. A\n// non-finite/negative count (a failed or malformed fetch) hides the pill and\n// restores the plain label — the nav degrades quietly rather than showing a\n// broken/\"NaN\" badge. hideWhenZero also hides the pill at exactly 0.\n/** @param {HTMLElement} link\n * @param {any} pill\n * @param {string} baseLabel\n * @param {boolean} hideWhenZero\n * @param {any} count\n */\nfunction applyNavBadge(link, pill, baseLabel, hideWhenZero, count) {\n const n = Number(count);\n if (!Number.isFinite(n) || n < 0 || (n === 0 && hideWhenZero)) {\n pill.hidden = true;\n pill.textContent = \"\";\n link.setAttribute(\"aria-label\", baseLabel);\n return;\n }\n const shown = String(Math.floor(n));\n pill.hidden = false;\n pill.textContent = shown;\n const aria = baseLabel + \" (\" + shown + \" open)\";\n pill.setAttribute(\"title\", aria);\n link.setAttribute(\"aria-label\", aria);\n}\n/** @param {HTMLElement} list\n * @param {any[]} items\n */\nfunction fillNav(list, items) {\n list.replaceChildren();\n let n = 0;\n for (const item of items || []) {\n if (!item || typeof item !== \"object\") continue;\n const a = navLink(item);\n if (a) { list.append(a); n++; }\n }\n if (!n) list.append(el(\"span\", { class: \"pc-nav-empty\" }, \"No pages\"));\n}\n/** @param {any} node */\nfunction renderNav(node) {\n const p = node.props || {};\n const rail = p.variant === \"rail\";\n // A bar nav can opt into staying pinned to the top of the viewport while the\n // page scrolls (sticky) — useful on the long, multi-section operator pages so\n // the page switcher is always reachable. Rails are already full-height, so the\n // flag only applies to the bar variant.\n const barSticky = !rail && p.sticky === true;\n const nav = el(\"nav\", {\n class: (rail ? \"pc-nav pc-rail\" : \"pc-nav pc-bar\") + (barSticky ? \" pc-sticky\" : \"\"),\n });\n if (p.title != null) nav.append(el(\"div\", { class: \"pc-nav-title\" }, String(p.title)));\n const list = el(\"div\", { class: \"pc-nav-items\" });\n nav.append(list);\n const items = p.items;\n if (Array.isArray(items)) {\n fillNav(list, items);\n } else {\n // \"auto\" (or unspecified): enumerate every page and link to each.\n getJSON(\"/app/pages\")\n .then((res) => fillNav(list, (res.pages || []).map(/** @param {any} pg */ (pg) => ({ label: pg.title || pg.id, page: pg.id }))))\n .catch(() => list.append(el(\"span\", { class: \"pc-nav-empty\" }, \"No pages\")));\n }\n return nav;\n}\n\n// A page-relative URL that resolves correctly whether the app is served at the\n// origin root (CLI on :3000) or under the Nano console's path-prefixed App-View\n// proxy (/console/app-view/<name>/). Same reasoning as the shell's `badgeHref`\n// and mount.js's reportUrl handling: a root-absolute \"/x\" would escape the proxy\n// prefix and hit the CONSOLE origin, so a single leading slash is stripped to\n// rebase onto the mount root; an already document-relative (\"./x\", \"x\") or an\n// absolute (\"http(s)://…\") / protocol-relative (\"//host\") URL is left untouched.\n// The result becomes an <iframe> src, so a scheme URL is only honoured when it is\n// http/https: a hostile/malformed page doc could otherwise smuggle an executable\n// `javascript:`/`data:`/`vbscript:` src, so any other scheme is rejected (\"\" →\n// caller omits the attribute). Input is trimmed and ASCII tab/newline stripped\n// FIRST (browsers strip those before parsing a URL, so `java\\tscript:` would still\n// execute) so scheme detection cannot be bypassed by embedded whitespace.\n/** @param {string} url */\nfunction baseRelativeUrl(url) {\n if (typeof url !== \"string\") return \"\";\n const cleaned = url.replace(/[\\t\\n\\r]/g, \"\").trim();\n if (cleaned === \"\") return \"\";\n const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(cleaned);\n if (scheme) {\n const proto = scheme[1].toLowerCase();\n return proto === \"http\" || proto === \"https\" ? cleaned : \"\"; // reject javascript:/data:/vbscript:/file:/…\n }\n if (cleaned.startsWith(\"//\")) return cleaned; // protocol-relative\n if (cleaned.startsWith(\"/\")) return cleaned.slice(1); // root-absolute → document-relative (rebase onto mount root)\n return cleaned; // already relative\n}\n\n// Resolve a same-origin, in-app relative path (e.g. a nav item's { path: \"/tasks\" }\n// targeting another mounted surface) onto the app's mount root, so it survives the\n// console App-View proxy prefix — the SAME rebasing rule as baseRelativeUrl (the\n// single source of truth for \"strip one leading slash to rebase onto the mount\n// root\"). Unlike baseRelativeUrl (whose output is an <iframe> src that may legally\n// be an http(s) URL), a cross-surface nav link must stay SAME-ORIGIN and same-tab:\n// a scheme (http(s):, and — defence in depth — javascript:/data:/… ) or a\n// protocol-relative \"//host\" is NOT an in-app path, so it is rejected (\"\") and the\n// caller falls through to the hardened new-tab {href} handling. Whitespace is\n// stripped/trimmed FIRST (browsers strip ASCII tab/newline before parsing a URL, so\n// `/ta\\tsks` must be judged on its cleaned form) so the scheme gate can't be\n// bypassed by embedded whitespace.\n/** @param {any} path */\nfunction sameOriginPath(path) {\n if (typeof path !== \"string\") return \"\";\n // Browsers normalize backslashes to `/` in URLs, so `\\\\evil.example/tasks` (or\n // `/\\evil.example`) is parsed as protocol-relative `//evil.example/…`. Fold `\\` → `/`\n // before the scheme/`//` gates so a backslash can't smuggle a cross-origin target past them.\n const cleaned = path.replace(/[\\t\\n\\r]/g, \"\").replace(/\\\\/g, \"/\").trim();\n if (cleaned === \"\") return \"\";\n if (/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return \"\"; // any scheme → not a same-origin in-app path\n if (cleaned.startsWith(\"//\")) return \"\"; // protocol-relative → different origin\n return baseRelativeUrl(cleaned); // rebase root-absolute onto the mount root (shared rule)\n}\n\n// `appView` (ADR 0057 / #416): mount an embedded App View — an <iframe> hosting a\n// self-contained view document (e.g. the agent cockpit's embed.html) served\n// alongside the page. This is the runtime counterpart of the console's App-View\n// surface: the SAME embed document renders here (in the app's own UI) and, when the\n// console embeds the app, there. Before this renderer existed an `appView` node fell\n// through to a blank <div> (RENDERERS[n.type] || (() => el(\"div\"))), so an entire\n// embedded view — the live agent cockpit — was SILENTLY invisible with no error.\n/** @param {PageNode} node */\nfunction renderAppView(node) {\n const p = node.props || {};\n const title = p.title != null ? String(p.title) : \"\";\n const fill = p.fill === true;\n const src = baseRelativeUrl(typeof p.embed === \"string\" ? p.embed : \"\");\n\n const frame = /** @type {HTMLIFrameElement} */ (\n el(\"iframe\", {\n class: \"pc-appview-frame\",\n // Base-relative so the embed resolves under both the app root and the console\n // App-View proxy prefix (see baseRelativeUrl). Omitted (about:blank) when no\n // usable `embed` is declared — an empty `src=\"\"` resolves to the current\n // document URL in browsers, risking a recursive self-embed. The node still\n // renders its chrome rather than throwing.\n src: src || undefined,\n title: title || \"App view\",\n loading: \"lazy\",\n referrerpolicy: \"same-origin\",\n })\n );\n\n // Forward the App-View endpoint config to the embed's own window (ADR 0057:\n // embed.html reads `window.__NANO_APP_VIEW__`). The console injects this when IT\n // hosts the embed; here — the same-origin app render — mount.js already defaults\n // every endpoint to the app's own origin, so the cockpit is fully functional with\n // nothing injected. We seed the frame's window on load only to thread through\n // explicit per-node overrides (props.reportUrl / relayUrl / transcriptsUrl), so a\n // page can point the embed at a non-default endpoint without editing the embed\n // document. Best-effort and guarded: a cross-origin embed is left to its defaults.\n /** @type {Record<string, string>} */\n const overrides = {};\n if (typeof p.reportUrl === \"string\") overrides.reportUrl = p.reportUrl;\n if (typeof p.relayUrl === \"string\") overrides.relayUrl = p.relayUrl;\n if (typeof p.transcriptsUrl === \"string\") overrides.transcriptsUrl = p.transcriptsUrl;\n frame.addEventListener(\"load\", () => {\n try {\n const w = /** @type {any} */ (frame.contentWindow);\n if (w) {\n w.__NANO_APP_VIEW__ = {\n ...(w.__NANO_APP_VIEW__ || {}),\n host: (typeof location !== \"undefined\" && location.origin) || \"\",\n ...overrides,\n };\n }\n } catch (_e) {\n // Cross-origin embed: we cannot (and need not) touch its window.\n }\n });\n\n const section = el(\"section\", { class: \"pc-appview\" + (fill ? \" pc-appview-fill\" : \"\") });\n // Register this frame in the App-View relay set (#518) so the page-level bridge\n // can (a) recognise its outbound postMessages as coming from a known App-View of\n // this page and (b) address it as a sibling target for another App-View's relay.\n // A teardown disposer drops it again, so a switched-away page's frames don't\n // linger as phantom relay endpoints.\n APP_VIEW_FRAMES.add(frame);\n disposers.push(() => APP_VIEW_FRAMES.delete(frame));\n // The title is an <h2> (the tag EVERY card renderer uses and makeCollapsible lifts into the\n // collapse header), not a <div>: a collapsible appView's title must be lifted out of the body,\n // otherwise it double-renders (header label + leftover in-body title). The .pc-appview-title CSS\n // is class-based, so a non-collapsible appView renders identically.\n if (title) section.append(el(\"h2\", { class: \"pc-appview-title\" }, title));\n section.append(frame);\n return section;\n}\n\n// The browser renderer's dispatch table: the runtime source of truth for which\n// page-node types can be drawn. `@satisfies Record<PageNodeType, Renderer>` locks\n// it, at compile time, to the shared `PAGE_NODE_TYPES` registry (`../core/page-nodes.ts`\n// — the canonical @nanobpm/nano-app-schema set, extended with the node types this\n// runtime ships ahead of the schema, e.g. `appView` #416). Adding a page-node type\n// without a renderer here (or vice-versa) is now a type error, so the table can no\n// longer silently drift from the registry (this replaces the #290 regex guard, which\n// had to string-scrape these keys precisely because this used to be a string). The\n// drift is also covered by a real-import unit test.\n/** @satisfies {Record<PageNodeType, Renderer>} */\nconst RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, prose: renderProse, nav: renderNav, button: renderButton, appView: renderAppView };\n\n// Durable per-node UI state. localStorage is keyed by the home page id + node\n// id so two grids on the same page (or the same grid across pages) don't clash,\n// and the collapsed state survives a full reload / new session — not just the\n// refresh poll. Every access is guarded: private-mode or storage-disabled\n// browsers throw on localStorage, and a UI preference must never break render.\n/** @param {string} key\n * @param {boolean} dflt\n */\nfunction readCollapsed(key, dflt) {\n try {\n const v = localStorage.getItem(key);\n return v === null ? dflt : v === \"1\";\n } catch (e) {\n return dflt;\n }\n}\n/** @param {string} key\n * @param {boolean} val\n */\nfunction writeCollapsed(key, val) {\n try {\n localStorage.setItem(key, val ? \"1\" : \"0\");\n } catch (e) {\n // Preference is best-effort; ignore storage failures.\n }\n}\n\n// Wrap a rendered node so its card can be collapsed from a clickable header.\n// Opt-in via the node's \"collapsible\" prop; \"defaultCollapsed\" seeds the state\n// the first time (before the user has toggled it). Generic across node types —\n// the body (everything the renderer produced, minus its own <h2> title, which\n// becomes the header label) is hidden as one unit.\n/** @param {any} node\n * @param {HTMLElement} card\n * @returns {HTMLElement}\n */\nfunction makeCollapsible(node, card) {\n const props = node.props || {};\n if (!props.collapsible) return card;\n // Normalize to a card container. Card renderers already return\n // <section class=\"pc-card\"> — we unwrap those in place (lift out the <h2>\n // title, reparent the rest into the body). A non-card renderer (e.g. text\n // returning <p>/<h1>) is nested whole inside a fresh section instead:\n // appending a <button>/<div> directly into a <p> is invalid markup the\n // browser silently re-parents, breaking layout and click handling.\n const isCard = card.tagName === \"SECTION\";\n const container = isCard ? card : el(\"section\", { class: \"pc-card\" });\n const h2 = isCard && card.querySelector ? card.querySelector(\"h2\") : null;\n // A dataGrid with \"showCount\" renders a live count badge inside its <h2>. Lift it\n // out before deriving the title (so the count doesn't leak into the label text),\n // then re-home it in the header alongside the title so it stays visible — and\n // refresh-updated by the grid's own refresh() — while the section is collapsed.\n const countBadge = h2 && h2.querySelector ? h2.querySelector(\".pc-count-badge\") : null;\n if (countBadge) countBadge.remove();\n // Trim the derived label: after the badge is lifted out, the leftover \" \" whitespace\n // node from h2.append(\" \", countBadge) would otherwise make a title-less section's\n // header a lone space (and leak whitespace into the storage key). Fall back to \"Section\".\n const titleText = props.title || (h2 ? h2.textContent.trim() : \"\") || \"Section\";\n if (h2) h2.remove();\n const body = el(\"div\", { class: \"pc-card-body\" });\n if (isCard) {\n while (card.firstChild) body.append(card.firstChild);\n } else {\n body.append(card);\n }\n const chevron = el(\"span\", { class: \"pc-chevron-inline\" }, \"▾\");\n const header = el(\n \"button\",\n { class: \"pc-collapse-header\", type: \"button\" },\n chevron,\n el(\"span\", { class: \"pc-collapse-title\" }, titleText),\n );\n if (countBadge) header.append(countBadge);\n const storageKey = \"pc:collapsed:\" + CURRENT + \":\" + (node.id || titleText);\n let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);\n function apply() {\n body.hidden = collapsed;\n chevron.textContent = collapsed ? \"▸\" : \"▾\";\n header.setAttribute(\"aria-expanded\", String(!collapsed));\n }\n header.addEventListener(\"click\", () => {\n collapsed = !collapsed;\n writeCollapsed(storageKey, collapsed);\n apply();\n });\n apply();\n container.append(header, body);\n return container;\n}\n\n// Tier-2 escape hatch (#268): a page may declare a distinct 'mobile' layout\n// variant — its own node list — for the rare case the derived card flip isn't\n// enough (reorder/omit sections, swap a dense grid for a summary card). It is\n// used ONLY on a narrow viewport; the default 'nodes' render everywhere else,\n// and anything malformed falls back to them, so the variant is purely additive.\n/** @param {any} doc\n * @returns {any[]}\n */\nfunction pickNodes(doc) {\n if (isNarrow() && doc && doc.mobile && Array.isArray(doc.mobile.nodes)) return doc.mobile.nodes;\n return (doc && doc.nodes) || [];\n}\n\n// Render the page named by the current hash route. A rail nav is lifted into a\n// left <aside> and the rest of the nodes flow into a content column; otherwise\n// (bar nav or none) nodes render in document order. Re-runs on every hashchange,\n// tearing down the previous page's grid polls first.\nasync function renderPage() {\n teardown();\n const mySeq = ++renderSeq;\n const route = parseRoute();\n CURRENT = route.page;\n PARAM = route.param;\n try {\n const doc = await getJSON(\"/app/pages/\" + encodeURIComponent(CURRENT));\n if (mySeq !== renderSeq) return;\n document.title = (doc && doc.title) || \"Urban App\";\n const nodes = pickNodes(doc);\n const built = nodes.map((n) => ({ n, node: makeCollapsible(n, (/** @type {Record<string, Renderer>} */ (RENDERERS)[n.type] || (() => el(\"div\")))(n)) }));\n const rail = built.find((b) => b.n.type === \"nav\" && b.n.props && b.n.props.variant === \"rail\");\n if (rail) {\n const col = el(\"div\", { class: \"pc-main-col\" });\n for (const b of built) if (b !== rail) col.append(b.node);\n root.replaceChildren(el(\"div\", { class: \"pc-layout\" }, el(\"aside\", { class: \"pc-rail-wrap\" }, rail.node), col));\n } else {\n root.replaceChildren(...built.map((b) => b.node));\n }\n } catch (e) {\n if (mySeq !== renderSeq) return;\n root.replaceChildren(el(\"p\", { class: \"pc-msg err\" }, \"Failed to load page: \" + String((e && e.message) || e)));\n }\n}\n// ── Bootstrap ─────────────────────────────────────────────────────────────\n// Every DOM/window side-effect is gathered here (not at module top level) so\n// importing this module — for a unit test or the string-generation build — is\n// inert. boot() runs only in a real browser document (see the guard below).\nfunction boot() {\n root = /** @type {HTMLElement} */ (document.getElementById(\"page\"));\n HOME = root.dataset.home || \"home\";\n MOBILE_MQ = window.matchMedia(\"(max-width:640px)\");\n CURRENT = currentPage();\n PARAM = parseRoute().param;\n NANO_EMBEDDED = computeEmbedded();\n installThemeBridge();\n installAppViewBridge();\n window.addEventListener(\"hashchange\", renderPage);\n // Re-render when the viewport crosses the mobile breakpoint so a Tier-2 'mobile'\n // variant swaps in/out on rotation or a window resize (the grid card flip, nav\n // collapse and button stacking are pure CSS and need no re-render). teardown()\n // inside renderPage stops the outgoing page's grid polls first. Prefer the modern\n // addEventListener but fall back to the deprecated addListener so older Safari/iOS\n // (< 14), where MediaQueryList.addEventListener is unimplemented, still swaps the\n // Tier-2 variant on rotation/resize instead of silently failing to register.\n if (typeof MOBILE_MQ.addEventListener === \"function\") MOBILE_MQ.addEventListener(\"change\", renderPage);\n else if (typeof MOBILE_MQ.addListener === \"function\") MOBILE_MQ.addListener(renderPage);\n renderPage();\n}\n\n// Only boot in a real browser document (a served <main id=\"page\"> exists). Under\n// Node/Deno import — for the unit tests or the string-generation build — this\n// guard is false, so the module loads with zero DOM side-effects and its\n// renderer functions / RENDERERS registry can be imported directly.\nif (typeof document !== \"undefined\" && document.getElementById(\"page\")) boot();\n\nexport { RENDERERS, renderText, renderButton, renderProse, renderNav, renderAppView, relayAppViewMessage, navLink, sameOriginPath, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, buildEngineForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };\n";
|
|
5
|
+
export const RENDERER_JS = "// @ts-check\n// ── Urban browser page runtime (authored source) ──────────────────────────\n// This is the real, type-checked / linted / unit-tested source for the browser\n// page renderer served at /app/runtime.js. It is NOT served from disk directly:\n// the checked-in string artifact `../core/modules/runtime.gen.ts` is *generated*\n// from this file by `scripts/gen-runtime.mjs` — the same \"generated derived\n// artifact + `git diff --exit-code` in CI\" pattern AGENTS.md uses elsewhere, so\n// a forgotten regeneration fails CI instead of shipping drift. The lone\n// `640px` placeholder is substituted from the shared MOBILE\n// breakpoint (the single source of truth) at generation time, so the JS\n// mode-switch and the shell CSS @media can never drift. Regenerate with\n// `npm run gen:runtime`.\n//\n// The module is import-safe under Node/Deno: it performs NO DOM access at load —\n// every bootstrap side-effect lives in boot(), gated on a real <main id=\"page\">.\n// That lets the renderer functions and the RENDERERS registry be imported and\n// unit-tested with a fake DOM, and lets the string-generation build load it.\n\n/** @typedef {import(\"../core/page-nodes.ts\").PageNodeType} PageNodeType */\n/**\n * A page node from a `*.page.json` document. `props` is an open, author-supplied\n * bag whose shape is owned/validated by `@nanobpm/nano-app-schema`, so it is\n * intentionally loose at this (already-validated) rendering boundary.\n * @typedef {{ type: string, id?: string, props?: Record<string, any> }} PageNode\n */\n/** A renderer turns one page node into a DOM element. @typedef {(node: PageNode) => HTMLElement} Renderer */\n/** Action-resolution context: the current form fields and (for grid rows) the row. @typedef {{ form?: Record<string, any>, row?: Record<string, any> }} ActionCtx */\n\n/** The page mount root (`<main id=\"page\">`). Assigned in boot(). @type {HTMLElement} */\nlet root;\n/** The home page id (from the mount root's `data-home`). @type {string} */\nlet HOME = \"home\";\n/** The single mobile-breakpoint media query. Assigned in boot(). @type {MediaQueryList} */\nlet MOBILE_MQ;\n/** True when this app runs inside the Nano console's same-origin iframe. Assigned in boot(). @type {boolean} */\nlet NANO_EMBEDDED = false;\n\n// Single mobile breakpoint, interpolated from the same MOBILE_MAX_WIDTH constant\n// as the shell CSS @media so the JS mode-switch and the pure-CSS card flip can\n// never drift on where \"narrow\" begins (#268). Only Tier-2 (an optional\n// page-level 'mobile' layout variant) needs this JS switch — the dataGrid card\n// flip, nav collapse and button/form stacking are all pure CSS + data-label.\nfunction isNarrow() { return MOBILE_MQ.matches; }\n\n// ── Multi-page routing (hash-based, reverse-proxy safe) ──────────────────\n// Pages are selected by the URL fragment (#/<page>) so navigation never hits\n// the server and works identically at the origin root and under the Nano\n// console's /console/app-view/<name>/ path-prefixed proxy (the hash is never\n// sent upstream). An empty/invalid hash falls back to the home page. Only a\n// safe id charset is accepted so the fragment can't smuggle a path/URL.\nconst PAGE_ID = /^[A-Za-z0-9_-]+$/;\n/** @param {any} value */\nfunction safePageId(value) {\n return typeof value === \"string\" && PAGE_ID.test(value);\n}\n// A route is #/<page> or #/<page>/<url-encoded-param>. We split on the FIRST\n// \"/\" *before* decoding, so a param that itself contains encoded slashes or\n// hashes (e.g. a plan_key \"owner/repo#123\" carried as \"owner%2Frepo%23123\")\n// survives intact instead of the inner \"%2F\" being decoded into a second\n// segment. The page segment is still restricted to the safe id charset; the\n// param is opaque free text (bound only as a whitelisted '?' SQL value, or\n// interpolated as DOM text — never as HTML/markup).\nfunction parseRoute() {\n const raw = (location.hash || \"\").replace(/^#\\/?/, \"\");\n const slash = raw.indexOf(\"/\");\n const pageRaw = slash >= 0 ? raw.slice(0, slash) : raw;\n const paramRaw = slash >= 0 ? raw.slice(slash + 1) : \"\";\n let page = HOME, param = \"\", pageOk = false;\n try { const p = decodeURIComponent(pageRaw); if (safePageId(p)) { page = p; pageOk = true; } } catch (e) { /* keep HOME */ }\n // Only carry the param when the page segment resolved to a real (safe-id) page.\n // A malformed/unknown page like #/not-a-page/x falls back to HOME and must NOT\n // inherit x, or that stray param would silently scope every eqParam grid and\n // {{param}} on the home page to an entity the user never selected.\n if (pageOk) { try { param = paramRaw ? decodeURIComponent(paramRaw) : \"\"; } catch (e) { param = \"\"; } }\n return { page, param };\n}\nfunction currentPage() { return parseRoute().page; }\nlet CURRENT = \"home\";\n// The current route param (the \"/<param>\" tail, or \"\" when absent). Exposed to a\n// page's datasource filters (a filter with { eqParam: true } binds its value to\n// this) and to {{param}} text interpolation, so one page template can scope\n// every section to a selected entity (e.g. an epic's plan_key).\nlet PARAM = \"\";\n// Monotonic render token. renderPage() is async and fires from both hashchange\n// and the matchMedia viewport-crossing handler, so two invocations can overlap\n// and their fetches resolve out of order. Each render captures the token it\n// bumped to; after its await it only touches the DOM if it is still the latest,\n// so a stale navigation/viewport render can't overwrite a newer one.\nlet renderSeq = 0;\n\n// Per-render teardown. Each dataGrid registers its poll interval + pc:refresh\n// listener here; navigating to another page runs every disposer before the new\n// page renders, so a switched-away grid stops polling (otherwise every visited\n// page would leave a live setInterval fetching forever).\n/** @type {any[]} */\nconst disposers = [];\nfunction teardown() {\n while (disposers.length) {\n try { disposers.pop()(); } catch (e) { /* best-effort cleanup */ }\n }\n}\n\n// Monotonic counter for per-modal element ids. aria-labelledby needs a unique\n// title id: a fixed id would clash if the page already contains that id, and\n// mis-associate the label for assistive tech.\nlet modalSeq = 0;\n// At most one modal is open at a time. A double-click (or two rapid button\n// clicks) would otherwise stack multiple overlays + document-level keydown\n// listeners; this guard keeps a single dialog + handler live.\nlet modalOpen = false;\n\n// Theme bridge for the Nano console embed. The app runs in a sandboxed,\n// same-origin iframe, so the console's --nano-* custom properties and its\n// data-appearance never cascade in. Instead the console postMessages its\n// resolved tokens; we lay them onto this document's :root and mirror the\n// appearance. Standalone (no console parent) nothing is posted, so the CSS\n// defaults stand. We announce readiness so the console can push the theme even\n// if its first message raced our listener install.\n/** @param {any} msg */\nfunction applyTheme(msg) {\n if (!msg || msg.type !== \"nano-theme\") return;\n const el = document.documentElement;\n if (msg.appearance === \"light\" || msg.appearance === \"dark\") el.dataset.appearance = msg.appearance;\n const vars = msg.vars;\n if (vars && typeof vars === \"object\") {\n for (const [k, v] of Object.entries(vars)) {\n // Only accept the shared token namespace; ignore anything else.\n if (typeof k === \"string\" && k.startsWith(\"--nano-\") && typeof v === \"string\") {\n el.style.setProperty(k, v);\n }\n }\n }\n}\n// True when this app runs inside the Nano console's same-origin iframe (vs a\n// standalone CLI run). Gates the theme bridge below AND in-host link navigation:\n// embedded, a processExplorer link routes the console in place; standalone, the\n// link keeps its native new-window behavior. The same-origin probe matters: a\n// cross-origin framer is also window.parent, but postMessage to our own origin\n// would never reach it and hostNavigate would still preventDefault the link —\n// so a cross-origin embed must fall through to the native _blank anchor. Reading\n// parent.location.origin throws under the same-origin policy for a cross-origin\n// parent, so the try/catch is the origin gate.\nfunction computeEmbedded() {\n if (!window.parent || window.parent === window) return false;\n try {\n return window.parent.location.origin === window.location.origin;\n } catch {\n return false;\n }\n}\n\n// Ask the framing console to navigate in-host. Returns true when a message was\n// posted (embedded) so the caller can suppress the native anchor; false\n// standalone so it falls through to the new-window <a>. The payload is\n// structured (target + params), never a raw href — the console constructs its\n// own route, so app/row data can't smuggle a path or scheme across the boundary.\n/** @param {string} target\n * @param {Record<string, any>} params\n */\nfunction hostNavigate(target, params) {\n if (!NANO_EMBEDDED) return false;\n window.parent.postMessage({ type: \"nano-navigate\", target: target, params: params }, window.location.origin);\n return true;\n}\n\nfunction installThemeBridge() {\n if (!NANO_EMBEDDED) return;\n window.addEventListener(\"message\", (ev) => {\n // Same-origin proxy (posture A): only trust a message from the framing\n // parent AND from our own origin. ev.source alone is insufficient — a\n // cross-origin page that frames a standalone run becomes window.parent, so\n // an origin check is what actually pins the trust boundary.\n if (ev.origin === window.location.origin && ev.source === window.parent) applyTheme(ev.data);\n });\n window.parent.postMessage({ type: \"nano-app-ready\" }, window.location.origin);\n}\n\n// ── App-View message relay (#518) ─────────────────────────────────────────\n// The App-View iframes this page currently mounts (renderAppView adds each one;\n// teardown removes it so a switched-away page's frames leave the relay set). The\n// bridge below uses this set as the trust membership: a message is only relayed\n// if ev.source is one of THESE frames' contentWindows (a nested App-View of this\n// page), never an arbitrary framed window.\n/** @type {Set<HTMLIFrameElement>} */\nconst APP_VIEW_FRAMES = new Set();\n\n// The relay primitive. A nested App-View posts to `window.parent` (this page),\n// but this page installs no bridge, so the message is dropped: it never reaches\n// the console host (forward-UP) nor the page's other App-Views (relay-ACROSS).\n// This forwards a whitelisted `nano-navigate` UP to the host (chaining the same\n// hostNavigate boundary the page's own links use, so a nested App-View can drive\n// the console explorer — Preview DI, instance deep-links) and relays any other\n// App-View message ACROSS to the page's sibling App-Views (e.g. Library \"Reuse\"\n// → Compose fill). Deliberately generic (forward-up + broadcast-across), not a\n// per-message special case, so the whole cross-frame failure class is closed at\n// once rather than patched button-by-button.\n//\n// Trust boundary: the message must be same-origin AND from a known App-View\n// iframe of THIS page. `ev.source` alone is insufficient — the origin pin plus\n// the membership check are what actually gate the boundary, so a message from a\n// non-App-View source or a cross-origin frame is never relayed.\n/**\n * @param {{ origin: string, source: any, data: any }} ev\n * @param {Iterable<{ contentWindow: any }>} frames\n * @param {{ origin: string, parent: any, embedded: boolean }} ctx\n */\nfunction relayAppViewMessage(ev, frames, ctx) {\n if (ev.origin !== ctx.origin) return;\n const list = Array.isArray(frames) ? frames : [...frames];\n let originFrame = null;\n for (const f of list) {\n if (f.contentWindow && f.contentWindow === ev.source) { originFrame = f; break; }\n }\n // Unknown source: not one of this page's App-Views — reject (trust boundary).\n if (!originFrame) return;\n const data = ev.data;\n if (data && data.type === \"nano-navigate\") {\n // Forward upward to the host/console, gated on the same same-origin embed\n // boundary as hostNavigate: standalone (no same-origin console parent) there\n // is nothing to forward to, so the message stays within the page.\n if (ctx.embedded && ctx.parent) ctx.parent.postMessage(data, ctx.origin);\n return;\n }\n // Relay across to the page's OTHER App-Views (never back to the originator).\n for (const f of list) {\n if (f === originFrame) continue;\n const w = f.contentWindow;\n if (w) w.postMessage(data, ctx.origin);\n }\n}\n\n// Install the App-View relay as a single page-level `message` listener. Unlike\n// the theme bridge this is NOT gated on NANO_EMBEDDED: the sibling relay-across\n// works within a standalone page too; only the forward-up leg is embed-gated\n// (via ctx.embedded in relayAppViewMessage).\nfunction installAppViewBridge() {\n window.addEventListener(\"message\", (ev) => {\n relayAppViewMessage(ev, APP_VIEW_FRAMES, {\n origin: window.location.origin,\n parent: window.parent,\n embedded: NANO_EMBEDDED,\n });\n });\n}\n\n// Resolve every app endpoint against where THIS module was actually served from,\n// so absolute-looking \"/app/…\" paths work both at the origin root (direct run,\n// e.g. the CLI on :3000) and under a path-prefixed reverse proxy (the Nano\n// console embeds the app at /console/app-view/<name>/). \"../\" drops the trailing\n// \"app/runtime.js\" to land on the app's mount root (always ends in \"/\").\nconst APP_BASE = new URL(\"../\", import.meta.url);\n/** @param {string} u */\nfunction apiUrl(u) {\n // Rebase root-absolute app paths (\"/app/…\") onto the mount root. Leave a\n // protocol-relative (\"//host/…\") or otherwise non-root-relative string alone —\n // only a single leading slash denotes an app path we own.\n return typeof u === \"string\" && u.startsWith(\"/\") && !u.startsWith(\"//\")\n ? new URL(u.slice(1), APP_BASE).toString()\n : u;\n}\n\n/** @param {string} url\n * @param {RequestInit=} opts\n * @returns {Promise<any>}\n */\nasync function getJSON(url, opts) {\n const r = await fetch(apiUrl(url), opts);\n const body = await r.json().catch(() => ({}));\n if (!r.ok) throw new Error(body.error || (\"HTTP \" + r.status));\n return body;\n}\n\n// ── route-driven actions ────────────────────────────────────────────────\n// Every page action is a single primitive: POST (or any method) the resolved\n// body to an app route path. There are NO bespoke action kinds — a form/row\n// reaches process start, message publish, cancel, an OpenAPI operation, a\n// webhook, or any app route uniformly by naming its path. Path + body may\n// interpolate the current form fields and (for grid rows/detail forms) the row\n// via {{form.KEY}} / {{row.KEY}} tokens; the whole-string tokens {{form}} and\n// {{row}} splice the object itself (type-preserving), so a body of\n// \"variables\": \"{{form}}\" sends the form object as the variable map.\n/** @param {string} path\n * @param {ActionCtx} ctx\n * @returns {any}\n */\nfunction lookupToken(path, ctx) {\n const segs = String(path).split(\".\");\n const head = segs[0];\n let base = head === \"form\" ? (ctx.form || {}) : head === \"row\" ? (ctx.row || {}) : undefined;\n for (let i = 1; i < segs.length; i++) base = base == null ? undefined : base[segs[i]];\n return base;\n}\n/** @param {any} node\n * @param {ActionCtx} ctx\n * @returns {any}\n */\nfunction resolveTemplate(node, ctx) {\n if (typeof node === \"string\") {\n const whole = node.match(/^\\{\\{\\s*([\\w.]+)\\s*\\}\\}$/);\n if (whole) return lookupToken(whole[1], ctx);\n return node.replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, (_, k) => {\n const v = lookupToken(k, ctx);\n return v == null ? \"\" : String(v);\n });\n }\n if (Array.isArray(node)) return node.map((n) => resolveTemplate(n, ctx));\n if (node && typeof node === \"object\") {\n // Null-proto target so a template key like \"__proto__\" can't trigger prototype\n // mutation (prototype-pollution class) when copied into the resolved object.\n const out = Object.create(null);\n for (const [k, v] of Object.entries(node)) out[k] = resolveTemplate(v, ctx);\n return out;\n }\n return node;\n}\n/** @param {any} action\n * @param {ActionCtx} ctx\n * @returns {Promise<any>}\n */\nasync function runRoute(action, ctx) {\n if (!action || typeof action.path !== \"string\" || action.path === \"\") {\n throw new Error(\"This action has no route configured (action.path is missing or blank)\");\n }\n const path = /** @type {any} */ (action.path).replace(/\\{\\{\\s*([\\w.]+)\\s*\\}\\}/g, /** @param {string} _ @param {string} k */ (_, k) => {\n const v = lookupToken(k, ctx);\n return v == null ? \"\" : encodeURIComponent(String(v));\n });\n const method = String(action.method || \"POST\").toUpperCase();\n let body;\n if (action.body !== undefined) body = resolveTemplate(action.body, ctx);\n else if (ctx.form) body = { variables: ctx.form };\n else body = {};\n /** @type {RequestInit} */\n const opts = { method, headers: { \"content-type\": \"application/json\" } };\n if (method !== \"GET\" && method !== \"HEAD\") opts.body = JSON.stringify(body);\n return getJSON(path, opts);\n}\n\n/** @param {string} tag\n * @param {Record<string, any>} [attrs]\n * @param {...any} kids\n * @returns {any}\n */\nfunction el(tag, attrs = {}, ...kids) {\n const n = document.createElement(tag);\n for (const [k, v] of Object.entries(attrs)) {\n if (k === \"class\") n.className = v;\n else if (k.startsWith(\"on\") && typeof v === \"function\") n.addEventListener(k.slice(2), v);\n else if (v != null) n.setAttribute(k, v);\n }\n for (const kid of kids) n.append(kid);\n return n;\n}\n\n// Interpolate the {{param}} token in a schema-supplied string with the current\n// route param. The result is only ever set as DOM text (textContent), so the\n// substituted value cannot inject markup. Other tokens are left untouched.\n// A function replacement inserts PARAM literally; a plain string replacement would\n// treat replacement patterns inside PARAM (an id like the dollar-one token) as\n// special and render the wrong text.\n/** @param {any} s\n * @returns {any}\n */\nfunction interpParam(s) {\n return typeof s === \"string\" ? s.replace(/\\{\\{param\\}\\}/g, () => PARAM) : s;\n}\n\n// Interpolate {{field}} tokens in a column's per-cell template with values from\n// the row, coercing each to a string (null/undefined → \"\"). Like interpParam the\n// result is only ever set as DOM text (textContent via el()), so a substituted\n// row value cannot inject markup or attributes. A function replacement inserts\n// each value literally, so a \"$\"-bearing value can't be reinterpreted as a\n// replacement pattern. Unknown tokens (no such field) render empty rather than\n// throwing, matching interpParam. This is a dumb text formatter: no expressions,\n// arithmetic, or conditionals — just field splicing. Only the row's own\n// fields resolve (own-property gate, enumerable or not): an inherited\n// Object.prototype key (toString, constructor, …) counts as unknown and\n// renders \"\", so a token can't pick up prototype cruft instead of blank.\n/** @param {any} tpl\n * @param {Record<string, any>} row\n * @returns {any}\n */\nfunction interpTemplate(tpl, row) {\n return typeof tpl === \"string\"\n ? tpl.replace(/\\{\\{([^{}]+)\\}\\}/g, (_m, name) => {\n const key = name.trim();\n const v = Object.prototype.hasOwnProperty.call(row, key) ? row[key] : null;\n return v == null ? \"\" : String(v);\n })\n : tpl;\n}\n\n// Format a single-field cell value per a column's opt-in `col.format` (issue\n// #327). This runs in the browser, so it is the ONLY place that can render a\n// stored UTC ISO timestamp in the VIEWER'S local timezone — the server never\n// knows the viewer's zone, so a server-formatted string would always be wrong\n// for someone. Currently the sole format is \"datetime\": a parseable ISO/date\n// value → \"11:42am Aug 19\" (h:mm + lowercase am/pm, then short-month day), both\n// parts in local time. Defensive by construction: an empty value, an unparseable\n// date, or any unknown format returns the raw text verbatim — a mis-set format\n// never blanks a cell or throws, matching how an unrecognised link.kind degrades\n// to plain text. Display-only: the untransformed rawText still drives the badge\n// gate/tooltip and any sort/groupBy (those key off the real field).\n/** @param {any} format\n * @param {string} raw\n * @returns {string} */\nfunction fmtCellValue(format, raw) {\n if (format !== \"datetime\" || raw === \"\") return raw;\n const d = new Date(raw);\n if (Number.isNaN(d.getTime())) return raw;\n // Present in a stable en-US format (\"11:42am Aug 19\") regardless of the\n // viewer's locale ordering (e.g. en-GB would emit \"19 Aug\"), while the ABSENCE\n // of a `timeZone` option keeps both parts in the viewer's LOCAL zone. hour12\n // forces am/pm; the replace lowercases \"AM\"/\"a.m.\"/… to \"am\"/\"pm\" and drops the\n // separating space so the output is exactly \"11:42am\".\n const time = d\n .toLocaleTimeString(\"en-US\", { hour: \"numeric\", minute: \"2-digit\", hour12: true })\n .replace(/\\s*([AaPp])\\.?\\s*[Mm]\\.?$/, (_m, p) => p.toLowerCase() + \"m\");\n const date = d.toLocaleDateString(\"en-US\", { month: \"short\", day: \"numeric\" });\n return time + \" \" + date;\n}\n/** @param {any} node */\nfunction renderText(node) {\n const v = node.props.variant;\n const cls = v === \"heading\" ? \"pc-heading\" : v === \"sub\" ? \"pc-sub\" : \"pc-body\";\n return el(v === \"heading\" ? \"h1\" : \"p\", { class: cls }, interpParam(node.props.text) || \"\");\n}\n\n// Copy text to the clipboard, resiliently. The async Clipboard API is tried\n// first, but it can reject inside a sandboxed console iframe (no clipboard-write\n// permission) — so fall back to a hidden <textarea> + execCommand(\"copy\"), which\n// works under a user gesture with only allow-scripts. Returns whether a copy\n// succeeded so the caller can nudge the user to ⌘/Ctrl+C when both paths fail.\n/** @param {string} text\n * @returns {Promise<boolean>}\n */\nasync function copyToClipboard(text) {\n try {\n if (navigator.clipboard && navigator.clipboard.writeText) {\n await navigator.clipboard.writeText(text);\n return true;\n }\n } catch (_e) { /* fall through to the execCommand path */ }\n const ta = document.createElement(\"textarea\");\n try {\n ta.value = text;\n ta.setAttribute(\"readonly\", \"\");\n ta.style.position = \"fixed\";\n ta.style.top = \"-1000px\";\n ta.style.opacity = \"0\";\n document.body.append(ta);\n ta.select();\n return document.execCommand(\"copy\");\n } catch (_e) { return false; }\n // Always remove the hidden textarea, even if select()/execCommand threw, so a\n // failed copy never leaves a detached node accreting in <body> on retry.\n finally { ta.remove(); }\n}\n\n// Rebase the sole {{appBase}} token in author-supplied copy text onto the app's\n// absolute mount root (always ends in \"/\"), so a prompt like\n// \"read {{appBase}}app/api/agent\" resolves to a URL an external agent can fetch —\n// the console proxy path when embedded, the app origin when standalone. No other\n// interpolation happens: copy text is otherwise emitted verbatim.\n/** @param {any} text */\nfunction resolveCopyText(text) {\n return typeof text === \"string\" ? text.split(\"{{appBase}}\").join(APP_BASE.toString()) : \"\";\n}\n\n// Open a lightweight modal appended to <body>. Closes on the ✕/Close button, a\n// backdrop click, Escape, or a page switch; the keydown listener is removed on\n// close so it never leaks, and close() is registered with teardown() so\n// navigating away (hashchange → renderPage → teardown) tears down any open modal\n// instead of leaving a stale overlay + document-level listener alive. When it\n// carries copyText it renders a scrollable code block plus a Copy button (see\n// copyToClipboard for the sandbox-safe fallback).\n/** @param {any} m\n * @returns {(() => void)|null}\n */\nfunction openModal(m) {\n // Only one modal at a time — drop a second (e.g. double-click) open so we\n // never stack overlays or register duplicate document-level keydown handlers.\n // Return null (not a close fn) so a guarded caller (confirmModal) can tell the\n // open was dropped and resolve rather than hang.\n if (modalOpen) return null;\n modalOpen = true;\n const overlay = el(\"div\", { class: \"pc-modal-overlay\" });\n // role=\"dialog\" + aria-modal need an accessible name: label by the visible\n // title when present, else fall back to a generic aria-label so screen readers\n // don't announce an unnamed dialog. The title id is unique per instance so it\n // can't collide with a pre-existing page id.\n const titleId = m.title ? \"pc-modal-title-\" + (++modalSeq) : null;\n const dialog = el(\"div\", titleId\n ? { class: \"pc-modal\", role: \"dialog\", \"aria-modal\": \"true\", \"aria-labelledby\": titleId }\n : { class: \"pc-modal\", role: \"dialog\", \"aria-modal\": \"true\", \"aria-label\": \"Dialog\" });\n if (m.title) dialog.append(el(\"h2\", { class: \"pc-modal-title\", id: titleId }, m.title));\n if (m.description) dialog.append(el(\"p\", { class: \"pc-modal-desc\" }, m.description));\n const copyText = resolveCopyText(m.copyText);\n if (copyText) dialog.append(el(\"pre\", { class: \"pc-modal-code\" }, copyText));\n const actions = el(\"div\", { class: \"pc-modal-actions\" });\n // Remember what had focus so we can restore it on close — a keyboard/screen\n // reader user returns to where they were instead of being dumped at <body>.\n const prevFocus = document.activeElement;\n // A dismissal (✕/Close button, backdrop, Escape, page switch) resolves with\n // m.dismissValue; an action button overrides it before closing. onResult fires\n // exactly once, from close(), so every exit path (button OR dismiss) reports a\n // result — this is what lets confirmModal() gate on a real user choice inside a\n // sandboxed iframe where native confirm() would silently return false (#276).\n const onResult = typeof m.onResult === \"function\" ? m.onResult : null;\n let resultValue = m.dismissValue;\n let closed = false;\n function close() {\n if (closed) return;\n closed = true;\n modalOpen = false;\n document.removeEventListener(\"keydown\", onKey);\n overlay.remove();\n // A manual close (button / Escape / backdrop) never pops the disposers\n // stack, so drop our own entry here — otherwise repeated open/close cycles\n // accrete dead close() closures until the next page navigation runs teardown.\n const i = disposers.indexOf(close);\n if (i !== -1) disposers.splice(i, 1);\n // A consumer-provided result callback must never abort teardown: if it throws,\n // swallow it so focus restoration below (and Escape/backdrop close) still runs.\n if (onResult) { try { onResult(resultValue); } catch (_e) {} }\n if (prevFocus instanceof HTMLElement && typeof prevFocus.focus === \"function\") prevFocus.focus();\n }\n // Focusable descendants of the dialog, in DOM order, for the Tab focus trap.\n /** @returns {any[]} */\n function focusables() {\n return Array.prototype.slice.call(dialog.querySelectorAll(\n 'button, [href], input, select, textarea, [tabindex]:not([tabindex=\"-1\"])'\n )).filter((n) => !n.disabled);\n }\n /** @param {KeyboardEvent} ev */\n function onKey(ev) {\n if (ev.key === \"Escape\") { close(); return; }\n if (ev.key !== \"Tab\") return;\n // Trap Tab within the dialog so focus can't slip behind the overlay onto the\n // inert page underneath — that would break aria-modal semantics.\n const f = focusables();\n if (!f.length) return;\n const first = f[0];\n const last = f[f.length - 1];\n if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }\n else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }\n }\n if (copyText) {\n const label = m.copyLabel || \"Copy\";\n const copyBtn = el(\"button\", { class: \"pc-btn pc-btn-sm\", type: \"button\" }, label);\n copyBtn.addEventListener(\"click\", async () => {\n const ok = await copyToClipboard(copyText);\n copyBtn.textContent = ok ? \"Copied ✓\" : \"Press ⌘/Ctrl+C\";\n setTimeout(() => { copyBtn.textContent = label; }, 2200);\n });\n actions.append(copyBtn);\n }\n const closeBtn = el(\"button\", { class: \"pc-btn pc-btn-sm pc-btn-ghost\", type: \"button\" }, m.closeLabel || \"Close\");\n closeBtn.addEventListener(\"click\", close);\n // Custom action buttons (m.actions) turn this into a decision dialog — each\n // sets the result then closes; the plain Close button is the default when none\n // are given. This is the in-DOM replacement for native confirm()/alert(): one\n // overlay + focus-trap + teardown machinery, no drift (AGENTS.md).\n let focusBtn = closeBtn;\n if (Array.isArray(m.actions) && m.actions.length) {\n let firstActionBtn = null;\n let defaultBtn = null;\n for (const a of m.actions) {\n const cls = \"pc-btn pc-btn-sm\" + (a.variant === \"ghost\" ? \" pc-btn-ghost\" : \"\");\n const ab = el(\"button\", { class: cls, type: \"button\" }, a.label);\n ab.addEventListener(\"click\", () => { resultValue = a.value; close(); });\n actions.append(ab);\n if (!firstActionBtn) firstActionBtn = ab;\n if (a.default) defaultBtn = ab;\n }\n // Default focus lands on the FIRST action (typically Cancel), never the last:\n // for confirmModal that keeps a stray Enter from accidentally triggering the\n // destructive Confirm. An action can opt into autofocus with default:true.\n focusBtn = defaultBtn || firstActionBtn;\n } else {\n actions.append(closeBtn);\n }\n dialog.append(actions);\n overlay.append(dialog);\n // A click on the backdrop (never the dialog itself) dismisses.\n overlay.addEventListener(\"click\", /** @param {MouseEvent} ev */ (ev) => { if (ev.target === overlay) close(); });\n document.addEventListener(\"keydown\", onKey);\n document.body.append(overlay);\n // A page switch runs teardown() before the next render — dispose the modal\n // there too so navigating away never leaves a stale overlay + keydown listener.\n disposers.push(close);\n focusBtn.focus();\n return close;\n}\n\n// In-DOM confirm — an accessible replacement for native confirm() that works in\n// a sandboxed iframe (without allow-modals native confirm() silently returns\n// false, dead-ending every confirm-guarded action; #276). Renders the message\n// with Cancel/Confirm and resolves the user's choice; a dismissal (Escape /\n// backdrop / ✕) resolves false. If a modal is already open the guarded open is\n// dropped — resolve false so the caller doesn't hang.\n/** @param {any} message\n * @returns {Promise<boolean>}\n */\nfunction confirmModal(message) {\n return new Promise((resolve) => {\n const opened = openModal({\n title: \"Confirm\",\n description: String(message),\n dismissValue: false,\n actions: [\n { label: \"Cancel\", value: false, variant: \"ghost\" },\n { label: \"Confirm\", value: true },\n ],\n /** @param {any} v */\n onResult: (v) => resolve(!!v),\n });\n if (!opened) resolve(false);\n });\n}\n\n// In-DOM error surface — replaces native alert(), which a sandboxed iframe also\n// suppresses, so action failures reported through it were invisible (#276).\n/** @param {any} message */\nfunction alertModal(message) {\n openModal({ title: \"Error\", description: String(message) });\n}\n\n// A standalone button node. Clicking it opens props.modal — used e.g. to surface\n// a copy-pasteable \"point your agent here\" prompt. props.variant === \"ghost\"\n// renders the muted outline style. A button without a modal is inert.\n/** @param {any} node */\nfunction renderButton(node) {\n const p = node.props || {};\n const btn = el(\n \"button\",\n { class: \"pc-btn\" + (p.variant === \"ghost\" ? \" pc-btn-ghost\" : \"\"), type: \"button\" },\n p.label || \"Open\",\n );\n const m = p.modal && typeof p.modal === \"object\" ? p.modal : null;\n if (m) btn.addEventListener(\"click\", () => openModal(m));\n return el(\"div\", { class: \"pc-buttonrow\" }, btn);\n}\n\n// A grid td cell with three column-declared rendering modes, checked in order:\n// 1. badge — a compact status indicator, gated on a non-empty field value\n// (see the detailed note at the check below).\n// 2. linkField — the cell text becomes a link to the URL held in that other\n// field. Only http(s) hrefs are linked (a javascript:/other-scheme URL\n// smuggled through row data falls back to plain text).\n// 3. link: { kind: \"processExplorer\", keyField } — a structured, engine-aware\n// link: the cell text links to the Nano console's explorer view for the\n// process instance whose key is held in the row's keyField. The console\n// path is constructed HERE (never taken from row data) and the key is\n// URL-encoded, so row data can't smuggle a path or scheme; only a\n// non-empty key (after trimming surrounding whitespace) produces a link.\n// Unknown link kinds fall back to plain text so an unrecognised schema\n// can't render a broken anchor.\n// The external linkField anchor always opens in a new tab (rel=noopener\n// noreferrer). The processExplorer anchor does too when the app runs\n// standalone, but when embedded in the console a plain click instead routes the\n// host's own explorer view in place via the nano-navigate postMessage bridge\n// (a modified/middle click keeps the new-tab behavior). Shared by the top-level\n// grid and child grids.\n//\n// Cell TEXT (the 'text' local below): the visible string a cell renders. By\n// default it is the column's single 'field' value, but an optional\n// 'col.template' string wins: its {{field}} tokens are spliced from the row\n// (interpTemplate) into DOM text. A template lets one cell combine several\n// fields / add surrounding text (e.g. \"{{current_wave}}/{{wave_count}}\")\n// without denormalising a display column into the datasource. The template\n// drives the rendered text for the plain, linkField and link\n// (page/processExplorer) paths — a link's href still comes from its own\n// keyField/linkField, never the template. The 'badge' presence gate and its\n// tooltip stay keyed to the raw 'field' value (a badge shows a fixed glyph, not\n// the field text), so a template does not change badge behaviour. Sorting,\n// groupBy and orderBy also key off real fields, never the rendered template.\n//\n// Two-line identity + one-line clamp (additive, opt-in, backward-compatible):\n// a column may also declare 'subtitleField'/'subtitleTemplate' to render a muted\n// second line under the primary text (the identity key beneath a title, GitHub/\n// Linear style) and 'truncate: true' to clamp each line to one row with an\n// ellipsis and a full-text title/aria-label tooltip (pairs with a per-column\n// width/weight — see buildColgroup — to give the clip a bounded column). The\n// subtitle is ALWAYS plain DOM text, never an href source. With none of these\n// set the cell is a single inline node exactly as before (see cellTd).\n// Build an in-app hash route to another page, scoped to a row key: #/<page>/<key>.\n// The page id is validated against the safe-id charset and the key must be\n// non-empty (after trimming); either failing yields \"\" (no link). The key is\n// URL-encoded into the route's param tail, which parseRoute() decodes back\n// verbatim, so row data can't smuggle a path or scheme. SINGLE source of truth\n// for the page-link href so the grid's link cell and the pipeline locus link\n// (issue #265) can't drift on how the route is built.\n/** @param {any} page\n * @param {any} key\n * @returns {string}\n */\nfunction pageHashHref(page, key) {\n const keyStr = key == null ? \"\" : String(key).trim();\n if (!safePageId(page) || keyStr === \"\") return \"\";\n return \"#/\" + encodeURIComponent(page) + \"/\" + encodeURIComponent(keyStr);\n}\n\n// SINGLE source of truth for a console process-explorer link. The grid link cell\n// (link kind \"processExplorer\") and the pipeline locus link (issue #347) both\n// route here so they can't drift on the href or the embedded-vs-standalone\n// navigation. The console path is constructed HERE (never taken from row data)\n// and the trimmed key URL-encoded, so row data can't smuggle a path/scheme.\n// Returns null for a blank key (after trimming) so the caller keeps the node\n// plain. Standalone the anchor opens a new tab (hardened rel); embedded in the\n// console a plain primary click instead routes the host's explorer in place via\n// the nano-navigate bridge (a non-primary/modified click keeps the new-tab\n// behavior).\n/** @param {any} label\n * @param {any} key\n * @param {string} cls\n * @returns {any}\n */\nfunction explorerAnchor(label, key, cls) {\n const keyStr = key == null ? \"\" : String(key).trim();\n if (keyStr === \"\") return null;\n const href = \"/console/explorer?instance=\" + encodeURIComponent(keyStr);\n return el(\"a\", {\n class: cls,\n href,\n target: \"_blank\",\n rel: \"noopener noreferrer\",\n /** @param {MouseEvent} ev */\n onclick: (ev) => {\n if (ev.button !== 0) return;\n if (ev.metaKey || ev.ctrlKey || ev.shiftKey || ev.altKey) return;\n if (hostNavigate(\"processExplorer\", { instance: keyStr })) ev.preventDefault();\n },\n }, label);\n}\n\n// Coerce a not-in-path value (issue #265) into a Set of stage keys. Accepts an\n// array of keys or a comma/whitespace-separated string; null/undefined → empty\n// set. Used to mark the stages a given row skips (dashed/omitted).\n/** @param {any} v\n * @returns {Set<string>}\n */\nfunction toStageSet(v) {\n // Trim + drop-empties on BOTH inputs so array entries with stray whitespace\n // (e.g. from CSV/JSON munging) match stages[].key just like the string path.\n if (Array.isArray(v)) return new Set(v.map((s) => String(s).trim()).filter((s) => s !== \"\"));\n if (v == null) return new Set();\n return new Set(String(v).split(/[\\s,]+/).filter((s) => s !== \"\"));\n}\n\n/** @param {any} col\n * @param {Record<string, any>} row\n * @param {string} role\n * @returns {HTMLTableCellElement}\n */\nfunction gridCell(col, row, role) {\n // Mobile card attributes stamped on every td so a dataGrid can flip to a\n // stacked card list below the breakpoint with no JS branching (#268):\n // 'data-label' drives the pure-CSS label:value line (the column header, or a\n // mobile:{label} override), and the role class picks the card region (title /\n // status chip / secondary field / hidden). The role is derived by convention\n // (classifyColumns) and refinable per-column via col.mobile.priority.\n const mrole = role || \"secondary\";\n const mlabel =\n col.mobile && typeof col.mobile.label === \"string\"\n ? col.mobile.label\n : col.header != null\n ? String(col.header)\n : String(col.field == null ? \"\" : col.field);\n const mob = { class: \"pc-mcell pc-mcell-\" + mrole, \"data-label\": mlabel };\n // Raw single-field value: drives the badge presence gate + tooltip and any\n // field-derived defaults. Kept separate from the (possibly templated) display\n // text so a template never re-lights or blanks a badge.\n const rawText = row[col.field] == null ? \"\" : String(row[col.field]);\n // Per-column display format (issue #327): an opt-in `col.format` transforms the\n // single-field value for display only (e.g. \"datetime\" → the viewer's local\n // time). rawText above stays untransformed so the badge gate/tooltip and any\n // sort/groupBy still key off the real field. A col.template composes its own\n // {{field}} tokens and therefore wins over the formatted field text below.\n const fieldText = col.format ? fmtCellValue(col.format, rawText) : rawText;\n // Visible text: a col.template (interpolated from the row) wins over the\n // formatted/raw field value, else the field value is shown verbatim.\n const text = typeof col.template === \"string\" ? interpTemplate(col.template, row) : fieldText;\n // 1. badge — a compact status indicator. When the row's field value is\n // non-empty (truthy after trimming) render a small circular badge (e.g. a\n // red \"1\" dot flagging an incident) whose tooltip is the full field text;\n // when empty the cell stays blank so the column is unobtrusive until it\n // matters. tone (danger|warn|ok|info, default danger) picks the color and\n // label the glyph inside (default \"1\"). Label/tone come from the app\n // schema (trusted); the tooltip is row data set via title/textContent\n // (DOM-escaped by the platform, so no HTML/attribute injection). The full\n // field text is also mirrored into aria-label so assistive tech announces\n // the incident/status text (the visible \"1\" glyph alone is not meaningful).\n if (col.badge) {\n // Even when blank, stamp data-label (the \"every td carries data-label\"\n // invariant): the cell still collapses on mobile via the pc-mcell-chip:empty\n // rule (attributes don't defeat :empty), so this keeps empty and non-empty\n // badge cells consistent without re-lighting the label.\n if (rawText.trim() === \"\") return el(\"td\", { class: \"pc-mcell pc-mcell-chip\", \"data-label\": mlabel });\n const t = col.badge.tone;\n const tone = t === \"warn\" || t === \"ok\" || t === \"info\" ? t : \"danger\";\n const label = col.badge.label == null ? \"1\" : String(col.badge.label);\n return el(\n \"td\",\n mob,\n el(\"span\", { class: \"pc-badge pc-badge-\" + tone, title: rawText, \"aria-label\": rawText }, label),\n );\n }\n // Optional second line (col.subtitleField / subtitleTemplate): a muted key\n // under the primary title. subtitleTemplate wins (interpolated from the row);\n // else the raw subtitleField value. Absent/null → \"\" → no extra line. It is\n // ALWAYS plain DOM text (see cellTd), never a link/href source — a subtitle\n // cannot smuggle a scheme or path, only linkField/keyField drive hrefs.\n const subRaw =\n col.subtitleField != null && row[col.subtitleField] != null ? String(row[col.subtitleField]) : \"\";\n const subText = typeof col.subtitleTemplate === \"string\" ? interpTemplate(col.subtitleTemplate, row) : subRaw;\n const truncate = col.truncate === true;\n // pipeline (issue #265): a dataGrid column declaring kind:\"pipeline\" renders\n // the ordered stages as a per-row horizontal progress track (upstream-filled\n // → active-lit → downstream-ghosted, not-in-path dashed). It owns the whole\n // cell, so it returns here before the link chain below. Gated purely on the\n // discriminator so every existing column mode is untouched when it is absent;\n // unknown/missing pipeline config degrades to plain cell text (never throws),\n // exactly as an unrecognised link.kind falls back today.\n if (col.kind === \"pipeline\") return pipelineCell(col, row, text, subText, truncate, mob);\n // Primary inline content: an anchor for a valid link mode (checked in the same\n // badge < linkField < link(page) < link(processExplorer) precedence as before),\n // else the plain display text. Each link mode only claims the cell while\n // 'primary' is still the bare text — an invalid href falls through to the next\n // mode and ultimately to plain text, exactly as the early-return chain did.\n let primary = text;\n if (col.linkField) {\n const href = row[col.linkField] == null ? \"\" : String(row[col.linkField]);\n if (text !== \"\" && /^https?:\\/\\//i.test(href)) {\n primary = el(\"a\", { class: \"pc-link\", href, target: \"_blank\", rel: \"noopener noreferrer\" }, text);\n }\n }\n if (primary === text && col.link && col.link.kind === \"page\" && col.link.page && col.link.keyField) {\n // An in-app link to another page, scoped to this row: #/<page>/<key>. Built\n // via pageHashHref (the single source of truth for the route): the page id is\n // validated against the safe-id charset and the key must be non-empty, so a\n // blank/keyless cell stays plain text. Navigation is a pure hash change\n // (no new tab, no server hit) — the SPA router re-renders in place.\n const href = pageHashHref(col.link.page, row[col.link.keyField]);\n if (text !== \"\" && href !== \"\") {\n primary = el(\"a\", { class: \"pc-link\", href }, text);\n }\n }\n if (primary === text && col.link && col.link.kind === \"processExplorer\" && col.link.keyField) {\n // Route through the shared explorerAnchor builder (single source of truth) so\n // this cell and the pipeline locus link can't drift on the href or the\n // embedded-in-place / standalone-new-tab navigation. A blank key → null →\n // the cell stays plain text.\n const a = explorerAnchor(text, row[col.link.keyField], \"pc-link\");\n if (text !== \"\" && a) {\n primary = a;\n }\n }\n return cellTd(primary, text, subText, truncate, mob);\n}\n\n// Compose a grid <td> from its primary inline content (a string or an anchor),\n// an optional muted subtitle line, and optional one-line truncation. With\n// NEITHER a subtitle nor truncation the cell is exactly the single inline node\n// as before, so existing grids render byte-for-byte unchanged (backward\n// compatible). When either is set the content is wrapped in a .pc-cell-main\n// line and, if a subtitle is present, a .pc-cell-sub line beneath it. truncate\n// adds .pc-truncate (nowrap + ellipsis) to each line and mirrors the full text\n// into title=/aria-label so the clipped value is still recoverable on hover and\n// to assistive tech. When the primary is an element (a link cell's <a>), title=/\n// aria-label= are ALSO mirrored onto it: HTML does not inherit those to\n// descendants, so hovering the link text or naming the anchor for assistive tech\n// would otherwise miss the full value the wrapper carries. The subtitle is set as\n// DOM text only — never an href. 'tdAttrs' (the mobile card attrs: data-label +\n// role class, see gridCell) is applied to whichever <td> shape is returned so\n// the CSS-only card flip labels every cell identically (#268).\n/** @param {any} primary\n * @param {string} primaryText\n * @param {string} subText\n * @param {boolean} truncate\n * @param {Record<string, any>} tdAttrs\n * @returns {HTMLTableCellElement}\n */\nfunction cellTd(primary, primaryText, subText, truncate, tdAttrs) {\n const td = tdAttrs || {};\n if (subText === \"\" && !truncate) return el(\"td\", td, primary);\n /** @type {Record<string, any>} */\n const mainAttrs = { class: \"pc-cell-main\" + (truncate ? \" pc-truncate\" : \"\") };\n if (truncate && primaryText !== \"\") {\n mainAttrs.title = primaryText; mainAttrs[\"aria-label\"] = primaryText;\n if (primary && typeof primary.setAttribute === \"function\") {\n primary.setAttribute(\"title\", primaryText); primary.setAttribute(\"aria-label\", primaryText);\n }\n }\n const main = el(\"div\", mainAttrs, primary);\n if (subText === \"\") return el(\"td\", td, main);\n /** @type {Record<string, any>} */\n const subAttrs = { class: \"pc-cell-sub\" + (truncate ? \" pc-truncate\" : \"\") };\n if (truncate) { subAttrs.title = subText; subAttrs[\"aria-label\"] = subText; }\n return el(\"td\", td, main, el(\"div\", subAttrs, subText));\n}\n\n// Classify each grid column into a mobile-card region so a dataGrid flips to a\n// stacked card list below the breakpoint with ZERO page-schema change (#268).\n// Convention (Tier 0): the first non-badge column is the card title (primary), a\n// badge column is a status chip, and everything else is a secondary label:value\n// row. Tier 1 refines it — a column may carry mobile:{priority:\"primary\" |\n// \"secondary\" | \"hidden\"}: an explicit \"primary\" suppresses the auto title pick\n// so a later column can lead the card, \"hidden\" drops the column off the card\n// (behind the row's \"More\" toggle), and \"secondary\" forces a plain field row.\n/** @param {any[]} cols\n * @returns {string[]}\n */\nfunction classifyColumns(cols) {\n const list = cols || [];\n const explicitPrimary = list.some((c) => c && c.mobile && c.mobile.priority === \"primary\");\n let titleTaken = explicitPrimary;\n return list.map((col) => {\n const pr = col && col.mobile ? col.mobile.priority : null;\n if (pr === \"primary\") return \"primary\";\n if (pr === \"secondary\") return \"secondary\";\n if (pr === \"hidden\") return \"hidden\";\n if (col && col.badge) return \"chip\";\n if (!titleTaken) { titleTaken = true; return \"primary\"; }\n return \"secondary\";\n });\n}\n\n// Per-row mobile \"More\" toggle cell: reveals the mobile:{priority:\"hidden\"} columns\n// a narrow viewport drops off the card. The cell is base-hidden (display:none) so\n// the desktop table is byte-for-byte unchanged; clicking flips .pc-open on the row,\n// swaps the label, and updates aria-expanded so the reveal state is exposed to\n// assistive tech (#268). One implementation shared by the top-level grid and child\n// grids so the affordance — and its accessibility — can't drift between them.\n/** @param {HTMLTableRowElement} tr */\nfunction mobileMoreCell(tr) {\n const moreBtn = el(\"button\",\n { class: \"pc-btn pc-btn-sm pc-btn-ghost pc-more\", type: \"button\", \"aria-expanded\": \"false\" }, \"More\");\n moreBtn.addEventListener(\"click\", () => {\n const open = tr.classList.toggle(\"pc-open\");\n moreBtn.textContent = open ? \"Less\" : \"More\";\n moreBtn.setAttribute(\"aria-expanded\", String(open));\n });\n return el(\"td\", { class: \"pc-mcard-toggle\" }, moreBtn);\n}\n\n// Pipeline / stepper cell (issue #265) — a dataGrid column with kind:\"pipeline\"\n// renders a per-row, multi-stage horizontal progress track. It is RENDER-ONLY:\n// stage state is read straight from the row fields the app supplies; no\n// app-specific stage derivation lives here. Column config (all fields are the\n// NAMES of row fields, mirroring the issue sketch):\n// { kind:\"pipeline\",\n// stages:[{key,label},...], // ordered stage definitions (required)\n// activeField:\"stage\", // row field → the current stage's key\n// stateField:\"stage_state\", // row field → ok | active | failed | blocked\n// badgeField:\"attention\", // optional row field → badge text shown on the active stage\n// notInPathField:\"skipped\", // optional row field → stages this row skips\n// notInPath:[\"converging\"], // static fallback used only when no field given\n// locus:{ field:\"pr_key\", stage?:\"<key>\",\n// link:{ kind:\"page\", page:\"home\" } } } // optional out-link\n// Rendering: stages BEFORE the active one are filled/completed, the active stage\n// is lit (its treatment driven by stateField — success ✓ vs failure ✕ vs blocked\n// ⊘ are made unmistakably distinct), in-path stages AFTER it are ghosted, and any\n// stage in the row's not-in-path set is dashed. Reuses the .pc-badge and .pc-link\n// classes and the shared pageHashHref route builder — no parallel implementations. Unknown or\n// missing config (no stages array) degrades gracefully to plain cell text.\n/** @param {{ stages?: any[], [key: string]: any }} col\n * @param {Record<string, any>} row\n * @param {string} text\n * @param {string} subText\n * @param {boolean} truncate\n * @param {Record<string, any>} tdAttrs\n * @returns {HTMLTableCellElement}\n */\nfunction pipelineCell(col, row, text, subText, truncate, tdAttrs) {\n // Drop null/undefined entries defensively: a schema with a hole must still\n // render (never throws on s.key) — same graceful-degradation goal as below.\n /** @type {any[]} */\n const stages = Array.isArray(col.stages) ? col.stages.filter((s) => s != null) : [];\n // Graceful fallback: an unrecognised/empty pipeline config renders plain text,\n // exactly like an unknown link.kind — never throws.\n if (stages.length === 0) return cellTd(text, text, subText, truncate, tdAttrs);\n // The current stage's key, matched against stages[].key to find the active index.\n // The row value is trimmed for consistency with the other row-derived keys\n // (pageHashHref, toStageSet), so a whitespace-padded value still matches its stage\n // instead of leaving the whole track rendered as upcoming.\n // Stage keys identify a single stage, so stop at the first match: the result is\n // deterministic even if a config accidentally repeats a key, and we avoid scanning\n // the remaining stages on every row.\n const activeKey = col.activeField != null && row[col.activeField] != null ? String(row[col.activeField]).trim() : \"\";\n let activeIdx = -1;\n for (let i = 0; i < stages.length; i++) {\n if (String(stages[i].key) === activeKey) {\n activeIdx = i;\n break;\n }\n }\n // The active stage's state drives its lit/failed/blocked treatment. Anything\n // outside the allow-list (or absent) is treated as an in-progress \"active\".\n const stRaw = col.stateField != null && row[col.stateField] != null ? String(row[col.stateField]) : \"\";\n const state = stRaw === \"ok\" || stRaw === \"failed\" || stRaw === \"blocked\" ? stRaw : \"active\";\n // Not-in-path (skipped) stages: driven per-row from notInPathField when given,\n // else the static notInPath config (per the issue: \"per row, from a field\").\n const skip =\n col.notInPathField != null && row[col.notInPathField] != null\n ? toStageSet(row[col.notInPathField])\n : toStageSet(col.notInPath);\n // Optional active-stage badge (escalation/attention → warn, blocked/failure →\n // danger), reusing .pc-badge tone classes — no new badge styles. The badge text\n // is a single row-level value (row[badgeField]) shown only on the active stage.\n const badgeText = col.badgeField != null && row[col.badgeField] != null ? String(row[col.badgeField]) : \"\";\n const badgeTone = state === \"failed\" || state === \"blocked\" ? \"danger\" : \"warn\";\n // Optional locus out-link: the relevant stage (default the active one, or\n // locus.stage by key) becomes a link when the row's locus key is non-empty.\n // Two link kinds are supported, mirroring the grid link cell and built via the\n // SAME single-source builders so the two can't drift: `page` → an in-app\n // #/<page>/<key> hash route (pageHashHref, safePageId guard + encodeURIComponent);\n // `processExplorer` → the console explorer for the row's process instance\n // (explorerAnchor, embedded-in-place / standalone-new-tab). An unknown kind or\n // blank key yields no link, so the stage stays plain.\n const locus = col.locus;\n const locusKey = locus && locus.field != null && row[locus.field] != null ? String(row[locus.field]) : \"\";\n /** @param {any} label\n * @returns {any} an anchor node for the locus stage, or null (→ plain label). */\n const locusLabelNode = (label) => {\n if (!locus || !locus.link || locusKey === \"\") return null;\n if (locus.link.kind === \"page\" && locus.link.page) {\n const href = pageHashHref(locus.link.page, locusKey);\n return href === \"\" ? null : el(\"a\", { class: \"pc-link pc-pipe-label\", href }, label);\n }\n if (locus.link.kind === \"processExplorer\") {\n return explorerAnchor(label, locusKey, \"pc-link pc-pipe-label\");\n }\n return null;\n };\n const track = el(\"div\", {\n class: \"pc-pipe\",\n role: \"list\",\n \"aria-label\": \"Progress\" + (activeIdx >= 0 ? \": \" + labelOf(stages[activeIdx]) : \"\"),\n });\n for (let i = 0; i < stages.length; i++) {\n const s = stages[i];\n // Active wins over skip: if the active stage's key is also in the not-in-path\n // set, it must still render as the current step (active styling + aria-current),\n // never as skipped. Excluding activeIdx here keeps that precedence consistent\n // across the stage class, the aria-current mark, and the connector fill below.\n const skipped = i !== activeIdx && skip.has(String(s.key));\n // Connector between adjacent stages; filled up to and including the active\n // stage, but never across a not-in-path stage — a skipped predecessor breaks\n // the fill so we don't visually imply it was on the path.\n if (i > 0) {\n const filled =\n activeIdx >= 0 && i <= activeIdx && !skipped && !skip.has(String(stages[i - 1].key)) ? \" filled\" : \"\";\n track.append(el(\"span\", { class: \"pc-pipe-conn\" + filled, \"aria-hidden\": \"true\" }));\n }\n let cls;\n let word;\n let glyph = \"\";\n let current = false;\n if (skipped) {\n cls = \"pc-pipe-skip\"; word = \"skipped\";\n } else if (activeIdx >= 0 && i < activeIdx) {\n cls = \"pc-pipe-done\"; word = \"completed\";\n } else if (i === activeIdx) {\n current = true;\n if (state === \"failed\") { cls = \"pc-pipe-active pc-pipe-failed\"; word = \"failed\"; glyph = \"\\u2715\"; }\n else if (state === \"blocked\") { cls = \"pc-pipe-active pc-pipe-blocked\"; word = \"blocked\"; glyph = \"\\u2298\"; }\n else if (state === \"ok\") { cls = \"pc-pipe-active pc-pipe-ok\"; word = \"done\"; glyph = \"\\u2713\"; }\n else { cls = \"pc-pipe-active\"; word = \"current\"; }\n } else {\n cls = \"pc-pipe-upcoming\"; word = \"upcoming\";\n }\n const atLocus = locus != null && (locus.stage != null ? String(s.key) === String(locus.stage) : i === activeIdx);\n const label = labelOf(s);\n const locusNode = atLocus ? locusLabelNode(label) : null;\n const labelNode = locusNode != null ? locusNode : el(\"span\", { class: \"pc-pipe-label\" }, label);\n /** @type {Record<string, any>} */\n const attrs = { class: \"pc-pipe-stage \" + cls, role: \"listitem\", \"aria-label\": label + \" \\u2014 \" + word };\n if (current) attrs[\"aria-current\"] = \"step\";\n const stage = el(\"span\", attrs, labelNode);\n if (glyph !== \"\") stage.append(el(\"span\", { class: \"pc-pipe-mark\", \"aria-hidden\": \"true\" }, glyph));\n if (current && badgeText.trim() !== \"\") {\n stage.append(el(\"span\", { class: \"pc-badge pc-badge-\" + badgeTone, title: badgeText, \"aria-label\": badgeText }, badgeText));\n }\n track.append(stage);\n }\n // The mobile card attrs (data-label + role class, #268) stamped by gridCell are\n // merged onto the pipeline <td> too, alongside its own pc-pipe-cell class, so a\n // pipeline column labels and classifies identically under the CSS-only card flip.\n const cellAttrs = { ...(tdAttrs || {}), class: (tdAttrs && tdAttrs.class ? tdAttrs.class + \" \" : \"\") + \"pc-pipe-cell\" };\n return el(\"td\", cellAttrs, track);\n}\n\n// A stage's visible label: its declared label, else its key as a fallback.\n/** @param {any} stage */\nfunction labelOf(stage) {\n return stage.label == null ? String(stage.key) : String(stage.label);\n}\n\n// Parse a \"<number>%\" width into its numeric percentage, or null if the string\n// is not a bare percentage (e.g. \"22rem\", \"120px\") — used to decide whether the\n// remainder left for weighted columns can be computed.\n/** @param {string} width\n * @returns {number|null}\n */\nfunction colWidthPct(width) {\n const m = /^\\s*([0-9]*\\.?[0-9]+)%\\s*$/.exec(width);\n return m ? parseFloat(m[1]) : null;\n}\n\n// A column carries an explicit width only when it is a non-empty string. Single\n// source of truth so colWidthStyle/buildColgroup can't drift on what \"explicit\"\n// means (which columns win verbatim, consume the remainder, or use their weight).\n/** @param {any} col */\nfunction hasExplicitWidth(col) {\n return typeof col.width === \"string\" && col.width !== \"\";\n}\n\n// A column's <col> width for the grid's <colgroup>: an explicit col.width string\n// (\"40%\", \"22rem\") wins verbatim; else a positive numeric col.weight is\n// normalised across the weighted columns into a percentage share of the width\n// left over by explicit columns (remainderPct); else null (no width — the column\n// shares the remainder equally under table-layout:fixed).\n/** @param {any} col\n * @param {number} weightTotal\n * @param {number} remainderPct\n * @returns {string|null}\n */\nfunction colWidthStyle(col, weightTotal, remainderPct) {\n if (hasExplicitWidth(col)) return col.width;\n if (typeof col.weight === \"number\" && col.weight > 0 && weightTotal > 0) {\n return Math.round((col.weight / weightTotal) * remainderPct * 100) / 100 + \"%\";\n }\n return null;\n}\n\n// Build a <colgroup> that sizes the grid's columns so table-layout:fixed stops\n// splitting width equally. Returns null when no column declares width/weight, so\n// an unsized grid emits no colgroup and lays out exactly as today. 'extraCount'\n// adds trailing unsized <col>s for the row-actions/detail column so the sized\n// data columns stay aligned with their headers.\n/** @param {any[]} cols\n * @param {number} extraCount\n * @returns {HTMLTableColElement|null}\n */\nfunction buildColgroup(cols, extraCount) {\n const hasSizing = cols.some(\n (c) => hasExplicitWidth(c) || (typeof c.weight === \"number\" && c.weight > 0),\n );\n if (!hasSizing) return null;\n // Only columns that will actually use their weight (no explicit width — an\n // explicit width wins verbatim in colWidthStyle and never consumes weight)\n // contribute to weightTotal; counting a width-bearing column's weight here\n // would inflate the divisor and shrink the truly-weighted columns' share.\n let weightTotal = 0;\n for (const c of cols)\n if (!hasExplicitWidth(c) && typeof c.weight === \"number\" && c.weight > 0) weightTotal += c.weight;\n // Weighted columns share only the width the explicit columns leave behind, so\n // mixing e.g. width:\"40%\" with weighted columns can't oversubscribe past 100%.\n // The remainder is only computable when every explicit width is a percentage;\n // if any uses an absolute/relative unit (rem/px) we can't subtract it from\n // 100%, so weighted columns fall back to a share of the full width.\n let explicitPctTotal = 0;\n let allExplicitArePct = true;\n for (const c of cols) {\n if (hasExplicitWidth(c)) {\n const pct = colWidthPct(c.width);\n if (pct === null) allExplicitArePct = false;\n else explicitPctTotal += pct;\n }\n }\n const remainderPct = allExplicitArePct ? Math.max(0, 100 - explicitPctTotal) : 100;\n const colEls = cols.map((c) => {\n const w = colWidthStyle(c, weightTotal, remainderPct);\n return el(\"col\", w ? { style: \"width:\" + w } : {});\n });\n for (let i = 0; i < extraCount; i++) colEls.push(el(\"col\", {}));\n return el(\"colgroup\", {}, ...colEls);\n}\n\n/** @param {any} node */\nfunction renderActionForm(node) {\n /** @type {any} */\n const p = node.props;\n const card = el(\"section\", { class: \"pc-card\" });\n if (p.title) card.append(el(\"h2\", {}, p.title));\n // Map/null-proto stores so a schema field keyed __proto__/constructor can't\n // pollute Object.prototype or shadow inherited props (prototype-pollution class).\n /** @type {Map<string, HTMLInputElement>} */\n const inputs = new Map();\n /** @type {Map<string, string>} */\n const fieldTypes = new Map();\n // Client-side required-field validation state: the set of required keys, each\n // field's custom message, and its inline error node (populated on a blocked\n // submit, cleared as soon as the user edits the field).\n /** @type {Set<string>} */\n const required = new Set();\n /** @type {Map<string, string>} */\n const reqMsg = new Map();\n /** @type {Map<string, HTMLElement>} */\n const errs = new Map();\n // A required field's label carries a danger-toned \"*\" so the requirement is\n // visible BEFORE submit; spread into el(...) — an empty array adds no child.\n /** @param {any} f\n * @returns {HTMLElement[]}\n */\n const reqMark = (f) => (f.required === true ? [el(\"span\", { class: \"pc-req\", title: \"Required\" }, \" *\")] : []);\n // Register a field's inline error node and, when required, mark the input\n // (aria-required) and wire a clear-on-edit handler so a hint disappears the\n // moment the user starts fixing it. Editing a required field also clears the\n // blocked-submit summary (\"Please fill in the required fields.\") so it never\n // lingers as a stale/misleading banner while the user is correcting input.\n /** @param {any} f\n * @param {HTMLInputElement} input\n * @param {HTMLElement} ferr\n * @param {string} evt\n */\n const wireField = (f, input, ferr, evt) => {\n errs.set(f.key, ferr);\n if (f.required !== true) return;\n required.add(f.key);\n if (typeof f.requiredMessage === \"string\" && f.requiredMessage !== \"\") reqMsg.set(f.key, f.requiredMessage);\n input.setAttribute(\"aria-required\", \"true\");\n input.addEventListener(evt, () => {\n input.classList.remove(\"pc-invalid\");\n input.removeAttribute(\"aria-invalid\");\n ferr.textContent = \"\";\n msg.textContent = \"\";\n msg.className = \"pc-msg\";\n });\n };\n for (const f of p.fields || []) {\n const kind = f.type === \"checkbox\" ? \"checkbox\" : (f.type === \"number\" ? \"number\" : \"text\");\n fieldTypes.set(f.key, kind);\n const ferr = el(\"p\", { class: \"pc-field-err\" });\n if (kind === \"checkbox\") {\n // A boolean field renders a real checkbox; its default checked state comes\n // from default/checked on the field (unset -> unchecked). We record that on\n // the input's defaultChecked so a post-submit reset can restore it without a\n // side map that could drift from the actual input.\n const checked = f.default === true || f.checked === true;\n const input = el(\"input\", { type: \"checkbox\" });\n input.defaultChecked = checked;\n input.checked = checked;\n inputs.set(f.key, input);\n wireField(f, input, ferr, \"change\");\n // Input-first, wrapped in the label so the whole row is a click target.\n card.append(\n el(\"div\", { class: \"pc-field pc-field-check\" }, el(\"label\", {}, input, \" \" + (f.label || f.key), ...reqMark(f)), ferr),\n );\n continue;\n }\n /** @type {Record<string, any>} */\n const attrs = { type: kind, placeholder: f.label || f.key };\n if (kind === \"number\") {\n attrs.inputmode = \"numeric\";\n if (f.min != null) attrs.min = String(f.min);\n if (f.max != null) attrs.max = String(f.max);\n attrs.step = f.step != null ? String(f.step) : \"1\";\n }\n const input = el(\"input\", attrs);\n inputs.set(f.key, input);\n wireField(f, input, ferr, \"input\");\n card.append(el(\"div\", { class: \"pc-field\" }, el(\"label\", {}, f.label || f.key, ...reqMark(f)), input, ferr));\n }\n const msg = el(\"p\", { class: \"pc-msg\" });\n const btn = el(\"button\", { class: \"pc-btn\" }, p.submitLabel || \"Submit\");\n btn.addEventListener(\"click\", async () => {\n // Required-field gate: block the submit and surface an inline hint on each\n // empty required field (a text/number left blank, a required checkbox left\n // unchecked), focusing the first offender — so a missing value never\n // round-trips to the server only to bounce back as a generic error.\n let firstInvalid = null;\n for (const [k, input] of inputs) {\n const ferr = errs.get(k);\n if (ferr) ferr.textContent = \"\";\n input.classList.remove(\"pc-invalid\");\n input.removeAttribute(\"aria-invalid\");\n if (!required.has(k)) continue;\n const type = fieldTypes.get(k);\n const val = String(input.value).trim();\n const missing = type === \"checkbox\"\n ? input.checked !== true\n : type === \"number\"\n ? (val === \"\" || !Number.isFinite(Number(val)))\n : val === \"\";\n if (missing) {\n if (ferr) ferr.textContent = reqMsg.get(k) || \"Required\";\n input.classList.add(\"pc-invalid\");\n input.setAttribute(\"aria-invalid\", \"true\");\n if (!firstInvalid) firstInvalid = input;\n }\n }\n if (firstInvalid) {\n msg.className = \"pc-msg err\";\n msg.textContent = \"Please fill in the required fields.\";\n firstInvalid.focus();\n return;\n }\n const variables = Object.create(null);\n for (const [k, input] of inputs) {\n if (fieldTypes.get(k) === \"checkbox\") {\n // Always emit a real boolean (checked -> true / false), never a string,\n // so a strict equality check on the action side sees the intended value.\n variables[k] = input.checked === true;\n } else if (fieldTypes.get(k) === \"number\") {\n const t = String(input.value).trim();\n // Blank → omit so the action-side default applies; a non-finite parse\n // (NaN/Infinity) is also omitted rather than smuggling a raw string\n // process variable through, which would defeat the numeric coercion.\n if (t === \"\") continue;\n const num = Number(t);\n if (!Number.isFinite(num)) continue;\n variables[k] = num;\n } else {\n variables[k] = input.value;\n }\n }\n btn.disabled = true; msg.className = \"pc-msg\"; msg.textContent = \"Submitting…\";\n try {\n // Route-driven: POST the form (as { variables } by default, or the action's\n // own body template) to the action's route path. correlationKey / process /\n // message are expressed by the path + body template the page author supplies,\n // e.g. { path: \"/app/actions/message\", body: { name, correlationKey, variables: \"{{form}}\" } }.\n const res = await runRoute(p.action, { form: variables });\n msg.className = \"pc-msg ok\";\n msg.textContent = (p.action && p.action.successLabel) ||\n (res && res.processInstanceKey != null ? \"Started (instance \" + res.processInstanceKey + \")\" : \"Done\");\n for (const [k, input] of inputs) {\n // Text/number inputs clear; a checkbox resets to its declared default,\n // read straight off the input's defaultChecked (no side map to drift).\n if (fieldTypes.get(k) === \"checkbox\") input.checked = input.defaultChecked;\n else input.value = \"\";\n }\n document.dispatchEvent(new CustomEvent(\"pc:refresh\"));\n } catch (e) {\n msg.className = \"pc-msg err\"; msg.textContent = String(e.message || e);\n } finally { btn.disabled = false; }\n });\n card.append(btn, msg);\n return card;\n}\n\n// Accessible name for the row-count badge. The visible text is just a number, so an\n// aria-label would override it with a bare \"row count\" (the count — the important part —\n// is dropped). Instead name it \"N row(s)\" and keep it in sync with the text on refresh.\n// Single source of truth for both the initial render and every refresh below.\n/** @param {number} n */\nfunction rowCountLabel(n) { return n + (n === 1 ? \" row\" : \" rows\"); }\n\n// Build the /app/data/<source>/<table> URL a data-bound list renderer fetches,\n// threading the whitelisted where/order query params. Shared by dataGrid and the\n// prose renderer so the two can't drift on how a filter/orderBy is encoded. A\n// filter is { field, in:[…] } (IN set), { field, eqParam:true } (bound to the\n// current route PARAM — \"show the selected entity's rows\"), or { field, eq } (a\n// literal equals); orderBy is { field, dir }. The source/table/field names are\n// server-side whitelisted (the /app/data route rejects unknown columns).\n/** @param {string} source\n * @param {string} tbl\n * @param {any[]} filters\n * @param {any} order\n */\nfunction dataUrl(source, tbl, filters, order) {\n let u = \"/app/data/\" + encodeURIComponent(source) + \"/\" + encodeURIComponent(tbl);\n const qs = [];\n for (const f of filters || []) {\n if (Array.isArray(f.in)) qs.push(\"where=\" + encodeURIComponent(f.field + \":in:\" + f.in.join(\",\")));\n else if (f.eqParam) qs.push(\"where=\" + encodeURIComponent(f.field + \":\" + PARAM));\n else qs.push(\"where=\" + encodeURIComponent(f.field + \":\" + f.eq));\n }\n if (order && order.field) qs.push(\"order=\" + encodeURIComponent(order.field + \":\" + (order.dir || \"asc\")));\n return qs.length ? u + \"?\" + qs.join(\"&\") : u;\n}\n\n// ── Minimal, safe Markdown → DOM (no innerHTML) — used by the prose renderer ──\n// A dependency-free CommonMark subset. It NEVER sets innerHTML: every block and\n// inline token is materialised as a known element via el(), with text set through\n// textContent / createTextNode — so a record's body field may carry markdown\n// WITHOUT ever becoming an HTML/script injection vector (the same text-only\n// guarantee interpTemplate gives a grid cell). Supported: ATX headings\n// (#..######), fenced + inline code, bold (**/__), italic (*/_), links\n// [text](url) (http/https/mailto only — every other scheme degrades to its plain\n// label), unordered (-,*,+) and ordered (1. / 1)) lists, blockquotes (>),\n// thematic breaks (---), and blank-line-separated paragraphs. MD_BACKTICK is the\n// backtick that delimits code spans/fences.\nconst MD_BACKTICK = \"`\";\n/** @param {any} c */\nfunction mdIsWordChar(c) { return typeof c === \"string\" && /[0-9A-Za-z]/.test(c); }\n// Only http(s)/mailto hrefs become anchors; anything else (javascript:, data:,\n// vbscript:, a bare relative path, …) returns \"\" so the caller renders the label\n// as plain text rather than a clickable, potentially-hostile link.\n/** @param {any} href */\nfunction mdSafeHref(href) {\n const h = String(href == null ? \"\" : href).trim();\n return /^(?:https?:|mailto:)/i.test(h) ? h : \"\";\n}\n// Find the closing run of 'marker' at or after 'from'. For underscore emphasis the\n// close must not be intra-word (a following alphanumeric means snake_case, not a\n// delimiter), so keep scanning past such runs; -1 when there is no valid close.\n/** @param {string} s\n * @param {string} marker\n * @param {number} from\n * @param {boolean} underscore\n */\nfunction mdFindClose(s, marker, from, underscore) {\n let j = from;\n while (true) {\n const k = s.indexOf(marker, j);\n if (k < 0) return -1;\n if (!underscore || !mdIsWordChar(s[k + marker.length])) return k;\n j = k + marker.length;\n }\n}\n/** @param {any} text\n * @returns {Array<Node|string>}\n */\nfunction mdInline(text) {\n const s = String(text == null ? \"\" : text);\n /** @type {Array<Node|string>} */\n const nodes = [];\n let buf = \"\";\n const flush = () => { if (buf) { nodes.push(document.createTextNode(buf)); buf = \"\"; } };\n let i = 0;\n while (i < s.length) {\n const ch = s[i];\n // Backslash escape: the next char is emitted literally, never as a delimiter.\n if (ch === \"\\\\\" && i + 1 < s.length) { buf += s[i + 1]; i += 2; continue; }\n // Inline code span — verbatim, no nested inline parsing.\n if (ch === MD_BACKTICK) {\n const end = s.indexOf(MD_BACKTICK, i + 1);\n if (end > i) { flush(); nodes.push(el(\"code\", { class: \"pc-md-code\" }, s.slice(i + 1, end))); i = end + 1; continue; }\n }\n // Strong (** or __). Underscore only when it opens on a word boundary so an\n // identifier like a__b doesn't get mangled into emphasis.\n if ((ch === \"*\" || ch === \"_\") && s[i + 1] === ch) {\n const underscore = ch === \"_\";\n if (!underscore || !mdIsWordChar(s[i - 1])) {\n const marker = ch + ch;\n const end = mdFindClose(s, marker, i + 2, underscore);\n if (end > i + 1) { flush(); nodes.push(el(\"strong\", {}, ...mdInline(s.slice(i + 2, end)))); i = end + 2; continue; }\n }\n }\n // Emphasis (* or _), same word-boundary guard for underscore.\n if (ch === \"*\" || ch === \"_\") {\n const underscore = ch === \"_\";\n if (!underscore || !mdIsWordChar(s[i - 1])) {\n const end = mdFindClose(s, ch, i + 1, underscore);\n if (end > i + 1) { flush(); nodes.push(el(\"em\", {}, ...mdInline(s.slice(i + 1, end)))); i = end + 1; continue; }\n }\n }\n // Link [label](href): href sanitised; an unsafe scheme drops to the label text.\n if (ch === \"[\") {\n const close = s.indexOf(\"]\", i + 1);\n if (close > i && s[close + 1] === \"(\") {\n const paren = s.indexOf(\")\", close + 2);\n if (paren > close) {\n const href = mdSafeHref(s.slice(close + 2, paren));\n const label = mdInline(s.slice(i + 1, close));\n flush();\n if (href) nodes.push(el(\"a\", { href: href, target: \"_blank\", rel: \"noopener noreferrer\" }, ...label));\n else for (const l of label) nodes.push(l);\n i = paren + 1; continue;\n }\n }\n }\n buf += ch; i++;\n }\n flush();\n return nodes;\n}\n// Regexes that classify a block-opening line; shared by the block loop and the\n// paragraph terminator so the two can't disagree on where a paragraph ends.\nconst MD_HEADING = /^(#{1,6})\\s+(.*)$/;\nconst MD_QUOTE = /^>\\s?/;\nconst MD_UL = /^[-*+]\\s+/;\nconst MD_OL = /^\\d+[.)]\\s+/;\nconst MD_HR = /^([-*_])(?:\\s*\\1){2,}\\s*$/;\n/** @param {string} t */\nfunction mdBlockStart(t) {\n return t.slice(0, 3) === MD_BACKTICK + MD_BACKTICK + MD_BACKTICK\n || MD_HEADING.test(t) || MD_QUOTE.test(t) || MD_UL.test(t) || MD_OL.test(t) || MD_HR.test(t);\n}\n// Parse a markdown string into an array of block-level DOM nodes.\n/** @param {any} src\n * @returns {HTMLElement[]}\n */\nfunction mdToNodes(src) {\n /** @type {HTMLElement[]} */\n const out = [];\n const fence = MD_BACKTICK + MD_BACKTICK + MD_BACKTICK;\n const lines = String(src == null ? \"\" : src).replace(/\\r\\n?/g, \"\\n\").split(\"\\n\");\n let i = 0;\n while (i < lines.length) {\n const t = lines[i].trim();\n if (t === \"\") { i++; continue; }\n // Fenced code block — content is verbatim (no inline parsing).\n if (t.slice(0, 3) === fence) {\n const body = [];\n i++;\n while (i < lines.length && lines[i].trim().slice(0, 3) !== fence) { body.push(lines[i]); i++; }\n if (i < lines.length) i++; // consume the closing fence\n out.push(el(\"pre\", { class: \"pc-md-pre\" }, el(\"code\", {}, body.join(\"\\n\"))));\n continue;\n }\n // Thematic break (checked before lists so \"***\"/\"---\" isn't read as a bullet).\n if (MD_HR.test(t)) { out.push(el(\"hr\", { class: \"pc-md-hr\" })); i++; continue; }\n // ATX heading.\n const h = MD_HEADING.exec(t);\n if (h) { out.push(el(\"h\" + h[1].length, { class: \"pc-md-h\" }, ...mdInline(h[2].trim()))); i++; continue; }\n // Blockquote — strip one '>' per line and re-parse the inner block(s).\n if (MD_QUOTE.test(t)) {\n const inner = [];\n while (i < lines.length && MD_QUOTE.test(lines[i].trim())) { inner.push(lines[i].trim().replace(MD_QUOTE, \"\")); i++; }\n const bq = el(\"blockquote\", { class: \"pc-md-quote\" });\n for (const b of mdToNodes(inner.join(\"\\n\"))) bq.append(b);\n out.push(bq); continue;\n }\n // Unordered list.\n if (MD_UL.test(t)) {\n const ul = el(\"ul\", { class: \"pc-md-list\" });\n while (i < lines.length && MD_UL.test(lines[i].trim())) {\n ul.append(el(\"li\", {}, ...mdInline(lines[i].trim().replace(MD_UL, \"\"))));\n i++;\n }\n out.push(ul); continue;\n }\n // Ordered list.\n if (MD_OL.test(t)) {\n const ol = el(\"ol\", { class: \"pc-md-list\" });\n while (i < lines.length && MD_OL.test(lines[i].trim())) {\n ol.append(el(\"li\", {}, ...mdInline(lines[i].trim().replace(MD_OL, \"\"))));\n i++;\n }\n out.push(ol); continue;\n }\n // Paragraph — soft-wrapped lines joined with a space until a blank line or the\n // next block opener.\n const para = [];\n while (i < lines.length) {\n const lt = lines[i].trim();\n if (lt === \"\" || mdBlockStart(lt)) break;\n para.push(lt); i++;\n }\n out.push(el(\"p\", { class: \"pc-md-p\" }, ...mdInline(para.join(\" \"))));\n }\n return out;\n}\n\n// A data-bound prose/markdown list (#274). Binds a datasource like dataGrid\n// (props.data = { source, table, orderBy, filter }) but renders each row as a\n// stacked prose block — a small header template (props.header, e.g.\n// \"Round {{round}} · {{approved}}\") over one body field (props.body) rendered as\n// sanitised markdown at a comfortable measure (props.measure ch, ~66 default).\n// This is the primitive for narrative records (plan-review findings, coordination\n// notes, escalation bodies) that read as a document, not a squeezed table cell.\n// It inherits collapsible/defaultCollapsed for free via makeCollapsible (which\n// unwraps the <h2> title), and refreshMs polling + pc:refresh like dataGrid.\n/** @param {any} node */\nfunction renderProse(node) {\n const p = node.props || {};\n const data = p.data || {};\n const card = el(\"section\", { class: \"pc-card\" });\n if (p.title) card.append(el(\"h2\", {}, p.title));\n const listEl = el(\"div\", { class: \"pc-prose-list\" });\n card.append(listEl);\n const headerTpl = typeof p.header === \"string\" ? p.header : null;\n const bodyField = p.body;\n // Clamp the reading measure to a sane range so a malformed schema can't set a\n // 5000ch (full-bleed) or 2ch (one-word-per-line) body. ~66ch is the default.\n let measure = Number(p.measure);\n if (!Number.isFinite(measure)) measure = 66;\n measure = Math.max(40, Math.min(100, Math.round(measure)));\n /** @type {any[]} */\n const activeFilter = Array.isArray(data.filter) ? data.filter : [];\n\n /** @param {Record<string, any>} row */\n function itemFor(row) {\n const item = el(\"article\", { class: \"pc-prose-item\" });\n if (headerTpl) item.append(el(\"div\", { class: \"pc-prose-head\" }, interpTemplate(headerTpl, row)));\n const bodyEl = el(\"div\", { class: \"pc-prose-body\", style: \"max-width:\" + measure + \"ch\" });\n // Own-property gate (matching interpTemplate) so a body field name that\n // collides with a prototype key can't pick up prototype cruft.\n const raw = bodyField != null && Object.prototype.hasOwnProperty.call(row, bodyField) && row[bodyField] != null\n ? String(row[bodyField])\n : \"\";\n for (const b of mdToNodes(raw)) bodyEl.append(b);\n item.append(bodyEl);\n return item;\n }\n\n async function refresh() {\n try {\n // A param-scoped list (\"show the selected entity's records\") with no route\n // param present renders nothing rather than a field=empty-string query.\n const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);\n /** @type {{ rows: Array<Record<string, any>> }} */\n /** @type {{ rows: Array<Record<string, any>> }} */\n const { rows } = paramScoped && PARAM === \"\"\n ? { rows: [] }\n : await getJSON(dataUrl(data.source, data.table, activeFilter, data.orderBy));\n listEl.replaceChildren();\n if (!rows.length) { listEl.append(el(\"p\", { class: \"pc-prose-empty\" }, p.empty || \"No records\")); return; }\n for (const row of rows) listEl.append(itemFor(row));\n } catch (e) {\n listEl.replaceChildren(el(\"p\", { class: \"pc-msg err\" }, String((e && e.message) || e)));\n }\n }\n document.addEventListener(\"pc:refresh\", refresh);\n disposers.push(() => document.removeEventListener(\"pc:refresh\", refresh));\n if (p.refreshMs && p.refreshMs > 0) {\n const timer = setInterval(refresh, p.refreshMs);\n disposers.push(() => clearInterval(timer));\n }\n refresh();\n return card;\n}\n\n// Build a detail/escalation answer form for a single grid row. This is the ONE\n// implementation of the `detail.form` contract — shared verbatim by the\n// top-level grid's detail panel AND a dataGrid child-grid row (#333) so the two\n// can never drift. The form is SCHEMA-DERIVED (#372): its fields come from the\n// element's `.form` contract, not hand-rolled markup. A form may declare either a\n// single free-text field (legacy: f.inputKey, rendered exactly as before) or an\n// array of field descriptors (f.fields) — each a `select` (enumerated\n// value/label options), `textarea`, or `text` widget bound to its own\n// f.inputKey, optionally gated by a per-field `conditional.hide` FEEL expression\n// measured against the form's other field values. Every visible field is\n// assembled into ONE `{{form}}` body ({ inputKey: value, … }); a hidden field is\n// omitted. There is an optional prompt echoed from the row (f.promptField), and a\n// route-driven submit (f.action) whose path/body interpolate {{form.*}} /\n// {{row.*}} tokens. It\n// renders only when the row's f.showWhenField is truthy (an answerable\n// escalation), returning null otherwise. On a successful submit it invokes\n// onSuccess — the top-level grid re-polls the whole page (pc:refresh) so the\n// answered row drops out on the next tick; a child grid re-fetches just its own\n// rows, matching the Tasks page where the row disappears once its user-task\n// completes.\n// A disclosure chevron for expanding an inline detail row. This is the ONE place\n// a \"▸/▾\" toggle button is built, so the glyph and its `aria-expanded` state can\n// never drift apart (they used to be set independently, leaving assistive tech\n// unaware a row had opened). Every chevron gets an explicit type=\"button\" (so it\n// never submits an enclosing form) and an accessible name.\n/** @param {string} [label] @returns {HTMLElement} */\nfunction chevronToggle(label) {\n return el(\"button\", {\n class: \"pc-btn pc-btn-sm pc-chevron\",\n type: \"button\",\n \"aria-label\": label || \"Toggle details\",\n \"aria-expanded\": \"false\",\n }, \"▸\");\n}\n// Set a chevron's open/closed state, keeping the glyph and aria-expanded in lock-step.\n/** @param {HTMLElement} btn @param {boolean} open */\nfunction setChevronOpen(btn, open) {\n btn.textContent = open ? \"▾\" : \"▸\";\n btn.setAttribute(\"aria-expanded\", String(open));\n}\n\n// Normalise a `detail.form` descriptor to a list of field descriptors so the\n// legacy single-field form (f.inputKey + free-text textarea) and the\n// schema-derived multi-field form (f.fields) share ONE render/submit path (no\n// drift). A descriptor carrying f.fields is used verbatim; otherwise the whole\n// form collapses to a single textarea field bound to f.inputKey — which renders\n// byte-for-byte as the pre-#372 form.\n/** @param {any} f\n * @returns {any[]}\n */\nfunction normalizeDetailFields(f) {\n if (Array.isArray(f.fields) && f.fields.length > 0) return f.fields;\n return [{ inputKey: f.inputKey, type: \"textarea\", inputLabel: f.inputLabel }];\n}\n\n// Normalise a select field's enumerated values (from the `.form` contract) to\n// {value,label} pairs. Accepts either objects ({ value, label } — label\n// defaults to the value) or bare scalars (value === label), so an adopter can\n// author the terse or the labelled form without a bespoke shape.\n/** @param {any} options\n * @returns {Array<{value: string, label: string}>}\n */\nfunction normalizeDetailOptions(options) {\n /** @type {Array<{value: string, label: string}>} */\n const out = [];\n for (const opt of Array.isArray(options) ? options : []) {\n if (opt && typeof opt === \"object\") {\n const value = opt.value != null ? String(opt.value) : \"\";\n const label = opt.label != null ? String(opt.label) : value;\n out.push({ value, label });\n } else {\n out.push({ value: String(opt), label: String(opt) });\n }\n }\n return out;\n}\n\n// ── conditional.hide evaluation (a tiny, SAFE FEEL subset) ──────────────────\n// A `.form` field can carry `conditional.hide` — a FEEL boolean (optionally with\n// the FEEL `=` prefix, e.g. `=resolution != \"answer\"`) that, when true, hides\n// the field from the operator AND drops it from the submitted `{{form}}` body.\n// We evaluate it against the form's CURRENT field values with a purpose-built\n// recursive-descent parser — never `eval`/`new Function` — so a schema-supplied\n// string can't execute arbitrary code. The grammar is deliberately small:\n// or := and ( \"or\" and )*\n// and := cmp ( \"and\" cmp )*\n// cmp := primary ( op primary )? op ∈ = == != < <= > >=\n// primary := ident | string | number | true | false | null | \"(\" or \")\"\n// An identifier resolves to the same-form field's current value. Anything the\n// parser can't understand fails OPEN (returns false → the field stays visible),\n// because erroneously HIDING a field could silently drop a required input.\n/** @param {string} src\n * @returns {any[]}\n */\nfunction tokenizeDetailCondition(src) {\n /** @type {any[]} */\n const toks = [];\n const s = String(src);\n let i = 0;\n while (i < s.length) {\n const ch = s[i];\n if (ch === \" \" || ch === \"\\t\" || ch === \"\\n\" || ch === \"\\r\") { i++; continue; }\n if (ch === '\"' || ch === \"'\") {\n const quote = ch; let j = i + 1; let str = \"\"; let closed = false;\n while (j < s.length) {\n if (s[j] === quote) { closed = true; break; }\n if (s[j] === \"\\\\\" && j + 1 < s.length) { str += s[j + 1]; j += 2; } else { str += s[j]; j++; }\n }\n // An unterminated string literal can't be part of the grammar; abandon\n // parsing so the whole expression fails open (field stays visible) rather\n // than treating a truncated FEEL string as a valid token.\n if (!closed) throw new Error(\"unterminated string in condition\");\n toks.push({ t: \"val\", v: str }); i = j + 1; continue;\n }\n if (ch === \"(\") { toks.push({ t: \"(\" }); i++; continue; }\n if (ch === \")\") { toks.push({ t: \")\" }); i++; continue; }\n if (ch === \"!\" && s[i + 1] === \"=\") { toks.push({ t: \"op\", v: \"!=\" }); i += 2; continue; }\n if (ch === \"=\" && s[i + 1] === \"=\") { toks.push({ t: \"op\", v: \"=\" }); i += 2; continue; }\n if (ch === \"<\" && s[i + 1] === \"=\") { toks.push({ t: \"op\", v: \"<=\" }); i += 2; continue; }\n if (ch === \">\" && s[i + 1] === \"=\") { toks.push({ t: \"op\", v: \">=\" }); i += 2; continue; }\n if (ch === \"=\") { toks.push({ t: \"op\", v: \"=\" }); i++; continue; }\n if (ch === \"<\") { toks.push({ t: \"op\", v: \"<\" }); i++; continue; }\n if (ch === \">\") { toks.push({ t: \"op\", v: \">\" }); i++; continue; }\n if (/[0-9]/.test(ch) || (ch === \"-\" && /[0-9]/.test(s[i + 1] || \"\"))) {\n let j = i + 1;\n while (j < s.length && /[0-9.]/.test(s[j])) j++;\n toks.push({ t: \"val\", v: Number(s.slice(i, j)) }); i = j; continue;\n }\n if (/[A-Za-z_]/.test(ch)) {\n let j = i + 1;\n while (j < s.length && /[A-Za-z0-9_.]/.test(s[j])) j++;\n const word = s.slice(i, j);\n const lower = word.toLowerCase();\n if (lower === \"and\" || lower === \"or\") toks.push({ t: lower });\n else if (lower === \"true\") toks.push({ t: \"val\", v: true });\n else if (lower === \"false\") toks.push({ t: \"val\", v: false });\n else if (lower === \"null\") toks.push({ t: \"val\", v: null });\n else toks.push({ t: \"id\", v: word });\n i = j; continue;\n }\n // An unrecognised character can't be part of the grammar; abandon parsing so\n // the whole expression fails open (field stays visible) rather than silently\n // mis-parsing.\n throw new Error(\"unexpected character in condition\");\n }\n return toks;\n}\n/** @param {any} a @param {any} b @returns {boolean} */\nfunction detailLooseEq(a, b) {\n if (a === b) return true;\n if (a == null || b == null) return a == null && b == null;\n return String(a) === String(b);\n}\n/** @param {string} op @param {any} a @param {any} b @returns {boolean} */\nfunction detailCompare(op, a, b) {\n if (op === \"=\") return detailLooseEq(a, b);\n if (op === \"!=\") return !detailLooseEq(a, b);\n const na = Number(a); const nb = Number(b);\n if (!Number.isFinite(na) || !Number.isFinite(nb)) return false;\n if (op === \"<\") return na < nb;\n if (op === \"<=\") return na <= nb;\n if (op === \">\") return na > nb;\n if (op === \">=\") return na >= nb;\n return false;\n}\n// A bare (op-less) primary used where a boolean is expected — a FEEL flag like\n// `=isUrgent`. Only an explicit boolean true / the string \"true\" reads as true.\n/** @param {any} v @returns {boolean} */\nfunction detailTruthy(v) { return v === true || v === \"true\"; }\n/** @param {any} f\n * @param {Record<string, any>} values\n * @returns {boolean}\n */\nfunction evalDetailCondition(f, values) {\n /** @param {any} p @returns {boolean} */\n const parseOr = (p) => {\n let left = parseAnd(p);\n while (p.i < p.toks.length && p.toks[p.i].t === \"or\") { p.i++; const right = parseAnd(p); left = left || right; }\n return left;\n };\n /** @param {any} p @returns {boolean} */\n function parseAnd(p) {\n let left = parseCmp(p);\n while (p.i < p.toks.length && p.toks[p.i].t === \"and\") { p.i++; const right = parseCmp(p); left = left && right; }\n return left;\n }\n /** @param {any} p @returns {boolean} */\n function parseCmp(p) {\n const left = parsePrimary(p);\n const tok = p.toks[p.i];\n if (tok && tok.t === \"op\") { p.i++; const right = parsePrimary(p); return detailCompare(tok.v, left, right); }\n return detailTruthy(left);\n }\n /** @param {any} p @returns {any} */\n function parsePrimary(p) {\n const tok = p.toks[p.i];\n if (!tok) throw new Error(\"unexpected end of condition\");\n if (tok.t === \"(\") { p.i++; const r = parseOr(p); if (!p.toks[p.i] || p.toks[p.i].t !== \")\") throw new Error(\"missing )\"); p.i++; return r; }\n if (tok.t === \"id\") { p.i++; return values[tok.v]; }\n if (tok.t === \"val\") { p.i++; return tok.v; }\n throw new Error(\"unexpected token in condition\");\n }\n try {\n let src = String(f == null ? \"\" : f).trim();\n if (src === \"\") return false;\n if (src.startsWith(\"=\")) src = src.slice(1);\n const p = { toks: tokenizeDetailCondition(src), i: 0 };\n if (p.toks.length === 0) return false;\n const result = parseOr(p);\n if (p.i !== p.toks.length) return false;\n return result === true;\n } catch (e) {\n return false;\n }\n}\n\n/** @param {any} f\n * @param {Record<string, any>} row\n * @param {() => void | Promise<void>} onSuccess\n * @returns {HTMLElement|null}\n */\nfunction buildDetailForm(f, row, onSuccess) {\n if (!f || !row[f.showWhenField]) return null;\n const box = el(\"div\", { class: \"pc-subform\" });\n if (f.title) box.append(el(\"div\", { class: \"pc-subform-title\" }, f.title));\n if (f.promptField && row[f.promptField] != null) {\n box.append(el(\"div\", { class: \"pc-prompt\" }, String(row[f.promptField])));\n }\n // Multi-field only when the descriptor supplies f.fields; a legacy single-field\n // form renders exactly as before (one bare textarea, no visible <label>).\n const multi = Array.isArray(f.fields) && f.fields.length > 0;\n const fields = normalizeDetailFields(f);\n // role=status + aria-live so the \"Sending…\"/success/error transitions are\n // announced to screen readers instead of silently changing text.\n const msg = el(\"p\", { class: \"pc-msg\", role: \"status\", \"aria-live\": \"polite\" });\n const btn = el(\"button\", { class: \"pc-btn pc-btn-sm\", type: \"button\" }, f.submitLabel || \"Submit\");\n /** @type {Array<{key: string, input: any, wrap: any, hideExpr: any}>} */\n const controls = [];\n for (const fd of fields) {\n const key = fd.inputKey != null ? fd.inputKey : fd.key;\n const label = fd.inputLabel || fd.label || key;\n const type = fd.type === \"select\" ? \"select\" : fd.type === \"text\" ? \"text\" : \"textarea\";\n let input;\n if (type === \"select\") {\n input = el(\"select\", { class: \"pc-select\", \"aria-label\": label });\n for (const opt of normalizeDetailOptions(fd.options)) input.append(el(\"option\", { value: opt.value }, opt.label));\n if (fd.default != null) input.value = String(fd.default);\n } else if (type === \"text\") {\n input = el(\"input\", { type: \"text\", class: \"pc-input\", placeholder: label, \"aria-label\": label });\n if (fd.default != null) input.value = String(fd.default);\n } else {\n input = el(\"textarea\", { class: \"pc-textarea\", placeholder: label, \"aria-label\": label });\n if (fd.default != null) input.value = String(fd.default);\n }\n // A multi-field form gets a visible <label> per widget; the legacy single\n // free-text form keeps its bare textarea (unchanged DOM).\n const wrap = multi\n ? el(\"div\", { class: \"pc-field\" }, el(\"label\", {}, label), input)\n : el(\"div\", { class: \"pc-field\" }, input);\n const hideExpr = fd.conditional && typeof fd.conditional.hide === \"string\" ? fd.conditional.hide : null;\n controls.push({ key, input, wrap, hideExpr });\n box.append(wrap);\n }\n // Snapshot every field's current value, then hide/show each conditional field.\n // A field with no conditional.hide is always visible; the value map is keyed by\n // inputKey so a hide expression can reference a sibling field (e.g. `answer`\n // hidden when `resolution != \"answer\"`).\n const applyConditions = () => {\n /** @type {Record<string, any>} */\n const values = Object.create(null);\n for (const c of controls) values[c.key] = c.input.value;\n for (const c of controls) {\n const hidden = c.hideExpr != null && evalDetailCondition(c.hideExpr, values);\n c.wrap.hidden = hidden;\n }\n };\n // Re-evaluate the conditions whenever any field changes so a select flip\n // reveals/hides its dependent field live.\n if (controls.some((c) => c.hideExpr != null)) {\n for (const c of controls) {\n c.input.addEventListener(\"change\", applyConditions);\n c.input.addEventListener(\"input\", applyConditions);\n }\n applyConditions();\n }\n btn.addEventListener(\"click\", async () => {\n btn.disabled = true; msg.className = \"pc-msg\"; msg.textContent = \"Sending…\";\n try {\n // Assemble every VISIBLE field into one form body, keyed by its inputKey;\n // route + body template come from f.action, e.g. { path: \"/app/actions/message\",\n // body: { name, correlationKey: \"{{row.pr_key}}\", variables: \"{{form}}\" } }.\n // Null-proto so an inputKey of \"__proto__\"/\"constructor\" can't mutate a\n // prototype. A hidden (conditional.hide) field is omitted so a stale value\n // for an inapplicable choice never reaches the action.\n const form = Object.create(null);\n for (const c of controls) { if (c.wrap.hidden) continue; form[c.key] = c.input.value; }\n await runRoute(f.action, { form, row });\n msg.className = \"pc-msg ok\"; msg.textContent = (f.action && f.action.successLabel) || \"Sent\";\n // Await onSuccess so a refresh that rejects (e.g. a child grid's re-fetch\n // failing) is surfaced here and re-enables the button below, instead of the\n // operator being stuck on a permanently-disabled button with a silent\n // failure. On success the refresh detaches/rebuilds this form, so leaving\n // the button disabled is moot; we only re-enable on the error path.\n await onSuccess();\n } catch (e) {\n btn.disabled = false; msg.className = \"pc-msg err\"; msg.textContent = String(e.message || e);\n }\n });\n box.append(btn, msg);\n return box;\n}\n\n// Build a dataGrid detail's ENGINE-declared form (ADR 0026, #457). Where\n// buildDetailForm renders a page-authored `detail.form` (fields duplicated from a\n// deployed `.form`), this resolves the row's engine `formKey` and renders the\n// AUTHORITATIVE deployed form-js schema — so one grid can render the several\n// different typed forms carried by a heterogeneous task inbox, and no page-local\n// copy can drift from the deployed form. It reuses the ONE shared form-js renderer\n// (globalThis.NanoFormJs, embedded by the shell) and the shared `/app/actions/form`\n// resolution gate + `/app/actions/complete` completion — the same seams the\n// taskInbox surface uses, no fork.\n//\n// `cfg.formKeyField` (default \"form_key\") names the row column carrying the engine\n// formKey; `cfg.userTaskKeyField` (default \"user_task_key\") names the column\n// carrying the user-task key to complete. A row with no formKey degrades to a bare\n// \"Complete\" button (empty variables), matching taskInbox's no-form fallback.\n/** @param {any} cfg\n * @param {Record<string, any>} row\n * @param {() => void | Promise<void>} onSuccess\n * @returns {HTMLElement|null}\n */\nfunction buildEngineForm(cfg, row, onSuccess) {\n if (!cfg) return null;\n const userTaskKey = row[cfg.userTaskKeyField || \"user_task_key\"];\n // Without a user-task key there is nothing to complete — render nothing rather\n // than a dead button.\n if (userTaskKey == null || userTaskKey === \"\") return null;\n const formKey = row[cfg.formKeyField || \"form_key\"];\n const box = el(\"div\", { class: \"pc-subform\" });\n const msg = el(\"p\", { class: \"njf-msg\", role: \"status\", \"aria-live\": \"polite\" });\n // Optional page-author knob: route completion through the app's own canonical\n // door instead of the runtime's generic seam. Defaults to /app/actions/complete\n // when unset (existing pages unchanged). Same trust class as detail.form's\n // action.path; rebased through apiUrl() by getJSON like every other page fetch.\n const completePath =\n typeof cfg.completePath === \"string\" && cfg.completePath.trim() !== \"\"\n ? cfg.completePath.trim()\n : \"/app/actions/complete\";\n /** @param {Record<string, any>} variables */\n const complete = (variables) =>\n getJSON(completePath, {\n method: \"POST\",\n headers: { \"content-type\": \"application/json\" },\n body: JSON.stringify({ userTaskKey, variables }),\n }).then(() => onSuccess());\n // No renderable form → the bare completion path (parity with taskInbox).\n const renderBare = () => {\n const bar = el(\"div\", { class: \"njf-actions\" });\n const b = el(\"button\", { class: \"pc-btn pc-btn-sm\", type: \"button\" }, \"Complete\");\n b.addEventListener(\"click\", async () => {\n b.disabled = true;\n msg.className = \"njf-msg\";\n msg.textContent = \"Sending\\u2026\";\n try {\n await complete(Object.create(null));\n } catch (e) {\n b.disabled = false;\n msg.className = \"njf-msg err\";\n msg.textContent = String(e.message || e);\n }\n });\n bar.append(b);\n box.append(bar, msg);\n };\n if (formKey == null || formKey === \"\") {\n renderBare();\n return box;\n }\n box.append(el(\"p\", { class: \"njf-text\" }, \"loading form\\u2026\"));\n getJSON(\"/app/actions/form?formKey=\" + encodeURIComponent(String(formKey)))\n .then((f) => {\n box.replaceChildren();\n const renderer = /** @type {any} */ (globalThis).NanoFormJs;\n if (!f || !f.schema || !renderer) {\n renderBare();\n return;\n }\n box.append(\n renderer.renderForm(f.schema, {\n submitLabel: \"Complete\",\n /** @param {Record<string, any>} variables */\n onSubmit: (variables) => {\n msg.className = \"njf-msg\";\n msg.textContent = \"Sending\\u2026\";\n // Surface completion failures to the operator (parity with the bare-completion\n // path). Rethrow so renderForm's catch still re-enables the submit button.\n return complete(variables).catch((e) => {\n msg.className = \"njf-msg err\";\n msg.textContent = String(e.message || e);\n throw e;\n });\n },\n }),\n msg,\n );\n })\n .catch((e) => {\n box.replaceChildren();\n msg.className = \"njf-msg err\";\n msg.textContent = \"Failed to load form: \" + String(e.message || e);\n box.append(msg);\n });\n return box;\n}\n\n/** @param {any} node */\nfunction renderDataGrid(node) {\n const p = node.props;\n const card = el(\"section\", { class: \"pc-card\" });\n // Opt-in live row-count badge (\"showCount\"). Rendered inside the <h2> so a plain\n // (non-collapsible) grid surfaces it beside the title; makeCollapsible re-homes it\n // into the collapse header so it stays visible — and refresh-updated — while the\n // section is collapsed. refresh() below rewrites its text on every poll/pc:refresh.\n const countBadge = p.showCount ? el(\"span\", { class: \"pc-badge pc-count-badge\", \"aria-label\": rowCountLabel(0) }, \"0\") : null;\n if (p.title || countBadge) {\n const h2 = el(\"h2\", {}, p.title || \"\");\n if (countBadge) { if (p.title) h2.append(\" \"); h2.append(countBadge); }\n card.append(h2);\n }\n /** @type {any[]} */\n const cols = p.columns || [];\n // Mobile card roles per column (derived by convention, refined by\n // col.mobile.priority) so the grid can flip to a stacked card list on a phone\n // (#268). 'hasHidden' gates the per-row \"More\" toggle that reveals the columns\n // a mobile:{priority:\"hidden\"} hint drops off the card.\n const roles = classifyColumns(cols);\n const hasHidden = roles.indexOf(\"hidden\") >= 0;\n /** @type {any[]} */\n const tabs = p.tabs || [];\n /** @type {any[]} */\n const rowActions = p.rowActions || [];\n const detail = p.detail || null;\n const hasExtra = rowActions.length > 0 || detail != null;\n /** @type {any[]} */\n let activeFilter = p.data.filter || [];\n\n if (tabs.length) {\n const bar = el(\"div\", { class: \"pc-tabs\" });\n activeFilter = tabs[0].filter || [];\n tabs.forEach((t, i) => {\n const b = el(\"button\", { class: \"pc-tab\" + (i === 0 ? \" active\" : \"\") }, t.label);\n b.addEventListener(\"click\", () => {\n activeFilter = t.filter || [];\n for (const c of bar.children) c.classList.remove(\"active\");\n b.classList.add(\"active\");\n refresh();\n });\n bar.append(b);\n });\n card.append(bar);\n }\n\n const headCells = cols.map((c) => el(\"th\", {}, c.header || c.field));\n if (hasExtra) headCells.push(el(\"th\", {}, \"\"));\n const thead = el(\"thead\", {}, el(\"tr\", {}, ...headCells));\n const tbody = el(\"tbody\", {});\n // A <colgroup> (present only when a column declares width/weight) sizes the\n // columns so table-layout:fixed stops splitting width equally; it must precede\n // <thead> in the table.\n const colgroup = buildColgroup(cols, hasExtra ? 1 : 0);\n const table = colgroup\n ? el(\"table\", { class: \"pc-grid\" }, colgroup, thead, tbody)\n : el(\"table\", { class: \"pc-grid\" }, thead, tbody);\n card.append(table);\n const span = String((cols.length || 1) + (hasExtra ? 1 : 0));\n\n // Expansion state has to outlive the poll: refresh() rebuilds the whole tbody\n // every refreshMs, so without this an open detail row (where you answer an\n // escalation) would collapse on the next tick. We remember which rowKeys are\n // open, and reuse the already-built detail <tr> for them across refreshes so a\n // half-typed answer survives too. Keyed by p.rowKey; grids without one keep the\n // old (collapse-on-refresh) behavior.\n const expanded = new Set();\n const detailNodes = new Map();\n // A row only participates in expansion/detail-node caching if it has a real\n // (non-null) rowKey. Coercing a missing key through String() would yield the\n // literal \"undefined\"/\"null\" and collide unrelated rows, so treat it as keyless.\n /** @param {Record<string, any>} row\n * @returns {string|null}\n */\n const rowKeyOf = (row) => {\n if (!p.rowKey) return null;\n const v = row[p.rowKey];\n return v == null ? null : String(v);\n };\n\n /** @param {any} action\n * @param {Record<string, any>} row\n */\n async function fireAction(action, row) {\n // Route-driven row action: POST the action's body template (default {})\n // to its route path, with {{row.KEY}} tokens resolved from this row.\n return runRoute(action, { row });\n }\n\n /** @param {Record<string, any>} row\n * @param {any} ra\n * @returns {HTMLButtonElement|null}\n */\n function rowActionButton(row, ra) {\n if (ra.showWhenField && !row[ra.showWhenField]) return null;\n const b = el(\"button\", { class: \"pc-btn pc-btn-sm\" }, ra.label);\n b.addEventListener(\"click\", /** @param {MouseEvent} ev */ async (ev) => {\n ev.stopPropagation();\n // In-DOM confirm/error (openModal) instead of native confirm()/alert():\n // a sandboxed iframe without allow-modals makes native confirm() return\n // false and alert() a no-op, so every confirm-guarded action silently\n // dead-ended and its errors vanished (#276).\n if (ra.confirm && !(await confirmModal(ra.confirm))) return;\n b.disabled = true;\n try {\n await fireAction(ra.action, row);\n document.dispatchEvent(new CustomEvent(\"pc:refresh\"));\n } catch (e) {\n b.disabled = false;\n alertModal(String(e.message || e));\n }\n });\n return b;\n }\n\n /** @param {any} cg\n * @param {Record<string, any>} row\n */\n async function childGrid(cg, row) {\n const wrap = el(\"div\", { class: \"pc-child\" });\n if (cg.title) wrap.append(el(\"div\", { class: \"pc-child-title\" }, cg.title));\n /** @type {any[]} */\n const ccols = cg.columns || [];\n const croles = classifyColumns(ccols);\n const chasHidden = croles.indexOf(\"hidden\") >= 0;\n // Per-row expansion for child grids: a `detail` (alias `expandable`) block\n // gives each child row a chevron that reveals the same detailPanel() content\n // the top-level grid uses (link + fields + nested child grids + form). Rows are\n // collapsed by default; `detail.defaultCollapsed`/`detail.collapsed` (default\n // true) seeds the initial state, and — when the child grid declares a `rowKey`\n // — the per-row collapse is persisted in localStorage keyed by page + node +\n // parent rowKey + child rowKey, so it survives the top-level grid's refresh\n // poll (which rebuilds this whole subtree) and a full reload, mirroring how\n // groupBy group-collapse is persisted. Absent a `detail` block the child grid\n // is byte-for-byte unchanged (backward compatible). A `detail.form` (#333) is\n // rendered inside that panel by the shared detailPanel/buildDetailForm, and its\n // submit re-fetches only this child grid (see load() below) rather than the\n // whole page — so answering a nested escalation drops the answered row without\n // disturbing the parent grid.\n const cdetail = cg.detail || cg.expandable || null;\n const chasExpand = cdetail != null;\n const cextra = (cg.lazyField ? 1 : 0) + (chasExpand ? 1 : 0);\n const cdefaultCollapsed = chasExpand\n ? (cdetail.defaultCollapsed != null\n ? !!cdetail.defaultCollapsed\n : cdetail.collapsed != null\n ? !!cdetail.collapsed\n : true)\n : true;\n const cnodeId = node.id || p.title || \"grid\";\n const cchildId = cg.node || cg.table || cg.title || \"child\";\n // The parent-row discriminator is the value this child grid is queried by\n // (row[cg.parentField]) — NOT the top-level grid's rowKey. That keeps per-row\n // collapse state unique per parent row even when the parent grid declares no\n // rowKey (rowKeyOf(row) would then be null and every parent row would collide\n // under the literal \"_\"). When no parent discriminator is available there is no\n // safe key, so persistence is disabled (ckeyBase null) instead of colliding.\n const cparentKey = cg.parentField != null && row[cg.parentField] != null\n ? String(row[cg.parentField]) : null;\n const ckeyBase = cparentKey == null ? null\n : \"pc:collapsed:\" + CURRENT + \":\" + cnodeId + \":\" + cchildId + \":\" + cparentKey + \":r:\";\n /** @param {Record<string, any>} cr @returns {string|null} */\n const crowKeyOf = (cr) => {\n if (!cg.rowKey) return null;\n const v = cr[cg.rowKey];\n return v == null ? null : String(v);\n };\n const cbody = el(\"tbody\", {});\n const ccolgroup = buildColgroup(ccols, cextra);\n const cthead = el(\"thead\", {}, el(\"tr\", {}, ...ccols.map((c) => el(\"th\", {}, c.header || c.field)),\n ...(cg.lazyField ? [el(\"th\", {}, \"\")] : []),\n ...(chasExpand ? [el(\"th\", {}, \"\")] : [])));\n const ctable = ccolgroup\n ? el(\"table\", { class: \"pc-grid\" }, ccolgroup, cthead, cbody)\n : el(\"table\", { class: \"pc-grid\" }, cthead, cbody);\n wrap.append(ctable);\n // Compute the colspan eagerly (before the fetch) so BOTH the success path and\n // the catch's error row have a valid, table-spanning colspan — if getJSON()\n // throws, a lazily-assigned cspan would still be undefined here.\n const cspan = String((ccols.length || 1) + cextra);\n // Re-fetch and repaint just this child grid's rows. Wired to the child detail\n // panel's form onSuccess so answering a nested escalation drops the row once\n // its user-task completes — the child-scoped mirror of the top-level grid's\n // pc:refresh re-poll, without disturbing the parent grid or its other detail\n // rows (which a global pc:refresh would leave untouched anyway, since open\n // detail rows are reused across the poll).\n async function load() {\n /** @type {{ rows: Array<Record<string, any>> }} */\n const { rows } = await getJSON(dataUrl(cg.source || \"app\", cg.table,\n [{ field: cg.childField, eq: row[cg.parentField] }], cg.orderBy));\n cbody.replaceChildren();\n if (!rows.length) {\n cbody.append(el(\"tr\", {}, el(\"td\", { colspan: cspan }, \"None\")));\n return;\n }\n for (const cr of rows) {\n const cells = ccols.map((c, i) => gridCell(c, cr, croles[i]));\n if (cg.lazyField) {\n const lf = cg.lazyField;\n const has = cr[lf.field] != null && String(cr[lf.field]).trim() !== \"\";\n const cell = el(\"td\", {});\n if (has) {\n const toggle = el(\"button\", { class: \"pc-btn pc-btn-sm\" }, lf.label || \"Show\");\n const pre = el(\"pre\", { class: \"pc-transcript\", hidden: \"\" });\n pre.textContent = String(cr[lf.field]);\n toggle.addEventListener(\"click\", () => {\n pre.hidden = !pre.hidden;\n toggle.textContent = pre.hidden ? (lf.label || \"Show\") : \"Hide\";\n });\n cell.append(toggle, pre);\n }\n cells.push(cell);\n }\n // Per-row expander cell (trailing, after any lazyField cell): a chevron\n // that reveals this row's detailPanel in a following full-width row. The\n // panel is built lazily on first open (and eagerly when the row starts\n // expanded), matching the top-level grid. The panel's escalation form (if\n // any) is wired to load() so answering it re-fetches only this child grid.\n // The chevron uses the shared chevronToggle()/setChevronOpen() so its glyph\n // and aria-expanded stay in lock-step for assistive tech.\n /** @type {any} */\n let cdtr = null;\n if (chasExpand) {\n const crk = crowKeyOf(cr);\n const cskey = ckeyBase && crk != null ? ckeyBase + crk : null;\n const collapsed = cskey ? readCollapsed(cskey, cdefaultCollapsed) : cdefaultCollapsed;\n const ctoggle = chevronToggle(\"Toggle row details\");\n setChevronOpen(ctoggle, !collapsed);\n cells.push(el(\"td\", { class: \"pc-row-actions\" }, ctoggle));\n cdtr = el(\"tr\", { hidden: collapsed ? \"\" : null }, el(\"td\", { colspan: cspan }));\n let built = false;\n const build = () => {\n // Return the reload promise (don't swallow it) so buildDetailForm can\n // surface a failed re-fetch and re-enable its submit button instead of\n // leaving the operator stuck on a false \"Sent\".\n if (!built) { built = true; cdtr.firstChild.append(detailPanel(cr, cdetail, () => load())); }\n };\n if (!collapsed) build();\n ctoggle.addEventListener(\"click\", /** @param {MouseEvent} ev */ (ev) => {\n ev.stopPropagation();\n const open = cdtr.hidden;\n cdtr.hidden = !open;\n setChevronOpen(ctoggle, open);\n if (open) build();\n if (cskey) writeCollapsed(cskey, !open);\n });\n }\n // Child grids classify columns into the same mobile roles (including\n // \"hidden\") as the top-level grid, so they need the same per-row \"More\"\n // toggle — without it a mobile:{priority:\"hidden\"} child column would be\n // permanently unreachable on a narrow viewport (#268).\n const ctr = el(\"tr\", {}, ...cells);\n if (chasHidden) ctr.append(mobileMoreCell(ctr));\n cbody.append(ctr);\n if (cdtr) cbody.append(cdtr);\n }\n }\n try {\n await load();\n } catch (e) {\n cbody.replaceChildren(el(\"tr\", {}, el(\"td\", { colspan: cspan }, String(e.message || e))));\n }\n return wrap;\n }\n\n /** Build an expandable-row detail panel. Used by the top-level grid (with the\n * closure `detail` config) and by child grids (which pass their own `cg.detail`\n * as `cfg`) so both share one look/behaviour — link, fields, nested child grids\n * and an optional escalation form.\n * @param {Record<string, any>} row\n * @param {any} [cfg] detail config; defaults to the top-level grid's `detail`\n * @param {(() => void)} [onSuccess] invoked after the panel's escalation form\n * submits successfully. Defaults to a whole-page `pc:refresh` re-poll (the\n * top-level grid's behaviour); a child grid passes a child-scoped reload so\n * answering a nested escalation refreshes only that child grid, not the page.\n */\n function detailPanel(row, cfg, onSuccess) {\n const d = cfg || detail;\n const box = el(\"div\", { class: \"pc-detail\" });\n if (d.linkField && row[d.linkField]) {\n const href = String(row[d.linkField]);\n // Render as a link only for http(s); anything else (e.g. a javascript: URL\n // smuggled through row data) is shown as inert text. External links get\n // rel=\"noopener noreferrer\" so the opened page can't reach window.opener.\n if (/^https?:\\/\\//i.test(href)) {\n box.append(el(\"a\", { class: \"pc-link\", href, target: \"_blank\", rel: \"noopener noreferrer\" }, href));\n } else {\n box.append(el(\"span\", { class: \"pc-link\" }, href));\n }\n }\n for (const df of d.fields || []) {\n box.append(el(\"div\", { class: \"pc-detail-field\" },\n el(\"span\", { class: \"pc-detail-label\" }, df.label || df.field),\n el(\"span\", {}, row[df.field] == null ? \"\" : String(row[df.field]))));\n }\n for (const cg of d.children || []) {\n const holder = el(\"div\", {});\n box.append(holder);\n childGrid(cg, row).then((w) => holder.replaceChildren(w));\n }\n const form = buildDetailForm(d.form, row, onSuccess || (() => { document.dispatchEvent(new CustomEvent(\"pc:refresh\")); }));\n if (form) box.append(form);\n // An engine-declared form (ADR 0026/#457) sits ALONGSIDE the page-authored\n // `detail.form`, not replacing it: a grid may render the deployed form-js schema\n // for the row's user task instead of (or in addition to) a hand-authored copy.\n const engineForm = buildEngineForm(d.engineForm, row, onSuccess || (() => { document.dispatchEvent(new CustomEvent(\"pc:refresh\")); }));\n if (engineForm) box.append(engineForm);\n return box;\n }\n\n /** @param {Record<string, any>} row\n * @param {Array<{ tr: HTMLTableRowElement, isDetail: boolean, key: string|null }>|null} sink\n */\n function renderRow(row, sink) {\n const cells = cols.map((c, i) => gridCell(c, row, roles[i]));\n const key = rowKeyOf(row);\n let toggle = null;\n if (hasExtra) {\n const actionCell = el(\"td\", { class: \"pc-row-actions\" });\n if (detail) {\n toggle = chevronToggle(\"Toggle row details\");\n actionCell.append(toggle);\n }\n for (const ra of rowActions) {\n const b = rowActionButton(row, ra);\n if (b) actionCell.append(b);\n }\n cells.push(actionCell);\n }\n // Per-row \"More\" toggle: only when a mobile:{priority:\"hidden\"} hint drops\n // columns off the card. Appended after the <tr> is built (the toggle wires a\n // click against its own row) and shared with child grids via mobileMoreCell so\n // the reveal affordance and its aria state can't drift (#268).\n const tr = el(\"tr\", {}, ...cells);\n if (hasHidden) tr.append(mobileMoreCell(tr));\n tbody.append(tr);\n // When grouping, the caller collects every <tr> this row produced (the data\n // row plus any detail row) so a group header can hide/show them as one unit.\n if (sink) sink.push({ tr, isDetail: false, key });\n if (detail && toggle) {\n const isOpen = key != null && expanded.has(key);\n // Reuse an already-built detail row for an open PR across refreshes so its\n // expanded state — and any half-typed escalation answer in the form — survives\n // the poll. A closed (or keyless) row always gets a fresh, lazily-built panel.\n let entry = key != null ? detailNodes.get(key) : null;\n if (!(isOpen && entry && entry.built)) {\n const dtr = el(\"tr\", { hidden: isOpen ? null : \"\" }, el(\"td\", { colspan: span }));\n entry = { dtr, built: false };\n if (key != null) detailNodes.set(key, entry);\n if (isOpen) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }\n }\n const dtr = entry.dtr;\n setChevronOpen(toggle, !dtr.hidden);\n toggle.addEventListener(\"click\", /** @param {MouseEvent} ev */ (ev) => {\n ev.stopPropagation();\n const open = dtr.hidden;\n dtr.hidden = !open;\n setChevronOpen(toggle, open);\n if (open) {\n if (key != null) expanded.add(key);\n if (!entry.built) { entry.built = true; dtr.firstChild.append(detailPanel(row)); }\n } else if (key != null) {\n expanded.delete(key);\n }\n });\n tbody.append(dtr);\n if (sink) sink.push({ tr: dtr, isDetail: true, key });\n }\n }\n\n // Append the fetched rows into the tbody, either flat (default) or partitioned\n // into collapsible groups when the grid declares 'groupBy: \"<field>\"'. Each\n // group renders a clickable header row (\"<Label> <value> (<count>)\") that\n // collapses/expands its member rows as one unit; the collapsed state is\n // persisted (localStorage, keyed by page + node + group value) so it survives\n // both the refresh poll and a full reload — the same durability the top-level\n // 'collapsible' node wrapper gives. Group order is numeric-aware ascending\n // (so waves 1,2,10 sort naturally), falling back to lexical for non-numeric\n // keys. 'groupDefaultCollapsed' seeds each group collapsed until toggled.\n /** @param {Array<Record<string, any>>} rows */\n function appendRows(rows) {\n const groupBy = p.groupBy;\n // Treat a non-string groupBy as \"no grouping\": labelBase calls groupBy.charAt()\n // below, so a truthy non-string (number/object from a malformed page schema)\n // would throw at render time and break the whole page instead of one grid.\n if (!groupBy || typeof groupBy !== \"string\") { for (const row of rows) renderRow(row, null); return; }\n const order = [];\n const groups = new Map();\n for (const row of rows) {\n const gvRaw = row[groupBy];\n const gv = gvRaw == null ? \"\" : String(gvRaw);\n if (!groups.has(gv)) { groups.set(gv, []); order.push(gv); }\n groups.get(gv).push(row);\n }\n order.sort((a, b) => {\n const na = Number(a), nb = Number(b);\n if (a !== \"\" && b !== \"\" && !Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;\n return a < b ? -1 : a > b ? 1 : 0;\n });\n const labelBase = p.groupLabel != null\n ? String(p.groupLabel)\n : groupBy.charAt(0).toUpperCase() + groupBy.slice(1);\n for (const gv of order) {\n const groupRows = groups.get(gv);\n const gkey = \"pc:collapsed:\" + CURRENT + \":\" + (node.id || p.title || \"grid\") + \":g:\" + gv;\n let gcollapsed = readCollapsed(gkey, !!p.groupDefaultCollapsed);\n const chevron = el(\"span\", { class: \"pc-chevron-inline\" }, gcollapsed ? \"▸\" : \"▾\");\n const label = (gv === \"\" ? labelBase + \" —\" : labelBase + \" \" + gv) + \" (\" + groupRows.length + \")\";\n const btn = el(\"button\", { class: \"pc-group-toggle\", type: \"button\" },\n chevron, el(\"span\", { class: \"pc-group-title\" }, label));\n tbody.append(el(\"tr\", { class: \"pc-group-header\" }, el(\"td\", { colspan: span }, btn)));\n /** @type {Array<{ tr: HTMLTableRowElement, isDetail: boolean, key: string|null }>} */\n const members = [];\n for (const row of groupRows) renderRow(row, members);\n const applyGroup = () => {\n chevron.textContent = gcollapsed ? \"▸\" : \"▾\";\n btn.setAttribute(\"aria-expanded\", String(!gcollapsed));\n for (const m of members) {\n m.tr.hidden = gcollapsed\n ? true\n : (m.isDetail ? !(m.key != null && expanded.has(m.key)) : false);\n }\n };\n btn.addEventListener(\"click\", () => {\n gcollapsed = !gcollapsed;\n writeCollapsed(gkey, gcollapsed);\n applyGroup();\n });\n applyGroup();\n }\n }\n\n // Is the user actively typing inside this grid? tbody.replaceChildren() in refresh()\n // detaches the subtree, and detaching the node that holds document.activeElement blurs\n // it — so an open detail form (where you answer an escalation) loses focus every\n // refreshMs even though #103 preserves its half-typed value. Used to skip the poll.\n const editingInGrid = () => {\n const a = /** @type {any} */ (document.activeElement);\n if (!a || !tbody.contains(a)) return false;\n return a.tagName === \"INPUT\" || a.tagName === \"TEXTAREA\" || a.tagName === \"SELECT\" || a.isContentEditable;\n };\n\n async function refresh() {\n try {\n // A grid scoped to a route param (any eqParam filter) means \"show the rows\n // for the selected entity\". With no param present, PARAM is empty and emitting\n // a where clause of field-colon-empty would read server-side as field equals\n // empty string and surface empty-valued rows. That is not the selected entity's\n // rows (there is no selection), so short-circuit to zero rows without a request.\n const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);\n const { rows } = paramScoped && PARAM === \"\"\n ? { rows: [] }\n : await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));\n // Live row-count badge: reflect the currently-fetched, filtered (active-tab)\n // set — exactly what the body renders — on every refresh (poll + pc:refresh),\n // whether the section is expanded or collapsed.\n if (countBadge) { countBadge.textContent = String(rows.length); countBadge.setAttribute(\"aria-label\", rowCountLabel(rows.length)); }\n // Forget expansion / cached detail nodes for rows no longer present so the maps\n // don't grow without bound and a stale answer can't resurface on a key reuse.\n if (p.rowKey) {\n /** @type {Set<string>} */\n const live = new Set();\n for (const r of rows) { const k = rowKeyOf(r); if (k != null) live.add(k); }\n for (const k of [...detailNodes.keys()]) if (!live.has(k)) detailNodes.delete(k);\n for (const k of [...expanded]) if (!live.has(k)) expanded.delete(k);\n }\n // Capture focus + caret before the DOM swap. replaceChildren() detaches (and so blurs)\n // the reused detail node; re-appending it restores its value but not its focus. If the\n // very same node is re-attached (an open, still-present row), put the caret back.\n const active = /** @type {any} */ (document.activeElement);\n const keepFocus = !!active && tbody.contains(active);\n let selStart = null, selEnd = null, savedRange = null;\n if (keepFocus) {\n if (active.isContentEditable) {\n // contenteditable has no selectionStart — its caret lives in the document\n // selection. The reused node is re-attached, so the cloned range still points\n // at live containers and can be restored after focus.\n try { const s = document.getSelection(); if (s && s.rangeCount) savedRange = s.getRangeAt(0).cloneRange(); } catch (e) { /* selection unavailable */ }\n } else {\n try { selStart = active.selectionStart; selEnd = active.selectionEnd; } catch (e) { /* non-text field */ }\n }\n }\n // Capture scroll offsets of scrolled descendants before the swap. An open detail node is\n // reused (same element instance), but replaceChildren() detaches it, which resets the\n // scrollTop/scrollLeft of any inner scroll container (e.g. a scrolled-into .pc-transcript,\n // max-height + overflow:auto) back to 0. Only built (open) detail subtrees are re-attached\n // by the rebuild, so walk just those — not the whole grid — to keep this O(open panels)\n // rather than O(grid size). Restoration below skips any node that isn't re-attached.\n /** @type {Array<[any, number, number]>} */\n const scrollSaved = [];\n for (const entry of detailNodes.values()) {\n if (!entry.built) continue;\n for (const sc of entry.dtr.querySelectorAll(\"*\")) {\n if (sc.scrollTop || sc.scrollLeft) scrollSaved.push([sc, sc.scrollTop, sc.scrollLeft]);\n }\n }\n tbody.replaceChildren();\n appendRows(rows);\n if (!rows.length) tbody.append(el(\"tr\", {}, el(\"td\", { colspan: span }, \"No rows\")));\n if (keepFocus && active.isConnected) {\n active.focus();\n if (savedRange) {\n try { const s = /** @type {Selection} */ (document.getSelection()); s.removeAllRanges(); s.addRange(savedRange); } catch (e) { /* selection unavailable */ }\n } else if (selStart != null && typeof active.setSelectionRange === \"function\") {\n try { active.setSelectionRange(selStart, selEnd); } catch (e) { /* non-text field */ }\n }\n }\n // Restore scroll depth on the reused nodes that survived the swap (still connected).\n for (const [sc, top, left] of scrollSaved) {\n if (sc.isConnected) { sc.scrollTop = top; sc.scrollLeft = left; }\n }\n } catch (e) {\n tbody.replaceChildren(el(\"tr\", {}, el(\"td\", { colspan: span }, String(e.message || e))));\n }\n }\n document.addEventListener(\"pc:refresh\", refresh);\n disposers.push(() => document.removeEventListener(\"pc:refresh\", refresh));\n // Skip the automatic poll while the user is typing in the grid — an explicit pc:refresh\n // (e.g. after submitting an answer) still runs — so rows never shift under an in-progress\n // answer and the detail input keeps focus across ticks. The interval is registered as a\n // disposer so a page switch (hashchange → renderPage → teardown) stops it.\n if (p.refreshMs && p.refreshMs > 0) {\n const timer = setInterval(() => { if (!editingInGrid()) refresh(); }, p.refreshMs);\n disposers.push(() => clearInterval(timer));\n }\n refresh();\n return card;\n}\n\n// A navigation node — a horizontal menu bar (variant \"bar\", default) or a\n// vertical side rail (variant \"rail\"). Each item links to one of three targets,\n// in precedence order:\n// 1. { label, page } → an in-app #/<page> hash link within THIS (pages)\n// surface, highlighted when it's the current page.\n// 2. { label, path } → a same-origin, same-tab link to another mounted\n// surface by relative path (e.g. \"/tasks\" → the taskInbox surface). The\n// path is resolved against the app's mount root via baseRelativeUrl, so a\n// root-absolute \"/tasks\" survives the Nano console's path-prefixed App-View\n// proxy (/console/app-view/<name>/tasks) instead of escaping to the console\n// origin. Only same-origin values are honoured here — a scheme\n// (http(s)/javascript:/…) or protocol-relative \"//host\" is rejected (\"\"),\n// falling through to the {href} case, so `path` can never smuggle an\n// external or executable-scheme target into a same-tab link. This is the\n// only link form that composes multi-surface apps into a coherent,\n// proxy-safe navigable whole (nanobpm/nano-ide#436).\n// 3. { label, href } → a hardened external new-tab link; only http(s) is\n// honoured.\n// With items:\"auto\" (or omitted) the nav lists every page from the /app/pages\n// index, using each page's title. An item may also carry a `badge` ({ source,\n// table, filter, tone, refreshMs, hideWhenZero }) that renders a live count pill\n// from the /app/data gateway.\n/** @param {any} item\n * @returns {HTMLAnchorElement|null}\n */\nfunction navLink(item) {\n const isPage = safePageId(item.page);\n // A same-origin relative path to another mounted surface (e.g. \"/tasks\"),\n // rebased onto the app mount root so it survives the console proxy prefix.\n const rel = isPage ? \"\" : sameOriginPath(item.path);\n const isRel = !isPage && rel !== \"\";\n const isExt = !isPage && !isRel && typeof item.href === \"string\" && /^https?:\\/\\//i.test(item.href);\n if (!isPage && !isRel && !isExt) return null;\n /** @type {Record<string, any>} */\n const attrs = { class: \"pc-nav-link\" };\n if (isPage) {\n attrs.href = \"#/\" + encodeURIComponent(item.page);\n if (item.page === CURRENT) { attrs.class += \" active\"; attrs[\"aria-current\"] = \"page\"; }\n } else if (isRel) {\n // Same-origin, same-tab: no target=_blank — this is in-app cross-surface\n // navigation, not an external hand-off.\n attrs.href = rel;\n } else {\n attrs.href = item.href; attrs.target = \"_blank\"; attrs.rel = \"noopener noreferrer\";\n }\n /** @type {Array<Node|string>} */\n const kids = [];\n if (item.icon != null) kids.push(el(\"span\", { class: \"pc-nav-icon\" }, String(item.icon)));\n // Default the label from the target actually used, not a fixed page>path>href\n // fallback: when `path` is rejected and we fell through to `href`, the label\n // must reflect the href we navigate to, never the discarded `path`.\n const defaultLabel = isPage ? item.page : (isRel ? item.path : item.href);\n const labelText = String(item.label != null ? item.label : defaultLabel);\n kids.push(el(\"span\", { class: \"pc-nav-label\" }, labelText));\n const link = el(\"a\", attrs, ...kids);\n // A nav item may carry a live count badge sourced from a datasource (e.g. the\n // Tasks tab showing how many escalations await a human). It reuses the same\n // /app/data gateway + .pc-badge tone pills as dataGrid, and polls on refreshMs.\n // Absent/invalid badge → the link is exactly as before (backward compatible).\n wireNavBadge(link, labelText, item.badge);\n return link;\n}\n\n// Attach + drive a nav item's live count badge. Best-effort throughout: a bad\n// config or a failed fetch degrades to no badge, never a broken nav link. The\n// pill is appended hidden and only appears once a fetch yields a count (and, when\n// hideWhenZero, only when that count is > 0). The accessible name of the whole\n// link is kept in sync (\"Tasks (3 open)\") so assistive tech announces the count,\n// which the small \"3\" glyph alone would not convey; the pill itself is aria-hidden\n// to avoid a double announcement.\n/** @param {HTMLElement} link\n * @param {string} baseLabel\n * @param {any} badge\n */\nfunction wireNavBadge(link, baseLabel, badge) {\n if (!badge || typeof badge !== \"object\") return;\n if (typeof badge.table !== \"string\" || badge.table === \"\") return;\n const source = typeof badge.source === \"string\" && badge.source !== \"\" ? badge.source : \"app\";\n const t = badge.tone;\n const tone = t === \"warn\" || t === \"ok\" || t === \"info\" || t === \"danger\" ? t : \"danger\";\n const hideWhenZero = badge.hideWhenZero === true;\n const pill = el(\"span\", { class: \"pc-badge pc-nav-badge pc-badge-\" + tone, \"aria-hidden\": \"true\" });\n pill.hidden = true;\n link.append(pill);\n const filter = Array.isArray(badge.filter) ? badge.filter : [];\n const paramScoped = filter.some(/** @param {any} f */(f) => f && f.eqParam);\n const refresh = () => {\n // A param-scoped badge (\"count for the selected entity\") with no route param\n // present would otherwise build the URL with dataUrl baking in an empty PARAM\n // and query field=\"\", surfacing a wrong count. Match the dataGrid/list pollers:\n // degrade to 0 (hidden when hideWhenZero) without a request instead.\n if (paramScoped && PARAM === \"\") {\n applyNavBadge(link, pill, baseLabel, hideWhenZero, 0);\n return;\n }\n const url = dataUrl(source, badge.table, filter, null);\n const countUrl = url + (url.indexOf(\"?\") >= 0 ? \"&\" : \"?\") + \"count=1\";\n getJSON(countUrl)\n .then(/** @param {any} res */(res) => applyNavBadge(link, pill, baseLabel, hideWhenZero, res && res.count))\n .catch(() => applyNavBadge(link, pill, baseLabel, hideWhenZero, NaN));\n };\n refresh();\n const ms = Number(badge.refreshMs);\n if (Number.isFinite(ms) && ms > 0) {\n const timer = setInterval(refresh, ms);\n disposers.push(() => clearInterval(timer));\n }\n}\n\n// Reflect a fetched count into the pill + the link's accessible name. A\n// non-finite/negative count (a failed or malformed fetch) hides the pill and\n// restores the plain label — the nav degrades quietly rather than showing a\n// broken/\"NaN\" badge. hideWhenZero also hides the pill at exactly 0.\n/** @param {HTMLElement} link\n * @param {any} pill\n * @param {string} baseLabel\n * @param {boolean} hideWhenZero\n * @param {any} count\n */\nfunction applyNavBadge(link, pill, baseLabel, hideWhenZero, count) {\n const n = Number(count);\n if (!Number.isFinite(n) || n < 0 || (n === 0 && hideWhenZero)) {\n pill.hidden = true;\n pill.textContent = \"\";\n link.setAttribute(\"aria-label\", baseLabel);\n return;\n }\n const shown = String(Math.floor(n));\n pill.hidden = false;\n pill.textContent = shown;\n const aria = baseLabel + \" (\" + shown + \" open)\";\n pill.setAttribute(\"title\", aria);\n link.setAttribute(\"aria-label\", aria);\n}\n/** @param {HTMLElement} list\n * @param {any[]} items\n */\nfunction fillNav(list, items) {\n list.replaceChildren();\n let n = 0;\n for (const item of items || []) {\n if (!item || typeof item !== \"object\") continue;\n const a = navLink(item);\n if (a) { list.append(a); n++; }\n }\n if (!n) list.append(el(\"span\", { class: \"pc-nav-empty\" }, \"No pages\"));\n}\n/** @param {any} node */\nfunction renderNav(node) {\n const p = node.props || {};\n const rail = p.variant === \"rail\";\n // A bar nav can opt into staying pinned to the top of the viewport while the\n // page scrolls (sticky) — useful on the long, multi-section operator pages so\n // the page switcher is always reachable. Rails are already full-height, so the\n // flag only applies to the bar variant.\n const barSticky = !rail && p.sticky === true;\n const nav = el(\"nav\", {\n class: (rail ? \"pc-nav pc-rail\" : \"pc-nav pc-bar\") + (barSticky ? \" pc-sticky\" : \"\"),\n });\n if (p.title != null) nav.append(el(\"div\", { class: \"pc-nav-title\" }, String(p.title)));\n const list = el(\"div\", { class: \"pc-nav-items\" });\n nav.append(list);\n const items = p.items;\n if (Array.isArray(items)) {\n fillNav(list, items);\n } else {\n // \"auto\" (or unspecified): enumerate every page and link to each.\n getJSON(\"/app/pages\")\n .then((res) => fillNav(list, (res.pages || []).map(/** @param {any} pg */ (pg) => ({ label: pg.title || pg.id, page: pg.id }))))\n .catch(() => list.append(el(\"span\", { class: \"pc-nav-empty\" }, \"No pages\")));\n }\n return nav;\n}\n\n// A page-relative URL that resolves correctly whether the app is served at the\n// origin root (CLI on :3000) or under the Nano console's path-prefixed App-View\n// proxy (/console/app-view/<name>/). Same reasoning as the shell's `badgeHref`\n// and mount.js's reportUrl handling: a root-absolute \"/x\" would escape the proxy\n// prefix and hit the CONSOLE origin, so a single leading slash is stripped to\n// rebase onto the mount root; an already document-relative (\"./x\", \"x\") or an\n// absolute (\"http(s)://…\") / protocol-relative (\"//host\") URL is left untouched.\n// The result becomes an <iframe> src, so a scheme URL is only honoured when it is\n// http/https: a hostile/malformed page doc could otherwise smuggle an executable\n// `javascript:`/`data:`/`vbscript:` src, so any other scheme is rejected (\"\" →\n// caller omits the attribute). Input is trimmed and ASCII tab/newline stripped\n// FIRST (browsers strip those before parsing a URL, so `java\\tscript:` would still\n// execute) so scheme detection cannot be bypassed by embedded whitespace.\n/** @param {string} url */\nfunction baseRelativeUrl(url) {\n if (typeof url !== \"string\") return \"\";\n const cleaned = url.replace(/[\\t\\n\\r]/g, \"\").trim();\n if (cleaned === \"\") return \"\";\n const scheme = /^([a-z][a-z0-9+.-]*):/i.exec(cleaned);\n if (scheme) {\n const proto = scheme[1].toLowerCase();\n return proto === \"http\" || proto === \"https\" ? cleaned : \"\"; // reject javascript:/data:/vbscript:/file:/…\n }\n if (cleaned.startsWith(\"//\")) return cleaned; // protocol-relative\n if (cleaned.startsWith(\"/\")) return cleaned.slice(1); // root-absolute → document-relative (rebase onto mount root)\n return cleaned; // already relative\n}\n\n// Resolve a same-origin, in-app relative path (e.g. a nav item's { path: \"/tasks\" }\n// targeting another mounted surface) onto the app's mount root, so it survives the\n// console App-View proxy prefix — the SAME rebasing rule as baseRelativeUrl (the\n// single source of truth for \"strip one leading slash to rebase onto the mount\n// root\"). Unlike baseRelativeUrl (whose output is an <iframe> src that may legally\n// be an http(s) URL), a cross-surface nav link must stay SAME-ORIGIN and same-tab:\n// a scheme (http(s):, and — defence in depth — javascript:/data:/… ) or a\n// protocol-relative \"//host\" is NOT an in-app path, so it is rejected (\"\") and the\n// caller falls through to the hardened new-tab {href} handling. Whitespace is\n// stripped/trimmed FIRST (browsers strip ASCII tab/newline before parsing a URL, so\n// `/ta\\tsks` must be judged on its cleaned form) so the scheme gate can't be\n// bypassed by embedded whitespace.\n/** @param {any} path */\nfunction sameOriginPath(path) {\n if (typeof path !== \"string\") return \"\";\n // Browsers normalize backslashes to `/` in URLs, so `\\\\evil.example/tasks` (or\n // `/\\evil.example`) is parsed as protocol-relative `//evil.example/…`. Fold `\\` → `/`\n // before the scheme/`//` gates so a backslash can't smuggle a cross-origin target past them.\n const cleaned = path.replace(/[\\t\\n\\r]/g, \"\").replace(/\\\\/g, \"/\").trim();\n if (cleaned === \"\") return \"\";\n if (/^[a-z][a-z0-9+.-]*:/i.test(cleaned)) return \"\"; // any scheme → not a same-origin in-app path\n if (cleaned.startsWith(\"//\")) return \"\"; // protocol-relative → different origin\n return baseRelativeUrl(cleaned); // rebase root-absolute onto the mount root (shared rule)\n}\n\n// `appView` (ADR 0057 / #416): mount an embedded App View — an <iframe> hosting a\n// self-contained view document (e.g. the agent cockpit's embed.html) served\n// alongside the page. This is the runtime counterpart of the console's App-View\n// surface: the SAME embed document renders here (in the app's own UI) and, when the\n// console embeds the app, there. Before this renderer existed an `appView` node fell\n// through to a blank <div> (RENDERERS[n.type] || (() => el(\"div\"))), so an entire\n// embedded view — the live agent cockpit — was SILENTLY invisible with no error.\n/** @param {PageNode} node */\nfunction renderAppView(node) {\n const p = node.props || {};\n const title = p.title != null ? String(p.title) : \"\";\n const fill = p.fill === true;\n const src = baseRelativeUrl(typeof p.embed === \"string\" ? p.embed : \"\");\n\n const frame = /** @type {HTMLIFrameElement} */ (\n el(\"iframe\", {\n class: \"pc-appview-frame\",\n // Base-relative so the embed resolves under both the app root and the console\n // App-View proxy prefix (see baseRelativeUrl). Omitted (about:blank) when no\n // usable `embed` is declared — an empty `src=\"\"` resolves to the current\n // document URL in browsers, risking a recursive self-embed. The node still\n // renders its chrome rather than throwing.\n src: src || undefined,\n title: title || \"App view\",\n loading: \"lazy\",\n referrerpolicy: \"same-origin\",\n })\n );\n\n // Forward the App-View endpoint config to the embed's own window (ADR 0057:\n // embed.html reads `window.__NANO_APP_VIEW__`). The console injects this when IT\n // hosts the embed; here — the same-origin app render — mount.js already defaults\n // every endpoint to the app's own origin, so the cockpit is fully functional with\n // nothing injected. We seed the frame's window on load only to thread through\n // explicit per-node overrides (props.reportUrl / relayUrl / transcriptsUrl), so a\n // page can point the embed at a non-default endpoint without editing the embed\n // document. Best-effort and guarded: a cross-origin embed is left to its defaults.\n /** @type {Record<string, string>} */\n const overrides = {};\n if (typeof p.reportUrl === \"string\") overrides.reportUrl = p.reportUrl;\n if (typeof p.relayUrl === \"string\") overrides.relayUrl = p.relayUrl;\n if (typeof p.transcriptsUrl === \"string\") overrides.transcriptsUrl = p.transcriptsUrl;\n frame.addEventListener(\"load\", () => {\n try {\n const w = /** @type {any} */ (frame.contentWindow);\n if (w) {\n w.__NANO_APP_VIEW__ = {\n ...(w.__NANO_APP_VIEW__ || {}),\n host: (typeof location !== \"undefined\" && location.origin) || \"\",\n ...overrides,\n };\n }\n } catch (_e) {\n // Cross-origin embed: we cannot (and need not) touch its window.\n }\n });\n\n const section = el(\"section\", { class: \"pc-appview\" + (fill ? \" pc-appview-fill\" : \"\") });\n // Register this frame in the App-View relay set (#518) so the page-level bridge\n // can (a) recognise its outbound postMessages as coming from a known App-View of\n // this page and (b) address it as a sibling target for another App-View's relay.\n // A teardown disposer drops it again, so a switched-away page's frames don't\n // linger as phantom relay endpoints.\n APP_VIEW_FRAMES.add(frame);\n disposers.push(() => APP_VIEW_FRAMES.delete(frame));\n // The title is an <h2> (the tag EVERY card renderer uses and makeCollapsible lifts into the\n // collapse header), not a <div>: a collapsible appView's title must be lifted out of the body,\n // otherwise it double-renders (header label + leftover in-body title). The .pc-appview-title CSS\n // is class-based, so a non-collapsible appView renders identically.\n if (title) section.append(el(\"h2\", { class: \"pc-appview-title\" }, title));\n section.append(frame);\n return section;\n}\n\n// The browser renderer's dispatch table: the runtime source of truth for which\n// page-node types can be drawn. `@satisfies Record<PageNodeType, Renderer>` locks\n// it, at compile time, to the shared `PAGE_NODE_TYPES` registry (`../core/page-nodes.ts`\n// — the canonical @nanobpm/nano-app-schema set, extended with the node types this\n// runtime ships ahead of the schema, e.g. `appView` #416). Adding a page-node type\n// without a renderer here (or vice-versa) is now a type error, so the table can no\n// longer silently drift from the registry (this replaces the #290 regex guard, which\n// had to string-scrape these keys precisely because this used to be a string). The\n// drift is also covered by a real-import unit test.\n/** @satisfies {Record<PageNodeType, Renderer>} */\nconst RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, prose: renderProse, nav: renderNav, button: renderButton, appView: renderAppView };\n\n// Durable per-node UI state. localStorage is keyed by the home page id + node\n// id so two grids on the same page (or the same grid across pages) don't clash,\n// and the collapsed state survives a full reload / new session — not just the\n// refresh poll. Every access is guarded: private-mode or storage-disabled\n// browsers throw on localStorage, and a UI preference must never break render.\n/** @param {string} key\n * @param {boolean} dflt\n */\nfunction readCollapsed(key, dflt) {\n try {\n const v = localStorage.getItem(key);\n return v === null ? dflt : v === \"1\";\n } catch (e) {\n return dflt;\n }\n}\n/** @param {string} key\n * @param {boolean} val\n */\nfunction writeCollapsed(key, val) {\n try {\n localStorage.setItem(key, val ? \"1\" : \"0\");\n } catch (e) {\n // Preference is best-effort; ignore storage failures.\n }\n}\n\n// Wrap a rendered node so its card can be collapsed from a clickable header.\n// Opt-in via the node's \"collapsible\" prop; \"defaultCollapsed\" seeds the state\n// the first time (before the user has toggled it). Generic across node types —\n// the body (everything the renderer produced, minus its own <h2> title, which\n// becomes the header label) is hidden as one unit.\n/** @param {any} node\n * @param {HTMLElement} card\n * @returns {HTMLElement}\n */\nfunction makeCollapsible(node, card) {\n const props = node.props || {};\n if (!props.collapsible) return card;\n // Normalize to a card container. Card renderers already return\n // <section class=\"pc-card\"> — we unwrap those in place (lift out the <h2>\n // title, reparent the rest into the body). A non-card renderer (e.g. text\n // returning <p>/<h1>) is nested whole inside a fresh section instead:\n // appending a <button>/<div> directly into a <p> is invalid markup the\n // browser silently re-parents, breaking layout and click handling.\n const isCard = card.tagName === \"SECTION\";\n const container = isCard ? card : el(\"section\", { class: \"pc-card\" });\n const h2 = isCard && card.querySelector ? card.querySelector(\"h2\") : null;\n // A dataGrid with \"showCount\" renders a live count badge inside its <h2>. Lift it\n // out before deriving the title (so the count doesn't leak into the label text),\n // then re-home it in the header alongside the title so it stays visible — and\n // refresh-updated by the grid's own refresh() — while the section is collapsed.\n const countBadge = h2 && h2.querySelector ? h2.querySelector(\".pc-count-badge\") : null;\n if (countBadge) countBadge.remove();\n // Trim the derived label: after the badge is lifted out, the leftover \" \" whitespace\n // node from h2.append(\" \", countBadge) would otherwise make a title-less section's\n // header a lone space (and leak whitespace into the storage key). Fall back to \"Section\".\n const titleText = props.title || (h2 ? h2.textContent.trim() : \"\") || \"Section\";\n if (h2) h2.remove();\n const body = el(\"div\", { class: \"pc-card-body\" });\n if (isCard) {\n while (card.firstChild) body.append(card.firstChild);\n } else {\n body.append(card);\n }\n const chevron = el(\"span\", { class: \"pc-chevron-inline\" }, \"▾\");\n const header = el(\n \"button\",\n { class: \"pc-collapse-header\", type: \"button\" },\n chevron,\n el(\"span\", { class: \"pc-collapse-title\" }, titleText),\n );\n if (countBadge) header.append(countBadge);\n const storageKey = \"pc:collapsed:\" + CURRENT + \":\" + (node.id || titleText);\n let collapsed = readCollapsed(storageKey, !!props.defaultCollapsed);\n function apply() {\n body.hidden = collapsed;\n chevron.textContent = collapsed ? \"▸\" : \"▾\";\n header.setAttribute(\"aria-expanded\", String(!collapsed));\n }\n header.addEventListener(\"click\", () => {\n collapsed = !collapsed;\n writeCollapsed(storageKey, collapsed);\n apply();\n });\n apply();\n container.append(header, body);\n return container;\n}\n\n// Tier-2 escape hatch (#268): a page may declare a distinct 'mobile' layout\n// variant — its own node list — for the rare case the derived card flip isn't\n// enough (reorder/omit sections, swap a dense grid for a summary card). It is\n// used ONLY on a narrow viewport; the default 'nodes' render everywhere else,\n// and anything malformed falls back to them, so the variant is purely additive.\n/** @param {any} doc\n * @returns {any[]}\n */\nfunction pickNodes(doc) {\n if (isNarrow() && doc && doc.mobile && Array.isArray(doc.mobile.nodes)) return doc.mobile.nodes;\n return (doc && doc.nodes) || [];\n}\n\n// Render the page named by the current hash route. A rail nav is lifted into a\n// left <aside> and the rest of the nodes flow into a content column; otherwise\n// (bar nav or none) nodes render in document order. Re-runs on every hashchange,\n// tearing down the previous page's grid polls first.\nasync function renderPage() {\n teardown();\n const mySeq = ++renderSeq;\n const route = parseRoute();\n CURRENT = route.page;\n PARAM = route.param;\n try {\n const doc = await getJSON(\"/app/pages/\" + encodeURIComponent(CURRENT));\n if (mySeq !== renderSeq) return;\n document.title = (doc && doc.title) || \"Urban App\";\n const nodes = pickNodes(doc);\n const built = nodes.map((n) => ({ n, node: makeCollapsible(n, (/** @type {Record<string, Renderer>} */ (RENDERERS)[n.type] || (() => el(\"div\")))(n)) }));\n const rail = built.find((b) => b.n.type === \"nav\" && b.n.props && b.n.props.variant === \"rail\");\n if (rail) {\n const col = el(\"div\", { class: \"pc-main-col\" });\n for (const b of built) if (b !== rail) col.append(b.node);\n root.replaceChildren(el(\"div\", { class: \"pc-layout\" }, el(\"aside\", { class: \"pc-rail-wrap\" }, rail.node), col));\n } else {\n root.replaceChildren(...built.map((b) => b.node));\n }\n } catch (e) {\n if (mySeq !== renderSeq) return;\n root.replaceChildren(el(\"p\", { class: \"pc-msg err\" }, \"Failed to load page: \" + String((e && e.message) || e)));\n }\n}\n// ── Bootstrap ─────────────────────────────────────────────────────────────\n// Every DOM/window side-effect is gathered here (not at module top level) so\n// importing this module — for a unit test or the string-generation build — is\n// inert. boot() runs only in a real browser document (see the guard below).\nfunction boot() {\n root = /** @type {HTMLElement} */ (document.getElementById(\"page\"));\n HOME = root.dataset.home || \"home\";\n MOBILE_MQ = window.matchMedia(\"(max-width:640px)\");\n CURRENT = currentPage();\n PARAM = parseRoute().param;\n NANO_EMBEDDED = computeEmbedded();\n installThemeBridge();\n installAppViewBridge();\n window.addEventListener(\"hashchange\", renderPage);\n // Re-render when the viewport crosses the mobile breakpoint so a Tier-2 'mobile'\n // variant swaps in/out on rotation or a window resize (the grid card flip, nav\n // collapse and button stacking are pure CSS and need no re-render). teardown()\n // inside renderPage stops the outgoing page's grid polls first. Prefer the modern\n // addEventListener but fall back to the deprecated addListener so older Safari/iOS\n // (< 14), where MediaQueryList.addEventListener is unimplemented, still swaps the\n // Tier-2 variant on rotation/resize instead of silently failing to register.\n if (typeof MOBILE_MQ.addEventListener === \"function\") MOBILE_MQ.addEventListener(\"change\", renderPage);\n else if (typeof MOBILE_MQ.addListener === \"function\") MOBILE_MQ.addListener(renderPage);\n renderPage();\n}\n\n// Only boot in a real browser document (a served <main id=\"page\"> exists). Under\n// Node/Deno import — for the unit tests or the string-generation build — this\n// guard is false, so the module loads with zero DOM side-effects and its\n// renderer functions / RENDERERS registry can be imported directly.\nif (typeof document !== \"undefined\" && document.getElementById(\"page\")) boot();\n\nexport { RENDERERS, renderText, renderButton, renderProse, renderNav, renderAppView, relayAppViewMessage, navLink, sameOriginPath, wireNavBadge, applyNavBadge, teardown, fmtCellValue, gridCell, buildDetailForm, buildEngineForm, evalDetailCondition, normalizeDetailOptions, chevronToggle, setChevronOpen };\n";
|