@nanobpm/urban 0.42.0 → 0.44.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.
@@ -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,12 +569,33 @@ 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
+ /* Grid group headers (dataGrid groupBy): a full-width, clickable band that
573
+ collapses/expands its member rows; the collapsed state is persisted. */
574
+ .pc-group-header td { padding:0; background:var(--nano-inset); border-top:1px solid var(--nano-edge); }
575
+ .pc-group-toggle { display:flex; align-items:center; gap:.5rem; width:100%; padding:.4rem .6rem; background:transparent; border:0; color:inherit; font:inherit; font-weight:600; cursor:pointer; text-align:left; }
576
+ .pc-group-toggle:hover { background:var(--nano-hover); }
577
+ .pc-group-title { font-size:.9rem; }
578
+ /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
579
+ .pc-buttonrow { margin:1rem 0; }
580
+ .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
581
+ .pc-btn-ghost:hover { color:var(--nano-text); border-color:var(--nano-accent); }
582
+ .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); }
583
+ .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); }
584
+ .pc-modal-title { font-size:1.15rem; margin:0 0 .5rem; }
585
+ .pc-modal-desc { color:var(--nano-text-muted); margin:0 0 .75rem; font-size:.9rem; white-space:pre-wrap; }
586
+ .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); }
587
+ .pc-modal-actions { display:flex; gap:.5rem; justify-content:flex-end; }
559
588
  /* Multi-page navigation: a horizontal menu bar or a vertical side rail. */
560
589
  .pc-layout { display:flex; gap:1.5rem; align-items:flex-start; }
561
590
  .pc-rail-wrap { flex:0 0 12rem; position:sticky; top:1rem; align-self:flex-start; }
562
591
  .pc-main-col { flex:1 1 auto; min-width:0; }
563
592
  .pc-nav-title { font-weight:650; }
564
593
  .pc-bar { display:flex; align-items:center; gap:.6rem; flex-wrap:wrap; border-bottom:1px solid var(--nano-edge); padding-bottom:.6rem; margin-bottom:1.25rem; }
594
+ /* Opt-in sticky bar: stays pinned to the viewport top while the page scrolls.
595
+ The negative inline margin + matching padding lets its background span the
596
+ body's 2rem inline padding so scrolled content never shows beside it, and a
597
+ solid app-colored background hides content passing underneath. */
598
+ .pc-bar.pc-sticky { position:sticky; top:0; z-index:20; background:var(--nano-app); margin-inline:-2rem; padding-inline:2rem; padding-top:.6rem; }
565
599
  .pc-bar .pc-nav-title { margin-right:.5rem; }
566
600
  .pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
567
601
  .pc-rail { display:flex; flex-direction:column; gap:.5rem; }
@@ -592,15 +626,34 @@ const PAGE_ID = /^[A-Za-z0-9_-]+$/;
592
626
  function safePageId(value) {
593
627
  return typeof value === "string" && PAGE_ID.test(value);
594
628
  }
595
- function currentPage() {
596
- try {
597
- const raw = decodeURIComponent((location.hash || "").replace(/^#\/?/, ""));
598
- return safePageId(raw) ? raw : HOME;
599
- } catch (e) {
600
- return HOME;
601
- }
629
+ // A route is #/<page> or #/<page>/<url-encoded-param>. We split on the FIRST
630
+ // "/" *before* decoding, so a param that itself contains encoded slashes or
631
+ // hashes (e.g. a plan_key "owner/repo#123" carried as "owner%2Frepo%23123")
632
+ // survives intact instead of the inner "%2F" being decoded into a second
633
+ // segment. The page segment is still restricted to the safe id charset; the
634
+ // param is opaque free text (bound only as a whitelisted '?' SQL value, or
635
+ // interpolated as DOM text — never as HTML/markup).
636
+ function parseRoute() {
637
+ const raw = (location.hash || "").replace(/^#\/?/, "");
638
+ const slash = raw.indexOf("/");
639
+ const pageRaw = slash >= 0 ? raw.slice(0, slash) : raw;
640
+ const paramRaw = slash >= 0 ? raw.slice(slash + 1) : "";
641
+ let page = HOME, param = "", pageOk = false;
642
+ try { const p = decodeURIComponent(pageRaw); if (safePageId(p)) { page = p; pageOk = true; } } catch (e) { /* keep HOME */ }
643
+ // Only carry the param when the page segment resolved to a real (safe-id) page.
644
+ // A malformed/unknown page like #/not-a-page/x falls back to HOME and must NOT
645
+ // inherit x, or that stray param would silently scope every eqParam grid and
646
+ // {{param}} on the home page to an entity the user never selected.
647
+ if (pageOk) { try { param = paramRaw ? decodeURIComponent(paramRaw) : ""; } catch (e) { param = ""; } }
648
+ return { page, param };
602
649
  }
650
+ function currentPage() { return parseRoute().page; }
603
651
  let CURRENT = currentPage();
652
+ // The current route param (the "/<param>" tail, or "" when absent). Exposed to a
653
+ // page's datasource filters (a filter with { eqParam: true } binds its value to
654
+ // this) and to {{param}} text interpolation, so one page template can scope
655
+ // every section to a selected entity (e.g. an epic's plan_key).
656
+ let PARAM = parseRoute().param;
604
657
 
605
658
  // Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
606
659
  // listener here; navigating to another page runs every disposer before the new
@@ -613,6 +666,15 @@ function teardown() {
613
666
  }
614
667
  }
615
668
 
669
+ // Monotonic counter for per-modal element ids. aria-labelledby needs a unique
670
+ // title id: a fixed id would clash if the page already contains that id, and
671
+ // mis-associate the label for assistive tech.
672
+ let modalSeq = 0;
673
+ // At most one modal is open at a time. A double-click (or two rapid button
674
+ // clicks) would otherwise stack multiple overlays + document-level keydown
675
+ // listeners; this guard keeps a single dialog + handler live.
676
+ let modalOpen = false;
677
+
616
678
  // Theme bridge for the Nano console embed. The app runs in a sandboxed,
617
679
  // same-origin iframe, so the console's --nano-* custom properties and its
618
680
  // data-appearance never cascade in. Instead the console postMessages its
@@ -760,17 +822,166 @@ function el(tag, attrs = {}, ...kids) {
760
822
  return n;
761
823
  }
762
824
 
825
+ // Interpolate the {{param}} token in a schema-supplied string with the current
826
+ // route param. The result is only ever set as DOM text (textContent), so the
827
+ // substituted value cannot inject markup. Other tokens are left untouched.
828
+ // A function replacement inserts PARAM literally; a plain string replacement would
829
+ // treat replacement patterns inside PARAM (an id like the dollar-one token) as
830
+ // special and render the wrong text.
831
+ function interpParam(s) {
832
+ return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
833
+ }
763
834
  function renderText(node) {
764
835
  const v = node.props.variant;
765
836
  const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
766
- return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
837
+ return el(v === "heading" ? "h1" : "p", { class: cls }, interpParam(node.props.text) || "");
838
+ }
839
+
840
+ // Copy text to the clipboard, resiliently. The async Clipboard API is tried
841
+ // first, but it can reject inside a sandboxed console iframe (no clipboard-write
842
+ // permission) — so fall back to a hidden <textarea> + execCommand("copy"), which
843
+ // works under a user gesture with only allow-scripts. Returns whether a copy
844
+ // succeeded so the caller can nudge the user to ⌘/Ctrl+C when both paths fail.
845
+ async function copyToClipboard(text) {
846
+ try {
847
+ if (navigator.clipboard && navigator.clipboard.writeText) {
848
+ await navigator.clipboard.writeText(text);
849
+ return true;
850
+ }
851
+ } catch (_e) { /* fall through to the execCommand path */ }
852
+ const ta = document.createElement("textarea");
853
+ try {
854
+ ta.value = text;
855
+ ta.setAttribute("readonly", "");
856
+ ta.style.position = "fixed";
857
+ ta.style.top = "-1000px";
858
+ ta.style.opacity = "0";
859
+ document.body.append(ta);
860
+ ta.select();
861
+ return document.execCommand("copy");
862
+ } catch (_e) { return false; }
863
+ // Always remove the hidden textarea, even if select()/execCommand threw, so a
864
+ // failed copy never leaves a detached node accreting in <body> on retry.
865
+ finally { ta.remove(); }
866
+ }
867
+
868
+ // Rebase the sole {{appBase}} token in author-supplied copy text onto the app's
869
+ // absolute mount root (always ends in "/"), so a prompt like
870
+ // "read {{appBase}}app/api/agent" resolves to a URL an external agent can fetch —
871
+ // the console proxy path when embedded, the app origin when standalone. No other
872
+ // interpolation happens: copy text is otherwise emitted verbatim.
873
+ function resolveCopyText(text) {
874
+ return typeof text === "string" ? text.split("{{appBase}}").join(APP_BASE.toString()) : "";
875
+ }
876
+
877
+ // Open a lightweight modal appended to <body>. Closes on the ✕/Close button, a
878
+ // backdrop click, Escape, or a page switch; the keydown listener is removed on
879
+ // close so it never leaks, and close() is registered with teardown() so
880
+ // navigating away (hashchange → renderPage → teardown) tears down any open modal
881
+ // instead of leaving a stale overlay + document-level listener alive. When it
882
+ // carries copyText it renders a scrollable code block plus a Copy button (see
883
+ // copyToClipboard for the sandbox-safe fallback).
884
+ function openModal(m) {
885
+ // Only one modal at a time — drop a second (e.g. double-click) open so we
886
+ // never stack overlays or register duplicate document-level keydown handlers.
887
+ if (modalOpen) return;
888
+ modalOpen = true;
889
+ const overlay = el("div", { class: "pc-modal-overlay" });
890
+ // role="dialog" + aria-modal need an accessible name: label by the visible
891
+ // title when present, else fall back to a generic aria-label so screen readers
892
+ // don't announce an unnamed dialog. The title id is unique per instance so it
893
+ // can't collide with a pre-existing page id.
894
+ const titleId = m.title ? "pc-modal-title-" + (++modalSeq) : null;
895
+ const dialog = el("div", titleId
896
+ ? { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-labelledby": titleId }
897
+ : { class: "pc-modal", role: "dialog", "aria-modal": "true", "aria-label": "Dialog" });
898
+ if (m.title) dialog.append(el("h2", { class: "pc-modal-title", id: titleId }, m.title));
899
+ if (m.description) dialog.append(el("p", { class: "pc-modal-desc" }, m.description));
900
+ const copyText = resolveCopyText(m.copyText);
901
+ if (copyText) dialog.append(el("pre", { class: "pc-modal-code" }, copyText));
902
+ const actions = el("div", { class: "pc-modal-actions" });
903
+ // Remember what had focus so we can restore it on close — a keyboard/screen
904
+ // reader user returns to where they were instead of being dumped at <body>.
905
+ const prevFocus = document.activeElement;
906
+ let closed = false;
907
+ function close() {
908
+ if (closed) return;
909
+ closed = true;
910
+ modalOpen = false;
911
+ document.removeEventListener("keydown", onKey);
912
+ overlay.remove();
913
+ // A manual close (button / Escape / backdrop) never pops the disposers
914
+ // stack, so drop our own entry here — otherwise repeated open/close cycles
915
+ // accrete dead close() closures until the next page navigation runs teardown.
916
+ const i = disposers.indexOf(close);
917
+ if (i !== -1) disposers.splice(i, 1);
918
+ if (prevFocus && typeof prevFocus.focus === "function") prevFocus.focus();
919
+ }
920
+ // Focusable descendants of the dialog, in DOM order, for the Tab focus trap.
921
+ function focusables() {
922
+ return Array.prototype.slice.call(dialog.querySelectorAll(
923
+ 'button, [href], input, select, textarea, [tabindex]:not([tabindex="-1"])'
924
+ )).filter((n) => !n.disabled);
925
+ }
926
+ function onKey(ev) {
927
+ if (ev.key === "Escape") { close(); return; }
928
+ if (ev.key !== "Tab") return;
929
+ // Trap Tab within the dialog so focus can't slip behind the overlay onto the
930
+ // inert page underneath — that would break aria-modal semantics.
931
+ const f = focusables();
932
+ if (!f.length) return;
933
+ const first = f[0];
934
+ const last = f[f.length - 1];
935
+ if (ev.shiftKey && document.activeElement === first) { ev.preventDefault(); last.focus(); }
936
+ else if (!ev.shiftKey && document.activeElement === last) { ev.preventDefault(); first.focus(); }
937
+ }
938
+ if (copyText) {
939
+ const label = m.copyLabel || "Copy";
940
+ const copyBtn = el("button", { class: "pc-btn pc-btn-sm", type: "button" }, label);
941
+ copyBtn.addEventListener("click", async () => {
942
+ const ok = await copyToClipboard(copyText);
943
+ copyBtn.textContent = ok ? "Copied ✓" : "Press ⌘/Ctrl+C";
944
+ setTimeout(() => { copyBtn.textContent = label; }, 2200);
945
+ });
946
+ actions.append(copyBtn);
947
+ }
948
+ const closeBtn = el("button", { class: "pc-btn pc-btn-sm pc-btn-ghost", type: "button" }, m.closeLabel || "Close");
949
+ closeBtn.addEventListener("click", close);
950
+ actions.append(closeBtn);
951
+ dialog.append(actions);
952
+ overlay.append(dialog);
953
+ // A click on the backdrop (never the dialog itself) dismisses.
954
+ overlay.addEventListener("click", (ev) => { if (ev.target === overlay) close(); });
955
+ document.addEventListener("keydown", onKey);
956
+ document.body.append(overlay);
957
+ // A page switch runs teardown() before the next render — dispose the modal
958
+ // there too so navigating away never leaves a stale overlay + keydown listener.
959
+ disposers.push(close);
960
+ closeBtn.focus();
767
961
  }
768
962
 
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
963
+ // A standalone button node. Clicking it opens props.modal — used e.g. to surface
964
+ // a copy-pasteable "point your agent here" prompt. props.variant === "ghost"
965
+ // renders the muted outline style. A button without a modal is inert.
966
+ function renderButton(node) {
967
+ const p = node.props || {};
968
+ const btn = el(
969
+ "button",
970
+ { class: "pc-btn" + (p.variant === "ghost" ? " pc-btn-ghost" : ""), type: "button" },
971
+ p.label || "Open",
972
+ );
973
+ const m = p.modal && typeof p.modal === "object" ? p.modal : null;
974
+ if (m) btn.addEventListener("click", () => openModal(m));
975
+ return el("div", { class: "pc-buttonrow" }, btn);
976
+ }
977
+
978
+ // A grid td cell with three column-declared rendering modes, checked in order:
979
+ // 1. badge — a compact status indicator, gated on a non-empty field value
980
+ // (see the detailed note at the check below).
981
+ // 2. linkField — the cell text becomes a link to the URL held in that other
771
982
  // field. Only http(s) hrefs are linked (a javascript:/other-scheme URL
772
983
  // smuggled through row data falls back to plain text).
773
- // 2. link: { kind: "processExplorer", keyField } — a structured, engine-aware
984
+ // 3. link: { kind: "processExplorer", keyField } — a structured, engine-aware
774
985
  // link: the cell text links to the Nano console's explorer view for the
775
986
  // process instance whose key is held in the row's keyField. The console
776
987
  // path is constructed HERE (never taken from row data) and the key is
@@ -786,6 +997,27 @@ function renderText(node) {
786
997
  // grid and child grids.
787
998
  function gridCell(col, row) {
788
999
  const text = row[col.field] == null ? "" : String(row[col.field]);
1000
+ // 1. badge — a compact status indicator. When the row's field value is
1001
+ // non-empty (truthy after trimming) render a small circular badge (e.g. a
1002
+ // red "1" dot flagging an incident) whose tooltip is the full field text;
1003
+ // when empty the cell stays blank so the column is unobtrusive until it
1004
+ // matters. tone (danger|warn|ok|info, default danger) picks the color and
1005
+ // label the glyph inside (default "1"). Label/tone come from the app
1006
+ // schema (trusted); the tooltip is row data set via title/textContent
1007
+ // (DOM-escaped by the platform, so no HTML/attribute injection). The full
1008
+ // field text is also mirrored into aria-label so assistive tech announces
1009
+ // the incident/status text (the visible "1" glyph alone is not meaningful).
1010
+ if (col.badge) {
1011
+ if (text.trim() === "") return el("td", {});
1012
+ const t = col.badge.tone;
1013
+ const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
1014
+ const label = col.badge.label == null ? "1" : String(col.badge.label);
1015
+ return el(
1016
+ "td",
1017
+ {},
1018
+ el("span", { class: "pc-badge pc-badge-" + tone, title: text, "aria-label": text }, label),
1019
+ );
1020
+ }
789
1021
  if (col.linkField) {
790
1022
  const href = row[col.linkField] == null ? "" : String(row[col.linkField]);
791
1023
  if (text !== "" && /^https?:\/\//i.test(href)) {
@@ -796,6 +1028,20 @@ function gridCell(col, row) {
796
1028
  );
797
1029
  }
798
1030
  }
1031
+ if (col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
1032
+ // An in-app link to another page, scoped to this row: #/<page>/<key>. The
1033
+ // key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
1034
+ // param tail, which parseRoute() decodes back verbatim; the page id is
1035
+ // validated against the safe-id charset and the key must be non-empty, so a
1036
+ // blank/keyless cell stays plain text. Navigation is a pure hash change
1037
+ // (no new tab, no server hit) — the SPA router re-renders in place.
1038
+ const key = row[col.link.keyField];
1039
+ const keyStr = key == null ? "" : String(key).trim();
1040
+ if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
1041
+ const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
1042
+ return el("td", {}, el("a", { class: "pc-link", href }, text));
1043
+ }
1044
+ }
799
1045
  if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
800
1046
  const key = row[col.link.keyField];
801
1047
  const keyStr = key == null ? "" : String(key).trim();
@@ -968,6 +1214,7 @@ function renderDataGrid(node) {
968
1214
  const qs = [];
969
1215
  for (const f of filters || []) {
970
1216
  if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1217
+ else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
971
1218
  else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
972
1219
  }
973
1220
  if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
@@ -1098,7 +1345,7 @@ function renderDataGrid(node) {
1098
1345
  return box;
1099
1346
  }
1100
1347
 
1101
- function renderRow(row) {
1348
+ function renderRow(row, sink) {
1102
1349
  const cells = cols.map((c) => gridCell(c, row));
1103
1350
  const key = rowKeyOf(row);
1104
1351
  let toggle = null;
@@ -1116,6 +1363,9 @@ function renderDataGrid(node) {
1116
1363
  }
1117
1364
  const tr = el("tr", {}, ...cells);
1118
1365
  tbody.append(tr);
1366
+ // When grouping, the caller collects every <tr> this row produced (the data
1367
+ // row plus any detail row) so a group header can hide/show them as one unit.
1368
+ if (sink) sink.push({ tr, isDetail: false, key });
1119
1369
  if (detail && toggle) {
1120
1370
  const isOpen = key != null && expanded.has(key);
1121
1371
  // Reuse an already-built detail row for an open PR across refreshes so its
@@ -1143,6 +1393,67 @@ function renderDataGrid(node) {
1143
1393
  }
1144
1394
  });
1145
1395
  tbody.append(dtr);
1396
+ if (sink) sink.push({ tr: dtr, isDetail: true, key });
1397
+ }
1398
+ }
1399
+
1400
+ // Append the fetched rows into the tbody, either flat (default) or partitioned
1401
+ // into collapsible groups when the grid declares 'groupBy: "<field>"'. Each
1402
+ // group renders a clickable header row ("<Label> <value> (<count>)") that
1403
+ // collapses/expands its member rows as one unit; the collapsed state is
1404
+ // persisted (localStorage, keyed by page + node + group value) so it survives
1405
+ // both the refresh poll and a full reload — the same durability the top-level
1406
+ // 'collapsible' node wrapper gives. Group order is numeric-aware ascending
1407
+ // (so waves 1,2,10 sort naturally), falling back to lexical for non-numeric
1408
+ // keys. 'groupDefaultCollapsed' seeds each group collapsed until toggled.
1409
+ function appendRows(rows) {
1410
+ const groupBy = p.groupBy;
1411
+ // Treat a non-string groupBy as "no grouping": labelBase calls groupBy.charAt()
1412
+ // below, so a truthy non-string (number/object from a malformed page schema)
1413
+ // would throw at render time and break the whole page instead of one grid.
1414
+ if (!groupBy || typeof groupBy !== "string") { for (const row of rows) renderRow(row, null); return; }
1415
+ const order = [];
1416
+ const groups = new Map();
1417
+ for (const row of rows) {
1418
+ const gvRaw = row[groupBy];
1419
+ const gv = gvRaw == null ? "" : String(gvRaw);
1420
+ if (!groups.has(gv)) { groups.set(gv, []); order.push(gv); }
1421
+ groups.get(gv).push(row);
1422
+ }
1423
+ order.sort((a, b) => {
1424
+ const na = Number(a), nb = Number(b);
1425
+ if (a !== "" && b !== "" && !Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
1426
+ return a < b ? -1 : a > b ? 1 : 0;
1427
+ });
1428
+ const labelBase = p.groupLabel != null
1429
+ ? String(p.groupLabel)
1430
+ : groupBy.charAt(0).toUpperCase() + groupBy.slice(1);
1431
+ for (const gv of order) {
1432
+ const groupRows = groups.get(gv);
1433
+ const gkey = "pc:collapsed:" + CURRENT + ":" + (node.id || p.title || "grid") + ":g:" + gv;
1434
+ let gcollapsed = readCollapsed(gkey, !!p.groupDefaultCollapsed);
1435
+ const chevron = el("span", { class: "pc-chevron-inline" }, gcollapsed ? "▸" : "▾");
1436
+ const label = (gv === "" ? labelBase + " —" : labelBase + " " + gv) + " (" + groupRows.length + ")";
1437
+ const btn = el("button", { class: "pc-group-toggle", type: "button" },
1438
+ chevron, el("span", { class: "pc-group-title" }, label));
1439
+ tbody.append(el("tr", { class: "pc-group-header" }, el("td", { colspan: span }, btn)));
1440
+ const members = [];
1441
+ for (const row of groupRows) renderRow(row, members);
1442
+ const applyGroup = () => {
1443
+ chevron.textContent = gcollapsed ? "▸" : "▾";
1444
+ btn.setAttribute("aria-expanded", String(!gcollapsed));
1445
+ for (const m of members) {
1446
+ m.tr.hidden = gcollapsed
1447
+ ? true
1448
+ : (m.isDetail ? !(m.key != null && expanded.has(m.key)) : false);
1449
+ }
1450
+ };
1451
+ btn.addEventListener("click", () => {
1452
+ gcollapsed = !gcollapsed;
1453
+ writeCollapsed(gkey, gcollapsed);
1454
+ applyGroup();
1455
+ });
1456
+ applyGroup();
1146
1457
  }
1147
1458
  }
1148
1459
 
@@ -1158,7 +1469,15 @@ function renderDataGrid(node) {
1158
1469
 
1159
1470
  async function refresh() {
1160
1471
  try {
1161
- const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
1472
+ // A grid scoped to a route param (any eqParam filter) means "show the rows
1473
+ // for the selected entity". With no param present, PARAM is empty and emitting
1474
+ // a where clause of field-colon-empty would read server-side as field equals
1475
+ // empty string and surface empty-valued rows. That is not the selected entity's
1476
+ // rows (there is no selection), so short-circuit to zero rows without a request.
1477
+ const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
1478
+ const { rows } = paramScoped && PARAM === ""
1479
+ ? { rows: [] }
1480
+ : await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
1162
1481
  // Forget expansion / cached detail nodes for rows no longer present so the maps
1163
1482
  // don't grow without bound and a stale answer can't resurface on a key reuse.
1164
1483
  if (p.rowKey) {
@@ -1197,7 +1516,7 @@ function renderDataGrid(node) {
1197
1516
  }
1198
1517
  }
1199
1518
  tbody.replaceChildren();
1200
- for (const row of rows) renderRow(row);
1519
+ appendRows(rows);
1201
1520
  if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
1202
1521
  if (keepFocus && active.isConnected) {
1203
1522
  active.focus();
@@ -1264,7 +1583,14 @@ function fillNav(list, items) {
1264
1583
  function renderNav(node) {
1265
1584
  const p = node.props || {};
1266
1585
  const rail = p.variant === "rail";
1267
- const nav = el("nav", { class: rail ? "pc-nav pc-rail" : "pc-nav pc-bar" });
1586
+ // A bar nav can opt into staying pinned to the top of the viewport while the
1587
+ // page scrolls (sticky) — useful on the long, multi-section operator pages so
1588
+ // the page switcher is always reachable. Rails are already full-height, so the
1589
+ // flag only applies to the bar variant.
1590
+ const barSticky = !rail && p.sticky === true;
1591
+ const nav = el("nav", {
1592
+ class: (rail ? "pc-nav pc-rail" : "pc-nav pc-bar") + (barSticky ? " pc-sticky" : ""),
1593
+ });
1268
1594
  if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
1269
1595
  const list = el("div", { class: "pc-nav-items" });
1270
1596
  nav.append(list);
@@ -1280,7 +1606,7 @@ function renderNav(node) {
1280
1606
  return nav;
1281
1607
  }
1282
1608
 
1283
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav };
1609
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
1284
1610
 
1285
1611
  // Durable per-node UI state. localStorage is keyed by the home page id + node
1286
1612
  // id so two grids on the same page (or the same grid across pages) don't clash,
@@ -1358,7 +1684,9 @@ function makeCollapsible(node, card) {
1358
1684
  // tearing down the previous page's grid polls first.
1359
1685
  async function renderPage() {
1360
1686
  teardown();
1361
- CURRENT = currentPage();
1687
+ const route = parseRoute();
1688
+ CURRENT = route.page;
1689
+ PARAM = route.param;
1362
1690
  try {
1363
1691
  const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
1364
1692
  document.title = (doc && doc.title) || "Urban App";