@nanobpm/urban 0.52.0 → 0.53.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (54) hide show
  1. package/README.md +47 -20
  2. package/dist/runtime/core/events.d.ts +135 -0
  3. package/dist/runtime/core/events.js +329 -0
  4. package/dist/runtime/core/extensions.d.ts +131 -0
  5. package/dist/runtime/core/extensions.js +137 -0
  6. package/dist/runtime/core/glob.d.ts +7 -6
  7. package/dist/runtime/core/glob.js +16 -14
  8. package/dist/runtime/core/host.d.ts +4 -1
  9. package/dist/runtime/core/modules/agent.d.ts +17 -0
  10. package/dist/runtime/core/modules/agent.js +63 -0
  11. package/dist/runtime/core/modules/deploy.d.ts +8 -5
  12. package/dist/runtime/core/modules/deploy.js +62 -16
  13. package/dist/runtime/core/modules/pages.js +397 -20
  14. package/dist/runtime/core/modules/surfaces.js +8 -0
  15. package/dist/runtime/core/phase.d.ts +168 -0
  16. package/dist/runtime/core/phase.js +367 -0
  17. package/dist/runtime/core/runtime.d.ts +17 -0
  18. package/dist/runtime/core/runtime.js +30 -0
  19. package/dist/runtime/index.d.ts +6 -0
  20. package/dist/runtime/index.js +7 -0
  21. package/dist/toolkit/derivers/system-brief.d.ts +62 -0
  22. package/dist/toolkit/derivers/system-brief.js +156 -0
  23. package/dist/toolkit/gen.d.ts +5 -5
  24. package/dist/toolkit/gen.js +20 -14
  25. package/dist/toolkit/index.d.ts +5 -0
  26. package/dist/toolkit/index.js +3 -0
  27. package/dist/toolkit/models.d.ts +4 -3
  28. package/dist/toolkit/models.js +4 -3
  29. package/package.json +1 -1
  30. package/src/runtime/core/events.test.ts +382 -0
  31. package/src/runtime/core/events.ts +370 -0
  32. package/src/runtime/core/extensions.test.ts +378 -0
  33. package/src/runtime/core/extensions.ts +276 -0
  34. package/src/runtime/core/glob.ts +15 -12
  35. package/src/runtime/core/host.ts +4 -1
  36. package/src/runtime/core/modules/agent.test.ts +104 -0
  37. package/src/runtime/core/modules/agent.ts +76 -0
  38. package/src/runtime/core/modules/deploy.test.ts +84 -16
  39. package/src/runtime/core/modules/deploy.ts +59 -16
  40. package/src/runtime/core/modules/pages.test.ts +309 -5
  41. package/src/runtime/core/modules/pages.ts +398 -20
  42. package/src/runtime/core/modules/surfaces.ts +9 -0
  43. package/src/runtime/core/phase.test.ts +350 -0
  44. package/src/runtime/core/phase.ts +526 -0
  45. package/src/runtime/core/runtime.extensions.test.ts +151 -0
  46. package/src/runtime/core/runtime.ts +49 -0
  47. package/src/runtime/devserver.test.ts +3 -0
  48. package/src/runtime/index.ts +57 -0
  49. package/src/toolkit/derivers/system-brief.test.ts +150 -0
  50. package/src/toolkit/derivers/system-brief.ts +210 -0
  51. package/src/toolkit/gen.test.ts +28 -4
  52. package/src/toolkit/gen.ts +20 -13
  53. package/src/toolkit/index.ts +14 -0
  54. package/src/toolkit/models.ts +4 -3
@@ -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
+ });