@primitiv-ui/tokens 0.1.27 → 0.1.28

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/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@primitiv-ui/tokens",
3
- "version": "0.1.27",
3
+ "version": "0.1.28",
4
4
  "description": "The DTCG-conformant design-token layer for the Primitiv ecosystem, plus helpers that transform Figma variable exports into DTCG JSON.",
5
5
  "type": "module",
6
6
  "main": "src/index.ts",
@@ -0,0 +1,113 @@
1
+ import palette from './palette.json'
2
+ import intent from './intent.json'
3
+
4
+ type Mode = 'light' | 'dark'
5
+ type DtcgNode = { $type?: string; $value?: unknown; [key: string]: unknown }
6
+
7
+ /**
8
+ * Resolve a DTCG alias (`{color.neutral.900}`) or literal value against a
9
+ * mode's palette tree, following chained aliases.
10
+ */
11
+ function resolveRef(mode: Mode, ref: string): string {
12
+ if (!ref.startsWith('{')) return ref
13
+ const path = ref.slice(1, -1).split('.')
14
+ let node: DtcgNode = (palette as Record<Mode, DtcgNode>)[mode]
15
+ for (const key of path) node = node[key] as DtcgNode
16
+ return resolveRef(mode, node.$value as string)
17
+ }
18
+
19
+ /** Resolve a slash-separated Intent token (`"surface/raised"`) to its hex colour in `mode`. */
20
+ function intentColor(mode: Mode, token: string): string {
21
+ let node: DtcgNode = (intent as Record<Mode, DtcgNode>)[mode]
22
+ for (const key of token.split('/')) node = node[key] as DtcgNode
23
+ const value = node.$value as string
24
+ return value.startsWith('{') ? resolveRef(mode, value) : value
25
+ }
26
+
27
+ /** WCAG relative luminance (sRGB, 0-1). */
28
+ function luminance(hex: string): number {
29
+ const channel = (value: number) => {
30
+ const c = value / 255
31
+ return c <= 0.03928 ? c / 12.92 : ((c + 0.055) / 1.055) ** 2.4
32
+ }
33
+ const r = parseInt(hex.slice(1, 3), 16)
34
+ const g = parseInt(hex.slice(3, 5), 16)
35
+ const b = parseInt(hex.slice(5, 7), 16)
36
+ return 0.2126 * channel(r) + 0.7152 * channel(g) + 0.0722 * channel(b)
37
+ }
38
+
39
+ /** WCAG contrast ratio between two colours (1-21). */
40
+ function contrastRatio(a: string, b: string): number {
41
+ const [lighter, darker] = [luminance(a), luminance(b)].sort((x, y) => y - x)
42
+ return (lighter + 0.05) / (darker + 0.05)
43
+ }
44
+
45
+ // Regression guard for the swapped-index bug (verified against the Figma
46
+ // Intent collection, which always resolves color/neutral/* through the
47
+ // Light-mode ramp — Dark-mode Intent variables pick a *different* neutral
48
+ // step, not a different ramp). Our codebase instead carries a separately
49
+ // generated Dark ramp that is already the Light ramp's near-mirror
50
+ // (`dark.neutral.50 ≈ light.neutral.900`), so reproducing the same visual
51
+ // role in Dark mode means picking the same or a hex-nearest step in *our*
52
+ // Dark ramp — not re-swapping the index a second time. Several tokens were
53
+ // swapped a second time, cancelling the intended flip and landing both
54
+ // modes on the same (or a same-toned) colour.
55
+ describe.each(['content/primary', 'content/secondary'] as const)('%s', (token) => {
56
+ it('resolves to a different colour in light vs dark mode', () => {
57
+ expect(intentColor('light', token)).not.toBe(intentColor('dark', token))
58
+ })
59
+
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
+ })
67
+
68
+ // Surfaces and borders that are supposed to track the theme (paler in light,
69
+ // darker in dark) rather than stay fixed.
70
+ describe.each([
71
+ 'surface/subtle',
72
+ 'surface/raised',
73
+ 'surface/overlay',
74
+ 'surface/inverse',
75
+ 'surface/sunken',
76
+ 'border/subtle',
77
+ 'border/default',
78
+ 'table/row/stripe',
79
+ 'table/row/hover',
80
+ 'action/secondary/default',
81
+ 'action/secondary/hover',
82
+ 'action/secondary/active',
83
+ 'action/secondary/disabled',
84
+ 'action/secondary/foreground/default',
85
+ 'action/secondary/border/default',
86
+ 'action/secondary/border/hover',
87
+ 'action/secondary/border/disabled',
88
+ ] as const)('%s', (token) => {
89
+ it('resolves to a different colour in light vs dark mode', () => {
90
+ expect(intentColor('light', token)).not.toBe(intentColor('dark', token))
91
+ })
92
+ })
93
+
94
+ // `surface/selected` (the ToggleGroup thumb) and `content/on-selected` (its
95
+ // label) are the deliberate exception: RFC 0017 requires them to read as
96
+ // light-surface-with-dark-label in *both* themes, so the thumb keeps lifting
97
+ // off a track that itself goes dark in dark mode. A near-1 contrast ratio
98
+ // between the two modes confirms neither one flipped.
99
+ describe('surface/selected and content/on-selected', () => {
100
+ it('stay visually consistent (not flipped) between light and dark mode', () => {
101
+ for (const token of ['surface/selected', 'content/on-selected'] as const) {
102
+ const ratio = contrastRatio(intentColor('light', token), intentColor('dark', token))
103
+ expect(ratio).toBeLessThan(1.25)
104
+ }
105
+ })
106
+
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
+ }
112
+ })
113
+ })
package/src/intent.json CHANGED
@@ -342,24 +342,24 @@
342
342
  "secondary": {
343
343
  "default": {
344
344
  "$type": "color",
345
- "$value": "{color.neutral.800}"
345
+ "$value": "{color.neutral.100}"
346
346
  },
347
347
  "hover": {
348
348
  "$type": "color",
349
- "$value": "{color.neutral.700}"
349
+ "$value": "{color.neutral.200}"
350
350
  },
351
351
  "active": {
352
352
  "$type": "color",
353
- "$value": "{color.neutral.600}"
353
+ "$value": "{color.neutral.400}"
354
354
  },
355
355
  "disabled": {
356
356
  "$type": "color",
357
- "$value": "{color.neutral.900}"
357
+ "$value": "{color.neutral.50}"
358
358
  },
359
359
  "foreground": {
360
360
  "default": {
361
361
  "$type": "color",
362
- "$value": "{color.neutral.50}"
362
+ "$value": "{color.neutral.900}"
363
363
  },
364
364
  "disabled": {
365
365
  "$type": "color",
@@ -369,11 +369,11 @@
369
369
  "border": {
370
370
  "default": {
371
371
  "$type": "color",
372
- "$value": "{color.neutral.700}"
372
+ "$value": "{color.neutral.200}"
373
373
  },
374
374
  "hover": {
375
375
  "$type": "color",
376
- "$value": "{color.neutral.600}"
376
+ "$value": "{color.neutral.400}"
377
377
  },
378
378
  "active": {
379
379
  "$type": "color",
@@ -381,7 +381,7 @@
381
381
  },
382
382
  "disabled": {
383
383
  "$type": "color",
384
- "$value": "{color.neutral.800}"
384
+ "$value": "{color.neutral.100}"
385
385
  }
386
386
  }
387
387
  },
@@ -463,27 +463,27 @@
463
463
  },
464
464
  "subtle": {
465
465
  "$type": "color",
466
- "$value": "{color.neutral.800}"
466
+ "$value": "{color.neutral.100}"
467
467
  },
468
468
  "raised": {
469
469
  "$type": "color",
470
- "$value": "{color.neutral.900}"
470
+ "$value": "{color.neutral.50}"
471
471
  },
472
472
  "overlay": {
473
473
  "$type": "color",
474
- "$value": "{color.neutral.50}"
474
+ "$value": "{color.neutral.900}"
475
475
  },
476
476
  "inverse": {
477
477
  "$type": "color",
478
- "$value": "{color.neutral.100}"
478
+ "$value": "{color.neutral.800}"
479
479
  },
480
480
  "sunken": {
481
481
  "$type": "color",
482
- "$value": "{color.neutral.800}"
482
+ "$value": "{color.neutral.100}"
483
483
  },
484
484
  "selected": {
485
485
  "$type": "color",
486
- "$value": "{color.neutral.50}"
486
+ "$value": "{color.neutral.900}"
487
487
  }
488
488
  },
489
489
  "scrim": {
@@ -493,11 +493,11 @@
493
493
  "content": {
494
494
  "primary": {
495
495
  "$type": "color",
496
- "$value": "{color.neutral.50}"
496
+ "$value": "{color.neutral.900}"
497
497
  },
498
498
  "secondary": {
499
499
  "$type": "color",
500
- "$value": "{color.neutral.200}"
500
+ "$value": "{color.neutral.700}"
501
501
  },
502
502
  "muted": {
503
503
  "$type": "color",
@@ -517,7 +517,7 @@
517
517
  },
518
518
  "on-selected": {
519
519
  "$type": "color",
520
- "$value": "{color.neutral.900}"
520
+ "$value": "{color.neutral.50}"
521
521
  },
522
522
  "error": {
523
523
  "$type": "color",
@@ -536,11 +536,11 @@
536
536
  "row": {
537
537
  "stripe": {
538
538
  "$type": "color",
539
- "$value": "{color.neutral.900}"
539
+ "$value": "{color.neutral.50}"
540
540
  },
541
541
  "hover": {
542
542
  "$type": "color",
543
- "$value": "{color.neutral.800}"
543
+ "$value": "{color.neutral.100}"
544
544
  },
545
545
  "selected": {
546
546
  "$type": "color",
@@ -557,11 +557,11 @@
557
557
  "border": {
558
558
  "subtle": {
559
559
  "$type": "color",
560
- "$value": "{color.neutral.700}"
560
+ "$value": "{color.neutral.200}"
561
561
  },
562
562
  "default": {
563
563
  "$type": "color",
564
- "$value": "{color.neutral.600}"
564
+ "$value": "{color.neutral.400}"
565
565
  },
566
566
  "strong": {
567
567
  "$type": "color",