actual-css 0.10.0 → 0.11.1

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 (89) hide show
  1. package/CHANGELOG.md +99 -1
  2. package/README.md +6 -2
  3. package/components.json +14 -3
  4. package/dist/actual.full.css +483 -128
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/enhancement-contract.md +16 -7
  8. package/docs/design-notes/platform-alignment.md +8 -4
  9. package/docs/design-notes/rich-text.md +61 -0
  10. package/docs/design-notes/steps.md +2 -2
  11. package/docs/navigation.json +4 -1
  12. package/docs/pages/components/aura.md +12 -13
  13. package/docs/pages/components/badge.md +10 -0
  14. package/docs/pages/components/bleed.md +5 -4
  15. package/docs/pages/components/button.md +1 -1
  16. package/docs/pages/components/chat.md +5 -0
  17. package/docs/pages/components/close.md +9 -3
  18. package/docs/pages/components/dialog.md +145 -13
  19. package/docs/pages/components/meter.md +2 -0
  20. package/docs/pages/components/pagination.md +15 -15
  21. package/docs/pages/components/shimmer.md +57 -0
  22. package/docs/pages/components/skeleton.md +2 -1
  23. package/docs/pages/components/spinner.md +11 -6
  24. package/docs/pages/components/table.md +13 -0
  25. package/docs/pages/enhancements/flyout.md +2 -0
  26. package/docs/pages/enhancements/tabs.md +4 -0
  27. package/docs/pages/examples/overview.md +1 -0
  28. package/docs/pages/forms/choice-cards.md +77 -4
  29. package/docs/pages/forms/combobox.md +16 -1
  30. package/docs/pages/forms/overview.md +4 -1
  31. package/docs/pages/forms/range.md +110 -5
  32. package/docs/pages/forms/rich-text.md +110 -0
  33. package/docs/pages/forms/validation.md +5 -1
  34. package/docs/pages/foundations/theming.md +49 -3
  35. package/docs/pages/foundations/tokens.md +15 -5
  36. package/docs/pages/guides/bootstrap.md +2 -1
  37. package/docs/pages/guides/design-tools.md +36 -0
  38. package/docs/pages/guides/introduction.md +1 -1
  39. package/docs/pages/guides/modular-import.md +1 -1
  40. package/docs/pages/guides/progressive-enhancement.md +13 -14
  41. package/docs/pages/guides/tailwind.md +4 -2
  42. package/llms.txt +4 -3
  43. package/package.json +6 -1
  44. package/reserved-classes.json +1 -0
  45. package/skills/actual-css/references/enhancements.md +35 -0
  46. package/src/cli/actual-css.js +49 -6
  47. package/src/css/components/avatar.css +1 -1
  48. package/src/css/components/bleed.css +5 -5
  49. package/src/css/components/button.css +14 -1
  50. package/src/css/components/close.css +17 -6
  51. package/src/css/components/flyout.css +4 -1
  52. package/src/css/components/meter.css +6 -3
  53. package/src/css/components/modal.css +123 -7
  54. package/src/css/components/pagination.css +3 -2
  55. package/src/css/components/rating.css +1 -1
  56. package/src/css/components/status-bar.css +2 -2
  57. package/src/css/components/steps.css +3 -3
  58. package/src/css/components/tab.css +3 -3
  59. package/src/css/components/table.css +6 -0
  60. package/src/css/components/tooltip.css +1 -1
  61. package/src/css/core/focus.css +14 -4
  62. package/src/css/core/theme.css +8 -0
  63. package/src/css/core/tokens.css +8 -4
  64. package/src/css/core/variants.css +2 -1
  65. package/src/css/effects/aura.css +51 -33
  66. package/src/css/effects/index.css +1 -0
  67. package/src/css/effects/shimmer.css +57 -0
  68. package/src/css/forms/choice-card.css +71 -23
  69. package/src/css/forms/control.css +2 -2
  70. package/src/css/forms/native.css +5 -1
  71. package/src/css/forms/otp.css +9 -1
  72. package/src/css/forms/range.css +64 -21
  73. package/src/js/command.js +61 -32
  74. package/src/js/dialog.js +8 -23
  75. package/src/js/dismiss.js +1 -2
  76. package/src/js/enhance.js +84 -123
  77. package/src/js/full.js +1 -0
  78. package/src/js/password.js +1 -8
  79. package/src/js/range.js +151 -0
  80. package/src/js/scrollspy.js +4 -5
  81. package/src/js/status.js +2 -10
  82. package/src/js/tooltip.js +11 -1
  83. package/src/js/validation.js +4 -10
  84. package/src/tooling/design-components.js +286 -0
  85. package/src/tooling/design-export.js +69 -0
  86. package/src/tooling/design-tokens.js +466 -0
  87. package/src/tooling/penpot-plugin/manifest.json +7 -0
  88. package/src/tooling/penpot-plugin/plugin.js +272 -0
  89. package/src/tooling/penpot-plugin/ui.html +58 -0
@@ -130,9 +130,13 @@ select {
130
130
  --ease-enter: cubic-bezier(0.2, 0, 0, 1);
131
131
  --ease-exit: cubic-bezier(0.4, 0, 1, 1);
132
132
 
133
- /* Focus ring — shared by every interactive component's :focus-visible */
133
+ /* Generic focus fallback — an adaptive currentColor line for elements
134
+ without a component ring (links, summary, plain items). Components draw
135
+ the themed ring instead: --focus-ring-width solid --focus. See focus.css
136
+ for the two contracts. Both share --focus-ring-width: they differ in
137
+ color only. */
134
138
  --focus-outline-color: currentColor;
135
- --focus-outline: calc(var(--border-width) * 2) solid var(--focus-outline-color);
139
+ --focus-outline: var(--focus-ring-width) solid var(--focus-outline-color);
136
140
  --focus-outline-offset: 2px;
137
141
  --focus-ring-width: 2px;
138
142
 
@@ -195,8 +199,8 @@ select {
195
199
  --state-selected-fg: var(--primary-fg);
196
200
  --state-disabled: var(--text-subtle);
197
201
 
198
- /* Status dot ring — follows the island's surface. */
199
- --indicator-ring: var(--surface);
202
+ /* Status dot ring — cuts the dot out of what it overlaps. */
203
+ --indicator-ring: var(--surface-opaque);
200
204
  }
201
205
 
202
206
  :root {
@@ -291,6 +295,8 @@ select {
291
295
  * Shadow color, Focus ring derivation.
292
296
  * Derived: "Theme-derived aliases", Tone subsystem, Backdrop fill — reference
293
297
  * other tokens; hard-coded overrides on a custom theme will break the intent.
298
+ * Exception: --surface-opaque and --surface-solid-fg are public; a theme with
299
+ * translucent surfaces overrides them with opaque values.
294
300
  * Internal: --hgroup-gap.
295
301
  */
296
302
  :root {
@@ -382,6 +388,12 @@ select {
382
388
  --heading: var(--text);
383
389
  --selection-bg: var(--primary);
384
390
  --selection-fg: var(--primary-fg);
391
+ /* Surface roles that must stay opaque when a theme paints translucent
392
+ surfaces; both equal --surface on an opaque palette. --surface-opaque
393
+ hides or separates what is painted beneath a ring, separator or marker —
394
+ never a fill. --surface-solid-fg is the ink on --surface-solid. */
395
+ --surface-opaque: var(--surface);
396
+ --surface-solid-fg: var(--surface);
385
397
  }
386
398
 
387
399
  /* Soft foreground hooks — the soft ink is the intent rebated toward the local
@@ -719,7 +731,8 @@ small {
719
731
  /* Shared control scale. Wrappers are listed when their descendants form one
720
732
  component (field, choice-card, floating-field, input-icon, join, pagination,
721
733
  tabs, flyout). A local size declaration beats inherited density because it
722
- is declared on the sized component itself. */
734
+ is declared on the sized component itself. Guarded by check:sync
735
+ (size-scale-participants): .sm and .lg must list the same components. */
723
736
  :where(
724
737
  .btn,
725
738
  .input,
@@ -798,12 +811,22 @@ small {
798
811
  for its keyboard focus indicator; forced colors keep outlines and drop
799
812
  shadows.
800
813
 
801
- Component indicators are a solid --focus-ring-width line in --focus: inset
802
- on fields (inside their border box), offset by --focus-outline-offset on
803
- discrete actions. The color never derives from an intent or a local
814
+ Two contracts share the job, both --focus-ring-width wide. Text-like
815
+ elements, whose ink is the thing being focused (links, summary, .nav-link,
816
+ .menu-item, breadcrumb links, .navbar-brand), declare no outline and take
817
+ the generic fallback below: a currentColor line that follows whatever the
818
+ application painted. Boxed components, with a fill or border of their own,
819
+ draw the themed ring instead — a solid line in --focus: inset on fields
820
+ (inside their border box), offset by --focus-outline-offset on discrete
821
+ actions. The color never derives from an intent or a local
804
822
  surface — the theme guarantees --focus at 3:1 against both --surface and
805
823
  --surface-solid, so one token reads on the page, on a solid band and on
806
- any nested surface. */
824
+ any nested surface.
825
+
826
+ Documented exception: an invalid field focuses in --form-invalid-border
827
+ (danger), so the error stays visible at the point of correction. The border
828
+ carries the same hook; report:theme-contrast measures that line at 3:1
829
+ against the field's background alongside --focus. */
807
830
  :focus {
808
831
  outline: var(--focus-outline);
809
832
  outline-offset: var(--focus-outline-offset);
@@ -2040,8 +2063,8 @@ small {
2040
2063
  cursor: var(--control-cursor);
2041
2064
  /* No outline: none here — the core :focus fallback must survive for browsers
2042
2065
  without :focus-visible. Pointer focus is hidden by core's
2043
- :focus:not(:focus-visible); keyboard focus swaps in the transparent
2044
- outline + ring below. */
2066
+ :focus:not(:focus-visible); keyboard focus draws the inset outline below
2067
+ (invalid fields take --form-invalid-border, the documented exception). */
2045
2068
  transition:
2046
2069
  border-color var(--duration),
2047
2070
  box-shadow var(--duration),
@@ -2425,7 +2448,11 @@ small {
2425
2448
  the whole control, not just the filename text. */
2426
2449
  min-block-size: var(--control-size);
2427
2450
  padding-block: var(--space-10);
2428
- padding-block: max(var(--space-10), calc((var(--control-size) - 1lh) / 2));
2451
+ /* Border-box height: both borders come out of the padding, as on .btn. */
2452
+ padding-block: max(
2453
+ var(--space-10),
2454
+ calc((var(--control-size) - 1lh - 2 * var(--border-width)) / 2)
2455
+ );
2429
2456
  padding-inline: var(--space-20);
2430
2457
  border: var(--border-width) solid var(--border);
2431
2458
  border-radius: var(--radius-sm);
@@ -2889,6 +2916,10 @@ small {
2889
2916
  * --range-track-height
2890
2917
  * --range-thumb-bg
2891
2918
  * --range-track-bg
2919
+ * --range-fill-bg
2920
+ *
2921
+ * Runtime-written (by actual-css/js/range, never set by hand):
2922
+ * --range-progress
2892
2923
  */
2893
2924
 
2894
2925
  /* Intent boundary — inherited intents do not tint this component.
@@ -2898,10 +2929,17 @@ small {
2898
2929
  }
2899
2930
 
2900
2931
  .range {
2901
- --range-thumb-size: 1.25rem;
2902
- --range-track-height: 0.25rem;
2932
+ --range-thumb-size: 1.125rem;
2933
+ --range-track-height: 0.625rem;
2903
2934
  --range-thumb-bg: var(--intent, var(--primary));
2904
2935
  --range-track-bg: var(--border);
2936
+ --range-fill-bg: var(--intent, var(--primary));
2937
+ /* Fill share, written by actual-css/js/range. 0% with no runtime means a
2938
+ plain track — the control stays fully usable without JavaScript. */
2939
+ --range-progress: 0%;
2940
+ /* Logical fill direction: the gradient below cannot use flow-relative
2941
+ keywords, so RTL flips the angle through this hook instead. */
2942
+ --range-fill-from: to right;
2905
2943
 
2906
2944
  appearance: none;
2907
2945
  -webkit-appearance: none;
@@ -2911,16 +2949,29 @@ small {
2911
2949
  cursor: pointer;
2912
2950
  }
2913
2951
 
2952
+ :where([dir="rtl"]) .range,
2953
+ .range:dir(rtl) {
2954
+ --range-fill-from: to left;
2955
+ }
2956
+
2914
2957
  .range::-webkit-slider-runnable-track {
2915
2958
  block-size: var(--range-track-height);
2916
2959
  border-radius: var(--radius-full);
2917
- background: var(--range-track-bg);
2960
+ background: linear-gradient(
2961
+ var(--range-fill-from),
2962
+ var(--range-fill-bg) var(--range-progress),
2963
+ var(--range-track-bg) var(--range-progress)
2964
+ );
2918
2965
  }
2919
2966
 
2920
2967
  .range::-moz-range-track {
2921
2968
  block-size: var(--range-track-height);
2922
2969
  border-radius: var(--radius-full);
2923
- background: var(--range-track-bg);
2970
+ background: linear-gradient(
2971
+ var(--range-fill-from),
2972
+ var(--range-fill-bg) var(--range-progress),
2973
+ var(--range-track-bg) var(--range-progress)
2974
+ );
2924
2975
  }
2925
2976
 
2926
2977
  .range::-webkit-slider-thumb {
@@ -2933,6 +2984,10 @@ small {
2933
2984
  background: var(--range-thumb-bg);
2934
2985
  margin-block-start: calc((var(--range-track-height) - var(--range-thumb-size)) / 2);
2935
2986
  cursor: inherit;
2987
+ /* Permanent surface separator: the fill defaults to the thumb color, so
2988
+ without this the thumb would melt into the filled part of the track.
2989
+ Focus-visible keeps this ring and adds the --focus ring outside it. */
2990
+ box-shadow: 0 0 0 2px var(--surface-opaque);
2936
2991
  transition: box-shadow var(--duration);
2937
2992
  }
2938
2993
 
@@ -2943,6 +2998,7 @@ small {
2943
2998
  border: 0;
2944
2999
  background: var(--range-thumb-bg);
2945
3000
  cursor: inherit;
3001
+ box-shadow: 0 0 0 2px var(--surface-opaque);
2946
3002
  transition: box-shadow var(--duration);
2947
3003
  }
2948
3004
 
@@ -2953,20 +3009,21 @@ small {
2953
3009
  /*
2954
3010
  * A thumb pseudo-element takes no reliable outline, so its ring is a solid
2955
3011
  * --focus shadow (the transparent outline above covers forced colors). The
2956
- * 1px surface-coloured separator keeps the ring visually distinct from the
2957
- * small, coloured thumb — unlike a full-size input where the same gap would
2958
- * read as an unwanted void.
3012
+ * surface-coloured separator keeps the ring visually distinct from the
3013
+ * coloured thumb — unlike a full-size input where the same gap would
3014
+ * read as an unwanted void. It doubles as the resting separator between
3015
+ * the thumb and the same-coloured fill.
2959
3016
  */
2960
3017
  .range:focus-visible::-webkit-slider-thumb {
2961
3018
  box-shadow:
2962
- 0 0 0 1px var(--surface),
2963
- 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus);
3019
+ 0 0 0 2px var(--surface-opaque),
3020
+ 0 0 0 calc(2px + var(--focus-ring-width)) var(--focus);
2964
3021
  }
2965
3022
 
2966
3023
  .range:focus-visible::-moz-range-thumb {
2967
3024
  box-shadow:
2968
- 0 0 0 1px var(--surface),
2969
- 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus);
3025
+ 0 0 0 2px var(--surface-opaque),
3026
+ 0 0 0 calc(2px + var(--focus-ring-width)) var(--focus);
2970
3027
  }
2971
3028
 
2972
3029
  .range:disabled {
@@ -2974,28 +3031,41 @@ small {
2974
3031
  cursor: not-allowed;
2975
3032
  opacity: var(--disabled-opacity);
2976
3033
  }
2977
-
2978
3034
  /* Forced colors: the track must stay a visible line against the page (Canvas
2979
- would merge with it) and the thumb needs a real boundary to remain
2980
- distinguishable from the track. Disabled keeps GrayText via :disabled.
2981
- Focus stays on the transparent outline of :focus-visible. Track and thumb
2982
- fills are written on the properties, not only the hooks: an application
2983
- hook override at the same specificity loads after this file and would win
2984
- the variable, leaving a Canvas-remapped fill. */
3035
+ would merge with it) and the thumb needs a real boundary to remain
3036
+ distinguishable from the track. The fill keeps its two-tone split with
3037
+ system colors so the value stays readable. Disabled keeps GrayText via
3038
+ :disabled. Focus stays on the transparent outline of :focus-visible. Track
3039
+ and thumb fills are written on the properties, not only the hooks: an
3040
+ application hook override at the same specificity loads after this file
3041
+ and would win the variable, leaving a Canvas-remapped fill.
3042
+ The split lives in a background-image, which forced-color-adjust: auto
3043
+ drops — so the tracks opt out locally. The colors stay system keywords,
3044
+ keeping native contrast adaptation; only the host keeps adjusting. */
2985
3045
  @media (forced-colors: active) {
2986
3046
  .range {
2987
3047
  --range-track-bg: CanvasText;
2988
3048
  --range-thumb-bg: ButtonFace;
3049
+ --range-fill-bg: Highlight;
2989
3050
  }
2990
3051
 
2991
3052
  .range::-webkit-slider-runnable-track {
2992
- background: CanvasText;
3053
+ forced-color-adjust: none;
3054
+ background: linear-gradient(
3055
+ var(--range-fill-from),
3056
+ Highlight var(--range-progress),
3057
+ CanvasText var(--range-progress)
3058
+ );
2993
3059
  }
2994
3060
 
2995
3061
  .range::-moz-range-track {
2996
- background: CanvasText;
3062
+ forced-color-adjust: none;
3063
+ background: linear-gradient(
3064
+ var(--range-fill-from),
3065
+ Highlight var(--range-progress),
3066
+ CanvasText var(--range-progress)
3067
+ );
2997
3068
  }
2998
-
2999
3069
  .range::-webkit-slider-thumb {
3000
3070
  background: ButtonFace;
3001
3071
  border: var(--border-width) solid ButtonText;
@@ -3014,13 +3084,15 @@ small {
3014
3084
  *
3015
3085
  * Each .choice-card is a <label> wrapping a native radio or checkbox. When
3016
3086
  * :has() is supported the input is visually hidden and the card mirrors its
3017
- * state: checked border and tint, checkmark badge (round for radio, rounded
3018
- * square for checkbox), focus ring, disabled dimming. Without :has() the
3019
- * native input stays visible so selection state never disappears.
3087
+ * state: an indicator on the leading edge (a ring for radio, a box for
3088
+ * checkbox) that fills with a check, checked border and tint, focus ring,
3089
+ * disabled dimming. Without :has() the native input stays visible in the
3090
+ * indicator's slot, so selection state never disappears.
3020
3091
  *
3021
- * The badge overhangs the top-right corner by 0.375em; ancestors with
3022
- * overflow: hidden will clip it. Pair with grid or flex utilities for group
3023
- * layout.
3092
+ * The card is a two-column grid: the indicator in the first column, level
3093
+ * with the first row, and each child element of the label on its own row in
3094
+ * the second. Wrap loose text in an element. A corner badge for tiles is a
3095
+ * documented recipe on these same layers, not a variant.
3024
3096
  *
3025
3097
  * .chip is the compact pill form (filter chips, selectable tags): control
3026
3098
  * height, and a leading dot that turns into the check in a fixed slot, so
@@ -3028,6 +3100,7 @@ small {
3028
3100
  *
3029
3101
  * Public hooks:
3030
3102
  * --choice-card-pad
3103
+ * --choice-card-gap
3031
3104
  * --choice-card-radius
3032
3105
  * --choice-card-border
3033
3106
  * --choice-card-bg
@@ -3042,19 +3115,25 @@ small {
3042
3115
 
3043
3116
  .choice-card {
3044
3117
  --choice-card-pad: var(--density-space, var(--space-40));
3118
+ --choice-card-gap: var(--space-30);
3045
3119
  --choice-card-radius: var(--radius);
3046
3120
  --choice-card-border: var(--border);
3047
3121
  --choice-card-bg: var(--surface);
3048
- --choice-card-check-size: 1.5em;
3122
+ --choice-card-check-size: 1.25em;
3049
3123
 
3050
3124
  position: relative;
3051
- display: block;
3125
+ display: grid;
3126
+ grid-template-columns: var(--choice-card-check-size) minmax(0, 1fr);
3127
+ /* why: a card stretched by a taller grid neighbour keeps its rows at the
3128
+ top instead of spreading them. */
3129
+ align-content: start;
3130
+ column-gap: var(--choice-card-gap);
3052
3131
  padding: var(--choice-card-pad);
3053
3132
  border: var(--border-width) solid var(--form-invalid-border, var(--choice-card-border));
3054
3133
  border-radius: var(--choice-card-radius);
3055
3134
  background: var(--choice-card-bg);
3056
3135
  color: var(--text);
3057
- /* Consumed so .sm/.lg reach the card; the badge's em sizing follows. */
3136
+ /* Consumed so .sm/.lg reach the card; the indicator's em sizing follows. */
3058
3137
  font-size: var(--control-font-size);
3059
3138
  cursor: pointer;
3060
3139
  transition:
@@ -3063,6 +3142,18 @@ small {
3063
3142
  box-shadow var(--duration);
3064
3143
  }
3065
3144
 
3145
+ .choice-card > * {
3146
+ grid-column: 2;
3147
+ }
3148
+
3149
+ /* The input owns the indicator's cell: visible there without :has(), hidden
3150
+ in it with :has(). */
3151
+ .choice-card > :is(input[type="radio"], input[type="checkbox"]) {
3152
+ grid-area: 1 / 1;
3153
+ place-self: center;
3154
+ margin: 0;
3155
+ }
3156
+
3066
3157
  /* The native input is visually hidden but stays focusable and participates in
3067
3158
  form submission and keyboard navigation (sr-only pattern). Guarded: without
3068
3159
  :has() the card cannot mirror the input's state, so the visible native
@@ -3108,24 +3199,31 @@ small {
3108
3199
  }
3109
3200
  }
3110
3201
 
3111
- /* Checkmark badge — always rendered at scale(0) and scaled in on check, so
3112
- the entrance can transition (a pseudo created on :checked could not). */
3202
+ /* Indicator: ::before is the ring or box, ::after the check. Both share the
3203
+ input's cell, centred on the first line — not the first row, which a long
3204
+ label wraps — and stay hidden until :has() can mirror the input. The em
3205
+ margin is the lh-less fallback at the default 1.5 line-height. */
3113
3206
  .choice-card::before,
3114
3207
  .choice-card::after {
3115
3208
  content: "";
3116
- position: absolute;
3117
- inset-inline-end: -0.375em;
3118
- inset-block-start: -0.375em;
3209
+ grid-area: 1 / 1;
3210
+ align-self: start;
3211
+ margin-block-start: calc((1.5em - var(--choice-card-check-size)) / 2);
3212
+ margin-block-start: calc((1lh - var(--choice-card-check-size)) / 2);
3119
3213
  inline-size: var(--choice-card-check-size);
3120
3214
  block-size: var(--choice-card-check-size);
3121
3215
  transform: scale(0);
3122
- transition: transform var(--duration);
3216
+ transition:
3217
+ transform var(--duration),
3218
+ background-color var(--duration),
3219
+ border-color var(--duration);
3123
3220
  }
3124
3221
 
3125
- /* Badge disc — circle for radio, rounded square for checkbox */
3222
+ /* A border, not an inset shadow: forced colors drop shadows, and the resting
3223
+ ring is the only sign of an unchecked option. The invalid border reaches it
3224
+ through the same hook as the card's. */
3126
3225
  .choice-card::before {
3127
- background: var(--intent, var(--state-selected));
3128
- box-shadow: 0 0 0 2px var(--surface);
3226
+ border: max(var(--border-width), 1.5px) solid var(--form-invalid-border, var(--text-muted));
3129
3227
  z-index: 1;
3130
3228
  }
3131
3229
 
@@ -3147,7 +3245,19 @@ small {
3147
3245
  z-index: 2;
3148
3246
  }
3149
3247
 
3150
- .choice-card:has(:checked)::before,
3248
+ /* Guarded with the input's hiding above: a resting ring beside a visible
3249
+ native input would draw the indicator twice. */
3250
+ @supports selector(:has(*)) {
3251
+ .choice-card::before {
3252
+ transform: none;
3253
+ }
3254
+ }
3255
+
3256
+ .choice-card:has(:checked)::before {
3257
+ border-color: var(--intent, var(--state-selected));
3258
+ background: var(--intent, var(--state-selected));
3259
+ }
3260
+
3151
3261
  .choice-card:has(:checked)::after {
3152
3262
  transform: scale(1);
3153
3263
  }
@@ -3173,11 +3283,16 @@ small {
3173
3283
  .choice-card.chip::before {
3174
3284
  --choice-chip-dot: calc(var(--control-pad-x) + var(--choice-card-check-size) / 2) 50%;
3175
3285
 
3286
+ position: absolute;
3176
3287
  inset: 0;
3288
+ /* why: an absolute box honours self-alignment — the card's start would
3289
+ shrink this layer to 0 height and the dot with it. */
3290
+ align-self: auto;
3291
+ margin: 0;
3177
3292
  inline-size: auto;
3178
3293
  block-size: auto;
3294
+ border: 0;
3179
3295
  border-radius: inherit;
3180
- box-shadow: none;
3181
3296
  background: var(--text-subtle);
3182
3297
  transform: none;
3183
3298
  clip-path: circle(0.1875em at var(--choice-chip-dot));
@@ -3191,10 +3306,11 @@ small {
3191
3306
 
3192
3307
  /* The check keeps the card's scale-in, in flow ahead of the label. */
3193
3308
  .choice-card.chip::after {
3194
- position: static;
3195
3309
  order: -1;
3196
3310
  flex: none;
3197
- margin-inline-end: 0.375em;
3311
+ align-self: center;
3312
+ margin-block: 0;
3313
+ margin-inline: 0 0.375em;
3198
3314
  background: var(--intent, var(--state-selected));
3199
3315
  -webkit-mask-size: contain;
3200
3316
  mask-size: contain;
@@ -3204,9 +3320,11 @@ small {
3204
3320
  this repair, forced colors pushes disc and check to the same system color
3205
3321
  and the state disappears. Highlight/Canvas/HighlightText are preserved as-is,
3206
3322
  so no forced-color-adjust is needed. The pseudo is not focusable — the
3207
- outline is a separator against the card, not a focus channel. */
3323
+ outline separates the disc from whatever it overlaps (the card's border,
3324
+ for a corner-badge recipe), not a focus channel. */
3208
3325
  @media (forced-colors: active) {
3209
3326
  .choice-card:has(:checked)::before {
3327
+ border-color: Highlight;
3210
3328
  background: Highlight;
3211
3329
  outline: 2px solid Canvas;
3212
3330
  }
@@ -3502,7 +3620,7 @@ small {
3502
3620
  padding-block: 0;
3503
3621
  padding-inline: calc((var(--otp-cell-size) - 1ch) / 2) 0;
3504
3622
  border: 0;
3505
- outline: 2px solid transparent;
3623
+ outline: none;
3506
3624
  box-shadow: none;
3507
3625
  background: transparent;
3508
3626
  color: var(--text);
@@ -3527,6 +3645,14 @@ small {
3527
3645
  character after it: the state rules select the cells as following siblings
3528
3646
  (input:focus ~ span, input:disabled ~ span, …), so that order is part of the
3529
3647
  contract. */
3648
+ /* The documented markup puts .input on the native input, so the field ring
3649
+ (.input:focus-visible:not(:disabled), control.css) would outrank the reset
3650
+ above and draw that group ring; this selector must stay one step above it.
3651
+ No transparent outline either: forced colors would paint it as a ring. */
3652
+ .otp > input:focus-visible:not(:disabled) {
3653
+ outline: none;
3654
+ }
3655
+
3530
3656
  .otp > input:focus ~ span {
3531
3657
  --otp-cell-border: var(--focus);
3532
3658
  --otp-cell-border-width: calc(var(--border-width) * 2);
@@ -3613,6 +3739,7 @@ small {
3613
3739
  * --btn-default-hover-bg
3614
3740
  * --btn-border-block-end-color
3615
3741
  * --btn-border-block-end-width
3742
+ * --btn-border-block
3616
3743
  * --spinner-width
3617
3744
  */
3618
3745
 
@@ -3638,6 +3765,10 @@ small {
3638
3765
  --btn-default-hover-bg: var(--btn-bg);
3639
3766
  --btn-radius: var(--radius);
3640
3767
  --btn-hover-overlay: var(--hover-overlay, rgb(0 0 0 / 0.08));
3768
+ --btn-border-block: calc(
3769
+ var(--border-width) +
3770
+ var(--btn-border-block-end-width, var(--border-width))
3771
+ );
3641
3772
 
3642
3773
  /* Positioning context for indicators, overlays, and grouped states. */
3643
3774
  position: relative;
@@ -3647,7 +3778,13 @@ small {
3647
3778
  gap: var(--btn-gap, 0.375em);
3648
3779
  min-block-size: var(--btn-min-size);
3649
3780
  padding-block: var(--space-20);
3650
- padding-block: max(var(--space-10), calc((var(--btn-min-size) - 1lh) / 2));
3781
+ /* why: centres one line in --btn-min-size and keeps that rhythm when the
3782
+ label wraps. min-block-size is a border-box length, so both borders come
3783
+ out of it; without them .btn stood 2px taller than .input. */
3784
+ padding-block: max(
3785
+ var(--space-10),
3786
+ calc((var(--btn-min-size) - 1lh - var(--btn-border-block)) / 2)
3787
+ );
3651
3788
  padding-inline: var(--btn-pad-x);
3652
3789
  border: var(--border-width) solid var(--btn-border-color, var(--btn-border));
3653
3790
  border-block-end-color: var(
@@ -3744,6 +3881,8 @@ small {
3744
3881
  --ui-fg: var(--intent, currentColor);
3745
3882
  --ui-border: transparent;
3746
3883
  --btn-radius: var(--radius-sm);
3884
+ /* border: 0 below; the padding must not subtract a border it lacks. */
3885
+ --btn-border-block: 0px;
3747
3886
 
3748
3887
  min-block-size: auto;
3749
3888
  padding-inline: 0;
@@ -3835,8 +3974,17 @@ a.btn[aria-disabled="true"] {
3835
3974
  * Public hooks:
3836
3975
  * --close-size
3837
3976
  * --close-icon-size
3977
+ * --close-bg
3978
+ * --close-color
3979
+ * --close-hover-color
3980
+ * --close-hover-overlay
3838
3981
  */
3839
3982
  .close {
3983
+ /* Routes the hover/active wash, as --btn-hover-overlay does for .btn. A
3984
+ context that wants ink-only feedback (the dialog corner) sets it
3985
+ transparent. */
3986
+ --close-hover-overlay: var(--hover-overlay);
3987
+
3840
3988
  display: inline-grid;
3841
3989
  place-items: center;
3842
3990
  flex: none;
@@ -3863,12 +4011,12 @@ a.btn[aria-disabled="true"] {
3863
4011
  the control (the dialog corner's --surface-subtle) instead of replacing it. */
3864
4012
  @media (hover: hover) {
3865
4013
  .close:hover {
3866
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
4014
+ box-shadow: inset 0 0 0 999px var(--close-hover-overlay);
3867
4015
  }
3868
4016
  }
3869
4017
 
3870
4018
  .close:active {
3871
- box-shadow: inset 0 0 0 999px var(--hover-overlay);
4019
+ box-shadow: inset 0 0 0 999px var(--close-hover-overlay);
3872
4020
  }
3873
4021
 
3874
4022
  .close:focus-visible {
@@ -3880,23 +4028,25 @@ a.btn[aria-disabled="true"] {
3880
4028
  surface rather than to the header or a scroll container inside it. Matched
3881
4029
  on the documented positions (the dialog, its form, its header, or a form
3882
4030
  inside that header) so a .close inside content — an alert in the body —
3883
- keeps its own place. */
4031
+ keeps its own place. The corner drops the wash: only the ink changes. */
3884
4032
  :where(dialog):is(.modal, .drawer) > .close,
3885
4033
  :where(dialog):is(.modal, .drawer) > :is(form, header) > .close,
3886
4034
  :where(dialog):is(.modal, .drawer) > :is(form, header) > :is(form, header) > .close {
4035
+ --close-hover-overlay: transparent;
4036
+
3887
4037
  position: absolute;
3888
4038
  z-index: 1;
3889
4039
  inset-block-start: var(--space-20);
3890
4040
  inset-inline-end: var(--space-20);
3891
- background: var(--surface-subtle);
3892
- color: var(--text-muted);
4041
+ background: var(--close-bg, var(--surface-subtle));
4042
+ color: var(--close-color, var(--text-muted));
3893
4043
  }
3894
4044
 
3895
4045
  @media (hover: hover) {
3896
4046
  :where(dialog):is(.modal, .drawer) > .close:hover,
3897
4047
  :where(dialog):is(.modal, .drawer) > :is(form, header) > .close:hover,
3898
4048
  :where(dialog):is(.modal, .drawer) > :is(form, header) > :is(form, header) > .close:hover {
3899
- color: var(--text);
4049
+ color: var(--close-hover-color, var(--text));
3900
4050
  }
3901
4051
  }
3902
4052
 
@@ -4581,6 +4731,8 @@ a.badge[aria-disabled="true"] {
4581
4731
  *
4582
4732
  * Public hooks:
4583
4733
  * --modal-size
4734
+ * --modal-pad
4735
+ * --modal-header-bg
4584
4736
  * --dialog-viewport-gap
4585
4737
  * --dialog-icon-size
4586
4738
  * --dialog-icon-glyph-size
@@ -4588,6 +4740,7 @@ a.badge[aria-disabled="true"] {
4588
4740
  * The corner close control is .close (components/close.css).
4589
4741
  *
4590
4742
  * Internal:
4743
+ * --dialog-inner-radius
4591
4744
  * --dialog-focus-bleed
4592
4745
  * --dialog-viewport-block
4593
4746
  * --dialog-viewport-gap-default
@@ -4606,6 +4759,11 @@ a.badge[aria-disabled="true"] {
4606
4759
  var(--dialog-viewport-gap, var(--dialog-viewport-gap-default))
4607
4760
  );
4608
4761
 
4762
+ /* The panel is borderless, so its inner and outer radius are the same. Kept
4763
+ as one name for the padded wrapper and any edge band (header,
4764
+ confirmation footer) so none of them drifts from the corner. */
4765
+ --dialog-inner-radius: var(--radius-lg);
4766
+
4609
4767
  /* Keep the surface anchored to the viewport in the top layer. This also
4610
4768
  provides the containing block for the absolutely positioned close button. */
4611
4769
  position: fixed;
@@ -4619,7 +4777,9 @@ a.badge[aria-disabled="true"] {
4619
4777
  dynamic token; only the modal pins the small viewport. */
4620
4778
  --dialog-viewport-block: var(--viewport-block);
4621
4779
  max-block-size: calc(var(--dialog-viewport-block) - var(--dialog-viewport-gap-2x));
4622
- border: var(--border-width) solid var(--border);
4780
+ /* Zero rather than absent: the UA stylesheet draws `border: solid` on
4781
+ dialog, and an authored edge reads as a hard seam against .bleed bands. */
4782
+ border: 0;
4623
4783
  border-radius: var(--radius-lg);
4624
4784
  background: var(--surface-raised);
4625
4785
  color: var(--text);
@@ -4641,14 +4801,73 @@ a.badge[aria-disabled="true"] {
4641
4801
  transform var(--duration) var(--ease-exit);
4642
4802
  }
4643
4803
 
4804
+ @media (forced-colors: active) {
4805
+ /* With no authored border the panel edge disappears once colors collapse.
4806
+ The browser forces the border color, so only the width is authored. */
4807
+ :where(dialog).modal {
4808
+ border: var(--border-width) solid CanvasText;
4809
+ }
4810
+ }
4811
+
4644
4812
  :where(dialog).modal > form,
4645
4813
  :where(dialog).modal > .stack {
4814
+ --surface-pad: var(--modal-pad, var(--space-40));
4815
+
4646
4816
  display: grid;
4647
4817
  gap: var(--space-40);
4648
4818
  max-block-size: calc(var(--dialog-viewport-block) - var(--dialog-viewport-gap-2x));
4819
+ border-radius: var(--dialog-inner-radius);
4649
4820
  overflow: auto;
4650
4821
  overscroll-behavior: contain;
4651
- padding: var(--space-40);
4822
+ padding: var(--surface-pad);
4823
+ }
4824
+
4825
+ /* Optional dialog chrome lives beside the padded body wrapper. Its leading
4826
+ region and trailing controls share one center axis; wrapping the close in
4827
+ the trailing cluster keeps it in flow instead of invoking the absolute
4828
+ corner treatment from close.css. */
4829
+ :where(dialog).modal > header {
4830
+ display: flex;
4831
+ align-items: center;
4832
+ justify-content: space-between;
4833
+ gap: var(--space-30);
4834
+ min-block-size: var(--close-size, var(--control-size));
4835
+ padding: var(--modal-pad, var(--space-40));
4836
+ border-start-start-radius: var(--dialog-inner-radius);
4837
+ border-start-end-radius: var(--dialog-inner-radius);
4838
+ background: var(--modal-header-bg, var(--surface-subtle));
4839
+ }
4840
+
4841
+ :where(dialog).modal > header > .media {
4842
+ flex: 1 1 auto;
4843
+ min-inline-size: 0;
4844
+ }
4845
+
4846
+ :where(dialog).modal > header > .media > :is(h1, h2, h3, h4, h5, h6) {
4847
+ display: flex;
4848
+ align-items: center;
4849
+ min-block-size: var(--close-size, var(--control-size));
4850
+ margin-block: 0;
4851
+ }
4852
+
4853
+ :where(dialog).modal > header > .cluster {
4854
+ --cluster-wrap: nowrap;
4855
+
4856
+ flex: none;
4857
+ }
4858
+
4859
+ @supports selector(dialog:has(> header)) {
4860
+ /* The header stays fixed while the body wrapper owns overflow. Without
4861
+ :has(), compact dialogs keep ordinary block flow as the fallback. */
4862
+ :where(dialog).modal[open]:has(> header) {
4863
+ display: grid;
4864
+ grid-template-rows: auto minmax(0, 1fr);
4865
+ }
4866
+
4867
+ :where(dialog).modal:has(> header) > :is(form, .stack) {
4868
+ min-block-size: 0;
4869
+ max-block-size: none;
4870
+ }
4652
4871
  }
4653
4872
 
4654
4873
  @supports (block-size: 100svh) {
@@ -4700,12 +4919,32 @@ a.badge[aria-disabled="true"] {
4700
4919
  margin-block-end: 0;
4701
4920
  }
4702
4921
 
4922
+ /* A media row inside the structural header is title anatomy, so its heading
4923
+ does not bring prose margins into the icon alignment. */
4924
+ :where(dialog).modal > :where(form, .stack) > header > .media > :is(h1, h2, h3, h4, h5, h6) {
4925
+ margin-block: 0;
4926
+ }
4927
+
4703
4928
  /* The close button is anchored to the dialog at top inline-end, out of the
4704
4929
  content flow; headers reserve inline room so titles never run under it.
4705
4930
  The form/.stack anatomy is structural, so it stays in :where() and never
4706
4931
  lends .stack's specificity to a bare <form>. */
4707
4932
  :where(dialog).modal > :where(form, .stack) > header {
4708
- padding-inline-end: calc(var(--close-size, var(--control-size)) + var(--space-30));
4933
+ padding-inline-end: calc(
4934
+ var(--close-size, var(--control-size)) +
4935
+ var(--space-20) +
4936
+ var(--space-30)
4937
+ );
4938
+ }
4939
+
4940
+ /* A corner .close that is a direct child of the chrome header needs the same
4941
+ reserve, but never below the header's own pad. Kept as its own rule so an
4942
+ unsupported :has() drops only this geometry, not the wrapper reserve. */
4943
+ :where(dialog).modal > header:has(> .close) {
4944
+ padding-inline-end: max(
4945
+ var(--modal-pad, var(--space-40)),
4946
+ calc(var(--close-size, var(--control-size)) + var(--space-20) + var(--space-30))
4947
+ );
4709
4948
  }
4710
4949
 
4711
4950
  :where(dialog).modal > :where(form, .stack) > footer {
@@ -4719,19 +4958,25 @@ a.badge[aria-disabled="true"] {
4719
4958
  action band. The media primitive owns icon/content alignment; this variant
4720
4959
  only removes the standard form inset so both regions can own their padding. */
4721
4960
  :where(dialog).modal.dialog-confirmation > form {
4961
+ --surface-pad: 0px;
4962
+
4722
4963
  gap: 0;
4723
4964
  padding: 0;
4724
4965
  }
4725
4966
 
4726
- :where(dialog).modal.dialog-confirmation > form > .media {
4967
+ :where(dialog).modal.dialog-confirmation > form > :is(.media, header) {
4727
4968
  gap: var(--space-40);
4728
4969
  padding: var(--space-50);
4729
4970
  }
4730
4971
 
4972
+ /* The action band splits a decision pair: the cancel-style action holds the
4973
+ leading edge and the committing action the trailing one, so the destructive
4974
+ choice is never adjacent to the dismiss. */
4731
4975
  :where(dialog).modal.dialog-confirmation > form > footer {
4976
+ justify-content: space-between;
4732
4977
  padding: var(--space-40) var(--space-50);
4733
- border-end-start-radius: calc(var(--radius-lg) - var(--border-width));
4734
- border-end-end-radius: calc(var(--radius-lg) - var(--border-width));
4978
+ border-end-start-radius: var(--dialog-inner-radius);
4979
+ border-end-end-radius: var(--dialog-inner-radius);
4735
4980
  background: var(--surface-subtle);
4736
4981
  }
4737
4982
 
@@ -4768,8 +5013,29 @@ a.badge[aria-disabled="true"] {
4768
5013
  line-height: 1;
4769
5014
  }
4770
5015
 
5016
+ :where(dialog).modal.dialog-confirmation > form > .media > header {
5017
+ display: grid;
5018
+ gap: var(--space-20);
5019
+ }
5020
+
5021
+ :where(dialog).modal.dialog-confirmation > form > .media > header > * {
5022
+ margin-block: 0;
5023
+ }
5024
+
5025
+ /* The first heading occupies the icon row, so its text centers on the icon
5026
+ without a font-relative nudge. Following copy stays in the same column. */
5027
+ :where(dialog).modal.dialog-confirmation
5028
+ > form
5029
+ > .media
5030
+ > header
5031
+ > :is(h1, h2, h3, h4, h5, h6):first-child {
5032
+ display: flex;
5033
+ align-items: center;
5034
+ min-block-size: var(--dialog-icon-size, 3rem);
5035
+ }
5036
+
4771
5037
  @media (max-width: 30rem) {
4772
- :where(dialog).modal.dialog-confirmation > form > .media,
5038
+ :where(dialog).modal.dialog-confirmation > form > :is(.media, header),
4773
5039
  :where(dialog).modal.dialog-confirmation > form > footer {
4774
5040
  padding: var(--space-40);
4775
5041
  }
@@ -5095,9 +5361,9 @@ html.has-modal-open.had-scrollbar {
5095
5361
  *
5096
5362
  * A surface that insets its direct children relays that inset to them as
5097
5363
  * --surface-pad; a direct .bleed child cancels it and reaches the surface
5098
- * edge. Card and drawer pad at the container level, so they own the token
5099
- * there; the accordion pads its summary and its panel instead, so the panel
5100
- * owns it and the item does not.
5364
+ * edge. Card and drawer pad at the container level, while modal pads its inner
5365
+ * wrapper; each owns the token where that inset lives. The accordion pads its
5366
+ * summary and panel instead, so the panel owns it and the item does not.
5101
5367
  *
5102
5368
  * Why the token is handed to the children rather than declared on the surface:
5103
5369
  * custom properties inherit, so a surface-level declaration would also reach a
@@ -5118,11 +5384,11 @@ html.has-modal-open.had-scrollbar {
5118
5384
  * Framework plumbing:
5119
5385
  * --surface-pad (declared by the surface)
5120
5386
  */
5121
- :where(.card, .drawer, .accordion details > summary + *) > * * {
5387
+ :where(.card, .drawer, dialog.modal > :is(form, .stack), .accordion details > summary + *) > * * {
5122
5388
  --bleed-pad: initial;
5123
5389
  }
5124
5390
 
5125
- :where(.card, .drawer, .accordion details > summary + *) > * {
5391
+ :where(.card, .drawer, dialog.modal > :is(form, .stack), .accordion details > summary + *) > * {
5126
5392
  --bleed-pad: var(--surface-pad);
5127
5393
  }
5128
5394
 
@@ -5186,6 +5452,7 @@ picture.bleed > img {
5186
5452
  * Public hooks:
5187
5453
  * --flyout-inline-size
5188
5454
  * --flyout-max-inline-size
5455
+ * --flyout-pad
5189
5456
  *
5190
5457
  * Internal:
5191
5458
  * --menu-item-size (derived from --control-size for menu rows)
@@ -5238,7 +5505,9 @@ picture.bleed > img {
5238
5505
  );
5239
5506
  overflow: auto;
5240
5507
  margin: 0;
5241
- padding: var(--space-10);
5508
+ /* Tight by default for action menus, whose items carry their own padding.
5509
+ Rich panels (forms, pickers) opt into dialog air per instance. */
5510
+ padding: var(--flyout-pad, var(--space-10));
5242
5511
  border: var(--border-width) solid var(--border);
5243
5512
  border-radius: var(--radius);
5244
5513
  background: var(--surface-raised);
@@ -5511,7 +5780,7 @@ picture.bleed > img {
5511
5780
  */
5512
5781
  .tooltip {
5513
5782
  --tooltip-bg: var(--surface-solid);
5514
- --tooltip-fg: var(--surface);
5783
+ --tooltip-fg: var(--surface-solid-fg);
5515
5784
  --tooltip-arrow-size: 5px;
5516
5785
 
5517
5786
  position: fixed;
@@ -6157,8 +6426,8 @@ picture.bleed > img {
6157
6426
  }
6158
6427
 
6159
6428
  .status-bar {
6160
- --status-bg: var(--intent, var(--text));
6161
- --status-fg: var(--intent-fg, var(--surface));
6429
+ --status-bg: var(--intent, var(--surface-solid));
6430
+ --status-fg: var(--intent-fg, var(--surface-solid-fg));
6162
6431
 
6163
6432
  position: fixed;
6164
6433
  /* Fixed positioning tracks the layout viewport, which the mobile software
@@ -6428,7 +6697,7 @@ a.avatar[aria-disabled="true"] {
6428
6697
  }
6429
6698
 
6430
6699
  .avatar-stack > .avatar {
6431
- box-shadow: 0 0 0 var(--avatar-stack-ring) var(--surface);
6700
+ box-shadow: 0 0 0 var(--avatar-stack-ring) var(--surface-opaque);
6432
6701
  }
6433
6702
 
6434
6703
  .avatar-stack > .avatar + .avatar {
@@ -6854,7 +7123,7 @@ a.avatar[aria-disabled="true"] {
6854
7123
  }
6855
7124
 
6856
7125
  .rating > input[type="radio"]:focus-visible {
6857
- outline: var(--focus-outline);
7126
+ outline: var(--focus-ring-width) solid var(--focus);
6858
7127
  outline-offset: var(--focus-outline-offset);
6859
7128
  background: var(--rating-color);
6860
7129
  }
@@ -6960,7 +7229,7 @@ a.avatar[aria-disabled="true"] {
6960
7229
  --step-size: 1.75rem;
6961
7230
  --step-inline-gap: var(--space-20);
6962
7231
  --step-line-size: var(--border-width);
6963
- --step-marker-bg: var(--surface);
7232
+ --step-marker-bg: var(--surface-opaque);
6964
7233
  --step-marker-border: var(--border);
6965
7234
  --step-marker-fg: var(--text-muted);
6966
7235
  --step-marker-radius: var(--radius-full);
@@ -7040,7 +7309,7 @@ a.avatar[aria-disabled="true"] {
7040
7309
  connector leaving current goes back to neutral. Declared after
7041
7310
  .step-complete at equal specificity: current wins on every channel. */
7042
7311
  .steps > [aria-current="step"] {
7043
- --step-marker-bg: var(--surface);
7312
+ --step-marker-bg: var(--surface-opaque);
7044
7313
  --step-marker-border: var(--state-selected);
7045
7314
  --step-marker-fg: var(--state-selected);
7046
7315
  --step-line: var(--border);
@@ -7079,7 +7348,7 @@ a.avatar[aria-disabled="true"] {
7079
7348
  focus ring's own width and offset. Only this composition scrolls, and only a
7080
7349
  link has a ring to protect. */
7081
7350
  .steps-horizontal:has(a[href]) {
7082
- padding-block: calc(var(--focus-outline-offset) + var(--border-width) * 2);
7351
+ padding-block: calc(var(--focus-outline-offset) + var(--focus-ring-width));
7083
7352
  }
7084
7353
 
7085
7354
  /* One explicit row, the marker's: a `.step-label` is auto-placed into an
@@ -7563,8 +7832,9 @@ a.avatar[aria-disabled="true"] {
7563
7832
  *
7564
7833
  * Docs: docs/pages/components/pagination.md
7565
7834
  *
7566
- * Render .btn elements inside a .pagination list. The active page is marked
7567
- * with aria-current="page"; .btn owns the active state styling.
7835
+ * Render .btn.ghost elements inside a .pagination list. The active page is
7836
+ * marked with aria-current="page"; .btn owns the active state styling, so the
7837
+ * current page is the only filled item.
7568
7838
  *
7569
7839
  * Truncated ranges are plain text (`.muted`, `aria-hidden="true"`), not .btn
7570
7840
  * — they are decorative, not actionable. They still get the same box sizing
@@ -7682,10 +7952,10 @@ a.avatar[aria-disabled="true"] {
7682
7952
  }
7683
7953
 
7684
7954
  /* The ring is square on the edge the tab is attached to, so it reads as part
7685
- of the strip rather than a floating chip. */
7955
+ of the strip rather than a floating chip. Color and width follow the
7956
+ component ring contract (focus.css); only the geometry is tab-specific. */
7686
7957
  .tab:focus-visible {
7687
- outline: var(--focus-outline);
7688
- outline-color: var(--focus);
7958
+ outline: var(--focus-ring-width) solid var(--focus);
7689
7959
  outline-offset: -2px;
7690
7960
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
7691
7961
  }
@@ -7938,6 +8208,12 @@ a.list-item[aria-current]::before {
7938
8208
  * .table-wrap provides the scroll container and outer chrome; .table owns row
7939
8209
  * and cell rules. Tokens are sourced from the shared scale.
7940
8210
  *
8211
+ * Responsive strategy is horizontal overflow: the wrap scrolls, the table
8212
+ * keeps its columns. Restructuring rows into cards would destroy the
8213
+ * column scanability tables exist for, and needs data-label duplication
8214
+ * because CSS cannot join a cell to its header — that representation choice
8215
+ * belongs to the application, not the framework.
8216
+ *
7941
8217
  * Public hooks:
7942
8218
  * --table-min
7943
8219
  * --table-cell-pad
@@ -8026,11 +8302,14 @@ a.list-item[aria-current]::before {
8026
8302
  * .sm/.lg are deliberately not supported.
8027
8303
  *
8028
8304
  * Public hooks:
8305
+ * --meter-track
8029
8306
  * --meter-value
8030
8307
  * --bar-height
8031
8308
  */
8032
8309
 
8033
8310
  .meter {
8311
+ --meter-track: var(--surface-subtle);
8312
+
8034
8313
  appearance: none;
8035
8314
  -webkit-appearance: none;
8036
8315
  display: block;
@@ -8039,7 +8318,7 @@ a.list-item[aria-current]::before {
8039
8318
  overflow: hidden;
8040
8319
  border: 0;
8041
8320
  border-radius: var(--radius-full);
8042
- background: var(--surface-subtle);
8321
+ background: var(--meter-track);
8043
8322
  }
8044
8323
 
8045
8324
  /* A non-native meter is a weighted flex container. The element type keeps the
@@ -8058,7 +8337,7 @@ a.list-item[aria-current]::before {
8058
8337
  }
8059
8338
 
8060
8339
  .meter:not(meter) > * + * {
8061
- border-inline-start: var(--border-width) solid var(--surface);
8340
+ border-inline-start: var(--border-width) solid var(--surface-opaque);
8062
8341
  }
8063
8342
 
8064
8343
  .meter::-webkit-meter-bar {
@@ -8066,7 +8345,7 @@ a.list-item[aria-current]::before {
8066
8345
  block-size: var(--bar-height);
8067
8346
  border: 0;
8068
8347
  border-radius: var(--radius-full);
8069
- background: var(--surface-subtle);
8348
+ background: var(--meter-track);
8070
8349
  }
8071
8350
 
8072
8351
  .meter::-webkit-meter-optimum-value {
@@ -8295,36 +8574,55 @@ a.list-item[aria-current]::before {
8295
8574
 
8296
8575
  .aura {
8297
8576
  --aura-color: var(--intent, var(--primary));
8298
- --aura-width: 2px;
8577
+ --aura-width: 1px;
8299
8578
  --aura-blur: 0.25rem;
8300
- --aura-intensity: 1;
8579
+ --aura-intensity: 0.4;
8301
8580
  --aura-radius: var(--radius-lg);
8302
8581
  --aura-duration: 6s;
8303
8582
 
8304
8583
  position: relative;
8305
8584
  isolation: isolate;
8306
8585
  display: inline-grid;
8307
- padding: var(--aura-width);
8308
8586
  border-radius: calc(var(--aura-radius) + var(--aura-width));
8309
- border: var(--border-width) solid transparent;
8587
+
8588
+ /* The frame is the border itself, so --aura-width is the painted thickness.
8589
+ A transparent border stays for forced colors, where it becomes the visible
8590
+ boundary; a padding frame inside it would paint both, doubling the line. */
8591
+ border: var(--aura-width) solid transparent;
8310
8592
  background: var(--aura-color);
8311
8593
 
8312
8594
  /* A short feathered accent rather than a long wedge with a hard edge, which
8313
8595
  reads as one lit side. Nothing tints the frame outside it: a constant tint
8314
- across 2px draws an outline, the opposite of the effect. */
8596
+ across the frame draws an outline, the opposite of the effect. `border-box`
8597
+ sizes the gradient to the border box: the default padding-box origin would
8598
+ tile a neighbouring copy of the gradient into the border, which is now the
8599
+ whole frame. */
8315
8600
  background: conic-gradient(
8316
- from var(--aura-angle),
8317
- transparent 0 78%,
8318
- var(--aura-color) 93%,
8319
- transparent
8320
- );
8321
- }
8322
-
8323
- /* Near halo — inherits the frame gradient, so it tracks the lit accent. */
8601
+ from var(--aura-angle),
8602
+ transparent 0 78%,
8603
+ var(--aura-color) 93%,
8604
+ transparent
8605
+ )
8606
+ border-box;
8607
+
8608
+ /* Far halo — flat and static. At this blur the rotating detail is no longer
8609
+ legible, so there is nothing to gain from animating a second wide blurred
8610
+ layer. This is the layer that stays lit at all times, so it — not the
8611
+ frame — is what keeps the object present between two passes of the
8612
+ accent. An outer shadow never paints under the box, so it stays out of a
8613
+ translucent child. min() because a color-mix() share above 100% is
8614
+ invalid, where the opacity it replaces clamped. */
8615
+ box-shadow: 0 0 calc(var(--aura-blur) * 8) var(--aura-blur)
8616
+ color-mix(in oklab, var(--aura-color) min(100%, calc(15% * var(--aura-intensity))), transparent);
8617
+ }
8618
+
8619
+ /* Near halo — inherits the frame gradient, so it tracks the lit accent. An
8620
+ absolute inset starts at the padding edge, inside the border that is the
8621
+ frame, so the negative inset puts the halo back on the frame. */
8324
8622
  .aura::before {
8325
8623
  position: absolute;
8326
8624
  z-index: -1;
8327
- inset: 0;
8625
+ inset: calc(-1 * var(--aura-width));
8328
8626
  border-radius: inherit;
8329
8627
  background: inherit;
8330
8628
  filter: blur(var(--aura-blur));
@@ -8333,29 +8631,22 @@ a.list-item[aria-current]::before {
8333
8631
  content: "";
8334
8632
  }
8335
8633
 
8336
- /* Far halo — flat and static. At this blur the rotating detail is no longer
8337
- legible, so there is nothing to gain from animating a second wide blurred
8338
- layer. It outgrows the frame, otherwise the child covers it and the whole
8339
- effect reads as a drop shadow. This is the layer that stays lit at all
8340
- times, so it — not the frame — is what keeps the object present between two
8341
- passes of the accent. */
8634
+ /* Occlusion — the frame and the near halo paint across the whole box, and only
8635
+ their edge is meant to show. This mask sits above both, under the child, so
8636
+ a transparent or translucent child (an .outline alert, a glass surface) is
8637
+ framed rather than filled with the frame color. It follows the child's
8638
+ shape through --aura-radius. */
8342
8639
  .aura::after {
8343
8640
  position: absolute;
8344
8641
  z-index: -1;
8345
- inset: calc(-1 * var(--aura-blur));
8346
- border-radius: calc(var(--aura-radius) + var(--aura-width) + var(--aura-blur));
8347
- background: var(--aura-color);
8348
- filter: blur(calc(var(--aura-blur) * 4));
8349
- opacity: calc(0.15 * var(--aura-intensity));
8642
+ inset: 0;
8643
+ border-radius: var(--aura-radius);
8644
+ background: var(--surface-opaque);
8350
8645
  pointer-events: none;
8351
8646
  content: "";
8352
8647
  }
8353
8648
 
8354
- /* Lifts the child above both halos. It also has to be opaque: the frame paints
8355
- across the whole box and only its edge is meant to show, so a transparent
8356
- child (an .outline alert, a .ghost button) is filled with the frame color
8357
- rather than framed by it. Documented as a constraint, not worked around —
8358
- masking a ring would cost more than the case is worth. */
8649
+ /* Lifts the child above the halo and the mask. */
8359
8650
  .aura > * {
8360
8651
  position: relative;
8361
8652
  }
@@ -8378,7 +8669,7 @@ a.list-item[aria-current]::before {
8378
8669
  animation: aura-breathe-near var(--aura-duration) ease-in-out infinite;
8379
8670
  }
8380
8671
 
8381
- .aura-glow::after {
8672
+ .aura-glow {
8382
8673
  animation: aura-breathe-far var(--aura-duration) ease-in-out infinite;
8383
8674
  }
8384
8675
  }
@@ -8390,8 +8681,8 @@ a.list-item[aria-current]::before {
8390
8681
  @media (prefers-reduced-motion: reduce) {
8391
8682
  .aura:not(.aura-glow) {
8392
8683
  background:
8393
- radial-gradient(4rem 4rem at 0% 0%, var(--aura-color), transparent 70%),
8394
- radial-gradient(4rem 4rem at 100% 100%, var(--aura-color), transparent 70%);
8684
+ radial-gradient(4rem 4rem at 0% 0%, var(--aura-color), transparent 70%) border-box,
8685
+ radial-gradient(4rem 4rem at 100% 100%, var(--aura-color), transparent 70%) border-box;
8395
8686
  }
8396
8687
  }
8397
8688
 
@@ -8412,10 +8703,16 @@ a.list-item[aria-current]::before {
8412
8703
  }
8413
8704
  }
8414
8705
 
8706
+ /* The far halo is the element's own shadow: opening its spread and share
8707
+ breathes it without scaling the child. */
8415
8708
  @keyframes aura-breathe-far {
8416
8709
  50% {
8417
- opacity: calc(0.28 * var(--aura-intensity));
8418
- transform: scale(1.07);
8710
+ box-shadow: 0 0 calc(var(--aura-blur) * 10) calc(var(--aura-blur) * 2)
8711
+ color-mix(
8712
+ in oklab,
8713
+ var(--aura-color) min(100%, calc(28% * var(--aura-intensity))),
8714
+ transparent
8715
+ );
8419
8716
  }
8420
8717
  }
8421
8718
 
@@ -8435,6 +8732,64 @@ a.list-item[aria-current]::before {
8435
8732
  --aura-radius: var(--radius-full);
8436
8733
  }
8437
8734
 
8735
+ /*
8736
+ * Highlight sweeping across a text label — an activity in progress.
8737
+ *
8738
+ * Docs: docs/pages/components/shimmer.md
8739
+ *
8740
+ * Presentation only: it carries no state and selects no ARIA attribute, so the
8741
+ * label stays whatever the author makes it (a role="status" message, a list
8742
+ * item, a heading).
8743
+ *
8744
+ * Public hooks:
8745
+ * --shimmer-color
8746
+ * --shimmer-highlight
8747
+ */
8748
+ .shimmer {
8749
+ --shimmer-color: var(--text-muted);
8750
+ --shimmer-highlight: var(--text);
8751
+
8752
+ color: var(--shimmer-color);
8753
+ }
8754
+
8755
+ /* The sweep paints the glyphs through a clipped background, which needs the
8756
+ glyph fill transparent. Without background-clip: text that would erase the
8757
+ label, so the transparency lives under the same query as the clip. It is
8758
+ the fill, not `color`: a label class that sets its own color (.muted, a
8759
+ caption style) would otherwise win the cascade and paint opaque glyphs over
8760
+ the sweep. Reduced motion keeps the plain label — a frozen band would read
8761
+ as a random tint. */
8762
+ @supports (background-clip: text) {
8763
+ @media (prefers-reduced-motion: no-preference) {
8764
+ .shimmer {
8765
+ -webkit-text-fill-color: transparent;
8766
+ background:
8767
+ linear-gradient(
8768
+ 90deg,
8769
+ var(--shimmer-color) 40%,
8770
+ var(--shimmer-highlight) 50%,
8771
+ var(--shimmer-color) 60%
8772
+ )
8773
+ 0 0 / 300% 100%;
8774
+ background-clip: text;
8775
+ animation: actual-shimmer var(--duration-shimmer) linear infinite;
8776
+ }
8777
+ }
8778
+ }
8779
+
8780
+ /* The band occupies the middle third of a background three times the label's
8781
+ width, so each pass leaves the end of the label before the next one enters
8782
+ at the start. */
8783
+ @keyframes actual-shimmer {
8784
+ from {
8785
+ background-position: 100% 0;
8786
+ }
8787
+
8788
+ to {
8789
+ background-position: 0% 0;
8790
+ }
8791
+ }
8792
+
8438
8793
  /* Utilities — small, single-purpose escape hatches. */
8439
8794
  /*
8440
8795
  * Small, single-purpose utilities for common escape hatches.