@primitiv-ui/tokens 0.1.29 → 0.1.30
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/README.md +2 -2
- package/package.json +1 -1
- package/src/breakpoint.json +10 -0
- package/src/code-typography.test.ts +64 -0
- package/src/context.json +2610 -226
- package/src/dark-mode-content.test.ts +128 -12
- package/src/dtcg.ts +1 -1
- package/src/heading-tracking.test.ts +88 -0
- package/src/intent.json +342 -86
- package/src/palette.json +167 -83
- package/src/primitives.json +8 -0
- package/src/swatch-sizing.test.ts +76 -0
|
@@ -24,6 +24,13 @@ function intentColor(mode: Mode, token: string): string {
|
|
|
24
24
|
return value.startsWith('{') ? resolveRef(mode, value) : value
|
|
25
25
|
}
|
|
26
26
|
|
|
27
|
+
// These two are for the properties only this file can see — a background that
|
|
28
|
+
// fails to track the theme, a state ramp running the wrong way, a pair that must
|
|
29
|
+
// stay consistent ACROSS modes. Every accessibility *floor* moved to
|
|
30
|
+
// `crates/harmoni-core/tests/intent_roles.rs`, where it is measured with the
|
|
31
|
+
// engine's own contrast maths against the step the engine derives, so a role
|
|
32
|
+
// cannot pass here and be wrong there.
|
|
33
|
+
|
|
27
34
|
/** WCAG relative luminance (sRGB, 0-1). */
|
|
28
35
|
function luminance(hex: string): number {
|
|
29
36
|
const channel = (value: number) => {
|
|
@@ -52,17 +59,32 @@ function contrastRatio(a: string, b: string): number {
|
|
|
52
59
|
// Dark ramp — not re-swapping the index a second time. Several tokens were
|
|
53
60
|
// swapped a second time, cancelling the intended flip and landing both
|
|
54
61
|
// modes on the same (or a same-toned) colour.
|
|
55
|
-
|
|
62
|
+
// `content/muted` and `action/link/foreground/default` were added after a
|
|
63
|
+
// contrast audit (docs/interface-audit.md, 2026-08-15 `better-colors`) found
|
|
64
|
+
// both failing in dark mode while passing in light — 4.20:1 and 3.78:1 against
|
|
65
|
+
// `surface/default`, missing 4.5:1. Neither was covered here, which is why the
|
|
66
|
+
// guard above did not catch them.
|
|
67
|
+
//
|
|
68
|
+
// CORRECTION (2026-08-17): an earlier version of this comment claimed the link's
|
|
69
|
+
// hover and active steps were "already correctly theme-inverted (dark 500 → 300 →
|
|
70
|
+
// 200 lighter)". They were not — in the DARK ramp a higher step is lighter, so
|
|
71
|
+
// 500 → 300 → 200 descends toward black and measured 5.95:1 → 1.74:1 → 1.33:1.
|
|
72
|
+
// The whole dark state set was broken, not just `default`, and fixing `default`
|
|
73
|
+
// alone made the jump to a near-black hover more obvious rather than less.
|
|
74
|
+
//
|
|
75
|
+
// The lesson is in the `action/link/foreground` describe block at the end of this
|
|
76
|
+
// file: a per-token contrast check cannot see a state ramp running the wrong way,
|
|
77
|
+
// because it only ever measures the resting value.
|
|
78
|
+
describe.each([
|
|
79
|
+
'content/primary',
|
|
80
|
+
'content/secondary',
|
|
81
|
+
'content/muted',
|
|
82
|
+
'action/link/foreground/default',
|
|
83
|
+
] as const)('%s', (token) => {
|
|
56
84
|
it('resolves to a different colour in light vs dark mode', () => {
|
|
57
85
|
expect(intentColor('light', token)).not.toBe(intentColor('dark', token))
|
|
58
86
|
})
|
|
59
87
|
|
|
60
|
-
it('meets WCAG AA (4.5:1) against surface/default in both modes', () => {
|
|
61
|
-
for (const mode of ['light', 'dark'] as const) {
|
|
62
|
-
const ratio = contrastRatio(intentColor(mode, token), intentColor(mode, 'surface/default'))
|
|
63
|
-
expect(ratio).toBeGreaterThanOrEqual(4.5)
|
|
64
|
-
}
|
|
65
|
-
})
|
|
66
88
|
})
|
|
67
89
|
|
|
68
90
|
// The neutral alpha ramp (Path A): the mode's veil colour — the neutral ramp's
|
|
@@ -105,6 +127,46 @@ describe('color/neutral-alpha ramp', () => {
|
|
|
105
127
|
})
|
|
106
128
|
})
|
|
107
129
|
|
|
130
|
+
// The brand alpha ramp: the same construction as neutral-alpha (one anchor
|
|
131
|
+
// colour, ten climbing alpha bytes) but anchored on `color.brand.500`, which is
|
|
132
|
+
// the SAME hex in both themes. That is what makes this ramp the exception to the
|
|
133
|
+
// mirror rule above — a `brand-alpha-inverse` companion would be identical to
|
|
134
|
+
// `brand-alpha`, so it does not exist. Added for `action/primary/soft`, the
|
|
135
|
+
// Stepper's current-step halo: `border/focus` and `action/primary/default` both
|
|
136
|
+
// resolve to #236ce1, so a primary ring on the current marker would be
|
|
137
|
+
// pixel-identical to the focus ring; a soft tint is what separates them.
|
|
138
|
+
describe('color/brand-alpha ramp', () => {
|
|
139
|
+
const steps = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'] as const
|
|
140
|
+
|
|
141
|
+
it.each(['light', 'dark'] as const)('%s mode anchors every step on the brand colour', (mode) => {
|
|
142
|
+
const brand = resolveRef(mode, '{color.brand.500}')
|
|
143
|
+
for (const step of steps) {
|
|
144
|
+
expect(resolveRef(mode, `{color.brand-alpha.${step}}`).slice(0, 7)).toBe(brand)
|
|
145
|
+
}
|
|
146
|
+
})
|
|
147
|
+
|
|
148
|
+
it.each(['light', 'dark'] as const)('%s mode climbs a strictly increasing alpha curve', (mode) => {
|
|
149
|
+
const alphas = steps.map((step) => parseInt(resolveRef(mode, `{color.brand-alpha.${step}}`).slice(7, 9), 16))
|
|
150
|
+
for (let i = 1; i < alphas.length; i++) {
|
|
151
|
+
expect(alphas[i]).toBeGreaterThan(alphas[i - 1])
|
|
152
|
+
}
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
// The reason there is no `brand-alpha-inverse`: unlike the neutral veil, the
|
|
156
|
+
// anchor does not flip with the theme, so both modes already agree.
|
|
157
|
+
it('resolves identically in light and dark mode, so it needs no inverse companion', () => {
|
|
158
|
+
for (const step of steps) {
|
|
159
|
+
expect(resolveRef('light', `{color.brand-alpha.${step}}`)).toBe(resolveRef('dark', `{color.brand-alpha.${step}}`))
|
|
160
|
+
}
|
|
161
|
+
})
|
|
162
|
+
|
|
163
|
+
it('backs action/primary/soft in both modes', () => {
|
|
164
|
+
for (const mode of ['light', 'dark'] as const) {
|
|
165
|
+
expect(intentColor(mode, 'action/primary/soft')).toBe(resolveRef(mode, '{color.brand-alpha.300}'))
|
|
166
|
+
}
|
|
167
|
+
})
|
|
168
|
+
})
|
|
169
|
+
|
|
108
170
|
// Surfaces and borders that are supposed to track the theme (paler in light,
|
|
109
171
|
// darker in dark) rather than stay fixed.
|
|
110
172
|
describe.each([
|
|
@@ -118,6 +180,7 @@ describe.each([
|
|
|
118
180
|
'border/default',
|
|
119
181
|
'table/row/stripe',
|
|
120
182
|
'table/row/hover',
|
|
183
|
+
'table/row/selected',
|
|
121
184
|
'action/secondary/default',
|
|
122
185
|
'action/secondary/hover',
|
|
123
186
|
'action/secondary/active',
|
|
@@ -128,6 +191,8 @@ describe.each([
|
|
|
128
191
|
'action/secondary/border/disabled',
|
|
129
192
|
'action/ghost/hover',
|
|
130
193
|
'action/ghost/active',
|
|
194
|
+
'tree/row/hover',
|
|
195
|
+
'tree/row/selected',
|
|
131
196
|
] as const)('%s', (token) => {
|
|
132
197
|
it('resolves to a different colour in light vs dark mode', () => {
|
|
133
198
|
expect(intentColor('light', token)).not.toBe(intentColor('dark', token))
|
|
@@ -146,11 +211,62 @@ describe('surface/selected and content/on-selected', () => {
|
|
|
146
211
|
expect(ratio).toBeLessThan(1.25)
|
|
147
212
|
}
|
|
148
213
|
})
|
|
214
|
+
})
|
|
149
215
|
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
216
|
+
// The swapped-index guard above only asserts light ≠ dark, which is too weak for
|
|
217
|
+
// an OPAQUE background: `table/row/selected` was light #cbe5ff / dark #c8edff —
|
|
218
|
+
// two different pale blues, so it passed that check while rendering a glaring
|
|
219
|
+
// pale row on a dark surface. This is the assertion that actually pins the role:
|
|
220
|
+
// an opaque background that tracks the theme must be light in light mode and dark
|
|
221
|
+
// in dark mode, not merely different.
|
|
222
|
+
//
|
|
223
|
+
// Deliberately opaque-only. The alpha state layers (`*/row/hover`,
|
|
224
|
+
// `tree/row/selected`, `action/ghost/*`) invert their ink by design — light mode
|
|
225
|
+
// tints with dark ink, dark mode with light ink — and `surface/overlay` is an
|
|
226
|
+
// inverse surface on purpose, so neither belongs here.
|
|
227
|
+
describe.each([
|
|
228
|
+
'surface/subtle',
|
|
229
|
+
'surface/raised',
|
|
230
|
+
'surface/floating',
|
|
231
|
+
'surface/sunken',
|
|
232
|
+
'action/secondary/default',
|
|
233
|
+
'table/row/stripe',
|
|
234
|
+
'table/row/selected',
|
|
235
|
+
'choice-card/selected/background',
|
|
236
|
+
] as const)('%s (opaque, theme-tracking background)', (token) => {
|
|
237
|
+
it('is light in light mode and dark in dark mode', () => {
|
|
238
|
+
expect(luminance(intentColor('light', token))).toBeGreaterThan(0.5)
|
|
239
|
+
expect(luminance(intentColor('dark', token))).toBeLessThan(0.5)
|
|
155
240
|
})
|
|
156
241
|
})
|
|
242
|
+
|
|
243
|
+
/*
|
|
244
|
+
* Interactive foreground states must gain contrast, not lose it.
|
|
245
|
+
*
|
|
246
|
+
* This exists because a per-token contrast check is not enough. The dark link
|
|
247
|
+
* ramp descended — default → hover → active resolved #5291f9 → #033a8c →
|
|
248
|
+
* #022968, i.e. 5.95:1 → 1.74:1 → 1.33:1 — and the earlier guard passed
|
|
249
|
+
* throughout, because it only ever measured `default`.
|
|
250
|
+
*
|
|
251
|
+
* The cause was reading the step numbers as if both ramps ran the same way. They
|
|
252
|
+
* do not: in the LIGHT ramp a higher step is darker, in the DARK ramp a higher
|
|
253
|
+
* step is lighter. So "500 → 300 → 200" gains contrast against white and loses
|
|
254
|
+
* it against black, and the same alias pattern that is right in one mode is
|
|
255
|
+
* exactly wrong in the other.
|
|
256
|
+
*
|
|
257
|
+
* Asserting the DIRECTION rather than the values is what makes this robust: it
|
|
258
|
+
* holds whichever steps a future palette picks, and it is mode-agnostic, so the
|
|
259
|
+
* inverted ramp cannot fool it again.
|
|
260
|
+
*/
|
|
261
|
+
describe('action/link/foreground interactive states', () => {
|
|
262
|
+
it.each(['light', 'dark'] as const)(
|
|
263
|
+
'%s mode: hover and active are more prominent than the resting state',
|
|
264
|
+
(mode) => {
|
|
265
|
+
const bg = intentColor(mode, 'surface/default')
|
|
266
|
+
const at = (state: string) =>
|
|
267
|
+
contrastRatio(intentColor(mode, `action/link/foreground/${state}`), bg)
|
|
268
|
+
expect(at('hover')).toBeGreaterThan(at('default'))
|
|
269
|
+
expect(at('active')).toBeGreaterThan(at('hover'))
|
|
270
|
+
},
|
|
271
|
+
)
|
|
272
|
+
})
|
package/src/dtcg.ts
CHANGED
|
@@ -70,7 +70,7 @@ export type AliasResolver = (variableId: string) => string[]
|
|
|
70
70
|
*
|
|
71
71
|
* Variables whose `variableCollectionId` does not match are skipped, so
|
|
72
72
|
* callers can pass the full variable list. Slash-separated names become
|
|
73
|
-
* nested objects (`font-family/heading` → `{ "font-family": { heading: {
|
|
73
|
+
* nested objects (`font-family/heading` → `{ "font-family": { heading: {...} } }`).
|
|
74
74
|
* Values are read from the collection's `defaultModeId`; multi-mode
|
|
75
75
|
* support is deferred until DTCG token sets land.
|
|
76
76
|
*
|
|
@@ -0,0 +1,88 @@
|
|
|
1
|
+
import context from './context.json'
|
|
2
|
+
import primitives from './primitives.json'
|
|
3
|
+
|
|
4
|
+
/*
|
|
5
|
+
* Heading letter-spacing (docs/interface-audit.md, 2026-08-15 `better-typography`:
|
|
6
|
+
* large headings want slightly negative tracking, and the `letter-spacing.tight` /
|
|
7
|
+
* `.tighter` primitives existed with nothing consuming them).
|
|
8
|
+
*
|
|
9
|
+
* Why this needs a test at all: tracking here is **absolute** (the primitive ramp
|
|
10
|
+
* emits `rem`, matching its `font-size` / `line-height` siblings in the same
|
|
11
|
+
* family), so the correct step differs per density mode — `heading.h1` is 18px in
|
|
12
|
+
* Dense and 88px in Spacious. That makes the source a 24-cell alias table, and a
|
|
13
|
+
* mis-keyed cell is invisible by inspection: `{letter-spacing.tighter}` looks just
|
|
14
|
+
* as plausible at h6 as at h1. The band assertion below is what ties each cell
|
|
15
|
+
* back to the reason it was chosen.
|
|
16
|
+
*/
|
|
17
|
+
|
|
18
|
+
type Mode = 'dense' | 'compact' | 'comfortable' | 'spacious'
|
|
19
|
+
type DtcgNode = { $type?: string; $value?: unknown; [key: string]: unknown }
|
|
20
|
+
|
|
21
|
+
const MODES: Mode[] = ['dense', 'compact', 'comfortable', 'spacious']
|
|
22
|
+
const LEVELS = ['h1', 'h2', 'h3', 'h4', 'h5', 'h6'] as const
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* The conventional band for display tracking, relative to the level's own font
|
|
26
|
+
* size. `LOOSEST` is the point below which a negative value stops being visible
|
|
27
|
+
* as tracking at all (so a level either sits in the band or is exactly normal);
|
|
28
|
+
* `TIGHTEST` is where letterforms start to collide. These are the two numbers the
|
|
29
|
+
* alias table was chosen against, named here so a failure reads as "this cell
|
|
30
|
+
* left the band" rather than "this cell is not -0.03".
|
|
31
|
+
*/
|
|
32
|
+
const BAND = { loosest: -0.01, tightest: -0.035 } as const
|
|
33
|
+
|
|
34
|
+
/** Resolve a DTCG alias (`{letter-spacing.snug}`) against the primitive tree. */
|
|
35
|
+
function resolvePrimitive(ref: string): number {
|
|
36
|
+
const path = ref.slice(1, -1).split('.')
|
|
37
|
+
let node: DtcgNode = primitives as DtcgNode
|
|
38
|
+
for (const key of path) node = node[key] as DtcgNode
|
|
39
|
+
return node.$value as number
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/** A heading member's value in `mode`, resolved through its alias to a raw number. */
|
|
43
|
+
function headingValue(mode: Mode, level: string, member: string): number {
|
|
44
|
+
const heading = (context as Record<Mode, DtcgNode>)[mode].heading as DtcgNode
|
|
45
|
+
const node = (heading[level] as DtcgNode)[member] as DtcgNode | undefined
|
|
46
|
+
if (node === undefined) throw new Error(`context.${mode}.heading.${level}.${member} is missing`)
|
|
47
|
+
const value = node.$value
|
|
48
|
+
return typeof value === 'string' ? resolvePrimitive(value) : (value as number)
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('heading letter-spacing', () => {
|
|
52
|
+
it.each(MODES)('%s mode: every level declares a letter-spacing', (mode) => {
|
|
53
|
+
for (const level of LEVELS) {
|
|
54
|
+
expect(() => headingValue(mode, level, 'letter-spacing')).not.toThrow()
|
|
55
|
+
}
|
|
56
|
+
})
|
|
57
|
+
|
|
58
|
+
// The band is the whole point of the token: negative tracking at display sizes,
|
|
59
|
+
// nothing at reading sizes. Expressed relative to the level's own font size,
|
|
60
|
+
// because that is the quantity the eye judges — an absolute -1.2px is a
|
|
61
|
+
// comfortable -0.025em at 48px and an unreadable -0.067em at 18px.
|
|
62
|
+
it.each(MODES)('%s mode: tracking is either normal or inside the band', (mode) => {
|
|
63
|
+
for (const level of LEVELS) {
|
|
64
|
+
const em = headingValue(mode, level, 'letter-spacing') / headingValue(mode, level, 'font-size')
|
|
65
|
+
expect(em).toBeLessThanOrEqual(0)
|
|
66
|
+
if (em !== 0) {
|
|
67
|
+
expect(em).toBeLessThanOrEqual(BAND.loosest)
|
|
68
|
+
expect(em).toBeGreaterThanOrEqual(BAND.tightest)
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
})
|
|
72
|
+
|
|
73
|
+
it.each(MODES)('%s mode: tracking loosens as the heading level descends', (mode) => {
|
|
74
|
+
const track = LEVELS.map((level) => headingValue(mode, level, 'letter-spacing'))
|
|
75
|
+
for (let i = 1; i < track.length; i++) {
|
|
76
|
+
expect(track[i]).toBeGreaterThanOrEqual(track[i - 1])
|
|
77
|
+
}
|
|
78
|
+
})
|
|
79
|
+
|
|
80
|
+
it.each(LEVELS)('%s: tracking tightens as the density mode grows', (level) => {
|
|
81
|
+
// MODES is ordered smallest type to largest, so tracking must never loosen
|
|
82
|
+
// along it — the guard against a cell copied from the wrong mode's column.
|
|
83
|
+
const track = MODES.map((mode) => headingValue(mode, level, 'letter-spacing'))
|
|
84
|
+
for (let i = 1; i < track.length; i++) {
|
|
85
|
+
expect(track[i]).toBeLessThanOrEqual(track[i - 1])
|
|
86
|
+
}
|
|
87
|
+
})
|
|
88
|
+
})
|