@akonwi/mica 0.1.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/mica.css ADDED
@@ -0,0 +1,1536 @@
1
+ /*! mica — custom elements, native behavior, nearly no JavaScript. */
2
+
3
+ /* Layer order. User's unlayered CSS always wins over all of it. */
4
+ @layer mica.tokens, mica.preset, mica.elements, mica.layout;
5
+
6
+ /* ------------------------------------------------------------------ *
7
+ * Tokens — mica's only opinion. Spacing and size scales.
8
+ * ------------------------------------------------------------------ */
9
+ @layer mica.tokens {
10
+ :root {
11
+ --space-2xs: 0.25rem;
12
+ --space-xs: 0.5rem;
13
+ --space-sm: 0.75rem;
14
+ --space-md: 1rem;
15
+ --space-lg: 1.5rem;
16
+ --space-xl: 2.5rem;
17
+ --space-2xl: 4rem;
18
+
19
+ --size-xs: 10rem;
20
+ --size-sm: 15rem;
21
+ --size-md: 20rem;
22
+ --size-lg: 30rem;
23
+ --size-xl: 40rem;
24
+
25
+ --measure: 60ch;
26
+
27
+ --font-body: system-ui, sans-serif;
28
+ --font-mono: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
29
+ --line-height: 1.5;
30
+
31
+ /* square is the design language; rounding back on is a theme:
32
+ :root { --radius-sm: .25rem; --radius-md: .375rem; --radius-lg: .75rem } */
33
+ --radius-sm: 0px;
34
+ --radius-md: 0px;
35
+ --radius-lg: 0px;
36
+ --radius-full: 9999px;
37
+
38
+ /* control heights, shadcn scale: sm 32 / md 36 / lg 40 */
39
+ --control-height-sm: 2rem;
40
+ --control-height: 2.25rem;
41
+ --control-height-lg: 2.5rem;
42
+
43
+ --focus-ring-width: 2px;
44
+ --focus-ring-offset: 2px;
45
+ --focus-ring-color: var(--color-accent);
46
+
47
+ /* checkmark glyph — SVG URIs can't read custom properties, so this
48
+ ships as a light-dark pair tracking on-primary (white / near-black) */
49
+ --check-glyph: light-dark(
50
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23fff' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E"),
51
+ url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3E%3Cpath fill='none' stroke='%23151922' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round' d='M3.5 8.5l3 3 6-7'/%3E%3C/svg%3E"));
52
+
53
+ /* -------------------------------------------------------------- *
54
+ * Color. Two knobs — change the theme by changing the knobs:
55
+ * --hue: the accent hue (OKLCH degrees)
56
+ * --chroma: accent intensity
57
+ * Neutrals are faintly tinted toward the accent hue.
58
+ * Components only ever use semantic roles, never raw values.
59
+ * -------------------------------------------------------------- */
60
+ color-scheme: light dark;
61
+
62
+ --hue: 263;
63
+ --chroma: 0.21;
64
+ --neutral-chroma: 0;
65
+
66
+ /* status hues — knobs, same as the brand hue. statuses deliberately
67
+ do NOT ride --chroma: danger must stay urgent in a muted theme. */
68
+ --danger-hue: 25;
69
+ --success-hue: 150;
70
+ --warn-hue: 48; /* orange — amber reads as mud at solid lightness */
71
+ --status-chroma: 0.18;
72
+
73
+ /* Numbered scales — theme-relative (Radix convention): a step means
74
+ the same *thing* in both modes. 1 = app bg … 6–8 = borders …
75
+ 9 = solid … 12 = high-contrast text. light-dark() is baked into
76
+ every step, so code written against steps gets dark mode free. */
77
+ --neutral-1: light-dark(oklch(100% var(--neutral-chroma) var(--hue)), oklch(14% var(--neutral-chroma) var(--hue)));
78
+ --neutral-2: light-dark(oklch(97.3% var(--neutral-chroma) var(--hue)), oklch(17% var(--neutral-chroma) var(--hue)));
79
+ --neutral-3: light-dark(oklch(95.2% var(--neutral-chroma) var(--hue)), oklch(20% var(--neutral-chroma) var(--hue)));
80
+ --neutral-4: light-dark(oklch(93% var(--neutral-chroma) var(--hue)), oklch(23% var(--neutral-chroma) var(--hue)));
81
+ --neutral-5: light-dark(oklch(91% var(--neutral-chroma) var(--hue)), oklch(26% var(--neutral-chroma) var(--hue)));
82
+ --neutral-6: light-dark(oklch(89% var(--neutral-chroma) var(--hue)), oklch(30% var(--neutral-chroma) var(--hue)));
83
+ --neutral-7: light-dark(oklch(82% var(--neutral-chroma) var(--hue)), oklch(37% var(--neutral-chroma) var(--hue)));
84
+ --neutral-8: light-dark(oklch(74% var(--neutral-chroma) var(--hue)), oklch(46% var(--neutral-chroma) var(--hue)));
85
+ --neutral-9: light-dark(oklch(55% var(--neutral-chroma) var(--hue)), oklch(58% var(--neutral-chroma) var(--hue)));
86
+ --neutral-10: light-dark(oklch(50% var(--neutral-chroma) var(--hue)), oklch(64% var(--neutral-chroma) var(--hue)));
87
+ --neutral-11: light-dark(oklch(44% var(--neutral-chroma) var(--hue)), oklch(71% var(--neutral-chroma) var(--hue)));
88
+ --neutral-12: light-dark(oklch(18% var(--neutral-chroma) var(--hue)), oklch(96% var(--neutral-chroma) var(--hue)));
89
+
90
+ /* accent scale: chroma tapers at the extremes, peaks at the solid */
91
+ --accent-1: light-dark(oklch(99% calc(var(--chroma) * 0.1) var(--hue)), oklch(19% calc(var(--chroma) * 0.15) var(--hue)));
92
+ --accent-2: light-dark(oklch(97.5% calc(var(--chroma) * 0.15) var(--hue)), oklch(23% calc(var(--chroma) * 0.2) var(--hue)));
93
+ --accent-3: light-dark(oklch(95% calc(var(--chroma) * 0.25) var(--hue)), oklch(27% calc(var(--chroma) * 0.35) var(--hue)));
94
+ --accent-4: light-dark(oklch(92% calc(var(--chroma) * 0.35) var(--hue)), oklch(31% calc(var(--chroma) * 0.5) var(--hue)));
95
+ --accent-5: light-dark(oklch(89% calc(var(--chroma) * 0.45) var(--hue)), oklch(35% calc(var(--chroma) * 0.6) var(--hue)));
96
+ --accent-6: light-dark(oklch(85% calc(var(--chroma) * 0.55) var(--hue)), oklch(39% calc(var(--chroma) * 0.7) var(--hue)));
97
+ --accent-7: light-dark(oklch(79% calc(var(--chroma) * 0.65) var(--hue)), oklch(46% calc(var(--chroma) * 0.8) var(--hue)));
98
+ --accent-8: light-dark(oklch(71% calc(var(--chroma) * 0.8) var(--hue)), oklch(58% calc(var(--chroma) * 0.9) var(--hue)));
99
+ --accent-9: light-dark(oklch(55% var(--chroma) var(--hue)), oklch(70% var(--chroma) var(--hue)));
100
+ --accent-10: light-dark(oklch(50% var(--chroma) var(--hue)), oklch(75% var(--chroma) var(--hue)));
101
+ --accent-11: light-dark(oklch(46% calc(var(--chroma) * 0.9) var(--hue)), oklch(80% calc(var(--chroma) * 0.9) var(--hue)));
102
+ --accent-12: light-dark(oklch(24% calc(var(--chroma) * 0.5) var(--hue)), oklch(94% calc(var(--chroma) * 0.3) var(--hue)));
103
+
104
+ /* Semantic roles — what components actually use. Defined in terms
105
+ of scale steps: knobs → scales → roles. */
106
+ --color-surface: var(--neutral-1);
107
+ --color-surface-raised: var(--neutral-2);
108
+ /* top-layer panels (dialog, menus, toasts): white in light like the
109
+ page; one step elevated in dark, where shadows can't carry lift */
110
+ --color-surface-overlay: light-dark(var(--neutral-1), var(--neutral-2));
111
+ --color-text: var(--neutral-12);
112
+ --color-text-muted: var(--neutral-11);
113
+ --color-border: var(--neutral-6);
114
+ --color-border-strong: var(--neutral-8);
115
+ --color-accent: var(--accent-9);
116
+ --color-on-accent: light-dark(
117
+ oklch(99% 0.01 var(--hue)),
118
+ oklch(18% 0.02 var(--hue)));
119
+
120
+ /* Primary — the action color, decoupled from accent (shadcn-school:
121
+ near-black light, near-white dark; the accent appears only where
122
+ attention is earned: focus, selection, checkmarks). Hover/active
123
+ are derived, so ANY value assigned to --color-primary gets correct
124
+ states free. Colorful primary is a two-line theme:
125
+ :root { --color-primary: var(--accent-9);
126
+ --color-on-primary: var(--color-on-accent); } */
127
+ --color-primary: var(--neutral-12);
128
+ --color-on-primary: var(--neutral-1);
129
+ --color-primary-hover: color-mix(in oklch, var(--color-primary) 90%, var(--color-surface));
130
+ --color-primary-active: color-mix(in oklch, var(--color-primary) 80%, var(--color-surface));
131
+
132
+ /* Status roles — the step-3/7/9/11 analogs, no full scales.
133
+ Off-role shades: raw oklch(L var(--status-chroma) var(--*-hue)). */
134
+ --color-danger-surface: light-dark(oklch(95% calc(var(--status-chroma) * 0.25) var(--danger-hue)), oklch(27% calc(var(--status-chroma) * 0.35) var(--danger-hue)));
135
+ --color-danger-border: light-dark(oklch(79% calc(var(--status-chroma) * 0.65) var(--danger-hue)), oklch(46% calc(var(--status-chroma) * 0.8) var(--danger-hue)));
136
+ --color-danger: light-dark(oklch(52% var(--status-chroma) var(--danger-hue)), oklch(70% var(--status-chroma) var(--danger-hue)));
137
+ --color-on-danger: light-dark(oklch(99% 0.01 var(--danger-hue)), oklch(18% 0.02 var(--danger-hue)));
138
+ --color-danger-text: light-dark(oklch(44% calc(var(--status-chroma) * 0.9) var(--danger-hue)), oklch(80% calc(var(--status-chroma) * 0.9) var(--danger-hue)));
139
+
140
+ --color-success-surface: light-dark(oklch(95% calc(var(--status-chroma) * 0.25) var(--success-hue)), oklch(27% calc(var(--status-chroma) * 0.35) var(--success-hue)));
141
+ --color-success-border: light-dark(oklch(79% calc(var(--status-chroma) * 0.65) var(--success-hue)), oklch(46% calc(var(--status-chroma) * 0.8) var(--success-hue)));
142
+ --color-success: light-dark(oklch(52% var(--status-chroma) var(--success-hue)), oklch(70% var(--status-chroma) var(--success-hue)));
143
+ --color-on-success: light-dark(oklch(99% 0.01 var(--success-hue)), oklch(18% 0.02 var(--success-hue)));
144
+ --color-success-text: light-dark(oklch(44% calc(var(--status-chroma) * 0.9) var(--success-hue)), oklch(80% calc(var(--status-chroma) * 0.9) var(--success-hue)));
145
+
146
+ /* warn solid runs lighter than its siblings — amber needs the
147
+ lightness to read as warning, and takes dark text in both modes */
148
+ --color-warn-surface: light-dark(oklch(95% calc(var(--status-chroma) * 0.25) var(--warn-hue)), oklch(27% calc(var(--status-chroma) * 0.35) var(--warn-hue)));
149
+ --color-warn-border: light-dark(oklch(79% calc(var(--status-chroma) * 0.65) var(--warn-hue)), oklch(46% calc(var(--status-chroma) * 0.8) var(--warn-hue)));
150
+ --color-warn: light-dark(oklch(78% var(--status-chroma) var(--warn-hue)), oklch(76% var(--status-chroma) var(--warn-hue)));
151
+ --color-on-warn: oklch(20% 0.03 var(--warn-hue));
152
+ --color-warn-text: light-dark(oklch(42% calc(var(--status-chroma) * 0.9) var(--warn-hue)), oklch(80% calc(var(--status-chroma) * 0.9) var(--warn-hue)));
153
+ }
154
+ }
155
+
156
+ /* ------------------------------------------------------------------ *
157
+ * Preset — constructive defaults, Kelp-school not Preflight-school:
158
+ * raw HTML should look finished, not like nothing. Zero-specificity
159
+ * (:where) inside a layer: doubly overridable.
160
+ * ------------------------------------------------------------------ */
161
+ @layer mica.preset {
162
+ *,
163
+ *::before,
164
+ *::after {
165
+ box-sizing: border-box;
166
+ }
167
+
168
+ html {
169
+ -webkit-text-size-adjust: none;
170
+ text-size-adjust: none;
171
+ }
172
+
173
+ :where(body) {
174
+ margin: 0;
175
+ min-block-size: 100svh;
176
+ font-family: var(--font-body);
177
+ line-height: var(--line-height);
178
+ background: var(--color-surface);
179
+ color: var(--color-text);
180
+ accent-color: var(--color-accent);
181
+ }
182
+
183
+ /* Vertical rhythm: flow content spaces itself; last child never
184
+ pushes against its container. Stacks override all of this with gap. */
185
+ :where(p, ol, ul, dl, dd, blockquote, pre, table, figure, fieldset,
186
+ details, address, audio, video, iframe) {
187
+ margin-block: 0 var(--flow-space, var(--space-md));
188
+ }
189
+ :where(p, ol, ul, dl, dd, blockquote, pre, table, figure, fieldset,
190
+ details, address, audio, video, iframe):last-child {
191
+ margin-block-end: 0;
192
+ }
193
+
194
+ :where(h1, h2, h3, h4, h5, h6) {
195
+ margin-block: 0 var(--space-sm);
196
+ line-height: 1.2;
197
+ text-wrap: balance;
198
+ }
199
+ /* Headings that follow content get breathing room above. */
200
+ :where(h2, h3, h4, h5, h6):where(:not(:first-child)) {
201
+ margin-block-start: var(--space-xl);
202
+ }
203
+
204
+ :where(p) {
205
+ text-wrap: pretty;
206
+ }
207
+
208
+ /* Media behaves like content, not like text. */
209
+ :where(img, svg, video, canvas, picture) {
210
+ display: block;
211
+ max-inline-size: 100%;
212
+ block-size: auto;
213
+ }
214
+
215
+ /* Form controls inherit typography instead of shipping 2009's. */
216
+ :where(button, input, select, textarea) {
217
+ font: inherit;
218
+ }
219
+
220
+ :where(pre, code, kbd, samp) {
221
+ font-family: var(--font-mono);
222
+ }
223
+
224
+ /* opt in to animating to/from intrinsic sizes (details, etc.) */
225
+ :where(html) {
226
+ interpolate-size: allow-keywords;
227
+ }
228
+
229
+ /* Tokenized focus ring — the theming story for focus, day one. */
230
+ :where(:focus-visible) {
231
+ outline: var(--focus-ring-width) solid var(--focus-ring-color);
232
+ outline-offset: var(--focus-ring-offset);
233
+ }
234
+
235
+ @media (prefers-reduced-motion: reduce) {
236
+ *,
237
+ *::before,
238
+ *::after {
239
+ animation-duration: 0.01ms !important;
240
+ animation-iteration-count: 1 !important;
241
+ transition-duration: 0.01ms !important;
242
+ scroll-behavior: auto !important;
243
+ }
244
+ }
245
+ }
246
+
247
+ /* ------------------------------------------------------------------ *
248
+ * Elements — Tier 1. Native elements styled directly; behavior is the
249
+ * browser's. Variants are classes: any collision with app CSS resolves
250
+ * in the user's favor because these live in a layer.
251
+ * ------------------------------------------------------------------ */
252
+ @layer mica.elements {
253
+ /* --- button (and things dressed as buttons) ---------------------- */
254
+ :where(button, .btn,
255
+ input:is([type="button"], [type="submit"], [type="reset"])) {
256
+ display: inline-flex;
257
+ align-items: center;
258
+ justify-content: center;
259
+ gap: var(--space-xs);
260
+ block-size: var(--control-height);
261
+ padding: 0 var(--space-md);
262
+ border: 1px solid var(--color-border-strong);
263
+ border-radius: var(--radius-md);
264
+ background: var(--neutral-2);
265
+ color: var(--color-text);
266
+ font-size: 0.875rem;
267
+ font-weight: 500;
268
+ text-decoration: none;
269
+ cursor: pointer;
270
+ transition: background-color 0.15s, border-color 0.15s;
271
+
272
+ &:hover:not(:disabled, [aria-disabled="true"]) {
273
+ background: var(--neutral-3);
274
+ }
275
+ &:active:not(:disabled, [aria-disabled="true"]) {
276
+ background: var(--neutral-4);
277
+ }
278
+
279
+ /* variants */
280
+ &:where(.primary) {
281
+ background: var(--color-primary);
282
+ border-color: transparent;
283
+ color: var(--color-on-primary);
284
+
285
+ &:hover:not(:disabled, [aria-disabled="true"]) {
286
+ background: var(--color-primary-hover);
287
+ }
288
+ &:active:not(:disabled, [aria-disabled="true"]) {
289
+ background: var(--color-primary-active);
290
+ }
291
+
292
+ /* disabled = same fill at the base rule's reduced opacity — a
293
+ color-mix toward gray was tried and reads as an enabled
294
+ secondary button when primary is near-black */
295
+ }
296
+
297
+ &:where(.danger) {
298
+ background: var(--color-danger);
299
+ border-color: transparent;
300
+ color: var(--color-on-danger);
301
+
302
+ &:hover:not(:disabled, [aria-disabled="true"]) {
303
+ background: color-mix(in oklch, var(--color-danger) 88%, var(--neutral-12));
304
+ }
305
+ &:active:not(:disabled, [aria-disabled="true"]) {
306
+ background: color-mix(in oklch, var(--color-danger) 76%, var(--neutral-12));
307
+ }
308
+ &:where(:disabled, [aria-disabled="true"]) {
309
+ background: color-mix(in oklch, var(--color-danger) 40%, var(--neutral-6));
310
+ }
311
+ }
312
+
313
+ /* deliberately zero rest affordance — a ghost is invisible until
314
+ hover/focus. use .ghost for toolbars and icon rows, not as the
315
+ lone action on a page. */
316
+ &:where(.ghost) {
317
+ background: transparent;
318
+ border-color: transparent;
319
+
320
+ &:hover:not(:disabled, [aria-disabled="true"]) {
321
+ background: var(--neutral-3);
322
+ }
323
+ &:active:not(:disabled, [aria-disabled="true"]) {
324
+ background: var(--neutral-4);
325
+ }
326
+ }
327
+
328
+ /* sizes (shadcn: h-8 px-3 / h-10 px-6, text-sm throughout) */
329
+ &:where(.small) {
330
+ block-size: var(--control-height-sm);
331
+ padding-inline: var(--space-sm);
332
+ }
333
+ &:where(.large) {
334
+ block-size: var(--control-height-lg);
335
+ padding-inline: var(--space-lg);
336
+ }
337
+
338
+ &:where(:disabled, [aria-disabled="true"]) {
339
+ cursor: not-allowed;
340
+ opacity: 0.55;
341
+ }
342
+ }
343
+
344
+ /* --- fields: text inputs, textarea, select ----------------------- *
345
+ * One control family with buttons: same radius, same border weight.
346
+ * Focus ring comes from the preset; text fields match :focus-visible
347
+ * even on mouse focus, per spec.
348
+ * ------------------------------------------------------------------ */
349
+ :where(input, textarea, select):where(:not([type="checkbox"],
350
+ [type="radio"], [type="range"], [type="color"], [type="file"],
351
+ [type="button"], [type="submit"], [type="reset"],
352
+ [type="image"])) {
353
+ inline-size: 100%;
354
+ padding: var(--space-xs) var(--space-sm);
355
+ border: 1px solid var(--color-border-strong);
356
+ border-radius: var(--radius-md);
357
+ background: var(--color-surface);
358
+ color: var(--color-text);
359
+ line-height: 1.375; /* textarea rhythm; single-line heights are fixed below */
360
+
361
+ &::placeholder {
362
+ color: var(--color-text-muted);
363
+ opacity: 1;
364
+ }
365
+
366
+ &:where(:disabled) {
367
+ cursor: not-allowed;
368
+ opacity: 0.55;
369
+ background: var(--neutral-2);
370
+ }
371
+
372
+ /* validation — only after the user has interacted (:user-invalid),
373
+ never on pristine fields. First consumer of the status roles. */
374
+ &:where(:user-invalid) {
375
+ border-color: var(--color-danger);
376
+ --focus-ring-color: var(--color-danger);
377
+ }
378
+ }
379
+
380
+ /* single-line controls share the fixed control height; text centers
381
+ in the content box (16px field text stays — iOS zoom rule) */
382
+ :where(select:not([multiple], [size]),
383
+ input:not([type="checkbox"], [type="radio"], [type="range"],
384
+ [type="color"], [type="file"], [type="button"],
385
+ [type="submit"], [type="reset"], [type="image"])) {
386
+ block-size: var(--control-height);
387
+ padding-block: 0;
388
+ }
389
+
390
+ /* --- customizable select, where the platform allows -------------- *
391
+ * appearance: base-select opts into styleable pickers (Chromium).
392
+ * Elsewhere this whole rule drops (unknown pseudo-element) and the
393
+ * native picker remains — progressive enhancement, zero JS.
394
+ * Both declarations are required for the opt-in, and grouping them
395
+ * means they fail together: never a half-opted-in select.
396
+ * ------------------------------------------------------------------ */
397
+ :where(select),
398
+ ::picker(select) {
399
+ appearance: base-select;
400
+ }
401
+
402
+ @supports (appearance: base-select) {
403
+ /* the trigger is a flex row; children default to stretch, which
404
+ top-aligns the value text inside the fixed control height */
405
+ :where(select) {
406
+ align-items: center;
407
+ }
408
+
409
+ ::picker(select) {
410
+ border: 1px solid var(--color-border);
411
+ border-radius: var(--radius-md);
412
+ background: var(--color-surface-overlay);
413
+ padding: var(--space-2xs);
414
+ margin-block: var(--space-2xs);
415
+ /* dark surfaces need heavier shadows to register at all */
416
+ box-shadow: 0 8px 24px light-dark(oklch(0 0 0 / 0.15), oklch(0 0 0 / 0.5));
417
+ /* positioning stays the UA's (position-try-order: most-block-size):
418
+ it can open upward on near-ties, but it guarantees no clipping.
419
+ Forcing block-end + flip-block was tried and clips off-viewport
420
+ in tight space — don't fight the picker's positioning. */
421
+ opacity: 1;
422
+ translate: 0 0;
423
+ }
424
+
425
+ /* enter animates; close is instant (shadcn behavior) — the
426
+ transition only exists while open, so unmatching :open drops it */
427
+ :where(select):open::picker(select) {
428
+ transition:
429
+ opacity 0.12s,
430
+ translate 0.12s,
431
+ overlay 0.12s allow-discrete,
432
+ display 0.12s allow-discrete;
433
+ }
434
+
435
+ @starting-style {
436
+ ::picker(select) {
437
+ opacity: 0;
438
+ translate: 0 -4px;
439
+ }
440
+ }
441
+
442
+ :where(select) :where(option) {
443
+ padding: var(--space-xs) var(--space-sm);
444
+ border-radius: var(--radius-sm);
445
+ cursor: pointer;
446
+ }
447
+ :where(select) :where(option):where(:hover, :focus-visible) {
448
+ background: var(--neutral-3);
449
+ outline: none; /* hover-follows-focus pattern; ring is redundant here */
450
+ }
451
+ :where(select) :where(option):checked {
452
+ font-weight: 500;
453
+ }
454
+ :where(select) :where(option)::checkmark {
455
+ color: var(--color-accent);
456
+ }
457
+
458
+ :where(select)::picker-icon {
459
+ color: var(--color-text-muted);
460
+ transition: rotate 0.15s;
461
+ }
462
+ :where(select):open::picker-icon {
463
+ rotate: 180deg;
464
+ }
465
+
466
+ /* opt-in via select.js: the picker overlays the trigger with the
467
+ selected option kept in place (macOS-native / Base UI behavior).
468
+ JS supplies only --m-sel-index; all geometry lives here.
469
+ offset = index × row (1lh + option padding) + picker padding/border;
470
+ max() keeps the top on-screen for selections deep in long lists. */
471
+ :where(select.picker-aligned)::picker(select) {
472
+ position-area: none;
473
+ margin-block: 0;
474
+ inset-inline-start: calc(anchor(left) - var(--space-2xs) - 1px);
475
+ min-inline-size: calc(anchor-size(width) + 2 * (var(--space-2xs) + 1px));
476
+ inset-block-start: max(
477
+ var(--space-2xs),
478
+ calc(
479
+ anchor(top) - var(--m-sel-index, 0) * (1lh + 2 * var(--space-xs)) -
480
+ var(--space-2xs) - 1px
481
+ )
482
+ );
483
+ /* unfold in place, don't slide */
484
+ translate: 0 0;
485
+ }
486
+ }
487
+
488
+ /* textarea grows with content — zero JS */
489
+ :where(textarea) {
490
+ field-sizing: content;
491
+ min-block-size: 3lh;
492
+ resize: vertical;
493
+ }
494
+
495
+ /* --- form grouping: label, fieldset, legend ---------------------- *
496
+ * Plain by default (shadcn-school): a fieldset is a titled group,
497
+ * not a bordered box. The legend reads as a small section heading.
498
+ * Space contents with m-vstack or rhythm.
499
+ * ------------------------------------------------------------------ */
500
+ :where(label) {
501
+ font-weight: 500;
502
+ }
503
+
504
+ :where(fieldset) {
505
+ border: 0;
506
+ padding: 0;
507
+ margin: 0;
508
+ min-inline-size: 0; /* fieldsets otherwise refuse to shrink */
509
+ }
510
+
511
+ :where(legend) {
512
+ font-weight: 600;
513
+ padding: 0;
514
+ margin-block-end: var(--space-md);
515
+ }
516
+
517
+ /* a disabled fieldset dims everything it silences */
518
+ :where(fieldset:disabled) {
519
+ color: var(--color-text-muted);
520
+
521
+ & :where(label) {
522
+ color: var(--color-text-muted);
523
+ cursor: not-allowed;
524
+ }
525
+ }
526
+
527
+ /* --- checkbox, radio, switch: drawn, semantics stay native ------- */
528
+ :where(input:is([type="checkbox"], [type="radio"])) {
529
+ appearance: none;
530
+ inline-size: 1.25rem;
531
+ block-size: 1.25rem;
532
+ margin: 0;
533
+ flex-shrink: 0;
534
+ vertical-align: -0.25em;
535
+ border: 1px solid var(--color-border-strong);
536
+ border-radius: var(--radius-sm);
537
+ background: var(--color-surface) center / contain no-repeat;
538
+ cursor: pointer;
539
+ transition: background-color 0.15s, border-color 0.15s,
540
+ background-position 0.15s;
541
+
542
+ &:checked {
543
+ border-color: var(--color-primary);
544
+ background-color: var(--color-primary);
545
+ }
546
+
547
+ &:disabled {
548
+ cursor: not-allowed;
549
+ opacity: 0.55;
550
+ }
551
+ }
552
+
553
+ :where(input[type="checkbox"]:not(.switch)) {
554
+ &:checked {
555
+ background-image: var(--check-glyph);
556
+ }
557
+
558
+ /* indeterminate dash — a gradient, no glyph needed */
559
+ &:indeterminate {
560
+ border-color: var(--color-primary);
561
+ background-color: var(--color-primary);
562
+ background-image: linear-gradient(
563
+ var(--color-on-primary),
564
+ var(--color-on-primary)
565
+ );
566
+ background-size: 55% 2px;
567
+ background-position: center;
568
+ background-repeat: no-repeat;
569
+ }
570
+ }
571
+
572
+ /* radio: "square target" — a solid pip in every state. muted pip
573
+ unchecked, bright pip on the primary fill when checked. the round
574
+ radio is a theme:
575
+ input[type="radio"] {
576
+ border-radius: var(--radius-full);
577
+ background-image: radial-gradient(circle closest-side,
578
+ var(--color-on-primary) 40%, transparent 46%);
579
+ } */
580
+ :where(input[type="radio"]) {
581
+ background-image: linear-gradient(
582
+ var(--color-border-strong),
583
+ var(--color-border-strong)
584
+ );
585
+ background-position: center;
586
+ background-size: 8px 8px;
587
+ background-repeat: no-repeat;
588
+
589
+ &:checked {
590
+ background-image: linear-gradient(
591
+ var(--color-on-primary),
592
+ var(--color-on-primary)
593
+ );
594
+ }
595
+ }
596
+
597
+ /* switch: a checkbox wearing a track; the thumb is a positioned
598
+ gradient (pseudo-elements don't render on inputs). square, per the
599
+ design language — a solid linear-gradient block */
600
+ :where(input[type="checkbox"].switch) {
601
+ inline-size: 2.25rem;
602
+ border-color: transparent;
603
+ border-radius: var(--radius-sm);
604
+ background-color: var(--neutral-7);
605
+ background-image: linear-gradient(white, white);
606
+ background-size: 1rem calc(100% - 4px);
607
+ background-position: 2px 50%;
608
+ background-repeat: no-repeat;
609
+
610
+ &:checked {
611
+ background-color: var(--color-primary);
612
+ background-position: calc(100% - 2px) 50%;
613
+ background-image: linear-gradient(
614
+ var(--color-on-primary),
615
+ var(--color-on-primary)
616
+ );
617
+ }
618
+ }
619
+
620
+ /* disabled + on: desaturate toward neutral, same recipe as disabled
621
+ .primary buttons — inert, not just faded (placed after the switch
622
+ block so it wins its background-color at equal specificity) */
623
+ :where(input:is([type="checkbox"], [type="radio"])):disabled:is(
624
+ :checked,
625
+ :indeterminate
626
+ ) {
627
+ border-color: transparent;
628
+ background-color: color-mix(in oklch, var(--color-primary) 40%, var(--neutral-6));
629
+ }
630
+
631
+ /* labels that wrap a check control become the click target */
632
+ :where(label:has(> input:is([type="checkbox"], [type="radio"]))) {
633
+ display: inline-flex;
634
+ align-items: center;
635
+ gap: var(--space-xs);
636
+ cursor: pointer;
637
+ }
638
+
639
+ /* a disabled control dims its whole label, not just itself */
640
+ :where(label:has(> input:disabled)) {
641
+ color: var(--color-text-muted);
642
+ cursor: not-allowed;
643
+ }
644
+
645
+ /* --- content: tables, lists, code, quotes ------------------------ */
646
+ :where(table) {
647
+ inline-size: 100%;
648
+ border-collapse: collapse;
649
+ font-variant-numeric: tabular-nums;
650
+ }
651
+ :where(caption) {
652
+ caption-side: bottom;
653
+ margin-block-start: var(--space-xs);
654
+ color: var(--color-text-muted);
655
+ font-size: 0.875rem;
656
+ text-align: start;
657
+ }
658
+ :where(th) {
659
+ text-align: start;
660
+ font-weight: 500;
661
+ color: var(--color-text-muted);
662
+ }
663
+ :where(th, td) {
664
+ padding: var(--space-sm);
665
+ border-block-end: 1px solid var(--color-border);
666
+ }
667
+ :where(tbody tr:last-child) :where(th, td) {
668
+ border-block-end: 0;
669
+ }
670
+ :where(tbody tr):hover {
671
+ background: var(--color-surface-raised);
672
+ }
673
+
674
+ :where(ul, ol) {
675
+ padding-inline-start: var(--space-lg);
676
+ }
677
+ :where(li) {
678
+ margin-block: var(--space-2xs);
679
+ }
680
+ :where(li)::marker {
681
+ color: var(--color-text-muted);
682
+ }
683
+ :where(dt) {
684
+ font-weight: 500;
685
+ }
686
+ :where(dd) {
687
+ margin-inline-start: 0;
688
+ }
689
+
690
+ :where(code, kbd, samp) {
691
+ font-size: 0.875em;
692
+ }
693
+ :where(:not(pre) > code) {
694
+ background: var(--color-surface-raised);
695
+ border: 1px solid var(--color-border);
696
+ padding: 0.125em 0.375em;
697
+ border-radius: var(--radius-sm);
698
+ }
699
+ :where(pre) {
700
+ background: var(--color-surface-raised);
701
+ border: 1px solid var(--color-border);
702
+ border-radius: var(--radius-sm);
703
+ padding: var(--space-md);
704
+ overflow-x: auto;
705
+ }
706
+ :where(kbd) {
707
+ background: var(--color-surface);
708
+ border: 1px solid var(--color-border-strong);
709
+ border-block-end-width: 2px;
710
+ padding: 0.125em 0.375em;
711
+ border-radius: var(--radius-sm);
712
+ font-size: 0.8em;
713
+ }
714
+
715
+ :where(blockquote) {
716
+ border-inline-start: 2px solid var(--color-border-strong);
717
+ padding-inline-start: var(--space-md);
718
+ color: var(--color-text-muted);
719
+ }
720
+
721
+ :where(hr) {
722
+ border: 0;
723
+ border-block-start: 1px solid var(--color-border-strong);
724
+ margin-block: var(--space-lg);
725
+ }
726
+
727
+ /* --- progress & meter: thin bars (shadcn-school) ------------------ *
728
+ * vendor pseudos must stay in separate rules — an unknown selector
729
+ * in a list invalidates the whole rule.
730
+ * ------------------------------------------------------------------ */
731
+ :where(progress, meter) {
732
+ appearance: none;
733
+ inline-size: 100%;
734
+ block-size: 0.5rem;
735
+ border: 0;
736
+ border-radius: var(--radius-sm);
737
+ overflow: hidden;
738
+ }
739
+
740
+ :where(progress) {
741
+ background: color-mix(in oklch, var(--color-primary) 20%, transparent);
742
+ }
743
+ :where(progress)::-webkit-progress-bar {
744
+ background: transparent;
745
+ }
746
+ :where(progress)::-webkit-progress-value {
747
+ background: var(--color-primary);
748
+ }
749
+ :where(progress)::-moz-progress-bar {
750
+ background: var(--color-primary);
751
+ }
752
+
753
+ /* meter grades through the status roles */
754
+ :where(meter) {
755
+ background: var(--neutral-4);
756
+ }
757
+ :where(meter)::-webkit-meter-bar {
758
+ background: transparent;
759
+ border: 0;
760
+ block-size: 0.5rem;
761
+ }
762
+ :where(meter)::-webkit-meter-optimum-value {
763
+ background: var(--color-success);
764
+ }
765
+ :where(meter)::-webkit-meter-suboptimum-value {
766
+ background: var(--color-warn);
767
+ }
768
+ :where(meter)::-webkit-meter-even-less-good-value {
769
+ background: var(--color-danger);
770
+ }
771
+ :where(meter):-moz-meter-optimum::-moz-meter-bar {
772
+ background: var(--color-success);
773
+ }
774
+ :where(meter):-moz-meter-sub-optimum::-moz-meter-bar {
775
+ background: var(--color-warn);
776
+ }
777
+ :where(meter):-moz-meter-sub-sub-optimum::-moz-meter-bar {
778
+ background: var(--color-danger);
779
+ }
780
+
781
+ /* --- dialog: modal + drawer --------------------------------------- *
782
+ * The browser brings focus trapping, Esc, top layer, ::backdrop, and
783
+ * form[method=dialog] closing. Invoker commands (commandfor/command)
784
+ * make open/close zero-JS. Exit animations: transition-behavior
785
+ * allow-discrete + @starting-style.
786
+ * ------------------------------------------------------------------ */
787
+ :where(dialog) {
788
+ border: 1px solid var(--color-border);
789
+ border-radius: var(--radius-lg);
790
+ background: var(--color-surface-overlay);
791
+ color: var(--color-text);
792
+ /* sections carry their own padding; the dialog is a bare frame */
793
+ padding: 0;
794
+ overflow: hidden;
795
+ inline-size: calc(100vw - 2rem);
796
+ max-inline-size: var(--size-lg);
797
+ max-block-size: calc(100svh - 2rem);
798
+ box-shadow: 0 16px 48px light-dark(oklch(0 0 0 / 0.2), oklch(0 0 0 / 0.6));
799
+ opacity: 0;
800
+ scale: 0.95;
801
+ transition:
802
+ opacity 0.15s,
803
+ scale 0.15s,
804
+ translate 0.15s,
805
+ overlay 0.15s allow-discrete,
806
+ display 0.15s allow-discrete;
807
+ }
808
+ /* composition: dialog > header (title + description) / body / footer.
809
+ layout lives on [open] — unconditional display would defeat the
810
+ UA's display:none on closed dialogs */
811
+ :where(dialog[open]) {
812
+ display: flex;
813
+ flex-direction: column;
814
+ opacity: 1;
815
+ scale: 1;
816
+
817
+ & > :where(*) {
818
+ margin-block: 0; /* the dialog owns spacing, like a stack */
819
+ }
820
+
821
+ /* the body: whatever sits between header and footer. one wrapper
822
+ element; it pads itself and is the scrollable region */
823
+ & > :where(:not(header, footer, button.close)) {
824
+ padding: var(--space-md);
825
+ flex: 1;
826
+ min-block-size: 0;
827
+ overflow-y: auto;
828
+ font-size: 0.875rem; /* body must not outweigh the 14px title */
829
+ }
830
+ }
831
+
832
+ /* corner close button — <button class="close" commandfor="…"
833
+ command="close" aria-label="Close">✕</button> (or wrap it in a
834
+ method=dialog form). absolute is safe: the dialog is positioned.
835
+ shared with toasts (popovertarget/popovertargetaction=hide there). */
836
+ :where(dialog, [popover].toast) > :where(button.close) {
837
+ position: absolute;
838
+ inset-block-start: var(--space-sm);
839
+ inset-inline-end: var(--space-sm);
840
+ padding: var(--space-2xs);
841
+ border: 0;
842
+ background: transparent;
843
+ color: var(--color-text-muted);
844
+ line-height: 1;
845
+
846
+ &:hover:not(:disabled, [aria-disabled="true"]) {
847
+ background: var(--neutral-3);
848
+ color: var(--color-text);
849
+ }
850
+ &:active:not(:disabled, [aria-disabled="true"]) {
851
+ background: var(--neutral-4);
852
+ }
853
+ }
854
+
855
+ :where(dialog) > :where(header) {
856
+ display: flex;
857
+ flex-direction: column;
858
+ gap: var(--space-2xs);
859
+ padding: var(--space-md);
860
+ border-block-end: 1px solid var(--color-border);
861
+
862
+ /* long titles must not run under the corner X */
863
+ :where(dialog:has(> button.close)) > & {
864
+ padding-inline-end: calc(var(--space-md) + var(--space-xl));
865
+ }
866
+
867
+ & > :where(*) {
868
+ margin-block: 0;
869
+ }
870
+
871
+ & :where(h1, h2, h3, h4) {
872
+ font-size: 0.875rem;
873
+ font-weight: 500;
874
+ }
875
+
876
+ & :where(p) {
877
+ color: var(--color-text-muted);
878
+ font-size: 0.75rem;
879
+ line-height: 1.6;
880
+ }
881
+ }
882
+
883
+ :where(dialog) > :where(footer) {
884
+ display: flex;
885
+ flex-wrap: wrap;
886
+ gap: var(--space-sm);
887
+ justify-content: flex-end;
888
+ padding: var(--space-md);
889
+ border-block-start: 1px solid var(--color-border);
890
+
891
+ & > :where(*) {
892
+ margin-block: 0;
893
+ }
894
+
895
+ /* a method=dialog form is the idiomatic close mechanism — let its
896
+ buttons participate in the footer's row directly */
897
+ & > :where(form[method="dialog" i]) {
898
+ display: contents;
899
+ }
900
+ }
901
+ @starting-style {
902
+ :where(dialog[open]) {
903
+ opacity: 0;
904
+ scale: 0.95;
905
+ }
906
+ }
907
+
908
+ :where(dialog)::backdrop {
909
+ background: transparent;
910
+ transition:
911
+ background-color 0.15s,
912
+ overlay 0.15s allow-discrete,
913
+ display 0.15s allow-discrete;
914
+ }
915
+ :where(dialog[open])::backdrop {
916
+ background: oklch(0 0 0 / 0.4);
917
+ }
918
+ @starting-style {
919
+ :where(dialog[open])::backdrop {
920
+ background: transparent;
921
+ }
922
+ }
923
+
924
+ /* drawer (shadcn sheet anatomy): inline-end panel, fade + 2.5rem
925
+ nudge rather than a full slide; no section dividers; footer pinned
926
+ to the bottom with stacked actions */
927
+ :where(dialog.drawer) {
928
+ margin-inline: auto 0;
929
+ margin-block: 0;
930
+ block-size: 100svh;
931
+ max-block-size: 100svh;
932
+ inline-size: 75vw;
933
+ max-inline-size: 24rem; /* sheet's max-w-sm */
934
+ border: 0;
935
+ border-inline-start: 1px solid var(--color-border);
936
+ font-size: 0.75rem;
937
+ line-height: 1.6;
938
+ scale: 1;
939
+ translate: 2.5rem 0;
940
+ }
941
+ :where(dialog.drawer[open]) {
942
+ translate: 0 0;
943
+
944
+ & > :where(:not(header, footer, button.close)) {
945
+ font-size: inherit; /* panel runs 12/relaxed throughout */
946
+ }
947
+ }
948
+ @starting-style {
949
+ :where(dialog.drawer[open]) {
950
+ translate: 2.5rem 0;
951
+ }
952
+ }
953
+
954
+ :where(dialog.drawer) > :where(header) {
955
+ border-block-end: 0;
956
+ }
957
+ :where(dialog.drawer) > :where(footer) {
958
+ margin-block-start: auto;
959
+ flex-direction: column;
960
+ align-items: stretch;
961
+ gap: var(--space-xs);
962
+ border-block-start: 0;
963
+ }
964
+
965
+ /* --- accordion / disclosure: details + summary -------------------- *
966
+ * shadcn accordion look; exclusivity is <details name="group">.
967
+ * open/close animation via ::details-content + interpolate-size,
968
+ * graceful (instant) where unsupported.
969
+ * ------------------------------------------------------------------ */
970
+ :where(details) {
971
+ border-block-end: 1px solid var(--color-border);
972
+ }
973
+
974
+ :where(summary) {
975
+ display: flex;
976
+ align-items: center;
977
+ justify-content: space-between;
978
+ gap: var(--space-sm);
979
+ padding-block: var(--space-md);
980
+ font-size: 0.875rem;
981
+ font-weight: 500;
982
+ cursor: pointer;
983
+ list-style: none;
984
+
985
+ &:hover {
986
+ text-decoration: underline;
987
+ }
988
+
989
+ /* chevron — masked so it rides currentColor-ish tokens */
990
+ &::after {
991
+ content: "";
992
+ inline-size: 1rem;
993
+ block-size: 1rem;
994
+ flex-shrink: 0;
995
+ background: var(--color-text-muted);
996
+ mask: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24'%3E%3Cpath fill='none' stroke='black' stroke-width='2' stroke-linecap='round' stroke-linejoin='round' d='m6 9 6 6 6-6'/%3E%3C/svg%3E")
997
+ center / contain no-repeat;
998
+ transition: rotate 0.2s;
999
+ }
1000
+ }
1001
+ :where(summary)::-webkit-details-marker {
1002
+ display: none;
1003
+ }
1004
+ :where(details[open]) > :where(summary)::after {
1005
+ rotate: 180deg;
1006
+ }
1007
+
1008
+ /* content: everything after the summary */
1009
+ :where(details) > :where(:not(summary)) {
1010
+ margin-block: 0 var(--space-md);
1011
+ font-size: 0.875rem;
1012
+ }
1013
+
1014
+ :where(details)::details-content {
1015
+ block-size: 0;
1016
+ overflow-y: clip;
1017
+ transition:
1018
+ block-size 0.2s,
1019
+ content-visibility 0.2s allow-discrete;
1020
+ }
1021
+ :where(details[open])::details-content {
1022
+ block-size: auto;
1023
+ }
1024
+
1025
+ /* --- popover: panels + menus -------------------------------------- *
1026
+ * toggle/light-dismiss/Esc are the browser's (popovertarget). anchored
1027
+ * to the invoker where anchor positioning exists; elsewhere the UA's
1028
+ * centered fallback applies. items close declaratively via
1029
+ * popovertarget/command — zero JS throughout.
1030
+ * ------------------------------------------------------------------ */
1031
+ :where([popover]) {
1032
+ border: 1px solid color-mix(in oklch, var(--color-text) 12%, transparent);
1033
+ border-radius: var(--radius-md);
1034
+ background: var(--color-surface-overlay);
1035
+ color: var(--color-text);
1036
+ padding: var(--space-sm);
1037
+ inline-size: max-content;
1038
+ max-inline-size: min(18rem, calc(100vw - 2rem));
1039
+ font-size: 0.75rem;
1040
+ line-height: 1.6;
1041
+ box-shadow: 0 8px 24px light-dark(oklch(0 0 0 / 0.15), oklch(0 0 0 / 0.5));
1042
+ opacity: 1;
1043
+ scale: 1;
1044
+ }
1045
+ /* enter animates; close is instant (house rule for transient layers) */
1046
+ :where([popover]:popover-open) {
1047
+ transition:
1048
+ opacity 0.1s,
1049
+ scale 0.1s,
1050
+ overlay 0.1s allow-discrete,
1051
+ display 0.1s allow-discrete;
1052
+ }
1053
+ @starting-style {
1054
+ :where([popover]:popover-open) {
1055
+ opacity: 0;
1056
+ scale: 0.95;
1057
+ }
1058
+ }
1059
+ /* anchor to the invoker where the platform can */
1060
+ @supports (position-area: block-end) {
1061
+ :where([popover]) {
1062
+ position-area: block-end span-inline-end;
1063
+ position-try-fallbacks: flip-block, flip-inline;
1064
+ margin: var(--space-2xs);
1065
+ }
1066
+ }
1067
+
1068
+ /* menu: a popover of buttons/links; separators are plain hrs */
1069
+ :where([popover].menu) {
1070
+ padding: var(--space-2xs);
1071
+ min-inline-size: 8rem;
1072
+
1073
+ & > :where(button, a) {
1074
+ display: flex;
1075
+ align-items: center;
1076
+ justify-content: flex-start;
1077
+ gap: var(--space-xs);
1078
+ inline-size: 100%;
1079
+ block-size: auto;
1080
+ padding: var(--space-xs);
1081
+ border: 0;
1082
+ border-radius: var(--radius-sm);
1083
+ background: transparent;
1084
+ color: var(--color-text);
1085
+ font-size: 0.75rem;
1086
+ font-weight: 400;
1087
+ text-align: start;
1088
+ text-decoration: none;
1089
+ cursor: default;
1090
+
1091
+ /* :not() guards mirror the base button states — an override of
1092
+ state must match the state's specificity (house rule) */
1093
+ &:hover:not(:disabled, [aria-disabled="true"]),
1094
+ &:focus-visible:not(:disabled, [aria-disabled="true"]) {
1095
+ background: var(--color-accent);
1096
+ color: var(--color-on-accent);
1097
+ outline: none; /* the highlight is the focus indicator here */
1098
+ }
1099
+ &:active:not(:disabled, [aria-disabled="true"]) {
1100
+ background: var(--color-accent);
1101
+ color: var(--color-on-accent);
1102
+ }
1103
+
1104
+ &.danger {
1105
+ color: var(--color-danger-text);
1106
+
1107
+ &:hover:not(:disabled, [aria-disabled="true"]),
1108
+ &:active:not(:disabled, [aria-disabled="true"]),
1109
+ &:focus-visible:not(:disabled, [aria-disabled="true"]) {
1110
+ background: var(--color-danger-surface);
1111
+ color: var(--color-danger-text);
1112
+ }
1113
+ }
1114
+
1115
+ &:disabled {
1116
+ opacity: 0.5;
1117
+ background: transparent;
1118
+ color: var(--color-text);
1119
+ }
1120
+ }
1121
+
1122
+ & > :where(hr) {
1123
+ margin-block: var(--space-2xs);
1124
+ margin-inline: calc(-1 * var(--space-2xs));
1125
+ border-block-start-color: var(--color-border);
1126
+ }
1127
+ }
1128
+
1129
+ /* --- combobox: m-combobox + combobox.js (Tier 2) ------------------ *
1130
+ * without the module: a native datalist — functional autocomplete.
1131
+ * with it: styled listbox, filtering, active-descendant keys.
1132
+ * positioned within the wrapper (no top layer) so it works everywhere.
1133
+ * ------------------------------------------------------------------ */
1134
+ :where(m-combobox) {
1135
+ display: block;
1136
+ position: relative;
1137
+ }
1138
+
1139
+ :where(m-combobox) > :where([role="listbox"]) {
1140
+ position: absolute;
1141
+ inset-block-start: calc(100% + var(--space-2xs));
1142
+ inset-inline: 0;
1143
+ z-index: 50;
1144
+ max-block-size: 15rem;
1145
+ overflow-y: auto;
1146
+ padding: var(--space-2xs);
1147
+ border: 1px solid color-mix(in oklch, var(--color-text) 12%, transparent);
1148
+ border-radius: var(--radius-md);
1149
+ background: var(--color-surface-overlay);
1150
+ box-shadow: 0 8px 24px light-dark(oklch(0 0 0 / 0.15), oklch(0 0 0 / 0.5));
1151
+ }
1152
+
1153
+ :where(m-combobox) :where([role="option"]) {
1154
+ padding: var(--space-xs);
1155
+ border-radius: var(--radius-sm);
1156
+ font-size: 0.875rem;
1157
+ cursor: default;
1158
+
1159
+ &:hover,
1160
+ &[aria-selected="true"] {
1161
+ background: var(--color-accent);
1162
+ color: var(--color-on-accent);
1163
+ }
1164
+ }
1165
+
1166
+ /* --- tabs: m-tabs + tabs.js (Tier 2) ------------------------------ *
1167
+ * without the module: inert nav, all panels visible in order —
1168
+ * complete content. with it: tablist semantics + roving focus.
1169
+ * shadcn segmented look: muted track, surface active tab.
1170
+ * ------------------------------------------------------------------ */
1171
+ :where(m-tabs) {
1172
+ display: block;
1173
+ }
1174
+
1175
+ :where(m-tabs) > :where(nav) {
1176
+ display: inline-flex;
1177
+ gap: var(--space-2xs);
1178
+ padding: var(--space-2xs);
1179
+ background: var(--neutral-3);
1180
+ border-radius: var(--radius-md);
1181
+
1182
+ & > :where(button) {
1183
+ block-size: var(--control-height-sm);
1184
+ padding-inline: var(--space-sm);
1185
+ border: 0;
1186
+ border-radius: var(--radius-sm);
1187
+ background: transparent;
1188
+ color: var(--color-text-muted);
1189
+
1190
+ /* :not() guards mirror the base button states (house rule) */
1191
+ &:hover:not(:disabled, [aria-disabled="true"]),
1192
+ &:active:not(:disabled, [aria-disabled="true"]) {
1193
+ background: transparent;
1194
+ color: var(--color-text);
1195
+ }
1196
+
1197
+ &[aria-selected="true"] {
1198
+ background: var(--color-surface);
1199
+ color: var(--color-text);
1200
+ box-shadow: 0 1px 2px oklch(0 0 0 / 0.08);
1201
+
1202
+ &:hover:not(:disabled, [aria-disabled="true"]),
1203
+ &:active:not(:disabled, [aria-disabled="true"]) {
1204
+ background: var(--color-surface);
1205
+ color: var(--color-text);
1206
+ }
1207
+ }
1208
+ }
1209
+ }
1210
+
1211
+ :where(m-tabs) > :where(section) {
1212
+ padding-block: var(--space-md);
1213
+ }
1214
+
1215
+ /* --- toast: display only (queueing/auto-dismiss are Tier 2) ------- *
1216
+ * <div popover="manual" class="toast" role="status"> — manual so it
1217
+ * survives light dismiss and other popovers; role=status announces.
1218
+ * ------------------------------------------------------------------ */
1219
+ :where([popover].toast) {
1220
+ position-area: none; /* not anchored — pinned to the corner */
1221
+ /* --m-toast-offset comes from toast.js when stacking; 0 without it */
1222
+ inset: auto var(--space-lg) calc(var(--space-lg) + var(--m-toast-offset, 0px)) auto;
1223
+ margin: 0;
1224
+ inline-size: min(22rem, calc(100vw - 2rem));
1225
+ max-inline-size: none;
1226
+ padding: var(--space-md);
1227
+ padding-inline-end: var(--space-2xl); /* room for the corner X */
1228
+ translate: 0 0;
1229
+
1230
+ & :where(b) {
1231
+ display: block;
1232
+ font-weight: 500;
1233
+ }
1234
+ & :where(span, p) {
1235
+ color: var(--color-text-muted);
1236
+ }
1237
+
1238
+ /* status edge variants */
1239
+ &:where(.success) {
1240
+ border-inline-start: 2px solid var(--color-success);
1241
+ }
1242
+ &:where(.danger) {
1243
+ border-inline-start: 2px solid var(--color-danger);
1244
+ }
1245
+ &:where(.warn) {
1246
+ border-inline-start: 2px solid var(--color-warn);
1247
+ }
1248
+ }
1249
+ /* arrives from below rather than zooming; reflow (restacking) eases */
1250
+ :where([popover].toast:popover-open) {
1251
+ transition:
1252
+ opacity 0.1s,
1253
+ translate 0.1s,
1254
+ inset-block-end 0.15s,
1255
+ overlay 0.1s allow-discrete,
1256
+ display 0.1s allow-discrete;
1257
+ }
1258
+ @starting-style {
1259
+ :where([popover].toast:popover-open) {
1260
+ translate: 0 0.75rem;
1261
+ }
1262
+ }
1263
+
1264
+ /* --- tooltip: css-only, [data-tip] -------------------------------- *
1265
+ * inverted chip (primary on on-primary), shows on hover/focus after a
1266
+ * beat, hides instantly. data-tip is visual — pair it with aria-label
1267
+ * or visible text for AT.
1268
+ * ------------------------------------------------------------------ */
1269
+ :where([data-tip]) {
1270
+ position: relative;
1271
+ }
1272
+ :where([data-tip])::after {
1273
+ content: attr(data-tip);
1274
+ position: absolute;
1275
+ inset-block-end: calc(100% + var(--space-xs));
1276
+ inset-inline-start: 50%;
1277
+ translate: -50% 2px;
1278
+ inline-size: max-content;
1279
+ max-inline-size: 18rem;
1280
+ padding: 0.375rem var(--space-sm);
1281
+ border-radius: var(--radius-sm);
1282
+ background: var(--color-primary);
1283
+ color: var(--color-on-primary);
1284
+ font-size: 0.75rem;
1285
+ font-weight: 400;
1286
+ line-height: 1.4;
1287
+ opacity: 0;
1288
+ pointer-events: none;
1289
+ transition:
1290
+ opacity 0.1s,
1291
+ translate 0.1s;
1292
+ z-index: 10;
1293
+ }
1294
+ :where([data-tip]:hover, [data-tip]:focus-visible)::after {
1295
+ opacity: 1;
1296
+ translate: -50% 0;
1297
+ transition-delay: 0.3s;
1298
+ }
1299
+
1300
+ /* --- validation errors ------------------------------------------- *
1301
+ * Tier 1 (no JS): a generic <m-error> after a field shows when the
1302
+ * field is :user-invalid. Submit attempts fall back to native bubbles.
1303
+ * Tier 2 (mica/field.js): <m-field> suppresses bubbles and activates
1304
+ * per-cause <m-error match="..."> messages. Rules below are all
1305
+ * zero-specificity; source order resolves them.
1306
+ * ------------------------------------------------------------------ */
1307
+ :where(m-field) {
1308
+ display: flex;
1309
+ flex-direction: column;
1310
+ gap: var(--space-2xs);
1311
+ }
1312
+
1313
+ /* every rule here is zero-specificity: source order alone resolves */
1314
+ :where(m-error) {
1315
+ display: none;
1316
+ font-size: 0.875rem;
1317
+ color: var(--color-danger-text);
1318
+ }
1319
+
1320
+ /* Tier-1 fallback: generic (matchless) error after user interaction */
1321
+ :where(input:user-invalid, textarea:user-invalid, select:user-invalid)
1322
+ ~ :where(m-error:not([match])) {
1323
+ display: block;
1324
+ }
1325
+
1326
+ /* Tier 2: once m-field upgrades, the module owns visibility */
1327
+ :where(m-field:defined) :where(m-error) {
1328
+ display: none;
1329
+ }
1330
+ :where(m-error[active]) {
1331
+ display: block;
1332
+ }
1333
+ }
1334
+
1335
+ /* ------------------------------------------------------------------ *
1336
+ * Layout primitives (Tier 0)
1337
+ *
1338
+ * Mechanism: attributes only set custom properties; rules read custom
1339
+ * properties. `gap="lg"` and `style="--gap: 2.5rem"` are the same thing.
1340
+ * ------------------------------------------------------------------ */
1341
+ @layer mica.layout {
1342
+ /* Custom elements default to display:inline; give ours a sane base. */
1343
+ m-vstack,
1344
+ m-hstack,
1345
+ m-zstack,
1346
+ m-center,
1347
+ m-box,
1348
+ m-grid,
1349
+ m-sidecar,
1350
+ m-switcher,
1351
+ m-reel {
1352
+ display: block;
1353
+ box-sizing: border-box;
1354
+ }
1355
+
1356
+ /* Shared attribute vocabulary -> custom properties */
1357
+ :is(m-vstack, m-hstack, m-grid, m-sidecar, m-switcher, m-reel) {
1358
+ &[gap="none"] { --gap: 0; }
1359
+ &[gap="2xs"] { --gap: var(--space-2xs); }
1360
+ &[gap="xs"] { --gap: var(--space-xs); }
1361
+ &[gap="sm"] { --gap: var(--space-sm); }
1362
+ &[gap="md"] { --gap: var(--space-md); }
1363
+ &[gap="lg"] { --gap: var(--space-lg); }
1364
+ &[gap="xl"] { --gap: var(--space-xl); }
1365
+ &[gap="2xl"] { --gap: var(--space-2xl); }
1366
+ }
1367
+
1368
+ :is(m-vstack, m-hstack) {
1369
+ &[align="start"] { --align: flex-start; }
1370
+ &[align="center"] { --align: center; }
1371
+ &[align="end"] { --align: flex-end; }
1372
+ &[align="stretch"] { --align: stretch; }
1373
+ }
1374
+
1375
+ /* --- m-vstack: vertical flow with consistent gaps ---------------- */
1376
+ m-vstack {
1377
+ display: flex;
1378
+ flex-direction: column;
1379
+ align-items: var(--align, stretch);
1380
+ gap: var(--gap, var(--space-md));
1381
+
1382
+ /* the stack owns spacing; preset rhythm yields to gap */
1383
+ & > * { margin-block: 0; }
1384
+ }
1385
+
1386
+ /* --- m-hstack: one horizontal row; `wrap` to allow wrapping ------ */
1387
+ m-hstack {
1388
+ display: flex;
1389
+ align-items: var(--align, center);
1390
+ justify-content: var(--justify, flex-start);
1391
+ gap: var(--gap, var(--space-md));
1392
+
1393
+ &[wrap] { flex-wrap: wrap; }
1394
+
1395
+ & > * { margin-block: 0; }
1396
+
1397
+ &[justify="start"] { --justify: flex-start; }
1398
+ &[justify="center"] { --justify: center; }
1399
+ &[justify="end"] { --justify: flex-end; }
1400
+ &[justify="between"] { --justify: space-between; }
1401
+ }
1402
+
1403
+ /* --- m-zstack: layering; children occupy the same grid cell ------ */
1404
+ m-zstack {
1405
+ display: grid;
1406
+ align-items: var(--align, stretch);
1407
+ justify-items: var(--justify, stretch);
1408
+
1409
+ &[align="start"] { --align: start; }
1410
+ &[align="center"] { --align: center; }
1411
+ &[align="end"] { --align: end; }
1412
+ &[align="stretch"] { --align: stretch; }
1413
+
1414
+ &[justify="start"] { --justify: start; }
1415
+ &[justify="center"] { --justify: center; }
1416
+ &[justify="end"] { --justify: end; }
1417
+ &[justify="stretch"] { --justify: stretch; }
1418
+
1419
+ /* every layer shares one cell; paint order is DOM order.
1420
+ per-layer placement is just user CSS: place-self on the child. */
1421
+ & > * {
1422
+ grid-area: 1 / 1;
1423
+ }
1424
+ }
1425
+
1426
+ /* --- m-center: intrinsic horizontal centering with a measure ----- */
1427
+ m-center {
1428
+ max-inline-size: var(--max, var(--measure));
1429
+ margin-inline: auto;
1430
+ padding-inline: var(--gutter, 0);
1431
+
1432
+ &[max="xs"] { --max: var(--size-xs); }
1433
+ &[max="sm"] { --max: var(--size-sm); }
1434
+ &[max="md"] { --max: var(--size-md); }
1435
+ &[max="lg"] { --max: var(--size-lg); }
1436
+ &[max="xl"] { --max: var(--size-xl); }
1437
+
1438
+ &[gutter="sm"] { --gutter: var(--space-sm); }
1439
+ &[gutter="md"] { --gutter: var(--space-md); }
1440
+ &[gutter="lg"] { --gutter: var(--space-lg); }
1441
+ }
1442
+
1443
+ /* --- m-box: padding, an inside for content ----------------------- */
1444
+ m-box {
1445
+ padding: var(--pad, var(--space-md));
1446
+
1447
+ &[pad="none"] { --pad: 0; }
1448
+ &[pad="xs"] { --pad: var(--space-xs); }
1449
+ &[pad="sm"] { --pad: var(--space-sm); }
1450
+ &[pad="md"] { --pad: var(--space-md); }
1451
+ &[pad="lg"] { --pad: var(--space-lg); }
1452
+ &[pad="xl"] { --pad: var(--space-xl); }
1453
+ }
1454
+
1455
+ /* --- m-grid: responsive grid, no media queries ------------------- */
1456
+ m-grid {
1457
+ display: grid;
1458
+ grid-template-columns: repeat(
1459
+ auto-fit,
1460
+ minmax(min(var(--min, var(--size-sm)), 100%), 1fr)
1461
+ );
1462
+ gap: var(--gap, var(--space-md));
1463
+
1464
+ &[min="xs"] { --min: var(--size-xs); }
1465
+ &[min="sm"] { --min: var(--size-sm); }
1466
+ &[min="md"] { --min: var(--size-md); }
1467
+ &[min="lg"] { --min: var(--size-lg); }
1468
+ }
1469
+
1470
+ /* --- m-sidecar: fixed + flexible pair, stacks when narrow -------- */
1471
+ m-sidecar {
1472
+ display: flex;
1473
+ flex-wrap: wrap;
1474
+ gap: var(--gap, var(--space-md));
1475
+
1476
+ /* the sidecar is the first child by default; `side="end"` flips it */
1477
+ & > :first-child {
1478
+ flex-basis: var(--side-width, var(--size-xs));
1479
+ flex-grow: 1;
1480
+ }
1481
+ & > :last-child {
1482
+ flex-basis: 0;
1483
+ flex-grow: 999;
1484
+ min-inline-size: var(--content-min, 50%);
1485
+ }
1486
+
1487
+ /* side="end": the *last* child is the sidecar (no visual reordering) */
1488
+ &[side="end"] > :first-child {
1489
+ flex-basis: 0;
1490
+ flex-grow: 999;
1491
+ min-inline-size: var(--content-min, 50%);
1492
+ }
1493
+ &[side="end"] > :last-child {
1494
+ flex-basis: var(--side-width, var(--size-xs));
1495
+ flex-grow: 1;
1496
+ min-inline-size: auto;
1497
+ }
1498
+
1499
+ &[side-width="xs"] { --side-width: var(--size-xs); }
1500
+ &[side-width="sm"] { --side-width: var(--size-sm); }
1501
+ &[side-width="md"] { --side-width: var(--size-md); }
1502
+ }
1503
+
1504
+ /* --- m-switcher: row that switches to a column below a threshold - */
1505
+ m-switcher {
1506
+ display: flex;
1507
+ flex-wrap: wrap;
1508
+ gap: var(--gap, var(--space-md));
1509
+
1510
+ & > * {
1511
+ flex-basis: calc((var(--threshold, var(--size-lg)) - 100%) * 999);
1512
+ flex-grow: 1;
1513
+ }
1514
+
1515
+ &[threshold="sm"] { --threshold: var(--size-sm); }
1516
+ &[threshold="md"] { --threshold: var(--size-md); }
1517
+ &[threshold="lg"] { --threshold: var(--size-lg); }
1518
+ &[threshold="xl"] { --threshold: var(--size-xl); }
1519
+ }
1520
+
1521
+ /* --- m-reel: horizontal scroll with snap ------------------------- */
1522
+ m-reel {
1523
+ display: flex;
1524
+ gap: var(--gap, var(--space-md));
1525
+ overflow-x: auto;
1526
+ scroll-snap-type: inline proximity;
1527
+
1528
+ & > * {
1529
+ flex-shrink: 0;
1530
+ scroll-snap-align: start;
1531
+ }
1532
+
1533
+ &[snap="none"] { scroll-snap-type: none; }
1534
+ &[snap="mandatory"] { scroll-snap-type: inline mandatory; }
1535
+ }
1536
+ }