@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,473 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Halogen — the legal-adjacency map.
|
|
3
|
+
*
|
|
4
|
+
* Every pair of semantic tokens that can meet inside a component, with the
|
|
5
|
+
* contrast that pairing must reach. This file is the Phase 1 gate: a token pair
|
|
6
|
+
* that is not listed here is not permitted to meet, and every pair that is
|
|
7
|
+
* listed is asserted in both themes on every build.
|
|
8
|
+
*
|
|
9
|
+
* The point is exhaustiveness. Spot-checking "orange on black looks fine"
|
|
10
|
+
* verifies the pair nobody was going to get wrong; the failures live in
|
|
11
|
+
* tertiary text on a popover, and a boundary on the one surface that is a shade
|
|
12
|
+
* lighter than the one it was designed against.
|
|
13
|
+
*/
|
|
14
|
+
|
|
15
|
+
export type ThemeName = 'dark' | 'light';
|
|
16
|
+
|
|
17
|
+
export interface ContrastPair {
|
|
18
|
+
fg: string;
|
|
19
|
+
bg: string;
|
|
20
|
+
/** Minimum WCAG 2.x contrast ratio. */
|
|
21
|
+
min: number;
|
|
22
|
+
/** The success criterion this pairing is held to. */
|
|
23
|
+
sc: string;
|
|
24
|
+
context: string;
|
|
25
|
+
/** Restrict to one theme when the pairing only exists there. */
|
|
26
|
+
themes?: ThemeName[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Surfaces a general-purpose text token may legally sit on.
|
|
31
|
+
*
|
|
32
|
+
* Deliberately excludes the interactive fills. `surface-subtle` and its hover
|
|
33
|
+
* and active steps run several ramp steps toward mid-gray, and the only text
|
|
34
|
+
* that appears on them is a control's own label — which is always
|
|
35
|
+
* `ink-primary`. Enumerating tertiary or accent text there would assert a
|
|
36
|
+
* pairing the system never produces, and would force the whole text ramp
|
|
37
|
+
* lighter to satisfy a case that does not exist.
|
|
38
|
+
*/
|
|
39
|
+
export const TEXT_SURFACES = [
|
|
40
|
+
'surface-ground',
|
|
41
|
+
'surface-raised',
|
|
42
|
+
'surface-overlay',
|
|
43
|
+
'surface-floating',
|
|
44
|
+
'surface-modal',
|
|
45
|
+
] as const;
|
|
46
|
+
|
|
47
|
+
/** Interactive fills. Only a control's own label appears here. */
|
|
48
|
+
export const CONTROL_FILLS = [
|
|
49
|
+
'surface-subtle',
|
|
50
|
+
'surface-subtle-hover',
|
|
51
|
+
'surface-subtle-active',
|
|
52
|
+
'surface-field',
|
|
53
|
+
] as const;
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Surfaces a boundary is asserted against.
|
|
57
|
+
*
|
|
58
|
+
* A control's boundary exists to identify the control against the surface it
|
|
59
|
+
* sits ON — plus its own resting fill, since an input's border separates the
|
|
60
|
+
* fill from the page. It is deliberately NOT asserted against the hover and
|
|
61
|
+
* pressed fills: those run several steps toward mid-gray, and requiring 3:1
|
|
62
|
+
* there would force every border in the system dark enough to fight the
|
|
63
|
+
* "restrained chrome" the aesthetic direction calls for, to satisfy a moment
|
|
64
|
+
* when the user's own cursor or finger is on the control and identification is
|
|
65
|
+
* not in question. State perception on those fills is covered separately.
|
|
66
|
+
*/
|
|
67
|
+
export const BOUNDARY_SURFACES = [...TEXT_SURFACES, 'surface-subtle', 'surface-field'] as const;
|
|
68
|
+
|
|
69
|
+
const STATUSES = ['success', 'warning', 'error', 'info'] as const;
|
|
70
|
+
|
|
71
|
+
/** Surfaces a status message can appear on, besides its own chip. */
|
|
72
|
+
const STATUS_HOST_SURFACES = ['surface-ground', 'surface-raised', 'surface-overlay'] as const;
|
|
73
|
+
|
|
74
|
+
const pairs: ContrastPair[] = [];
|
|
75
|
+
|
|
76
|
+
const add = (p: ContrastPair): void => {
|
|
77
|
+
pairs.push(p);
|
|
78
|
+
};
|
|
79
|
+
|
|
80
|
+
/* ---- Body and supporting text: WCAG 1.4.3 ---- */
|
|
81
|
+
for (const bg of TEXT_SURFACES) {
|
|
82
|
+
for (const fg of ['ink-primary', 'ink-secondary', 'ink-tertiary'] as const) {
|
|
83
|
+
add({ fg, bg, min: 4.5, sc: '1.4.3 Contrast (Minimum)', context: `${fg} on ${bg}` });
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/**
|
|
88
|
+
* The house standard: AAA for primary body text on the two ground surfaces.
|
|
89
|
+
* Held separately from the AA floor so a regression that drops it below 7:1 but
|
|
90
|
+
* above 4.5:1 still shows up as a named failure rather than passing quietly.
|
|
91
|
+
*/
|
|
92
|
+
for (const bg of ['surface-ground', 'surface-raised'] as const) {
|
|
93
|
+
add({
|
|
94
|
+
fg: 'ink-primary',
|
|
95
|
+
bg,
|
|
96
|
+
min: 7,
|
|
97
|
+
sc: '1.4.6 Contrast (Enhanced) — Halogen house standard',
|
|
98
|
+
context: `primary body text on ${bg}`,
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
/* ---- Control labels sit on the interactive fills, through every state. ---- */
|
|
103
|
+
for (const bg of CONTROL_FILLS) {
|
|
104
|
+
add({ fg: 'ink-primary', bg, min: 4.5, sc: '1.4.3', context: `control label on ${bg}` });
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* ---- Placeholder text is text. It fails AA in most systems. ---- */
|
|
108
|
+
for (const bg of ['surface-field', 'surface-raised'] as const) {
|
|
109
|
+
add({ fg: 'ink-placeholder', bg, min: 4.5, sc: '1.4.3', context: `placeholder in an input on ${bg}` });
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/* ---- Accent text and links ---- */
|
|
113
|
+
for (const bg of TEXT_SURFACES) {
|
|
114
|
+
for (const fg of ['ink-accent', 'ink-link', 'ink-link-hover'] as const) {
|
|
115
|
+
add({ fg, bg, min: 4.5, sc: '1.4.3', context: `${fg} on ${bg}` });
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
/* ---- Labels on fills. The near-black-on-warm rule, asserted per state. ---- */
|
|
120
|
+
for (const bg of ['surface-accent', 'surface-accent-hover', 'surface-accent-active'] as const) {
|
|
121
|
+
add({ fg: 'ink-on-accent', bg, min: 4.5, sc: '1.4.3', context: `button label on ${bg}` });
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* The accent-tinted surfaces, at every ink step that legally appears on them.
|
|
125
|
+
*
|
|
126
|
+
* This list used to hold `ink-primary` alone, and that gap shipped a real
|
|
127
|
+
* failure: the notification row put a timestamp in `ink-tertiary` on the unread
|
|
128
|
+
* row's tint, which measures 4.44:1 in the light theme. axe caught it in the
|
|
129
|
+
* browser; the token suite had nothing to say, because it had never been asked.
|
|
130
|
+
* `ink-tertiary` is now forbidden here — see FORBIDDEN_PAIRS, which asserts the
|
|
131
|
+
* shortfall rather than merely omitting the pair.
|
|
132
|
+
*/
|
|
133
|
+
for (const bg of ['surface-accent-subtle', 'surface-accent-subtle-hover'] as const) {
|
|
134
|
+
for (const fg of ['ink-primary', 'ink-secondary'] as const) {
|
|
135
|
+
add({ fg, bg, min: 4.5, sc: '1.4.3 Contrast (Minimum)', context: `${fg} on ${bg}` });
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
// The accent text step appears on the resting tint only: a selected chip does
|
|
139
|
+
// not change ground on hover, so it never meets the deeper hovered step.
|
|
140
|
+
add({
|
|
141
|
+
fg: 'ink-on-accent-subtle',
|
|
142
|
+
bg: 'surface-accent-subtle',
|
|
143
|
+
min: 4.5,
|
|
144
|
+
sc: '1.4.3 Contrast (Minimum)',
|
|
145
|
+
context: 'accent text on the quiet accent surface',
|
|
146
|
+
});
|
|
147
|
+
// The accent badge. This pair was missing from the map and axe found it in the
|
|
148
|
+
// browser before the token layer did — a component reached a combination the
|
|
149
|
+
// map had not enumerated, which is exactly the gap in-browser testing exists for.
|
|
150
|
+
add({
|
|
151
|
+
fg: 'ink-on-accent-subtle',
|
|
152
|
+
bg: 'surface-accent-subtle',
|
|
153
|
+
min: 4.5,
|
|
154
|
+
sc: '1.4.3',
|
|
155
|
+
context: 'accent badge label on its own chip',
|
|
156
|
+
});
|
|
157
|
+
add({
|
|
158
|
+
fg: 'line-accent',
|
|
159
|
+
bg: 'surface-accent-subtle',
|
|
160
|
+
min: 3,
|
|
161
|
+
sc: '1.4.11',
|
|
162
|
+
context: 'accent badge boundary on its own chip',
|
|
163
|
+
});
|
|
164
|
+
add({ fg: 'ink-inverse', bg: 'surface-inverse', min: 4.5, sc: '1.4.3', context: 'text on an intentionally inverted card' });
|
|
165
|
+
|
|
166
|
+
/* ---- Status ---- */
|
|
167
|
+
for (const s of STATUSES) {
|
|
168
|
+
add({
|
|
169
|
+
fg: `status-${s}-ink`,
|
|
170
|
+
bg: `status-${s}-surface`,
|
|
171
|
+
min: 4.5,
|
|
172
|
+
sc: '1.4.3',
|
|
173
|
+
context: `${s} text on its own chip`,
|
|
174
|
+
});
|
|
175
|
+
for (const bg of STATUS_HOST_SURFACES) {
|
|
176
|
+
add({ fg: `status-${s}-ink`, bg, min: 4.5, sc: '1.4.3', context: `inline ${s} message on ${bg}` });
|
|
177
|
+
}
|
|
178
|
+
add({
|
|
179
|
+
fg: `status-${s}-on-solid`,
|
|
180
|
+
bg: `status-${s}-solid`,
|
|
181
|
+
min: 4.5,
|
|
182
|
+
sc: '1.4.3',
|
|
183
|
+
context: `label on the ${s} solid fill`,
|
|
184
|
+
});
|
|
185
|
+
add({
|
|
186
|
+
fg: `status-${s}-line`,
|
|
187
|
+
bg: `status-${s}-surface`,
|
|
188
|
+
min: 3,
|
|
189
|
+
sc: '1.4.11 Non-text Contrast',
|
|
190
|
+
context: `${s} chip boundary`,
|
|
191
|
+
});
|
|
192
|
+
for (const bg of STATUS_HOST_SURFACES) {
|
|
193
|
+
add({ fg: `status-${s}-line`, bg, min: 3, sc: '1.4.11', context: `${s} boundary on ${bg}` });
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/* ---- Boundaries that identify a control: WCAG 1.4.11 ---- */
|
|
198
|
+
for (const bg of BOUNDARY_SURFACES) {
|
|
199
|
+
for (const fg of ['line-control', 'line-strong', 'line-focus'] as const) {
|
|
200
|
+
add({ fg, bg, min: 3, sc: '1.4.11 Non-text Contrast', context: `${fg} on ${bg}` });
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
for (const bg of ['surface-ground', 'surface-raised', 'surface-overlay'] as const) {
|
|
204
|
+
add({ fg: 'line-accent', bg, min: 3, sc: '1.4.11', context: `accent boundary on ${bg}` });
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
/**
|
|
208
|
+
* The focus ring must also clear 3:1 against the accent fill it sits on — an
|
|
209
|
+
* orange ring on an orange button is not a ring. This is where the ring flips
|
|
210
|
+
* to near-black.
|
|
211
|
+
*/
|
|
212
|
+
add({
|
|
213
|
+
fg: 'ink-on-accent',
|
|
214
|
+
bg: 'surface-accent',
|
|
215
|
+
min: 3,
|
|
216
|
+
sc: '2.4.11 Focus Appearance',
|
|
217
|
+
context: 'focus ring on a primary button flips to near-black',
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
/* ---- Data visualization marks: WCAG 1.4.11 ---- */
|
|
221
|
+
for (const bg of ['surface-ground', 'surface-raised'] as const) {
|
|
222
|
+
for (let i = 1; i <= 7; i++) {
|
|
223
|
+
add({ fg: `viz-${i}`, bg, min: 3, sc: '1.4.11', context: `chart series ${i} on ${bg}` });
|
|
224
|
+
}
|
|
225
|
+
add({ fg: 'viz-axis', bg, min: 4.5, sc: '1.4.3', context: `axis label on ${bg}` });
|
|
226
|
+
/**
|
|
227
|
+
* Only the signal end of a sequential ramp is held to 3:1 — the top two steps.
|
|
228
|
+
*
|
|
229
|
+
* A five-step scale running from near-surface to fully saturated necessarily
|
|
230
|
+
* passes through a mid-tone, and in the light theme that mid-tone cannot clear
|
|
231
|
+
* 3:1 against white however it is chosen: it is halfway between the page and
|
|
232
|
+
* the darkest ink by construction. WCAG 1.4.11 covers graphical parts required
|
|
233
|
+
* to understand the content; in a ramp that job is done by position within the
|
|
234
|
+
* scale and its labels, not by every cell standing off the page. Requiring all
|
|
235
|
+
* five would produce a ramp with no low end and no middle.
|
|
236
|
+
*
|
|
237
|
+
* The steps that *are* exempt get a different guarantee instead: adjacent
|
|
238
|
+
* steps must be distinguishable from one another (see the ramp-separation
|
|
239
|
+
* test), which is the property a sequential scale actually depends on.
|
|
240
|
+
*/
|
|
241
|
+
for (const i of [3, 4]) {
|
|
242
|
+
add({ fg: `viz-sequential-${i}`, bg, min: 3, sc: '1.4.11', context: `sequential step ${i} on ${bg}` });
|
|
243
|
+
}
|
|
244
|
+
for (const k of ['neg-2', 'neg-1', 'pos-1', 'pos-2']) {
|
|
245
|
+
add({ fg: `viz-diverging-${k}`, bg, min: 3, sc: '1.4.11', context: `diverging ${k} on ${bg}` });
|
|
246
|
+
}
|
|
247
|
+
}
|
|
248
|
+
|
|
249
|
+
export const CONTRAST_PAIRS: readonly ContrastPair[] = pairs;
|
|
250
|
+
|
|
251
|
+
/**
|
|
252
|
+
* Pairs that are NOT legal, kept as measured prohibitions rather than silence.
|
|
253
|
+
*
|
|
254
|
+
* An omitted pair says nothing — it reads identically whether the combination
|
|
255
|
+
* was considered and rejected or simply never thought about. `ink-tertiary` on
|
|
256
|
+
* an accent-tinted surface was the second kind, and it shipped.
|
|
257
|
+
*
|
|
258
|
+
* Every entry is asserted to actually fail its threshold. If a future token
|
|
259
|
+
* change makes one of these pass, the test says so and the prohibition should be
|
|
260
|
+
* lifted rather than left standing as folklore.
|
|
261
|
+
*/
|
|
262
|
+
export interface ForbiddenPair {
|
|
263
|
+
fg: string;
|
|
264
|
+
bg: string;
|
|
265
|
+
/** The threshold it must fail. */
|
|
266
|
+
min: number;
|
|
267
|
+
themes: readonly ('dark' | 'light')[];
|
|
268
|
+
why: string;
|
|
269
|
+
/** What a component should reach for instead. */
|
|
270
|
+
instead: string;
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
export const FORBIDDEN_PAIRS: readonly ForbiddenPair[] = [
|
|
274
|
+
{
|
|
275
|
+
fg: 'ink-tertiary',
|
|
276
|
+
bg: 'surface-accent-subtle',
|
|
277
|
+
min: 4.5,
|
|
278
|
+
themes: ['light'],
|
|
279
|
+
why:
|
|
280
|
+
'The quiet accent tint is light enough in the light theme that the tertiary ink step lands at 4.44:1 — a ' +
|
|
281
|
+
'shortfall of 0.06, invisible to the eye and fatal to the claim. Deepening the tint was rejected: the tint ' +
|
|
282
|
+
'is a product decision, and the timestamp had no business being the quietest step on a highlighted row.',
|
|
283
|
+
instead: 'ink-secondary',
|
|
284
|
+
},
|
|
285
|
+
{
|
|
286
|
+
fg: 'ink-tertiary',
|
|
287
|
+
bg: 'surface-accent-subtle-hover',
|
|
288
|
+
min: 4.5,
|
|
289
|
+
themes: ['dark', 'light'],
|
|
290
|
+
why:
|
|
291
|
+
'The hovered tint is deeper than the resting one by design, which costs contrast in both themes: 4.19:1 dark ' +
|
|
292
|
+
'and 3.91:1 light. Any ink step legal on the resting tint must also clear the hovered one, because a row ' +
|
|
293
|
+
'does not stop being readable while the pointer is over it.',
|
|
294
|
+
instead: 'ink-secondary',
|
|
295
|
+
},
|
|
296
|
+
];
|
|
297
|
+
|
|
298
|
+
/**
|
|
299
|
+
* Pairings that are knowingly below their requirement.
|
|
300
|
+
*
|
|
301
|
+
* A waiver is not a deleted assertion. The pair stays in the map, the shortfall
|
|
302
|
+
* is measured on every build, and it is reported in the contrast report and the
|
|
303
|
+
* accessibility statement. The difference between a design system that has one
|
|
304
|
+
* documented exception and one that quietly dropped a test is the difference
|
|
305
|
+
* between an honest product and a misleading one.
|
|
306
|
+
*
|
|
307
|
+
* Every entry must actually fail — a waiver for something that now passes is
|
|
308
|
+
* stale and is treated as an error, so this list cannot accumulate cover for
|
|
309
|
+
* problems that were fixed years ago.
|
|
310
|
+
*/
|
|
311
|
+
export interface ContrastWaiver {
|
|
312
|
+
fg: string;
|
|
313
|
+
/** Specific backgrounds, or '*' for every pairing this foreground appears in. */
|
|
314
|
+
bgs: readonly string[] | '*';
|
|
315
|
+
themes: readonly ThemeName[];
|
|
316
|
+
why: string;
|
|
317
|
+
/** Who made the call. Waivers are product decisions, not engineering ones. */
|
|
318
|
+
decidedBy: string;
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
export const CONTRAST_WAIVERS: readonly ContrastWaiver[] = [
|
|
322
|
+
{
|
|
323
|
+
fg: 'line-control',
|
|
324
|
+
bgs: '*',
|
|
325
|
+
themes: ['dark', 'light'],
|
|
326
|
+
why:
|
|
327
|
+
'The control border is set to sit almost on the fill in both themes — neutral-800 on dark, neutral-100 ' +
|
|
328
|
+
'(matching surface-subtle) on light — giving a near-borderless field. It reads about 1.29:1 on dark and ' +
|
|
329
|
+
'1.21:1 on light against the field, where WCAG 1.4.11 requires 3:1. Nothing else identifies the control: a ' +
|
|
330
|
+
'filled input is only 1.28:1 (dark) / 1.12:1 (light) from the page. A field is therefore found by its ' +
|
|
331
|
+
'label and placeholder rather than by its edge. This is the larger of the two waivers by a wide margin.',
|
|
332
|
+
decidedBy: 'Product owner, 2026-08-26 (dark) and 2026-08-27 (light), with the measured shortfall stated.',
|
|
333
|
+
},
|
|
334
|
+
{
|
|
335
|
+
fg: 'line-focus',
|
|
336
|
+
bgs: '*',
|
|
337
|
+
themes: ['light'],
|
|
338
|
+
why:
|
|
339
|
+
'The focus ring uses the brand orange in both themes so the accent does not shift between them. On a white ' +
|
|
340
|
+
'page that reads 2.96:1 where WCAG 1.4.11 requires 3:1 — a shortfall of roughly 1%, not the 60% of the ' +
|
|
341
|
+
'border waiver, and visually indistinguishable from a compliant ring. Recorded separately so the two are ' +
|
|
342
|
+
'never mistaken for equivalent risks. Restoring compliance costs only a slightly deeper orange ' +
|
|
343
|
+
'(orange.mark-light, solved for exactly 3:1) if that trade is ever preferred.',
|
|
344
|
+
decidedBy: 'Product owner, 2026-08-27, with the measured shortfall stated.',
|
|
345
|
+
},
|
|
346
|
+
];
|
|
347
|
+
|
|
348
|
+
/** True when this exact pairing is covered by a declared waiver. */
|
|
349
|
+
export function isWaived(fg: string, bg: string, theme: ThemeName): ContrastWaiver | undefined {
|
|
350
|
+
return CONTRAST_WAIVERS.find(
|
|
351
|
+
(w) => w.fg === fg && w.themes.includes(theme) && (w.bgs === '*' || w.bgs.includes(bg)),
|
|
352
|
+
);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
/**
|
|
356
|
+
* Ordered ramps. Adjacent steps must be distinguishable from each other, which
|
|
357
|
+
* is the property a sequential or diverging scale actually relies on — and the
|
|
358
|
+
* one that replaces the 3:1-against-the-page rule for the ramps' quiet ends.
|
|
359
|
+
*/
|
|
360
|
+
export const RAMP_SEQUENCES: ReadonlyArray<{
|
|
361
|
+
name: string;
|
|
362
|
+
steps: string[];
|
|
363
|
+
/** Minimum OKLCH lightness delta between adjacent steps. */
|
|
364
|
+
minAdjacentL: number;
|
|
365
|
+
/** Sequential scales encode magnitude and must be monotonic; diverging ones must not. */
|
|
366
|
+
monotonic: boolean;
|
|
367
|
+
}> = [
|
|
368
|
+
{
|
|
369
|
+
name: 'sequential-heat',
|
|
370
|
+
steps: ['viz-sequential-0', 'viz-sequential-1', 'viz-sequential-2', 'viz-sequential-3', 'viz-sequential-4'],
|
|
371
|
+
minAdjacentL: 0.06,
|
|
372
|
+
monotonic: true,
|
|
373
|
+
},
|
|
374
|
+
{
|
|
375
|
+
// A diverging scale is deliberately NOT monotonic — it recedes to a neutral
|
|
376
|
+
// midpoint and rises again. Only step separation is meaningful.
|
|
377
|
+
name: 'diverging',
|
|
378
|
+
steps: [
|
|
379
|
+
'viz-diverging-neg-2',
|
|
380
|
+
'viz-diverging-neg-1',
|
|
381
|
+
'viz-diverging-0',
|
|
382
|
+
'viz-diverging-pos-1',
|
|
383
|
+
'viz-diverging-pos-2',
|
|
384
|
+
],
|
|
385
|
+
minAdjacentL: 0.05,
|
|
386
|
+
monotonic: false,
|
|
387
|
+
},
|
|
388
|
+
];
|
|
389
|
+
|
|
390
|
+
/**
|
|
391
|
+
* Tokens deliberately exempt from a contrast minimum, with the reason. Listed
|
|
392
|
+
* explicitly so "it isn't tested" and "it doesn't need testing" are different
|
|
393
|
+
* states, and so a reviewer can challenge each exemption individually.
|
|
394
|
+
*/
|
|
395
|
+
export const CONTRAST_EXEMPT: ReadonlyArray<{ token: string; why: string }> = [
|
|
396
|
+
{
|
|
397
|
+
token: 'line-divider',
|
|
398
|
+
why: 'Structural separation between regions of one surface — a footer bar, a toolbar edge. WCAG 1.4.11 covers boundaries required to identify a component; a rule between two parts of the same panel is not one. Sized to be clearly visible regardless.',
|
|
399
|
+
},
|
|
400
|
+
{
|
|
401
|
+
token: 'line-subtle',
|
|
402
|
+
why: 'Decorative dividers and card hairlines. WCAG 1.4.11 applies to boundaries required to identify a component; a divider is not one. Barred from interactive controls by lint.',
|
|
403
|
+
},
|
|
404
|
+
{
|
|
405
|
+
token: 'line-disabled',
|
|
406
|
+
why: 'Disabled controls are explicitly exempt from 1.4.3 and 1.4.11.',
|
|
407
|
+
},
|
|
408
|
+
{
|
|
409
|
+
token: 'ink-disabled',
|
|
410
|
+
why: 'Disabled text is explicitly exempt from 1.4.3. Still chosen to remain perceivable.',
|
|
411
|
+
},
|
|
412
|
+
{
|
|
413
|
+
token: 'line-edge-highlight',
|
|
414
|
+
why: 'Purely an elevation cue standing in for a shadow in dark mode. Carries no information; the surface change beneath it does.',
|
|
415
|
+
},
|
|
416
|
+
{ token: 'viz-grid', why: 'Chart gridlines are decorative scaffolding; the axis labels carry the information.' },
|
|
417
|
+
{
|
|
418
|
+
token: 'viz-sequential-0',
|
|
419
|
+
why: 'The low end of a sequential ramp is meant to sit near the surface — that is what makes a heatmap readable as a scale. Held instead to the even-spacing and monotonicity rules in RAMP_SEQUENCES.',
|
|
420
|
+
},
|
|
421
|
+
{
|
|
422
|
+
token: 'viz-sequential-1',
|
|
423
|
+
why: 'Second step of the sequential ramp, same reasoning as step 0. Held to adjacent-step separation.',
|
|
424
|
+
},
|
|
425
|
+
{
|
|
426
|
+
token: 'viz-sequential-2',
|
|
427
|
+
why: 'The midpoint of the scale. In the light theme a mid-tone cannot clear 3:1 against a white page however it is chosen — it sits halfway between page and ink by construction. Held to even spacing and monotonicity instead.',
|
|
428
|
+
},
|
|
429
|
+
{
|
|
430
|
+
token: 'viz-diverging-0',
|
|
431
|
+
why: 'The neutral midpoint of a diverging scale represents "no deviation" and is meant to recede. Held to adjacent-step separation.',
|
|
432
|
+
},
|
|
433
|
+
{
|
|
434
|
+
token: 'ink-on-scrim-secondary',
|
|
435
|
+
why: 'Text over a flat scrim. Same reasoning as ink-on-scrim: the background is a blend, not a token, and the scrim opacity is chosen so this step clears AA over the worst of them.',
|
|
436
|
+
},
|
|
437
|
+
{
|
|
438
|
+
token: 'ink-on-scrim',
|
|
439
|
+
why: 'Text over a flat scrim. The scrim is translucent and sits over an arbitrary gradient, so there is no single background to assert against — the scrimmed result is what matters, and the scrim opacity is chosen so this token clears AA over every blend.',
|
|
440
|
+
},
|
|
441
|
+
{
|
|
442
|
+
token: 'surface-selection',
|
|
443
|
+
why: 'A translucent selection wash. The selected text keeps its own color, which is asserted against the surface beneath; the wash only tints what is behind it.',
|
|
444
|
+
},
|
|
445
|
+
{
|
|
446
|
+
token: 'surface-knob',
|
|
447
|
+
why: 'The switch thumb. Its job is positional, not informational — the state it conveys is where it sits, and it carries a shadow so its edge reads on any track.',
|
|
448
|
+
},
|
|
449
|
+
{
|
|
450
|
+
token: 'surface-state-hover',
|
|
451
|
+
why: 'A translucent interaction wash. It carries no information of its own — it lightens or darkens whatever surface is beneath it, and the content on top is asserted against that surface.',
|
|
452
|
+
},
|
|
453
|
+
{
|
|
454
|
+
token: 'surface-state-active',
|
|
455
|
+
why: 'A translucent interaction wash, as above. Pressed rather than hovered.',
|
|
456
|
+
},
|
|
457
|
+
{
|
|
458
|
+
token: 'line-focus-glow',
|
|
459
|
+
why: 'A soft halo behind the focus ring, at low alpha. The ring itself is the indicator and is contrast-asserted; the glow only emphasizes it.',
|
|
460
|
+
},
|
|
461
|
+
{
|
|
462
|
+
token: 'surface-subtle-hover',
|
|
463
|
+
why: 'A hover fill carries no information of its own; the label on it is text-primary, which IS asserted against it.',
|
|
464
|
+
},
|
|
465
|
+
{
|
|
466
|
+
token: 'surface-subtle-active',
|
|
467
|
+
why: 'A pressed fill carries no information of its own; the label on it is text-primary, which IS asserted against it.',
|
|
468
|
+
},
|
|
469
|
+
{ token: 'surface-scrim', why: 'A scrim has no foreground of its own; contrast is asserted on what sits above it.' },
|
|
470
|
+
{ token: 'surface-disabled', why: 'Disabled surfaces are exempt; their text is text-disabled, also exempt.' },
|
|
471
|
+
{ token: 'gradient-glow', why: 'A background atmosphere field with no text directly on it.' },
|
|
472
|
+
{ token: 'gradient-ground', why: 'A near-invisible page-ground gradient between two adjacent neutral steps.' },
|
|
473
|
+
];
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Halogen — the Tailwind namespaces the system owns.
|
|
3
|
+
*
|
|
4
|
+
* ONE source of truth, imported by both the emitter and the test that verifies
|
|
5
|
+
* the emitter. It used to be two: `fan-out.ts` held the real list and
|
|
6
|
+
* `contrast.test.ts` held a hand-copied duplicate. Adding `aspect` to the
|
|
7
|
+
* emitter made the test fail — correctly, but for the wrong reason: it was
|
|
8
|
+
* reporting that its own copy was stale. Two lists that must agree will
|
|
9
|
+
* eventually disagree, and the failure looks like a bug in the thing being
|
|
10
|
+
* tested.
|
|
11
|
+
*/
|
|
12
|
+
/**
|
|
13
|
+
* Every Tailwind namespace Halogen owns is reset to initial before ours are
|
|
14
|
+
* declared. That is what turns "use tokens" from a guideline into a compile
|
|
15
|
+
* error: after this block there is no bg-blue-500, no p-7, no rounded-3xl
|
|
16
|
+
* — those utilities do not exist.
|
|
17
|
+
*/
|
|
18
|
+
export const REPLACED_NAMESPACES = [
|
|
19
|
+
'color', 'spacing', 'radius', 'font', 'text', 'font-weight', 'tracking',
|
|
20
|
+
'leading', 'shadow', 'ease', 'breakpoint', 'container',
|
|
21
|
+
// `duration-*` reads from `--transition-duration-*`, not `--duration-*`.
|
|
22
|
+
// Getting that wrong is invisible: `duration-fast` compiles to nothing and
|
|
23
|
+
// the transition silently falls back to the browser default.
|
|
24
|
+
'transition-duration',
|
|
25
|
+
// Media ratios. Tailwind ships aspect-video and aspect-square; replacing the
|
|
26
|
+
// namespace means those come from the token scale instead, and a fourth ratio
|
|
27
|
+
// has to be added to the scale rather than written inline.
|
|
28
|
+
'aspect',
|
|
29
|
+
// Stacking order and text measure. Both were reachable as CSS variables and
|
|
30
|
+
// invisible as utilities: `z-sticky` and `max-w-measure` compiled to nothing.
|
|
31
|
+
'z-index',
|
|
32
|
+
];
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* Reset with NOTHING declared back — deliberately, so these utilities cannot
|
|
36
|
+
* compile at all.
|
|
37
|
+
*
|
|
38
|
+
* This is the aesthetic direction enforced by the compiler rather than by a
|
|
39
|
+
* code review. §36 rejects glassmorphism and excessive blur outright, so
|
|
40
|
+
* backdrop-blur-md should not be a thing a developer can reach for and be
|
|
41
|
+
* talked out of later; it should not exist. Same for keyframe animation: Halogen
|
|
42
|
+
* motion is transitions, and animate-bounce is on the anti-pattern list.
|
|
43
|
+
*
|
|
44
|
+
* Adding a value here is a design decision, not a convenience. Phase 4's spinner
|
|
45
|
+
* will need one keyframe, and it will be added as a named token on purpose.
|
|
46
|
+
*/
|
|
47
|
+
export const ABOLISHED_NAMESPACES = ['blur'];
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Reset, then reopened to an explicit whitelist — nothing else in the namespace
|
|
51
|
+
* can compile.
|
|
52
|
+
*
|
|
53
|
+
* animate exists for exactly one thing: a loading spinner, which is a genuine
|
|
54
|
+
* product need that cannot be expressed as a transition. animate-spin works;
|
|
55
|
+
* animate-bounce and animate-pulse still do not exist, because the aesthetic
|
|
56
|
+
* direction rejects bounce and constant movement. Widening this list is a design
|
|
57
|
+
* decision that has to be argued for, which is the point of keeping it a list.
|
|
58
|
+
*/
|
|
59
|
+
export const BOUNDED_NAMESPACES: Record<string, string[]> = { animate: ['spin', 'pulse'] };
|