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