@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
package/src/styles.ts CHANGED
@@ -74,11 +74,37 @@
74
74
  * is what lets an app override any framework rule without `!important` — the
75
75
  * restyling this phase exists to enable.
76
76
  *
77
+ * ## Motion
78
+ *
79
+ * Every `transition` here names the published motion scale rather than a literal.
80
+ * It wrote `150ms ease` 28 times and `100ms ease` once while reading a motion token
81
+ * zero times — four duration tokens and three easings published in 2a with no reader,
82
+ * which is the shape `--color-fg-on-brand` was deleted for. Wiring them was provably
83
+ * inert: `--motion-duration-fast` IS `150ms`, `--motion-duration-instant` IS `100ms`
84
+ * and `--motion-easing-standard` IS `ease`, so all 29 literals mapped onto a token
85
+ * pair and no computed value changed.
86
+ *
87
+ * Four tokens stay unread, and that is a recorded position rather than an oversight:
88
+ * `--motion-duration-base`, `--motion-duration-slow`, `--motion-easing-entrance` and
89
+ * `--motion-easing-exit` map onto no literal this sheet contains. Deleting them is a
90
+ * contract change (the manifest publishes them); giving them readers means inventing
91
+ * overlay entrance/exit animations, which is a behaviour change dressed as a token
92
+ * wiring — and the screenshot lane runs with `animations: "disabled"`, so it could not
93
+ * see either the animation or a wrong duration in it. They are named in
94
+ * `tokens.guard.test.ts` as an exact list, so wiring one shrinks that list and
95
+ * publishing an eighth forces the decision instead of drifting.
96
+ *
97
+ * The spinner's `0.8s` is the one deliberate literal left. It is a rotation period
98
+ * rather than an interaction step, and the scale tops out at 400ms, so there is no
99
+ * token to name — the gate is scoped to `transition` for exactly that reason.
100
+ *
77
101
  * The injector is idempotent, SSR-safe (guarded on `document`), and appends
78
102
  * the rules through `textContent` — never `innerHTML` — so no HTML sink is
79
103
  * touched.
80
104
  */
81
105
 
106
+ import { WIDE_VIEWPORT_QUERY } from "./breakpoints";
107
+
82
108
  /** The `<style>` element id used to detect a prior injection. */
83
109
  export const TERP_STYLES_ID = "terp-core-styles";
84
110
 
@@ -119,6 +145,37 @@ export const TERP_STYLES_CSS = `
119
145
  depend on that at all: a custom property declared on an ancestor is inherited
120
146
  rather than cascaded against, so a DataView stamping the attribute on itself
121
147
  wins over :root whatever the source order. */
148
+ /* The comfortable island, which ADR 0094 deferred "until something asks" — and the shell
149
+ taking a density of its own is what asked. DataView's own docstring names the gap: inside an
150
+ already-compact subtree, density="comfortable" did not make anything comfortable, because
151
+ comfortable was the ABSENCE of an attribute and absence cannot override an ancestor.
152
+ That was fine while nothing could put a DataView inside a compact subtree. With
153
+ AppShell density="compact" it becomes a legal prop combination that silently does nothing —
154
+ the shape this phase keeps refusing, most recently in Select's options union. (A running tally
155
+ lived here and in four other places, and two of them said three. A citation keeps.)
156
+ The mechanism is the compact rule mirrored, and it works through INHERITANCE rather than
157
+ specificity: the nearest ancestor carrying either attribute sets the live tokens for its
158
+ subtree, so an island simply re-sets them. The two selectors never match the same element,
159
+ so they never compete. Unlayered for the compact rule's reason — inside a layer it would
160
+ tie with the contract's own unlayered :root values whenever the attribute lands on <html>,
161
+ which is what the :root-qualified copies below settle.
162
+ The values are the :root values by construction, so stamping comfortable where nothing is
163
+ compact computes exactly what it computed before: provably zero-diff. */
164
+ /* Each selector is written twice, and the :root-qualified copy is the load-bearing one. The
165
+ contract declares these same custom properties on :root, unlayered, at (0,1,0) — and a
166
+ bare [data-density="..."] on <html> is ALSO (0,1,0), so the two tied and only the order
167
+ the two sheets happened to load decided the winner. A production build extracts tokens.css
168
+ to a <link> that precedes the injected sheet, so react-core won by construction and the
169
+ exposure was the dev server and any host loading the tokens late. :root[data-density] is
170
+ (0,2,0) and wins outright; the unqualified copy stays for a density island on a subtree,
171
+ where there is no :root to qualify and nothing to compete with. */
172
+ :root[data-density="comfortable"],
173
+ [data-density="comfortable"] {
174
+ --density-control-min-height: var(--density-comfortable-control-min-height);
175
+ --density-cell-pad-y: var(--density-comfortable-cell-pad-y);
176
+ --density-cell-pad-x: var(--density-comfortable-cell-pad-x);
177
+ }
178
+ :root[data-density="compact"],
122
179
  [data-density="compact"] {
123
180
  --density-control-min-height: var(--density-compact-control-min-height);
124
181
  --density-cell-pad-y: var(--density-compact-cell-pad-y);
@@ -158,9 +215,26 @@ body {
158
215
  they match every scrollable element globally). Paired with the color-scheme
159
216
  declaration on the token roots so any native chrome we do not restyle here
160
217
  (notably the native <select> option popup) also follows the theme. */
218
+ /* And the gutter is reserved whether or not the page is currently long enough to need
219
+ it. Without this, every navigation between a page that fits and a page that does not
220
+ changes the width of the content box by the scrollbar's width, so the whole layout —
221
+ the header, the table, the centred login card — jumps sideways on the way in and back
222
+ on the way out. The document is the scroll container here (the sidebar is sticky in
223
+ normal flow rather than a scroller of its own), so the root is the right and only
224
+ place for it.
225
+
226
+ stable, not "stable both-edges": the gutter belongs where the scrollbar goes. The
227
+ cost is that a page which never scrolls is off true viewport centre by the gutter,
228
+ most visibly on the sign-in screen, and that is the trade every app that reserves the
229
+ gutter takes. It is layered, so an app that would rather have the jump can turn it off
230
+ from its own unlayered theme.css.
231
+
232
+ This one moves pixels, which is why it is a deliberate line here rather than something
233
+ to slip in beside a refactor: it narrows the content box of every scroll-free page. */
161
234
  html {
162
235
  scrollbar-width: thin;
163
236
  scrollbar-color: var(--color-neutral-300) transparent;
237
+ scrollbar-gutter: stable;
164
238
  }
165
239
  ::-webkit-scrollbar {
166
240
  width: 10px;
@@ -204,8 +278,12 @@ html {
204
278
  font-size: var(--font-size-sm);
205
279
  font-weight: var(--font-weight-medium);
206
280
  line-height: 1.2;
207
- transition: background-color 150ms ease, color 150ms ease,
208
- border-color 150ms ease, box-shadow 150ms ease, transform 100ms ease;
281
+ transition:
282
+ background-color var(--motion-duration-fast) var(--motion-easing-standard),
283
+ color var(--motion-duration-fast) var(--motion-easing-standard),
284
+ border-color var(--motion-duration-fast) var(--motion-easing-standard),
285
+ box-shadow var(--motion-duration-fast) var(--motion-easing-standard),
286
+ transform var(--motion-duration-instant) var(--motion-easing-standard);
209
287
  }
210
288
  [data-terp="button"][data-variant="primary"] {
211
289
  background: var(--color-brand-primary);
@@ -225,6 +303,33 @@ html {
225
303
  background: transparent;
226
304
  color: var(--color-neutral-700);
227
305
  }
306
+ /* Size. Two rules, not three: the standard control's geometry is the base rule above, so
307
+ md is the absence of an attribute — the same shape density takes, where "comfortable" is
308
+ the token sheet's own :root value and the attribute for it matches no rule.
309
+
310
+ The heights are a calc() off the density token rather than a second family of tokens,
311
+ and that is what makes size and density compose without either knowing about the other:
312
+ a small button in a compact subtree resolves 2rem - 0.5rem, because the compact
313
+ re-scoping has already moved the token this reads. A --control-height-sm of its own
314
+ would have needed a compact counterpart, a re-scoping line, and a rule to keep the two
315
+ in step, to express something the space scale already says. */
316
+ [data-terp="button"][data-size="sm"] {
317
+ min-height: calc(var(--density-control-min-height) - var(--space-2));
318
+ padding: 0 var(--space-3);
319
+ font-size: var(--font-size-xs);
320
+ }
321
+ [data-terp="button"][data-size="lg"] {
322
+ min-height: calc(var(--density-control-min-height) + var(--space-2));
323
+ padding: 0 var(--space-6);
324
+ font-size: var(--font-size-base);
325
+ }
326
+ /* Full width beats the base rule's width: fit-content on specificity — (0,2,0) against
327
+ (0,1,0) in the same layer — so it needs neither a layer of its own nor an escalation.
328
+ It exists as a prop because the only other way to reach it was the caller writing
329
+ style={{ width: "100%" }}, which app modules may not do. */
330
+ [data-terp="button"][data-full-width="true"] {
331
+ width: 100%;
332
+ }
228
333
  [data-terp="button-icon"] {
229
334
  display: inline-flex;
230
335
  align-items: center;
@@ -337,7 +442,9 @@ html {
337
442
  font-size: var(--font-size-sm);
338
443
  font-weight: var(--font-weight-normal);
339
444
  line-height: 1.25;
340
- transition: border-color 150ms ease, box-shadow 150ms ease;
445
+ transition:
446
+ border-color var(--motion-duration-fast) var(--motion-easing-standard),
447
+ box-shadow var(--motion-duration-fast) var(--motion-easing-standard);
341
448
  }
342
449
  input[data-terp="input"] {
343
450
  min-height: var(--density-control-min-height);
@@ -374,6 +481,14 @@ textarea[data-terp="input"] {
374
481
  flex-direction: column;
375
482
  gap: var(--space-2);
376
483
  margin: 0;
484
+ /* The list reset both of these primitives document a use for and neither had. as="ul" is
485
+ offered by Stack and Grid alike, and a <ul> arrives with a 40px inline padding and a
486
+ marker per child from the UA sheet — so the documented use rendered bulleted and indented.
487
+ hubpage-grid and resource-list-items already carry exactly this, because both are always
488
+ lists; these two are lists only when asked, which is why it was missed. A no-op on a div,
489
+ and the data-padding rules outweigh it at (0,2,0) when an inset is asked for. */
490
+ padding: 0;
491
+ list-style: none;
377
492
  }
378
493
  [data-terp="stack"][data-direction="row"] { flex-direction: row; }
379
494
  [data-terp="stack"][data-gap="0"] { gap: var(--space-0); }
@@ -384,6 +499,116 @@ textarea[data-terp="input"] {
384
499
  [data-terp="stack"][data-gap="6"] { gap: var(--space-6); }
385
500
  [data-terp="stack"][data-gap="8"] { gap: var(--space-8); }
386
501
  [data-terp="stack"][data-wrap="true"] { flex-wrap: wrap; }
502
+ /* Padding, the dimension Stack did not have — which is why a padded region was reachable
503
+ only through a Card, whose border and background came along whether or not they were
504
+ wanted. A step on the same token scale as gap, so there are no arbitrary insets. */
505
+ [data-terp="stack"][data-padding="0"] { padding: var(--space-0); }
506
+ [data-terp="stack"][data-padding="1"] { padding: var(--space-1); }
507
+ [data-terp="stack"][data-padding="2"] { padding: var(--space-2); }
508
+ [data-terp="stack"][data-padding="3"] { padding: var(--space-3); }
509
+ [data-terp="stack"][data-padding="4"] { padding: var(--space-4); }
510
+ [data-terp="stack"][data-padding="6"] { padding: var(--space-6); }
511
+ [data-terp="stack"][data-padding="8"] { padding: var(--space-8); }
512
+ /* The wide half of a responsive Stack. This block must stay AFTER the rules above and not
513
+ merely above the Grid family: a stack with direction {narrow: "row", wide: "column"}
514
+ carries both data-direction="row" and data-direction-wide="column", and the two selectors
515
+ weigh the same (0,2,0), so nothing but source order decides which wins above the cutover.
516
+ Same for every gap pair. styles.test.ts pins the order, because getting it backwards
517
+ renders the narrow value at every width and looks like the prop not working.
518
+
519
+ The query is INTERPOLATED from ./breakpoints, which is the one \${…} in this sheet and is
520
+ deliberate rather than a slip: it is the complement of the exact string AppShell and
521
+ DataView hand to matchMedia, so the two halves of the cutover partition the viewport by
522
+ construction. Written out here they would be two literals that agree until someone edits
523
+ one. (The convention of grepping this literal for \${ still holds — there should be
524
+ exactly this one.) */
525
+ @media ${WIDE_VIEWPORT_QUERY} {
526
+ [data-terp="stack"][data-direction-wide="column"] { flex-direction: column; }
527
+ [data-terp="stack"][data-direction-wide="row"] { flex-direction: row; }
528
+ [data-terp="stack"][data-gap-wide="0"] { gap: var(--space-0); }
529
+ [data-terp="stack"][data-gap-wide="1"] { gap: var(--space-1); }
530
+ [data-terp="stack"][data-gap-wide="2"] { gap: var(--space-2); }
531
+ [data-terp="stack"][data-gap-wide="3"] { gap: var(--space-3); }
532
+ [data-terp="stack"][data-gap-wide="4"] { gap: var(--space-4); }
533
+ [data-terp="stack"][data-gap-wide="6"] { gap: var(--space-6); }
534
+ [data-terp="stack"][data-gap-wide="8"] { gap: var(--space-8); }
535
+ /* The split's two columns, at the one cutover the chrome around it already uses.
536
+ Three list tracks, three rules, no length ever handed in as a style — the listWidth
537
+ prop is a step for the reason Grid's minColumn is (ADR 0097 §4). The detail track is
538
+ minmax(0, 1fr) so it takes the remainder and still lets a wide table scroll inside
539
+ itself rather than widening the row. */
540
+ [data-terp="splitpage-panes"][data-list-width="sm"] {
541
+ grid-template-columns: minmax(0, 18rem) minmax(0, 1fr);
542
+ }
543
+ [data-terp="splitpage-panes"][data-list-width="md"] {
544
+ grid-template-columns: minmax(0, 24rem) minmax(0, 1fr);
545
+ }
546
+ [data-terp="splitpage-panes"][data-list-width="lg"] {
547
+ grid-template-columns: minmax(0, 32rem) minmax(0, 1fr);
548
+ }
549
+ }
550
+
551
+ /* Grids -------------------------------------------------------------------- */
552
+ /* The two-dimensional primitive. The base rule is the DEFAULT shape — auto-fit at the
553
+ sm track floor, stretched cells — so the three defaults match no attribute, exactly
554
+ as density's "comfortable" and Button's md do.
555
+
556
+ min(16rem, 100%) rather than a bare 16rem, and the min() is load-bearing rather than
557
+ defensive: a bare floor wider than the container makes the single track overflow it,
558
+ so a grid in a narrow panel would scroll sideways instead of going one-column. Same
559
+ mechanism the hub grid uses, and the same 16rem, so the two agree by construction
560
+ rather than by coincidence.
561
+
562
+ The four floors are rem literals rather than tokens on purpose. They are a design
563
+ scale with exactly one consumer today, and vocabulary published before something
564
+ reads it gets retired here — the four density cell tokens were deleted for precisely
565
+ that and came back with their readers. They become tokens the day an app asks to move
566
+ them, which is also when a compact counterpart would have to be decided. */
567
+ [data-terp="grid"] {
568
+ display: grid;
569
+ grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
570
+ gap: var(--space-4);
571
+ align-items: stretch;
572
+ min-width: 0;
573
+ margin: 0;
574
+ /* See the note on stack above: the same reset, for the same documented as="ul". */
575
+ padding: 0;
576
+ list-style: none;
577
+ }
578
+ [data-terp="grid"][data-min-column="xs"] {
579
+ grid-template-columns: repeat(auto-fit, minmax(min(10rem, 100%), 1fr));
580
+ }
581
+ [data-terp="grid"][data-min-column="md"] {
582
+ grid-template-columns: repeat(auto-fit, minmax(min(20rem, 100%), 1fr));
583
+ }
584
+ [data-terp="grid"][data-min-column="lg"] {
585
+ grid-template-columns: repeat(auto-fit, minmax(min(26rem, 100%), 1fr));
586
+ }
587
+ /* A fixed count. minmax(0, 1fr) rather than a bare 1fr, because 1fr floors at the
588
+ track's min-content size — so one long unbroken word in a cell widens its column and
589
+ the grid overflows its container, which is the failure a two-column form of long field
590
+ labels walks straight into. */
591
+ [data-terp="grid"][data-columns="1"] { grid-template-columns: minmax(0, 1fr); }
592
+ [data-terp="grid"][data-columns="2"] { grid-template-columns: repeat(2, minmax(0, 1fr)); }
593
+ [data-terp="grid"][data-columns="3"] { grid-template-columns: repeat(3, minmax(0, 1fr)); }
594
+ [data-terp="grid"][data-columns="4"] { grid-template-columns: repeat(4, minmax(0, 1fr)); }
595
+ [data-terp="grid"][data-gap="0"] { gap: var(--space-0); }
596
+ [data-terp="grid"][data-gap="1"] { gap: var(--space-1); }
597
+ [data-terp="grid"][data-gap="2"] { gap: var(--space-2); }
598
+ [data-terp="grid"][data-gap="3"] { gap: var(--space-3); }
599
+ [data-terp="grid"][data-gap="4"] { gap: var(--space-4); }
600
+ [data-terp="grid"][data-gap="6"] { gap: var(--space-6); }
601
+ [data-terp="grid"][data-gap="8"] { gap: var(--space-8); }
602
+ [data-terp="grid"][data-align="start"] { align-items: start; }
603
+ [data-terp="grid"][data-align="center"] { align-items: center; }
604
+ [data-terp="grid"][data-align="end"] { align-items: end; }
605
+ [data-terp="grid"][data-padding="0"] { padding: var(--space-0); }
606
+ [data-terp="grid"][data-padding="1"] { padding: var(--space-1); }
607
+ [data-terp="grid"][data-padding="2"] { padding: var(--space-2); }
608
+ [data-terp="grid"][data-padding="3"] { padding: var(--space-3); }
609
+ [data-terp="grid"][data-padding="4"] { padding: var(--space-4); }
610
+ [data-terp="grid"][data-padding="6"] { padding: var(--space-6); }
611
+ [data-terp="grid"][data-padding="8"] { padding: var(--space-8); }
387
612
 
388
613
  /* Detail lists ------------------------------------------------------------- */
389
614
  /* The term and value are inline boxes inside a block row, which is what makes
@@ -392,14 +617,66 @@ textarea[data-terp="input"] {
392
617
  margin: 0;
393
618
  display: grid;
394
619
  gap: var(--space-1);
620
+ grid-template-columns: minmax(0, 1fr);
621
+ }
622
+ [data-terp="detail-list-row"] {
623
+ min-width: 0;
395
624
  }
396
625
  [data-terp="detail-list-term"] {
397
626
  display: inline;
398
627
  font-weight: var(--font-weight-medium);
399
628
  }
629
+ /* The colon belongs to the inline layout alone, so it is a rule rather than a text node —
630
+ aligned and stacked must not have one, and no rule can withdraw a text node. Decorative
631
+ either way: the dt/dd pairing is what carries the relationship to assistive tech. */
632
+ [data-terp="detail-list"]:not([data-layout]) [data-terp="detail-list-term"]::after {
633
+ content: ": ";
634
+ white-space: pre;
635
+ }
400
636
  [data-terp="detail-list-value"] {
401
637
  display: inline;
402
638
  margin: 0;
639
+ min-width: 0;
640
+ /* Flooring the track at 0 is not enough on its own, and the specimen is what showed it: a
641
+ 64-character digest has nothing to break at, so it overflows the column whatever the
642
+ column's floor. This is the declaration that makes it wrap. Same answer profile-email
643
+ already uses for a long address — where it is noted as unobservable, because that
644
+ screen's session is a fixed short one; detail-list-long-value is the first picture of
645
+ the mechanism anywhere in the suite. */
646
+ overflow-wrap: anywhere;
647
+ }
648
+ /* Two pairs per row. minmax(0, 1fr) rather than 1fr for the reason Grid's fixed counts need
649
+ it: a bare 1fr floors at min-content, and a 64-character digest with nothing to break on
650
+ then widens its column and pushes the list past its container — which is the defect the
651
+ diagnosis was describing, rather than the missing alignment it named. */
652
+ [data-terp="detail-list"][data-columns="2"] {
653
+ grid-template-columns: repeat(2, minmax(0, 1fr));
654
+ column-gap: var(--space-4);
655
+ }
656
+ /* Aligned: every label in a shared column. The row wrapper becomes display: contents so the
657
+ dt and dd are grid items of the dl itself — the only way to align across rows without
658
+ changing the DOM, and the reason the wrapper needed a marker at all. */
659
+ [data-terp="detail-list"][data-layout="aligned"] {
660
+ grid-template-columns: auto minmax(0, 1fr);
661
+ column-gap: var(--space-3);
662
+ }
663
+ [data-terp="detail-list"][data-layout="aligned"][data-columns="2"] {
664
+ grid-template-columns: repeat(2, auto minmax(0, 1fr));
665
+ }
666
+ [data-terp="detail-list"][data-layout="aligned"] [data-terp="detail-list-row"] {
667
+ display: contents;
668
+ }
669
+ [data-terp="detail-list"][data-layout="aligned"] [data-terp="detail-list-term"],
670
+ [data-terp="detail-list"][data-layout="aligned"] [data-terp="detail-list-value"],
671
+ [data-terp="detail-list"][data-layout="stacked"] [data-terp="detail-list-term"],
672
+ [data-terp="detail-list"][data-layout="stacked"] [data-terp="detail-list-value"] {
673
+ display: block;
674
+ }
675
+ /* Stacked: label above value. The label takes the muted step so the pair reads as one unit
676
+ rather than two lines of equal weight. */
677
+ [data-terp="detail-list"][data-layout="stacked"] [data-terp="detail-list-term"] {
678
+ font-size: var(--font-size-xs);
679
+ color: var(--color-fg-muted);
403
680
  }
404
681
 
405
682
  /* Checkboxes / radios / switches ------------------------------------------- */
@@ -428,7 +705,7 @@ textarea[data-terp="input"] {
428
705
  block-size: 1.25rem;
429
706
  accent-color: var(--color-fg-accent);
430
707
  cursor: pointer;
431
- transition: background-color 150ms ease;
708
+ transition: background-color var(--motion-duration-fast) var(--motion-easing-standard);
432
709
  }
433
710
  [data-terp="radio-group"] {
434
711
  display: grid;
@@ -477,6 +754,141 @@ textarea[data-terp="input"] {
477
754
  [data-terp="card-heading"] {
478
755
  min-width: 0;
479
756
  }
757
+ /* Chrome off, heading kept. Three declarations removed rather than a second component
758
+ with six markers of its own describing the same DOM: a titled region inside something
759
+ that is already a surface wants no second border, and the commonest instance is a
760
+ section whose body is a DataView — boxed, the table gets a border inside a border and
761
+ loses the full width its own scroll container gives it.
762
+
763
+ padding: 0 rather than dropping the declaration, because the base rule sets it and an
764
+ absent value inherits nothing useful. */
765
+ [data-terp="card"][data-variant="plain"] {
766
+ background: none;
767
+ border-color: transparent;
768
+ padding: 0;
769
+ }
770
+
771
+ /* Prose --------------------------------------------------------------------- */
772
+ /* The first readers of the published type scale. --font-line-height-* and
773
+ --font-letter-spacing-* shipped in 0.7.0 with nothing reading them, and unlike the
774
+ motion family they could not simply be wired in: this sheet writes line heights of
775
+ 1.2, 1.25, 1.3, 1.4 and 1.5, and the scale offers 1.2, 1.35, 1.5 and 1.7 — so only 8
776
+ of 32 literals map, and converting the rest would change rendered line heights across
777
+ a dozen components. That is a typography pass with its own baselines. New components
778
+ have nothing depending on their metrics, so they take the published scale and the
779
+ family gets honest consumers; tokens.guard.test.ts tracks what is still unread.
780
+
781
+ Headings carry no colour: they inherit the page's ink, so a heading inside a tinted
782
+ surface stays legible without a rule per surface. margin: 0 because the browser
783
+ default fights the parent's gap, which is the same reason page-title and card-title
784
+ both declare it. */
785
+ [data-terp="heading"] {
786
+ margin: 0;
787
+ font-family: var(--font-family-sans);
788
+ font-weight: var(--font-weight-semibold);
789
+ line-height: var(--font-line-height-tight);
790
+ letter-spacing: var(--font-letter-spacing-tight);
791
+ }
792
+ [data-terp="heading"][data-size="sm"] {
793
+ font-size: var(--font-size-sm);
794
+ line-height: var(--font-line-height-snug);
795
+ letter-spacing: var(--font-letter-spacing-base);
796
+ }
797
+ [data-terp="heading"][data-size="base"] {
798
+ font-size: var(--font-size-base);
799
+ line-height: var(--font-line-height-snug);
800
+ letter-spacing: var(--font-letter-spacing-base);
801
+ }
802
+ [data-terp="heading"][data-size="lg"] { font-size: var(--font-size-lg); }
803
+ [data-terp="heading"][data-size="xl"] { font-size: var(--font-size-xl); }
804
+
805
+ /* Body copy. The base rule is the default tone and step, so neither stamps an
806
+ attribute. A measure is capped in ch rather than rem, because the readable line
807
+ length is a count of characters and follows the font size — in rem it would stop
808
+ being a measure the moment an app changed the type scale. */
809
+ [data-terp="text"] {
810
+ margin: 0;
811
+ font-family: var(--font-family-sans);
812
+ font-size: var(--font-size-base);
813
+ line-height: var(--font-line-height-base);
814
+ color: var(--color-fg-default);
815
+ }
816
+ [data-terp="text"][data-size="xs"] { font-size: var(--font-size-xs); }
817
+ [data-terp="text"][data-size="sm"] { font-size: var(--font-size-sm); }
818
+ [data-terp="text"][data-size="lg"] {
819
+ font-size: var(--font-size-lg);
820
+ line-height: var(--font-line-height-relaxed);
821
+ }
822
+ [data-terp="text"][data-tone="muted"] { color: var(--color-fg-muted); }
823
+ [data-terp="text"][data-tone="subtle"] { color: var(--color-fg-subtle); }
824
+ [data-terp="text"][data-measure="narrow"] { max-width: 48ch; }
825
+ [data-terp="text"][data-measure="base"] { max-width: 72ch; }
826
+
827
+ /* Code. The inline form takes a tinted chip so an identifier reads as one inside a
828
+ sentence; the block form drops the chip — a bordered box around a bordered box again —
829
+ and keeps the border on the <pre>. overflow-x on the block is what makes a long line
830
+ scroll rather than widen the page, and it is the reason the <pre> is focusable: a
831
+ scroll container a keyboard cannot reach cannot be scrolled at all (SC 2.1.1). */
832
+ [data-terp="code"] {
833
+ font-family: var(--font-family-mono);
834
+ font-size: 0.875em;
835
+ padding: 0.1em 0.32em;
836
+ border-radius: var(--radius-sm);
837
+ background: var(--color-neutral-100);
838
+ color: var(--color-fg-default);
839
+ }
840
+ [data-terp="code-block"] {
841
+ margin: 0;
842
+ padding: var(--space-3);
843
+ border: 1px solid var(--color-neutral-200);
844
+ border-radius: var(--radius-md);
845
+ background: var(--color-neutral-50);
846
+ overflow-x: auto;
847
+ font-size: var(--font-size-sm);
848
+ line-height: var(--font-line-height-base);
849
+ }
850
+ [data-terp="code-block"] [data-terp="code"] {
851
+ padding: 0;
852
+ background: none;
853
+ border-radius: 0;
854
+ font-size: inherit;
855
+ }
856
+
857
+ /* Links. Two selector shapes for one marker, because an in-app link's marker lands on a
858
+ wrapper: navLink accepts { to, children } and nothing else, so the router's own Link
859
+ cannot be handed an attribute. The external case marks the anchor itself. Same shape
860
+ HubCard already uses, for the same reason. */
861
+ [data-terp="link"],
862
+ [data-terp="link"] a {
863
+ color: var(--color-fg-accent);
864
+ text-decoration: underline;
865
+ text-underline-offset: 2px;
866
+ border-radius: var(--radius-sm);
867
+ }
868
+
869
+ /* Dividers ----------------------------------------------------------------- */
870
+ /* An <hr>, so the separation reaches the accessibility tree and not only the pixels.
871
+ Its own border reset first: a bare <hr> comes with a browser border and margin that
872
+ differ between engines, which is most of why a module reaches for a bordered div.
873
+
874
+ The vertical form takes its height from its flex or grid line rather than inventing
875
+ one, so it works between the items of a row Stack and is zero-height in a block
876
+ parent. That is worth knowing before reaching for it, and it is why its specimen
877
+ renders inside a fixed-height row — with equal-height siblings there would be nothing
878
+ to see either way. */
879
+ [data-terp="divider"] {
880
+ border: 0;
881
+ margin: 0;
882
+ align-self: stretch;
883
+ background: var(--color-neutral-200);
884
+ block-size: 1px;
885
+ inline-size: auto;
886
+ }
887
+ [data-terp="divider"][data-orientation="vertical"] {
888
+ block-size: auto;
889
+ inline-size: 1px;
890
+ }
891
+
480
892
  [data-terp="card-actions"] {
481
893
  flex-shrink: 0;
482
894
  }
@@ -519,7 +931,10 @@ textarea[data-terp="input"] {
519
931
  margin-block-end: -1px;
520
932
  border-top-left-radius: var(--radius-sm);
521
933
  border-top-right-radius: var(--radius-sm);
522
- transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
934
+ transition:
935
+ background-color var(--motion-duration-fast) var(--motion-easing-standard),
936
+ color var(--motion-duration-fast) var(--motion-easing-standard),
937
+ border-color var(--motion-duration-fast) var(--motion-easing-standard);
523
938
  }
524
939
  [data-terp="tab-panel"] {
525
940
  color: var(--color-neutral-900);
@@ -584,7 +999,7 @@ textarea[data-terp="input"] {
584
999
  [data-terp="breadcrumbs"] a {
585
1000
  color: var(--color-neutral-600);
586
1001
  text-decoration: none;
587
- transition: color 150ms ease;
1002
+ transition: color var(--motion-duration-fast) var(--motion-easing-standard);
588
1003
  }
589
1004
  [data-terp="breadcrumbs-separator"] {
590
1005
  display: inline-flex;
@@ -607,7 +1022,29 @@ textarea[data-terp="input"] {
607
1022
  shell's hover rule has keyed on it since before this migration.
608
1023
 
609
1024
  That is why nothing here needs a style object handed across a public boundary, which
610
- is what AppShellLinkContext.style and RenderBrandLink's style param used to be. */
1025
+ is what AppShellLinkContext.style and RenderBrandLink's style param used to be.
1026
+
1027
+ The sidebar paints from its OWN colour family now — --color-sidebar-bg / -fg / -muted /
1028
+ -accent / -border — rather than from the neutral ramp. Those five were declared in all five
1029
+ themes and read by NOTHING, which is exactly the offence --color-fg-on-brand was deleted
1030
+ for; the difference is that here the vocabulary is right and the readers were missing, so
1031
+ wiring is the fix and deleting would have been the mistake. It went unnoticed for four
1032
+ releases because tokens.guard.test.ts tracked three families and --color- was not one.
1033
+
1034
+ Mostly inert, and recounted rather than estimated: of the twenty-five declarations, FIFTEEN
1035
+ already equalled the neutral the sheet was reading — background, foreground and border agree
1036
+ in every theme except the light background. Ten move, in three groups.
1037
+
1038
+ The light sidebar goes #ffffff -> #f8fafc, a faint separation from the canvas that the dark
1039
+ themes always had and light never did. The nav link's resting ink dims in every theme (light
1040
+ #334155 -> #475569, dark #e2e8f0 -> #b4c0d0), which is the deliberate half: a sidebar's
1041
+ resting links are secondary to the page and the active one should carry the weight. And the
1042
+ hover wash changes in four themes — every one but light, where the two values agree — which
1043
+ an earlier version of this comment did not mention at all, because it counted the resting
1044
+ declarations and forgot that accent is one of the five.
1045
+
1046
+ Every text pairing was checked before being wired, not after: 7.24:1 light, 7.94 dark, 7.50
1047
+ midnight, 7.60 twilight, and 18.42 for contrast against its AAA floor. */
611
1048
  [data-terp="appshell"] {
612
1049
  display: flex;
613
1050
  align-items: stretch;
@@ -632,13 +1069,13 @@ textarea[data-terp="input"] {
632
1069
  top: 0;
633
1070
  height: 100vh;
634
1071
  overflow-x: hidden;
635
- width: 15rem;
636
- background: var(--color-neutral-0);
637
- border-inline-end: 1px solid var(--color-neutral-200);
638
- transition: width 150ms ease;
1072
+ width: var(--shell-sidebar-width-expanded);
1073
+ background: var(--color-sidebar-bg);
1074
+ border-inline-end: 1px solid var(--color-sidebar-border);
1075
+ transition: width var(--motion-duration-fast) var(--motion-easing-standard);
639
1076
  }
640
1077
  [data-terp="appshell-sidebar"][data-collapsed="true"] {
641
- width: 4rem;
1078
+ width: var(--shell-sidebar-width-collapsed);
642
1079
  }
643
1080
  /* The mobile drawer, reached from the shell root's variant rather than from an attribute
644
1081
  of its own — the viewport is one fact and the root owns it. 100dvh rather than 100vh so
@@ -671,16 +1108,51 @@ textarea[data-terp="input"] {
671
1108
  gap: var(--space-2);
672
1109
  padding: var(--space-1) var(--space-2);
673
1110
  min-height: 2.25rem;
674
- color: var(--color-neutral-900);
1111
+ color: var(--color-sidebar-fg);
675
1112
  text-decoration: none;
676
1113
  border-radius: var(--radius-md);
677
1114
  box-sizing: border-box;
678
- transition: background-color 150ms ease;
1115
+ transition: background-color var(--motion-duration-fast) var(--motion-easing-standard);
679
1116
  }
680
1117
  [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-brand"] {
681
1118
  justify-content: center;
682
1119
  padding-inline: 0;
683
1120
  }
1121
+ /* The brand mark's box (ADR 0098 §9). The brand link used to hand whatever it was given
1122
+ straight into a flex row, so an app's asset sized itself and an oversized one was clipped by
1123
+ the aside's overflow-x: hidden with nothing to say so — in the 4rem rail, which is exactly
1124
+ where a brand most needs to survive. One declared size, published as --shell-brand-size, caps
1125
+ it in every placement, and the descendant rule catches the img or svg inside a wrapper rather
1126
+ than only a direct child.
1127
+ Zero-diff for every shell shipped so far: the default TerpMark is 28px and the token is
1128
+ 1.75rem, so the box is exactly the size of the thing that used to be the flex item. */
1129
+ [data-terp="appshell-mark"] {
1130
+ display: inline-flex;
1131
+ flex: none;
1132
+ align-items: center;
1133
+ justify-content: center;
1134
+ width: var(--shell-brand-size);
1135
+ height: var(--shell-brand-size);
1136
+ }
1137
+ [data-terp="appshell-mark"] * {
1138
+ max-width: 100%;
1139
+ max-height: 100%;
1140
+ }
1141
+ /* The light/dark pair, and the switch is a token because the alternative rots. The framework
1142
+ ships five themes and three of them are dark; a company mark with dark ink is invisible on
1143
+ those three, and the bundled icons' currentColor answer is not available to a brand asset.
1144
+ Enumerating the dark themes HERE would be a list that goes stale the first time one is added,
1145
+ so the token build emits --appearance-show-light / --appearance-show-dark from each theme's
1146
+ declared appearance — the same field it already emits color-scheme from, and one
1147
+ themes.json requires. A sixth theme cannot forget to answer.
1148
+ The values are block / none rather than this rule's own display type, so a theme never has
1149
+ to know what layout the shell uses: the box above does the centring, not the mark. */
1150
+ [data-terp="appshell-mark"] > [data-appearance="light"] {
1151
+ display: var(--appearance-show-light);
1152
+ }
1153
+ [data-terp="appshell-mark"] > [data-appearance="dark"] {
1154
+ display: var(--appearance-show-dark);
1155
+ }
684
1156
  [data-terp="appshell-brand-row"] {
685
1157
  display: flex;
686
1158
  align-items: center;
@@ -696,7 +1168,7 @@ textarea[data-terp="input"] {
696
1168
  white-space: nowrap;
697
1169
  font-size: var(--font-size-base);
698
1170
  font-weight: var(--font-weight-semibold);
699
- color: var(--color-neutral-900);
1171
+ color: var(--color-sidebar-fg);
700
1172
  letter-spacing: 0;
701
1173
  }
702
1174
  [data-terp="appshell-nav"] {
@@ -711,6 +1183,47 @@ textarea[data-terp="input"] {
711
1183
  display: grid;
712
1184
  gap: var(--space-1);
713
1185
  }
1186
+ /* Navigation groups. The wrapper itself declares nothing in the sidebar: it is a plain block
1187
+ containing the same list, which is what makes adding it to the ungrouped case free.
1188
+
1189
+ The separation is on the ADJACENT SIBLING, and these two rules are the first + combinators in
1190
+ this sheet. That is deliberate rather than careless: what is wanted is a separation BETWEEN
1191
+ siblings, which is exactly what + expresses, and the alternatives are worse here. A gap on the
1192
+ nav would make the nav a grid or flex container, and a stretched single row would then resize
1193
+ the one group every app has today. A margin on every group would need a :first-child to undo
1194
+ it, which is a positional selector where a sibling one says the thing directly. With one group
1195
+ neither rule matches at all, so the flat sidebar is untouched to the pixel.
1196
+
1197
+ SCOPED TO THE SIDEBAR, and that scope is load-bearing. In header placement the nav is a flex
1198
+ ROW (below), where margin-block-start is a CROSS-axis margin — never collapsed, applied to a
1199
+ flex item — so an unscoped rule would push every group after the first down by 1rem and grow
1200
+ the sticky header with it. The mobile drawer is the same aside carrying the same marker, so it
1201
+ keeps this rule, which is correct: the drawer stacks. */
1202
+ [data-terp="appshell-sidebar"] [data-terp="appshell-nav-group"] + [data-terp="appshell-nav-group"] {
1203
+ margin-block-start: var(--space-4);
1204
+ }
1205
+ /* The group label. Not a heading element — see the AppShell render for why the outline is the
1206
+ binding constraint and why axe cannot see it.
1207
+
1208
+ The horizontal padding matches the nav link's own (var(--space-3)), so the label sits on the
1209
+ same left edge as the icons under it rather than floating in the rail's gutter.
1210
+
1211
+ letter-spacing comes from the published scale rather than a bare literal, and this is the rule
1212
+ the scale was waiting for: tokens.guard.test.ts records --font-letter-spacing-wide as unread
1213
+ with the comment "for the uppercase-label treatment nothing in the package uses". This is that
1214
+ treatment, so the token gets its first reader and leaves the unread list. font-weight is our
1215
+ own choice and not inherited from the login separator, which declares none — a group label
1216
+ competing with the links under it needs the weight to read as a header rather than as a
1217
+ disabled item. */
1218
+ [data-terp="appshell-nav-group-label"] {
1219
+ display: block;
1220
+ padding: var(--space-1) var(--space-3);
1221
+ font-size: var(--font-size-xs);
1222
+ font-weight: var(--font-weight-semibold);
1223
+ letter-spacing: var(--font-letter-spacing-wide);
1224
+ text-transform: uppercase;
1225
+ color: var(--color-sidebar-muted);
1226
+ }
714
1227
  /* Sidebar navigation links, and this is the rule the whole shell migration was for. The
715
1228
  geometry used to be NAV_LINK_STYLE, a CSSProperties object exported from AppShell for
716
1229
  every router's link renderer to spread — so an app could not restyle a nav link at all
@@ -727,7 +1240,7 @@ textarea[data-terp="input"] {
727
1240
  gap: var(--space-2);
728
1241
  padding: var(--space-2) var(--space-3);
729
1242
  border-radius: var(--radius-md);
730
- color: var(--color-neutral-700);
1243
+ color: var(--color-sidebar-muted);
731
1244
  font-size: var(--font-size-sm);
732
1245
  font-weight: var(--font-weight-medium);
733
1246
  text-decoration: none;
@@ -735,7 +1248,9 @@ textarea[data-terp="input"] {
735
1248
  overflow: hidden;
736
1249
  box-sizing: border-box;
737
1250
  min-height: 2.25rem;
738
- transition: background-color 150ms ease, color 150ms ease;
1251
+ transition:
1252
+ background-color var(--motion-duration-fast) var(--motion-easing-standard),
1253
+ color var(--motion-duration-fast) var(--motion-easing-standard);
739
1254
  }
740
1255
  /* The collapsed rail's link geometry: one centred fixed-size icon in the content track.
741
1256
  (0,3,1) against the base's (0,1,1), so it wins on specificity with no source-order
@@ -751,16 +1266,20 @@ textarea[data-terp="input"] {
751
1266
  text-overflow: ellipsis;
752
1267
  white-space: nowrap;
753
1268
  }
754
- /* Visually hidden, four elements, one rule. Two are the drawer's focus sentinels, which
1269
+ /* Visually hidden, five elements, one rule. One is the skip link, which is the whole point of
1270
+ the block for it: hidden at rest and un-hidden by a rule in terp.state. Two are the drawer's
1271
+ focus sentinels, which
755
1272
  must stay focusable and so cannot be display: none. The other two are the brand title
756
1273
  and the nav labels in the icon rail, which were a style-object TERNARY before this —
757
1274
  the component picked between two objects per render, and the collapsed branch was
758
1275
  painted by nothing, because the rail state was internal and no specimen could reach it.
759
1276
  That is what defaultCollapsed is for. */
1277
+ [data-terp="appshell-skip-link"],
760
1278
  [data-terp="drawer-focus-start"],
761
1279
  [data-terp="drawer-focus-end"],
762
1280
  [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-brand-title"],
763
- [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-nav-label"] {
1281
+ [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-nav-label"],
1282
+ [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-nav-group-label"] {
764
1283
  position: absolute;
765
1284
  width: 1px;
766
1285
  height: 1px;
@@ -787,7 +1306,7 @@ textarea[data-terp="input"] {
787
1306
  justify-content: space-between;
788
1307
  gap: var(--space-3);
789
1308
  padding: var(--space-2) var(--space-4);
790
- min-height: 3rem;
1309
+ min-height: var(--shell-header-height);
791
1310
  box-sizing: border-box;
792
1311
  background: var(--color-neutral-0);
793
1312
  border-block-end: 1px solid var(--color-neutral-200);
@@ -826,6 +1345,19 @@ textarea[data-terp="input"] {
826
1345
  font-weight: var(--font-weight-normal);
827
1346
  line-height: 1.25;
828
1347
  }
1348
+ /* The drawer's close button sits INSIDE the sidebar, so its INK comes from the sidebar family
1349
+ and not from the header toggle's neutral. Everything else it shares with the toggle above —
1350
+ both are icon buttons in shell chrome, and only the colour depends on which chrome.
1351
+ Wiring the sidebar family without this left two adjacent controls in the same drawer on two
1352
+ different ramps: in dark a nav link hovering to #334155 beside a close button hovering to
1353
+ #263449, and in contrast a blue wash beside a grey one.
1354
+ An override, NOT a split of the rule above. Splitting it was the first attempt and it moved
1355
+ the shared declarations into this selector, so the header toggle lost its background, border,
1356
+ radius, cursor and type — visible immediately as ~1,160 repainted pixels on every shell
1357
+ specimen, which is how a one-line edit to a grouped selector announces itself. */
1358
+ [data-terp="appshell-brand-row"] > [data-terp="iconbutton"] {
1359
+ color: var(--color-sidebar-muted);
1360
+ }
829
1361
  [data-terp="appshell-main"] {
830
1362
  flex-grow: 1;
831
1363
  padding: var(--space-6);
@@ -840,6 +1372,72 @@ textarea[data-terp="input"] {
840
1372
  color: var(--color-fg-subtle);
841
1373
  font-size: var(--font-size-xs);
842
1374
  }
1375
+ /* The header placement (ADR 0098 §8): the nav moves into the header and the sidebar is not
1376
+ rendered at all. Three rules, and the attribute is stamped only on desktop and only when the
1377
+ prop asked for it, so none of them needs a [data-variant] guard and no existing shell is in
1378
+ scope of any of them.
1379
+
1380
+ The header BECOMES the sidebar surface, which is one declaration doing the work of six.
1381
+ Every part of the navigation — the brand, its title, the resting link, the hover wash, the
1382
+ active link — already reads --color-sidebar-*, so moving the surface carries the whole family
1383
+ with it and not one property is overridden here. It is also the only reading that survives
1384
+ theming: an app that paints its sidebar navy gets a navy header with the same legible ink,
1385
+ where per-property overrides would have given it navy ink on a white header. The declared
1386
+ sidebar pairings are therefore still exactly the pairings in play and the contrast gate needs
1387
+ nothing new — which is the check that the mechanism is right rather than merely short.
1388
+
1389
+ In the shipped themes this moves almost nothing: --color-sidebar-bg equals
1390
+ --color-neutral-0 in four of the five, and in light it is the same #f8fafc the sidebar
1391
+ already uses. The rule is load-bearing for an app's theme, not for ours. */
1392
+ [data-terp="appshell"][data-nav-placement="header"] [data-terp="appshell-header"] {
1393
+ background: var(--color-sidebar-bg);
1394
+ border-block-end-color: var(--color-sidebar-border);
1395
+ }
1396
+ /* The list turns horizontal. Flex rather than a row of grid columns, because the header is
1397
+ already a wrapping row: a nav with more items than fit takes a second line and the header
1398
+ grows, instead of overflowing to somewhere no pointer can reach. */
1399
+ [data-terp="appshell"][data-nav-placement="header"] [data-terp="appshell-nav-list"] {
1400
+ display: flex;
1401
+ flex-wrap: wrap;
1402
+ }
1403
+ /* The nav keeps its flex-grow from the sidebar rule, which here takes the slack between the
1404
+ brand and the header group and pins the controls right with no margin of its own.
1405
+ overflow is a FIX rather than a reset: the sidebar's nav is a vertical scroll container, and
1406
+ a computed overflow-y of auto forces overflow-x to auto as well, so in a header — where the
1407
+ box is exactly one link tall — a focused link's 2px outline and 3px ring would be clipped on
1408
+ both edges by a scroller that can never scroll. */
1409
+ [data-terp="appshell"][data-nav-placement="header"] [data-terp="appshell-nav"] {
1410
+ overflow: visible;
1411
+ /* The groups are a ROW here, not a column. Without this each group wrapper is a block and a
1412
+ two-group header renders one stacked list per group — a nav placed in the header to avoid
1413
+ permanent chrome, growing the header instead.
1414
+
1415
+ The row lives on the NAV rather than on the wrappers because the wrappers are what has to
1416
+ line up, and it is why the stacking margin above is scoped away from this placement: a
1417
+ block-start margin on a flex item is a cross-axis margin and is never collapsed.
1418
+
1419
+ With one group this changes nothing measurable. The single wrapper becomes a flex item
1420
+ sized to its content instead of a full-width block, and its list is laid out from the same
1421
+ left edge either way; the existing app-shell-header-nav baseline is what says so. */
1422
+ display: flex;
1423
+ flex-wrap: wrap;
1424
+ gap: var(--space-5);
1425
+ }
1426
+ /* A group in the header: its label sits beside its links rather than above them, since a header
1427
+ row has no second line to put it on. The gap is the label-to-list separation and nothing
1428
+ else — the list keeps its own var(--space-1) between links. */
1429
+ [data-terp="appshell"][data-nav-placement="header"] [data-terp="appshell-nav-group"] {
1430
+ display: flex;
1431
+ align-items: center;
1432
+ gap: var(--space-2);
1433
+ }
1434
+ /* The label's block padding is the sidebar's, where it separates the label from the links BELOW
1435
+ it. In a row that padding is on the wrong axis: it adds to the group gap and pushes the label
1436
+ off the links' centre line. Zeroed to the inline axis only, so the label keeps the horizontal
1437
+ rhythm and loses the vertical. */
1438
+ [data-terp="appshell"][data-nav-placement="header"] [data-terp="appshell-nav-group-label"] {
1439
+ padding-block: 0;
1440
+ }
843
1441
 
844
1442
  /* The page frame ----------------------------------------------------------- */
845
1443
  /* Every routed view is this shape: one header carrying the breadcrumb trail (when
@@ -867,6 +1465,20 @@ textarea[data-terp="input"] {
867
1465
  align-content: start;
868
1466
  min-width: 0;
869
1467
  }
1468
+ /* The narrow frame: a form or a settings screen, capped header and all.
1469
+ 32rem is not a new number. It is exactly what admin-form declares on the two packaged create
1470
+ screens and what ProfileView's card carries, and 4b already named that card as a page measure
1471
+ wearing a card's clothes. So this is the mechanism those three were each hand-rolling, and
1472
+ folding them into it is the follow-up rather than part of shipping it.
1473
+ max-width on the ARTICLE, not width on its children, which is the opposite of the shell's
1474
+ content measure one rule above. Two reasons. The header is meant to be capped here — a Save
1475
+ button a screen-width from its field is worse than one over it — so there is nothing to
1476
+ exempt and no :not() to write. And capping the article composes with the shell measure by
1477
+ construction instead of competing with it: the article is already at most the shell's measure,
1478
+ and this takes it narrower still. */
1479
+ [data-terp="page"][data-measure="narrow"] {
1480
+ max-width: 32rem;
1481
+ }
870
1482
  [data-terp="page-header"] {
871
1483
  display: grid;
872
1484
  gap: var(--space-2);
@@ -899,6 +1511,94 @@ textarea[data-terp="input"] {
899
1511
  color: var(--color-neutral-900);
900
1512
  line-height: 1.3;
901
1513
  }
1514
+ /* The content measure, and the subheader band, which are ONE declaration rather than two
1515
+ features (ADR 0097 §2). A full-width band only means anything once the column beside it is
1516
+ constrained, and constraining the column is what leaves the header spanning the full track.
1517
+
1518
+ No new element and no portal, and both were considered rather than assumed. A wrapper
1519
+ around the body — display: contents included — becomes the sole child of article.children
1520
+ and fails every governed page closed, because that slot check is a DOM traversal and sees
1521
+ the node whether or not it generates a box. A portal leaves no node and survives that, but
1522
+ createPortal needs a container that exists when the child renders and the shell can only
1523
+ publish one through state: first commit local, second commit in the band, a one-frame jump
1524
+ on every navigation traded for nothing.
1525
+
1526
+ Neither is needed, because [data-terp="page"] is ALREADY a single-column grid. The header
1527
+ keeps the track; every other child takes the measure. So the band is the header that was
1528
+ always there.
1529
+
1530
+ The exemption is keyed on the page-header MARKER rather than on the header TAG, and that
1531
+ distinction is a fix rather than a detail. A :not(header) exempts every <header> that happens
1532
+ to be a direct child, so a bespoke screen writing
1533
+ <Page><header>section head</header><DataView/></Page> — legal, since the plain Page is
1534
+ deliberately unconstrained by the layout contract — would get a second full-width band it
1535
+ never asked for, silently. The frame's own header is the only thing meant to span the track,
1536
+ and the marker says so. (The layout contract's runtime check still drops the header by TAG
1537
+ name, because that check runs where no marker is guaranteed; the two mechanisms answer
1538
+ different questions and only this one is a style.)
1539
+
1540
+ And no backticks in this block, which is not a style note: a backtick here TERMINATES
1541
+ TERP_STYLES_CSS and the parse fails somewhere else entirely with "try inserting a
1542
+ semicolon". This comment cost that mistake once while being written.
1543
+
1544
+ Gated on an attribute the SHELL stamps, so nothing moves for any app today: with
1545
+ data-content-width absent this rule matches nothing at all. And "full width" means the full
1546
+ width of the article's own track — appshell-main's padding is outside it, so this is a
1547
+ measure within the content column rather than a bleed to the window edge, which would need
1548
+ a negative margin and therefore an inline site.
1549
+
1550
+ WIDTH, not max-width, and that is the whole correctness of the rule rather than a
1551
+ preference. This selector weighs (0,4,0) — four attribute selectors, three of them here and
1552
+ one inside :not(), and the universal contributes nothing — so as a max-width it OUTRANKS
1553
+ every component that declares a narrower one, and
1554
+ five of them are legal children of a governed body: resource-list (40rem), admin-form
1555
+ (32rem), dialog (26rem) and text[data-measure] at 48ch and 72ch. Measured before it was
1556
+ fixed: an admin-form inside a measured shell computed max-width 1280px instead of 512px,
1557
+ so the packaged provisioning form rendered two and a half times too wide. The shell would
1558
+ have been WIDENING the very components that already carry their own measure — including the
1559
+ Text prop this mechanism was modelled on.
1560
+
1561
+ As a width it composes instead of competing, because CSS resolves max-width AFTER width:
1562
+ min(100%, measure) caps a child that has no measure of its own, and a child that has one
1563
+ still wins with it. min() rather than a bare token so a track narrower than the measure is
1564
+ untouched rather than overflowing. */
1565
+ [data-terp="appshell"][data-content-width="measured"]
1566
+ [data-terp="page"] > *:not([data-terp="page-header"]) {
1567
+ width: min(100%, var(--shell-content-max-width));
1568
+ }
1569
+ /* The reach-through, for the one body child that generates no box of its own. Markdown is
1570
+ display: contents (see its rule, which used to claim no child-star selector existed in this
1571
+ sheet — the one above is exactly that selector, and the claim is corrected there). The rule
1572
+ above therefore MATCHES the markdown wrapper and then has nothing to apply a width to, since
1573
+ a non-inherited property on a boxless element is dropped. The result was prose running the
1574
+ full width of a measured shell, which is the one thing that mechanism exists to prevent, on
1575
+ the one component whose whole purpose is long-form text. Capping its blocks instead reaches
1576
+ the boxes the wrapper stands in for. */
1577
+ [data-terp="appshell"][data-content-width="measured"]
1578
+ [data-terp="page"] > [data-terp="markdown"] > * {
1579
+ width: min(100%, var(--shell-content-max-width));
1580
+ }
1581
+
1582
+ /* The split archetype ------------------------------------------------------ */
1583
+ /* A list beside the record it selects. Mobile-first: one column, list first, so the tab
1584
+ sequence is the reading order in both layouts and the stacked case needs no rule at all.
1585
+ The two-column form lives in the sheet's ONE existing wide-viewport block further down,
1586
+ rather than opening a second @media — same reason Stack's responsive rules went there.
1587
+
1588
+ align-items: start so a short detail pane does not stretch to the list's height, which is
1589
+ what makes the two read as panes rather than as table cells. */
1590
+ [data-terp="splitpage-panes"] {
1591
+ display: grid;
1592
+ grid-template-columns: minmax(0, 1fr);
1593
+ gap: var(--space-4);
1594
+ align-items: start;
1595
+ min-width: 0;
1596
+ }
1597
+ /* Each pane is a min-width: 0 grid item, or a wide DataView inside one refuses to shrink and
1598
+ pushes the row past its track — the same floor Grid's cells carry, for the same reason. */
1599
+ [data-terp="splitpane"] {
1600
+ min-width: 0;
1601
+ }
902
1602
 
903
1603
  /* The sign-in screen ------------------------------------------------------- */
904
1604
  /* The one screen an unauthenticated user sees, and the only full-viewport page in
@@ -966,10 +1666,6 @@ textarea[data-terp="input"] {
966
1666
  display: grid;
967
1667
  gap: var(--space-3);
968
1668
  }
969
- [data-terp="login-form"] > [data-terp="button"],
970
- [data-terp="login-sso"] > [data-terp="button"] {
971
- width: 100%;
972
- }
973
1669
  [data-terp="login-separator"] {
974
1670
  display: flex;
975
1671
  align-items: center;
@@ -1010,12 +1706,20 @@ textarea[data-terp="input"] {
1010
1706
  border: 1px solid var(--color-neutral-200);
1011
1707
  border-radius: var(--radius-lg);
1012
1708
  }
1013
- /* The initials tile. It is aria-hidden, so axe skips it by design and the declared
1014
- pairing is the only thing measuring its ink: brand-primary-contrast on
1015
- brand-primary is primary-button-label, which the contrast gate holds at AA in all
1016
- five themes. Exactly the shape of NavIcon's fallback tile, which failed at 1.60
1017
- for as long as nothing declared it. */
1018
- [data-terp="profile-avatar"] {
1709
+ /* The initials tile, once. This was two rules profile-avatar and user-menu-avatar
1710
+ of eleven declarations each, identical but for a width, a height and a font size,
1711
+ which is a component the framework happened to ship twice under two names.
1712
+
1713
+ It is aria-hidden, so axe skips it by design and the declared pairing is the only
1714
+ thing measuring its ink: brand-primary-contrast on brand-primary is
1715
+ primary-button-label, which the contrast gate holds at AA in all five themes.
1716
+ Exactly the shape of NavIcon's fallback tile, which failed at 1.60 for as long as
1717
+ nothing declared it.
1718
+
1719
+ md carries no attribute of its own, the way every other sized component here works:
1720
+ the base rule IS the default, and a data-size="md" rule would leave two places
1721
+ describing the same tile. */
1722
+ [data-terp="avatar"] {
1019
1723
  display: inline-flex;
1020
1724
  align-items: center;
1021
1725
  justify-content: center;
@@ -1028,6 +1732,11 @@ textarea[data-terp="input"] {
1028
1732
  font-size: var(--font-size-lg);
1029
1733
  font-weight: var(--font-weight-medium);
1030
1734
  }
1735
+ [data-terp="avatar"][data-size="sm"] {
1736
+ width: 2rem;
1737
+ height: 2rem;
1738
+ font-size: var(--font-size-sm);
1739
+ }
1031
1740
  [data-terp="profile-email"] {
1032
1741
  overflow-wrap: anywhere;
1033
1742
  }
@@ -1059,21 +1768,6 @@ textarea[data-terp="input"] {
1059
1768
  margin: 0;
1060
1769
  font-size: var(--font-size-base);
1061
1770
  }
1062
- /* The audit event's JSON payload. No font-family: it is a <pre>, so the UA
1063
- stylesheet's monospace already applies and the inline object set none either.
1064
-
1065
- font-size-sm loses the inline fallback the object carried (0.875rem) and no other
1066
- rule in this sheet has one. The fallback could never fire — tokens.guard.test.ts
1067
- refuses any var() in react-core naming a property the contract does not publish,
1068
- so the token is always there. It recorded an author's doubt, not an option. */
1069
- [data-terp="admin-payload"] {
1070
- margin: 0;
1071
- padding: var(--space-3);
1072
- background: var(--color-neutral-100);
1073
- border-radius: var(--radius-md);
1074
- font-size: var(--font-size-sm);
1075
- overflow-x: auto;
1076
- }
1077
1771
 
1078
1772
  /* Hub cards --------------------------------------------------------------- */
1079
1773
  /* This whole family was in terp.state, resting declarations and all, for the same
@@ -1141,7 +1835,9 @@ textarea[data-terp="input"] {
1141
1835
  [data-terp="hubcard"] {
1142
1836
  height: 100%;
1143
1837
  min-height: 0;
1144
- transition: box-shadow 150ms ease, transform 150ms ease;
1838
+ transition:
1839
+ box-shadow var(--motion-duration-fast) var(--motion-easing-standard),
1840
+ transform var(--motion-duration-fast) var(--motion-easing-standard);
1145
1841
  }
1146
1842
  [data-terp="hubcard-body"] {
1147
1843
  display: grid;
@@ -1155,7 +1851,7 @@ textarea[data-terp="input"] {
1155
1851
  background: var(--color-neutral-0);
1156
1852
  color: var(--color-neutral-900);
1157
1853
  box-sizing: border-box;
1158
- transition: border-color 150ms ease;
1854
+ transition: border-color var(--motion-duration-fast) var(--motion-easing-standard);
1159
1855
  }
1160
1856
  /* -heading, not -title: in this sheet a heading is the BOX holding a title
1161
1857
  (card-heading, dataview-card-heading) and a title is the text box itself
@@ -1185,7 +1881,7 @@ textarea[data-terp="input"] {
1185
1881
  color: var(--color-neutral-900);
1186
1882
  font-size: var(--font-size-base);
1187
1883
  font-weight: var(--font-weight-semibold);
1188
- transition: color 150ms ease;
1884
+ transition: color var(--motion-duration-fast) var(--motion-easing-standard);
1189
1885
  }
1190
1886
  /* neutral-600 rather than fg-muted, and it is not the tinted-surface case: this text
1191
1887
  sits on the card's own neutral-0 and measures 7.58 / 7.94 / 7.50 / 7.60 / 18.42. */
@@ -1423,6 +2119,46 @@ textarea[data-terp="input"] {
1423
2119
  cursor: pointer;
1424
2120
  color: var(--color-fg-subtle);
1425
2121
  }
2122
+ /* The password field's reveal toggle, built on the search box above rather than beside
2123
+ it: same positioning context, same absolutely-placed control, same specificity
2124
+ argument. Only type="password" wraps, so every other input is still a bare element
2125
+ and the two child selectors in this sheet that reach for data-terp="input" — the
2126
+ toolbar search and the resource-list create field — can never meet a wrapper. */
2127
+ [data-terp="input-password"] {
2128
+ position: relative;
2129
+ display: inline-flex;
2130
+ align-items: center;
2131
+ }
2132
+ /* Room for the toggle. It must out-rank input[data-terp="input"] { padding: 0
2133
+ var(--space-3) } and does so on SPECIFICITY — two attributes (0,2,0) against an
2134
+ attribute plus a type (0,1,1) — the same trap and the same escape the search field
2135
+ documents above. Asymmetric on purpose: the glyph sits at the end, and reserving room
2136
+ at both ends would indent the value for nothing. */
2137
+ [data-terp="input-password"] > [data-terp="input"] {
2138
+ padding-inline-end: var(--space-6);
2139
+ width: 100%;
2140
+ }
2141
+ /* The toggle itself, the seventeenth element wearing the iconbutton marker. It takes
2142
+ that marker rather than one of its own because it is one: the shared rule already
2143
+ carries its transition, its hover wash and its disabled treatment, and duplicating
2144
+ those under a new name to avoid editing one enumeration would be the wrong trade. */
2145
+ /* Edge ships its own reveal control inside every password field, so without this the user gets
2146
+ two: the native eye sitting on top of ours, in a box sized for one glyph. Same class of fix as
2147
+ the number stepper this sheet already suppresses -- an unthemeable browser affordance the
2148
+ framework replaces rather than competes with. */
2149
+ input[data-terp="input"][type="password"]::-ms-reveal {
2150
+ display: none;
2151
+ }
2152
+ [data-terp="input-password"] > [data-terp="iconbutton"] {
2153
+ position: absolute;
2154
+ inset-inline-end: var(--space-1);
2155
+ display: inline-flex;
2156
+ padding: var(--space-1);
2157
+ background: transparent;
2158
+ border: none;
2159
+ cursor: pointer;
2160
+ color: var(--color-fg-subtle);
2161
+ }
1426
2162
  /* "Refreshing…", and this is the one place the prefer-an-existing-DOM-attribute
1427
2163
  rule is REFUSED with its own reasoning. [data-terp="dataview-toolbar"]
1428
2164
  > [role="status"] looks textbook — the component does own this span's role — but
@@ -1491,6 +2227,28 @@ textarea[data-terp="input"] {
1491
2227
  white-space: nowrap;
1492
2228
  background: var(--color-neutral-0);
1493
2229
  }
2230
+ /* A column's declared track. A MINIMUM rather than a width, because a specified width is only a
2231
+ preference under table-layout: auto and the algorithm shrinks it to fit — which is why the pixel
2232
+ hint this replaces did nothing at all, measured in the workbench at three columns asking for
2233
+ 700px each and fitting the box exactly. What auto layout cannot take away is a minimum.
2234
+
2235
+ Three steps, and no more: these are the three bands the framework's own tables declare, and a
2236
+ step is additive to add and breaking to remove. In rem, so a declared track follows the root
2237
+ font size; the system columns below keep their pixels on purpose, being chrome rather than
2238
+ content, and converting them is a density pass with its own baselines.
2239
+
2240
+ Nothing here ever meets an inline width. A resized column stops emitting the attribute, so the
2241
+ user's own drag replaces the declared track instead of losing to it — the minimum would win the
2242
+ cascade, and a column springing back from a drag reads as a broken resizer. */
2243
+ [data-terp="dataview-table"] > thead > tr > th[data-width="xs"] {
2244
+ min-inline-size: 5rem;
2245
+ }
2246
+ [data-terp="dataview-table"] > thead > tr > th[data-width="sm"] {
2247
+ min-inline-size: 6.5rem;
2248
+ }
2249
+ [data-terp="dataview-table"] > thead > tr > th[data-width="md"] {
2250
+ min-inline-size: 9.5rem;
2251
+ }
1494
2252
  [data-terp="dataview-row"] > td {
1495
2253
  padding: var(--density-cell-pad-y) var(--density-cell-pad-x);
1496
2254
  border-bottom: 1px solid var(--color-neutral-100);
@@ -2260,7 +3018,10 @@ button[data-terp="input"][data-placeholder="true"] {
2260
3018
  font-weight: var(--font-weight-medium);
2261
3019
  line-height: 1.4;
2262
3020
  box-shadow: var(--shadow-md);
2263
- pointer-events: none;
3021
+ /* No pointer-events: none. It was here, and it makes WCAG 1.4.13's Hoverable clause
3022
+ impossible by construction: a bubble the pointer cannot reach is a bubble nobody
3023
+ tracking with a pointer, or reading under magnification, can finish reading. The
3024
+ component keeps it open across the gap with a short close delay instead. */
2264
3025
  white-space: normal;
2265
3026
  }
2266
3027
 
@@ -2285,7 +3046,10 @@ button[data-terp="input"][data-placeholder="true"] {
2285
3046
  font-size: var(--font-size-sm);
2286
3047
  font-weight: var(--font-weight-normal);
2287
3048
  line-height: 1.25;
2288
- transition: background-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
3049
+ transition:
3050
+ background-color var(--motion-duration-fast) var(--motion-easing-standard),
3051
+ color var(--motion-duration-fast) var(--motion-easing-standard),
3052
+ box-shadow var(--motion-duration-fast) var(--motion-easing-standard);
2289
3053
  }
2290
3054
  /* The panel's contents. This sits INSIDE popover-panel, which supplies the
2291
3055
  surface — so the menu owns only the stacking of its items. */
@@ -2310,7 +3074,9 @@ button[data-terp="input"][data-placeholder="true"] {
2310
3074
  font-size: var(--font-size-sm);
2311
3075
  font-weight: var(--font-weight-normal);
2312
3076
  line-height: 1.25;
2313
- transition: background-color 150ms ease, color 150ms ease;
3077
+ transition:
3078
+ background-color var(--motion-duration-fast) var(--motion-easing-standard),
3079
+ color var(--motion-duration-fast) var(--motion-easing-standard);
2314
3080
  }
2315
3081
  /* Destructive is the one enumerable choice an item has, so it is an attribute.
2316
3082
  The disabled treatment is a state rule keyed on :disabled, because the element
@@ -2363,7 +3129,12 @@ button[data-terp="input"][data-placeholder="true"] {
2363
3129
  width: 100%;
2364
3130
  padding: var(--space-2);
2365
3131
  text-align: left;
2366
- color: var(--color-neutral-900);
3132
+ /* The sidebar family, not the neutral one. This row renders inside the sidebar (and inside the
3133
+ header group under navPlacement="header", which takes the sidebar surface), so its ink and
3134
+ that background are a pairing in play — and the contrast gate can only measure a pairing it
3135
+ can name. Provably zero-diff: --color-neutral-900 and --color-sidebar-fg are byte-equal in
3136
+ all five themes. */
3137
+ color: var(--color-sidebar-fg);
2367
3138
  border-color: transparent;
2368
3139
  min-height: 0;
2369
3140
  }
@@ -2373,19 +3144,6 @@ button[data-terp="input"][data-placeholder="true"] {
2373
3144
  gap: 0;
2374
3145
  padding: 0;
2375
3146
  }
2376
- [data-terp="user-menu-avatar"] {
2377
- display: inline-flex;
2378
- align-items: center;
2379
- justify-content: center;
2380
- width: 2rem;
2381
- height: 2rem;
2382
- flex-shrink: 0;
2383
- border-radius: var(--radius-full);
2384
- background: var(--color-brand-primary);
2385
- color: var(--color-brand-primary-contrast);
2386
- font-size: var(--font-size-sm);
2387
- font-weight: var(--font-weight-medium);
2388
- }
2389
3147
  [data-terp="user-menu-identity"] {
2390
3148
  display: grid;
2391
3149
  min-width: 0;
@@ -2396,9 +3154,17 @@ button[data-terp="input"][data-placeholder="true"] {
2396
3154
  text-overflow: ellipsis;
2397
3155
  white-space: nowrap;
2398
3156
  }
3157
+ /* Two surfaces, one marker. UserMenu renders this span in the trigger AND in the portalled
3158
+ panel, and only the first sits on the sidebar — the panel is in document.body, where the
3159
+ sidebar palette does not apply. So the sidebar copy is scoped (the portal puts the panel
3160
+ outside this selector by construction) and the panel keeps the neutral. The sheet already
3161
+ argues this exact split for the drawer close button. */
2399
3162
  [data-terp="user-menu-role"] {
2400
3163
  color: var(--color-neutral-600);
2401
3164
  }
3165
+ [data-terp="user-menu"] [data-terp="user-menu-role"] {
3166
+ color: var(--color-sidebar-muted);
3167
+ }
2402
3168
  /* The panel's identity block, and the panel's own geometry — both keyed on the
2403
3169
  owner, because the portal put them outside every selector that could otherwise
2404
3170
  reach them. */
@@ -2440,8 +3206,14 @@ button[data-terp="input"][data-placeholder="true"] {
2440
3206
  rules ([data-terp="markdown"] p, ... ul), and a declaration added here would
2441
3207
  simply do nothing with nothing to say so. And display: contents does not change
2442
3208
  selector matching, only box generation: a parent's > * child selector now matches
2443
- this wrapper rather than the blocks. Nothing in this sheet uses one, which is why
2444
- the wrapper is free today.
3209
+ this wrapper rather than the blocks.
3210
+
3211
+ That last sentence used to end "Nothing in this sheet uses one, which is why the
3212
+ wrapper is free today", and it stopped being true when the measured content width
3213
+ shipped: that rule is a child-star selector on the page's body children. It matched it,
3214
+ found no box to give a width to, and let prose run full-bleed in a measured shell.
3215
+ The reach-through beside that rule is the fix; this wrapper is free of everything
3216
+ else.
2445
3217
 
2446
3218
  Under SSR the sheet is not injected at all (the injector is document-guarded), so
2447
3219
  a server-rendered page has this element as a block box until hydration. That is
@@ -2574,12 +3346,15 @@ button[data-terp="input"][data-placeholder="true"] {
2574
3346
  margin-block-start: var(--space-2);
2575
3347
  }
2576
3348
 
2577
- /* Icon-only buttons. Sixteen elements wear this marker and not one declares a
3349
+ /* Icon-only buttons. Seventeen elements wear this marker and not one declares a
2578
3350
  transition inline, so this belongs in terp.base — it sat in terp.state only
2579
3351
  because that is where the hover rules needing it live. Same correction 817f572
2580
3352
  made for Tabs and Breadcrumbs. */
2581
3353
  [data-terp="iconbutton"] {
2582
- transition: background-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
3354
+ transition:
3355
+ background-color var(--motion-duration-fast) var(--motion-easing-standard),
3356
+ color var(--motion-duration-fast) var(--motion-easing-standard),
3357
+ box-shadow var(--motion-duration-fast) var(--motion-easing-standard);
2583
3358
  }
2584
3359
 
2585
3360
  /* Popover ------------------------------------------------------------------ */
@@ -2613,9 +3388,12 @@ button[data-terp="input"][data-placeholder="true"] {
2613
3388
 
2614
3389
  The stacking level is the token that was published for it. Every component in
2615
3390
  the package hardcoded its own number while a full --z-index-* family sat
2616
- unread. AppShell still writes 50/40/30 for drawer/backdrop/sticky and comes
2617
- right with its own migration; the toast viewport already reads
2618
- --z-index-toast. Tooltip's z-index:
3391
+ unread. That is finished: AppShell reads --z-index-drawer, --z-index-backdrop
3392
+ and --z-index-sticky, the skip link reads --z-index-skip-link, and the toast
3393
+ viewport reads --z-index-toast. (This paragraph said "AppShell still writes
3394
+ 50/40/30 ... and comes right with its own migration" for a release after that
3395
+ migration landed, which is the shape of stale comment worth naming: it read as
3396
+ a known gap rather than as a finished one.) Tooltip's z-index:
2619
3397
  1 above is deliberately NOT a token — the tooltip is absolutely positioned
2620
3398
  inside its own anchor, so 1 is a local lift within a stacking context rather
2621
3399
  than a place in the app-wide order. */
@@ -2712,12 +3490,24 @@ button[data-terp="input"][data-placeholder="true"] {
2712
3490
  opacity: 0.55;
2713
3491
  cursor: not-allowed;
2714
3492
  }
3493
+ /* A loading button IS disabled — the component sets both — so this has to sit here rather
3494
+ than in terp.base beside the other attribute-keyed button rules. In terp.base it would
3495
+ lose to the :disabled rule above on layer order and the cursor would silently stay
3496
+ not-allowed, which reads as "you may not" where the truth is "not yet". Declared after
3497
+ it so source order settles the tie the equal (0,2,0) specificity leaves.
3498
+
3499
+ Neither cursor is visible to any lane: Playwright's screenshots do not paint a pointer.
3500
+ The computed lane asserts both. */
3501
+ [data-terp="button"][data-loading="true"] {
3502
+ cursor: progress;
3503
+ }
2715
3504
 
2716
3505
  /* Icon-only buttons: the shell's two header toggles, four pagination arrows, the
2717
3506
  toast dismisser, the combobox's clear button, the calendar's two month arrows,
2718
- the DataView's expand toggle, the view-options panel's two reorder arrows, and
2719
- the DataView toolbar's clear-search button and two layout toggles.
2720
- SIXTEEN SITES sharing a transition and nothing else — no shared SURFACE, because
3507
+ the DataView's expand toggle, the view-options panel's two reorder arrows, the
3508
+ DataView toolbar's clear-search button and two layout toggles, and the password
3509
+ field's reveal toggle.
3510
+ SEVENTEEN SITES sharing a transition and nothing else — no shared SURFACE, because
2721
3511
  each is styled by where it sits. Sites rather than elements: the reorder arrows
2722
3512
  render twice per column row, so the element count is a function of how many
2723
3513
  columns a view has, while the list of places to check is fixed.
@@ -2761,7 +3551,7 @@ button[data-terp="input"][data-placeholder="true"] {
2761
3551
 
2762
3552
  The :disabled cursor no longer shouts, and re-deriving that is more useful than
2763
3553
  trusting it. The question is never "has anything migrated" but "can any element
2764
- this selector matches still beat it" — so: which of the eleven can carry the
3554
+ this selector matches still beat it" — so: which of the seventeen can carry the
2765
3555
  disabled attribute at all? The shell's toggles cannot (no disabled prop). The
2766
3556
  toast dismisser cannot. The combobox's clear button renders only while the
2767
3557
  field is enabled and takes no disabled of its own. The calendar's arrows page
@@ -2773,7 +3563,8 @@ button[data-terp="input"][data-placeholder="true"] {
2773
3563
  background: var(--color-neutral-100);
2774
3564
  color: var(--color-neutral-900);
2775
3565
  }
2776
- [data-terp="iconbutton"]:disabled {
3566
+ [data-terp="iconbutton"]:disabled,
3567
+ [data-terp="iconbutton"][aria-disabled="true"] {
2777
3568
  opacity: 0.4;
2778
3569
  cursor: not-allowed;
2779
3570
  }
@@ -2787,7 +3578,13 @@ button[data-terp="input"][data-placeholder="true"] {
2787
3578
  only the text controls had migrated left a disabled Combobox painted exactly
2788
3579
  like an enabled one and deleted the aria-invalid border outright. All six
2789
3580
  now take their base from this sheet, so layer order is enough. */
2790
- [data-terp="input"]:hover:not(:disabled):not(:focus) {
3581
+ /* The invalid exclusion is not tidying. This selector weighs (0,4,0) and the danger border below
3582
+ weighs (0,2,0), both unlayered against each other inside terp.state — so without the third
3583
+ :not() a pointer resting on a field that has just failed validation repaints its border from
3584
+ the danger token to a neutral grey, and the error state disappears for exactly as long as the
3585
+ user is pointing at the thing they need to fix. Narrowing the aggressor rather than adding a
3586
+ competing [aria-invalid="true"]:hover rule is this sheet's convention. */
3587
+ [data-terp="input"]:hover:not(:disabled):not(:focus):not([aria-invalid="true"]) {
2791
3588
  border-color: var(--color-neutral-400);
2792
3589
  }
2793
3590
  [data-terp="input"]:focus,
@@ -2861,8 +3658,8 @@ button[data-terp="input"][data-placeholder="true"] {
2861
3658
  The rail's scrollbar suppression moved from an attribute on the nav to the sidebar's,
2862
3659
  because collapsed is one fact and it now has one owner. */
2863
3660
  [data-terp="appshell-nav"] a:hover:not([aria-current="page"]) {
2864
- background: var(--color-neutral-100);
2865
- color: var(--color-neutral-900);
3661
+ background: var(--color-sidebar-accent);
3662
+ color: var(--color-sidebar-fg);
2866
3663
  }
2867
3664
  [data-terp="appshell-nav"] a[aria-current="page"] {
2868
3665
  background: var(--color-brand-primary-soft);
@@ -2873,12 +3670,80 @@ button[data-terp="input"][data-placeholder="true"] {
2873
3670
  overflow-x: hidden;
2874
3671
  scrollbar-width: none;
2875
3672
  }
3673
+ /* The rail's group separation. The label is visually hidden here (it joins the block above), so
3674
+ without this the groups are a single undifferentiated column of icons and the structure the
3675
+ expanded sidebar shows simply disappears at 4rem. A rule the LINE has to carry, because the
3676
+ label cannot: the divider is what is left of the label once the text is gone.
3677
+
3678
+ It replaces rather than adds to the expanded margin — same specificity family, one attribute
3679
+ more — so the rail does not pay 1rem per group in a column that is already scrolling. */
3680
+ [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-nav-group"] + [data-terp="appshell-nav-group"] {
3681
+ margin-block-start: var(--space-2);
3682
+ padding-block-start: var(--space-2);
3683
+ border-block-start: 1px solid var(--color-sidebar-border);
3684
+ }
2876
3685
  [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-nav"]::-webkit-scrollbar {
2877
3686
  width: 0;
2878
3687
  height: 0;
2879
3688
  }
2880
3689
  [data-terp="appshell-brand"]:hover {
2881
- background: var(--color-neutral-100);
3690
+ background: var(--color-sidebar-accent);
3691
+ }
3692
+ /* And its hover, for the same reason: the shared iconbutton hover wash is a neutral, which is
3693
+ the header toggle's context and not this one's. */
3694
+ [data-terp="appshell-brand-row"] > [data-terp="iconbutton"]:hover {
3695
+ background: var(--color-sidebar-accent);
3696
+ color: var(--color-sidebar-fg);
3697
+ }
3698
+ /* The skip link, visible only while focused.
3699
+ In terp.state, and that is not filing: the resting half is the shared visually-hidden block
3700
+ in terp.base, which sets position, a 1px box and clip, and un-hiding has to beat all of it.
3701
+ On specificity it would not — a selector list takes the specificity of the member that
3702
+ MATCHES, and for this element that member is [data-terp="appshell-skip-link"] at (0,1,0),
3703
+ the same weight as this rule. (An earlier version of this comment cited the list's (0,3,0)
3704
+ member, which is the collapsed-rail selector and never matches a skip link; that reading
3705
+ would have made the rules a source-order coin flip rather than a layer decision.) Layer
3706
+ order settles it with nothing to reason about.
3707
+ :focus-visible rather than :focus, matching the sheet's shared ring: a skip link reached by
3708
+ pointer is a link nobody asked to see.
3709
+ Above the sticky header (30) and its backdrop (40) so it is not painted under the chrome it
3710
+ sits over; below the drawer (50) because nothing should paint over an open modal. Stacking
3711
+ order does NOT keep it out of the drawer's focus trap and this comment used to say it did —
3712
+ z-index has no bearing on tab order. The link is simply not RENDERED while the drawer is
3713
+ open; see AppShell. */
3714
+ /* The skip link's target takes focus and must NOT paint the shared ring.
3715
+ The main element carries a data-terp marker and now a tabIndex of -1, which together put it
3716
+ in scope of the shared [data-terp]:focus-visible ring — so activating the skip link outlined
3717
+ the entire content column, header to footer, plus a 3px halo. Measured: it matches
3718
+ :focus-visible, with a 2px solid outline and rgba(37,99,235,0.35) 0 0 0 3px. The ring exists
3719
+ to say which CONTROL will take the next keystroke; a scroll target that was focused
3720
+ programmatically is not one, and the visible result of following a skip link should be the
3721
+ content rather than a box drawn around it.
3722
+ Scoped to this marker rather than to tabindex=-1 in general: other elements take -1 for other
3723
+ reasons and some of them are controls. */
3724
+ [data-terp="appshell-main"]:focus-visible {
3725
+ outline: none;
3726
+ box-shadow: none;
3727
+ }
3728
+ [data-terp="appshell-skip-link"]:focus-visible {
3729
+ position: fixed;
3730
+ top: var(--space-2);
3731
+ inset-inline-start: var(--space-2);
3732
+ z-index: var(--z-index-skip-link);
3733
+ width: auto;
3734
+ height: auto;
3735
+ margin: 0;
3736
+ padding: var(--space-2) var(--space-3);
3737
+ clip: auto;
3738
+ overflow: visible;
3739
+ background: var(--color-neutral-0);
3740
+ color: var(--color-fg-accent);
3741
+ border: var(--border-width-thin) solid var(--color-fg-accent);
3742
+ border-radius: var(--radius-md);
3743
+ font-family: var(--font-family-sans);
3744
+ font-size: var(--font-size-sm);
3745
+ font-weight: var(--font-weight-medium);
3746
+ text-decoration: none;
2882
3747
  }
2883
3748
 
2884
3749
  /* Tabs -------------------------------------------------------------------- */
@@ -2998,13 +3863,14 @@ button[data-terp="input"][data-placeholder="true"] {
2998
3863
  }
2999
3864
 
3000
3865
  /* The pager's disabled ink, scoped rather than added to the shared iconbutton
3001
- rule above: of the sixteen sites wearing that marker only six can be disabled at
3866
+ rule above: of the seventeen sites wearing that marker only six can be disabled at
3002
3867
  all — these four and the view-options panel's two reorder arrows, which carry
3003
3868
  their own scoped ink below for the same reason — and giving the shared rule a
3004
3869
  colour would change how a disabled calendar arrow looks the day one becomes
3005
3870
  disableable. The shared rule supplies the opacity and the cursor; this supplies
3006
3871
  the ink the pager had inline. */
3007
- [data-terp="dataview-pager"] > [data-terp="iconbutton"]:disabled {
3872
+ [data-terp="dataview-pager"] > [data-terp="iconbutton"]:disabled,
3873
+ [data-terp="dataview-pager"] > [data-terp="iconbutton"][aria-disabled="true"] {
3008
3874
  color: var(--color-neutral-300);
3009
3875
  }
3010
3876
 
@@ -3034,7 +3900,7 @@ button[data-terp="input"][data-placeholder="true"] {
3034
3900
  table-layout: fixed;
3035
3901
  }
3036
3902
  [data-terp="dataview-table"] tbody tr {
3037
- transition: background-color 150ms ease;
3903
+ transition: background-color var(--motion-duration-fast) var(--motion-easing-standard);
3038
3904
  }
3039
3905
  [data-terp="dataview-table"] tbody tr:hover td {
3040
3906
  background: var(--color-neutral-50);
@@ -3061,8 +3927,13 @@ button[data-terp="input"][data-placeholder="true"] {
3061
3927
  is enough, and the card's tone rules lose to it on layer rather than on
3062
3928
  specificity. Keeping the escalation past this point is the quiet failure: nothing
3063
3929
  would render differently, the declaration would simply become unthemeable. */
3930
+ /* Both halves carry the data-clickable guard, and the card half did not. A card list stamps
3931
+ data-clickable only when onRowClick is set, but renders the selection checkbox on
3932
+ selectionEnabled alone — so in a selectable-but-not-clickable list, focusing a checkbox
3933
+ washed the whole card in brand-soft and buried its data-tone. Focus is not selection, and
3934
+ a card that does nothing when clicked has no "activate me" state to advertise. */
3064
3935
  [data-terp="dataview-row"][data-clickable="true"]:focus-within td,
3065
- [data-terp="dataview-card"]:focus-within {
3936
+ [data-terp="dataview-card"][data-clickable="true"]:focus-within {
3066
3937
  background: var(--color-brand-primary-soft);
3067
3938
  }
3068
3939