@nanobpm/urban 0.52.0 → 0.53.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.
Files changed (54) hide show
  1. package/README.md +47 -20
  2. package/dist/runtime/core/events.d.ts +135 -0
  3. package/dist/runtime/core/events.js +329 -0
  4. package/dist/runtime/core/extensions.d.ts +131 -0
  5. package/dist/runtime/core/extensions.js +137 -0
  6. package/dist/runtime/core/glob.d.ts +7 -6
  7. package/dist/runtime/core/glob.js +16 -14
  8. package/dist/runtime/core/host.d.ts +4 -1
  9. package/dist/runtime/core/modules/agent.d.ts +17 -0
  10. package/dist/runtime/core/modules/agent.js +63 -0
  11. package/dist/runtime/core/modules/deploy.d.ts +8 -5
  12. package/dist/runtime/core/modules/deploy.js +62 -16
  13. package/dist/runtime/core/modules/pages.js +397 -20
  14. package/dist/runtime/core/modules/surfaces.js +8 -0
  15. package/dist/runtime/core/phase.d.ts +168 -0
  16. package/dist/runtime/core/phase.js +367 -0
  17. package/dist/runtime/core/runtime.d.ts +17 -0
  18. package/dist/runtime/core/runtime.js +30 -0
  19. package/dist/runtime/index.d.ts +6 -0
  20. package/dist/runtime/index.js +7 -0
  21. package/dist/toolkit/derivers/system-brief.d.ts +62 -0
  22. package/dist/toolkit/derivers/system-brief.js +156 -0
  23. package/dist/toolkit/gen.d.ts +5 -5
  24. package/dist/toolkit/gen.js +20 -14
  25. package/dist/toolkit/index.d.ts +5 -0
  26. package/dist/toolkit/index.js +3 -0
  27. package/dist/toolkit/models.d.ts +4 -3
  28. package/dist/toolkit/models.js +4 -3
  29. package/package.json +1 -1
  30. package/src/runtime/core/events.test.ts +382 -0
  31. package/src/runtime/core/events.ts +370 -0
  32. package/src/runtime/core/extensions.test.ts +378 -0
  33. package/src/runtime/core/extensions.ts +276 -0
  34. package/src/runtime/core/glob.ts +15 -12
  35. package/src/runtime/core/host.ts +4 -1
  36. package/src/runtime/core/modules/agent.test.ts +104 -0
  37. package/src/runtime/core/modules/agent.ts +76 -0
  38. package/src/runtime/core/modules/deploy.test.ts +84 -16
  39. package/src/runtime/core/modules/deploy.ts +59 -16
  40. package/src/runtime/core/modules/pages.test.ts +309 -5
  41. package/src/runtime/core/modules/pages.ts +398 -20
  42. package/src/runtime/core/modules/surfaces.ts +9 -0
  43. package/src/runtime/core/phase.test.ts +350 -0
  44. package/src/runtime/core/phase.ts +526 -0
  45. package/src/runtime/core/runtime.extensions.test.ts +151 -0
  46. package/src/runtime/core/runtime.ts +49 -0
  47. package/src/runtime/devserver.test.ts +3 -0
  48. package/src/runtime/index.ts +57 -0
  49. package/src/toolkit/derivers/system-brief.test.ts +150 -0
  50. package/src/toolkit/derivers/system-brief.ts +210 -0
  51. package/src/toolkit/gen.test.ts +28 -4
  52. package/src/toolkit/gen.ts +20 -13
  53. package/src/toolkit/index.ts +14 -0
  54. package/src/toolkit/models.ts +4 -3
@@ -433,6 +433,13 @@ function rendererShell(homePage, apiDocsPath) {
433
433
  // console, so the defaults below stand on their own: dark by default, light when
434
434
  // the OS asks for it and nothing has themed us. `--pc-*` are kept as thin
435
435
  // aliases so existing class rules re-resolve through the shared tokens.
436
+ // The single mobile breakpoint (max-width) for the urban responsive primitive
437
+ // (#268). ONE source of truth: it is interpolated into both the shell CSS
438
+ // `@media` query (which drives the pure-CSS card flip / nav collapse / button
439
+ // stacking) AND the runtime's `matchMedia` mode-switch (which only Tier-2's
440
+ // optional page-level `mobile` layout variant needs), so the two can never
441
+ // drift apart on where "narrow" begins.
442
+ const MOBILE_MAX_WIDTH = "640px";
436
443
  const RENDERER_CSS = `
437
444
  :root {
438
445
  color-scheme: dark;
@@ -528,6 +535,30 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
528
535
  .pc-count-badge { color:var(--nano-text); background:var(--nano-inset); border:1px solid var(--nano-edge); font-weight:600; }
529
536
  :root[data-appearance="light"] .pc-badge-warn { color:#fff; }
530
537
  @media (prefers-color-scheme: light) { :root:not([data-appearance]) .pc-badge-warn { color:#fff; } }
538
+ /* Pipeline / stepper cell (issue #265): a per-row, multi-stage horizontal
539
+ progress track rendered by pipelineCell. Only the track/stage chrome lives
540
+ here (the .pc-pipe classes); the active-stage badge and locus links reuse the
541
+ .pc-badge and .pc-link classes.
542
+ All colours come from the shared --nano-* tokens so light/dark themes work.
543
+ Stage treatments: done = filled/ok-tinted, active = lit accent, active+ok =
544
+ success (✓), active+failed = danger (✕), active+blocked = warn (⊘), upcoming =
545
+ faint/ghosted, skip (not-in-path) = dashed. */
546
+ .pc-pipe { display:flex; align-items:center; flex-wrap:wrap; gap:.15rem; font-size:.8rem; }
547
+ .pc-pipe-stage { display:inline-flex; align-items:center; gap:.3rem; padding:.12rem .5rem; border:1px solid var(--nano-edge); border-radius:999px; background:transparent; color:var(--nano-text-muted); white-space:nowrap; }
548
+ .pc-pipe-label { color:inherit; }
549
+ .pc-pipe-mark { font-weight:700; line-height:1; }
550
+ .pc-pipe-conn { flex:0 0 auto; width:.9rem; height:2px; border-radius:1px; background:var(--nano-edge); }
551
+ .pc-pipe-conn.filled { background:var(--nano-ok); }
552
+ .pc-pipe-done { color:var(--nano-text); border-color:var(--nano-ok); background:var(--nano-hover); }
553
+ .pc-pipe-active { color:var(--nano-text); font-weight:650; border-color:var(--nano-accent); background:var(--nano-hover); box-shadow:0 0 0 1px var(--nano-accent) inset; }
554
+ .pc-pipe-active.pc-pipe-ok { border-color:var(--nano-ok); box-shadow:0 0 0 1px var(--nano-ok) inset; }
555
+ .pc-pipe-active.pc-pipe-ok .pc-pipe-mark { color:var(--nano-ok); }
556
+ .pc-pipe-active.pc-pipe-failed { border-color:var(--nano-danger); box-shadow:0 0 0 1px var(--nano-danger) inset; }
557
+ .pc-pipe-active.pc-pipe-failed .pc-pipe-mark { color:var(--nano-danger); }
558
+ .pc-pipe-active.pc-pipe-blocked { border-color:var(--nano-warn); box-shadow:0 0 0 1px var(--nano-warn) inset; }
559
+ .pc-pipe-active.pc-pipe-blocked .pc-pipe-mark { color:var(--nano-warn); }
560
+ .pc-pipe-upcoming { color:var(--nano-text-faint); border-color:var(--nano-edge); opacity:.75; }
561
+ .pc-pipe-skip { color:var(--nano-text-faint); border-style:dashed; opacity:.6; }
531
562
  .pc-child { margin:.6rem 0; }
532
563
  .pc-child-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); margin-bottom:.25rem; }
533
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; }
@@ -575,6 +606,79 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
575
606
  .pc-nav-link.active { background:var(--nano-accent); color:var(--nano-on-accent); }
576
607
  .pc-nav-icon { line-height:1; }
577
608
  .pc-nav-empty { color:var(--nano-text-faint); font-size:.85rem; padding:.4rem .7rem; }
609
+ /* The per-row mobile "More" toggle (revealing mobile:{priority:"hidden"} columns)
610
+ only exists for the card view — hidden on the desktop table, shown by the
611
+ mobile @media below. Base-hidden here so a grid renders byte-for-byte
612
+ unchanged on desktop even when it carries hidden-column hints. */
613
+ .pc-mcard-toggle { display:none; }
614
+ /* ── Urban responsive / mobile primitive (#268) ──────────────────────────
615
+ Below MOBILE_MAX_WIDTH every app adapts to a phone with ZERO page-schema
616
+ change. The dense dataGrid — the one primitive CSS alone can't reflow — flips
617
+ from a wide <table> to a stacked card list purely from the per-cell
618
+ 'data-label' the renderer already stamps on (no JS branching): thead is
619
+ hidden, each <tr> becomes a bordered card and each <td> a full-width
620
+ label:value line whose label is the column header. The nav collapses to a
621
+ thumb-reachable scrollable bottom bar, buttons/form actions go full-width
622
+ single column, and text reflows on its own. The breakpoint is interpolated
623
+ from the shared MOBILE_MAX_WIDTH constant so it can't drift from the runtime's
624
+ matchMedia. */
625
+ @media (max-width:${MOBILE_MAX_WIDTH}) {
626
+ body { padding:1rem; }
627
+ /* dataGrid → card list. Once cells are display:block the fixed colgroup width
628
+ is irrelevant, so a wide table stops overflowing the phone. table-layout is
629
+ reset to auto so the block cells size to content. */
630
+ table.pc-grid { table-layout:auto; }
631
+ table.pc-grid, table.pc-grid tbody, table.pc-grid tr, table.pc-grid td { display:block; width:auto; }
632
+ table.pc-grid colgroup, table.pc-grid thead { display:none; }
633
+ table.pc-grid tr { border:1px solid var(--nano-edge); border-radius:.6rem; padding:.55rem .75rem; margin:.6rem 0; background:var(--nano-panel); }
634
+ table.pc-grid td { border-bottom:0; padding:.15rem 0; display:flex; gap:.75rem; justify-content:space-between; align-items:baseline; }
635
+ /* label:value — the column header (data-label) prefixes the value as a muted
636
+ caption on the same line. Suppressed for the title, chip, actions and any
637
+ colspan (group/empty/error) cells below. */
638
+ table.pc-grid td[data-label]::before { content:attr(data-label); color:var(--nano-text-muted); font-size:.72rem; font-weight:600; text-transform:uppercase; letter-spacing:.03em; flex:0 0 auto; }
639
+ /* The derived (or mobile:{priority:"primary"}-hinted) card title leads the
640
+ card: larger, full width, no label caption. */
641
+ table.pc-grid td.pc-mcell-primary { display:block; font-size:1rem; font-weight:650; padding-top:.15rem; }
642
+ table.pc-grid td.pc-mcell-primary::before { content:none; }
643
+ /* A badge column becomes a left-aligned status chip with no caption. */
644
+ table.pc-grid td.pc-mcell-chip { justify-content:flex-start; }
645
+ table.pc-grid td.pc-mcell-chip::before { content:none; }
646
+ /* An empty badge cell (row value blank) still carries the chip class but has no
647
+ child content; without this it renders as a blank flex row (padding/gap) that
648
+ leaves a stray empty line in the card. Collapse those empty chip cells (#268). */
649
+ table.pc-grid td.pc-mcell-chip:empty { display:none; }
650
+ /* Low-value (mobile:{priority:"hidden"}) columns drop off the card until the
651
+ row's "More" toggle opens them; on desktop they render as normal columns. */
652
+ table.pc-grid td.pc-mcell-hidden { display:none; }
653
+ table.pc-grid tr.pc-open td.pc-mcell-hidden { display:flex; }
654
+ .pc-mcard-toggle { display:block; padding:.3rem 0 0; }
655
+ .pc-mcard-toggle .pc-more { width:100%; margin-right:0; }
656
+ /* Row actions stack full-width under the card body. */
657
+ table.pc-grid td.pc-row-actions { display:flex; flex-wrap:wrap; gap:.4rem; text-align:left; padding-top:.45rem; white-space:normal; }
658
+ table.pc-grid td.pc-row-actions::before { content:none; }
659
+ table.pc-grid td.pc-row-actions .pc-btn { flex:1 1 auto; margin-right:0; }
660
+ /* Group-header + empty/error rows span the full card width with no caption. */
661
+ table.pc-grid td[colspan] { display:block; }
662
+ table.pc-grid td[colspan]::before { content:none; }
663
+ .pc-group-header td { padding:0; }
664
+ /* Nav → horizontally-scrollable bar (bar) / inline stack (rail). The bar keeps
665
+ its normal top-of-flow position and scrolls sideways when the pages overflow;
666
+ an opt-in sticky bar stays pinned to the top via the desktop .pc-sticky rule
667
+ (top:0). A rail drops its fixed side column and flows inline. */
668
+ .pc-layout { display:block; }
669
+ .pc-rail-wrap { position:static; flex-basis:auto; width:auto; margin:0 0 1rem; }
670
+ .pc-rail .pc-nav-items { flex-direction:row; flex-wrap:wrap; }
671
+ .pc-bar { flex-wrap:nowrap; overflow-x:auto; }
672
+ .pc-bar.pc-sticky { position:sticky; }
673
+ .pc-bar .pc-nav-items { flex-wrap:nowrap; }
674
+ .pc-nav-link { white-space:nowrap; }
675
+ .pc-tabs { overflow-x:auto; }
676
+ /* Buttons / form actions go full-width single-column. */
677
+ .pc-buttonrow .pc-btn, .pc-card .pc-btn { width:100%; }
678
+ .pc-modal { max-width:100%; }
679
+ .pc-modal-actions { flex-direction:column; }
680
+ .pc-modal-actions .pc-btn { width:100%; }
681
+ }
578
682
  `;
579
683
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
580
684
  // /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
@@ -584,6 +688,14 @@ const RENDERER_JS = String.raw `
584
688
  const root = document.getElementById("page");
585
689
  const HOME = root.dataset.home || "home";
586
690
 
691
+ // Single mobile breakpoint, interpolated from the same MOBILE_MAX_WIDTH constant
692
+ // as the shell CSS @media so the JS mode-switch and the pure-CSS card flip can
693
+ // never drift on where "narrow" begins (#268). Only Tier-2 (an optional
694
+ // page-level 'mobile' layout variant) needs this JS switch — the dataGrid card
695
+ // flip, nav collapse and button/form stacking are all pure CSS + data-label.
696
+ const MOBILE_MQ = window.matchMedia("(max-width:${MOBILE_MAX_WIDTH})");
697
+ function isNarrow() { return MOBILE_MQ.matches; }
698
+
587
699
  // ── Multi-page routing (hash-based, reverse-proxy safe) ──────────────────
588
700
  // Pages are selected by the URL fragment (#/<page>) so navigation never hits
589
701
  // the server and works identically at the origin root and under the Nano
@@ -622,6 +734,12 @@ let CURRENT = currentPage();
622
734
  // this) and to {{param}} text interpolation, so one page template can scope
623
735
  // every section to a selected entity (e.g. an epic's plan_key).
624
736
  let PARAM = parseRoute().param;
737
+ // Monotonic render token. renderPage() is async and fires from both hashchange
738
+ // and the matchMedia viewport-crossing handler, so two invocations can overlap
739
+ // and their fetches resolve out of order. Each render captures the token it
740
+ // bumped to; after its await it only touches the DOM if it is still the latest,
741
+ // so a stale navigation/viewport render can't overwrite a newer one.
742
+ let renderSeq = 0;
625
743
 
626
744
  // Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
627
745
  // listener here; navigating to another page runs every disposer before the new
@@ -1006,7 +1124,45 @@ function renderButton(node) {
1006
1124
  // width/weight — see buildColgroup — to give the clip a bounded column). The
1007
1125
  // subtitle is ALWAYS plain DOM text, never an href source. With none of these
1008
1126
  // set the cell is a single inline node exactly as before (see cellTd).
1009
- function gridCell(col, row) {
1127
+ // Build an in-app hash route to another page, scoped to a row key: #/<page>/<key>.
1128
+ // The page id is validated against the safe-id charset and the key must be
1129
+ // non-empty (after trimming); either failing yields "" (no link). The key is
1130
+ // URL-encoded into the route's param tail, which parseRoute() decodes back
1131
+ // verbatim, so row data can't smuggle a path or scheme. SINGLE source of truth
1132
+ // for the page-link href so the grid's link cell and the pipeline locus link
1133
+ // (issue #265) can't drift on how the route is built.
1134
+ function pageHashHref(page, key) {
1135
+ const keyStr = key == null ? "" : String(key).trim();
1136
+ if (!safePageId(page) || keyStr === "") return "";
1137
+ return "#/" + encodeURIComponent(page) + "/" + encodeURIComponent(keyStr);
1138
+ }
1139
+
1140
+ // Coerce a not-in-path value (issue #265) into a Set of stage keys. Accepts an
1141
+ // array of keys or a comma/whitespace-separated string; null/undefined → empty
1142
+ // set. Used to mark the stages a given row skips (dashed/omitted).
1143
+ function toStageSet(v) {
1144
+ // Trim + drop-empties on BOTH inputs so array entries with stray whitespace
1145
+ // (e.g. from CSV/JSON munging) match stages[].key just like the string path.
1146
+ if (Array.isArray(v)) return new Set(v.map((s) => String(s).trim()).filter((s) => s !== ""));
1147
+ if (v == null) return new Set();
1148
+ return new Set(String(v).split(/[\s,]+/).filter((s) => s !== ""));
1149
+ }
1150
+
1151
+ function gridCell(col, row, role) {
1152
+ // Mobile card attributes stamped on every td so a dataGrid can flip to a
1153
+ // stacked card list below the breakpoint with no JS branching (#268):
1154
+ // 'data-label' drives the pure-CSS label:value line (the column header, or a
1155
+ // mobile:{label} override), and the role class picks the card region (title /
1156
+ // status chip / secondary field / hidden). The role is derived by convention
1157
+ // (classifyColumns) and refinable per-column via col.mobile.priority.
1158
+ const mrole = role || "secondary";
1159
+ const mlabel =
1160
+ col.mobile && typeof col.mobile.label === "string"
1161
+ ? col.mobile.label
1162
+ : col.header != null
1163
+ ? String(col.header)
1164
+ : String(col.field == null ? "" : col.field);
1165
+ const mob = { class: "pc-mcell pc-mcell-" + mrole, "data-label": mlabel };
1010
1166
  // Raw single-field value: drives the badge presence gate + tooltip and any
1011
1167
  // field-derived defaults. Kept separate from the (possibly templated) display
1012
1168
  // text so a template never re-lights or blanks a badge.
@@ -1025,13 +1181,17 @@ function gridCell(col, row) {
1025
1181
  // field text is also mirrored into aria-label so assistive tech announces
1026
1182
  // the incident/status text (the visible "1" glyph alone is not meaningful).
1027
1183
  if (col.badge) {
1028
- if (rawText.trim() === "") return el("td", {});
1184
+ // Even when blank, stamp data-label (the "every td carries data-label"
1185
+ // invariant): the cell still collapses on mobile via the pc-mcell-chip:empty
1186
+ // rule (attributes don't defeat :empty), so this keeps empty and non-empty
1187
+ // badge cells consistent without re-lighting the label.
1188
+ if (rawText.trim() === "") return el("td", { class: "pc-mcell pc-mcell-chip", "data-label": mlabel });
1029
1189
  const t = col.badge.tone;
1030
1190
  const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
1031
1191
  const label = col.badge.label == null ? "1" : String(col.badge.label);
1032
1192
  return el(
1033
1193
  "td",
1034
- {},
1194
+ mob,
1035
1195
  el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
1036
1196
  );
1037
1197
  }
@@ -1044,6 +1204,14 @@ function gridCell(col, row) {
1044
1204
  col.subtitleField != null && row[col.subtitleField] != null ? String(row[col.subtitleField]) : "";
1045
1205
  const subText = typeof col.subtitleTemplate === "string" ? interpTemplate(col.subtitleTemplate, row) : subRaw;
1046
1206
  const truncate = col.truncate === true;
1207
+ // pipeline (issue #265): a dataGrid column declaring kind:"pipeline" renders
1208
+ // the ordered stages as a per-row horizontal progress track (upstream-filled
1209
+ // → active-lit → downstream-ghosted, not-in-path dashed). It owns the whole
1210
+ // cell, so it returns here before the link chain below. Gated purely on the
1211
+ // discriminator so every existing column mode is untouched when it is absent;
1212
+ // unknown/missing pipeline config degrades to plain cell text (never throws),
1213
+ // exactly as an unrecognised link.kind falls back today.
1214
+ if (col.kind === "pipeline") return pipelineCell(col, row, text, subText, truncate, mob);
1047
1215
  // Primary inline content: an anchor for a valid link mode (checked in the same
1048
1216
  // badge < linkField < link(page) < link(processExplorer) precedence as before),
1049
1217
  // else the plain display text. Each link mode only claims the cell while
@@ -1057,16 +1225,13 @@ function gridCell(col, row) {
1057
1225
  }
1058
1226
  }
1059
1227
  if (primary === text && col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
1060
- // An in-app link to another page, scoped to this row: #/<page>/<key>. The
1061
- // key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
1062
- // param tail, which parseRoute() decodes back verbatim; the page id is
1228
+ // An in-app link to another page, scoped to this row: #/<page>/<key>. Built
1229
+ // via pageHashHref (the single source of truth for the route): the page id is
1063
1230
  // validated against the safe-id charset and the key must be non-empty, so a
1064
1231
  // blank/keyless cell stays plain text. Navigation is a pure hash change
1065
1232
  // (no new tab, no server hit) — the SPA router re-renders in place.
1066
- const key = row[col.link.keyField];
1067
- const keyStr = key == null ? "" : String(key).trim();
1068
- if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
1069
- const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
1233
+ const href = pageHashHref(col.link.page, row[col.link.keyField]);
1234
+ if (text !== "" && href !== "") {
1070
1235
  primary = el("a", { class: "pc-link", href }, text);
1071
1236
  }
1072
1237
  }
@@ -1093,7 +1258,7 @@ function gridCell(col, row) {
1093
1258
  }, text);
1094
1259
  }
1095
1260
  }
1096
- return cellTd(primary, text, subText, truncate);
1261
+ return cellTd(primary, text, subText, truncate, mob);
1097
1262
  }
1098
1263
 
1099
1264
  // Compose a grid <td> from its primary inline content (a string or an anchor),
@@ -1108,9 +1273,12 @@ function gridCell(col, row) {
1108
1273
  // aria-label= are ALSO mirrored onto it: HTML does not inherit those to
1109
1274
  // descendants, so hovering the link text or naming the anchor for assistive tech
1110
1275
  // would otherwise miss the full value the wrapper carries. The subtitle is set as
1111
- // DOM text only — never an href.
1112
- function cellTd(primary, primaryText, subText, truncate) {
1113
- if (subText === "" && !truncate) return el("td", {}, primary);
1276
+ // DOM text only — never an href. 'tdAttrs' (the mobile card attrs: data-label +
1277
+ // role class, see gridCell) is applied to whichever <td> shape is returned so
1278
+ // the CSS-only card flip labels every cell identically (#268).
1279
+ function cellTd(primary, primaryText, subText, truncate, tdAttrs) {
1280
+ const td = tdAttrs || {};
1281
+ if (subText === "" && !truncate) return el("td", td, primary);
1114
1282
  const mainAttrs = { class: "pc-cell-main" + (truncate ? " pc-truncate" : "") };
1115
1283
  if (truncate && primaryText !== "") {
1116
1284
  mainAttrs.title = primaryText; mainAttrs["aria-label"] = primaryText;
@@ -1119,10 +1287,178 @@ function cellTd(primary, primaryText, subText, truncate) {
1119
1287
  }
1120
1288
  }
1121
1289
  const main = el("div", mainAttrs, primary);
1122
- if (subText === "") return el("td", {}, main);
1290
+ if (subText === "") return el("td", td, main);
1123
1291
  const subAttrs = { class: "pc-cell-sub" + (truncate ? " pc-truncate" : "") };
1124
1292
  if (truncate) { subAttrs.title = subText; subAttrs["aria-label"] = subText; }
1125
- return el("td", {}, main, el("div", subAttrs, subText));
1293
+ return el("td", td, main, el("div", subAttrs, subText));
1294
+ }
1295
+
1296
+ // Classify each grid column into a mobile-card region so a dataGrid flips to a
1297
+ // stacked card list below the breakpoint with ZERO page-schema change (#268).
1298
+ // Convention (Tier 0): the first non-badge column is the card title (primary), a
1299
+ // badge column is a status chip, and everything else is a secondary label:value
1300
+ // row. Tier 1 refines it — a column may carry mobile:{priority:"primary" |
1301
+ // "secondary" | "hidden"}: an explicit "primary" suppresses the auto title pick
1302
+ // so a later column can lead the card, "hidden" drops the column off the card
1303
+ // (behind the row's "More" toggle), and "secondary" forces a plain field row.
1304
+ function classifyColumns(cols) {
1305
+ const list = cols || [];
1306
+ const explicitPrimary = list.some((c) => c && c.mobile && c.mobile.priority === "primary");
1307
+ let titleTaken = explicitPrimary;
1308
+ return list.map((col) => {
1309
+ const pr = col && col.mobile ? col.mobile.priority : null;
1310
+ if (pr === "primary") return "primary";
1311
+ if (pr === "secondary") return "secondary";
1312
+ if (pr === "hidden") return "hidden";
1313
+ if (col && col.badge) return "chip";
1314
+ if (!titleTaken) { titleTaken = true; return "primary"; }
1315
+ return "secondary";
1316
+ });
1317
+ }
1318
+
1319
+ // Per-row mobile "More" toggle cell: reveals the mobile:{priority:"hidden"} columns
1320
+ // a narrow viewport drops off the card. The cell is base-hidden (display:none) so
1321
+ // the desktop table is byte-for-byte unchanged; clicking flips .pc-open on the row,
1322
+ // swaps the label, and updates aria-expanded so the reveal state is exposed to
1323
+ // assistive tech (#268). One implementation shared by the top-level grid and child
1324
+ // grids so the affordance — and its accessibility — can't drift between them.
1325
+ function mobileMoreCell(tr) {
1326
+ const moreBtn = el("button",
1327
+ { class: "pc-btn pc-btn-sm pc-btn-ghost pc-more", type: "button", "aria-expanded": "false" }, "More");
1328
+ moreBtn.addEventListener("click", () => {
1329
+ const open = tr.classList.toggle("pc-open");
1330
+ moreBtn.textContent = open ? "Less" : "More";
1331
+ moreBtn.setAttribute("aria-expanded", String(open));
1332
+ });
1333
+ return el("td", { class: "pc-mcard-toggle" }, moreBtn);
1334
+ }
1335
+
1336
+ // Pipeline / stepper cell (issue #265) — a dataGrid column with kind:"pipeline"
1337
+ // renders a per-row, multi-stage horizontal progress track. It is RENDER-ONLY:
1338
+ // stage state is read straight from the row fields the app supplies; no
1339
+ // app-specific stage derivation lives here. Column config (all fields are the
1340
+ // NAMES of row fields, mirroring the issue sketch):
1341
+ // { kind:"pipeline",
1342
+ // stages:[{key,label},...], // ordered stage definitions (required)
1343
+ // activeField:"stage", // row field → the current stage's key
1344
+ // stateField:"stage_state", // row field → ok | active | failed | blocked
1345
+ // badgeField:"attention", // optional row field → badge text shown on the active stage
1346
+ // notInPathField:"skipped", // optional row field → stages this row skips
1347
+ // notInPath:["converging"], // static fallback used only when no field given
1348
+ // locus:{ field:"pr_key", stage?:"<key>",
1349
+ // link:{ kind:"page", page:"home" } } } // optional out-link
1350
+ // Rendering: stages BEFORE the active one are filled/completed, the active stage
1351
+ // is lit (its treatment driven by stateField — success ✓ vs failure ✕ vs blocked
1352
+ // ⊘ are made unmistakably distinct), in-path stages AFTER it are ghosted, and any
1353
+ // stage in the row's not-in-path set is dashed. Reuses the .pc-badge and .pc-link
1354
+ // classes and the shared pageHashHref route builder — no parallel implementations. Unknown or
1355
+ // missing config (no stages array) degrades gracefully to plain cell text.
1356
+ function pipelineCell(col, row, text, subText, truncate, tdAttrs) {
1357
+ // Drop null/undefined entries defensively: a schema with a hole must still
1358
+ // render (never throws on s.key) — same graceful-degradation goal as below.
1359
+ const stages = Array.isArray(col.stages) ? col.stages.filter((s) => s != null) : [];
1360
+ // Graceful fallback: an unrecognised/empty pipeline config renders plain text,
1361
+ // exactly like an unknown link.kind — never throws.
1362
+ if (stages.length === 0) return cellTd(text, text, subText, truncate, tdAttrs);
1363
+ // The current stage's key, matched against stages[].key to find the active index.
1364
+ // The row value is trimmed for consistency with the other row-derived keys
1365
+ // (pageHashHref, toStageSet), so a whitespace-padded value still matches its stage
1366
+ // instead of leaving the whole track rendered as upcoming.
1367
+ // Stage keys identify a single stage, so stop at the first match: the result is
1368
+ // deterministic even if a config accidentally repeats a key, and we avoid scanning
1369
+ // the remaining stages on every row.
1370
+ const activeKey = col.activeField != null && row[col.activeField] != null ? String(row[col.activeField]).trim() : "";
1371
+ let activeIdx = -1;
1372
+ for (let i = 0; i < stages.length; i++) {
1373
+ if (String(stages[i].key) === activeKey) {
1374
+ activeIdx = i;
1375
+ break;
1376
+ }
1377
+ }
1378
+ // The active stage's state drives its lit/failed/blocked treatment. Anything
1379
+ // outside the allow-list (or absent) is treated as an in-progress "active".
1380
+ const stRaw = col.stateField != null && row[col.stateField] != null ? String(row[col.stateField]) : "";
1381
+ const state = stRaw === "ok" || stRaw === "failed" || stRaw === "blocked" ? stRaw : "active";
1382
+ // Not-in-path (skipped) stages: driven per-row from notInPathField when given,
1383
+ // else the static notInPath config (per the issue: "per row, from a field").
1384
+ const skip =
1385
+ col.notInPathField != null && row[col.notInPathField] != null
1386
+ ? toStageSet(row[col.notInPathField])
1387
+ : toStageSet(col.notInPath);
1388
+ // Optional active-stage badge (escalation/attention → warn, blocked/failure →
1389
+ // danger), reusing .pc-badge tone classes — no new badge styles. The badge text
1390
+ // is a single row-level value (row[badgeField]) shown only on the active stage.
1391
+ const badgeText = col.badgeField != null && row[col.badgeField] != null ? String(row[col.badgeField]) : "";
1392
+ const badgeTone = state === "failed" || state === "blocked" ? "danger" : "warn";
1393
+ // Optional locus out-link: the relevant stage (default the active one, or
1394
+ // locus.stage by key) becomes an in-app page link when the row's locus key is
1395
+ // non-empty. Href comes from the shared pageHashHref builder (safePageId guard,
1396
+ // encodeURIComponent) — a blank key yields "" so the stage stays plain.
1397
+ const locus = col.locus;
1398
+ const locusKey = locus && locus.field != null && row[locus.field] != null ? String(row[locus.field]) : "";
1399
+ const locusHref =
1400
+ locus && locus.link && locus.link.kind === "page" && locus.link.page ? pageHashHref(locus.link.page, locusKey) : "";
1401
+ const track = el("div", {
1402
+ class: "pc-pipe",
1403
+ role: "list",
1404
+ "aria-label": "Progress" + (activeIdx >= 0 ? ": " + labelOf(stages[activeIdx]) : ""),
1405
+ });
1406
+ for (let i = 0; i < stages.length; i++) {
1407
+ const s = stages[i];
1408
+ // Active wins over skip: if the active stage's key is also in the not-in-path
1409
+ // set, it must still render as the current step (active styling + aria-current),
1410
+ // never as skipped. Excluding activeIdx here keeps that precedence consistent
1411
+ // across the stage class, the aria-current mark, and the connector fill below.
1412
+ const skipped = i !== activeIdx && skip.has(String(s.key));
1413
+ // Connector between adjacent stages; filled up to and including the active
1414
+ // stage, but never across a not-in-path stage — a skipped predecessor breaks
1415
+ // the fill so we don't visually imply it was on the path.
1416
+ if (i > 0) {
1417
+ const filled =
1418
+ activeIdx >= 0 && i <= activeIdx && !skipped && !skip.has(String(stages[i - 1].key)) ? " filled" : "";
1419
+ track.append(el("span", { class: "pc-pipe-conn" + filled, "aria-hidden": "true" }));
1420
+ }
1421
+ let cls;
1422
+ let word;
1423
+ let glyph = "";
1424
+ let current = false;
1425
+ if (skipped) {
1426
+ cls = "pc-pipe-skip"; word = "skipped";
1427
+ } else if (activeIdx >= 0 && i < activeIdx) {
1428
+ cls = "pc-pipe-done"; word = "completed";
1429
+ } else if (i === activeIdx) {
1430
+ current = true;
1431
+ if (state === "failed") { cls = "pc-pipe-active pc-pipe-failed"; word = "failed"; glyph = "\u2715"; }
1432
+ else if (state === "blocked") { cls = "pc-pipe-active pc-pipe-blocked"; word = "blocked"; glyph = "\u2298"; }
1433
+ else if (state === "ok") { cls = "pc-pipe-active pc-pipe-ok"; word = "done"; glyph = "\u2713"; }
1434
+ else { cls = "pc-pipe-active"; word = "current"; }
1435
+ } else {
1436
+ cls = "pc-pipe-upcoming"; word = "upcoming";
1437
+ }
1438
+ const isLocus = locusHref !== "" && (locus.stage != null ? String(s.key) === String(locus.stage) : i === activeIdx);
1439
+ const label = labelOf(s);
1440
+ const labelNode = isLocus
1441
+ ? el("a", { class: "pc-link pc-pipe-label", href: locusHref }, label)
1442
+ : el("span", { class: "pc-pipe-label" }, label);
1443
+ const attrs = { class: "pc-pipe-stage " + cls, role: "listitem", "aria-label": label + " \u2014 " + word };
1444
+ if (current) attrs["aria-current"] = "step";
1445
+ const stage = el("span", attrs, labelNode);
1446
+ if (glyph !== "") stage.append(el("span", { class: "pc-pipe-mark", "aria-hidden": "true" }, glyph));
1447
+ if (current && badgeText.trim() !== "") {
1448
+ stage.append(el("span", { class: "pc-badge pc-badge-" + badgeTone, title: badgeText, "aria-label": badgeText }, badgeText));
1449
+ }
1450
+ track.append(stage);
1451
+ }
1452
+ // The mobile card attrs (data-label + role class, #268) stamped by gridCell are
1453
+ // merged onto the pipeline <td> too, alongside its own pc-pipe-cell class, so a
1454
+ // pipeline column labels and classifies identically under the CSS-only card flip.
1455
+ const cellAttrs = { ...(tdAttrs || {}), class: (tdAttrs && tdAttrs.class ? tdAttrs.class + " " : "") + "pc-pipe-cell" };
1456
+ return el("td", cellAttrs, track);
1457
+ }
1458
+
1459
+ // A stage's visible label: its declared label, else its key as a fallback.
1460
+ function labelOf(stage) {
1461
+ return stage.label == null ? String(stage.key) : String(stage.label);
1126
1462
  }
1127
1463
 
1128
1464
  // Parse a "<number>%" width into its numeric percentage, or null if the string
@@ -1360,6 +1696,12 @@ function renderDataGrid(node) {
1360
1696
  card.append(h2);
1361
1697
  }
1362
1698
  const cols = p.columns || [];
1699
+ // Mobile card roles per column (derived by convention, refined by
1700
+ // col.mobile.priority) so the grid can flip to a stacked card list on a phone
1701
+ // (#268). 'hasHidden' gates the per-row "More" toggle that reveals the columns
1702
+ // a mobile:{priority:"hidden"} hint drops off the card.
1703
+ const roles = classifyColumns(cols);
1704
+ const hasHidden = roles.indexOf("hidden") >= 0;
1363
1705
  const tabs = p.tabs || [];
1364
1706
  const rowActions = p.rowActions || [];
1365
1707
  const detail = p.detail || null;
@@ -1483,6 +1825,8 @@ function renderDataGrid(node) {
1483
1825
  const wrap = el("div", { class: "pc-child" });
1484
1826
  if (cg.title) wrap.append(el("div", { class: "pc-child-title" }, cg.title));
1485
1827
  const ccols = cg.columns || [];
1828
+ const croles = classifyColumns(ccols);
1829
+ const chasHidden = croles.indexOf("hidden") >= 0;
1486
1830
  const cbody = el("tbody", {});
1487
1831
  const ccolgroup = buildColgroup(ccols, cg.lazyField ? 1 : 0);
1488
1832
  const cthead = el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
@@ -1499,7 +1843,7 @@ function renderDataGrid(node) {
1499
1843
  cbody.append(el("tr", {}, el("td", { colspan: cspan }, "None")));
1500
1844
  }
1501
1845
  for (const cr of rows) {
1502
- const cells = ccols.map((c) => gridCell(c, cr));
1846
+ const cells = ccols.map((c, i) => gridCell(c, cr, croles[i]));
1503
1847
  if (cg.lazyField) {
1504
1848
  const lf = cg.lazyField;
1505
1849
  const has = cr[lf.field] != null && String(cr[lf.field]).trim() !== "";
@@ -1516,7 +1860,13 @@ function renderDataGrid(node) {
1516
1860
  }
1517
1861
  cells.push(cell);
1518
1862
  }
1519
- cbody.append(el("tr", {}, ...cells));
1863
+ // Child grids classify columns into the same mobile roles (including
1864
+ // "hidden") as the top-level grid, so they need the same per-row "More"
1865
+ // toggle — without it a mobile:{priority:"hidden"} child column would be
1866
+ // permanently unreachable on a narrow viewport (#268).
1867
+ const ctr = el("tr", {}, ...cells);
1868
+ if (chasHidden) ctr.append(mobileMoreCell(ctr));
1869
+ cbody.append(ctr);
1520
1870
  }
1521
1871
  } catch (e) {
1522
1872
  cbody.append(el("tr", {}, el("td", { colspan: cspan }, String(e.message || e))));
@@ -1553,7 +1903,7 @@ function renderDataGrid(node) {
1553
1903
  }
1554
1904
 
1555
1905
  function renderRow(row, sink) {
1556
- const cells = cols.map((c) => gridCell(c, row));
1906
+ const cells = cols.map((c, i) => gridCell(c, row, roles[i]));
1557
1907
  const key = rowKeyOf(row);
1558
1908
  let toggle = null;
1559
1909
  if (hasExtra) {
@@ -1568,7 +1918,12 @@ function renderDataGrid(node) {
1568
1918
  }
1569
1919
  cells.push(actionCell);
1570
1920
  }
1921
+ // Per-row "More" toggle: only when a mobile:{priority:"hidden"} hint drops
1922
+ // columns off the card. Appended after the <tr> is built (the toggle wires a
1923
+ // click against its own row) and shared with child grids via mobileMoreCell so
1924
+ // the reveal affordance and its aria state can't drift (#268).
1571
1925
  const tr = el("tr", {}, ...cells);
1926
+ if (hasHidden) tr.append(mobileMoreCell(tr));
1572
1927
  tbody.append(tr);
1573
1928
  // When grouping, the caller collects every <tr> this row produced (the data
1574
1929
  // row plus any detail row) so a group header can hide/show them as one unit.
@@ -1899,19 +2254,31 @@ function makeCollapsible(node, card) {
1899
2254
  return container;
1900
2255
  }
1901
2256
 
2257
+ // Tier-2 escape hatch (#268): a page may declare a distinct 'mobile' layout
2258
+ // variant — its own node list — for the rare case the derived card flip isn't
2259
+ // enough (reorder/omit sections, swap a dense grid for a summary card). It is
2260
+ // used ONLY on a narrow viewport; the default 'nodes' render everywhere else,
2261
+ // and anything malformed falls back to them, so the variant is purely additive.
2262
+ function pickNodes(doc) {
2263
+ if (isNarrow() && doc && doc.mobile && Array.isArray(doc.mobile.nodes)) return doc.mobile.nodes;
2264
+ return (doc && doc.nodes) || [];
2265
+ }
2266
+
1902
2267
  // Render the page named by the current hash route. A rail nav is lifted into a
1903
2268
  // left <aside> and the rest of the nodes flow into a content column; otherwise
1904
2269
  // (bar nav or none) nodes render in document order. Re-runs on every hashchange,
1905
2270
  // tearing down the previous page's grid polls first.
1906
2271
  async function renderPage() {
1907
2272
  teardown();
2273
+ const mySeq = ++renderSeq;
1908
2274
  const route = parseRoute();
1909
2275
  CURRENT = route.page;
1910
2276
  PARAM = route.param;
1911
2277
  try {
1912
2278
  const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
2279
+ if (mySeq !== renderSeq) return;
1913
2280
  document.title = (doc && doc.title) || "Urban App";
1914
- const nodes = (doc && doc.nodes) || [];
2281
+ const nodes = pickNodes(doc);
1915
2282
  const built = nodes.map((n) => ({ n, node: makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n)) }));
1916
2283
  const rail = built.find((b) => b.n.type === "nav" && b.n.props && b.n.props.variant === "rail");
1917
2284
  if (rail) {
@@ -1922,10 +2289,20 @@ async function renderPage() {
1922
2289
  root.replaceChildren(...built.map((b) => b.node));
1923
2290
  }
1924
2291
  } catch (e) {
2292
+ if (mySeq !== renderSeq) return;
1925
2293
  root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String((e && e.message) || e)));
1926
2294
  }
1927
2295
  }
1928
2296
  window.addEventListener("hashchange", renderPage);
2297
+ // Re-render when the viewport crosses the mobile breakpoint so a Tier-2 'mobile'
2298
+ // variant swaps in/out on rotation or a window resize (the grid card flip, nav
2299
+ // collapse and button stacking are pure CSS and need no re-render). teardown()
2300
+ // inside renderPage stops the outgoing page's grid polls first. Prefer the modern
2301
+ // addEventListener but fall back to the deprecated addListener so older Safari/iOS
2302
+ // (< 14), where MediaQueryList.addEventListener is unimplemented, still swaps the
2303
+ // Tier-2 variant on rotation/resize instead of silently failing to register.
2304
+ if (typeof MOBILE_MQ.addEventListener === "function") MOBILE_MQ.addEventListener("change", renderPage);
2305
+ else if (typeof MOBILE_MQ.addListener === "function") MOBILE_MQ.addListener(renderPage);
1929
2306
  renderPage();
1930
2307
  `;
1931
2308
  /** A tiny, dependency-free FNV-1a hash → 8 hex chars. Used to fingerprint the renderer
@@ -9,6 +9,7 @@
9
9
  import { presentFormIdentifier } from "../host.js";
10
10
  import { html, json, noContent, normalizeRoutePath } from "../router.js";
11
11
  import { mountActions } from "./actions.js";
12
+ import { mountAgent } from "./agent.js";
12
13
  import { mountApi } from "./api.js";
13
14
  import { mountPages } from "./pages.js";
14
15
  /** Escape HTML-significant characters before embedding a value in markup. */
@@ -283,6 +284,13 @@ export function mountSurfaces(ctx, app) {
283
284
  routes.push(...api.routes);
284
285
  enabled.push("api");
285
286
  }
287
+ // The app-scoped institutional-memory brief (ADR 0060 §2): `/app/agent` + `/app/agent.json`,
288
+ // the app-level mirror of the node `/agent`. Mounted unconditionally (before the generic pages
289
+ // routes, though its exact paths never collide) — the brief is a property of the app's models,
290
+ // not an opt-in surface, and reads through to the `urban gen`-derived artifacts.
291
+ const agent = mountAgent(ctx);
292
+ routes.push(...agent.routes);
293
+ enabled.push("agent@/app/agent");
286
294
  // The pages surface (the schema-driven page runtime) mounts its own routes.
287
295
  const pages = mountPages(ctx, app);
288
296
  if (pages.routes.length > 0) {