@nanobpm/urban 0.53.0 → 0.55.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.
@@ -16,6 +16,7 @@ const noEngine: EngineClient = {
16
16
  cancelInstance: async () => {},
17
17
  publishMessage: async () => {},
18
18
  searchUserTasks: async () => [],
19
+ openUserTasks: async () => [],
19
20
  getForm: async () => null,
20
21
  completeUserTask: async () => {},
21
22
  searchProcessInstances: async () => [],
@@ -96,6 +96,7 @@ function fakeEngine(states: Record<string, ProcessInstanceSnapshot["state"]>): {
96
96
  cancelInstance: notUsed("cancelInstance"),
97
97
  publishMessage: notUsed("publishMessage"),
98
98
  searchUserTasks: notUsed("searchUserTasks"),
99
+ openUserTasks: notUsed("openUserTasks"),
99
100
  getForm: notUsed("getForm"),
100
101
  completeUserTask: notUsed("completeUserTask"),
101
102
  registerWorker: notUsed("registerWorker"),
@@ -40,6 +40,7 @@ function fakeEngine(): { engine: EngineClient; calls: FakeEngineCalls } {
40
40
  calls.messages.push(input);
41
41
  },
42
42
  async searchUserTasks() { return []; },
43
+ async openUserTasks() { return []; },
43
44
  async getForm() { return null; },
44
45
  async completeUserTask() {},
45
46
  async searchProcessInstances() { return []; },
@@ -891,6 +892,133 @@ test("the renderer ships a button node that opens a copy-pasteable modal", async
891
892
  });
892
893
 
893
894
 
895
+ test("row-action confirm/error route through the in-DOM modal, never native confirm()/alert() (#276)", async () => {
896
+ // A sandboxed iframe without `allow-modals` makes window.confirm() return false
897
+ // WITHOUT prompting and turns alert()/prompt() into no-ops — so every
898
+ // confirm-guarded row action silently dead-ended (the !confirm(...) guard was
899
+ // always true → fireAction never ran) and its errors vanished. The fix routes
900
+ // both through urban's in-DOM openModal overlay, which works with no host
901
+ // sandbox change.
902
+ const res = await dispatch("GET", "/app/runtime.js");
903
+ const js = res.body ?? "";
904
+ // The confirm gate awaits an in-DOM dialog and only proceeds on a real choice.
905
+ assert.match(js, /if \(ra\.confirm && !\(await confirmModal\(ra\.confirm\)\)\) return/);
906
+ // Errors surface through the in-DOM alert instead of the suppressed native one.
907
+ assert.match(js, /alertModal\(String\(e\.message \|\| e\)\)/);
908
+ // The primitives exist and are built on the shared openModal overlay (no drift).
909
+ assert.match(js, /function confirmModal\(message\)/);
910
+ assert.match(js, /return new Promise\(\(resolve\) => \{/);
911
+ assert.match(js, /function alertModal\(message\)/);
912
+ // A dropped guarded open (a modal already showing) resolves false, never hangs.
913
+ assert.match(js, /if \(!opened\) resolve\(false\)/);
914
+ // openModal grew action buttons + a single-fire result callback so it can back a
915
+ // decision dialog; a dismissal (Escape/backdrop/✕/page switch) resolves the
916
+ // caller's default (dismissValue).
917
+ assert.match(js, /Array\.isArray\(m\.actions\)/);
918
+ assert.match(js, /resultValue = a\.value; close\(\);/);
919
+ // Match the semantic call site only — teardown wraps onResult in a try/catch so
920
+ // a throwing callback can't abort focus restoration, so don't pin the control flow.
921
+ assert.match(js, /onResult\(resultValue\)/);
922
+ // Defect-class guard: NO native confirm()/alert()/prompt() CALL (invoked with a
923
+ // string/identifier/template argument) survives anywhere in the served bundle —
924
+ // they are all false/no-ops under a sandboxed iframe. This also catches qualified
925
+ // calls (window.confirm(...) / globalThis.alert(...)): the lookbehind only excludes
926
+ // a preceding word char (so confirmModal(...) / alertModal(...) don't match), not a
927
+ // preceding ".", so a member call still trips the guard. The argument-start class
928
+ // covers string/template literals and identifier heads including $ and _
929
+ // (e.g. confirm(_msg), alert($msg), prompt(`...`)). Empty-paren mentions in
930
+ // comments (e.g. "native confirm()") are documentation, not calls, so are not matched.
931
+ assert.doesNotMatch(js, /(?<!\w)(?:confirm|alert|prompt)\(\s*["'`$_a-zA-Z]/);
932
+ });
933
+
934
+
935
+ test("the renderer ships a data-bound prose/markdown list node (#274)", async () => {
936
+ const res = await dispatch("GET", "/app/runtime.js");
937
+ const js = res.body ?? "";
938
+ // prose is a first-class node type alongside dataGrid.
939
+ assert.match(js, /prose: renderProse/);
940
+ assert.match(js, /function renderProse\(node\)/);
941
+ // It binds a datasource like dataGrid via the SHARED dataUrl builder (no drift):
942
+ // dataUrl is hoisted to module scope and used by both renderers.
943
+ assert.match(js, /function dataUrl\(source, tbl, filters, order\)/);
944
+ assert.match(js, /getJSON\(dataUrl\(data\.source, data\.table, activeFilter, data\.orderBy\)\)/);
945
+ // A param-scoped list with no route param renders nothing (no field=empty query),
946
+ // mirroring the grid's eqParam short-circuit.
947
+ assert.match(js, /paramScoped && PARAM === ""/);
948
+ // The active filter is Array.isArray-guarded (matching other malformed-schema
949
+ // guards) so a truthy non-array filter degrades to [] instead of throwing on .some.
950
+ assert.match(js, /const activeFilter = Array\.isArray\(data\.filter\) \? data\.filter : \[\]/);
951
+ // Per-item: a header template (interpTemplate, text-only) over one markdown body field.
952
+ assert.match(js, /el\("div", \{ class: "pc-prose-head" \}, interpTemplate\(headerTpl, row\)\)/);
953
+ assert.match(js, /const bodyField = p\.body/);
954
+ assert.match(js, /for \(const b of mdToNodes\(raw\)\) bodyEl\.append\(b\)/);
955
+ // The reading measure is clamped to a sane ch range (default ~66) and applied inline.
956
+ assert.match(js, /measure = Math\.max\(40, Math\.min\(100, Math\.round\(measure\)\)\)/);
957
+ assert.match(js, /"max-width:" \+ measure \+ "ch"/);
958
+ // Own-property gate on the body field (matching interpTemplate) so it can't pick
959
+ // up prototype cruft.
960
+ assert.match(js, /Object\.prototype\.hasOwnProperty\.call\(row, bodyField\)/);
961
+ // It polls + honours pc:refresh like dataGrid, and registers disposers so a page
962
+ // switch stops the poll / removes the listener.
963
+ assert.match(js, /document\.addEventListener\("pc:refresh", refresh\)/);
964
+ assert.match(js, /const timer = setInterval\(refresh, p\.refreshMs\)/);
965
+ assert.match(js, /disposers\.push\(\(\) => clearInterval\(timer\)\)/);
966
+ // It returns a pc-card with an <h2> title so makeCollapsible gives it
967
+ // collapsible/defaultCollapsed parity for free.
968
+ assert.match(js, /el\("h2", \{\}, p\.title\)/);
969
+ });
970
+
971
+ test("the prose renderer ships a safe, dependency-free markdown → DOM converter (#274)", async () => {
972
+ const res = await dispatch("GET", "/app/runtime.js");
973
+ const js = res.body ?? "";
974
+ // Block + inline parsers exist.
975
+ assert.match(js, /function mdToNodes\(src\)/);
976
+ assert.match(js, /function mdInline\(text\)/);
977
+ // NEVER innerHTML: the whole renderer builds DOM via el()/textContent only, so a
978
+ // record's markdown body can't become an HTML/script injection vector.
979
+ assert.doesNotMatch(js, /\.innerHTML\s*=/);
980
+ // Nodes are materialised as known elements with text set via createTextNode.
981
+ assert.match(js, /nodes\.push\(document\.createTextNode\(buf\)\)/);
982
+ // Links are scheme-sanitised: only http(s)/mailto become anchors; anything else
983
+ // degrades to plain label text (no javascript:/data: hrefs).
984
+ assert.match(js, /function mdSafeHref\(href\)/);
985
+ assert.match(js, /\^\(\?:https\?:\|mailto:\)/);
986
+ assert.match(js, /el\("a", \{ href: href, target: "_blank", rel: "noopener noreferrer" \}/);
987
+ // Emphasis, strong, inline code, headings, lists, quotes, fences are supported.
988
+ assert.match(js, /el\("strong", \{\}/);
989
+ assert.match(js, /el\("em", \{\}/);
990
+ assert.match(js, /el\("code", \{ class: "pc-md-code" \}/);
991
+ assert.match(js, /el\("pre", \{ class: "pc-md-pre" \}/);
992
+ assert.match(js, /el\("blockquote", \{ class: "pc-md-quote" \}/);
993
+ // Thematic breaks emit a themed <hr> (styled via .pc-md-hr), not a bare default rule.
994
+ assert.match(js, /el\("hr", \{ class: "pc-md-hr" \}\)/);
995
+ assert.match(js, /const MD_UL = \/\^\[-\*\+\]\\s\+\//);
996
+ assert.match(js, /const MD_OL = \/\^\\d\+\[\.\)\]\\s\+\//);
997
+ // Underscore emphasis is word-boundary guarded so snake_case identifiers survive.
998
+ assert.match(js, /function mdIsWordChar\(c\)/);
999
+ assert.match(js, /!mdIsWordChar\(s\[i - 1\]\)/);
1000
+ // The code backtick is built from a char code so this source stays inside the
1001
+ // String.raw template literal.
1002
+ assert.match(js, /const MD_BACKTICK = String\.fromCharCode\(96\)/);
1003
+ });
1004
+
1005
+ test("the shell styles the prose/markdown list at a comfortable reading measure (#274)", async () => {
1006
+ const res = await dispatch("GET", "/");
1007
+ const html = res.body ?? "";
1008
+ // The list stacks items; each body is clamped to ~66ch by default (the JS sets
1009
+ // the per-node measure inline, this is the no-JS fallback).
1010
+ assert.match(html, /\.pc-prose-list \{[^}]*flex-direction:column[^}]*\}/);
1011
+ assert.match(html, /\.pc-prose-body \{ max-width:66ch; \}/);
1012
+ // The item header is a small muted caption; markdown blocks reuse --nano-* tokens.
1013
+ assert.match(html, /\.pc-prose-head \{/);
1014
+ assert.match(html, /\.pc-md-pre \{[^}]*var\(--nano-inset\)[^}]*\}/);
1015
+ assert.match(html, /\.pc-md-quote \{[^}]*var\(--nano-edge-strong\)[^}]*\}/);
1016
+ // Thematic breaks are themed via --nano-* tokens, matching the other markdown blocks.
1017
+ assert.match(html, /\.pc-md-hr \{[^}]*var\(--nano-edge\)[^}]*\}/);
1018
+ assert.match(html, /\.pc-prose-body a \{ color:var\(--nano-accent-strong\); \}/);
1019
+ });
1020
+
1021
+
894
1022
  test("GET /app/data/<source>/<table> returns rows", async () => {
895
1023
  const res = await dispatch("GET", "/app/data/app/orders");
896
1024
  assert.equal(res.status, 200);
@@ -627,6 +627,30 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
627
627
  .pc-group-toggle { display:flex; align-items:center; gap:.5rem; width:100%; padding:.4rem .6rem; background:transparent; border:0; color:inherit; font:inherit; font-weight:600; cursor:pointer; text-align:left; }
628
628
  .pc-group-toggle:hover { background:var(--nano-hover); }
629
629
  .pc-group-title { font-size:.9rem; }
630
+ /* Data-bound prose/markdown list (renderProse, #274). Long-form narrative
631
+ records — plan-review findings, coordination notes, escalation bodies — read
632
+ as a stacked, full-width document at a comfortable measure (~66ch) instead of
633
+ being jammed into a grid column. Each item stacks a small header over a
634
+ markdown-rendered body; the body max-width is set inline per node (props.measure)
635
+ with this rule as the no-JS fallback. */
636
+ .pc-prose-list { display:flex; flex-direction:column; gap:1.1rem; }
637
+ .pc-prose-item { border-top:1px solid var(--nano-edge); padding-top:.9rem; }
638
+ .pc-prose-item:first-child { border-top:0; padding-top:0; }
639
+ .pc-prose-head { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); text-transform:uppercase; letter-spacing:.03em; margin:0 0 .35rem; }
640
+ .pc-prose-empty { color:var(--nano-text-faint); }
641
+ .pc-prose-body { max-width:66ch; }
642
+ .pc-prose-body > :first-child { margin-top:0; }
643
+ .pc-prose-body > :last-child { margin-bottom:0; }
644
+ .pc-md-p { margin:.55rem 0; overflow-wrap:anywhere; }
645
+ .pc-md-h { font-size:1.05rem; font-weight:650; margin:.95rem 0 .4rem; }
646
+ .pc-md-list { margin:.45rem 0; padding-left:1.35rem; }
647
+ .pc-md-list li { margin:.15rem 0; }
648
+ .pc-md-quote { margin:.6rem 0; padding:.1rem .9rem; border-left:3px solid var(--nano-edge-strong); color:var(--nano-text-muted); }
649
+ .pc-md-pre { background:var(--nano-inset); border:1px solid var(--nano-edge); border-radius:.4rem; padding:.7rem .8rem; overflow:auto; margin:.6rem 0; }
650
+ .pc-md-pre code { font:.82rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--nano-text); white-space:pre; }
651
+ .pc-md-code { background:var(--nano-inset); border-radius:.3rem; padding:.05rem .3rem; font:.85em ui-monospace,SFMono-Regular,Menlo,monospace; }
652
+ .pc-md-hr { border:0; border-top:1px solid var(--nano-edge); margin:1rem 0; }
653
+ .pc-prose-body a { color:var(--nano-accent-strong); }
630
654
  /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
631
655
  .pc-buttonrow { margin:1rem 0; }
632
656
  .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
@@ -735,8 +759,8 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
735
759
 
736
760
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
737
761
  // /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
738
- // fetches the home page's page.json and renders text / actionForm / dataGrid nodes,
739
- // wiring actionForm → /app/actions/start and dataGrid → /app/data (with a refresh).
762
+ // fetches the home page's page.json and renders text / actionForm / dataGrid / prose
763
+ // nodes, wiring actionForm → /app/actions/start and dataGrid/prose → /app/data (with a refresh).
740
764
  const RENDERER_JS = String.raw`
741
765
  const root = document.getElementById("page");
742
766
  const HOME = root.dataset.home || "home";
@@ -1044,7 +1068,9 @@ function resolveCopyText(text) {
1044
1068
  function openModal(m) {
1045
1069
  // Only one modal at a time — drop a second (e.g. double-click) open so we
1046
1070
  // never stack overlays or register duplicate document-level keydown handlers.
1047
- if (modalOpen) return;
1071
+ // Return null (not a close fn) so a guarded caller (confirmModal) can tell the
1072
+ // open was dropped and resolve rather than hang.
1073
+ if (modalOpen) return null;
1048
1074
  modalOpen = true;
1049
1075
  const overlay = el("div", { class: "pc-modal-overlay" });
1050
1076
  // role="dialog" + aria-modal need an accessible name: label by the visible
@@ -1063,6 +1089,13 @@ function openModal(m) {
1063
1089
  // Remember what had focus so we can restore it on close — a keyboard/screen
1064
1090
  // reader user returns to where they were instead of being dumped at <body>.
1065
1091
  const prevFocus = document.activeElement;
1092
+ // A dismissal (✕/Close button, backdrop, Escape, page switch) resolves with
1093
+ // m.dismissValue; an action button overrides it before closing. onResult fires
1094
+ // exactly once, from close(), so every exit path (button OR dismiss) reports a
1095
+ // result — this is what lets confirmModal() gate on a real user choice inside a
1096
+ // sandboxed iframe where native confirm() would silently return false (#276).
1097
+ const onResult = typeof m.onResult === "function" ? m.onResult : null;
1098
+ let resultValue = m.dismissValue;
1066
1099
  let closed = false;
1067
1100
  function close() {
1068
1101
  if (closed) return;
@@ -1075,6 +1108,9 @@ function openModal(m) {
1075
1108
  // accrete dead close() closures until the next page navigation runs teardown.
1076
1109
  const i = disposers.indexOf(close);
1077
1110
  if (i !== -1) disposers.splice(i, 1);
1111
+ // A consumer-provided result callback must never abort teardown: if it throws,
1112
+ // swallow it so focus restoration below (and Escape/backdrop close) still runs.
1113
+ if (onResult) { try { onResult(resultValue); } catch (_e) {} }
1078
1114
  if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
1079
1115
  }
1080
1116
  // Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
@@ -1107,7 +1143,29 @@ function openModal(m) {
1107
1143
  }
1108
1144
  const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
1109
1145
  closeBtn.addEventListener("click", close);
1110
- actions.append(closeBtn);
1146
+ // Custom action buttons (m.actions) turn this into a decision dialog — each
1147
+ // sets the result then closes; the plain Close button is the default when none
1148
+ // are given. This is the in-DOM replacement for native confirm()/alert(): one
1149
+ // overlay + focus-trap + teardown machinery, no drift (AGENTS.md).
1150
+ let focusBtn = closeBtn;
1151
+ if (Array.isArray(m.actions) && m.actions.length) {
1152
+ let firstActionBtn = null;
1153
+ let defaultBtn = null;
1154
+ for (const a of m.actions) {
1155
+ const cls = "pc-btn pc-btn-sm" + (a.variant === "ghost" ? " pc-btn-ghost" : "");
1156
+ const ab = el("button", { class: cls, type: "button" }, a.label);
1157
+ ab.addEventListener("click", () => { resultValue = a.value; close(); });
1158
+ actions.append(ab);
1159
+ if (!firstActionBtn) firstActionBtn = ab;
1160
+ if (a.default) defaultBtn = ab;
1161
+ }
1162
+ // Default focus lands on the FIRST action (typically Cancel), never the last:
1163
+ // for confirmModal that keeps a stray Enter from accidentally triggering the
1164
+ // destructive Confirm. An action can opt into autofocus with default:true.
1165
+ focusBtn = defaultBtn || firstActionBtn;
1166
+ } else {
1167
+ actions.append(closeBtn);
1168
+ }
1111
1169
  dialog.append(actions);
1112
1170
  overlay.append(dialog);
1113
1171
  // A click on the backdrop (never the dialog itself) dismisses.
@@ -1117,7 +1175,36 @@ function openModal(m) {
1117
1175
  // A page switch runs teardown() before the next render — dispose the modal
1118
1176
  // there too so navigating away never leaves a stale overlay + keydown listener.
1119
1177
  disposers.push(close);
1120
- closeBtn.focus();
1178
+ focusBtn.focus();
1179
+ return close;
1180
+ }
1181
+
1182
+ // In-DOM confirm — an accessible replacement for native confirm() that works in
1183
+ // a sandboxed iframe (without allow-modals native confirm() silently returns
1184
+ // false, dead-ending every confirm-guarded action; #276). Renders the message
1185
+ // with Cancel/Confirm and resolves the user's choice; a dismissal (Escape /
1186
+ // backdrop / ✕) resolves false. If a modal is already open the guarded open is
1187
+ // dropped — resolve false so the caller doesn't hang.
1188
+ function confirmModal(message) {
1189
+ return new Promise((resolve) => {
1190
+ const opened = openModal({
1191
+ title: "Confirm",
1192
+ description: String(message),
1193
+ dismissValue: false,
1194
+ actions: [
1195
+ { label: "Cancel", value: false, variant: "ghost" },
1196
+ { label: "Confirm", value: true },
1197
+ ],
1198
+ onResult: (v) => resolve(!!v),
1199
+ });
1200
+ if (!opened) resolve(false);
1201
+ });
1202
+ }
1203
+
1204
+ // In-DOM error surface — replaces native alert(), which a sandboxed iframe also
1205
+ // suppresses, so action failures reported through it were invisible (#276).
1206
+ function alertModal(message) {
1207
+ openModal({ title: "Error", description: String(message) });
1121
1208
  }
1122
1209
 
1123
1210
  // A standalone button node. Clicking it opens props.modal — used e.g. to surface
@@ -1735,6 +1822,248 @@ function renderActionForm(node) {
1735
1822
  // Single source of truth for both the initial render and every refresh below.
1736
1823
  function rowCountLabel(n) { return n + (n === 1 ? " row" : " rows"); }
1737
1824
 
1825
+ // Build the /app/data/<source>/<table> URL a data-bound list renderer fetches,
1826
+ // threading the whitelisted where/order query params. Shared by dataGrid and the
1827
+ // prose renderer so the two can't drift on how a filter/orderBy is encoded. A
1828
+ // filter is { field, in:[…] } (IN set), { field, eqParam:true } (bound to the
1829
+ // current route PARAM — "show the selected entity's rows"), or { field, eq } (a
1830
+ // literal equals); orderBy is { field, dir }. The source/table/field names are
1831
+ // server-side whitelisted (the /app/data route rejects unknown columns).
1832
+ function dataUrl(source, tbl, filters, order) {
1833
+ let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
1834
+ const qs = [];
1835
+ for (const f of filters || []) {
1836
+ if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1837
+ else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1838
+ else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1839
+ }
1840
+ if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
1841
+ return qs.length ? u + "?" + qs.join("&") : u;
1842
+ }
1843
+
1844
+ // ── Minimal, safe Markdown → DOM (no innerHTML) — used by the prose renderer ──
1845
+ // A dependency-free CommonMark subset. It NEVER sets innerHTML: every block and
1846
+ // inline token is materialised as a known element via el(), with text set through
1847
+ // textContent / createTextNode — so a record's body field may carry markdown
1848
+ // WITHOUT ever becoming an HTML/script injection vector (the same text-only
1849
+ // guarantee interpTemplate gives a grid cell). Supported: ATX headings
1850
+ // (#..######), fenced + inline code, bold (**/__), italic (*/_), links
1851
+ // [text](url) (http/https/mailto only — every other scheme degrades to its plain
1852
+ // label), unordered (-,*,+) and ordered (1. / 1)) lists, blockquotes (>),
1853
+ // thematic breaks (---), and blank-line-separated paragraphs. The backtick used
1854
+ // for code spans/fences is built from a char code so this source stays inside the
1855
+ // String.raw template literal (which is itself delimited by a backtick).
1856
+ const MD_BACKTICK = String.fromCharCode(96);
1857
+ function mdIsWordChar(c) { return typeof c === "string" && /[0-9A-Za-z]/.test(c); }
1858
+ // Only http(s)/mailto hrefs become anchors; anything else (javascript:, data:,
1859
+ // vbscript:, a bare relative path, …) returns "" so the caller renders the label
1860
+ // as plain text rather than a clickable, potentially-hostile link.
1861
+ function mdSafeHref(href) {
1862
+ const h = String(href == null ? "" : href).trim();
1863
+ return /^(?:https?:|mailto:)/i.test(h) ? h : "";
1864
+ }
1865
+ // Find the closing run of 'marker' at or after 'from'. For underscore emphasis the
1866
+ // close must not be intra-word (a following alphanumeric means snake_case, not a
1867
+ // delimiter), so keep scanning past such runs; -1 when there is no valid close.
1868
+ function mdFindClose(s, marker, from, underscore) {
1869
+ let j = from;
1870
+ while (true) {
1871
+ const k = s.indexOf(marker, j);
1872
+ if (k < 0) return -1;
1873
+ if (!underscore || !mdIsWordChar(s[k + marker.length])) return k;
1874
+ j = k + marker.length;
1875
+ }
1876
+ }
1877
+ function mdInline(text) {
1878
+ const s = String(text == null ? "" : text);
1879
+ const nodes = [];
1880
+ let buf = "";
1881
+ const flush = () => { if (buf) { nodes.push(document.createTextNode(buf)); buf = ""; } };
1882
+ let i = 0;
1883
+ while (i < s.length) {
1884
+ const ch = s[i];
1885
+ // Backslash escape: the next char is emitted literally, never as a delimiter.
1886
+ if (ch === "\\" && i + 1 < s.length) { buf += s[i + 1]; i += 2; continue; }
1887
+ // Inline code span — verbatim, no nested inline parsing.
1888
+ if (ch === MD_BACKTICK) {
1889
+ const end = s.indexOf(MD_BACKTICK, i + 1);
1890
+ if (end > i) { flush(); nodes.push(el("code", { class: "pc-md-code" }, s.slice(i + 1, end))); i = end + 1; continue; }
1891
+ }
1892
+ // Strong (** or __). Underscore only when it opens on a word boundary so an
1893
+ // identifier like a__b doesn't get mangled into emphasis.
1894
+ if ((ch === "*" || ch === "_") && s[i + 1] === ch) {
1895
+ const underscore = ch === "_";
1896
+ if (!underscore || !mdIsWordChar(s[i - 1])) {
1897
+ const marker = ch + ch;
1898
+ const end = mdFindClose(s, marker, i + 2, underscore);
1899
+ if (end > i + 1) { flush(); nodes.push(el("strong", {}, ...mdInline(s.slice(i + 2, end)))); i = end + 2; continue; }
1900
+ }
1901
+ }
1902
+ // Emphasis (* or _), same word-boundary guard for underscore.
1903
+ if (ch === "*" || ch === "_") {
1904
+ const underscore = ch === "_";
1905
+ if (!underscore || !mdIsWordChar(s[i - 1])) {
1906
+ const end = mdFindClose(s, ch, i + 1, underscore);
1907
+ if (end > i + 1) { flush(); nodes.push(el("em", {}, ...mdInline(s.slice(i + 1, end)))); i = end + 1; continue; }
1908
+ }
1909
+ }
1910
+ // Link [label](href): href sanitised; an unsafe scheme drops to the label text.
1911
+ if (ch === "[") {
1912
+ const close = s.indexOf("]", i + 1);
1913
+ if (close > i && s[close + 1] === "(") {
1914
+ const paren = s.indexOf(")", close + 2);
1915
+ if (paren > close) {
1916
+ const href = mdSafeHref(s.slice(close + 2, paren));
1917
+ const label = mdInline(s.slice(i + 1, close));
1918
+ flush();
1919
+ if (href) nodes.push(el("a", { href: href, target: "_blank", rel: "noopener noreferrer" }, ...label));
1920
+ else for (const l of label) nodes.push(l);
1921
+ i = paren + 1; continue;
1922
+ }
1923
+ }
1924
+ }
1925
+ buf += ch; i++;
1926
+ }
1927
+ flush();
1928
+ return nodes;
1929
+ }
1930
+ // Regexes that classify a block-opening line; shared by the block loop and the
1931
+ // paragraph terminator so the two can't disagree on where a paragraph ends.
1932
+ const MD_HEADING = /^(#{1,6})\s+(.*)$/;
1933
+ const MD_QUOTE = /^>\s?/;
1934
+ const MD_UL = /^[-*+]\s+/;
1935
+ const MD_OL = /^\d+[.)]\s+/;
1936
+ const MD_HR = /^([-*_])(?:\s*\1){2,}\s*$/;
1937
+ function mdBlockStart(t) {
1938
+ return t.slice(0, 3) === MD_BACKTICK + MD_BACKTICK + MD_BACKTICK
1939
+ || MD_HEADING.test(t) || MD_QUOTE.test(t) || MD_UL.test(t) || MD_OL.test(t) || MD_HR.test(t);
1940
+ }
1941
+ // Parse a markdown string into an array of block-level DOM nodes.
1942
+ function mdToNodes(src) {
1943
+ const out = [];
1944
+ const fence = MD_BACKTICK + MD_BACKTICK + MD_BACKTICK;
1945
+ const lines = String(src == null ? "" : src).replace(/\r\n?/g, "\n").split("\n");
1946
+ let i = 0;
1947
+ while (i < lines.length) {
1948
+ const t = lines[i].trim();
1949
+ if (t === "") { i++; continue; }
1950
+ // Fenced code block — content is verbatim (no inline parsing).
1951
+ if (t.slice(0, 3) === fence) {
1952
+ const body = [];
1953
+ i++;
1954
+ while (i < lines.length && lines[i].trim().slice(0, 3) !== fence) { body.push(lines[i]); i++; }
1955
+ if (i < lines.length) i++; // consume the closing fence
1956
+ out.push(el("pre", { class: "pc-md-pre" }, el("code", {}, body.join("\n"))));
1957
+ continue;
1958
+ }
1959
+ // Thematic break (checked before lists so "***"/"---" isn't read as a bullet).
1960
+ if (MD_HR.test(t)) { out.push(el("hr", { class: "pc-md-hr" })); i++; continue; }
1961
+ // ATX heading.
1962
+ const h = MD_HEADING.exec(t);
1963
+ if (h) { out.push(el("h" + h[1].length, { class: "pc-md-h" }, ...mdInline(h[2].trim()))); i++; continue; }
1964
+ // Blockquote — strip one '>' per line and re-parse the inner block(s).
1965
+ if (MD_QUOTE.test(t)) {
1966
+ const inner = [];
1967
+ while (i < lines.length && MD_QUOTE.test(lines[i].trim())) { inner.push(lines[i].trim().replace(MD_QUOTE, "")); i++; }
1968
+ const bq = el("blockquote", { class: "pc-md-quote" });
1969
+ for (const b of mdToNodes(inner.join("\n"))) bq.append(b);
1970
+ out.push(bq); continue;
1971
+ }
1972
+ // Unordered list.
1973
+ if (MD_UL.test(t)) {
1974
+ const ul = el("ul", { class: "pc-md-list" });
1975
+ while (i < lines.length && MD_UL.test(lines[i].trim())) {
1976
+ ul.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_UL, ""))));
1977
+ i++;
1978
+ }
1979
+ out.push(ul); continue;
1980
+ }
1981
+ // Ordered list.
1982
+ if (MD_OL.test(t)) {
1983
+ const ol = el("ol", { class: "pc-md-list" });
1984
+ while (i < lines.length && MD_OL.test(lines[i].trim())) {
1985
+ ol.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_OL, ""))));
1986
+ i++;
1987
+ }
1988
+ out.push(ol); continue;
1989
+ }
1990
+ // Paragraph — soft-wrapped lines joined with a space until a blank line or the
1991
+ // next block opener.
1992
+ const para = [];
1993
+ while (i < lines.length) {
1994
+ const lt = lines[i].trim();
1995
+ if (lt === "" || mdBlockStart(lt)) break;
1996
+ para.push(lt); i++;
1997
+ }
1998
+ out.push(el("p", { class: "pc-md-p" }, ...mdInline(para.join(" "))));
1999
+ }
2000
+ return out;
2001
+ }
2002
+
2003
+ // A data-bound prose/markdown list (#274). Binds a datasource like dataGrid
2004
+ // (props.data = { source, table, orderBy, filter }) but renders each row as a
2005
+ // stacked prose block — a small header template (props.header, e.g.
2006
+ // "Round {{round}} · {{approved}}") over one body field (props.body) rendered as
2007
+ // sanitised markdown at a comfortable measure (props.measure ch, ~66 default).
2008
+ // This is the primitive for narrative records (plan-review findings, coordination
2009
+ // notes, escalation bodies) that read as a document, not a squeezed table cell.
2010
+ // It inherits collapsible/defaultCollapsed for free via makeCollapsible (which
2011
+ // unwraps the <h2> title), and refreshMs polling + pc:refresh like dataGrid.
2012
+ function renderProse(node) {
2013
+ const p = node.props || {};
2014
+ const data = p.data || {};
2015
+ const card = el("section", { class: "pc-card" });
2016
+ if (p.title) card.append(el("h2", {}, p.title));
2017
+ const listEl = el("div", { class: "pc-prose-list" });
2018
+ card.append(listEl);
2019
+ const headerTpl = typeof p.header === "string" ? p.header : null;
2020
+ const bodyField = p.body;
2021
+ // Clamp the reading measure to a sane range so a malformed schema can't set a
2022
+ // 5000ch (full-bleed) or 2ch (one-word-per-line) body. ~66ch is the default.
2023
+ let measure = Number(p.measure);
2024
+ if (!Number.isFinite(measure)) measure = 66;
2025
+ measure = Math.max(40, Math.min(100, Math.round(measure)));
2026
+ const activeFilter = Array.isArray(data.filter) ? data.filter : [];
2027
+
2028
+ function itemFor(row) {
2029
+ const item = el("article", { class: "pc-prose-item" });
2030
+ if (headerTpl) item.append(el("div", { class: "pc-prose-head" }, interpTemplate(headerTpl, row)));
2031
+ const bodyEl = el("div", { class: "pc-prose-body", style: "max-width:" + measure + "ch" });
2032
+ // Own-property gate (matching interpTemplate) so a body field name that
2033
+ // collides with a prototype key can't pick up prototype cruft.
2034
+ const raw = bodyField != null && Object.prototype.hasOwnProperty.call(row, bodyField) && row[bodyField] != null
2035
+ ? String(row[bodyField])
2036
+ : "";
2037
+ for (const b of mdToNodes(raw)) bodyEl.append(b);
2038
+ item.append(bodyEl);
2039
+ return item;
2040
+ }
2041
+
2042
+ async function refresh() {
2043
+ try {
2044
+ // A param-scoped list ("show the selected entity's records") with no route
2045
+ // param present renders nothing rather than a field=empty-string query.
2046
+ const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
2047
+ const { rows } = paramScoped && PARAM === ""
2048
+ ? { rows: [] }
2049
+ : await getJSON(dataUrl(data.source, data.table, activeFilter, data.orderBy));
2050
+ listEl.replaceChildren();
2051
+ if (!rows.length) { listEl.append(el("p", { class: "pc-prose-empty" }, p.empty || "No records")); return; }
2052
+ for (const row of rows) listEl.append(itemFor(row));
2053
+ } catch (e) {
2054
+ listEl.replaceChildren(el("p", { class: "pc-msg err" }, String((e && e.message) || e)));
2055
+ }
2056
+ }
2057
+ document.addEventListener("pc:refresh", refresh);
2058
+ disposers.push(() => document.removeEventListener("pc:refresh", refresh));
2059
+ if (p.refreshMs && p.refreshMs > 0) {
2060
+ const timer = setInterval(refresh, p.refreshMs);
2061
+ disposers.push(() => clearInterval(timer));
2062
+ }
2063
+ refresh();
2064
+ return card;
2065
+ }
2066
+
1738
2067
  function renderDataGrid(node) {
1739
2068
  const p = node.props;
1740
2069
  const card = el("section", { class: "pc-card" });
@@ -1808,18 +2137,6 @@ function renderDataGrid(node) {
1808
2137
  return v == null ? null : String(v);
1809
2138
  };
1810
2139
 
1811
- function dataUrl(source, tbl, filters, order) {
1812
- let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
1813
- const qs = [];
1814
- for (const f of filters || []) {
1815
- if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1816
- else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1817
- else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1818
- }
1819
- if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
1820
- return qs.length ? u + "?" + qs.join("&") : u;
1821
- }
1822
-
1823
2140
  async function fireAction(action, row) {
1824
2141
  // Route-driven row action: POST the action's body template (default {})
1825
2142
  // to its route path, with {{row.KEY}} tokens resolved from this row.
@@ -1831,14 +2148,18 @@ function renderDataGrid(node) {
1831
2148
  const b = el("button", { class: "pc-btn pc-btn-sm" }, ra.label);
1832
2149
  b.addEventListener("click", async (ev) => {
1833
2150
  ev.stopPropagation();
1834
- if (ra.confirm && !confirm(ra.confirm)) return;
2151
+ // In-DOM confirm/error (openModal) instead of native confirm()/alert():
2152
+ // a sandboxed iframe without allow-modals makes native confirm() return
2153
+ // false and alert() a no-op, so every confirm-guarded action silently
2154
+ // dead-ended and its errors vanished (#276).
2155
+ if (ra.confirm && !(await confirmModal(ra.confirm))) return;
1835
2156
  b.disabled = true;
1836
2157
  try {
1837
2158
  await fireAction(ra.action, row);
1838
2159
  document.dispatchEvent(new CustomEvent("pc:refresh"));
1839
2160
  } catch (e) {
1840
2161
  b.disabled = false;
1841
- alert(String(e.message || e));
2162
+ alertModal(String(e.message || e));
1842
2163
  }
1843
2164
  });
1844
2165
  return b;
@@ -2225,7 +2546,7 @@ function renderNav(node) {
2225
2546
  return nav;
2226
2547
  }
2227
2548
 
2228
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
2549
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, prose: renderProse, nav: renderNav, button: renderButton };
2229
2550
 
2230
2551
  // Durable per-node UI state. localStorage is keyed by the home page id + node
2231
2552
  // id so two grids on the same page (or the same grid across pages) don't clash,
@@ -29,6 +29,7 @@ function ctxWith(surfaces: Record<string, unknown>): Parameters<typeof mountSurf
29
29
  cancelInstance: async () => {},
30
30
  publishMessage: async () => {},
31
31
  searchUserTasks: async () => [],
32
+ openUserTasks: async () => [],
32
33
  getForm: async () => null,
33
34
  completeUserTask: async () => {},
34
35
  searchProcessInstances: async () => [],
@@ -44,6 +45,7 @@ const fakeEngine: EngineClient = {
44
45
  cancelInstance: async () => {},
45
46
  publishMessage: async () => {},
46
47
  searchUserTasks: async () => [],
48
+ openUserTasks: async () => [],
47
49
  getForm: async () => null,
48
50
  completeUserTask: async () => {},
49
51
  searchProcessInstances: async () => [],
@@ -30,6 +30,7 @@ class MiniEngine implements EngineClient {
30
30
  this.calls.push({ kind: "message", target: input.name, correlationKey: input.correlationKey, variables: input.variables });
31
31
  }
32
32
  async searchUserTasks(): Promise<[]> { return []; }
33
+ async openUserTasks(): Promise<[]> { return []; }
33
34
  async getForm(): Promise<null> { return null; }
34
35
  async completeUserTask(): Promise<void> {}
35
36
  async searchProcessInstances(): Promise<[]> { return []; }
@@ -27,6 +27,7 @@ class MiniEngine implements EngineClient {
27
27
  async cancelInstance(): Promise<void> {}
28
28
  async publishMessage(): Promise<void> {}
29
29
  async searchUserTasks(): Promise<[]> { return []; }
30
+ async openUserTasks(): Promise<[]> { return []; }
30
31
  async getForm(): Promise<null> { return null; }
31
32
  async completeUserTask(): Promise<void> {}
32
33
  async searchProcessInstances(): Promise<[]> { return []; }
@@ -22,6 +22,9 @@ class NoopEngine implements EngineClient {
22
22
  async searchUserTasks() {
23
23
  return [];
24
24
  }
25
+ async openUserTasks() {
26
+ return [];
27
+ }
25
28
  async getForm() {
26
29
  return null;
27
30
  }
@@ -41,6 +41,9 @@ class FakeEngine implements EngineClient {
41
41
  async searchUserTasks() {
42
42
  return this.userTasks;
43
43
  }
44
+ async openUserTasks() {
45
+ return this.userTasks;
46
+ }
44
47
  async getForm() {
45
48
  return null;
46
49
  }