actual-css 0.13.0 → 0.14.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 (58) hide show
  1. package/CHANGELOG.md +53 -0
  2. package/README.md +4 -4
  3. package/components.json +116 -10
  4. package/dist/actual.full.css +648 -77
  5. package/dist/actual.full.js +1 -1
  6. package/dist/actual.full.min.css +1 -1
  7. package/docs/navigation.json +2 -0
  8. package/docs/pages/components/alert.md +12 -2
  9. package/docs/pages/components/button.md +3 -2
  10. package/docs/pages/components/list.md +6 -3
  11. package/docs/pages/components/segmented.md +83 -0
  12. package/docs/pages/components/steps.md +4 -2
  13. package/docs/pages/enhancements/flyout.md +5 -0
  14. package/docs/pages/enhancements/tabs.md +7 -2
  15. package/docs/pages/examples/overview.md +3 -1
  16. package/docs/pages/forms/color-swatches.md +131 -0
  17. package/docs/pages/forms/combobox.md +2 -2
  18. package/docs/pages/forms/controls.md +6 -3
  19. package/docs/pages/forms/joined-controls.md +6 -2
  20. package/docs/pages/forms/overview.md +1 -0
  21. package/docs/pages/forms/switch.md +4 -2
  22. package/docs/pages/foundations/theming.md +1 -1
  23. package/docs/pages/foundations/tokens.md +54 -23
  24. package/docs/pages/guides/design-tools.md +13 -7
  25. package/docs/pages/guides/introduction.md +1 -1
  26. package/docs/pages/layout/scroll-snap.md +4 -2
  27. package/package.json +2 -2
  28. package/reserved-classes.json +3 -0
  29. package/src/cli/actual-css.js +3 -3
  30. package/src/css/components/alert.css +65 -14
  31. package/src/css/components/app-nav.css +17 -9
  32. package/src/css/components/badge.css +23 -9
  33. package/src/css/components/button.css +17 -2
  34. package/src/css/components/close.css +6 -3
  35. package/src/css/components/index.css +1 -0
  36. package/src/css/components/list.css +18 -3
  37. package/src/css/components/navbar.css +5 -2
  38. package/src/css/components/segmented.css +186 -0
  39. package/src/css/components/steps.css +6 -3
  40. package/src/css/components/tab.css +1 -1
  41. package/src/css/core/focus.css +3 -0
  42. package/src/css/core/tokens.css +20 -4
  43. package/src/css/core/variants.css +16 -5
  44. package/src/css/forms/color-swatch.css +159 -0
  45. package/src/css/forms/form-actions.css +9 -0
  46. package/src/css/forms/index.css +1 -0
  47. package/src/css/forms/native.css +50 -20
  48. package/src/css/forms/switch.css +5 -2
  49. package/src/css/layout/scroll-snap.css +30 -0
  50. package/src/css/layout/topbar.css +10 -0
  51. package/src/js/focus.js +16 -1
  52. package/src/js/surface.js +10 -0
  53. package/src/tooling/color.js +14 -1
  54. package/src/tooling/css-bundle.js +0 -5
  55. package/src/tooling/design-components.js +9 -5
  56. package/src/tooling/design-export.js +8 -0
  57. package/src/tooling/design-tokens.js +163 -43
  58. package/src/tooling/theme-contrast.js +151 -22
@@ -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,159 @@
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
+ border: 0;
53
+ background: transparent;
54
+ color: inherit;
55
+ /* Consumed so .sm/.lg reach the em-sized swatch, as on .choice-card. */
56
+ font: inherit;
57
+ font-size: var(--control-font-size);
58
+ cursor: pointer;
59
+ }
60
+
61
+ .color-swatch::before {
62
+ content: "";
63
+ grid-area: 1 / 1;
64
+ inline-size: var(--color-swatch-size);
65
+ block-size: var(--color-swatch-size);
66
+ box-sizing: border-box;
67
+ border: var(--border-width) solid var(--border-control);
68
+ border-radius: var(--color-swatch-radius);
69
+ background: var(--color-swatch-value, transparent) padding-box;
70
+ /* The color is the content, as on .color (native.css). */
71
+ forced-color-adjust: none;
72
+ transition:
73
+ border-color var(--duration),
74
+ box-shadow var(--duration);
75
+ }
76
+
77
+ /* Shares the swatch's cell and paints after ::before, so a native radio left
78
+ visible sits on the color. */
79
+ .color-swatch > input {
80
+ grid-area: 1 / 1;
81
+ position: relative;
82
+ margin: 0;
83
+ }
84
+
85
+ /* Visually hidden but focusable, submitted and arrow-key navigable (sr-only
86
+ pattern). Guarded: without :has() the swatch cannot mirror :checked, so the
87
+ visible native radio is the fallback. */
88
+ @supports selector(:has(*)) {
89
+ .color-swatch > input {
90
+ position: absolute;
91
+ inline-size: 1px;
92
+ block-size: 1px;
93
+ padding: 0;
94
+ margin: -1px;
95
+ overflow: hidden;
96
+ clip: rect(0, 0, 0, 0);
97
+ white-space: nowrap;
98
+ border: 0;
99
+ }
100
+ }
101
+
102
+ .color-swatch:hover::before {
103
+ border-color: var(--text-subtle);
104
+ }
105
+
106
+ .color-swatch:has(> input:checked)::before {
107
+ box-shadow:
108
+ inset 0 0 0 2px var(--text),
109
+ inset 0 0 0 4px var(--surface-opaque);
110
+ }
111
+
112
+ /* The ring goes around the visible swatch, not the larger hit area. */
113
+ .color-swatch:focus-visible {
114
+ outline: none;
115
+ }
116
+
117
+ .color-swatch:focus-visible::before {
118
+ outline: var(--focus-ring-width) solid var(--focus);
119
+ outline-offset: var(--focus-outline-offset);
120
+ }
121
+
122
+ .color-swatch:has(> input:focus-visible)::before {
123
+ outline: var(--focus-ring-width) solid var(--focus);
124
+ outline-offset: var(--focus-outline-offset);
125
+ }
126
+
127
+ .color-swatch:disabled {
128
+ opacity: var(--disabled-opacity);
129
+ cursor: not-allowed;
130
+ }
131
+
132
+ .color-swatch:has(> input:disabled) {
133
+ opacity: var(--disabled-opacity);
134
+ cursor: not-allowed;
135
+ }
136
+
137
+ /* A disabled swatch keeps its color: --disabled-opacity is 1 here
138
+ (tokens.css), and the edge carries the state. */
139
+ @media (forced-colors: active) {
140
+ .color-swatch::before {
141
+ --focus: CanvasText;
142
+
143
+ border-color: CanvasText;
144
+ }
145
+
146
+ .color-swatch:has(> input:checked)::before {
147
+ box-shadow:
148
+ inset 0 0 0 2px CanvasText,
149
+ inset 0 0 0 4px Canvas;
150
+ }
151
+
152
+ .color-swatch:disabled::before {
153
+ border-color: GrayText;
154
+ }
155
+
156
+ .color-swatch:has(> input:disabled)::before {
157
+ border-color: GrayText;
158
+ }
159
+ }
@@ -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);
@@ -13,16 +13,36 @@
13
13
  * --scroll-snap-align
14
14
  * --scroll-snap-item-size
15
15
  * --scroll-snap-padding
16
+ *
17
+ * Internal:
18
+ * --scroll-snap-focus-bleed
16
19
  */
17
20
  .scroll-snap {
18
21
  --scroll-snap-gap: var(--gap);
19
22
  --scroll-snap-align: start;
20
23
  --scroll-snap-item-size: auto;
24
+ /* Declared here, not as a :root alias: an inherited alias arrives already
25
+ computed, so a --focus-ring-width override on the rail would not reach it. */
26
+ --scroll-snap-focus-bleed: calc(var(--focus-outline-offset) + var(--focus-ring-width));
21
27
 
22
28
  display: flex;
23
29
  flex-direction: row;
24
30
  gap: var(--scroll-snap-gap);
31
+ /* Both axes clip: `auto` on one computes the other to `auto`, so a ring on
32
+ the top or bottom item would be lost too. Every side reserves the ring's
33
+ own width; the block reserve returns to the flow through a negative
34
+ margin, while the inline reserve stays as the rail's focus gutter.
35
+ Bleeding the inline reserve outward instead would make the border box
36
+ `containing block + 2 * reserve` wide, which at the viewport edge grows
37
+ the page: a rail cannot be flush with arbitrary surrounding content and
38
+ free of page overflow at once. */
39
+ margin-block: calc(var(--scroll-snap-focus-bleed) * -1);
40
+ padding-block: var(--scroll-snap-focus-bleed);
41
+ padding-inline: var(--scroll-snap-focus-bleed);
25
42
  overflow-x: auto;
43
+ /* One scroll axis, stated: a fractional content height could otherwise raise
44
+ a phantom vertical scrollbar under one font or zoom step. */
45
+ overflow-y: hidden;
26
46
  }
27
47
 
28
48
  .scroll-snap > * {
@@ -32,7 +52,17 @@
32
52
  @supports (scroll-snap-type: x mandatory) {
33
53
  .scroll-snap {
34
54
  overscroll-behavior-inline: contain;
55
+ /* Fallback only, never the ring's protector: padding-inline above already
56
+ guarantees paint. A unitless `0` drops the clamp below but keeps this
57
+ declaration, so the worst case is a snapped item flush with the gutter,
58
+ not a clipped ring. */
35
59
  scroll-padding-inline: var(--scroll-snap-padding, var(--scroll-snap-gap));
60
+ /* A conforming <length> stays at least the focus reserve, so scroll and
61
+ snap stop with the gutter's air. */
62
+ scroll-padding-inline: max(
63
+ var(--scroll-snap-padding, var(--scroll-snap-gap)),
64
+ var(--scroll-snap-focus-bleed)
65
+ );
36
66
  /* x then inline: an engine that lacks the logical keyword keeps x.
37
67
  proximity is the default, so a short rail cannot trap the pointer. */
38
68
  scroll-snap-type: x proximity;
@@ -45,3 +45,13 @@
45
45
  -webkit-backdrop-filter: blur(0.75rem);
46
46
  }
47
47
  }
48
+
49
+ /* A reduce override, not a no-preference gate: a browser without the feature
50
+ evaluates it false and keeps the frosted bar instead of losing it. */
51
+ @media (prefers-reduced-transparency: reduce) {
52
+ .topbar {
53
+ background: var(--surface);
54
+ backdrop-filter: none;
55
+ -webkit-backdrop-filter: none;
56
+ }
57
+ }
package/src/js/focus.js CHANGED
@@ -44,8 +44,23 @@ export function getFocusable(root) {
44
44
  return [...root.querySelectorAll(FOCUSABLE_SELECTOR)].filter(isFocusable);
45
45
  }
46
46
 
47
+ /* Tab reaches a radio group at its checked radio, or at its first one when none
48
+ is checked; the other radios are arrow-key stops only. Entering a panel on
49
+ an unchecked radio put focus on a value the user had not picked. */
50
+ function isRadioTabStop(el) {
51
+ if (el.type !== "radio" || el.checked || !el.name) return true;
52
+ // form.elements also holds controls associated through form="…".
53
+ const candidates = el.form
54
+ ? [...el.form.elements]
55
+ : [...el.getRootNode().querySelectorAll('input[type="radio"]')];
56
+ return !candidates.some(
57
+ (radio) =>
58
+ radio.type === "radio" && radio.name === el.name && radio.form === el.form && radio.checked,
59
+ );
60
+ }
61
+
47
62
  export function focusFirstDescendant(root) {
48
- const target = getFocusable(root)[0];
63
+ const target = getFocusable(root).filter(isRadioTabStop)[0];
49
64
  target?.focus();
50
65
  return !!target;
51
66
  }
package/src/js/surface.js CHANGED
@@ -386,8 +386,17 @@ export function disconnectSurface(menu) {
386
386
  }
387
387
  }
388
388
 
389
+ /* Arrow keys in a radio group check the next radio by dispatching a click
390
+ (detail 0, no pointer). That click is navigation, not a pick: closing on it
391
+ shut a palette on the first arrow press, before the user could reach the
392
+ color they wanted. Space dispatches the same click and is left alone too. */
393
+ function isKeyboardRadioMove(e) {
394
+ return e.detail === 0 && e.target?.matches?.('input[type="radio"]') === true;
395
+ }
396
+
389
397
  function onDocumentClick(e) {
390
398
  const path = e.composedPath();
399
+ const radioMove = isKeyboardRadioMove(e);
391
400
 
392
401
  for (const menu of openSurfaces) {
393
402
  if (menu.ownerDocument !== e.currentTarget) continue;
@@ -396,6 +405,7 @@ function onDocumentClick(e) {
396
405
 
397
406
  const menuIndex = path.indexOf(menu);
398
407
  const isInside = menuIndex >= 0;
408
+ if (isInside && radioMove) continue;
399
409
  const hasCloseTrigger =
400
410
  isInside &&
401
411
  path
@@ -10,7 +10,8 @@
10
10
  * `const rgba = ${RASTERIZE};`. getComputedStyle returns color-mix() and
11
11
  * light-dark() results as oklab() or color(), so a 1x1 canvas turns any
12
12
  * computed color into sRGB bytes. Alpha is kept so contrast() can refuse a
13
- * translucent color instead of measuring it as if it were opaque.
13
+ * translucent color instead of measuring it as if it were opaque; composite()
14
+ * resolves it over its backdrop first.
14
15
  */
15
16
  export const RASTERIZE = `(() => {
16
17
  const canvas = Object.assign(document.createElement("canvas"), { width: 1, height: 1 });
@@ -46,6 +47,18 @@ export function contrast(a, b) {
46
47
  return (hi + 0.05) / (lo + 0.05);
47
48
  }
48
49
 
50
+ /* `top` painted over `base`, the way the page composites a fill: source-over
51
+ on gamma-encoded sRGB bytes. The result is opaque when `base` is, so a
52
+ translucent soft fill becomes measurable once its backdrop is known. */
53
+ export function composite(top, base) {
54
+ const a = (top[3] ?? 255) / 255;
55
+ const b = (base[3] ?? 255) / 255;
56
+ const alpha = a + b * (1 - a);
57
+ if (alpha === 0) return [0, 0, 0, 0];
58
+ const channel = (i) => Math.round((top[i] * a + base[i] * b * (1 - a)) / alpha);
59
+ return [channel(0), channel(1), channel(2), Math.round(alpha * 255)];
60
+ }
61
+
49
62
  /* OKLCH of an sRGB color: L 0-1, C chroma, h degrees. */
50
63
  export function oklch([r, g, b]) {
51
64
  const [lr, lg, lb] = [r, g, b].map(toLinear);
@@ -298,11 +298,6 @@ export function bundledCssIssues(css) {
298
298
  const issues = [];
299
299
 
300
300
  if (css.includes("@import")) issues.push("contains unresolved @import");
301
- if (css.includes("--lightningcss")) issues.push("contains --lightningcss-* variables");
302
- if (/color-mix\(in srgb/.test(css)) issues.push("contains color-mix(in srgb, ...)");
303
- if (/@media[^{]*\(\s*width\s*[<>]=/.test(css)) {
304
- issues.push("contains transpiled media query range syntax");
305
- }
306
301
 
307
302
  return issues;
308
303
  }
@@ -15,7 +15,7 @@
15
15
  * padding, height, type — about 1,800 bindings over 221 variants) froze
16
16
  * Penpot, and a theme's own lengths already arrive through the measurement.
17
17
  */
18
- import { hexOf, inPage, parseSrgb } from "./design-tokens.js";
18
+ import { hexOf, hexRgbOf, inPage, parseSrgb } from "./design-tokens.js";
19
19
 
20
20
  /* Every list starts with its default: the first combination is the one a
21
21
  design tool picks for a new instance. */
@@ -214,11 +214,15 @@ export async function measureComponents({ css, resolved, linked, components = CO
214
214
  modes.forEach((mode, m) => {
215
215
  const known = recipes[mode][token];
216
216
  // A recipe name carries no size and no component: if two of them
217
- // ever paint different colors, the name has to grow a dimension.
218
- if (known && known.hex !== hexes[m]) {
219
- throw new Error(`Recipe ${token} paints ${known.hex} and ${hexes[m]} in ${mode}`);
217
+ // ever paint different colors, the name has to grow a dimension. The
218
+ // comparison is on the CSS form (alpha included), the stored hex is the
219
+ // 6-digit DTCG form.
220
+ if (known && hexOf(known.color) !== hexes[m]) {
221
+ throw new Error(
222
+ `Recipe ${token} paints ${hexOf(known.color)} and ${hexes[m]} in ${mode}`,
223
+ );
220
224
  }
221
- recipes[mode][token] = { color: colors[m], hex: hexes[m] };
225
+ recipes[mode][token] = { color: colors[m], hex: hexRgbOf(colors[m]) };
222
226
  });
223
227
  }
224
228
  const [first] = colors;
@@ -14,6 +14,7 @@ import {
14
14
  linkTokens,
15
15
  resolveTokens,
16
16
  themeNameOf,
17
+ toDtcg,
17
18
  toFigma,
18
19
  toPenpot,
19
20
  } from "./design-tokens.js";
@@ -24,6 +25,7 @@ const PLUGIN = fileURLToPath(new URL("./penpot-plugin/", import.meta.url));
24
25
  /*
25
26
  * Writes, under `out`:
26
27
  * figma/foundations.tokens.json, figma/<scheme>.tokens.json
28
+ * dtcg/foundations.tokens.json, dtcg/<scheme>.tokens.json
27
29
  * penpot/tokens.json
28
30
  * penpot/plugin/ (manifest.json, plugin.js, ui.html, spec.json)
29
31
  * Without `themeFile`, exports the default theme. `name` overrides the
@@ -49,6 +51,12 @@ export async function exportDesign({ themeFile, name, out }) {
49
51
  doc,
50
52
  ]),
51
53
  ),
54
+ ...Object.fromEntries(
55
+ Object.entries(toDtcg(linked, recipes)).map(([file, doc]) => [
56
+ `dtcg/${file}.tokens.json`,
57
+ doc,
58
+ ]),
59
+ ),
52
60
  "penpot/tokens.json": toPenpot(linked, recipes),
53
61
  "penpot/plugin/spec.json": spec,
54
62
  };