@terpjs/react-core 0.9.0 → 0.11.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (101) hide show
  1. package/README.md +57 -21
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +323 -4
  4. package/src/AppShell.tsx +401 -66
  5. package/src/EmptyState.test.tsx +30 -0
  6. package/src/EmptyState.tsx +23 -3
  7. package/src/Field.test.tsx +30 -0
  8. package/src/Field.tsx +36 -8
  9. package/src/FormPage.tsx +54 -0
  10. package/src/LoginView.test.tsx +34 -2
  11. package/src/LoginView.tsx +43 -18
  12. package/src/ModuleNav.test.tsx +17 -10
  13. package/src/ModuleNav.tsx +35 -3
  14. package/src/Page.tsx +23 -1
  15. package/src/ProfileView.test.tsx +1 -1
  16. package/src/ProfileView.tsx +2 -4
  17. package/src/SettingsPage.tsx +50 -0
  18. package/src/SplitPage.tsx +150 -0
  19. package/src/UserMenu.test.tsx +28 -5
  20. package/src/UserMenu.tsx +15 -9
  21. package/src/admin/AuditLogAdmin.tsx +21 -7
  22. package/src/admin/GroupCreate.tsx +17 -3
  23. package/src/admin/GroupDetail.tsx +48 -13
  24. package/src/admin/GroupsAdmin.tsx +13 -5
  25. package/src/admin/UserCreate.tsx +40 -11
  26. package/src/admin/UserDetail.tsx +4 -1
  27. package/src/admin/UsersAdmin.tsx +14 -6
  28. package/src/admin/admin.test.tsx +212 -8
  29. package/src/admin/fieldErrors.ts +45 -0
  30. package/src/bootstrap.test.tsx +208 -0
  31. package/src/bootstrap.tsx +121 -5
  32. package/src/breakpoints.ts +41 -0
  33. package/src/dataview/DataView.tsx +12 -5
  34. package/src/dataview/DataViewCardList.tsx +8 -7
  35. package/src/dataview/DataViewPagination.tsx +15 -8
  36. package/src/dataview/DataViewTable.tsx +32 -21
  37. package/src/dataview/README.md +13 -2
  38. package/src/dataview/index.ts +1 -0
  39. package/src/dataview/internal.tsx +31 -1
  40. package/src/dataview/types.ts +26 -3
  41. package/src/format.test.tsx +213 -0
  42. package/src/format.ts +150 -0
  43. package/src/icons.tsx +67 -5
  44. package/src/index.ts +56 -6
  45. package/src/layout.manifest.json +118 -0
  46. package/src/layout.manifest.test.ts +205 -0
  47. package/src/layout.test.tsx +198 -1
  48. package/src/layout.tsx +208 -11
  49. package/src/layoutContract.test.tsx +311 -2
  50. package/src/layoutContract.ts +44 -3
  51. package/src/layoutDeclaration.test.ts +435 -0
  52. package/src/layoutDeclaration.ts +531 -0
  53. package/src/locale.tsx +12 -0
  54. package/src/markers.test.ts +27 -5
  55. package/src/nav.test.ts +234 -4
  56. package/src/nav.ts +180 -6
  57. package/src/navActive.test.ts +115 -0
  58. package/src/navActive.ts +119 -0
  59. package/src/navLink.tsx +20 -2
  60. package/src/previewBridge.test.ts +327 -0
  61. package/src/previewBridge.ts +278 -0
  62. package/src/raw.d.ts +14 -2
  63. package/src/review.test.tsx +272 -0
  64. package/src/router.test.tsx +575 -2
  65. package/src/router.tsx +212 -19
  66. package/src/styles.test.ts +535 -58
  67. package/src/styles.ts +1130 -111
  68. package/src/theme.test.tsx +29 -0
  69. package/src/theme.themes.test.ts +13 -7
  70. package/src/theme.tsx +30 -33
  71. package/src/themes.ts +54 -0
  72. package/src/toast.tsx +2 -1
  73. package/src/tokens.guard.test.ts +239 -0
  74. package/src/typography.test.tsx +213 -0
  75. package/src/typography.tsx +255 -0
  76. package/src/ui/Avatar.test.tsx +63 -0
  77. package/src/ui/Avatar.tsx +65 -0
  78. package/src/ui/Button.test.tsx +69 -3
  79. package/src/ui/Button.tsx +57 -4
  80. package/src/ui/Card.test.tsx +13 -0
  81. package/src/ui/Card.tsx +28 -1
  82. package/src/ui/Checkbox.tsx +10 -2
  83. package/src/ui/Combobox.test.tsx +139 -0
  84. package/src/ui/Combobox.tsx +255 -43
  85. package/src/ui/DatePicker.tsx +44 -12
  86. package/src/ui/Input.test.tsx +123 -0
  87. package/src/ui/Input.tsx +65 -2
  88. package/src/ui/Menu.tsx +16 -5
  89. package/src/ui/Popover.tsx +13 -0
  90. package/src/ui/Radio.tsx +10 -5
  91. package/src/ui/Select.test.tsx +232 -0
  92. package/src/ui/Select.tsx +177 -8
  93. package/src/ui/Switch.tsx +10 -2
  94. package/src/ui/Tabs.test.tsx +28 -0
  95. package/src/ui/Tabs.tsx +30 -6
  96. package/src/ui/Tooltip.test.tsx +56 -1
  97. package/src/ui/Tooltip.tsx +69 -6
  98. package/src/uiText.literals.test.ts +199 -0
  99. package/src/uiText.tsx +36 -0
  100. package/src/unwrap.test.ts +132 -0
  101. package/src/unwrap.ts +118 -32
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. */
@@ -1243,20 +1939,32 @@ textarea[data-terp="input"] {
1243
1939
  [data-terp="dataview"] {
1244
1940
  display: grid;
1245
1941
  }
1246
- /* The full variant's surface. (0,2,0) against the bare marker's (0,1,0), so it wins
1247
- on specificity no tie, no :not(), no source order. BOTH values of data-variant
1248
- are stamped and only this one has a rule, which is the theme-toggle idiom where
1249
- inline is stamped and takes the shared base while only stacked declares anything.
1250
- Rejected: putting the surface on the bare marker and un-declaring it under
1251
- [data-variant="embedded"], which needs background: transparent, border: 0 and
1252
- border-radius: 0 the shape ADR 0094 exists to avoid. Also rejected: stamping
1253
- nothing for the default on the density precedent, which holds only because
1254
- comfortable IS the :root value and so has nothing to declare.
1255
- Byte-identical to [data-terp="card"]'s trio; written flat anyway, the Badge /
1256
- Alert / row-tone precedent. No overflow: hidden, and none should be added here
1257
- the last row's border crossing the rounded bottom corners is pre-existing and
1258
- belongs to its own commit. */
1259
- [data-terp="dataview"][data-variant="full"] {
1942
+ /* The full variant's surface belongs to the TABLE, not to the whole view.
1943
+ It used to wrap everything: one card holding the toolbar, the table and the
1944
+ pagination, divided internally by a border under the toolbar and over the
1945
+ pagination. That reads as three bands of one object, and it costs twice — the
1946
+ table's cells are then flush against the outer frame (nothing between the first
1947
+ column's text and the card edge but cell padding), and the toolbar's controls sit
1948
+ inside a surface they do not belong to.
1949
+
1950
+ So the surface moves down one level, onto whatever occupies the table's slot, and
1951
+ the toolbar and the pagination float on the page background instead. The table
1952
+ becomes the object; the controls above and below it become controls. That also
1953
+ dissolves the flush-to-the-edge problem rather than padding around it: the table's
1954
+ own frame is the edge now, and --density-cell-pad-x is already the inset from it.
1955
+
1956
+ The alternative considered was keeping the outer card and adding inline padding to
1957
+ the table inside it. Rejected: it fixes the symptom by making the card thicker,
1958
+ leaves the toolbar inside a surface, and leaves two nested frames whenever the view
1959
+ is empty (the empty state's dashed frame inside the card's solid one).
1960
+
1961
+ Keyed on [data-variant="full"] rather than the bare marker for the reason the
1962
+ previous rule gave and which still holds: [data-variant="embedded"] must declare
1963
+ nothing, and un-declaring a surface with background: transparent / border: 0 is the
1964
+ shape ADR 0094 exists to avoid. */
1965
+ [data-terp="dataview"][data-variant="full"] > [data-terp="dataview-scroll"],
1966
+ [data-terp="dataview"][data-variant="full"] > [data-terp="dataview-error"],
1967
+ [data-terp="dataview"][data-variant="full"] > [data-terp="dataview-skeleton"] {
1260
1968
  background: var(--color-neutral-0);
1261
1969
  border: 1px solid var(--color-neutral-200);
1262
1970
  border-radius: var(--radius-lg);
@@ -1335,22 +2043,38 @@ textarea[data-terp="input"] {
1335
2043
  No colour declaration here on purpose. Two of this element's direct children
1336
2044
  are arbitrary caller slots, and inheriting a muted ink onto app-authored filter
1337
2045
  controls would be a silent restyle of app DOM. */
2046
+ /* Floating: no background, no divider, and no inline padding. The divider was the
2047
+ seam between two bands of one card and there is no card now — a border under a strip
2048
+ that sits on the page background is a line drawn across nothing. Dropping the inline
2049
+ padding aligns the controls with the table's OUTER edge (its frame) rather than with
2050
+ its cell text, which is the alignment a floating control row wants: the eye follows
2051
+ the frame, and a control indented to meet the first column's text reads as belonging
2052
+ inside the table.
2053
+
2054
+ The background's previous justification is worth answering rather than deleting: it
2055
+ was there for the EMBEDDED variant, whose root declares nothing but a display, so that
2056
+ the band would not show the page canvas through it. That is now the intent in both
2057
+ variants. A floating strip shows whatever is behind it — the page in the full variant,
2058
+ the app's own card in the embedded one — and in neither case is a neutral-0 rectangle
2059
+ under the controls something the design asks for. dataview-toolbar-bare still renders
2060
+ on a neutral-50 host, so the difference is visible in a baseline either way. */
1338
2061
  [data-terp="dataview-toolbar"] {
1339
2062
  display: flex;
1340
2063
  align-items: center;
1341
2064
  gap: var(--space-2);
1342
2065
  flex-wrap: wrap;
1343
- padding: var(--space-2) var(--density-cell-pad-x);
1344
- border-block-end: 1px solid var(--color-neutral-200);
1345
- background: var(--color-neutral-0);
1346
- border-top-left-radius: var(--radius-lg);
1347
- border-top-right-radius: var(--radius-lg);
2066
+ padding-block: var(--space-2);
1348
2067
  min-height: 3rem;
1349
2068
  }
1350
2069
  /* Selection mode. A resting surface rather than an interaction state, so
1351
2070
  terp.base — and (0,2,0) against the base's (0,1,0) means it wins on
1352
2071
  specificity alone, needing no :not() and no source-order dependency. */
1353
2072
  [data-terp="dataview-toolbar"][data-variant="selection"] {
2073
+ /* Still a filled surface, because it marks a MODE and losing that would make
2074
+ selection invisible — but now it is a surface of its own rather than a band of the
2075
+ card, so it takes the padding and radius that make it read as one. */
2076
+ padding-inline: var(--density-cell-pad-x);
2077
+ border-radius: var(--radius-md);
1354
2078
  background: var(--color-neutral-50);
1355
2079
  }
1356
2080
  [data-terp="dataview-toolbar-count"] {
@@ -1423,6 +2147,46 @@ textarea[data-terp="input"] {
1423
2147
  cursor: pointer;
1424
2148
  color: var(--color-fg-subtle);
1425
2149
  }
2150
+ /* The password field's reveal toggle, built on the search box above rather than beside
2151
+ it: same positioning context, same absolutely-placed control, same specificity
2152
+ argument. Only type="password" wraps, so every other input is still a bare element
2153
+ and the two child selectors in this sheet that reach for data-terp="input" — the
2154
+ toolbar search and the resource-list create field — can never meet a wrapper. */
2155
+ [data-terp="input-password"] {
2156
+ position: relative;
2157
+ display: inline-flex;
2158
+ align-items: center;
2159
+ }
2160
+ /* Room for the toggle. It must out-rank input[data-terp="input"] { padding: 0
2161
+ var(--space-3) } and does so on SPECIFICITY — two attributes (0,2,0) against an
2162
+ attribute plus a type (0,1,1) — the same trap and the same escape the search field
2163
+ documents above. Asymmetric on purpose: the glyph sits at the end, and reserving room
2164
+ at both ends would indent the value for nothing. */
2165
+ [data-terp="input-password"] > [data-terp="input"] {
2166
+ padding-inline-end: var(--space-6);
2167
+ width: 100%;
2168
+ }
2169
+ /* The toggle itself, the seventeenth element wearing the iconbutton marker. It takes
2170
+ that marker rather than one of its own because it is one: the shared rule already
2171
+ carries its transition, its hover wash and its disabled treatment, and duplicating
2172
+ those under a new name to avoid editing one enumeration would be the wrong trade. */
2173
+ /* Edge ships its own reveal control inside every password field, so without this the user gets
2174
+ two: the native eye sitting on top of ours, in a box sized for one glyph. Same class of fix as
2175
+ the number stepper this sheet already suppresses -- an unthemeable browser affordance the
2176
+ framework replaces rather than competes with. */
2177
+ input[data-terp="input"][type="password"]::-ms-reveal {
2178
+ display: none;
2179
+ }
2180
+ [data-terp="input-password"] > [data-terp="iconbutton"] {
2181
+ position: absolute;
2182
+ inset-inline-end: var(--space-1);
2183
+ display: inline-flex;
2184
+ padding: var(--space-1);
2185
+ background: transparent;
2186
+ border: none;
2187
+ cursor: pointer;
2188
+ color: var(--color-fg-subtle);
2189
+ }
1426
2190
  /* "Refreshing…", and this is the one place the prefer-an-existing-DOM-attribute
1427
2191
  rule is REFUSED with its own reasoning. [data-terp="dataview-toolbar"]
1428
2192
  > [role="status"] looks textbook — the component does own this span's role — but
@@ -1491,6 +2255,28 @@ textarea[data-terp="input"] {
1491
2255
  white-space: nowrap;
1492
2256
  background: var(--color-neutral-0);
1493
2257
  }
2258
+ /* A column's declared track. A MINIMUM rather than a width, because a specified width is only a
2259
+ preference under table-layout: auto and the algorithm shrinks it to fit — which is why the pixel
2260
+ hint this replaces did nothing at all, measured in the workbench at three columns asking for
2261
+ 700px each and fitting the box exactly. What auto layout cannot take away is a minimum.
2262
+
2263
+ Three steps, and no more: these are the three bands the framework's own tables declare, and a
2264
+ step is additive to add and breaking to remove. In rem, so a declared track follows the root
2265
+ font size; the system columns below keep their pixels on purpose, being chrome rather than
2266
+ content, and converting them is a density pass with its own baselines.
2267
+
2268
+ Nothing here ever meets an inline width. A resized column stops emitting the attribute, so the
2269
+ user's own drag replaces the declared track instead of losing to it — the minimum would win the
2270
+ cascade, and a column springing back from a drag reads as a broken resizer. */
2271
+ [data-terp="dataview-table"] > thead > tr > th[data-width="xs"] {
2272
+ min-inline-size: 5rem;
2273
+ }
2274
+ [data-terp="dataview-table"] > thead > tr > th[data-width="sm"] {
2275
+ min-inline-size: 6.5rem;
2276
+ }
2277
+ [data-terp="dataview-table"] > thead > tr > th[data-width="md"] {
2278
+ min-inline-size: 9.5rem;
2279
+ }
1494
2280
  [data-terp="dataview-row"] > td {
1495
2281
  padding: var(--density-cell-pad-y) var(--density-cell-pad-x);
1496
2282
  border-bottom: 1px solid var(--color-neutral-100);
@@ -1505,6 +2291,17 @@ textarea[data-terp="input"] {
1505
2291
  data-tone on an element no selector could reach, and the row-tones baseline
1506
2292
  would have lost its tints the moment the tone moved out of a style object.
1507
2293
  It is unconditional now, and clickability is an attribute of its own. */
2294
+ /* The last row draws no bottom border, which fixes two things the full variant's own
2295
+ comment had already named and deferred. The container carries a 1px border and a
2296
+ radius, so the last row's border sat a pixel inside it as a DOUBLE line, and with no
2297
+ overflow: hidden it also ran straight across the rounded bottom corners. Dropping the
2298
+ border is the fix rather than clipping the container: overflow: hidden here would trap
2299
+ the horizontal scroll container and any overlay a cell renders, which is why that
2300
+ comment refused it. tbody's last row, not the table's — a footer row would want its
2301
+ own rule. */
2302
+ [data-terp="dataview-row"]:last-child > td {
2303
+ border-bottom: none;
2304
+ }
1508
2305
  [data-terp="dataview-row"][data-clickable="true"] {
1509
2306
  cursor: pointer;
1510
2307
  }
@@ -1538,15 +2335,31 @@ textarea[data-terp="input"] {
1538
2335
  sorted, so the unsorted glyph's dimming keys off its ABSENCE rather than off
1539
2336
  an attribute minted for it — this component owns aria-sort, unlike the
1540
2337
  breadcrumb's aria-current, which a router stamps on every ancestor link. */
2338
+ /* font: inherit is not enough, and the gap it leaves split the header row in two.
2339
+ The font shorthand carries no text-transform and no letter-spacing, and the UA
2340
+ stylesheet resets both on form controls — so in any table mixing sortable and
2341
+ non-sortable columns the plain th rendered uppercase with 0.04em tracking while
2342
+ the sortable one rendered sentence case with none, side by side in one row. Both
2343
+ are named explicitly because inheritance is what the UA overrode.
2344
+
2345
+ The block padding mirrors the th's so the button fills the cell it sits in,
2346
+ pulled back out by the negative margin. The control was a 17px-tall target inside
2347
+ a 34px cell — half the cell unused, and the most-used control in a data app
2348
+ clearing WCAG 2.5.8 only through the spacing exception. Filling the cell costs
2349
+ nothing and changes no layout: the button's box grows into padding the th
2350
+ already reserved. */
1541
2351
  [data-terp="dataview-column-sort"] {
1542
2352
  display: inline-flex;
1543
2353
  align-items: center;
1544
2354
  gap: var(--space-1);
1545
2355
  font: inherit;
2356
+ text-transform: inherit;
2357
+ letter-spacing: inherit;
1546
2358
  color: inherit;
1547
2359
  background: transparent;
1548
2360
  border: none;
1549
- padding: 0;
2361
+ padding: var(--space-2) 0;
2362
+ margin-block: calc(-1 * var(--space-2));
1550
2363
  cursor: pointer;
1551
2364
  }
1552
2365
  [data-terp="dataview-table"] > thead > tr > th:not([aria-sort]) > [data-terp="dataview-column-sort"] > svg {
@@ -1760,8 +2573,7 @@ th[data-terp="dataview-actions-cell"] > span {
1760
2573
  justify-content: space-between;
1761
2574
  gap: var(--space-3);
1762
2575
  flex-wrap: wrap;
1763
- padding: var(--space-2) var(--density-cell-pad-x);
1764
- border-block-start: 1px solid var(--color-neutral-200);
2576
+ padding-block: var(--space-2);
1765
2577
  font-size: var(--font-size-sm);
1766
2578
  color: var(--color-fg-subtle);
1767
2579
  }
@@ -1949,6 +2761,24 @@ th[data-terp="dataview-actions-cell"] > span {
1949
2761
  border-radius: var(--radius-lg);
1950
2762
  background: var(--color-neutral-0);
1951
2763
  }
2764
+ /* Compact: a section's emptiness rather than the page's. Same frame and same words,
2765
+ laid out as a row — the glyph beside the text instead of above it — so two of
2766
+ these stacked read as two quiet sections rather than 480px of repeated poster. */
2767
+ [data-terp="empty-state"][data-size="compact"] {
2768
+ grid-template-columns: auto 1fr;
2769
+ justify-items: start;
2770
+ align-items: center;
2771
+ gap: var(--space-2) var(--space-3);
2772
+ padding: var(--space-3) var(--space-4);
2773
+ text-align: start;
2774
+ }
2775
+ [data-terp="empty-state"][data-size="compact"] > [data-terp="empty-state-title"] {
2776
+ font-size: var(--font-size-sm);
2777
+ }
2778
+ [data-terp="empty-state"][data-size="compact"] > [data-terp="empty-state-description"],
2779
+ [data-terp="empty-state"][data-size="compact"] > :not([data-terp="empty-state-icon"]):not([data-terp="empty-state-title"]) {
2780
+ grid-column: 2;
2781
+ }
1952
2782
  [data-terp="empty-state-icon"] {
1953
2783
  color: var(--color-neutral-400);
1954
2784
  display: inline-flex;
@@ -2064,6 +2894,71 @@ input[data-terp="input"][role="combobox"] {
2064
2894
  position: relative;
2065
2895
  display: grid;
2066
2896
  }
2897
+ /* Multiple: the tokens share the field's box with the input, wrapping onto as many rows
2898
+ as the selection needs. A fixed-height field would either clip the third token or
2899
+ reserve room for tokens nobody has chosen — and a set-valued field whose height never
2900
+ changes is lying about how much is in it.
2901
+
2902
+ The input keeps a minimum inline size so a filter is still typeable when the tokens have
2903
+ taken most of a row, and flex-basis 0 so it yields to them rather than pushing the last
2904
+ token out of the box. */
2905
+ [data-terp="combobox-field"][data-multiple="true"] {
2906
+ display: flex;
2907
+ flex-wrap: wrap;
2908
+ align-items: center;
2909
+ gap: var(--space-1);
2910
+ padding: var(--space-1);
2911
+ border: 1px solid var(--color-neutral-300);
2912
+ border-radius: var(--radius-md);
2913
+ background: var(--color-neutral-0);
2914
+ }
2915
+ [data-terp="combobox-field"][data-multiple="true"] > [data-terp="input"] {
2916
+ flex: 1 1 0;
2917
+ min-inline-size: 6rem;
2918
+ border: none;
2919
+ background: transparent;
2920
+ padding-inline: var(--space-1);
2921
+ }
2922
+ [data-terp="combobox-token"] {
2923
+ display: inline-flex;
2924
+ align-items: center;
2925
+ gap: var(--space-1);
2926
+ padding-block: 0;
2927
+ padding-inline: var(--space-2);
2928
+ font-size: var(--font-size-sm);
2929
+ color: var(--color-neutral-900);
2930
+ background: var(--color-neutral-100);
2931
+ border: 1px solid var(--color-neutral-200);
2932
+ border-radius: var(--radius-sm);
2933
+ /* Matches the control height a token sits beside, so a row of tokens and the input
2934
+ share one baseline instead of the tokens riding high. */
2935
+ min-block-size: calc(var(--density-control-min-height) - var(--space-2));
2936
+ }
2937
+ /* The remove control is a real button and a real tab stop, which is the accessible half of
2938
+ the Backspace shortcut rather than a duplicate of it: the shortcut is discoverable only if
2939
+ you already know it, and a token nobody can reach by keyboard cannot be removed by one. */
2940
+ [data-terp="combobox-token-remove"] {
2941
+ display: inline-flex;
2942
+ align-items: center;
2943
+ justify-content: center;
2944
+ min-inline-size: var(--space-4);
2945
+ min-block-size: var(--space-4);
2946
+ padding: 0;
2947
+ color: var(--color-neutral-600);
2948
+ background: transparent;
2949
+ border: none;
2950
+ border-radius: var(--radius-sm);
2951
+ cursor: pointer;
2952
+ line-height: 1;
2953
+ }
2954
+ [data-terp="combobox-token-remove"]:hover:not(:disabled) {
2955
+ color: var(--color-neutral-900);
2956
+ background: var(--color-neutral-200);
2957
+ }
2958
+ [data-terp="combobox-token-remove"]:disabled {
2959
+ cursor: not-allowed;
2960
+ opacity: 0.5;
2961
+ }
2067
2962
  /* Addressed structurally rather than by a marker of its own: it is an
2068
2963
  iconbutton, and the only thing distinguishing it is where it sits. */
2069
2964
  [data-terp="combobox-field"] > [data-terp="iconbutton"] {
@@ -2248,7 +3143,7 @@ button[data-terp="input"][data-placeholder="true"] {
2248
3143
  }
2249
3144
  [data-terp="tooltip"] {
2250
3145
  position: absolute;
2251
- z-index: 1;
3146
+ z-index: var(--z-index-tooltip);
2252
3147
  inset-block-end: calc(100% + var(--space-1));
2253
3148
  inset-inline-start: 0;
2254
3149
  max-inline-size: min(18rem, calc(100vw - 2 * var(--space-4)));
@@ -2260,7 +3155,10 @@ button[data-terp="input"][data-placeholder="true"] {
2260
3155
  font-weight: var(--font-weight-medium);
2261
3156
  line-height: 1.4;
2262
3157
  box-shadow: var(--shadow-md);
2263
- pointer-events: none;
3158
+ /* No pointer-events: none. It was here, and it makes WCAG 1.4.13's Hoverable clause
3159
+ impossible by construction: a bubble the pointer cannot reach is a bubble nobody
3160
+ tracking with a pointer, or reading under magnification, can finish reading. The
3161
+ component keeps it open across the gap with a short close delay instead. */
2264
3162
  white-space: normal;
2265
3163
  }
2266
3164
 
@@ -2285,7 +3183,10 @@ button[data-terp="input"][data-placeholder="true"] {
2285
3183
  font-size: var(--font-size-sm);
2286
3184
  font-weight: var(--font-weight-normal);
2287
3185
  line-height: 1.25;
2288
- transition: background-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
3186
+ transition:
3187
+ background-color var(--motion-duration-fast) var(--motion-easing-standard),
3188
+ color var(--motion-duration-fast) var(--motion-easing-standard),
3189
+ box-shadow var(--motion-duration-fast) var(--motion-easing-standard);
2289
3190
  }
2290
3191
  /* The panel's contents. This sits INSIDE popover-panel, which supplies the
2291
3192
  surface — so the menu owns only the stacking of its items. */
@@ -2310,7 +3211,9 @@ button[data-terp="input"][data-placeholder="true"] {
2310
3211
  font-size: var(--font-size-sm);
2311
3212
  font-weight: var(--font-weight-normal);
2312
3213
  line-height: 1.25;
2313
- transition: background-color 150ms ease, color 150ms ease;
3214
+ transition:
3215
+ background-color var(--motion-duration-fast) var(--motion-easing-standard),
3216
+ color var(--motion-duration-fast) var(--motion-easing-standard);
2314
3217
  }
2315
3218
  /* Destructive is the one enumerable choice an item has, so it is an attribute.
2316
3219
  The disabled treatment is a state rule keyed on :disabled, because the element
@@ -2363,7 +3266,12 @@ button[data-terp="input"][data-placeholder="true"] {
2363
3266
  width: 100%;
2364
3267
  padding: var(--space-2);
2365
3268
  text-align: left;
2366
- color: var(--color-neutral-900);
3269
+ /* The sidebar family, not the neutral one. This row renders inside the sidebar (and inside the
3270
+ header group under navPlacement="header", which takes the sidebar surface), so its ink and
3271
+ that background are a pairing in play — and the contrast gate can only measure a pairing it
3272
+ can name. Provably zero-diff: --color-neutral-900 and --color-sidebar-fg are byte-equal in
3273
+ all five themes. */
3274
+ color: var(--color-sidebar-fg);
2367
3275
  border-color: transparent;
2368
3276
  min-height: 0;
2369
3277
  }
@@ -2373,19 +3281,6 @@ button[data-terp="input"][data-placeholder="true"] {
2373
3281
  gap: 0;
2374
3282
  padding: 0;
2375
3283
  }
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
3284
  [data-terp="user-menu-identity"] {
2390
3285
  display: grid;
2391
3286
  min-width: 0;
@@ -2396,9 +3291,17 @@ button[data-terp="input"][data-placeholder="true"] {
2396
3291
  text-overflow: ellipsis;
2397
3292
  white-space: nowrap;
2398
3293
  }
3294
+ /* Two surfaces, one marker. UserMenu renders this span in the trigger AND in the portalled
3295
+ panel, and only the first sits on the sidebar — the panel is in document.body, where the
3296
+ sidebar palette does not apply. So the sidebar copy is scoped (the portal puts the panel
3297
+ outside this selector by construction) and the panel keeps the neutral. The sheet already
3298
+ argues this exact split for the drawer close button. */
2399
3299
  [data-terp="user-menu-role"] {
2400
3300
  color: var(--color-neutral-600);
2401
3301
  }
3302
+ [data-terp="user-menu"] [data-terp="user-menu-role"] {
3303
+ color: var(--color-sidebar-muted);
3304
+ }
2402
3305
  /* The panel's identity block, and the panel's own geometry — both keyed on the
2403
3306
  owner, because the portal put them outside every selector that could otherwise
2404
3307
  reach them. */
@@ -2440,8 +3343,14 @@ button[data-terp="input"][data-placeholder="true"] {
2440
3343
  rules ([data-terp="markdown"] p, ... ul), and a declaration added here would
2441
3344
  simply do nothing with nothing to say so. And display: contents does not change
2442
3345
  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.
3346
+ this wrapper rather than the blocks.
3347
+
3348
+ That last sentence used to end "Nothing in this sheet uses one, which is why the
3349
+ wrapper is free today", and it stopped being true when the measured content width
3350
+ shipped: that rule is a child-star selector on the page's body children. It matched it,
3351
+ found no box to give a width to, and let prose run full-bleed in a measured shell.
3352
+ The reach-through beside that rule is the fix; this wrapper is free of everything
3353
+ else.
2445
3354
 
2446
3355
  Under SSR the sheet is not injected at all (the injector is document-guarded), so
2447
3356
  a server-rendered page has this element as a block box until hydration. That is
@@ -2574,12 +3483,15 @@ button[data-terp="input"][data-placeholder="true"] {
2574
3483
  margin-block-start: var(--space-2);
2575
3484
  }
2576
3485
 
2577
- /* Icon-only buttons. Sixteen elements wear this marker and not one declares a
3486
+ /* Icon-only buttons. Seventeen elements wear this marker and not one declares a
2578
3487
  transition inline, so this belongs in terp.base — it sat in terp.state only
2579
3488
  because that is where the hover rules needing it live. Same correction 817f572
2580
3489
  made for Tabs and Breadcrumbs. */
2581
3490
  [data-terp="iconbutton"] {
2582
- transition: background-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
3491
+ transition:
3492
+ background-color var(--motion-duration-fast) var(--motion-easing-standard),
3493
+ color var(--motion-duration-fast) var(--motion-easing-standard),
3494
+ box-shadow var(--motion-duration-fast) var(--motion-easing-standard);
2583
3495
  }
2584
3496
 
2585
3497
  /* Popover ------------------------------------------------------------------ */
@@ -2613,12 +3525,26 @@ button[data-terp="input"][data-placeholder="true"] {
2613
3525
 
2614
3526
  The stacking level is the token that was published for it. Every component in
2615
3527
  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:
2619
- 1 above is deliberately NOT a token the tooltip is absolutely positioned
2620
- inside its own anchor, so 1 is a local lift within a stacking context rather
2621
- than a place in the app-wide order. */
3528
+ unread. That is finished: AppShell reads --z-index-drawer, --z-index-backdrop
3529
+ and --z-index-sticky, the skip link reads --z-index-skip-link, and the toast
3530
+ viewport reads --z-index-toast. (This paragraph said "AppShell still writes
3531
+ 50/40/30 ... and comes right with its own migration" for a release after that
3532
+ migration landed, which is the shape of stale comment worth naming: it read as
3533
+ a known gap rather than as a finished one.) Tooltip's z-index WAS 1 on the
3534
+ reasoning that the tooltip is absolutely positioned inside its own anchor, so 1
3535
+ is a local lift within a stacking context rather than a place in the app-wide
3536
+ order. That is sound about the anchor and wrong about the page:
3537
+ [data-terp="tooltip-anchor"] is only position: relative, which does NOT create a
3538
+ stacking context, so the 1 competed in the ROOT context — against a sticky header
3539
+ at 30 and an open popover at 60 — and lost. Reported as a tooltip that renders
3540
+ below content "sometimes, not always", which is exactly what a level that depends
3541
+ on whatever ancestor happens to establish a context looks like. It reads
3542
+ --z-index-tooltip (70) now, the level published for it.
3543
+
3544
+ Still true, and NOT fixed by this: an ancestor with overflow: hidden clips an
3545
+ absolutely positioned tooltip whatever its level. The fix for that is the one
3546
+ [data-terp="popover-panel"] already uses — position: fixed with measured
3547
+ coordinates — and it is a change to the component rather than to this sheet. */
2622
3548
  [data-terp="popover-panel"] {
2623
3549
  position: fixed;
2624
3550
  z-index: var(--z-index-popover);
@@ -2712,12 +3638,24 @@ button[data-terp="input"][data-placeholder="true"] {
2712
3638
  opacity: 0.55;
2713
3639
  cursor: not-allowed;
2714
3640
  }
3641
+ /* A loading button IS disabled — the component sets both — so this has to sit here rather
3642
+ than in terp.base beside the other attribute-keyed button rules. In terp.base it would
3643
+ lose to the :disabled rule above on layer order and the cursor would silently stay
3644
+ not-allowed, which reads as "you may not" where the truth is "not yet". Declared after
3645
+ it so source order settles the tie the equal (0,2,0) specificity leaves.
3646
+
3647
+ Neither cursor is visible to any lane: Playwright's screenshots do not paint a pointer.
3648
+ The computed lane asserts both. */
3649
+ [data-terp="button"][data-loading="true"] {
3650
+ cursor: progress;
3651
+ }
2715
3652
 
2716
3653
  /* Icon-only buttons: the shell's two header toggles, four pagination arrows, the
2717
3654
  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
3655
+ the DataView's expand toggle, the view-options panel's two reorder arrows, the
3656
+ DataView toolbar's clear-search button and two layout toggles, and the password
3657
+ field's reveal toggle.
3658
+ SEVENTEEN SITES sharing a transition and nothing else — no shared SURFACE, because
2721
3659
  each is styled by where it sits. Sites rather than elements: the reorder arrows
2722
3660
  render twice per column row, so the element count is a function of how many
2723
3661
  columns a view has, while the list of places to check is fixed.
@@ -2761,7 +3699,7 @@ button[data-terp="input"][data-placeholder="true"] {
2761
3699
 
2762
3700
  The :disabled cursor no longer shouts, and re-deriving that is more useful than
2763
3701
  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
3702
+ this selector matches still beat it" — so: which of the seventeen can carry the
2765
3703
  disabled attribute at all? The shell's toggles cannot (no disabled prop). The
2766
3704
  toast dismisser cannot. The combobox's clear button renders only while the
2767
3705
  field is enabled and takes no disabled of its own. The calendar's arrows page
@@ -2773,7 +3711,8 @@ button[data-terp="input"][data-placeholder="true"] {
2773
3711
  background: var(--color-neutral-100);
2774
3712
  color: var(--color-neutral-900);
2775
3713
  }
2776
- [data-terp="iconbutton"]:disabled {
3714
+ [data-terp="iconbutton"]:disabled,
3715
+ [data-terp="iconbutton"][aria-disabled="true"] {
2777
3716
  opacity: 0.4;
2778
3717
  cursor: not-allowed;
2779
3718
  }
@@ -2787,7 +3726,13 @@ button[data-terp="input"][data-placeholder="true"] {
2787
3726
  only the text controls had migrated left a disabled Combobox painted exactly
2788
3727
  like an enabled one and deleted the aria-invalid border outright. All six
2789
3728
  now take their base from this sheet, so layer order is enough. */
2790
- [data-terp="input"]:hover:not(:disabled):not(:focus) {
3729
+ /* The invalid exclusion is not tidying. This selector weighs (0,4,0) and the danger border below
3730
+ weighs (0,2,0), both unlayered against each other inside terp.state — so without the third
3731
+ :not() a pointer resting on a field that has just failed validation repaints its border from
3732
+ the danger token to a neutral grey, and the error state disappears for exactly as long as the
3733
+ user is pointing at the thing they need to fix. Narrowing the aggressor rather than adding a
3734
+ competing [aria-invalid="true"]:hover rule is this sheet's convention. */
3735
+ [data-terp="input"]:hover:not(:disabled):not(:focus):not([aria-invalid="true"]) {
2791
3736
  border-color: var(--color-neutral-400);
2792
3737
  }
2793
3738
  [data-terp="input"]:focus,
@@ -2861,8 +3806,8 @@ button[data-terp="input"][data-placeholder="true"] {
2861
3806
  The rail's scrollbar suppression moved from an attribute on the nav to the sidebar's,
2862
3807
  because collapsed is one fact and it now has one owner. */
2863
3808
  [data-terp="appshell-nav"] a:hover:not([aria-current="page"]) {
2864
- background: var(--color-neutral-100);
2865
- color: var(--color-neutral-900);
3809
+ background: var(--color-sidebar-accent);
3810
+ color: var(--color-sidebar-fg);
2866
3811
  }
2867
3812
  [data-terp="appshell-nav"] a[aria-current="page"] {
2868
3813
  background: var(--color-brand-primary-soft);
@@ -2873,12 +3818,80 @@ button[data-terp="input"][data-placeholder="true"] {
2873
3818
  overflow-x: hidden;
2874
3819
  scrollbar-width: none;
2875
3820
  }
3821
+ /* The rail's group separation. The label is visually hidden here (it joins the block above), so
3822
+ without this the groups are a single undifferentiated column of icons and the structure the
3823
+ expanded sidebar shows simply disappears at 4rem. A rule the LINE has to carry, because the
3824
+ label cannot: the divider is what is left of the label once the text is gone.
3825
+
3826
+ It replaces rather than adds to the expanded margin — same specificity family, one attribute
3827
+ more — so the rail does not pay 1rem per group in a column that is already scrolling. */
3828
+ [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-nav-group"] + [data-terp="appshell-nav-group"] {
3829
+ margin-block-start: var(--space-2);
3830
+ padding-block-start: var(--space-2);
3831
+ border-block-start: 1px solid var(--color-sidebar-border);
3832
+ }
2876
3833
  [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-nav"]::-webkit-scrollbar {
2877
3834
  width: 0;
2878
3835
  height: 0;
2879
3836
  }
2880
3837
  [data-terp="appshell-brand"]:hover {
2881
- background: var(--color-neutral-100);
3838
+ background: var(--color-sidebar-accent);
3839
+ }
3840
+ /* And its hover, for the same reason: the shared iconbutton hover wash is a neutral, which is
3841
+ the header toggle's context and not this one's. */
3842
+ [data-terp="appshell-brand-row"] > [data-terp="iconbutton"]:hover {
3843
+ background: var(--color-sidebar-accent);
3844
+ color: var(--color-sidebar-fg);
3845
+ }
3846
+ /* The skip link, visible only while focused.
3847
+ In terp.state, and that is not filing: the resting half is the shared visually-hidden block
3848
+ in terp.base, which sets position, a 1px box and clip, and un-hiding has to beat all of it.
3849
+ On specificity it would not — a selector list takes the specificity of the member that
3850
+ MATCHES, and for this element that member is [data-terp="appshell-skip-link"] at (0,1,0),
3851
+ the same weight as this rule. (An earlier version of this comment cited the list's (0,3,0)
3852
+ member, which is the collapsed-rail selector and never matches a skip link; that reading
3853
+ would have made the rules a source-order coin flip rather than a layer decision.) Layer
3854
+ order settles it with nothing to reason about.
3855
+ :focus-visible rather than :focus, matching the sheet's shared ring: a skip link reached by
3856
+ pointer is a link nobody asked to see.
3857
+ Above the sticky header (30) and its backdrop (40) so it is not painted under the chrome it
3858
+ sits over; below the drawer (50) because nothing should paint over an open modal. Stacking
3859
+ order does NOT keep it out of the drawer's focus trap and this comment used to say it did —
3860
+ z-index has no bearing on tab order. The link is simply not RENDERED while the drawer is
3861
+ open; see AppShell. */
3862
+ /* The skip link's target takes focus and must NOT paint the shared ring.
3863
+ The main element carries a data-terp marker and now a tabIndex of -1, which together put it
3864
+ in scope of the shared [data-terp]:focus-visible ring — so activating the skip link outlined
3865
+ the entire content column, header to footer, plus a 3px halo. Measured: it matches
3866
+ :focus-visible, with a 2px solid outline and rgba(37,99,235,0.35) 0 0 0 3px. The ring exists
3867
+ to say which CONTROL will take the next keystroke; a scroll target that was focused
3868
+ programmatically is not one, and the visible result of following a skip link should be the
3869
+ content rather than a box drawn around it.
3870
+ Scoped to this marker rather than to tabindex=-1 in general: other elements take -1 for other
3871
+ reasons and some of them are controls. */
3872
+ [data-terp="appshell-main"]:focus-visible {
3873
+ outline: none;
3874
+ box-shadow: none;
3875
+ }
3876
+ [data-terp="appshell-skip-link"]:focus-visible {
3877
+ position: fixed;
3878
+ top: var(--space-2);
3879
+ inset-inline-start: var(--space-2);
3880
+ z-index: var(--z-index-skip-link);
3881
+ width: auto;
3882
+ height: auto;
3883
+ margin: 0;
3884
+ padding: var(--space-2) var(--space-3);
3885
+ clip: auto;
3886
+ overflow: visible;
3887
+ background: var(--color-neutral-0);
3888
+ color: var(--color-fg-accent);
3889
+ border: var(--border-width-thin) solid var(--color-fg-accent);
3890
+ border-radius: var(--radius-md);
3891
+ font-family: var(--font-family-sans);
3892
+ font-size: var(--font-size-sm);
3893
+ font-weight: var(--font-weight-medium);
3894
+ text-decoration: none;
2882
3895
  }
2883
3896
 
2884
3897
  /* Tabs -------------------------------------------------------------------- */
@@ -2998,13 +4011,14 @@ button[data-terp="input"][data-placeholder="true"] {
2998
4011
  }
2999
4012
 
3000
4013
  /* 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
4014
+ rule above: of the seventeen sites wearing that marker only six can be disabled at
3002
4015
  all — these four and the view-options panel's two reorder arrows, which carry
3003
4016
  their own scoped ink below for the same reason — and giving the shared rule a
3004
4017
  colour would change how a disabled calendar arrow looks the day one becomes
3005
4018
  disableable. The shared rule supplies the opacity and the cursor; this supplies
3006
4019
  the ink the pager had inline. */
3007
- [data-terp="dataview-pager"] > [data-terp="iconbutton"]:disabled {
4020
+ [data-terp="dataview-pager"] > [data-terp="iconbutton"]:disabled,
4021
+ [data-terp="dataview-pager"] > [data-terp="iconbutton"][aria-disabled="true"] {
3008
4022
  color: var(--color-neutral-300);
3009
4023
  }
3010
4024
 
@@ -3034,7 +4048,7 @@ button[data-terp="input"][data-placeholder="true"] {
3034
4048
  table-layout: fixed;
3035
4049
  }
3036
4050
  [data-terp="dataview-table"] tbody tr {
3037
- transition: background-color 150ms ease;
4051
+ transition: background-color var(--motion-duration-fast) var(--motion-easing-standard);
3038
4052
  }
3039
4053
  [data-terp="dataview-table"] tbody tr:hover td {
3040
4054
  background: var(--color-neutral-50);
@@ -3061,8 +4075,13 @@ button[data-terp="input"][data-placeholder="true"] {
3061
4075
  is enough, and the card's tone rules lose to it on layer rather than on
3062
4076
  specificity. Keeping the escalation past this point is the quiet failure: nothing
3063
4077
  would render differently, the declaration would simply become unthemeable. */
4078
+ /* Both halves carry the data-clickable guard, and the card half did not. A card list stamps
4079
+ data-clickable only when onRowClick is set, but renders the selection checkbox on
4080
+ selectionEnabled alone — so in a selectable-but-not-clickable list, focusing a checkbox
4081
+ washed the whole card in brand-soft and buried its data-tone. Focus is not selection, and
4082
+ a card that does nothing when clicked has no "activate me" state to advertise. */
3064
4083
  [data-terp="dataview-row"][data-clickable="true"]:focus-within td,
3065
- [data-terp="dataview-card"]:focus-within {
4084
+ [data-terp="dataview-card"][data-clickable="true"]:focus-within {
3066
4085
  background: var(--color-brand-primary-soft);
3067
4086
  }
3068
4087