@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.
- package/dist/runtime/core/modules/cancel.d.ts +8 -4
- package/dist/runtime/core/modules/cancel.js +23 -11
- package/dist/runtime/core/modules/pages.js +198 -5
- package/package.json +2 -2
- package/src/runtime/core/modules/cancel.test.ts +28 -0
- package/src/runtime/core/modules/cancel.ts +23 -11
- package/src/runtime/core/modules/pages.test.ts +104 -0
- package/src/runtime/core/modules/pages.ts +198 -5
|
@@ -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
|
|
23
|
-
*
|
|
24
|
-
*
|
|
25
|
-
*
|
|
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
|
|
17
|
-
//
|
|
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
|
|
29
|
-
*
|
|
30
|
-
*
|
|
31
|
-
*
|
|
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
|
|
47
|
-
// The cancel
|
|
48
|
-
// terminal
|
|
49
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
718
|
-
//
|
|
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
|
-
//
|
|
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.
|
|
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.
|
|
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
|
|
17
|
-
//
|
|
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
|
|
48
|
-
*
|
|
49
|
-
*
|
|
50
|
-
*
|
|
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
|
|
72
|
-
// The cancel
|
|
73
|
-
// terminal
|
|
74
|
-
|
|
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
|
-
|
|
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
|
-
//
|
|
770
|
-
//
|
|
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
|
-
//
|
|
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,
|