actual-css 0.10.0 → 0.11.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 (71) hide show
  1. package/CHANGELOG.md +73 -1
  2. package/README.md +2 -2
  3. package/components.json +2 -1
  4. package/dist/actual.full.css +314 -106
  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 +2 -0
  12. package/docs/pages/components/aura.md +12 -13
  13. package/docs/pages/components/badge.md +10 -0
  14. package/docs/pages/components/button.md +1 -1
  15. package/docs/pages/components/chat.md +5 -0
  16. package/docs/pages/components/dialog.md +5 -2
  17. package/docs/pages/components/pagination.md +15 -15
  18. package/docs/pages/components/shimmer.md +57 -0
  19. package/docs/pages/components/skeleton.md +2 -1
  20. package/docs/pages/components/spinner.md +11 -6
  21. package/docs/pages/components/table.md +13 -0
  22. package/docs/pages/enhancements/flyout.md +2 -0
  23. package/docs/pages/enhancements/tabs.md +4 -0
  24. package/docs/pages/forms/choice-cards.md +77 -4
  25. package/docs/pages/forms/combobox.md +16 -1
  26. package/docs/pages/forms/overview.md +4 -1
  27. package/docs/pages/forms/range.md +110 -5
  28. package/docs/pages/forms/rich-text.md +110 -0
  29. package/docs/pages/forms/validation.md +5 -1
  30. package/docs/pages/foundations/theming.md +49 -3
  31. package/docs/pages/foundations/tokens.md +15 -5
  32. package/docs/pages/guides/bootstrap.md +2 -1
  33. package/docs/pages/guides/introduction.md +1 -1
  34. package/docs/pages/guides/modular-import.md +1 -1
  35. package/docs/pages/guides/progressive-enhancement.md +13 -14
  36. package/docs/pages/guides/tailwind.md +4 -2
  37. package/llms.txt +4 -3
  38. package/package.json +5 -1
  39. package/reserved-classes.json +1 -0
  40. package/src/css/components/avatar.css +1 -1
  41. package/src/css/components/flyout.css +4 -1
  42. package/src/css/components/meter.css +1 -1
  43. package/src/css/components/pagination.css +3 -2
  44. package/src/css/components/rating.css +1 -1
  45. package/src/css/components/status-bar.css +2 -2
  46. package/src/css/components/steps.css +3 -3
  47. package/src/css/components/tab.css +3 -3
  48. package/src/css/components/table.css +6 -0
  49. package/src/css/components/tooltip.css +1 -1
  50. package/src/css/core/focus.css +14 -4
  51. package/src/css/core/theme.css +8 -0
  52. package/src/css/core/tokens.css +8 -4
  53. package/src/css/core/variants.css +2 -1
  54. package/src/css/effects/aura.css +51 -33
  55. package/src/css/effects/index.css +1 -0
  56. package/src/css/effects/shimmer.css +57 -0
  57. package/src/css/forms/choice-card.css +71 -23
  58. package/src/css/forms/control.css +2 -2
  59. package/src/css/forms/otp.css +9 -1
  60. package/src/css/forms/range.css +64 -21
  61. package/src/js/command.js +61 -32
  62. package/src/js/dialog.js +8 -23
  63. package/src/js/dismiss.js +1 -2
  64. package/src/js/enhance.js +84 -123
  65. package/src/js/full.js +1 -0
  66. package/src/js/password.js +1 -8
  67. package/src/js/range.js +151 -0
  68. package/src/js/scrollspy.js +4 -5
  69. package/src/js/status.js +2 -10
  70. package/src/js/tooltip.js +11 -1
  71. package/src/js/validation.js +4 -10
@@ -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),
@@ -2889,6 +2912,10 @@ small {
2889
2912
  * --range-track-height
2890
2913
  * --range-thumb-bg
2891
2914
  * --range-track-bg
2915
+ * --range-fill-bg
2916
+ *
2917
+ * Runtime-written (by actual-css/js/range, never set by hand):
2918
+ * --range-progress
2892
2919
  */
2893
2920
 
2894
2921
  /* Intent boundary — inherited intents do not tint this component.
@@ -2898,10 +2925,17 @@ small {
2898
2925
  }
2899
2926
 
2900
2927
  .range {
2901
- --range-thumb-size: 1.25rem;
2902
- --range-track-height: 0.25rem;
2928
+ --range-thumb-size: 1.125rem;
2929
+ --range-track-height: 0.625rem;
2903
2930
  --range-thumb-bg: var(--intent, var(--primary));
2904
2931
  --range-track-bg: var(--border);
2932
+ --range-fill-bg: var(--intent, var(--primary));
2933
+ /* Fill share, written by actual-css/js/range. 0% with no runtime means a
2934
+ plain track — the control stays fully usable without JavaScript. */
2935
+ --range-progress: 0%;
2936
+ /* Logical fill direction: the gradient below cannot use flow-relative
2937
+ keywords, so RTL flips the angle through this hook instead. */
2938
+ --range-fill-from: to right;
2905
2939
 
2906
2940
  appearance: none;
2907
2941
  -webkit-appearance: none;
@@ -2911,16 +2945,29 @@ small {
2911
2945
  cursor: pointer;
2912
2946
  }
2913
2947
 
2948
+ :where([dir="rtl"]) .range,
2949
+ .range:dir(rtl) {
2950
+ --range-fill-from: to left;
2951
+ }
2952
+
2914
2953
  .range::-webkit-slider-runnable-track {
2915
2954
  block-size: var(--range-track-height);
2916
2955
  border-radius: var(--radius-full);
2917
- background: var(--range-track-bg);
2956
+ background: linear-gradient(
2957
+ var(--range-fill-from),
2958
+ var(--range-fill-bg) var(--range-progress),
2959
+ var(--range-track-bg) var(--range-progress)
2960
+ );
2918
2961
  }
2919
2962
 
2920
2963
  .range::-moz-range-track {
2921
2964
  block-size: var(--range-track-height);
2922
2965
  border-radius: var(--radius-full);
2923
- background: var(--range-track-bg);
2966
+ background: linear-gradient(
2967
+ var(--range-fill-from),
2968
+ var(--range-fill-bg) var(--range-progress),
2969
+ var(--range-track-bg) var(--range-progress)
2970
+ );
2924
2971
  }
2925
2972
 
2926
2973
  .range::-webkit-slider-thumb {
@@ -2933,6 +2980,10 @@ small {
2933
2980
  background: var(--range-thumb-bg);
2934
2981
  margin-block-start: calc((var(--range-track-height) - var(--range-thumb-size)) / 2);
2935
2982
  cursor: inherit;
2983
+ /* Permanent surface separator: the fill defaults to the thumb color, so
2984
+ without this the thumb would melt into the filled part of the track.
2985
+ Focus-visible keeps this ring and adds the --focus ring outside it. */
2986
+ box-shadow: 0 0 0 2px var(--surface-opaque);
2936
2987
  transition: box-shadow var(--duration);
2937
2988
  }
2938
2989
 
@@ -2943,6 +2994,7 @@ small {
2943
2994
  border: 0;
2944
2995
  background: var(--range-thumb-bg);
2945
2996
  cursor: inherit;
2997
+ box-shadow: 0 0 0 2px var(--surface-opaque);
2946
2998
  transition: box-shadow var(--duration);
2947
2999
  }
2948
3000
 
@@ -2953,20 +3005,21 @@ small {
2953
3005
  /*
2954
3006
  * A thumb pseudo-element takes no reliable outline, so its ring is a solid
2955
3007
  * --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.
3008
+ * surface-coloured separator keeps the ring visually distinct from the
3009
+ * coloured thumb — unlike a full-size input where the same gap would
3010
+ * read as an unwanted void. It doubles as the resting separator between
3011
+ * the thumb and the same-coloured fill.
2959
3012
  */
2960
3013
  .range:focus-visible::-webkit-slider-thumb {
2961
3014
  box-shadow:
2962
- 0 0 0 1px var(--surface),
2963
- 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus);
3015
+ 0 0 0 2px var(--surface-opaque),
3016
+ 0 0 0 calc(2px + var(--focus-ring-width)) var(--focus);
2964
3017
  }
2965
3018
 
2966
3019
  .range:focus-visible::-moz-range-thumb {
2967
3020
  box-shadow:
2968
- 0 0 0 1px var(--surface),
2969
- 0 0 0 calc(1px + var(--focus-ring-width)) var(--focus);
3021
+ 0 0 0 2px var(--surface-opaque),
3022
+ 0 0 0 calc(2px + var(--focus-ring-width)) var(--focus);
2970
3023
  }
2971
3024
 
2972
3025
  .range:disabled {
@@ -2974,28 +3027,41 @@ small {
2974
3027
  cursor: not-allowed;
2975
3028
  opacity: var(--disabled-opacity);
2976
3029
  }
2977
-
2978
3030
  /* 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. */
3031
+ would merge with it) and the thumb needs a real boundary to remain
3032
+ distinguishable from the track. The fill keeps its two-tone split with
3033
+ system colors so the value stays readable. Disabled keeps GrayText via
3034
+ :disabled. Focus stays on the transparent outline of :focus-visible. Track
3035
+ and thumb fills are written on the properties, not only the hooks: an
3036
+ application hook override at the same specificity loads after this file
3037
+ and would win the variable, leaving a Canvas-remapped fill.
3038
+ The split lives in a background-image, which forced-color-adjust: auto
3039
+ drops — so the tracks opt out locally. The colors stay system keywords,
3040
+ keeping native contrast adaptation; only the host keeps adjusting. */
2985
3041
  @media (forced-colors: active) {
2986
3042
  .range {
2987
3043
  --range-track-bg: CanvasText;
2988
3044
  --range-thumb-bg: ButtonFace;
3045
+ --range-fill-bg: Highlight;
2989
3046
  }
2990
3047
 
2991
3048
  .range::-webkit-slider-runnable-track {
2992
- background: CanvasText;
3049
+ forced-color-adjust: none;
3050
+ background: linear-gradient(
3051
+ var(--range-fill-from),
3052
+ Highlight var(--range-progress),
3053
+ CanvasText var(--range-progress)
3054
+ );
2993
3055
  }
2994
3056
 
2995
3057
  .range::-moz-range-track {
2996
- background: CanvasText;
3058
+ forced-color-adjust: none;
3059
+ background: linear-gradient(
3060
+ var(--range-fill-from),
3061
+ Highlight var(--range-progress),
3062
+ CanvasText var(--range-progress)
3063
+ );
2997
3064
  }
2998
-
2999
3065
  .range::-webkit-slider-thumb {
3000
3066
  background: ButtonFace;
3001
3067
  border: var(--border-width) solid ButtonText;
@@ -3014,13 +3080,15 @@ small {
3014
3080
  *
3015
3081
  * Each .choice-card is a <label> wrapping a native radio or checkbox. When
3016
3082
  * :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.
3083
+ * state: an indicator on the leading edge (a ring for radio, a box for
3084
+ * checkbox) that fills with a check, checked border and tint, focus ring,
3085
+ * disabled dimming. Without :has() the native input stays visible in the
3086
+ * indicator's slot, so selection state never disappears.
3020
3087
  *
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.
3088
+ * The card is a two-column grid: the indicator in the first column, level
3089
+ * with the first row, and each child element of the label on its own row in
3090
+ * the second. Wrap loose text in an element. A corner badge for tiles is a
3091
+ * documented recipe on these same layers, not a variant.
3024
3092
  *
3025
3093
  * .chip is the compact pill form (filter chips, selectable tags): control
3026
3094
  * height, and a leading dot that turns into the check in a fixed slot, so
@@ -3028,6 +3096,7 @@ small {
3028
3096
  *
3029
3097
  * Public hooks:
3030
3098
  * --choice-card-pad
3099
+ * --choice-card-gap
3031
3100
  * --choice-card-radius
3032
3101
  * --choice-card-border
3033
3102
  * --choice-card-bg
@@ -3042,19 +3111,25 @@ small {
3042
3111
 
3043
3112
  .choice-card {
3044
3113
  --choice-card-pad: var(--density-space, var(--space-40));
3114
+ --choice-card-gap: var(--space-30);
3045
3115
  --choice-card-radius: var(--radius);
3046
3116
  --choice-card-border: var(--border);
3047
3117
  --choice-card-bg: var(--surface);
3048
- --choice-card-check-size: 1.5em;
3118
+ --choice-card-check-size: 1.25em;
3049
3119
 
3050
3120
  position: relative;
3051
- display: block;
3121
+ display: grid;
3122
+ grid-template-columns: var(--choice-card-check-size) minmax(0, 1fr);
3123
+ /* why: a card stretched by a taller grid neighbour keeps its rows at the
3124
+ top instead of spreading them. */
3125
+ align-content: start;
3126
+ column-gap: var(--choice-card-gap);
3052
3127
  padding: var(--choice-card-pad);
3053
3128
  border: var(--border-width) solid var(--form-invalid-border, var(--choice-card-border));
3054
3129
  border-radius: var(--choice-card-radius);
3055
3130
  background: var(--choice-card-bg);
3056
3131
  color: var(--text);
3057
- /* Consumed so .sm/.lg reach the card; the badge's em sizing follows. */
3132
+ /* Consumed so .sm/.lg reach the card; the indicator's em sizing follows. */
3058
3133
  font-size: var(--control-font-size);
3059
3134
  cursor: pointer;
3060
3135
  transition:
@@ -3063,6 +3138,18 @@ small {
3063
3138
  box-shadow var(--duration);
3064
3139
  }
3065
3140
 
3141
+ .choice-card > * {
3142
+ grid-column: 2;
3143
+ }
3144
+
3145
+ /* The input owns the indicator's cell: visible there without :has(), hidden
3146
+ in it with :has(). */
3147
+ .choice-card > :is(input[type="radio"], input[type="checkbox"]) {
3148
+ grid-area: 1 / 1;
3149
+ place-self: center;
3150
+ margin: 0;
3151
+ }
3152
+
3066
3153
  /* The native input is visually hidden but stays focusable and participates in
3067
3154
  form submission and keyboard navigation (sr-only pattern). Guarded: without
3068
3155
  :has() the card cannot mirror the input's state, so the visible native
@@ -3108,24 +3195,31 @@ small {
3108
3195
  }
3109
3196
  }
3110
3197
 
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). */
3198
+ /* Indicator: ::before is the ring or box, ::after the check. Both share the
3199
+ input's cell, centred on the first line — not the first row, which a long
3200
+ label wraps — and stay hidden until :has() can mirror the input. The em
3201
+ margin is the lh-less fallback at the default 1.5 line-height. */
3113
3202
  .choice-card::before,
3114
3203
  .choice-card::after {
3115
3204
  content: "";
3116
- position: absolute;
3117
- inset-inline-end: -0.375em;
3118
- inset-block-start: -0.375em;
3205
+ grid-area: 1 / 1;
3206
+ align-self: start;
3207
+ margin-block-start: calc((1.5em - var(--choice-card-check-size)) / 2);
3208
+ margin-block-start: calc((1lh - var(--choice-card-check-size)) / 2);
3119
3209
  inline-size: var(--choice-card-check-size);
3120
3210
  block-size: var(--choice-card-check-size);
3121
3211
  transform: scale(0);
3122
- transition: transform var(--duration);
3212
+ transition:
3213
+ transform var(--duration),
3214
+ background-color var(--duration),
3215
+ border-color var(--duration);
3123
3216
  }
3124
3217
 
3125
- /* Badge disc — circle for radio, rounded square for checkbox */
3218
+ /* A border, not an inset shadow: forced colors drop shadows, and the resting
3219
+ ring is the only sign of an unchecked option. The invalid border reaches it
3220
+ through the same hook as the card's. */
3126
3221
  .choice-card::before {
3127
- background: var(--intent, var(--state-selected));
3128
- box-shadow: 0 0 0 2px var(--surface);
3222
+ border: max(var(--border-width), 1.5px) solid var(--form-invalid-border, var(--text-muted));
3129
3223
  z-index: 1;
3130
3224
  }
3131
3225
 
@@ -3147,7 +3241,19 @@ small {
3147
3241
  z-index: 2;
3148
3242
  }
3149
3243
 
3150
- .choice-card:has(:checked)::before,
3244
+ /* Guarded with the input's hiding above: a resting ring beside a visible
3245
+ native input would draw the indicator twice. */
3246
+ @supports selector(:has(*)) {
3247
+ .choice-card::before {
3248
+ transform: none;
3249
+ }
3250
+ }
3251
+
3252
+ .choice-card:has(:checked)::before {
3253
+ border-color: var(--intent, var(--state-selected));
3254
+ background: var(--intent, var(--state-selected));
3255
+ }
3256
+
3151
3257
  .choice-card:has(:checked)::after {
3152
3258
  transform: scale(1);
3153
3259
  }
@@ -3173,11 +3279,16 @@ small {
3173
3279
  .choice-card.chip::before {
3174
3280
  --choice-chip-dot: calc(var(--control-pad-x) + var(--choice-card-check-size) / 2) 50%;
3175
3281
 
3282
+ position: absolute;
3176
3283
  inset: 0;
3284
+ /* why: an absolute box honours self-alignment — the card's start would
3285
+ shrink this layer to 0 height and the dot with it. */
3286
+ align-self: auto;
3287
+ margin: 0;
3177
3288
  inline-size: auto;
3178
3289
  block-size: auto;
3290
+ border: 0;
3179
3291
  border-radius: inherit;
3180
- box-shadow: none;
3181
3292
  background: var(--text-subtle);
3182
3293
  transform: none;
3183
3294
  clip-path: circle(0.1875em at var(--choice-chip-dot));
@@ -3191,10 +3302,11 @@ small {
3191
3302
 
3192
3303
  /* The check keeps the card's scale-in, in flow ahead of the label. */
3193
3304
  .choice-card.chip::after {
3194
- position: static;
3195
3305
  order: -1;
3196
3306
  flex: none;
3197
- margin-inline-end: 0.375em;
3307
+ align-self: center;
3308
+ margin-block: 0;
3309
+ margin-inline: 0 0.375em;
3198
3310
  background: var(--intent, var(--state-selected));
3199
3311
  -webkit-mask-size: contain;
3200
3312
  mask-size: contain;
@@ -3204,9 +3316,11 @@ small {
3204
3316
  this repair, forced colors pushes disc and check to the same system color
3205
3317
  and the state disappears. Highlight/Canvas/HighlightText are preserved as-is,
3206
3318
  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. */
3319
+ outline separates the disc from whatever it overlaps (the card's border,
3320
+ for a corner-badge recipe), not a focus channel. */
3208
3321
  @media (forced-colors: active) {
3209
3322
  .choice-card:has(:checked)::before {
3323
+ border-color: Highlight;
3210
3324
  background: Highlight;
3211
3325
  outline: 2px solid Canvas;
3212
3326
  }
@@ -3502,7 +3616,7 @@ small {
3502
3616
  padding-block: 0;
3503
3617
  padding-inline: calc((var(--otp-cell-size) - 1ch) / 2) 0;
3504
3618
  border: 0;
3505
- outline: 2px solid transparent;
3619
+ outline: none;
3506
3620
  box-shadow: none;
3507
3621
  background: transparent;
3508
3622
  color: var(--text);
@@ -3527,6 +3641,14 @@ small {
3527
3641
  character after it: the state rules select the cells as following siblings
3528
3642
  (input:focus ~ span, input:disabled ~ span, …), so that order is part of the
3529
3643
  contract. */
3644
+ /* The documented markup puts .input on the native input, so the field ring
3645
+ (.input:focus-visible:not(:disabled), control.css) would outrank the reset
3646
+ above and draw that group ring; this selector must stay one step above it.
3647
+ No transparent outline either: forced colors would paint it as a ring. */
3648
+ .otp > input:focus-visible:not(:disabled) {
3649
+ outline: none;
3650
+ }
3651
+
3530
3652
  .otp > input:focus ~ span {
3531
3653
  --otp-cell-border: var(--focus);
3532
3654
  --otp-cell-border-width: calc(var(--border-width) * 2);
@@ -5186,6 +5308,7 @@ picture.bleed > img {
5186
5308
  * Public hooks:
5187
5309
  * --flyout-inline-size
5188
5310
  * --flyout-max-inline-size
5311
+ * --flyout-pad
5189
5312
  *
5190
5313
  * Internal:
5191
5314
  * --menu-item-size (derived from --control-size for menu rows)
@@ -5238,7 +5361,9 @@ picture.bleed > img {
5238
5361
  );
5239
5362
  overflow: auto;
5240
5363
  margin: 0;
5241
- padding: var(--space-10);
5364
+ /* Tight by default for action menus, whose items carry their own padding.
5365
+ Rich panels (forms, pickers) opt into dialog air per instance. */
5366
+ padding: var(--flyout-pad, var(--space-10));
5242
5367
  border: var(--border-width) solid var(--border);
5243
5368
  border-radius: var(--radius);
5244
5369
  background: var(--surface-raised);
@@ -5511,7 +5636,7 @@ picture.bleed > img {
5511
5636
  */
5512
5637
  .tooltip {
5513
5638
  --tooltip-bg: var(--surface-solid);
5514
- --tooltip-fg: var(--surface);
5639
+ --tooltip-fg: var(--surface-solid-fg);
5515
5640
  --tooltip-arrow-size: 5px;
5516
5641
 
5517
5642
  position: fixed;
@@ -6157,8 +6282,8 @@ picture.bleed > img {
6157
6282
  }
6158
6283
 
6159
6284
  .status-bar {
6160
- --status-bg: var(--intent, var(--text));
6161
- --status-fg: var(--intent-fg, var(--surface));
6285
+ --status-bg: var(--intent, var(--surface-solid));
6286
+ --status-fg: var(--intent-fg, var(--surface-solid-fg));
6162
6287
 
6163
6288
  position: fixed;
6164
6289
  /* Fixed positioning tracks the layout viewport, which the mobile software
@@ -6428,7 +6553,7 @@ a.avatar[aria-disabled="true"] {
6428
6553
  }
6429
6554
 
6430
6555
  .avatar-stack > .avatar {
6431
- box-shadow: 0 0 0 var(--avatar-stack-ring) var(--surface);
6556
+ box-shadow: 0 0 0 var(--avatar-stack-ring) var(--surface-opaque);
6432
6557
  }
6433
6558
 
6434
6559
  .avatar-stack > .avatar + .avatar {
@@ -6854,7 +6979,7 @@ a.avatar[aria-disabled="true"] {
6854
6979
  }
6855
6980
 
6856
6981
  .rating > input[type="radio"]:focus-visible {
6857
- outline: var(--focus-outline);
6982
+ outline: var(--focus-ring-width) solid var(--focus);
6858
6983
  outline-offset: var(--focus-outline-offset);
6859
6984
  background: var(--rating-color);
6860
6985
  }
@@ -6960,7 +7085,7 @@ a.avatar[aria-disabled="true"] {
6960
7085
  --step-size: 1.75rem;
6961
7086
  --step-inline-gap: var(--space-20);
6962
7087
  --step-line-size: var(--border-width);
6963
- --step-marker-bg: var(--surface);
7088
+ --step-marker-bg: var(--surface-opaque);
6964
7089
  --step-marker-border: var(--border);
6965
7090
  --step-marker-fg: var(--text-muted);
6966
7091
  --step-marker-radius: var(--radius-full);
@@ -7040,7 +7165,7 @@ a.avatar[aria-disabled="true"] {
7040
7165
  connector leaving current goes back to neutral. Declared after
7041
7166
  .step-complete at equal specificity: current wins on every channel. */
7042
7167
  .steps > [aria-current="step"] {
7043
- --step-marker-bg: var(--surface);
7168
+ --step-marker-bg: var(--surface-opaque);
7044
7169
  --step-marker-border: var(--state-selected);
7045
7170
  --step-marker-fg: var(--state-selected);
7046
7171
  --step-line: var(--border);
@@ -7079,7 +7204,7 @@ a.avatar[aria-disabled="true"] {
7079
7204
  focus ring's own width and offset. Only this composition scrolls, and only a
7080
7205
  link has a ring to protect. */
7081
7206
  .steps-horizontal:has(a[href]) {
7082
- padding-block: calc(var(--focus-outline-offset) + var(--border-width) * 2);
7207
+ padding-block: calc(var(--focus-outline-offset) + var(--focus-ring-width));
7083
7208
  }
7084
7209
 
7085
7210
  /* One explicit row, the marker's: a `.step-label` is auto-placed into an
@@ -7563,8 +7688,9 @@ a.avatar[aria-disabled="true"] {
7563
7688
  *
7564
7689
  * Docs: docs/pages/components/pagination.md
7565
7690
  *
7566
- * Render .btn elements inside a .pagination list. The active page is marked
7567
- * with aria-current="page"; .btn owns the active state styling.
7691
+ * Render .btn.ghost elements inside a .pagination list. The active page is
7692
+ * marked with aria-current="page"; .btn owns the active state styling, so the
7693
+ * current page is the only filled item.
7568
7694
  *
7569
7695
  * Truncated ranges are plain text (`.muted`, `aria-hidden="true"`), not .btn
7570
7696
  * — they are decorative, not actionable. They still get the same box sizing
@@ -7682,10 +7808,10 @@ a.avatar[aria-disabled="true"] {
7682
7808
  }
7683
7809
 
7684
7810
  /* 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. */
7811
+ of the strip rather than a floating chip. Color and width follow the
7812
+ component ring contract (focus.css); only the geometry is tab-specific. */
7686
7813
  .tab:focus-visible {
7687
- outline: var(--focus-outline);
7688
- outline-color: var(--focus);
7814
+ outline: var(--focus-ring-width) solid var(--focus);
7689
7815
  outline-offset: -2px;
7690
7816
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
7691
7817
  }
@@ -7938,6 +8064,12 @@ a.list-item[aria-current]::before {
7938
8064
  * .table-wrap provides the scroll container and outer chrome; .table owns row
7939
8065
  * and cell rules. Tokens are sourced from the shared scale.
7940
8066
  *
8067
+ * Responsive strategy is horizontal overflow: the wrap scrolls, the table
8068
+ * keeps its columns. Restructuring rows into cards would destroy the
8069
+ * column scanability tables exist for, and needs data-label duplication
8070
+ * because CSS cannot join a cell to its header — that representation choice
8071
+ * belongs to the application, not the framework.
8072
+ *
7941
8073
  * Public hooks:
7942
8074
  * --table-min
7943
8075
  * --table-cell-pad
@@ -8058,7 +8190,7 @@ a.list-item[aria-current]::before {
8058
8190
  }
8059
8191
 
8060
8192
  .meter:not(meter) > * + * {
8061
- border-inline-start: var(--border-width) solid var(--surface);
8193
+ border-inline-start: var(--border-width) solid var(--surface-opaque);
8062
8194
  }
8063
8195
 
8064
8196
  .meter::-webkit-meter-bar {
@@ -8295,36 +8427,55 @@ a.list-item[aria-current]::before {
8295
8427
 
8296
8428
  .aura {
8297
8429
  --aura-color: var(--intent, var(--primary));
8298
- --aura-width: 2px;
8430
+ --aura-width: 1px;
8299
8431
  --aura-blur: 0.25rem;
8300
- --aura-intensity: 1;
8432
+ --aura-intensity: 0.4;
8301
8433
  --aura-radius: var(--radius-lg);
8302
8434
  --aura-duration: 6s;
8303
8435
 
8304
8436
  position: relative;
8305
8437
  isolation: isolate;
8306
8438
  display: inline-grid;
8307
- padding: var(--aura-width);
8308
8439
  border-radius: calc(var(--aura-radius) + var(--aura-width));
8309
- border: var(--border-width) solid transparent;
8440
+
8441
+ /* The frame is the border itself, so --aura-width is the painted thickness.
8442
+ A transparent border stays for forced colors, where it becomes the visible
8443
+ boundary; a padding frame inside it would paint both, doubling the line. */
8444
+ border: var(--aura-width) solid transparent;
8310
8445
  background: var(--aura-color);
8311
8446
 
8312
8447
  /* A short feathered accent rather than a long wedge with a hard edge, which
8313
8448
  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. */
8449
+ across the frame draws an outline, the opposite of the effect. `border-box`
8450
+ sizes the gradient to the border box: the default padding-box origin would
8451
+ tile a neighbouring copy of the gradient into the border, which is now the
8452
+ whole frame. */
8315
8453
  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. */
8454
+ from var(--aura-angle),
8455
+ transparent 0 78%,
8456
+ var(--aura-color) 93%,
8457
+ transparent
8458
+ )
8459
+ border-box;
8460
+
8461
+ /* Far halo — flat and static. At this blur the rotating detail is no longer
8462
+ legible, so there is nothing to gain from animating a second wide blurred
8463
+ layer. This is the layer that stays lit at all times, so it — not the
8464
+ frame — is what keeps the object present between two passes of the
8465
+ accent. An outer shadow never paints under the box, so it stays out of a
8466
+ translucent child. min() because a color-mix() share above 100% is
8467
+ invalid, where the opacity it replaces clamped. */
8468
+ box-shadow: 0 0 calc(var(--aura-blur) * 8) var(--aura-blur)
8469
+ color-mix(in oklab, var(--aura-color) min(100%, calc(15% * var(--aura-intensity))), transparent);
8470
+ }
8471
+
8472
+ /* Near halo — inherits the frame gradient, so it tracks the lit accent. An
8473
+ absolute inset starts at the padding edge, inside the border that is the
8474
+ frame, so the negative inset puts the halo back on the frame. */
8324
8475
  .aura::before {
8325
8476
  position: absolute;
8326
8477
  z-index: -1;
8327
- inset: 0;
8478
+ inset: calc(-1 * var(--aura-width));
8328
8479
  border-radius: inherit;
8329
8480
  background: inherit;
8330
8481
  filter: blur(var(--aura-blur));
@@ -8333,29 +8484,22 @@ a.list-item[aria-current]::before {
8333
8484
  content: "";
8334
8485
  }
8335
8486
 
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. */
8487
+ /* Occlusion — the frame and the near halo paint across the whole box, and only
8488
+ their edge is meant to show. This mask sits above both, under the child, so
8489
+ a transparent or translucent child (an .outline alert, a glass surface) is
8490
+ framed rather than filled with the frame color. It follows the child's
8491
+ shape through --aura-radius. */
8342
8492
  .aura::after {
8343
8493
  position: absolute;
8344
8494
  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));
8495
+ inset: 0;
8496
+ border-radius: var(--aura-radius);
8497
+ background: var(--surface-opaque);
8350
8498
  pointer-events: none;
8351
8499
  content: "";
8352
8500
  }
8353
8501
 
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. */
8502
+ /* Lifts the child above the halo and the mask. */
8359
8503
  .aura > * {
8360
8504
  position: relative;
8361
8505
  }
@@ -8378,7 +8522,7 @@ a.list-item[aria-current]::before {
8378
8522
  animation: aura-breathe-near var(--aura-duration) ease-in-out infinite;
8379
8523
  }
8380
8524
 
8381
- .aura-glow::after {
8525
+ .aura-glow {
8382
8526
  animation: aura-breathe-far var(--aura-duration) ease-in-out infinite;
8383
8527
  }
8384
8528
  }
@@ -8390,8 +8534,8 @@ a.list-item[aria-current]::before {
8390
8534
  @media (prefers-reduced-motion: reduce) {
8391
8535
  .aura:not(.aura-glow) {
8392
8536
  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%);
8537
+ radial-gradient(4rem 4rem at 0% 0%, var(--aura-color), transparent 70%) border-box,
8538
+ radial-gradient(4rem 4rem at 100% 100%, var(--aura-color), transparent 70%) border-box;
8395
8539
  }
8396
8540
  }
8397
8541
 
@@ -8412,10 +8556,16 @@ a.list-item[aria-current]::before {
8412
8556
  }
8413
8557
  }
8414
8558
 
8559
+ /* The far halo is the element's own shadow: opening its spread and share
8560
+ breathes it without scaling the child. */
8415
8561
  @keyframes aura-breathe-far {
8416
8562
  50% {
8417
- opacity: calc(0.28 * var(--aura-intensity));
8418
- transform: scale(1.07);
8563
+ box-shadow: 0 0 calc(var(--aura-blur) * 10) calc(var(--aura-blur) * 2)
8564
+ color-mix(
8565
+ in oklab,
8566
+ var(--aura-color) min(100%, calc(28% * var(--aura-intensity))),
8567
+ transparent
8568
+ );
8419
8569
  }
8420
8570
  }
8421
8571
 
@@ -8435,6 +8585,64 @@ a.list-item[aria-current]::before {
8435
8585
  --aura-radius: var(--radius-full);
8436
8586
  }
8437
8587
 
8588
+ /*
8589
+ * Highlight sweeping across a text label — an activity in progress.
8590
+ *
8591
+ * Docs: docs/pages/components/shimmer.md
8592
+ *
8593
+ * Presentation only: it carries no state and selects no ARIA attribute, so the
8594
+ * label stays whatever the author makes it (a role="status" message, a list
8595
+ * item, a heading).
8596
+ *
8597
+ * Public hooks:
8598
+ * --shimmer-color
8599
+ * --shimmer-highlight
8600
+ */
8601
+ .shimmer {
8602
+ --shimmer-color: var(--text-muted);
8603
+ --shimmer-highlight: var(--text);
8604
+
8605
+ color: var(--shimmer-color);
8606
+ }
8607
+
8608
+ /* The sweep paints the glyphs through a clipped background, which needs the
8609
+ glyph fill transparent. Without background-clip: text that would erase the
8610
+ label, so the transparency lives under the same query as the clip. It is
8611
+ the fill, not `color`: a label class that sets its own color (.muted, a
8612
+ caption style) would otherwise win the cascade and paint opaque glyphs over
8613
+ the sweep. Reduced motion keeps the plain label — a frozen band would read
8614
+ as a random tint. */
8615
+ @supports (background-clip: text) {
8616
+ @media (prefers-reduced-motion: no-preference) {
8617
+ .shimmer {
8618
+ -webkit-text-fill-color: transparent;
8619
+ background:
8620
+ linear-gradient(
8621
+ 90deg,
8622
+ var(--shimmer-color) 40%,
8623
+ var(--shimmer-highlight) 50%,
8624
+ var(--shimmer-color) 60%
8625
+ )
8626
+ 0 0 / 300% 100%;
8627
+ background-clip: text;
8628
+ animation: actual-shimmer var(--duration-shimmer) linear infinite;
8629
+ }
8630
+ }
8631
+ }
8632
+
8633
+ /* The band occupies the middle third of a background three times the label's
8634
+ width, so each pass leaves the end of the label before the next one enters
8635
+ at the start. */
8636
+ @keyframes actual-shimmer {
8637
+ from {
8638
+ background-position: 100% 0;
8639
+ }
8640
+
8641
+ to {
8642
+ background-position: 0% 0;
8643
+ }
8644
+ }
8645
+
8438
8646
  /* Utilities — small, single-purpose escape hatches. */
8439
8647
  /*
8440
8648
  * Small, single-purpose utilities for common escape hatches.