@nanobpm/urban 0.51.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (73) 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/execContext.d.ts +5 -0
  5. package/dist/runtime/core/extensions.d.ts +131 -0
  6. package/dist/runtime/core/extensions.js +137 -0
  7. package/dist/runtime/core/glob.d.ts +7 -6
  8. package/dist/runtime/core/glob.js +16 -14
  9. package/dist/runtime/core/host.d.ts +4 -1
  10. package/dist/runtime/core/lineage.d.ts +116 -0
  11. package/dist/runtime/core/lineage.js +186 -0
  12. package/dist/runtime/core/modules/agent.d.ts +17 -0
  13. package/dist/runtime/core/modules/agent.js +63 -0
  14. package/dist/runtime/core/modules/datasource.d.ts +6 -0
  15. package/dist/runtime/core/modules/datasource.js +8 -0
  16. package/dist/runtime/core/modules/deploy.d.ts +8 -5
  17. package/dist/runtime/core/modules/deploy.js +62 -16
  18. package/dist/runtime/core/modules/lineage-store.d.ts +81 -0
  19. package/dist/runtime/core/modules/lineage-store.js +175 -0
  20. package/dist/runtime/core/modules/pages.js +397 -20
  21. package/dist/runtime/core/modules/surfaces.js +8 -0
  22. package/dist/runtime/core/modules/workers.js +69 -2
  23. package/dist/runtime/core/phase.d.ts +168 -0
  24. package/dist/runtime/core/phase.js +367 -0
  25. package/dist/runtime/core/runtime.d.ts +17 -0
  26. package/dist/runtime/core/runtime.js +30 -0
  27. package/dist/runtime/engine/nanosdk.js +8 -2
  28. package/dist/runtime/index.d.ts +10 -0
  29. package/dist/runtime/index.js +12 -0
  30. package/dist/toolkit/derivers/system-brief.d.ts +62 -0
  31. package/dist/toolkit/derivers/system-brief.js +156 -0
  32. package/dist/toolkit/gen.d.ts +5 -5
  33. package/dist/toolkit/gen.js +20 -14
  34. package/dist/toolkit/index.d.ts +5 -0
  35. package/dist/toolkit/index.js +3 -0
  36. package/dist/toolkit/models.d.ts +4 -3
  37. package/dist/toolkit/models.js +4 -3
  38. package/package.json +1 -1
  39. package/src/runtime/core/events.test.ts +382 -0
  40. package/src/runtime/core/events.ts +370 -0
  41. package/src/runtime/core/execContext.ts +5 -0
  42. package/src/runtime/core/extensions.test.ts +378 -0
  43. package/src/runtime/core/extensions.ts +276 -0
  44. package/src/runtime/core/glob.ts +15 -12
  45. package/src/runtime/core/host.ts +4 -1
  46. package/src/runtime/core/lineage.test.ts +215 -0
  47. package/src/runtime/core/lineage.ts +297 -0
  48. package/src/runtime/core/modules/agent.test.ts +104 -0
  49. package/src/runtime/core/modules/agent.ts +76 -0
  50. package/src/runtime/core/modules/datasource.ts +9 -0
  51. package/src/runtime/core/modules/deploy.test.ts +84 -16
  52. package/src/runtime/core/modules/deploy.ts +59 -16
  53. package/src/runtime/core/modules/lineage-store.test.ts +177 -0
  54. package/src/runtime/core/modules/lineage-store.ts +239 -0
  55. package/src/runtime/core/modules/pages.test.ts +309 -5
  56. package/src/runtime/core/modules/pages.ts +398 -20
  57. package/src/runtime/core/modules/surfaces.ts +9 -0
  58. package/src/runtime/core/modules/workers.test.ts +136 -1
  59. package/src/runtime/core/modules/workers.ts +72 -6
  60. package/src/runtime/core/phase.test.ts +350 -0
  61. package/src/runtime/core/phase.ts +526 -0
  62. package/src/runtime/core/runtime.extensions.test.ts +151 -0
  63. package/src/runtime/core/runtime.ts +49 -0
  64. package/src/runtime/devserver.test.ts +3 -0
  65. package/src/runtime/engine/nanosdk.test.ts +97 -3
  66. package/src/runtime/engine/nanosdk.ts +8 -2
  67. package/src/runtime/index.ts +92 -0
  68. package/src/toolkit/derivers/system-brief.test.ts +150 -0
  69. package/src/toolkit/derivers/system-brief.ts +210 -0
  70. package/src/toolkit/gen.test.ts +28 -4
  71. package/src/toolkit/gen.ts +20 -13
  72. package/src/toolkit/index.ts +14 -0
  73. package/src/toolkit/models.ts +4 -3
@@ -0,0 +1,239 @@
1
+ // lineage-store — the durable read-model projection behind the lineage primitive (issue #254).
2
+ //
3
+ // A framework-level, per-source sidecar (like the write-provenance table) that materialises the
4
+ // lineage edges the SDK auto-threads (`_urban.lineage`) and — where the engine emits it — the
5
+ // native execution edge (`parentProcessInstanceKey`, Magikcraft/nano-bpm#808). `getLineage(root)`
6
+ // unions both and returns the stitched descendant tree for one root request. Apps attach their own
7
+ // domain rows (PRs, tasks) to a node via {@link LineageStore.attach} WITHOUT the framework knowing
8
+ // their schema — the extension point that lets intent→progress visualisation be app-agnostic.
9
+ //
10
+ // The tree assembly itself is the pure {@link buildLineageTree}; this store is only its thin,
11
+ // idempotent persistence + query layer, so recording the same instance twice (an engine job
12
+ // re-activation) collapses to a no-op.
13
+
14
+ import type { SqliteDb } from "../host.ts";
15
+ import {
16
+ buildLineageTree,
17
+ type LineageAttachment,
18
+ type LineageEdge,
19
+ type LineageEdgeType,
20
+ type LineageTree,
21
+ readLineage,
22
+ } from "../lineage.ts";
23
+
24
+ /** The lineage edge table name. Framework bookkeeping, so `_urban_`-prefixed to stay hidden from
25
+ * the domain model / DB Manager (see `SqliteGateway.schema()`), like the migrations ledger. */
26
+ export const LINEAGE_EDGES_TABLE = "_urban_lineage_edges";
27
+ /** The lineage attachment table name (app-registered domain rows on a node). `_urban_`-prefixed for
28
+ * the same reason as {@link LINEAGE_EDGES_TABLE}. */
29
+ export const LINEAGE_ATTACHMENTS_TABLE = "_urban_lineage_attachments";
30
+
31
+ /**
32
+ * The canonical lineage DDL — the single source of truth applied by
33
+ * {@link LineageStore.ensureSchema}, mirrored verbatim by the boot migration
34
+ * `db/migrations/004_urban_lineage.sql` (a drift-guard test asserts they match).
35
+ *
36
+ * Forward-only and additive. `_urban_lineage_edges` is keyed by `(instance_key, edge_type)` so a
37
+ * node can hold both its weak (envelope) and strong (execution) edge at once and a re-record is a
38
+ * no-op; `_urban_lineage_attachments` is keyed by `(node_key, kind, ref)` so re-attaching the same
39
+ * domain row is idempotent. Both carry `root_request_key` with an index so a `getLineage(root)`
40
+ * reads only that thread.
41
+ */
42
+ export const LINEAGE_SCHEMA_SQL = `CREATE TABLE IF NOT EXISTS ${LINEAGE_EDGES_TABLE} (
43
+ root_request_key TEXT NOT NULL,
44
+ instance_key TEXT NOT NULL,
45
+ caused_by_instance_key TEXT,
46
+ edge_type TEXT NOT NULL DEFAULT 'weak',
47
+ created_at TEXT NOT NULL,
48
+ PRIMARY KEY (instance_key, edge_type)
49
+ );
50
+ CREATE INDEX IF NOT EXISTS idx_${LINEAGE_EDGES_TABLE}_root ON ${LINEAGE_EDGES_TABLE} (root_request_key);
51
+ CREATE TABLE IF NOT EXISTS ${LINEAGE_ATTACHMENTS_TABLE} (
52
+ root_request_key TEXT NOT NULL,
53
+ node_key TEXT NOT NULL,
54
+ kind TEXT NOT NULL,
55
+ ref TEXT NOT NULL,
56
+ label TEXT,
57
+ created_at TEXT NOT NULL,
58
+ PRIMARY KEY (node_key, kind, ref)
59
+ );
60
+ CREATE INDEX IF NOT EXISTS idx_${LINEAGE_ATTACHMENTS_TABLE}_root ON ${LINEAGE_ATTACHMENTS_TABLE} (root_request_key);`;
61
+
62
+ /** A monotonic wall clock, injectable for deterministic `created_at` in tests. */
63
+ export interface Clock {
64
+ now(): number;
65
+ }
66
+
67
+ /** The default clock: `Date.now()`. */
68
+ export const systemClock: Clock = { now: () => Date.now() };
69
+
70
+ /** A minimal job shape {@link LineageStore.recordFromJob} reads — a structural subset of the
71
+ * runtime's `EngineJob` plus the future engine execution edge (`parentProcessInstanceKey`,
72
+ * Magikcraft/nano-bpm#808), consumed as a strong edge where present. */
73
+ export interface LineageJobLike {
74
+ readonly processInstanceKey?: string;
75
+ readonly variables?: Record<string, unknown>;
76
+ /** The engine-native execution parent, when the engine emits it (#808). */
77
+ readonly parentProcessInstanceKey?: string;
78
+ }
79
+
80
+ export interface LineageStoreOptions {
81
+ readonly clock?: Clock;
82
+ }
83
+
84
+ interface EdgeRow {
85
+ root_request_key: string;
86
+ instance_key: string;
87
+ caused_by_instance_key: string | null;
88
+ edge_type: string;
89
+ }
90
+
91
+ interface AttachmentRow {
92
+ root_request_key: string;
93
+ node_key: string;
94
+ kind: string;
95
+ ref: string;
96
+ label: string | null;
97
+ }
98
+
99
+ function normalizeEdgeType(raw: string): LineageEdgeType {
100
+ return raw === "strong" ? "strong" : "weak";
101
+ }
102
+
103
+ export class LineageStore {
104
+ readonly #db: SqliteDb;
105
+ readonly #clock: Clock;
106
+
107
+ constructor(db: SqliteDb, options: LineageStoreOptions = {}) {
108
+ this.#db = db;
109
+ this.#clock = options.clock ?? systemClock;
110
+ }
111
+
112
+ /** Apply the canonical lineage DDL (idempotent). Callers whose app DataLayer applies the boot
113
+ * migration `db/migrations/004_urban_lineage.sql` do not need this; the runtime calls it so the
114
+ * store is usable against a bare source too. The DDL is identical to the migration (drift-guarded). */
115
+ ensureSchema(): void {
116
+ this.#db.exec(LINEAGE_SCHEMA_SQL);
117
+ }
118
+
119
+ /**
120
+ * Record one lineage edge, idempotently. A re-record of the same `(instance_key, edge_type)` is a
121
+ * no-op (an engine may re-activate a job on retry), so recording is safe to drive from any
122
+ * at-least-once observation point. A blank `rootRequestKey` or `instanceKey` is ignored.
123
+ */
124
+ recordEdge(edge: LineageEdge): boolean {
125
+ if (!edge.rootRequestKey || !edge.instanceKey) return false;
126
+ const { changes } = this.#db.run(
127
+ `INSERT OR IGNORE INTO ${LINEAGE_EDGES_TABLE}
128
+ (root_request_key, instance_key, caused_by_instance_key, edge_type, created_at)
129
+ VALUES (?, ?, ?, ?, ?)`,
130
+ [
131
+ edge.rootRequestKey,
132
+ edge.instanceKey,
133
+ edge.causedByInstanceKey ?? null,
134
+ edge.edgeType,
135
+ new Date(this.#clock.now()).toISOString(),
136
+ ],
137
+ );
138
+ return changes > 0;
139
+ }
140
+
141
+ /**
142
+ * Record the WEAK edge carried by an instance's `_urban.lineage` envelope. `instanceKey` is the
143
+ * effect/descendant node (a created instance's returned key, or a running job's own instance).
144
+ * A no-op when the variables carry no valid envelope.
145
+ */
146
+ recordEnvelope(instanceKey: string | undefined, variables: unknown): boolean {
147
+ if (!instanceKey) return false;
148
+ const env = readLineage(variables);
149
+ if (!env) return false;
150
+ return this.recordEdge({
151
+ rootRequestKey: env.rootRequestKey,
152
+ instanceKey,
153
+ causedByInstanceKey: env.causedByInstanceKey,
154
+ edgeType: "weak",
155
+ });
156
+ }
157
+
158
+ /**
159
+ * Record every edge an activated job reveals about its own instance: the weak envelope edge, and
160
+ * — where the engine supplies `parentProcessInstanceKey` (#808) — the strong execution edge under
161
+ * the same root. The natural single observation point for the worker runtime, since every instance
162
+ * that does work activates a job carrying its envelope. Never throws for a malformed job.
163
+ */
164
+ recordFromJob(job: LineageJobLike): void {
165
+ const env = readLineage(job.variables);
166
+ if (!env || !job.processInstanceKey) return;
167
+ this.recordEnvelope(job.processInstanceKey, job.variables);
168
+ if (job.parentProcessInstanceKey) {
169
+ this.recordEdge({
170
+ rootRequestKey: env.rootRequestKey,
171
+ instanceKey: job.processInstanceKey,
172
+ causedByInstanceKey: job.parentProcessInstanceKey,
173
+ edgeType: "strong",
174
+ });
175
+ }
176
+ }
177
+
178
+ /**
179
+ * Attach an app-defined domain row to a lineage node, idempotently. The framework stores it
180
+ * opaquely by `(node_key, kind, ref)` — it never interprets the app's schema. This is the
181
+ * extension point that lets an app hang its PRs/tasks off the lineage tree.
182
+ */
183
+ attach(rootRequestKey: string, attachment: LineageAttachment): boolean {
184
+ if (!rootRequestKey || !attachment.nodeKey || !attachment.kind || !attachment.ref) return false;
185
+ const { changes } = this.#db.run(
186
+ `INSERT OR IGNORE INTO ${LINEAGE_ATTACHMENTS_TABLE}
187
+ (root_request_key, node_key, kind, ref, label, created_at)
188
+ VALUES (?, ?, ?, ?, ?, ?)`,
189
+ [
190
+ rootRequestKey,
191
+ attachment.nodeKey,
192
+ attachment.kind,
193
+ attachment.ref,
194
+ attachment.label ?? null,
195
+ new Date(this.#clock.now()).toISOString(),
196
+ ],
197
+ );
198
+ return changes > 0;
199
+ }
200
+
201
+ /** The persisted edges for one root, in insertion order. */
202
+ edges(rootRequestKey: string): LineageEdge[] {
203
+ const rows = this.#db.all<EdgeRow>(
204
+ `SELECT root_request_key, instance_key, caused_by_instance_key, edge_type
205
+ FROM ${LINEAGE_EDGES_TABLE} WHERE root_request_key = ? ORDER BY rowid`,
206
+ [rootRequestKey],
207
+ );
208
+ return rows.map((r) => ({
209
+ rootRequestKey: r.root_request_key,
210
+ instanceKey: r.instance_key,
211
+ causedByInstanceKey: r.caused_by_instance_key ?? undefined,
212
+ edgeType: normalizeEdgeType(r.edge_type),
213
+ }));
214
+ }
215
+
216
+ /** The persisted attachments for one root, in insertion order. */
217
+ attachments(rootRequestKey: string): LineageAttachment[] {
218
+ const rows = this.#db.all<AttachmentRow>(
219
+ `SELECT root_request_key, node_key, kind, ref, label
220
+ FROM ${LINEAGE_ATTACHMENTS_TABLE} WHERE root_request_key = ? ORDER BY rowid`,
221
+ [rootRequestKey],
222
+ );
223
+ return rows.map((r) => ({
224
+ nodeKey: r.node_key,
225
+ kind: r.kind,
226
+ ref: r.ref,
227
+ ...(r.label ? { label: r.label } : {}),
228
+ }));
229
+ }
230
+
231
+ /**
232
+ * The stitched descendant tree for `rootRequestKey`, unioning the weak (envelope) and strong
233
+ * (engine execution) edges and hanging each app attachment off its node. The generic read
234
+ * surface any Urban app queries for intent→progress.
235
+ */
236
+ getLineage(rootRequestKey: string): LineageTree {
237
+ return buildLineageTree(rootRequestKey, this.edges(rootRequestKey), this.attachments(rootRequestKey));
238
+ }
239
+ }
@@ -344,6 +344,14 @@ test("renderer wires a column's badge to a tone-classed pill shown only when pre
344
344
  assert.match(js, /class:\s*"pc-badge pc-badge-"\s*\+\s*tone/);
345
345
  assert.match(js, /title:\s*rawText/);
346
346
  assert.match(js, /"aria-label":\s*rawText/);
347
+ // Even a blank badge cell stamps data-label, upholding the "every td carries
348
+ // data-label" invariant so empty and non-empty badge cells stay consistent
349
+ // (the mobile pc-mcell-chip:empty rule still collapses it — attributes don't
350
+ // defeat :empty).
351
+ assert.match(
352
+ js,
353
+ /rawText\.trim\(\)\s*===\s*""\s*\)\s*return el\("td",\s*\{ class: "pc-mcell pc-mcell-chip", "data-label": mlabel \}\)/,
354
+ );
347
355
  });
348
356
 
349
357
  test("renderer interpolates a column's per-cell template into DOM text (#214)", async () => {
@@ -386,7 +394,7 @@ test("renderer renders a column's subtitle as a muted second line, never an href
386
394
  assert.match(js, /class: "pc-cell-sub"/);
387
395
  // Absent subtitle (empty) and no truncation collapses back to the single inline
388
396
  // node, so existing grids render exactly as before (backward compatible).
389
- assert.match(js, /if \(subText === "" && !truncate\) return el\("td", \{\}, primary\);/);
397
+ assert.match(js, /if \(subText === "" && !truncate\) return el\("td", td, primary\);/);
390
398
  });
391
399
 
392
400
  test("renderer truncates a column to one line with an ellipsis + full-text tooltip (#257)", async () => {
@@ -515,11 +523,15 @@ test("renderer wires a column's page link to an in-app scoped hash route", async
515
523
  const res = await dispatch("GET", "/app/runtime.js");
516
524
  const js = res.body ?? "";
517
525
  // link: { kind: "page", page, keyField } → an in-app #/<page>/<key> hash link
518
- // (no new tab, no server hit). The page id is safe-id-validated, the key is
519
- // trimmed + URL-encoded into the param tail, and a blank/keyless cell stays text.
526
+ // (no new tab, no server hit). The route is built by the shared pageHashHref
527
+ // helper: the page id is safe-id-validated, the key is trimmed + URL-encoded
528
+ // into the param tail, and a blank/keyless cell stays text.
520
529
  assert.match(js, /col\.link && col\.link\.kind === "page" && col\.link\.page && col\.link\.keyField/);
521
- assert.match(js, /safePageId\(col\.link\.page\)/);
522
- assert.match(js, /"#\/" \+ encodeURIComponent\(col\.link\.page\) \+ "\/" \+ encodeURIComponent\(keyStr\)/);
530
+ assert.match(js, /const href = pageHashHref\(col\.link\.page, row\[col\.link\.keyField\]\)/);
531
+ // pageHashHref is the single source of truth: safe-id guard + encoded route.
532
+ assert.match(js, /function pageHashHref\(page, key\)/);
533
+ assert.match(js, /if \(!safePageId\(page\) \|\| keyStr === ""\) return ""/);
534
+ assert.match(js, /"#\/" \+ encodeURIComponent\(page\) \+ "\/" \+ encodeURIComponent\(keyStr\)/);
523
535
  // In-app: NOT a new-tab link (no target/rel on this anchor).
524
536
  assert.match(js, /el\("a", \{ class: "pc-link", href \}, text\)/);
525
537
  });
@@ -781,6 +793,14 @@ test("the renderer routes between pages by hash and tears down grid polls", asyn
781
793
  assert.match(js, /location\.hash/);
782
794
  assert.match(js, /window\.addEventListener\("hashchange", renderPage\)/);
783
795
  assert.match(js, /async function renderPage\(\)/);
796
+ // renderPage() fires from both hashchange and the matchMedia viewport handler,
797
+ // so overlapping async renders must not apply out of order: each captures a
798
+ // monotonic token and bails after its await if a newer render superseded it
799
+ // (guarding both the success path and the stale error render).
800
+ assert.match(js, /let renderSeq = 0/);
801
+ assert.match(js, /const mySeq = \+\+renderSeq/);
802
+ assert.match(js, /const doc = await getJSON\([\s\S]*?\);\s*if \(mySeq !== renderSeq\) return;/);
803
+ assert.match(js, /catch \(e\) \{\s*if \(mySeq !== renderSeq\) return;/);
784
804
  // Navigation runs every registered disposer first, so a switched-away grid's
785
805
  // poll interval + pc:refresh listener are removed rather than leaking forever.
786
806
  assert.match(js, /const disposers = \[\]/);
@@ -1039,3 +1059,287 @@ test("rowLimit respects an explicit 0 and falls back for non-finite values", asy
1039
1059
  await run(undefined);
1040
1060
  assert.deepEqual(limits, ["0", "200", "200"]);
1041
1061
  });
1062
+
1063
+ test("the shell ships a mobile @media block that flips a dataGrid into a card list (#268)", async () => {
1064
+ const res = await dispatch("GET", "/");
1065
+ const html = res.body ?? "";
1066
+ // Tier 0 — zero-declaration responsive default: a single (max-width) breakpoint
1067
+ // reflows every app for a phone. The dense dataGrid — the one primitive CSS
1068
+ // alone can't reflow — flips from a wide <table> to a stacked card list purely
1069
+ // from the per-cell data-label the renderer stamps on (no JS branching).
1070
+ const media = (html.match(/@media \(max-width:640px\) \{[\s\S]*?\n\}/) ?? [""])[0];
1071
+ assert.ok(media, "a (max-width) media block must be present");
1072
+ // thead hidden, rows/cells become blocks (the card flip).
1073
+ assert.match(media, /table\.pc-grid colgroup, table\.pc-grid thead \{ display:none; \}/);
1074
+ assert.match(media, /table\.pc-grid, table\.pc-grid tbody, table\.pc-grid tr, table\.pc-grid td \{ display:block[^}]*\}/);
1075
+ // The column header prefixes each value as a label:value line via data-label.
1076
+ assert.match(media, /td\[data-label\]::before \{ content:attr\(data-label\)/);
1077
+ // The derived title leads the card with no caption; a badge column is a chip.
1078
+ assert.match(media, /td\.pc-mcell-primary::before \{ content:none; \}/);
1079
+ assert.match(media, /td\.pc-mcell-chip::before \{ content:none; \}/);
1080
+ // An empty badge cell (blank field value) carries the chip class but no content;
1081
+ // it must collapse on mobile so it doesn't leave a stray blank flex row in the card.
1082
+ assert.match(media, /td\.pc-mcell-chip:empty \{ display:none; \}/);
1083
+ // Low-value (hidden) columns drop off the card until the row's More toggle opens them.
1084
+ assert.match(media, /td\.pc-mcell-hidden \{ display:none; \}/);
1085
+ assert.match(media, /tr\.pc-open td\.pc-mcell-hidden \{ display:flex; \}/);
1086
+ });
1087
+
1088
+ test("the shell mobile block collapses the nav and stacks buttons/form actions (#268)", async () => {
1089
+ const res = await dispatch("GET", "/");
1090
+ const html = res.body ?? "";
1091
+ const media = (html.match(/@media \(max-width:640px\) \{[\s\S]*?\n\}/) ?? [""])[0];
1092
+ // nav → scrollable bottom-ish bar (bar) / inline stack (rail); buttons and
1093
+ // modal/form actions go full-width single column; text reflows on its own.
1094
+ assert.match(media, /\.pc-bar \{ flex-wrap:nowrap; overflow-x:auto; \}/);
1095
+ assert.match(media, /\.pc-rail \.pc-nav-items \{ flex-direction:row/);
1096
+ assert.match(media, /\.pc-buttonrow \.pc-btn, \.pc-card \.pc-btn \{ width:100%; \}/);
1097
+ assert.match(media, /\.pc-modal-actions \{ flex-direction:column; \}/);
1098
+ // Row actions stack full-width under the card body.
1099
+ assert.match(media, /td\.pc-row-actions \{ display:flex/);
1100
+ // The per-row "More" toggle is base-hidden (outside the media block) so a grid
1101
+ // with hidden-column hints renders unchanged on desktop, shown only on mobile.
1102
+ assert.match(html, /\.pc-mcard-toggle \{ display:none; \}/);
1103
+ assert.match(media, /\.pc-mcard-toggle \{ display:block/);
1104
+ });
1105
+
1106
+ test("the mobile breakpoint has one source of truth shared by CSS and matchMedia (#268)", async () => {
1107
+ // AGENTS.md: no drift surfaces. The breakpoint must appear identically in the
1108
+ // shell CSS @media and the runtime's matchMedia — both interpolated from the
1109
+ // single MOBILE_MAX_WIDTH constant — so "narrow" can never diverge between the
1110
+ // pure-CSS card flip and the Tier-2 JS mode-switch.
1111
+ const shell = (await dispatch("GET", "/")).body ?? "";
1112
+ const js = (await dispatch("GET", "/app/runtime.js")).body ?? "";
1113
+ assert.match(shell, /@media \(max-width:640px\)/);
1114
+ assert.match(js, /window\.matchMedia\("\(max-width:640px\)"\)/);
1115
+ });
1116
+
1117
+ test("the renderer classifies grid columns into mobile card regions by convention (#268)", async () => {
1118
+ const res = await dispatch("GET", "/app/runtime.js");
1119
+ const js = res.body ?? "";
1120
+ // Tier 0 heuristic (classifyColumns): the first non-badge column is the card
1121
+ // title (primary), a badge column is a status chip, the rest are secondary
1122
+ // label:value rows. Tier 1 refines it — mobile.priority ("primary"|"secondary"
1123
+ // |"hidden") wins, and an explicit "primary" suppresses the auto title pick.
1124
+ assert.match(js, /function classifyColumns\(cols\)/);
1125
+ assert.match(js, /const explicitPrimary = list\.some\(\(c\) => c && c\.mobile && c\.mobile\.priority === "primary"\);/);
1126
+ assert.match(js, /if \(pr === "primary"\) return "primary";/);
1127
+ assert.match(js, /if \(pr === "hidden"\) return "hidden";/);
1128
+ assert.match(js, /if \(col && col\.badge\) return "chip";/);
1129
+ assert.match(js, /if \(!titleTaken\) \{ titleTaken = true; return "primary"; \}/);
1130
+ });
1131
+
1132
+ test("the renderer stamps every grid cell with a data-label + mobile role class (#268)", async () => {
1133
+ const res = await dispatch("GET", "/app/runtime.js");
1134
+ const js = res.body ?? "";
1135
+ // Every td carries data-label (the mobile.label override or the column header)
1136
+ // and a pc-mcell-<role> class so the card flip is pure CSS. The role is derived
1137
+ // per column (classifyColumns) and threaded through gridCell into cellTd.
1138
+ assert.match(js, /col\.mobile && typeof col\.mobile\.label === "string"\s*\n?\s*\? col\.mobile\.label/);
1139
+ assert.match(js, /const mob = \{ class: "pc-mcell pc-mcell-" \+ mrole, "data-label": mlabel \};/);
1140
+ assert.match(js, /function gridCell\(col, row, role\)/);
1141
+ assert.match(js, /const cells = cols\.map\(\(c, i\) => gridCell\(c, row, roles\[i\]\)\);/);
1142
+ assert.match(js, /return cellTd\(primary, text, subText, truncate, mob\);/);
1143
+ // cellTd applies the mobile attrs to whichever td shape it returns.
1144
+ assert.match(js, /function cellTd\(primary, primaryText, subText, truncate, tdAttrs\)/);
1145
+ assert.match(js, /const td = tdAttrs \|\| \{\};/);
1146
+ });
1147
+
1148
+ test("the renderer adds a per-row More toggle only when a column is mobile-hidden (#268)", async () => {
1149
+ const res = await dispatch("GET", "/app/runtime.js");
1150
+ const js = res.body ?? "";
1151
+ // Tier 1: mobile:{priority:"hidden"} columns are revealed behind a per-row
1152
+ // "More" toggle. The toggle cell is base-hidden and shown only inside the
1153
+ // mobile @media, so desktop is unchanged; it appears only when the grid
1154
+ // actually has a hidden column. The cell is built by the shared mobileMoreCell
1155
+ // helper and appended to the row (both the top-level grid and child grids use it).
1156
+ assert.match(js, /const hasHidden = roles\.indexOf\("hidden"\) >= 0;/);
1157
+ assert.match(js, /if \(hasHidden\) tr\.append\(mobileMoreCell\(tr\)\);/);
1158
+ assert.match(js, /function mobileMoreCell\(tr\)/);
1159
+ assert.match(js, /class: "pc-mcard-toggle"/);
1160
+ assert.match(js, /const open = tr\.classList\.toggle\("pc-open"\);/);
1161
+ assert.match(js, /moreBtn\.textContent = open \? "Less" : "More";/);
1162
+ });
1163
+
1164
+ test("the per-row More toggle exposes its reveal state via aria-expanded (#268)", async () => {
1165
+ const res = await dispatch("GET", "/app/runtime.js");
1166
+ const js = res.body ?? "";
1167
+ // The toggle is a button that shows/hides card content, so assistive tech needs
1168
+ // aria-expanded: it starts collapsed ("false") and is updated on every toggle.
1169
+ assert.match(js, /"aria-expanded": "false"/);
1170
+ assert.match(js, /moreBtn\.setAttribute\("aria-expanded", String\(open\)\);/);
1171
+ });
1172
+
1173
+ test("child grids reuse the same per-row More toggle for hidden columns (#268)", async () => {
1174
+ const res = await dispatch("GET", "/app/runtime.js");
1175
+ const js = res.body ?? "";
1176
+ // Child grids classify columns into the same mobile roles (including "hidden"),
1177
+ // so — like the top-level grid — they must render the shared More toggle, or a
1178
+ // mobile:{priority:"hidden"} child column would be permanently unreachable on a
1179
+ // narrow viewport. Derived from the same mobileMoreCell helper (no drift).
1180
+ assert.match(js, /const chasHidden = croles\.indexOf\("hidden"\) >= 0;/);
1181
+ assert.match(js, /if \(chasHidden\) ctr\.append\(mobileMoreCell\(ctr\)\);/);
1182
+ });
1183
+
1184
+ test("the renderer honours an optional Tier-2 page-level mobile layout variant (#268)", async () => {
1185
+ const res = await dispatch("GET", "/app/runtime.js");
1186
+ const js = res.body ?? "";
1187
+ // Tier 2 escape hatch: a page may declare a distinct `mobile` node list used
1188
+ // ONLY on a narrow viewport (matchMedia), with the default nodes everywhere
1189
+ // else and a fallback for anything malformed. The page re-renders when the
1190
+ // viewport crosses the breakpoint so the variant swaps in/out on rotation.
1191
+ assert.match(js, /function pickNodes\(doc\)/);
1192
+ assert.match(js, /if \(isNarrow\(\) && doc && doc\.mobile && Array\.isArray\(doc\.mobile\.nodes\)\) return doc\.mobile\.nodes;/);
1193
+ assert.match(js, /const nodes = pickNodes\(doc\);/);
1194
+ assert.match(js, /MOBILE_MQ\.addEventListener\("change", renderPage\);/);
1195
+ // …but degrade gracefully on older Safari/iOS (< 14) where MediaQueryList
1196
+ // lacks addEventListener, else the Tier-2 variant would never swap on rotation.
1197
+ assert.match(js, /typeof MOBILE_MQ\.addEventListener === "function"/);
1198
+ assert.match(js, /else if \(typeof MOBILE_MQ\.addListener === "function"\) MOBILE_MQ\.addListener\(renderPage\);/);
1199
+ });
1200
+
1201
+ // --- Pipeline / stepper cell renderer (issue #265) ---------------------------
1202
+ // These guard the runtime shape the way the other column-mode tests do: the
1203
+ // renderer JS is asserted for the pipeline logic, and the shell CSS for the
1204
+ // .pc-pipe* chrome. Together they lock the six rendering requirements so the
1205
+ // primitive can't silently regress.
1206
+
1207
+ test("renderer gates the pipeline cell on kind:\"pipeline\" and leaves other modes untouched", async () => {
1208
+ const res = await dispatch("GET", "/app/runtime.js");
1209
+ const js = res.body ?? "";
1210
+ // A dataGrid column declaring kind:"pipeline" is dispatched to pipelineCell;
1211
+ // the gate is a pure discriminator so absent it, every existing column mode
1212
+ // (badge/linkField/link/template/truncate) renders exactly as before.
1213
+ assert.match(js, /if\s*\(col\.kind === "pipeline"\) return pipelineCell\(col, row, text, subText, truncate, mob\)/);
1214
+ assert.match(js, /function pipelineCell\(col, row, text, subText, truncate, tdAttrs\)/);
1215
+ });
1216
+
1217
+ test("pipeline orders stages upstream-filled / active-lit / downstream-ghosted from activeField", async () => {
1218
+ const res = await dispatch("GET", "/app/runtime.js");
1219
+ const js = res.body ?? "";
1220
+ // The active stage is located by matching activeField's value against
1221
+ // stages[].key; stages before it are done/filled, the match is lit (active),
1222
+ // and in-path stages after it are ghosted/upcoming. The lookup breaks on the
1223
+ // first key match so a repeated key resolves deterministically to the first stage.
1224
+ assert.match(js, /const activeKey = col\.activeField != null/);
1225
+ // The row value is trimmed so a whitespace-padded key still matches its stage.
1226
+ assert.match(js, /\? String\(row\[col\.activeField\]\)\.trim\(\) : ""/);
1227
+ assert.match(js, /if \(String\(stages\[i\]\.key\) === activeKey\) \{\s*activeIdx = i;\s*break;\s*\}/);
1228
+ assert.match(js, /i < activeIdx\) \{\s*cls = "pc-pipe-done"; word = "completed";/);
1229
+ assert.match(js, /cls = "pc-pipe-upcoming"; word = "upcoming";/);
1230
+ // The connector fills up to the active stage, but a not-in-path predecessor
1231
+ // breaks the fill so a skipped stage is never implied to be on the path.
1232
+ assert.match(js, /const filled =\s*activeIdx >= 0 && i <= activeIdx && !skipped && !skip\.has\(String\(stages\[i - 1\]\.key\)\)/);
1233
+ assert.match(js, /class: "pc-pipe-conn" \+ filled/);
1234
+ });
1235
+
1236
+ test("pipeline reads the not-in-path set per-row from a field, falling back to static config", async () => {
1237
+ const res = await dispatch("GET", "/app/runtime.js");
1238
+ const js = res.body ?? "";
1239
+ // Skipped stages are dashed (.pc-pipe-skip). The set comes from notInPathField
1240
+ // on the row when given, else the static col.notInPath — via toStageSet, which
1241
+ // accepts an array or a comma/space-separated string, trimming + dropping
1242
+ // empties on BOTH paths so whitespace-padded array keys still match.
1243
+ assert.match(js, /col\.notInPathField != null && row\[col\.notInPathField\] != null/);
1244
+ assert.match(js, /toStageSet\(row\[col\.notInPathField\]\)/);
1245
+ assert.match(js, /toStageSet\(col\.notInPath\)/);
1246
+ assert.match(js, /cls = "pc-pipe-skip"; word = "skipped";/);
1247
+ assert.match(js, /function toStageSet\(v\)/);
1248
+ assert.match(js, /Array\.isArray\(v\)\) return new Set\(v\.map\(\(s\) => String\(s\)\.trim\(\)\)\.filter\(\(s\) => s !== ""\)\)/);
1249
+ });
1250
+
1251
+ test("pipeline keeps the active stage current even if its key is also in the not-in-path set (active wins over skip)", async () => {
1252
+ const res = await dispatch("GET", "/app/runtime.js");
1253
+ const js = res.body ?? "";
1254
+ // Precedence guard: the skipped predicate excludes the active index so a stage
1255
+ // that is both the active stage AND in the not-in-path set still renders as the
1256
+ // current step (active styling + aria-current), never as dashed/skipped. The
1257
+ // skip branch is checked before i === activeIdx, so without this exclusion the
1258
+ // active stage would silently lose aria-current="step".
1259
+ assert.match(js, /const skipped = i !== activeIdx && skip\.has\(String\(s\.key\)\);/);
1260
+ });
1261
+
1262
+ test("pipeline renders failure distinctly from success on the active stage (stateField)", async () => {
1263
+ const res = await dispatch("GET", "/app/runtime.js");
1264
+ const js = res.body ?? "";
1265
+ // stateField drives the active stage's terminal treatment: ok → success ✓,
1266
+ // failed → ✕, blocked → ⊘ — each a distinct class + glyph so success and
1267
+ // failure are unmistakably different.
1268
+ assert.match(js, /stRaw === "ok" \|\| stRaw === "failed" \|\| stRaw === "blocked" \? stRaw : "active"/);
1269
+ assert.match(js, /state === "failed"\) \{ cls = "pc-pipe-active pc-pipe-failed"; word = "failed"; glyph = "\\u2715";/);
1270
+ assert.match(js, /state === "ok"\) \{ cls = "pc-pipe-active pc-pipe-ok"; word = "done"; glyph = "\\u2713";/);
1271
+ assert.match(js, /state === "blocked"\) \{ cls = "pc-pipe-active pc-pipe-blocked"; word = "blocked"; glyph = "\\u2298";/);
1272
+ });
1273
+
1274
+ test("pipeline renders an active-stage badge from badgeField reusing .pc-badge tone classes", async () => {
1275
+ const res = await dispatch("GET", "/app/runtime.js");
1276
+ const js = res.body ?? "";
1277
+ // The badge sits on the active stage, only when badgeField is non-empty, and
1278
+ // reuses the existing .pc-badge tone classes (no new badge styles): warn for
1279
+ // attention/escalation, danger for blocked/failure.
1280
+ assert.match(js, /const badgeText = col\.badgeField != null && row\[col\.badgeField\] != null/);
1281
+ assert.match(js, /const badgeTone = state === "failed" \|\| state === "blocked" \? "danger" : "warn"/);
1282
+ assert.match(js, /current && badgeText\.trim\(\) !== ""/);
1283
+ assert.match(js, /class: "pc-badge pc-badge-" \+ badgeTone, title: badgeText, "aria-label": badgeText/);
1284
+ });
1285
+
1286
+ test("pipeline builds a locus link via the shared pageHashHref route builder, only when the key is non-empty", async () => {
1287
+ const res = await dispatch("GET", "/app/runtime.js");
1288
+ const js = res.body ?? "";
1289
+ // The locus stage becomes a #/<page>/<key> in-app link built by the SAME
1290
+ // pageHashHref helper the grid's page-link cell uses (single source of truth):
1291
+ // safePageId guard + encodeURIComponent, a blank key yields "" so no link, and
1292
+ // the anchor reuses .pc-link — no duplicated href construction.
1293
+ assert.match(js, /function pageHashHref\(page, key\)/);
1294
+ assert.match(js, /return "#\/" \+ encodeURIComponent\(page\) \+ "\/" \+ encodeURIComponent\(keyStr\)/);
1295
+ // The grid page-link cell was refactored onto the shared builder too.
1296
+ assert.match(js, /const href = pageHashHref\(col\.link\.page, row\[col\.link\.keyField\]\)/);
1297
+ // The locus link uses it and the shared .pc-link class; blank key → "" → plain.
1298
+ assert.match(js, /pageHashHref\(locus\.link\.page, locusKey\)/);
1299
+ assert.match(js, /el\("a", \{ class: "pc-link pc-pipe-label", href: locusHref \}, label\)/);
1300
+ });
1301
+
1302
+ test("pipeline exposes step semantics to assistive tech (role/list + aria-current)", async () => {
1303
+ const res = await dispatch("GET", "/app/runtime.js");
1304
+ const js = res.body ?? "";
1305
+ // The track is a labelled list; each stage is a listitem with an aria-label
1306
+ // stating its label + status word; the current stage is marked aria-current.
1307
+ assert.match(js, /class: "pc-pipe",\s*role: "list",\s*"aria-label": "Progress"/);
1308
+ assert.match(js, /role: "listitem", "aria-label": label \+ " \\u2014 " \+ word/);
1309
+ assert.match(js, /if \(current\) attrs\["aria-current"\] = "step"/);
1310
+ });
1311
+
1312
+ test("pipeline degrades to plain cell text on missing/unknown config (never throws)", async () => {
1313
+ const res = await dispatch("GET", "/app/runtime.js");
1314
+ const js = res.body ?? "";
1315
+ // No stages array → the cell falls back to plain text via cellTd, matching how
1316
+ // an unknown link.kind falls back today. Null/undefined entries are filtered so
1317
+ // a schema hole never throws on s.key.
1318
+ assert.match(js, /const stages = Array\.isArray\(col\.stages\) \? col\.stages\.filter\(\(s\) => s != null\) : \[\]/);
1319
+ assert.match(js, /if \(stages\.length === 0\) return cellTd\(text, text, subText, truncate, tdAttrs\)/);
1320
+ });
1321
+
1322
+ test("pipeline cell inherits the mobile card attrs so it labels/classifies under the card flip (#268)", async () => {
1323
+ const res = await dispatch("GET", "/app/runtime.js");
1324
+ const js = res.body ?? "";
1325
+ // The pipeline <td> merges gridCell's mobile card attrs (data-label + role
1326
+ // class) alongside its own pc-pipe-cell class, so a pipeline column is not left
1327
+ // out of the CSS-only card flip. Single source of truth: the same `mob` object
1328
+ // every other cell path receives.
1329
+ assert.match(js, /const cellAttrs = \{ \.\.\.\(tdAttrs \|\| \{\}\), class: \(tdAttrs && tdAttrs\.class \? tdAttrs\.class \+ " " : ""\) \+ "pc-pipe-cell" \}/);
1330
+ assert.match(js, /return el\("td", cellAttrs, track\)/);
1331
+ });
1332
+
1333
+ test("shell CSS carries the .pc-pipe* track chrome reusing --nano-* tokens", async () => {
1334
+ const res = await dispatch("GET", "/");
1335
+ const html = res.body ?? "";
1336
+ // Structural track/stage styles only (badges/links reuse .pc-badge and .pc-link);
1337
+ // all colours resolve through the shared token vocabulary so themes work.
1338
+ assert.match(html, /\.pc-pipe \{ display:flex;/);
1339
+ assert.match(html, /\.pc-pipe-done \{[^}]*border-color:var\(--nano-ok\)/);
1340
+ assert.match(html, /\.pc-pipe-active \{[^}]*border-color:var\(--nano-accent\)/);
1341
+ assert.match(html, /\.pc-pipe-active\.pc-pipe-failed \{[^}]*border-color:var\(--nano-danger\)/);
1342
+ assert.match(html, /\.pc-pipe-active\.pc-pipe-ok \{[^}]*border-color:var\(--nano-ok\)/);
1343
+ assert.match(html, /\.pc-pipe-skip \{[^}]*border-style:dashed/);
1344
+ assert.match(html, /\.pc-pipe-upcoming \{[^}]*var\(--nano-text-faint\)/);
1345
+ });