@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.
- package/README.md +47 -20
- package/dist/runtime/core/events.d.ts +135 -0
- package/dist/runtime/core/events.js +329 -0
- package/dist/runtime/core/extensions.d.ts +131 -0
- package/dist/runtime/core/extensions.js +137 -0
- package/dist/runtime/core/glob.d.ts +7 -6
- package/dist/runtime/core/glob.js +16 -14
- package/dist/runtime/core/host.d.ts +4 -1
- package/dist/runtime/core/modules/agent.d.ts +17 -0
- package/dist/runtime/core/modules/agent.js +63 -0
- package/dist/runtime/core/modules/deploy.d.ts +8 -5
- package/dist/runtime/core/modules/deploy.js +62 -16
- package/dist/runtime/core/modules/pages.js +666 -35
- package/dist/runtime/core/modules/surfaces.js +8 -0
- package/dist/runtime/core/phase.d.ts +168 -0
- package/dist/runtime/core/phase.js +367 -0
- package/dist/runtime/core/runtime.d.ts +17 -0
- package/dist/runtime/core/runtime.js +30 -0
- package/dist/runtime/index.d.ts +6 -0
- package/dist/runtime/index.js +7 -0
- package/dist/toolkit/derivers/system-brief.d.ts +62 -0
- package/dist/toolkit/derivers/system-brief.js +156 -0
- package/dist/toolkit/gen.d.ts +5 -5
- package/dist/toolkit/gen.js +20 -14
- package/dist/toolkit/index.d.ts +5 -0
- package/dist/toolkit/index.js +3 -0
- package/dist/toolkit/models.d.ts +4 -3
- package/dist/toolkit/models.js +4 -3
- package/package.json +1 -1
- package/src/runtime/core/events.test.ts +382 -0
- package/src/runtime/core/events.ts +370 -0
- package/src/runtime/core/extensions.test.ts +378 -0
- package/src/runtime/core/extensions.ts +276 -0
- package/src/runtime/core/glob.ts +15 -12
- package/src/runtime/core/host.ts +4 -1
- package/src/runtime/core/modules/agent.test.ts +104 -0
- package/src/runtime/core/modules/agent.ts +76 -0
- package/src/runtime/core/modules/deploy.test.ts +84 -16
- package/src/runtime/core/modules/deploy.ts +59 -16
- package/src/runtime/core/modules/pages.test.ts +396 -5
- package/src/runtime/core/modules/pages.ts +667 -35
- package/src/runtime/core/modules/surfaces.ts +9 -0
- package/src/runtime/core/phase.test.ts +350 -0
- package/src/runtime/core/phase.ts +526 -0
- package/src/runtime/core/runtime.extensions.test.ts +151 -0
- package/src/runtime/core/runtime.ts +49 -0
- package/src/runtime/devserver.test.ts +3 -0
- package/src/runtime/index.ts +57 -0
- package/src/toolkit/derivers/system-brief.test.ts +150 -0
- package/src/toolkit/derivers/system-brief.ts +210 -0
- package/src/toolkit/gen.test.ts +28 -4
- package/src/toolkit/gen.ts +20 -13
- package/src/toolkit/index.ts +14 -0
- 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; }
|
|
@@ -544,6 +575,30 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
544
575
|
.pc-group-toggle { display:flex; align-items:center; gap:.5rem; width:100%; padding:.4rem .6rem; background:transparent; border:0; color:inherit; font:inherit; font-weight:600; cursor:pointer; text-align:left; }
|
|
545
576
|
.pc-group-toggle:hover { background:var(--nano-hover); }
|
|
546
577
|
.pc-group-title { font-size:.9rem; }
|
|
578
|
+
/* Data-bound prose/markdown list (renderProse, #274). Long-form narrative
|
|
579
|
+
records — plan-review findings, coordination notes, escalation bodies — read
|
|
580
|
+
as a stacked, full-width document at a comfortable measure (~66ch) instead of
|
|
581
|
+
being jammed into a grid column. Each item stacks a small header over a
|
|
582
|
+
markdown-rendered body; the body max-width is set inline per node (props.measure)
|
|
583
|
+
with this rule as the no-JS fallback. */
|
|
584
|
+
.pc-prose-list { display:flex; flex-direction:column; gap:1.1rem; }
|
|
585
|
+
.pc-prose-item { border-top:1px solid var(--nano-edge); padding-top:.9rem; }
|
|
586
|
+
.pc-prose-item:first-child { border-top:0; padding-top:0; }
|
|
587
|
+
.pc-prose-head { font-size:.8rem; font-weight:600; color:var(--nano-text-muted); text-transform:uppercase; letter-spacing:.03em; margin:0 0 .35rem; }
|
|
588
|
+
.pc-prose-empty { color:var(--nano-text-faint); }
|
|
589
|
+
.pc-prose-body { max-width:66ch; }
|
|
590
|
+
.pc-prose-body > :first-child { margin-top:0; }
|
|
591
|
+
.pc-prose-body > :last-child { margin-bottom:0; }
|
|
592
|
+
.pc-md-p { margin:.55rem 0; overflow-wrap:anywhere; }
|
|
593
|
+
.pc-md-h { font-size:1.05rem; font-weight:650; margin:.95rem 0 .4rem; }
|
|
594
|
+
.pc-md-list { margin:.45rem 0; padding-left:1.35rem; }
|
|
595
|
+
.pc-md-list li { margin:.15rem 0; }
|
|
596
|
+
.pc-md-quote { margin:.6rem 0; padding:.1rem .9rem; border-left:3px solid var(--nano-edge-strong); color:var(--nano-text-muted); }
|
|
597
|
+
.pc-md-pre { background:var(--nano-inset); border:1px solid var(--nano-edge); border-radius:.4rem; padding:.7rem .8rem; overflow:auto; margin:.6rem 0; }
|
|
598
|
+
.pc-md-pre code { font:.82rem/1.5 ui-monospace,SFMono-Regular,Menlo,monospace; color:var(--nano-text); white-space:pre; }
|
|
599
|
+
.pc-md-code { background:var(--nano-inset); border-radius:.3rem; padding:.05rem .3rem; font:.85em ui-monospace,SFMono-Regular,Menlo,monospace; }
|
|
600
|
+
.pc-md-hr { border:0; border-top:1px solid var(--nano-edge); margin:1rem 0; }
|
|
601
|
+
.pc-prose-body a { color:var(--nano-accent-strong); }
|
|
547
602
|
/* A standalone button node + the modal it opens (e.g. a copy-pasteable prompt). */
|
|
548
603
|
.pc-buttonrow { margin:1rem 0; }
|
|
549
604
|
.pc-btn-ghost { background:transparent; color:var(--nano-text-muted); border:1px solid var(--nano-edge); }
|
|
@@ -575,15 +630,96 @@ table.pc-grid th { font-weight:600; color:var(--nano-text-muted); }
|
|
|
575
630
|
.pc-nav-link.active { background:var(--nano-accent); color:var(--nano-on-accent); }
|
|
576
631
|
.pc-nav-icon { line-height:1; }
|
|
577
632
|
.pc-nav-empty { color:var(--nano-text-faint); font-size:.85rem; padding:.4rem .7rem; }
|
|
633
|
+
/* The per-row mobile "More" toggle (revealing mobile:{priority:"hidden"} columns)
|
|
634
|
+
only exists for the card view — hidden on the desktop table, shown by the
|
|
635
|
+
mobile @media below. Base-hidden here so a grid renders byte-for-byte
|
|
636
|
+
unchanged on desktop even when it carries hidden-column hints. */
|
|
637
|
+
.pc-mcard-toggle { display:none; }
|
|
638
|
+
/* ── Urban responsive / mobile primitive (#268) ──────────────────────────
|
|
639
|
+
Below MOBILE_MAX_WIDTH every app adapts to a phone with ZERO page-schema
|
|
640
|
+
change. The dense dataGrid — the one primitive CSS alone can't reflow — flips
|
|
641
|
+
from a wide <table> to a stacked card list purely from the per-cell
|
|
642
|
+
'data-label' the renderer already stamps on (no JS branching): thead is
|
|
643
|
+
hidden, each <tr> becomes a bordered card and each <td> a full-width
|
|
644
|
+
label:value line whose label is the column header. The nav collapses to a
|
|
645
|
+
thumb-reachable scrollable bottom bar, buttons/form actions go full-width
|
|
646
|
+
single column, and text reflows on its own. The breakpoint is interpolated
|
|
647
|
+
from the shared MOBILE_MAX_WIDTH constant so it can't drift from the runtime's
|
|
648
|
+
matchMedia. */
|
|
649
|
+
@media (max-width:${MOBILE_MAX_WIDTH}) {
|
|
650
|
+
body { padding:1rem; }
|
|
651
|
+
/* dataGrid → card list. Once cells are display:block the fixed colgroup width
|
|
652
|
+
is irrelevant, so a wide table stops overflowing the phone. table-layout is
|
|
653
|
+
reset to auto so the block cells size to content. */
|
|
654
|
+
table.pc-grid { table-layout:auto; }
|
|
655
|
+
table.pc-grid, table.pc-grid tbody, table.pc-grid tr, table.pc-grid td { display:block; width:auto; }
|
|
656
|
+
table.pc-grid colgroup, table.pc-grid thead { display:none; }
|
|
657
|
+
table.pc-grid tr { border:1px solid var(--nano-edge); border-radius:.6rem; padding:.55rem .75rem; margin:.6rem 0; background:var(--nano-panel); }
|
|
658
|
+
table.pc-grid td { border-bottom:0; padding:.15rem 0; display:flex; gap:.75rem; justify-content:space-between; align-items:baseline; }
|
|
659
|
+
/* label:value — the column header (data-label) prefixes the value as a muted
|
|
660
|
+
caption on the same line. Suppressed for the title, chip, actions and any
|
|
661
|
+
colspan (group/empty/error) cells below. */
|
|
662
|
+
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; }
|
|
663
|
+
/* The derived (or mobile:{priority:"primary"}-hinted) card title leads the
|
|
664
|
+
card: larger, full width, no label caption. */
|
|
665
|
+
table.pc-grid td.pc-mcell-primary { display:block; font-size:1rem; font-weight:650; padding-top:.15rem; }
|
|
666
|
+
table.pc-grid td.pc-mcell-primary::before { content:none; }
|
|
667
|
+
/* A badge column becomes a left-aligned status chip with no caption. */
|
|
668
|
+
table.pc-grid td.pc-mcell-chip { justify-content:flex-start; }
|
|
669
|
+
table.pc-grid td.pc-mcell-chip::before { content:none; }
|
|
670
|
+
/* An empty badge cell (row value blank) still carries the chip class but has no
|
|
671
|
+
child content; without this it renders as a blank flex row (padding/gap) that
|
|
672
|
+
leaves a stray empty line in the card. Collapse those empty chip cells (#268). */
|
|
673
|
+
table.pc-grid td.pc-mcell-chip:empty { display:none; }
|
|
674
|
+
/* Low-value (mobile:{priority:"hidden"}) columns drop off the card until the
|
|
675
|
+
row's "More" toggle opens them; on desktop they render as normal columns. */
|
|
676
|
+
table.pc-grid td.pc-mcell-hidden { display:none; }
|
|
677
|
+
table.pc-grid tr.pc-open td.pc-mcell-hidden { display:flex; }
|
|
678
|
+
.pc-mcard-toggle { display:block; padding:.3rem 0 0; }
|
|
679
|
+
.pc-mcard-toggle .pc-more { width:100%; margin-right:0; }
|
|
680
|
+
/* Row actions stack full-width under the card body. */
|
|
681
|
+
table.pc-grid td.pc-row-actions { display:flex; flex-wrap:wrap; gap:.4rem; text-align:left; padding-top:.45rem; white-space:normal; }
|
|
682
|
+
table.pc-grid td.pc-row-actions::before { content:none; }
|
|
683
|
+
table.pc-grid td.pc-row-actions .pc-btn { flex:1 1 auto; margin-right:0; }
|
|
684
|
+
/* Group-header + empty/error rows span the full card width with no caption. */
|
|
685
|
+
table.pc-grid td[colspan] { display:block; }
|
|
686
|
+
table.pc-grid td[colspan]::before { content:none; }
|
|
687
|
+
.pc-group-header td { padding:0; }
|
|
688
|
+
/* Nav → horizontally-scrollable bar (bar) / inline stack (rail). The bar keeps
|
|
689
|
+
its normal top-of-flow position and scrolls sideways when the pages overflow;
|
|
690
|
+
an opt-in sticky bar stays pinned to the top via the desktop .pc-sticky rule
|
|
691
|
+
(top:0). A rail drops its fixed side column and flows inline. */
|
|
692
|
+
.pc-layout { display:block; }
|
|
693
|
+
.pc-rail-wrap { position:static; flex-basis:auto; width:auto; margin:0 0 1rem; }
|
|
694
|
+
.pc-rail .pc-nav-items { flex-direction:row; flex-wrap:wrap; }
|
|
695
|
+
.pc-bar { flex-wrap:nowrap; overflow-x:auto; }
|
|
696
|
+
.pc-bar.pc-sticky { position:sticky; }
|
|
697
|
+
.pc-bar .pc-nav-items { flex-wrap:nowrap; }
|
|
698
|
+
.pc-nav-link { white-space:nowrap; }
|
|
699
|
+
.pc-tabs { overflow-x:auto; }
|
|
700
|
+
/* Buttons / form actions go full-width single-column. */
|
|
701
|
+
.pc-buttonrow .pc-btn, .pc-card .pc-btn { width:100%; }
|
|
702
|
+
.pc-modal { max-width:100%; }
|
|
703
|
+
.pc-modal-actions { flex-direction:column; }
|
|
704
|
+
.pc-modal-actions .pc-btn { width:100%; }
|
|
705
|
+
}
|
|
578
706
|
`;
|
|
579
707
|
// The schema-driven browser renderer (ADR 0042 §3). Plain ES module string served at
|
|
580
708
|
// /app/runtime.js — it does NOT ship Craft.js (authoring is console-side only). It
|
|
581
|
-
// fetches the home page's page.json and renders text / actionForm / dataGrid
|
|
582
|
-
// wiring actionForm → /app/actions/start and dataGrid → /app/data (with a refresh).
|
|
709
|
+
// fetches the home page's page.json and renders text / actionForm / dataGrid / prose
|
|
710
|
+
// nodes, wiring actionForm → /app/actions/start and dataGrid/prose → /app/data (with a refresh).
|
|
583
711
|
const RENDERER_JS = String.raw `
|
|
584
712
|
const root = document.getElementById("page");
|
|
585
713
|
const HOME = root.dataset.home || "home";
|
|
586
714
|
|
|
715
|
+
// Single mobile breakpoint, interpolated from the same MOBILE_MAX_WIDTH constant
|
|
716
|
+
// as the shell CSS @media so the JS mode-switch and the pure-CSS card flip can
|
|
717
|
+
// never drift on where "narrow" begins (#268). Only Tier-2 (an optional
|
|
718
|
+
// page-level 'mobile' layout variant) needs this JS switch — the dataGrid card
|
|
719
|
+
// flip, nav collapse and button/form stacking are all pure CSS + data-label.
|
|
720
|
+
const MOBILE_MQ = window.matchMedia("(max-width:${MOBILE_MAX_WIDTH})");
|
|
721
|
+
function isNarrow() { return MOBILE_MQ.matches; }
|
|
722
|
+
|
|
587
723
|
// ── Multi-page routing (hash-based, reverse-proxy safe) ──────────────────
|
|
588
724
|
// Pages are selected by the URL fragment (#/<page>) so navigation never hits
|
|
589
725
|
// the server and works identically at the origin root and under the Nano
|
|
@@ -622,6 +758,12 @@ let CURRENT = currentPage();
|
|
|
622
758
|
// this) and to {{param}} text interpolation, so one page template can scope
|
|
623
759
|
// every section to a selected entity (e.g. an epic's plan_key).
|
|
624
760
|
let PARAM = parseRoute().param;
|
|
761
|
+
// Monotonic render token. renderPage() is async and fires from both hashchange
|
|
762
|
+
// and the matchMedia viewport-crossing handler, so two invocations can overlap
|
|
763
|
+
// and their fetches resolve out of order. Each render captures the token it
|
|
764
|
+
// bumped to; after its await it only touches the DOM if it is still the latest,
|
|
765
|
+
// so a stale navigation/viewport render can't overwrite a newer one.
|
|
766
|
+
let renderSeq = 0;
|
|
625
767
|
|
|
626
768
|
// Per-render teardown. Each dataGrid registers its poll interval + pc:refresh
|
|
627
769
|
// listener here; navigating to another page runs every disposer before the new
|
|
@@ -1006,7 +1148,45 @@ function renderButton(node) {
|
|
|
1006
1148
|
// width/weight — see buildColgroup — to give the clip a bounded column). The
|
|
1007
1149
|
// subtitle is ALWAYS plain DOM text, never an href source. With none of these
|
|
1008
1150
|
// set the cell is a single inline node exactly as before (see cellTd).
|
|
1009
|
-
|
|
1151
|
+
// Build an in-app hash route to another page, scoped to a row key: #/<page>/<key>.
|
|
1152
|
+
// The page id is validated against the safe-id charset and the key must be
|
|
1153
|
+
// non-empty (after trimming); either failing yields "" (no link). The key is
|
|
1154
|
+
// URL-encoded into the route's param tail, which parseRoute() decodes back
|
|
1155
|
+
// verbatim, so row data can't smuggle a path or scheme. SINGLE source of truth
|
|
1156
|
+
// for the page-link href so the grid's link cell and the pipeline locus link
|
|
1157
|
+
// (issue #265) can't drift on how the route is built.
|
|
1158
|
+
function pageHashHref(page, key) {
|
|
1159
|
+
const keyStr = key == null ? "" : String(key).trim();
|
|
1160
|
+
if (!safePageId(page) || keyStr === "") return "";
|
|
1161
|
+
return "#/" + encodeURIComponent(page) + "/" + encodeURIComponent(keyStr);
|
|
1162
|
+
}
|
|
1163
|
+
|
|
1164
|
+
// Coerce a not-in-path value (issue #265) into a Set of stage keys. Accepts an
|
|
1165
|
+
// array of keys or a comma/whitespace-separated string; null/undefined → empty
|
|
1166
|
+
// set. Used to mark the stages a given row skips (dashed/omitted).
|
|
1167
|
+
function toStageSet(v) {
|
|
1168
|
+
// Trim + drop-empties on BOTH inputs so array entries with stray whitespace
|
|
1169
|
+
// (e.g. from CSV/JSON munging) match stages[].key just like the string path.
|
|
1170
|
+
if (Array.isArray(v)) return new Set(v.map((s) => String(s).trim()).filter((s) => s !== ""));
|
|
1171
|
+
if (v == null) return new Set();
|
|
1172
|
+
return new Set(String(v).split(/[\s,]+/).filter((s) => s !== ""));
|
|
1173
|
+
}
|
|
1174
|
+
|
|
1175
|
+
function gridCell(col, row, role) {
|
|
1176
|
+
// Mobile card attributes stamped on every td so a dataGrid can flip to a
|
|
1177
|
+
// stacked card list below the breakpoint with no JS branching (#268):
|
|
1178
|
+
// 'data-label' drives the pure-CSS label:value line (the column header, or a
|
|
1179
|
+
// mobile:{label} override), and the role class picks the card region (title /
|
|
1180
|
+
// status chip / secondary field / hidden). The role is derived by convention
|
|
1181
|
+
// (classifyColumns) and refinable per-column via col.mobile.priority.
|
|
1182
|
+
const mrole = role || "secondary";
|
|
1183
|
+
const mlabel =
|
|
1184
|
+
col.mobile && typeof col.mobile.label === "string"
|
|
1185
|
+
? col.mobile.label
|
|
1186
|
+
: col.header != null
|
|
1187
|
+
? String(col.header)
|
|
1188
|
+
: String(col.field == null ? "" : col.field);
|
|
1189
|
+
const mob = { class: "pc-mcell pc-mcell-" + mrole, "data-label": mlabel };
|
|
1010
1190
|
// Raw single-field value: drives the badge presence gate + tooltip and any
|
|
1011
1191
|
// field-derived defaults. Kept separate from the (possibly templated) display
|
|
1012
1192
|
// text so a template never re-lights or blanks a badge.
|
|
@@ -1025,13 +1205,17 @@ function gridCell(col, row) {
|
|
|
1025
1205
|
// field text is also mirrored into aria-label so assistive tech announces
|
|
1026
1206
|
// the incident/status text (the visible "1" glyph alone is not meaningful).
|
|
1027
1207
|
if (col.badge) {
|
|
1028
|
-
|
|
1208
|
+
// Even when blank, stamp data-label (the "every td carries data-label"
|
|
1209
|
+
// invariant): the cell still collapses on mobile via the pc-mcell-chip:empty
|
|
1210
|
+
// rule (attributes don't defeat :empty), so this keeps empty and non-empty
|
|
1211
|
+
// badge cells consistent without re-lighting the label.
|
|
1212
|
+
if (rawText.trim() === "") return el("td", { class: "pc-mcell pc-mcell-chip", "data-label": mlabel });
|
|
1029
1213
|
const t = col.badge.tone;
|
|
1030
1214
|
const tone = t === "warn" || t === "ok" || t === "info" ? t : "danger";
|
|
1031
1215
|
const label = col.badge.label == null ? "1" : String(col.badge.label);
|
|
1032
1216
|
return el(
|
|
1033
1217
|
"td",
|
|
1034
|
-
|
|
1218
|
+
mob,
|
|
1035
1219
|
el("span", { class: "pc-badge pc-badge-" + tone, title: rawText, "aria-label": rawText }, label),
|
|
1036
1220
|
);
|
|
1037
1221
|
}
|
|
@@ -1044,6 +1228,14 @@ function gridCell(col, row) {
|
|
|
1044
1228
|
col.subtitleField != null && row[col.subtitleField] != null ? String(row[col.subtitleField]) : "";
|
|
1045
1229
|
const subText = typeof col.subtitleTemplate === "string" ? interpTemplate(col.subtitleTemplate, row) : subRaw;
|
|
1046
1230
|
const truncate = col.truncate === true;
|
|
1231
|
+
// pipeline (issue #265): a dataGrid column declaring kind:"pipeline" renders
|
|
1232
|
+
// the ordered stages as a per-row horizontal progress track (upstream-filled
|
|
1233
|
+
// → active-lit → downstream-ghosted, not-in-path dashed). It owns the whole
|
|
1234
|
+
// cell, so it returns here before the link chain below. Gated purely on the
|
|
1235
|
+
// discriminator so every existing column mode is untouched when it is absent;
|
|
1236
|
+
// unknown/missing pipeline config degrades to plain cell text (never throws),
|
|
1237
|
+
// exactly as an unrecognised link.kind falls back today.
|
|
1238
|
+
if (col.kind === "pipeline") return pipelineCell(col, row, text, subText, truncate, mob);
|
|
1047
1239
|
// Primary inline content: an anchor for a valid link mode (checked in the same
|
|
1048
1240
|
// badge < linkField < link(page) < link(processExplorer) precedence as before),
|
|
1049
1241
|
// else the plain display text. Each link mode only claims the cell while
|
|
@@ -1057,16 +1249,13 @@ function gridCell(col, row) {
|
|
|
1057
1249
|
}
|
|
1058
1250
|
}
|
|
1059
1251
|
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>.
|
|
1061
|
-
//
|
|
1062
|
-
// param tail, which parseRoute() decodes back verbatim; the page id is
|
|
1252
|
+
// An in-app link to another page, scoped to this row: #/<page>/<key>. Built
|
|
1253
|
+
// via pageHashHref (the single source of truth for the route): the page id is
|
|
1063
1254
|
// validated against the safe-id charset and the key must be non-empty, so a
|
|
1064
1255
|
// blank/keyless cell stays plain text. Navigation is a pure hash change
|
|
1065
1256
|
// (no new tab, no server hit) — the SPA router re-renders in place.
|
|
1066
|
-
const
|
|
1067
|
-
|
|
1068
|
-
if (text !== "" && safePageId(col.link.page) && keyStr !== "") {
|
|
1069
|
-
const href = "#/" + encodeURIComponent(col.link.page) + "/" + encodeURIComponent(keyStr);
|
|
1257
|
+
const href = pageHashHref(col.link.page, row[col.link.keyField]);
|
|
1258
|
+
if (text !== "" && href !== "") {
|
|
1070
1259
|
primary = el("a", { class: "pc-link", href }, text);
|
|
1071
1260
|
}
|
|
1072
1261
|
}
|
|
@@ -1093,7 +1282,7 @@ function gridCell(col, row) {
|
|
|
1093
1282
|
}, text);
|
|
1094
1283
|
}
|
|
1095
1284
|
}
|
|
1096
|
-
return cellTd(primary, text, subText, truncate);
|
|
1285
|
+
return cellTd(primary, text, subText, truncate, mob);
|
|
1097
1286
|
}
|
|
1098
1287
|
|
|
1099
1288
|
// Compose a grid <td> from its primary inline content (a string or an anchor),
|
|
@@ -1108,9 +1297,12 @@ function gridCell(col, row) {
|
|
|
1108
1297
|
// aria-label= are ALSO mirrored onto it: HTML does not inherit those to
|
|
1109
1298
|
// descendants, so hovering the link text or naming the anchor for assistive tech
|
|
1110
1299
|
// would otherwise miss the full value the wrapper carries. The subtitle is set as
|
|
1111
|
-
// DOM text only — never an href.
|
|
1112
|
-
|
|
1113
|
-
|
|
1300
|
+
// DOM text only — never an href. 'tdAttrs' (the mobile card attrs: data-label +
|
|
1301
|
+
// role class, see gridCell) is applied to whichever <td> shape is returned so
|
|
1302
|
+
// the CSS-only card flip labels every cell identically (#268).
|
|
1303
|
+
function cellTd(primary, primaryText, subText, truncate, tdAttrs) {
|
|
1304
|
+
const td = tdAttrs || {};
|
|
1305
|
+
if (subText === "" && !truncate) return el("td", td, primary);
|
|
1114
1306
|
const mainAttrs = { class: "pc-cell-main" + (truncate ? " pc-truncate" : "") };
|
|
1115
1307
|
if (truncate && primaryText !== "") {
|
|
1116
1308
|
mainAttrs.title = primaryText; mainAttrs["aria-label"] = primaryText;
|
|
@@ -1119,10 +1311,178 @@ function cellTd(primary, primaryText, subText, truncate) {
|
|
|
1119
1311
|
}
|
|
1120
1312
|
}
|
|
1121
1313
|
const main = el("div", mainAttrs, primary);
|
|
1122
|
-
if (subText === "") return el("td",
|
|
1314
|
+
if (subText === "") return el("td", td, main);
|
|
1123
1315
|
const subAttrs = { class: "pc-cell-sub" + (truncate ? " pc-truncate" : "") };
|
|
1124
1316
|
if (truncate) { subAttrs.title = subText; subAttrs["aria-label"] = subText; }
|
|
1125
|
-
return el("td",
|
|
1317
|
+
return el("td", td, main, el("div", subAttrs, subText));
|
|
1318
|
+
}
|
|
1319
|
+
|
|
1320
|
+
// Classify each grid column into a mobile-card region so a dataGrid flips to a
|
|
1321
|
+
// stacked card list below the breakpoint with ZERO page-schema change (#268).
|
|
1322
|
+
// Convention (Tier 0): the first non-badge column is the card title (primary), a
|
|
1323
|
+
// badge column is a status chip, and everything else is a secondary label:value
|
|
1324
|
+
// row. Tier 1 refines it — a column may carry mobile:{priority:"primary" |
|
|
1325
|
+
// "secondary" | "hidden"}: an explicit "primary" suppresses the auto title pick
|
|
1326
|
+
// so a later column can lead the card, "hidden" drops the column off the card
|
|
1327
|
+
// (behind the row's "More" toggle), and "secondary" forces a plain field row.
|
|
1328
|
+
function classifyColumns(cols) {
|
|
1329
|
+
const list = cols || [];
|
|
1330
|
+
const explicitPrimary = list.some((c) => c && c.mobile && c.mobile.priority === "primary");
|
|
1331
|
+
let titleTaken = explicitPrimary;
|
|
1332
|
+
return list.map((col) => {
|
|
1333
|
+
const pr = col && col.mobile ? col.mobile.priority : null;
|
|
1334
|
+
if (pr === "primary") return "primary";
|
|
1335
|
+
if (pr === "secondary") return "secondary";
|
|
1336
|
+
if (pr === "hidden") return "hidden";
|
|
1337
|
+
if (col && col.badge) return "chip";
|
|
1338
|
+
if (!titleTaken) { titleTaken = true; return "primary"; }
|
|
1339
|
+
return "secondary";
|
|
1340
|
+
});
|
|
1341
|
+
}
|
|
1342
|
+
|
|
1343
|
+
// Per-row mobile "More" toggle cell: reveals the mobile:{priority:"hidden"} columns
|
|
1344
|
+
// a narrow viewport drops off the card. The cell is base-hidden (display:none) so
|
|
1345
|
+
// the desktop table is byte-for-byte unchanged; clicking flips .pc-open on the row,
|
|
1346
|
+
// swaps the label, and updates aria-expanded so the reveal state is exposed to
|
|
1347
|
+
// assistive tech (#268). One implementation shared by the top-level grid and child
|
|
1348
|
+
// grids so the affordance — and its accessibility — can't drift between them.
|
|
1349
|
+
function mobileMoreCell(tr) {
|
|
1350
|
+
const moreBtn = el("button",
|
|
1351
|
+
{ class: "pc-btn pc-btn-sm pc-btn-ghost pc-more", type: "button", "aria-expanded": "false" }, "More");
|
|
1352
|
+
moreBtn.addEventListener("click", () => {
|
|
1353
|
+
const open = tr.classList.toggle("pc-open");
|
|
1354
|
+
moreBtn.textContent = open ? "Less" : "More";
|
|
1355
|
+
moreBtn.setAttribute("aria-expanded", String(open));
|
|
1356
|
+
});
|
|
1357
|
+
return el("td", { class: "pc-mcard-toggle" }, moreBtn);
|
|
1358
|
+
}
|
|
1359
|
+
|
|
1360
|
+
// Pipeline / stepper cell (issue #265) — a dataGrid column with kind:"pipeline"
|
|
1361
|
+
// renders a per-row, multi-stage horizontal progress track. It is RENDER-ONLY:
|
|
1362
|
+
// stage state is read straight from the row fields the app supplies; no
|
|
1363
|
+
// app-specific stage derivation lives here. Column config (all fields are the
|
|
1364
|
+
// NAMES of row fields, mirroring the issue sketch):
|
|
1365
|
+
// { kind:"pipeline",
|
|
1366
|
+
// stages:[{key,label},...], // ordered stage definitions (required)
|
|
1367
|
+
// activeField:"stage", // row field → the current stage's key
|
|
1368
|
+
// stateField:"stage_state", // row field → ok | active | failed | blocked
|
|
1369
|
+
// badgeField:"attention", // optional row field → badge text shown on the active stage
|
|
1370
|
+
// notInPathField:"skipped", // optional row field → stages this row skips
|
|
1371
|
+
// notInPath:["converging"], // static fallback used only when no field given
|
|
1372
|
+
// locus:{ field:"pr_key", stage?:"<key>",
|
|
1373
|
+
// link:{ kind:"page", page:"home" } } } // optional out-link
|
|
1374
|
+
// Rendering: stages BEFORE the active one are filled/completed, the active stage
|
|
1375
|
+
// is lit (its treatment driven by stateField — success ✓ vs failure ✕ vs blocked
|
|
1376
|
+
// ⊘ are made unmistakably distinct), in-path stages AFTER it are ghosted, and any
|
|
1377
|
+
// stage in the row's not-in-path set is dashed. Reuses the .pc-badge and .pc-link
|
|
1378
|
+
// classes and the shared pageHashHref route builder — no parallel implementations. Unknown or
|
|
1379
|
+
// missing config (no stages array) degrades gracefully to plain cell text.
|
|
1380
|
+
function pipelineCell(col, row, text, subText, truncate, tdAttrs) {
|
|
1381
|
+
// Drop null/undefined entries defensively: a schema with a hole must still
|
|
1382
|
+
// render (never throws on s.key) — same graceful-degradation goal as below.
|
|
1383
|
+
const stages = Array.isArray(col.stages) ? col.stages.filter((s) => s != null) : [];
|
|
1384
|
+
// Graceful fallback: an unrecognised/empty pipeline config renders plain text,
|
|
1385
|
+
// exactly like an unknown link.kind — never throws.
|
|
1386
|
+
if (stages.length === 0) return cellTd(text, text, subText, truncate, tdAttrs);
|
|
1387
|
+
// The current stage's key, matched against stages[].key to find the active index.
|
|
1388
|
+
// The row value is trimmed for consistency with the other row-derived keys
|
|
1389
|
+
// (pageHashHref, toStageSet), so a whitespace-padded value still matches its stage
|
|
1390
|
+
// instead of leaving the whole track rendered as upcoming.
|
|
1391
|
+
// Stage keys identify a single stage, so stop at the first match: the result is
|
|
1392
|
+
// deterministic even if a config accidentally repeats a key, and we avoid scanning
|
|
1393
|
+
// the remaining stages on every row.
|
|
1394
|
+
const activeKey = col.activeField != null && row[col.activeField] != null ? String(row[col.activeField]).trim() : "";
|
|
1395
|
+
let activeIdx = -1;
|
|
1396
|
+
for (let i = 0; i < stages.length; i++) {
|
|
1397
|
+
if (String(stages[i].key) === activeKey) {
|
|
1398
|
+
activeIdx = i;
|
|
1399
|
+
break;
|
|
1400
|
+
}
|
|
1401
|
+
}
|
|
1402
|
+
// The active stage's state drives its lit/failed/blocked treatment. Anything
|
|
1403
|
+
// outside the allow-list (or absent) is treated as an in-progress "active".
|
|
1404
|
+
const stRaw = col.stateField != null && row[col.stateField] != null ? String(row[col.stateField]) : "";
|
|
1405
|
+
const state = stRaw === "ok" || stRaw === "failed" || stRaw === "blocked" ? stRaw : "active";
|
|
1406
|
+
// Not-in-path (skipped) stages: driven per-row from notInPathField when given,
|
|
1407
|
+
// else the static notInPath config (per the issue: "per row, from a field").
|
|
1408
|
+
const skip =
|
|
1409
|
+
col.notInPathField != null && row[col.notInPathField] != null
|
|
1410
|
+
? toStageSet(row[col.notInPathField])
|
|
1411
|
+
: toStageSet(col.notInPath);
|
|
1412
|
+
// Optional active-stage badge (escalation/attention → warn, blocked/failure →
|
|
1413
|
+
// danger), reusing .pc-badge tone classes — no new badge styles. The badge text
|
|
1414
|
+
// is a single row-level value (row[badgeField]) shown only on the active stage.
|
|
1415
|
+
const badgeText = col.badgeField != null && row[col.badgeField] != null ? String(row[col.badgeField]) : "";
|
|
1416
|
+
const badgeTone = state === "failed" || state === "blocked" ? "danger" : "warn";
|
|
1417
|
+
// Optional locus out-link: the relevant stage (default the active one, or
|
|
1418
|
+
// locus.stage by key) becomes an in-app page link when the row's locus key is
|
|
1419
|
+
// non-empty. Href comes from the shared pageHashHref builder (safePageId guard,
|
|
1420
|
+
// encodeURIComponent) — a blank key yields "" so the stage stays plain.
|
|
1421
|
+
const locus = col.locus;
|
|
1422
|
+
const locusKey = locus && locus.field != null && row[locus.field] != null ? String(row[locus.field]) : "";
|
|
1423
|
+
const locusHref =
|
|
1424
|
+
locus && locus.link && locus.link.kind === "page" && locus.link.page ? pageHashHref(locus.link.page, locusKey) : "";
|
|
1425
|
+
const track = el("div", {
|
|
1426
|
+
class: "pc-pipe",
|
|
1427
|
+
role: "list",
|
|
1428
|
+
"aria-label": "Progress" + (activeIdx >= 0 ? ": " + labelOf(stages[activeIdx]) : ""),
|
|
1429
|
+
});
|
|
1430
|
+
for (let i = 0; i < stages.length; i++) {
|
|
1431
|
+
const s = stages[i];
|
|
1432
|
+
// Active wins over skip: if the active stage's key is also in the not-in-path
|
|
1433
|
+
// set, it must still render as the current step (active styling + aria-current),
|
|
1434
|
+
// never as skipped. Excluding activeIdx here keeps that precedence consistent
|
|
1435
|
+
// across the stage class, the aria-current mark, and the connector fill below.
|
|
1436
|
+
const skipped = i !== activeIdx && skip.has(String(s.key));
|
|
1437
|
+
// Connector between adjacent stages; filled up to and including the active
|
|
1438
|
+
// stage, but never across a not-in-path stage — a skipped predecessor breaks
|
|
1439
|
+
// the fill so we don't visually imply it was on the path.
|
|
1440
|
+
if (i > 0) {
|
|
1441
|
+
const filled =
|
|
1442
|
+
activeIdx >= 0 && i <= activeIdx && !skipped && !skip.has(String(stages[i - 1].key)) ? " filled" : "";
|
|
1443
|
+
track.append(el("span", { class: "pc-pipe-conn" + filled, "aria-hidden": "true" }));
|
|
1444
|
+
}
|
|
1445
|
+
let cls;
|
|
1446
|
+
let word;
|
|
1447
|
+
let glyph = "";
|
|
1448
|
+
let current = false;
|
|
1449
|
+
if (skipped) {
|
|
1450
|
+
cls = "pc-pipe-skip"; word = "skipped";
|
|
1451
|
+
} else if (activeIdx >= 0 && i < activeIdx) {
|
|
1452
|
+
cls = "pc-pipe-done"; word = "completed";
|
|
1453
|
+
} else if (i === activeIdx) {
|
|
1454
|
+
current = true;
|
|
1455
|
+
if (state === "failed") { cls = "pc-pipe-active pc-pipe-failed"; word = "failed"; glyph = "\u2715"; }
|
|
1456
|
+
else if (state === "blocked") { cls = "pc-pipe-active pc-pipe-blocked"; word = "blocked"; glyph = "\u2298"; }
|
|
1457
|
+
else if (state === "ok") { cls = "pc-pipe-active pc-pipe-ok"; word = "done"; glyph = "\u2713"; }
|
|
1458
|
+
else { cls = "pc-pipe-active"; word = "current"; }
|
|
1459
|
+
} else {
|
|
1460
|
+
cls = "pc-pipe-upcoming"; word = "upcoming";
|
|
1461
|
+
}
|
|
1462
|
+
const isLocus = locusHref !== "" && (locus.stage != null ? String(s.key) === String(locus.stage) : i === activeIdx);
|
|
1463
|
+
const label = labelOf(s);
|
|
1464
|
+
const labelNode = isLocus
|
|
1465
|
+
? el("a", { class: "pc-link pc-pipe-label", href: locusHref }, label)
|
|
1466
|
+
: el("span", { class: "pc-pipe-label" }, label);
|
|
1467
|
+
const attrs = { class: "pc-pipe-stage " + cls, role: "listitem", "aria-label": label + " \u2014 " + word };
|
|
1468
|
+
if (current) attrs["aria-current"] = "step";
|
|
1469
|
+
const stage = el("span", attrs, labelNode);
|
|
1470
|
+
if (glyph !== "") stage.append(el("span", { class: "pc-pipe-mark", "aria-hidden": "true" }, glyph));
|
|
1471
|
+
if (current && badgeText.trim() !== "") {
|
|
1472
|
+
stage.append(el("span", { class: "pc-badge pc-badge-" + badgeTone, title: badgeText, "aria-label": badgeText }, badgeText));
|
|
1473
|
+
}
|
|
1474
|
+
track.append(stage);
|
|
1475
|
+
}
|
|
1476
|
+
// The mobile card attrs (data-label + role class, #268) stamped by gridCell are
|
|
1477
|
+
// merged onto the pipeline <td> too, alongside its own pc-pipe-cell class, so a
|
|
1478
|
+
// pipeline column labels and classifies identically under the CSS-only card flip.
|
|
1479
|
+
const cellAttrs = { ...(tdAttrs || {}), class: (tdAttrs && tdAttrs.class ? tdAttrs.class + " " : "") + "pc-pipe-cell" };
|
|
1480
|
+
return el("td", cellAttrs, track);
|
|
1481
|
+
}
|
|
1482
|
+
|
|
1483
|
+
// A stage's visible label: its declared label, else its key as a fallback.
|
|
1484
|
+
function labelOf(stage) {
|
|
1485
|
+
return stage.label == null ? String(stage.key) : String(stage.label);
|
|
1126
1486
|
}
|
|
1127
1487
|
|
|
1128
1488
|
// Parse a "<number>%" width into its numeric percentage, or null if the string
|
|
@@ -1346,6 +1706,248 @@ function renderActionForm(node) {
|
|
|
1346
1706
|
// Single source of truth for both the initial render and every refresh below.
|
|
1347
1707
|
function rowCountLabel(n) { return n + (n === 1 ? " row" : " rows"); }
|
|
1348
1708
|
|
|
1709
|
+
// Build the /app/data/<source>/<table> URL a data-bound list renderer fetches,
|
|
1710
|
+
// threading the whitelisted where/order query params. Shared by dataGrid and the
|
|
1711
|
+
// prose renderer so the two can't drift on how a filter/orderBy is encoded. A
|
|
1712
|
+
// filter is { field, in:[…] } (IN set), { field, eqParam:true } (bound to the
|
|
1713
|
+
// current route PARAM — "show the selected entity's rows"), or { field, eq } (a
|
|
1714
|
+
// literal equals); orderBy is { field, dir }. The source/table/field names are
|
|
1715
|
+
// server-side whitelisted (the /app/data route rejects unknown columns).
|
|
1716
|
+
function dataUrl(source, tbl, filters, order) {
|
|
1717
|
+
let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
|
|
1718
|
+
const qs = [];
|
|
1719
|
+
for (const f of filters || []) {
|
|
1720
|
+
if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
|
|
1721
|
+
else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
|
|
1722
|
+
else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
|
|
1723
|
+
}
|
|
1724
|
+
if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
|
|
1725
|
+
return qs.length ? u + "?" + qs.join("&") : u;
|
|
1726
|
+
}
|
|
1727
|
+
|
|
1728
|
+
// ── Minimal, safe Markdown → DOM (no innerHTML) — used by the prose renderer ──
|
|
1729
|
+
// A dependency-free CommonMark subset. It NEVER sets innerHTML: every block and
|
|
1730
|
+
// inline token is materialised as a known element via el(), with text set through
|
|
1731
|
+
// textContent / createTextNode — so a record's body field may carry markdown
|
|
1732
|
+
// WITHOUT ever becoming an HTML/script injection vector (the same text-only
|
|
1733
|
+
// guarantee interpTemplate gives a grid cell). Supported: ATX headings
|
|
1734
|
+
// (#..######), fenced + inline code, bold (**/__), italic (*/_), links
|
|
1735
|
+
// [text](url) (http/https/mailto only — every other scheme degrades to its plain
|
|
1736
|
+
// label), unordered (-,*,+) and ordered (1. / 1)) lists, blockquotes (>),
|
|
1737
|
+
// thematic breaks (---), and blank-line-separated paragraphs. The backtick used
|
|
1738
|
+
// for code spans/fences is built from a char code so this source stays inside the
|
|
1739
|
+
// String.raw template literal (which is itself delimited by a backtick).
|
|
1740
|
+
const MD_BACKTICK = String.fromCharCode(96);
|
|
1741
|
+
function mdIsWordChar(c) { return typeof c === "string" && /[0-9A-Za-z]/.test(c); }
|
|
1742
|
+
// Only http(s)/mailto hrefs become anchors; anything else (javascript:, data:,
|
|
1743
|
+
// vbscript:, a bare relative path, …) returns "" so the caller renders the label
|
|
1744
|
+
// as plain text rather than a clickable, potentially-hostile link.
|
|
1745
|
+
function mdSafeHref(href) {
|
|
1746
|
+
const h = String(href == null ? "" : href).trim();
|
|
1747
|
+
return /^(?:https?:|mailto:)/i.test(h) ? h : "";
|
|
1748
|
+
}
|
|
1749
|
+
// Find the closing run of 'marker' at or after 'from'. For underscore emphasis the
|
|
1750
|
+
// close must not be intra-word (a following alphanumeric means snake_case, not a
|
|
1751
|
+
// delimiter), so keep scanning past such runs; -1 when there is no valid close.
|
|
1752
|
+
function mdFindClose(s, marker, from, underscore) {
|
|
1753
|
+
let j = from;
|
|
1754
|
+
while (true) {
|
|
1755
|
+
const k = s.indexOf(marker, j);
|
|
1756
|
+
if (k < 0) return -1;
|
|
1757
|
+
if (!underscore || !mdIsWordChar(s[k + marker.length])) return k;
|
|
1758
|
+
j = k + marker.length;
|
|
1759
|
+
}
|
|
1760
|
+
}
|
|
1761
|
+
function mdInline(text) {
|
|
1762
|
+
const s = String(text == null ? "" : text);
|
|
1763
|
+
const nodes = [];
|
|
1764
|
+
let buf = "";
|
|
1765
|
+
const flush = () => { if (buf) { nodes.push(document.createTextNode(buf)); buf = ""; } };
|
|
1766
|
+
let i = 0;
|
|
1767
|
+
while (i < s.length) {
|
|
1768
|
+
const ch = s[i];
|
|
1769
|
+
// Backslash escape: the next char is emitted literally, never as a delimiter.
|
|
1770
|
+
if (ch === "\\" && i + 1 < s.length) { buf += s[i + 1]; i += 2; continue; }
|
|
1771
|
+
// Inline code span — verbatim, no nested inline parsing.
|
|
1772
|
+
if (ch === MD_BACKTICK) {
|
|
1773
|
+
const end = s.indexOf(MD_BACKTICK, i + 1);
|
|
1774
|
+
if (end > i) { flush(); nodes.push(el("code", { class: "pc-md-code" }, s.slice(i + 1, end))); i = end + 1; continue; }
|
|
1775
|
+
}
|
|
1776
|
+
// Strong (** or __). Underscore only when it opens on a word boundary so an
|
|
1777
|
+
// identifier like a__b doesn't get mangled into emphasis.
|
|
1778
|
+
if ((ch === "*" || ch === "_") && s[i + 1] === ch) {
|
|
1779
|
+
const underscore = ch === "_";
|
|
1780
|
+
if (!underscore || !mdIsWordChar(s[i - 1])) {
|
|
1781
|
+
const marker = ch + ch;
|
|
1782
|
+
const end = mdFindClose(s, marker, i + 2, underscore);
|
|
1783
|
+
if (end > i + 1) { flush(); nodes.push(el("strong", {}, ...mdInline(s.slice(i + 2, end)))); i = end + 2; continue; }
|
|
1784
|
+
}
|
|
1785
|
+
}
|
|
1786
|
+
// Emphasis (* or _), same word-boundary guard for underscore.
|
|
1787
|
+
if (ch === "*" || ch === "_") {
|
|
1788
|
+
const underscore = ch === "_";
|
|
1789
|
+
if (!underscore || !mdIsWordChar(s[i - 1])) {
|
|
1790
|
+
const end = mdFindClose(s, ch, i + 1, underscore);
|
|
1791
|
+
if (end > i + 1) { flush(); nodes.push(el("em", {}, ...mdInline(s.slice(i + 1, end)))); i = end + 1; continue; }
|
|
1792
|
+
}
|
|
1793
|
+
}
|
|
1794
|
+
// Link [label](href): href sanitised; an unsafe scheme drops to the label text.
|
|
1795
|
+
if (ch === "[") {
|
|
1796
|
+
const close = s.indexOf("]", i + 1);
|
|
1797
|
+
if (close > i && s[close + 1] === "(") {
|
|
1798
|
+
const paren = s.indexOf(")", close + 2);
|
|
1799
|
+
if (paren > close) {
|
|
1800
|
+
const href = mdSafeHref(s.slice(close + 2, paren));
|
|
1801
|
+
const label = mdInline(s.slice(i + 1, close));
|
|
1802
|
+
flush();
|
|
1803
|
+
if (href) nodes.push(el("a", { href: href, target: "_blank", rel: "noopener noreferrer" }, ...label));
|
|
1804
|
+
else for (const l of label) nodes.push(l);
|
|
1805
|
+
i = paren + 1; continue;
|
|
1806
|
+
}
|
|
1807
|
+
}
|
|
1808
|
+
}
|
|
1809
|
+
buf += ch; i++;
|
|
1810
|
+
}
|
|
1811
|
+
flush();
|
|
1812
|
+
return nodes;
|
|
1813
|
+
}
|
|
1814
|
+
// Regexes that classify a block-opening line; shared by the block loop and the
|
|
1815
|
+
// paragraph terminator so the two can't disagree on where a paragraph ends.
|
|
1816
|
+
const MD_HEADING = /^(#{1,6})\s+(.*)$/;
|
|
1817
|
+
const MD_QUOTE = /^>\s?/;
|
|
1818
|
+
const MD_UL = /^[-*+]\s+/;
|
|
1819
|
+
const MD_OL = /^\d+[.)]\s+/;
|
|
1820
|
+
const MD_HR = /^([-*_])(?:\s*\1){2,}\s*$/;
|
|
1821
|
+
function mdBlockStart(t) {
|
|
1822
|
+
return t.slice(0, 3) === MD_BACKTICK + MD_BACKTICK + MD_BACKTICK
|
|
1823
|
+
|| MD_HEADING.test(t) || MD_QUOTE.test(t) || MD_UL.test(t) || MD_OL.test(t) || MD_HR.test(t);
|
|
1824
|
+
}
|
|
1825
|
+
// Parse a markdown string into an array of block-level DOM nodes.
|
|
1826
|
+
function mdToNodes(src) {
|
|
1827
|
+
const out = [];
|
|
1828
|
+
const fence = MD_BACKTICK + MD_BACKTICK + MD_BACKTICK;
|
|
1829
|
+
const lines = String(src == null ? "" : src).replace(/\r\n?/g, "\n").split("\n");
|
|
1830
|
+
let i = 0;
|
|
1831
|
+
while (i < lines.length) {
|
|
1832
|
+
const t = lines[i].trim();
|
|
1833
|
+
if (t === "") { i++; continue; }
|
|
1834
|
+
// Fenced code block — content is verbatim (no inline parsing).
|
|
1835
|
+
if (t.slice(0, 3) === fence) {
|
|
1836
|
+
const body = [];
|
|
1837
|
+
i++;
|
|
1838
|
+
while (i < lines.length && lines[i].trim().slice(0, 3) !== fence) { body.push(lines[i]); i++; }
|
|
1839
|
+
if (i < lines.length) i++; // consume the closing fence
|
|
1840
|
+
out.push(el("pre", { class: "pc-md-pre" }, el("code", {}, body.join("\n"))));
|
|
1841
|
+
continue;
|
|
1842
|
+
}
|
|
1843
|
+
// Thematic break (checked before lists so "***"/"---" isn't read as a bullet).
|
|
1844
|
+
if (MD_HR.test(t)) { out.push(el("hr", { class: "pc-md-hr" })); i++; continue; }
|
|
1845
|
+
// ATX heading.
|
|
1846
|
+
const h = MD_HEADING.exec(t);
|
|
1847
|
+
if (h) { out.push(el("h" + h[1].length, { class: "pc-md-h" }, ...mdInline(h[2].trim()))); i++; continue; }
|
|
1848
|
+
// Blockquote — strip one '>' per line and re-parse the inner block(s).
|
|
1849
|
+
if (MD_QUOTE.test(t)) {
|
|
1850
|
+
const inner = [];
|
|
1851
|
+
while (i < lines.length && MD_QUOTE.test(lines[i].trim())) { inner.push(lines[i].trim().replace(MD_QUOTE, "")); i++; }
|
|
1852
|
+
const bq = el("blockquote", { class: "pc-md-quote" });
|
|
1853
|
+
for (const b of mdToNodes(inner.join("\n"))) bq.append(b);
|
|
1854
|
+
out.push(bq); continue;
|
|
1855
|
+
}
|
|
1856
|
+
// Unordered list.
|
|
1857
|
+
if (MD_UL.test(t)) {
|
|
1858
|
+
const ul = el("ul", { class: "pc-md-list" });
|
|
1859
|
+
while (i < lines.length && MD_UL.test(lines[i].trim())) {
|
|
1860
|
+
ul.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_UL, ""))));
|
|
1861
|
+
i++;
|
|
1862
|
+
}
|
|
1863
|
+
out.push(ul); continue;
|
|
1864
|
+
}
|
|
1865
|
+
// Ordered list.
|
|
1866
|
+
if (MD_OL.test(t)) {
|
|
1867
|
+
const ol = el("ol", { class: "pc-md-list" });
|
|
1868
|
+
while (i < lines.length && MD_OL.test(lines[i].trim())) {
|
|
1869
|
+
ol.append(el("li", {}, ...mdInline(lines[i].trim().replace(MD_OL, ""))));
|
|
1870
|
+
i++;
|
|
1871
|
+
}
|
|
1872
|
+
out.push(ol); continue;
|
|
1873
|
+
}
|
|
1874
|
+
// Paragraph — soft-wrapped lines joined with a space until a blank line or the
|
|
1875
|
+
// next block opener.
|
|
1876
|
+
const para = [];
|
|
1877
|
+
while (i < lines.length) {
|
|
1878
|
+
const lt = lines[i].trim();
|
|
1879
|
+
if (lt === "" || mdBlockStart(lt)) break;
|
|
1880
|
+
para.push(lt); i++;
|
|
1881
|
+
}
|
|
1882
|
+
out.push(el("p", { class: "pc-md-p" }, ...mdInline(para.join(" "))));
|
|
1883
|
+
}
|
|
1884
|
+
return out;
|
|
1885
|
+
}
|
|
1886
|
+
|
|
1887
|
+
// A data-bound prose/markdown list (#274). Binds a datasource like dataGrid
|
|
1888
|
+
// (props.data = { source, table, orderBy, filter }) but renders each row as a
|
|
1889
|
+
// stacked prose block — a small header template (props.header, e.g.
|
|
1890
|
+
// "Round {{round}} · {{approved}}") over one body field (props.body) rendered as
|
|
1891
|
+
// sanitised markdown at a comfortable measure (props.measure ch, ~66 default).
|
|
1892
|
+
// This is the primitive for narrative records (plan-review findings, coordination
|
|
1893
|
+
// notes, escalation bodies) that read as a document, not a squeezed table cell.
|
|
1894
|
+
// It inherits collapsible/defaultCollapsed for free via makeCollapsible (which
|
|
1895
|
+
// unwraps the <h2> title), and refreshMs polling + pc:refresh like dataGrid.
|
|
1896
|
+
function renderProse(node) {
|
|
1897
|
+
const p = node.props || {};
|
|
1898
|
+
const data = p.data || {};
|
|
1899
|
+
const card = el("section", { class: "pc-card" });
|
|
1900
|
+
if (p.title) card.append(el("h2", {}, p.title));
|
|
1901
|
+
const listEl = el("div", { class: "pc-prose-list" });
|
|
1902
|
+
card.append(listEl);
|
|
1903
|
+
const headerTpl = typeof p.header === "string" ? p.header : null;
|
|
1904
|
+
const bodyField = p.body;
|
|
1905
|
+
// Clamp the reading measure to a sane range so a malformed schema can't set a
|
|
1906
|
+
// 5000ch (full-bleed) or 2ch (one-word-per-line) body. ~66ch is the default.
|
|
1907
|
+
let measure = Number(p.measure);
|
|
1908
|
+
if (!Number.isFinite(measure)) measure = 66;
|
|
1909
|
+
measure = Math.max(40, Math.min(100, Math.round(measure)));
|
|
1910
|
+
const activeFilter = Array.isArray(data.filter) ? data.filter : [];
|
|
1911
|
+
|
|
1912
|
+
function itemFor(row) {
|
|
1913
|
+
const item = el("article", { class: "pc-prose-item" });
|
|
1914
|
+
if (headerTpl) item.append(el("div", { class: "pc-prose-head" }, interpTemplate(headerTpl, row)));
|
|
1915
|
+
const bodyEl = el("div", { class: "pc-prose-body", style: "max-width:" + measure + "ch" });
|
|
1916
|
+
// Own-property gate (matching interpTemplate) so a body field name that
|
|
1917
|
+
// collides with a prototype key can't pick up prototype cruft.
|
|
1918
|
+
const raw = bodyField != null && Object.prototype.hasOwnProperty.call(row, bodyField) && row[bodyField] != null
|
|
1919
|
+
? String(row[bodyField])
|
|
1920
|
+
: "";
|
|
1921
|
+
for (const b of mdToNodes(raw)) bodyEl.append(b);
|
|
1922
|
+
item.append(bodyEl);
|
|
1923
|
+
return item;
|
|
1924
|
+
}
|
|
1925
|
+
|
|
1926
|
+
async function refresh() {
|
|
1927
|
+
try {
|
|
1928
|
+
// A param-scoped list ("show the selected entity's records") with no route
|
|
1929
|
+
// param present renders nothing rather than a field=empty-string query.
|
|
1930
|
+
const paramScoped = (activeFilter || []).some((f) => f && f.eqParam);
|
|
1931
|
+
const { rows } = paramScoped && PARAM === ""
|
|
1932
|
+
? { rows: [] }
|
|
1933
|
+
: await getJSON(dataUrl(data.source, data.table, activeFilter, data.orderBy));
|
|
1934
|
+
listEl.replaceChildren();
|
|
1935
|
+
if (!rows.length) { listEl.append(el("p", { class: "pc-prose-empty" }, p.empty || "No records")); return; }
|
|
1936
|
+
for (const row of rows) listEl.append(itemFor(row));
|
|
1937
|
+
} catch (e) {
|
|
1938
|
+
listEl.replaceChildren(el("p", { class: "pc-msg err" }, String((e && e.message) || e)));
|
|
1939
|
+
}
|
|
1940
|
+
}
|
|
1941
|
+
document.addEventListener("pc:refresh", refresh);
|
|
1942
|
+
disposers.push(() => document.removeEventListener("pc:refresh", refresh));
|
|
1943
|
+
if (p.refreshMs && p.refreshMs > 0) {
|
|
1944
|
+
const timer = setInterval(refresh, p.refreshMs);
|
|
1945
|
+
disposers.push(() => clearInterval(timer));
|
|
1946
|
+
}
|
|
1947
|
+
refresh();
|
|
1948
|
+
return card;
|
|
1949
|
+
}
|
|
1950
|
+
|
|
1349
1951
|
function renderDataGrid(node) {
|
|
1350
1952
|
const p = node.props;
|
|
1351
1953
|
const card = el("section", { class: "pc-card" });
|
|
@@ -1360,6 +1962,12 @@ function renderDataGrid(node) {
|
|
|
1360
1962
|
card.append(h2);
|
|
1361
1963
|
}
|
|
1362
1964
|
const cols = p.columns || [];
|
|
1965
|
+
// Mobile card roles per column (derived by convention, refined by
|
|
1966
|
+
// col.mobile.priority) so the grid can flip to a stacked card list on a phone
|
|
1967
|
+
// (#268). 'hasHidden' gates the per-row "More" toggle that reveals the columns
|
|
1968
|
+
// a mobile:{priority:"hidden"} hint drops off the card.
|
|
1969
|
+
const roles = classifyColumns(cols);
|
|
1970
|
+
const hasHidden = roles.indexOf("hidden") >= 0;
|
|
1363
1971
|
const tabs = p.tabs || [];
|
|
1364
1972
|
const rowActions = p.rowActions || [];
|
|
1365
1973
|
const detail = p.detail || null;
|
|
@@ -1413,18 +2021,6 @@ function renderDataGrid(node) {
|
|
|
1413
2021
|
return v == null ? null : String(v);
|
|
1414
2022
|
};
|
|
1415
2023
|
|
|
1416
|
-
function dataUrl(source, tbl, filters, order) {
|
|
1417
|
-
let u = "/app/data/" + encodeURIComponent(source) + "/" + encodeURIComponent(tbl);
|
|
1418
|
-
const qs = [];
|
|
1419
|
-
for (const f of filters || []) {
|
|
1420
|
-
if (Array.isArray(f.in)) qs.push("where=" + encodeURIComponent(f.field + ":in:" + f.in.join(",")));
|
|
1421
|
-
else if (f.eqParam) qs.push("where=" + encodeURIComponent(f.field + ":" + PARAM));
|
|
1422
|
-
else qs.push("where=" + encodeURIComponent(f.field + ":" + f.eq));
|
|
1423
|
-
}
|
|
1424
|
-
if (order && order.field) qs.push("order=" + encodeURIComponent(order.field + ":" + (order.dir || "asc")));
|
|
1425
|
-
return qs.length ? u + "?" + qs.join("&") : u;
|
|
1426
|
-
}
|
|
1427
|
-
|
|
1428
2024
|
async function fireAction(action, row) {
|
|
1429
2025
|
// Route-driven row action: POST the action's body template (default {})
|
|
1430
2026
|
// to its route path, with {{row.KEY}} tokens resolved from this row.
|
|
@@ -1483,6 +2079,8 @@ function renderDataGrid(node) {
|
|
|
1483
2079
|
const wrap = el("div", { class: "pc-child" });
|
|
1484
2080
|
if (cg.title) wrap.append(el("div", { class: "pc-child-title" }, cg.title));
|
|
1485
2081
|
const ccols = cg.columns || [];
|
|
2082
|
+
const croles = classifyColumns(ccols);
|
|
2083
|
+
const chasHidden = croles.indexOf("hidden") >= 0;
|
|
1486
2084
|
const cbody = el("tbody", {});
|
|
1487
2085
|
const ccolgroup = buildColgroup(ccols, cg.lazyField ? 1 : 0);
|
|
1488
2086
|
const cthead = el("thead", {}, el("tr", {}, ...ccols.map((c) => el("th", {}, c.header || c.field)),
|
|
@@ -1499,7 +2097,7 @@ function renderDataGrid(node) {
|
|
|
1499
2097
|
cbody.append(el("tr", {}, el("td", { colspan: cspan }, "None")));
|
|
1500
2098
|
}
|
|
1501
2099
|
for (const cr of rows) {
|
|
1502
|
-
const cells = ccols.map((c) => gridCell(c, cr));
|
|
2100
|
+
const cells = ccols.map((c, i) => gridCell(c, cr, croles[i]));
|
|
1503
2101
|
if (cg.lazyField) {
|
|
1504
2102
|
const lf = cg.lazyField;
|
|
1505
2103
|
const has = cr[lf.field] != null && String(cr[lf.field]).trim() !== "";
|
|
@@ -1516,7 +2114,13 @@ function renderDataGrid(node) {
|
|
|
1516
2114
|
}
|
|
1517
2115
|
cells.push(cell);
|
|
1518
2116
|
}
|
|
1519
|
-
|
|
2117
|
+
// Child grids classify columns into the same mobile roles (including
|
|
2118
|
+
// "hidden") as the top-level grid, so they need the same per-row "More"
|
|
2119
|
+
// toggle — without it a mobile:{priority:"hidden"} child column would be
|
|
2120
|
+
// permanently unreachable on a narrow viewport (#268).
|
|
2121
|
+
const ctr = el("tr", {}, ...cells);
|
|
2122
|
+
if (chasHidden) ctr.append(mobileMoreCell(ctr));
|
|
2123
|
+
cbody.append(ctr);
|
|
1520
2124
|
}
|
|
1521
2125
|
} catch (e) {
|
|
1522
2126
|
cbody.append(el("tr", {}, el("td", { colspan: cspan }, String(e.message || e))));
|
|
@@ -1553,7 +2157,7 @@ function renderDataGrid(node) {
|
|
|
1553
2157
|
}
|
|
1554
2158
|
|
|
1555
2159
|
function renderRow(row, sink) {
|
|
1556
|
-
const cells = cols.map((c) => gridCell(c, row));
|
|
2160
|
+
const cells = cols.map((c, i) => gridCell(c, row, roles[i]));
|
|
1557
2161
|
const key = rowKeyOf(row);
|
|
1558
2162
|
let toggle = null;
|
|
1559
2163
|
if (hasExtra) {
|
|
@@ -1568,7 +2172,12 @@ function renderDataGrid(node) {
|
|
|
1568
2172
|
}
|
|
1569
2173
|
cells.push(actionCell);
|
|
1570
2174
|
}
|
|
2175
|
+
// Per-row "More" toggle: only when a mobile:{priority:"hidden"} hint drops
|
|
2176
|
+
// columns off the card. Appended after the <tr> is built (the toggle wires a
|
|
2177
|
+
// click against its own row) and shared with child grids via mobileMoreCell so
|
|
2178
|
+
// the reveal affordance and its aria state can't drift (#268).
|
|
1571
2179
|
const tr = el("tr", {}, ...cells);
|
|
2180
|
+
if (hasHidden) tr.append(mobileMoreCell(tr));
|
|
1572
2181
|
tbody.append(tr);
|
|
1573
2182
|
// When grouping, the caller collects every <tr> this row produced (the data
|
|
1574
2183
|
// row plus any detail row) so a group header can hide/show them as one unit.
|
|
@@ -1817,7 +2426,7 @@ function renderNav(node) {
|
|
|
1817
2426
|
return nav;
|
|
1818
2427
|
}
|
|
1819
2428
|
|
|
1820
|
-
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, nav: renderNav, button: renderButton };
|
|
2429
|
+
const RENDERERS = { text: renderText, actionForm: renderActionForm, dataGrid: renderDataGrid, prose: renderProse, nav: renderNav, button: renderButton };
|
|
1821
2430
|
|
|
1822
2431
|
// Durable per-node UI state. localStorage is keyed by the home page id + node
|
|
1823
2432
|
// id so two grids on the same page (or the same grid across pages) don't clash,
|
|
@@ -1899,19 +2508,31 @@ function makeCollapsible(node, card) {
|
|
|
1899
2508
|
return container;
|
|
1900
2509
|
}
|
|
1901
2510
|
|
|
2511
|
+
// Tier-2 escape hatch (#268): a page may declare a distinct 'mobile' layout
|
|
2512
|
+
// variant — its own node list — for the rare case the derived card flip isn't
|
|
2513
|
+
// enough (reorder/omit sections, swap a dense grid for a summary card). It is
|
|
2514
|
+
// used ONLY on a narrow viewport; the default 'nodes' render everywhere else,
|
|
2515
|
+
// and anything malformed falls back to them, so the variant is purely additive.
|
|
2516
|
+
function pickNodes(doc) {
|
|
2517
|
+
if (isNarrow() && doc && doc.mobile && Array.isArray(doc.mobile.nodes)) return doc.mobile.nodes;
|
|
2518
|
+
return (doc && doc.nodes) || [];
|
|
2519
|
+
}
|
|
2520
|
+
|
|
1902
2521
|
// Render the page named by the current hash route. A rail nav is lifted into a
|
|
1903
2522
|
// left <aside> and the rest of the nodes flow into a content column; otherwise
|
|
1904
2523
|
// (bar nav or none) nodes render in document order. Re-runs on every hashchange,
|
|
1905
2524
|
// tearing down the previous page's grid polls first.
|
|
1906
2525
|
async function renderPage() {
|
|
1907
2526
|
teardown();
|
|
2527
|
+
const mySeq = ++renderSeq;
|
|
1908
2528
|
const route = parseRoute();
|
|
1909
2529
|
CURRENT = route.page;
|
|
1910
2530
|
PARAM = route.param;
|
|
1911
2531
|
try {
|
|
1912
2532
|
const doc = await getJSON("/app/pages/" + encodeURIComponent(CURRENT));
|
|
2533
|
+
if (mySeq !== renderSeq) return;
|
|
1913
2534
|
document.title = (doc && doc.title) || "Urban App";
|
|
1914
|
-
const nodes = (doc
|
|
2535
|
+
const nodes = pickNodes(doc);
|
|
1915
2536
|
const built = nodes.map((n) => ({ n, node: makeCollapsible(n, (RENDERERS[n.type] || (() => el("div")))(n)) }));
|
|
1916
2537
|
const rail = built.find((b) => b.n.type === "nav" && b.n.props && b.n.props.variant === "rail");
|
|
1917
2538
|
if (rail) {
|
|
@@ -1922,10 +2543,20 @@ async function renderPage() {
|
|
|
1922
2543
|
root.replaceChildren(...built.map((b) => b.node));
|
|
1923
2544
|
}
|
|
1924
2545
|
} catch (e) {
|
|
2546
|
+
if (mySeq !== renderSeq) return;
|
|
1925
2547
|
root.replaceChildren(el("p", { class: "pc-msg err" }, "Failed to load page: " + String((e && e.message) || e)));
|
|
1926
2548
|
}
|
|
1927
2549
|
}
|
|
1928
2550
|
window.addEventListener("hashchange", renderPage);
|
|
2551
|
+
// Re-render when the viewport crosses the mobile breakpoint so a Tier-2 'mobile'
|
|
2552
|
+
// variant swaps in/out on rotation or a window resize (the grid card flip, nav
|
|
2553
|
+
// collapse and button stacking are pure CSS and need no re-render). teardown()
|
|
2554
|
+
// inside renderPage stops the outgoing page's grid polls first. Prefer the modern
|
|
2555
|
+
// addEventListener but fall back to the deprecated addListener so older Safari/iOS
|
|
2556
|
+
// (< 14), where MediaQueryList.addEventListener is unimplemented, still swaps the
|
|
2557
|
+
// Tier-2 variant on rotation/resize instead of silently failing to register.
|
|
2558
|
+
if (typeof MOBILE_MQ.addEventListener === "function") MOBILE_MQ.addEventListener("change", renderPage);
|
|
2559
|
+
else if (typeof MOBILE_MQ.addListener === "function") MOBILE_MQ.addListener(renderPage);
|
|
1929
2560
|
renderPage();
|
|
1930
2561
|
`;
|
|
1931
2562
|
/** A tiny, dependency-free FNV-1a hash → 8 hex chars. Used to fingerprint the renderer
|