@nanobpm/urban 0.43.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.
@@ -518,6 +518,12 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
518
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; }
519
519
  .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
520
520
  .pc-card-body[hidden] { display:none; }
521
+ /* Grid group headers (dataGrid groupBy): a full-width, clickable band that
522
+ collapses/expands its member rows; the collapsed state is persisted. */
523
+ .pc-group-header td { padding:0; background:var(--nano-inset); border-top:1px solid var(--nano-edge); }
524
+ .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; }
525
+ .pc-group-toggle:hover { background:var(--nano-hover); }
526
+ .pc-group-title { font-size:.9rem; }
521
527
  /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
522
528
  .pc-buttonrow { margin:1rem 0; }
523
529
  .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
@@ -534,6 +540,11 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
534
540
  .pc-main-col { flex:1 1 auto; min-width:0; }
535
541
  .pc-nav-title { font-weight:650; }
536
542
  .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; }
543
+ /* Opt-in sticky bar: stays pinned to the viewport top while the page scrolls.
544
+ The negative inline margin + matching padding lets its background span the
545
+ body's 2rem inline padding so scrolled content never shows beside it, and a
546
+ solid app-colored background hides content passing underneath. */
547
+ .pc-bar.pc-sticky { position:sticky; top:0; z-index:20; background:var(--nano-app); margin-inline:-2rem; padding-inline:2rem; padding-top:.6rem; }
537
548
  .pc-bar .pc-nav-title { margin-right:.5rem; }
538
549
  .pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
539
550
  .pc-rail { display:flex; flex-direction:column; gap:.5rem; }
@@ -563,15 +574,34 @@ const PAGE_ID = /^[A-Za-z0-9_-]+$/;
563
574
  function safePageId(value) {
564
575
  return typeof value === "string" && PAGE_ID.test(value);
565
576
  }
566
- function currentPage() {
567
- try {
568
- const raw = decodeURIComponent((location.hash || "").replace(/^#\/?/, ""));
569
- return safePageId(raw) ? raw : HOME;
570
- } catch (e) {
571
- return HOME;
572
- }
577
+ // A route is #/<page> or #/<page>/<url-encoded-param>. We split on the FIRST
578
+ // "/" *before* decoding, so a param that itself contains encoded slashes or
579
+ // hashes (e.g. a plan_key "owner/repo#123" carried as "owner%2Frepo%23123")
580
+ // survives intact instead of the inner "%2F" being decoded into a second
581
+ // segment. The page segment is still restricted to the safe id charset; the
582
+ // param is opaque free text (bound only as a whitelisted '?' SQL value, or
583
+ // interpolated as DOM text — never as HTML/markup).
584
+ function parseRoute() {
585
+ const raw = (location.hash || "").replace(/^#\/?/, "");
586
+ const slash = raw.indexOf("/");
587
+ const pageRaw = slash >= 0 ? raw.slice(0, slash) : raw;
588
+ const paramRaw = slash >= 0 ? raw.slice(slash + 1) : "";
589
+ let page = HOME, param = "", pageOk = false;
590
+ try { const p = decodeURIComponent(pageRaw); if (safePageId(p)) { page = p; pageOk = true; } } catch (e) { /* keep HOME */ }
591
+ // Only carry the param when the page segment resolved to a real (safe-id) page.
592
+ // A malformed/unknown page like #/not-a-page/x falls back to HOME and must NOT
593
+ // inherit x, or that stray param would silently scope every eqParam grid and
594
+ // {{param}} on the home page to an entity the user never selected.
595
+ if (pageOk) { try { param = paramRaw ? decodeURIComponent(paramRaw) : ""; } catch (e) { param = ""; } }
596
+ return { page, param };
573
597
  }
598
+ function currentPage() { return parseRoute().page; }
574
599
  let CURRENT = currentPage();
600
+ // The current route param (the "/<param>" tail, or "" when absent). Exposed to a
601
+ // page's datasource filters (a filter with { eqParam: true } binds its value to
602
+ // this) and to {{param}} text interpolation, so one page template can scope
603
+ // every section to a selected entity (e.g. an epic's plan_key).
604
+ let PARAM = parseRoute().param;
575
605
 
576
606
  // Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
577
607
  // listener here; navigating to another page runs every disposer before the new
@@ -740,10 +770,19 @@ function el(tag, attrs = {}, ...kids) {
740
770
  return n;
741
771
  }
742
772
 
773
+ // Interpolate the {{param}} token in a schema-supplied string with the current
774
+ // route param. The result is only ever set as DOM text (textContent), so the
775
+ // substituted value cannot inject markup. Other tokens are left untouched.
776
+ // A function replacement inserts PARAM literally; a plain string replacement would
777
+ // treat replacement patterns inside PARAM (an id like the dollar-one token) as
778
+ // special and render the wrong text.
779
+ function interpParam(s) {
780
+ return typeof s === "string" ? s.replace(/\{\{param\}\}/g, () => PARAM) : s;
781
+ }
743
782
  function renderText(node) {
744
783
  const v = node.props.variant;
745
784
  const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
746
- return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
785
+ return el(v === "heading" ? "h1" : "p", { class: cls }, interpParam(node.props.text) || "");
747
786
  }
748
787
 
749
788
  // Copy text to the clipboard, resiliently. The async Clipboard API is tried
@@ -937,6 +976,20 @@ function gridCell(col, row) {
937
976
  );
938
977
  }
939
978
  }
979
+ if (col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
980
+ // An in-app link to another page, scoped to this row: #/<page>/<key>. The
981
+ // key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
982
+ // param tail, which parseRoute() decodes back verbatim; the page id is
983
+ // validated against the safe-id charset and the key must be non-empty, so a
984
+ // blank/keyless cell stays plain text. Navigation is a pure hash change
985
+ // (no new tab, no server hit) — the SPA router re-renders in place.
986
+ const key = row[col.link.keyField];
987
+ const keyStr = key == null ? "" : String(key).trim();
988
+ if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
989
+ const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
990
+ return el("td", {}, el("a", { class: "pc-link", href }, text));
991
+ }
992
+ }
940
993
  if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
941
994
  const key = row[col.link.keyField];
942
995
  const keyStr = key == null ? "" : String(key).trim();
@@ -1109,6 +1162,7 @@ function renderDataGrid(node) {
1109
1162
  const qs = [];
1110
1163
  for (const f of filters || []) {
1111
1164
  if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1165
+ else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1112
1166
  else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1113
1167
  }
1114
1168
  if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
@@ -1239,7 +1293,7 @@ function renderDataGrid(node) {
1239
1293
  return box;
1240
1294
  }
1241
1295
 
1242
- function renderRow(row) {
1296
+ function renderRow(row, sink) {
1243
1297
  const cells = cols.map((c) => gridCell(c, row));
1244
1298
  const key = rowKeyOf(row);
1245
1299
  let toggle = null;
@@ -1257,6 +1311,9 @@ function renderDataGrid(node) {
1257
1311
  }
1258
1312
  const tr = el("tr", {}, ...cells);
1259
1313
  tbody.append(tr);
1314
+ // When grouping, the caller collects every <tr> this row produced (the data
1315
+ // row plus any detail row) so a group header can hide/show them as one unit.
1316
+ if (sink) sink.push({ tr, isDetail: false, key });
1260
1317
  if (detail && toggle) {
1261
1318
  const isOpen = key != null && expanded.has(key);
1262
1319
  // Reuse an already-built detail row for an open PR across refreshes so its
@@ -1284,6 +1341,67 @@ function renderDataGrid(node) {
1284
1341
  }
1285
1342
  });
1286
1343
  tbody.append(dtr);
1344
+ if (sink) sink.push({ tr: dtr, isDetail: true, key });
1345
+ }
1346
+ }
1347
+
1348
+ // Append the fetched rows into the tbody, either flat (default) or partitioned
1349
+ // into collapsible groups when the grid declares 'groupBy: "<field>"'. Each
1350
+ // group renders a clickable header row ("<Label> <value> (<count>)") that
1351
+ // collapses/expands its member rows as one unit; the collapsed state is
1352
+ // persisted (localStorage, keyed by page + node + group value) so it survives
1353
+ // both the refresh poll and a full reload — the same durability the top-level
1354
+ // 'collapsible' node wrapper gives. Group order is numeric-aware ascending
1355
+ // (so waves 1,2,10 sort naturally), falling back to lexical for non-numeric
1356
+ // keys. 'groupDefaultCollapsed' seeds each group collapsed until toggled.
1357
+ function appendRows(rows) {
1358
+ const groupBy = p.groupBy;
1359
+ // Treat a non-string groupBy as "no grouping": labelBase calls groupBy.charAt()
1360
+ // below, so a truthy non-string (number/object from a malformed page schema)
1361
+ // would throw at render time and break the whole page instead of one grid.
1362
+ if (!groupBy || typeof groupBy !== "string") { for (const row of rows) renderRow(row, null); return; }
1363
+ const order = [];
1364
+ const groups = new Map();
1365
+ for (const row of rows) {
1366
+ const gvRaw = row[groupBy];
1367
+ const gv = gvRaw == null ? "" : String(gvRaw);
1368
+ if (!groups.has(gv)) { groups.set(gv, []); order.push(gv); }
1369
+ groups.get(gv).push(row);
1370
+ }
1371
+ order.sort((a, b) => {
1372
+ const na = Number(a), nb = Number(b);
1373
+ if (a !== "" && b !== "" && !Number.isNaN(na) && !Number.isNaN(nb)) return na - nb;
1374
+ return a < b ? -1 : a > b ? 1 : 0;
1375
+ });
1376
+ const labelBase = p.groupLabel != null
1377
+ ? String(p.groupLabel)
1378
+ : groupBy.charAt(0).toUpperCase() + groupBy.slice(1);
1379
+ for (const gv of order) {
1380
+ const groupRows = groups.get(gv);
1381
+ const gkey = "pc:collapsed:" + CURRENT + ":" + (node.id || p.title || "grid") + ":g:" + gv;
1382
+ let gcollapsed = readCollapsed(gkey, !!p.groupDefaultCollapsed);
1383
+ const chevron = el("span", { class: "pc-chevron-inline" }, gcollapsed ? "▸" : "▾");
1384
+ const label = (gv === "" ? labelBase + " —" : labelBase + " " + gv) + " (" + groupRows.length + ")";
1385
+ const btn = el("button", { class: "pc-group-toggle", type: "button" },
1386
+ chevron, el("span", { class: "pc-group-title" }, label));
1387
+ tbody.append(el("tr", { class: "pc-group-header" }, el("td", { colspan: span }, btn)));
1388
+ const members = [];
1389
+ for (const row of groupRows) renderRow(row, members);
1390
+ const applyGroup = () => {
1391
+ chevron.textContent = gcollapsed ? "▸" : "▾";
1392
+ btn.setAttribute("aria-expanded", String(!gcollapsed));
1393
+ for (const m of members) {
1394
+ m.tr.hidden = gcollapsed
1395
+ ? true
1396
+ : (m.isDetail ? !(m.key != null && expanded.has(m.key)) : false);
1397
+ }
1398
+ };
1399
+ btn.addEventListener("click", () => {
1400
+ gcollapsed = !gcollapsed;
1401
+ writeCollapsed(gkey, gcollapsed);
1402
+ applyGroup();
1403
+ });
1404
+ applyGroup();
1287
1405
  }
1288
1406
  }
1289
1407
 
@@ -1299,7 +1417,15 @@ function renderDataGrid(node) {
1299
1417
 
1300
1418
  async function refresh() {
1301
1419
  try {
1302
- const { rows } = await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
1420
+ // A grid scoped to a route param (any eqParam filter) means "show the rows
1421
+ // for the selected entity". With no param present, PARAM is empty and emitting
1422
+ // a where clause of field-colon-empty would read server-side as field equals
1423
+ // empty string and surface empty-valued rows. That is not the selected entity's
1424
+ // rows (there is no selection), so short-circuit to zero rows without a request.
1425
+ const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
1426
+ const { rows } = paramScoped && PARAM === ""
1427
+ ? { rows: [] }
1428
+ : await getJSON(dataUrl(p.data.source, p.data.table, activeFilter, p.data.orderBy));
1303
1429
  // Forget expansion / cached detail nodes for rows no longer present so the maps
1304
1430
  // don't grow without bound and a stale answer can't resurface on a key reuse.
1305
1431
  if (p.rowKey) {
@@ -1338,7 +1464,7 @@ function renderDataGrid(node) {
1338
1464
  }
1339
1465
  }
1340
1466
  tbody.replaceChildren();
1341
- for (const row of rows) renderRow(row);
1467
+ appendRows(rows);
1342
1468
  if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
1343
1469
  if (keepFocus && active.isConnected) {
1344
1470
  active.focus();
@@ -1405,7 +1531,14 @@ function fillNav(list, items) {
1405
1531
  function renderNav(node) {
1406
1532
  const p = node.props || {};
1407
1533
  const rail = p.variant === "rail";
1408
- const nav = el("nav", { class: rail ? "pc-nav pc-rail" : "pc-nav pc-bar" });
1534
+ // A bar nav can opt into staying pinned to the top of the viewport while the
1535
+ // page scrolls (sticky) — useful on the long, multi-section operator pages so
1536
+ // the page switcher is always reachable. Rails are already full-height, so the
1537
+ // flag only applies to the bar variant.
1538
+ const barSticky = !rail && p.sticky === true;
1539
+ const nav = el("nav", {
1540
+ class: (rail ? "pc-nav pc-rail" : "pc-nav pc-bar") + (barSticky ? " pc-sticky" : ""),
1541
+ });
1409
1542
  if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
1410
1543
  const list = el("div", { class: "pc-nav-items" });
1411
1544
  nav.append(list);
@@ -1499,7 +1632,9 @@ function makeCollapsible(node, card) {
1499
1632
  // tearing down the previous page's grid polls first.
1500
1633
  async function renderPage() {
1501
1634
  teardown();
1502
- CURRENT = currentPage();
1635
+ const route = parseRoute();
1636
+ CURRENT = route.page;
1637
+ PARAM = route.param;
1503
1638
  try {
1504
1639
  const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
1505
1640
  document.title = (doc && doc.title) || "Urban App";
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nanobpm/urban",
3
- "version": "0.43.0",
3
+ "version": "0.44.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",
@@ -364,6 +364,84 @@ test("renderer wires a column's processExplorer link to the console explorer", a
364
364
  assert.match(js, /ev\.preventDefault\(\)/);
365
365
  });
366
366
 
367
+ test("renderer binds a route param: parseRoute splits page/param, filters + text consume it (#134)", async () => {
368
+ const res = await dispatch("GET", "/app/runtime.js");
369
+ const js = res.body ?? "";
370
+ // A route is #/<page>/<url-encoded-param>. parseRoute splits on the FIRST "/"
371
+ // BEFORE decoding so a param carrying encoded slashes/hashes (a plan_key like
372
+ // "owner/repo#123") survives; the param segment is decoded verbatim into PARAM.
373
+ assert.match(js, /const slash = raw\.indexOf\("\/"\)/);
374
+ assert.match(js, /const paramRaw = slash >= 0 \? raw\.slice\(slash \+ 1\) : ""/);
375
+ assert.match(js, /param = paramRaw \? decodeURIComponent\(paramRaw\) : ""/);
376
+ assert.match(js, /let PARAM = parseRoute\(\)\.param/);
377
+ // renderPage refreshes PARAM from the current route on every (re)render.
378
+ assert.match(js, /const route = parseRoute\(\);\s*CURRENT = route\.page;\s*PARAM = route\.param;/s);
379
+ // A datasource filter with { eqParam: true } binds its value to the live PARAM,
380
+ // so one page template scopes every section to the selected entity.
381
+ assert.match(js, /else if \(f\.eqParam\) qs\.push\("where=" \+ encodeURIComponent\(f\.field \+ ":" \+ PARAM\)\)/);
382
+ // {{param}} interpolates into text nodes as DOM text (never markup). A function
383
+ // replacement inserts PARAM literally, so replacement patterns inside a param id
384
+ // can't be reinterpreted.
385
+ assert.match(js, /function interpParam\(s\)/);
386
+ assert.match(js, /replace\(\/\\\{\\\{param\\\}\\\}\/g, \(\) => PARAM\)/);
387
+ assert.match(js, /interpParam\(node\.props\.text\)/);
388
+ // A grid whose active filter binds to the route param renders zero rows (no data
389
+ // request) when no param is present, rather than emitting a where clause of
390
+ // field-colon-empty (server reads that as field equals empty string and would
391
+ // surface empty-valued rows).
392
+ assert.match(js, /const paramScoped = \(activeFilter \|\| \[\]\)\.some\(\(f\) => f && f\.eqParam\)/);
393
+ assert.match(js, /paramScoped && PARAM === ""\s*\?\s*\{ rows: \[\] \}/s);
394
+ });
395
+
396
+ test("renderer wires a column's page link to an in-app scoped hash route", async () => {
397
+ const res = await dispatch("GET", "/app/runtime.js");
398
+ const js = res.body ?? "";
399
+ // link: { kind: "page", page, keyField } → an in-app #/<page>/<key> hash link
400
+ // (no new tab, no server hit). The page id is safe-id-validated, the key is
401
+ // trimmed + URL-encoded into the param tail, and a blank/keyless cell stays text.
402
+ assert.match(js, /col\.link && col\.link\.kind === "page" && col\.link\.page && col\.link\.keyField/);
403
+ assert.match(js, /safePageId\(col\.link\.page\)/);
404
+ assert.match(js, /"#\/" \+ encodeURIComponent\(col\.link\.page\) \+ "\/" \+ encodeURIComponent\(keyStr\)/);
405
+ // In-app: NOT a new-tab link (no target/rel on this anchor).
406
+ assert.match(js, /el\("a", \{ class: "pc-link", href \}, text\)/);
407
+ });
408
+
409
+ test("renderer groups a dataGrid by a field into persisted collapsible bands (waves)", async () => {
410
+ const res = await dispatch("GET", "/app/runtime.js");
411
+ const js = res.body ?? "";
412
+ // groupBy partitions rows into collapsible groups; each header toggles its
413
+ // members as a unit and the collapsed state is persisted (survives poll+reload).
414
+ assert.match(js, /function appendRows\(rows\)/);
415
+ assert.match(js, /const groupBy = p\.groupBy/);
416
+ assert.match(js, /if \(!groupBy \|\| typeof groupBy !== "string"\) \{ for \(const row of rows\) renderRow\(row, null\); return; \}/);
417
+ // Numeric-aware ascending group order (waves 1,2,10 sort naturally).
418
+ assert.match(js, /const na = Number\(a\), nb = Number\(b\)/);
419
+ // Collapsed state persisted per page + node + group value via the shared helpers.
420
+ assert.match(js, /readCollapsed\(gkey, !!p\.groupDefaultCollapsed\)/);
421
+ assert.match(js, /writeCollapsed\(gkey, gcollapsed\)/);
422
+ assert.match(js, /"pc:collapsed:" \+ CURRENT \+ ":" \+ \(node\.id \|\| p\.title \|\| "grid"\) \+ ":g:" \+ gv/);
423
+ // A collapsed group hides every member row; expanded restores data rows and
424
+ // leaves detail rows at their own expansion state.
425
+ assert.match(js, /m\.tr\.hidden = gcollapsed/);
426
+ assert.match(js, /class: "pc-group-header"/);
427
+ // The group toggle exposes its expanded/collapsed state to assistive tech,
428
+ // mirroring makeCollapsible's header (screen readers announce the change).
429
+ assert.match(js, /btn\.setAttribute\("aria-expanded", String\(!gcollapsed\)\)/);
430
+ // refresh() renders through appendRows so grouping applies on every poll.
431
+ assert.match(js, /tbody\.replaceChildren\(\);\s*appendRows\(rows\);/s);
432
+ });
433
+
434
+ test("renderer supports an opt-in sticky nav bar", async () => {
435
+ const res = await dispatch("GET", "/app/runtime.js");
436
+ const js = res.body ?? "";
437
+ const css = (await dispatch("GET", "/")).body ?? "";
438
+ // A bar nav with { sticky: true } gets the pc-sticky class (rails are excluded).
439
+ assert.match(js, /const barSticky = !rail && p\.sticky === true/);
440
+ assert.match(js, /\(barSticky \? " pc-sticky" : ""\)/);
441
+ // The CSS pins it to the viewport top with a solid background.
442
+ assert.match(css, /\.pc-bar\.pc-sticky \{[^}]*position:sticky[^}]*top:0/);
443
+ });
444
+
367
445
  test("renderer exposes an embed-gated host-navigation bridge", async () => {
368
446
  const res = await dispatch("GET", "/app/runtime.js");
369
447
  const js = res.body ?? "";
@@ -489,8 +567,15 @@ test("the renderer routes between pages by hash and tears down grid polls", asyn
489
567
  assert.match(js, /const PAGE_ID = \/\^\[A-Za-z0-9_-/);
490
568
  assert.match(js, /function safePageId\(value\)/);
491
569
  assert.match(js, /function currentPage\(\)/);
492
- assert.match(js, /try \{\s*const raw = decodeURIComponent/s);
493
- assert.match(js, /catch \(e\) \{\s*return HOME;/s);
570
+ // The hash is parsed into a page + optional param by parseRoute(); the page
571
+ // segment is decoded and safe-id-validated (falling back to HOME).
572
+ assert.match(js, /function parseRoute\(\)/);
573
+ assert.match(js, /const raw = \(location\.hash \|\| ""\)\.replace/s);
574
+ assert.match(js, /if \(safePageId\(p\)\) \{ page = p; pageOk = true; \}/s);
575
+ // A stray param is only carried when the page segment resolved to a real page,
576
+ // so a malformed/unknown page (#/not-a-page/x) falls back to HOME without
577
+ // inheriting x (which would otherwise silently scope home's eqParam grids).
578
+ assert.match(js, /if \(pageOk\) \{ try \{ param = paramRaw \? decodeURIComponent\(paramRaw\) : ""; \}/s);
494
579
  assert.match(js, /location\.hash/);
495
580
  assert.match(js, /window\.addEventListener\("hashchange", renderPage\)/);
496
581
  assert.match(js, /async function renderPage\(\)/);
@@ -569,6 +569,12 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
569
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; }
570
570
  .pc-chevron-inline { color:var(--nano-text-faint); font-size:.75rem; width:1em; }
571
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; }
572
578
  /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
573
579
  .pc-buttonrow { margin:1rem 0; }
574
580
  .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
@@ -585,6 +591,11 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
585
591
  .pc-main-col { flex:1 1 auto; min-width:0; }
586
592
  .pc-nav-title { font-weight:650; }
587
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; }
588
599
  .pc-bar .pc-nav-title { margin-right:.5rem; }
589
600
  .pc-bar .pc-nav-items { display:flex; gap:.25rem; flex-wrap:wrap; }
590
601
  .pc-rail { display:flex; flex-direction:column; gap:.5rem; }
@@ -615,15 +626,34 @@ const PAGE_ID = /^[A-Za-z0-9_-]+$/;
615
626
  function safePageId(value) {
616
627
  return typeof value === "string" && PAGE_ID.test(value);
617
628
  }
618
- function currentPage() {
619
- try {
620
- const raw = decodeURIComponent((location.hash || "").replace(/^#\/?/, ""));
621
- return safePageId(raw) ? raw : HOME;
622
- } catch (e) {
623
- return HOME;
624
- }
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 };
625
649
  }
650
+ function currentPage() { return parseRoute().page; }
626
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;
627
657
 
628
658
  // Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
629
659
  // listener here; navigating to another page runs every disposer before the new
@@ -792,10 +822,19 @@ function el(tag, attrs = {}, ...kids) {
792
822
  return n;
793
823
  }
794
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
+ }
795
834
  function renderText(node) {
796
835
  const v = node.props.variant;
797
836
  const cls = v === "heading" ? "pc-heading" : v === "sub" ? "pc-sub" : "pc-body";
798
- return el(v === "heading" ? "h1" : "p", { class: cls }, node.props.text || "");
837
+ return el(v === "heading" ? "h1" : "p", { class: cls }, interpParam(node.props.text) || "");
799
838
  }
800
839
 
801
840
  // Copy text to the clipboard, resiliently. The async Clipboard API is tried
@@ -989,6 +1028,20 @@ function gridCell(col, row) {
989
1028
  );
990
1029
  }
991
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
+ }
992
1045
  if (col.link && col.link.kind === "processExplorer" && col.link.keyField) {
993
1046
  const key = row[col.link.keyField];
994
1047
  const keyStr = key == null ? "" : String(key).trim();
@@ -1161,6 +1214,7 @@ function renderDataGrid(node) {
1161
1214
  const qs = [];
1162
1215
  for (const f of filters || []) {
1163
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));
1164
1218
  else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1165
1219
  }
1166
1220
  if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
@@ -1291,7 +1345,7 @@ function renderDataGrid(node) {
1291
1345
  return box;
1292
1346
  }
1293
1347
 
1294
- function renderRow(row) {
1348
+ function renderRow(row, sink) {
1295
1349
  const cells = cols.map((c) => gridCell(c, row));
1296
1350
  const key = rowKeyOf(row);
1297
1351
  let toggle = null;
@@ -1309,6 +1363,9 @@ function renderDataGrid(node) {
1309
1363
  }
1310
1364
  const tr = el("tr", {}, ...cells);
1311
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 });
1312
1369
  if (detail && toggle) {
1313
1370
  const isOpen = key != null && expanded.has(key);
1314
1371
  // Reuse an already-built detail row for an open PR across refreshes so its
@@ -1336,6 +1393,67 @@ function renderDataGrid(node) {
1336
1393
  }
1337
1394
  });
1338
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();
1339
1457
  }
1340
1458
  }
1341
1459
 
@@ -1351,7 +1469,15 @@ function renderDataGrid(node) {
1351
1469
 
1352
1470
  async function refresh() {
1353
1471
  try {
1354
- 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));
1355
1481
  // Forget expansion / cached detail nodes for rows no longer present so the maps
1356
1482
  // don't grow without bound and a stale answer can't resurface on a key reuse.
1357
1483
  if (p.rowKey) {
@@ -1390,7 +1516,7 @@ function renderDataGrid(node) {
1390
1516
  }
1391
1517
  }
1392
1518
  tbody.replaceChildren();
1393
- for (const row of rows) renderRow(row);
1519
+ appendRows(rows);
1394
1520
  if (!rows.length) tbody.append(el("tr", {}, el("td", { colspan: span }, "No rows")));
1395
1521
  if (keepFocus && active.isConnected) {
1396
1522
  active.focus();
@@ -1457,7 +1583,14 @@ function fillNav(list, items) {
1457
1583
  function renderNav(node) {
1458
1584
  const p = node.props || {};
1459
1585
  const rail = p.variant === "rail";
1460
- 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
+ });
1461
1594
  if (p.title != null) nav.append(el("div", { class: "pc-nav-title" }, String(p.title)));
1462
1595
  const list = el("div", { class: "pc-nav-items" });
1463
1596
  nav.append(list);
@@ -1551,7 +1684,9 @@ function makeCollapsible(node, card) {
1551
1684
  // tearing down the previous page's grid polls first.
1552
1685
  async function renderPage() {
1553
1686
  teardown();
1554
- CURRENT = currentPage();
1687
+ const route = parseRoute();
1688
+ CURRENT = route.page;
1689
+ PARAM = route.param;
1555
1690
  try {
1556
1691
  const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
1557
1692
  document.title = (doc && doc.title) || "Urban App";