@nanobpm/urban 0.42.0 → 0.43.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.
@@ -19,10 +19,14 @@ type CancelApi = Pick<AppApi, "engine" | "data" | "log">;
19
19
  * Outcome rules:
20
20
  * - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
21
21
  * 204); trust it even if the read model still lags at ACTIVE.
22
- * - `cancelInstance` throws but the instance reads back non-ACTIVE (already TERMINATED/COMPLETED
23
- * /gone) → the cancel was effectively idempotent; treat as success.
24
- * - `cancelInstance` throws and the instance is still ACTIVE → an honest failure: report it and
25
- * do NOT touch any row (the instance is still running).
22
+ * - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
23
+ * → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
24
+ * row here (see below); a COMPLETED read intentionally reconciles nothing.
25
+ * - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
26
+ * (absent from the read model, which after an engine restart can transiently lack a live
27
+ * instance) → an honest failure: report it and do NOT touch any row (the instance may still be
28
+ * running). A throw is not a committed cancel, and absence from the read model is not proof of
29
+ * termination, so success is never inferred from it.
26
30
  *
27
31
  * Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
28
32
  * own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
@@ -13,9 +13,9 @@
13
13
  //
14
14
  // This primitive keys success on the engine's response, then reconciles off the read model: a
15
15
  // non-throwing `cancelInstance` is a committed termination (trusted even if the read model still
16
- // lags at ACTIVE), while a throw whose instance reads back still-ACTIVE is an honest failure. It
17
- // reuses the instanceTracking bindings' `onTerminated.set` patch, so the immediate reconcile can
18
- // never drift from the poll reconciler's.
16
+ // lags at ACTIVE), while a throw that does not read back a positively-terminal state (still ACTIVE,
17
+ // or absent from the read model) is an honest failure. It reuses the instanceTracking bindings'
18
+ // `onTerminated.set` patch, so the immediate reconcile can never drift from the poll reconciler's.
19
19
  import { errorMessage } from "../guards.js";
20
20
  import { reconcileTerminatedKey } from "./instance-tracking.js";
21
21
  /**
@@ -25,10 +25,14 @@ import { reconcileTerminatedKey } from "./instance-tracking.js";
25
25
  * Outcome rules:
26
26
  * - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
27
27
  * 204); trust it even if the read model still lags at ACTIVE.
28
- * - `cancelInstance` throws but the instance reads back non-ACTIVE (already TERMINATED/COMPLETED
29
- * /gone) → the cancel was effectively idempotent; treat as success.
30
- * - `cancelInstance` throws and the instance is still ACTIVE → an honest failure: report it and
31
- * do NOT touch any row (the instance is still running).
28
+ * - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
29
+ * → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
30
+ * row here (see below); a COMPLETED read intentionally reconciles nothing.
31
+ * - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
32
+ * (absent from the read model, which after an engine restart can transiently lack a live
33
+ * instance) → an honest failure: report it and do NOT touch any row (the instance may still be
34
+ * running). A throw is not a committed cancel, and absence from the read model is not proof of
35
+ * termination, so success is never inferred from it.
32
36
  *
33
37
  * Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
34
38
  * own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
@@ -43,11 +47,19 @@ export async function cancelInstanceReconciling(api, bindings, processInstanceKe
43
47
  threw = error;
44
48
  }
45
49
  const state = await readState(api, key, threw);
46
- if (threw && state === "ACTIVE") {
47
- // The cancel genuinely failed and the instance is still running — never flip a row to a
48
- // terminal status while the engine keeps executing it.
49
- api.log("error", "cancel: engine termination failed, instance still active", {
50
+ if (threw && state !== "TERMINATED" && state !== "COMPLETED") {
51
+ // The cancel threw, so we never got a committed 204 — and the verify read did NOT positively
52
+ // confirm a terminal state. Two shapes land here, both honest failures:
53
+ // • state === "ACTIVE": the instance is demonstrably still running.
54
+ // • state === "gone": the read model has no record of the key. A throw makes this
55
+ // ambiguous — after an engine restart the query store can transiently lack a live instance
56
+ // before it rehydrates, so "gone" is absence-of-evidence, NOT proof of termination.
57
+ // Trust neither as success: claiming a cancel that may not have happened (while the run keeps
58
+ // executing) is the worst possible lie for a cancel button. Report ok:false so the caller
59
+ // surfaces it and retries; never flip a tracked row to terminal while the instance may live.
60
+ api.log("error", "cancel: engine termination not confirmed, instance may still be active", {
50
61
  processInstanceKey: key,
62
+ state,
51
63
  error: errorMessage(threw),
52
64
  });
53
65
  return { ok: false, processInstanceKey: key, state, reconciled: 0, error: errorMessage(threw) };
@@ -483,7 +483,13 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
483
483
  .pc-msg.err { color:var(--nano-danger); }
484
484
  .pc-msg.ok { color:var(--nano-ok); }
485
485
  table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; }
486
- table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); }
486
+ /* Wrap long, space-less cell values (e.g. a JSON blob or a 40-char SHA) so one
487
+ cell can't force its column wide and blow the whole table past the page.
488
+ overflow-wrap:anywhere reduces the cell's min-content width so the auto table
489
+ layout can shrink it to fit; max-width caps a single column when there is
490
+ ample room. vertical-align:top keeps a multi-line cell aligned with its
491
+ single-line neighbours. */
492
+ table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); overflow-wrap:anywhere; word-wrap:break-word; max-width:32rem; vertical-align:top; }
487
493
  table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
488
494
  .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
489
495
  .pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
@@ -495,6 +501,13 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
495
501
  .pc-detail-field { display:flex; gap:.5rem; font-size:.85rem; margin:.15rem 0; }
496
502
  .pc-detail-label { color:var(--nano-text-muted); min-width:8rem; }
497
503
  .pc-link { color:var(--nano-accent); }
504
+ .pc-badge { display:inline-flex; align-items:center; justify-content:center; min-width:1.35rem; height:1.35rem; box-sizing:border-box; padding:0 .4rem; border-radius:999px; font-size:.72rem; font-weight:700; line-height:1; color:#fff; background:var(--nano-danger); }
505
+ .pc-badge-danger { background:var(--nano-danger); }
506
+ .pc-badge-warn { background:var(--nano-warn); color:#3a2a00; }
507
+ .pc-badge-ok { background:var(--nano-ok); }
508
+ .pc-badge-info { background:var(--nano-info); }
509
+ :root[data-appearance="light"] .pc-badge-warn { color:#fff; }
510
+ @media (prefers-color-scheme: light) { :root:not([data-appearance]) .pc-badge-warn { color:#fff; } }
498
511
  .pc-child { margin:.6rem 0; }
499
512
  .pc-child-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); margin-bottom:.25rem; }
500
513
  .pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:var(--nano-inset); padding:.5rem; border-radius:.4rem; font-size:.8rem; margin-top:.4rem; }
@@ -505,6 +518,16 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
505
518
  .pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
506
519
  .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
507
520
  .pc-card-body[hidden] { display:none; }
521
+ /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
522
+ .pc-buttonrow { margin:1rem 0; }
523
+ .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
524
+ .pc-btn-ghost:hover { color:var(--nano-text); border-color:var(--nano-accent); }
525
+ .pc-modal-overlay { position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center; padding:1.5rem; background:rgba(0,0,0,.55); }
526
+ .pc-modal { background:var(--nano-panel); border:1px solid var(--nano-edge-strong); border-radius:.6rem; padding:1.25rem 1.35rem; max-width:44rem; width:100%; max-height:85vh; overflow:auto; box-shadow:0 12px 40px rgba(0,0,0,.45); }
527
+ .pc-modal-title { font-size:1.15rem; margin:0 0 .5rem; }
528
+ .pc-modal-desc { color:var(--nano-text-muted); margin:0 0 .75rem; font-size:.9rem; white-space:pre-wrap; }
529
+ .pc-modal-code { white-space:pre-wrap; word-break:break-word; background:var(--nano-inset); border:1px solid var(--nano-edge); border-radius:.4rem; padding:.75rem; font:.82rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; max-height:45vh; overflow:auto; margin:0 0 .85rem; color:var(--nano-text); }
530
+ .pc-modal-actions { display:flex; gap:.5rem; justify-content:flex-end; }
508
531
  /* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
509
532
  .pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
510
533
  .pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
@@ -561,6 +584,15 @@ function teardown() {
561
584
  }
562
585
  }
563
586
 
587
+ // Monotonic counter for per-modal element ids. aria-labelledby needs a unique
588
+ // title id: a fixed id would clash if the page already contains that id, and
589
+ // mis-associate the label for assistive tech.
590
+ let modalSeq = 0;
591
+ // At most one modal is open at a time. A double-click (or two rapid button
592
+ // clicks) would otherwise stack multiple overlays + document-level keydown
593
+ // listeners; this guard keeps a single dialog + handler live.
594
+ let modalOpen = false;
595
+
564
596
  // Theme bridge for the Nano console embed. The app runs in a sandboxed,
565
597
  // same-origin iframe, so the console's --nano-* custom properties and its
566
598
  // data-appearance never cascade in. Instead the console postMessages its
@@ -714,11 +746,151 @@ function renderText(node) {
714
746
  return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
715
747
  }
716
748
 
717
- // A grid td cell. Two column-declared linking modes, checked in order:
718
- // 1. linkField — the cell text becomes a link to the URL held in that other
749
+ // Copy text to the clipboard, resiliently. The async Clipboard API is tried
750
+ // first, but it can reject inside a sandboxed console iframe (no clipboard-write
751
+ // permission) — so fall back to a hidden <textarea> + execCommand("copy"), which
752
+ // works under a user gesture with only allow-scripts. Returns whether a copy
753
+ // succeeded so the caller can nudge the user to ⌘/Ctrl+C when both paths fail.
754
+ async function copyToClipboard(text) {
755
+ try {
756
+ if (navigator.clipboard && navigator.clipboard.writeText) {
757
+ await navigator.clipboard.writeText(text);
758
+ return true;
759
+ }
760
+ } catch (_e) { /* fall through to the execCommand path */ }
761
+ const ta = document.createElement("textarea");
762
+ try {
763
+ ta.value = text;
764
+ ta.setAttribute("readonly", "");
765
+ ta.style.position = "fixed";
766
+ ta.style.top = "-1000px";
767
+ ta.style.opacity = "0";
768
+ document.body.append(ta);
769
+ ta.select();
770
+ return document.execCommand("copy");
771
+ } catch (_e) { return false; }
772
+ // Always remove the hidden textarea, even if select()/execCommand threw, so a
773
+ // failed copy never leaves a detached node accreting in <body> on retry.
774
+ finally { ta.remove(); }
775
+ }
776
+
777
+ // Rebase the sole {{appBase}} token in author-supplied copy text onto the app's
778
+ // absolute mount root (always ends in "/"), so a prompt like
779
+ // "read {{appBase}}app/api/agent" resolves to a URL an external agent can fetch —
780
+ // the console proxy path when embedded, the app origin when standalone. No other
781
+ // interpolation happens: copy text is otherwise emitted verbatim.
782
+ function resolveCopyText(text) {
783
+ return typeof text === "string" ? text.split("{{appBase}}").join(APP_BASE.toString()) : "";
784
+ }
785
+
786
+ // Open a lightweight modal appended to <body>. Closes on the ✕/Close button, a
787
+ // backdrop click, Escape, or a page switch; the keydown listener is removed on
788
+ // close so it never leaks, and close() is registered with teardown() so
789
+ // navigating away (hashchange → renderPage → teardown) tears down any open modal
790
+ // instead of leaving a stale overlay + document-level listener alive. When it
791
+ // carries copyText it renders a scrollable code block plus a Copy button (see
792
+ // copyToClipboard for the sandbox-safe fallback).
793
+ function openModal(m) {
794
+ // Only one modal at a time — drop a second (e.g. double-click) open so we
795
+ // never stack overlays or register duplicate document-level keydown handlers.
796
+ if (modalOpen) return;
797
+ modalOpen = true;
798
+ const overlay = el("div", { class: "pc-modal-overlay" });
799
+ // role="dialog" + aria-modal need an accessible name: label by the visible
800
+ // title when present, else fall back to a generic aria-label so screen readers
801
+ // don't announce an unnamed dialog. The title id is unique per instance so it
802
+ // can't collide with a pre-existing page id.
803
+ const titleId = m.title ? "pc-modal-title-" + (++modalSeq) : null;
804
+ const dialog = el("div", titleId
805
+ ? { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId }
806
+ : { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-label": "Dialog" });
807
+ if (m.title) dialog.append(el("h2", { class: "pc-modal-title", id: titleId }, m.title));
808
+ if (m.description) dialog.append(el("p", { class: "pc-modal-desc" }, m.description));
809
+ const copyText = resolveCopyText(m.copyText);
810
+ if (copyText) dialog.append(el("pre", { class: "pc-modal-code" }, copyText));
811
+ const actions = el("div", { class: "pc-modal-actions" });
812
+ // Remember what had focus so we can restore it on close — a keyboard/screen
813
+ // reader user returns to where they were instead of being dumped at <body>.
814
+ const prevFocus = document.activeElement;
815
+ let closed = false;
816
+ function close() {
817
+ if (closed) return;
818
+ closed = true;
819
+ modalOpen = false;
820
+ document.removeEventListener("keydown", onKey);
821
+ overlay.remove();
822
+ // A manual close (button / Escape / backdrop) never pops the disposers
823
+ // stack, so drop our own entry here — otherwise repeated open/close cycles
824
+ // accrete dead close() closures until the next page navigation runs teardown.
825
+ const i = disposers.indexOf(close);
826
+ if (i !== -1) disposers.splice(i, 1);
827
+ if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
828
+ }
829
+ // Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
830
+ function focusables() {
831
+ return Array.prototype.slice.call(dialog.querySelectorAll(
832
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
833
+ )).filter((n) => !n.disabled);
834
+ }
835
+ function onKey(ev) {
836
+ if (ev.key === "Escape") { close(); return; }
837
+ if (ev.key !== "Tab") return;
838
+ // Trap Tab within the dialog so focus can't slip behind the overlay onto the
839
+ // inert page underneath — that would break aria-modal semantics.
840
+ const f = focusables();
841
+ if (!f.length) return;
842
+ const first = f[0];
843
+ const last = f[f.length - 1];
844
+ if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
845
+ else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
846
+ }
847
+ if (copyText) {
848
+ const label = m.copyLabel || "Copy";
849
+ const copyBtn = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, label);
850
+ copyBtn.addEventListener("click", async () => {
851
+ const ok = await copyToClipboard(copyText);
852
+ copyBtn.textContent = ok ? "Copied ✓" : "Press ⌘/Ctrl+C";
853
+ setTimeout(() => { copyBtn.textContent = label; }, 2200);
854
+ });
855
+ actions.append(copyBtn);
856
+ }
857
+ const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
858
+ closeBtn.addEventListener("click", close);
859
+ actions.append(closeBtn);
860
+ dialog.append(actions);
861
+ overlay.append(dialog);
862
+ // A click on the backdrop (never the dialog itself) dismisses.
863
+ overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); });
864
+ document.addEventListener("keydown", onKey);
865
+ document.body.append(overlay);
866
+ // A page switch runs teardown() before the next render — dispose the modal
867
+ // there too so navigating away never leaves a stale overlay + keydown listener.
868
+ disposers.push(close);
869
+ closeBtn.focus();
870
+ }
871
+
872
+ // A standalone button node. Clicking it opens props.modal — used e.g. to surface
873
+ // a copy-pasteable "point your agent here" prompt. props.variant === "ghost"
874
+ // renders the muted outline style. A button without a modal is inert.
875
+ function renderButton(node) {
876
+ const p = node.props || {};
877
+ const btn = el(
878
+ "button",
879
+ { class: "pc-btn" + (p.variant === "ghost" ? " pc-btn-ghost" : ""), type: "button" },
880
+ p.label || "Open",
881
+ );
882
+ const m = p.modal && typeof p.modal === "object" ? p.modal : null;
883
+ if (m) btn.addEventListener("click", () => openModal(m));
884
+ return el("div", { class: "pc-buttonrow" }, btn);
885
+ }
886
+
887
+ // A grid td cell with three column-declared rendering modes, checked in order:
888
+ // 1. badge — a compact status indicator, gated on a non-empty field value
889
+ // (see the detailed note at the check below).
890
+ // 2. linkField — the cell text becomes a link to the URL held in that other
719
891
  // field. Only http(s) hrefs are linked (a javascript:/other-scheme URL
720
892
  // smuggled through row data falls back to plain text).
721
- // 2. link: { kind: "processExplorer", keyField } — a structured, engine-aware
893
+ // 3. link: { kind: "processExplorer", keyField } — a structured, engine-aware
722
894
  // link: the cell text links to the Nano console's explorer view for the
723
895
  // process instance whose key is held in the row's keyField. The console
724
896
  // path is constructed HERE (never taken from row data) and the key is
@@ -734,6 +906,27 @@ function renderText(node) {
734
906
  // grid and child grids.
735
907
  function gridCell(col, row) {
736
908
  const text = row[col.field] == null ? "" : String(row[col.field]);
909
+ // 1. badge — a compact status indicator. When the row's field value is
910
+ // non-empty (truthy after trimming) render a small circular badge (e.g. a
911
+ // red "1" dot flagging an incident) whose tooltip is the full field text;
912
+ // when empty the cell stays blank so the column is unobtrusive until it
913
+ // matters. tone (danger|warn|ok|info, default danger) picks the color and
914
+ // label the glyph inside (default "1"). Label/tone come from the app
915
+ // schema (trusted); the tooltip is row data set via title/textContent
916
+ // (DOM-escaped by the platform, so no HTML/attribute injection). The full
917
+ // field text is also mirrored into aria-label so assistive tech announces
918
+ // the incident/status text (the visible "1" glyph alone is not meaningful).
919
+ if (col.badge) {
920
+ if (text.trim() === "") return el("td", {});
921
+ const t = col.badge.tone;
922
+ const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
923
+ const label = col.badge.label == null ? "1" : String(col.badge.label);
924
+ return el(
925
+ "td",
926
+ {},
927
+ el("span", { class: "pc-badge pc-badge-" + tone, title: text, "aria-label": text }, label),
928
+ );
929
+ }
737
930
  if (col.linkField) {
738
931
  const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
739
932
  if (text !== "" && /^https?:\/\//i.test(href)) {
@@ -1228,7 +1421,7 @@ function renderNav(node) {
1228
1421
  return nav;
1229
1422
  }
1230
1423
 
1231
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
1424
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
1232
1425
 
1233
1426
  // Durable per-node UI state. localStorage is keyed by the home page id + node
1234
1427
  // id so two grids on the same page (or the same grid across pages) don't clash,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.42.0",
3
+ "version": "0.43.0",
4
4
  "description": "Urban: build and run code-first apps on Nano — runtime, derivation toolkit, and CLI in one. Author durable processes with @nanobpm/workflow (defineFlow), a typed datasource, forms, triggers and surfaces from a nano.app.json manifest, on Node or Deno.",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",
@@ -66,7 +66,7 @@
66
66
  "@nanobpm/nano-sdk": "^1.2.3",
67
67
  "@nanobpm/workflow": "^0.8.0",
68
68
  "bpmn-auto-layout": "^2.0.0-alpha.2",
69
- "create-urban-app": "^0.13.0",
69
+ "create-urban-app": "^0.14.0",
70
70
  "yaml": "^2.9.0"
71
71
  },
72
72
  "devDependencies": {
@@ -311,3 +311,31 @@ test("a thrown cancel whose verify read also fails is reported as an honest fail
311
311
  await h.close();
312
312
  }
313
313
  });
314
+
315
+ test("a thrown cancel whose verify read comes back empty (gone) is an unverified failure, not a false success", async () => {
316
+ // The engine-restart trap: the cancel POST throws (dead keep-alive socket / the brief window
317
+ // the gateway is down), then the verify search hits the just-restarted engine whose query store
318
+ // has NOT yet rehydrated the still-running instance — so the read comes back empty ("gone").
319
+ // A throw is not a committed 204, and absence from the read model is not proof of termination, so
320
+ // reporting success here is the "worst possible lie": the button says cancelled while the run
321
+ // keeps going. It must be an honest ok:false so the caller surfaces it and the user retries.
322
+ const { engine } = cancelEngine({
323
+ states: {}, // read model transiently empty (mid-restart), though instance "100" is still live
324
+ onCancel: () => {
325
+ throw new Error("fetch failed");
326
+ },
327
+ });
328
+ const h = await withHarness(engine);
329
+ try {
330
+ const r = await cancelInstanceReconciling(h.api, [PR_BINDING], "100");
331
+ assert.equal(r.ok, false); // NOT a false success
332
+ assert.equal(r.state, "gone");
333
+ assert.equal(r.reconciled, 0);
334
+ assert.match(r.error ?? "", /fetch failed/);
335
+ const row = await h.table.get("100");
336
+ assert.equal(row?.status, "converging"); // never flip a possibly-live run to a terminal status
337
+ assert.ok(h.logs.some((l) => l.level === "error"));
338
+ } finally {
339
+ await h.close();
340
+ }
341
+ });
@@ -13,9 +13,9 @@
13
13
  //
14
14
  // This primitive keys success on the engine's response, then reconciles off the read model: a
15
15
  // non-throwing `cancelInstance` is a committed termination (trusted even if the read model still
16
- // lags at ACTIVE), while a throw whose instance reads back still-ACTIVE is an honest failure. It
17
- // reuses the instanceTracking bindings' `onTerminated.set` patch, so the immediate reconcile can
18
- // never drift from the poll reconciler's.
16
+ // lags at ACTIVE), while a throw that does not read back a positively-terminal state (still ACTIVE,
17
+ // or absent from the read model) is an honest failure. It reuses the instanceTracking bindings'
18
+ // `onTerminated.set` patch, so the immediate reconcile can never drift from the poll reconciler's.
19
19
 
20
20
  import type { AppApi } from "../context.ts";
21
21
  import type { InstanceTracking } from "../manifest.ts";
@@ -44,10 +44,14 @@ type CancelApi = Pick<AppApi, "engine" | "data" | "log">;
44
44
  * Outcome rules:
45
45
  * - `cancelInstance` returns without throwing → the engine accepted the termination (a committed
46
46
  * 204); trust it even if the read model still lags at ACTIVE.
47
- * - `cancelInstance` throws but the instance reads back non-ACTIVE (already TERMINATED/COMPLETED
48
- * /gone) → the cancel was effectively idempotent; treat as success.
49
- * - `cancelInstance` throws and the instance is still ACTIVE → an honest failure: report it and
50
- * do NOT touch any row (the instance is still running).
47
+ * - `cancelInstance` throws but the instance reads back positively terminal (TERMINATED/COMPLETED)
48
+ * → the cancel was effectively idempotent; treat as success. Only a TERMINATED read reconciles a
49
+ * row here (see below); a COMPLETED read intentionally reconciles nothing.
50
+ * - `cancelInstance` throws and the read is NOT positively terminal — still ACTIVE, or "gone"
51
+ * (absent from the read model, which after an engine restart can transiently lack a live
52
+ * instance) → an honest failure: report it and do NOT touch any row (the instance may still be
53
+ * running). A throw is not a committed cancel, and absence from the read model is not proof of
54
+ * termination, so success is never inferred from it.
51
55
  *
52
56
  * Reconcile only on `TERMINATED`: a COMPLETED instance's terminal row-write is owned by the app's
53
57
  * own finalize logic, and a gone/ACTIVE instance has nothing to flip here.
@@ -68,11 +72,19 @@ export async function cancelInstanceReconciling(
68
72
 
69
73
  const state = await readState(api, key, threw);
70
74
 
71
- if (threw && state === "ACTIVE") {
72
- // The cancel genuinely failed and the instance is still running — never flip a row to a
73
- // terminal status while the engine keeps executing it.
74
- api.log("error", "cancel: engine termination failed, instance still active", {
75
+ if (threw && state !== "TERMINATED" && state !== "COMPLETED") {
76
+ // The cancel threw, so we never got a committed 204 — and the verify read did NOT positively
77
+ // confirm a terminal state. Two shapes land here, both honest failures:
78
+ // • state === "ACTIVE": the instance is demonstrably still running.
79
+ // • state === "gone": the read model has no record of the key. A throw makes this
80
+ // ambiguous — after an engine restart the query store can transiently lack a live instance
81
+ // before it rehydrates, so "gone" is absence-of-evidence, NOT proof of termination.
82
+ // Trust neither as success: claiming a cancel that may not have happened (while the run keeps
83
+ // executing) is the worst possible lie for a cancel button. Report ok:false so the caller
84
+ // surfaces it and retries; never flip a tracked row to terminal while the instance may live.
85
+ api.log("error", "cancel: engine termination not confirmed, instance may still be active", {
75
86
  processInstanceKey: key,
87
+ state,
76
88
  error: errorMessage(threw),
77
89
  });
78
90
  return { ok: false, processInstanceKey: key, state, reconciled: 0, error: errorMessage(threw) };
@@ -138,6 +138,36 @@ test("the shell styles the app through the shared --nano-* token contract", asyn
138
138
  assert.match(html, /:root:not\(\[data-appearance\]\)/);
139
139
  });
140
140
 
141
+ test("the warn badge stays legible in light appearance", async () => {
142
+ const res = await dispatch("GET", "/");
143
+ const html = res.body ?? "";
144
+ // The warn badge sits on --nano-warn, which is a bright amber in dark mode
145
+ // (dark text reads well) but a dark orange in light mode (#b45309), where the
146
+ // same dark text collapses to near-zero contrast. Both light paths — the
147
+ // console-themed [data-appearance="light"] and the standalone
148
+ // prefers-color-scheme fallback — must flip the warn label to white so it
149
+ // stays readable.
150
+ assert.match(html, /:root\[data-appearance="light"\]\s*\.pc-badge-warn\s*\{\s*color:#fff;\s*\}/);
151
+ assert.match(
152
+ html,
153
+ /@media \(prefers-color-scheme: light\)\s*\{\s*:root:not\(\[data-appearance\]\)\s*\.pc-badge-warn\s*\{\s*color:#fff;\s*\}\s*\}/,
154
+ );
155
+ });
156
+
157
+ test("grid cells wrap long, space-less values so one cell can't overflow the table", async () => {
158
+ // Regression: a dataGrid cell holding a long space-less value (e.g. a JSON
159
+ // blob or a 40-char SHA) has no soft-wrap opportunity, so with table-layout
160
+ // auto it forces its column to the value's full width and pushes the whole
161
+ // table past the page. The shared grid CSS must let such a cell break so the
162
+ // column can shrink to fit its container.
163
+ const res = await dispatch("GET", "/");
164
+ const html = res.body ?? "";
165
+ const rule = (html.match(/table\.pc-grid th,\s*table\.pc-grid td\s*\{[^}]*\}/) ?? [""])[0];
166
+ assert.ok(rule, "grid th/td rule must be present");
167
+ assert.match(rule, /overflow-wrap:anywhere/);
168
+ assert.match(rule, /max-width:/);
169
+ });
170
+
141
171
  test("GET /app/runtime.js serves the renderer module", async () => {
142
172
  const res = await dispatch("GET", "/app/runtime.js");
143
173
  assert.match(res.headers?.["content-type"] ?? "", /javascript/);
@@ -290,6 +320,24 @@ test("renderer wires a column's linkField to a new-tab anchor", async () => {
290
320
  assert.match(js, /rel: "noopener noreferrer"/);
291
321
  });
292
322
 
323
+ test("renderer wires a column's badge to a tone-classed pill shown only when present", async () => {
324
+ const res = await dispatch("GET", "/app/runtime.js");
325
+ const js = res.body ?? "";
326
+ // A column declaring `badge` renders a compact circular pill (e.g. a red "1"
327
+ // flagging an incident) only when the field value is non-empty; an empty value
328
+ // leaves the cell blank so the column is unobtrusive until it matters. Guard
329
+ // the shape: presence-gated on the trimmed field, tone is allow-listed
330
+ // (default danger), the label defaults to "1", and the full field text becomes
331
+ // the tooltip.
332
+ assert.match(js, /if\s*\(col\.badge\)/);
333
+ assert.match(js, /text\.trim\(\)\s*===\s*""/);
334
+ assert.match(js, /t\s*===\s*"warn"\s*\|\|\s*t\s*===\s*"ok"\s*\|\|\s*t\s*===\s*"info"\s*\?\s*t\s*:\s*"danger"/);
335
+ assert.match(js, /col\.badge\.label\s*==\s*null\s*\?\s*"1"/);
336
+ assert.match(js, /class:\s*"pc-badge pc-badge-"\s*\+\s*tone/);
337
+ assert.match(js, /title:\s*text/);
338
+ assert.match(js, /"aria-label":\s*text/);
339
+ });
340
+
293
341
  test("renderer wires a column's processExplorer link to the console explorer", async () => {
294
342
  const res = await dispatch("GET", "/app/runtime.js");
295
343
  const js = res.body ?? "";
@@ -480,6 +528,62 @@ test("the renderer ships a nav node (menu bar / rail) with in-app + external lin
480
528
  assert.match(js, /pc-main-col/);
481
529
  });
482
530
 
531
+ test("the renderer ships a button node that opens a copy-pasteable modal", async () => {
532
+ const res = await dispatch("GET", "/app/runtime.js");
533
+ const js = res.body ?? "";
534
+ // button is a first-class node type.
535
+ assert.match(js, /button: renderButton/);
536
+ assert.match(js, /function renderButton\(node\)/);
537
+ // A ghost variant renders the muted outline style; a button carries its modal.
538
+ assert.match(js, /p\.variant === "ghost"/);
539
+ assert.match(js, /btn\.addEventListener\("click", \(\) => openModal\(m\)\)/);
540
+ // The modal is appended to <body>, is dismissable, and cleans up its keydown.
541
+ assert.match(js, /function openModal\(m\)/);
542
+ assert.match(js, /class: "pc-modal-overlay"/);
543
+ assert.match(js, /"aria-modal": "true"/);
544
+ assert.match(js, /if \(ev\.key === "Escape"\) \{ close\(\); return; \}/);
545
+ assert.match(js, /document\.removeEventListener\("keydown", onKey\)/);
546
+ // {{appBase}} in copy text is rebased onto the absolute mount root so an
547
+ // external agent gets a fetchable URL.
548
+ assert.match(js, /function resolveCopyText\(text\)/);
549
+ assert.match(js, /split\("\{\{appBase\}\}"\)\.join\(APP_BASE\.toString\(\)\)/);
550
+ // Copy is clipboard-first with a sandbox-safe execCommand fallback.
551
+ assert.match(js, /async function copyToClipboard\(text\)/);
552
+ assert.match(js, /navigator\.clipboard\.writeText/);
553
+ assert.match(js, /document\.execCommand\("copy"\)/);
554
+ // The fallback textarea is torn down in a finally so a throwing select()/
555
+ // execCommand never leaves a detached node accreting in <body> on retry.
556
+ assert.match(js, /finally \{ ta\.remove\(\); \}/);
557
+ // The dialog carries an accessible name (labelled by its title, or a fallback
558
+ // aria-label) so screen readers don't announce an unnamed dialog.
559
+ assert.match(js, /"aria-labelledby": titleId/);
560
+ assert.match(js, /"aria-label": "Dialog"/);
561
+ // An open modal is registered with teardown() so a page switch disposes it
562
+ // instead of leaking a stale overlay + document-level keydown listener.
563
+ assert.match(js, /disposers\.push\(close\)/);
564
+ // A manual close removes its own disposer entry, so repeated open/close cycles
565
+ // don't accrete dead close() closures on the disposers stack.
566
+ assert.match(js, /disposers\.indexOf\(close\)/);
567
+ assert.match(js, /disposers\.splice\(i, 1\)/);
568
+ // Focus is restored to the previously-focused element on close, and Tab is
569
+ // trapped inside the dialog so it can't reach the inert page behind the overlay.
570
+ assert.match(js, /const prevFocus = document\.activeElement/);
571
+ assert.match(js, /if \(ev\.key !== "Tab"\) return/);
572
+ assert.match(js, /ev\.preventDefault\(\); last\.focus\(\)/);
573
+ assert.match(js, /ev\.preventDefault\(\); first\.focus\(\)/);
574
+ // Only one modal opens at a time: a second open (e.g. double-click) is dropped
575
+ // so overlays and document-level keydown listeners never stack.
576
+ assert.match(js, /let modalOpen = false/);
577
+ assert.match(js, /if \(modalOpen\) return/);
578
+ assert.match(js, /modalOpen = true/);
579
+ assert.match(js, /modalOpen = false/);
580
+ // The aria-labelledby title id is unique per modal instance so it can't
581
+ // collide with a pre-existing page id and mis-associate the label.
582
+ assert.match(js, /let modalSeq = 0/);
583
+ assert.match(js, /"pc-modal-title-" \+ \(\+\+modalSeq\)/);
584
+ });
585
+
586
+
483
587
  test("GET /app/data/<source>/<table> returns rows", async () => {
484
588
  const res = await dispatch("GET", "/app/data/app/orders");
485
589
  assert.equal(res.status, 200);
@@ -534,7 +534,13 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
534
534
  .pc-msg.err { color:var(--nano-danger); }
535
535
  .pc-msg.ok { color:var(--nano-ok); }
536
536
  table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; }
537
- table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); }
537
+ /* Wrap long, space-less cell values (e.g. a JSON blob or a 40-char SHA) so one
538
+ cell can't force its column wide and blow the whole table past the page.
539
+ overflow-wrap:anywhere reduces the cell's min-content width so the auto table
540
+ layout can shrink it to fit; max-width caps a single column when there is
541
+ ample room. vertical-align:top keeps a multi-line cell aligned with its
542
+ single-line neighbours. */
543
+ table.pc-grid th, table.pc-grid td { text-align:left; padding:.4rem .6rem; border-bottom:1px solid var(--nano-edge); overflow-wrap:anywhere; word-wrap:break-word; max-width:32rem; vertical-align:top; }
538
544
  table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
539
545
  .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
540
546
  .pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
@@ -546,6 +552,13 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
546
552
  .pc-detail-field { display:flex; gap:.5rem; font-size:.85rem; margin:.15rem 0; }
547
553
  .pc-detail-label { color:var(--nano-text-muted); min-width:8rem; }
548
554
  .pc-link { color:var(--nano-accent); }
555
+ .pc-badge { display:inline-flex; align-items:center; justify-content:center; min-width:1.35rem; height:1.35rem; box-sizing:border-box; padding:0 .4rem; border-radius:999px; font-size:.72rem; font-weight:700; line-height:1; color:#fff; background:var(--nano-danger); }
556
+ .pc-badge-danger { background:var(--nano-danger); }
557
+ .pc-badge-warn { background:var(--nano-warn); color:#3a2a00; }
558
+ .pc-badge-ok { background:var(--nano-ok); }
559
+ .pc-badge-info { background:var(--nano-info); }
560
+ :root[data-appearance="light"] .pc-badge-warn { color:#fff; }
561
+ @media (prefers-color-scheme: light) { :root:not([data-appearance]) .pc-badge-warn { color:#fff; } }
549
562
  .pc-child { margin:.6rem 0; }
550
563
  .pc-child-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); margin-bottom:.25rem; }
551
564
  .pc-transcript { white-space:pre-wrap; max-height:22rem; overflow:auto; background:var(--nano-inset); padding:.5rem; border-radius:.4rem; font-size:.8rem; margin-top:.4rem; }
@@ -556,6 +569,16 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
556
569
  .pc-collapse-header { display:flex; align-items:center; gap:.5rem; width:100%; margin:0 0 .75rem; padding:0; background:transparent; border:0; color:inherit; font:inherit; font-size:1rem; font-weight:600; cursor:pointer; text-align:left; }
557
570
  .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
558
571
  .pc-card-body[hidden] { display:none; }
572
+ /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
573
+ .pc-buttonrow { margin:1rem 0; }
574
+ .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
575
+ .pc-btn-ghost:hover { color:var(--nano-text); border-color:var(--nano-accent); }
576
+ .pc-modal-overlay { position:fixed; inset:0; z-index:1000; display:flex; align-items:center; justify-content:center; padding:1.5rem; background:rgba(0,0,0,.55); }
577
+ .pc-modal { background:var(--nano-panel); border:1px solid var(--nano-edge-strong); border-radius:.6rem; padding:1.25rem 1.35rem; max-width:44rem; width:100%; max-height:85vh; overflow:auto; box-shadow:0 12px 40px rgba(0,0,0,.45); }
578
+ .pc-modal-title { font-size:1.15rem; margin:0 0 .5rem; }
579
+ .pc-modal-desc { color:var(--nano-text-muted); margin:0 0 .75rem; font-size:.9rem; white-space:pre-wrap; }
580
+ .pc-modal-code { white-space:pre-wrap; word-break:break-word; background:var(--nano-inset); border:1px solid var(--nano-edge); border-radius:.4rem; padding:.75rem; font:.82rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; max-height:45vh; overflow:auto; margin:0 0 .85rem; color:var(--nano-text); }
581
+ .pc-modal-actions { display:flex; gap:.5rem; justify-content:flex-end; }
559
582
  /* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
560
583
  .pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
561
584
  .pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
@@ -613,6 +636,15 @@ function teardown() {
613
636
  }
614
637
  }
615
638
 
639
+ // Monotonic counter for per-modal element ids. aria-labelledby needs a unique
640
+ // title id: a fixed id would clash if the page already contains that id, and
641
+ // mis-associate the label for assistive tech.
642
+ let modalSeq = 0;
643
+ // At most one modal is open at a time. A double-click (or two rapid button
644
+ // clicks) would otherwise stack multiple overlays + document-level keydown
645
+ // listeners; this guard keeps a single dialog + handler live.
646
+ let modalOpen = false;
647
+
616
648
  // Theme bridge for the Nano console embed. The app runs in a sandboxed,
617
649
  // same-origin iframe, so the console's --nano-* custom properties and its
618
650
  // data-appearance never cascade in. Instead the console postMessages its
@@ -766,11 +798,151 @@ function renderText(node) {
766
798
  return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
767
799
  }
768
800
 
769
- // A grid td cell. Two column-declared linking modes, checked in order:
770
- // 1. linkField — the cell text becomes a link to the URL held in that other
801
+ // Copy text to the clipboard, resiliently. The async Clipboard API is tried
802
+ // first, but it can reject inside a sandboxed console iframe (no clipboard-write
803
+ // permission) — so fall back to a hidden <textarea> + execCommand("copy"), which
804
+ // works under a user gesture with only allow-scripts. Returns whether a copy
805
+ // succeeded so the caller can nudge the user to ⌘/Ctrl+C when both paths fail.
806
+ async function copyToClipboard(text) {
807
+ try {
808
+ if (navigator.clipboard && navigator.clipboard.writeText) {
809
+ await navigator.clipboard.writeText(text);
810
+ return true;
811
+ }
812
+ } catch (_e) { /* fall through to the execCommand path */ }
813
+ const ta = document.createElement("textarea");
814
+ try {
815
+ ta.value = text;
816
+ ta.setAttribute("readonly", "");
817
+ ta.style.position = "fixed";
818
+ ta.style.top = "-1000px";
819
+ ta.style.opacity = "0";
820
+ document.body.append(ta);
821
+ ta.select();
822
+ return document.execCommand("copy");
823
+ } catch (_e) { return false; }
824
+ // Always remove the hidden textarea, even if select()/execCommand threw, so a
825
+ // failed copy never leaves a detached node accreting in <body> on retry.
826
+ finally { ta.remove(); }
827
+ }
828
+
829
+ // Rebase the sole {{appBase}} token in author-supplied copy text onto the app's
830
+ // absolute mount root (always ends in "/"), so a prompt like
831
+ // "read {{appBase}}app/api/agent" resolves to a URL an external agent can fetch —
832
+ // the console proxy path when embedded, the app origin when standalone. No other
833
+ // interpolation happens: copy text is otherwise emitted verbatim.
834
+ function resolveCopyText(text) {
835
+ return typeof text === "string" ? text.split("{{appBase}}").join(APP_BASE.toString()) : "";
836
+ }
837
+
838
+ // Open a lightweight modal appended to <body>. Closes on the ✕/Close button, a
839
+ // backdrop click, Escape, or a page switch; the keydown listener is removed on
840
+ // close so it never leaks, and close() is registered with teardown() so
841
+ // navigating away (hashchange → renderPage → teardown) tears down any open modal
842
+ // instead of leaving a stale overlay + document-level listener alive. When it
843
+ // carries copyText it renders a scrollable code block plus a Copy button (see
844
+ // copyToClipboard for the sandbox-safe fallback).
845
+ function openModal(m) {
846
+ // Only one modal at a time — drop a second (e.g. double-click) open so we
847
+ // never stack overlays or register duplicate document-level keydown handlers.
848
+ if (modalOpen) return;
849
+ modalOpen = true;
850
+ const overlay = el("div", { class: "pc-modal-overlay" });
851
+ // role="dialog" + aria-modal need an accessible name: label by the visible
852
+ // title when present, else fall back to a generic aria-label so screen readers
853
+ // don't announce an unnamed dialog. The title id is unique per instance so it
854
+ // can't collide with a pre-existing page id.
855
+ const titleId = m.title ? "pc-modal-title-" + (++modalSeq) : null;
856
+ const dialog = el("div", titleId
857
+ ? { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId }
858
+ : { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-label": "Dialog" });
859
+ if (m.title) dialog.append(el("h2", { class: "pc-modal-title", id: titleId }, m.title));
860
+ if (m.description) dialog.append(el("p", { class: "pc-modal-desc" }, m.description));
861
+ const copyText = resolveCopyText(m.copyText);
862
+ if (copyText) dialog.append(el("pre", { class: "pc-modal-code" }, copyText));
863
+ const actions = el("div", { class: "pc-modal-actions" });
864
+ // Remember what had focus so we can restore it on close — a keyboard/screen
865
+ // reader user returns to where they were instead of being dumped at <body>.
866
+ const prevFocus = document.activeElement;
867
+ let closed = false;
868
+ function close() {
869
+ if (closed) return;
870
+ closed = true;
871
+ modalOpen = false;
872
+ document.removeEventListener("keydown", onKey);
873
+ overlay.remove();
874
+ // A manual close (button / Escape / backdrop) never pops the disposers
875
+ // stack, so drop our own entry here — otherwise repeated open/close cycles
876
+ // accrete dead close() closures until the next page navigation runs teardown.
877
+ const i = disposers.indexOf(close);
878
+ if (i !== -1) disposers.splice(i, 1);
879
+ if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
880
+ }
881
+ // Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
882
+ function focusables() {
883
+ return Array.prototype.slice.call(dialog.querySelectorAll(
884
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
885
+ )).filter((n) => !n.disabled);
886
+ }
887
+ function onKey(ev) {
888
+ if (ev.key === "Escape") { close(); return; }
889
+ if (ev.key !== "Tab") return;
890
+ // Trap Tab within the dialog so focus can't slip behind the overlay onto the
891
+ // inert page underneath — that would break aria-modal semantics.
892
+ const f = focusables();
893
+ if (!f.length) return;
894
+ const first = f[0];
895
+ const last = f[f.length - 1];
896
+ if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
897
+ else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
898
+ }
899
+ if (copyText) {
900
+ const label = m.copyLabel || "Copy";
901
+ const copyBtn = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, label);
902
+ copyBtn.addEventListener("click", async () => {
903
+ const ok = await copyToClipboard(copyText);
904
+ copyBtn.textContent = ok ? "Copied ✓" : "Press ⌘/Ctrl+C";
905
+ setTimeout(() => { copyBtn.textContent = label; }, 2200);
906
+ });
907
+ actions.append(copyBtn);
908
+ }
909
+ const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
910
+ closeBtn.addEventListener("click", close);
911
+ actions.append(closeBtn);
912
+ dialog.append(actions);
913
+ overlay.append(dialog);
914
+ // A click on the backdrop (never the dialog itself) dismisses.
915
+ overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); });
916
+ document.addEventListener("keydown", onKey);
917
+ document.body.append(overlay);
918
+ // A page switch runs teardown() before the next render — dispose the modal
919
+ // there too so navigating away never leaves a stale overlay + keydown listener.
920
+ disposers.push(close);
921
+ closeBtn.focus();
922
+ }
923
+
924
+ // A standalone button node. Clicking it opens props.modal — used e.g. to surface
925
+ // a copy-pasteable "point your agent here" prompt. props.variant === "ghost"
926
+ // renders the muted outline style. A button without a modal is inert.
927
+ function renderButton(node) {
928
+ const p = node.props || {};
929
+ const btn = el(
930
+ "button",
931
+ { class: "pc-btn" + (p.variant === "ghost" ? " pc-btn-ghost" : ""), type: "button" },
932
+ p.label || "Open",
933
+ );
934
+ const m = p.modal && typeof p.modal === "object" ? p.modal : null;
935
+ if (m) btn.addEventListener("click", () => openModal(m));
936
+ return el("div", { class: "pc-buttonrow" }, btn);
937
+ }
938
+
939
+ // A grid td cell with three column-declared rendering modes, checked in order:
940
+ // 1. badge — a compact status indicator, gated on a non-empty field value
941
+ // (see the detailed note at the check below).
942
+ // 2. linkField — the cell text becomes a link to the URL held in that other
771
943
  // field. Only http(s) hrefs are linked (a javascript:/other-scheme URL
772
944
  // smuggled through row data falls back to plain text).
773
- // 2. link: { kind: "processExplorer", keyField } — a structured, engine-aware
945
+ // 3. link: { kind: "processExplorer", keyField } — a structured, engine-aware
774
946
  // link: the cell text links to the Nano console's explorer view for the
775
947
  // process instance whose key is held in the row's keyField. The console
776
948
  // path is constructed HERE (never taken from row data) and the key is
@@ -786,6 +958,27 @@ function renderText(node) {
786
958
  // grid and child grids.
787
959
  function gridCell(col, row) {
788
960
  const text = row[col.field] == null ? "" : String(row[col.field]);
961
+ // 1. badge — a compact status indicator. When the row's field value is
962
+ // non-empty (truthy after trimming) render a small circular badge (e.g. a
963
+ // red "1" dot flagging an incident) whose tooltip is the full field text;
964
+ // when empty the cell stays blank so the column is unobtrusive until it
965
+ // matters. tone (danger|warn|ok|info, default danger) picks the color and
966
+ // label the glyph inside (default "1"). Label/tone come from the app
967
+ // schema (trusted); the tooltip is row data set via title/textContent
968
+ // (DOM-escaped by the platform, so no HTML/attribute injection). The full
969
+ // field text is also mirrored into aria-label so assistive tech announces
970
+ // the incident/status text (the visible "1" glyph alone is not meaningful).
971
+ if (col.badge) {
972
+ if (text.trim() === "") return el("td", {});
973
+ const t = col.badge.tone;
974
+ const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
975
+ const label = col.badge.label == null ? "1" : String(col.badge.label);
976
+ return el(
977
+ "td",
978
+ {},
979
+ el("span", { class: "pc-badge pc-badge-" + tone, title: text, "aria-label": text }, label),
980
+ );
981
+ }
789
982
  if (col.linkField) {
790
983
  const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
791
984
  if (text !== "" && /^https?:\/\//i.test(href)) {
@@ -1280,7 +1473,7 @@ function renderNav(node) {
1280
1473
  return nav;
1281
1474
  }
1282
1475
 
1283
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
1476
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
1284
1477
 
1285
1478
  // Durable per-node UI state. localStorage is keyed by the home page id + node
1286
1479
  // id so two grids on the same page (or the same grid across pages) don't clash,