@terpjs/react-core 0.7.0 → 0.9.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 (94) hide show
  1. package/README.md +6 -2
  2. package/package.json +2 -2
  3. package/src/AppShell.test.tsx +33 -12
  4. package/src/AppShell.tsx +69 -249
  5. package/src/Authorized.test.tsx +63 -1
  6. package/src/Authorized.tsx +35 -2
  7. package/src/Breadcrumbs.test.tsx +24 -0
  8. package/src/Breadcrumbs.tsx +9 -32
  9. package/src/ConfirmDialog.tsx +13 -44
  10. package/src/EmptyState.tsx +8 -36
  11. package/src/ErrorState.tsx +8 -36
  12. package/src/Field.test.tsx +57 -0
  13. package/src/Field.tsx +46 -22
  14. package/src/HubPage.test.tsx +22 -13
  15. package/src/HubPage.tsx +25 -97
  16. package/src/LoadingState.tsx +3 -24
  17. package/src/LoginView.tsx +22 -75
  18. package/src/ModuleNav.test.tsx +19 -0
  19. package/src/ModuleNav.tsx +10 -35
  20. package/src/Page.test.tsx +9 -6
  21. package/src/Page.tsx +15 -39
  22. package/src/PageActions.tsx +5 -10
  23. package/src/ProfileView.test.tsx +15 -0
  24. package/src/ProfileView.tsx +8 -33
  25. package/src/ResourceList.tsx +13 -24
  26. package/src/UserMenu.test.tsx +12 -5
  27. package/src/UserMenu.tsx +33 -62
  28. package/src/admin/AuditLogAdmin.tsx +1 -10
  29. package/src/admin/GroupCreate.tsx +1 -1
  30. package/src/admin/GroupDetail.tsx +2 -2
  31. package/src/admin/UserCreate.tsx +1 -1
  32. package/src/admin/admin.test.tsx +31 -0
  33. package/src/dataview/DataView.test.tsx +109 -5
  34. package/src/dataview/DataView.tsx +41 -23
  35. package/src/dataview/DataViewCardList.tsx +14 -60
  36. package/src/dataview/DataViewColumnSettings.tsx +46 -51
  37. package/src/dataview/DataViewExpandableRow.tsx +2 -17
  38. package/src/dataview/DataViewPagination.tsx +2 -32
  39. package/src/dataview/DataViewRowActions.tsx +13 -33
  40. package/src/dataview/DataViewTable.tsx +16 -103
  41. package/src/dataview/DataViewToolbar.tsx +53 -76
  42. package/src/dataview/README.md +6 -0
  43. package/src/dataview/index.ts +1 -0
  44. package/src/dataview/internal.tsx +4 -1
  45. package/src/dataview/types.ts +13 -0
  46. package/src/download.test.tsx +153 -0
  47. package/src/download.tsx +132 -0
  48. package/src/feedback.test.tsx +26 -0
  49. package/src/files.test.tsx +18 -0
  50. package/src/files.tsx +15 -15
  51. package/src/icons.test.tsx +10 -6
  52. package/src/icons.tsx +9 -37
  53. package/src/index.ts +7 -4
  54. package/src/layout.test.tsx +24 -9
  55. package/src/layout.tsx +24 -21
  56. package/src/layoutContract.test.tsx +95 -0
  57. package/src/locale.tsx +24 -4
  58. package/src/markers.test.ts +354 -25
  59. package/src/routeSearch.ts +73 -0
  60. package/src/routeTypes.ts +50 -6
  61. package/src/router.test.tsx +191 -1
  62. package/src/router.tsx +81 -18
  63. package/src/sso.test.tsx +6 -3
  64. package/src/ssr.test.tsx +1 -3
  65. package/src/styles.test.ts +855 -6
  66. package/src/styles.ts +3049 -153
  67. package/src/theme.tsx +24 -3
  68. package/src/toast.tsx +35 -71
  69. package/src/ui/Alert.test.tsx +12 -0
  70. package/src/ui/Alert.tsx +15 -43
  71. package/src/ui/Badge.test.tsx +14 -3
  72. package/src/ui/Badge.tsx +9 -28
  73. package/src/ui/Button.test.tsx +19 -4
  74. package/src/ui/Button.tsx +10 -63
  75. package/src/ui/Card.test.tsx +6 -2
  76. package/src/ui/Card.tsx +11 -39
  77. package/src/ui/Checkbox.tsx +2 -19
  78. package/src/ui/Combobox.test.tsx +22 -0
  79. package/src/ui/Combobox.tsx +31 -80
  80. package/src/ui/DatePicker.test.tsx +131 -4
  81. package/src/ui/DatePicker.tsx +158 -106
  82. package/src/ui/Input.tsx +6 -19
  83. package/src/ui/Markdown.test.tsx +26 -0
  84. package/src/ui/Markdown.tsx +28 -2
  85. package/src/ui/Menu.test.tsx +38 -4
  86. package/src/ui/Menu.tsx +50 -52
  87. package/src/ui/Popover.tsx +53 -19
  88. package/src/ui/Radio.tsx +5 -30
  89. package/src/ui/Select.tsx +7 -30
  90. package/src/ui/Switch.tsx +2 -20
  91. package/src/ui/Tabs.tsx +4 -28
  92. package/src/ui/Textarea.tsx +6 -17
  93. package/src/ui/Tooltip.tsx +9 -21
  94. package/src/ui/controlStyles.ts +0 -9
package/src/styles.ts CHANGED
@@ -1,111 +1,2706 @@
1
1
  /**
2
- * The one interaction-state stylesheet for react-core.
2
+ * The one stylesheet for react-core: component base styles, variants and
3
+ * interaction states, keyed by the `data-terp` / `data-variant` attributes the
4
+ * components stamp on their roots (ADR 0094).
3
5
  *
4
- * Components keep their inline token styles as the visible base (so tests can
5
- * still assert `element.style.background` etc.); this sheet layers hover,
6
- * active, focus-visible and small animation polish on top, keyed by
7
- * `data-terp` / `data-variant` attributes the components already set. That
8
- * keeps the boundary lint's inline-style rule intact (react-core itself is
9
- * allowed to inject a stylesheet — the rule targets *app modules*), while
10
- * giving every button, link, tab, row and menu real interaction states.
6
+ * Rules are attribute selectors, never class names, so the boundary lint's
7
+ * `style`/`className` prohibition on app modules is untouched — react-core
8
+ * itself is allowed to inject a stylesheet; the rule targets *app modules*.
11
9
  *
12
- * Because the base styles are inline (`style={}` wins the cascade over author
13
- * stylesheets, even for `:hover` / `:focus` / `:disabled` rules), every
14
- * interaction-state declaration that overrides an inline base property is
15
- * marked `!important`. The rules are state-scoped (hover / focus / disabled
16
- * only), so the escalation cannot leak into resting styles.
10
+ * THIS SHEET CARRIES NO `!important`. It carried 35 at 0.7.0, and the count going
11
+ * to zero is what the migration was for: every rule here is now beatable by an
12
+ * app's unlayered `theme.css` without `!important` and without out-specifying
13
+ * anything. A new escalation is therefore a claim that some element still styles
14
+ * itself inline on the same property — state it, with the file, or do not add it.
15
+ *
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.
28
+ *
29
+ * A migrated component gets its base here and renders no `style={}` for it —
30
+ * though it may still pass an inline value the sheet has no business owning, which
31
+ * is why `Stack` keeps `align` / `justify` inline and why DataViewTable keeps a
32
+ * dragged column width.
33
+ *
34
+ * The mechanism is worth keeping even with the ledger empty, because it is what a
35
+ * new rule has to reason about: `style={}` outranks any author rule, in any layer,
36
+ * for `:hover` / `:focus` / `:disabled` alike, so a state rule aimed at an element
37
+ * that styles itself inline is INERT — it works, nothing renders differently, and
38
+ * the claim quietly becomes a lie. And the escalation that fixes it comes off per
39
+ * CONSUMER, not per rule: several selectors are shared, so a shared rule may drop
40
+ * its `!important` only when the LAST element it matches has migrated. Dropping
41
+ * `input`'s when the first three had left a disabled `Combobox` painted exactly
42
+ * like an enabled one.
43
+ *
44
+ * It runs the other way too, and that direction is quieter: an escalation kept
45
+ * after its last inline consumer migrated still outranks the app `theme.css`
46
+ * this phase exists to empower, and nothing renders differently to say so.
47
+ * `tab` was in that state until its rules were relaxed.
48
+ *
49
+ * ## Layers
50
+ *
51
+ * The sheet is ordered by cascade layer rather than by source order, because
52
+ * source order is what actually decides these rules and it is not something a
53
+ * reader can see. `[data-terp]:focus-visible` and
54
+ * `[data-terp="button"][data-variant="primary"]` both weigh (0,2,0) — an
55
+ * attribute plus a pseudo-class against two attributes — so nothing but their
56
+ * order in this file separates them. The shared focus ring has always been
57
+ * declared near the top; the moment the primary button's resting shadow became
58
+ * a rule here instead of an inline style, the ring stopped painting on the most
59
+ * focus-relevant control in the package. Measured, not reasoned about: with
60
+ * both in one layer the focused primary button computes
61
+ * `rgba(15,23,42,0.06) 0 1px 2px` — its resting shadow — and with the ring in
62
+ * `terp.state` it computes `rgba(37,99,235,0.35) 0 0 0 3px`.
63
+ *
64
+ * `terp.state` sits above `terp.base`, so a state rule wins on layer order
65
+ * whatever its specificity and wherever it sits in the file, and needs no
66
+ * `!important`. `terp.motion` sits above both so the reduced-motion override
67
+ * beats every transition.
68
+ *
69
+ * Two things are deliberately left UNLAYERED, and the reason is the same for
70
+ * both: unlayered author declarations beat layered ones regardless of
71
+ * specificity. The density re-scoping must therefore stay unlayered, or the
72
+ * contract's own unlayered `:root` token values would beat it and the compact
73
+ * attribute would do nothing. And an app's `theme.css` is unlayered too, which
74
+ * is what lets an app override any framework rule without `!important` — the
75
+ * restyling this phase exists to enable.
17
76
  *
18
77
  * The injector is idempotent, SSR-safe (guarded on `document`), and appends
19
78
  * the rules through `textContent` — never `innerHTML` — so no HTML sink is
20
79
  * touched.
21
80
  */
22
81
 
23
- /** The `<style>` element id used to detect a prior injection. */
24
- export const TERP_STYLES_ID = "terp-core-styles";
82
+ /** The `<style>` element id used to detect a prior injection. */
83
+ export const TERP_STYLES_ID = "terp-core-styles";
84
+
85
+ /** The component base, variant and interaction rules react-core injects once. */
86
+ export const TERP_STYLES_CSS = `
87
+ @layer terp.reset, terp.base, terp.state, terp.motion;
88
+
89
+ /* Density: a subtree stamped data-density="compact" re-scopes the live density
90
+ tokens to their compact counterparts, and every rule reading a live token
91
+ follows via custom-property inheritance. Two dimensions today: control
92
+ height, read by Button, Input, Select and the date-picker trigger; and cell
93
+ padding, read by the DataView's header and body cells, its cards, its
94
+ expanded-row cell and the inline padding of its toolbar and pagination bars.
95
+ The cell tokens were published one stage before anything read them and were
96
+ deleted for it — they are back here, with their readers, in the same commit.
97
+
98
+ The bars read only the inline half (--density-cell-pad-x, with --space-2
99
+ vertically) so a bar's left edge stays flush with the first cell's text at
100
+ either density. The header reads the same inline half and keeps --space-2
101
+ vertically, because its comfortable value already IS the compact cell value:
102
+ density moves the header's inline axis only, and tying a comfortable header
103
+ to the compact scale would surprise anyone moving it from theme.css.
104
+
105
+ "comfortable" is the token sheet's :root value, so the attribute for it
106
+ matches no rule — an app sets density per subtree (the shell for an app-wide
107
+ default, an embedded DataView for one table), never per rule. A comfortable
108
+ island inside a compact subtree is not expressible yet; that needs a named
109
+ comfortable copy of each live token, which ADR 0094 defers until something
110
+ asks for it. Nothing does until AppShell takes a density of its own, which is
111
+ the first time a DataView can find itself inside an already-compact subtree.
112
+
113
+ Unlayered on purpose: the contract's token sheet declares these on :root
114
+ without a layer, and an unlayered declaration beats a layered one whatever
115
+ its specificity — inside a layer this rule would lose to :root whenever the
116
+ attribute sits on the same element as those :root declarations (the app sets
117
+ data-density on <html> for an app-wide default), and the attribute would
118
+ silently do nothing. On any element BELOW the root the mechanism does not
119
+ depend on that at all: a custom property declared on an ancestor is inherited
120
+ rather than cascaded against, so a DataView stamping the attribute on itself
121
+ wins over :root whatever the source order. */
122
+ [data-density="compact"] {
123
+ --density-control-min-height: var(--density-compact-control-min-height);
124
+ --density-cell-pad-y: var(--density-compact-cell-pad-y);
125
+ --density-cell-pad-x: var(--density-compact-cell-pad-x);
126
+ }
127
+
128
+ @layer terp.reset {
129
+ /* Document reset: the app shell owns the full canvas. Without this the
130
+ browser's default 8px body margin leaves the document's own (white)
131
+ canvas visible as a ring around the shell — most obvious in Studio's
132
+ preview iframe and in dark mode. The body carries the same canvas token
133
+ as the shell so overscroll never flashes white. */
134
+ html, body {
135
+ margin: 0;
136
+ }
137
+ body {
138
+ background: var(--color-neutral-50);
139
+ }
140
+ /* Border-box baseline: react-core sizes components as padding-inclusive
141
+ (e.g. LoginView's 100vh page with padding, inputs at width:100% with
142
+ padding) and app modules cannot ship a stylesheet of their own, so the
143
+ framework owns this. Under the browser default (content-box) a
144
+ min-height:100vh + padding screen overflows the viewport by exactly its
145
+ padding — phantom scrollbars on pages that fit. */
146
+ *,
147
+ *::before,
148
+ *::after {
149
+ box-sizing: border-box;
150
+ }
151
+
152
+ /* Themed scrollbars: the OS default chrome (thick, grey, light-only) ignores
153
+ the app theme and looks foreign against a token-styled surface — most
154
+ obvious on the horizontal overflow of a DataView table. These rules give
155
+ every scroll container a thin, token-coloured bar that tracks light/dark.
156
+ Firefox uses the inheritable scrollbar-* properties (set once on the root);
157
+ WebKit/Blink use the ::-webkit-scrollbar pseudo-elements (not inherited, so
158
+ they match every scrollable element globally). Paired with the color-scheme
159
+ declaration on the token roots so any native chrome we do not restyle here
160
+ (notably the native <select> option popup) also follows the theme. */
161
+ html {
162
+ scrollbar-width: thin;
163
+ scrollbar-color: var(--color-neutral-300) transparent;
164
+ }
165
+ ::-webkit-scrollbar {
166
+ width: 10px;
167
+ height: 10px;
168
+ }
169
+ ::-webkit-scrollbar-track {
170
+ background: transparent;
171
+ }
172
+ ::-webkit-scrollbar-thumb {
173
+ background-color: var(--color-neutral-300);
174
+ border: 2px solid transparent;
175
+ background-clip: padding-box;
176
+ border-radius: var(--radius-full);
177
+ }
178
+ ::-webkit-scrollbar-thumb:hover {
179
+ background-color: var(--color-neutral-400);
180
+ }
181
+ ::-webkit-scrollbar-corner {
182
+ background: transparent;
183
+ }
184
+ }
185
+
186
+ @layer terp.base {
187
+ /* Buttons ------------------------------------------------------------------ */
188
+ [data-terp="button"] {
189
+ display: inline-flex;
190
+ align-items: center;
191
+ justify-content: center;
192
+ gap: var(--space-2);
193
+ width: fit-content;
194
+ max-width: 100%;
195
+ min-height: var(--density-control-min-height);
196
+ padding: 0 var(--space-4);
197
+ border: 1px solid transparent;
198
+ border-radius: var(--radius-md);
199
+ box-sizing: border-box;
200
+ cursor: pointer;
201
+ white-space: normal;
202
+ text-align: center;
203
+ font-family: var(--font-family-sans);
204
+ font-size: var(--font-size-sm);
205
+ font-weight: var(--font-weight-medium);
206
+ line-height: 1.2;
207
+ transition: background-color 150ms ease, color 150ms ease,
208
+ border-color 150ms ease, box-shadow 150ms ease, transform 100ms ease;
209
+ }
210
+ [data-terp="button"][data-variant="primary"] {
211
+ background: var(--color-brand-primary);
212
+ color: var(--color-brand-primary-contrast);
213
+ box-shadow: var(--shadow-sm);
214
+ }
215
+ [data-terp="button"][data-variant="secondary"] {
216
+ background: var(--color-neutral-0);
217
+ color: var(--color-neutral-900);
218
+ border-color: var(--color-neutral-300);
219
+ }
220
+ [data-terp="button"][data-variant="danger"] {
221
+ background: var(--color-status-danger);
222
+ color: var(--color-neutral-0);
223
+ }
224
+ [data-terp="button"][data-variant="ghost"] {
225
+ background: transparent;
226
+ color: var(--color-neutral-700);
227
+ }
228
+ [data-terp="button-icon"] {
229
+ display: inline-flex;
230
+ align-items: center;
231
+ justify-content: center;
232
+ flex-shrink: 0;
233
+ }
234
+
235
+ /* Badges ------------------------------------------------------------------- */
236
+ /* The border matches the soft fill exactly, so it reads as one flat pill while
237
+ still occupying a border box — which is what keeps a Badge the same height
238
+ next to a bordered control. */
239
+ [data-terp="badge"] {
240
+ display: inline-flex;
241
+ align-items: center;
242
+ border: 1px solid;
243
+ border-radius: var(--radius-full);
244
+ padding: 2px var(--space-2);
245
+ font-size: var(--font-size-xs);
246
+ font-weight: var(--font-weight-semibold);
247
+ line-height: 1.4;
248
+ white-space: nowrap;
249
+ }
250
+ [data-terp="badge"][data-tone="neutral"] {
251
+ color: var(--color-neutral-600);
252
+ background: var(--color-neutral-100);
253
+ border-color: var(--color-neutral-100);
254
+ }
255
+ [data-terp="badge"][data-tone="info"] {
256
+ color: var(--color-status-info);
257
+ background: var(--color-status-info-soft);
258
+ border-color: var(--color-status-info-soft);
259
+ }
260
+ [data-terp="badge"][data-tone="success"] {
261
+ color: var(--color-status-success);
262
+ background: var(--color-status-success-soft);
263
+ border-color: var(--color-status-success-soft);
264
+ }
265
+ [data-terp="badge"][data-tone="warning"] {
266
+ color: var(--color-status-warning);
267
+ background: var(--color-status-warning-soft);
268
+ border-color: var(--color-status-warning-soft);
269
+ }
270
+ [data-terp="badge"][data-tone="danger"] {
271
+ color: var(--color-status-danger);
272
+ background: var(--color-status-danger-soft);
273
+ border-color: var(--color-status-danger-soft);
274
+ }
275
+
276
+ /* Alerts ------------------------------------------------------------------- */
277
+ /* The tone sets the root's color, which the border picks up as currentColor
278
+ and the icon inherits — so a tone is one declaration pair rather than three
279
+ places to keep in step. The body restates the reading colour, because the
280
+ copy must stay neutral-900 while the frame and glyph carry the tone. */
281
+ [data-terp="alert"] {
282
+ display: grid;
283
+ grid-template-columns: auto 1fr;
284
+ gap: var(--space-3);
285
+ padding: var(--space-3) var(--space-4);
286
+ border: 1px solid;
287
+ border-radius: var(--radius-md);
288
+ }
289
+ [data-terp="alert"][data-tone="neutral"] {
290
+ color: var(--color-neutral-600);
291
+ background: var(--color-neutral-50);
292
+ }
293
+ [data-terp="alert"][data-tone="info"] {
294
+ color: var(--color-status-info);
295
+ background: var(--color-status-info-soft);
296
+ }
297
+ [data-terp="alert"][data-tone="success"] {
298
+ color: var(--color-status-success);
299
+ background: var(--color-status-success-soft);
300
+ }
301
+ [data-terp="alert"][data-tone="warning"] {
302
+ color: var(--color-status-warning);
303
+ background: var(--color-status-warning-soft);
304
+ }
305
+ [data-terp="alert"][data-tone="danger"] {
306
+ color: var(--color-status-danger);
307
+ background: var(--color-status-danger-soft);
308
+ }
309
+ [data-terp="alert-icon"] {
310
+ display: inline-flex;
311
+ align-items: flex-start;
312
+ padding-top: 2px;
313
+ }
314
+ [data-terp="alert-body"] {
315
+ display: grid;
316
+ gap: var(--space-1);
317
+ min-width: 0;
318
+ color: var(--color-neutral-900);
319
+ }
320
+ [data-terp="alert-title"] {
321
+ font-weight: var(--font-weight-semibold);
322
+ }
323
+
324
+ /* Text controls ------------------------------------------------------------ */
325
+ /* Input, Select and Textarea deliberately share one marker, because the focus
326
+ ring, the hover border and the disabled treatment are the same control
327
+ affordance in all three. Only their geometry differs, so the element type
328
+ carries that — no second attribute for a distinction the tag name already
329
+ makes. */
330
+ [data-terp="input"] {
331
+ border: 1px solid var(--color-neutral-300);
332
+ border-radius: var(--radius-md);
333
+ color: var(--color-neutral-900);
334
+ background: var(--color-neutral-0);
335
+ box-sizing: border-box;
336
+ font-family: var(--font-family-sans);
337
+ font-size: var(--font-size-sm);
338
+ font-weight: var(--font-weight-normal);
339
+ line-height: 1.25;
340
+ transition: border-color 150ms ease, box-shadow 150ms ease;
341
+ }
342
+ input[data-terp="input"] {
343
+ min-height: var(--density-control-min-height);
344
+ padding: 0 var(--space-3);
345
+ line-height: 1.2;
346
+ }
347
+ /* The chevron is a background image, so the padding-right reserves its box and
348
+ the shorthand restates the surface colour behind it. Appearance is reset on
349
+ all three prefixes: the native affordance cannot be token-themed. */
350
+ select[data-terp="input"] {
351
+ max-width: 100%;
352
+ min-width: 0;
353
+ min-height: var(--density-control-min-height);
354
+ padding: 0 calc(var(--space-3) + 1.25rem) 0 var(--space-3);
355
+ line-height: 1.2;
356
+ background: url("data:image/svg+xml;charset=UTF-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 20 20' fill='none' stroke='%2364748b' stroke-width='2' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='m5 8 5 5 5-5'/%3E%3C/svg%3E") no-repeat right var(--space-2) center / 1rem 1rem, var(--color-neutral-0);
357
+ appearance: none;
358
+ -webkit-appearance: none;
359
+ -moz-appearance: none;
360
+ }
361
+ textarea[data-terp="input"] {
362
+ padding: var(--space-2) var(--space-3);
363
+ line-height: 1.4;
364
+ }
365
+
366
+ /* Stack -------------------------------------------------------------------- */
367
+ /* direction, gap and wrap are closed sets, so they are attributes with a rule
368
+ each. align and justify are not: they take any alignment keyword CSS
369
+ accepts, and minting a rule per keyword would be inventing a vocabulary the
370
+ platform already has. Those two stay inline — the boundary ADR 0094 draws
371
+ between styling policy and a value the caller measures. */
372
+ [data-terp="stack"] {
373
+ display: flex;
374
+ flex-direction: column;
375
+ gap: var(--space-2);
376
+ margin: 0;
377
+ }
378
+ [data-terp="stack"][data-direction="row"] { flex-direction: row; }
379
+ [data-terp="stack"][data-gap="0"] { gap: var(--space-0); }
380
+ [data-terp="stack"][data-gap="1"] { gap: var(--space-1); }
381
+ [data-terp="stack"][data-gap="2"] { gap: var(--space-2); }
382
+ [data-terp="stack"][data-gap="3"] { gap: var(--space-3); }
383
+ [data-terp="stack"][data-gap="4"] { gap: var(--space-4); }
384
+ [data-terp="stack"][data-gap="6"] { gap: var(--space-6); }
385
+ [data-terp="stack"][data-gap="8"] { gap: var(--space-8); }
386
+ [data-terp="stack"][data-wrap="true"] { flex-wrap: wrap; }
387
+
388
+ /* Detail lists ------------------------------------------------------------- */
389
+ /* The term and value are inline boxes inside a block row, which is what makes
390
+ "Label: value" read as one line and wrap as one paragraph. */
391
+ [data-terp="detail-list"] {
392
+ margin: 0;
393
+ display: grid;
394
+ gap: var(--space-1);
395
+ }
396
+ [data-terp="detail-list-term"] {
397
+ display: inline;
398
+ font-weight: var(--font-weight-medium);
399
+ }
400
+ [data-terp="detail-list-value"] {
401
+ display: inline;
402
+ margin: 0;
403
+ }
404
+
405
+ /* Checkboxes / radios / switches ------------------------------------------- */
406
+ /* One label shape for all three, so the marker is shared: the control differs,
407
+ the "box or dot, gap, then its text" arrangement does not. accent-color is
408
+ what paints the control itself — the browser picks the check colour, so the
409
+ contrast that matters is the control against the page, which is why this is
410
+ the ink token and not the filled-surface one (ADR 0093). */
411
+ [data-terp="control-label"] {
412
+ display: inline-flex;
413
+ align-items: center;
414
+ gap: var(--space-2);
415
+ color: var(--color-neutral-900);
416
+ cursor: pointer;
417
+ font-size: var(--font-size-sm);
418
+ }
419
+ [data-terp="checkbox"],
420
+ [data-terp="radio"] {
421
+ inline-size: 1rem;
422
+ block-size: 1rem;
423
+ accent-color: var(--color-fg-accent);
424
+ cursor: pointer;
425
+ }
426
+ [data-terp="switch"] {
427
+ inline-size: 2.25rem;
428
+ block-size: 1.25rem;
429
+ accent-color: var(--color-fg-accent);
430
+ cursor: pointer;
431
+ transition: background-color 150ms ease;
432
+ }
433
+ [data-terp="radio-group"] {
434
+ display: grid;
435
+ gap: var(--space-2);
436
+ border: 0;
437
+ padding: 0;
438
+ margin: 0;
439
+ }
440
+ [data-terp="radio-group-legend"] {
441
+ font-weight: var(--font-weight-medium);
442
+ padding: 0;
443
+ margin-block-end: var(--space-1);
444
+ font-size: var(--font-size-sm);
445
+ color: var(--color-neutral-700);
446
+ }
447
+ [data-terp="radio-group-options"] {
448
+ display: grid;
449
+ gap: var(--space-2);
450
+ }
451
+
452
+ /* Cards -------------------------------------------------------------------- */
453
+ [data-terp="card"] {
454
+ display: flex;
455
+ flex-direction: column;
456
+ gap: var(--space-3);
457
+ background: var(--color-neutral-0);
458
+ border: 1px solid var(--color-neutral-200);
459
+ border-radius: var(--radius-lg);
460
+ padding: var(--space-4);
461
+ min-width: 0;
462
+ }
463
+ [data-terp="card"][data-gap="0"] { gap: var(--space-0); }
464
+ [data-terp="card"][data-gap="1"] { gap: var(--space-1); }
465
+ [data-terp="card"][data-gap="2"] { gap: var(--space-2); }
466
+ [data-terp="card"][data-gap="3"] { gap: var(--space-3); }
467
+ [data-terp="card"][data-gap="4"] { gap: var(--space-4); }
468
+ [data-terp="card"][data-gap="6"] { gap: var(--space-6); }
469
+ [data-terp="card"][data-gap="8"] { gap: var(--space-8); }
470
+ [data-terp="card-header"] {
471
+ display: flex;
472
+ align-items: center;
473
+ justify-content: space-between;
474
+ flex-wrap: wrap;
475
+ gap: var(--space-3);
476
+ }
477
+ [data-terp="card-heading"] {
478
+ min-width: 0;
479
+ }
480
+ [data-terp="card-actions"] {
481
+ flex-shrink: 0;
482
+ }
483
+ [data-terp="card-title"] {
484
+ margin: 0;
485
+ font-size: var(--font-size-base);
486
+ font-weight: var(--font-weight-semibold);
487
+ line-height: 1.3;
488
+ }
489
+ [data-terp="card-description"] {
490
+ margin: 0;
491
+ color: var(--color-neutral-600);
492
+ font-size: var(--font-size-sm);
493
+ }
494
+
495
+ /* Tabs --------------------------------------------------------------------- */
496
+ /* The tab strip sits one pixel over the list's bottom rule, so the selected
497
+ tab's own 2px edge covers it rather than stacking beside it. */
498
+ [data-terp="tabs"] {
499
+ display: grid;
500
+ gap: var(--space-3);
501
+ }
502
+ [data-terp="tab-list"] {
503
+ display: flex;
504
+ flex-wrap: wrap;
505
+ gap: var(--space-1);
506
+ border-block-end: 1px solid var(--color-neutral-200);
507
+ }
508
+ [data-terp="tab"] {
509
+ font-family: var(--font-family-sans);
510
+ font-size: var(--font-size-sm);
511
+ font-weight: var(--font-weight-medium);
512
+ line-height: 1.25;
513
+ padding: var(--space-2) var(--space-3);
514
+ border: 0;
515
+ border-block-end: 2px solid transparent;
516
+ color: var(--color-neutral-600);
517
+ background: transparent;
518
+ cursor: pointer;
519
+ margin-block-end: -1px;
520
+ border-top-left-radius: var(--radius-sm);
521
+ border-top-right-radius: var(--radius-sm);
522
+ transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
523
+ }
524
+ [data-terp="tab-panel"] {
525
+ color: var(--color-neutral-900);
526
+ }
527
+
528
+ /* Module navigation -------------------------------------------------------- */
529
+ /* Secondary tabs for a module's sub-pages, and very nearly the same object as tab
530
+ above: a transparent 2px edge the active item colours, muted ink the active item
531
+ darkens. The difference is that these are router links rather than buttons, and
532
+ that difference decides where the active state is keyed — see terp.state.
533
+
534
+ The edges are logical (border-block-end) to match tab and appshell-footer rather
535
+ than the physical borderBottom the component declared. Identical in every writing
536
+ mode this framework ships, and the sheet already had one convention. */
537
+ [data-terp="module-nav"] {
538
+ border-block-end: 1px solid var(--color-neutral-200);
539
+ }
540
+ [data-terp="module-nav-list"] {
541
+ list-style: none;
542
+ margin: 0;
543
+ padding: 0;
544
+ display: flex;
545
+ flex-wrap: wrap;
546
+ gap: var(--space-3);
547
+ }
548
+ [data-terp="module-nav-link"] {
549
+ display: inline-flex;
550
+ align-items: center;
551
+ padding: var(--space-2) 0;
552
+ color: var(--color-neutral-600);
553
+ text-decoration: none;
554
+ border-block-end: 2px solid transparent;
555
+ }
556
+
557
+ /* Breadcrumbs -------------------------------------------------------------- */
558
+ /* The trail owns its whole subtree, so the list and its items are addressed
559
+ structurally and the current crumb by the aria-current it already carries —
560
+ no marker for something the accessibility tree already states. */
561
+ [data-terp="breadcrumbs"] {
562
+ font-size: var(--font-size-sm);
563
+ color: var(--color-neutral-600);
564
+ }
565
+ [data-terp="breadcrumbs"] ol,
566
+ [data-terp="breadcrumbs"] li {
567
+ list-style: none;
568
+ margin: 0;
569
+ padding: 0;
570
+ display: flex;
571
+ flex-wrap: wrap;
572
+ align-items: center;
573
+ gap: var(--space-2);
574
+ }
575
+ /* Keyed on our own marker, not on [aria-current="page"]. The trail's ancestor
576
+ crumbs are the app router's links, and TanStack stamps aria-current="page" on
577
+ every link whose path is a PREFIX of the current one — which every ancestor
578
+ crumb is, by definition. Borrowing the attribute therefore painted the whole
579
+ trail as the current page. */
580
+ [data-terp="breadcrumbs-current"] {
581
+ color: var(--color-neutral-900);
582
+ font-weight: var(--font-weight-medium);
583
+ }
584
+ [data-terp="breadcrumbs"] a {
585
+ color: var(--color-neutral-600);
586
+ text-decoration: none;
587
+ transition: color 150ms ease;
588
+ }
589
+ [data-terp="breadcrumbs-separator"] {
590
+ display: inline-flex;
591
+ color: var(--color-neutral-400);
592
+ line-height: 0;
593
+ }
594
+
595
+ /* The app shell -------------------------------------------------------------- */
596
+ /* Twenty-two style objects came out of AppShell.tsx into this block, and with them the
597
+ last five escalations in the sheet. Three facts drive every rule here and each has
598
+ exactly one owner in the DOM:
599
+
600
+ data-variant on the SHELL ROOT says mobile or desktop. The breakpoint itself stays in
601
+ the component's media query rather than being restated as a CSS @media rule that
602
+ could drift from it, so everything the viewport decides descends from this one
603
+ attribute.
604
+ data-collapsed on the SIDEBAR says icon rail. Everything the rail decides — its
605
+ width, the brand's centring, the two hidden labels — descends from that.
606
+ aria-current="page" on a nav link says active route. Every router sets it; the
607
+ shell's hover rule has keyed on it since before this migration.
608
+
609
+ That is why nothing here needs a style object handed across a public boundary, which
610
+ is what AppShellLinkContext.style and RenderBrandLink's style param used to be. */
611
+ [data-terp="appshell"] {
612
+ display: flex;
613
+ align-items: stretch;
614
+ min-height: 100vh;
615
+ font-family: var(--font-family-sans);
616
+ color: var(--color-neutral-900);
617
+ background: var(--color-neutral-50);
618
+ }
619
+ /* The sidebar. width is a rule now rather than an inline value chosen per render, which
620
+ is what finally puts its transition inside terp.motion's reach: the aside carried no
621
+ marker, so the reduced-motion block matched nothing on it and a reduced-motion user
622
+ watched the rail animate. The sheet's own comment said so and named this commit.
623
+ border-inline-end rather than border-right: RTL-correct and zero-diff in LTR. */
624
+ [data-terp="appshell-sidebar"] {
625
+ display: flex;
626
+ flex-direction: column;
627
+ gap: var(--space-4);
628
+ padding: var(--space-3);
629
+ box-sizing: border-box;
630
+ flex-shrink: 0;
631
+ position: sticky;
632
+ top: 0;
633
+ height: 100vh;
634
+ overflow-x: hidden;
635
+ width: 15rem;
636
+ background: var(--color-neutral-0);
637
+ border-inline-end: 1px solid var(--color-neutral-200);
638
+ transition: width 150ms ease;
639
+ }
640
+ [data-terp="appshell-sidebar"][data-collapsed="true"] {
641
+ width: 4rem;
642
+ }
643
+ /* The mobile drawer, reached from the shell root's variant rather than from an attribute
644
+ of its own — the viewport is one fact and the root owns it. 100dvh rather than 100vh so
645
+ a mobile browser's collapsing toolbar does not clip the drawer's footer.
646
+ --z-index-drawer's FIRST reader anywhere: the token shipped with the family and the
647
+ only thing that ever wanted it hardcoded 50, which is how the one binding that existed
648
+ ended up pointing at the popover level instead. --z-index-backdrop and
649
+ --z-index-sticky below are likewise first readers. */
650
+ [data-terp="appshell"][data-variant="mobile"] [data-terp="appshell-sidebar"] {
651
+ position: fixed;
652
+ inset: 0 auto 0 0;
653
+ height: 100dvh;
654
+ z-index: var(--z-index-drawer);
655
+ box-shadow: var(--shadow-lg);
656
+ }
657
+ [data-terp="appshell-backdrop"] {
658
+ position: fixed;
659
+ inset: 0;
660
+ z-index: var(--z-index-backdrop);
661
+ background: rgb(0 0 0 / 0.4);
662
+ }
663
+ /* The brand, in its three looks. Only the first is a rule about the brand itself; the
664
+ other two are rules about where it sits, which is what let the style-object parameter
665
+ go. The collapsed look descends from the sidebar's attribute; the mobile look descends
666
+ from the drawer's brand row, which exists only on mobile — so the DOM already says
667
+ which look applies and the shell no longer has to compute one. */
668
+ [data-terp="appshell-brand"] {
669
+ display: flex;
670
+ align-items: center;
671
+ gap: var(--space-2);
672
+ padding: var(--space-1) var(--space-2);
673
+ min-height: 2.25rem;
674
+ color: var(--color-neutral-900);
675
+ text-decoration: none;
676
+ border-radius: var(--radius-md);
677
+ box-sizing: border-box;
678
+ transition: background-color 150ms ease;
679
+ }
680
+ [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-brand"] {
681
+ justify-content: center;
682
+ padding-inline: 0;
683
+ }
684
+ [data-terp="appshell-brand-row"] {
685
+ display: flex;
686
+ align-items: center;
687
+ gap: var(--space-2);
688
+ }
689
+ [data-terp="appshell-brand-row"] > [data-terp="appshell-brand"] {
690
+ flex: 1;
691
+ min-width: 0;
692
+ }
693
+ [data-terp="appshell-brand-title"] {
694
+ overflow: hidden;
695
+ text-overflow: ellipsis;
696
+ white-space: nowrap;
697
+ font-size: var(--font-size-base);
698
+ font-weight: var(--font-weight-semibold);
699
+ color: var(--color-neutral-900);
700
+ letter-spacing: 0;
701
+ }
702
+ [data-terp="appshell-nav"] {
703
+ flex-grow: 1;
704
+ overflow-y: auto;
705
+ min-height: 0;
706
+ }
707
+ [data-terp="appshell-nav-list"] {
708
+ list-style: none;
709
+ margin: 0;
710
+ padding: 0;
711
+ display: grid;
712
+ gap: var(--space-1);
713
+ }
714
+ /* Sidebar navigation links, and this is the rule the whole shell migration was for. The
715
+ geometry used to be NAV_LINK_STYLE, a CSSProperties object exported from AppShell for
716
+ every router's link renderer to spread — so an app could not restyle a nav link at all
717
+ (a style attribute outranks any author rule in any layer) and every stack duplicated the
718
+ spread. The selector already existed here for the transition and the hover; it carries
719
+ the resting look now, so the exported constants and the two style parameters are gone.
720
+
721
+ The selector deliberately stays a descendant of the nav rather than gaining a marker of
722
+ its own: the link element belongs to the caller's router, not to the shell, so there is
723
+ no element here to stamp. */
724
+ [data-terp="appshell-nav"] a {
725
+ display: flex;
726
+ align-items: center;
727
+ gap: var(--space-2);
728
+ padding: var(--space-2) var(--space-3);
729
+ border-radius: var(--radius-md);
730
+ color: var(--color-neutral-700);
731
+ font-size: var(--font-size-sm);
732
+ font-weight: var(--font-weight-medium);
733
+ text-decoration: none;
734
+ white-space: nowrap;
735
+ overflow: hidden;
736
+ box-sizing: border-box;
737
+ min-height: 2.25rem;
738
+ transition: background-color 150ms ease, color 150ms ease;
739
+ }
740
+ /* The collapsed rail's link geometry: one centred fixed-size icon in the content track.
741
+ (0,3,1) against the base's (0,1,1), so it wins on specificity with no source-order
742
+ dependency. */
743
+ [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-nav"] a {
744
+ justify-content: center;
745
+ gap: 0;
746
+ padding: var(--space-2);
747
+ width: 100%;
748
+ }
749
+ [data-terp="appshell-nav-label"] {
750
+ overflow: hidden;
751
+ text-overflow: ellipsis;
752
+ white-space: nowrap;
753
+ }
754
+ /* Visually hidden, four elements, one rule. Two are the drawer's focus sentinels, which
755
+ must stay focusable and so cannot be display: none. The other two are the brand title
756
+ and the nav labels in the icon rail, which were a style-object TERNARY before this —
757
+ the component picked between two objects per render, and the collapsed branch was
758
+ painted by nothing, because the rail state was internal and no specimen could reach it.
759
+ That is what defaultCollapsed is for. */
760
+ [data-terp="drawer-focus-start"],
761
+ [data-terp="drawer-focus-end"],
762
+ [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-brand-title"],
763
+ [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-nav-label"] {
764
+ position: absolute;
765
+ width: 1px;
766
+ height: 1px;
767
+ padding: 0;
768
+ margin: -1px;
769
+ overflow: hidden;
770
+ clip: rect(0 0 0 0);
771
+ white-space: nowrap;
772
+ border: 0;
773
+ }
774
+ [data-terp="appshell-column"] {
775
+ display: flex;
776
+ flex-direction: column;
777
+ flex-grow: 1;
778
+ min-width: 0;
779
+ }
780
+ [data-terp="appshell-header"] {
781
+ position: sticky;
782
+ top: 0;
783
+ z-index: var(--z-index-sticky);
784
+ display: flex;
785
+ flex-wrap: wrap;
786
+ align-items: center;
787
+ justify-content: space-between;
788
+ gap: var(--space-3);
789
+ padding: var(--space-2) var(--space-4);
790
+ min-height: 3rem;
791
+ box-sizing: border-box;
792
+ background: var(--color-neutral-0);
793
+ border-block-end: 1px solid var(--color-neutral-200);
794
+ }
795
+ [data-terp="appshell-header-group"] {
796
+ display: flex;
797
+ align-items: center;
798
+ gap: var(--space-2);
799
+ }
800
+ /* The shell's two toggles: the header's sidebar control and the drawer's close button.
801
+ Both already wore the shared iconbutton marker and overrode every one of that marker's
802
+ would-be declarations from toggleStyle, which is what kept the shared hover rule
803
+ shouting — a style attribute outranks any author rule in any layer, so
804
+ [data-terp="iconbutton"]:hover needed !important on background and colour to reach
805
+ these two elements and only these two. They are the last such consumers, so those two
806
+ escalations retire here.
807
+ Reached structurally rather than by a marker of their own, the combobox-clear-button
808
+ precedent: each is an icon button and the only thing distinguishing it is where it
809
+ sits. The typography was CONTROL_TEXT_STYLE, whose only consumer this was — the module
810
+ is deleted with it. */
811
+ [data-terp="appshell-header"] > [data-terp="iconbutton"],
812
+ [data-terp="appshell-brand-row"] > [data-terp="iconbutton"] {
813
+ display: inline-flex;
814
+ align-items: center;
815
+ justify-content: center;
816
+ width: 2.25rem;
817
+ height: 2.25rem;
818
+ padding: 0;
819
+ color: var(--color-neutral-700);
820
+ background: transparent;
821
+ border: 1px solid transparent;
822
+ border-radius: var(--radius-md);
823
+ cursor: pointer;
824
+ font-family: var(--font-family-sans);
825
+ font-size: var(--font-size-sm);
826
+ font-weight: var(--font-weight-normal);
827
+ line-height: 1.25;
828
+ }
829
+ [data-terp="appshell-main"] {
830
+ flex-grow: 1;
831
+ padding: var(--space-6);
832
+ min-width: 0;
833
+ }
834
+ [data-terp="appshell"][data-variant="mobile"] [data-terp="appshell-main"] {
835
+ padding: var(--space-4);
836
+ }
837
+ [data-terp="appshell-footer"] {
838
+ padding: var(--space-3) var(--space-6);
839
+ border-block-start: 1px solid var(--color-neutral-200);
840
+ color: var(--color-fg-subtle);
841
+ font-size: var(--font-size-xs);
842
+ }
843
+
844
+ /* The page frame ----------------------------------------------------------- */
845
+ /* Every routed view is this shape: one header carrying the breadcrumb trail (when
846
+ there is a path back up) and the title row, then the body. HubPage, OverviewPage
847
+ and DetailPage are all this frame with a different trail.
848
+
849
+ The header is a <header> ELEMENT and Page has to keep it one, which is a
850
+ constraint this sheet cannot express and the component records at the site: the
851
+ layout contract's runtime slot check reads article.children and drops the header
852
+ by TAG NAME, so re-rendering it as a marked <div> would put it back into the body
853
+ set and fail every governed OverviewPage and DetailPage closed. Marking it is
854
+ additive; retagging it is not. The body likewise takes no wrapper — not even a
855
+ display: contents one, since that check is a DOM traversal and would see the node
856
+ whether or not it generates a box.
857
+
858
+ align-content: start is what keeps the rows at the top of a page taller than its
859
+ content — the loading and error frames, where the body is one small block. With
860
+ the default the two rows would spread to fill the height. It needs something to
861
+ stretch the article before it is observable at all, which is why page-loading and
862
+ page-error render inside a grid box rather than a plain tall div. */
863
+ [data-terp="page"] {
864
+ display: grid;
865
+ grid-template-columns: minmax(0, 1fr);
866
+ gap: var(--space-4);
867
+ align-content: start;
868
+ min-width: 0;
869
+ }
870
+ [data-terp="page-header"] {
871
+ display: grid;
872
+ gap: var(--space-2);
873
+ }
874
+ /* The crumb row keeps a 2rem floor, and it is doing work rather than reserving
875
+ space for its own sake: the trail is shorter than 2rem at font-size-sm, so
876
+ dropping the floor closes the gap under the trail on every page that has one.
877
+ Measured — removing it moves all six baselines with a trail and nothing else. */
878
+ [data-terp="page-breadcrumbs"] {
879
+ display: flex;
880
+ align-items: center;
881
+ min-height: 2rem;
882
+ }
883
+ /* Title left, the actions slot right, wrapping rather than overflowing when a long
884
+ title meets a wide action cluster. */
885
+ [data-terp="page-heading"] {
886
+ display: flex;
887
+ align-items: center;
888
+ justify-content: space-between;
889
+ gap: var(--space-3);
890
+ flex-wrap: wrap;
891
+ }
892
+ /* The single h1 of the view. margin: 0 is load-bearing — the browser default h1
893
+ margin would otherwise fight the header's own gap. */
894
+ [data-terp="page-title"] {
895
+ margin: 0;
896
+ font-size: var(--font-size-lg);
897
+ font-weight: var(--font-weight-semibold);
898
+ letter-spacing: 0;
899
+ color: var(--color-neutral-900);
900
+ line-height: 1.3;
901
+ }
902
+
903
+ /* The sign-in screen ------------------------------------------------------- */
904
+ /* The one screen an unauthenticated user sees, and the only full-viewport page in
905
+ the package: a 100vh grid centring one card. The reset layer's box-sizing note
906
+ already names this page as the reason it exists — content-box plus 100vh plus
907
+ padding overflows the viewport by exactly the padding, which is a phantom
908
+ scrollbar on a page that fits.
909
+
910
+ The buttons fill their group, and that is a rule on the GROUP rather than a prop
911
+ on Button, because it has exactly one consumer in the package. Button declares
912
+ width: fit-content, which is a definite width — so grid stretch does NOT do this
913
+ for free, and dropping the declaration shrinks all four buttons to their labels.
914
+ Two selectors at (0,2,0) against the button's own (0,1,0), same layer, so
915
+ specificity settles it and source order never enters into it. A block prop on
916
+ Button would be a new public API minted for one internal caller; the sheet can
917
+ already reach the thing, which is the test stage 4 set when it deleted Menu's
918
+ style props.
919
+
920
+ The separator's ink moves from --color-neutral-500 to --color-fg-subtle, which is
921
+ the rest of the migration ec36a2b started rather than a new decision: the two
922
+ tokens are byte-identical in light and dark, and only fg-subtle has a declared
923
+ pairing (subtle-on-surface) for the gate to measure. So the screenshot themes do
924
+ not move and midnight and twilight get the value the gate has been measuring all
925
+ along. The "or" is aria-hidden but it is visible text, so it is held to AA rather
926
+ than treated as an ornament.
927
+
928
+ The error line has no specimen and cannot have one: the error is internal state set
929
+ only in a catch, and sso.error needs a real failed callback, which needs a URL the
930
+ lane owns. Its ink is gated statically instead — danger-on-card, declared for this
931
+ surface and measured in all five themes. */
932
+ [data-terp="login-view"] {
933
+ min-height: 100vh;
934
+ display: grid;
935
+ place-items: center;
936
+ padding: var(--space-6);
937
+ background: var(--color-neutral-50);
938
+ font-family: var(--font-family-sans);
939
+ color: var(--color-neutral-900);
940
+ }
941
+ [data-terp="login-card"] {
942
+ width: 100%;
943
+ max-width: 24rem;
944
+ display: grid;
945
+ gap: var(--space-4);
946
+ padding: var(--space-6);
947
+ background: var(--color-neutral-0);
948
+ border: 1px solid var(--color-neutral-200);
949
+ border-radius: var(--radius-lg);
950
+ box-shadow: var(--shadow-md);
951
+ }
952
+ [data-terp="login-brand"] {
953
+ display: flex;
954
+ align-items: center;
955
+ gap: var(--space-2);
956
+ color: var(--color-neutral-900);
957
+ }
958
+ [data-terp="login-title"] {
959
+ margin: 0;
960
+ font-size: var(--font-size-xl);
961
+ font-weight: var(--font-weight-bold);
962
+ letter-spacing: 0;
963
+ }
964
+ [data-terp="login-form"],
965
+ [data-terp="login-sso"] {
966
+ display: grid;
967
+ gap: var(--space-3);
968
+ }
969
+ [data-terp="login-form"] > [data-terp="button"],
970
+ [data-terp="login-sso"] > [data-terp="button"] {
971
+ width: 100%;
972
+ }
973
+ [data-terp="login-separator"] {
974
+ display: flex;
975
+ align-items: center;
976
+ gap: var(--space-2);
977
+ color: var(--color-fg-subtle);
978
+ font-size: var(--font-size-xs);
979
+ text-transform: uppercase;
980
+ letter-spacing: 0.06em;
981
+ }
982
+ [data-terp="login-separator-rule"] {
983
+ flex: 1;
984
+ border-block-start: 1px solid var(--color-neutral-200);
985
+ }
986
+ [data-terp="login-error"] {
987
+ margin: 0;
988
+ color: var(--color-status-danger);
989
+ font-size: var(--font-size-sm);
990
+ }
991
+
992
+ /* The profile screen ------------------------------------------------------- */
993
+ /* The built-in /profile view: two cards, an avatar tile, and the identity lines.
994
+ Its cards are its own rather than the Card component's — the same declarations on
995
+ a different element — and folding the two together is a component decision, not a
996
+ styling one, so it stays a follow-up rather than riding in on a migration whose
997
+ whole contract is zero pixel movement.
998
+
999
+ overflow-wrap on the address is real and unobservable: the workbench session is a
1000
+ fixed user whose address is short, so no specimen can paint the case it exists for
1001
+ (an address has no spaces to break at, so a long one widens the card past its own
1002
+ max-width instead of wrapping). Asserted in the unit test as the marker it keys on;
1003
+ there is no picture of it and cannot be until the mock session is variable. */
1004
+ [data-terp="profile-card"] {
1005
+ display: grid;
1006
+ gap: var(--space-4);
1007
+ padding: var(--space-4);
1008
+ max-width: 32rem;
1009
+ background: var(--color-neutral-0);
1010
+ border: 1px solid var(--color-neutral-200);
1011
+ border-radius: var(--radius-lg);
1012
+ }
1013
+ /* The initials tile. It is aria-hidden, so axe skips it by design and the declared
1014
+ pairing is the only thing measuring its ink: brand-primary-contrast on
1015
+ brand-primary is primary-button-label, which the contrast gate holds at AA in all
1016
+ five themes. Exactly the shape of NavIcon's fallback tile, which failed at 1.60
1017
+ for as long as nothing declared it. */
1018
+ [data-terp="profile-avatar"] {
1019
+ display: inline-flex;
1020
+ align-items: center;
1021
+ justify-content: center;
1022
+ width: 3.5rem;
1023
+ height: 3.5rem;
1024
+ flex-shrink: 0;
1025
+ border-radius: var(--radius-full);
1026
+ background: var(--color-brand-primary);
1027
+ color: var(--color-brand-primary-contrast);
1028
+ font-size: var(--font-size-lg);
1029
+ font-weight: var(--font-weight-medium);
1030
+ }
1031
+ [data-terp="profile-email"] {
1032
+ overflow-wrap: anywhere;
1033
+ }
1034
+ [data-terp="profile-role"] {
1035
+ margin: 0;
1036
+ color: var(--color-neutral-600);
1037
+ }
1038
+
1039
+ /* The built-in admin screens ----------------------------------------------- */
1040
+ /* The packaged /admin views. Three surfaces, and they were invisible to both
1041
+ ratchets for the entire migration — which is why they are here rather than in
1042
+ 0.8.0. The worklist names files with NO marker at all, and every admin view
1043
+ rendered none, so it read as a view composition and was excluded on purpose. The
1044
+ ledger counts module-scope CSSProperties declarations, and these were four
1045
+ call-site literals plus one unannotated object. Neither gate was wrong; both were
1046
+ narrower than they looked, and the same commit widens the measure.
1047
+
1048
+ The form box is ONE marker across two files, because UserCreate and GroupCreate
1049
+ constrain their form to the same measure — the same surface twice, not two
1050
+ surfaces that happen to agree today. */
1051
+ [data-terp="admin-form"] {
1052
+ max-width: 32rem;
1053
+ }
1054
+ /* A section heading inside a detail screen: the members list, the permission
1055
+ grants. font-size-base rather than the UA default, which for an h2 is LARGER than
1056
+ the page's own h1 at font-size-lg — so without this a section outranks the view
1057
+ it sits in. */
1058
+ [data-terp="admin-section-title"] {
1059
+ margin: 0;
1060
+ font-size: var(--font-size-base);
1061
+ }
1062
+ /* The audit event's JSON payload. No font-family: it is a <pre>, so the UA
1063
+ stylesheet's monospace already applies and the inline object set none either.
1064
+
1065
+ font-size-sm loses the inline fallback the object carried (0.875rem) and no other
1066
+ rule in this sheet has one. The fallback could never fire — tokens.guard.test.ts
1067
+ refuses any var() in react-core naming a property the contract does not publish,
1068
+ so the token is always there. It recorded an author's doubt, not an option. */
1069
+ [data-terp="admin-payload"] {
1070
+ margin: 0;
1071
+ padding: var(--space-3);
1072
+ background: var(--color-neutral-100);
1073
+ border-radius: var(--radius-md);
1074
+ font-size: var(--font-size-sm);
1075
+ overflow-x: auto;
1076
+ }
1077
+
1078
+ /* Hub cards --------------------------------------------------------------- */
1079
+ /* This whole family was in terp.state, resting declarations and all, for the same
1080
+ reason the icon button's transition was: that is where the hover rules needing it
1081
+ live. The resting half belongs here — a layer above is a layer an app's own state
1082
+ rule has to out-rank for no reason.
1083
+
1084
+ The hub grid. auto-fit with a min(16rem, 100%) track floor is what makes a hub
1085
+ reflow from four columns to one with no media query anywhere, and the min() rather
1086
+ than a bare 16rem is what stops a viewport narrower than the track overflowing.
1087
+ grid-auto-rows: 1fr with align-items: stretch is what makes every card in a row the
1088
+ same height — which is the entire reason HubCard renders placeholder rows, so those
1089
+ two decisions are one mechanism split across a rule and a component. */
1090
+ [data-terp="hubpage-grid"] {
1091
+ display: grid;
1092
+ grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
1093
+ grid-auto-rows: 1fr;
1094
+ gap: var(--space-4);
1095
+ align-items: stretch;
1096
+ list-style: none;
1097
+ margin: 0;
1098
+ padding: 0;
1099
+ }
1100
+ /* The card is one link. text-decoration and colour are the load-bearing pair here; the
1101
+ display and the height are belt, and saying which is which took measuring the whole
1102
+ chain in a row taller than the body's floor.
1103
+
1104
+ What actually equalises two cards is ONE declaration, and it is not in this rule:
1105
+ hubcard-body's height: 100%. Percentage heights resolve against the nearest block
1106
+ container and skip inline boxes, so the body reaches past this anchor to the <li> the
1107
+ grid stretched — which is why forcing this anchor to display: inline with height: auto
1108
+ changes nothing in either configuration measured (both cards at the 10rem floor, and a
1109
+ row stretched to 189px by a long description). Removing the BODY's height: 100% in that
1110
+ same stretched row is the one change that shows: the bare card drops to 160 while its
1111
+ neighbour stays at 189.
1112
+
1113
+ So these two declarations are kept as documentation of the intent rather than as the
1114
+ mechanism, and the one configuration not measured is the router path, where this marker
1115
+ is a <span> wrapping the stack's own Link rather than being the anchor itself. */
1116
+ [data-terp="hubcard-link"],
1117
+ [data-terp="hubcard"] a {
1118
+ text-decoration: none;
1119
+ color: inherit;
1120
+ display: block;
1121
+ height: 100%;
1122
+ min-height: 0;
1123
+ }
1124
+ /* The card and its body. The visible edge is on the BODY, not on the card: the card is
1125
+ the outer <li> and has no border at all. That distinction is what made the hover
1126
+ accent edge dead for as long as it was — see the state rules.
1127
+
1128
+ The body's height: 100% is the single declaration that makes two cards in a row the
1129
+ same height, and the only one: measured in a row stretched past the 10rem floor,
1130
+ removing it drops a bare card to 160 while its neighbour stays at 189, while removing
1131
+ the grid's align-items: stretch, its grid-auto-rows: 1fr, the card's own height: 100%
1132
+ or the link's display/height changes nothing. Those four restate a default or resolve
1133
+ through an ancestor. hub-card-bare is what catches this one, and only because its full
1134
+ card is deliberately long enough to set the row height.
1135
+
1136
+ The transition splits three ways, and each half is declared where its property
1137
+ lives: box-shadow and transform animate on the card, border-color on the body,
1138
+ colour on the title. Two of those were already rules; the title's was inline until
1139
+ this commit, which means terp.motion could not reach it and a reduced-motion user
1140
+ watched it animate. All three are inside the block's reach now. */
1141
+ [data-terp="hubcard"] {
1142
+ height: 100%;
1143
+ min-height: 0;
1144
+ transition: box-shadow 150ms ease, transform 150ms ease;
1145
+ }
1146
+ [data-terp="hubcard-body"] {
1147
+ display: grid;
1148
+ grid-template-rows: auto minmax(3rem, 1fr) auto;
1149
+ gap: var(--space-2);
1150
+ height: 100%;
1151
+ min-height: 10rem;
1152
+ padding: var(--space-4);
1153
+ border: 1px solid var(--color-neutral-200);
1154
+ border-radius: var(--radius-lg);
1155
+ background: var(--color-neutral-0);
1156
+ color: var(--color-neutral-900);
1157
+ box-sizing: border-box;
1158
+ transition: border-color 150ms ease;
1159
+ }
1160
+ /* -heading, not -title: in this sheet a heading is the BOX holding a title
1161
+ (card-heading, dataview-card-heading) and a title is the text box itself
1162
+ (card-title, dialog-title, hubcard-title). This is the row. */
1163
+ [data-terp="hubcard-heading"] {
1164
+ display: flex;
1165
+ align-items: center;
1166
+ gap: var(--space-3);
1167
+ }
1168
+ /* The icon tile. Its own marker rather than a structural hubcard-heading > span, and
1169
+ the reason is that the tile is CONDITIONAL while the title is not: a structural
1170
+ selector would move the tile's fill onto whatever else ends up first in that row the
1171
+ day the markup changes. fg-accent on brand-primary-soft measures 6.16 / 5.28 / 5.19 /
1172
+ 5.29 / 7.98, so the glyph clears AA on its own tile in every theme. */
1173
+ [data-terp="hubcard-icon"] {
1174
+ display: inline-flex;
1175
+ align-items: center;
1176
+ justify-content: center;
1177
+ width: 2.25rem;
1178
+ height: 2.25rem;
1179
+ flex-shrink: 0;
1180
+ border-radius: var(--radius-md);
1181
+ background: var(--color-brand-primary-soft);
1182
+ color: var(--color-fg-accent);
1183
+ }
1184
+ [data-terp="hubcard-title"] {
1185
+ color: var(--color-neutral-900);
1186
+ font-size: var(--font-size-base);
1187
+ font-weight: var(--font-weight-semibold);
1188
+ transition: color 150ms ease;
1189
+ }
1190
+ /* neutral-600 rather than fg-muted, and it is not the tinted-surface case: this text
1191
+ sits on the card's own neutral-0 and measures 7.58 / 7.94 / 7.50 / 7.60 / 18.42. */
1192
+ [data-terp="hubcard-description"] {
1193
+ color: var(--color-neutral-600);
1194
+ font-size: var(--font-size-sm);
1195
+ line-height: 1.5;
1196
+ }
1197
+ [data-terp="hubcard-stat"] {
1198
+ color: var(--color-neutral-900);
1199
+ font-size: var(--font-size-sm);
1200
+ font-weight: var(--font-weight-semibold);
1201
+ }
1202
+ /* The placeholder rows. The span still renders, carrying a non-breaking space, so its
1203
+ grid row keeps its height and a bare card stays flush with a full one — but that is the
1204
+ MARKUP's doing, not this rule's. This rule is invisible to every lane and it is not an
1205
+ oversight: a non-breaking space paints nothing, so a hidden placeholder and a visible
1206
+ one are pixel-identical. Measured — width 557 and height 89 either way.
1207
+
1208
+ Which leaves exactly one reason for the declaration, and it is the reason it must be
1209
+ visibility rather than opacity: visibility: hidden also removes the text from the
1210
+ accessibility tree. Without it a screen reader reaches a card and announces a blank
1211
+ row. No screenshot and no axe run can see that, so the gate is a text assertion.
1212
+
1213
+ Two markers and one rule: the shape is identical and there is nothing true of the stat
1214
+ here that is not true of the description. */
1215
+ [data-terp="hubcard-description"][data-empty="true"],
1216
+ [data-terp="hubcard-stat"][data-empty="true"] {
1217
+ visibility: hidden;
1218
+ }
1219
+
1220
+ /* DataView: the composition root ------------------------------------------- */
1221
+ /* One display for both return paths, so it belongs on the bare marker rather than
1222
+ being duplicated per variant.
1223
+
1224
+ And it is the one declaration in this block that NO lane gates, which is worth
1225
+ saying out loud rather than leaving for someone to discover by deleting it.
1226
+ Measured, not assumed: forcing this root to display: block leaves rootW, rootH,
1227
+ every child's width and the scroll wrapper's clientWidth/scrollWidth identical on
1228
+ dataview-wide, dataview-full and dataview-embedded alike — because the root's three
1229
+ children are full-width block boxes with no margins, which a single-column grid and
1230
+ a block container stack the same way. Deleting it would move no baseline. It is
1231
+ kept because the inline style declared it and this commit moves declarations rather
1232
+ than deciding them, and because it is not inert in the way a token with no reader
1233
+ is: the browser reads it, it simply cannot be told apart from the initial value by
1234
+ the compositions that exist.
1235
+
1236
+ It also does not mean what the pairing story would have it mean, and the difference
1237
+ only shows up if the rule below is ever removed. A grid item's automatic minimum
1238
+ size is content-based unless its overflow is something other than visible, so under
1239
+ grid a wide table without overflow-x would widen the whole DataView, while under
1240
+ block the wrapper would stay at 100% and the table would simply spill out of it.
1241
+ Both are wrong; they are wrong differently. So the two rules are coupled, and
1242
+ dropping overflow-x is what dataview-wide catches — not this. */
1243
+ [data-terp="dataview"] {
1244
+ display: grid;
1245
+ }
1246
+ /* The full variant's surface. (0,2,0) against the bare marker's (0,1,0), so it wins
1247
+ on specificity — no tie, no :not(), no source order. BOTH values of data-variant
1248
+ are stamped and only this one has a rule, which is the theme-toggle idiom where
1249
+ inline is stamped and takes the shared base while only stacked declares anything.
1250
+ Rejected: putting the surface on the bare marker and un-declaring it under
1251
+ [data-variant="embedded"], which needs background: transparent, border: 0 and
1252
+ border-radius: 0 — the shape ADR 0094 exists to avoid. Also rejected: stamping
1253
+ nothing for the default on the density precedent, which holds only because
1254
+ comfortable IS the :root value and so has nothing to declare.
1255
+ Byte-identical to [data-terp="card"]'s trio; written flat anyway, the Badge /
1256
+ Alert / row-tone precedent. No overflow: hidden, and none should be added here —
1257
+ the last row's border crossing the rounded bottom corners is pre-existing and
1258
+ belongs to its own commit. */
1259
+ [data-terp="dataview"][data-variant="full"] {
1260
+ background: var(--color-neutral-0);
1261
+ border: 1px solid var(--color-neutral-200);
1262
+ border-radius: var(--radius-lg);
1263
+ }
1264
+ /* The horizontal scroll container the reset's scrollbar comment already names. A
1265
+ marker rather than [data-terp="dataview"] > div, which also matches the toolbar.
1266
+ Marking it makes it match [data-terp]:focus-visible for the first time: Chrome
1267
+ focuses a scroller only when it holds no keyboard-focusable descendant —
1268
+ reachable with every column enableSorting: false, no selection, no onRowClick and
1269
+ no row actions — so such a view now paints the accent ring around its table.
1270
+ Arguably correct, and invisible to all three lanes. */
1271
+ [data-terp="dataview-scroll"] {
1272
+ overflow-x: auto;
1273
+ }
1274
+ /* The default error path's inset. Presence-conditional rather than
1275
+ value-conditional: this wrapper exists only when the caller supplies no
1276
+ renderError, so a caller-supplied error node gets no inset at all. The empty
1277
+ branch has no inset either — a pre-existing asymmetry visible in dataview-empty,
1278
+ and not something to tidy while moving declarations. */
1279
+ [data-terp="dataview-error"] {
1280
+ padding: var(--space-4);
1281
+ }
1282
+ /* The loading skeleton. --space-3 stays --space-3 and does NOT become
1283
+ --density-cell-pad-x: comfortable is 0.75rem either way, so reading the token
1284
+ would be zero-diff in every existing baseline and a silent geometry change at
1285
+ compact — the trap the table header's comment already records, arriving here as
1286
+ the opposite decision because the skeleton's bars deliberately do not follow
1287
+ density. */
1288
+ [data-terp="dataview-skeleton"] {
1289
+ display: grid;
1290
+ gap: var(--space-2);
1291
+ padding: var(--space-3);
1292
+ }
1293
+ /* The five placeholder bars. Structural rather than five markers, the card-main /
1294
+ actions-cell precedent: repeated identical boxes in a place where only bars sit.
1295
+ 2.75rem is a literal — no published token carries it and the density family has
1296
+ no small-control step. In the contrast theme neutral-100 on neutral-0 is 1.12:1
1297
+ and so nearly invisible; that is pre-existing, non-text and aria-hidden.
1298
+ If this ever gains a shimmer: a transition here is caught by styles.test.ts,
1299
+ because the selector ends in a bare tag and its reduced-motion entry is then
1300
+ demanded — but an animation, the natural choice, is caught by nothing. */
1301
+ [data-terp="dataview-skeleton"] > div {
1302
+ height: 2.75rem;
1303
+ background: var(--color-neutral-100);
1304
+ border-radius: var(--radius-md);
1305
+ }
1306
+
1307
+ /* DataView: the toolbar ---------------------------------------------------- */
1308
+ /* The band, both modes. The background is one of the ten declarations and that is
1309
+ deliberate: the inline style it replaces read
1310
+ selectionMode ? neutral-50 : neutral-0 — an explicit value in BOTH branches,
1311
+ not a conditional-or-nothing. Dropping it from the resting rule is invisible in
1312
+ every lane (the full variant's root and the workbench's specimen card are both
1313
+ neutral-0) and breaks the EMBEDDED variant in a real app, whose root declares
1314
+ nothing but display: grid — the band would go transparent and show the page
1315
+ canvas, body's neutral-50. dataview-toolbar-bare renders on a neutral-50 host
1316
+ precisely so that mutation fails a baseline instead of only a browser.
1317
+
1318
+ Padding reads the inline half of the cell tokens with --space-2 vertically, so
1319
+ the bar's left edge stays flush with the first cell's text at either density —
1320
+ the same bargain the pagination bar strikes at the other end of the box, and
1321
+ the reader the density comment at the top of this file already claims to have.
1322
+ Comfortable --density-cell-pad-x IS --space-3, so only dataview-compact moves.
1323
+
1324
+ The two radii are PHYSICAL, matching [data-terp="tab"], and they are
1325
+ load-bearing rather than decorative: DataView's root rounds its border with no
1326
+ overflow: hidden, so nothing else keeps the selection band's neutral-50 inside
1327
+ the rounded frame.
1328
+
1329
+ min-height is inert at comfortable (1rem of block padding plus a 2.25rem
1330
+ control is 3.25rem) and exactly equal at compact (a 2rem control is 3.00rem),
1331
+ and it is not dead: it is the only floor when the embedded band renders for a
1332
+ caller's filter slot or trailing slot alone. It stays a literal for the same
1333
+ reason the pager arrows' 2rem does — no published token carries it.
1334
+
1335
+ No colour declaration here on purpose. Two of this element's direct children
1336
+ are arbitrary caller slots, and inheriting a muted ink onto app-authored filter
1337
+ controls would be a silent restyle of app DOM. */
1338
+ [data-terp="dataview-toolbar"] {
1339
+ display: flex;
1340
+ align-items: center;
1341
+ gap: var(--space-2);
1342
+ flex-wrap: wrap;
1343
+ padding: var(--space-2) var(--density-cell-pad-x);
1344
+ border-block-end: 1px solid var(--color-neutral-200);
1345
+ background: var(--color-neutral-0);
1346
+ border-top-left-radius: var(--radius-lg);
1347
+ border-top-right-radius: var(--radius-lg);
1348
+ min-height: 3rem;
1349
+ }
1350
+ /* Selection mode. A resting surface rather than an interaction state, so
1351
+ terp.base — and (0,2,0) against the base's (0,1,0) means it wins on
1352
+ specificity alone, needing no :not() and no source-order dependency. */
1353
+ [data-terp="dataview-toolbar"][data-variant="selection"] {
1354
+ background: var(--color-neutral-50);
1355
+ }
1356
+ [data-terp="dataview-toolbar-count"] {
1357
+ font-weight: var(--font-weight-medium);
1358
+ }
1359
+ /* The batch-action group. NOT [data-terp="page-actions"] in disguise — that rule
1360
+ adds align-items: center and justify-content: flex-end. */
1361
+ [data-terp="dataview-toolbar-actions"] {
1362
+ display: inline-flex;
1363
+ gap: var(--space-2);
1364
+ flex-wrap: wrap;
1365
+ }
1366
+ /* One name, two elements, both of them spacers — the dataview-card-meta call.
1367
+ A bare > span:empty would reach both with no marker at all and is rejected: the
1368
+ batch-action group above is legitimately empty whenever a caller passes no
1369
+ batchActions, and would take flex: 1 in the commonest selection configuration. */
1370
+ [data-terp="dataview-toolbar-spacer"] {
1371
+ flex: 1;
1372
+ }
1373
+ /* The search box's positioning context, the anchor three descendant rules hang
1374
+ off — exactly as [data-terp="combobox-field"] anchors the combobox's input and
1375
+ clear button. align-items: center is what vertically centres the absolutely
1376
+ positioned glyph, which is why the icon rule below needs no
1377
+ inset-block-start / translateY pair the way the combobox's clear button does. */
1378
+ [data-terp="dataview-toolbar-search"] {
1379
+ position: relative;
1380
+ display: inline-flex;
1381
+ align-items: center;
1382
+ }
1383
+ /* The search glyph. Structural, and safe HERE and only here: this wrapper holds
1384
+ no caller slot, and once the clear button wears data-terp="iconbutton" it is a
1385
+ <button>, so this is the wrapper's only span child. neutral-500 on an
1386
+ aria-hidden decorative glyph owes no token pairing and axe abstains on it — do
1387
+ not "fix" it to fg-subtle by analogy with the status text below. */
1388
+ [data-terp="dataview-toolbar-search"] > span {
1389
+ position: absolute;
1390
+ inset-inline-start: var(--space-2);
1391
+ display: inline-flex;
1392
+ color: var(--color-fg-subtle);
1393
+ pointer-events: none;
1394
+ }
1395
+ /* The field. It must out-rank input[data-terp="input"] { padding: 0 var(--space-3) }
1396
+ and does so on SPECIFICITY rather than source order: two attributes (0,2,0)
1397
+ against an attribute plus a type (0,1,1). This is the equal-weight trap the
1398
+ header warns about, except the weights are not equal and that is the whole
1399
+ reason the rule is safe. The symmetric physical shorthand mirrors
1400
+ input[data-terp="input"][role="combobox"], which reserves room for its own clear
1401
+ button the same way; paired with logical insets above it is RTL-correct and
1402
+ zero-diff in LTR, the one place logical properties cost nothing here. 16rem is
1403
+ a fixed design width, not a caller measurement, so it is rule-side. */
1404
+ [data-terp="dataview-toolbar-search"] > [data-terp="input"] {
1405
+ padding: 0 var(--space-6);
1406
+ width: 16rem;
1407
+ max-width: 100%;
1408
+ }
1409
+ /* The clear-search button, the package's fourth bare-glyph icon button.
1410
+ Deliberately NOT merged into the combobox clear button's selector list: that
1411
+ rule adds min-width and min-height 1.75rem, a radius and a centring pair, so
1412
+ joining them would enlarge this hit area and round its hover chip — a
1413
+ hover-only diff no baseline can catch. It can never match :disabled (it takes
1414
+ no disabled prop and renders only while the field is non-empty), so the shared
1415
+ :disabled derivation is unaffected by its arrival. */
1416
+ [data-terp="dataview-toolbar-search"] > [data-terp="iconbutton"] {
1417
+ position: absolute;
1418
+ inset-inline-end: var(--space-1);
1419
+ display: inline-flex;
1420
+ padding: var(--space-1);
1421
+ background: transparent;
1422
+ border: none;
1423
+ cursor: pointer;
1424
+ color: var(--color-fg-subtle);
1425
+ }
1426
+ /* "Refreshing…", and this is the one place the prefer-an-existing-DOM-attribute
1427
+ rule is REFUSED with its own reasoning. [data-terp="dataview-toolbar"]
1428
+ > [role="status"] looks textbook — the component does own this span's role — but
1429
+ the selector's scope is the band's direct children, two of which are arbitrary
1430
+ caller slots, so a caller's live region in a filter slot would silently take the
1431
+ muted 14px treatment. Owning one instance of an attribute is not owning every
1432
+ element a selector reaches. The ink is fg-subtle for the same declared-pairing
1433
+ reason as the column-settings caption: neutral-500 fails AA on a tinted surface,
1434
+ and this band has one in selection mode. */
1435
+ [data-terp="dataview-toolbar-status"] {
1436
+ font-size: var(--font-size-sm);
1437
+ color: var(--color-fg-subtle);
1438
+ }
1439
+ /* The layout-toggle group: the anchor for the two buttons, as dataview-pager is
1440
+ for the four arrows. NOT dataview-pager itself — that carries gap: var(--space-2)
1441
+ and align-items: center, so sharing it would move these two apart. */
1442
+ [data-terp="dataview-toolbar-layout"] {
1443
+ display: inline-flex;
1444
+ gap: var(--space-1);
1445
+ }
1446
+ /* The two toggles at rest, which is to say INACTIVE. 2rem stays a literal, pager
1447
+ reasoning. Their border is the neutral-300 control boundary, which measures
1448
+ 1.42-2.36:1 across the surfaces a bordered control sits on in four themes — below
1449
+ the 3:1 SC 1.4.11 asks of a control boundary. Still not fixed here, because a token
1450
+ clearing 3:1 repaints every bordered control in the package, but no longer only a
1451
+ sentence: control-boundary-on-surface and control-boundary-on-canvas are declared
1452
+ pairings held at their measured floors by BELOW_UI in tokens.contrast.test.js, so
1453
+ the debt can only shrink and emptying that table is the acceptance criterion. */
1454
+ [data-terp="dataview-toolbar-layout"] > [data-terp="iconbutton"] {
1455
+ display: inline-flex;
1456
+ align-items: center;
1457
+ justify-content: center;
1458
+ min-height: 2rem;
1459
+ padding: var(--space-1) var(--space-2);
1460
+ background: transparent;
1461
+ border: 1px solid var(--color-neutral-300);
1462
+ border-radius: var(--radius-md);
1463
+ cursor: pointer;
1464
+ color: var(--color-fg-subtle);
1465
+ }
1466
+
1467
+ /* DataView: the table ------------------------------------------------------ */
1468
+ /* Cells are reached from the ROW rather than from the table. A
1469
+ "tbody td" descendant selector would also match the expanded row's cell,
1470
+ which has a padding and a border of its own and would then have to
1471
+ out-specify this rule rather than simply not match it. */
1472
+ [data-terp="dataview-table"] {
1473
+ width: 100%;
1474
+ border-collapse: collapse;
1475
+ table-layout: auto;
1476
+ }
1477
+ /* The header cell's inline axis follows density; its block axis does not,
1478
+ because --space-2 is already worth what --density-compact-cell-pad-y is. The
1479
+ 0.04em tracking stays a literal: the contract's letter-spacing scale offers
1480
+ tight / base / wide, and wide is 0.08em, which would double it. */
1481
+ [data-terp="dataview-table"] > thead > tr > th {
1482
+ position: relative;
1483
+ padding: var(--space-2) var(--density-cell-pad-x);
1484
+ text-align: left;
1485
+ font-size: var(--font-size-xs);
1486
+ font-weight: var(--font-weight-semibold);
1487
+ color: var(--color-fg-subtle);
1488
+ text-transform: uppercase;
1489
+ letter-spacing: 0.04em;
1490
+ border-bottom: 1px solid var(--color-neutral-200);
1491
+ white-space: nowrap;
1492
+ background: var(--color-neutral-0);
1493
+ }
1494
+ [data-terp="dataview-row"] > td {
1495
+ padding: var(--density-cell-pad-y) var(--density-cell-pad-x);
1496
+ border-bottom: 1px solid var(--color-neutral-100);
1497
+ font-size: var(--font-size-sm);
1498
+ color: var(--color-neutral-900);
1499
+ overflow: hidden;
1500
+ text-overflow: ellipsis;
1501
+ }
1502
+
1503
+ /* The row marker was conditional on the row being clickable, which made every
1504
+ rule keyed on it conditional too: a toned but unclickable row carried
1505
+ data-tone on an element no selector could reach, and the row-tones baseline
1506
+ would have lost its tints the moment the tone moved out of a style object.
1507
+ It is unconditional now, and clickability is an attribute of its own. */
1508
+ [data-terp="dataview-row"][data-clickable="true"] {
1509
+ cursor: pointer;
1510
+ }
1511
+ /* Row tone: a flat rule per tone, the shape Badge, Alert and toast use, because
1512
+ a private plumbing custom property is what tokens.guard.test.ts refuses.
1513
+
1514
+ A row's own state outranks the selection tint, and the :not() is what says so
1515
+ without depending on source order — both selectors weigh (0,2,0) otherwise,
1516
+ which is precisely the trap the layer comment at the top of this file
1517
+ describes. Selection stays legible through the checkbox and data-selected. */
1518
+ [data-terp="dataview-row"][data-selected="true"]:not([data-tone]) {
1519
+ background: var(--color-neutral-50);
1520
+ }
1521
+ [data-terp="dataview-row"][data-tone="neutral"] {
1522
+ background: var(--color-neutral-100);
1523
+ }
1524
+ [data-terp="dataview-row"][data-tone="info"] {
1525
+ background: var(--color-status-info-soft);
1526
+ }
1527
+ [data-terp="dataview-row"][data-tone="success"] {
1528
+ background: var(--color-status-success-soft);
1529
+ }
1530
+ [data-terp="dataview-row"][data-tone="warning"] {
1531
+ background: var(--color-status-warning-soft);
1532
+ }
1533
+ [data-terp="dataview-row"][data-tone="danger"] {
1534
+ background: var(--color-status-danger-soft);
1535
+ }
1536
+
1537
+ /* The sort control. aria-sort sits on the th and only while the column IS
1538
+ sorted, so the unsorted glyph's dimming keys off its ABSENCE rather than off
1539
+ an attribute minted for it — this component owns aria-sort, unlike the
1540
+ breadcrumb's aria-current, which a router stamps on every ancestor link. */
1541
+ [data-terp="dataview-column-sort"] {
1542
+ display: inline-flex;
1543
+ align-items: center;
1544
+ gap: var(--space-1);
1545
+ font: inherit;
1546
+ color: inherit;
1547
+ background: transparent;
1548
+ border: none;
1549
+ padding: 0;
1550
+ cursor: pointer;
1551
+ }
1552
+ [data-terp="dataview-table"] > thead > tr > th:not([aria-sort]) > [data-terp="dataview-column-sort"] > svg {
1553
+ opacity: 0.5;
1554
+ }
1555
+ /* The resize handle sits flush with the cell's inline end. A negative offset
1556
+ would let the last column's handle spill past the table and trip the scroll
1557
+ container's overflow-x, adding a spurious scrollbar. The z-index is a local
1558
+ lift inside the cell's stacking context and deliberately NOT a place in the
1559
+ app-wide --z-index-* order. */
1560
+ [data-terp="dataview-column-resizer"] {
1561
+ position: absolute;
1562
+ inset-block-start: 0;
1563
+ inset-inline-end: 0;
1564
+ width: 7px;
1565
+ height: 100%;
1566
+ cursor: col-resize;
1567
+ z-index: 1;
1568
+ touch-action: none;
1569
+ }
1570
+ /* System columns are pinned at pixel widths that ignore font size and density.
1571
+ Kept exactly as they were: a column-sizing model is on the debt list, and
1572
+ rewriting 40px as 2.5rem here would move them for any app whose root font
1573
+ size is not 16px. Scoped to th, which is where the width sits; the matching
1574
+ td wears the marker so the expand toggle has something to descend from. */
1575
+ th[data-terp="dataview-expand-cell"],
1576
+ th[data-terp="dataview-select-cell"] {
1577
+ width: 40px;
1578
+ }
1579
+ th[data-terp="dataview-actions-cell"] {
1580
+ width: 56px;
1581
+ }
1582
+ td[data-terp="dataview-actions-cell"] {
1583
+ text-align: right;
1584
+ }
1585
+ /* Visually hidden, twice over: the row's native activation button and the
1586
+ actions column's header text. The th qualifier is load-bearing — the actions
1587
+ BODY cell's only child is the row-actions cluster, and an unqualified
1588
+ descendant span would clip that out of the layout entirely. */
1589
+ [data-terp="dataview-row-open"],
1590
+ th[data-terp="dataview-actions-cell"] > span {
1591
+ position: absolute;
1592
+ width: 1px;
1593
+ height: 1px;
1594
+ padding: 0;
1595
+ margin: -1px;
1596
+ overflow: hidden;
1597
+ clip: rect(0 0 0 0);
1598
+ white-space: nowrap;
1599
+ border: 0;
1600
+ }
1601
+
1602
+ /* DataView: the card layout ------------------------------------------------ */
1603
+ /* The responsive stand-in for a row, so it reads the same cell-padding tokens:
1604
+ a card IS the row's padding, and having compact tighten the table while
1605
+ leaving the cards alone would make the attribute mean two things. */
1606
+ [data-terp="dataview-card-list"] {
1607
+ list-style: none;
1608
+ margin: 0;
1609
+ padding: var(--space-2);
1610
+ display: grid;
1611
+ gap: var(--space-2);
1612
+ }
1613
+ [data-terp="dataview-card"] {
1614
+ display: grid;
1615
+ gap: var(--space-2);
1616
+ padding: var(--density-cell-pad-y) var(--density-cell-pad-x);
1617
+ background: var(--color-neutral-0);
1618
+ border: 1px solid var(--color-neutral-200);
1619
+ border-radius: var(--radius-lg);
1620
+ box-shadow: var(--shadow-sm);
1621
+ }
1622
+ [data-terp="dataview-card"][data-clickable="true"] {
1623
+ cursor: pointer;
1624
+ }
1625
+ /* Tone outranks the resting surface on specificity alone — two attributes
1626
+ against one — so unlike the row's selection tint this needs no :not() and no
1627
+ source-order dependency. A card has no selected surface to compete with: the
1628
+ checkbox is the whole signal in card view. */
1629
+ [data-terp="dataview-card"][data-tone="neutral"] {
1630
+ background: var(--color-neutral-100);
1631
+ }
1632
+ [data-terp="dataview-card"][data-tone="info"] {
1633
+ background: var(--color-status-info-soft);
1634
+ }
1635
+ [data-terp="dataview-card"][data-tone="success"] {
1636
+ background: var(--color-status-success-soft);
1637
+ }
1638
+ [data-terp="dataview-card"][data-tone="warning"] {
1639
+ background: var(--color-status-warning-soft);
1640
+ }
1641
+ [data-terp="dataview-card"][data-tone="danger"] {
1642
+ background: var(--color-status-danger-soft);
1643
+ }
1644
+ [data-terp="dataview-card-main"] {
1645
+ display: flex;
1646
+ align-items: flex-start;
1647
+ gap: var(--space-2);
1648
+ }
1649
+ /* The two inline-flex wrappers in the card head: the one stopping click
1650
+ propagation around the checkbox, and the row-actions cluster. Addressed
1651
+ structurally because that is all they are — a span whose only job is to be a
1652
+ box, in a place where only those two spans sit. */
1653
+ [data-terp="dataview-card-main"] > span {
1654
+ display: inline-flex;
1655
+ }
1656
+ [data-terp="dataview-card-body"] {
1657
+ flex: 1;
1658
+ min-width: 0;
1659
+ }
1660
+ [data-terp="dataview-card-expanded"] {
1661
+ border-block-start: 1px solid var(--color-neutral-200);
1662
+ padding-block-start: var(--space-2);
1663
+ }
1664
+ /* The auto-composed body: what the columns' mobileSlot meta produces when the
1665
+ caller supplies no renderCard. */
1666
+ [data-terp="dataview-card-fields"] {
1667
+ display: grid;
1668
+ gap: var(--space-1);
1669
+ }
1670
+ [data-terp="dataview-card-heading"] {
1671
+ display: flex;
1672
+ align-items: center;
1673
+ gap: var(--space-2);
1674
+ flex-wrap: wrap;
1675
+ }
1676
+ [data-terp="dataview-card-title"] {
1677
+ font-weight: var(--font-weight-medium);
1678
+ }
1679
+ [data-terp="dataview-card-status"] {
1680
+ font-size: var(--font-size-sm);
1681
+ padding: 0 var(--space-2);
1682
+ background: var(--color-neutral-100);
1683
+ border-radius: var(--radius-full);
1684
+ color: var(--color-neutral-700);
1685
+ }
1686
+ /* Subtitle and date share one name because they share every declaration. Two
1687
+ markers would suggest the sheet distinguishes them, and it does not.
1688
+
1689
+ The ink is --color-fg-muted rather than the --color-fg-subtle every other
1690
+ subdued surface in this sheet takes, and that is a contrast fix rather than a
1691
+ preference. A card's background is whichever soft tone getRowTone returned, and
1692
+ subtle fails WCAG AA against three of the six surfaces this text can land on:
1693
+ measured, light neutral-100 4.34, light info-soft 4.46 and light danger-soft
1694
+ 4.35. fg-muted is 6.10 at its worst across all five themes, and carries the
1695
+ same value as --color-neutral-600 in every one of them, so this costs nothing
1696
+ but the name.
1697
+
1698
+ Worth knowing HOW that was found, because the lesson is about the lane rather
1699
+ than the colour. The numbers in the next sentence are the raw
1700
+ --color-neutral-500 step this rule originally carried — four failures rather
1701
+ than three, because midnight's neutral-500 (#7d8590) sits below the fg-subtle
1702
+ its palette lifts it to (#8b949e) and took info-soft to 4.12. This layout had
1703
+ no specimen at all, so the pairing had never been rendered for axe to measure;
1704
+ the first card specimen failed immediately.
1705
+ And axe reported exactly ONE of the four failures — the light danger-soft one
1706
+ — because the specimen paints the danger and warning tones and midnight's two
1707
+ failures are on info and success. Fixing what axe named would have left three
1708
+ real failures standing behind tones no specimen renders, which is why the fix
1709
+ is the token and why all five tone washes are now declared pairings. */
1710
+ [data-terp="dataview-card-meta"] {
1711
+ font-size: var(--font-size-sm);
1712
+ color: var(--color-fg-muted);
1713
+ }
1714
+
1715
+ /* The expand toggle, and the panel row it opens ---------------------------- */
1716
+ /* The toggle wears the shared iconbutton marker rather than a name of its own,
1717
+ which is the third time the package has met this exact control: a bare
1718
+ chevron with no border, transparent, in muted ink. The toast dismisser and the
1719
+ combobox's clear button are the other two, and all three are addressed the
1720
+ same way — structurally, from the marked ancestor they sit in, because where
1721
+ it sits is the only thing distinguishing one from another.
1722
+
1723
+ Two ancestors here, not one, and that is why this cannot key off the button.
1724
+ The same toggle renders in the table's expand column and in a card's head row.
1725
+ A descendant selector loose enough to cover both — anything like "an
1726
+ iconbutton inside a row" — would also catch an icon button an app renders
1727
+ inside one of its own cells, since a cell's content is arbitrary.
1728
+
1729
+ Adopting the marker is an INTENTIONAL diff, and a hover-only one no baseline
1730
+ can see: the toggle gains the shared hover background and the transition every
1731
+ other icon button in the package already has. It had neither, which read as an
1732
+ oversight rather than a decision. */
1733
+ [data-terp="dataview-expand-cell"] > [data-terp="iconbutton"],
1734
+ [data-terp="dataview-card-main"] > [data-terp="iconbutton"] {
1735
+ display: inline-flex;
1736
+ padding: var(--space-1);
1737
+ background: transparent;
1738
+ border: none;
1739
+ border-radius: var(--radius-sm);
1740
+ cursor: pointer;
1741
+ color: var(--color-fg-subtle);
1742
+ }
1743
+ /* The panel cell spans every column. Its block padding follows density like any
1744
+ other cell; its inline padding is deliberately one step wider than a cell's
1745
+ and stays on the spacing scale, because the extra inset is what reads as
1746
+ "this belongs to the row above" rather than as another row. */
1747
+ [data-terp="dataview-expanded-cell"] {
1748
+ padding: var(--density-cell-pad-y) var(--space-4);
1749
+ background: var(--color-neutral-50);
1750
+ border-block-end: 1px solid var(--color-neutral-200);
1751
+ }
1752
+
1753
+ /* DataView: the pagination bar --------------------------------------------- */
1754
+ /* The bar reads only the inline half of the cell padding, with --space-2
1755
+ vertically, so its left edge stays flush with the first cell's text at either
1756
+ density — the same bargain the toolbar strikes at the other end of the box. */
1757
+ [data-terp="dataview-pagination"] {
1758
+ display: flex;
1759
+ align-items: center;
1760
+ justify-content: space-between;
1761
+ gap: var(--space-3);
1762
+ flex-wrap: wrap;
1763
+ padding: var(--space-2) var(--density-cell-pad-x);
1764
+ border-block-start: 1px solid var(--color-neutral-200);
1765
+ font-size: var(--font-size-sm);
1766
+ color: var(--color-fg-subtle);
1767
+ }
1768
+ [data-terp="dataview-pager"] {
1769
+ display: inline-flex;
1770
+ align-items: center;
1771
+ gap: var(--space-2);
1772
+ }
1773
+ /* The four arrows: outlined icon buttons, addressed from the pager for the same
1774
+ reason the calendar's month arrows are addressed from the calendar header.
1775
+ The 2rem height is a literal and stays one: it happens to equal
1776
+ --density-compact-control-min-height, but reading that token would say these
1777
+ arrows are pinned to the compact scale, and moving the compact scale from
1778
+ theme.css would then resize a control at comfortable density. A small-control
1779
+ step in the density family is the honest fix and nothing has asked for one. */
1780
+ [data-terp="dataview-pager"] > [data-terp="iconbutton"] {
1781
+ display: inline-flex;
1782
+ align-items: center;
1783
+ min-height: 2rem;
1784
+ padding: var(--space-1) var(--space-2);
1785
+ background: var(--color-neutral-0);
1786
+ border: 1px solid var(--color-neutral-300);
1787
+ border-radius: var(--radius-md);
1788
+ cursor: pointer;
1789
+ color: var(--color-neutral-700);
1790
+ }
1791
+
1792
+ /* DataView: the per-row action cluster ------------------------------------- */
1793
+ [data-terp="dataview-row-actions"] {
1794
+ display: inline-flex;
1795
+ align-items: center;
1796
+ justify-content: flex-end;
1797
+ gap: var(--space-1);
1798
+ }
1799
+ /* The custom-control wrappers, addressed structurally rather than named: a span
1800
+ whose only job is to be a box, and the only span that is a DIRECT child here.
1801
+ Checked rather than assumed — the inline actions are buttons and the overflow
1802
+ menu's root is Popover's div, so neither is caught by this. */
1803
+ [data-terp="dataview-row-actions"] > span {
1804
+ display: inline-flex;
1805
+ }
1806
+ /* An inline action: a bordered text-and-icon control. The font pair is
1807
+ order-dependent and stays in this order — the font shorthand resets font-size,
1808
+ so inheriting the row's font and THEN stepping the size down is the sequence
1809
+ that produces small text rather than inherited text. */
1810
+ [data-terp="dataview-inline-action"] {
1811
+ font: inherit;
1812
+ font-size: var(--font-size-sm);
1813
+ display: inline-flex;
1814
+ align-items: center;
1815
+ min-height: 2rem;
1816
+ gap: var(--space-1);
1817
+ padding: var(--space-1) var(--space-2);
1818
+ background: transparent;
1819
+ border: 1px solid var(--color-neutral-300);
1820
+ border-radius: var(--radius-md);
1821
+ cursor: pointer;
1822
+ color: var(--color-neutral-700);
1823
+ }
1824
+ /* The leading icon: the only span inside the control, its sibling being a text
1825
+ node. */
1826
+ [data-terp="dataview-inline-action"] > span {
1827
+ display: inline-flex;
1828
+ }
1829
+ /* Destructive is the same vocabulary MenuItem already uses for the same idea, so
1830
+ the attribute name is data-destructive there and here rather than a second
1831
+ spelling of one concept. */
1832
+ [data-terp="dataview-inline-action"][data-destructive="true"] {
1833
+ color: var(--color-status-danger);
1834
+ }
1835
+
1836
+ /* DataView: the view-options panel ----------------------------------------- */
1837
+ /* The panel content. It carries data-owner="dataview-column-settings" on the
1838
+ portalled panel too, so a panel-level rule has somewhere to hang if this ever
1839
+ needs geometry a menu's panel does not have. */
1840
+ /* No rule for the trigger's text: [data-terp="menu-trigger"] already declares
1841
+ font-size: var(--font-size-sm) and the span inherits it, so a marker here would
1842
+ be a name and a rule that change nothing — which is the offence the density
1843
+ tokens were deleted for, in miniature. The span stays unmarked deliberately. */
1844
+ [data-terp="dataview-column-settings"] {
1845
+ display: grid;
1846
+ gap: var(--space-1);
1847
+ }
1848
+ [data-terp="dataview-column-settings-title"] {
1849
+ padding: var(--space-1) var(--space-2);
1850
+ font-size: var(--font-size-sm);
1851
+ font-weight: var(--font-weight-medium);
1852
+ color: var(--color-fg-subtle);
1853
+ }
1854
+ [data-terp="dataview-column-option"] {
1855
+ display: flex;
1856
+ align-items: center;
1857
+ gap: var(--space-2);
1858
+ padding: var(--space-1) var(--space-2);
1859
+ border-radius: var(--radius-sm);
1860
+ }
1861
+ /* The checkbox and its column name. The only label in the panel, so it is
1862
+ reached structurally rather than named. */
1863
+ [data-terp="dataview-column-option"] > label {
1864
+ display: flex;
1865
+ align-items: center;
1866
+ gap: var(--space-2);
1867
+ flex: 1;
1868
+ cursor: pointer;
1869
+ font-size: var(--font-size-sm);
1870
+ }
1871
+ /* The reorder arrows, which now wear the shared icon-button marker — the fourth
1872
+ instance of the bare-chevron archetype, after the toast dismisser, the
1873
+ combobox's clear button and the expand toggle. Same structural treatment: the
1874
+ geometry hangs off the row they sit in. */
1875
+ [data-terp="dataview-column-option"] > [data-terp="iconbutton"] {
1876
+ display: inline-flex;
1877
+ padding: var(--space-1);
1878
+ background: transparent;
1879
+ border: none;
1880
+ border-radius: var(--radius-sm);
1881
+ cursor: pointer;
1882
+ color: var(--color-neutral-700);
1883
+ }
1884
+
1885
+ /* Resource list ------------------------------------------------------------ */
1886
+ /* The plain listing screen: a write-gated create row, then the rows. The DataView is
1887
+ the same job at scale, and this one stays deliberately simple, so its rules are
1888
+ geometry plus two inks.
1889
+
1890
+ The create row keeps its flex layout and the input keeps flex: 1 as a RULE rather
1891
+ than becoming a 1fr auto grid, and that is a move rather than a decision. With
1892
+ flex: 1 the input's basis is 0 so it may shrink below its intrinsic width; a 1fr
1893
+ track floors at min-content instead. The two agree at this list's 40rem cap, so
1894
+ swapping them would have changed nothing anybody could see until some narrower
1895
+ container found the difference — which is the kind of diff this migration exists
1896
+ not to introduce.
1897
+
1898
+ Neither paragraph resets its margin, and that is verbatim rather than an
1899
+ oversight: both are <p> elements whose default margin is what separates them from
1900
+ the form above and the rows below. Adding margin: 0 here would move the rows. */
1901
+ [data-terp="resource-list"] {
1902
+ display: grid;
1903
+ gap: var(--space-4);
1904
+ max-width: 40rem;
1905
+ }
1906
+ [data-terp="resource-list-create"] {
1907
+ display: flex;
1908
+ gap: var(--space-2);
1909
+ }
1910
+ [data-terp="resource-list-create"] > [data-terp="input"] {
1911
+ flex: 1;
1912
+ }
1913
+ [data-terp="resource-list-error"] {
1914
+ color: var(--color-status-danger);
1915
+ }
1916
+ [data-terp="resource-list-empty"] {
1917
+ color: var(--color-neutral-600);
1918
+ }
1919
+ [data-terp="resource-list-items"] {
1920
+ list-style: none;
1921
+ margin: 0;
1922
+ padding: 0;
1923
+ display: grid;
1924
+ gap: var(--space-2);
1925
+ }
1926
+ [data-terp="resource-list-row"] {
1927
+ display: flex;
1928
+ align-items: center;
1929
+ justify-content: space-between;
1930
+ gap: var(--space-3);
1931
+ padding: var(--space-3);
1932
+ border: 1px solid var(--color-neutral-200);
1933
+ border-radius: var(--radius-md);
1934
+ background: var(--color-neutral-0);
1935
+ }
1936
+
1937
+ /* Empty / error / loading states ------------------------------------------- */
1938
+ /* Same centred block, opposite messages: empty is a dashed outline on the page
1939
+ surface because nothing is wrong, error is a filled danger wash because
1940
+ something is. */
1941
+ [data-terp="empty-state"] {
1942
+ display: grid;
1943
+ justify-items: center;
1944
+ gap: var(--space-3);
1945
+ padding: var(--space-8) var(--space-6);
1946
+ text-align: center;
1947
+ color: var(--color-neutral-600);
1948
+ border: 1px dashed var(--color-neutral-300);
1949
+ border-radius: var(--radius-lg);
1950
+ background: var(--color-neutral-0);
1951
+ }
1952
+ [data-terp="empty-state-icon"] {
1953
+ color: var(--color-neutral-400);
1954
+ display: inline-flex;
1955
+ }
1956
+ [data-terp="empty-state-title"] {
1957
+ margin: 0;
1958
+ color: var(--color-neutral-900);
1959
+ font-size: var(--font-size-base);
1960
+ font-weight: var(--font-weight-semibold);
1961
+ }
1962
+ [data-terp="empty-state-description"] {
1963
+ color: var(--color-neutral-600);
1964
+ font-size: var(--font-size-sm);
1965
+ line-height: 1.5;
1966
+ max-width: 36ch;
1967
+ }
1968
+ [data-terp="error-state"] {
1969
+ display: grid;
1970
+ justify-items: center;
1971
+ gap: var(--space-3);
1972
+ padding: var(--space-6);
1973
+ text-align: center;
1974
+ color: var(--color-neutral-700);
1975
+ background: var(--color-status-danger-soft);
1976
+ border: 1px solid var(--color-status-danger);
1977
+ border-radius: var(--radius-lg);
1978
+ }
1979
+ [data-terp="error-state-icon"] {
1980
+ color: var(--color-status-danger);
1981
+ display: inline-flex;
1982
+ }
1983
+ [data-terp="error-state-title"] {
1984
+ margin: 0;
1985
+ color: var(--color-status-danger);
1986
+ font-size: var(--font-size-base);
1987
+ font-weight: var(--font-weight-semibold);
1988
+ }
1989
+ [data-terp="error-state-description"] {
1990
+ color: var(--color-neutral-700);
1991
+ font-size: var(--font-size-sm);
1992
+ line-height: 1.5;
1993
+ max-width: 48ch;
1994
+ }
1995
+ [data-terp="loading-state"] {
1996
+ display: flex;
1997
+ align-items: center;
1998
+ justify-content: center;
1999
+ gap: var(--space-2);
2000
+ padding: var(--space-6);
2001
+ color: var(--color-fg-subtle);
2002
+ font-size: var(--font-size-sm);
2003
+ }
2004
+ [data-terp="loading-state-spinner"] {
2005
+ color: var(--color-fg-accent);
2006
+ }
2007
+ /* The ring's box is inline (the caller passes a pixel size); everything about
2008
+ how it sits on the line is not. */
2009
+ [data-terp="spinner-ring"] {
2010
+ display: inline-block;
2011
+ vertical-align: middle;
2012
+ line-height: 0;
2013
+ }
2014
+ [data-terp="spinner-ring"] > svg {
2015
+ display: block;
2016
+ }
2017
+
2018
+ /* Icons -------------------------------------------------------------------- */
2019
+ /* Icon takes any CSS length for its size, so width/height stay inline; the box
2020
+ it draws them in does not vary. */
2021
+ [data-terp="icon"] {
2022
+ display: inline-flex;
2023
+ align-items: center;
2024
+ justify-content: center;
2025
+ line-height: 1;
2026
+ color: inherit;
2027
+ }
2028
+ [data-terp="nav-icon"] {
2029
+ display: inline-flex;
2030
+ align-items: center;
2031
+ justify-content: center;
2032
+ width: 1.25rem;
2033
+ height: 1.25rem;
2034
+ flex: 0 0 1.25rem;
2035
+ font-size: 1rem;
2036
+ line-height: 1;
2037
+ }
2038
+ [data-terp="nav-icon-fallback"] {
2039
+ display: inline-flex;
2040
+ align-items: center;
2041
+ justify-content: center;
2042
+ width: 100%;
2043
+ height: 100%;
2044
+ border-radius: var(--radius-sm);
2045
+ background: var(--color-brand-primary-soft);
2046
+ color: var(--color-fg-accent);
2047
+ font-size: 0.7em;
2048
+ font-weight: var(--font-weight-medium);
2049
+ line-height: 1;
2050
+ }
25
2051
 
26
- /** The interaction-state rules layered over the component's inline base styles. */
27
- export const TERP_STYLES_CSS = `
28
- /* Document reset: the app shell owns the full canvas. Without this the
29
- browser's default 8px body margin leaves the document's own (white)
30
- canvas visible as a ring around the shell most obvious in Studio's
31
- preview iframe and in dark mode. The body carries the same canvas token
32
- as the shell so overscroll never flashes white. */
33
- html, body {
34
- margin: 0;
2052
+ /* Combobox ----------------------------------------------------------------- */
2053
+ /* The text box keeps the shared input marker, so it inherits the whole control
2054
+ surface; role="combobox" is already on it for ARIA and distinguishes its
2055
+ geometry here without minting an attribute for something the element
2056
+ already says. The extra inline-end padding reserves the clear button's box. */
2057
+ input[data-terp="input"][role="combobox"] {
2058
+ width: 100%;
2059
+ min-width: 0;
2060
+ padding: 0 calc(var(--space-3) + 1.5rem) 0 var(--space-3);
35
2061
  }
36
- body {
37
- background: var(--color-neutral-50);
2062
+ [data-terp="combobox"],
2063
+ [data-terp="combobox-field"] {
2064
+ position: relative;
2065
+ display: grid;
38
2066
  }
39
- /* Border-box baseline: react-core sizes components as padding-inclusive
40
- (e.g. LoginView's 100vh page with padding, inputs at width:100% with
41
- padding) and app modules cannot ship a stylesheet of their own, so the
42
- framework owns this. Under the browser default (content-box) a
43
- min-height:100vh + padding screen overflows the viewport by exactly its
44
- padding — phantom scrollbars on pages that fit. */
45
- *,
46
- *::before,
47
- *::after {
48
- box-sizing: border-box;
2067
+ /* Addressed structurally rather than by a marker of its own: it is an
2068
+ iconbutton, and the only thing distinguishing it is where it sits. */
2069
+ [data-terp="combobox-field"] > [data-terp="iconbutton"] {
2070
+ position: absolute;
2071
+ inset-inline-end: var(--space-1);
2072
+ inset-block-start: 50%;
2073
+ transform: translateY(-50%);
2074
+ border: none;
2075
+ background: transparent;
2076
+ color: var(--color-fg-subtle);
2077
+ cursor: pointer;
2078
+ min-width: 1.75rem;
2079
+ min-height: 1.75rem;
2080
+ border-radius: var(--radius-sm);
2081
+ }
2082
+ /* Stacked at the popover level, which is what this is: an anchored disclosure
2083
+ panel. It read --z-index-drawer, and was the token's only reader anywhere —
2084
+ while the actual drawer, AppShell's mobile sidebar, hardcodes 50. So the one
2085
+ binding that existed pointed at the wrong level. The drawer token keeps its
2086
+ place in the family because AppShell's own migration is its reader; it is a
2087
+ pending consumer, not an unread token. */
2088
+ [data-terp="combobox-list"] {
2089
+ position: absolute;
2090
+ inset-inline-start: 0;
2091
+ inset-inline-end: 0;
2092
+ inset-block-start: calc(100% + var(--space-1));
2093
+ z-index: var(--z-index-popover);
2094
+ display: grid;
2095
+ gap: var(--space-1);
2096
+ max-height: 16rem;
2097
+ overflow-y: auto;
2098
+ padding: var(--space-1);
2099
+ background: var(--color-neutral-0);
2100
+ border: 1px solid var(--color-neutral-200);
2101
+ border-radius: var(--radius-lg);
2102
+ box-shadow: var(--shadow-lg);
2103
+ }
2104
+ [data-terp="combobox-option"] {
2105
+ font-family: var(--font-family-sans);
2106
+ font-size: var(--font-size-sm);
2107
+ font-weight: var(--font-weight-normal);
2108
+ line-height: 1.25;
2109
+ text-align: left;
2110
+ padding: var(--space-2) var(--space-3);
2111
+ border: none;
2112
+ border-radius: var(--radius-sm);
2113
+ background: transparent;
2114
+ color: var(--color-neutral-900);
2115
+ cursor: pointer;
2116
+ }
2117
+ [data-terp="combobox-empty"] {
2118
+ padding: var(--space-2) var(--space-3);
2119
+ color: var(--color-fg-subtle);
2120
+ font-size: var(--font-size-sm);
49
2121
  }
50
2122
 
51
- /* Themed scrollbars: the OS default chrome (thick, grey, light-only) ignores
52
- the app theme and looks foreign against a token-styled surface most
53
- obvious on the horizontal overflow of a DataView table. These rules give
54
- every scroll container a thin, token-coloured bar that tracks light/dark.
55
- Firefox uses the inheritable scrollbar-* properties (set once on the root);
56
- WebKit/Blink use the ::-webkit-scrollbar pseudo-elements (not inherited, so
57
- they match every scrollable element globally). Paired with the color-scheme
58
- declaration on the token roots so any native chrome we do not restyle here
59
- (notably the native <select> option popup) also follows the theme. */
60
- html {
61
- scrollbar-width: thin;
62
- scrollbar-color: var(--color-neutral-300) transparent;
2123
+ /* Date pickers ------------------------------------------------------------- */
2124
+ /* The trigger is a button wearing the input surface, so the element type is
2125
+ again what separates its geometry from the three text controls. */
2126
+ button[data-terp="input"] {
2127
+ min-height: var(--density-control-min-height);
2128
+ width: 100%;
2129
+ display: inline-flex;
2130
+ align-items: center;
2131
+ justify-content: space-between;
2132
+ gap: var(--space-2);
2133
+ padding: 0 var(--space-3);
2134
+ line-height: 1.2;
2135
+ cursor: pointer;
63
2136
  }
64
- ::-webkit-scrollbar {
65
- width: 10px;
66
- height: 10px;
2137
+ button[data-terp="input"][data-placeholder="true"] {
2138
+ color: var(--color-fg-subtle);
67
2139
  }
68
- ::-webkit-scrollbar-track {
2140
+ [data-terp="calendar"] {
2141
+ display: grid;
2142
+ gap: var(--space-2);
2143
+ min-width: 18rem;
2144
+ }
2145
+ [data-terp="calendar-header"] {
2146
+ display: flex;
2147
+ align-items: center;
2148
+ justify-content: space-between;
2149
+ gap: var(--space-2);
2150
+ }
2151
+ [data-terp="calendar-header"] > [data-terp="iconbutton"] {
2152
+ display: inline-flex;
2153
+ align-items: center;
2154
+ justify-content: center;
2155
+ width: 2rem;
2156
+ height: 2rem;
2157
+ border: 1px solid var(--color-neutral-300);
2158
+ border-radius: var(--radius-md);
69
2159
  background: transparent;
2160
+ color: var(--color-neutral-700);
2161
+ cursor: pointer;
70
2162
  }
71
- ::-webkit-scrollbar-thumb {
72
- background-color: var(--color-neutral-300);
73
- border: 2px solid transparent;
74
- background-clip: padding-box;
75
- border-radius: var(--radius-full);
2163
+ [data-terp="calendar-title"] {
2164
+ font-weight: var(--font-weight-semibold);
2165
+ color: var(--color-neutral-900);
76
2166
  }
77
- ::-webkit-scrollbar-thumb:hover {
78
- background-color: var(--color-neutral-400);
2167
+ /* The month grid holds six week rows; each row holds seven day cells. The split is
2168
+ what makes the ARIA grid valid, and the two gaps below are the one token the flat
2169
+ 42-cell grid used for both axes, so the cells land unmoved. */
2170
+ [data-terp="calendar-grid"] {
2171
+ display: grid;
2172
+ gap: var(--space-1);
79
2173
  }
80
- ::-webkit-scrollbar-corner {
2174
+ [data-terp="calendar-week"] {
2175
+ display: grid;
2176
+ grid-template-columns: repeat(7, 1fr);
2177
+ gap: var(--space-1);
2178
+ }
2179
+ /* The weekday initials and the days outside the visible month are the calendar's two
2180
+ subdued surfaces, and both take the SEMANTIC subtle ink rather than a ramp step. The
2181
+ distinction is not cosmetic: token-pairs.json declares subtle-on-surface, so the
2182
+ contrast gate measures this pairing in every theme, while --color-neutral-500 is the
2183
+ raw step and two palettes deliberately lift --color-fg-subtle above it (midnight
2184
+ #8b949e vs #7d8590, twilight #a294bd vs #9d90b8) — exactly the correction 0.7.0 made
2185
+ after axe found neutral-500 used as muted copy. The weekday row is aria-hidden, so
2186
+ axe can never report it: an undeclared pairing there is permanently unmeasured. */
2187
+ [data-terp="calendar-weekday"] {
2188
+ text-align: center;
2189
+ font-size: var(--font-size-xs);
2190
+ color: var(--color-fg-subtle);
2191
+ }
2192
+ [data-terp="calendar-day"] {
2193
+ font-family: var(--font-family-sans);
2194
+ font-size: var(--font-size-sm);
2195
+ font-weight: var(--font-weight-normal);
2196
+ line-height: 1.25;
2197
+ min-height: 2rem;
2198
+ border: 1px solid transparent;
2199
+ border-radius: var(--radius-md);
2200
+ background: transparent;
2201
+ color: var(--color-neutral-900);
2202
+ cursor: pointer;
2203
+ }
2204
+ /* A day outside the visible month still occupies its grid cell — it is dimmed rather
2205
+ than hidden so the weeks keep their shape. Dimmed by INK, not by opacity, and that
2206
+ was a real defect rather than a preference: opacity: 0.45 composited the body ink
2207
+ against the panel to an effective colour that fails WCAG AA in all five themes
2208
+ (measured — light 2.93:1, dark 3.93:1, midnight 4.25:1, twilight 4.05:1, contrast
2209
+ 3.36:1). Nothing could see it. axe never reached the subtree because nothing in the
2210
+ repo opened a calendar, and the static gate measures declared token PAIRINGS, which a
2211
+ composite of one token against another is not. The subtle ink is 4.76:1 at worst.
2212
+
2213
+ Opacity also dimmed the cell's background, so an out-of-month day that is selected or
2214
+ inside a range used to render as a washed-out chip; it now paints at full strength,
2215
+ because a selected day should read as selected whichever month owns it. */
2216
+ [data-terp="calendar-day"][data-outside-month="true"] {
2217
+ color: var(--color-fg-subtle);
2218
+ }
2219
+
2220
+ /* Fields ------------------------------------------------------------------- */
2221
+ [data-terp="field"],
2222
+ [data-terp="field-label"] {
2223
+ display: grid;
2224
+ gap: var(--space-1);
2225
+ }
2226
+ [data-terp="field-label-text"] {
2227
+ font-weight: var(--font-weight-medium);
2228
+ font-size: var(--font-size-sm);
2229
+ color: var(--color-neutral-700);
2230
+ }
2231
+ [data-terp="field-hint"] {
2232
+ color: var(--color-fg-subtle);
2233
+ font-size: var(--font-size-xs);
2234
+ }
2235
+ [data-terp="field-error"] {
2236
+ color: var(--color-status-danger);
2237
+ font-size: var(--font-size-xs);
2238
+ font-weight: var(--font-weight-medium);
2239
+ }
2240
+
2241
+ /* Tooltips ----------------------------------------------------------------- */
2242
+ /* No display declaration here on purpose: the panel is hidden with the hidden
2243
+ attribute, and any author display would beat the UA's [hidden] rule and
2244
+ leave the tooltip permanently visible. */
2245
+ [data-terp="tooltip-anchor"] {
2246
+ position: relative;
2247
+ display: inline-flex;
2248
+ }
2249
+ [data-terp="tooltip"] {
2250
+ position: absolute;
2251
+ z-index: 1;
2252
+ inset-block-end: calc(100% + var(--space-1));
2253
+ inset-inline-start: 0;
2254
+ max-inline-size: min(18rem, calc(100vw - 2 * var(--space-4)));
2255
+ padding: var(--space-1) var(--space-2);
2256
+ border-radius: var(--radius-sm);
2257
+ color: var(--color-neutral-0);
2258
+ background: var(--color-neutral-900);
2259
+ font-size: var(--font-size-xs);
2260
+ font-weight: var(--font-weight-medium);
2261
+ line-height: 1.4;
2262
+ box-shadow: var(--shadow-md);
2263
+ pointer-events: none;
2264
+ white-space: normal;
2265
+ }
2266
+
2267
+ /* Menus -------------------------------------------------------------------- */
2268
+ /* The trigger. It wore the shared iconbutton marker and overrode every one of
2269
+ that marker's declarations inline, which made it indistinguishable in the sheet
2270
+ from a pagination arrow and a toast dismisser. Its own name now, so this look —
2271
+ an outlined control with control typography — is stated once and reachable. */
2272
+ [data-terp="menu-trigger"] {
2273
+ display: inline-flex;
2274
+ align-items: center;
2275
+ justify-content: center;
2276
+ min-height: 2rem;
2277
+ gap: var(--space-1);
2278
+ padding: var(--space-1) var(--space-2);
2279
+ background: transparent;
2280
+ color: var(--color-neutral-700);
2281
+ border: 1px solid var(--color-neutral-300);
2282
+ border-radius: var(--radius-md);
2283
+ cursor: pointer;
2284
+ font-family: var(--font-family-sans);
2285
+ font-size: var(--font-size-sm);
2286
+ font-weight: var(--font-weight-normal);
2287
+ line-height: 1.25;
2288
+ transition: background-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
2289
+ }
2290
+ /* The panel's contents. This sits INSIDE popover-panel, which supplies the
2291
+ surface — so the menu owns only the stacking of its items. */
2292
+ [data-terp="menu"] {
2293
+ display: grid;
2294
+ gap: var(--space-1);
2295
+ }
2296
+ [data-terp="menu-item"] {
2297
+ display: flex;
2298
+ align-items: center;
2299
+ justify-content: flex-start;
2300
+ gap: var(--space-2);
2301
+ width: 100%;
2302
+ padding: var(--space-2);
81
2303
  background: transparent;
2304
+ border: none;
2305
+ border-radius: var(--radius-sm);
2306
+ text-align: left;
2307
+ cursor: pointer;
2308
+ color: var(--color-neutral-900);
2309
+ font-family: var(--font-family-sans);
2310
+ font-size: var(--font-size-sm);
2311
+ font-weight: var(--font-weight-normal);
2312
+ line-height: 1.25;
2313
+ transition: background-color 150ms ease, color 150ms ease;
2314
+ }
2315
+ /* Destructive is the one enumerable choice an item has, so it is an attribute.
2316
+ The disabled treatment is a state rule keyed on :disabled, because the element
2317
+ is a real button carrying the real attribute. */
2318
+ [data-terp="menu-item"][data-destructive="true"] {
2319
+ color: var(--color-status-danger);
2320
+ }
2321
+ [data-terp="menu-item-icon"],
2322
+ [data-terp="menu-item-check"] {
2323
+ display: inline-flex;
2324
+ }
2325
+ /* The check sits at the far end of the row rather than beside the label. */
2326
+ [data-terp="menu-item-check"] {
2327
+ margin-inline-start: auto;
2328
+ }
2329
+
2330
+ /* Theme and language menus -------------------------------------------------- */
2331
+ /* Two variants, and only ONE of them is a popover: inline returns a bare Menu,
2332
+ so its root takes the wrapper geometry declared with Popover BELOW, while
2333
+ stacked renders
2334
+ a captioned grid with the menu inside it. So the stacked rule replaces the
2335
+ display rather than adding to it. The inherited position: relative is inert
2336
+ here — the stacked root has no positioned descendant of its own, because the
2337
+ menu inside carries its own wrapper. */
2338
+ [data-terp="theme-toggle"][data-variant="stacked"],
2339
+ [data-terp="language-switcher"][data-variant="stacked"] {
2340
+ display: grid;
2341
+ justify-items: start;
2342
+ gap: var(--space-1);
2343
+ font-size: var(--font-size-sm);
2344
+ }
2345
+ [data-terp="theme-toggle-label"],
2346
+ [data-terp="language-switcher-label"] {
2347
+ color: var(--color-neutral-600);
2348
+ }
2349
+
2350
+ /* Account menu -------------------------------------------------------------- */
2351
+ /* UserMenu's root is the popover wrapper too, but unlike the two chrome toggles
2352
+ its trigger and its panel look nothing like the defaults — which is why it was
2353
+ the last consumer of Menu's triggerStyle and panelStyle props, and why both
2354
+ could be deleted once the root had a name. The trigger is reached by descending
2355
+ from that name; the PANEL cannot be, because it is portalled to document.body,
2356
+ so Popover stamps data-owner on it and the geometry hangs off that instead. */
2357
+ /* A full-width identity row rather than the outlined control menu-trigger
2358
+ describes: two attributes against one, so it wins on specificity in the same
2359
+ layer without needing to be declared after it. */
2360
+ [data-terp="user-menu"] [data-terp="menu-trigger"] {
2361
+ justify-content: flex-start;
2362
+ gap: var(--space-2);
2363
+ width: 100%;
2364
+ padding: var(--space-2);
2365
+ text-align: left;
2366
+ color: var(--color-neutral-900);
2367
+ border-color: transparent;
2368
+ min-height: 0;
2369
+ }
2370
+ /* Icon-rail mode: the avatar alone, centred, with nothing around it. */
2371
+ [data-terp="user-menu"][data-variant="collapsed"] [data-terp="menu-trigger"] {
2372
+ justify-content: center;
2373
+ gap: 0;
2374
+ padding: 0;
2375
+ }
2376
+ [data-terp="user-menu-avatar"] {
2377
+ display: inline-flex;
2378
+ align-items: center;
2379
+ justify-content: center;
2380
+ width: 2rem;
2381
+ height: 2rem;
2382
+ flex-shrink: 0;
2383
+ border-radius: var(--radius-full);
2384
+ background: var(--color-brand-primary);
2385
+ color: var(--color-brand-primary-contrast);
2386
+ font-size: var(--font-size-sm);
2387
+ font-weight: var(--font-weight-medium);
2388
+ }
2389
+ [data-terp="user-menu-identity"] {
2390
+ display: grid;
2391
+ min-width: 0;
2392
+ font-size: var(--font-size-sm);
2393
+ }
2394
+ [data-terp="user-menu-email"] {
2395
+ overflow: hidden;
2396
+ text-overflow: ellipsis;
2397
+ white-space: nowrap;
2398
+ }
2399
+ [data-terp="user-menu-role"] {
2400
+ color: var(--color-neutral-600);
2401
+ }
2402
+ /* The panel's identity block, and the panel's own geometry — both keyed on the
2403
+ owner, because the portal put them outside every selector that could otherwise
2404
+ reach them. */
2405
+ [data-terp="popover-panel"][data-owner="user-menu"] {
2406
+ min-width: 14rem;
2407
+ padding: var(--space-2);
2408
+ }
2409
+ [data-terp="user-menu-header"] {
2410
+ display: grid;
2411
+ gap: var(--space-1);
2412
+ padding: var(--space-2);
2413
+ margin-block-end: var(--space-1);
2414
+ border-block-end: 1px solid var(--color-neutral-200);
2415
+ font-size: var(--font-size-sm);
2416
+ overflow-wrap: anywhere;
2417
+ }
2418
+
2419
+ /* Page actions ------------------------------------------------------------- */
2420
+ [data-terp="page-actions"] {
2421
+ display: flex;
2422
+ flex-wrap: wrap;
2423
+ align-items: center;
2424
+ justify-content: flex-end;
2425
+ gap: var(--space-2);
2426
+ }
2427
+
2428
+ /* Markdown ------------------------------------------------------------------ */
2429
+ /* One declaration, and it is the whole design. Markdown emitted a fragment of
2430
+ bare block elements with no root, so it could not be found, styled or verified
2431
+ by anything. A wrapper fixes that but a wrapper is normally a new block box in
2432
+ every consumer's layout; display: contents generates NO box, so the blocks stay
2433
+ in-flow siblings exactly as the fragment left them, and inside a Stack or a Card
2434
+ or a Page article they become real flex and grid items so the parent's gap still
2435
+ falls between each one rather than once around the lot.
2436
+
2437
+ Two things to know before adding a second declaration here. Every non-inherited
2438
+ property on a display: contents element is silently dropped — padding, margin,
2439
+ border, background, box-shadow — so prose rhythm has to be written as descendant
2440
+ rules ([data-terp="markdown"] p, ... ul), and a declaration added here would
2441
+ simply do nothing with nothing to say so. And display: contents does not change
2442
+ selector matching, only box generation: a parent's > * child selector now matches
2443
+ this wrapper rather than the blocks. Nothing in this sheet uses one, which is why
2444
+ the wrapper is free today.
2445
+
2446
+ Under SSR the sheet is not injected at all (the injector is document-guarded), so
2447
+ a server-rendered page has this element as a block box until hydration. That is
2448
+ true of every rule in this file, but here the failure mode is restructuring rather
2449
+ than degrading. */
2450
+ [data-terp="markdown"] {
2451
+ display: contents;
2452
+ }
2453
+
2454
+ /* Toasts -------------------------------------------------------------------- */
2455
+ /* The viewport is fixed to the corner of the screen, which is what puts it —
2456
+ like a portalled panel and a top-layer dialog — outside the box any per-specimen
2457
+ element screenshot could see. It reads the stacking token published for it; the
2458
+ component hardcoded 100. */
2459
+ [data-terp="toast-viewport"] {
2460
+ position: fixed;
2461
+ inset-block-end: var(--space-4);
2462
+ inset-inline-end: var(--space-4);
2463
+ display: grid;
2464
+ gap: var(--space-2);
2465
+ z-index: var(--z-index-toast);
2466
+ max-width: min(22.5rem, calc(100vw - 2 * var(--space-4)));
2467
+ }
2468
+ [data-terp="toast"] {
2469
+ display: grid;
2470
+ grid-template-columns: auto 1fr auto;
2471
+ align-items: start;
2472
+ gap: var(--space-2);
2473
+ padding: var(--space-3) var(--space-4);
2474
+ border-radius: var(--radius-md);
2475
+ border: 1px solid transparent;
2476
+ border-inline-start-width: 3px;
2477
+ background: var(--color-neutral-0);
2478
+ color: var(--color-neutral-900);
2479
+ font-size: var(--font-size-sm);
2480
+ box-shadow: var(--shadow-md);
2481
+ }
2482
+ [data-terp="toast-icon"] {
2483
+ display: inline-flex;
2484
+ align-items: center;
2485
+ padding-block-start: 2px;
2486
+ }
2487
+ [data-terp="toast-body"] {
2488
+ display: grid;
2489
+ gap: var(--space-1);
2490
+ }
2491
+ [data-terp="toast-title"] {
2492
+ font-weight: var(--font-weight-semibold);
2493
+ }
2494
+ /* Tone is data-tone, the same attribute Badge and Alert use, so one tone means one
2495
+ thing across the package. Written flat, per tone, like theirs: a private custom
2496
+ property would read better but tokens.guard.test.ts holds every var() in this
2497
+ package to a property the contract actually publishes, and an internal plumbing
2498
+ variable is not a token a theme editor should be offered. */
2499
+ [data-terp="toast"][data-tone="success"] {
2500
+ border-color: var(--color-status-success-soft);
2501
+ border-inline-start-color: var(--color-status-success);
82
2502
  }
2503
+ [data-terp="toast"][data-tone="success"] [data-terp="toast-icon"],
2504
+ [data-terp="toast"][data-tone="success"] [data-terp="toast-title"] {
2505
+ color: var(--color-status-success);
2506
+ }
2507
+ [data-terp="toast"][data-tone="warning"] {
2508
+ border-color: var(--color-status-warning-soft);
2509
+ border-inline-start-color: var(--color-status-warning);
2510
+ }
2511
+ [data-terp="toast"][data-tone="warning"] [data-terp="toast-icon"],
2512
+ [data-terp="toast"][data-tone="warning"] [data-terp="toast-title"] {
2513
+ color: var(--color-status-warning);
2514
+ }
2515
+ [data-terp="toast"][data-tone="danger"] {
2516
+ border-color: var(--color-status-danger-soft);
2517
+ border-inline-start-color: var(--color-status-danger);
2518
+ }
2519
+ [data-terp="toast"][data-tone="danger"] [data-terp="toast-icon"],
2520
+ [data-terp="toast"][data-tone="danger"] [data-terp="toast-title"] {
2521
+ color: var(--color-status-danger);
2522
+ }
2523
+ /* The dismisser, addressed structurally for the same reason the combobox's clear
2524
+ button and the calendar's month arrows are. */
2525
+ [data-terp="toast"] > [data-terp="iconbutton"] {
2526
+ border: none;
2527
+ background: none;
2528
+ padding: var(--space-1);
2529
+ cursor: pointer;
2530
+ color: var(--color-fg-subtle);
2531
+ font-size: var(--font-size-base);
2532
+ line-height: 1;
2533
+ border-radius: var(--radius-sm);
2534
+ }
2535
+
2536
+ /* Dialogs ------------------------------------------------------------------- */
2537
+ /* A native dialog opened with showModal(). Note what is NOT declared: display.
2538
+ The UA sheet hides a closed dialog with dialog:not([open]) { display: none },
2539
+ and any author display here would beat it and leave the dialog permanently
2540
+ visible — the same trap the tooltip's comment records. Author rules beat the UA
2541
+ sheet whatever layer they sit in, so terp.base is enough to replace the UA
2542
+ dialog's border, padding, background and colour. */
2543
+ [data-terp="dialog"] {
2544
+ width: 100%;
2545
+ max-width: 26rem;
2546
+ padding: 0;
2547
+ border: 1px solid var(--color-neutral-200);
2548
+ border-radius: var(--radius-lg);
2549
+ box-shadow: var(--shadow-lg);
2550
+ background: var(--color-neutral-0);
2551
+ color: var(--color-neutral-900);
2552
+ }
2553
+ [data-terp="dialog-body"] {
2554
+ display: grid;
2555
+ gap: var(--space-3);
2556
+ padding: var(--space-6);
2557
+ }
2558
+ [data-terp="dialog-title"] {
2559
+ margin: 0;
2560
+ font-size: var(--font-size-lg);
2561
+ font-weight: var(--font-weight-semibold);
2562
+ letter-spacing: 0;
2563
+ color: var(--color-neutral-900);
2564
+ }
2565
+ [data-terp="dialog-description"] {
2566
+ color: var(--color-neutral-600);
2567
+ font-size: var(--font-size-sm);
2568
+ line-height: 1.5;
2569
+ }
2570
+ [data-terp="dialog-actions"] {
2571
+ display: flex;
2572
+ justify-content: flex-end;
2573
+ gap: var(--space-2);
2574
+ margin-block-start: var(--space-2);
2575
+ }
2576
+
2577
+ /* Icon-only buttons. Sixteen elements wear this marker and not one declares a
2578
+ transition inline, so this belongs in terp.base — it sat in terp.state only
2579
+ because that is where the hover rules needing it live. Same correction 817f572
2580
+ made for Tabs and Breadcrumbs. */
2581
+ [data-terp="iconbutton"] {
2582
+ transition: background-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
2583
+ }
2584
+
2585
+ /* Popover ------------------------------------------------------------------ */
2586
+ /* The wrapper the trigger sits in. Popover is the rendered root of Menu and of
2587
+ both date pickers, so this is the geometry of far more than one component —
2588
+ and of three components that ARE it. ThemeToggle, LanguageSwitcher and
2589
+ UserMenu each return a bare Menu, so this element is their rendered root and
2590
+ they name it themselves through Menu rather than wrapping it in a box nobody
2591
+ asked for. Every one of those names has to be in this selector list or the
2592
+ wrapper silently loses its geometry the moment a component claims its root:
2593
+ [data-terp="popover"] stops matching an element that now says theme-toggle,
2594
+ and nothing about the rule looks wrong. */
2595
+ [data-terp="popover"],
2596
+ [data-terp="theme-toggle"],
2597
+ [data-terp="language-switcher"],
2598
+ [data-terp="user-menu"] {
2599
+ position: relative;
2600
+ display: inline-flex;
2601
+ }
2602
+ /* The panel, which is portalled to document.body — so nothing about it can be
2603
+ reached by a descendant selector from the trigger's side of the tree.
83
2604
 
2605
+ position: fixed belongs here rather than inline: it is structural, the same
2606
+ for every panel, and the fixed CONTAINING BLOCK is what makes the portal work
2607
+ (a panel positioned against the viewport is never clipped by an ancestor's
2608
+ overflow). What stays inline is only the measured part — the left/top the
2609
+ layout effect computes from the trigger's rect and clamps against the
2610
+ viewport, plus the visibility that hides the panel for the one frame before
2611
+ that measurement exists. Those are caller-measured lengths in ADR 0094's
2612
+ sense, and no rule could carry them.
2613
+
2614
+ The stacking level is the token that was published for it. Every component in
2615
+ the package hardcoded its own number while a full --z-index-* family sat
2616
+ unread. AppShell still writes 50/40/30 for drawer/backdrop/sticky and comes
2617
+ right with its own migration; the toast viewport already reads
2618
+ --z-index-toast. Tooltip's z-index:
2619
+ 1 above is deliberately NOT a token — the tooltip is absolutely positioned
2620
+ inside its own anchor, so 1 is a local lift within a stacking context rather
2621
+ than a place in the app-wide order. */
2622
+ [data-terp="popover-panel"] {
2623
+ position: fixed;
2624
+ z-index: var(--z-index-popover);
2625
+ min-width: 12rem;
2626
+ padding: var(--space-1);
2627
+ font-family: var(--font-family-sans);
2628
+ color: var(--color-neutral-900);
2629
+ background: var(--color-neutral-0);
2630
+ border: 1px solid var(--color-neutral-200);
2631
+ border-radius: var(--radius-lg);
2632
+ box-shadow: var(--shadow-lg);
2633
+ }
2634
+ }
2635
+
2636
+ @layer terp.state {
84
2637
  /* Shared focus-visible ring: every interactive element that opts in via
85
- [data-terp] shows a soft outline ring. !important lets the ring beat
86
- inline base box-shadows (e.g. the primary button's resting shadow) so
87
- keyboard focus is always visible. */
2638
+ [data-terp] shows a soft outline ring. It must stay in terp.state. It ties
2639
+ with [data-terp="button"][data-variant="primary"] on specificity both
2640
+ (0,2,0) so in a single layer the later rule would win, and this one is
2641
+ declared first: the primary button's resting shadow would suppress the ring
2642
+ entirely. The layer is what makes the ring independent of where either rule
2643
+ happens to sit in this file. */
2644
+ /* The ring had no opaque part. The outline was transparent — present only so
2645
+ forced-colors mode has an outline to force — which left a translucent
2646
+ box-shadow as the entire visible indicator, and a translucent shadow's real
2647
+ colour is its alpha blend over whatever sits behind it. Measured that way,
2648
+ against neutral-0 and neutral-50, it reaches 1.67 / 1.65 in light, 2.30 / 2.43
2649
+ in dark, 2.51 / 2.59 in twilight and 2.73 / 2.68 in midnight. WCAG 2.1 SC
2650
+ 1.4.11 asks 3:1 of a focus indicator, so four of the five shipped themes failed
2651
+ it on every focusable component in the package — and on many of them the ring
2652
+ REPLACED a user-agent outline that did meet it, because this rule sets outline
2653
+ at all.
2654
+
2655
+ The outline now carries the colour and the shadow stays as its halo.
2656
+ --color-fg-accent is 6.70 / 6.41 light, 5.75 / 7.02 dark, 7.49 / 8.13
2657
+ midnight, 5.87 / 6.53 twilight, 9.14 contrast — never below 5.75. The offset
2658
+ keeps a 1px gap of surface between the element's own edge and the outline, so
2659
+ the indicator's adjacent colour is the surface rather than the control's fill,
2660
+ which is what makes one accent value safe on a filled button and on a bare
2661
+ input alike.
2662
+
2663
+ Which lane saw it is worth recording, because the first answer was wrong. axe
2664
+ does not evaluate focus indicators and the keyboard lane asserts where focus
2665
+ GOES rather than what it paints — but SEVEN baselines moved on this change, and
2666
+ they are the visual proof of it: every specimen that renders a control focused
2667
+ on mount (both date pickers, three open menus, and both confirm dialogs) paints
2668
+ the ring at rest. What no lane covers is the ring on a control focused by an
2669
+ actual keystroke, which is every other component in the package, so the text
2670
+ assertion below is still the general gate.
2671
+
2672
+ One detail fell out of that, and it is the useful half: the view-options panel
2673
+ did NOT move. Popover focuses the panel container, which carries tabIndex -1,
2674
+ and a programmatically focused non-interactive container does not match
2675
+ :focus-visible — while a programmatically focused BUTTON does. So the
2676
+ often-repeated shorthand that programmatic focus never matches :focus-visible is
2677
+ too coarse to plan a specimen with.
2678
+
2679
+ The arithmetic is no longer only recorded here. token-pairs.json now carries a
2680
+ nonTextPairs section, and the ring is measured in it at 3:1 in every theme, so it
2681
+ cannot silently return to what it was. One entry rather than two: focus-ring-on-canvas
2682
+ holds the accent against the canvas, while the ring on a card is the same pair of
2683
+ tokens as the TEXT pairing accent-on-surface, which already holds them to 4.5 and so
2684
+ would go red first. What stays prose is the halo: the box-shadow is reinforcement around the
2685
+ opaque outline rather than the indicator itself, and declaring a ratio WCAG does
2686
+ not ask for is how a data file teaches people to ignore it. */
88
2687
  [data-terp]:focus-visible {
89
- outline: 2px solid transparent;
2688
+ outline: 2px solid var(--color-fg-accent);
90
2689
  outline-offset: 1px;
91
- box-shadow: 0 0 0 3px var(--color-focus-ring) !important;
2690
+ box-shadow: 0 0 0 3px var(--color-focus-ring);
92
2691
  }
93
2692
 
94
2693
  /* Buttons ------------------------------------------------------------------ */
95
- [data-terp="button"] {
96
- transition: background-color 150ms ease, color 150ms ease,
97
- border-color 150ms ease, box-shadow 150ms ease, transform 100ms ease;
98
- }
99
2694
  [data-terp="button"][data-variant="primary"]:hover:not(:disabled) {
100
- background: var(--color-brand-primary-hover) !important;
2695
+ background: var(--color-brand-primary-hover);
101
2696
  }
102
2697
  [data-terp="button"][data-variant="secondary"]:hover:not(:disabled) {
103
- background: var(--color-neutral-100) !important;
104
- border-color: var(--color-neutral-300) !important;
2698
+ background: var(--color-neutral-100);
2699
+ border-color: var(--color-neutral-300);
105
2700
  }
106
2701
  [data-terp="button"][data-variant="ghost"]:hover:not(:disabled) {
107
- background: var(--color-neutral-100) !important;
108
- color: var(--color-neutral-900) !important;
2702
+ background: var(--color-neutral-100);
2703
+ color: var(--color-neutral-900);
109
2704
  }
110
2705
  [data-terp="button"][data-variant="danger"]:hover:not(:disabled) {
111
2706
  filter: brightness(0.94);
@@ -115,37 +2710,94 @@ html {
115
2710
  }
116
2711
  [data-terp="button"]:disabled {
117
2712
  opacity: 0.55;
118
- cursor: not-allowed !important;
2713
+ cursor: not-allowed;
119
2714
  }
120
2715
 
121
- /* Icon-only buttons (header toggle, dismissers, pagination). */
122
- [data-terp="iconbutton"] {
123
- transition: background-color 150ms ease, color 150ms ease, box-shadow 150ms ease;
124
- }
125
- [data-terp="iconbutton"]:hover:not(:disabled) {
126
- background: var(--color-neutral-100) !important;
127
- color: var(--color-neutral-900) !important;
2716
+ /* Icon-only buttons: the shell's two header toggles, four pagination arrows, the
2717
+ toast dismisser, the combobox's clear button, the calendar's two month arrows,
2718
+ the DataView's expand toggle, the view-options panel's two reorder arrows, and
2719
+ the DataView toolbar's clear-search button and two layout toggles.
2720
+ SIXTEEN SITES sharing a transition and nothing else — no shared SURFACE, because
2721
+ each is styled by where it sits. Sites rather than elements: the reorder arrows
2722
+ render twice per column row, so the element count is a function of how many
2723
+ columns a view has, while the list of places to check is fixed.
2724
+
2725
+ The list matters because the escalations here retired per consumer, and an
2726
+ incomplete list is the input a future reader uses to decide whether a new one is
2727
+ needed. It is also the thing most likely to go stale, and it did: this count sat
2728
+ at eleven, then thirteen, while the toolbar added three wearers in a commit that
2729
+ updated none of the five places the number is written down. Grep for the number
2730
+ word before trusting it: a comment-stripped scan for the marker attribute itself is
2731
+ the only authority.
2732
+
2733
+ THE HOVER PAIR NO LONGER SHOUTS, and the component that was why is the one that
2734
+ just migrated: AppShell's toggleStyle declared background and colour inline on the
2735
+ shell's two toggles, the last two elements wearing this marker able to out-rank a
2736
+ layered rule. Their resting look is a scoped base rule now
2737
+ ([data-terp="appshell-header"] > [data-terp="iconbutton"] and the drawer's brand row),
2738
+ so this rule wins on LAYER instead — terp.state over terp.base — whatever its
2739
+ specificity. These were the last two escalations in the sheet.
2740
+
2741
+ The :not([aria-pressed="true"]) on that pair is not defensive, and it is what
2742
+ makes shouting here safe. This marker is worn by toggles whose PRESSED look is
2743
+ byte-identical to these two values, so unguarded an !important hover paints an
2744
+ inactive toggle exactly like the active one — and because the declaration is
2745
+ both layered and important, nothing could restore it: not a higher-specificity
2746
+ author rule, not a later one, not an app's unlayered theme.css, which for
2747
+ important declarations sorts BELOW every layer. The guard makes the two rules
2748
+ mutually exclusive rather than merely equal-valued; without it they weigh
2749
+ (0,3,0) against (0,4,0) in one layer with the !important on the wrong one, and
2750
+ are benign only by coincidence of values. Same shape as
2751
+ [data-terp="tab"]:hover:not(:disabled):not([aria-selected="true"]).
2752
+
2753
+ Safe by enumeration rather than by hope: aria-pressed appears at exactly two
2754
+ sites in the whole package, and both of them ARE these toggles — no header toggle,
2755
+ pager arrow, month arrow, reorder arrow, toast dismisser or combobox clear button
2756
+ carries it, so every other element wearing this marker keeps its hover unchanged.
2757
+ It was three until the search-scope control stopped claiming to be a toggle button
2758
+ when what it actually does is swap its label. That the enumeration got smaller
2759
+ rather than larger is the useful direction: this guard is safe for a reason that
2760
+ has to be re-derived whenever a component gains the attribute.
2761
+
2762
+ The :disabled cursor no longer shouts, and re-deriving that is more useful than
2763
+ trusting it. The question is never "has anything migrated" but "can any element
2764
+ this selector matches still beat it" — so: which of the eleven can carry the
2765
+ disabled attribute at all? The shell's toggles cannot (no disabled prop). The
2766
+ toast dismisser cannot. The combobox's clear button renders only while the
2767
+ field is enabled and takes no disabled of its own. The calendar's arrows page
2768
+ the month unconditionally. The expand toggle has no disabled state. That leaves
2769
+ the four pagination arrows, which were the ONLY consumer, and which set cursor
2770
+ in pagerButtonStyle until this commit. So the escalation retired with them, and
2771
+ the day a calendar arrow gains a min/max bound the answer changes back. */
2772
+ [data-terp="iconbutton"]:hover:not(:disabled):not([aria-pressed="true"]) {
2773
+ background: var(--color-neutral-100);
2774
+ color: var(--color-neutral-900);
128
2775
  }
129
2776
  [data-terp="iconbutton"]:disabled {
130
2777
  opacity: 0.4;
131
- cursor: not-allowed !important;
2778
+ cursor: not-allowed;
132
2779
  }
133
2780
 
134
2781
  /* Inputs / selects / textareas -------------------------------------------- */
135
- [data-terp="input"] {
136
- transition: border-color 150ms ease, box-shadow 150ms ease;
137
- }
2782
+ /* The escalation these carried is gone, and the condition for removing it was
2783
+ the marker's LAST consumer rather than its first. The input marker is shared
2784
+ by six elements — Input, Select, Textarea, the Combobox text box and both
2785
+ date-picker triggers — and while the latter three still styled themselves
2786
+ inline, these rules needed !important to reach them at all. Dropping it when
2787
+ only the text controls had migrated left a disabled Combobox painted exactly
2788
+ like an enabled one and deleted the aria-invalid border outright. All six
2789
+ now take their base from this sheet, so layer order is enough. */
138
2790
  [data-terp="input"]:hover:not(:disabled):not(:focus) {
139
- border-color: var(--color-neutral-400, var(--color-neutral-500)) !important;
2791
+ border-color: var(--color-neutral-400);
140
2792
  }
141
2793
  [data-terp="input"]:focus,
142
2794
  [data-terp="input"]:focus-visible {
143
2795
  outline: none;
144
- border-color: var(--color-fg-accent) !important;
145
- box-shadow: 0 0 0 3px var(--color-focus-ring) !important;
2796
+ border-color: var(--color-fg-accent);
2797
+ box-shadow: 0 0 0 3px var(--color-focus-ring);
146
2798
  }
147
2799
  [data-terp="input"]::placeholder {
148
- color: var(--color-neutral-500);
2800
+ color: var(--color-fg-subtle);
149
2801
  opacity: 1;
150
2802
  }
151
2803
  [data-terp="input"] option {
@@ -155,12 +2807,12 @@ html {
155
2807
  [data-terp="input"]:disabled {
156
2808
  /* background-color (not the background shorthand) so the Select's chevron,
157
2809
  drawn as a background-image, survives the disabled state. */
158
- background-color: var(--color-neutral-50) !important;
159
- color: var(--color-neutral-500) !important;
160
- cursor: not-allowed !important;
2810
+ background-color: var(--color-neutral-50);
2811
+ color: var(--color-fg-subtle);
2812
+ cursor: not-allowed;
161
2813
  }
162
2814
  [data-terp="input"][aria-invalid="true"] {
163
- border-color: var(--color-status-danger) !important;
2815
+ border-color: var(--color-status-danger);
164
2816
  }
165
2817
  /* Number steppers are browser chrome and cannot be token-themed consistently.
166
2818
  Keep keyboard/wheel/manual numeric input while removing the mismatched arrows. */
@@ -179,111 +2831,325 @@ html {
179
2831
  [data-terp="checkbox"]:disabled,
180
2832
  [data-terp="radio"]:disabled,
181
2833
  [data-terp="switch"]:disabled {
182
- cursor: not-allowed !important;
2834
+ cursor: not-allowed;
183
2835
  }
184
- label:has([data-terp="checkbox"]:disabled),
185
- label:has([data-terp="radio"]:disabled),
186
- label:has([data-terp="switch"]:disabled) {
187
- cursor: not-allowed !important;
188
- color: var(--color-neutral-500) !important;
2836
+ /* :has() rather than an attribute on the label: the label has no idea whether
2837
+ its control is disabled, and threading that through would duplicate state
2838
+ the DOM already carries. */
2839
+ [data-terp="control-label"]:has([data-terp="checkbox"]:disabled),
2840
+ [data-terp="control-label"]:has([data-terp="radio"]:disabled),
2841
+ [data-terp="control-label"]:has([data-terp="switch"]:disabled) {
2842
+ cursor: not-allowed;
2843
+ color: var(--color-fg-subtle);
189
2844
  }
190
2845
 
191
- /* Sidebar navigation links (from the shell or any app-provided <a>). The hover
192
- rule skips the active route's link (aria-current="page") so the brand-soft
193
- active highlight is not washed out on hover. */
194
- [data-terp="appshell-nav"] a {
195
- transition: background-color 150ms ease, color 150ms ease;
196
- }
2846
+ /* Sidebar navigation links (from the shell or any app-provided <a>).
2847
+
2848
+ THE LAST TWO NAV ESCALATIONS ARE GONE. Their consumer was NAV_LINK_STYLE, the
2849
+ CSSProperties object AppShell exported for every router's link renderer to spread onto
2850
+ its own link element — colour and background inline on the very elements this selector
2851
+ matches, so nothing but !important could reach them. The resting look is a base rule
2852
+ now and the exported constants no longer exist.
2853
+
2854
+ The hover skips the active route (aria-current="page") so the brand-soft active
2855
+ highlight is not washed out on hover, and that same attribute is what carries the
2856
+ active look at all now that NAV_LINK_ACTIVE_STYLE is gone. It is not a shell attribute:
2857
+ every router sets it on the link it considers current, which is exactly the kind of
2858
+ reuse the breadcrumb case sanctions — the caller owns the element and the attribute, and
2859
+ the shell owns only where it sits.
2860
+
2861
+ The rail's scrollbar suppression moved from an attribute on the nav to the sidebar's,
2862
+ because collapsed is one fact and it now has one owner. */
197
2863
  [data-terp="appshell-nav"] a:hover:not([aria-current="page"]) {
198
- background: var(--color-neutral-100) !important;
199
- color: var(--color-neutral-900) !important;
2864
+ background: var(--color-neutral-100);
2865
+ color: var(--color-neutral-900);
200
2866
  }
201
- [data-terp="appshell-nav"][data-collapsed="true"] {
2867
+ [data-terp="appshell-nav"] a[aria-current="page"] {
2868
+ background: var(--color-brand-primary-soft);
2869
+ color: var(--color-fg-accent);
2870
+ font-weight: var(--font-weight-semibold);
2871
+ }
2872
+ [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-nav"] {
202
2873
  overflow-x: hidden;
203
2874
  scrollbar-width: none;
204
2875
  }
205
- [data-terp="appshell-nav"][data-collapsed="true"]::-webkit-scrollbar {
2876
+ [data-terp="appshell-sidebar"][data-collapsed="true"] [data-terp="appshell-nav"]::-webkit-scrollbar {
206
2877
  width: 0;
207
2878
  height: 0;
208
2879
  }
209
- [data-terp="appshell-brand"] {
210
- transition: background-color 150ms ease;
211
- }
212
2880
  [data-terp="appshell-brand"]:hover {
213
- background: var(--color-neutral-100) !important;
2881
+ background: var(--color-neutral-100);
214
2882
  }
215
2883
 
216
2884
  /* Tabs -------------------------------------------------------------------- */
217
- [data-terp="tab"] {
218
- transition: background-color 150ms ease, color 150ms ease, border-color 150ms ease;
219
- border-top-left-radius: var(--radius-sm);
220
- border-top-right-radius: var(--radius-sm);
2885
+ /* Selection is a state, so it lives here rather than beside the tab's base:
2886
+ the accent edge and label have to beat the resting colour, and aria-selected
2887
+ is already on the element for assistive tech. */
2888
+ [data-terp="tab"][aria-selected="true"] {
2889
+ font-weight: var(--font-weight-semibold);
2890
+ border-block-end-color: var(--color-fg-accent);
2891
+ color: var(--color-fg-accent);
221
2892
  }
222
2893
  [data-terp="tab"]:hover:not(:disabled):not([aria-selected="true"]) {
223
- color: var(--color-neutral-900) !important;
224
- background: var(--color-neutral-100) !important;
2894
+ color: var(--color-neutral-900);
2895
+ background: var(--color-neutral-100);
225
2896
  }
226
2897
  [data-terp="tab"]:disabled {
227
2898
  opacity: 0.5;
228
- cursor: not-allowed !important;
2899
+ cursor: not-allowed;
229
2900
  }
230
2901
 
231
- /* Hub cards --------------------------------------------------------------- */
232
- [data-terp="hubcard-link"],
233
- [data-terp="hubcard"] a {
234
- text-decoration: none;
235
- color: inherit;
236
- display: block;
237
- height: 100%;
238
- min-height: 0;
239
- }
240
- [data-terp="hubcard"] {
241
- transition: border-color 150ms ease, box-shadow 150ms ease, transform 150ms ease;
2902
+ /* Module navigation ------------------------------------------------------- */
2903
+ /* Which sub-page you are on, here for the same reason the selected tab is: the
2904
+ accent edge and the darker ink have to beat the resting pair.
2905
+
2906
+ Keyed on data-active, which ModuleNav writes, and deliberately NOT on the
2907
+ aria-current the same element also carries. That attribute has a second author:
2908
+ TanStack's link props spread the router's own active props LAST, after the
2909
+ caller's, so on a Link the router has the final word on aria-current. This is
2910
+ the breadcrumb lesson in its exact form — reuse a semantic only where the
2911
+ component is its sole author.
2912
+
2913
+ The two notions of "active" are not the same predicate, and they diverge in BOTH
2914
+ directions, which is worth knowing before touching either. activeOptions
2915
+ .includeSearch defaults to true, so the router additionally demands an exact
2916
+ query-string match that ModuleNav does not — the router is narrower there. And
2917
+ with exact matching the router compares through exactPathTest, which is
2918
+ removeTrailingSlash(a) === removeTrailingSlash(b), while ModuleNav compares
2919
+ pathname === item.to raw — so on a path with a trailing slash the ROUTER is
2920
+ active and ModuleNav is not, and this rule withholds the accent edge from a tab
2921
+ the router considers current. That second case is a real defect and it is older
2922
+ than this rule: the inline styling it replaced read the same isActive, so the
2923
+ behaviour is unchanged and only the reasoning was wrong. Fixing the predicate
2924
+ belongs with the navigation model, because that is what decides what "active"
2925
+ should mean. */
2926
+ [data-terp="module-nav-link"][data-active="true"] {
2927
+ color: var(--color-neutral-900);
2928
+ border-block-end-color: var(--color-fg-accent);
242
2929
  }
2930
+
2931
+ /* Hub cards --------------------------------------------------------------- */
2932
+ /* The hover edge recolours hubcard-BODY, not the card.
2933
+
2934
+ The rule here used to set border-color on [data-terp="hubcard"], which is the outer <li>
2935
+ and has no border: HubPage puts the visible edge on the inner hubcard-body span. So the
2936
+ accent edge — clearly the intent, since the title goes accent and the card lifts and gains
2937
+ a shadow at the same moment — never painted. Measured in a browser rather than reasoned
2938
+ about, because no baseline captures a hover: the li computed border 0px none at rest and
2939
+ 0px none in the accent colour on hover, while the shadow and the transform did apply.
2940
+
2941
+ BOTH ESCALATIONS ARE GONE, and this file was the condition for both. hubcard-body's
2942
+ border and hubcard-title's colour were declared inline in HubPage, on the very elements
2943
+ these two selectors match, so no layered rule could reach them at any specificity. Both
2944
+ surfaces take their base from terp.base now, so layer order alone is enough. Two of the
2945
+ seven, and neither could have retired one commit earlier. */
243
2946
  [data-terp="hubcard"]:hover {
244
- border-color: var(--color-fg-accent) !important;
245
2947
  box-shadow: var(--shadow-sm);
246
2948
  transform: translateY(-1px);
247
2949
  }
2950
+ [data-terp="hubcard"]:hover [data-terp="hubcard-body"] {
2951
+ border-color: var(--color-fg-accent);
2952
+ }
248
2953
  [data-terp="hubcard"]:hover [data-terp="hubcard-title"] {
249
- color: var(--color-fg-accent) !important;
2954
+ color: var(--color-fg-accent);
250
2955
  }
251
2956
 
252
- /* Breadcrumb links -------------------------------------------------------- */
253
- [data-terp="breadcrumbs"] a {
254
- color: var(--color-neutral-600);
255
- text-decoration: none;
256
- transition: color 150ms ease;
2957
+ /* Which layout is active, expressed as more than a wash. terp.state, and keyed on the
2958
+ real ARIA attribute — the [data-terp="tab"][aria-selected="true"] precedent — and the
2959
+ reuse is safe in the strong sense the breadcrumb case defines: DataViewToolbar is the
2960
+ sole author of aria-pressed on these two elements, setting it from its own layout
2961
+ prop, and no router, wrapper or caller can reach them.
2962
+
2963
+ The border is the whole point of this rule, and it replaces a state that was carried
2964
+ by colour alone. Measured over the manifest's per-theme values: the neutral-100 fill
2965
+ against the band's transparent is 1.10 (light), 1.16 (dark), 1.09 (midnight), 1.12
2966
+ (twilight), 1.12 (contrast) — nowhere near the 3:1 SC 1.4.11 asks of a state
2967
+ indicator — and on two icon-only controls whose entire job is to say which layout is
2968
+ current, an ink difference alone is also SC 1.4.1. The accent border measures 6.12 /
2969
+ 4.95 / 6.85 / 5.23 / 8.17 against its own neutral-100 fill and 6.70 / 5.75 / 7.49 /
2970
+ 5.87 / 9.14 against the band, so it clears 3:1 on both sides in every theme. It is a
2971
+ NON-TEXT pairing, and token-pairs.json now has a section for it: active-toggle-border
2972
+ holds the accent against the neutral-100 fill at 3:1 in every theme. The border against
2973
+ the BAND is not a second entry — those are the same two tokens as the text pairing
2974
+ accent-on-surface, held there to 4.5, so the numbers above are one pairing seen from two
2975
+ sides and the stricter side already gates it. The 1.10 fill is deliberately not declared,
2976
+ for the same reason the focus halo is not.
2977
+
2978
+ Deliberately NOT a box-shadow, and this one is a cascade fact rather than a taste:
2979
+ [data-terp]:focus-visible sets box-shadow in this same layer at (0,2,0) against this
2980
+ rule's (0,3,0), so an inset shadow here would win and a focused active toggle would
2981
+ lose its focus ring.
2982
+
2983
+ No tie with the shared [data-terp="iconbutton"]:hover rule, and NOT because this one
2984
+ out-specifies it: that rule carries :not([aria-pressed="true"]), so the two selectors
2985
+ are mutually exclusive. Before that guard they were (0,3,0) and (0,4,0) in this same
2986
+ layer with identical values and the !important on the wrong one, so hovering the
2987
+ inactive toggle painted it exactly like the active one. */
2988
+ [data-terp="dataview-toolbar-layout"] > [data-terp="iconbutton"][aria-pressed="true"] {
2989
+ background: var(--color-neutral-100);
2990
+ border-color: var(--color-fg-accent);
2991
+ color: var(--color-fg-accent);
257
2992
  }
2993
+
2994
+ /* Breadcrumb links -------------------------------------------------------- */
258
2995
  [data-terp="breadcrumbs"] a:hover {
259
2996
  color: var(--color-neutral-900);
260
2997
  text-decoration: underline;
261
2998
  }
262
2999
 
263
- /* DataView table row hover ------------------------------------------------- */
3000
+ /* The pager's disabled ink, scoped rather than added to the shared iconbutton
3001
+ rule above: of the sixteen sites wearing that marker only six can be disabled at
3002
+ all — these four and the view-options panel's two reorder arrows, which carry
3003
+ their own scoped ink below for the same reason — and giving the shared rule a
3004
+ colour would change how a disabled calendar arrow looks the day one becomes
3005
+ disableable. The shared rule supplies the opacity and the cursor; this supplies
3006
+ the ink the pager had inline. */
3007
+ [data-terp="dataview-pager"] > [data-terp="iconbutton"]:disabled {
3008
+ color: var(--color-neutral-300);
3009
+ }
3010
+
3011
+ /* A disabled inline action outranks a destructive one, which is the order the
3012
+ component's own ternary had: disabled first, destructive second. Here that
3013
+ ordering is the LAYER rather than the sequence of two equal-specificity rules
3014
+ — terp.state over terp.base — so it cannot be broken by moving a block. */
3015
+ [data-terp="dataview-inline-action"]:disabled {
3016
+ color: var(--color-neutral-300);
3017
+ cursor: not-allowed;
3018
+ }
3019
+
3020
+ /* A reorder arrow at the end of the list. The ink is scoped here for the same
3021
+ reason the pager's is: only a handful of the elements wearing the icon-button
3022
+ marker can be disabled, and putting a colour on the shared rule would change
3023
+ how a disabled month arrow looks the day one becomes disableable. */
3024
+ [data-terp="dataview-column-option"] > [data-terp="iconbutton"]:disabled {
3025
+ color: var(--color-neutral-300);
3026
+ }
3027
+
3028
+ /* DataView table ----------------------------------------------------------- */
3029
+ /* Column resizing switches the table from auto to fixed layout, so the columns
3030
+ the user is not dragging stop reflowing mid-drag. A state, and an enumerable
3031
+ one, so it is an attribute rather than the inline table-layout it used to be
3032
+ — which also means the resting table declares no style attribute at all. */
3033
+ [data-terp="dataview-table"][data-resizing="true"] {
3034
+ table-layout: fixed;
3035
+ }
264
3036
  [data-terp="dataview-table"] tbody tr {
265
3037
  transition: background-color 150ms ease;
266
3038
  }
267
3039
  [data-terp="dataview-table"] tbody tr:hover td {
268
3040
  background: var(--color-neutral-50);
269
3041
  }
270
- [data-terp="dataview-row"]:focus-within td,
3042
+ /* The row containing keyboard focus, highlighted because it is the one Enter would
3043
+ open. Guarded on data-clickable, and the guard is not decoration: the row marker
3044
+ became unconditional when the tone rules moved into this sheet, and without the
3045
+ guard this selector silently widened to any row with something focusable in it —
3046
+ a selection checkbox in a view with no onRowClick, painting a brand wash over a
3047
+ row nothing will open, and over its status tone. Widening it deliberately is a
3048
+ defensible change; arriving there as a side effect of marking the row is not.
3049
+
3050
+ Neither lane can see this. The baselines capture the resting state, axe reads a
3051
+ static tree, and the keyboard lane is about where focus GOES rather than what it
3052
+ paints. So it is pinned from both ends instead: styles.test.ts asserts the guard
3053
+ is on this selector, and a unit test asserts a non-clickable row claims no
3054
+ data-clickable for it to match.
3055
+
3056
+ The !important is GONE, and the condition was this rule's last inline consumer
3057
+ rather than its first. The two halves were never in the same state: the row half
3058
+ had nothing to out-shout — a body cell declares no background — while the card
3059
+ half faced DataViewCardList's inline background on the very element it matches.
3060
+ Both halves now take their surface from this sheet, so terp.state's layer order
3061
+ is enough, and the card's tone rules lose to it on layer rather than on
3062
+ specificity. Keeping the escalation past this point is the quiet failure: nothing
3063
+ would render differently, the declaration would simply become unthemeable. */
3064
+ [data-terp="dataview-row"][data-clickable="true"]:focus-within td,
271
3065
  [data-terp="dataview-card"]:focus-within {
272
- background: var(--color-brand-primary-soft) !important;
3066
+ background: var(--color-brand-primary-soft);
273
3067
  }
274
3068
 
275
- /* Menu items (UserMenu, DataView row-actions / column settings). */
276
- [data-terp="menu-item"] {
277
- transition: background-color 150ms ease, color 150ms ease;
278
- border-radius: var(--radius-sm);
3069
+ /* Combobox options. data-active is the roving keyboard/pointer highlight, which
3070
+ is not selection — both can be true at once, and the selected rule is
3071
+ declared second so it wins on the option the user actually chose. */
3072
+ [data-terp="combobox-option"][data-active="true"] {
3073
+ background: var(--color-neutral-100);
3074
+ }
3075
+ [data-terp="combobox-option"][aria-selected="true"] {
3076
+ color: var(--color-fg-accent);
3077
+ font-weight: var(--font-weight-semibold);
3078
+ }
3079
+ [data-terp="combobox-option"]:disabled {
3080
+ color: var(--color-neutral-400);
3081
+ cursor: not-allowed;
3082
+ }
3083
+
3084
+ /* Calendar days. Selection is the filled accent surface, so its label is the
3085
+ one token allowed on it; a day inside a range gets the soft wash instead. */
3086
+ [data-terp="calendar-day"][data-in-range="true"] {
3087
+ background: var(--color-brand-primary-soft);
3088
+ }
3089
+ [data-terp="calendar-day"][aria-selected="true"] {
3090
+ border-color: var(--color-brand-primary);
3091
+ background: var(--color-brand-primary);
3092
+ color: var(--color-brand-primary-contrast);
279
3093
  }
3094
+ /* A day that is BOTH outside the visible month and inside the selected range: the two rules
3095
+ above set different properties, so both apply and the subtle ink lands on the accent wash
3096
+ rather than on the panel surface. That pairing fails WCAG AA in two of the five themes
3097
+ (measured: light 4.37:1, midnight 4.26:1). The muted ink is 5.19:1 at worst across the five,
3098
+ and muted-on-soft is now a declared pairing — which is what gates it, and not for the reason
3099
+ one would guess. A specimen was added that paints the state, and axe still will not report
3100
+ it: it returns color-contrast as INCOMPLETE for these cells, reasoning that "Element content
3101
+ is too short to determine if it is actual text content". A one- or two-digit day number is
3102
+ below its heuristic. So axe can never gate contrast on a day cell — nor on any other
3103
+ one-glyph surface here, the toast dismisser and the breadcrumb separator included — and the
3104
+ declared-pairings gate in @terpjs/contract is the only lane that covers them. Measured,
3105
+ because the obvious assumption was that painting the state would be enough.
3106
+
3107
+ The :not() guard is load-bearing rather than defensive. Without it this selector weighs
3108
+ (0,3,0) against the aria-selected rule's (0,2,0) in the same layer and would strip the
3109
+ contrast ink off a selected range ENDPOINT — and the endpoints are in range, because
3110
+ isWithinRange uses >= and <=. */
3111
+ [data-terp="calendar-day"][data-in-range="true"][data-outside-month="true"]:not([aria-selected="true"]) {
3112
+ color: var(--color-fg-muted);
3113
+ }
3114
+ [data-terp="calendar-day"]:disabled {
3115
+ color: var(--color-neutral-300);
3116
+ cursor: not-allowed;
3117
+ }
3118
+
3119
+ /* Menu items and the menu trigger.
3120
+
3121
+ The escalation on these two item rules is GONE, and the condition for removing
3122
+ it was the marker's LAST consumer rather than its first. MenuItem is the only
3123
+ thing in the package that renders data-terp="menu-item" — unlike input, which
3124
+ six elements wear — so once it stopped carrying inline base styles there was
3125
+ nothing left for the rules to out-shout, and layer order is enough. Keeping
3126
+ !important past that point is the quiet failure: nothing renders differently,
3127
+ the rule just silently outranks the app theme.css this phase exists to
3128
+ empower. Both directions are pinned in styles.test.ts.
3129
+
3130
+ The base declarations that used to sit up here — the item's transition and
3131
+ radius — have moved down to terp.base where they belong. They were here only
3132
+ because an inline style would have beaten them anywhere. */
280
3133
  [data-terp="menu-item"]:hover:not(:disabled) {
281
- background: var(--color-neutral-100) !important;
282
- color: var(--color-neutral-900) !important;
3134
+ background: var(--color-neutral-100);
3135
+ color: var(--color-neutral-900);
283
3136
  }
284
3137
  [data-terp="menu-item"][data-selected="true"] {
285
- background: var(--color-brand-primary-soft) !important;
286
- color: var(--color-fg-accent) !important;
3138
+ background: var(--color-brand-primary-soft);
3139
+ color: var(--color-fg-accent);
3140
+ }
3141
+ [data-terp="menu-item"]:disabled {
3142
+ color: var(--color-neutral-300);
3143
+ cursor: not-allowed;
3144
+ }
3145
+ /* No :disabled rule for the trigger: Menu exposes no disabled prop, so the
3146
+ button can never carry the attribute. A selector matching nothing is dead
3147
+ styling that reads as live — the rule sits in the sheet looking like the state
3148
+ is handled. :not(:disabled) stays on the hover, because it costs nothing and
3149
+ the day the prop arrives the hover is already correct. */
3150
+ [data-terp="menu-trigger"]:hover:not(:disabled) {
3151
+ background: var(--color-neutral-100);
3152
+ color: var(--color-neutral-900);
287
3153
  }
288
3154
 
289
3155
  /* Dialogs: ::backdrop cannot be set inline, so the dim layer lives here and
@@ -301,15 +3167,45 @@ label:has([data-terp="switch"]:disabled) {
301
3167
  animation: terp-spin 0.8s linear infinite;
302
3168
  }
303
3169
 
3170
+ }
3171
+
3172
+ @layer terp.motion {
304
3173
  /* Respect the user's reduced-motion preference — kill transitions and the
305
- spinner animation everywhere the sheet applies them. */
3174
+ spinner animation everywhere the sheet applies them.
3175
+
3176
+ The layer puts this above terp.base and terp.state, which is enough for
3177
+ every transition this sheet declares ON a marked element. It is NOT enough
3178
+ for the ones still declared inline: AppShell's nav links and HubPage's card
3179
+ title each set transition in a style object, and no layer beats the style
3180
+ attribute — without !important a reduced-motion user still got those
3181
+ animations, an accessibility preference silently ignored that no screenshot
3182
+ can see. Those come off when the two components migrate.
3183
+
3184
+ Note the selector list is the real limit here, not the layer: a transition
3185
+ on an element carrying no data-terp is reached only if something below names
3186
+ it. Breadcrumb links are such a case and are listed, and so are the shell's
3187
+ nav links, whose element belongs to the caller's router.
3188
+
3189
+ THE LAST ESCALATION IN THE SHEET IS GONE, and it was the widest. Its
3190
+ consumers were the three inline transitions left in the package: the shell's
3191
+ nav-link transition (NAV_LINK_STYLE), the hub card title's (titleTextStyle)
3192
+ and the sidebar's own transition: width. The first two are rules now; the
3193
+ third was the documented escape — the aside carried no marker, so nothing
3194
+ here matched it and a reduced-motion user watched the rail animate — and it
3195
+ is closed by the sidebar taking a marker and its width becoming a rule. With
3196
+ no style attribute left to out-shout, layer order alone wins: terp.motion
3197
+ sits above terp.base and terp.state, so transition: none needs nothing
3198
+ shouted. Measured, not assumed: under prefers-reduced-motion the sidebar,
3199
+ a nav link and a hub card title all compute transition-duration 0s. */
306
3200
  @media (prefers-reduced-motion: reduce) {
307
3201
  [data-terp],
308
3202
  [data-terp="appshell-nav"] a,
3203
+ [data-terp="breadcrumbs"] a,
309
3204
  [data-terp="dataview-table"] tbody tr {
310
- transition: none !important;
3205
+ transition: none;
311
3206
  }
312
- [data-terp="spinner-ring"] { animation: none !important; }
3207
+ [data-terp="spinner-ring"] { animation: none; }
3208
+ }
313
3209
  }
314
3210
  `;
315
3211