actual-css 0.1.0 → 0.2.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (51) hide show
  1. package/CHANGELOG.md +53 -1
  2. package/README.md +2 -0
  3. package/dist/actual.css +356 -124
  4. package/dist/actual.js +2 -2
  5. package/dist/actual.js.map +13 -13
  6. package/dist/actual.min.css +1 -1
  7. package/docs/README.md +2 -0
  8. package/docs/components.md +2 -2
  9. package/docs/design-notes/browser-support.md +9 -0
  10. package/docs/design-notes/enhancement-contract.md +104 -0
  11. package/docs/design-notes/naming.md +1 -0
  12. package/docs/design-notes/widget-primitives.md +83 -0
  13. package/docs/forms.md +4 -4
  14. package/docs/javascript.md +104 -34
  15. package/docs/patterns.md +89 -0
  16. package/docs/ui.md +22 -30
  17. package/llms.txt +6 -0
  18. package/package.json +14 -8
  19. package/src/css/base.css +0 -4
  20. package/src/css/components/alert.css +88 -2
  21. package/src/css/components/badge.css +30 -7
  22. package/src/css/components/busy.css +1 -0
  23. package/src/css/components/button.css +6 -8
  24. package/src/css/components/flyout.css +0 -1
  25. package/src/css/components/join.css +1 -2
  26. package/src/css/components/scrollspy.css +2 -2
  27. package/src/css/components/spinner.css +4 -4
  28. package/src/css/components/status-bar.css +3 -4
  29. package/src/css/components/tab.css +1 -0
  30. package/src/css/focus.css +37 -0
  31. package/src/css/forms/choice-card.css +1 -2
  32. package/src/css/forms/choice.css +1 -2
  33. package/src/css/forms/control.css +1 -10
  34. package/src/css/forms/index.css +1 -0
  35. package/src/css/forms/native.css +8 -32
  36. package/src/css/forms/range.css +95 -0
  37. package/src/css/forms/switch.css +1 -2
  38. package/src/css/optional/scroller.css +6 -0
  39. package/src/css/theme.css +9 -9
  40. package/src/css/themes/edge.css +3 -27
  41. package/src/css/tokens.css +9 -3
  42. package/src/js/context-menu.js +21 -34
  43. package/src/js/enhance.js +26 -0
  44. package/src/js/flyout.js +12 -19
  45. package/src/js/menu.js +15 -14
  46. package/src/js/scrollspy.js +125 -62
  47. package/src/js/selectors.js +14 -24
  48. package/src/js/status.js +13 -13
  49. package/src/js/surface.js +57 -12
  50. package/src/js/tab.js +10 -13
  51. package/src/js/validation.js +11 -43
package/dist/actual.css CHANGED
@@ -143,7 +143,8 @@ select {
143
143
  /* Shape */
144
144
  --border-width: 1px;
145
145
  --radius-full: 9999px;
146
- --shadow: 0 0.5rem 1.5rem rgb(0 0 0 / 0.08);
146
+ --shadow-xs: 0 1px 2px rgb(0 0 0 / 0.04);
147
+ --shadow: 0 1px 3px rgb(0 0 0 / 0.05), 0 4px 12px rgb(0 0 0 / 0.04);
147
148
  --shadow-popout: 0 0.25rem 0.75rem rgb(0 0 0 / 0.15);
148
149
 
149
150
  /* Motion — see reset.css for the prefers-reduced-motion hard reset.
@@ -157,8 +158,10 @@ select {
157
158
  --ease: ease;
158
159
 
159
160
  /* Focus ring — shared by every interactive component's :focus-visible */
160
- --focus-outline: calc(var(--border-width) * 2) solid var(--focus);
161
+ --focus-outline-color: currentColor;
162
+ --focus-outline: calc(var(--border-width) * 2) solid var(--focus-outline-color);
161
163
  --focus-outline-offset: 2px;
164
+ --focus-ring-width: 3px;
162
165
 
163
166
  /* Status dot positioning — used by .avatar > .badge:empty. The offset is
164
167
  the circle-aware inset (1 - 1/√2) / 2 ≈ 14.65%; the ring surrounds the
@@ -225,7 +228,10 @@ select {
225
228
 
226
229
  @supports (color: color-mix(in oklch, red, white)) {
227
230
  :root {
228
- --shadow: 0 0.5rem 1.5rem color-mix(in oklch, var(--shadow-color) 8%, transparent);
231
+ --shadow-xs: 0 1px 2px color-mix(in oklch, var(--shadow-color) 4%, transparent);
232
+ --shadow:
233
+ 0 1px 3px color-mix(in oklch, var(--shadow-color) 5%, transparent),
234
+ 0 4px 12px color-mix(in oklch, var(--shadow-color) 4%, transparent);
229
235
  --shadow-popout: 0 0.25rem 0.75rem color-mix(in oklch, var(--shadow-color) 15%, transparent);
230
236
  --backdrop-fill: color-mix(
231
237
  in oklch,
@@ -298,11 +304,11 @@ select {
298
304
 
299
305
  /* Lines and focus */
300
306
  --border: hsl(260 15% 88%);
301
- --focus: var(--primary);
307
+ --focus: var(--neutral);
302
308
  /* Pre-color-mix fallback only — superseded below by a derivation from
303
- --focus so overriding --primary/--focus never leaves a stale ring. */
304
- --focus-ring: hsl(268 32% 33% / 0.22);
305
- --focus-ring-shadow: 0 0 0 3px var(--focus-ring);
309
+ --focus so overriding --neutral/--focus never leaves a stale ring. */
310
+ --focus-ring: hsl(258 7% 43% / 0.3);
311
+ --focus-ring-shadow: 0 0 0 var(--focus-ring-width) var(--focus-ring);
306
312
  --hover-overlay: hsl(260 20% 10% / 0.04);
307
313
 
308
314
  /* Radius scale — themes may override the whole scale (e.g. [data-theme="square"]) */
@@ -343,7 +349,7 @@ select {
343
349
  :root,
344
350
  [data-theme="light"],
345
351
  [data-theme="dark"] {
346
- --focus-ring: color-mix(in oklch, var(--focus) 22%, transparent);
352
+ --focus-ring: color-mix(in oklch, var(--focus) 30%, transparent);
347
353
  }
348
354
  }
349
355
 
@@ -383,7 +389,7 @@ select {
383
389
  --text-subtle: light-dark(hsl(258 7% 57%), hsl(258 7% 54%));
384
390
 
385
391
  --border: light-dark(hsl(260 15% 88%), hsl(262 12% 24%));
386
- --focus-ring: light-dark(hsl(268 32% 33% / 0.22), hsl(268 42% 76% / 0.28));
392
+ --focus-ring: light-dark(hsl(258 7% 43% / 0.3), hsl(258 10% 72% / 0.28));
387
393
  --hover-overlay: light-dark(hsl(260 20% 10% / 0.04), hsl(0 0% 100% / 0.06));
388
394
  --shadow-color: light-dark(hsl(260 25% 10%), hsl(262 40% 2%));
389
395
  }
@@ -393,7 +399,7 @@ select {
393
399
  :root,
394
400
  [data-theme="light"] {
395
401
  --focus-ring: light-dark(
396
- color-mix(in oklch, var(--focus) 22%, transparent),
402
+ color-mix(in oklch, var(--focus) 30%, transparent),
397
403
  color-mix(in oklch, var(--focus) 28%, transparent)
398
404
  );
399
405
  }
@@ -403,7 +409,7 @@ select {
403
409
  :root,
404
410
  [data-theme="light"],
405
411
  [data-theme="dark"] {
406
- --focus-outline: calc(var(--border-width) * 2) solid Highlight;
412
+ --focus-outline-color: Highlight;
407
413
  --focus-ring-shadow: none;
408
414
 
409
415
  --backdrop-fill: Canvas;
@@ -416,7 +422,7 @@ select {
416
422
  [data-theme="dark"] {
417
423
  --border-width: 2px;
418
424
  --focus-outline-offset: 3px;
419
- --focus-ring-shadow: 0 0 0 4px var(--focus-ring);
425
+ --focus-ring-width: 4px;
420
426
  --text-muted: var(--text);
421
427
  --text-subtle: var(--text);
422
428
  }
@@ -441,10 +447,6 @@ a {
441
447
  text-underline-offset: 0.18em;
442
448
  }
443
449
 
444
- a:focus-visible {
445
- box-shadow: var(--focus-ring-shadow);
446
- }
447
-
448
450
  @media (hover: hover) {
449
451
  a:hover {
450
452
  text-decoration-thickness: 0.12em;
@@ -1047,7 +1049,6 @@ small {
1047
1049
  --btn-border: var(--ui-border, var(--btn-default-border, transparent));
1048
1050
  --btn-radius: var(--radius);
1049
1051
  --btn-focus-color: var(--intent, var(--focus));
1050
- --btn-focus-outline-width: calc(var(--border-width) * 2);
1051
1052
  --btn-hover-bg: var(--ui-hover-bg, var(--btn-bg));
1052
1053
  --btn-hover-overlay: var(--hover-overlay, rgb(0 0 0 / 0.08));
1053
1054
 
@@ -1055,7 +1056,7 @@ small {
1055
1056
  display: inline-flex;
1056
1057
  align-items: center;
1057
1058
  justify-content: center;
1058
- gap: 0.5em;
1059
+ gap: 0.375em;
1059
1060
  min-block-size: var(--btn-min-size);
1060
1061
  padding-block: var(--space-2);
1061
1062
  padding-block: max(var(--space-1), calc((var(--btn-min-size) - 1lh) / 2));
@@ -1071,7 +1072,7 @@ small {
1071
1072
  color: var(--btn-fg);
1072
1073
  font: inherit;
1073
1074
  font-size: var(--btn-font-size);
1074
- font-weight: var(--font-weight-strong);
1075
+ font-weight: var(--font-weight-medium);
1075
1076
  line-height: 1;
1076
1077
  text-decoration: none;
1077
1078
  cursor: pointer;
@@ -1147,14 +1148,14 @@ small {
1147
1148
  }
1148
1149
 
1149
1150
  .btn:focus-visible {
1150
- outline: var(--btn-focus-outline-width) solid var(--btn-focus-color);
1151
- outline-offset: var(--focus-outline-offset);
1152
- box-shadow: 0 0 0 3px var(--btn-focus-ring-color, var(--focus-ring));
1151
+ outline: none;
1152
+ box-shadow: 0 0 0 var(--focus-ring-width) var(--btn-focus-ring-color, var(--focus-ring));
1153
1153
  }
1154
1154
 
1155
1155
  .btn.link:focus-visible {
1156
- outline-color: currentColor;
1156
+ outline: var(--focus-outline);
1157
1157
  outline-offset: 0.2em;
1158
+ box-shadow: none;
1158
1159
  }
1159
1160
 
1160
1161
  .btn:disabled {
@@ -1178,7 +1179,6 @@ a.btn[aria-disabled="true"] {
1178
1179
  }
1179
1180
 
1180
1181
  .btn[aria-busy="true"] > .spinner {
1181
- --spinner-size: 1em;
1182
1182
  --spinner-width: 0.15em;
1183
1183
  }
1184
1184
 
@@ -1232,8 +1232,9 @@ a.btn[aria-disabled="true"] {
1232
1232
  /*
1233
1233
  * Badges for compact labels, counts, and state hints.
1234
1234
  *
1235
- * Solid by default. Shared .soft/.outline variants stay available for quieter
1236
- * tags without adding a badge-specific color API.
1235
+ * Soft by default. .solid for counters and notification badges. The dot
1236
+ * (.badge:empty) stays solid regardless — a soft dot is pointless.
1237
+ * Shared .outline variant stays available.
1237
1238
  */
1238
1239
  /* Intent boundary — inherited intents do not tint this component.
1239
1240
  Local intent classes still win because :where() has zero specificity. */
@@ -1253,9 +1254,9 @@ a.btn[aria-disabled="true"] {
1253
1254
  --badge-pad-x: 0.55em;
1254
1255
  --badge-font-size: var(--variant-compact-font-size, 0.8125rem);
1255
1256
  --badge-dot-size: 0.5rem;
1256
- --badge-bg: var(--ui-bg, var(--intent, var(--neutral)));
1257
- --badge-fg: var(--ui-fg, var(--intent-fg, var(--neutral-fg)));
1258
- --badge-border: var(--ui-border, transparent);
1257
+ --badge-bg: var(--ui-bg, transparent);
1258
+ --badge-fg: var(--ui-fg, var(--intent, var(--text)));
1259
+ --badge-border: var(--ui-border, var(--intent, var(--border)));
1259
1260
  --badge-radius: 999px;
1260
1261
 
1261
1262
  display: inline-flex;
@@ -1280,8 +1281,31 @@ a.btn[aria-disabled="true"] {
1280
1281
  box-shadow var(--duration);
1281
1282
  }
1282
1283
 
1284
+ /* Soft by default — duplicated here with @sync markers for check-sync.js
1285
+ to guard, the same way alert.css guards its own soft recipe. */
1286
+ @supports (color: color-mix(in oklch, red, white)) {
1287
+ .badge:where(:not(.solid, .outline)) {
1288
+ /* @sync soft-recipe */
1289
+ --badge-bg: color-mix(
1290
+ in oklch,
1291
+ var(--surface) var(--soft-bg-mix),
1292
+ var(--intent, var(--neutral))
1293
+ );
1294
+ --badge-fg: var(--intent, var(--text));
1295
+ --badge-border: color-mix(
1296
+ in oklch,
1297
+ var(--surface) var(--soft-border-mix),
1298
+ var(--intent, var(--neutral))
1299
+ );
1300
+ /* @sync soft-recipe */
1301
+ }
1302
+ }
1303
+
1283
1304
  .badge:empty {
1284
1305
  --badge-size: var(--badge-dot-size);
1306
+ --badge-bg: var(--intent, var(--neutral));
1307
+ --badge-fg: var(--intent-fg, var(--neutral-fg));
1308
+ --badge-border: transparent;
1285
1309
  padding: 0;
1286
1310
  border: 0;
1287
1311
  inline-size: var(--badge-size);
@@ -1314,8 +1338,7 @@ a.btn[aria-disabled="true"] {
1314
1338
  }
1315
1339
 
1316
1340
  /* .solid/.soft/.outline are shared via variants.css, including the
1317
- .soft/.outline hover recipe (--ui-hover-bg). The --badge-* fallbacks make
1318
- .badge solid by default. */
1341
+ .soft/.outline hover recipe (--ui-hover-bg). */
1319
1342
  @media (hover: hover) {
1320
1343
  :where(a.badge, button.badge):not(:where(:disabled, [aria-disabled="true"])):hover {
1321
1344
  background: var(--ui-hover-bg, var(--badge-bg));
@@ -1397,7 +1420,7 @@ a.badge[aria-disabled="true"] {
1397
1420
  }
1398
1421
 
1399
1422
  .alert {
1400
- --alert-pad-block: var(--variant-pad-block, var(--space-4));
1423
+ --alert-pad-block: var(--variant-pad-block, var(--space-3));
1401
1424
  --alert-pad-inline: var(--space-4);
1402
1425
  --alert-font-size: var(--variant-font-size, var(--font-size-md));
1403
1426
  --alert-icon-size: var(--variant-icon-size, 1.25rem);
@@ -1447,7 +1470,7 @@ a.badge[aria-disabled="true"] {
1447
1470
  }
1448
1471
 
1449
1472
  @supports (color: color-mix(in oklch, red, white)) {
1450
- .alert:where(:not(.solid, .outline)) {
1473
+ .alert:where(:not(.solid, .outline, .callout)) {
1451
1474
  /* @sync soft-recipe */
1452
1475
  --ui-bg: color-mix(in oklch, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)));
1453
1476
  --ui-border: color-mix(
@@ -1511,6 +1534,92 @@ a.badge[aria-disabled="true"] {
1511
1534
  Canvas backplate (unreadable) — so .solid intentionally falls back to the
1512
1535
  default look. */
1513
1536
 
1537
+ /* Callout — neutral surface with an inset accent band on the leading edge.
1538
+ The ::before pseudo is absolutely positioned and inset to respect the
1539
+ outer border-radius, so it never needs its own radius. */
1540
+ .alert.callout {
1541
+ --alert-bg: var(--surface-subtle);
1542
+ --ui-bg: var(--alert-bg);
1543
+ --ui-fg: var(--text);
1544
+ --ui-border: var(--border);
1545
+ position: relative;
1546
+ padding-inline-start: calc(var(--alert-pad-inline) + var(--space-3));
1547
+ }
1548
+
1549
+ .alert.callout::before {
1550
+ content: "";
1551
+ position: absolute;
1552
+ inset-block: var(--space-2);
1553
+ inset-inline-start: var(--space-2);
1554
+ inline-size: 0.25rem;
1555
+ border-radius: var(--radius-full);
1556
+ background: var(--intent, var(--neutral));
1557
+ }
1558
+
1559
+ /* Admonition — structured title/body anatomy with a tinted title band.
1560
+ The root is layout-neutralized: no padding, no gap, no icon grid. */
1561
+ .alert.admonition {
1562
+ padding: 0;
1563
+ gap: 0;
1564
+ display: block;
1565
+ overflow: clip;
1566
+ }
1567
+
1568
+ .alert-title {
1569
+ padding-block: var(--alert-pad-block);
1570
+ padding-inline: var(--alert-pad-inline);
1571
+
1572
+ @supports (color: color-mix(in oklch, red, white)) {
1573
+ background: color-mix(
1574
+ in oklch,
1575
+ var(--surface) var(--soft-bg-mix),
1576
+ var(--intent, var(--neutral))
1577
+ );
1578
+ }
1579
+
1580
+ @supports not (color: color-mix(in oklch, red, white)) {
1581
+ background: var(--intent, var(--neutral));
1582
+ }
1583
+
1584
+ border-start-start-radius: calc(var(--radius-lg) - var(--border-width));
1585
+ border-start-end-radius: calc(var(--radius-lg) - var(--border-width));
1586
+ }
1587
+
1588
+ .alert-title:where(summary) {
1589
+ cursor: pointer;
1590
+ user-select: none;
1591
+ list-style: none;
1592
+ }
1593
+
1594
+ .alert-title:where(summary)::-webkit-details-marker {
1595
+ display: none;
1596
+ }
1597
+
1598
+ .alert-title[aria-hidden] {
1599
+ display: inline-flex;
1600
+ gap: var(--space-2);
1601
+ align-items: center;
1602
+ }
1603
+
1604
+ .alert-body {
1605
+ padding-block: var(--alert-pad-block);
1606
+ padding-inline: var(--alert-pad-inline);
1607
+ color: var(--text);
1608
+ }
1609
+
1610
+ .alert-body > :first-child {
1611
+ margin-block-start: 0;
1612
+ }
1613
+
1614
+ .alert-body > :last-child {
1615
+ margin-block-end: 0;
1616
+ }
1617
+
1618
+ details.alert.admonition[open] .alert-title {
1619
+ border-end-start-radius: 0;
1620
+ border-end-end-radius: 0;
1621
+ }
1622
+
1514
1623
  /*
1515
1624
  * Actions — a horizontal action-button cluster.
1516
1625
  *
@@ -2201,7 +2310,6 @@ dialog.drawer nav {
2201
2310
  background: var(--surface-subtle);
2202
2311
  outline: var(--focus-outline);
2203
2312
  outline-offset: var(--focus-outline-offset);
2204
- box-shadow: var(--focus-ring-shadow);
2205
2313
  }
2206
2314
 
2207
2315
  :is(.flyout, .flyout :where(li))
@@ -2487,8 +2595,8 @@ dialog.drawer nav {
2487
2595
  }
2488
2596
 
2489
2597
  .spinner {
2490
- --spinner-size: 1.5rem;
2491
- --spinner-width: 2px;
2598
+ --spinner-size: 1em;
2599
+ --spinner-width: 0.125em;
2492
2600
  --spinner-track: currentColor;
2493
2601
  --spinner-value: transparent;
2494
2602
 
@@ -2514,11 +2622,11 @@ dialog.drawer nav {
2514
2622
  }
2515
2623
 
2516
2624
  .spinner.sm {
2517
- --spinner-size: 1rem;
2625
+ --spinner-size: 0.75em;
2518
2626
  }
2519
2627
 
2520
2628
  .spinner.lg {
2521
- --spinner-size: 3rem;
2629
+ --spinner-size: 2rem;
2522
2630
  --spinner-width: 3px;
2523
2631
  }
2524
2632
 
@@ -2626,6 +2734,7 @@ dialog.drawer nav {
2626
2734
  }
2627
2735
 
2628
2736
  [aria-busy="true"]:not(.btn) > .spinner:last-child {
2737
+ --spinner-size: 2rem;
2629
2738
  position: absolute;
2630
2739
  inset: 0;
2631
2740
  z-index: 2;
@@ -2780,10 +2889,9 @@ dialog.drawer nav {
2780
2889
  * the coloring via --intent / --intent-fg. status.js applies these as plain
2781
2890
  * classes on the target; it does not know their names or that they exist.
2782
2891
  *
2783
- * Public API: .status-bar + [data-status], both required — status.js reads
2784
- * the class name from js/selectors.js rather than hardcoding it, but still
2785
- * needs it to scope its document-wide lookup away from unrelated elements
2786
- * that carry their own generic `data-status`.
2892
+ * CSS API: .status-bar
2893
+ * JS target contract: [data-status][role="status"]
2894
+ * Normally combined, but they describe separate concerns.
2787
2895
  */
2788
2896
 
2789
2897
  /* Intent boundary — inherited intents do not tint this component.
@@ -3392,6 +3500,7 @@ a.avatar[aria-disabled="true"] {
3392
3500
 
3393
3501
  .tab:focus-visible {
3394
3502
  outline: var(--focus-outline);
3503
+ outline-color: var(--focus);
3395
3504
  outline-offset: -2px;
3396
3505
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
3397
3506
  }
@@ -3460,8 +3569,8 @@ a.avatar[aria-disabled="true"] {
3460
3569
  * Progressive enhancement: on browsers that support :target-current,
3461
3570
  * this file enables the native target group and marks the active link.
3462
3571
  *
3463
- * Falls back gracefully: links still work when IntersectionObserver or
3464
- * :target-current is unavailable.
3572
+ * Falls back gracefully: links still work when :target-current is unavailable
3573
+ * and when the JavaScript enhancer is not loaded at all.
3465
3574
  */
3466
3575
 
3467
3576
  @supports selector(.scrollspy a:target-current) {
@@ -3759,8 +3868,7 @@ a.avatar[aria-disabled="true"] {
3759
3868
  .textarea:focus-visible,
3760
3869
  .select:focus-visible {
3761
3870
  --control-border: var(--focus);
3762
- outline: var(--focus-outline);
3763
- outline-offset: var(--focus-outline-offset);
3871
+ outline: none;
3764
3872
  box-shadow: var(--focus-ring-shadow);
3765
3873
  }
3766
3874
 
@@ -3785,14 +3893,6 @@ a.avatar[aria-disabled="true"] {
3785
3893
  color: var(--text-muted);
3786
3894
  }
3787
3895
 
3788
- @media (forced-colors: active) {
3789
- .input:focus-visible,
3790
- .textarea:focus-visible,
3791
- .select:focus-visible {
3792
- box-shadow: none;
3793
- }
3794
- }
3795
-
3796
3896
  /*
3797
3897
  * Input Icon — leading decorative icon inside a relative wrapper.
3798
3898
  *
@@ -3988,8 +4088,7 @@ a.avatar[aria-disabled="true"] {
3988
4088
  .check:focus-visible,
3989
4089
  .radio:focus-visible {
3990
4090
  --choice-border: var(--focus);
3991
- outline: var(--focus-outline);
3992
- outline-offset: var(--focus-outline-offset);
4091
+ outline: none;
3993
4092
  box-shadow: var(--focus-ring-shadow);
3994
4093
  }
3995
4094
 
@@ -4030,9 +4129,15 @@ a.avatar[aria-disabled="true"] {
4030
4129
  /*
4031
4130
  * Choice cards — card-style radio and checkbox labels.
4032
4131
  *
4033
- * Each .choice-card is a <label> with a hidden native input. Both radio and
4034
- * checkbox show a checkmark badge on selection: round for radio, rounded
4035
- * square for checkbox. Pair with grid or flex utilities for group layout.
4132
+ * Each .choice-card is a <label> wrapping a native radio or checkbox. When
4133
+ * :has() is supported the input is visually hidden and the card mirrors its
4134
+ * state: checked border and tint, checkmark badge (round for radio, rounded
4135
+ * square for checkbox), focus ring, disabled dimming. Without :has() the
4136
+ * native input stays visible so selection state never disappears.
4137
+ *
4138
+ * The badge overhangs the top-right corner by 0.375em; ancestors with
4139
+ * overflow: hidden will clip it. Pair with grid or flex utilities for group
4140
+ * layout.
4036
4141
  */
4037
4142
  :where(.choice-card) {
4038
4143
  /* @sync intent-boundary */
@@ -4055,7 +4160,7 @@ a.avatar[aria-disabled="true"] {
4055
4160
  position: relative;
4056
4161
  display: block;
4057
4162
  padding: var(--choice-card-pad);
4058
- border: var(--border-width) solid var(--choice-card-border);
4163
+ border: var(--border-width) solid var(--form-invalid-border, var(--choice-card-border));
4059
4164
  border-radius: var(--choice-card-radius);
4060
4165
  background: var(--choice-card-bg);
4061
4166
  color: var(--text);
@@ -4067,23 +4172,26 @@ a.avatar[aria-disabled="true"] {
4067
4172
  }
4068
4173
 
4069
4174
  /* The native input is visually hidden but stays focusable and participates in
4070
- form submission and keyboard navigation (sr-only pattern). */
4071
- .choice-card > input {
4072
- position: absolute;
4073
- inline-size: 1px;
4074
- block-size: 1px;
4075
- padding: 0;
4076
- margin: -1px;
4077
- overflow: hidden;
4078
- clip: rect(0, 0, 0, 0);
4079
- white-space: nowrap;
4080
- border: 0;
4175
+ form submission and keyboard navigation (sr-only pattern). Guarded: without
4176
+ :has() the card cannot mirror the input's state, so the visible native
4177
+ control is the fallback affordance. */
4178
+ @supports selector(:has(*)) {
4179
+ .choice-card > :is(input[type="radio"], input[type="checkbox"]) {
4180
+ position: absolute;
4181
+ inline-size: 1px;
4182
+ block-size: 1px;
4183
+ padding: 0;
4184
+ margin: -1px;
4185
+ overflow: hidden;
4186
+ clip: rect(0, 0, 0, 0);
4187
+ white-space: nowrap;
4188
+ border: 0;
4189
+ }
4081
4190
  }
4082
4191
 
4083
4192
  /* Focus ring on the label follows the hidden input's focus. */
4084
4193
  .choice-card:has(:focus-visible) {
4085
- outline: var(--focus-outline);
4086
- outline-offset: var(--focus-outline-offset);
4194
+ outline: none;
4087
4195
  box-shadow: var(--focus-ring-shadow);
4088
4196
  }
4089
4197
 
@@ -4110,40 +4218,48 @@ a.avatar[aria-disabled="true"] {
4110
4218
  }
4111
4219
  }
4112
4220
 
4113
- /* Checkmark badge — circle for radio, rounded square for checkbox */
4114
- .choice-card:has(:checked)::before {
4221
+ /* Checkmark badge — always rendered at scale(0) and scaled in on check, so
4222
+ the entrance can transition (a pseudo created on :checked could not). */
4223
+ .choice-card::before,
4224
+ .choice-card::after {
4115
4225
  content: "";
4116
4226
  position: absolute;
4117
4227
  inset-inline-end: -0.375em;
4118
4228
  inset-block-start: -0.375em;
4119
4229
  inline-size: var(--choice-card-check-size);
4120
4230
  block-size: var(--choice-card-check-size);
4231
+ transform: scale(0);
4232
+ transition: transform var(--duration);
4233
+ }
4234
+
4235
+ /* Badge disc — circle for radio, rounded square for checkbox */
4236
+ .choice-card::before {
4121
4237
  background: var(--intent, var(--primary));
4122
- z-index: 1;
4123
4238
  box-shadow: 0 0 0 2px var(--surface);
4239
+ z-index: 1;
4124
4240
  }
4125
4241
 
4126
- .choice-card:has(input[type="radio"]:checked)::before {
4242
+ .choice-card:has(input[type="radio"])::before {
4127
4243
  border-radius: var(--radius-full);
4128
4244
  }
4129
4245
 
4130
- .choice-card:has(input[type="checkbox"]:checked)::before {
4246
+ .choice-card:has(input[type="checkbox"])::before {
4131
4247
  border-radius: var(--radius-sm);
4132
4248
  }
4133
4249
 
4134
- .choice-card:has(:checked)::after {
4135
- content: "";
4136
- position: absolute;
4137
- inset-inline-end: -0.375em;
4138
- inset-block-start: -0.375em;
4139
- inline-size: var(--choice-card-check-size);
4140
- block-size: var(--choice-card-check-size);
4250
+ /* Check mark */
4251
+ .choice-card::after {
4141
4252
  background: var(--intent-fg, var(--primary-fg));
4142
- mask: var(--icon-check-mask) center / 60% no-repeat;
4143
4253
  -webkit-mask: var(--icon-check-mask) center / 60% no-repeat;
4254
+ mask: var(--icon-check-mask) center / 60% no-repeat;
4144
4255
  z-index: 2;
4145
4256
  }
4146
4257
 
4258
+ .choice-card:has(:checked)::before,
4259
+ .choice-card:has(:checked)::after {
4260
+ transform: scale(1);
4261
+ }
4262
+
4147
4263
  @media (forced-colors: active) {
4148
4264
  .choice-card {
4149
4265
  --choice-card-border: ButtonText;
@@ -4152,6 +4268,9 @@ a.avatar[aria-disabled="true"] {
4152
4268
 
4153
4269
  .choice-card:has(:checked) {
4154
4270
  --choice-card-border: Highlight;
4271
+ /* Pin the background: with forced-color-adjust: none the color-mix tint
4272
+ above would actually render, uncontrolled in high-contrast themes. */
4273
+ --choice-card-bg: Canvas;
4155
4274
  forced-color-adjust: none;
4156
4275
  }
4157
4276
 
@@ -4160,14 +4279,6 @@ a.avatar[aria-disabled="true"] {
4160
4279
  box-shadow: 0 0 0 2px Canvas;
4161
4280
  }
4162
4281
 
4163
- .choice-card:has(input[type="radio"]:checked)::before {
4164
- border-radius: var(--radius-full);
4165
- }
4166
-
4167
- .choice-card:has(input[type="checkbox"]:checked)::before {
4168
- border-radius: var(--radius-sm);
4169
- }
4170
-
4171
4282
  .choice-card:has(:checked)::after {
4172
4283
  background: HighlightText;
4173
4284
  }
@@ -4305,23 +4416,13 @@ a.avatar[aria-disabled="true"] {
4305
4416
  }
4306
4417
 
4307
4418
  /*
4308
- * Native-only control polish: file and range inputs that defeat most custom
4309
- * styling, so the framework stays out of their way and just keeps them readable
4310
- * and theme-aware.
4419
+ * Native-only control polish: file input that mostly defeats custom styling,
4420
+ * so the framework stays out of the way and just keeps it readable and
4421
+ * theme-aware.
4422
+ *
4423
+ * Range has its own custom-styled file (range.css) so the focus ring can
4424
+ * target the thumb pseudo-element rather than the rectangular CSS box.
4311
4425
  */
4312
- /* Intent boundary — inherited intents do not tint this component.
4313
- Local intent classes still win because :where() has zero specificity. */
4314
- :where(.range) {
4315
- /* @sync intent-boundary */
4316
- --intent: initial;
4317
- --intent-fg: initial;
4318
- --ui-bg: initial;
4319
- --ui-fg: initial;
4320
- --ui-border: initial;
4321
- --ui-hover-bg: initial;
4322
- /* @sync intent-boundary */
4323
- }
4324
-
4325
4426
  .file {
4326
4427
  color: var(--text-muted);
4327
4428
  }
@@ -4354,23 +4455,105 @@ a.avatar[aria-disabled="true"] {
4354
4455
  }
4355
4456
  }
4356
4457
 
4458
+ .file:focus-visible {
4459
+ outline: none;
4460
+ box-shadow: var(--focus-ring-shadow);
4461
+ }
4462
+
4463
+ /*
4464
+ * Range slider — custom-styled so the focus ring always targets the
4465
+ * user-manipulated surface (the thumb) rather than the CSS box.
4466
+ *
4467
+ * appearance: none gives us full control over track and thumb; without it,
4468
+ * pseudo-element rules for ::-webkit-slider-thumb and ::-moz-range-thumb
4469
+ * are not reliably applied across browsers.
4470
+ */
4471
+
4472
+ /* Intent boundary — inherited intents do not tint this component.
4473
+ Local intent classes still win because :where() has zero specificity. */
4474
+ :where(.range) {
4475
+ /* @sync intent-boundary */
4476
+ --intent: initial;
4477
+ --intent-fg: initial;
4478
+ --ui-bg: initial;
4479
+ --ui-fg: initial;
4480
+ --ui-border: initial;
4481
+ --ui-hover-bg: initial;
4482
+ /* @sync intent-boundary */
4483
+ }
4484
+
4357
4485
  .range {
4486
+ --range-thumb-size: 1.25rem;
4487
+ --range-track-height: 0.25rem;
4488
+ --range-thumb-bg: var(--intent, var(--primary));
4489
+ --range-track-bg: var(--border);
4490
+
4491
+ appearance: none;
4358
4492
  inline-size: 100%;
4359
- accent-color: var(--intent, var(--primary));
4493
+ block-size: var(--range-thumb-size);
4494
+ background: transparent;
4495
+ cursor: pointer;
4496
+ }
4497
+
4498
+ .range::-webkit-slider-runnable-track {
4499
+ block-size: var(--range-track-height);
4500
+ border-radius: var(--radius-full);
4501
+ background: var(--range-track-bg);
4502
+ }
4503
+
4504
+ .range::-moz-range-track {
4505
+ block-size: var(--range-track-height);
4506
+ border-radius: var(--radius-full);
4507
+ background: var(--range-track-bg);
4508
+ }
4509
+
4510
+ .range::-webkit-slider-thumb {
4511
+ appearance: none;
4512
+ inline-size: var(--range-thumb-size);
4513
+ block-size: var(--range-thumb-size);
4514
+ border-radius: var(--radius-full);
4515
+ border: 0;
4516
+ background: var(--range-thumb-bg);
4517
+ margin-block-start: calc((var(--range-track-height) - var(--range-thumb-size)) / 2);
4518
+ cursor: inherit;
4519
+ transition: box-shadow var(--duration);
4520
+ }
4521
+
4522
+ .range::-moz-range-thumb {
4523
+ inline-size: var(--range-thumb-size);
4524
+ block-size: var(--range-thumb-size);
4525
+ border-radius: var(--radius-full);
4526
+ border: 0;
4527
+ background: var(--range-thumb-bg);
4528
+ cursor: inherit;
4529
+ transition: box-shadow var(--duration);
4360
4530
  }
4361
4531
 
4362
- .file:focus-visible,
4363
4532
  .range:focus-visible {
4364
- outline: var(--focus-outline);
4365
- outline-offset: var(--focus-outline-offset);
4366
- box-shadow: var(--focus-ring-shadow);
4533
+ outline: none;
4367
4534
  }
4368
4535
 
4369
- @media (forced-colors: active) {
4370
- .file:focus-visible,
4371
- .range:focus-visible {
4372
- box-shadow: none;
4373
- }
4536
+ /*
4537
+ * The 1px surface-coloured separator keeps the ring visually distinct from
4538
+ * the small, coloured thumb — unlike a full-size input where the same gap
4539
+ * would read as an unwanted void.
4540
+ */
4541
+ .range:focus-visible::-webkit-slider-thumb {
4542
+ box-shadow:
4543
+ 0 0 0 1px var(--surface),
4544
+ 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus-ring);
4545
+ }
4546
+
4547
+ .range:focus-visible::-moz-range-thumb {
4548
+ box-shadow:
4549
+ 0 0 0 1px var(--surface),
4550
+ 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus-ring);
4551
+ }
4552
+
4553
+ .range:disabled {
4554
+ --range-thumb-bg: var(--text-subtle);
4555
+ cursor: not-allowed;
4556
+ opacity: var(--disabled-opacity);
4374
4557
  }
4375
4558
 
4376
4559
  /*
@@ -4461,8 +4644,7 @@ a.avatar[aria-disabled="true"] {
4461
4644
 
4462
4645
  .switch:focus-visible {
4463
4646
  --switch-border: var(--focus);
4464
- outline: var(--focus-outline);
4465
- outline-offset: var(--focus-outline-offset);
4647
+ outline: none;
4466
4648
  box-shadow: var(--focus-ring-shadow);
4467
4649
  }
4468
4650
 
@@ -4539,6 +4721,16 @@ a.avatar[aria-disabled="true"] {
4539
4721
  --switch-bg: var(--danger);
4540
4722
  }
4541
4723
 
4724
+ /* Choice cards hide their native input, so invalid state must surface on the
4725
+ card itself. Sets the same --form-invalid-border hook the card's border
4726
+ consumes. */
4727
+ @supports selector(:has(*)) {
4728
+ .choice-card:has([aria-invalid="true"]),
4729
+ .needs-validation.was-validated .choice-card:has(:invalid) {
4730
+ --form-invalid-border: var(--danger);
4731
+ }
4732
+ }
4733
+
4542
4734
  @supports selector(:user-invalid) {
4543
4735
  .needs-validation :is(.input, .textarea, .select, .check, .radio, .switch):user-invalid {
4544
4736
  --form-invalid-border: var(--danger);
@@ -4549,6 +4741,10 @@ a.avatar[aria-disabled="true"] {
4549
4741
  .needs-validation .field:has(:user-invalid) > .field-error {
4550
4742
  display: block;
4551
4743
  }
4744
+
4745
+ .needs-validation .choice-card:has(:user-invalid) {
4746
+ --form-invalid-border: var(--danger);
4747
+ }
4552
4748
  }
4553
4749
 
4554
4750
  /*
@@ -4856,8 +5052,7 @@ a.avatar[aria-disabled="true"] {
4856
5052
  focus, and unsupported :has() browsers retain the control's normal ring. */
4857
5053
  .join:has(> :is(.input, .textarea, .select):focus-visible) {
4858
5054
  border-radius: var(--join-radius);
4859
- outline: var(--focus-outline);
4860
- outline-offset: var(--focus-outline-offset);
5055
+ outline: none;
4861
5056
  box-shadow: var(--focus-ring-shadow);
4862
5057
  }
4863
5058
 
@@ -4880,6 +5075,43 @@ a.avatar[aria-disabled="true"] {
4880
5075
  }
4881
5076
  }
4882
5077
 
5078
+ /*
5079
+ * Forced-colors focus — centralised so individual components do not need
5080
+ * their own fallback. The double :focus keeps specificity high enough
5081
+ * (0,0,2,0) to beat a single :focus-visible; against a class selector like
5082
+ * .input:focus-visible it ties, but this file loads last so it wins on
5083
+ * source order.
5084
+ *
5085
+ * .choice-card and .join receive focus indirectly (:has on a wrapper that
5086
+ * never matches :focus), so they are listed explicitly.
5087
+ *
5088
+ * .range is excluded from the rectangular fallback: its focus is drawn on
5089
+ * the thumb pseudo-element, which is the manipulated surface.
5090
+ *
5091
+ * box-shadow: none is redundant when forced-color-adjust is auto, but safe
5092
+ * when a component uses forced-color-adjust: none (e.g. checked switches /
5093
+ * checkboxes).
5094
+ */
5095
+ @media (forced-colors: active) {
5096
+ :focus:focus-visible:not(.range),
5097
+ .choice-card:has(:focus-visible),
5098
+ .join:has(> :is(.input, .textarea, .select):focus-visible) {
5099
+ outline: var(--focus-outline);
5100
+ outline-offset: var(--focus-outline-offset);
5101
+ box-shadow: none;
5102
+ }
5103
+
5104
+ .range:focus-visible::-webkit-slider-thumb {
5105
+ outline: var(--focus-outline);
5106
+ outline-offset: var(--focus-outline-offset);
5107
+ }
5108
+
5109
+ .range:focus-visible::-moz-range-thumb {
5110
+ outline: var(--focus-outline);
5111
+ outline-offset: var(--focus-outline-offset);
5112
+ }
5113
+ }
5114
+
4883
5115
  @media print {
4884
5116
  :root,
4885
5117
  body {