@primitiv-ui/tokens 0.1.32 → 0.1.34

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,31 +24,6 @@ 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
-
34
- /** WCAG relative luminance (sRGB, 0-1). */
35
- function luminance(hex: string): number {
36
- const channel = (value: number) => {
37
- const c = value / 255
38
- return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
39
- }
40
- const r = parseInt(hex.slice(1, 3), 16)
41
- const g = parseInt(hex.slice(3, 5), 16)
42
- const b = parseInt(hex.slice(5, 7), 16)
43
- return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
44
- }
45
-
46
- /** WCAG contrast ratio between two colours (1-21). */
47
- function contrastRatio(a: string, b: string): number {
48
- const [lighter, darker] = [luminance(a), luminance(b)].sort((x, y) => y - x)
49
- return (lighter + 0.05) / (darker + 0.05)
50
- }
51
-
52
27
  // Regression guard for the swapped-index bug (verified against the Figma
53
28
  // Intent collection, which always resolves color/neutral/* through the
54
29
  // Light-mode ramp — Dark-mode Intent variables pick a *different* neutral
@@ -95,20 +70,6 @@ describe.each([
95
70
  describe('color/neutral-alpha ramp', () => {
96
71
  const steps = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'] as const
97
72
 
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
73
  // The inverse companion: every step is the OTHER theme's neutral-alpha step,
113
74
  // so each mode carries the opposite veil (soft-white in light, soft-black in
114
75
  // dark). It exists for Figma's dark step-swap idiom — the file resolves the
@@ -138,22 +99,6 @@ describe('color/neutral-alpha ramp', () => {
138
99
  describe('color/brand-alpha ramp', () => {
139
100
  const steps = ['50', '100', '200', '300', '400', '500', '600', '700', '800', '900'] as const
140
101
 
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
102
  it('resolves identically in light and dark mode, so it needs no inverse companion', () => {
158
103
  for (const step of steps) {
159
104
  expect(resolveRef('light', `{color.brand-alpha.${step}}`)).toBe(resolveRef('dark', `{color.brand-alpha.${step}}`))
@@ -199,74 +144,39 @@ describe.each([
199
144
  })
200
145
  })
201
146
 
202
- // `surface/selected` (the ToggleGroup thumb) and `content/on-selected` (its
203
- // label) are the deliberate exception: RFC 0017 requires them to read as
204
- // light-surface-with-dark-label in *both* themes, so the thumb keeps lifting
205
- // off a track that itself goes dark in dark mode. A near-1 contrast ratio
206
- // between the two modes confirms neither one flipped.
207
- describe('surface/selected and content/on-selected', () => {
208
- it('stay visually consistent (not flipped) between light and dark mode', () => {
209
- for (const token of ['surface/selected', 'content/on-selected'] as const) {
210
- const ratio = contrastRatio(intentColor('light', token), intentColor('dark', token))
211
- expect(ratio).toBeLessThan(1.25)
212
- }
213
- })
214
- })
215
147
 
216
148
  // The swapped-index guard above only asserts light ≠ dark, which is too weak for
217
149
  // an OPAQUE background: `table/row/selected` was light #cbe5ff / dark #c8edff —
218
150
  // 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)
240
- })
241
- })
151
+
242
152
 
243
153
  /*
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`.
154
+ * The sibling property the block above does not cover: a disabled link must be
155
+ * QUIETER than a resting one.
250
156
  *
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.
157
+ * Both modes shipped `disabled` aliasing the exact step `default` does
158
+ * (`brand.500` light, `brand.600` dark), so a disabled link rendered
159
+ * identically to an active one — indistinguishable to everyone, and to a
160
+ * keyboard user the only cue that it is unavailable. The states block could
161
+ * not see it: it only ever compared hover and active.
256
162
  *
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.
163
+ * The companion assertion — that a disabled link is measurably quieter than a
164
+ * resting one — needs the engine's contrast maths, so it lives in
165
+ * `crates/harmoni-core/tests/token_source_colours.rs`. It is also meaningful
166
+ * only for `link`, whose foreground genuinely sits on the page surface;
167
+ * `primary`/`secondary`/`danger` foregrounds sit on their own disabled
168
+ * backgrounds. The equality below needs no such care, so it covers all four —
169
+ * and it is the invariant that would have caught this on any of them.
260
170
  */
261
- describe('action/link/foreground interactive states', () => {
171
+ describe('disabled action foregrounds', () => {
262
172
  it.each(['light', 'dark'] as const)(
263
- '%s mode: hover and active are more prominent than the resting state',
173
+ '%s mode: no disabled foreground reuses its own resting colour',
264
174
  (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'))
175
+ for (const family of ['primary', 'secondary', 'danger', 'link']) {
176
+ const resting = intentColor(mode, `action/${family}/foreground/default`)
177
+ const disabled = intentColor(mode, `action/${family}/foreground/disabled`)
178
+ expect(disabled, `action/${family}/foreground`).not.toBe(resting)
179
+ }
270
180
  },
271
181
  )
272
182
  })
@@ -3,15 +3,15 @@
3
3
  "color": {
4
4
  "strong": {
5
5
  "$type": "color",
6
- "$value": "#00000014"
6
+ "$value": "oklch(0 0 0 / 0.0784)"
7
7
  },
8
8
  "medium": {
9
9
  "$type": "color",
10
- "$value": "#0000000f"
10
+ "$value": "oklch(0 0 0 / 0.0588)"
11
11
  },
12
12
  "soft": {
13
13
  "$type": "color",
14
- "$value": "#0000000a"
14
+ "$value": "oklch(0 0 0 / 0.0392)"
15
15
  }
16
16
  },
17
17
  "1": {
package/src/intent.json CHANGED
@@ -169,7 +169,7 @@
169
169
  },
170
170
  "disabled": {
171
171
  "$type": "color",
172
- "$value": "{color.brand.500}"
172
+ "$value": "{color.neutral.400}"
173
173
  },
174
174
  "visited": {
175
175
  "$type": "color",
@@ -376,7 +376,7 @@
376
376
  },
377
377
  "scrim": {
378
378
  "$type": "color",
379
- "$value": "#00000080"
379
+ "$value": "oklch(0 0 0 / 0.502)"
380
380
  },
381
381
  "content": {
382
382
  "primary": {
@@ -669,7 +669,7 @@
669
669
  },
670
670
  "disabled": {
671
671
  "$type": "color",
672
- "$value": "{color.brand.600}"
672
+ "$value": "{color.neutral.500}"
673
673
  },
674
674
  "visited": {
675
675
  "$type": "color",
@@ -876,7 +876,7 @@
876
876
  },
877
877
  "scrim": {
878
878
  "$type": "color",
879
- "$value": "#00000080"
879
+ "$value": "oklch(0 0 0 / 0.502)"
880
880
  },
881
881
  "content": {
882
882
  "primary": {