@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.
- package/package.json +2 -2
- package/src/AppShell.test.tsx +33 -12
- package/src/AppShell.tsx +69 -249
- package/src/Breadcrumbs.test.tsx +24 -0
- package/src/Breadcrumbs.tsx +9 -32
- package/src/ConfirmDialog.tsx +13 -44
- package/src/EmptyState.tsx +8 -36
- package/src/ErrorState.tsx +8 -36
- package/src/Field.test.tsx +57 -0
- package/src/Field.tsx +46 -22
- package/src/HubPage.test.tsx +22 -13
- package/src/HubPage.tsx +25 -97
- package/src/LoadingState.tsx +3 -24
- package/src/PageActions.tsx +5 -10
- package/src/UserMenu.test.tsx +12 -5
- package/src/UserMenu.tsx +33 -62
- package/src/dataview/DataView.test.tsx +109 -5
- package/src/dataview/DataView.tsx +41 -23
- package/src/dataview/DataViewCardList.tsx +14 -60
- package/src/dataview/DataViewColumnSettings.tsx +46 -51
- package/src/dataview/DataViewExpandableRow.tsx +2 -17
- package/src/dataview/DataViewPagination.tsx +2 -32
- package/src/dataview/DataViewRowActions.tsx +13 -33
- package/src/dataview/DataViewTable.tsx +16 -103
- package/src/dataview/DataViewToolbar.tsx +53 -76
- package/src/dataview/README.md +6 -0
- package/src/dataview/index.ts +1 -0
- package/src/dataview/internal.tsx +4 -1
- package/src/dataview/types.ts +13 -0
- package/src/feedback.test.tsx +26 -0
- package/src/files.test.tsx +18 -0
- package/src/files.tsx +13 -4
- package/src/icons.test.tsx +10 -6
- package/src/icons.tsx +9 -37
- package/src/index.ts +0 -3
- package/src/layout.test.tsx +24 -9
- package/src/layout.tsx +24 -21
- package/src/layoutContract.test.tsx +95 -0
- package/src/locale.tsx +24 -4
- package/src/markers.test.ts +242 -19
- package/src/router.tsx +6 -9
- package/src/ssr.test.tsx +1 -3
- package/src/styles.test.ts +823 -6
- package/src/styles.ts +2699 -153
- package/src/theme.tsx +24 -3
- package/src/toast.tsx +35 -71
- package/src/ui/Alert.test.tsx +12 -0
- package/src/ui/Alert.tsx +15 -43
- package/src/ui/Badge.test.tsx +14 -3
- package/src/ui/Badge.tsx +9 -28
- package/src/ui/Button.test.tsx +17 -4
- package/src/ui/Button.tsx +10 -63
- package/src/ui/Card.test.tsx +6 -2
- package/src/ui/Card.tsx +11 -39
- package/src/ui/Checkbox.tsx +2 -19
- package/src/ui/Combobox.test.tsx +22 -0
- package/src/ui/Combobox.tsx +31 -80
- package/src/ui/DatePicker.test.tsx +131 -4
- package/src/ui/DatePicker.tsx +158 -106
- package/src/ui/Input.tsx +6 -19
- package/src/ui/Markdown.test.tsx +26 -0
- package/src/ui/Markdown.tsx +28 -2
- package/src/ui/Menu.test.tsx +38 -4
- package/src/ui/Menu.tsx +50 -52
- package/src/ui/Popover.tsx +53 -19
- package/src/ui/Radio.tsx +5 -30
- package/src/ui/Select.tsx +7 -30
- package/src/ui/Switch.tsx +2 -20
- package/src/ui/Tabs.tsx +4 -28
- package/src/ui/Textarea.tsx +6 -17
- package/src/ui/Tooltip.tsx +9 -21
- package/src/ui/controlStyles.ts +0 -9
package/src/styles.ts
CHANGED
|
@@ -1,111 +1,2385 @@
|
|
|
1
1
|
/**
|
|
2
|
-
* The one
|
|
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
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
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
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
*
|
|
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
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
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
|
-
|
|
37
|
-
|
|
1741
|
+
[data-terp="combobox"],
|
|
1742
|
+
[data-terp="combobox-field"] {
|
|
1743
|
+
position: relative;
|
|
1744
|
+
display: grid;
|
|
38
1745
|
}
|
|
39
|
-
/*
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
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
|
-
/*
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
height: 10px;
|
|
1816
|
+
button[data-terp="input"][data-placeholder="true"] {
|
|
1817
|
+
color: var(--color-fg-subtle);
|
|
67
1818
|
}
|
|
68
|
-
|
|
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
|
-
|
|
72
|
-
|
|
73
|
-
|
|
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
|
-
|
|
78
|
-
|
|
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
|
-
|
|
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.
|
|
86
|
-
|
|
87
|
-
|
|
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
|
|
2367
|
+
outline: 2px solid var(--color-fg-accent);
|
|
90
2368
|
outline-offset: 1px;
|
|
91
|
-
box-shadow: 0 0 0 3px var(--color-focus-ring)
|
|
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)
|
|
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)
|
|
104
|
-
border-color: var(--color-neutral-300)
|
|
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)
|
|
108
|
-
color: var(--color-neutral-900)
|
|
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
|
|
2392
|
+
cursor: not-allowed;
|
|
119
2393
|
}
|
|
120
2394
|
|
|
121
|
-
/* Icon-only buttons
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
|
|
125
|
-
|
|
126
|
-
|
|
127
|
-
|
|
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
|
|
2457
|
+
cursor: not-allowed;
|
|
132
2458
|
}
|
|
133
2459
|
|
|
134
2460
|
/* Inputs / selects / textareas -------------------------------------------- */
|
|
135
|
-
|
|
136
|
-
|
|
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
|
|
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)
|
|
145
|
-
box-shadow: 0 0 0 3px var(--color-focus-ring)
|
|
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-
|
|
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)
|
|
159
|
-
color: var(--color-
|
|
160
|
-
cursor: not-allowed
|
|
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)
|
|
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
|
|
2513
|
+
cursor: not-allowed;
|
|
183
2514
|
}
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
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>).
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
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)
|
|
199
|
-
color: var(--color-neutral-900)
|
|
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-
|
|
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-
|
|
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)
|
|
2560
|
+
background: var(--color-neutral-100);
|
|
214
2561
|
}
|
|
215
2562
|
|
|
216
2563
|
/* Tabs -------------------------------------------------------------------- */
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
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)
|
|
224
|
-
background: var(--color-neutral-100)
|
|
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
|
|
2578
|
+
cursor: not-allowed;
|
|
229
2579
|
}
|
|
230
2580
|
|
|
231
2581
|
/* Hub cards --------------------------------------------------------------- */
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
|
|
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)
|
|
2604
|
+
color: var(--color-fg-accent);
|
|
250
2605
|
}
|
|
251
2606
|
|
|
252
|
-
/*
|
|
253
|
-
[data-terp="
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
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
|
-
/*
|
|
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
|
-
|
|
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)
|
|
2716
|
+
background: var(--color-brand-primary-soft);
|
|
273
2717
|
}
|
|
274
2718
|
|
|
275
|
-
/*
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
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)
|
|
282
|
-
color: var(--color-neutral-900)
|
|
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)
|
|
286
|
-
color: var(--color-fg-accent)
|
|
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
|
|
2855
|
+
transition: none;
|
|
311
2856
|
}
|
|
312
|
-
[data-terp="spinner-ring"] { animation: none
|
|
2857
|
+
[data-terp="spinner-ring"] { animation: none; }
|
|
2858
|
+
}
|
|
313
2859
|
}
|
|
314
2860
|
`;
|
|
315
2861
|
|