@nanobpm/urban 0.41.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.
@@ -525,14 +525,22 @@ body { margin:0; font:15px/1.5 system-ui,sans-serif; padding:2rem; max-width:64r
525
525
  .pc-card h2 { font-size:1rem; margin:0 0 .75rem; }
526
526
  .pc-field { display:flex; flex-direction:column; gap:.25rem; margin-bottom:.6rem; }
527
527
  .pc-field label { font-size:.8rem; color:var(--nano-text-muted); }
528
- .pc-field input { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
528
+ .pc-field input:not([type=checkbox]) { padding:.5rem .6rem; border:1px solid var(--nano-edge); border-radius:.4rem; font:inherit; background:var(--nano-inset); color:var(--nano-text); }
529
+ .pc-field-check label { display:flex; flex-direction:row; align-items:center; gap:.5rem; font-size:.9rem; color:var(--nano-text); cursor:pointer; }
530
+ .pc-field-check input { width:auto; padding:0; accent-color:var(--nano-accent); cursor:pointer; }
529
531
  .pc-btn { padding:.5rem .9rem; border:0; border-radius:.4rem; background:var(--nano-accent); color:var(--nano-on-accent); font:inherit; cursor:pointer; }
530
532
  .pc-btn:disabled { opacity:.5; cursor:default; }
531
533
  .pc-msg { font-size:.85rem; margin-top:.5rem; min-height:1.2em; }
532
534
  .pc-msg.err { color:var(--nano-danger); }
533
535
  .pc-msg.ok { color:var(--nano-ok); }
534
536
  table.pc-grid { width:100%; border-collapse:collapse; font-size:.9rem; }
535
- 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; }
536
544
  table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
537
545
  .pc-tabs { display:flex; gap:.5rem; margin-bottom:.75rem; }
538
546
  .pc-tab { padding:.35rem .8rem; border:1px solid var(--nano-edge); border-radius:.4rem; background:transparent; color:inherit; font:inherit; cursor:pointer; }
@@ -544,6 +552,13 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
544
552
  .pc-detail-field { display:flex; gap:.5rem; font-size:.85rem; margin:.15rem 0; }
545
553
  .pc-detail-label { color:var(--nano-text-muted); min-width:8rem; }
546
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; } }
547
562
  .pc-child { margin:.6rem 0; }
548
563
  .pc-child-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); margin-bottom:.25rem; }
549
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; }
@@ -554,6 +569,16 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
554
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; }
555
570
  .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
556
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; }
557
582
  /* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
558
583
  .pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
559
584
  .pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
@@ -611,6 +636,15 @@ function teardown() {
611
636
  }
612
637
  }
613
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
+
614
648
  // Theme bridge for the Nano console embed. The app runs in a sandboxed,
615
649
  // same-origin iframe, so the console's --nano-* custom properties and its
616
650
  // data-appearance never cascade in. Instead the console postMessages its
@@ -764,11 +798,151 @@ function renderText(node) {
764
798
  return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
765
799
  }
766
800
 
767
- // A grid td cell. Two column-declared linking modes, checked in order:
768
- // 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
769
943
  // field. Only http(s) hrefs are linked (a javascript:/other-scheme URL
770
944
  // smuggled through row data falls back to plain text).
771
- // 2. link: { kind: "processExplorer", keyField } — a structured, engine-aware
945
+ // 3. link: { kind: "processExplorer", keyField } — a structured, engine-aware
772
946
  // link: the cell text links to the Nano console's explorer view for the
773
947
  // process instance whose key is held in the row's keyField. The console
774
948
  // path is constructed HERE (never taken from row data) and the key is
@@ -784,6 +958,27 @@ function renderText(node) {
784
958
  // grid and child grids.
785
959
  function gridCell(col, row) {
786
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
+ }
787
982
  if (col.linkField) {
788
983
  const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
789
984
  if (text !== "" && /^https?:\/\//i.test(href)) {
@@ -833,8 +1028,24 @@ function renderActionForm(node) {
833
1028
  const inputs = new Map();
834
1029
  const fieldTypes = new Map();
835
1030
  for (const f of p.fields || []) {
836
- const kind = f.type === "number" ? "number" : "text";
1031
+ const kind = f.type === "checkbox" ? "checkbox" : (f.type === "number" ? "number" : "text");
837
1032
  fieldTypes.set(f.key, kind);
1033
+ if (kind === "checkbox") {
1034
+ // A boolean field renders a real checkbox; its default checked state comes
1035
+ // from default/checked on the field (unset -> unchecked). We record that on
1036
+ // the input's defaultChecked so a post-submit reset can restore it without a
1037
+ // side map that could drift from the actual input.
1038
+ const checked = f.default === true || f.checked === true;
1039
+ const input = el("input", { type: "checkbox" });
1040
+ input.defaultChecked = checked;
1041
+ input.checked = checked;
1042
+ inputs.set(f.key, input);
1043
+ // Input-first, wrapped in the label so the whole row is a click target.
1044
+ card.append(
1045
+ el("div", { class: "pc-field pc-field-check" }, el("label", {}, input, " " + (f.label || f.key))),
1046
+ );
1047
+ continue;
1048
+ }
838
1049
  const attrs = { type: kind, placeholder: f.label || f.key };
839
1050
  if (kind === "number") {
840
1051
  attrs.inputmode = "numeric";
@@ -851,7 +1062,11 @@ function renderActionForm(node) {
851
1062
  btn.addEventListener("click", async () => {
852
1063
  const variables = Object.create(null);
853
1064
  for (const [k, input] of inputs) {
854
- if (fieldTypes.get(k) === "number") {
1065
+ if (fieldTypes.get(k) === "checkbox") {
1066
+ // Always emit a real boolean (checked -> true / false), never a string,
1067
+ // so a strict equality check on the action side sees the intended value.
1068
+ variables[k] = input.checked === true;
1069
+ } else if (fieldTypes.get(k) === "number") {
855
1070
  const t = String(input.value).trim();
856
1071
  // Blank → omit so the action-side default applies; a non-finite parse
857
1072
  // (NaN/Infinity) is also omitted rather than smuggling a raw string
@@ -874,7 +1089,12 @@ function renderActionForm(node) {
874
1089
  msg.className = "pc-msg ok";
875
1090
  msg.textContent = (p.action && p.action.successLabel) ||
876
1091
  (res && res.processInstanceKey != null ? "Started (instance " + res.processInstanceKey + ")" : "Done");
877
- for (const input of inputs.values()) input.value = "";
1092
+ for (const [k, input] of inputs) {
1093
+ // Text/number inputs clear; a checkbox resets to its declared default,
1094
+ // read straight off the input's defaultChecked (no side map to drift).
1095
+ if (fieldTypes.get(k) === "checkbox") input.checked = input.defaultChecked;
1096
+ else input.value = "";
1097
+ }
878
1098
  document.dispatchEvent(new CustomEvent("pc:refresh"));
879
1099
  } catch (e) {
880
1100
  msg.className = "pc-msg err"; msg.textContent = String(e.message || e);
@@ -1253,7 +1473,7 @@ function renderNav(node) {
1253
1473
  return nav;
1254
1474
  }
1255
1475
 
1256
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
1476
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
1257
1477
 
1258
1478
  // Durable per-node UI state. localStorage is keyed by the home page id + node
1259
1479
  // id so two grids on the same page (or the same grid across pages) don't clash,
@@ -54,8 +54,10 @@ export type {
54
54
  WorkerSubscription,
55
55
  } from "./core/host.ts";
56
56
  export type { AppApi, Mounted, RuntimeContext } from "./core/context.ts";
57
- // Structured logging surface (see core/logger.ts): the shape of `AppApi.log`.
58
- export type { Logger, LogLevel, LogFields } from "./core/logger.ts";
57
+ // Structured logging surface (see core/logger.ts): the shape of `AppApi.log`, plus `createLogger`
58
+ // so consumers can build a Logger for a custom sink or a no-op test double (`createLogger(() => {})`).
59
+ export { createLogger } from "./core/logger.ts";
60
+ export type { Logger, LogLevel, LogFields, LogSink } from "./core/logger.ts";
59
61
 
60
62
  // BPMN error contract: a handler throws `BpmnError(code)` to raise a modelled
61
63
  // error routed to an error boundary (ADR 0050), instead of a retryable failure.