@halogen-ui/tokens 0.1.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/LICENSE.md +102 -0
- package/README.md +43 -0
- package/dist/CONTRAST-REPORT.md +511 -0
- package/dist/figma.json +1932 -0
- package/dist/fonts/Geist-Variable.woff2 +0 -0
- package/dist/fonts/GeistMono-Variable.woff2 +0 -0
- package/dist/fonts/LICENSE-Geist-OFL-1.1.txt +92 -0
- package/dist/fonts.css +24 -0
- package/dist/halogen.tokens.json +2061 -0
- package/dist/theme.css +859 -0
- package/dist/token-review.html +902 -0
- package/dist/tokens.css +782 -0
- package/dist/tokens.d.ts +535 -0
- package/dist/tokens.js +532 -0
- package/dist/tokens.ts +540 -0
- package/dist/tw-classgroups.json +351 -0
- package/fonts/Geist-Variable.woff2 +0 -0
- package/fonts/GeistMono-Variable.woff2 +0 -0
- package/fonts/LICENSE-Geist-OFL-1.1.txt +92 -0
- package/package.json +66 -0
- package/src/color/oklch.ts +306 -0
- package/src/contrast-pairs.ts +473 -0
- package/src/namespaces.ts +59 -0
- package/src/primitives/color.ts +404 -0
- package/src/primitives/elevation.ts +38 -0
- package/src/primitives/motion.ts +129 -0
- package/src/primitives/scale.ts +163 -0
- package/src/primitives/typography.ts +184 -0
- package/src/semantic/index.ts +415 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Halogen — color primitives (layer 1 of 2).
|
|
3
|
+
*
|
|
4
|
+
* Authored as OKLCH specifications and *derived* into sRGB, rather than
|
|
5
|
+
* authored as hex. Three reasons this matters:
|
|
6
|
+
*
|
|
7
|
+
* 1. Perceptual spacing. Even steps in OKLCH L are even steps to the eye;
|
|
8
|
+
* even steps in hex are not.
|
|
9
|
+
* 2. Contrast solving. Several values here cannot be chosen by taste — they
|
|
10
|
+
* are the answer to "the most vivid step that still clears 4.5:1". Those
|
|
11
|
+
* are solved at build time, not guessed.
|
|
12
|
+
* 3. Auditability. A reviewer can see the intent (lightness, hue, how much of
|
|
13
|
+
* the available chroma) instead of an opaque six-digit number.
|
|
14
|
+
*
|
|
15
|
+
* Nothing in this file is a semantic decision. No token here knows what a
|
|
16
|
+
* "button" or an "error" is — that is `src/semantic/`. This layer only answers
|
|
17
|
+
* "what colors exist".
|
|
18
|
+
*/
|
|
19
|
+
import { hexToOklch, type Oklch } from '../color/oklch.js';
|
|
20
|
+
|
|
21
|
+
/* -------------------------------------------------------------------------- */
|
|
22
|
+
/* Neutrals */
|
|
23
|
+
/* -------------------------------------------------------------------------- */
|
|
24
|
+
|
|
25
|
+
/**
|
|
26
|
+
* The neutral ramp is warmed to hue 75° at very low chroma — imperceptible as
|
|
27
|
+
* color, perceptible as warmth. This is what stops a near-black UI reading as
|
|
28
|
+
* blue-gray "developer tool" and keeps the grays in the same world as the
|
|
29
|
+
* orange. Chroma rises slightly toward the dark end, where the warmth does the
|
|
30
|
+
* most work, and falls to zero at pure white.
|
|
31
|
+
*
|
|
32
|
+
* L values are anchored to the brand brief. Three were adjusted to satisfy the
|
|
33
|
+
* ">= 0.04 L between adjacent surfaces" rule from the aesthetic direction; every
|
|
34
|
+
* adjustment is listed in `BRIEF_ANCHORS` below and reported in the build's
|
|
35
|
+
* contrast report rather than being made silently.
|
|
36
|
+
*/
|
|
37
|
+
export const NEUTRAL_HUE = 75;
|
|
38
|
+
|
|
39
|
+
export const neutral: Record<string, Oklch> = {
|
|
40
|
+
0: { l: 1.0, c: 0, h: NEUTRAL_HUE },
|
|
41
|
+
25: { l: 0.978, c: 0.002, h: NEUTRAL_HUE },
|
|
42
|
+
50: { l: 0.952, c: 0.003, h: NEUTRAL_HUE },
|
|
43
|
+
100: { l: 0.915, c: 0.004, h: NEUTRAL_HUE },
|
|
44
|
+
200: { l: 0.862, c: 0.004, h: NEUTRAL_HUE },
|
|
45
|
+
300: { l: 0.786, c: 0.004, h: NEUTRAL_HUE },
|
|
46
|
+
400: { l: 0.724, c: 0.004, h: NEUTRAL_HUE },
|
|
47
|
+
450: { l: 0.668, c: 0.004, h: NEUTRAL_HUE },
|
|
48
|
+
500: { l: 0.634, c: 0.004, h: NEUTRAL_HUE },
|
|
49
|
+
/**
|
|
50
|
+
* The control border, in BOTH themes.
|
|
51
|
+
*
|
|
52
|
+
* Solved rather than picked: this is the softest a control boundary can be
|
|
53
|
+
* while still clearing WCAG 1.4.11's 3:1 against every surface a control sits
|
|
54
|
+
* on — 3.5:1 on the dark fill, 4.0:1 on the light one. It is deliberately the
|
|
55
|
+
* same value in both themes, because a mid gray is the one border that reads
|
|
56
|
+
* as quiet against a near-black fill and against an off-white one.
|
|
57
|
+
*
|
|
58
|
+
* It cannot go softer. A filled control identifies itself at only 1.28:1
|
|
59
|
+
* (dark) and 1.12:1 (light) against the page, so the border is the only thing
|
|
60
|
+
* that makes an input findable.
|
|
61
|
+
*/
|
|
62
|
+
550: { l: 0.58, c: 0.005, h: NEUTRAL_HUE },
|
|
63
|
+
600: { l: 0.532, c: 0.005, h: NEUTRAL_HUE }, // solved: 4.58:1 on neutral-50, the light page ground — at 0.548 it read 4.27 and failed there while passing on white
|
|
64
|
+
700: { l: 0.44, c: 0.005, h: NEUTRAL_HUE },
|
|
65
|
+
800: { l: 0.345, c: 0.005, h: NEUTRAL_HUE },
|
|
66
|
+
850: { l: 0.3, c: 0.005, h: NEUTRAL_HUE },
|
|
67
|
+
900: { l: 0.274, c: 0.005, h: NEUTRAL_HUE },
|
|
68
|
+
950: { l: 0.227, c: 0.005, h: NEUTRAL_HUE },
|
|
69
|
+
1000: { l: 0.176, c: 0.005, h: NEUTRAL_HUE },
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* The hexes named in the brand brief, kept so the build can report exactly how
|
|
74
|
+
* far the derived ramp moved from them and why. The brief states these are
|
|
75
|
+
* "aesthetic references, not final production tokens" — this is the audit trail
|
|
76
|
+
* for taking it at its word.
|
|
77
|
+
*/
|
|
78
|
+
export const BRIEF_ANCHORS: Array<{
|
|
79
|
+
name: string;
|
|
80
|
+
hex: string;
|
|
81
|
+
mapsTo: string;
|
|
82
|
+
note?: string;
|
|
83
|
+
}> = [
|
|
84
|
+
{ name: 'White', hex: '#FFFFFF', mapsTo: 'neutral-0' },
|
|
85
|
+
{
|
|
86
|
+
name: 'Soft White',
|
|
87
|
+
hex: '#F3F3F1',
|
|
88
|
+
mapsTo: 'neutral-50',
|
|
89
|
+
note: 'Lightened→deepened by 0.012 L. At the brief value the light-mode page ground sat only 0.036 L from a white card — below the 0.04 surface-separation rule, so cards would go mushy on cheap panels.',
|
|
90
|
+
},
|
|
91
|
+
{ name: 'Muted Gray', hex: '#8A8A8A', mapsTo: 'neutral-500', note: 'L preserved exactly.' },
|
|
92
|
+
{
|
|
93
|
+
name: 'Surface Dark',
|
|
94
|
+
hex: '#252525',
|
|
95
|
+
mapsTo: 'neutral-900',
|
|
96
|
+
note: 'Lifted 0.009 L. At the brief value it sat 0.038 L from Deep Charcoal — below the 0.04 surface-separation rule.',
|
|
97
|
+
},
|
|
98
|
+
{ name: 'Deep Charcoal', hex: '#1C1C1C', mapsTo: 'neutral-950', note: 'L preserved exactly.' },
|
|
99
|
+
{ name: 'Near Black', hex: '#101010', mapsTo: 'neutral-1000', note: 'L preserved exactly.' },
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
/* -------------------------------------------------------------------------- */
|
|
103
|
+
/* Brand accents */
|
|
104
|
+
/* -------------------------------------------------------------------------- */
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Accent tiers, from the aesthetic direction §3. The tier decides how many
|
|
108
|
+
* steps get generated — not every accent needs a full ramp, and generating one
|
|
109
|
+
* anyway is exactly the checklist-satisfying bloat the audit framework warns
|
|
110
|
+
* against.
|
|
111
|
+
*
|
|
112
|
+
* - `primary` full set. The one accent that carries interaction.
|
|
113
|
+
* - `expressive` anchor + hover/active + a light-mode-safe step. These live in
|
|
114
|
+
* gradients, fills and charts; they never carry interaction.
|
|
115
|
+
* - `semantic` full set, and removed from decorative use entirely.
|
|
116
|
+
*/
|
|
117
|
+
export type AccentTier = 'primary' | 'expressive' | 'semantic';
|
|
118
|
+
|
|
119
|
+
export interface AccentSpec {
|
|
120
|
+
/** The brand hex, preserved exactly as the `base` step. */
|
|
121
|
+
anchor: string;
|
|
122
|
+
tier: AccentTier;
|
|
123
|
+
/** Why this color exists — carried into the generated docs. */
|
|
124
|
+
role: string;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
export const accents: Record<string, AccentSpec> = {
|
|
128
|
+
orange: {
|
|
129
|
+
anchor: '#FF641F',
|
|
130
|
+
tier: 'primary',
|
|
131
|
+
role: 'The single primary accent. Interaction, focus, progress, chart series 1.',
|
|
132
|
+
},
|
|
133
|
+
coral: {
|
|
134
|
+
anchor: '#FF493F',
|
|
135
|
+
tier: 'expressive',
|
|
136
|
+
role: 'Gradient stop, feature surface, chart series 5. Never a status color.',
|
|
137
|
+
},
|
|
138
|
+
amber: {
|
|
139
|
+
anchor: '#FF9F00',
|
|
140
|
+
tier: 'expressive',
|
|
141
|
+
role: 'Gradient stop, feature surface, progress. Never a status color.',
|
|
142
|
+
},
|
|
143
|
+
yellow: {
|
|
144
|
+
anchor: '#FFD21A',
|
|
145
|
+
tier: 'expressive',
|
|
146
|
+
role: 'Gradient stop, high-emphasis promo, chart series 3.',
|
|
147
|
+
},
|
|
148
|
+
peach: {
|
|
149
|
+
anchor: '#FFB46B',
|
|
150
|
+
tier: 'expressive',
|
|
151
|
+
role: 'Gradient stop, soft surface, chart series 6.',
|
|
152
|
+
},
|
|
153
|
+
pink: {
|
|
154
|
+
anchor: '#E8A6D8',
|
|
155
|
+
tier: 'expressive',
|
|
156
|
+
role: 'Chart series 4, secondary gradient stop.',
|
|
157
|
+
},
|
|
158
|
+
teal: {
|
|
159
|
+
anchor: '#22B8A8',
|
|
160
|
+
tier: 'semantic',
|
|
161
|
+
role: 'Status: info. Chart series 2. Removed from decorative use.',
|
|
162
|
+
},
|
|
163
|
+
};
|
|
164
|
+
|
|
165
|
+
/* -------------------------------------------------------------------------- */
|
|
166
|
+
/* Status hues */
|
|
167
|
+
/* -------------------------------------------------------------------------- */
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Status colors are authored independently of the brand accents and live in
|
|
171
|
+
* their own namespace. This is the single most important decision in the color
|
|
172
|
+
* system (aesthetic direction §5): the brand owns orange, amber, yellow and
|
|
173
|
+
* coral, which is precisely where warning and error normally live. Aliasing a
|
|
174
|
+
* status to a brand accent would make an expressive highlight indistinguishable
|
|
175
|
+
* from an error.
|
|
176
|
+
*
|
|
177
|
+
* `minDeltaFromBrand` is asserted at build time against every brand accent, so
|
|
178
|
+
* the separation is a checked invariant rather than an intention.
|
|
179
|
+
*/
|
|
180
|
+
export interface StatusSpec {
|
|
181
|
+
/** Authored directly in OKLCH — chosen for separation, not sampled from a swatch. */
|
|
182
|
+
base: Oklch;
|
|
183
|
+
role: string;
|
|
184
|
+
/** Minimum Oklab distance required from every brand accent anchor. */
|
|
185
|
+
minDeltaFromBrand: number;
|
|
186
|
+
/**
|
|
187
|
+
* What text sits on this color used as a solid fill.
|
|
188
|
+
*
|
|
189
|
+
* This is a *recognition* mechanism, not just a contrast one. Every brand
|
|
190
|
+
* warm fill takes a near-black label. `status-error` is the one fill in the
|
|
191
|
+
* system that takes white — which means a destructive button and an
|
|
192
|
+
* expressive coral surface can never be confused, because they are
|
|
193
|
+
* structurally different objects, not merely different hues.
|
|
194
|
+
*
|
|
195
|
+
* That matters because the probe proved no red exists that is both legible
|
|
196
|
+
* on near-black AND perceptually distant from brand coral: coral occupies
|
|
197
|
+
* exactly that region of the space. Separating by lightness and label color
|
|
198
|
+
* solves what separating by hue could not.
|
|
199
|
+
*/
|
|
200
|
+
onSolid: 'near-black' | 'white';
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
export const status: Record<string, StatusSpec> = {
|
|
204
|
+
success: {
|
|
205
|
+
// Hue 140 — the brand owns no green, so this region is unambiguous. Pushed
|
|
206
|
+
// toward yellow-green rather than blue-green for two reasons: it belongs to
|
|
207
|
+
// a warm palette, and it buys separation from teal/info. Green-vs-teal is a
|
|
208
|
+
// genuinely hard discrimination, especially for deuteranopes, so the
|
|
209
|
+
// distance is an asserted invariant rather than a hope.
|
|
210
|
+
base: { l: 0.745, c: 0.19, h: 140 },
|
|
211
|
+
role: 'Success, confirmation, healthy state.',
|
|
212
|
+
minDeltaFromBrand: 0.12,
|
|
213
|
+
onSolid: 'near-black',
|
|
214
|
+
},
|
|
215
|
+
warning: {
|
|
216
|
+
// Deliberately desaturated and lightened away from brand amber (#FF9F00,
|
|
217
|
+
// L 0.782 C 0.172 H 67.6). Convention requires warning read as amber, so
|
|
218
|
+
// separation comes from chroma and lightness — reinforced by the mandatory
|
|
219
|
+
// triangle icon, and by the rule that brand amber never appears as a chip.
|
|
220
|
+
base: { l: 0.75, c: 0.115, h: 78 },
|
|
221
|
+
role: 'Warning, caution, degraded state. Always paired with a triangle icon.',
|
|
222
|
+
minDeltaFromBrand: 0.06,
|
|
223
|
+
onSolid: 'near-black',
|
|
224
|
+
},
|
|
225
|
+
error: {
|
|
226
|
+
// Authored as an exact brand hex rather than a derived triple, so the value
|
|
227
|
+
// ships precisely as specified. Everything downstream — the text steps, the
|
|
228
|
+
// chip surfaces, the boundaries — is still solved from it.
|
|
229
|
+
//
|
|
230
|
+
// Still the resolution of the brand/semantic collision: materially darker
|
|
231
|
+
// than brand coral (L 0.667) and the only fill in the system carrying a
|
|
232
|
+
// white label. Chasing hue separation failed — at every lightness that
|
|
233
|
+
// clears 4.5:1 on near-black a red sits within ΔE 0.08 of coral, and
|
|
234
|
+
// pushing toward magenta stops reading as "error". Going darker inverts the
|
|
235
|
+
// label color, separating error from every brand fill structurally rather
|
|
236
|
+
// than chromatically.
|
|
237
|
+
base: hexToOklch('#CD1F2C'),
|
|
238
|
+
role: 'Error, destructive action, failed state. The one white-label fill.',
|
|
239
|
+
minDeltaFromBrand: 0.09,
|
|
240
|
+
onSolid: 'white',
|
|
241
|
+
},
|
|
242
|
+
};
|
|
243
|
+
|
|
244
|
+
/**
|
|
245
|
+
* The single declared exception to "no status token may alias a brand accent".
|
|
246
|
+
*
|
|
247
|
+
* `status-info` IS teal. Generating a second, identical set of teal primitives
|
|
248
|
+
* under a status name would be pure duplication — nine tokens with the same
|
|
249
|
+
* values and a different label, which the audit framework rightly treats as a
|
|
250
|
+
* defect. So info aliases teal in the semantic layer instead.
|
|
251
|
+
*
|
|
252
|
+
* The exception is safe only because it is *declared and bounded*: a test
|
|
253
|
+
* asserts this list has exactly one entry, so a future "warning aliases amber"
|
|
254
|
+
* cannot be slipped in beside it. That is the difference between an exception
|
|
255
|
+
* and a loophole.
|
|
256
|
+
*/
|
|
257
|
+
export const STATUS_ALIAS_EXCEPTIONS: ReadonlyArray<{ status: string; accent: string; why: string }> = [
|
|
258
|
+
{
|
|
259
|
+
status: 'info',
|
|
260
|
+
accent: 'teal',
|
|
261
|
+
why: 'Teal is removed from decorative use entirely and exists to carry the info role. One value, one name.',
|
|
262
|
+
},
|
|
263
|
+
];
|
|
264
|
+
|
|
265
|
+
/* -------------------------------------------------------------------------- */
|
|
266
|
+
/* Derivation targets */
|
|
267
|
+
/* -------------------------------------------------------------------------- */
|
|
268
|
+
|
|
269
|
+
/**
|
|
270
|
+
* Interaction washes — translucent, not opaque.
|
|
271
|
+
*
|
|
272
|
+
* An opaque hover fill has to name a surface, and `surface-subtle` happens to be
|
|
273
|
+
* the same value as `surface-floating`. So a ghost button inside a dialog, and
|
|
274
|
+
* every dropdown-menu item, hovered to exactly the color they were already
|
|
275
|
+
* sitting on: the state existed, was tested, and was invisible.
|
|
276
|
+
*
|
|
277
|
+
* A translucent wash lightens or darkens whatever is underneath it, so one token
|
|
278
|
+
* works on the page, on a card, and inside an overlay. This is the only place
|
|
279
|
+
* Halogen uses alpha for a fill, and it is why.
|
|
280
|
+
*/
|
|
281
|
+
export const stateWash: Record<string, string> = {
|
|
282
|
+
'hover-dark': '#FFFFFF14',
|
|
283
|
+
'active-dark': '#FFFFFF24',
|
|
284
|
+
'hover-light': '#0000000F',
|
|
285
|
+
'active-light': '#0000001A',
|
|
286
|
+
/** The soft halo behind a focused field. */
|
|
287
|
+
'focus-glow': '#FF641F3D',
|
|
288
|
+
/**
|
|
289
|
+
* Text selection. The browser default is a blue-gray that belongs to no
|
|
290
|
+
* design system and reads as an accident on a warm palette.
|
|
291
|
+
*
|
|
292
|
+
* A translucent accent rather than a solid one, so the selected text keeps
|
|
293
|
+
* its own color and stays legible over either ground — a solid selection
|
|
294
|
+
* fill would need a matching text color per theme and would still collide
|
|
295
|
+
* with any colored text it happened to cover.
|
|
296
|
+
*/
|
|
297
|
+
'selection-dark': '#FF641F59',
|
|
298
|
+
'selection-light': '#FF641F52',
|
|
299
|
+
};
|
|
300
|
+
|
|
301
|
+
/** How far the scrim blurs behind a modal surface. */
|
|
302
|
+
export const SCRIM_BLUR = '5px';
|
|
303
|
+
|
|
304
|
+
/**
|
|
305
|
+
* Opacity of the flat scrim that makes light text legal over a gradient.
|
|
306
|
+
*
|
|
307
|
+
* MEASURED, not chosen. The worst case is not a gradient at all — it is the
|
|
308
|
+
* region where a gradient is transparent (`glow` fades to transparent at 70%),
|
|
309
|
+
* so the scrim ends up over the light theme's white page. `ink-on-scrim`
|
|
310
|
+
* (#f0efed) over that blend:
|
|
311
|
+
*
|
|
312
|
+
* 0.60 -> 4.30:1 fails AA
|
|
313
|
+
* 0.70 -> 6.18:1 passes, with room
|
|
314
|
+
*
|
|
315
|
+
* It shipped at 0.60, which is why light-theme scrimmed text was unreadable.
|
|
316
|
+
* The figure is asserted in the token suite against the white underlay, so a
|
|
317
|
+
* future nudge downward fails the build rather than the eye.
|
|
318
|
+
*/
|
|
319
|
+
export const SCRIM_OPACITY = 0.7;
|
|
320
|
+
|
|
321
|
+
/** Lightness deltas applied to an anchor to produce interaction states. */
|
|
322
|
+
export const STATE_DELTA = {
|
|
323
|
+
/**
|
|
324
|
+
* Hover on a warm fill goes *lighter*, never darker. The label on a warm fill
|
|
325
|
+
* is near-black (aesthetic direction §3), so darkening the fill would reduce
|
|
326
|
+
* label contrast — the opposite of what a hover state should do.
|
|
327
|
+
*/
|
|
328
|
+
bright: 0.055,
|
|
329
|
+
/** Pressed/active. Contrast against the near-black label is asserted, not assumed. */
|
|
330
|
+
dim: -0.045,
|
|
331
|
+
} as const;
|
|
332
|
+
|
|
333
|
+
/**
|
|
334
|
+
* Tinted surfaces. These are chosen by eye within a tolerance — a chip
|
|
335
|
+
* background has no contrast requirement of its own, only a separation one.
|
|
336
|
+
*/
|
|
337
|
+
export const TINT = {
|
|
338
|
+
/** Accent surface on a dark ground — a dark, low-chroma wash of the hue. */
|
|
339
|
+
surfaceDark: { l: 0.265, chromaFraction: 0.45 },
|
|
340
|
+
/** Accent surface on a light ground. */
|
|
341
|
+
surfaceLight: { l: 0.945, chromaFraction: 0.5 },
|
|
342
|
+
/**
|
|
343
|
+
* Hover steps for the tinted surfaces.
|
|
344
|
+
*
|
|
345
|
+
* An opaque step, not a translucent wash. `bg-` REPLACES a background rather
|
|
346
|
+
* than layering on it, so hovering an accent-tinted row with a white wash
|
|
347
|
+
* swapped the tint out entirely and the row went gray — the opposite of
|
|
348
|
+
* deepening. Only an opaque step in the same hue can do this.
|
|
349
|
+
*/
|
|
350
|
+
surfaceDarkHover: { l: 0.325, chromaFraction: 0.45 },
|
|
351
|
+
surfaceLightHover: { l: 0.905, chromaFraction: 0.5 },
|
|
352
|
+
} as const;
|
|
353
|
+
|
|
354
|
+
/**
|
|
355
|
+
* Boundary chroma. Borders are *solved* for 3:1 rather than assigned a
|
|
356
|
+
* lightness: a hand-picked L that happens to work for orange fails for teal,
|
|
357
|
+
* which is exactly what the first build run demonstrated.
|
|
358
|
+
*/
|
|
359
|
+
export const BORDER_CHROMA = {
|
|
360
|
+
onDark: 0.55,
|
|
361
|
+
onLight: 0.8,
|
|
362
|
+
} as const;
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Contrast targets solved at build time. `textOnLight` is the reason this whole
|
|
366
|
+
* derivation exists: every brand accent fails 4.5:1 as text on white, so the
|
|
367
|
+
* light theme needs a darkened step per accent, and it must be the *lightest*
|
|
368
|
+
* step that passes — anything darker throws away brand vividness for nothing.
|
|
369
|
+
*/
|
|
370
|
+
/**
|
|
371
|
+
* Every solve targets the WORST surface in its theme, never a convenient one.
|
|
372
|
+
*
|
|
373
|
+
* Solving light-theme values against pure white and dark-theme values against
|
|
374
|
+
* the page ground is the most common way a token system passes its own tests
|
|
375
|
+
* and still fails in the browser: the popover is neutral-900, not neutral-1000,
|
|
376
|
+
* and the page ground is neutral-50, not white. A value solved to exactly 4.5:1
|
|
377
|
+
* on white reads 4.35:1 on the actual page.
|
|
378
|
+
*/
|
|
379
|
+
/**
|
|
380
|
+
* Headroom added to every solve target.
|
|
381
|
+
*
|
|
382
|
+
* The solver returns the most vivid step that *just* clears its target, which
|
|
383
|
+
* ships values sitting at exactly 3.00:1 and 4.50:1 — mathematically correct and
|
|
384
|
+
* operationally fragile, since any later nudge to a surface or a ramp step turns
|
|
385
|
+
* a pass into a violation with no warning. Solving to 3.06 / 4.56 costs a
|
|
386
|
+
* difference no eye can see and buys a margin every future change can spend.
|
|
387
|
+
*
|
|
388
|
+
* Assertions still test against the real WCAG numbers, not the padded ones —
|
|
389
|
+
* the margin is headroom, not a redefinition of the requirement.
|
|
390
|
+
*/
|
|
391
|
+
export const SOLVE_MARGIN = 0.06;
|
|
392
|
+
|
|
393
|
+
export const SOLVE = {
|
|
394
|
+
textOnLight: { target: 4.5, against: 'neutral.50 (darkest light surface)' },
|
|
395
|
+
textOnDark: { target: 4.5, against: 'neutral.900 (lightest dark surface)' },
|
|
396
|
+
/** Non-text marks — chart series, icons, boundaries — need 3:1 (WCAG 1.4.11). */
|
|
397
|
+
markOnLight: { target: 3, against: 'neutral.50 (darkest light surface)' },
|
|
398
|
+
/**
|
|
399
|
+
* Status text is solved to the AAA house standard rather than the AA floor.
|
|
400
|
+
* A value solved to exactly 4.5:1 is minimum-legible by construction, and it
|
|
401
|
+
* showed: every hue came out muddy on the first build.
|
|
402
|
+
*/
|
|
403
|
+
statusText: { target: 7 },
|
|
404
|
+
} as const;
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Halogen — elevation primitives.
|
|
3
|
+
*
|
|
4
|
+
* Depth comes from surface contrast, not shadow. Levels 0-2 carry no shadow at
|
|
5
|
+
* all; only floating surfaces get one, and there are exactly three shadow
|
|
6
|
+
* values.
|
|
7
|
+
*
|
|
8
|
+
* In dark mode a shadow on a near-black ground is close to invisible, so
|
|
9
|
+
* `elev-3` and `elev-4` substitute a 1px top highlight — light appearing to
|
|
10
|
+
* catch the top edge. Same intent, actually visible. That substitution is
|
|
11
|
+
* expressed in the semantic layer, not here.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Alpha values used for hairlines, highlights and scrims. */
|
|
15
|
+
export const alpha = {
|
|
16
|
+
/** Hairline border on a dark ground (white over near-black). */
|
|
17
|
+
hairlineDark: 0.08,
|
|
18
|
+
/** Hairline border on a light ground (black over white). */
|
|
19
|
+
hairlineLight: 0.08,
|
|
20
|
+
/** The dark-mode elevation highlight that replaces a shadow. */
|
|
21
|
+
edgeHighlight: 0.06,
|
|
22
|
+
/** Modal scrim. Flat tint, no backdrop-filter, ever. */
|
|
23
|
+
scrimDark: 0.72,
|
|
24
|
+
scrimLight: 0.48,
|
|
25
|
+
/** Disabled content. */
|
|
26
|
+
disabled: 0.38,
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Three shadows. Each is one ambient layer plus at most one contact layer.
|
|
31
|
+
* Soft, diffuse, low-contrast — never a color, never stacked, never decorative.
|
|
32
|
+
*/
|
|
33
|
+
export const shadow = {
|
|
34
|
+
sm: '0 1px 2px 0 rgb(0 0 0 / 0.16)',
|
|
35
|
+
md: '0 4px 12px -2px rgb(0 0 0 / 0.24), 0 2px 4px -2px rgb(0 0 0 / 0.16)',
|
|
36
|
+
lg: '0 16px 40px -8px rgb(0 0 0 / 0.36), 0 4px 8px -4px rgb(0 0 0 / 0.2)',
|
|
37
|
+
none: 'none',
|
|
38
|
+
} as const;
|
|
@@ -0,0 +1,129 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Halogen — motion primitives.
|
|
3
|
+
*
|
|
4
|
+
* Reduced-motion handling lives in the generated CSS, not here: transforms are
|
|
5
|
+
* zeroed but opacity fades are retained at `fast`, because removing state-change
|
|
6
|
+
* feedback entirely makes changes harder to perceive for the people the
|
|
7
|
+
* preference exists to serve (aesthetic direction §17).
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export const duration = {
|
|
11
|
+
instant: '0ms',
|
|
12
|
+
/**
|
|
13
|
+
* Press-down only.
|
|
14
|
+
*
|
|
15
|
+
* Deliberately faster than `fast`. A press is the one moment where the
|
|
16
|
+
* interface is responding to a finger already on the control — anything at or
|
|
17
|
+
* above 100ms reads as lag rather than feedback, because the user's own motion
|
|
18
|
+
* has already finished.
|
|
19
|
+
*/
|
|
20
|
+
press: '70ms',
|
|
21
|
+
fast: '120ms',
|
|
22
|
+
base: '180ms',
|
|
23
|
+
slow: '260ms',
|
|
24
|
+
slower: '400ms',
|
|
25
|
+
/** Page and hero transitions only. */
|
|
26
|
+
deliberate: '600ms',
|
|
27
|
+
} as const;
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Choosing a curve: is the journey the point, or the arrival?
|
|
31
|
+
*
|
|
32
|
+
* `ease-enter` is easeOutQuart and is violently front-loaded — roughly 40% of
|
|
33
|
+
* the way in the first 15% of the duration. That is exactly right for something
|
|
34
|
+
* *arriving*, where the eye should register the end state and not the travel.
|
|
35
|
+
*
|
|
36
|
+
* It is wrong for anything whose motion is the point. Three separate effects in
|
|
37
|
+
* this system were built on it and read as instant despite animating correctly:
|
|
38
|
+
* the icon reel, the input's focus fade, and an early hover treatment. In every
|
|
39
|
+
* case the fix was the same — `ease-move`, which is symmetric and spends the
|
|
40
|
+
* duration on the journey.
|
|
41
|
+
*
|
|
42
|
+
* The rule: if a person is meant to SEE the motion, use `move`. If they are
|
|
43
|
+
* meant to see the result, use `enter`.
|
|
44
|
+
*/
|
|
45
|
+
export const easing = {
|
|
46
|
+
/** Appear / expand. The default for arrival. */
|
|
47
|
+
enter: 'cubic-bezier(0.22, 1, 0.36, 1)',
|
|
48
|
+
/** Dismiss / collapse. */
|
|
49
|
+
exit: 'cubic-bezier(0.64, 0, 0.78, 0)',
|
|
50
|
+
/** Reposition / morph. */
|
|
51
|
+
move: 'cubic-bezier(0.65, 0, 0.35, 1)',
|
|
52
|
+
/** Gentle overshoot, <= 4%. Selection, toggle and success only. */
|
|
53
|
+
overshoot: 'cubic-bezier(0.34, 1.4, 0.64, 1)',
|
|
54
|
+
linear: 'linear',
|
|
55
|
+
} as const;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* The press interaction.
|
|
59
|
+
*
|
|
60
|
+
* 3% is the whole effect: enough to read as a physical push at 32px, small
|
|
61
|
+
* enough that a 56px marketing CTA does not appear to jump. Scale rather than
|
|
62
|
+
* translate, because a fixed offset that feels right on a small control looks
|
|
63
|
+
* like a glitch on a large one.
|
|
64
|
+
*
|
|
65
|
+
* The timing is asymmetric, and inverted from the system's usual "enter fast,
|
|
66
|
+
* exit faster" rule: the press goes down in `duration-press` (70ms) and returns
|
|
67
|
+
* over `duration-fast` (120ms). That inversion is what makes a button feel
|
|
68
|
+
* sprung rather than mushy — instant commitment, relaxed release. The general
|
|
69
|
+
* rule is about elements arriving and leaving; this is a surface responding to
|
|
70
|
+
* touch, and it wants the opposite shape.
|
|
71
|
+
*
|
|
72
|
+
* No overshoot. The aesthetic direction reserves `ease-overshoot` for selection,
|
|
73
|
+
* toggle and success, and explicitly rejects bounce — a button that springs past
|
|
74
|
+
* its resting size reads as a toy.
|
|
75
|
+
*/
|
|
76
|
+
export const press = {
|
|
77
|
+
scale: '0.97',
|
|
78
|
+
} as const;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* The icon reel — a slot-machine swap on hover.
|
|
82
|
+
*
|
|
83
|
+
* Two copies of the glyph stacked in an overflow-hidden window; on hover the
|
|
84
|
+
* pair travels one window height, so the resting icon leaves upward as its twin
|
|
85
|
+
* arrives from below. Travel is a percentage rather than a length so the effect
|
|
86
|
+
* is correct at every icon size without a second token.
|
|
87
|
+
*
|
|
88
|
+
* Set to 0% under reduced motion, which makes the whole thing inert without the
|
|
89
|
+
* component needing to know.
|
|
90
|
+
*/
|
|
91
|
+
export const reel = {
|
|
92
|
+
travel: '100%',
|
|
93
|
+
} as const;
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Overlay entrances.
|
|
97
|
+
*
|
|
98
|
+
* Overlays are the one place Halogen uses keyframes rather than transitions,
|
|
99
|
+
* and not by preference: Radix decides when a closed overlay may unmount by
|
|
100
|
+
* watching `animationName`. A transition is invisible to it, so an exit written
|
|
101
|
+
* as a transition simply never plays — the element is gone before it starts.
|
|
102
|
+
*
|
|
103
|
+
* Each overlay runs TWO animations: opacity on `duration-fade`, transform on a
|
|
104
|
+
* movement duration. That split is what lets reduced motion neutralise the
|
|
105
|
+
* travel and the zoom while keeping the fade perceptible, which is the same
|
|
106
|
+
* rule the press and the spinner follow.
|
|
107
|
+
*
|
|
108
|
+
* Reduced motion collapses these to 1ms rather than setting `animation: none`,
|
|
109
|
+
* so the opacity fade survives while the travel and the zoom do not — the same
|
|
110
|
+
* rule the press and the spinner follow.
|
|
111
|
+
*
|
|
112
|
+
* Not, as first assumed, because `none` would strand a closed overlay in the
|
|
113
|
+
* DOM. Radix reads `animationName`, treats `none` as "no animation" and
|
|
114
|
+
* unmounts immediately; the assumption was tested in a browser and disproved.
|
|
115
|
+
*/
|
|
116
|
+
export const overlay = {
|
|
117
|
+
/** Dialogs settle in from fractionally small. Subtle enough not to read as a bounce. */
|
|
118
|
+
zoomFrom: '0.96',
|
|
119
|
+
/** Sheets travel their own full width or height. */
|
|
120
|
+
sheetTravel: '100%',
|
|
121
|
+
} as const;
|
|
122
|
+
|
|
123
|
+
/** Maximum travel distance, per aesthetic direction §17. */
|
|
124
|
+
export const travel = {
|
|
125
|
+
/** Product UI */
|
|
126
|
+
ui: '8px',
|
|
127
|
+
/** Marketing surfaces */
|
|
128
|
+
expressive: '24px',
|
|
129
|
+
} as const;
|