@terpjs/react-core 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (106) hide show
  1. package/README.md +62 -22
  2. package/package.json +6 -5
  3. package/src/AppShell.test.tsx +314 -0
  4. package/src/AppShell.tsx +384 -63
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  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.tsx +35 -75
  11. package/src/ModuleNav.test.tsx +26 -0
  12. package/src/ModuleNav.tsx +45 -38
  13. package/src/Page.test.tsx +9 -6
  14. package/src/Page.tsx +37 -39
  15. package/src/ProfileView.test.tsx +15 -0
  16. package/src/ProfileView.tsx +9 -36
  17. package/src/ResourceList.tsx +13 -24
  18. package/src/SettingsPage.tsx +50 -0
  19. package/src/SplitPage.tsx +150 -0
  20. package/src/UserMenu.test.tsx +28 -5
  21. package/src/UserMenu.tsx +15 -9
  22. package/src/admin/AuditLogAdmin.tsx +21 -16
  23. package/src/admin/GroupCreate.tsx +18 -4
  24. package/src/admin/GroupDetail.tsx +50 -15
  25. package/src/admin/GroupsAdmin.tsx +13 -5
  26. package/src/admin/UserCreate.tsx +41 -12
  27. package/src/admin/UserDetail.tsx +4 -1
  28. package/src/admin/UsersAdmin.tsx +14 -6
  29. package/src/admin/admin.test.tsx +238 -3
  30. package/src/admin/fieldErrors.ts +45 -0
  31. package/src/bootstrap.test.tsx +208 -0
  32. package/src/bootstrap.tsx +121 -5
  33. package/src/breakpoints.ts +41 -0
  34. package/src/dataview/DataView.tsx +12 -5
  35. package/src/dataview/DataViewCardList.tsx +8 -7
  36. package/src/dataview/DataViewPagination.tsx +15 -8
  37. package/src/dataview/DataViewTable.tsx +32 -21
  38. package/src/dataview/README.md +13 -2
  39. package/src/dataview/index.ts +1 -0
  40. package/src/dataview/internal.tsx +31 -1
  41. package/src/dataview/types.ts +26 -3
  42. package/src/download.test.tsx +153 -0
  43. package/src/download.tsx +132 -0
  44. package/src/files.tsx +2 -11
  45. package/src/format.test.tsx +213 -0
  46. package/src/format.ts +150 -0
  47. package/src/icons.tsx +67 -5
  48. package/src/index.ts +63 -7
  49. package/src/layout.manifest.json +118 -0
  50. package/src/layout.manifest.test.ts +205 -0
  51. package/src/layout.test.tsx +198 -1
  52. package/src/layout.tsx +208 -11
  53. package/src/layoutContract.test.tsx +311 -2
  54. package/src/layoutContract.ts +44 -3
  55. package/src/layoutDeclaration.test.ts +435 -0
  56. package/src/layoutDeclaration.ts +531 -0
  57. package/src/locale.tsx +3 -0
  58. package/src/markers.test.ts +141 -15
  59. package/src/nav.test.ts +234 -4
  60. package/src/nav.ts +180 -6
  61. package/src/navActive.test.ts +115 -0
  62. package/src/navActive.ts +119 -0
  63. package/src/navLink.tsx +20 -2
  64. package/src/previewBridge.test.ts +327 -0
  65. package/src/previewBridge.ts +278 -0
  66. package/src/raw.d.ts +14 -2
  67. package/src/review.test.tsx +272 -0
  68. package/src/routeSearch.ts +73 -0
  69. package/src/routeTypes.ts +50 -6
  70. package/src/router.test.tsx +766 -3
  71. package/src/router.tsx +277 -28
  72. package/src/sso.test.tsx +6 -3
  73. package/src/styles.test.ts +518 -27
  74. package/src/styles.ts +1287 -66
  75. package/src/theme.test.tsx +29 -0
  76. package/src/theme.themes.test.ts +13 -7
  77. package/src/theme.tsx +30 -33
  78. package/src/themes.ts +54 -0
  79. package/src/toast.tsx +2 -1
  80. package/src/tokens.guard.test.ts +192 -0
  81. package/src/typography.test.tsx +213 -0
  82. package/src/typography.tsx +255 -0
  83. package/src/ui/Avatar.test.tsx +63 -0
  84. package/src/ui/Avatar.tsx +65 -0
  85. package/src/ui/Button.test.tsx +71 -3
  86. package/src/ui/Button.tsx +57 -4
  87. package/src/ui/Card.test.tsx +13 -0
  88. package/src/ui/Card.tsx +28 -1
  89. package/src/ui/Checkbox.tsx +10 -2
  90. package/src/ui/Combobox.test.tsx +49 -0
  91. package/src/ui/Combobox.tsx +8 -2
  92. package/src/ui/DatePicker.tsx +28 -5
  93. package/src/ui/Input.test.tsx +123 -0
  94. package/src/ui/Input.tsx +65 -2
  95. package/src/ui/Menu.tsx +16 -5
  96. package/src/ui/Popover.tsx +13 -0
  97. package/src/ui/Radio.tsx +10 -5
  98. package/src/ui/Select.test.tsx +232 -0
  99. package/src/ui/Select.tsx +177 -8
  100. package/src/ui/Switch.tsx +10 -2
  101. package/src/ui/Tabs.tsx +16 -6
  102. package/src/ui/Tooltip.test.tsx +56 -1
  103. package/src/ui/Tooltip.tsx +69 -6
  104. package/src/uiText.tsx +9 -0
  105. package/src/unwrap.test.ts +132 -0
  106. package/src/unwrap.ts +118 -32
package/src/styles.ts CHANGED
@@ -13,12 +13,18 @@
13
13
  * anything. A new escalation is therefore a claim that some element still styles
14
14
  * itself inline on the same property — state it, with the file, or do not add it.
15
15
  *
16
- * The migration itself is not finished: five modules still declare module-scope
17
- * base style objects (23 between them, gated in markers.test.ts). They are just no
18
- * longer in anyone's way of what they render, only `module-nav` and
19
- * `resource-list` carry a marker, and no rule in `terp.state` targets either, so
20
- * retiring the last escalations left nothing inert. Checked by scanning the layer
21
- * against those files rather than by spot-checking a hover.
16
+ * THE LEDGER IS EMPTY. No module declares a module-scope base style object any more,
17
+ * and the unmarked-surface worklist is empty with it — both gated in markers.test.ts,
18
+ * both kept rather than deleted, for the reason the escalation ledger below is kept:
19
+ * an empty gate is where the next entry has to justify itself.
20
+ *
21
+ * And the measure is now the whole surface rather than the annotated part of it. The
22
+ * ledger counts module-scope `CSSProperties` declarations, which a call-site literal and
23
+ * an unannotated style object both slip past — that is how the built-in admin views kept
24
+ * five base styles through the entire migration with both ratchets reading clean. A third
25
+ * gate counts inline style SITES per file, so the only way out of it is to render none;
26
+ * the nine that remain are ADR 0094 §3's permanent inline side and nothing else, named
27
+ * one by one in markers.test.ts.
22
28
  *
23
29
  * A migrated component gets its base here and renders no `style={}` for it —
24
30
  * though it may still pass an inline value the sheet has no business owning, which
@@ -68,11 +74,37 @@
68
74
  * is what lets an app override any framework rule without `!important` — the
69
75
  * restyling this phase exists to enable.
70
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
+ *
71
101
  * The injector is idempotent, SSR-safe (guarded on `document`), and appends
72
102
  * the rules through `textContent` — never `innerHTML` — so no HTML sink is
73
103
  * touched.
74
104
  */
75
105
 
106
+ import { WIDE_VIEWPORT_QUERY } from "./breakpoints";
107
+
76
108
  /** The `<style>` element id used to detect a prior injection. */
77
109
  export const TERP_STYLES_ID = "terp-core-styles";
78
110
 
@@ -113,6 +145,37 @@ export const TERP_STYLES_CSS = `
113
145
  depend on that at all: a custom property declared on an ancestor is inherited
114
146
  rather than cascaded against, so a DataView stamping the attribute on itself
115
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"],
116
179
  [data-density="compact"] {
117
180
  --density-control-min-height: var(--density-compact-control-min-height);
118
181
  --density-cell-pad-y: var(--density-compact-cell-pad-y);
@@ -152,9 +215,26 @@ body {
152
215
  they match every scrollable element globally). Paired with the color-scheme
153
216
  declaration on the token roots so any native chrome we do not restyle here
154
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. */
155
234
  html {
156
235
  scrollbar-width: thin;
157
236
  scrollbar-color: var(--color-neutral-300) transparent;
237
+ scrollbar-gutter: stable;
158
238
  }
159
239
  ::-webkit-scrollbar {
160
240
  width: 10px;
@@ -198,8 +278,12 @@ html {
198
278
  font-size: var(--font-size-sm);
199
279
  font-weight: var(--font-weight-medium);
200
280
  line-height: 1.2;
201
- transition: background-color 150ms ease, color 150ms ease,
202
- 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);
203
287
  }
204
288
  [data-terp="button"][data-variant="primary"] {
205
289
  background: var(--color-brand-primary);
@@ -219,6 +303,33 @@ html {
219
303
  background: transparent;
220
304
  color: var(--color-neutral-700);
221
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
+ }
222
333
  [data-terp="button-icon"] {
223
334
  display: inline-flex;
224
335
  align-items: center;
@@ -331,7 +442,9 @@ html {
331
442
  font-size: var(--font-size-sm);
332
443
  font-weight: var(--font-weight-normal);
333
444
  line-height: 1.25;
334
- 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);
335
448
  }
336
449
  input[data-terp="input"] {
337
450
  min-height: var(--density-control-min-height);
@@ -368,6 +481,14 @@ textarea[data-terp="input"] {
368
481
  flex-direction: column;
369
482
  gap: var(--space-2);
370
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;
371
492
  }
372
493
  [data-terp="stack"][data-direction="row"] { flex-direction: row; }
373
494
  [data-terp="stack"][data-gap="0"] { gap: var(--space-0); }
@@ -378,6 +499,116 @@ textarea[data-terp="input"] {
378
499
  [data-terp="stack"][data-gap="6"] { gap: var(--space-6); }
379
500
  [data-terp="stack"][data-gap="8"] { gap: var(--space-8); }
380
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); }
381
612
 
382
613
  /* Detail lists ------------------------------------------------------------- */
383
614
  /* The term and value are inline boxes inside a block row, which is what makes
@@ -386,14 +617,66 @@ textarea[data-terp="input"] {
386
617
  margin: 0;
387
618
  display: grid;
388
619
  gap: var(--space-1);
620
+ grid-template-columns: minmax(0, 1fr);
621
+ }
622
+ [data-terp="detail-list-row"] {
623
+ min-width: 0;
389
624
  }
390
625
  [data-terp="detail-list-term"] {
391
626
  display: inline;
392
627
  font-weight: var(--font-weight-medium);
393
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
+ }
394
636
  [data-terp="detail-list-value"] {
395
637
  display: inline;
396
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);
397
680
  }
398
681
 
399
682
  /* Checkboxes / radios / switches ------------------------------------------- */
@@ -422,7 +705,7 @@ textarea[data-terp="input"] {
422
705
  block-size: 1.25rem;
423
706
  accent-color: var(--color-fg-accent);
424
707
  cursor: pointer;
425
- transition: background-color 150ms ease;
708
+ transition: background-color var(--motion-duration-fast) var(--motion-easing-standard);
426
709
  }
427
710
  [data-terp="radio-group"] {
428
711
  display: grid;
@@ -471,6 +754,141 @@ textarea[data-terp="input"] {
471
754
  [data-terp="card-heading"] {
472
755
  min-width: 0;
473
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
+
474
892
  [data-terp="card-actions"] {
475
893
  flex-shrink: 0;
476
894
  }
@@ -513,12 +931,44 @@ textarea[data-terp="input"] {
513
931
  margin-block-end: -1px;
514
932
  border-top-left-radius: var(--radius-sm);
515
933
  border-top-right-radius: var(--radius-sm);
516
- 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);
517
938
  }
518
939
  [data-terp="tab-panel"] {
519
940
  color: var(--color-neutral-900);
520
941
  }
521
942
 
943
+ /* Module navigation -------------------------------------------------------- */
944
+ /* Secondary tabs for a module's sub-pages, and very nearly the same object as tab
945
+ above: a transparent 2px edge the active item colours, muted ink the active item
946
+ darkens. The difference is that these are router links rather than buttons, and
947
+ that difference decides where the active state is keyed — see terp.state.
948
+
949
+ The edges are logical (border-block-end) to match tab and appshell-footer rather
950
+ than the physical borderBottom the component declared. Identical in every writing
951
+ mode this framework ships, and the sheet already had one convention. */
952
+ [data-terp="module-nav"] {
953
+ border-block-end: 1px solid var(--color-neutral-200);
954
+ }
955
+ [data-terp="module-nav-list"] {
956
+ list-style: none;
957
+ margin: 0;
958
+ padding: 0;
959
+ display: flex;
960
+ flex-wrap: wrap;
961
+ gap: var(--space-3);
962
+ }
963
+ [data-terp="module-nav-link"] {
964
+ display: inline-flex;
965
+ align-items: center;
966
+ padding: var(--space-2) 0;
967
+ color: var(--color-neutral-600);
968
+ text-decoration: none;
969
+ border-block-end: 2px solid transparent;
970
+ }
971
+
522
972
  /* Breadcrumbs -------------------------------------------------------------- */
523
973
  /* The trail owns its whole subtree, so the list and its items are addressed
524
974
  structurally and the current crumb by the aria-current it already carries —
@@ -549,7 +999,7 @@ textarea[data-terp="input"] {
549
999
  [data-terp="breadcrumbs"] a {
550
1000
  color: var(--color-neutral-600);
551
1001
  text-decoration: none;
552
- transition: color 150ms ease;
1002
+ transition: color var(--motion-duration-fast) var(--motion-easing-standard);
553
1003
  }
554
1004
  [data-terp="breadcrumbs-separator"] {
555
1005
  display: inline-flex;
@@ -572,7 +1022,29 @@ textarea[data-terp="input"] {
572
1022
  shell's hover rule has keyed on it since before this migration.
573
1023
 
574
1024
  That is why nothing here needs a style object handed across a public boundary, which
575
- 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. */
576
1048
  [data-terp="appshell"] {
577
1049
  display: flex;
578
1050
  align-items: stretch;
@@ -597,13 +1069,13 @@ textarea[data-terp="input"] {
597
1069
  top: 0;
598
1070
  height: 100vh;
599
1071
  overflow-x: hidden;
600
- width: 15rem;
601
- background: var(--color-neutral-0);
602
- border-inline-end: 1px solid var(--color-neutral-200);
603
- 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);
604
1076
  }
605
1077
  [data-terp="appshell-sidebar"][data-collapsed="true"] {
606
- width: 4rem;
1078
+ width: var(--shell-sidebar-width-collapsed);
607
1079
  }
608
1080
  /* The mobile drawer, reached from the shell root's variant rather than from an attribute
609
1081
  of its own — the viewport is one fact and the root owns it. 100dvh rather than 100vh so
@@ -636,16 +1108,51 @@ textarea[data-terp="input"] {
636
1108
  gap: var(--space-2);
637
1109
  padding: var(--space-1) var(--space-2);
638
1110
  min-height: 2.25rem;
639
- color: var(--color-neutral-900);
1111
+ color: var(--color-sidebar-fg);
640
1112
  text-decoration: none;
641
1113
  border-radius: var(--radius-md);
642
1114
  box-sizing: border-box;
643
- transition: background-color 150ms ease;
1115
+ transition: background-color var(--motion-duration-fast) var(--motion-easing-standard);
644
1116
  }
645
1117
  [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-brand"] {
646
1118
  justify-content: center;
647
1119
  padding-inline: 0;
648
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
+ }
649
1156
  [data-terp="appshell-brand-row"] {
650
1157
  display: flex;
651
1158
  align-items: center;
@@ -661,7 +1168,7 @@ textarea[data-terp="input"] {
661
1168
  white-space: nowrap;
662
1169
  font-size: var(--font-size-base);
663
1170
  font-weight: var(--font-weight-semibold);
664
- color: var(--color-neutral-900);
1171
+ color: var(--color-sidebar-fg);
665
1172
  letter-spacing: 0;
666
1173
  }
667
1174
  [data-terp="appshell-nav"] {
@@ -676,6 +1183,47 @@ textarea[data-terp="input"] {
676
1183
  display: grid;
677
1184
  gap: var(--space-1);
678
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
+ }
679
1227
  /* Sidebar navigation links, and this is the rule the whole shell migration was for. The
680
1228
  geometry used to be NAV_LINK_STYLE, a CSSProperties object exported from AppShell for
681
1229
  every router's link renderer to spread — so an app could not restyle a nav link at all
@@ -692,7 +1240,7 @@ textarea[data-terp="input"] {
692
1240
  gap: var(--space-2);
693
1241
  padding: var(--space-2) var(--space-3);
694
1242
  border-radius: var(--radius-md);
695
- color: var(--color-neutral-700);
1243
+ color: var(--color-sidebar-muted);
696
1244
  font-size: var(--font-size-sm);
697
1245
  font-weight: var(--font-weight-medium);
698
1246
  text-decoration: none;
@@ -700,7 +1248,9 @@ textarea[data-terp="input"] {
700
1248
  overflow: hidden;
701
1249
  box-sizing: border-box;
702
1250
  min-height: 2.25rem;
703
- 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);
704
1254
  }
705
1255
  /* The collapsed rail's link geometry: one centred fixed-size icon in the content track.
706
1256
  (0,3,1) against the base's (0,1,1), so it wins on specificity with no source-order
@@ -716,16 +1266,20 @@ textarea[data-terp="input"] {
716
1266
  text-overflow: ellipsis;
717
1267
  white-space: nowrap;
718
1268
  }
719
- /* 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
720
1272
  must stay focusable and so cannot be display: none. The other two are the brand title
721
1273
  and the nav labels in the icon rail, which were a style-object TERNARY before this —
722
1274
  the component picked between two objects per render, and the collapsed branch was
723
1275
  painted by nothing, because the rail state was internal and no specimen could reach it.
724
1276
  That is what defaultCollapsed is for. */
1277
+ [data-terp="appshell-skip-link"],
725
1278
  [data-terp="drawer-focus-start"],
726
1279
  [data-terp="drawer-focus-end"],
727
1280
  [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-brand-title"],
728
- [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"] {
729
1283
  position: absolute;
730
1284
  width: 1px;
731
1285
  height: 1px;
@@ -752,7 +1306,7 @@ textarea[data-terp="input"] {
752
1306
  justify-content: space-between;
753
1307
  gap: var(--space-3);
754
1308
  padding: var(--space-2) var(--space-4);
755
- min-height: 3rem;
1309
+ min-height: var(--shell-header-height);
756
1310
  box-sizing: border-box;
757
1311
  background: var(--color-neutral-0);
758
1312
  border-block-end: 1px solid var(--color-neutral-200);
@@ -791,6 +1345,19 @@ textarea[data-terp="input"] {
791
1345
  font-weight: var(--font-weight-normal);
792
1346
  line-height: 1.25;
793
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
+ }
794
1361
  [data-terp="appshell-main"] {
795
1362
  flex-grow: 1;
796
1363
  padding: var(--space-6);
@@ -805,6 +1372,402 @@ textarea[data-terp="input"] {
805
1372
  color: var(--color-fg-subtle);
806
1373
  font-size: var(--font-size-xs);
807
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
+ }
1441
+
1442
+ /* The page frame ----------------------------------------------------------- */
1443
+ /* Every routed view is this shape: one header carrying the breadcrumb trail (when
1444
+ there is a path back up) and the title row, then the body. HubPage, OverviewPage
1445
+ and DetailPage are all this frame with a different trail.
1446
+
1447
+ The header is a <header> ELEMENT and Page has to keep it one, which is a
1448
+ constraint this sheet cannot express and the component records at the site: the
1449
+ layout contract's runtime slot check reads article.children and drops the header
1450
+ by TAG NAME, so re-rendering it as a marked <div> would put it back into the body
1451
+ set and fail every governed OverviewPage and DetailPage closed. Marking it is
1452
+ additive; retagging it is not. The body likewise takes no wrapper — not even a
1453
+ display: contents one, since that check is a DOM traversal and would see the node
1454
+ whether or not it generates a box.
1455
+
1456
+ align-content: start is what keeps the rows at the top of a page taller than its
1457
+ content — the loading and error frames, where the body is one small block. With
1458
+ the default the two rows would spread to fill the height. It needs something to
1459
+ stretch the article before it is observable at all, which is why page-loading and
1460
+ page-error render inside a grid box rather than a plain tall div. */
1461
+ [data-terp="page"] {
1462
+ display: grid;
1463
+ grid-template-columns: minmax(0, 1fr);
1464
+ gap: var(--space-4);
1465
+ align-content: start;
1466
+ min-width: 0;
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
+ }
1482
+ [data-terp="page-header"] {
1483
+ display: grid;
1484
+ gap: var(--space-2);
1485
+ }
1486
+ /* The crumb row keeps a 2rem floor, and it is doing work rather than reserving
1487
+ space for its own sake: the trail is shorter than 2rem at font-size-sm, so
1488
+ dropping the floor closes the gap under the trail on every page that has one.
1489
+ Measured — removing it moves all six baselines with a trail and nothing else. */
1490
+ [data-terp="page-breadcrumbs"] {
1491
+ display: flex;
1492
+ align-items: center;
1493
+ min-height: 2rem;
1494
+ }
1495
+ /* Title left, the actions slot right, wrapping rather than overflowing when a long
1496
+ title meets a wide action cluster. */
1497
+ [data-terp="page-heading"] {
1498
+ display: flex;
1499
+ align-items: center;
1500
+ justify-content: space-between;
1501
+ gap: var(--space-3);
1502
+ flex-wrap: wrap;
1503
+ }
1504
+ /* The single h1 of the view. margin: 0 is load-bearing — the browser default h1
1505
+ margin would otherwise fight the header's own gap. */
1506
+ [data-terp="page-title"] {
1507
+ margin: 0;
1508
+ font-size: var(--font-size-lg);
1509
+ font-weight: var(--font-weight-semibold);
1510
+ letter-spacing: 0;
1511
+ color: var(--color-neutral-900);
1512
+ line-height: 1.3;
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
+ }
1602
+
1603
+ /* The sign-in screen ------------------------------------------------------- */
1604
+ /* The one screen an unauthenticated user sees, and the only full-viewport page in
1605
+ the package: a 100vh grid centring one card. The reset layer's box-sizing note
1606
+ already names this page as the reason it exists — content-box plus 100vh plus
1607
+ padding overflows the viewport by exactly the padding, which is a phantom
1608
+ scrollbar on a page that fits.
1609
+
1610
+ The buttons fill their group, and that is a rule on the GROUP rather than a prop
1611
+ on Button, because it has exactly one consumer in the package. Button declares
1612
+ width: fit-content, which is a definite width — so grid stretch does NOT do this
1613
+ for free, and dropping the declaration shrinks all four buttons to their labels.
1614
+ Two selectors at (0,2,0) against the button's own (0,1,0), same layer, so
1615
+ specificity settles it and source order never enters into it. A block prop on
1616
+ Button would be a new public API minted for one internal caller; the sheet can
1617
+ already reach the thing, which is the test stage 4 set when it deleted Menu's
1618
+ style props.
1619
+
1620
+ The separator's ink moves from --color-neutral-500 to --color-fg-subtle, which is
1621
+ the rest of the migration ec36a2b started rather than a new decision: the two
1622
+ tokens are byte-identical in light and dark, and only fg-subtle has a declared
1623
+ pairing (subtle-on-surface) for the gate to measure. So the screenshot themes do
1624
+ not move and midnight and twilight get the value the gate has been measuring all
1625
+ along. The "or" is aria-hidden but it is visible text, so it is held to AA rather
1626
+ than treated as an ornament.
1627
+
1628
+ The error line has no specimen and cannot have one: the error is internal state set
1629
+ only in a catch, and sso.error needs a real failed callback, which needs a URL the
1630
+ lane owns. Its ink is gated statically instead — danger-on-card, declared for this
1631
+ surface and measured in all five themes. */
1632
+ [data-terp="login-view"] {
1633
+ min-height: 100vh;
1634
+ display: grid;
1635
+ place-items: center;
1636
+ padding: var(--space-6);
1637
+ background: var(--color-neutral-50);
1638
+ font-family: var(--font-family-sans);
1639
+ color: var(--color-neutral-900);
1640
+ }
1641
+ [data-terp="login-card"] {
1642
+ width: 100%;
1643
+ max-width: 24rem;
1644
+ display: grid;
1645
+ gap: var(--space-4);
1646
+ padding: var(--space-6);
1647
+ background: var(--color-neutral-0);
1648
+ border: 1px solid var(--color-neutral-200);
1649
+ border-radius: var(--radius-lg);
1650
+ box-shadow: var(--shadow-md);
1651
+ }
1652
+ [data-terp="login-brand"] {
1653
+ display: flex;
1654
+ align-items: center;
1655
+ gap: var(--space-2);
1656
+ color: var(--color-neutral-900);
1657
+ }
1658
+ [data-terp="login-title"] {
1659
+ margin: 0;
1660
+ font-size: var(--font-size-xl);
1661
+ font-weight: var(--font-weight-bold);
1662
+ letter-spacing: 0;
1663
+ }
1664
+ [data-terp="login-form"],
1665
+ [data-terp="login-sso"] {
1666
+ display: grid;
1667
+ gap: var(--space-3);
1668
+ }
1669
+ [data-terp="login-separator"] {
1670
+ display: flex;
1671
+ align-items: center;
1672
+ gap: var(--space-2);
1673
+ color: var(--color-fg-subtle);
1674
+ font-size: var(--font-size-xs);
1675
+ text-transform: uppercase;
1676
+ letter-spacing: 0.06em;
1677
+ }
1678
+ [data-terp="login-separator-rule"] {
1679
+ flex: 1;
1680
+ border-block-start: 1px solid var(--color-neutral-200);
1681
+ }
1682
+ [data-terp="login-error"] {
1683
+ margin: 0;
1684
+ color: var(--color-status-danger);
1685
+ font-size: var(--font-size-sm);
1686
+ }
1687
+
1688
+ /* The profile screen ------------------------------------------------------- */
1689
+ /* The built-in /profile view: two cards, an avatar tile, and the identity lines.
1690
+ Its cards are its own rather than the Card component's — the same declarations on
1691
+ a different element — and folding the two together is a component decision, not a
1692
+ styling one, so it stays a follow-up rather than riding in on a migration whose
1693
+ whole contract is zero pixel movement.
1694
+
1695
+ overflow-wrap on the address is real and unobservable: the workbench session is a
1696
+ fixed user whose address is short, so no specimen can paint the case it exists for
1697
+ (an address has no spaces to break at, so a long one widens the card past its own
1698
+ max-width instead of wrapping). Asserted in the unit test as the marker it keys on;
1699
+ there is no picture of it and cannot be until the mock session is variable. */
1700
+ [data-terp="profile-card"] {
1701
+ display: grid;
1702
+ gap: var(--space-4);
1703
+ padding: var(--space-4);
1704
+ max-width: 32rem;
1705
+ background: var(--color-neutral-0);
1706
+ border: 1px solid var(--color-neutral-200);
1707
+ border-radius: var(--radius-lg);
1708
+ }
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"] {
1723
+ display: inline-flex;
1724
+ align-items: center;
1725
+ justify-content: center;
1726
+ width: 3.5rem;
1727
+ height: 3.5rem;
1728
+ flex-shrink: 0;
1729
+ border-radius: var(--radius-full);
1730
+ background: var(--color-brand-primary);
1731
+ color: var(--color-brand-primary-contrast);
1732
+ font-size: var(--font-size-lg);
1733
+ font-weight: var(--font-weight-medium);
1734
+ }
1735
+ [data-terp="avatar"][data-size="sm"] {
1736
+ width: 2rem;
1737
+ height: 2rem;
1738
+ font-size: var(--font-size-sm);
1739
+ }
1740
+ [data-terp="profile-email"] {
1741
+ overflow-wrap: anywhere;
1742
+ }
1743
+ [data-terp="profile-role"] {
1744
+ margin: 0;
1745
+ color: var(--color-neutral-600);
1746
+ }
1747
+
1748
+ /* The built-in admin screens ----------------------------------------------- */
1749
+ /* The packaged /admin views. Three surfaces, and they were invisible to both
1750
+ ratchets for the entire migration — which is why they are here rather than in
1751
+ 0.8.0. The worklist names files with NO marker at all, and every admin view
1752
+ rendered none, so it read as a view composition and was excluded on purpose. The
1753
+ ledger counts module-scope CSSProperties declarations, and these were four
1754
+ call-site literals plus one unannotated object. Neither gate was wrong; both were
1755
+ narrower than they looked, and the same commit widens the measure.
1756
+
1757
+ The form box is ONE marker across two files, because UserCreate and GroupCreate
1758
+ constrain their form to the same measure — the same surface twice, not two
1759
+ surfaces that happen to agree today. */
1760
+ [data-terp="admin-form"] {
1761
+ max-width: 32rem;
1762
+ }
1763
+ /* A section heading inside a detail screen: the members list, the permission
1764
+ grants. font-size-base rather than the UA default, which for an h2 is LARGER than
1765
+ the page's own h1 at font-size-lg — so without this a section outranks the view
1766
+ it sits in. */
1767
+ [data-terp="admin-section-title"] {
1768
+ margin: 0;
1769
+ font-size: var(--font-size-base);
1770
+ }
808
1771
 
809
1772
  /* Hub cards --------------------------------------------------------------- */
810
1773
  /* This whole family was in terp.state, resting declarations and all, for the same
@@ -872,7 +1835,9 @@ textarea[data-terp="input"] {
872
1835
  [data-terp="hubcard"] {
873
1836
  height: 100%;
874
1837
  min-height: 0;
875
- 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);
876
1841
  }
877
1842
  [data-terp="hubcard-body"] {
878
1843
  display: grid;
@@ -886,7 +1851,7 @@ textarea[data-terp="input"] {
886
1851
  background: var(--color-neutral-0);
887
1852
  color: var(--color-neutral-900);
888
1853
  box-sizing: border-box;
889
- transition: border-color 150ms ease;
1854
+ transition: border-color var(--motion-duration-fast) var(--motion-easing-standard);
890
1855
  }
891
1856
  /* -heading, not -title: in this sheet a heading is the BOX holding a title
892
1857
  (card-heading, dataview-card-heading) and a title is the text box itself
@@ -916,7 +1881,7 @@ textarea[data-terp="input"] {
916
1881
  color: var(--color-neutral-900);
917
1882
  font-size: var(--font-size-base);
918
1883
  font-weight: var(--font-weight-semibold);
919
- transition: color 150ms ease;
1884
+ transition: color var(--motion-duration-fast) var(--motion-easing-standard);
920
1885
  }
921
1886
  /* neutral-600 rather than fg-muted, and it is not the tinted-surface case: this text
922
1887
  sits on the card's own neutral-0 and measures 7.58 / 7.94 / 7.50 / 7.60 / 18.42. */
@@ -1154,6 +2119,46 @@ textarea[data-terp="input"] {
1154
2119
  cursor: pointer;
1155
2120
  color: var(--color-fg-subtle);
1156
2121
  }
2122
+ /* The password field's reveal toggle, built on the search box above rather than beside
2123
+ it: same positioning context, same absolutely-placed control, same specificity
2124
+ argument. Only type="password" wraps, so every other input is still a bare element
2125
+ and the two child selectors in this sheet that reach for data-terp="input" — the
2126
+ toolbar search and the resource-list create field — can never meet a wrapper. */
2127
+ [data-terp="input-password"] {
2128
+ position: relative;
2129
+ display: inline-flex;
2130
+ align-items: center;
2131
+ }
2132
+ /* Room for the toggle. It must out-rank input[data-terp="input"] { padding: 0
2133
+ var(--space-3) } and does so on SPECIFICITY — two attributes (0,2,0) against an
2134
+ attribute plus a type (0,1,1) — the same trap and the same escape the search field
2135
+ documents above. Asymmetric on purpose: the glyph sits at the end, and reserving room
2136
+ at both ends would indent the value for nothing. */
2137
+ [data-terp="input-password"] > [data-terp="input"] {
2138
+ padding-inline-end: var(--space-6);
2139
+ width: 100%;
2140
+ }
2141
+ /* The toggle itself, the seventeenth element wearing the iconbutton marker. It takes
2142
+ that marker rather than one of its own because it is one: the shared rule already
2143
+ carries its transition, its hover wash and its disabled treatment, and duplicating
2144
+ those under a new name to avoid editing one enumeration would be the wrong trade. */
2145
+ /* Edge ships its own reveal control inside every password field, so without this the user gets
2146
+ two: the native eye sitting on top of ours, in a box sized for one glyph. Same class of fix as
2147
+ the number stepper this sheet already suppresses -- an unthemeable browser affordance the
2148
+ framework replaces rather than competes with. */
2149
+ input[data-terp="input"][type="password"]::-ms-reveal {
2150
+ display: none;
2151
+ }
2152
+ [data-terp="input-password"] > [data-terp="iconbutton"] {
2153
+ position: absolute;
2154
+ inset-inline-end: var(--space-1);
2155
+ display: inline-flex;
2156
+ padding: var(--space-1);
2157
+ background: transparent;
2158
+ border: none;
2159
+ cursor: pointer;
2160
+ color: var(--color-fg-subtle);
2161
+ }
1157
2162
  /* "Refreshing…", and this is the one place the prefer-an-existing-DOM-attribute
1158
2163
  rule is REFUSED with its own reasoning. [data-terp="dataview-toolbar"]
1159
2164
  > [role="status"] looks textbook — the component does own this span's role — but
@@ -1222,6 +2227,28 @@ textarea[data-terp="input"] {
1222
2227
  white-space: nowrap;
1223
2228
  background: var(--color-neutral-0);
1224
2229
  }
2230
+ /* A column's declared track. A MINIMUM rather than a width, because a specified width is only a
2231
+ preference under table-layout: auto and the algorithm shrinks it to fit — which is why the pixel
2232
+ hint this replaces did nothing at all, measured in the workbench at three columns asking for
2233
+ 700px each and fitting the box exactly. What auto layout cannot take away is a minimum.
2234
+
2235
+ Three steps, and no more: these are the three bands the framework's own tables declare, and a
2236
+ step is additive to add and breaking to remove. In rem, so a declared track follows the root
2237
+ font size; the system columns below keep their pixels on purpose, being chrome rather than
2238
+ content, and converting them is a density pass with its own baselines.
2239
+
2240
+ Nothing here ever meets an inline width. A resized column stops emitting the attribute, so the
2241
+ user's own drag replaces the declared track instead of losing to it — the minimum would win the
2242
+ cascade, and a column springing back from a drag reads as a broken resizer. */
2243
+ [data-terp="dataview-table"] > thead > tr > th[data-width="xs"] {
2244
+ min-inline-size: 5rem;
2245
+ }
2246
+ [data-terp="dataview-table"] > thead > tr > th[data-width="sm"] {
2247
+ min-inline-size: 6.5rem;
2248
+ }
2249
+ [data-terp="dataview-table"] > thead > tr > th[data-width="md"] {
2250
+ min-inline-size: 9.5rem;
2251
+ }
1225
2252
  [data-terp="dataview-row"] > td {
1226
2253
  padding: var(--density-cell-pad-y) var(--density-cell-pad-x);
1227
2254
  border-bottom: 1px solid var(--color-neutral-100);
@@ -1613,6 +2640,58 @@ th[data-terp="dataview-actions-cell"] > span {
1613
2640
  color: var(--color-neutral-700);
1614
2641
  }
1615
2642
 
2643
+ /* Resource list ------------------------------------------------------------ */
2644
+ /* The plain listing screen: a write-gated create row, then the rows. The DataView is
2645
+ the same job at scale, and this one stays deliberately simple, so its rules are
2646
+ geometry plus two inks.
2647
+
2648
+ The create row keeps its flex layout and the input keeps flex: 1 as a RULE rather
2649
+ than becoming a 1fr auto grid, and that is a move rather than a decision. With
2650
+ flex: 1 the input's basis is 0 so it may shrink below its intrinsic width; a 1fr
2651
+ track floors at min-content instead. The two agree at this list's 40rem cap, so
2652
+ swapping them would have changed nothing anybody could see until some narrower
2653
+ container found the difference — which is the kind of diff this migration exists
2654
+ not to introduce.
2655
+
2656
+ Neither paragraph resets its margin, and that is verbatim rather than an
2657
+ oversight: both are <p> elements whose default margin is what separates them from
2658
+ the form above and the rows below. Adding margin: 0 here would move the rows. */
2659
+ [data-terp="resource-list"] {
2660
+ display: grid;
2661
+ gap: var(--space-4);
2662
+ max-width: 40rem;
2663
+ }
2664
+ [data-terp="resource-list-create"] {
2665
+ display: flex;
2666
+ gap: var(--space-2);
2667
+ }
2668
+ [data-terp="resource-list-create"] > [data-terp="input"] {
2669
+ flex: 1;
2670
+ }
2671
+ [data-terp="resource-list-error"] {
2672
+ color: var(--color-status-danger);
2673
+ }
2674
+ [data-terp="resource-list-empty"] {
2675
+ color: var(--color-neutral-600);
2676
+ }
2677
+ [data-terp="resource-list-items"] {
2678
+ list-style: none;
2679
+ margin: 0;
2680
+ padding: 0;
2681
+ display: grid;
2682
+ gap: var(--space-2);
2683
+ }
2684
+ [data-terp="resource-list-row"] {
2685
+ display: flex;
2686
+ align-items: center;
2687
+ justify-content: space-between;
2688
+ gap: var(--space-3);
2689
+ padding: var(--space-3);
2690
+ border: 1px solid var(--color-neutral-200);
2691
+ border-radius: var(--radius-md);
2692
+ background: var(--color-neutral-0);
2693
+ }
2694
+
1616
2695
  /* Empty / error / loading states ------------------------------------------- */
1617
2696
  /* Same centred block, opposite messages: empty is a dashed outline on the page
1618
2697
  surface because nothing is wrong, error is a filled danger wash because
@@ -1939,7 +3018,10 @@ button[data-terp="input"][data-placeholder="true"] {
1939
3018
  font-weight: var(--font-weight-medium);
1940
3019
  line-height: 1.4;
1941
3020
  box-shadow: var(--shadow-md);
1942
- pointer-events: none;
3021
+ /* No pointer-events: none. It was here, and it makes WCAG 1.4.13's Hoverable clause
3022
+ impossible by construction: a bubble the pointer cannot reach is a bubble nobody
3023
+ tracking with a pointer, or reading under magnification, can finish reading. The
3024
+ component keeps it open across the gap with a short close delay instead. */
1943
3025
  white-space: normal;
1944
3026
  }
1945
3027
 
@@ -1964,7 +3046,10 @@ button[data-terp="input"][data-placeholder="true"] {
1964
3046
  font-size: var(--font-size-sm);
1965
3047
  font-weight: var(--font-weight-normal);
1966
3048
  line-height: 1.25;
1967
- transition: background-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
3049
+ transition:
3050
+ background-color var(--motion-duration-fast) var(--motion-easing-standard),
3051
+ color var(--motion-duration-fast) var(--motion-easing-standard),
3052
+ box-shadow var(--motion-duration-fast) var(--motion-easing-standard);
1968
3053
  }
1969
3054
  /* The panel's contents. This sits INSIDE popover-panel, which supplies the
1970
3055
  surface — so the menu owns only the stacking of its items. */
@@ -1989,7 +3074,9 @@ button[data-terp="input"][data-placeholder="true"] {
1989
3074
  font-size: var(--font-size-sm);
1990
3075
  font-weight: var(--font-weight-normal);
1991
3076
  line-height: 1.25;
1992
- transition: background-color 150ms ease, color 150ms ease;
3077
+ transition:
3078
+ background-color var(--motion-duration-fast) var(--motion-easing-standard),
3079
+ color var(--motion-duration-fast) var(--motion-easing-standard);
1993
3080
  }
1994
3081
  /* Destructive is the one enumerable choice an item has, so it is an attribute.
1995
3082
  The disabled treatment is a state rule keyed on :disabled, because the element
@@ -2042,7 +3129,12 @@ button[data-terp="input"][data-placeholder="true"] {
2042
3129
  width: 100%;
2043
3130
  padding: var(--space-2);
2044
3131
  text-align: left;
2045
- color: var(--color-neutral-900);
3132
+ /* The sidebar family, not the neutral one. This row renders inside the sidebar (and inside the
3133
+ header group under navPlacement="header", which takes the sidebar surface), so its ink and
3134
+ that background are a pairing in play — and the contrast gate can only measure a pairing it
3135
+ can name. Provably zero-diff: --color-neutral-900 and --color-sidebar-fg are byte-equal in
3136
+ all five themes. */
3137
+ color: var(--color-sidebar-fg);
2046
3138
  border-color: transparent;
2047
3139
  min-height: 0;
2048
3140
  }
@@ -2052,19 +3144,6 @@ button[data-terp="input"][data-placeholder="true"] {
2052
3144
  gap: 0;
2053
3145
  padding: 0;
2054
3146
  }
2055
- [data-terp="user-menu-avatar"] {
2056
- display: inline-flex;
2057
- align-items: center;
2058
- justify-content: center;
2059
- width: 2rem;
2060
- height: 2rem;
2061
- flex-shrink: 0;
2062
- border-radius: var(--radius-full);
2063
- background: var(--color-brand-primary);
2064
- color: var(--color-brand-primary-contrast);
2065
- font-size: var(--font-size-sm);
2066
- font-weight: var(--font-weight-medium);
2067
- }
2068
3147
  [data-terp="user-menu-identity"] {
2069
3148
  display: grid;
2070
3149
  min-width: 0;
@@ -2075,9 +3154,17 @@ button[data-terp="input"][data-placeholder="true"] {
2075
3154
  text-overflow: ellipsis;
2076
3155
  white-space: nowrap;
2077
3156
  }
3157
+ /* Two surfaces, one marker. UserMenu renders this span in the trigger AND in the portalled
3158
+ panel, and only the first sits on the sidebar — the panel is in document.body, where the
3159
+ sidebar palette does not apply. So the sidebar copy is scoped (the portal puts the panel
3160
+ outside this selector by construction) and the panel keeps the neutral. The sheet already
3161
+ argues this exact split for the drawer close button. */
2078
3162
  [data-terp="user-menu-role"] {
2079
3163
  color: var(--color-neutral-600);
2080
3164
  }
3165
+ [data-terp="user-menu"] [data-terp="user-menu-role"] {
3166
+ color: var(--color-sidebar-muted);
3167
+ }
2081
3168
  /* The panel's identity block, and the panel's own geometry — both keyed on the
2082
3169
  owner, because the portal put them outside every selector that could otherwise
2083
3170
  reach them. */
@@ -2119,8 +3206,14 @@ button[data-terp="input"][data-placeholder="true"] {
2119
3206
  rules ([data-terp="markdown"] p, ... ul), and a declaration added here would
2120
3207
  simply do nothing with nothing to say so. And display: contents does not change
2121
3208
  selector matching, only box generation: a parent's > * child selector now matches
2122
- this wrapper rather than the blocks. Nothing in this sheet uses one, which is why
2123
- the wrapper is free today.
3209
+ this wrapper rather than the blocks.
3210
+
3211
+ That last sentence used to end "Nothing in this sheet uses one, which is why the
3212
+ wrapper is free today", and it stopped being true when the measured content width
3213
+ shipped: that rule is a child-star selector on the page's body children. It matched it,
3214
+ found no box to give a width to, and let prose run full-bleed in a measured shell.
3215
+ The reach-through beside that rule is the fix; this wrapper is free of everything
3216
+ else.
2124
3217
 
2125
3218
  Under SSR the sheet is not injected at all (the injector is document-guarded), so
2126
3219
  a server-rendered page has this element as a block box until hydration. That is
@@ -2253,12 +3346,15 @@ button[data-terp="input"][data-placeholder="true"] {
2253
3346
  margin-block-start: var(--space-2);
2254
3347
  }
2255
3348
 
2256
- /* Icon-only buttons. Sixteen elements wear this marker and not one declares a
3349
+ /* Icon-only buttons. Seventeen elements wear this marker and not one declares a
2257
3350
  transition inline, so this belongs in terp.base — it sat in terp.state only
2258
3351
  because that is where the hover rules needing it live. Same correction 817f572
2259
3352
  made for Tabs and Breadcrumbs. */
2260
3353
  [data-terp="iconbutton"] {
2261
- transition: background-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
3354
+ transition:
3355
+ background-color var(--motion-duration-fast) var(--motion-easing-standard),
3356
+ color var(--motion-duration-fast) var(--motion-easing-standard),
3357
+ box-shadow var(--motion-duration-fast) var(--motion-easing-standard);
2262
3358
  }
2263
3359
 
2264
3360
  /* Popover ------------------------------------------------------------------ */
@@ -2292,9 +3388,12 @@ button[data-terp="input"][data-placeholder="true"] {
2292
3388
 
2293
3389
  The stacking level is the token that was published for it. Every component in
2294
3390
  the package hardcoded its own number while a full --z-index-* family sat
2295
- unread. AppShell still writes 50/40/30 for drawer/backdrop/sticky and comes
2296
- right with its own migration; the toast viewport already reads
2297
- --z-index-toast. Tooltip's z-index:
3391
+ unread. That is finished: AppShell reads --z-index-drawer, --z-index-backdrop
3392
+ and --z-index-sticky, the skip link reads --z-index-skip-link, and the toast
3393
+ viewport reads --z-index-toast. (This paragraph said "AppShell still writes
3394
+ 50/40/30 ... and comes right with its own migration" for a release after that
3395
+ migration landed, which is the shape of stale comment worth naming: it read as
3396
+ a known gap rather than as a finished one.) Tooltip's z-index:
2298
3397
  1 above is deliberately NOT a token — the tooltip is absolutely positioned
2299
3398
  inside its own anchor, so 1 is a local lift within a stacking context rather
2300
3399
  than a place in the app-wide order. */
@@ -2391,12 +3490,24 @@ button[data-terp="input"][data-placeholder="true"] {
2391
3490
  opacity: 0.55;
2392
3491
  cursor: not-allowed;
2393
3492
  }
3493
+ /* A loading button IS disabled — the component sets both — so this has to sit here rather
3494
+ than in terp.base beside the other attribute-keyed button rules. In terp.base it would
3495
+ lose to the :disabled rule above on layer order and the cursor would silently stay
3496
+ not-allowed, which reads as "you may not" where the truth is "not yet". Declared after
3497
+ it so source order settles the tie the equal (0,2,0) specificity leaves.
3498
+
3499
+ Neither cursor is visible to any lane: Playwright's screenshots do not paint a pointer.
3500
+ The computed lane asserts both. */
3501
+ [data-terp="button"][data-loading="true"] {
3502
+ cursor: progress;
3503
+ }
2394
3504
 
2395
3505
  /* Icon-only buttons: the shell's two header toggles, four pagination arrows, the
2396
3506
  toast dismisser, the combobox's clear button, the calendar's two month arrows,
2397
- the DataView's expand toggle, the view-options panel's two reorder arrows, and
2398
- the DataView toolbar's clear-search button and two layout toggles.
2399
- SIXTEEN SITES sharing a transition and nothing else — no shared SURFACE, because
3507
+ the DataView's expand toggle, the view-options panel's two reorder arrows, the
3508
+ DataView toolbar's clear-search button and two layout toggles, and the password
3509
+ field's reveal toggle.
3510
+ SEVENTEEN SITES sharing a transition and nothing else — no shared SURFACE, because
2400
3511
  each is styled by where it sits. Sites rather than elements: the reorder arrows
2401
3512
  render twice per column row, so the element count is a function of how many
2402
3513
  columns a view has, while the list of places to check is fixed.
@@ -2440,7 +3551,7 @@ button[data-terp="input"][data-placeholder="true"] {
2440
3551
 
2441
3552
  The :disabled cursor no longer shouts, and re-deriving that is more useful than
2442
3553
  trusting it. The question is never "has anything migrated" but "can any element
2443
- this selector matches still beat it" — so: which of the eleven can carry the
3554
+ this selector matches still beat it" — so: which of the seventeen can carry the
2444
3555
  disabled attribute at all? The shell's toggles cannot (no disabled prop). The
2445
3556
  toast dismisser cannot. The combobox's clear button renders only while the
2446
3557
  field is enabled and takes no disabled of its own. The calendar's arrows page
@@ -2452,7 +3563,8 @@ button[data-terp="input"][data-placeholder="true"] {
2452
3563
  background: var(--color-neutral-100);
2453
3564
  color: var(--color-neutral-900);
2454
3565
  }
2455
- [data-terp="iconbutton"]:disabled {
3566
+ [data-terp="iconbutton"]:disabled,
3567
+ [data-terp="iconbutton"][aria-disabled="true"] {
2456
3568
  opacity: 0.4;
2457
3569
  cursor: not-allowed;
2458
3570
  }
@@ -2466,7 +3578,13 @@ button[data-terp="input"][data-placeholder="true"] {
2466
3578
  only the text controls had migrated left a disabled Combobox painted exactly
2467
3579
  like an enabled one and deleted the aria-invalid border outright. All six
2468
3580
  now take their base from this sheet, so layer order is enough. */
2469
- [data-terp="input"]:hover:not(:disabled):not(:focus) {
3581
+ /* The invalid exclusion is not tidying. This selector weighs (0,4,0) and the danger border below
3582
+ weighs (0,2,0), both unlayered against each other inside terp.state — so without the third
3583
+ :not() a pointer resting on a field that has just failed validation repaints its border from
3584
+ the danger token to a neutral grey, and the error state disappears for exactly as long as the
3585
+ user is pointing at the thing they need to fix. Narrowing the aggressor rather than adding a
3586
+ competing [aria-invalid="true"]:hover rule is this sheet's convention. */
3587
+ [data-terp="input"]:hover:not(:disabled):not(:focus):not([aria-invalid="true"]) {
2470
3588
  border-color: var(--color-neutral-400);
2471
3589
  }
2472
3590
  [data-terp="input"]:focus,
@@ -2540,8 +3658,8 @@ button[data-terp="input"][data-placeholder="true"] {
2540
3658
  The rail's scrollbar suppression moved from an attribute on the nav to the sidebar's,
2541
3659
  because collapsed is one fact and it now has one owner. */
2542
3660
  [data-terp="appshell-nav"] a:hover:not([aria-current="page"]) {
2543
- background: var(--color-neutral-100);
2544
- color: var(--color-neutral-900);
3661
+ background: var(--color-sidebar-accent);
3662
+ color: var(--color-sidebar-fg);
2545
3663
  }
2546
3664
  [data-terp="appshell-nav"] a[aria-current="page"] {
2547
3665
  background: var(--color-brand-primary-soft);
@@ -2552,12 +3670,80 @@ button[data-terp="input"][data-placeholder="true"] {
2552
3670
  overflow-x: hidden;
2553
3671
  scrollbar-width: none;
2554
3672
  }
3673
+ /* The rail's group separation. The label is visually hidden here (it joins the block above), so
3674
+ without this the groups are a single undifferentiated column of icons and the structure the
3675
+ expanded sidebar shows simply disappears at 4rem. A rule the LINE has to carry, because the
3676
+ label cannot: the divider is what is left of the label once the text is gone.
3677
+
3678
+ It replaces rather than adds to the expanded margin — same specificity family, one attribute
3679
+ more — so the rail does not pay 1rem per group in a column that is already scrolling. */
3680
+ [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-nav-group"] + [data-terp="appshell-nav-group"] {
3681
+ margin-block-start: var(--space-2);
3682
+ padding-block-start: var(--space-2);
3683
+ border-block-start: 1px solid var(--color-sidebar-border);
3684
+ }
2555
3685
  [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-nav"]::-webkit-scrollbar {
2556
3686
  width: 0;
2557
3687
  height: 0;
2558
3688
  }
2559
3689
  [data-terp="appshell-brand"]:hover {
2560
- background: var(--color-neutral-100);
3690
+ background: var(--color-sidebar-accent);
3691
+ }
3692
+ /* And its hover, for the same reason: the shared iconbutton hover wash is a neutral, which is
3693
+ the header toggle's context and not this one's. */
3694
+ [data-terp="appshell-brand-row"] > [data-terp="iconbutton"]:hover {
3695
+ background: var(--color-sidebar-accent);
3696
+ color: var(--color-sidebar-fg);
3697
+ }
3698
+ /* The skip link, visible only while focused.
3699
+ In terp.state, and that is not filing: the resting half is the shared visually-hidden block
3700
+ in terp.base, which sets position, a 1px box and clip, and un-hiding has to beat all of it.
3701
+ On specificity it would not — a selector list takes the specificity of the member that
3702
+ MATCHES, and for this element that member is [data-terp="appshell-skip-link"] at (0,1,0),
3703
+ the same weight as this rule. (An earlier version of this comment cited the list's (0,3,0)
3704
+ member, which is the collapsed-rail selector and never matches a skip link; that reading
3705
+ would have made the rules a source-order coin flip rather than a layer decision.) Layer
3706
+ order settles it with nothing to reason about.
3707
+ :focus-visible rather than :focus, matching the sheet's shared ring: a skip link reached by
3708
+ pointer is a link nobody asked to see.
3709
+ Above the sticky header (30) and its backdrop (40) so it is not painted under the chrome it
3710
+ sits over; below the drawer (50) because nothing should paint over an open modal. Stacking
3711
+ order does NOT keep it out of the drawer's focus trap and this comment used to say it did —
3712
+ z-index has no bearing on tab order. The link is simply not RENDERED while the drawer is
3713
+ open; see AppShell. */
3714
+ /* The skip link's target takes focus and must NOT paint the shared ring.
3715
+ The main element carries a data-terp marker and now a tabIndex of -1, which together put it
3716
+ in scope of the shared [data-terp]:focus-visible ring — so activating the skip link outlined
3717
+ the entire content column, header to footer, plus a 3px halo. Measured: it matches
3718
+ :focus-visible, with a 2px solid outline and rgba(37,99,235,0.35) 0 0 0 3px. The ring exists
3719
+ to say which CONTROL will take the next keystroke; a scroll target that was focused
3720
+ programmatically is not one, and the visible result of following a skip link should be the
3721
+ content rather than a box drawn around it.
3722
+ Scoped to this marker rather than to tabindex=-1 in general: other elements take -1 for other
3723
+ reasons and some of them are controls. */
3724
+ [data-terp="appshell-main"]:focus-visible {
3725
+ outline: none;
3726
+ box-shadow: none;
3727
+ }
3728
+ [data-terp="appshell-skip-link"]:focus-visible {
3729
+ position: fixed;
3730
+ top: var(--space-2);
3731
+ inset-inline-start: var(--space-2);
3732
+ z-index: var(--z-index-skip-link);
3733
+ width: auto;
3734
+ height: auto;
3735
+ margin: 0;
3736
+ padding: var(--space-2) var(--space-3);
3737
+ clip: auto;
3738
+ overflow: visible;
3739
+ background: var(--color-neutral-0);
3740
+ color: var(--color-fg-accent);
3741
+ border: var(--border-width-thin) solid var(--color-fg-accent);
3742
+ border-radius: var(--radius-md);
3743
+ font-family: var(--font-family-sans);
3744
+ font-size: var(--font-size-sm);
3745
+ font-weight: var(--font-weight-medium);
3746
+ text-decoration: none;
2561
3747
  }
2562
3748
 
2563
3749
  /* Tabs -------------------------------------------------------------------- */
@@ -2578,6 +3764,35 @@ button[data-terp="input"][data-placeholder="true"] {
2578
3764
  cursor: not-allowed;
2579
3765
  }
2580
3766
 
3767
+ /* Module navigation ------------------------------------------------------- */
3768
+ /* Which sub-page you are on, here for the same reason the selected tab is: the
3769
+ accent edge and the darker ink have to beat the resting pair.
3770
+
3771
+ Keyed on data-active, which ModuleNav writes, and deliberately NOT on the
3772
+ aria-current the same element also carries. That attribute has a second author:
3773
+ TanStack's link props spread the router's own active props LAST, after the
3774
+ caller's, so on a Link the router has the final word on aria-current. This is
3775
+ the breadcrumb lesson in its exact form — reuse a semantic only where the
3776
+ component is its sole author.
3777
+
3778
+ The two notions of "active" are not the same predicate, and they diverge in BOTH
3779
+ directions, which is worth knowing before touching either. activeOptions
3780
+ .includeSearch defaults to true, so the router additionally demands an exact
3781
+ query-string match that ModuleNav does not — the router is narrower there. And
3782
+ with exact matching the router compares through exactPathTest, which is
3783
+ removeTrailingSlash(a) === removeTrailingSlash(b), while ModuleNav compares
3784
+ pathname === item.to raw — so on a path with a trailing slash the ROUTER is
3785
+ active and ModuleNav is not, and this rule withholds the accent edge from a tab
3786
+ the router considers current. That second case is a real defect and it is older
3787
+ than this rule: the inline styling it replaced read the same isActive, so the
3788
+ behaviour is unchanged and only the reasoning was wrong. Fixing the predicate
3789
+ belongs with the navigation model, because that is what decides what "active"
3790
+ should mean. */
3791
+ [data-terp="module-nav-link"][data-active="true"] {
3792
+ color: var(--color-neutral-900);
3793
+ border-block-end-color: var(--color-fg-accent);
3794
+ }
3795
+
2581
3796
  /* Hub cards --------------------------------------------------------------- */
2582
3797
  /* The hover edge recolours hubcard-BODY, not the card.
2583
3798
 
@@ -2648,13 +3863,14 @@ button[data-terp="input"][data-placeholder="true"] {
2648
3863
  }
2649
3864
 
2650
3865
  /* The pager's disabled ink, scoped rather than added to the shared iconbutton
2651
- rule above: of the sixteen sites wearing that marker only six can be disabled at
3866
+ rule above: of the seventeen sites wearing that marker only six can be disabled at
2652
3867
  all — these four and the view-options panel's two reorder arrows, which carry
2653
3868
  their own scoped ink below for the same reason — and giving the shared rule a
2654
3869
  colour would change how a disabled calendar arrow looks the day one becomes
2655
3870
  disableable. The shared rule supplies the opacity and the cursor; this supplies
2656
3871
  the ink the pager had inline. */
2657
- [data-terp="dataview-pager"] > [data-terp="iconbutton"]:disabled {
3872
+ [data-terp="dataview-pager"] > [data-terp="iconbutton"]:disabled,
3873
+ [data-terp="dataview-pager"] > [data-terp="iconbutton"][aria-disabled="true"] {
2658
3874
  color: var(--color-neutral-300);
2659
3875
  }
2660
3876
 
@@ -2684,7 +3900,7 @@ button[data-terp="input"][data-placeholder="true"] {
2684
3900
  table-layout: fixed;
2685
3901
  }
2686
3902
  [data-terp="dataview-table"] tbody tr {
2687
- transition: background-color 150ms ease;
3903
+ transition: background-color var(--motion-duration-fast) var(--motion-easing-standard);
2688
3904
  }
2689
3905
  [data-terp="dataview-table"] tbody tr:hover td {
2690
3906
  background: var(--color-neutral-50);
@@ -2711,8 +3927,13 @@ button[data-terp="input"][data-placeholder="true"] {
2711
3927
  is enough, and the card's tone rules lose to it on layer rather than on
2712
3928
  specificity. Keeping the escalation past this point is the quiet failure: nothing
2713
3929
  would render differently, the declaration would simply become unthemeable. */
3930
+ /* Both halves carry the data-clickable guard, and the card half did not. A card list stamps
3931
+ data-clickable only when onRowClick is set, but renders the selection checkbox on
3932
+ selectionEnabled alone — so in a selectable-but-not-clickable list, focusing a checkbox
3933
+ washed the whole card in brand-soft and buried its data-tone. Focus is not selection, and
3934
+ a card that does nothing when clicked has no "activate me" state to advertise. */
2714
3935
  [data-terp="dataview-row"][data-clickable="true"]:focus-within td,
2715
- [data-terp="dataview-card"]:focus-within {
3936
+ [data-terp="dataview-card"][data-clickable="true"]:focus-within {
2716
3937
  background: var(--color-brand-primary-soft);
2717
3938
  }
2718
3939