@terpjs/react-core 0.9.0 → 0.11.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 (101) hide show
  1. package/README.md +57 -21
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +323 -4
  4. package/src/AppShell.tsx +401 -66
  5. package/src/EmptyState.test.tsx +30 -0
  6. package/src/EmptyState.tsx +23 -3
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.test.tsx +34 -2
  11. package/src/LoginView.tsx +43 -18
  12. package/src/ModuleNav.test.tsx +17 -10
  13. package/src/ModuleNav.tsx +35 -3
  14. package/src/Page.tsx +23 -1
  15. package/src/ProfileView.test.tsx +1 -1
  16. package/src/ProfileView.tsx +2 -4
  17. package/src/SettingsPage.tsx +50 -0
  18. package/src/SplitPage.tsx +150 -0
  19. package/src/UserMenu.test.tsx +28 -5
  20. package/src/UserMenu.tsx +15 -9
  21. package/src/admin/AuditLogAdmin.tsx +21 -7
  22. package/src/admin/GroupCreate.tsx +17 -3
  23. package/src/admin/GroupDetail.tsx +48 -13
  24. package/src/admin/GroupsAdmin.tsx +13 -5
  25. package/src/admin/UserCreate.tsx +40 -11
  26. package/src/admin/UserDetail.tsx +4 -1
  27. package/src/admin/UsersAdmin.tsx +14 -6
  28. package/src/admin/admin.test.tsx +212 -8
  29. package/src/admin/fieldErrors.ts +45 -0
  30. package/src/bootstrap.test.tsx +208 -0
  31. package/src/bootstrap.tsx +121 -5
  32. package/src/breakpoints.ts +41 -0
  33. package/src/dataview/DataView.tsx +12 -5
  34. package/src/dataview/DataViewCardList.tsx +8 -7
  35. package/src/dataview/DataViewPagination.tsx +15 -8
  36. package/src/dataview/DataViewTable.tsx +32 -21
  37. package/src/dataview/README.md +13 -2
  38. package/src/dataview/index.ts +1 -0
  39. package/src/dataview/internal.tsx +31 -1
  40. package/src/dataview/types.ts +26 -3
  41. package/src/format.test.tsx +213 -0
  42. package/src/format.ts +150 -0
  43. package/src/icons.tsx +67 -5
  44. package/src/index.ts +56 -6
  45. package/src/layout.manifest.json +118 -0
  46. package/src/layout.manifest.test.ts +205 -0
  47. package/src/layout.test.tsx +198 -1
  48. package/src/layout.tsx +208 -11
  49. package/src/layoutContract.test.tsx +311 -2
  50. package/src/layoutContract.ts +44 -3
  51. package/src/layoutDeclaration.test.ts +435 -0
  52. package/src/layoutDeclaration.ts +531 -0
  53. package/src/locale.tsx +12 -0
  54. package/src/markers.test.ts +27 -5
  55. package/src/nav.test.ts +234 -4
  56. package/src/nav.ts +180 -6
  57. package/src/navActive.test.ts +115 -0
  58. package/src/navActive.ts +119 -0
  59. package/src/navLink.tsx +20 -2
  60. package/src/previewBridge.test.ts +327 -0
  61. package/src/previewBridge.ts +278 -0
  62. package/src/raw.d.ts +14 -2
  63. package/src/review.test.tsx +272 -0
  64. package/src/router.test.tsx +575 -2
  65. package/src/router.tsx +212 -19
  66. package/src/styles.test.ts +535 -58
  67. package/src/styles.ts +1130 -111
  68. package/src/theme.test.tsx +29 -0
  69. package/src/theme.themes.test.ts +13 -7
  70. package/src/theme.tsx +30 -33
  71. package/src/themes.ts +54 -0
  72. package/src/toast.tsx +2 -1
  73. package/src/tokens.guard.test.ts +239 -0
  74. package/src/typography.test.tsx +213 -0
  75. package/src/typography.tsx +255 -0
  76. package/src/ui/Avatar.test.tsx +63 -0
  77. package/src/ui/Avatar.tsx +65 -0
  78. package/src/ui/Button.test.tsx +69 -3
  79. package/src/ui/Button.tsx +57 -4
  80. package/src/ui/Card.test.tsx +13 -0
  81. package/src/ui/Card.tsx +28 -1
  82. package/src/ui/Checkbox.tsx +10 -2
  83. package/src/ui/Combobox.test.tsx +139 -0
  84. package/src/ui/Combobox.tsx +255 -43
  85. package/src/ui/DatePicker.tsx +44 -12
  86. package/src/ui/Input.test.tsx +123 -0
  87. package/src/ui/Input.tsx +65 -2
  88. package/src/ui/Menu.tsx +16 -5
  89. package/src/ui/Popover.tsx +13 -0
  90. package/src/ui/Radio.tsx +10 -5
  91. package/src/ui/Select.test.tsx +232 -0
  92. package/src/ui/Select.tsx +177 -8
  93. package/src/ui/Switch.tsx +10 -2
  94. package/src/ui/Tabs.test.tsx +28 -0
  95. package/src/ui/Tabs.tsx +30 -6
  96. package/src/ui/Tooltip.test.tsx +56 -1
  97. package/src/ui/Tooltip.tsx +69 -6
  98. package/src/uiText.literals.test.ts +199 -0
  99. package/src/uiText.tsx +36 -0
  100. package/src/unwrap.test.ts +132 -0
  101. package/src/unwrap.ts +118 -32
@@ -6,6 +6,17 @@ import { TERP_STYLES_ID, TERP_STYLES_CSS, injectTerpStyles } from "./styles";
6
6
  /** The sheet with comments removed — prose must not satisfy a structural assertion. */
7
7
  const css = TERP_STYLES_CSS.replace(/\/\*[\s\S]*?\*\//g, "");
8
8
 
9
+ /**
10
+ * One source file, read as text, so a union declared in TypeScript can be compared against the
11
+ * rules the sheet declares for it. Narrow on purpose: this file needs exactly one module, and a
12
+ * `./**` glob would pull the 180 KB stylesheet in a second time.
13
+ */
14
+ const sources = import.meta.glob("./dataview/types.ts", {
15
+ query: "?raw",
16
+ import: "default",
17
+ eager: true,
18
+ }) as Record<string, string>;
19
+
9
20
  /**
10
21
  * The body of one `@layer <name> { … }` block, brace-matched.
11
22
  *
@@ -70,6 +81,11 @@ describe("injectTerpStyles", () => {
70
81
  expect(sheet).toContain("scrollbar-color: var(--color-neutral-300) transparent");
71
82
  expect(sheet).toContain("::-webkit-scrollbar");
72
83
  expect(sheet).toContain("::-webkit-scrollbar-thumb");
84
+ // And reserves the gutter, so the content box is the same width on a page that
85
+ // scrolls and a page that fits. The structural half; the workbench's computed lane
86
+ // measures the reserved width itself, which is the half that can tell whether the
87
+ // browser honoured it.
88
+ expect(sheet).toContain("scrollbar-gutter: stable");
73
89
  });
74
90
  });
75
91
 
@@ -204,10 +220,19 @@ describe("cascade structure", () => {
204
220
  it("keeps the shell declarations no lane can reach", () => {
205
221
  // Three groups, and every one of them was established by mutation rather than assumed.
206
222
  //
207
- // The sidebar's flex-shrink. It is a flex item with an explicit width in a container that
208
- // has room to spare at the pinned 1280px viewport, so deleting it moves no baseline —
209
- // measured. It bites at narrow DESKTOP widths, above the mobile breakpoint where the drawer
210
- // takes over, which the screenshot lane cannot reach because the viewport is pinned.
223
+ // Two of the three have since GAINED a baseline, and the reason is worth keeping because
224
+ // the earlier version of this comment said flatly that the screenshot lane could not reach
225
+ // them: it could not, while the viewport was pinned at 1280 for every specimen. A
226
+ // per-specimen `viewport` in the workbench is what changed, and what these two assertions
227
+ // are now is belt rather than the sole gate.
228
+ //
229
+ // The sidebar's flex-shrink. At the pinned 1280 the row has room to spare, so deleting it
230
+ // moves no baseline — measured, and still true. It bites above the mobile breakpoint and
231
+ // below wide, and `app-shell-narrow` (820x900, with a DataView supplying the content
232
+ // pressure a short paragraph does not) is that band: removing the declaration now repaints
233
+ // 124,797 pixels there, in both themes, and leaves the other three shell specimens
234
+ // untouched. A row under no pressure never asks a flex item whether it may shrink, which
235
+ // is why the specimen needed the table and not just the narrower window.
211
236
  const base = layerBody("terp.base");
212
237
  const at = base.indexOf('[data-terp="appshell-sidebar"] {');
213
238
  expect(at, "the sidebar should have a base rule").toBeGreaterThan(-1);
@@ -228,10 +253,65 @@ describe("cascade structure", () => {
228
253
  `z-index: var(${token})`,
229
254
  );
230
255
  }
231
- // And the mobile half of the shell, which the screenshot lane cannot render at all: the
232
- // viewport is pinned at 1280 and the drawer needs 768 or less. The BEHAVIOUR is covered by
233
- // AppShell.test.tsx (focus containment, inert page, close-on-nav) with a stubbed matchMedia;
234
- // the geometry is covered by nothing, so these three exist as text or not at all.
256
+ // The shell's three geometry literals, same shape and the same reason. `15rem`, `4rem` and
257
+ // `3rem` are now published tokens an app moves from its own unlayered `theme.css` with no
258
+ // prop at all (ADR 0097 §1), and restating any of them as a number here would move no
259
+ // baseline the values are identical, which is what made the conversion provably
260
+ // zero-diff — while quietly taking the knob away again. `tokens.guard.test.ts` holds the
261
+ // other direction: a shell token nothing reads fails there.
262
+ for (const [declaration, what] of [
263
+ ["width: var(--shell-sidebar-width-expanded)", "the expanded sidebar"],
264
+ ["width: var(--shell-sidebar-width-collapsed)", "the collapsed icon rail"],
265
+ ["min-height: var(--shell-header-height)", "the sticky header's floor"],
266
+ ] as const) {
267
+ expect(base, `${what} must read its token rather than restating the length`).toContain(
268
+ declaration,
269
+ );
270
+ }
271
+ // And the content measure, which no structural check can reach through a marker: it is the
272
+ // one shell rule keyed on a descendant of an attribute rather than on a marker of its own,
273
+ // because the mechanism is deliberately NOT a new element (ADR 0097 §2). The `:not(header)`
274
+ // is the whole band: the header keeps the page grid's full track while its siblings take
275
+ // the measure.
276
+ // Selector AND declaration read out of ONE rule body, not as two independent substrings
277
+ // of the layer. Asserted separately, an empty measure rule plus the declaration moved onto
278
+ // some other rule during a consolidation would satisfy both — and the only baseline that
279
+ // moved would read as an intentional layout change.
280
+ const measureRule =
281
+ /\[data-terp="appshell"\]\[data-content-width="measured"\]\s*\n?\s*\[data-terp="page"\] > \*:not\(\[data-terp="page-header"\]\) \{([^}]*)\}/.exec(
282
+ base,
283
+ );
284
+ expect(measureRule, "the content measure must be one rule keyed on the shell's attribute").not
285
+ .toBeNull();
286
+ const measureBody = measureRule![1]!;
287
+ // WIDTH, not max-width, and this assertion is the gate on that. The selector weighs
288
+ // (0,4,0), so as a max-width it outranks every component declaring a narrower one —
289
+ // resource-list, admin-form, dialog and both text measures — and an admin-form inside a
290
+ // measured shell computed 1280px instead of 512px. As a width it composes, because CSS
291
+ // resolves max-width after width. Nothing renders differently for a child with no measure
292
+ // of its own, which is why only a probe found it.
293
+ expect(
294
+ measureBody,
295
+ "the measure must be a width, or it outranks every component's own narrower max-width",
296
+ ).toContain("width: min(100%, var(--shell-content-max-width))");
297
+ expect(
298
+ base.includes("max-width: var(--shell-content-max-width)"),
299
+ "as a max-width the measure would widen resource-list, admin-form, dialog and Text",
300
+ ).toBe(false);
301
+ // And the mobile half of the shell, where the three selectors below now differ from each
302
+ // other and the difference is the interesting part. The BEHAVIOUR is covered throughout by
303
+ // AppShell.test.tsx (focus containment, inert page, close-on-nav) with a stubbed
304
+ // matchMedia; what varies is the geometry.
305
+ //
306
+ // `appshell-main`'s tightened padding is painted: `app-shell-mobile` renders at 420x900,
307
+ // and moving that padding one step to the desktop value repaints 1,309 pixels there in both
308
+ // themes and nothing else.
309
+ //
310
+ // The other two DID wait for a door, and it arrived: `defaultDrawerOpen` plus the
311
+ // `app-shell-drawer-open` specimen (420x900, overlay) paint the drawer's own geometry and
312
+ // its backdrop for the first time. So this loop is belt for all three now rather than the
313
+ // sole gate for two of them — the message below still says "no baseline can hold it", which
314
+ // was true when it was written and is not any more.
235
315
  for (const selector of [
236
316
  '[data-terp="appshell"][data-variant="mobile"] [data-terp="appshell-sidebar"]',
237
317
  '[data-terp="appshell-backdrop"]',
@@ -239,7 +319,8 @@ describe("cascade structure", () => {
239
319
  ]) {
240
320
  expect(
241
321
  declaresRuleFor(base, selector),
242
- `${selector} is mobile-only, so no baseline can hold it`,
322
+ `${selector} is mobile-only; app-shell-mobile and app-shell-drawer-open are the only ` +
323
+ "pictures of it",
243
324
  ).toBe(true);
244
325
  }
245
326
  });
@@ -285,20 +366,30 @@ describe("cascade structure", () => {
285
366
  ).toContain("height: 100%");
286
367
  });
287
368
 
288
- it("puts the DataView's surface on the full variant, not on the bare marker", () => {
369
+ it("puts the DataView's surface on the full variant's TABLE, not on the root or the marker", () => {
289
370
  // Both values of data-variant are stamped and only one has a rule, which is what keeps
290
371
  // the embedded variant a bare grid. The alternative — surface on the marker, un-declared
291
372
  // under [data-variant="embedded"] — needs background: transparent, border: 0 and
292
373
  // border-radius: 0, the un-declaring shape ADR 0094 exists to avoid.
293
374
  //
294
375
  // dataview-embedded is the negative evidence this rests on: deleting the full-variant
295
- // rule must move five baselines and leave that one untouched.
376
+ // rule must move the full-variant baselines and leave that one untouched.
377
+ //
378
+ // The OWNER changed and the guarantee did not. The surface used to sit on the root, which
379
+ // made one card of the toolbar, the table and the pagination, divided by internal borders
380
+ // — and left the table's cells flush against the outer frame. It now sits on whatever
381
+ // occupies the table's slot, so the table is the object and the controls above and below
382
+ // it float on the page. Still keyed on the variant, for the reason above.
296
383
  const base = layerBody("terp.base");
297
384
  expect(declaresRuleFor(base, '[data-terp="dataview"]'), "the root needs a display").toBe(true);
298
385
  expect(
299
- declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"]'),
300
- "the surface belongs to the full variant",
386
+ declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"] > [data-terp="dataview-scroll"]'),
387
+ "the surface belongs to the full variant's table slot",
301
388
  ).toBe(true);
389
+ expect(
390
+ declaresRuleFor(base, '[data-terp="dataview"][data-variant="full"]'),
391
+ "the root must NOT carry a surface — that is the card this change removed",
392
+ ).toBe(false);
302
393
  expect(
303
394
  base,
304
395
  "un-declaring a surface under the embedded variant is the shape ADR 0094 avoids",
@@ -321,38 +412,30 @@ describe("cascade structure", () => {
321
412
  );
322
413
  });
323
414
 
324
- it("keeps the toolbar band declaring its own surface, and no ink", () => {
325
- // Three separate invariants about one element, and each has a way of going wrong that
326
- // nothing else in the suite can see.
415
+ it("keeps the toolbar FLOATING no surface, no divider, and still no ink", () => {
416
+ // This assertion reversed, and the reversal is the change: the band used to declare
417
+ // neutral-0 and two top radii because it was the top third of a card, and nothing else
418
+ // kept the selection colour inside the root's rounded frame. There is no card now. A
419
+ // background on a strip that sits on the page canvas paints a rectangle the design does
420
+ // not have, and a border under it draws a line across nothing.
327
421
  //
328
- // The background. The inline style this replaced read `selectionMode ? neutral-50 :
329
- // neutral-0` an explicit value in BOTH branches, so the resting rule has to carry
330
- // neutral-0 rather than leaving it to the host. Against every composed DataView specimen
331
- // dropping it moves nothing, because the full variant's root and the workbench's specimen
332
- // card are both neutral-0; it breaks the EMBEDDED variant in a real app, whose root
333
- // declares nothing but a display, and the band would show the page canvas through it.
334
- // `dataview-toolbar-bare` renders on a neutral-50 host so that mutation fails a baseline.
422
+ // The radii went with the card for the same reason: there is no rounded frame above the
423
+ // toolbar to stay inside. Selection mode keeps a fill it marks a MODE and losing that
424
+ // makes selection invisible but as a surface of its own, with its own padding and
425
+ // radius, asserted below.
335
426
  //
336
- // The two top radii, which are load-bearing rather than decorative: the DataView root
337
- // rounds its border with no overflow: hidden, so nothing else keeps the selection band's
338
- // neutral-50 inside the rounded frame.
339
- //
340
- // And NO colour. Two of this element's direct children are arbitrary caller slots
341
- // (`children` and `trailing`), so a muted ink here would inherit into app-authored filter
342
- // controls — a silent restyle of app DOM, which is exactly what this migration exists to
343
- // stop the framework doing.
427
+ // NO colour survives unchanged, and it is the invariant with the sharpest edge: two of
428
+ // this element's direct children are arbitrary caller slots (`children` and `trailing`),
429
+ // so a muted ink here would inherit into app-authored filter controls a silent restyle
430
+ // of app DOM, which is what the styling migration exists to stop the framework doing.
344
431
  const base = layerBody("terp.base");
345
432
  const at = base.indexOf('[data-terp="dataview-toolbar"]');
346
433
  expect(at, "the toolbar band should have a base rule").toBeGreaterThan(-1);
347
434
  const block = base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at));
348
- expect(block, "the band must declare its own surface, not inherit the host's").toContain(
349
- "background: var(--color-neutral-0)",
435
+ expect(block, "a floating strip paints no surface of its own").not.toContain("background:");
436
+ expect(block, "a divider under a floating strip is a line across nothing").not.toContain(
437
+ "border-block-end:",
350
438
  );
351
- for (const radius of ["border-top-left-radius", "border-top-right-radius"]) {
352
- expect(block, `${radius} keeps the selection band inside the root's rounded frame`).toContain(
353
- `${radius}: var(--radius-lg)`,
354
- );
355
- }
356
439
  expect(
357
440
  /(^|;)\s*color:/.test(block),
358
441
  "a colour here inherits into the caller's filter slot and trailing slot",
@@ -368,11 +451,27 @@ describe("cascade structure", () => {
368
451
  layerBody("terp.state"),
369
452
  "selection mode is a resting surface, not an interaction state",
370
453
  ).not.toContain('[data-variant="selection"]');
371
- // The bar reads the density token rather than a literal --space-3, which is what makes a
372
- // compact view's band line up with its first cell. Zero-diff at comfortable, because
373
- // comfortable --density-cell-pad-x IS --space-3 so only dataview-compact can catch it.
374
- expect(block, "the band's inline padding must follow density").toContain(
375
- "padding: var(--space-2) var(--density-cell-pad-x)",
454
+ // The band used to read --density-cell-pad-x for its INLINE padding, so a compact view's
455
+ // band lined up with its first cell. That alignment target is gone with the card: the
456
+ // toolbar now aligns with the table's outer frame, whose position does not move with
457
+ // density, so inline padding here would push the controls off it. Block padding stays on
458
+ // the spacing scale — vertical rhythm was never the density question.
459
+ expect(block, "the band's block padding stays on the spacing scale").toContain(
460
+ "padding-block: var(--space-2)",
461
+ );
462
+ expect(
463
+ /padding-inline|padding:/.test(block),
464
+ "inline padding would push the controls off the table frame they now align to",
465
+ ).toBe(false);
466
+ // Selection mode is the exception and declares its own inline padding, because there it
467
+ // IS a surface and its fill needs to clear its text.
468
+ const selectionAt = base.indexOf('[data-terp="dataview-toolbar"][data-variant="selection"]');
469
+ const selectionBlock = base.slice(
470
+ base.indexOf("{", selectionAt) + 1,
471
+ base.indexOf("}", selectionAt),
472
+ );
473
+ expect(selectionBlock, "the selection surface reads density for its own inset").toContain(
474
+ "padding-inline: var(--density-cell-pad-x)",
376
475
  );
377
476
  });
378
477
 
@@ -443,10 +542,28 @@ describe("cascade structure", () => {
443
542
  }
444
543
  // Every transition this sheet declares is either on a marked element or on one of the
445
544
  // descendant selectors above. A new descendant rule with a transition has to join them.
446
- const base = layerBody("terp.base");
447
- for (const match of base.matchAll(/([^{}]+)\{([^{}]*transition:[^{}]*)\}/g)) {
545
+ // BOTH layers. This read only terp.base, so a transition declared in terp.state — where
546
+ // every hover, focus and selected treatment lives — was audited by nothing at all. The one
547
+ // that exists there today passed only because its selector happens to be in the explicit
548
+ // list above, which is luck rather than coverage.
549
+ //
550
+ // And the "marked" heuristic needs the trailing pseudo stripped before it is applied.
551
+ // `\s[a-z]+$` asks whether the selector ends in a bare element name preceded by whitespace,
552
+ // but `[data-terp="card"] span:hover` ends in `:hover` — the character before the final run
553
+ // is a colon, not a space — so the test scored it as marked and waved it through. Reduced
554
+ // motion cannot reach that rule: terp.motion is the last layer and beats any earlier one on
555
+ // the elements its selectors MATCH, and its list matches marked elements and four named
556
+ // descendants. A transition on any other descendant simply escapes.
557
+ const transitionRules = [layerBody("terp.base"), layerBody("terp.state")].flatMap((body) => [
558
+ ...body.matchAll(/([^{}]+)\{([^{}]*transition:[^{}]*)\}/g),
559
+ ]);
560
+ expect(transitionRules.length, "no transition rules were scanned at all").toBeGreaterThan(0);
561
+ for (const match of transitionRules) {
448
562
  const selector = match[1].trim().replace(/\s+/g, " ");
449
- const marked = selector.includes("[data-terp") && !/\s[a-z]+$/.test(selector);
563
+ // Strip trailing pseudo-classes / pseudo-elements, so the bare-element test sees the
564
+ // element the selector actually ends on.
565
+ const withoutPseudo = selector.replace(/(::?[a-z-]+(\([^)]*\))?)+$/, "");
566
+ const marked = withoutPseudo.includes("[data-terp") && !/\s[a-z]+$/.test(withoutPseudo);
450
567
  expect(
451
568
  marked || motion.includes(selector),
452
569
  `${selector} declares a transition but reduced motion cannot reach it`,
@@ -454,18 +571,270 @@ describe("cascade structure", () => {
454
571
  }
455
572
  });
456
573
 
574
+ it("times every transition off the published motion scale, never a literal", () => {
575
+ // The scale shipped in 2a and was read by nothing: this sheet wrote `150ms ease`
576
+ // 28 times and `100ms ease` once across 16 declarations, and read a motion token
577
+ // zero times — so a Studio editor built from the manifest would have offered four
578
+ // duration controls that moved nothing. Wiring them was inert by construction:
579
+ // --motion-duration-fast IS 150ms, --motion-duration-instant IS 100ms and
580
+ // --motion-easing-standard IS ease, so every literal mapped onto a token pair.
581
+ //
582
+ // This gate has to be structural, and that is the whole reason it exists here
583
+ // rather than in a baseline. The screenshot lane runs with
584
+ // `animations: "disabled"`, so a duration is invisible to it: a wrong value — or
585
+ // literal number 30 — would move no pixel and nothing would say so.
586
+ //
587
+ // Scoped to `transition` on purpose. The spinner's `animation: terp-spin 0.8s` is
588
+ // a rotation period rather than an interaction step and the scale tops out at
589
+ // 400ms, so there is no token for it to name.
590
+ const declarations = [...css.matchAll(/transition:\s*([^;]+);/g)].map((match) => match[1]!);
591
+ expect(declarations.length).toBeGreaterThan(10);
592
+ expect(
593
+ declarations.filter((value) => /\d+m?s\b/.test(value)),
594
+ "a transition's duration belongs on the published motion scale, not in the rule",
595
+ ).toEqual([]);
596
+ });
597
+
598
+ it("puts the responsive Stack rules after the ones they override", () => {
599
+ // A stack with direction { narrow: "row", wide: "column" } carries BOTH
600
+ // data-direction="row" and data-direction-wide="column", and the two selectors weigh the
601
+ // same (0,2,0) — so nothing but source order decides which applies above the cutover.
602
+ // Backwards, the narrow value renders at every width, which looks exactly like the prop
603
+ // not working rather than like a cascade mistake.
604
+ //
605
+ // The same tie the focus ring and the loading cursor both turned on, and the third time
606
+ // it has mattered in this sheet: equal specificity in one layer is settled by position,
607
+ // and position is the one thing a reader cannot see from the rule.
608
+ const base = layerBody("terp.base");
609
+ const wideAt = base.indexOf("@media not all and (max-width: 768px)");
610
+ expect(wideAt, "the wide half of the responsive props should be in terp.base").toBeGreaterThan(
611
+ -1,
612
+ );
613
+ for (const selector of [
614
+ '[data-terp="stack"][data-direction="row"]',
615
+ '[data-terp="stack"][data-gap="8"]',
616
+ ]) {
617
+ const at = base.indexOf(selector);
618
+ expect(at, `${selector} should exist`).toBeGreaterThan(-1);
619
+ expect(at, `${selector} must be declared before the wide block that overrides it`).toBeLessThan(
620
+ wideAt,
621
+ );
622
+ }
623
+ });
624
+
625
+ it("declares a wide-half gap rule for every step SpaceToken allows", () => {
626
+ // The narrow half is covered by the roll-call below; this is its counterpart. A responsive
627
+ // gap whose wide step has no rule silently renders the narrow gap at every width — which
628
+ // is the failure mode of the whole responsive idea, and invisible unless a specimen happens
629
+ // to be recorded at both viewports.
630
+ const base = layerBody("terp.base");
631
+ const wide = base.slice(base.indexOf("@media not all and (max-width: 768px)"));
632
+ for (const token of [0, 1, 2, 3, 4, 6, 8]) {
633
+ expect(
634
+ declaresRuleFor(wide, `[data-terp="stack"][data-gap-wide="${token}"]`),
635
+ `the wide half has no rule for gap ${token}`,
636
+ ).toBe(true);
637
+ }
638
+ for (const direction of ["column", "row"]) {
639
+ expect(
640
+ declaresRuleFor(wide, `[data-terp="stack"][data-direction-wide="${direction}"]`),
641
+ `the wide half has no rule for direction ${direction}`,
642
+ ).toBe(true);
643
+ }
644
+ });
645
+
646
+ it("pins the four Grid track floors, which no baseline can hold", () => {
647
+ // The grid specimens gate the MECHANISM and not the values, and the gap between those two
648
+ // was measured rather than assumed. An auto-fit grid quantises: a floor only changes the
649
+ // rendered column count when it crosses a threshold for the container's width. At
650
+ // `grid-min-column`'s 66rem, moving the md floor 20rem -> 22rem drops it from three columns
651
+ // to two and repaints 63,524 pixels — while moving it 20rem -> 21rem changes **nothing**,
652
+ // and all ten grid baselines pass. So the pictures prove the attribute selects a different
653
+ // floor; only this proves which floor.
654
+ //
655
+ // The same shape as the shell declarations below, and the reason the values are pinned here
656
+ // rather than promoted to tokens: four published tokens with one consumer is the vocabulary
657
+ // the density cell tokens were deleted for. They become tokens the day an app asks.
658
+ const base = layerBody("terp.base");
659
+ for (const [attribute, floor] of [
660
+ [null, "16rem"],
661
+ ["xs", "10rem"],
662
+ ["md", "20rem"],
663
+ ["lg", "26rem"],
664
+ ] as const) {
665
+ const selector =
666
+ attribute === null
667
+ ? '[data-terp="grid"]'
668
+ : `[data-terp="grid"][data-min-column="${attribute}"]`;
669
+ const at = base.indexOf(`${selector} {`);
670
+ expect(at, `${selector} should have a rule`).toBeGreaterThan(-1);
671
+ expect(
672
+ base.slice(at, base.indexOf("}", at)),
673
+ `${selector} should floor its tracks at ${floor}`,
674
+ ).toContain(`minmax(min(${floor}, 100%), 1fr)`);
675
+ }
676
+ // sm is the default and therefore the base rule, so it has no attribute of its own — and
677
+ // its floor is the same 16rem the hub grid uses, which is why the two agree by construction
678
+ // instead of by coincidence.
679
+ expect(
680
+ declaresRuleFor(base, '[data-terp="grid"][data-min-column="sm"]'),
681
+ "sm is the base rule; an attribute rule for it means the default is described twice",
682
+ ).toBe(false);
683
+ const hub = base.indexOf('[data-terp="hubpage-grid"] {');
684
+ expect(base.slice(hub, base.indexOf("}", hub))).toContain("minmax(min(16rem, 100%), 1fr)");
685
+ });
686
+
687
+ it("floors every fixed Grid column at zero, not at min-content", () => {
688
+ // `repeat(N, 1fr)` looks equivalent and is not: a bare 1fr floors at the track's
689
+ // min-content size, so one long unbroken word in a cell widens its column and the grid
690
+ // overflows its container. A two-column form of long field labels walks straight into it,
691
+ // which is the case Grid exists for. minmax(0, 1fr) is the fix, and it is invisible to
692
+ // every baseline that does not happen to contain an overflowing word.
693
+ const base = layerBody("terp.base");
694
+ for (const count of [1, 2, 3, 4]) {
695
+ const selector = `[data-terp="grid"][data-columns="${count}"]`;
696
+ const at = base.indexOf(`${selector} {`);
697
+ expect(at, `${selector} should have a rule`).toBeGreaterThan(-1);
698
+ expect(
699
+ base.slice(at, base.indexOf("}", at)),
700
+ `${selector} must floor its tracks at 0, or a long word widens the column`,
701
+ ).toContain("minmax(0, 1fr)");
702
+ }
703
+ });
704
+
705
+ it("declares a rule for every size Button names, and none for its default", () => {
706
+ // The same bargain the gap rules strike: the union and the sheet are two lists kept by
707
+ // hand, so widening ButtonSize without adding a rule would silently fall back to the
708
+ // standard geometry rather than fail.
709
+ //
710
+ // And the converse, which is the part worth pinning: md must have NO rule of its own. Its
711
+ // geometry is the base rule, exactly as "comfortable" is the token sheet's :root value, and
712
+ // a data-size="md" rule appearing here would mean the component had started stamping the
713
+ // attribute for its default — leaving two places that describe the standard control.
714
+ const base = layerBody("terp.base");
715
+ for (const size of ["sm", "lg"]) {
716
+ expect(
717
+ declaresRuleFor(base, `[data-terp="button"][data-size="${size}"]`),
718
+ `Button has no rule for size ${size}`,
719
+ ).toBe(true);
720
+ }
721
+ expect(
722
+ declaresRuleFor(base, '[data-terp="button"][data-size="md"]'),
723
+ "md is the base rule; a rule of its own means the default is described twice",
724
+ ).toBe(false);
725
+ // Sizes read the density token rather than heights of their own, which is what makes the
726
+ // two dimensions compose: the compact re-scoping moves the token these calc() off.
727
+ const at = base.indexOf('[data-terp="button"][data-size="sm"]');
728
+ expect(
729
+ base.slice(at, base.indexOf("}", at)),
730
+ "a size that hardcodes its height stops following density",
731
+ ).toContain("var(--density-control-min-height)");
732
+ });
733
+
734
+ it("declares one avatar rule plus a small variant, and nothing for the default size", () => {
735
+ // The tile used to be two rules of eleven declarations each — profile-avatar and
736
+ // user-menu-avatar — identical but for a width, a height and a font size. Merging them is only
737
+ // zero-pixel if the variant keeps BOTH geometries, so this pins the shape of the pair: a base
738
+ // rule carrying md, and an sm rule carrying exactly the three declarations that differ.
739
+ const base = layerBody("terp.base");
740
+ expect(declaresRuleFor(base, '[data-terp="avatar"]')).toBe(true);
741
+ expect(declaresRuleFor(base, '[data-terp="avatar"][data-size="sm"]')).toBe(true);
742
+ // md is the base rule, as with Button. A rule of its own means the default is described twice.
743
+ expect(
744
+ declaresRuleFor(base, '[data-terp="avatar"][data-size="md"]'),
745
+ "md is the base rule; a rule of its own means the default is described twice",
746
+ ).toBe(false);
747
+ const at = base.indexOf('[data-terp="avatar"][data-size="sm"]');
748
+ const body = base.slice(at, base.indexOf("}", at));
749
+ for (const declaration of ["width: 2rem", "height: 2rem", "var(--font-size-sm)"]) {
750
+ expect(body, `the small variant must restate ${declaration}`).toContain(declaration);
751
+ }
752
+ // And nothing else: a declaration here that the base already sets is a second description of
753
+ // the same tile, which is the duplication the merge removed.
754
+ expect(body).not.toContain("border-radius");
755
+ expect(body).not.toContain("background");
756
+ });
757
+
758
+ it("declares a min-inline-size for every step ColumnWidth names, and nothing it does not", () => {
759
+ // The Button gate above notes that its union and its sheet are two lists kept by hand. This
760
+ // one reads the union out of the source instead, because a declared track that silently does
761
+ // nothing is exactly the defect this attribute exists to end: `meta.width` was a px hint that
762
+ // `table-layout: auto` shrank away, so widening ColumnWidth without a rule would restore the
763
+ // old behaviour under a new spelling.
764
+ const source = sources["./dataview/types.ts"];
765
+ const union = /export type ColumnWidth =([^;]+);/.exec(source);
766
+ expect(union, "ColumnWidth is not declared where this test looks for it").not.toBeNull();
767
+ const steps = [...(union?.[1] ?? "").matchAll(/"([a-z]+)"/g)].map((match) => match[1]);
768
+ expect(steps.length, "parsed no steps out of ColumnWidth").toBeGreaterThan(0);
769
+
770
+ const base = layerBody("terp.base");
771
+ for (const step of steps) {
772
+ const selector = `[data-terp="dataview-table"] > thead > tr > th[data-width="${step}"]`;
773
+ expect(declaresRuleFor(base, selector), `no rule for step ${step}`).toBe(true);
774
+ const at = base.indexOf(selector);
775
+ expect(
776
+ base.slice(at, base.indexOf("}", at)),
777
+ `step ${step} must bind as a minimum; a width is a preference auto layout shrinks away`,
778
+ ).toContain("min-inline-size");
779
+ }
780
+ // And nothing beyond the union. `lg` and `content` were both drafted and declined for having
781
+ // no consumer, so a rule for either means the sheet grew a step the type cannot express.
782
+ for (const absent of ["lg", "xl", "content", "auto"]) {
783
+ expect(
784
+ declaresRuleFor(
785
+ base,
786
+ `[data-terp="dataview-table"] > thead > tr > th[data-width="${absent}"]`,
787
+ ),
788
+ `the sheet declares a step ColumnWidth does not name: ${absent}`,
789
+ ).toBe(false);
790
+ }
791
+ });
792
+
793
+ it("keeps the loading cursor where it can beat the disabled cursor", () => {
794
+ // A loading button is also `:disabled` — the component sets both — and the disabled rule
795
+ // lives in terp.state. So a `data-loading` rule in terp.base loses on layer order and the
796
+ // cursor silently stays `not-allowed`, which tells a user "you may not" where the truth is
797
+ // "not yet". It has to be in terp.state, and after the disabled rule, because the two weigh
798
+ // the same (0,2,0) and nothing but source order separates them.
799
+ //
800
+ // This is the focus-ring lesson in miniature, and it is invisible to every other lane:
801
+ // Playwright's screenshots do not paint a pointer, so no baseline has ever held either
802
+ // cursor. The computed lane asserts the resolved values; this asserts the structure that
803
+ // produces them.
804
+ const state = layerBody("terp.state");
805
+ const base = layerBody("terp.base");
806
+ expect(
807
+ declaresRuleFor(base, '[data-terp="button"][data-loading="true"]'),
808
+ "in terp.base this rule loses to the disabled cursor and does nothing",
809
+ ).toBe(false);
810
+ const disabledAt = state.indexOf('[data-terp="button"]:disabled');
811
+ const loadingAt = state.indexOf('[data-terp="button"][data-loading="true"]');
812
+ expect(disabledAt, "the disabled rule should be in terp.state").toBeGreaterThan(-1);
813
+ expect(loadingAt, "the loading rule should be in terp.state").toBeGreaterThan(-1);
814
+ expect(loadingAt).toBeGreaterThan(disabledAt);
815
+ });
816
+
457
817
  it("declares a gap rule for every step SpaceToken allows", () => {
458
818
  // gap moved from a computed inline value to a rule per step, so the union and the sheet
459
819
  // are now two lists maintained by hand. Widening SpaceToken without adding rules would
460
820
  // silently fall back to the default gap rather than fail.
461
821
  const base = layerBody("terp.base");
462
822
  for (const token of [0, 1, 2, 3, 4, 6, 8]) {
463
- for (const marker of ["stack", "card"]) {
823
+ for (const marker of ["stack", "card", "grid"]) {
464
824
  expect(
465
825
  declaresRuleFor(base, `[data-terp="${marker}"][data-gap="${token}"]`),
466
826
  `${marker} has no rule for gap ${token}`,
467
827
  ).toBe(true);
468
828
  }
829
+ // Padding is the same bargain on the same scale, and it is the dimension Stack did not
830
+ // have at all — a padded region used to be reachable only through a Card, which brought
831
+ // its border and background whether or not they were wanted.
832
+ for (const marker of ["stack", "grid"]) {
833
+ expect(
834
+ declaresRuleFor(base, `[data-terp="${marker}"][data-padding="${token}"]`),
835
+ `${marker} has no rule for padding ${token}`,
836
+ ).toBe(true);
837
+ }
469
838
  }
470
839
  });
471
840
 
@@ -497,13 +866,14 @@ describe("cascade structure", () => {
497
866
  // DataViewCardList's inline background on the element it matches. The escalation
498
867
  // came off when the card migrated, which is the LAST of the two rather than the
499
868
  // first — and the card's tone rules now lose to it on layer instead.
500
- '[data-terp="dataview-card"]:focus-within',
869
+ '[data-terp="dataview-card"][data-clickable="true"]:focus-within',
501
870
  // Re-derived rather than assumed, because the condition is not "has something
502
- // migrated" but "can any element this selector matches still beat it". Of the SIXTEEN
871
+ // migrated" but "can any element this selector matches still beat it". Of the SEVENTEEN
503
872
  // sites wearing the iconbutton marker, only six can carry the disabled attribute at all:
504
873
  // the four pagination arrows and the two reorder arrows. The shell's toggles, the toast
505
- // dismisser, the combobox's clear button, the calendar's month arrows, the expand toggle
506
- // and the toolbar's clear-search button and two layout toggles have no disabled state
874
+ // dismisser, the combobox's clear button, the calendar's month arrows, the expand toggle,
875
+ // the toolbar's clear-search button and two layout toggles and the password reveal have no
876
+ // disabled state —
507
877
  // and each of the six set cursor inline until it migrated. The day a calendar arrow gains
508
878
  // a min/max bound, this answer changes back.
509
879
  '[data-terp="iconbutton"]:disabled',
@@ -706,10 +1076,51 @@ describe("cascade structure", () => {
706
1076
  // check below only asks that the block is non-empty, which a block of dead declarations
707
1077
  // satisfies. Prose rhythm belongs in descendant rules.
708
1078
  const base = layerBody("terp.base");
709
- const at = base.indexOf('[data-terp="markdown"]');
710
- expect(at).toBeGreaterThan(-1);
711
- const block = base.slice(base.indexOf("{", at) + 1, base.indexOf("}", at));
712
- expect(block.trim()).toBe("display: contents;");
1079
+ // Anchored to the STANDALONE rule, not to the first mention of the marker. A plain indexOf
1080
+ // finds whichever selector merely contains it — and one now does: the measured-width
1081
+ // reach-through that caps this wrapper's blocks is declared earlier in the layer, so the
1082
+ // loose locator sliced that rule's body instead and reported a width where it expected
1083
+ // display: contents. The assertion was right and its aim was not.
1084
+ const rule = [...base.matchAll(/([^{}]+)\{([^{}]*)\}/g)].find(
1085
+ (match) => match[1]!.trim() === '[data-terp="markdown"]',
1086
+ );
1087
+ expect(rule, 'no standalone [data-terp="markdown"] rule in terp.base').toBeDefined();
1088
+ expect(rule![2]!.trim()).toBe("display: contents;");
1089
+ });
1090
+
1091
+ it("pins the values of five declarations no lane can reach", () => {
1092
+ // Presence is not enough for these five, and that is the distinction the roll-calls above
1093
+ // already draw for Button sizes and gap steps: a rule can be present and WRONG, and nothing
1094
+ // here would notice. Each of these is reachable through a published prop, and none of them
1095
+ // is painted by any specimen — so today their values could be changed to anything and the
1096
+ // whole suite stays green.
1097
+ //
1098
+ // Two disabled paints, both reachable (Combobox and DatePicker each take `disabled`), and
1099
+ // neither rendered in that state by any specimen. Mutation: change either colour token.
1100
+ const state = layerBody("terp.state");
1101
+ const ruleFor = (selector: string) => {
1102
+ const match = [...state.matchAll(/([^{}]+)\{([^{}]*)\}/g)].find(
1103
+ (rule) => rule[1]!.trim().replace(/\s+/g, " ") === selector,
1104
+ );
1105
+ expect(match, `${selector} has no rule of its own`).toBeDefined();
1106
+ return match![2]!;
1107
+ };
1108
+ expect(ruleFor('[data-terp="combobox-option"]:disabled')).toContain(
1109
+ "color: var(--color-neutral-400)",
1110
+ );
1111
+ expect(ruleFor('[data-terp="calendar-day"]:disabled')).toContain(
1112
+ "color: var(--color-neutral-300)",
1113
+ );
1114
+
1115
+ // The split archetype's two outer list widths. `md` is painted by the split-page specimen;
1116
+ // `sm` and `lg` are painted by nothing, and they live inside a wide-viewport media block the
1117
+ // pinned 1280 does reach — so the rules apply and simply have no picture. A renamed
1118
+ // attribute value would silently stop matching. Mutation: rename "sm" to "small", or change
1119
+ // either track measure.
1120
+ const wide = css.slice(css.indexOf('[data-terp="splitpage-panes"][data-list-width="sm"]'));
1121
+ expect(wide.slice(0, 120)).toContain("minmax(0, 18rem) minmax(0, 1fr)");
1122
+ const lg = css.slice(css.indexOf('[data-terp="splitpage-panes"][data-list-width="lg"]'));
1123
+ expect(lg.slice(0, 120)).toContain("minmax(0, 32rem) minmax(0, 1fr)");
713
1124
  });
714
1125
 
715
1126
  it("gives every migrated component a base rule in terp.base", () => {
@@ -720,16 +1131,21 @@ describe("cascade structure", () => {
720
1131
  const base = layerBody("terp.base");
721
1132
  for (const marker of [
722
1133
  "button",
1134
+ "avatar",
723
1135
  "badge",
724
1136
  "alert",
725
1137
  "tooltip",
726
1138
  "input",
1139
+ "input-password",
727
1140
  "field",
728
1141
  "control-label",
729
1142
  "checkbox",
730
1143
  "radio",
731
1144
  "switch",
732
1145
  "stack",
1146
+ "appshell-skip-link",
1147
+ "splitpage-panes",
1148
+ "splitpane",
733
1149
  "detail-list",
734
1150
  "combobox",
735
1151
  "combobox-list",
@@ -777,7 +1193,6 @@ describe("cascade structure", () => {
777
1193
  "dialog-description",
778
1194
  "dialog-actions",
779
1195
  "user-menu",
780
- "user-menu-avatar",
781
1196
  "user-menu-email",
782
1197
  "user-menu-header",
783
1198
  "user-menu-identity",
@@ -854,7 +1269,6 @@ describe("cascade structure", () => {
854
1269
  "module-nav-list",
855
1270
  "module-nav-link",
856
1271
  "profile-card",
857
- "profile-avatar",
858
1272
  "profile-email",
859
1273
  "profile-role",
860
1274
  "login-view",
@@ -868,7 +1282,70 @@ describe("cascade structure", () => {
868
1282
  "login-error",
869
1283
  "admin-form",
870
1284
  "admin-section-title",
871
- "admin-payload",
1285
+ "grid",
1286
+ "divider",
1287
+ "heading",
1288
+ "text",
1289
+ "code",
1290
+ "code-block",
1291
+ "link",
1292
+ "detail-list-row",
1293
+ // `appshell-nav-group-label` is here; `appshell-nav-group` deliberately is NOT, and the
1294
+ // omission joins `appshell-mark` and `appshell-skip-link` above rather than starting a
1295
+ // new kind of exception. The wrapper declares nothing on its own — in the sidebar it is a
1296
+ // plain block whose whole job is to be a sibling, so its two rules are a `+` pair and a
1297
+ // placement override, and it has no standalone selector to match. `declaresRuleFor` wants
1298
+ // exact selector equality, which is what makes it worth having: adding the family pattern
1299
+ // here to make the list look complete would fail, correctly.
1300
+ "appshell-nav-group-label",
1301
+ // The 32 below close a gap the roll-call carried quietly: it listed barely more than
1302
+ // three quarters of the pinned markers, so a quarter of the sheet's base blocks could be
1303
+ // DELETED and only the screenshot lane would notice — and only for a marker some specimen
1304
+ // happens to paint. (No figures here on purpose: both sides of that ratio have moved twice
1305
+ // since it was written, and a citation keeps where a tally rots.)
1306
+ // markers.test.ts cannot see a deleted rule either: removing a block only shrinks the
1307
+ // styled set, which still satisfies both of its directions.
1308
+ //
1309
+ // Seven pinned markers are deliberately NOT here, because they have no standalone base
1310
+ // rule to assert. appshell-nav-group declares nothing on its own (it is a
1311
+ // sibling-combinator pair plus a placement override); dataview-row, dataview-select-cell,
1312
+ // dataview-expand-cell and dataview-actions-cell are styled through their descendants
1313
+ // and attribute variants; and drawer-focus-start / drawer-focus-end appear only in the
1314
+ // shared visually-hidden selector list. Adding any of them here fails, correctly —
1315
+ // declaresRuleFor wants exact selector equality, which is what makes this list mean
1316
+ // something.
1317
+ "alert-body",
1318
+ "alert-icon",
1319
+ "alert-title",
1320
+ "appshell-mark",
1321
+ "breadcrumbs-separator",
1322
+ "button-icon",
1323
+ "calendar-header",
1324
+ "calendar-title",
1325
+ "calendar-weekday",
1326
+ "card-actions",
1327
+ "card-description",
1328
+ "card-heading",
1329
+ "combobox-empty",
1330
+ "combobox-field",
1331
+ "detail-list-term",
1332
+ "detail-list-value",
1333
+ "empty-state-description",
1334
+ "empty-state-icon",
1335
+ "empty-state-title",
1336
+ "error-state-description",
1337
+ "error-state-icon",
1338
+ "error-state-title",
1339
+ "field-error",
1340
+ "field-hint",
1341
+ "field-label",
1342
+ "field-label-text",
1343
+ "iconbutton",
1344
+ "loading-state-spinner",
1345
+ "radio-group",
1346
+ "radio-group-legend",
1347
+ "radio-group-options",
1348
+ "tooltip-anchor",
872
1349
  ]) {
873
1350
  expect(
874
1351
  declaresRuleFor(base, `[data-terp="${marker}"]`),