@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.
@@ -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
- 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', () => {
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
- it('content/on-selected meets WCAG AA (4.5:1) against surface/selected in both modes', () => {
151
- for (const mode of ['light', 'dark'] as const) {
152
- const ratio = contrastRatio(intentColor(mode, 'content/on-selected'), intentColor(mode, 'surface/selected'))
153
- expect(ratio).toBeGreaterThanOrEqual(4.5)
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
+ })