@terpjs/react-core 0.9.0 → 0.10.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 (96) hide show
  1. package/README.md +56 -20
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Field.test.tsx +30 -0
  6. package/src/Field.tsx +36 -8
  7. package/src/FormPage.tsx +54 -0
  8. package/src/LoginView.tsx +17 -4
  9. package/src/ModuleNav.test.tsx +17 -10
  10. package/src/ModuleNav.tsx +35 -3
  11. package/src/Page.tsx +23 -1
  12. package/src/ProfileView.test.tsx +1 -1
  13. package/src/ProfileView.tsx +2 -4
  14. package/src/SettingsPage.tsx +50 -0
  15. package/src/SplitPage.tsx +150 -0
  16. package/src/UserMenu.test.tsx +28 -5
  17. package/src/UserMenu.tsx +15 -9
  18. package/src/admin/AuditLogAdmin.tsx +21 -7
  19. package/src/admin/GroupCreate.tsx +17 -3
  20. package/src/admin/GroupDetail.tsx +48 -13
  21. package/src/admin/GroupsAdmin.tsx +13 -5
  22. package/src/admin/UserCreate.tsx +40 -11
  23. package/src/admin/UserDetail.tsx +4 -1
  24. package/src/admin/UsersAdmin.tsx +14 -6
  25. package/src/admin/admin.test.tsx +212 -8
  26. package/src/admin/fieldErrors.ts +45 -0
  27. package/src/bootstrap.test.tsx +208 -0
  28. package/src/bootstrap.tsx +121 -5
  29. package/src/breakpoints.ts +41 -0
  30. package/src/dataview/DataView.tsx +12 -5
  31. package/src/dataview/DataViewCardList.tsx +8 -7
  32. package/src/dataview/DataViewPagination.tsx +15 -8
  33. package/src/dataview/DataViewTable.tsx +32 -21
  34. package/src/dataview/README.md +13 -2
  35. package/src/dataview/index.ts +1 -0
  36. package/src/dataview/internal.tsx +31 -1
  37. package/src/dataview/types.ts +26 -3
  38. package/src/format.test.tsx +213 -0
  39. package/src/format.ts +150 -0
  40. package/src/icons.tsx +67 -5
  41. package/src/index.ts +56 -6
  42. package/src/layout.manifest.json +118 -0
  43. package/src/layout.manifest.test.ts +205 -0
  44. package/src/layout.test.tsx +198 -1
  45. package/src/layout.tsx +208 -11
  46. package/src/layoutContract.test.tsx +311 -2
  47. package/src/layoutContract.ts +44 -3
  48. package/src/layoutDeclaration.test.ts +435 -0
  49. package/src/layoutDeclaration.ts +531 -0
  50. package/src/locale.tsx +3 -0
  51. package/src/markers.test.ts +25 -5
  52. package/src/nav.test.ts +234 -4
  53. package/src/nav.ts +180 -6
  54. package/src/navActive.test.ts +115 -0
  55. package/src/navActive.ts +119 -0
  56. package/src/navLink.tsx +20 -2
  57. package/src/previewBridge.test.ts +327 -0
  58. package/src/previewBridge.ts +278 -0
  59. package/src/raw.d.ts +14 -2
  60. package/src/review.test.tsx +272 -0
  61. package/src/router.test.tsx +575 -2
  62. package/src/router.tsx +202 -19
  63. package/src/styles.test.ts +483 -24
  64. package/src/styles.ts +956 -85
  65. package/src/theme.test.tsx +29 -0
  66. package/src/theme.themes.test.ts +13 -7
  67. package/src/theme.tsx +30 -33
  68. package/src/themes.ts +54 -0
  69. package/src/toast.tsx +2 -1
  70. package/src/tokens.guard.test.ts +192 -0
  71. package/src/typography.test.tsx +213 -0
  72. package/src/typography.tsx +255 -0
  73. package/src/ui/Avatar.test.tsx +63 -0
  74. package/src/ui/Avatar.tsx +65 -0
  75. package/src/ui/Button.test.tsx +69 -3
  76. package/src/ui/Button.tsx +57 -4
  77. package/src/ui/Card.test.tsx +13 -0
  78. package/src/ui/Card.tsx +28 -1
  79. package/src/ui/Checkbox.tsx +10 -2
  80. package/src/ui/Combobox.test.tsx +49 -0
  81. package/src/ui/Combobox.tsx +8 -2
  82. package/src/ui/DatePicker.tsx +28 -5
  83. package/src/ui/Input.test.tsx +123 -0
  84. package/src/ui/Input.tsx +65 -2
  85. package/src/ui/Menu.tsx +16 -5
  86. package/src/ui/Popover.tsx +13 -0
  87. package/src/ui/Radio.tsx +10 -5
  88. package/src/ui/Select.test.tsx +232 -0
  89. package/src/ui/Select.tsx +177 -8
  90. package/src/ui/Switch.tsx +10 -2
  91. package/src/ui/Tabs.tsx +16 -6
  92. package/src/ui/Tooltip.test.tsx +56 -1
  93. package/src/ui/Tooltip.tsx +69 -6
  94. package/src/uiText.tsx +9 -0
  95. package/src/unwrap.test.ts +132 -0
  96. 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
  });
@@ -443,10 +524,28 @@ describe("cascade structure", () => {
443
524
  }
444
525
  // Every transition this sheet declares is either on a marked element or on one of the
445
526
  // 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)) {
527
+ // BOTH layers. This read only terp.base, so a transition declared in terp.state — where
528
+ // every hover, focus and selected treatment lives — was audited by nothing at all. The one
529
+ // that exists there today passed only because its selector happens to be in the explicit
530
+ // list above, which is luck rather than coverage.
531
+ //
532
+ // And the "marked" heuristic needs the trailing pseudo stripped before it is applied.
533
+ // `\s[a-z]+$` asks whether the selector ends in a bare element name preceded by whitespace,
534
+ // but `[data-terp="card"] span:hover` ends in `:hover` — the character before the final run
535
+ // is a colon, not a space — so the test scored it as marked and waved it through. Reduced
536
+ // motion cannot reach that rule: terp.motion is the last layer and beats any earlier one on
537
+ // the elements its selectors MATCH, and its list matches marked elements and four named
538
+ // descendants. A transition on any other descendant simply escapes.
539
+ const transitionRules = [layerBody("terp.base"), layerBody("terp.state")].flatMap((body) => [
540
+ ...body.matchAll(/([^{}]+)\{([^{}]*transition:[^{}]*)\}/g),
541
+ ]);
542
+ expect(transitionRules.length, "no transition rules were scanned at all").toBeGreaterThan(0);
543
+ for (const match of transitionRules) {
448
544
  const selector = match[1].trim().replace(/\s+/g, " ");
449
- const marked = selector.includes("[data-terp") && !/\s[a-z]+$/.test(selector);
545
+ // Strip trailing pseudo-classes / pseudo-elements, so the bare-element test sees the
546
+ // element the selector actually ends on.
547
+ const withoutPseudo = selector.replace(/(::?[a-z-]+(\([^)]*\))?)+$/, "");
548
+ const marked = withoutPseudo.includes("[data-terp") && !/\s[a-z]+$/.test(withoutPseudo);
450
549
  expect(
451
550
  marked || motion.includes(selector),
452
551
  `${selector} declares a transition but reduced motion cannot reach it`,
@@ -454,18 +553,270 @@ describe("cascade structure", () => {
454
553
  }
455
554
  });
456
555
 
556
+ it("times every transition off the published motion scale, never a literal", () => {
557
+ // The scale shipped in 2a and was read by nothing: this sheet wrote `150ms ease`
558
+ // 28 times and `100ms ease` once across 16 declarations, and read a motion token
559
+ // zero times — so a Studio editor built from the manifest would have offered four
560
+ // duration controls that moved nothing. Wiring them was inert by construction:
561
+ // --motion-duration-fast IS 150ms, --motion-duration-instant IS 100ms and
562
+ // --motion-easing-standard IS ease, so every literal mapped onto a token pair.
563
+ //
564
+ // This gate has to be structural, and that is the whole reason it exists here
565
+ // rather than in a baseline. The screenshot lane runs with
566
+ // `animations: "disabled"`, so a duration is invisible to it: a wrong value — or
567
+ // literal number 30 — would move no pixel and nothing would say so.
568
+ //
569
+ // Scoped to `transition` on purpose. The spinner's `animation: terp-spin 0.8s` is
570
+ // a rotation period rather than an interaction step and the scale tops out at
571
+ // 400ms, so there is no token for it to name.
572
+ const declarations = [...css.matchAll(/transition:\s*([^;]+);/g)].map((match) => match[1]!);
573
+ expect(declarations.length).toBeGreaterThan(10);
574
+ expect(
575
+ declarations.filter((value) => /\d+m?s\b/.test(value)),
576
+ "a transition's duration belongs on the published motion scale, not in the rule",
577
+ ).toEqual([]);
578
+ });
579
+
580
+ it("puts the responsive Stack rules after the ones they override", () => {
581
+ // A stack with direction { narrow: "row", wide: "column" } carries BOTH
582
+ // data-direction="row" and data-direction-wide="column", and the two selectors weigh the
583
+ // same (0,2,0) — so nothing but source order decides which applies above the cutover.
584
+ // Backwards, the narrow value renders at every width, which looks exactly like the prop
585
+ // not working rather than like a cascade mistake.
586
+ //
587
+ // The same tie the focus ring and the loading cursor both turned on, and the third time
588
+ // it has mattered in this sheet: equal specificity in one layer is settled by position,
589
+ // and position is the one thing a reader cannot see from the rule.
590
+ const base = layerBody("terp.base");
591
+ const wideAt = base.indexOf("@media not all and (max-width: 768px)");
592
+ expect(wideAt, "the wide half of the responsive props should be in terp.base").toBeGreaterThan(
593
+ -1,
594
+ );
595
+ for (const selector of [
596
+ '[data-terp="stack"][data-direction="row"]',
597
+ '[data-terp="stack"][data-gap="8"]',
598
+ ]) {
599
+ const at = base.indexOf(selector);
600
+ expect(at, `${selector} should exist`).toBeGreaterThan(-1);
601
+ expect(at, `${selector} must be declared before the wide block that overrides it`).toBeLessThan(
602
+ wideAt,
603
+ );
604
+ }
605
+ });
606
+
607
+ it("declares a wide-half gap rule for every step SpaceToken allows", () => {
608
+ // The narrow half is covered by the roll-call below; this is its counterpart. A responsive
609
+ // gap whose wide step has no rule silently renders the narrow gap at every width — which
610
+ // is the failure mode of the whole responsive idea, and invisible unless a specimen happens
611
+ // to be recorded at both viewports.
612
+ const base = layerBody("terp.base");
613
+ const wide = base.slice(base.indexOf("@media not all and (max-width: 768px)"));
614
+ for (const token of [0, 1, 2, 3, 4, 6, 8]) {
615
+ expect(
616
+ declaresRuleFor(wide, `[data-terp="stack"][data-gap-wide="${token}"]`),
617
+ `the wide half has no rule for gap ${token}`,
618
+ ).toBe(true);
619
+ }
620
+ for (const direction of ["column", "row"]) {
621
+ expect(
622
+ declaresRuleFor(wide, `[data-terp="stack"][data-direction-wide="${direction}"]`),
623
+ `the wide half has no rule for direction ${direction}`,
624
+ ).toBe(true);
625
+ }
626
+ });
627
+
628
+ it("pins the four Grid track floors, which no baseline can hold", () => {
629
+ // The grid specimens gate the MECHANISM and not the values, and the gap between those two
630
+ // was measured rather than assumed. An auto-fit grid quantises: a floor only changes the
631
+ // rendered column count when it crosses a threshold for the container's width. At
632
+ // `grid-min-column`'s 66rem, moving the md floor 20rem -> 22rem drops it from three columns
633
+ // to two and repaints 63,524 pixels — while moving it 20rem -> 21rem changes **nothing**,
634
+ // and all ten grid baselines pass. So the pictures prove the attribute selects a different
635
+ // floor; only this proves which floor.
636
+ //
637
+ // The same shape as the shell declarations below, and the reason the values are pinned here
638
+ // rather than promoted to tokens: four published tokens with one consumer is the vocabulary
639
+ // the density cell tokens were deleted for. They become tokens the day an app asks.
640
+ const base = layerBody("terp.base");
641
+ for (const [attribute, floor] of [
642
+ [null, "16rem"],
643
+ ["xs", "10rem"],
644
+ ["md", "20rem"],
645
+ ["lg", "26rem"],
646
+ ] as const) {
647
+ const selector =
648
+ attribute === null
649
+ ? '[data-terp="grid"]'
650
+ : `[data-terp="grid"][data-min-column="${attribute}"]`;
651
+ const at = base.indexOf(`${selector} {`);
652
+ expect(at, `${selector} should have a rule`).toBeGreaterThan(-1);
653
+ expect(
654
+ base.slice(at, base.indexOf("}", at)),
655
+ `${selector} should floor its tracks at ${floor}`,
656
+ ).toContain(`minmax(min(${floor}, 100%), 1fr)`);
657
+ }
658
+ // sm is the default and therefore the base rule, so it has no attribute of its own — and
659
+ // its floor is the same 16rem the hub grid uses, which is why the two agree by construction
660
+ // instead of by coincidence.
661
+ expect(
662
+ declaresRuleFor(base, '[data-terp="grid"][data-min-column="sm"]'),
663
+ "sm is the base rule; an attribute rule for it means the default is described twice",
664
+ ).toBe(false);
665
+ const hub = base.indexOf('[data-terp="hubpage-grid"] {');
666
+ expect(base.slice(hub, base.indexOf("}", hub))).toContain("minmax(min(16rem, 100%), 1fr)");
667
+ });
668
+
669
+ it("floors every fixed Grid column at zero, not at min-content", () => {
670
+ // `repeat(N, 1fr)` looks equivalent and is not: a bare 1fr floors at the track's
671
+ // min-content size, so one long unbroken word in a cell widens its column and the grid
672
+ // overflows its container. A two-column form of long field labels walks straight into it,
673
+ // which is the case Grid exists for. minmax(0, 1fr) is the fix, and it is invisible to
674
+ // every baseline that does not happen to contain an overflowing word.
675
+ const base = layerBody("terp.base");
676
+ for (const count of [1, 2, 3, 4]) {
677
+ const selector = `[data-terp="grid"][data-columns="${count}"]`;
678
+ const at = base.indexOf(`${selector} {`);
679
+ expect(at, `${selector} should have a rule`).toBeGreaterThan(-1);
680
+ expect(
681
+ base.slice(at, base.indexOf("}", at)),
682
+ `${selector} must floor its tracks at 0, or a long word widens the column`,
683
+ ).toContain("minmax(0, 1fr)");
684
+ }
685
+ });
686
+
687
+ it("declares a rule for every size Button names, and none for its default", () => {
688
+ // The same bargain the gap rules strike: the union and the sheet are two lists kept by
689
+ // hand, so widening ButtonSize without adding a rule would silently fall back to the
690
+ // standard geometry rather than fail.
691
+ //
692
+ // And the converse, which is the part worth pinning: md must have NO rule of its own. Its
693
+ // geometry is the base rule, exactly as "comfortable" is the token sheet's :root value, and
694
+ // a data-size="md" rule appearing here would mean the component had started stamping the
695
+ // attribute for its default — leaving two places that describe the standard control.
696
+ const base = layerBody("terp.base");
697
+ for (const size of ["sm", "lg"]) {
698
+ expect(
699
+ declaresRuleFor(base, `[data-terp="button"][data-size="${size}"]`),
700
+ `Button has no rule for size ${size}`,
701
+ ).toBe(true);
702
+ }
703
+ expect(
704
+ declaresRuleFor(base, '[data-terp="button"][data-size="md"]'),
705
+ "md is the base rule; a rule of its own means the default is described twice",
706
+ ).toBe(false);
707
+ // Sizes read the density token rather than heights of their own, which is what makes the
708
+ // two dimensions compose: the compact re-scoping moves the token these calc() off.
709
+ const at = base.indexOf('[data-terp="button"][data-size="sm"]');
710
+ expect(
711
+ base.slice(at, base.indexOf("}", at)),
712
+ "a size that hardcodes its height stops following density",
713
+ ).toContain("var(--density-control-min-height)");
714
+ });
715
+
716
+ it("declares one avatar rule plus a small variant, and nothing for the default size", () => {
717
+ // The tile used to be two rules of eleven declarations each — profile-avatar and
718
+ // user-menu-avatar — identical but for a width, a height and a font size. Merging them is only
719
+ // zero-pixel if the variant keeps BOTH geometries, so this pins the shape of the pair: a base
720
+ // rule carrying md, and an sm rule carrying exactly the three declarations that differ.
721
+ const base = layerBody("terp.base");
722
+ expect(declaresRuleFor(base, '[data-terp="avatar"]')).toBe(true);
723
+ expect(declaresRuleFor(base, '[data-terp="avatar"][data-size="sm"]')).toBe(true);
724
+ // md is the base rule, as with Button. A rule of its own means the default is described twice.
725
+ expect(
726
+ declaresRuleFor(base, '[data-terp="avatar"][data-size="md"]'),
727
+ "md is the base rule; a rule of its own means the default is described twice",
728
+ ).toBe(false);
729
+ const at = base.indexOf('[data-terp="avatar"][data-size="sm"]');
730
+ const body = base.slice(at, base.indexOf("}", at));
731
+ for (const declaration of ["width: 2rem", "height: 2rem", "var(--font-size-sm)"]) {
732
+ expect(body, `the small variant must restate ${declaration}`).toContain(declaration);
733
+ }
734
+ // And nothing else: a declaration here that the base already sets is a second description of
735
+ // the same tile, which is the duplication the merge removed.
736
+ expect(body).not.toContain("border-radius");
737
+ expect(body).not.toContain("background");
738
+ });
739
+
740
+ it("declares a min-inline-size for every step ColumnWidth names, and nothing it does not", () => {
741
+ // The Button gate above notes that its union and its sheet are two lists kept by hand. This
742
+ // one reads the union out of the source instead, because a declared track that silently does
743
+ // nothing is exactly the defect this attribute exists to end: `meta.width` was a px hint that
744
+ // `table-layout: auto` shrank away, so widening ColumnWidth without a rule would restore the
745
+ // old behaviour under a new spelling.
746
+ const source = sources["./dataview/types.ts"];
747
+ const union = /export type ColumnWidth =([^;]+);/.exec(source);
748
+ expect(union, "ColumnWidth is not declared where this test looks for it").not.toBeNull();
749
+ const steps = [...(union?.[1] ?? "").matchAll(/"([a-z]+)"/g)].map((match) => match[1]);
750
+ expect(steps.length, "parsed no steps out of ColumnWidth").toBeGreaterThan(0);
751
+
752
+ const base = layerBody("terp.base");
753
+ for (const step of steps) {
754
+ const selector = `[data-terp="dataview-table"] > thead > tr > th[data-width="${step}"]`;
755
+ expect(declaresRuleFor(base, selector), `no rule for step ${step}`).toBe(true);
756
+ const at = base.indexOf(selector);
757
+ expect(
758
+ base.slice(at, base.indexOf("}", at)),
759
+ `step ${step} must bind as a minimum; a width is a preference auto layout shrinks away`,
760
+ ).toContain("min-inline-size");
761
+ }
762
+ // And nothing beyond the union. `lg` and `content` were both drafted and declined for having
763
+ // no consumer, so a rule for either means the sheet grew a step the type cannot express.
764
+ for (const absent of ["lg", "xl", "content", "auto"]) {
765
+ expect(
766
+ declaresRuleFor(
767
+ base,
768
+ `[data-terp="dataview-table"] > thead > tr > th[data-width="${absent}"]`,
769
+ ),
770
+ `the sheet declares a step ColumnWidth does not name: ${absent}`,
771
+ ).toBe(false);
772
+ }
773
+ });
774
+
775
+ it("keeps the loading cursor where it can beat the disabled cursor", () => {
776
+ // A loading button is also `:disabled` — the component sets both — and the disabled rule
777
+ // lives in terp.state. So a `data-loading` rule in terp.base loses on layer order and the
778
+ // cursor silently stays `not-allowed`, which tells a user "you may not" where the truth is
779
+ // "not yet". It has to be in terp.state, and after the disabled rule, because the two weigh
780
+ // the same (0,2,0) and nothing but source order separates them.
781
+ //
782
+ // This is the focus-ring lesson in miniature, and it is invisible to every other lane:
783
+ // Playwright's screenshots do not paint a pointer, so no baseline has ever held either
784
+ // cursor. The computed lane asserts the resolved values; this asserts the structure that
785
+ // produces them.
786
+ const state = layerBody("terp.state");
787
+ const base = layerBody("terp.base");
788
+ expect(
789
+ declaresRuleFor(base, '[data-terp="button"][data-loading="true"]'),
790
+ "in terp.base this rule loses to the disabled cursor and does nothing",
791
+ ).toBe(false);
792
+ const disabledAt = state.indexOf('[data-terp="button"]:disabled');
793
+ const loadingAt = state.indexOf('[data-terp="button"][data-loading="true"]');
794
+ expect(disabledAt, "the disabled rule should be in terp.state").toBeGreaterThan(-1);
795
+ expect(loadingAt, "the loading rule should be in terp.state").toBeGreaterThan(-1);
796
+ expect(loadingAt).toBeGreaterThan(disabledAt);
797
+ });
798
+
457
799
  it("declares a gap rule for every step SpaceToken allows", () => {
458
800
  // gap moved from a computed inline value to a rule per step, so the union and the sheet
459
801
  // are now two lists maintained by hand. Widening SpaceToken without adding rules would
460
802
  // silently fall back to the default gap rather than fail.
461
803
  const base = layerBody("terp.base");
462
804
  for (const token of [0, 1, 2, 3, 4, 6, 8]) {
463
- for (const marker of ["stack", "card"]) {
805
+ for (const marker of ["stack", "card", "grid"]) {
464
806
  expect(
465
807
  declaresRuleFor(base, `[data-terp="${marker}"][data-gap="${token}"]`),
466
808
  `${marker} has no rule for gap ${token}`,
467
809
  ).toBe(true);
468
810
  }
811
+ // Padding is the same bargain on the same scale, and it is the dimension Stack did not
812
+ // have at all — a padded region used to be reachable only through a Card, which brought
813
+ // its border and background whether or not they were wanted.
814
+ for (const marker of ["stack", "grid"]) {
815
+ expect(
816
+ declaresRuleFor(base, `[data-terp="${marker}"][data-padding="${token}"]`),
817
+ `${marker} has no rule for padding ${token}`,
818
+ ).toBe(true);
819
+ }
469
820
  }
470
821
  });
471
822
 
@@ -497,13 +848,14 @@ describe("cascade structure", () => {
497
848
  // DataViewCardList's inline background on the element it matches. The escalation
498
849
  // came off when the card migrated, which is the LAST of the two rather than the
499
850
  // first — and the card's tone rules now lose to it on layer instead.
500
- '[data-terp="dataview-card"]:focus-within',
851
+ '[data-terp="dataview-card"][data-clickable="true"]:focus-within',
501
852
  // 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
853
+ // migrated" but "can any element this selector matches still beat it". Of the SEVENTEEN
503
854
  // sites wearing the iconbutton marker, only six can carry the disabled attribute at all:
504
855
  // 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
856
+ // dismisser, the combobox's clear button, the calendar's month arrows, the expand toggle,
857
+ // the toolbar's clear-search button and two layout toggles and the password reveal have no
858
+ // disabled state —
507
859
  // and each of the six set cursor inline until it migrated. The day a calendar arrow gains
508
860
  // a min/max bound, this answer changes back.
509
861
  '[data-terp="iconbutton"]:disabled',
@@ -706,10 +1058,51 @@ describe("cascade structure", () => {
706
1058
  // check below only asks that the block is non-empty, which a block of dead declarations
707
1059
  // satisfies. Prose rhythm belongs in descendant rules.
708
1060
  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;");
1061
+ // Anchored to the STANDALONE rule, not to the first mention of the marker. A plain indexOf
1062
+ // finds whichever selector merely contains it — and one now does: the measured-width
1063
+ // reach-through that caps this wrapper's blocks is declared earlier in the layer, so the
1064
+ // loose locator sliced that rule's body instead and reported a width where it expected
1065
+ // display: contents. The assertion was right and its aim was not.
1066
+ const rule = [...base.matchAll(/([^{}]+)\{([^{}]*)\}/g)].find(
1067
+ (match) => match[1]!.trim() === '[data-terp="markdown"]',
1068
+ );
1069
+ expect(rule, 'no standalone [data-terp="markdown"] rule in terp.base').toBeDefined();
1070
+ expect(rule![2]!.trim()).toBe("display: contents;");
1071
+ });
1072
+
1073
+ it("pins the values of five declarations no lane can reach", () => {
1074
+ // Presence is not enough for these five, and that is the distinction the roll-calls above
1075
+ // already draw for Button sizes and gap steps: a rule can be present and WRONG, and nothing
1076
+ // here would notice. Each of these is reachable through a published prop, and none of them
1077
+ // is painted by any specimen — so today their values could be changed to anything and the
1078
+ // whole suite stays green.
1079
+ //
1080
+ // Two disabled paints, both reachable (Combobox and DatePicker each take `disabled`), and
1081
+ // neither rendered in that state by any specimen. Mutation: change either colour token.
1082
+ const state = layerBody("terp.state");
1083
+ const ruleFor = (selector: string) => {
1084
+ const match = [...state.matchAll(/([^{}]+)\{([^{}]*)\}/g)].find(
1085
+ (rule) => rule[1]!.trim().replace(/\s+/g, " ") === selector,
1086
+ );
1087
+ expect(match, `${selector} has no rule of its own`).toBeDefined();
1088
+ return match![2]!;
1089
+ };
1090
+ expect(ruleFor('[data-terp="combobox-option"]:disabled')).toContain(
1091
+ "color: var(--color-neutral-400)",
1092
+ );
1093
+ expect(ruleFor('[data-terp="calendar-day"]:disabled')).toContain(
1094
+ "color: var(--color-neutral-300)",
1095
+ );
1096
+
1097
+ // The split archetype's two outer list widths. `md` is painted by the split-page specimen;
1098
+ // `sm` and `lg` are painted by nothing, and they live inside a wide-viewport media block the
1099
+ // pinned 1280 does reach — so the rules apply and simply have no picture. A renamed
1100
+ // attribute value would silently stop matching. Mutation: rename "sm" to "small", or change
1101
+ // either track measure.
1102
+ const wide = css.slice(css.indexOf('[data-terp="splitpage-panes"][data-list-width="sm"]'));
1103
+ expect(wide.slice(0, 120)).toContain("minmax(0, 18rem) minmax(0, 1fr)");
1104
+ const lg = css.slice(css.indexOf('[data-terp="splitpage-panes"][data-list-width="lg"]'));
1105
+ expect(lg.slice(0, 120)).toContain("minmax(0, 32rem) minmax(0, 1fr)");
713
1106
  });
714
1107
 
715
1108
  it("gives every migrated component a base rule in terp.base", () => {
@@ -720,16 +1113,21 @@ describe("cascade structure", () => {
720
1113
  const base = layerBody("terp.base");
721
1114
  for (const marker of [
722
1115
  "button",
1116
+ "avatar",
723
1117
  "badge",
724
1118
  "alert",
725
1119
  "tooltip",
726
1120
  "input",
1121
+ "input-password",
727
1122
  "field",
728
1123
  "control-label",
729
1124
  "checkbox",
730
1125
  "radio",
731
1126
  "switch",
732
1127
  "stack",
1128
+ "appshell-skip-link",
1129
+ "splitpage-panes",
1130
+ "splitpane",
733
1131
  "detail-list",
734
1132
  "combobox",
735
1133
  "combobox-list",
@@ -777,7 +1175,6 @@ describe("cascade structure", () => {
777
1175
  "dialog-description",
778
1176
  "dialog-actions",
779
1177
  "user-menu",
780
- "user-menu-avatar",
781
1178
  "user-menu-email",
782
1179
  "user-menu-header",
783
1180
  "user-menu-identity",
@@ -854,7 +1251,6 @@ describe("cascade structure", () => {
854
1251
  "module-nav-list",
855
1252
  "module-nav-link",
856
1253
  "profile-card",
857
- "profile-avatar",
858
1254
  "profile-email",
859
1255
  "profile-role",
860
1256
  "login-view",
@@ -868,7 +1264,70 @@ describe("cascade structure", () => {
868
1264
  "login-error",
869
1265
  "admin-form",
870
1266
  "admin-section-title",
871
- "admin-payload",
1267
+ "grid",
1268
+ "divider",
1269
+ "heading",
1270
+ "text",
1271
+ "code",
1272
+ "code-block",
1273
+ "link",
1274
+ "detail-list-row",
1275
+ // `appshell-nav-group-label` is here; `appshell-nav-group` deliberately is NOT, and the
1276
+ // omission joins `appshell-mark` and `appshell-skip-link` above rather than starting a
1277
+ // new kind of exception. The wrapper declares nothing on its own — in the sidebar it is a
1278
+ // plain block whose whole job is to be a sibling, so its two rules are a `+` pair and a
1279
+ // placement override, and it has no standalone selector to match. `declaresRuleFor` wants
1280
+ // exact selector equality, which is what makes it worth having: adding the family pattern
1281
+ // here to make the list look complete would fail, correctly.
1282
+ "appshell-nav-group-label",
1283
+ // The 32 below close a gap the roll-call carried quietly: it listed barely more than
1284
+ // three quarters of the pinned markers, so a quarter of the sheet's base blocks could be
1285
+ // DELETED and only the screenshot lane would notice — and only for a marker some specimen
1286
+ // happens to paint. (No figures here on purpose: both sides of that ratio have moved twice
1287
+ // since it was written, and a citation keeps where a tally rots.)
1288
+ // markers.test.ts cannot see a deleted rule either: removing a block only shrinks the
1289
+ // styled set, which still satisfies both of its directions.
1290
+ //
1291
+ // Seven pinned markers are deliberately NOT here, because they have no standalone base
1292
+ // rule to assert. appshell-nav-group declares nothing on its own (it is a
1293
+ // sibling-combinator pair plus a placement override); dataview-row, dataview-select-cell,
1294
+ // dataview-expand-cell and dataview-actions-cell are styled through their descendants
1295
+ // and attribute variants; and drawer-focus-start / drawer-focus-end appear only in the
1296
+ // shared visually-hidden selector list. Adding any of them here fails, correctly —
1297
+ // declaresRuleFor wants exact selector equality, which is what makes this list mean
1298
+ // something.
1299
+ "alert-body",
1300
+ "alert-icon",
1301
+ "alert-title",
1302
+ "appshell-mark",
1303
+ "breadcrumbs-separator",
1304
+ "button-icon",
1305
+ "calendar-header",
1306
+ "calendar-title",
1307
+ "calendar-weekday",
1308
+ "card-actions",
1309
+ "card-description",
1310
+ "card-heading",
1311
+ "combobox-empty",
1312
+ "combobox-field",
1313
+ "detail-list-term",
1314
+ "detail-list-value",
1315
+ "empty-state-description",
1316
+ "empty-state-icon",
1317
+ "empty-state-title",
1318
+ "error-state-description",
1319
+ "error-state-icon",
1320
+ "error-state-title",
1321
+ "field-error",
1322
+ "field-hint",
1323
+ "field-label",
1324
+ "field-label-text",
1325
+ "iconbutton",
1326
+ "loading-state-spinner",
1327
+ "radio-group",
1328
+ "radio-group-legend",
1329
+ "radio-group-options",
1330
+ "tooltip-anchor",
872
1331
  ]) {
873
1332
  expect(
874
1333
  declaresRuleFor(base, `[data-terp="${marker}"]`),