actual-css 0.13.0 → 0.14.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 (66) hide show
  1. package/CHANGELOG.md +67 -0
  2. package/README.md +8 -8
  3. package/components.json +116 -10
  4. package/dist/actual.full.css +652 -77
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/design-notes/widget-primitives.md +4 -0
  8. package/docs/navigation.json +2 -0
  9. package/docs/pages/components/alert.md +12 -2
  10. package/docs/pages/components/button.md +3 -2
  11. package/docs/pages/components/list.md +6 -3
  12. package/docs/pages/components/segmented.md +83 -0
  13. package/docs/pages/components/steps.md +4 -2
  14. package/docs/pages/enhancements/flyout.md +9 -3
  15. package/docs/pages/enhancements/overview.md +2 -1
  16. package/docs/pages/enhancements/tabs.md +7 -2
  17. package/docs/pages/enhancements/tooltip.md +3 -1
  18. package/docs/pages/examples/overview.md +5 -3
  19. package/docs/pages/forms/color-swatches.md +131 -0
  20. package/docs/pages/forms/combobox.md +2 -2
  21. package/docs/pages/forms/controls.md +6 -3
  22. package/docs/pages/forms/joined-controls.md +6 -2
  23. package/docs/pages/forms/overview.md +1 -0
  24. package/docs/pages/forms/switch.md +4 -2
  25. package/docs/pages/foundations/theming.md +1 -1
  26. package/docs/pages/foundations/tokens.md +54 -23
  27. package/docs/pages/guides/design-tools.md +13 -7
  28. package/docs/pages/guides/introduction.md +1 -1
  29. package/docs/pages/layout/scroll-snap.md +4 -2
  30. package/package.json +5 -4
  31. package/reserved-classes.json +3 -0
  32. package/src/cli/actual-css.js +3 -3
  33. package/src/css/components/alert.css +65 -14
  34. package/src/css/components/app-nav.css +17 -9
  35. package/src/css/components/badge.css +23 -9
  36. package/src/css/components/button.css +17 -2
  37. package/src/css/components/close.css +6 -3
  38. package/src/css/components/index.css +1 -0
  39. package/src/css/components/list.css +18 -3
  40. package/src/css/components/navbar.css +5 -2
  41. package/src/css/components/segmented.css +186 -0
  42. package/src/css/components/steps.css +6 -3
  43. package/src/css/components/tab.css +1 -1
  44. package/src/css/core/focus.css +3 -0
  45. package/src/css/core/tokens.css +20 -4
  46. package/src/css/core/variants.css +16 -5
  47. package/src/css/forms/color-swatch.css +163 -0
  48. package/src/css/forms/form-actions.css +9 -0
  49. package/src/css/forms/index.css +1 -0
  50. package/src/css/forms/native.css +50 -20
  51. package/src/css/forms/switch.css +5 -2
  52. package/src/css/layout/scroll-snap.css +30 -0
  53. package/src/css/layout/topbar.css +10 -0
  54. package/src/js/enhance.js +14 -0
  55. package/src/js/floating-space.js +26 -0
  56. package/src/js/flyout.js +3 -1
  57. package/src/js/focus.js +16 -1
  58. package/src/js/parse-config.js +5 -2
  59. package/src/js/surface.js +10 -0
  60. package/src/js/tooltip.js +2 -2
  61. package/src/tooling/color.js +14 -1
  62. package/src/tooling/css-bundle.js +0 -5
  63. package/src/tooling/design-components.js +9 -5
  64. package/src/tooling/design-export.js +8 -0
  65. package/src/tooling/design-tokens.js +163 -43
  66. package/src/tooling/theme-contrast.js +151 -22
@@ -0,0 +1,186 @@
1
+ /*
2
+ * Segmented control — one compact choice among a few, on a shared track.
3
+ *
4
+ * Docs: docs/pages/components/segmented.md
5
+ *
6
+ * .segmented is the track, each .segment a direct child. The look is shared;
7
+ * the semantics stay with the markup, and the selected state is read from it:
8
+ * radios <label class="segment"><input type="radio"> :checked
9
+ * tabs <button class="segment" role="tab"> aria-selected="true"
10
+ * links <a class="segment"> aria-current="page"
11
+ * No script of its own: radios bring their arrow keys, a tablist takes
12
+ * data-enhance="tabs". Without :has() the radio anatomy loses its selected
13
+ * fill and focus ring, and the native radio stays visible to carry both.
14
+ *
15
+ * One row of equal shares (grid-auto-columns: 1fr), at least --control-size
16
+ * tall so it lines up with .btn and .input. Labels wrap instead of truncating;
17
+ * a word longer than its share breaks, so the control never overflows its
18
+ * container. A strip too long for one row is .tabs, which scrolls.
19
+ *
20
+ * Focus is drawn inside the segment, unlike other discrete actions
21
+ * (focus.css): an offset ring would cross the track's padding and cover the
22
+ * neighbouring segment.
23
+ *
24
+ * Public hooks:
25
+ * --segmented-bg
26
+ * --segmented-pad
27
+ * --segmented-radius
28
+ * --segmented-fg
29
+ * --segmented-selected-bg
30
+ * --segmented-selected-border
31
+ */
32
+ .segmented {
33
+ --segmented-bg: var(--surface-subtle);
34
+ --segmented-pad: var(--space-10);
35
+ --segmented-radius: var(--radius);
36
+ --segmented-fg: var(--text-muted);
37
+ --segmented-selected-bg: var(--surface-raised);
38
+ /* The edge is what separates the selected segment where --surface-raised
39
+ is darker than --surface-subtle (the default dark palette). */
40
+ --segmented-selected-border: var(--border);
41
+
42
+ display: inline-grid;
43
+ grid-auto-flow: column;
44
+ grid-auto-columns: 1fr;
45
+ gap: var(--segmented-pad);
46
+ /* why: margin and border also reset <fieldset>, the radio anatomy's
47
+ element. The border is transparent, not 0: forced colors paint it, which
48
+ outlines the track once its fill is gone. */
49
+ margin: 0;
50
+ padding: var(--segmented-pad);
51
+ border: var(--border-width) solid transparent;
52
+ border-radius: var(--segmented-radius);
53
+ background: var(--segmented-bg);
54
+ font-size: var(--control-font-size);
55
+ vertical-align: middle;
56
+ }
57
+
58
+ .segment {
59
+ position: relative;
60
+ display: flex;
61
+ align-items: center;
62
+ justify-content: center;
63
+ gap: 0.375em;
64
+ /* The track is --control-size tall through its segments, not through its
65
+ own min-block-size: a <fieldset> lays its content in an anonymous box
66
+ sized to the content, so a minimum on the track left the segments short.
67
+ A minimum, not a height: a wrapped label grows the whole row. */
68
+ min-block-size: calc(var(--control-size) - 2 * (var(--segmented-pad) + var(--border-width)));
69
+ padding: calc(var(--space-10) / 2) var(--space-30);
70
+ border: 0;
71
+ border-radius: calc(var(--segmented-radius) - var(--segmented-pad));
72
+ background: transparent;
73
+ color: var(--segmented-fg);
74
+ font: inherit;
75
+ /* Constant across states: a heavier selected label would resize its share. */
76
+ font-weight: var(--font-weight-medium);
77
+ line-height: 1.2;
78
+ text-align: center;
79
+ text-decoration: none;
80
+ /* why: also shrinks the min-content contribution, so 1fr keeps equal
81
+ shares under constraint instead of widening one around a long word. */
82
+ overflow-wrap: anywhere;
83
+ cursor: pointer;
84
+ transition:
85
+ background-color var(--duration),
86
+ color var(--duration),
87
+ box-shadow var(--duration);
88
+ }
89
+
90
+ .segment > input {
91
+ margin: 0;
92
+ }
93
+
94
+ /* The radio is visually hidden but stays focusable, submitted and arrow-key
95
+ navigable (sr-only pattern). Guarded: without :has() the segment cannot
96
+ mirror :checked, so the visible native radio is the fallback. */
97
+ @supports selector(:has(*)) {
98
+ .segment > input {
99
+ position: absolute;
100
+ inline-size: 1px;
101
+ block-size: 1px;
102
+ padding: 0;
103
+ margin: -1px;
104
+ overflow: hidden;
105
+ clip: rect(0, 0, 0, 0);
106
+ white-space: nowrap;
107
+ border: 0;
108
+ }
109
+ }
110
+
111
+ .segment:hover {
112
+ color: var(--text);
113
+ }
114
+
115
+ /* Separate rules from the :has() forms below: an engine without :has() would
116
+ discard a shared selector list, ARIA states included. */
117
+ .segment:is([aria-selected="true"], [aria-current="page"], [aria-current="true"]) {
118
+ background: var(--segmented-selected-bg);
119
+ color: var(--text);
120
+ box-shadow:
121
+ 0 0 0 var(--border-width) var(--segmented-selected-border),
122
+ var(--shadow);
123
+ }
124
+
125
+ .segment:has(> input:checked) {
126
+ background: var(--segmented-selected-bg);
127
+ color: var(--text);
128
+ box-shadow:
129
+ 0 0 0 var(--border-width) var(--segmented-selected-border),
130
+ var(--shadow);
131
+ }
132
+
133
+ .segment:focus-visible {
134
+ outline: var(--focus-ring-width) solid var(--focus);
135
+ outline-offset: calc(var(--focus-ring-width) * -1);
136
+ }
137
+
138
+ .segment:has(> input:focus-visible) {
139
+ outline: var(--focus-ring-width) solid var(--focus);
140
+ outline-offset: calc(var(--focus-ring-width) * -1);
141
+ }
142
+
143
+ .segment:is(:disabled, [aria-disabled="true"]) {
144
+ opacity: var(--disabled-opacity);
145
+ cursor: not-allowed;
146
+ }
147
+
148
+ .segment:has(> input:disabled) {
149
+ opacity: var(--disabled-opacity);
150
+ cursor: not-allowed;
151
+ }
152
+
153
+ /* Forced colors drop the raised fill and its edge. The selected segment
154
+ takes the system highlight — a background, so the outline stays the focus
155
+ channel alone. forced-color-adjust: none is required, not cosmetic: the UA
156
+ otherwise paints a Canvas backplate behind the label, which hid the text on
157
+ the highlight. Opting out also stops the forcing of the rest, so the shadow
158
+ is dropped here and the focus line takes the highlight's text color. */
159
+ @media (forced-colors: active) {
160
+ .segment:is([aria-selected="true"], [aria-current="page"], [aria-current="true"]) {
161
+ --focus: HighlightText;
162
+
163
+ forced-color-adjust: none;
164
+ background: Highlight;
165
+ color: HighlightText;
166
+ box-shadow: none;
167
+ }
168
+
169
+ .segment:has(> input:checked) {
170
+ --focus: HighlightText;
171
+
172
+ forced-color-adjust: none;
173
+ background: Highlight;
174
+ color: HighlightText;
175
+ box-shadow: none;
176
+ }
177
+
178
+ /* --disabled-opacity is 1 here (tokens.css): the dimming needs a color. */
179
+ .segment:is(:disabled, [aria-disabled="true"]) {
180
+ color: GrayText;
181
+ }
182
+
183
+ .segment:has(> input:disabled) {
184
+ color: GrayText;
185
+ }
186
+ }
@@ -137,11 +137,14 @@
137
137
 
138
138
  /* Complete owns the accented connector that reaches the current step, so the
139
139
  connector leaving current goes back to neutral. Declared after
140
- .step-complete at equal specificity: current wins on every channel. */
140
+ .step-complete at equal specificity: current wins on every channel. The
141
+ current marker is a regular surface, so its number and ring are selected
142
+ ink on a surface, not a selected fill; they are held to different ratios
143
+ (4.5:1 for the number, 3:1 for the ring) by report:theme-contrast. */
141
144
  .steps > [aria-current="step"] {
142
145
  --step-marker-bg: var(--surface-opaque);
143
- --step-marker-border: var(--state-selected);
144
- --step-marker-fg: var(--state-selected);
146
+ --step-marker-border: var(--state-selected-text, var(--state-selected));
147
+ --step-marker-fg: var(--state-selected-text, var(--state-selected));
145
148
  --step-line: var(--border);
146
149
 
147
150
  color: var(--text);
@@ -219,7 +219,7 @@
219
219
  stays inert. */
220
220
  .tab[aria-selected="true"],
221
221
  .tab:is([aria-current="page"], [aria-current="true"]) {
222
- color: var(--intent, var(--state-selected));
222
+ color: var(--intent, var(--state-selected-text, var(--state-selected)));
223
223
  }
224
224
 
225
225
  /* The selected indicator lives on a positioned pseudo so that only the active
@@ -18,6 +18,9 @@
18
18
  --surface-solid, so one token reads on the page, on a solid band and on
19
19
  any nested surface.
20
20
 
21
+ A .segment is an action but takes the inset ring: its track's padding is
22
+ narrower than an offset ring, which would cover the neighbouring segment.
23
+
21
24
  Documented exception: an invalid field focuses in --form-invalid-border
22
25
  (danger), so the error stays visible at the point of correction. The border
23
26
  carries the same hook; report:theme-contrast measures that line at 3:1
@@ -162,9 +162,20 @@
162
162
  [data-theme] {
163
163
  /* State tokens — semantic colors for selected/pressed/current and disabled.
164
164
  Components consume these instead of hard-wiring --primary/--text-subtle
165
- so forced-colors can override them in one place. */
165
+ so forced-colors can override them in one place. --state-selected is a
166
+ selected fill or accent, --state-selected-fg the ink on that fill.
167
+ --state-selected-text is selected ink on a regular surface (current nav
168
+ link, selected tab, current step number). It is reset to the
169
+ guaranteed-invalid value on every theme boundary, so a consumer reads its
170
+ local --state-selected through the fallback var(--state-selected-text,
171
+ var(--state-selected)): a theme that resolves it to var(--link) bakes that
172
+ reference where it is declared, so a nested island that does not set it
173
+ inherits an already-resolved value the local fallback can never reach. A
174
+ theme sets it when one color cannot be both a fill under its -fg and text
175
+ on the page, typically in a dark scheme. */
166
176
  --state-selected: var(--primary);
167
177
  --state-selected-fg: var(--primary-fg);
178
+ --state-selected-text: initial;
168
179
  --state-disabled: var(--text-subtle);
169
180
 
170
181
  /* Status dot ring — cuts the dot out of what it overlaps. */
@@ -173,10 +184,15 @@
173
184
 
174
185
  :root {
175
186
  /* Variant tuning — themes may adjust generated soft surfaces without
176
- replacing the component rules. */
187
+ replacing the component rules. --soft-bg-mix is the share of surface in a
188
+ soft fill: an opaque mix on the shared .soft, an opacity (100% minus it)
189
+ on the translucent badge, alert and soft-button tint. --soft-hover-alpha
190
+ is the extra intent share on hover. --soft-border-mix has no default: it
191
+ is a fallback-only hook whose absence lets each recipe keep its own rim
192
+ (a tinted rim on .soft, none on the tint), and a theme that sets it gives
193
+ both a rim of that weight. */
177
194
  --soft-bg-mix: 88%;
178
- --soft-border-mix: 65%;
179
- --soft-hover-alpha: 12%;
195
+ --soft-hover-alpha: 6%;
180
196
  /* Soft foreground: the share of raw intent in soft text. 100% keeps the
181
197
  pure intent color; lower values rebate it toward --text, which vivid or
182
198
  light intent palettes need to stay legible on their own soft surface.
@@ -34,6 +34,9 @@
34
34
  *
35
35
  * Intent list source of truth: intents.css. Components consume --intent* tokens
36
36
  * instead of re-listing intent classes.
37
+ *
38
+ * Public hooks:
39
+ * --soft-border-mix (fallback-only: a soft rim of that weight on every recipe)
37
40
  */
38
41
  .solid {
39
42
  --ui-bg: var(--intent, var(--neutral));
@@ -97,7 +100,7 @@
97
100
 
98
101
  /* Shared control scale. Wrappers are listed when their descendants form one
99
102
  component (field, choice-card, floating-field, input-icon, join, pagination,
100
- tabs, flyout). A local size declaration beats inherited density because it
103
+ tabs, segmented, flyout). A local size declaration beats inherited density because it
101
104
  is declared on the sized component itself. Guarded by check:sync
102
105
  (size-scale-participants): .sm and .lg must list the same components. */
103
106
  :where(
@@ -110,6 +113,7 @@
110
113
  .field,
111
114
  .choice,
112
115
  .choice-card,
116
+ .color-swatch,
113
117
  .input-icon,
114
118
  .floating-field,
115
119
  .otp,
@@ -117,6 +121,7 @@
117
121
  .pagination,
118
122
  .tabs,
119
123
  .tab,
124
+ .segmented,
120
125
  .flyout
121
126
  ).sm {
122
127
  --control-size: var(--control-size-sm);
@@ -137,6 +142,7 @@
137
142
  .field,
138
143
  .choice,
139
144
  .choice-card,
145
+ .color-swatch,
140
146
  .input-icon,
141
147
  .floating-field,
142
148
  .otp,
@@ -144,6 +150,7 @@
144
150
  .pagination,
145
151
  .tabs,
146
152
  .tab,
153
+ .segmented,
147
154
  .flyout
148
155
  ).lg {
149
156
  --control-size: var(--control-size-lg);
@@ -155,21 +162,25 @@
155
162
  }
156
163
 
157
164
  /* Modern color enhancement. Baseline variant values above remain usable
158
- everywhere. Interactive states live in the components that consume them. */
165
+ everywhere. Interactive states live in the components that consume them.
166
+ The shared .soft is opaque: it paints zones (.card, .navbar, .app-nav,
167
+ .chat-bubble) an author may lay over an image, where a translucent fill
168
+ would hand the text's contrast to whatever is behind it. The badge, alert
169
+ and soft button own a translucent tint instead (soft-tint). */
159
170
  @supports (color: color-mix(in oklch, red, white)) {
160
171
  .soft {
161
- /* @sync soft-recipe */
162
172
  --ui-bg: color-mix(in oklab, var(--surface) var(--soft-bg-mix), var(--intent, var(--neutral)));
163
173
  --ui-border: color-mix(
164
174
  in oklab,
165
- var(--surface) var(--soft-border-mix),
175
+ var(--surface) var(--soft-border-mix, 65%),
166
176
  var(--intent, var(--neutral))
167
177
  );
178
+ /* @sync soft-ink */
168
179
  --ui-fg: var(
169
180
  --intent-soft-fg,
170
181
  color-mix(in oklab, var(--text), var(--intent, var(--text)) var(--soft-fg-mix))
171
182
  );
172
- /* @sync soft-recipe */
183
+ /* @sync soft-ink */
173
184
  }
174
185
  }
175
186
 
@@ -0,0 +1,163 @@
1
+ /*
2
+ * Color swatch — a color chosen from a palette the application defines.
3
+ *
4
+ * Docs: docs/pages/forms/color-swatches.md
5
+ *
6
+ * A <label class="color-swatch"> wraps a native radio (one color) or checkbox
7
+ * (several) and paints --color-swatch-value. The same class on a <button> is
8
+ * the trigger of a palette flyout. The application owns the colors; Actual
9
+ * owns the shape, the selection mark and the focus ring.
10
+ *
11
+ * Two sizes: the visible swatch (::before) is the height of a .switch track, so
12
+ * a swatch and a switch in neighbouring rows match; the element is the hit
13
+ * area around it. The hit area reaches half the inherited --gap past the
14
+ * swatch, up to --control-size, and overflows its layout box: neighbours in a
15
+ * .cluster or .grid meet in the middle of the gap instead of overlapping, and
16
+ * rows sit one gap apart like columns. Edge, selection ring and focus ring all
17
+ * belong to the visible swatch.
18
+ *
19
+ * The color is arbitrary, so nothing drawn on it may assume a contrast: the
20
+ * selected mark is a double ring (--text outside, --surface-opaque inside) that
21
+ * reads on any fill, and a hairline edge keeps a near-white color apart from
22
+ * the page. ::before opts out of forced colors to keep the color, so it takes
23
+ * system colors itself there.
24
+ *
25
+ * Without :has() the native radio stays visible in the swatch and carries the
26
+ * state; the ring needs :has() to mirror it.
27
+ *
28
+ * Public hooks:
29
+ * --color-swatch-value (fallback-only: the color, set per swatch)
30
+ * --color-swatch-size
31
+ * --color-swatch-radius
32
+ */
33
+ .color-swatch {
34
+ /* The .switch track height (switch.css); a browser test holds them equal. */
35
+ --color-swatch-size: calc(var(--choice-control-size) + 0.125em);
36
+ --color-swatch-radius: var(--radius-full);
37
+ /* Internal: what the hit area adds to the swatch, half on each side. One gap
38
+ at most, so two neighbours never claim the same point — a wider reach let
39
+ the next swatch take a click on this one's center. */
40
+ --color-swatch-reach: clamp(0px, var(--control-size) - var(--color-swatch-size), var(--gap));
41
+
42
+ position: relative;
43
+ display: inline-grid;
44
+ place-items: center;
45
+ flex: none;
46
+ inline-size: calc(var(--color-swatch-size) + var(--color-swatch-reach));
47
+ block-size: calc(var(--color-swatch-size) + var(--color-swatch-reach));
48
+ /* Lay out at the visible size: gaps are measured from the swatch, not from
49
+ its invisible reach. */
50
+ margin: calc(var(--color-swatch-reach) / -2);
51
+ padding: 0;
52
+ /* No in-flow text, so the baseline would be the box's bottom edge and an
53
+ inline swatch would ride above the line (a flyout trigger beside its
54
+ label). A palette in .cluster or .grid ignores it. */
55
+ vertical-align: middle;
56
+ border: 0;
57
+ background: transparent;
58
+ color: inherit;
59
+ /* Consumed so .sm/.lg reach the em-sized swatch, as on .choice-card. */
60
+ font: inherit;
61
+ font-size: var(--control-font-size);
62
+ cursor: pointer;
63
+ }
64
+
65
+ .color-swatch::before {
66
+ content: "";
67
+ grid-area: 1 / 1;
68
+ inline-size: var(--color-swatch-size);
69
+ block-size: var(--color-swatch-size);
70
+ box-sizing: border-box;
71
+ border: var(--border-width) solid var(--border-control);
72
+ border-radius: var(--color-swatch-radius);
73
+ background: var(--color-swatch-value, transparent) padding-box;
74
+ /* The color is the content, as on .color (native.css). */
75
+ forced-color-adjust: none;
76
+ transition:
77
+ border-color var(--duration),
78
+ box-shadow var(--duration);
79
+ }
80
+
81
+ /* Shares the swatch's cell and paints after ::before, so a native radio left
82
+ visible sits on the color. */
83
+ .color-swatch > input {
84
+ grid-area: 1 / 1;
85
+ position: relative;
86
+ margin: 0;
87
+ }
88
+
89
+ /* Visually hidden but focusable, submitted and arrow-key navigable (sr-only
90
+ pattern). Guarded: without :has() the swatch cannot mirror :checked, so the
91
+ visible native radio is the fallback. */
92
+ @supports selector(:has(*)) {
93
+ .color-swatch > input {
94
+ position: absolute;
95
+ inline-size: 1px;
96
+ block-size: 1px;
97
+ padding: 0;
98
+ margin: -1px;
99
+ overflow: hidden;
100
+ clip: rect(0, 0, 0, 0);
101
+ white-space: nowrap;
102
+ border: 0;
103
+ }
104
+ }
105
+
106
+ .color-swatch:hover::before {
107
+ border-color: var(--text-subtle);
108
+ }
109
+
110
+ .color-swatch:has(> input:checked)::before {
111
+ box-shadow:
112
+ inset 0 0 0 2px var(--text),
113
+ inset 0 0 0 4px var(--surface-opaque);
114
+ }
115
+
116
+ /* The ring goes around the visible swatch, not the larger hit area. */
117
+ .color-swatch:focus-visible {
118
+ outline: none;
119
+ }
120
+
121
+ .color-swatch:focus-visible::before {
122
+ outline: var(--focus-ring-width) solid var(--focus);
123
+ outline-offset: var(--focus-outline-offset);
124
+ }
125
+
126
+ .color-swatch:has(> input:focus-visible)::before {
127
+ outline: var(--focus-ring-width) solid var(--focus);
128
+ outline-offset: var(--focus-outline-offset);
129
+ }
130
+
131
+ .color-swatch:disabled {
132
+ opacity: var(--disabled-opacity);
133
+ cursor: not-allowed;
134
+ }
135
+
136
+ .color-swatch:has(> input:disabled) {
137
+ opacity: var(--disabled-opacity);
138
+ cursor: not-allowed;
139
+ }
140
+
141
+ /* A disabled swatch keeps its color: --disabled-opacity is 1 here
142
+ (tokens.css), and the edge carries the state. */
143
+ @media (forced-colors: active) {
144
+ .color-swatch::before {
145
+ --focus: CanvasText;
146
+
147
+ border-color: CanvasText;
148
+ }
149
+
150
+ .color-swatch:has(> input:checked)::before {
151
+ box-shadow:
152
+ inset 0 0 0 2px CanvasText,
153
+ inset 0 0 0 4px Canvas;
154
+ }
155
+
156
+ .color-swatch:disabled::before {
157
+ border-color: GrayText;
158
+ }
159
+
160
+ .color-swatch:has(> input:disabled)::before {
161
+ border-color: GrayText;
162
+ }
163
+ }
@@ -57,3 +57,12 @@
57
57
  background: color-mix(in oklch, var(--surface) 88%, transparent);
58
58
  }
59
59
  }
60
+
61
+ /* Same reduce override as .topbar (src/css/layout/topbar.css). */
62
+ @media (prefers-reduced-transparency: reduce) {
63
+ .form-actions.sticky {
64
+ background: var(--surface);
65
+ -webkit-backdrop-filter: none;
66
+ backdrop-filter: none;
67
+ }
68
+ }
@@ -8,6 +8,7 @@
8
8
  @import "./switch.css";
9
9
  @import "./range.css";
10
10
  @import "./choice-card.css";
11
+ @import "./color-swatch.css";
11
12
  @import "./custom-select.css";
12
13
  @import "./floating-field.css";
13
14
  @import "./otp.css";
@@ -7,6 +7,9 @@
7
7
  *
8
8
  * Range has its own custom-styled file (range.css) so the focus ring can
9
9
  * target the thumb pseudo-element rather than the rectangular CSS box.
10
+ *
11
+ * Public hooks:
12
+ * --color-size
10
13
  */
11
14
  .file {
12
15
  color: var(--text-muted);
@@ -52,16 +55,24 @@
52
55
  outline-offset: var(--focus-outline-offset);
53
56
  }
54
57
 
55
- /* Native color picker — keep the browser's dialog and selected-color UI. The
56
- swatch fills almost the whole control so it reads as a color, not as a tiny
57
- square inside a button; the 1px padding keeps a hair of --surface around it. */
58
+ /* Native color picker — keep the browser's dialog and selected-color UI. Same
59
+ geometry as .color-swatch: a --control-size hit area around a round swatch
60
+ the height of a .switch track, so a picker, a palette and a switch compose.
61
+ The input is the hit area; its padding leaves the swatch, which carries the
62
+ edge and the focus ring. Vendor pseudos cannot share a rule. */
58
63
  .color {
59
- inline-size: var(--control-size);
60
- block-size: var(--control-size);
61
- padding: var(--border-width);
62
- border: var(--border-width) solid var(--border-control);
63
- border-radius: var(--radius);
64
- background: var(--surface);
64
+ --color-size: calc(var(--choice-control-size) + 0.125em);
65
+
66
+ inline-size: max(var(--control-size), var(--color-size));
67
+ block-size: max(var(--control-size), var(--color-size));
68
+ padding: calc((max(var(--control-size), var(--color-size)) - var(--color-size)) / 2);
69
+ /* Lay out at the visible width, as .color-swatch does: the hit area
70
+ overflows on both sides so a label gap is measured from the swatch. */
71
+ margin: 0 calc((var(--color-size) - max(var(--control-size), var(--color-size))) / 2);
72
+ border: 0;
73
+ background: transparent;
74
+ /* Consumed so .sm/.lg reach the em-sized swatch. */
75
+ font-size: var(--control-font-size);
65
76
  cursor: pointer;
66
77
  }
67
78
 
@@ -70,13 +81,21 @@
70
81
  }
71
82
 
72
83
  .color::-webkit-color-swatch {
73
- border: 0;
74
- border-radius: calc(var(--radius) - var(--border-width));
84
+ border: var(--border-width) solid var(--border-control);
85
+ border-radius: var(--radius-full);
75
86
  }
76
87
 
77
88
  .color::-moz-color-swatch {
78
- border: 0;
79
- border-radius: calc(var(--radius) - var(--border-width));
89
+ border: var(--border-width) solid var(--border-control);
90
+ border-radius: var(--radius-full);
91
+ }
92
+
93
+ .color:hover::-webkit-color-swatch {
94
+ border-color: var(--text-subtle);
95
+ }
96
+
97
+ .color:hover::-moz-color-swatch {
98
+ border-color: var(--text-subtle);
80
99
  }
81
100
 
82
101
  .color:disabled {
@@ -84,24 +103,35 @@
84
103
  opacity: var(--disabled-opacity);
85
104
  }
86
105
 
106
+ /* The ring goes around the visible swatch, not the larger hit area. */
87
107
  .color:focus-visible {
108
+ outline: none;
109
+ }
110
+
111
+ .color:focus-visible::-webkit-color-swatch {
88
112
  outline: var(--focus-ring-width) solid var(--focus);
89
113
  outline-offset: var(--focus-outline-offset);
90
114
  }
91
115
 
92
- @media (forced-colors: active) {
93
- .color {
94
- border-color: ButtonText;
95
- background: ButtonFace;
96
- }
116
+ .color:focus-visible::-moz-color-swatch {
117
+ outline: var(--focus-ring-width) solid var(--focus);
118
+ outline-offset: var(--focus-outline-offset);
119
+ }
97
120
 
98
- /* The swatch color is content, so preserve it without disabling the host's
99
- native forced-color focus treatment. Vendor pseudos cannot share a rule. */
121
+ @media (forced-colors: active) {
122
+ /* The swatch color is content, so the swatch opts out of forcing and takes
123
+ system colors for its edge and ring itself. */
100
124
  .color::-webkit-color-swatch {
125
+ --focus: CanvasText;
126
+
101
127
  forced-color-adjust: none;
128
+ border-color: CanvasText;
102
129
  }
103
130
 
104
131
  .color::-moz-color-swatch {
132
+ --focus: CanvasText;
133
+
105
134
  forced-color-adjust: none;
135
+ border-color: CanvasText;
106
136
  }
107
137
  }
@@ -14,6 +14,7 @@
14
14
  * --switch-block-size
15
15
  * --switch-knob-margin
16
16
  * --switch-knob-size
17
+ * --switch-knob-inline-size
17
18
  * --switch-travel
18
19
  *
19
20
  * The track is sized from --choice-control-size (tokens.css), not
@@ -47,9 +48,11 @@
47
48
  2 *
48
49
  var(--switch-knob-margin)
49
50
  );
51
+ /* A wider knob is a capsule; the travel follows its width. */
52
+ --switch-knob-inline-size: var(--switch-knob-size);
50
53
  --switch-travel: calc(
51
54
  var(--switch-width) -
52
- var(--switch-knob-size) -
55
+ var(--switch-knob-inline-size) -
53
56
  2 *
54
57
  var(--border-width) -
55
58
  2 *
@@ -85,7 +88,7 @@
85
88
  position: absolute;
86
89
  inset-block-start: var(--switch-knob-margin);
87
90
  inset-inline-start: var(--switch-knob-margin);
88
- inline-size: var(--switch-knob-size);
91
+ inline-size: var(--switch-knob-inline-size);
89
92
  block-size: var(--switch-knob-size);
90
93
  border-radius: var(--radius-full);
91
94
  background: var(--switch-knob);