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
@@ -33,8 +33,8 @@
33
33
  }
34
34
 
35
35
  .status-bar {
36
- --status-bg: var(--intent, var(--text));
37
- --status-fg: var(--intent-fg, var(--surface));
36
+ --status-bg: var(--intent, var(--surface-solid));
37
+ --status-fg: var(--intent-fg, var(--surface-solid-fg));
38
38
 
39
39
  position: fixed;
40
40
  /* Fixed positioning tracks the layout viewport, which the mobile software
@@ -58,7 +58,7 @@
58
58
  --step-size: 1.75rem;
59
59
  --step-inline-gap: var(--space-20);
60
60
  --step-line-size: var(--border-width);
61
- --step-marker-bg: var(--surface);
61
+ --step-marker-bg: var(--surface-opaque);
62
62
  --step-marker-border: var(--border);
63
63
  --step-marker-fg: var(--text-muted);
64
64
  --step-marker-radius: var(--radius-full);
@@ -138,7 +138,7 @@
138
138
  connector leaving current goes back to neutral. Declared after
139
139
  .step-complete at equal specificity: current wins on every channel. */
140
140
  .steps > [aria-current="step"] {
141
- --step-marker-bg: var(--surface);
141
+ --step-marker-bg: var(--surface-opaque);
142
142
  --step-marker-border: var(--state-selected);
143
143
  --step-marker-fg: var(--state-selected);
144
144
  --step-line: var(--border);
@@ -177,7 +177,7 @@
177
177
  focus ring's own width and offset. Only this composition scrolls, and only a
178
178
  link has a ring to protect. */
179
179
  .steps-horizontal:has(a[href]) {
180
- padding-block: calc(var(--focus-outline-offset) + var(--border-width) * 2);
180
+ padding-block: calc(var(--focus-outline-offset) + var(--focus-ring-width));
181
181
  }
182
182
 
183
183
  /* One explicit row, the marker's: a `.step-label` is auto-placed into an
@@ -76,10 +76,10 @@
76
76
  }
77
77
 
78
78
  /* The ring is square on the edge the tab is attached to, so it reads as part
79
- of the strip rather than a floating chip. */
79
+ of the strip rather than a floating chip. Color and width follow the
80
+ component ring contract (focus.css); only the geometry is tab-specific. */
80
81
  .tab:focus-visible {
81
- outline: var(--focus-outline);
82
- outline-color: var(--focus);
82
+ outline: var(--focus-ring-width) solid var(--focus);
83
83
  outline-offset: -2px;
84
84
  border-radius: var(--radius-sm) var(--radius-sm) 0 0;
85
85
  }
@@ -6,6 +6,12 @@
6
6
  * .table-wrap provides the scroll container and outer chrome; .table owns row
7
7
  * and cell rules. Tokens are sourced from the shared scale.
8
8
  *
9
+ * Responsive strategy is horizontal overflow: the wrap scrolls, the table
10
+ * keeps its columns. Restructuring rows into cards would destroy the
11
+ * column scanability tables exist for, and needs data-label duplication
12
+ * because CSS cannot join a cell to its header — that representation choice
13
+ * belongs to the application, not the framework.
14
+ *
9
15
  * Public hooks:
10
16
  * --table-min
11
17
  * --table-cell-pad
@@ -22,7 +22,7 @@
22
22
  */
23
23
  .tooltip {
24
24
  --tooltip-bg: var(--surface-solid);
25
- --tooltip-fg: var(--surface);
25
+ --tooltip-fg: var(--surface-solid-fg);
26
26
  --tooltip-arrow-size: 5px;
27
27
 
28
28
  position: fixed;
@@ -6,12 +6,22 @@
6
6
  for its keyboard focus indicator; forced colors keep outlines and drop
7
7
  shadows.
8
8
 
9
- Component indicators are a solid --focus-ring-width line in --focus: inset
10
- on fields (inside their border box), offset by --focus-outline-offset on
11
- discrete actions. The color never derives from an intent or a local
9
+ Two contracts share the job, both --focus-ring-width wide. Text-like
10
+ elements, whose ink is the thing being focused (links, summary, .nav-link,
11
+ .menu-item, breadcrumb links, .navbar-brand), declare no outline and take
12
+ the generic fallback below: a currentColor line that follows whatever the
13
+ application painted. Boxed components, with a fill or border of their own,
14
+ draw the themed ring instead — a solid line in --focus: inset on fields
15
+ (inside their border box), offset by --focus-outline-offset on discrete
16
+ actions. The color never derives from an intent or a local
12
17
  surface — the theme guarantees --focus at 3:1 against both --surface and
13
18
  --surface-solid, so one token reads on the page, on a solid band and on
14
- any nested surface. */
19
+ any nested surface.
20
+
21
+ Documented exception: an invalid field focuses in --form-invalid-border
22
+ (danger), so the error stays visible at the point of correction. The border
23
+ carries the same hook; report:theme-contrast measures that line at 3:1
24
+ against the field's background alongside --focus. */
15
25
  :focus {
16
26
  outline: var(--focus-outline);
17
27
  outline-offset: var(--focus-outline-offset);
@@ -7,6 +7,8 @@
7
7
  * Shadow color, Focus ring derivation.
8
8
  * Derived: "Theme-derived aliases", Tone subsystem, Backdrop fill — reference
9
9
  * other tokens; hard-coded overrides on a custom theme will break the intent.
10
+ * Exception: --surface-opaque and --surface-solid-fg are public; a theme with
11
+ * translucent surfaces overrides them with opaque values.
10
12
  * Internal: --hgroup-gap.
11
13
  */
12
14
  :root {
@@ -98,6 +100,12 @@
98
100
  --heading: var(--text);
99
101
  --selection-bg: var(--primary);
100
102
  --selection-fg: var(--primary-fg);
103
+ /* Surface roles that must stay opaque when a theme paints translucent
104
+ surfaces; both equal --surface on an opaque palette. --surface-opaque
105
+ hides or separates what is painted beneath a ring, separator or marker —
106
+ never a fill. --surface-solid-fg is the ink on --surface-solid. */
107
+ --surface-opaque: var(--surface);
108
+ --surface-solid-fg: var(--surface);
101
109
  }
102
110
 
103
111
  /* Soft foreground hooks — the soft ink is the intent rebated toward the local
@@ -78,9 +78,13 @@
78
78
  --ease-enter: cubic-bezier(0.2, 0, 0, 1);
79
79
  --ease-exit: cubic-bezier(0.4, 0, 1, 1);
80
80
 
81
- /* Focus ring — shared by every interactive component's :focus-visible */
81
+ /* Generic focus fallback — an adaptive currentColor line for elements
82
+ without a component ring (links, summary, plain items). Components draw
83
+ the themed ring instead: --focus-ring-width solid --focus. See focus.css
84
+ for the two contracts. Both share --focus-ring-width: they differ in
85
+ color only. */
82
86
  --focus-outline-color: currentColor;
83
- --focus-outline: calc(var(--border-width) * 2) solid var(--focus-outline-color);
87
+ --focus-outline: var(--focus-ring-width) solid var(--focus-outline-color);
84
88
  --focus-outline-offset: 2px;
85
89
  --focus-ring-width: 2px;
86
90
 
@@ -143,8 +147,8 @@
143
147
  --state-selected-fg: var(--primary-fg);
144
148
  --state-disabled: var(--text-subtle);
145
149
 
146
- /* Status dot ring — follows the island's surface. */
147
- --indicator-ring: var(--surface);
150
+ /* Status dot ring — cuts the dot out of what it overlaps. */
151
+ --indicator-ring: var(--surface-opaque);
148
152
  }
149
153
 
150
154
  :root {
@@ -77,7 +77,8 @@
77
77
  /* Shared control scale. Wrappers are listed when their descendants form one
78
78
  component (field, choice-card, floating-field, input-icon, join, pagination,
79
79
  tabs, flyout). A local size declaration beats inherited density because it
80
- is declared on the sized component itself. */
80
+ is declared on the sized component itself. Guarded by check:sync
81
+ (size-scale-participants): .sm and .lg must list the same components. */
81
82
  :where(
82
83
  .btn,
83
84
  .input,
@@ -35,36 +35,55 @@
35
35
 
36
36
  .aura {
37
37
  --aura-color: var(--intent, var(--primary));
38
- --aura-width: 2px;
38
+ --aura-width: 1px;
39
39
  --aura-blur: 0.25rem;
40
- --aura-intensity: 1;
40
+ --aura-intensity: 0.4;
41
41
  --aura-radius: var(--radius-lg);
42
42
  --aura-duration: 6s;
43
43
 
44
44
  position: relative;
45
45
  isolation: isolate;
46
46
  display: inline-grid;
47
- padding: var(--aura-width);
48
47
  border-radius: calc(var(--aura-radius) + var(--aura-width));
49
- border: var(--border-width) solid transparent;
48
+
49
+ /* The frame is the border itself, so --aura-width is the painted thickness.
50
+ A transparent border stays for forced colors, where it becomes the visible
51
+ boundary; a padding frame inside it would paint both, doubling the line. */
52
+ border: var(--aura-width) solid transparent;
50
53
  background: var(--aura-color);
51
54
 
52
55
  /* A short feathered accent rather than a long wedge with a hard edge, which
53
56
  reads as one lit side. Nothing tints the frame outside it: a constant tint
54
- across 2px draws an outline, the opposite of the effect. */
57
+ across the frame draws an outline, the opposite of the effect. `border-box`
58
+ sizes the gradient to the border box: the default padding-box origin would
59
+ tile a neighbouring copy of the gradient into the border, which is now the
60
+ whole frame. */
55
61
  background: conic-gradient(
56
- from var(--aura-angle),
57
- transparent 0 78%,
58
- var(--aura-color) 93%,
59
- transparent
60
- );
62
+ from var(--aura-angle),
63
+ transparent 0 78%,
64
+ var(--aura-color) 93%,
65
+ transparent
66
+ )
67
+ border-box;
68
+
69
+ /* Far halo — flat and static. At this blur the rotating detail is no longer
70
+ legible, so there is nothing to gain from animating a second wide blurred
71
+ layer. This is the layer that stays lit at all times, so it — not the
72
+ frame — is what keeps the object present between two passes of the
73
+ accent. An outer shadow never paints under the box, so it stays out of a
74
+ translucent child. min() because a color-mix() share above 100% is
75
+ invalid, where the opacity it replaces clamped. */
76
+ box-shadow: 0 0 calc(var(--aura-blur) * 8) var(--aura-blur)
77
+ color-mix(in oklab, var(--aura-color) min(100%, calc(15% * var(--aura-intensity))), transparent);
61
78
  }
62
79
 
63
- /* Near halo — inherits the frame gradient, so it tracks the lit accent. */
80
+ /* Near halo — inherits the frame gradient, so it tracks the lit accent. An
81
+ absolute inset starts at the padding edge, inside the border that is the
82
+ frame, so the negative inset puts the halo back on the frame. */
64
83
  .aura::before {
65
84
  position: absolute;
66
85
  z-index: -1;
67
- inset: 0;
86
+ inset: calc(-1 * var(--aura-width));
68
87
  border-radius: inherit;
69
88
  background: inherit;
70
89
  filter: blur(var(--aura-blur));
@@ -73,29 +92,22 @@
73
92
  content: "";
74
93
  }
75
94
 
76
- /* Far halo — flat and static. At this blur the rotating detail is no longer
77
- legible, so there is nothing to gain from animating a second wide blurred
78
- layer. It outgrows the frame, otherwise the child covers it and the whole
79
- effect reads as a drop shadow. This is the layer that stays lit at all
80
- times, so it — not the frame — is what keeps the object present between two
81
- passes of the accent. */
95
+ /* Occlusion — the frame and the near halo paint across the whole box, and only
96
+ their edge is meant to show. This mask sits above both, under the child, so
97
+ a transparent or translucent child (an .outline alert, a glass surface) is
98
+ framed rather than filled with the frame color. It follows the child's
99
+ shape through --aura-radius. */
82
100
  .aura::after {
83
101
  position: absolute;
84
102
  z-index: -1;
85
- inset: calc(-1 * var(--aura-blur));
86
- border-radius: calc(var(--aura-radius) + var(--aura-width) + var(--aura-blur));
87
- background: var(--aura-color);
88
- filter: blur(calc(var(--aura-blur) * 4));
89
- opacity: calc(0.15 * var(--aura-intensity));
103
+ inset: 0;
104
+ border-radius: var(--aura-radius);
105
+ background: var(--surface-opaque);
90
106
  pointer-events: none;
91
107
  content: "";
92
108
  }
93
109
 
94
- /* Lifts the child above both halos. It also has to be opaque: the frame paints
95
- across the whole box and only its edge is meant to show, so a transparent
96
- child (an .outline alert, a .ghost button) is filled with the frame color
97
- rather than framed by it. Documented as a constraint, not worked around —
98
- masking a ring would cost more than the case is worth. */
110
+ /* Lifts the child above the halo and the mask. */
99
111
  .aura > * {
100
112
  position: relative;
101
113
  }
@@ -118,7 +130,7 @@
118
130
  animation: aura-breathe-near var(--aura-duration) ease-in-out infinite;
119
131
  }
120
132
 
121
- .aura-glow::after {
133
+ .aura-glow {
122
134
  animation: aura-breathe-far var(--aura-duration) ease-in-out infinite;
123
135
  }
124
136
  }
@@ -130,8 +142,8 @@
130
142
  @media (prefers-reduced-motion: reduce) {
131
143
  .aura:not(.aura-glow) {
132
144
  background:
133
- radial-gradient(4rem 4rem at 0% 0%, var(--aura-color), transparent 70%),
134
- radial-gradient(4rem 4rem at 100% 100%, var(--aura-color), transparent 70%);
145
+ radial-gradient(4rem 4rem at 0% 0%, var(--aura-color), transparent 70%) border-box,
146
+ radial-gradient(4rem 4rem at 100% 100%, var(--aura-color), transparent 70%) border-box;
135
147
  }
136
148
  }
137
149
 
@@ -152,10 +164,16 @@
152
164
  }
153
165
  }
154
166
 
167
+ /* The far halo is the element's own shadow: opening its spread and share
168
+ breathes it without scaling the child. */
155
169
  @keyframes aura-breathe-far {
156
170
  50% {
157
- opacity: calc(0.28 * var(--aura-intensity));
158
- transform: scale(1.07);
171
+ box-shadow: 0 0 calc(var(--aura-blur) * 10) calc(var(--aura-blur) * 2)
172
+ color-mix(
173
+ in oklab,
174
+ var(--aura-color) min(100%, calc(28% * var(--aura-intensity))),
175
+ transparent
176
+ );
159
177
  }
160
178
  }
161
179
 
@@ -1,2 +1,3 @@
1
1
  /* Effects — purely visual enrichments. */
2
2
  @import "./aura.css";
3
+ @import "./shimmer.css";
@@ -0,0 +1,57 @@
1
+ /*
2
+ * Highlight sweeping across a text label — an activity in progress.
3
+ *
4
+ * Docs: docs/pages/components/shimmer.md
5
+ *
6
+ * Presentation only: it carries no state and selects no ARIA attribute, so the
7
+ * label stays whatever the author makes it (a role="status" message, a list
8
+ * item, a heading).
9
+ *
10
+ * Public hooks:
11
+ * --shimmer-color
12
+ * --shimmer-highlight
13
+ */
14
+ .shimmer {
15
+ --shimmer-color: var(--text-muted);
16
+ --shimmer-highlight: var(--text);
17
+
18
+ color: var(--shimmer-color);
19
+ }
20
+
21
+ /* The sweep paints the glyphs through a clipped background, which needs the
22
+ glyph fill transparent. Without background-clip: text that would erase the
23
+ label, so the transparency lives under the same query as the clip. It is
24
+ the fill, not `color`: a label class that sets its own color (.muted, a
25
+ caption style) would otherwise win the cascade and paint opaque glyphs over
26
+ the sweep. Reduced motion keeps the plain label — a frozen band would read
27
+ as a random tint. */
28
+ @supports (background-clip: text) {
29
+ @media (prefers-reduced-motion: no-preference) {
30
+ .shimmer {
31
+ -webkit-text-fill-color: transparent;
32
+ background:
33
+ linear-gradient(
34
+ 90deg,
35
+ var(--shimmer-color) 40%,
36
+ var(--shimmer-highlight) 50%,
37
+ var(--shimmer-color) 60%
38
+ )
39
+ 0 0 / 300% 100%;
40
+ background-clip: text;
41
+ animation: actual-shimmer var(--duration-shimmer) linear infinite;
42
+ }
43
+ }
44
+ }
45
+
46
+ /* The band occupies the middle third of a background three times the label's
47
+ width, so each pass leaves the end of the label before the next one enters
48
+ at the start. */
49
+ @keyframes actual-shimmer {
50
+ from {
51
+ background-position: 100% 0;
52
+ }
53
+
54
+ to {
55
+ background-position: 0% 0;
56
+ }
57
+ }
@@ -5,13 +5,15 @@
5
5
  *
6
6
  * Each .choice-card is a <label> wrapping a native radio or checkbox. When
7
7
  * :has() is supported the input is visually hidden and the card mirrors its
8
- * state: checked border and tint, checkmark badge (round for radio, rounded
9
- * square for checkbox), focus ring, disabled dimming. Without :has() the
10
- * native input stays visible so selection state never disappears.
8
+ * state: an indicator on the leading edge (a ring for radio, a box for
9
+ * checkbox) that fills with a check, checked border and tint, focus ring,
10
+ * disabled dimming. Without :has() the native input stays visible in the
11
+ * indicator's slot, so selection state never disappears.
11
12
  *
12
- * The badge overhangs the top-right corner by 0.375em; ancestors with
13
- * overflow: hidden will clip it. Pair with grid or flex utilities for group
14
- * layout.
13
+ * The card is a two-column grid: the indicator in the first column, level
14
+ * with the first row, and each child element of the label on its own row in
15
+ * the second. Wrap loose text in an element. A corner badge for tiles is a
16
+ * documented recipe on these same layers, not a variant.
15
17
  *
16
18
  * .chip is the compact pill form (filter chips, selectable tags): control
17
19
  * height, and a leading dot that turns into the check in a fixed slot, so
@@ -19,6 +21,7 @@
19
21
  *
20
22
  * Public hooks:
21
23
  * --choice-card-pad
24
+ * --choice-card-gap
22
25
  * --choice-card-radius
23
26
  * --choice-card-border
24
27
  * --choice-card-bg
@@ -33,19 +36,25 @@
33
36
 
34
37
  .choice-card {
35
38
  --choice-card-pad: var(--density-space, var(--space-40));
39
+ --choice-card-gap: var(--space-30);
36
40
  --choice-card-radius: var(--radius);
37
41
  --choice-card-border: var(--border);
38
42
  --choice-card-bg: var(--surface);
39
- --choice-card-check-size: 1.5em;
43
+ --choice-card-check-size: 1.25em;
40
44
 
41
45
  position: relative;
42
- display: block;
46
+ display: grid;
47
+ grid-template-columns: var(--choice-card-check-size) minmax(0, 1fr);
48
+ /* why: a card stretched by a taller grid neighbour keeps its rows at the
49
+ top instead of spreading them. */
50
+ align-content: start;
51
+ column-gap: var(--choice-card-gap);
43
52
  padding: var(--choice-card-pad);
44
53
  border: var(--border-width) solid var(--form-invalid-border, var(--choice-card-border));
45
54
  border-radius: var(--choice-card-radius);
46
55
  background: var(--choice-card-bg);
47
56
  color: var(--text);
48
- /* Consumed so .sm/.lg reach the card; the badge's em sizing follows. */
57
+ /* Consumed so .sm/.lg reach the card; the indicator's em sizing follows. */
49
58
  font-size: var(--control-font-size);
50
59
  cursor: pointer;
51
60
  transition:
@@ -54,6 +63,18 @@
54
63
  box-shadow var(--duration);
55
64
  }
56
65
 
66
+ .choice-card > * {
67
+ grid-column: 2;
68
+ }
69
+
70
+ /* The input owns the indicator's cell: visible there without :has(), hidden
71
+ in it with :has(). */
72
+ .choice-card > :is(input[type="radio"], input[type="checkbox"]) {
73
+ grid-area: 1 / 1;
74
+ place-self: center;
75
+ margin: 0;
76
+ }
77
+
57
78
  /* The native input is visually hidden but stays focusable and participates in
58
79
  form submission and keyboard navigation (sr-only pattern). Guarded: without
59
80
  :has() the card cannot mirror the input's state, so the visible native
@@ -99,24 +120,31 @@
99
120
  }
100
121
  }
101
122
 
102
- /* Checkmark badge — always rendered at scale(0) and scaled in on check, so
103
- the entrance can transition (a pseudo created on :checked could not). */
123
+ /* Indicator: ::before is the ring or box, ::after the check. Both share the
124
+ input's cell, centred on the first line — not the first row, which a long
125
+ label wraps — and stay hidden until :has() can mirror the input. The em
126
+ margin is the lh-less fallback at the default 1.5 line-height. */
104
127
  .choice-card::before,
105
128
  .choice-card::after {
106
129
  content: "";
107
- position: absolute;
108
- inset-inline-end: -0.375em;
109
- inset-block-start: -0.375em;
130
+ grid-area: 1 / 1;
131
+ align-self: start;
132
+ margin-block-start: calc((1.5em - var(--choice-card-check-size)) / 2);
133
+ margin-block-start: calc((1lh - var(--choice-card-check-size)) / 2);
110
134
  inline-size: var(--choice-card-check-size);
111
135
  block-size: var(--choice-card-check-size);
112
136
  transform: scale(0);
113
- transition: transform var(--duration);
137
+ transition:
138
+ transform var(--duration),
139
+ background-color var(--duration),
140
+ border-color var(--duration);
114
141
  }
115
142
 
116
- /* Badge disc — circle for radio, rounded square for checkbox */
143
+ /* A border, not an inset shadow: forced colors drop shadows, and the resting
144
+ ring is the only sign of an unchecked option. The invalid border reaches it
145
+ through the same hook as the card's. */
117
146
  .choice-card::before {
118
- background: var(--intent, var(--state-selected));
119
- box-shadow: 0 0 0 2px var(--surface);
147
+ border: max(var(--border-width), 1.5px) solid var(--form-invalid-border, var(--text-muted));
120
148
  z-index: 1;
121
149
  }
122
150
 
@@ -138,7 +166,19 @@
138
166
  z-index: 2;
139
167
  }
140
168
 
141
- .choice-card:has(:checked)::before,
169
+ /* Guarded with the input's hiding above: a resting ring beside a visible
170
+ native input would draw the indicator twice. */
171
+ @supports selector(:has(*)) {
172
+ .choice-card::before {
173
+ transform: none;
174
+ }
175
+ }
176
+
177
+ .choice-card:has(:checked)::before {
178
+ border-color: var(--intent, var(--state-selected));
179
+ background: var(--intent, var(--state-selected));
180
+ }
181
+
142
182
  .choice-card:has(:checked)::after {
143
183
  transform: scale(1);
144
184
  }
@@ -164,11 +204,16 @@
164
204
  .choice-card.chip::before {
165
205
  --choice-chip-dot: calc(var(--control-pad-x) + var(--choice-card-check-size) / 2) 50%;
166
206
 
207
+ position: absolute;
167
208
  inset: 0;
209
+ /* why: an absolute box honours self-alignment — the card's start would
210
+ shrink this layer to 0 height and the dot with it. */
211
+ align-self: auto;
212
+ margin: 0;
168
213
  inline-size: auto;
169
214
  block-size: auto;
215
+ border: 0;
170
216
  border-radius: inherit;
171
- box-shadow: none;
172
217
  background: var(--text-subtle);
173
218
  transform: none;
174
219
  clip-path: circle(0.1875em at var(--choice-chip-dot));
@@ -182,10 +227,11 @@
182
227
 
183
228
  /* The check keeps the card's scale-in, in flow ahead of the label. */
184
229
  .choice-card.chip::after {
185
- position: static;
186
230
  order: -1;
187
231
  flex: none;
188
- margin-inline-end: 0.375em;
232
+ align-self: center;
233
+ margin-block: 0;
234
+ margin-inline: 0 0.375em;
189
235
  background: var(--intent, var(--state-selected));
190
236
  -webkit-mask-size: contain;
191
237
  mask-size: contain;
@@ -195,9 +241,11 @@
195
241
  this repair, forced colors pushes disc and check to the same system color
196
242
  and the state disappears. Highlight/Canvas/HighlightText are preserved as-is,
197
243
  so no forced-color-adjust is needed. The pseudo is not focusable — the
198
- outline is a separator against the card, not a focus channel. */
244
+ outline separates the disc from whatever it overlaps (the card's border,
245
+ for a corner-badge recipe), not a focus channel. */
199
246
  @media (forced-colors: active) {
200
247
  .choice-card:has(:checked)::before {
248
+ border-color: Highlight;
201
249
  background: Highlight;
202
250
  outline: 2px solid Canvas;
203
251
  }
@@ -53,8 +53,8 @@
53
53
  cursor: var(--control-cursor);
54
54
  /* No outline: none here — the core :focus fallback must survive for browsers
55
55
  without :focus-visible. Pointer focus is hidden by core's
56
- :focus:not(:focus-visible); keyboard focus swaps in the transparent
57
- outline + ring below. */
56
+ :focus:not(:focus-visible); keyboard focus draws the inset outline below
57
+ (invalid fields take --form-invalid-border, the documented exception). */
58
58
  transition:
59
59
  border-color var(--duration),
60
60
  box-shadow var(--duration),
@@ -64,7 +64,7 @@
64
64
  padding-block: 0;
65
65
  padding-inline: calc((var(--otp-cell-size) - 1ch) / 2) 0;
66
66
  border: 0;
67
- outline: 2px solid transparent;
67
+ outline: none;
68
68
  box-shadow: none;
69
69
  background: transparent;
70
70
  color: var(--text);
@@ -89,6 +89,14 @@
89
89
  character after it: the state rules select the cells as following siblings
90
90
  (input:focus ~ span, input:disabled ~ span, …), so that order is part of the
91
91
  contract. */
92
+ /* The documented markup puts .input on the native input, so the field ring
93
+ (.input:focus-visible:not(:disabled), control.css) would outrank the reset
94
+ above and draw that group ring; this selector must stay one step above it.
95
+ No transparent outline either: forced colors would paint it as a ring. */
96
+ .otp > input:focus-visible:not(:disabled) {
97
+ outline: none;
98
+ }
99
+
92
100
  .otp > input:focus ~ span {
93
101
  --otp-cell-border: var(--focus);
94
102
  --otp-cell-border-width: calc(var(--border-width) * 2);