@primitiv-ui/tokens 0.1.26 → 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 +1 -1
- package/src/dark-mode-content.test.ts +113 -0
- package/src/intent.json +21 -21
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@primitiv-ui/tokens",
|
|
3
|
-
"version": "0.1.
|
|
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.
|
|
345
|
+
"$value": "{color.neutral.100}"
|
|
346
346
|
},
|
|
347
347
|
"hover": {
|
|
348
348
|
"$type": "color",
|
|
349
|
-
"$value": "{color.neutral.
|
|
349
|
+
"$value": "{color.neutral.200}"
|
|
350
350
|
},
|
|
351
351
|
"active": {
|
|
352
352
|
"$type": "color",
|
|
353
|
-
"$value": "{color.neutral.
|
|
353
|
+
"$value": "{color.neutral.400}"
|
|
354
354
|
},
|
|
355
355
|
"disabled": {
|
|
356
356
|
"$type": "color",
|
|
357
|
-
"$value": "{color.neutral.
|
|
357
|
+
"$value": "{color.neutral.50}"
|
|
358
358
|
},
|
|
359
359
|
"foreground": {
|
|
360
360
|
"default": {
|
|
361
361
|
"$type": "color",
|
|
362
|
-
"$value": "{color.neutral.
|
|
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.
|
|
372
|
+
"$value": "{color.neutral.200}"
|
|
373
373
|
},
|
|
374
374
|
"hover": {
|
|
375
375
|
"$type": "color",
|
|
376
|
-
"$value": "{color.neutral.
|
|
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.
|
|
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.
|
|
466
|
+
"$value": "{color.neutral.100}"
|
|
467
467
|
},
|
|
468
468
|
"raised": {
|
|
469
469
|
"$type": "color",
|
|
470
|
-
"$value": "{color.neutral.
|
|
470
|
+
"$value": "{color.neutral.50}"
|
|
471
471
|
},
|
|
472
472
|
"overlay": {
|
|
473
473
|
"$type": "color",
|
|
474
|
-
"$value": "{color.neutral.
|
|
474
|
+
"$value": "{color.neutral.900}"
|
|
475
475
|
},
|
|
476
476
|
"inverse": {
|
|
477
477
|
"$type": "color",
|
|
478
|
-
"$value": "{color.neutral.
|
|
478
|
+
"$value": "{color.neutral.800}"
|
|
479
479
|
},
|
|
480
480
|
"sunken": {
|
|
481
481
|
"$type": "color",
|
|
482
|
-
"$value": "{color.neutral.
|
|
482
|
+
"$value": "{color.neutral.100}"
|
|
483
483
|
},
|
|
484
484
|
"selected": {
|
|
485
485
|
"$type": "color",
|
|
486
|
-
"$value": "{color.neutral.
|
|
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.
|
|
496
|
+
"$value": "{color.neutral.900}"
|
|
497
497
|
},
|
|
498
498
|
"secondary": {
|
|
499
499
|
"$type": "color",
|
|
500
|
-
"$value": "{color.neutral.
|
|
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.
|
|
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.
|
|
539
|
+
"$value": "{color.neutral.50}"
|
|
540
540
|
},
|
|
541
541
|
"hover": {
|
|
542
542
|
"$type": "color",
|
|
543
|
-
"$value": "{color.neutral.
|
|
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.
|
|
560
|
+
"$value": "{color.neutral.200}"
|
|
561
561
|
},
|
|
562
562
|
"default": {
|
|
563
563
|
"$type": "color",
|
|
564
|
-
"$value": "{color.neutral.
|
|
564
|
+
"$value": "{color.neutral.400}"
|
|
565
565
|
},
|
|
566
566
|
"strong": {
|
|
567
567
|
"$type": "color",
|