@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.
@@ -1,66 +1,75 @@
1
1
  // Copyright (c) Meta Platforms, Inc. and affiliates.
2
2
 
3
- /**
4
- * Neutral Theme
5
- *
6
- * A pure grayscale spine with a from-scratch OKLCH-derived categorical
7
- * palette. Hues are placed at evenly-spaced positions on the OKLCH wheel,
8
- * chosen to keep each color recognizable at every tone (no red drift for
9
- * orange, no blue drift for purple) and well-separated from its neighbors.
10
- *
11
- * Core neutral palette: #fafafa, #f5f5f5, #e5e5e5, #737373, #262626, #0a0a0a
12
- *
13
- * Categorical hues (OKLCH; chroma = max-in-gamut at the saturated stop):
14
- * Red H=25 Orange H=65 Yellow H=90 Green H=145
15
- * Teal H=180 Cyan H=215 Blue H=250 Purple H=320 Pink H=355
16
- *
17
- * Saturated badge stops:
18
- * • Cool/medium hues sit at OKLCH L=0.48–0.50 with white text (AA+)
19
- * • Bright warm hues (orange L=0.68, yellow L=0.80) use dark text
20
- *
21
- * Token tonal stops:
22
- * bg = T90 (light) / T20 (dark)
23
- * border = T80 / T30
24
- * icon = T30 / T80
25
- * text = T30 / T80
26
- *
27
- * All 9 saturated badge values pass WCAG AA against their label (>= 4.5:1);
28
- * `scripts/check-badge-contrast.test.mjs` holds every theme to that.
29
- *
30
- * Only overrides tokens that differ from the defaults.
31
- */
32
-
33
- import {defineTheme, defineSyntaxTheme} from '@astryxdesign/core/theme';
3
+ import {
4
+ defineTheme,
5
+ defineSyntaxTheme,
6
+ type TokenValue,
7
+ } from '@astryxdesign/core/theme';
34
8
  import {neutralIconRegistry} from './icons';
9
+ import {neutralPaletteRefs} from './neutralPaletteRefs.generated';
10
+
11
+ const {blue, cyan, green, neutral, orange, pink, purple, red, teal, yellow} =
12
+ neutralPaletteRefs;
13
+ const withAlpha = (color: string, alpha: string) => `${color}${alpha}`;
35
14
 
36
- /**
37
- * Neutral syntax palette — pulled from the OKLCH T30 (light) / T80 (dark)
38
- * stops of the categorical ramps. Same colors used by --color-icon-* tokens.
39
- */
40
15
  const neutralSyntax = defineSyntaxTheme({
41
- name: 'xds-neutral',
16
+ name: 'astryx-neutral',
42
17
  tokens: {
43
- keyword: ['#700084', '#efa8ff'], // purple T30/T80
44
- string: ['#005600', '#a6d2a2'], // green (sat T30 / pastel T80)
45
- comment: ['#737373', '#a3a3a3'], // neutral
46
- number: ['#6e3500', '#ffb37f'], // orange
47
- function: ['#00458c', '#a0caff'], // blue T30/T80 H=255
48
- type: ['#700084', '#efa8ff'], // purple
49
- variable: ['#171717', '#e5e5e5'], // near-black / near-white
50
- operator: ['#737373', '#a3a3a3'], // neutral
51
- constant: ['#6e3500', '#ffb37f'], // orange
52
- tag: ['#89001a', '#ffaeaa'], // red
53
- attribute: ['#584400', '#eec12f'], // yellow
54
- property: ['#005348', '#83dac9'], // teal
55
- // #a3a3a3/#525252 (this pair's own disabled-text tone) failed WCAG AA
56
- // against the syntax background: 2.42:1 light, 2.53:1 dark. #5386.
57
- punctuation: ['#6e6e6e', '#a0a0a0'], // neutral, 4.89:1 / 7.57:1
58
- background: ['#fafafa', '#0a0a0a'],
18
+ keyword: [purple.light[30], purple.light[80]],
19
+ string: [green.light[30], green.light[80]],
20
+ comment: [neutral.light[45], neutral.dark[65]],
21
+ number: [orange.light[30], orange.dark[80]],
22
+ function: [blue.light[30], blue.dark[80]],
23
+ type: [purple.light[30], purple.light[80]],
24
+ variable: [neutral.light[5], neutral.dark[90]],
25
+ operator: [neutral.light[45], neutral.dark[65]],
26
+ constant: [orange.light[30], orange.dark[80]],
27
+ tag: [red.light[30], red.dark[80]],
28
+ attribute: [yellow.light[30], yellow.light[80]],
29
+ property: [teal.light[30], teal.light[80]],
30
+ punctuation: [neutral.light[45], neutral.dark[65]],
31
+ background: [neutral.light[100], neutral.dark[5]],
59
32
  },
60
33
  });
61
34
 
35
+ const neutralLocalTokens: Record<string, TokenValue> = {
36
+ '--astryx-theme-neutral-color-status-fill-accent': ['#0074e2', '#6d9cfe'],
37
+ '--astryx-theme-neutral-color-status-fill-success': ['#198100', '#64af4c'],
38
+ '--astryx-theme-neutral-color-status-fill-warning': '#ffce2f',
39
+ '--astryx-theme-neutral-color-status-fill-error': ['#c9303a', '#ff705d'],
40
+ '--astryx-theme-neutral-color-status-muted-accent': [
41
+ blue.light[85],
42
+ withAlpha(blue.dark[75], '3D'),
43
+ ],
44
+ '--astryx-theme-neutral-color-on-tint-neutral': ['#fafafa4D', '#0a0a0a4D'],
45
+ '--astryx-theme-neutral-color-on-tint-overlay-hover': [
46
+ '#fafafa1A',
47
+ '#0a0a0a1A',
48
+ ],
49
+ '--astryx-theme-neutral-color-on-tint-overlay-pressed': [
50
+ '#fafafa33',
51
+ '#0a0a0a33',
52
+ ],
53
+ '--astryx-theme-neutral-color-destructive-overlay-hover': [
54
+ withAlpha(red.light[70], '0D'),
55
+ withAlpha(red.dark[65], '0D'),
56
+ ],
57
+ '--astryx-theme-neutral-color-destructive-overlay-pressed': [
58
+ withAlpha(red.light[70], '1A'),
59
+ withAlpha(red.dark[65], '1A'),
60
+ ],
61
+ };
62
+
63
+ const statusFill = {
64
+ accent: 'var(--astryx-theme-neutral-color-status-fill-accent)',
65
+ success: 'var(--astryx-theme-neutral-color-status-fill-success)',
66
+ warning: 'var(--astryx-theme-neutral-color-status-fill-warning)',
67
+ error: 'var(--astryx-theme-neutral-color-status-fill-error)',
68
+ } as const;
69
+
62
70
  export const neutralTheme = defineTheme({
63
71
  name: 'neutral',
72
+ localTokens: neutralLocalTokens,
64
73
 
65
74
  // Typography: Figtree across body, heading, and display sizes (display
66
75
  // size tokens inherit from heading.family). Monospace stays as the
@@ -94,235 +103,124 @@ export const neutralTheme = defineTheme({
94
103
  syntax: neutralSyntax,
95
104
 
96
105
  tokens: {
97
- // =========================================================================
98
- // Core — pure grayscale spine (Tailwind neutral)
99
- // 50:#fafafa 100:#f5f5f5 200:#e5e5e5 300:#d4d4d4 400:#a3a3a3
100
- // 500:#737373 600:#525252 700:#404040 800:#262626 900:#171717 950:#0a0a0a
101
- // =========================================================================
102
-
103
- // =========================================================================
104
- // Backgrounds — Figma-style flat with a single lifted surface.
105
- //
106
- // Dark mode collapses card / popover / muted to body T10. Cards and
107
- // popovers lift purely via shadow + inset highlight (see --shadow-*
108
- // below) — they don't need a distinct tone.
109
- //
110
- // Surface is the exception: it's tonally LIGHTER than body (T15) so
111
- // interactive components that sit on top of body have a clear,
112
- // differentiated foreground. Real consumers of --color-background-surface
113
- // are: switches, radios, checkboxes, multi-selectors, dialogs, app
114
- // shells, sections — all things that need to lift above the canvas.
115
- //
116
- // surface T15 #262626 — interactive surfaces lifted above body
117
- // body T10 #1b1b1b — main canvas
118
- // card T10 #1b1b1b — same as body, lifts via --shadow-low
119
- // popover T10 #1b1b1b — same as body, lifts via --shadow-med
120
- // muted T10 #1b1b1b — same as body
121
- //
122
- // Light mode keeps the standard ladder (white surfaces float on tinted
123
- // body; shadows do most of the lifting):
124
- // surface T100 #ffffff
125
- // body T95 #f1f1f1
126
- // card T100 #ffffff
127
- // popover T100 #ffffff
128
- // muted T95 #f1f1f1
129
- //
130
- // All values use the OKLCH Neutral tonal palette (chroma=0).
131
- // =========================================================================
132
- '--color-background-surface': ['#ffffff', '#262626'],
133
- '--color-background-body': ['#f1f1f1', '#1b1b1b'],
134
- '--color-background-card': ['#ffffff', '#1b1b1b'],
135
- '--color-background-popover': ['#ffffff', '#1b1b1b'],
136
- '--color-background-muted': ['#f1f1f1', '#1b1b1b'],
137
-
138
- // Accent + neutral surface tints (sit alongside backgrounds)
139
- '--color-accent': ['#262626', '#ebebeb'],
140
- '--color-accent-muted': ['#f1f1f1', '#262626'],
141
- '--color-neutral': ['#0000000F', '#FFFFFF1A'],
106
+ '--color-background-surface': [neutral.light[100], neutral.dark[15]],
107
+ '--color-background-body': [neutral.light[95], neutral.dark[10]],
108
+ '--color-background-card': [neutral.light[100], neutral.dark[10]],
109
+ '--color-background-popover': [neutral.light[100], neutral.dark[10]],
110
+ '--color-background-muted': [neutral.light[95], neutral.dark[10]],
111
+
112
+ '--color-accent': [neutral.light[15], neutral.dark[95]],
113
+ '--color-accent-muted': [neutral.light[95], neutral.dark[15]],
114
+ '--color-neutral': [
115
+ withAlpha(neutral.light[0], '0F'),
116
+ withAlpha(neutral.dark[100], '1A'),
117
+ ],
142
118
 
143
119
  // Overlays (modal scrims, hover/pressed tints)
144
- '--color-overlay': ['#00000080', '#000000CC'],
145
- '--color-overlay-hover': ['#0000000D', '#FFFFFF0D'],
146
- '--color-overlay-pressed': ['#0000001A', '#FFFFFF1A'],
120
+ '--color-overlay': [
121
+ withAlpha(neutral.light[0], '80'),
122
+ withAlpha(neutral.dark[0], 'CC'),
123
+ ],
124
+ '--color-overlay-hover': [
125
+ withAlpha(neutral.light[0], '0D'),
126
+ withAlpha(neutral.dark[100], '0D'),
127
+ ],
128
+ '--color-overlay-pressed': [
129
+ withAlpha(neutral.light[0], '1A'),
130
+ withAlpha(neutral.dark[100], '1A'),
131
+ ],
147
132
 
148
133
  // Text
149
- '--color-text-primary': ['#171717', '#fafafa'],
150
- // Light secondary is neutral-600 (#525252), not 500 (#737373): 500 only
151
- // reaches 4.19:1 on the T95 body (#f1f1f1), just under WCAG AA 4.5:1.
152
- // 600 clears it (6.9:1 on body, 7.8:1 on card). Dark stays neutral-400.
153
- '--color-text-secondary': ['#525252', '#a3a3a3'],
154
- '--color-text-disabled': ['#a3a3a3', '#525252'],
155
- '--color-text-accent': ['#262626', '#ebebeb'],
156
- '--color-on-dark': '#ffffff',
157
- '--color-on-light': '#171717',
158
- // Contrast: neutral accent is near-black (L) / near-white (D)
159
- '--color-on-accent': ['#ffffff', '#171717'],
160
- '--color-on-success': ['#ffffff', '#171717'],
161
- '--color-on-error': ['#ffffff', '#171717'],
162
- '--color-on-warning': '#171717',
134
+ '--color-text-primary': [neutral.light[5], neutral.dark[100]],
135
+ '--color-text-secondary': [neutral.light[35], neutral.dark[65]],
136
+ '--color-text-disabled': [neutral.light[65], neutral.dark[35]],
137
+ '--color-text-accent': [neutral.light[15], neutral.dark[95]],
138
+ '--color-on-dark': neutral.light[100],
139
+ '--color-on-light': neutral.light[5],
140
+ '--color-on-accent': [neutral.light[100], neutral.dark[5]],
141
+ '--color-on-success': [neutral.light[100], neutral.dark[5]],
142
+ '--color-on-error': [neutral.light[100], neutral.dark[5]],
143
+ '--color-on-warning': neutral.light[5],
163
144
 
164
145
  // Icon
165
- '--color-icon-accent': ['#262626', '#ebebeb'],
166
- '--color-icon-primary': ['#171717', '#fafafa'],
167
- '--color-icon-secondary': ['#737373', '#a3a3a3'],
168
- '--color-icon-disabled': ['#a3a3a3', '#525252'],
169
-
170
- // Status / Sentiment — dark mode follows the issue #2150 rubric:
171
- //
172
- // Light mode: pastel T90 banner bg + dark T30/T40 text/icon. Locked
173
- // light values for cards/banners/inputs/destructive btn.
174
- // Dark mode : tinted-dark T20 bg + light pastel T80 text. INVERTED
175
- // from light. Avoids the §5 "pastel-in-both-modes"
176
- // anti-pattern (locked pastels glow against a dark body).
177
- //
178
- // --color-X = "saturated text/icon stop":
179
- // light = T40 dark colored (sits on light pastel)
180
- // dark = T80 light pastel (sits on dark tinted bg)
181
- // Used by destructive button text, input border/icon
182
- // (in light), banner-status-* text overrides.
183
- // --color-X-muted = "muted bg stop":
184
- // light = T90 light pastel
185
- // dark = hue-tinted alpha overlay (T70 stop @ 24%)
186
- // Used by banner bg, status-input message bg,
187
- // destructive button bg. Dark mode uses an alpha
188
- // overlay rather than a solid T20 tinted bg so
189
- // the surface composes onto whatever sits behind
190
- // it (body, card, popover) rather than reading
191
- // as a hard colored panel.
192
- //
193
- // 24% alpha = '3D' suffix. Hue values match --color-icon-{X} dark
194
- // slots (palette T70). Composited onto body #1b1b1b, the effective
195
- // bg luminance hits ~1.65-1.70:1 vs body — visible colored surface
196
- // without the heaviness of a solid T20 panel.
197
- '--color-success': ['#007004', '#9fe59b'],
198
- '--color-error': ['#a50c25', '#ffc6c1'],
199
- '--color-warning': ['#745b00', '#fdcf4f'],
200
- '--color-success-muted': ['#c5e5c0', '#84c9803D'],
201
- '--color-error-muted': ['#facecb', '#ff9e973D'],
202
- '--color-warning-muted': ['#f8da9d', '#deb4333D'],
203
-
204
- // Border
205
- '--color-border': ['#00000014', '#FFFFFF1A'],
206
- '--color-border-emphasized': ['#d4d4d4', '#525252'],
146
+ '--color-icon-accent': [neutral.light[15], neutral.dark[95]],
147
+ '--color-icon-primary': [neutral.light[5], neutral.dark[100]],
148
+ '--color-icon-secondary': [neutral.light[50], neutral.dark[65]],
149
+ '--color-icon-disabled': [neutral.light[65], neutral.dark[35]],
150
+
151
+ '--color-success': [green.light[40], green.light[80]],
152
+ '--color-error': [red.light[35], red.dark[85]],
153
+ '--color-warning': [yellow.light[40], yellow.light[85]],
154
+ '--color-success-muted': [green.dark[85], withAlpha(green.light[75], '3D')],
155
+ '--color-error-muted': [red.light[85], withAlpha(red.dark[75], '3D')],
156
+ '--color-warning-muted': [
157
+ yellow.dark[90],
158
+ withAlpha(yellow.light[75], '3D'),
159
+ ],
160
+
161
+ '--color-border': [
162
+ withAlpha(neutral.light[0], '14'),
163
+ withAlpha(neutral.dark[100], '1A'),
164
+ ],
165
+ '--color-border-emphasized': [neutral.light[85], neutral.dark[35]],
207
166
 
208
167
  // Effects
209
- '--color-skeleton': ['#ebebeb', '#525252'],
210
- '--color-shadow': ['#0000001A', '#0000004D'],
168
+ '--color-skeleton': [neutral.light[95], neutral.dark[35]],
169
+ '--color-shadow': [
170
+ withAlpha(neutral.light[0], '1A'),
171
+ withAlpha(neutral.dark[0], '4D'),
172
+ ],
211
173
  '--color-tint-hover': ['black', 'white'],
212
174
 
213
- // =========================================================================
214
- // Categorical light mode uses pastel surfaces + dark colored text;
215
- // dark mode INVERTS to a hue-tinted alpha overlay surface +
216
- // light pastel text (per #2150 rubric §3 — pick the tone
217
- // that satisfies required contrast against every surface
218
- // the token touches).
219
- //
220
- // Per-token tone choice (CIELab L*):
221
- // bg light=T87-T90 pastel dark=T70 hue @ 24% alpha overlay
222
- // (composites onto body to ~1.65:1
223
- // vs body — colored surface that
224
- // feels lighter than a solid T20
225
- // panel; same hue as --color-icon-X
226
- // dark slot, just at lower opacity)
227
- // border light=T80 pastel dark=T60 mid-bright (>=5.8:1 vs body)
228
- // icon light=T30 dark colored dark=T70 light pastel
229
- // text light=T30 dark colored dark=T80 light pastel (>=7:1 on bg)
230
- //
231
- // Light pastels still use the per-hue chroma table (red/blue C=0.05,
232
- // orange/green/purple/pink C=0.06, teal/cyan C=0.07, yellow H=85 C=0.10)
233
- // for equal PERCEIVED saturation. Dark stops (T60/T70/T80) come from
234
- // the dark-mode tonal palette (chroma×0.85, +5 tone lift tapering 80-95).
235
- // =========================================================================
236
-
237
- // Each row's dark slots are HCT-derived from the source hex listed in
238
- // apps/sandbox/src/app/(fullscreen)/pages/neutral-palette/page.tsx via
239
- // the canonical dark-ramp transform (chroma×0.85, +5 tone-lift taper)
240
- // same algorithm the Tonal Palettes preview renders. Border=T60,
241
- // icon=T70, text=T80. Background uses the T70 hue at 24% alpha so the
242
- // overlay surface composites onto body to ~1.65:1 luminance.
243
-
244
- // Red H=22 — source #eb183a
245
- '--color-background-red': ['#facecb', '#ff9e973D'],
246
- '--color-border-red': ['#e6bab8', '#ff6f6c'],
247
- '--color-icon-red': ['#89001a', '#ff9e97'],
248
- '--color-text-red': ['#89001a', '#ffc6c1'],
249
-
250
- // Orange H=55 — source #d57113
251
- '--color-background-orange': ['#fad0b5', '#ffa2583D'],
252
- '--color-border-orange': ['#e6bda2', '#e2883e'],
253
- '--color-icon-orange': ['#6e3500', '#ffa258'],
254
- '--color-text-orange': ['#6e3500', '#ffc9a2'],
255
-
256
- // Yellow H=90 — source #f8c723
257
- // Light-mode butter-yellow pastel at H=85 C=0.085 L=0.90 — yellow
258
- // sits at the green-cyan luminance peak so it feels louder than the
259
- // other status hues at the same canonical L. Picker decision: pull
260
- // L down one step (0.91→0.90) and C down to its identity floor
261
- // (0.10→0.085, just above the bronze threshold) so it sits closer
262
- // to red/blue's perceived brightness without losing yellow identity.
263
- // Dark-mode comes from the canonical H=90 ramp for tonal-palette
264
- // consistency.
265
- '--color-background-yellow': ['#f8da9d', '#deb4333D'],
266
- '--color-border-yellow': ['#e4c279', '#c0990e'],
267
- '--color-icon-yellow': ['#584400', '#deb433'],
268
- '--color-text-yellow': ['#584400', '#fdcf4f'],
269
-
270
- // Green H=144 — source #358a3a
271
- '--color-background-green': ['#c5e5c0', '#84c9803D'],
272
- '--color-border-green': ['#b2d1ac', '#69ad67'],
273
- '--color-icon-green': ['#0c5700', '#84c980'],
274
- '--color-text-green': ['#0c5700', '#9fe59b'],
275
-
276
- // Teal H=180 — source #0c7365
277
- // Light pastel uses L=0.87 C=0.065 (a step darker + less chroma than
278
- // the L=0.888 C=0.07 used by other hues) to compensate for the
279
- // green-cyan luminance overshoot — at the same OKLCH L, teal/cyan read
280
- // ~5% brighter than red/blue because the eye's luminance response
281
- // peaks in this band. Dropping L+C brings perceived brightness in
282
- // line with the rest of the palette without losing hue identity.
283
- '--color-background-teal': ['#a5e3d6', '#7ec6b83D'],
284
- '--color-border-teal': ['#94d6c8', '#63ab9d'],
285
- '--color-icon-teal': ['#005348', '#7ec6b8'],
286
- '--color-text-teal': ['#005348', '#99e2d3'],
287
-
288
- // Cyan H=215 — source #0c6f82
289
- // Same L=0.87 C=0.065 pastel as teal (luminance overshoot compensation).
290
- '--color-background-cyan': ['#a3e0ef', '#83c2d43D'],
291
- '--color-border-cyan': ['#91d3e3', '#67a7b8'],
292
- '--color-icon-cyan': ['#00505f', '#83c2d4'],
293
- '--color-text-cyan': ['#00505f', '#9edef0'],
294
-
295
- // Blue H=255 — source #0074e2
296
- // T50 #0074e2 reserved for filled Info badge / progressbar / inset hover.
297
- '--color-background-blue': ['#c4ddfb', '#9eb7ff3D'],
298
- '--color-border-blue': ['#b1c9e7', '#6d9cfe'],
299
- '--color-icon-blue': ['#00458c', '#9eb7ff'],
300
- '--color-text-blue': ['#00458c', '#c7d3ff'],
301
-
302
- // Purple H=320 — source #980fb2
303
- '--color-background-purple': ['#eccef3', '#f297ff3D'],
304
- '--color-border-purple': ['#d8bbdf', '#dd74f0'],
305
- '--color-icon-purple': ['#700084', '#f297ff'],
306
- '--color-text-purple': ['#700084', '#fac1ff'],
307
-
308
- // Pink H=355 — source #b10e69
309
- '--color-background-pink': ['#fccadc', '#ff99c33D'],
310
- '--color-border-pink': ['#e7b7c8', '#f273aa'],
311
- '--color-icon-pink': ['#83004b', '#ff99c3'],
312
- '--color-text-pink': ['#83004b', '#ffc3da'],
313
-
314
- // Gray (categorical neutral, chroma 0)
315
- // Light: #e5e5e5 (Neutral 200) so it's visibly distinct from the
316
- // lighter body / muted surface (both #f5f5f5).
317
- // Dark : var(--color-neutral) — semi-transparent white wash
318
- // (#FFFFFF1A, 10%). Matches the same treatment the gray
319
- // badge uses; clearly distinct from the body T10 #1b1b1b
320
- // while staying chroma-0 neutral. Solid T15 #1c1c1c was
321
- // indistinguishable from --color-background-muted.
322
- '--color-background-gray': ['#e5e5e5', 'var(--color-neutral)'],
323
- '--color-border-gray': ['#d4d4d4', '#262626'],
324
- '--color-icon-gray': ['#525252', '#a3a3a3'],
325
- '--color-text-gray': ['#262626', '#e5e5e5'],
175
+ '--color-background-red': [red.light[85], red.dark[25]],
176
+ '--color-border-red': [red.light[80], red.light[65]],
177
+ '--color-icon-red': [red.light[30], red.dark[75]],
178
+ '--color-text-red': [red.light[30], red.dark[80]],
179
+
180
+ '--color-background-orange': [orange.light[85], orange.dark[25]],
181
+ '--color-border-orange': [orange.light[85], orange.dark[65]],
182
+ '--color-icon-orange': [orange.light[30], orange.light[75]],
183
+ '--color-text-orange': [orange.light[30], orange.dark[80]],
184
+
185
+ '--color-background-yellow': [yellow.dark[90], yellow.dark[25]],
186
+ '--color-border-yellow': [yellow.dark[80], yellow.light[65]],
187
+ '--color-icon-yellow': [yellow.light[30], yellow.light[75]],
188
+ '--color-text-yellow': [yellow.light[30], yellow.light[80]],
189
+
190
+ '--color-background-green': [green.dark[85], green.dark[25]],
191
+ '--color-border-green': [green.dark[80], green.light[65]],
192
+ '--color-icon-green': [green.light[30], green.light[75]],
193
+ '--color-text-green': [green.light[30], green.light[75]],
194
+
195
+ '--color-background-teal': [teal.light[85], teal.dark[25]],
196
+ '--color-border-teal': [teal.light[80], teal.dark[65]],
197
+ '--color-icon-teal': [teal.light[30], teal.dark[75]],
198
+ '--color-text-teal': [teal.light[30], teal.light[80]],
199
+
200
+ '--color-background-cyan': [cyan.dark[85], cyan.dark[25]],
201
+ '--color-border-cyan': [cyan.dark[80], cyan.dark[65]],
202
+ '--color-icon-cyan': [cyan.light[30], cyan.dark[75]],
203
+ '--color-text-cyan': [cyan.light[30], cyan.dark[80]],
204
+
205
+ '--color-background-blue': [blue.light[85], blue.dark[25]],
206
+ '--color-border-blue': [blue.light[80], blue.dark[65]],
207
+ '--color-icon-blue': [blue.light[30], blue.dark[75]],
208
+ '--color-text-blue': [blue.light[30], blue.dark[80]],
209
+
210
+ '--color-background-purple': [purple.light[90], purple.dark[25]],
211
+ '--color-border-purple': [purple.light[85], purple.light[70]],
212
+ '--color-icon-purple': [purple.light[30], purple.light[75]],
213
+ '--color-text-purple': [purple.light[30], purple.dark[80]],
214
+
215
+ '--color-background-pink': [pink.light[85], pink.dark[25]],
216
+ '--color-border-pink': [pink.light[85], pink.light[70]],
217
+ '--color-icon-pink': [pink.light[30], pink.dark[75]],
218
+ '--color-text-pink': [pink.light[30], pink.dark[80]],
219
+
220
+ '--color-background-gray': [neutral.light[90], neutral.dark[20]],
221
+ '--color-border-gray': [neutral.light[85], neutral.dark[15]],
222
+ '--color-icon-gray': [neutral.light[35], neutral.dark[65]],
223
+ '--color-text-gray': [neutral.light[15], neutral.dark[85]],
326
224
 
327
225
  // =========================================================================
328
226
  // Radius — slightly larger than default (kept as-is)
@@ -367,92 +265,47 @@ export const neutralTheme = defineTheme({
367
265
  '0 4px 6px light-dark(oklch(0 0 0 / 10%), oklch(0 0 0 / 50%)), ' +
368
266
  '0 12px 24px light-dark(oklch(0 0 0 / 15%), oklch(0 0 0 / 70%)), ' +
369
267
  'inset 0 0 0 1px light-dark(transparent, oklch(1 0 0 / 15%))',
370
- '--shadow-inset-hover': 'inset 0px 0px 0px 2px #0074e24D',
371
- '--shadow-inset-selected': 'inset 0px 0px 0px 2px #0074e280',
372
- '--shadow-inset-success': 'inset 0px 0px 0px 2px #1981004D',
373
- '--shadow-inset-warning': 'inset 0px 0px 0px 2px #ffce2f4D',
374
- '--shadow-inset-error': 'inset 0px 0px 0px 2px #e33f4a4D',
268
+ '--shadow-inset-hover': `inset 0px 0px 0px 2px ${withAlpha(blue.light[50], '4D')}`,
269
+ '--shadow-inset-selected': `inset 0px 0px 0px 2px ${withAlpha(blue.light[50], '80')}`,
270
+ '--shadow-inset-success': `inset 0px 0px 0px 2px ${withAlpha(green.light[45], '4D')}`,
271
+ '--shadow-inset-warning': `inset 0px 0px 0px 2px ${withAlpha(yellow.light[85], '4D')}`,
272
+ '--shadow-inset-error': `inset 0px 0px 0px 2px ${withAlpha(red.light[55], '4D')}`,
375
273
  },
376
274
 
377
275
  components: {
378
- // =========================================================================
379
- // Button — primary gets white text, secondary gets a border, destructive
380
- // uses the OKLCH red filled treatment.
381
- // =========================================================================
382
276
  button: {
383
277
  'variant:destructive': {
384
- backgroundColor: 'var(--color-error-muted)', // locked pastel red bg
385
- color: 'var(--color-error)', // locked T30 red — matches banner/input error text
278
+ backgroundColor: 'var(--color-error-muted)',
279
+ color: 'var(--color-error)',
280
+ '--color-overlay-hover':
281
+ 'var(--astryx-theme-neutral-color-destructive-overlay-hover)',
282
+ '--color-overlay-pressed':
283
+ 'var(--astryx-theme-neutral-color-destructive-overlay-pressed)',
386
284
  },
387
285
  },
388
286
 
389
- // =========================================================================
390
- // Badge —
391
- // Semantic (info/success/warning/error): filled saturated T50 + contrasting
392
- // text (white, or dark on yellow). The filled-button rule from #2150
393
- // §3 — text contrast locks the bg tone, so this stays at T50 in
394
- // BOTH modes, unlike pastel surfaces which invert by mode.
395
- // Categorical (blue/green/red/orange/etc.): pastel-tinted hue surface +
396
- // colored text — light mode = soft T87-T90 + dark T30 text; dark mode
397
- // = T20 tinted + T80 light pastel text (sources: --color-background-X
398
- // and --color-text-X tokens).
399
- // Neutral: light gray bg + dark text (or inverted in dark mode).
400
- // =========================================================================
401
287
  badge: {
402
- // Semantic — filled saturated bg + contrasting text.
403
- // Light: vivid T45-T55 from the OKLCH palette + white text
404
- // (~4.5-5:1 — Material/Linear/Vercel pop).
405
- // Dark : T60 stop from the dark-mode tonal palette (chroma×0.85,
406
- // +5 tone-lift taper from issue #2150 §4) + DARK text.
407
- // T60+white fails AA-large (~2.7:1); T60+dark hits 6.6-7:1
408
- // and tames the §4 vibration. Same dark-text-on-bright-bg
409
- // treatment that warning yellow uses in both modes.
410
288
  'variant:info': {
411
- // Light: T50 #0074e2 (palette saturated stop)
412
- // Dark : T60 stop from dark-mode tonal palette of source #0074e2
413
- backgroundColor: 'light-dark(#0074e2, #6d9cfe)',
414
- color: 'light-dark(#ffffff, #171717)',
289
+ backgroundColor: statusFill.accent,
290
+ color: 'var(--color-on-accent)',
415
291
  },
416
292
  'variant:neutral': {
417
- // Mirrors the gray categorical badge — same neutral chip treatment
418
- // (Neutral 200 light / semi-transparent white wash dark) sourced
419
- // from the gray hue tokens, so a single change at the token layer
420
- // updates both variants.
421
293
  backgroundColor: 'var(--color-background-gray)',
422
294
  color: 'var(--color-text-gray)',
423
295
  },
424
296
  'variant:success': {
425
- // Light: T45 #198100 (palette saturated stop)
426
- // Dark : T60 stop from dark-mode tonal palette of source #198100
427
- backgroundColor: 'light-dark(#198100, #64af4c)',
428
- color: 'light-dark(#ffffff, #171717)',
297
+ backgroundColor: statusFill.success,
298
+ color: 'var(--color-on-success)',
429
299
  },
430
300
  'variant:warning': {
431
- // Yellow stays at the same hex in both modes — chroma reduction
432
- // is barely visible at T85, and dark text on yellow doesn't
433
- // suffer from the §4 vibration concern.
434
- backgroundColor: '#ffce2f',
435
- color: '#171717',
301
+ backgroundColor: statusFill.warning,
302
+ color: 'var(--color-on-warning)',
436
303
  },
437
304
  'variant:error': {
438
- // Light: T58 #c9303a. The T55 stop #e33f4a pairs with white at only
439
- // 4.14:1 — the label is 12px/500, so AA wants 4.5, not the 3:1
440
- // large-text allowance. One tonal step down holds the hue
441
- // (OKLCH H 21.9 -> 22.8, C 0.200 -> 0.189) and reaches 5.29:1.
442
- // Dark : T60 stop from dark-mode tonal palette of Tailwind red-600
443
- // source #dc2626 (kept on H=27 alarm-red rather than coral).
444
- // Dark text on it is 6.60:1 and unchanged.
445
- backgroundColor: 'light-dark(#c9303a, #ff705d)',
446
- color: 'light-dark(#ffffff, #171717)',
447
- },
448
-
449
- // Categorical — bg + text reference the per-hue tokens, so behavior
450
- // tracks the categorical palette automatically:
451
- // Light: pastel T87-T90 bg + dark T30 colored text (low-key chip)
452
- // Dark : tinted T20 bg + light T80 colored text (per #2150 §5,
453
- // inverted from light to avoid the "pastel-in-both-modes"
454
- // anti-pattern that makes locked light pastels glow on a
455
- // dark body)
305
+ backgroundColor: statusFill.error,
306
+ color: 'var(--color-on-error)',
307
+ },
308
+
456
309
  'variant:red': {
457
310
  backgroundColor: 'var(--color-background-red)',
458
311
  color: 'var(--color-text-red)',
@@ -495,64 +348,55 @@ export const neutralTheme = defineTheme({
495
348
  },
496
349
  },
497
350
 
498
- // =========================================================================
499
- // StatusDot — fill uses the SAME vivid stops as the filled semantic Badge
500
- // (and ProgressBar), so a dot and its badge read as one status language.
501
- //
502
- // The default component maps each variant to a raw semantic token
503
- // (--color-success / --color-error / --color-warning / --color-icon-
504
- // secondary), which in light mode are the dark T30/T40 stops meant to
505
- // sit as TEXT on a pastel surface — as a solid dot they read muddy
506
- // (dark green / maroon / brown). Redirect them to the badge fills.
507
- //
508
- // success → badge success bg (green T45 / dark-ramp T60)
509
- // warning → badge warning bg (yellow T85, same hex both modes)
510
- // error → badge error bg (red T58 / dark-ramp T60)
511
- // accent → badge info bg (blue T50 / dark-ramp T60) — the
512
- // StatusDot "accent" is the info/attention color, so it
513
- // pairs with the info badge rather than --color-accent
514
- // (near-black #262626, the darkest offender).
515
- //
516
- // `neutral` is intentionally NOT overridden: the neutral badge bg is a
517
- // near-invisible light gray (--color-background-gray #e5e5e5 / 10% white
518
- // wash), fine as a large pill but unreadable as an 8px dot. It keeps the
519
- // component default's visible mid-gray (--color-icon-secondary), which is
520
- // not among the "too dark" cases.
521
- // =========================================================================
522
351
  statusdot: {
523
- 'variant:success': {backgroundColor: 'light-dark(#198100, #64af4c)'},
524
- 'variant:warning': {backgroundColor: '#ffce2f'},
525
- 'variant:error': {backgroundColor: 'light-dark(#c9303a, #ff705d)'},
526
- 'variant:accent': {backgroundColor: 'light-dark(#0074e2, #6d9cfe)'},
352
+ 'variant:success': {backgroundColor: statusFill.success},
353
+ 'variant:warning': {backgroundColor: statusFill.warning},
354
+ 'variant:error': {backgroundColor: statusFill.error},
355
+ 'variant:accent': {backgroundColor: statusFill.accent},
356
+ },
357
+
358
+ 'avatar-status-dot': {
359
+ 'variant:success': {backgroundColor: statusFill.success},
360
+ 'variant:error': {backgroundColor: statusFill.error},
361
+ },
362
+
363
+ // Give the Neutral segmented control a roomier inset without changing its
364
+ // outside height. The selected item stays flat against the tinted track.
365
+ 'segmented-control': {
366
+ base: {
367
+ padding: 'var(--spacing-1)',
368
+ },
369
+ },
370
+ 'segmented-control-item': {
371
+ 'size:sm': {
372
+ height: 'calc(var(--size-element-sm) - 8px)',
373
+ },
374
+ 'size:md': {
375
+ height: 'calc(var(--size-element-md) - 8px)',
376
+ },
377
+ 'size:lg': {
378
+ height: 'calc(var(--size-element-lg) - 8px)',
379
+ },
380
+ selected: {
381
+ boxShadow: 'none',
382
+ },
527
383
  },
528
384
 
529
- // =========================================================================
530
- // Banner — sits on a hue-tinted surface with colored text/icon:
531
- // Light: pastel T90 bg (pulled from --color-{X}-muted / --color-background-blue)
532
- // + dark T30 colored text (--color-text-{hue}).
533
- // Dark : tinted T20 bg (same tokens, dark slot) + light T80 colored text.
534
- // Per #2150 §5 — large hue-tinted surfaces in dark mode invert
535
- // to a deep tinted bg + light text rather than locking the
536
- // light-mode pastel.
537
- //
538
- // The inner-header *-muted token carries the tinted background for every
539
- // status, info included. A theme override that sets a plain CSS property
540
- // instead lands in @layer astryx-theme, which StyleX's @layer priority4
541
- // outranks, so `backgroundColor` here would silently do nothing and the
542
- // info banner would paint no background at all.
543
- //
544
- // Status overrides reference --color-text-{hue} so text/icon colors
545
- // stay in sync with the palette anchors automatically.
546
385
  banner: {
386
+ base: {
387
+ '--color-neutral': 'var(--astryx-theme-neutral-color-on-tint-neutral)',
388
+ '--color-overlay-hover':
389
+ 'var(--astryx-theme-neutral-color-on-tint-overlay-hover)',
390
+ '--color-overlay-pressed':
391
+ 'var(--astryx-theme-neutral-color-on-tint-overlay-pressed)',
392
+ },
547
393
  'status:info': {
548
- '--color-accent-muted': 'var(--color-background-blue)',
394
+ '--color-accent-muted':
395
+ 'var(--astryx-theme-neutral-color-status-muted-accent)',
549
396
  '--color-text-primary': 'var(--color-text-blue)',
550
397
  '--color-text-secondary': 'var(--color-text-blue)',
551
398
  '--color-accent': 'var(--color-text-blue)',
552
399
  },
553
- // success/warning/error banner bgs come from --color-{X}-muted, which
554
- // already carries the correct light/dark tinted values. We only need
555
- // to redirect the text/icon to the palette colored stop.
556
400
  'status:success': {
557
401
  '--color-text-primary': 'var(--color-text-green)',
558
402
  '--color-text-secondary': 'var(--color-text-green)',
@@ -570,23 +414,13 @@ export const neutralTheme = defineTheme({
570
414
  },
571
415
  },
572
416
 
573
- // =========================================================================
574
- // TextInput — no per-status overrides needed. The global tokens
575
- // --color-{success,error,warning} carry the correct values in both
576
- // modes (light=T40 dark colored, dark=T80 light pastel) for both
577
- // surfaces the input border/icon touches: the input surface
578
- // (white/T15-dark) and the status message bubble (light pastel T90 /
579
- // dark T20). Verified all six combinations clear AA non-text 3:1.
580
- // =========================================================================
417
+ 'step-indicator': {
418
+ 'status:accent': {'--color-accent': statusFill.accent},
419
+ 'status:success': {'--color-success': statusFill.success},
420
+ 'status:warning': {'--color-warning': statusFill.warning},
421
+ 'status:error': {'--color-error': statusFill.error},
422
+ },
581
423
 
582
- // =========================================================================
583
- // Switch — off-state track uses the same lifted-neutral surface as the
584
- // ProgressBar track (--color-border-emphasized). Aligns the two
585
- // "channel-on-body" components so their off-states share one visual
586
- // language: light T85 #d4d4d4 sits one step darker than the body T95
587
- // bg, dark T35 #525252 sits one step lighter than the body T10. Each
588
- // is a defined channel, not a wash that blends in.
589
- // =========================================================================
590
424
  switch: {
591
425
  base: {
592
426
  '--color-background-gray': 'var(--color-border-emphasized)',
@@ -595,46 +429,28 @@ export const neutralTheme = defineTheme({
595
429
 
596
430
  progressbar: {
597
431
  base: {
598
- // Track uses --color-background-muted; override it to
599
- // --color-border-emphasized (Neutral T85 #d4d4d4 in light mode) so
600
- // the track is clearly darker than the body bg (Neutral T95 #f1f1f1)
601
- // and reads as a defined channel rather than blending in. Dark
602
- // mode inherits T35 #525252 — same one-step-lighter behavior.
603
432
  '--color-background-muted': 'var(--color-border-emphasized)',
604
433
  },
605
- // Vivid stops match the filled semantic badge colors (info/success/
606
- // warning/error variants in the badge override above). Same hex
607
- // values; documented per role with palette provenance.
608
434
  'variant:accent': {
609
- // Blue T50 saturated stop (= variant:info badge bg)
610
- '--color-accent': '#0074e2',
435
+ '--color-accent': statusFill.accent,
611
436
  },
612
437
  'variant:success': {
613
- // Green T45 saturated stop (= variant:success badge bg)
614
- '--color-success': '#198100',
438
+ '--color-success': statusFill.success,
615
439
  },
616
440
  'variant:warning': {
617
- // Yellow T85 saturated stop (= variant:warning badge bg)
618
- '--color-warning': '#ffce2f',
441
+ '--color-warning': statusFill.warning,
619
442
  },
620
443
  'variant:error': {
621
- // Red T58 saturated stop (= variant:error badge bg)
622
- '--color-error': '#c9303a',
444
+ '--color-error': statusFill.error,
623
445
  },
624
446
  },
625
447
 
626
- // =========================================================================
627
- // Card — tighter padding via public card padding token
628
- // =========================================================================
629
448
  card: {
630
449
  base: {
631
450
  padding: 'var(--spacing-3)',
632
451
  },
633
452
  },
634
453
 
635
- // =========================================================================
636
- // Section — tighter padding via public section padding token
637
- // =========================================================================
638
454
  section: {
639
455
  base: {
640
456
  padding: 'var(--spacing-3)',