@astryxdesign/theme-neutral 0.5.2 → 0.5.3

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.
@@ -0,0 +1,373 @@
1
+ // Copyright (c) Meta Platforms, Inc. and affiliates.
2
+
3
+ import {describe, expect, it} from 'vitest';
4
+ import {neutralPalettes} from './neutralPalettes';
5
+ import {neutralTheme} from './neutralTheme';
6
+
7
+ function contrastRatio(foreground: string, background: string): number {
8
+ const luminance = (hex: string): number => {
9
+ const channels = [0, 2, 4].map(offset =>
10
+ Number.parseInt(hex.slice(offset + 1, offset + 3), 16),
11
+ );
12
+ return channels.reduce((sum, channel, index) => {
13
+ const srgb = channel / 255;
14
+ const linear =
15
+ srgb <= 0.04045 ? srgb / 12.92 : Math.pow((srgb + 0.055) / 1.055, 2.4);
16
+ return sum + linear * [0.2126, 0.7152, 0.0722][index];
17
+ }, 0);
18
+ };
19
+ const foregroundLuminance = luminance(foreground);
20
+ const backgroundLuminance = luminance(background);
21
+ const lighter = Math.max(foregroundLuminance, backgroundLuminance);
22
+ const darker = Math.min(foregroundLuminance, backgroundLuminance);
23
+ return (lighter + 0.05) / (darker + 0.05);
24
+ }
25
+
26
+ const statusFill = {
27
+ accent: 'var(--astryx-theme-neutral-color-status-fill-accent)',
28
+ success: 'var(--astryx-theme-neutral-color-status-fill-success)',
29
+ warning: 'var(--astryx-theme-neutral-color-status-fill-warning)',
30
+ error: 'var(--astryx-theme-neutral-color-status-fill-error)',
31
+ } as const;
32
+
33
+ const lightDark = (light: string, dark: string) =>
34
+ `light-dark(${light}, ${dark})`;
35
+
36
+ describe('neutral theme palette mappings', () => {
37
+ it('keeps semantic and categorical tokens aligned with reviewed stops', () => {
38
+ const mappings = {
39
+ '--color-background-surface': lightDark(
40
+ neutralPalettes.neutral.light[100],
41
+ neutralPalettes.neutral.dark[15],
42
+ ),
43
+ '--color-background-body': lightDark(
44
+ neutralPalettes.neutral.light[95],
45
+ neutralPalettes.neutral.dark[10],
46
+ ),
47
+ '--color-text-primary': lightDark(
48
+ neutralPalettes.neutral.light[5],
49
+ neutralPalettes.neutral.dark[100],
50
+ ),
51
+ '--color-icon-primary': lightDark(
52
+ neutralPalettes.neutral.light[5],
53
+ neutralPalettes.neutral.dark[100],
54
+ ),
55
+ '--color-success': lightDark(
56
+ neutralPalettes.green.light[40],
57
+ neutralPalettes.green.light[80],
58
+ ),
59
+ '--color-warning': lightDark(
60
+ neutralPalettes.yellow.light[40],
61
+ neutralPalettes.yellow.light[85],
62
+ ),
63
+ '--color-error': lightDark(
64
+ neutralPalettes.red.light[35],
65
+ neutralPalettes.red.dark[85],
66
+ ),
67
+ '--color-background-red': lightDark(
68
+ neutralPalettes.red.light[85],
69
+ neutralPalettes.red.dark[25],
70
+ ),
71
+ '--color-border-red': lightDark(
72
+ neutralPalettes.red.light[80],
73
+ neutralPalettes.red.light[65],
74
+ ),
75
+ '--color-icon-red': lightDark(
76
+ neutralPalettes.red.light[30],
77
+ neutralPalettes.red.dark[75],
78
+ ),
79
+ '--color-text-red': lightDark(
80
+ neutralPalettes.red.light[30],
81
+ neutralPalettes.red.dark[80],
82
+ ),
83
+ '--color-background-orange': lightDark(
84
+ neutralPalettes.orange.light[85],
85
+ neutralPalettes.orange.dark[25],
86
+ ),
87
+ '--color-border-orange': lightDark(
88
+ neutralPalettes.orange.light[85],
89
+ neutralPalettes.orange.dark[65],
90
+ ),
91
+ '--color-icon-orange': lightDark(
92
+ neutralPalettes.orange.light[30],
93
+ neutralPalettes.orange.light[75],
94
+ ),
95
+ '--color-text-orange': lightDark(
96
+ neutralPalettes.orange.light[30],
97
+ neutralPalettes.orange.dark[80],
98
+ ),
99
+ '--color-background-yellow': lightDark(
100
+ neutralPalettes.yellow.dark[90],
101
+ neutralPalettes.yellow.dark[25],
102
+ ),
103
+ '--color-border-yellow': lightDark(
104
+ neutralPalettes.yellow.dark[80],
105
+ neutralPalettes.yellow.light[65],
106
+ ),
107
+ '--color-icon-yellow': lightDark(
108
+ neutralPalettes.yellow.light[30],
109
+ neutralPalettes.yellow.light[75],
110
+ ),
111
+ '--color-text-yellow': lightDark(
112
+ neutralPalettes.yellow.light[30],
113
+ neutralPalettes.yellow.light[80],
114
+ ),
115
+ '--color-background-green': lightDark(
116
+ neutralPalettes.green.dark[85],
117
+ neutralPalettes.green.dark[25],
118
+ ),
119
+ '--color-border-green': lightDark(
120
+ neutralPalettes.green.dark[80],
121
+ neutralPalettes.green.light[65],
122
+ ),
123
+ '--color-icon-green': lightDark(
124
+ neutralPalettes.green.light[30],
125
+ neutralPalettes.green.light[75],
126
+ ),
127
+ '--color-text-green': lightDark(
128
+ neutralPalettes.green.light[30],
129
+ neutralPalettes.green.light[75],
130
+ ),
131
+ '--color-background-teal': lightDark(
132
+ neutralPalettes.teal.light[85],
133
+ neutralPalettes.teal.dark[25],
134
+ ),
135
+ '--color-border-teal': lightDark(
136
+ neutralPalettes.teal.light[80],
137
+ neutralPalettes.teal.dark[65],
138
+ ),
139
+ '--color-icon-teal': lightDark(
140
+ neutralPalettes.teal.light[30],
141
+ neutralPalettes.teal.dark[75],
142
+ ),
143
+ '--color-text-teal': lightDark(
144
+ neutralPalettes.teal.light[30],
145
+ neutralPalettes.teal.light[80],
146
+ ),
147
+ '--color-background-cyan': lightDark(
148
+ neutralPalettes.cyan.dark[85],
149
+ neutralPalettes.cyan.dark[25],
150
+ ),
151
+ '--color-border-cyan': lightDark(
152
+ neutralPalettes.cyan.dark[80],
153
+ neutralPalettes.cyan.dark[65],
154
+ ),
155
+ '--color-icon-cyan': lightDark(
156
+ neutralPalettes.cyan.light[30],
157
+ neutralPalettes.cyan.dark[75],
158
+ ),
159
+ '--color-text-cyan': lightDark(
160
+ neutralPalettes.cyan.light[30],
161
+ neutralPalettes.cyan.dark[80],
162
+ ),
163
+ '--color-background-blue': lightDark(
164
+ neutralPalettes.blue.light[85],
165
+ neutralPalettes.blue.dark[25],
166
+ ),
167
+ '--color-border-blue': lightDark(
168
+ neutralPalettes.blue.light[80],
169
+ neutralPalettes.blue.dark[65],
170
+ ),
171
+ '--color-icon-blue': lightDark(
172
+ neutralPalettes.blue.light[30],
173
+ neutralPalettes.blue.dark[75],
174
+ ),
175
+ '--color-text-blue': lightDark(
176
+ neutralPalettes.blue.light[30],
177
+ neutralPalettes.blue.dark[80],
178
+ ),
179
+ '--color-background-purple': lightDark(
180
+ neutralPalettes.purple.light[90],
181
+ neutralPalettes.purple.dark[25],
182
+ ),
183
+ '--color-border-purple': lightDark(
184
+ neutralPalettes.purple.light[85],
185
+ neutralPalettes.purple.light[70],
186
+ ),
187
+ '--color-icon-purple': lightDark(
188
+ neutralPalettes.purple.light[30],
189
+ neutralPalettes.purple.light[75],
190
+ ),
191
+ '--color-text-purple': lightDark(
192
+ neutralPalettes.purple.light[30],
193
+ neutralPalettes.purple.dark[80],
194
+ ),
195
+ '--color-background-pink': lightDark(
196
+ neutralPalettes.pink.light[85],
197
+ neutralPalettes.pink.dark[25],
198
+ ),
199
+ '--color-border-pink': lightDark(
200
+ neutralPalettes.pink.light[85],
201
+ neutralPalettes.pink.light[70],
202
+ ),
203
+ '--color-icon-pink': lightDark(
204
+ neutralPalettes.pink.light[30],
205
+ neutralPalettes.pink.dark[75],
206
+ ),
207
+ '--color-text-pink': lightDark(
208
+ neutralPalettes.pink.light[30],
209
+ neutralPalettes.pink.dark[80],
210
+ ),
211
+ '--color-background-gray': lightDark(
212
+ neutralPalettes.neutral.light[90],
213
+ neutralPalettes.neutral.dark[20],
214
+ ),
215
+ '--color-text-gray': lightDark(
216
+ neutralPalettes.neutral.light[15],
217
+ neutralPalettes.neutral.dark[85],
218
+ ),
219
+ };
220
+
221
+ expect(neutralTheme.tokens).toMatchObject(mappings);
222
+ });
223
+
224
+ it('keeps dark categorical text readable on its background', () => {
225
+ const pairs = [
226
+ [neutralPalettes.red.dark[80], neutralPalettes.red.dark[25]],
227
+ [neutralPalettes.orange.dark[80], neutralPalettes.orange.dark[25]],
228
+ [neutralPalettes.yellow.light[80], neutralPalettes.yellow.dark[25]],
229
+ [neutralPalettes.green.light[75], neutralPalettes.green.dark[25]],
230
+ [neutralPalettes.teal.light[80], neutralPalettes.teal.dark[25]],
231
+ [neutralPalettes.cyan.dark[80], neutralPalettes.cyan.dark[25]],
232
+ [neutralPalettes.blue.dark[80], neutralPalettes.blue.dark[25]],
233
+ [neutralPalettes.purple.dark[80], neutralPalettes.purple.dark[25]],
234
+ [neutralPalettes.pink.dark[80], neutralPalettes.pink.dark[25]],
235
+ [neutralPalettes.neutral.dark[85], neutralPalettes.neutral.dark[20]],
236
+ ] as const;
237
+
238
+ for (const [foreground, background] of pairs) {
239
+ expect(contrastRatio(foreground, background)).toBeGreaterThanOrEqual(4.5);
240
+ }
241
+ });
242
+ });
243
+
244
+ describe('neutral syntax contrast', () => {
245
+ it('keeps light CodeBlock comments and operators at normal-text AA', () => {
246
+ const background = neutralPalettes.neutral.light[100];
247
+ for (const token of ['comment', 'operator'] as const) {
248
+ expect(neutralTheme.tokens[`--color-syntax-${token}`]).toBe(
249
+ lightDark(
250
+ neutralPalettes.neutral.light[45],
251
+ neutralPalettes.neutral.dark[65],
252
+ ),
253
+ );
254
+ expect(
255
+ contrastRatio(neutralPalettes.neutral.light[45], background),
256
+ ).toBeGreaterThanOrEqual(4.5);
257
+ }
258
+ });
259
+ });
260
+
261
+ describe('neutral theme-local status mappings', () => {
262
+ it('preserves the independently approved component status fills', () => {
263
+ expect(neutralTheme.localTokens).toMatchObject({
264
+ '--astryx-theme-neutral-color-status-fill-accent':
265
+ 'light-dark(#0074e2, #6d9cfe)',
266
+ '--astryx-theme-neutral-color-status-fill-success':
267
+ 'light-dark(#198100, #64af4c)',
268
+ '--astryx-theme-neutral-color-status-fill-warning': '#ffce2f',
269
+ '--astryx-theme-neutral-color-status-fill-error':
270
+ 'light-dark(#c9303a, #ff705d)',
271
+ });
272
+ expect(neutralTheme.tokens).not.toHaveProperty(
273
+ '--astryx-theme-neutral-color-status-fill-accent',
274
+ );
275
+ });
276
+
277
+ it('shares each status fill across the components with the same role', () => {
278
+ expect(neutralTheme.components?.badge?.['variant:info']).toMatchObject({
279
+ backgroundColor: statusFill.accent,
280
+ });
281
+ expect(neutralTheme.components?.statusdot?.['variant:success']).toEqual({
282
+ backgroundColor: statusFill.success,
283
+ });
284
+ expect(
285
+ neutralTheme.components?.['avatar-status-dot']?.['variant:error'],
286
+ ).toEqual({backgroundColor: statusFill.error});
287
+ expect(
288
+ neutralTheme.components?.['step-indicator']?.['status:warning'],
289
+ ).toEqual({'--color-warning': statusFill.warning});
290
+ expect(neutralTheme.components?.progressbar?.['variant:accent']).toEqual({
291
+ '--color-accent': statusFill.accent,
292
+ });
293
+ });
294
+
295
+ it('does not adopt the unapproved table row-status target', () => {
296
+ expect(neutralTheme.components).not.toHaveProperty('table-row-status');
297
+ });
298
+ });
299
+
300
+ describe('neutral Banner tint mappings', () => {
301
+ it('uses a dedicated muted info surface without changing categorical blue', () => {
302
+ const mutedAccent = 'var(--astryx-theme-neutral-color-status-muted-accent)';
303
+ expect(neutralTheme.localTokens).toMatchObject({
304
+ '--astryx-theme-neutral-color-status-muted-accent': lightDark(
305
+ neutralPalettes.blue.light[85],
306
+ `${neutralPalettes.blue.dark[75]}3D`,
307
+ ),
308
+ });
309
+ expect(neutralTheme.components?.banner?.['status:info']).toMatchObject({
310
+ '--color-accent-muted': mutedAccent,
311
+ });
312
+ expect(neutralTheme.tokens['--color-background-blue']).toBe(
313
+ lightDark(neutralPalettes.blue.light[85], neutralPalettes.blue.dark[25]),
314
+ );
315
+ });
316
+
317
+ it('keeps the error background on the semantic muted token', () => {
318
+ expect(
319
+ neutralTheme.components?.banner?.['status:error'],
320
+ ).not.toHaveProperty('--color-error-muted');
321
+ });
322
+
323
+ it('uses light overlays in light mode and dark overlays in dark mode', () => {
324
+ expect(neutralTheme.localTokens).toMatchObject({
325
+ '--astryx-theme-neutral-color-on-tint-neutral':
326
+ 'light-dark(#fafafa4D, #0a0a0a4D)',
327
+ '--astryx-theme-neutral-color-on-tint-overlay-hover':
328
+ 'light-dark(#fafafa1A, #0a0a0a1A)',
329
+ '--astryx-theme-neutral-color-on-tint-overlay-pressed':
330
+ 'light-dark(#fafafa33, #0a0a0a33)',
331
+ });
332
+ expect(neutralTheme.components?.banner?.base).toMatchObject({
333
+ '--color-neutral': 'var(--astryx-theme-neutral-color-on-tint-neutral)',
334
+ '--color-overlay-hover':
335
+ 'var(--astryx-theme-neutral-color-on-tint-overlay-hover)',
336
+ '--color-overlay-pressed':
337
+ 'var(--astryx-theme-neutral-color-on-tint-overlay-pressed)',
338
+ });
339
+ });
340
+ });
341
+
342
+ describe('neutral destructive Button interaction treatment', () => {
343
+ it('uses component-scoped red palette overlays', () => {
344
+ expect(neutralTheme.localTokens).toMatchObject({
345
+ '--astryx-theme-neutral-color-destructive-overlay-hover':
346
+ 'light-dark(#ff7f770D, #ee736c0D)',
347
+ '--astryx-theme-neutral-color-destructive-overlay-pressed':
348
+ 'light-dark(#ff7f771A, #ee736c1A)',
349
+ });
350
+ expect(
351
+ neutralTheme.components?.button?.['variant:destructive'],
352
+ ).toMatchObject({
353
+ '--color-overlay-hover':
354
+ 'var(--astryx-theme-neutral-color-destructive-overlay-hover)',
355
+ '--color-overlay-pressed':
356
+ 'var(--astryx-theme-neutral-color-destructive-overlay-pressed)',
357
+ });
358
+ });
359
+ });
360
+
361
+ describe('neutral theme component overrides', () => {
362
+ it('keeps the roomier segmented-control inset height-neutral', () => {
363
+ expect(neutralTheme.components?.['segmented-control']).toEqual({
364
+ base: {padding: 'var(--spacing-1)'},
365
+ });
366
+ expect(neutralTheme.components?.['segmented-control-item']).toEqual({
367
+ 'size:sm': {height: 'calc(var(--size-element-sm) - 8px)'},
368
+ 'size:md': {height: 'calc(var(--size-element-md) - 8px)'},
369
+ 'size:lg': {height: 'calc(var(--size-element-lg) - 8px)'},
370
+ selected: {boxShadow: 'none'},
371
+ });
372
+ });
373
+ });