@primitiv-ui/tokens 0.1.33 → 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.
- package/package.json +1 -1
- package/src/context.json +448 -8
- package/src/dark-mode-content.test.ts +22 -112
- package/src/elevation.json +3 -3
- package/src/intent.json +4 -4
- package/src/palette.json +188 -188
|
@@ -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
|
-
|
|
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
|
-
*
|
|
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
|
-
*
|
|
252
|
-
*
|
|
253
|
-
*
|
|
254
|
-
*
|
|
255
|
-
*
|
|
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
|
-
*
|
|
258
|
-
*
|
|
259
|
-
*
|
|
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
|
|
171
|
+
describe('disabled action foregrounds', () => {
|
|
262
172
|
it.each(['light', 'dark'] as const)(
|
|
263
|
-
'%s mode:
|
|
173
|
+
'%s mode: no disabled foreground reuses its own resting colour',
|
|
264
174
|
(mode) => {
|
|
265
|
-
const
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
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
|
})
|
package/src/elevation.json
CHANGED
|
@@ -3,15 +3,15 @@
|
|
|
3
3
|
"color": {
|
|
4
4
|
"strong": {
|
|
5
5
|
"$type": "color",
|
|
6
|
-
"$value": "
|
|
6
|
+
"$value": "oklch(0 0 0 / 0.0784)"
|
|
7
7
|
},
|
|
8
8
|
"medium": {
|
|
9
9
|
"$type": "color",
|
|
10
|
-
"$value": "
|
|
10
|
+
"$value": "oklch(0 0 0 / 0.0588)"
|
|
11
11
|
},
|
|
12
12
|
"soft": {
|
|
13
13
|
"$type": "color",
|
|
14
|
-
"$value": "
|
|
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.
|
|
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": "
|
|
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.
|
|
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": "
|
|
879
|
+
"$value": "oklch(0 0 0 / 0.502)"
|
|
880
880
|
},
|
|
881
881
|
"content": {
|
|
882
882
|
"primary": {
|