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.
- package/CHANGELOG.md +53 -0
- package/README.md +4 -4
- package/components.json +116 -10
- package/dist/actual.full.css +648 -77
- package/dist/actual.full.js +1 -1
- package/dist/actual.full.min.css +1 -1
- package/docs/navigation.json +2 -0
- package/docs/pages/components/alert.md +12 -2
- package/docs/pages/components/button.md +3 -2
- package/docs/pages/components/list.md +6 -3
- package/docs/pages/components/segmented.md +83 -0
- package/docs/pages/components/steps.md +4 -2
- package/docs/pages/enhancements/flyout.md +5 -0
- package/docs/pages/enhancements/tabs.md +7 -2
- package/docs/pages/examples/overview.md +3 -1
- package/docs/pages/forms/color-swatches.md +131 -0
- package/docs/pages/forms/combobox.md +2 -2
- package/docs/pages/forms/controls.md +6 -3
- package/docs/pages/forms/joined-controls.md +6 -2
- package/docs/pages/forms/overview.md +1 -0
- package/docs/pages/forms/switch.md +4 -2
- package/docs/pages/foundations/theming.md +1 -1
- package/docs/pages/foundations/tokens.md +54 -23
- package/docs/pages/guides/design-tools.md +13 -7
- package/docs/pages/guides/introduction.md +1 -1
- package/docs/pages/layout/scroll-snap.md +4 -2
- package/package.json +2 -2
- package/reserved-classes.json +3 -0
- package/src/cli/actual-css.js +3 -3
- package/src/css/components/alert.css +65 -14
- package/src/css/components/app-nav.css +17 -9
- package/src/css/components/badge.css +23 -9
- package/src/css/components/button.css +17 -2
- package/src/css/components/close.css +6 -3
- package/src/css/components/index.css +1 -0
- package/src/css/components/list.css +18 -3
- package/src/css/components/navbar.css +5 -2
- package/src/css/components/segmented.css +186 -0
- package/src/css/components/steps.css +6 -3
- package/src/css/components/tab.css +1 -1
- package/src/css/core/focus.css +3 -0
- package/src/css/core/tokens.css +20 -4
- package/src/css/core/variants.css +16 -5
- package/src/css/forms/color-swatch.css +159 -0
- package/src/css/forms/form-actions.css +9 -0
- package/src/css/forms/index.css +1 -0
- package/src/css/forms/native.css +50 -20
- package/src/css/forms/switch.css +5 -2
- package/src/css/layout/scroll-snap.css +30 -0
- package/src/css/layout/topbar.css +10 -0
- package/src/js/focus.js +16 -1
- package/src/js/surface.js +10 -0
- package/src/tooling/color.js +14 -1
- package/src/tooling/css-bundle.js +0 -5
- package/src/tooling/design-components.js +9 -5
- package/src/tooling/design-export.js +8 -0
- package/src/tooling/design-tokens.js +163 -43
- package/src/tooling/theme-contrast.js +151 -22
package/src/css/core/tokens.css
CHANGED
|
@@ -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-
|
|
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-
|
|
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
|
+
}
|
package/src/css/forms/index.css
CHANGED
package/src/css/forms/native.css
CHANGED
|
@@ -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.
|
|
56
|
-
|
|
57
|
-
|
|
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
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
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:
|
|
74
|
-
border-radius:
|
|
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:
|
|
79
|
-
border-radius:
|
|
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
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
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
|
-
|
|
99
|
-
|
|
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
|
}
|
package/src/css/forms/switch.css
CHANGED
|
@@ -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
|
package/src/tooling/color.js
CHANGED
|
@@ -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
|
-
|
|
219
|
-
|
|
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:
|
|
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
|
};
|