@nanobpm/urban 0.52.0 → 0.54.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 +666 -35
  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 +396 -5
  41. package/src/runtime/core/modules/pages.ts +667 -35
  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
@@ -484,6 +484,14 @@ function rendererShell(homePage: string, apiDocsPath?: string): string {
484
484
  // console, so the defaults below stand on their own: dark by default, light when
485
485
  // the OS asks for it and nothing has themed us. `--pc-*` are kept as thin
486
486
  // aliases so existing class rules re-resolve through the shared tokens.
487
+ // The single mobile breakpoint (max-width) for the urban responsive primitive
488
+ // (#268). ONE source of truth: it is interpolated into both the shell CSS
489
+ // `@media` query (which drives the pure-CSS card flip / nav collapse / button
490
+ // stacking) AND the runtime's `matchMedia` mode-switch (which only Tier-2's
491
+ // optional page-level `mobile` layout variant needs), so the two can never
492
+ // drift apart on where "narrow" begins.
493
+ const MOBILE_MAX_WIDTH = "640px";
494
+
487
495
  const RENDERER_CSS = `
488
496
  :root {
489
497
  color-scheme: dark;
@@ -579,6 +587,30 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
579
587
  .pc-count-badge { color:var(--nano-text); background:var(--nano-inset); border:1px solid var(--nano-edge); font-weight:600; }
580
588
  :root[data-appearance="light"] .pc-badge-warn { color:#fff; }
581
589
  @media (prefers-color-scheme: light) { :root:not([data-appearance]) .pc-badge-warn { color:#fff; } }
590
+ /* Pipeline / stepper cell (issue #265): a per-row, multi-stage horizontal
591
+ progress track rendered by pipelineCell. Only the track/stage chrome lives
592
+ here (the .pc-pipe classes); the active-stage badge and locus links reuse the
593
+ .pc-badge and .pc-link classes.
594
+ All colours come from the shared --nano-* tokens so light/dark themes work.
595
+ Stage treatments: done = filled/ok-tinted, active = lit accent, active+ok =
596
+ success (✓), active+failed = danger (✕), active+blocked = warn (⊘), upcoming =
597
+ faint/ghosted, skip (not-in-path) = dashed. */
598
+ .pc-pipe { display:flex; align-items:center; flex-wrap:wrap; gap:.15rem; font-size:.8rem; }
599
+ .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; }
600
+ .pc-pipe-label { color:inherit; }
601
+ .pc-pipe-mark { font-weight:700; line-height:1; }
602
+ .pc-pipe-conn { flex:0 0 auto; width:.9rem; height:2px; border-radius:1px; background:var(--nano-edge); }
603
+ .pc-pipe-conn.filled { background:var(--nano-ok); }
604
+ .pc-pipe-done { color:var(--nano-text); border-color:var(--nano-ok); background:var(--nano-hover); }
605
+ .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; }
606
+ .pc-pipe-active.pc-pipe-ok { border-color:var(--nano-ok); box-shadow:0 0 0 1px var(--nano-ok) inset; }
607
+ .pc-pipe-active.pc-pipe-ok .pc-pipe-mark { color:var(--nano-ok); }
608
+ .pc-pipe-active.pc-pipe-failed { border-color:var(--nano-danger); box-shadow:0 0 0 1px var(--nano-danger) inset; }
609
+ .pc-pipe-active.pc-pipe-failed .pc-pipe-mark { color:var(--nano-danger); }
610
+ .pc-pipe-active.pc-pipe-blocked { border-color:var(--nano-warn); box-shadow:0 0 0 1px var(--nano-warn) inset; }
611
+ .pc-pipe-active.pc-pipe-blocked .pc-pipe-mark { color:var(--nano-warn); }
612
+ .pc-pipe-upcoming { color:var(--nano-text-faint); border-color:var(--nano-edge); opacity:.75; }
613
+ .pc-pipe-skip { color:var(--nano-text-faint); border-style:dashed; opacity:.6; }
582
614
  .pc-child { margin:.6rem 0; }
583
615
  .pc-child-title { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); margin-bottom:.25rem; }
584
616
  .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; }
@@ -595,6 +627,30 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
595
627
  .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; }
596
628
  .pc-group-toggle:hover { background:var(--nano-hover); }
597
629
  .pc-group-title { font-size:.9rem; }
630
+ /* Data-bound prose/markdown list (renderProse, #274). Long-form narrative
631
+ records — plan-review findings, coordination notes, escalation bodies — read
632
+ as a stacked, full-width document at a comfortable measure (~66ch) instead of
633
+ being jammed into a grid column. Each item stacks a small header over a
634
+ markdown-rendered body; the body max-width is set inline per node (props.measure)
635
+ with this rule as the no-JS fallback. */
636
+ .pc-prose-list { display:flex; flex-direction:column; gap:1.1rem; }
637
+ .pc-prose-item { border-top:1px solid var(--nano-edge); padding-top:.9rem; }
638
+ .pc-prose-item:first-child { border-top:0; padding-top:0; }
639
+ .pc-prose-head { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); text-transform:uppercase; letter-spacing:.03em; margin:0 0 .35rem; }
640
+ .pc-prose-empty { color:var(--nano-text-faint); }
641
+ .pc-prose-body { max-width:66ch; }
642
+ .pc-prose-body > :first-child { margin-top:0; }
643
+ .pc-prose-body > :last-child { margin-bottom:0; }
644
+ .pc-md-p { margin:.55rem 0; overflow-wrap:anywhere; }
645
+ .pc-md-h { font-size:1.05rem; font-weight:650; margin:.95rem 0 .4rem; }
646
+ .pc-md-list { margin:.45rem 0; padding-left:1.35rem; }
647
+ .pc-md-list li { margin:.15rem 0; }
648
+ .pc-md-quote { margin:.6rem 0; padding:.1rem .9rem; border-left:3px solid var(--nano-edge-strong); color:var(--nano-text-muted); }
649
+ .pc-md-pre { background:var(--nano-inset); border:1px solid var(--nano-edge); border-radius:.4rem; padding:.7rem .8rem; overflow:auto; margin:.6rem 0; }
650
+ .pc-md-pre code { font:.82rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--nano-text); white-space:pre; }
651
+ .pc-md-code { background:var(--nano-inset); border-radius:.3rem; padding:.05rem .3rem; font:.85em ui-monospace,SFMono-Regular,Menlo,monospace; }
652
+ .pc-md-hr { border:0; border-top:1px solid var(--nano-edge); margin:1rem 0; }
653
+ .pc-prose-body a { color:var(--nano-accent-strong); }
598
654
  /* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
599
655
  .pc-buttonrow { margin:1rem 0; }
600
656
  .pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
@@ -626,16 +682,97 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
626
682
  .pc-nav-link.active { background:var(--nano-accent); color:var(--nano-on-accent); }
627
683
  .pc-nav-icon { line-height:1; }
628
684
  .pc-nav-empty { color:var(--nano-text-faint); font-size:.85rem; padding:.4rem .7rem; }
685
+ /* The per-row mobile "More" toggle (revealing mobile:{priority:"hidden"} columns)
686
+ only exists for the card view — hidden on the desktop table, shown by the
687
+ mobile @media below. Base-hidden here so a grid renders byte-for-byte
688
+ unchanged on desktop even when it carries hidden-column hints. */
689
+ .pc-mcard-toggle { display:none; }
690
+ /* ── Urban responsive / mobile primitive (#268) ──────────────────────────
691
+ Below MOBILE_MAX_WIDTH every app adapts to a phone with ZERO page-schema
692
+ change. The dense dataGrid — the one primitive CSS alone can't reflow — flips
693
+ from a wide <table> to a stacked card list purely from the per-cell
694
+ 'data-label' the renderer already stamps on (no JS branching): thead is
695
+ hidden, each <tr> becomes a bordered card and each <td> a full-width
696
+ label:value line whose label is the column header. The nav collapses to a
697
+ thumb-reachable scrollable bottom bar, buttons/form actions go full-width
698
+ single column, and text reflows on its own. The breakpoint is interpolated
699
+ from the shared MOBILE_MAX_WIDTH constant so it can't drift from the runtime's
700
+ matchMedia. */
701
+ @media (max-width:${MOBILE_MAX_WIDTH}) {
702
+ body { padding:1rem; }
703
+ /* dataGrid → card list. Once cells are display:block the fixed colgroup width
704
+ is irrelevant, so a wide table stops overflowing the phone. table-layout is
705
+ reset to auto so the block cells size to content. */
706
+ table.pc-grid { table-layout:auto; }
707
+ table.pc-grid, table.pc-grid tbody, table.pc-grid tr, table.pc-grid td { display:block; width:auto; }
708
+ table.pc-grid colgroup, table.pc-grid thead { display:none; }
709
+ table.pc-grid tr { border:1px solid var(--nano-edge); border-radius:.6rem; padding:.55rem .75rem; margin:.6rem 0; background:var(--nano-panel); }
710
+ table.pc-grid td { border-bottom:0; padding:.15rem 0; display:flex; gap:.75rem; justify-content:space-between; align-items:baseline; }
711
+ /* label:value — the column header (data-label) prefixes the value as a muted
712
+ caption on the same line. Suppressed for the title, chip, actions and any
713
+ colspan (group/empty/error) cells below. */
714
+ 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; }
715
+ /* The derived (or mobile:{priority:"primary"}-hinted) card title leads the
716
+ card: larger, full width, no label caption. */
717
+ table.pc-grid td.pc-mcell-primary { display:block; font-size:1rem; font-weight:650; padding-top:.15rem; }
718
+ table.pc-grid td.pc-mcell-primary::before { content:none; }
719
+ /* A badge column becomes a left-aligned status chip with no caption. */
720
+ table.pc-grid td.pc-mcell-chip { justify-content:flex-start; }
721
+ table.pc-grid td.pc-mcell-chip::before { content:none; }
722
+ /* An empty badge cell (row value blank) still carries the chip class but has no
723
+ child content; without this it renders as a blank flex row (padding/gap) that
724
+ leaves a stray empty line in the card. Collapse those empty chip cells (#268). */
725
+ table.pc-grid td.pc-mcell-chip:empty { display:none; }
726
+ /* Low-value (mobile:{priority:"hidden"}) columns drop off the card until the
727
+ row's "More" toggle opens them; on desktop they render as normal columns. */
728
+ table.pc-grid td.pc-mcell-hidden { display:none; }
729
+ table.pc-grid tr.pc-open td.pc-mcell-hidden { display:flex; }
730
+ .pc-mcard-toggle { display:block; padding:.3rem 0 0; }
731
+ .pc-mcard-toggle .pc-more { width:100%; margin-right:0; }
732
+ /* Row actions stack full-width under the card body. */
733
+ table.pc-grid td.pc-row-actions { display:flex; flex-wrap:wrap; gap:.4rem; text-align:left; padding-top:.45rem; white-space:normal; }
734
+ table.pc-grid td.pc-row-actions::before { content:none; }
735
+ table.pc-grid td.pc-row-actions .pc-btn { flex:1 1 auto; margin-right:0; }
736
+ /* Group-header + empty/error rows span the full card width with no caption. */
737
+ table.pc-grid td[colspan] { display:block; }
738
+ table.pc-grid td[colspan]::before { content:none; }
739
+ .pc-group-header td { padding:0; }
740
+ /* Nav → horizontally-scrollable bar (bar) / inline stack (rail). The bar keeps
741
+ its normal top-of-flow position and scrolls sideways when the pages overflow;
742
+ an opt-in sticky bar stays pinned to the top via the desktop .pc-sticky rule
743
+ (top:0). A rail drops its fixed side column and flows inline. */
744
+ .pc-layout { display:block; }
745
+ .pc-rail-wrap { position:static; flex-basis:auto; width:auto; margin:0 0 1rem; }
746
+ .pc-rail .pc-nav-items { flex-direction:row; flex-wrap:wrap; }
747
+ .pc-bar { flex-wrap:nowrap; overflow-x:auto; }
748
+ .pc-bar.pc-sticky { position:sticky; }
749
+ .pc-bar .pc-nav-items { flex-wrap:nowrap; }
750
+ .pc-nav-link { white-space:nowrap; }
751
+ .pc-tabs { overflow-x:auto; }
752
+ /* Buttons / form actions go full-width single-column. */
753
+ .pc-buttonrow .pc-btn, .pc-card .pc-btn { width:100%; }
754
+ .pc-modal { max-width:100%; }
755
+ .pc-modal-actions { flex-direction:column; }
756
+ .pc-modal-actions .pc-btn { width:100%; }
757
+ }
629
758
  `;
630
759
 
631
760
  // The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
632
761
  // /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
633
- // fetches the home page's page.json and renders text / actionForm / dataGrid nodes,
634
- // wiring actionForm → /app/actions/start and dataGrid → /app/data (with a refresh).
762
+ // fetches the home page's page.json and renders text / actionForm / dataGrid / prose
763
+ // nodes, wiring actionForm → /app/actions/start and dataGrid/prose → /app/data (with a refresh).
635
764
  const RENDERER_JS = String.raw`
636
765
  const root = document.getElementById("page");
637
766
  const HOME = root.dataset.home || "home";
638
767
 
768
+ // Single mobile breakpoint, interpolated from the same MOBILE_MAX_WIDTH constant
769
+ // as the shell CSS @media so the JS mode-switch and the pure-CSS card flip can
770
+ // never drift on where "narrow" begins (#268). Only Tier-2 (an optional
771
+ // page-level 'mobile' layout variant) needs this JS switch — the dataGrid card
772
+ // flip, nav collapse and button/form stacking are all pure CSS + data-label.
773
+ const MOBILE_MQ = window.matchMedia("(max-width:${MOBILE_MAX_WIDTH})");
774
+ function isNarrow() { return MOBILE_MQ.matches; }
775
+
639
776
  // ── Multi-page routing (hash-based, reverse-proxy safe) ──────────────────
640
777
  // Pages are selected by the URL fragment (#/<page>) so navigation never hits
641
778
  // the server and works identically at the origin root and under the Nano
@@ -674,6 +811,12 @@ let CURRENT = currentPage();
674
811
  // this) and to {{param}} text interpolation, so one page template can scope
675
812
  // every section to a selected entity (e.g. an epic's plan_key).
676
813
  let PARAM = parseRoute().param;
814
+ // Monotonic render token. renderPage() is async and fires from both hashchange
815
+ // and the matchMedia viewport-crossing handler, so two invocations can overlap
816
+ // and their fetches resolve out of order. Each render captures the token it
817
+ // bumped to; after its await it only touches the DOM if it is still the latest,
818
+ // so a stale navigation/viewport render can't overwrite a newer one.
819
+ let renderSeq = 0;
677
820
 
678
821
  // Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
679
822
  // listener here; navigating to another page runs every disposer before the new
@@ -1058,7 +1201,45 @@ function renderButton(node) {
1058
1201
  // width/weight — see buildColgroup — to give the clip a bounded column). The
1059
1202
  // subtitle is ALWAYS plain DOM text, never an href source. With none of these
1060
1203
  // set the cell is a single inline node exactly as before (see cellTd).
1061
- function gridCell(col, row) {
1204
+ // Build an in-app hash route to another page, scoped to a row key: #/<page>/<key>.
1205
+ // The page id is validated against the safe-id charset and the key must be
1206
+ // non-empty (after trimming); either failing yields "" (no link). The key is
1207
+ // URL-encoded into the route's param tail, which parseRoute() decodes back
1208
+ // verbatim, so row data can't smuggle a path or scheme. SINGLE source of truth
1209
+ // for the page-link href so the grid's link cell and the pipeline locus link
1210
+ // (issue #265) can't drift on how the route is built.
1211
+ function pageHashHref(page, key) {
1212
+ const keyStr = key == null ? "" : String(key).trim();
1213
+ if (!safePageId(page) || keyStr === "") return "";
1214
+ return "#/" + encodeURIComponent(page) + "/" + encodeURIComponent(keyStr);
1215
+ }
1216
+
1217
+ // Coerce a not-in-path value (issue #265) into a Set of stage keys. Accepts an
1218
+ // array of keys or a comma/whitespace-separated string; null/undefined → empty
1219
+ // set. Used to mark the stages a given row skips (dashed/omitted).
1220
+ function toStageSet(v) {
1221
+ // Trim + drop-empties on BOTH inputs so array entries with stray whitespace
1222
+ // (e.g. from CSV/JSON munging) match stages[].key just like the string path.
1223
+ if (Array.isArray(v)) return new Set(v.map((s) => String(s).trim()).filter((s) => s !== ""));
1224
+ if (v == null) return new Set();
1225
+ return new Set(String(v).split(/[\s,]+/).filter((s) => s !== ""));
1226
+ }
1227
+
1228
+ function gridCell(col, row, role) {
1229
+ // Mobile card attributes stamped on every td so a dataGrid can flip to a
1230
+ // stacked card list below the breakpoint with no JS branching (#268):
1231
+ // 'data-label' drives the pure-CSS label:value line (the column header, or a
1232
+ // mobile:{label} override), and the role class picks the card region (title /
1233
+ // status chip / secondary field / hidden). The role is derived by convention
1234
+ // (classifyColumns) and refinable per-column via col.mobile.priority.
1235
+ const mrole = role || "secondary";
1236
+ const mlabel =
1237
+ col.mobile && typeof col.mobile.label === "string"
1238
+ ? col.mobile.label
1239
+ : col.header != null
1240
+ ? String(col.header)
1241
+ : String(col.field == null ? "" : col.field);
1242
+ const mob = { class: "pc-mcell pc-mcell-" + mrole, "data-label": mlabel };
1062
1243
  // Raw single-field value: drives the badge presence gate + tooltip and any
1063
1244
  // field-derived defaults. Kept separate from the (possibly templated) display
1064
1245
  // text so a template never re-lights or blanks a badge.
@@ -1077,13 +1258,17 @@ function gridCell(col, row) {
1077
1258
  // field text is also mirrored into aria-label so assistive tech announces
1078
1259
  // the incident/status text (the visible "1" glyph alone is not meaningful).
1079
1260
  if (col.badge) {
1080
- if (rawText.trim() === "") return el("td", {});
1261
+ // Even when blank, stamp data-label (the "every td carries data-label"
1262
+ // invariant): the cell still collapses on mobile via the pc-mcell-chip:empty
1263
+ // rule (attributes don't defeat :empty), so this keeps empty and non-empty
1264
+ // badge cells consistent without re-lighting the label.
1265
+ if (rawText.trim() === "") return el("td", { class: "pc-mcell pc-mcell-chip", "data-label": mlabel });
1081
1266
  const t = col.badge.tone;
1082
1267
  const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
1083
1268
  const label = col.badge.label == null ? "1" : String(col.badge.label);
1084
1269
  return el(
1085
1270
  "td",
1086
- {},
1271
+ mob,
1087
1272
  el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
1088
1273
  );
1089
1274
  }
@@ -1096,6 +1281,14 @@ function gridCell(col, row) {
1096
1281
  col.subtitleField != null && row[col.subtitleField] != null ? String(row[col.subtitleField]) : "";
1097
1282
  const subText = typeof col.subtitleTemplate === "string" ? interpTemplate(col.subtitleTemplate, row) : subRaw;
1098
1283
  const truncate = col.truncate === true;
1284
+ // pipeline (issue #265): a dataGrid column declaring kind:"pipeline" renders
1285
+ // the ordered stages as a per-row horizontal progress track (upstream-filled
1286
+ // → active-lit → downstream-ghosted, not-in-path dashed). It owns the whole
1287
+ // cell, so it returns here before the link chain below. Gated purely on the
1288
+ // discriminator so every existing column mode is untouched when it is absent;
1289
+ // unknown/missing pipeline config degrades to plain cell text (never throws),
1290
+ // exactly as an unrecognised link.kind falls back today.
1291
+ if (col.kind === "pipeline") return pipelineCell(col, row, text, subText, truncate, mob);
1099
1292
  // Primary inline content: an anchor for a valid link mode (checked in the same
1100
1293
  // badge < linkField < link(page) < link(processExplorer) precedence as before),
1101
1294
  // else the plain display text. Each link mode only claims the cell while
@@ -1109,16 +1302,13 @@ function gridCell(col, row) {
1109
1302
  }
1110
1303
  }
1111
1304
  if (primary === text && col.link && col.link.kind === "page" && col.link.page && col.link.keyField) {
1112
- // An in-app link to another page, scoped to this row: #/<page>/<key>. The
1113
- // key (e.g. a plan_key "owner/repo#123") is URL-encoded into the route's
1114
- // param tail, which parseRoute() decodes back verbatim; the page id is
1305
+ // An in-app link to another page, scoped to this row: #/<page>/<key>. Built
1306
+ // via pageHashHref (the single source of truth for the route): the page id is
1115
1307
  // validated against the safe-id charset and the key must be non-empty, so a
1116
1308
  // blank/keyless cell stays plain text. Navigation is a pure hash change
1117
1309
  // (no new tab, no server hit) — the SPA router re-renders in place.
1118
- const key = row[col.link.keyField];
1119
- const keyStr = key == null ? "" : String(key).trim();
1120
- if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
1121
- const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
1310
+ const href = pageHashHref(col.link.page, row[col.link.keyField]);
1311
+ if (text !== "" && href !== "") {
1122
1312
  primary = el("a", { class: "pc-link", href }, text);
1123
1313
  }
1124
1314
  }
@@ -1145,7 +1335,7 @@ function gridCell(col, row) {
1145
1335
  }, text);
1146
1336
  }
1147
1337
  }
1148
- return cellTd(primary, text, subText, truncate);
1338
+ return cellTd(primary, text, subText, truncate, mob);
1149
1339
  }
1150
1340
 
1151
1341
  // Compose a grid <td> from its primary inline content (a string or an anchor),
@@ -1160,9 +1350,12 @@ function gridCell(col, row) {
1160
1350
  // aria-label= are ALSO mirrored onto it: HTML does not inherit those to
1161
1351
  // descendants, so hovering the link text or naming the anchor for assistive tech
1162
1352
  // would otherwise miss the full value the wrapper carries. The subtitle is set as
1163
- // DOM text only — never an href.
1164
- function cellTd(primary, primaryText, subText, truncate) {
1165
- if (subText === "" && !truncate) return el("td", {}, primary);
1353
+ // DOM text only — never an href. 'tdAttrs' (the mobile card attrs: data-label +
1354
+ // role class, see gridCell) is applied to whichever <td> shape is returned so
1355
+ // the CSS-only card flip labels every cell identically (#268).
1356
+ function cellTd(primary, primaryText, subText, truncate, tdAttrs) {
1357
+ const td = tdAttrs || {};
1358
+ if (subText === "" && !truncate) return el("td", td, primary);
1166
1359
  const mainAttrs = { class: "pc-cell-main" + (truncate ? " pc-truncate" : "") };
1167
1360
  if (truncate && primaryText !== "") {
1168
1361
  mainAttrs.title = primaryText; mainAttrs["aria-label"] = primaryText;
@@ -1171,10 +1364,178 @@ function cellTd(primary, primaryText, subText, truncate) {
1171
1364
  }
1172
1365
  }
1173
1366
  const main = el("div", mainAttrs, primary);
1174
- if (subText === "") return el("td", {}, main);
1367
+ if (subText === "") return el("td", td, main);
1175
1368
  const subAttrs = { class: "pc-cell-sub" + (truncate ? " pc-truncate" : "") };
1176
1369
  if (truncate) { subAttrs.title = subText; subAttrs["aria-label"] = subText; }
1177
- return el("td", {}, main, el("div", subAttrs, subText));
1370
+ return el("td", td, main, el("div", subAttrs, subText));
1371
+ }
1372
+
1373
+ // Classify each grid column into a mobile-card region so a dataGrid flips to a
1374
+ // stacked card list below the breakpoint with ZERO page-schema change (#268).
1375
+ // Convention (Tier 0): the first non-badge column is the card title (primary), a
1376
+ // badge column is a status chip, and everything else is a secondary label:value
1377
+ // row. Tier 1 refines it — a column may carry mobile:{priority:"primary" |
1378
+ // "secondary" | "hidden"}: an explicit "primary" suppresses the auto title pick
1379
+ // so a later column can lead the card, "hidden" drops the column off the card
1380
+ // (behind the row's "More" toggle), and "secondary" forces a plain field row.
1381
+ function classifyColumns(cols) {
1382
+ const list = cols || [];
1383
+ const explicitPrimary = list.some((c) => c && c.mobile && c.mobile.priority === "primary");
1384
+ let titleTaken = explicitPrimary;
1385
+ return list.map((col) => {
1386
+ const pr = col && col.mobile ? col.mobile.priority : null;
1387
+ if (pr === "primary") return "primary";
1388
+ if (pr === "secondary") return "secondary";
1389
+ if (pr === "hidden") return "hidden";
1390
+ if (col && col.badge) return "chip";
1391
+ if (!titleTaken) { titleTaken = true; return "primary"; }
1392
+ return "secondary";
1393
+ });
1394
+ }
1395
+
1396
+ // Per-row mobile "More" toggle cell: reveals the mobile:{priority:"hidden"} columns
1397
+ // a narrow viewport drops off the card. The cell is base-hidden (display:none) so
1398
+ // the desktop table is byte-for-byte unchanged; clicking flips .pc-open on the row,
1399
+ // swaps the label, and updates aria-expanded so the reveal state is exposed to
1400
+ // assistive tech (#268). One implementation shared by the top-level grid and child
1401
+ // grids so the affordance — and its accessibility — can't drift between them.
1402
+ function mobileMoreCell(tr) {
1403
+ const moreBtn = el("button",
1404
+ { class: "pc-btn pc-btn-sm pc-btn-ghost pc-more", type: "button", "aria-expanded": "false" }, "More");
1405
+ moreBtn.addEventListener("click", () => {
1406
+ const open = tr.classList.toggle("pc-open");
1407
+ moreBtn.textContent = open ? "Less" : "More";
1408
+ moreBtn.setAttribute("aria-expanded", String(open));
1409
+ });
1410
+ return el("td", { class: "pc-mcard-toggle" }, moreBtn);
1411
+ }
1412
+
1413
+ // Pipeline / stepper cell (issue #265) — a dataGrid column with kind:"pipeline"
1414
+ // renders a per-row, multi-stage horizontal progress track. It is RENDER-ONLY:
1415
+ // stage state is read straight from the row fields the app supplies; no
1416
+ // app-specific stage derivation lives here. Column config (all fields are the
1417
+ // NAMES of row fields, mirroring the issue sketch):
1418
+ // { kind:"pipeline",
1419
+ // stages:[{key,label},...], // ordered stage definitions (required)
1420
+ // activeField:"stage", // row field → the current stage's key
1421
+ // stateField:"stage_state", // row field → ok | active | failed | blocked
1422
+ // badgeField:"attention", // optional row field → badge text shown on the active stage
1423
+ // notInPathField:"skipped", // optional row field → stages this row skips
1424
+ // notInPath:["converging"], // static fallback used only when no field given
1425
+ // locus:{ field:"pr_key", stage?:"<key>",
1426
+ // link:{ kind:"page", page:"home" } } } // optional out-link
1427
+ // Rendering: stages BEFORE the active one are filled/completed, the active stage
1428
+ // is lit (its treatment driven by stateField — success ✓ vs failure ✕ vs blocked
1429
+ // ⊘ are made unmistakably distinct), in-path stages AFTER it are ghosted, and any
1430
+ // stage in the row's not-in-path set is dashed. Reuses the .pc-badge and .pc-link
1431
+ // classes and the shared pageHashHref route builder — no parallel implementations. Unknown or
1432
+ // missing config (no stages array) degrades gracefully to plain cell text.
1433
+ function pipelineCell(col, row, text, subText, truncate, tdAttrs) {
1434
+ // Drop null/undefined entries defensively: a schema with a hole must still
1435
+ // render (never throws on s.key) — same graceful-degradation goal as below.
1436
+ const stages = Array.isArray(col.stages) ? col.stages.filter((s) => s != null) : [];
1437
+ // Graceful fallback: an unrecognised/empty pipeline config renders plain text,
1438
+ // exactly like an unknown link.kind — never throws.
1439
+ if (stages.length === 0) return cellTd(text, text, subText, truncate, tdAttrs);
1440
+ // The current stage's key, matched against stages[].key to find the active index.
1441
+ // The row value is trimmed for consistency with the other row-derived keys
1442
+ // (pageHashHref, toStageSet), so a whitespace-padded value still matches its stage
1443
+ // instead of leaving the whole track rendered as upcoming.
1444
+ // Stage keys identify a single stage, so stop at the first match: the result is
1445
+ // deterministic even if a config accidentally repeats a key, and we avoid scanning
1446
+ // the remaining stages on every row.
1447
+ const activeKey = col.activeField != null && row[col.activeField] != null ? String(row[col.activeField]).trim() : "";
1448
+ let activeIdx = -1;
1449
+ for (let i = 0; i < stages.length; i++) {
1450
+ if (String(stages[i].key) === activeKey) {
1451
+ activeIdx = i;
1452
+ break;
1453
+ }
1454
+ }
1455
+ // The active stage's state drives its lit/failed/blocked treatment. Anything
1456
+ // outside the allow-list (or absent) is treated as an in-progress "active".
1457
+ const stRaw = col.stateField != null && row[col.stateField] != null ? String(row[col.stateField]) : "";
1458
+ const state = stRaw === "ok" || stRaw === "failed" || stRaw === "blocked" ? stRaw : "active";
1459
+ // Not-in-path (skipped) stages: driven per-row from notInPathField when given,
1460
+ // else the static notInPath config (per the issue: "per row, from a field").
1461
+ const skip =
1462
+ col.notInPathField != null && row[col.notInPathField] != null
1463
+ ? toStageSet(row[col.notInPathField])
1464
+ : toStageSet(col.notInPath);
1465
+ // Optional active-stage badge (escalation/attention → warn, blocked/failure →
1466
+ // danger), reusing .pc-badge tone classes — no new badge styles. The badge text
1467
+ // is a single row-level value (row[badgeField]) shown only on the active stage.
1468
+ const badgeText = col.badgeField != null && row[col.badgeField] != null ? String(row[col.badgeField]) : "";
1469
+ const badgeTone = state === "failed" || state === "blocked" ? "danger" : "warn";
1470
+ // Optional locus out-link: the relevant stage (default the active one, or
1471
+ // locus.stage by key) becomes an in-app page link when the row's locus key is
1472
+ // non-empty. Href comes from the shared pageHashHref builder (safePageId guard,
1473
+ // encodeURIComponent) — a blank key yields "" so the stage stays plain.
1474
+ const locus = col.locus;
1475
+ const locusKey = locus && locus.field != null && row[locus.field] != null ? String(row[locus.field]) : "";
1476
+ const locusHref =
1477
+ locus && locus.link && locus.link.kind === "page" && locus.link.page ? pageHashHref(locus.link.page, locusKey) : "";
1478
+ const track = el("div", {
1479
+ class: "pc-pipe",
1480
+ role: "list",
1481
+ "aria-label": "Progress" + (activeIdx >= 0 ? ": " + labelOf(stages[activeIdx]) : ""),
1482
+ });
1483
+ for (let i = 0; i < stages.length; i++) {
1484
+ const s = stages[i];
1485
+ // Active wins over skip: if the active stage's key is also in the not-in-path
1486
+ // set, it must still render as the current step (active styling + aria-current),
1487
+ // never as skipped. Excluding activeIdx here keeps that precedence consistent
1488
+ // across the stage class, the aria-current mark, and the connector fill below.
1489
+ const skipped = i !== activeIdx && skip.has(String(s.key));
1490
+ // Connector between adjacent stages; filled up to and including the active
1491
+ // stage, but never across a not-in-path stage — a skipped predecessor breaks
1492
+ // the fill so we don't visually imply it was on the path.
1493
+ if (i > 0) {
1494
+ const filled =
1495
+ activeIdx >= 0 && i <= activeIdx && !skipped && !skip.has(String(stages[i - 1].key)) ? " filled" : "";
1496
+ track.append(el("span", { class: "pc-pipe-conn" + filled, "aria-hidden": "true" }));
1497
+ }
1498
+ let cls;
1499
+ let word;
1500
+ let glyph = "";
1501
+ let current = false;
1502
+ if (skipped) {
1503
+ cls = "pc-pipe-skip"; word = "skipped";
1504
+ } else if (activeIdx >= 0 && i < activeIdx) {
1505
+ cls = "pc-pipe-done"; word = "completed";
1506
+ } else if (i === activeIdx) {
1507
+ current = true;
1508
+ if (state === "failed") { cls = "pc-pipe-active pc-pipe-failed"; word = "failed"; glyph = "\u2715"; }
1509
+ else if (state === "blocked") { cls = "pc-pipe-active pc-pipe-blocked"; word = "blocked"; glyph = "\u2298"; }
1510
+ else if (state === "ok") { cls = "pc-pipe-active pc-pipe-ok"; word = "done"; glyph = "\u2713"; }
1511
+ else { cls = "pc-pipe-active"; word = "current"; }
1512
+ } else {
1513
+ cls = "pc-pipe-upcoming"; word = "upcoming";
1514
+ }
1515
+ const isLocus = locusHref !== "" && (locus.stage != null ? String(s.key) === String(locus.stage) : i === activeIdx);
1516
+ const label = labelOf(s);
1517
+ const labelNode = isLocus
1518
+ ? el("a", { class: "pc-link pc-pipe-label", href: locusHref }, label)
1519
+ : el("span", { class: "pc-pipe-label" }, label);
1520
+ const attrs = { class: "pc-pipe-stage " + cls, role: "listitem", "aria-label": label + " \u2014 " + word };
1521
+ if (current) attrs["aria-current"] = "step";
1522
+ const stage = el("span", attrs, labelNode);
1523
+ if (glyph !== "") stage.append(el("span", { class: "pc-pipe-mark", "aria-hidden": "true" }, glyph));
1524
+ if (current && badgeText.trim() !== "") {
1525
+ stage.append(el("span", { class: "pc-badge pc-badge-" + badgeTone, title: badgeText, "aria-label": badgeText }, badgeText));
1526
+ }
1527
+ track.append(stage);
1528
+ }
1529
+ // The mobile card attrs (data-label + role class, #268) stamped by gridCell are
1530
+ // merged onto the pipeline <td> too, alongside its own pc-pipe-cell class, so a
1531
+ // pipeline column labels and classifies identically under the CSS-only card flip.
1532
+ const cellAttrs = { ...(tdAttrs || {}), class: (tdAttrs && tdAttrs.class ? tdAttrs.class + " " : "") + "pc-pipe-cell" };
1533
+ return el("td", cellAttrs, track);
1534
+ }
1535
+
1536
+ // A stage's visible label: its declared label, else its key as a fallback.
1537
+ function labelOf(stage) {
1538
+ return stage.label == null ? String(stage.key) : String(stage.label);
1178
1539
  }
1179
1540
 
1180
1541
  // Parse a "<number>%" width into its numeric percentage, or null if the string
@@ -1398,6 +1759,248 @@ function renderActionForm(node) {
1398
1759
  // Single source of truth for both the initial render and every refresh below.
1399
1760
  function rowCountLabel(n) { return n + (n === 1 ? " row" : " rows"); }
1400
1761
 
1762
+ // Build the /app/data/<source>/<table> URL a data-bound list renderer fetches,
1763
+ // threading the whitelisted where/order query params. Shared by dataGrid and the
1764
+ // prose renderer so the two can't drift on how a filter/orderBy is encoded. A
1765
+ // filter is { field, in:[…] } (IN set), { field, eqParam:true } (bound to the
1766
+ // current route PARAM — "show the selected entity's rows"), or { field, eq } (a
1767
+ // literal equals); orderBy is { field, dir }. The source/table/field names are
1768
+ // server-side whitelisted (the /app/data route rejects unknown columns).
1769
+ function dataUrl(source, tbl, filters, order) {
1770
+ let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
1771
+ const qs = [];
1772
+ for (const f of filters || []) {
1773
+ if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1774
+ else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1775
+ else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1776
+ }
1777
+ if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
1778
+ return qs.length ? u + "?" + qs.join("&") : u;
1779
+ }
1780
+
1781
+ // ── Minimal, safe Markdown → DOM (no innerHTML) — used by the prose renderer ──
1782
+ // A dependency-free CommonMark subset. It NEVER sets innerHTML: every block and
1783
+ // inline token is materialised as a known element via el(), with text set through
1784
+ // textContent / createTextNode — so a record's body field may carry markdown
1785
+ // WITHOUT ever becoming an HTML/script injection vector (the same text-only
1786
+ // guarantee interpTemplate gives a grid cell). Supported: ATX headings
1787
+ // (#..######), fenced + inline code, bold (**/__), italic (*/_), links
1788
+ // [text](url) (http/https/mailto only — every other scheme degrades to its plain
1789
+ // label), unordered (-,*,+) and ordered (1. / 1)) lists, blockquotes (>),
1790
+ // thematic breaks (---), and blank-line-separated paragraphs. The backtick used
1791
+ // for code spans/fences is built from a char code so this source stays inside the
1792
+ // String.raw template literal (which is itself delimited by a backtick).
1793
+ const MD_BACKTICK = String.fromCharCode(96);
1794
+ function mdIsWordChar(c) { return typeof c === "string" && /[0-9A-Za-z]/.test(c); }
1795
+ // Only http(s)/mailto hrefs become anchors; anything else (javascript:, data:,
1796
+ // vbscript:, a bare relative path, …) returns "" so the caller renders the label
1797
+ // as plain text rather than a clickable, potentially-hostile link.
1798
+ function mdSafeHref(href) {
1799
+ const h = String(href == null ? "" : href).trim();
1800
+ return /^(?:https?:|mailto:)/i.test(h) ? h : "";
1801
+ }
1802
+ // Find the closing run of 'marker' at or after 'from'. For underscore emphasis the
1803
+ // close must not be intra-word (a following alphanumeric means snake_case, not a
1804
+ // delimiter), so keep scanning past such runs; -1 when there is no valid close.
1805
+ function mdFindClose(s, marker, from, underscore) {
1806
+ let j = from;
1807
+ while (true) {
1808
+ const k = s.indexOf(marker, j);
1809
+ if (k < 0) return -1;
1810
+ if (!underscore || !mdIsWordChar(s[k + marker.length])) return k;
1811
+ j = k + marker.length;
1812
+ }
1813
+ }
1814
+ function mdInline(text) {
1815
+ const s = String(text == null ? "" : text);
1816
+ const nodes = [];
1817
+ let buf = "";
1818
+ const flush = () => { if (buf) { nodes.push(document.createTextNode(buf)); buf = ""; } };
1819
+ let i = 0;
1820
+ while (i < s.length) {
1821
+ const ch = s[i];
1822
+ // Backslash escape: the next char is emitted literally, never as a delimiter.
1823
+ if (ch === "\\" && i + 1 < s.length) { buf += s[i + 1]; i += 2; continue; }
1824
+ // Inline code span — verbatim, no nested inline parsing.
1825
+ if (ch === MD_BACKTICK) {
1826
+ const end = s.indexOf(MD_BACKTICK, i + 1);
1827
+ if (end > i) { flush(); nodes.push(el("code", { class: "pc-md-code" }, s.slice(i + 1, end))); i = end + 1; continue; }
1828
+ }
1829
+ // Strong (** or __). Underscore only when it opens on a word boundary so an
1830
+ // identifier like a__b doesn't get mangled into emphasis.
1831
+ if ((ch === "*" || ch === "_") && s[i + 1] === ch) {
1832
+ const underscore = ch === "_";
1833
+ if (!underscore || !mdIsWordChar(s[i - 1])) {
1834
+ const marker = ch + ch;
1835
+ const end = mdFindClose(s, marker, i + 2, underscore);
1836
+ if (end > i + 1) { flush(); nodes.push(el("strong", {}, ...mdInline(s.slice(i + 2, end)))); i = end + 2; continue; }
1837
+ }
1838
+ }
1839
+ // Emphasis (* or _), same word-boundary guard for underscore.
1840
+ if (ch === "*" || ch === "_") {
1841
+ const underscore = ch === "_";
1842
+ if (!underscore || !mdIsWordChar(s[i - 1])) {
1843
+ const end = mdFindClose(s, ch, i + 1, underscore);
1844
+ if (end > i + 1) { flush(); nodes.push(el("em", {}, ...mdInline(s.slice(i + 1, end)))); i = end + 1; continue; }
1845
+ }
1846
+ }
1847
+ // Link [label](href): href sanitised; an unsafe scheme drops to the label text.
1848
+ if (ch === "[") {
1849
+ const close = s.indexOf("]", i + 1);
1850
+ if (close > i && s[close + 1] === "(") {
1851
+ const paren = s.indexOf(")", close + 2);
1852
+ if (paren > close) {
1853
+ const href = mdSafeHref(s.slice(close + 2, paren));
1854
+ const label = mdInline(s.slice(i + 1, close));
1855
+ flush();
1856
+ if (href) nodes.push(el("a", { href: href, target: "_blank", rel: "noopener noreferrer" }, ...label));
1857
+ else for (const l of label) nodes.push(l);
1858
+ i = paren + 1; continue;
1859
+ }
1860
+ }
1861
+ }
1862
+ buf += ch; i++;
1863
+ }
1864
+ flush();
1865
+ return nodes;
1866
+ }
1867
+ // Regexes that classify a block-opening line; shared by the block loop and the
1868
+ // paragraph terminator so the two can't disagree on where a paragraph ends.
1869
+ const MD_HEADING = /^(#{1,6})\s+(.*)$/;
1870
+ const MD_QUOTE = /^>\s?/;
1871
+ const MD_UL = /^[-*+]\s+/;
1872
+ const MD_OL = /^\d+[.)]\s+/;
1873
+ const MD_HR = /^([-*_])(?:\s*\1){2,}\s*$/;
1874
+ function mdBlockStart(t) {
1875
+ return t.slice(0, 3) === MD_BACKTICK + MD_BACKTICK + MD_BACKTICK
1876
+ || MD_HEADING.test(t) || MD_QUOTE.test(t) || MD_UL.test(t) || MD_OL.test(t) || MD_HR.test(t);
1877
+ }
1878
+ // Parse a markdown string into an array of block-level DOM nodes.
1879
+ function mdToNodes(src) {
1880
+ const out = [];
1881
+ const fence = MD_BACKTICK + MD_BACKTICK + MD_BACKTICK;
1882
+ const lines = String(src == null ? "" : src).replace(/\r\n?/g, "\n").split("\n");
1883
+ let i = 0;
1884
+ while (i < lines.length) {
1885
+ const t = lines[i].trim();
1886
+ if (t === "") { i++; continue; }
1887
+ // Fenced code block — content is verbatim (no inline parsing).
1888
+ if (t.slice(0, 3) === fence) {
1889
+ const body = [];
1890
+ i++;
1891
+ while (i < lines.length && lines[i].trim().slice(0, 3) !== fence) { body.push(lines[i]); i++; }
1892
+ if (i < lines.length) i++; // consume the closing fence
1893
+ out.push(el("pre", { class: "pc-md-pre" }, el("code", {}, body.join("\n"))));
1894
+ continue;
1895
+ }
1896
+ // Thematic break (checked before lists so "***"/"---" isn't read as a bullet).
1897
+ if (MD_HR.test(t)) { out.push(el("hr", { class: "pc-md-hr" })); i++; continue; }
1898
+ // ATX heading.
1899
+ const h = MD_HEADING.exec(t);
1900
+ if (h) { out.push(el("h" + h[1].length, { class: "pc-md-h" }, ...mdInline(h[2].trim()))); i++; continue; }
1901
+ // Blockquote — strip one '>' per line and re-parse the inner block(s).
1902
+ if (MD_QUOTE.test(t)) {
1903
+ const inner = [];
1904
+ while (i < lines.length && MD_QUOTE.test(lines[i].trim())) { inner.push(lines[i].trim().replace(MD_QUOTE, "")); i++; }
1905
+ const bq = el("blockquote", { class: "pc-md-quote" });
1906
+ for (const b of mdToNodes(inner.join("\n"))) bq.append(b);
1907
+ out.push(bq); continue;
1908
+ }
1909
+ // Unordered list.
1910
+ if (MD_UL.test(t)) {
1911
+ const ul = el("ul", { class: "pc-md-list" });
1912
+ while (i < lines.length && MD_UL.test(lines[i].trim())) {
1913
+ ul.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_UL, ""))));
1914
+ i++;
1915
+ }
1916
+ out.push(ul); continue;
1917
+ }
1918
+ // Ordered list.
1919
+ if (MD_OL.test(t)) {
1920
+ const ol = el("ol", { class: "pc-md-list" });
1921
+ while (i < lines.length && MD_OL.test(lines[i].trim())) {
1922
+ ol.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_OL, ""))));
1923
+ i++;
1924
+ }
1925
+ out.push(ol); continue;
1926
+ }
1927
+ // Paragraph — soft-wrapped lines joined with a space until a blank line or the
1928
+ // next block opener.
1929
+ const para = [];
1930
+ while (i < lines.length) {
1931
+ const lt = lines[i].trim();
1932
+ if (lt === "" || mdBlockStart(lt)) break;
1933
+ para.push(lt); i++;
1934
+ }
1935
+ out.push(el("p", { class: "pc-md-p" }, ...mdInline(para.join(" "))));
1936
+ }
1937
+ return out;
1938
+ }
1939
+
1940
+ // A data-bound prose/markdown list (#274). Binds a datasource like dataGrid
1941
+ // (props.data = { source, table, orderBy, filter }) but renders each row as a
1942
+ // stacked prose block — a small header template (props.header, e.g.
1943
+ // "Round {{round}} · {{approved}}") over one body field (props.body) rendered as
1944
+ // sanitised markdown at a comfortable measure (props.measure ch, ~66 default).
1945
+ // This is the primitive for narrative records (plan-review findings, coordination
1946
+ // notes, escalation bodies) that read as a document, not a squeezed table cell.
1947
+ // It inherits collapsible/defaultCollapsed for free via makeCollapsible (which
1948
+ // unwraps the <h2> title), and refreshMs polling + pc:refresh like dataGrid.
1949
+ function renderProse(node) {
1950
+ const p = node.props || {};
1951
+ const data = p.data || {};
1952
+ const card = el("section", { class: "pc-card" });
1953
+ if (p.title) card.append(el("h2", {}, p.title));
1954
+ const listEl = el("div", { class: "pc-prose-list" });
1955
+ card.append(listEl);
1956
+ const headerTpl = typeof p.header === "string" ? p.header : null;
1957
+ const bodyField = p.body;
1958
+ // Clamp the reading measure to a sane range so a malformed schema can't set a
1959
+ // 5000ch (full-bleed) or 2ch (one-word-per-line) body. ~66ch is the default.
1960
+ let measure = Number(p.measure);
1961
+ if (!Number.isFinite(measure)) measure = 66;
1962
+ measure = Math.max(40, Math.min(100, Math.round(measure)));
1963
+ const activeFilter = Array.isArray(data.filter) ? data.filter : [];
1964
+
1965
+ function itemFor(row) {
1966
+ const item = el("article", { class: "pc-prose-item" });
1967
+ if (headerTpl) item.append(el("div", { class: "pc-prose-head" }, interpTemplate(headerTpl, row)));
1968
+ const bodyEl = el("div", { class: "pc-prose-body", style: "max-width:" + measure + "ch" });
1969
+ // Own-property gate (matching interpTemplate) so a body field name that
1970
+ // collides with a prototype key can't pick up prototype cruft.
1971
+ const raw = bodyField != null && Object.prototype.hasOwnProperty.call(row, bodyField) && row[bodyField] != null
1972
+ ? String(row[bodyField])
1973
+ : "";
1974
+ for (const b of mdToNodes(raw)) bodyEl.append(b);
1975
+ item.append(bodyEl);
1976
+ return item;
1977
+ }
1978
+
1979
+ async function refresh() {
1980
+ try {
1981
+ // A param-scoped list ("show the selected entity's records") with no route
1982
+ // param present renders nothing rather than a field=empty-string query.
1983
+ const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
1984
+ const { rows } = paramScoped && PARAM === ""
1985
+ ? { rows: [] }
1986
+ : await getJSON(dataUrl(data.source, data.table, activeFilter, data.orderBy));
1987
+ listEl.replaceChildren();
1988
+ if (!rows.length) { listEl.append(el("p", { class: "pc-prose-empty" }, p.empty || "No records")); return; }
1989
+ for (const row of rows) listEl.append(itemFor(row));
1990
+ } catch (e) {
1991
+ listEl.replaceChildren(el("p", { class: "pc-msg err" }, String((e && e.message) || e)));
1992
+ }
1993
+ }
1994
+ document.addEventListener("pc:refresh", refresh);
1995
+ disposers.push(() => document.removeEventListener("pc:refresh", refresh));
1996
+ if (p.refreshMs && p.refreshMs > 0) {
1997
+ const timer = setInterval(refresh, p.refreshMs);
1998
+ disposers.push(() => clearInterval(timer));
1999
+ }
2000
+ refresh();
2001
+ return card;
2002
+ }
2003
+
1401
2004
  function renderDataGrid(node) {
1402
2005
  const p = node.props;
1403
2006
  const card = el("section", { class: "pc-card" });
@@ -1412,6 +2015,12 @@ function renderDataGrid(node) {
1412
2015
  card.append(h2);
1413
2016
  }
1414
2017
  const cols = p.columns || [];
2018
+ // Mobile card roles per column (derived by convention, refined by
2019
+ // col.mobile.priority) so the grid can flip to a stacked card list on a phone
2020
+ // (#268). 'hasHidden' gates the per-row "More" toggle that reveals the columns
2021
+ // a mobile:{priority:"hidden"} hint drops off the card.
2022
+ const roles = classifyColumns(cols);
2023
+ const hasHidden = roles.indexOf("hidden") >= 0;
1415
2024
  const tabs = p.tabs || [];
1416
2025
  const rowActions = p.rowActions || [];
1417
2026
  const detail = p.detail || null;
@@ -1465,18 +2074,6 @@ function renderDataGrid(node) {
1465
2074
  return v == null ? null : String(v);
1466
2075
  };
1467
2076
 
1468
- function dataUrl(source, tbl, filters, order) {
1469
- let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
1470
- const qs = [];
1471
- for (const f of filters || []) {
1472
- if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
1473
- else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
1474
- else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
1475
- }
1476
- if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
1477
- return qs.length ? u + "?" + qs.join("&") : u;
1478
- }
1479
-
1480
2077
  async function fireAction(action, row) {
1481
2078
  // Route-driven row action: POST the action's body template (default {})
1482
2079
  // to its route path, with {{row.KEY}} tokens resolved from this row.
@@ -1535,6 +2132,8 @@ function renderDataGrid(node) {
1535
2132
  const wrap = el("div", { class: "pc-child" });
1536
2133
  if (cg.title) wrap.append(el("div", { class: "pc-child-title" }, cg.title));
1537
2134
  const ccols = cg.columns || [];
2135
+ const croles = classifyColumns(ccols);
2136
+ const chasHidden = croles.indexOf("hidden") >= 0;
1538
2137
  const cbody = el("tbody", {});
1539
2138
  const ccolgroup = buildColgroup(ccols, cg.lazyField ? 1 : 0);
1540
2139
  const cthead = el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
@@ -1551,7 +2150,7 @@ function renderDataGrid(node) {
1551
2150
  cbody.append(el("tr", {}, el("td", { colspan: cspan }, "None")));
1552
2151
  }
1553
2152
  for (const cr of rows) {
1554
- const cells = ccols.map((c) => gridCell(c, cr));
2153
+ const cells = ccols.map((c, i) => gridCell(c, cr, croles[i]));
1555
2154
  if (cg.lazyField) {
1556
2155
  const lf = cg.lazyField;
1557
2156
  const has = cr[lf.field] != null && String(cr[lf.field]).trim() !== "";
@@ -1568,7 +2167,13 @@ function renderDataGrid(node) {
1568
2167
  }
1569
2168
  cells.push(cell);
1570
2169
  }
1571
- cbody.append(el("tr", {}, ...cells));
2170
+ // Child grids classify columns into the same mobile roles (including
2171
+ // "hidden") as the top-level grid, so they need the same per-row "More"
2172
+ // toggle — without it a mobile:{priority:"hidden"} child column would be
2173
+ // permanently unreachable on a narrow viewport (#268).
2174
+ const ctr = el("tr", {}, ...cells);
2175
+ if (chasHidden) ctr.append(mobileMoreCell(ctr));
2176
+ cbody.append(ctr);
1572
2177
  }
1573
2178
  } catch (e) {
1574
2179
  cbody.append(el("tr", {}, el("td", { colspan: cspan }, String(e.message || e))));
@@ -1605,7 +2210,7 @@ function renderDataGrid(node) {
1605
2210
  }
1606
2211
 
1607
2212
  function renderRow(row, sink) {
1608
- const cells = cols.map((c) => gridCell(c, row));
2213
+ const cells = cols.map((c, i) => gridCell(c, row, roles[i]));
1609
2214
  const key = rowKeyOf(row);
1610
2215
  let toggle = null;
1611
2216
  if (hasExtra) {
@@ -1620,7 +2225,12 @@ function renderDataGrid(node) {
1620
2225
  }
1621
2226
  cells.push(actionCell);
1622
2227
  }
2228
+ // Per-row "More" toggle: only when a mobile:{priority:"hidden"} hint drops
2229
+ // columns off the card. Appended after the <tr> is built (the toggle wires a
2230
+ // click against its own row) and shared with child grids via mobileMoreCell so
2231
+ // the reveal affordance and its aria state can't drift (#268).
1623
2232
  const tr = el("tr", {}, ...cells);
2233
+ if (hasHidden) tr.append(mobileMoreCell(tr));
1624
2234
  tbody.append(tr);
1625
2235
  // When grouping, the caller collects every <tr> this row produced (the data
1626
2236
  // row plus any detail row) so a group header can hide/show them as one unit.
@@ -1869,7 +2479,7 @@ function renderNav(node) {
1869
2479
  return nav;
1870
2480
  }
1871
2481
 
1872
- const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
2482
+ const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, prose: renderProse, nav: renderNav, button: renderButton };
1873
2483
 
1874
2484
  // Durable per-node UI state. localStorage is keyed by the home page id + node
1875
2485
  // id so two grids on the same page (or the same grid across pages) don't clash,
@@ -1951,19 +2561,31 @@ function makeCollapsible(node, card) {
1951
2561
  return container;
1952
2562
  }
1953
2563
 
2564
+ // Tier-2 escape hatch (#268): a page may declare a distinct 'mobile' layout
2565
+ // variant — its own node list — for the rare case the derived card flip isn't
2566
+ // enough (reorder/omit sections, swap a dense grid for a summary card). It is
2567
+ // used ONLY on a narrow viewport; the default 'nodes' render everywhere else,
2568
+ // and anything malformed falls back to them, so the variant is purely additive.
2569
+ function pickNodes(doc) {
2570
+ if (isNarrow() && doc && doc.mobile && Array.isArray(doc.mobile.nodes)) return doc.mobile.nodes;
2571
+ return (doc && doc.nodes) || [];
2572
+ }
2573
+
1954
2574
  // Render the page named by the current hash route. A rail nav is lifted into a
1955
2575
  // left <aside> and the rest of the nodes flow into a content column; otherwise
1956
2576
  // (bar nav or none) nodes render in document order. Re-runs on every hashchange,
1957
2577
  // tearing down the previous page's grid polls first.
1958
2578
  async function renderPage() {
1959
2579
  teardown();
2580
+ const mySeq = ++renderSeq;
1960
2581
  const route = parseRoute();
1961
2582
  CURRENT = route.page;
1962
2583
  PARAM = route.param;
1963
2584
  try {
1964
2585
  const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
2586
+ if (mySeq !== renderSeq) return;
1965
2587
  document.title = (doc && doc.title) || "Urban App";
1966
- const nodes = (doc && doc.nodes) || [];
2588
+ const nodes = pickNodes(doc);
1967
2589
  const built = nodes.map((n) => ({ n, node: makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n)) }));
1968
2590
  const rail = built.find((b) => b.n.type === "nav" && b.n.props && b.n.props.variant === "rail");
1969
2591
  if (rail) {
@@ -1974,10 +2596,20 @@ async function renderPage() {
1974
2596
  root.replaceChildren(...built.map((b) => b.node));
1975
2597
  }
1976
2598
  } catch (e) {
2599
+ if (mySeq !== renderSeq) return;
1977
2600
  root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String((e && e.message) || e)));
1978
2601
  }
1979
2602
  }
1980
2603
  window.addEventListener("hashchange", renderPage);
2604
+ // Re-render when the viewport crosses the mobile breakpoint so a Tier-2 'mobile'
2605
+ // variant swaps in/out on rotation or a window resize (the grid card flip, nav
2606
+ // collapse and button stacking are pure CSS and need no re-render). teardown()
2607
+ // inside renderPage stops the outgoing page's grid polls first. Prefer the modern
2608
+ // addEventListener but fall back to the deprecated addListener so older Safari/iOS
2609
+ // (< 14), where MediaQueryList.addEventListener is unimplemented, still swaps the
2610
+ // Tier-2 variant on rotation/resize instead of silently failing to register.
2611
+ if (typeof MOBILE_MQ.addEventListener === "function") MOBILE_MQ.addEventListener("change", renderPage);
2612
+ else if (typeof MOBILE_MQ.addListener === "function") MOBILE_MQ.addListener(renderPage);
1981
2613
  renderPage();
1982
2614
  `;
1983
2615