@primitiv-ui/tokens 0.1.28 → 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.
@@ -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,15 +59,110 @@ 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
- describe.each(['content/primary', 'content/secondary'] as const)('%s', (token) => {
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', () => {
88
+ })
89
+
90
+ // The neutral alpha ramp (Path A): the mode's veil colour — the neutral ramp's
91
+ // 900 end (soft-black in light, soft-white in dark) — held constant across all
92
+ // ten steps while only the alpha byte climbs. Guards both the anchor (the rgb
93
+ // part must be the veil, so the ramp inverts with the theme) and the curve
94
+ // (strictly increasing opacity).
95
+ describe('color/neutral-alpha ramp', () => {
96
+ const steps = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'] as const
97
+
98
+ it.each(['light', 'dark'] as const)("%s mode anchors every step on the mode's veil colour", (mode) => {
99
+ const veil = resolveRef(mode, '{color.neutral.900}')
100
+ for (const step of steps) {
101
+ expect(resolveRef(mode, `{color.neutral-alpha.${step}}`).slice(0, 7)).toBe(veil)
102
+ }
103
+ })
104
+
105
+ it.each(['light', 'dark'] as const)('%s mode climbs a strictly increasing alpha curve', (mode) => {
106
+ const alphas = steps.map((step) => parseInt(resolveRef(mode, `{color.neutral-alpha.${step}}`).slice(7, 9), 16))
107
+ for (let i = 1; i < alphas.length; i++) {
108
+ expect(alphas[i]).toBeGreaterThan(alphas[i - 1])
109
+ }
110
+ })
111
+
112
+ // The inverse companion: every step is the OTHER theme's neutral-alpha step,
113
+ // so each mode carries the opposite veil (soft-white in light, soft-black in
114
+ // dark). It exists for Figma's dark step-swap idiom — the file resolves the
115
+ // Palette collection through Light mode even on dark frames, so dark Intent
116
+ // variables alias neutral-alpha-inverse to reach the white veil — and the
117
+ // exact mirror keeps a variables backup a no-op.
118
+ it.each([
119
+ ['light', 'dark'],
120
+ ['dark', 'light'],
121
+ ] as const)("neutral-alpha-inverse in %s mode mirrors the %s mode's neutral-alpha ramp exactly", (mode, other) => {
122
+ for (const step of steps) {
123
+ expect(resolveRef(mode, `{color.neutral-alpha-inverse.${step}}`)).toBe(
124
+ resolveRef(other, `{color.neutral-alpha.${step}}`),
125
+ )
126
+ }
127
+ })
128
+ })
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', () => {
61
164
  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)
165
+ expect(intentColor(mode, 'action/primary/soft')).toBe(resolveRef(mode, '{color.brand-alpha.300}'))
64
166
  }
65
167
  })
66
168
  })
@@ -71,12 +173,14 @@ describe.each([
71
173
  'surface/subtle',
72
174
  'surface/raised',
73
175
  'surface/overlay',
176
+ 'surface/floating',
74
177
  'surface/inverse',
75
178
  'surface/sunken',
76
179
  'border/subtle',
77
180
  'border/default',
78
181
  'table/row/stripe',
79
182
  'table/row/hover',
183
+ 'table/row/selected',
80
184
  'action/secondary/default',
81
185
  'action/secondary/hover',
82
186
  'action/secondary/active',
@@ -85,6 +189,10 @@ describe.each([
85
189
  'action/secondary/border/default',
86
190
  'action/secondary/border/hover',
87
191
  'action/secondary/border/disabled',
192
+ 'action/ghost/hover',
193
+ 'action/ghost/active',
194
+ 'tree/row/hover',
195
+ 'tree/row/selected',
88
196
  ] as const)('%s', (token) => {
89
197
  it('resolves to a different colour in light vs dark mode', () => {
90
198
  expect(intentColor('light', token)).not.toBe(intentColor('dark', token))
@@ -103,11 +211,62 @@ describe('surface/selected and content/on-selected', () => {
103
211
  expect(ratio).toBeLessThan(1.25)
104
212
  }
105
213
  })
214
+ })
106
215
 
107
- it('content/on-selected meets WCAG AA (4.5:1) against surface/selected in both modes', () => {
108
- for (const mode of ['light', 'dark'] as const) {
109
- const ratio = contrastRatio(intentColor(mode, 'content/on-selected'), intentColor(mode, 'surface/selected'))
110
- expect(ratio).toBeGreaterThanOrEqual(4.5)
111
- }
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)
112
240
  })
113
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
+ })