@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.
@@ -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;