@mindlogic-ai/logician-ui 3.1.0-alpha.8 → 3.1.0

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.
Files changed (42) hide show
  1. package/dist/components/InfoSprinkle/InfoSprinkle.d.ts +1 -1
  2. package/dist/components/InfoSprinkle/InfoSprinkle.d.ts.map +1 -1
  3. package/dist/components/InfoSprinkle/InfoSprinkle.js +25 -2
  4. package/dist/components/InfoSprinkle/InfoSprinkle.js.map +1 -1
  5. package/dist/components/InfoSprinkle/InfoSprinkle.mjs +25 -2
  6. package/dist/components/InfoSprinkle/InfoSprinkle.mjs.map +1 -1
  7. package/dist/components/SegmentedControl/SegmentedControl.d.ts.map +1 -1
  8. package/dist/components/SegmentedControl/SegmentedControl.js +20 -4
  9. package/dist/components/SegmentedControl/SegmentedControl.js.map +1 -1
  10. package/dist/components/SegmentedControl/SegmentedControl.mjs +20 -4
  11. package/dist/components/SegmentedControl/SegmentedControl.mjs.map +1 -1
  12. package/dist/hooks/useHasHover.d.ts +13 -0
  13. package/dist/hooks/useHasHover.d.ts.map +1 -0
  14. package/dist/hooks/useHasHover.js +34 -0
  15. package/dist/hooks/useHasHover.js.map +1 -0
  16. package/dist/hooks/useHasHover.mjs +29 -0
  17. package/dist/hooks/useHasHover.mjs.map +1 -0
  18. package/dist/index.d.ts +1 -0
  19. package/dist/index.d.ts.map +1 -1
  20. package/dist/index.js +2 -0
  21. package/dist/index.js.map +1 -1
  22. package/dist/index.mjs +1 -0
  23. package/dist/index.mjs.map +1 -1
  24. package/dist/theme/colors.d.ts +196 -44
  25. package/dist/theme/colors.d.ts.map +1 -1
  26. package/dist/theme/colors.js +184 -22
  27. package/dist/theme/colors.js.map +1 -1
  28. package/dist/theme/colors.mjs +184 -22
  29. package/dist/theme/colors.mjs.map +1 -1
  30. package/dist/theme/global.d.ts.map +1 -1
  31. package/dist/theme/global.js +30 -2
  32. package/dist/theme/global.js.map +1 -1
  33. package/dist/theme/global.mjs +30 -2
  34. package/dist/theme/global.mjs.map +1 -1
  35. package/package.json +1 -1
  36. package/src/components/InfoSprinkle/InfoSprinkle.tsx +32 -0
  37. package/src/components/SegmentedControl/SegmentedControl.tsx +21 -4
  38. package/src/hooks/useHasHover.ts +32 -0
  39. package/src/index.ts +1 -0
  40. package/src/theme/SemanticTokens.mdx +61 -7
  41. package/src/theme/colors.ts +216 -26
  42. package/src/theme/global.ts +31 -2
@@ -0,0 +1,32 @@
1
+ 'use client';
2
+
3
+ import { useEffect, useState } from 'react';
4
+
5
+ /**
6
+ * Returns whether the primary pointing device is capable of hovering
7
+ * (i.e. a mouse/trackpad rather than a touchscreen).
8
+ *
9
+ * Useful for components that rely on hover interactions on desktop but need a
10
+ * tap/click affordance on touch devices, where hover events never fire.
11
+ *
12
+ * Defaults to `true` so server-rendered markup matches the most common
13
+ * (desktop) case, then resolves to the real value after mount.
14
+ */
15
+ export const useHasHover = () => {
16
+ const [hasHover, setHasHover] = useState(true);
17
+
18
+ useEffect(() => {
19
+ if (typeof window === 'undefined' || !window.matchMedia) return;
20
+
21
+ const mql = window.matchMedia('(hover: hover)');
22
+ const update = () => setHasHover(mql.matches);
23
+
24
+ update();
25
+ mql.addEventListener('change', update);
26
+ return () => mql.removeEventListener('change', update);
27
+ }, []);
28
+
29
+ return hasHover;
30
+ };
31
+
32
+ export default useHasHover;
package/src/index.ts CHANGED
@@ -102,6 +102,7 @@ export {
102
102
  } from './components/Typography';
103
103
 
104
104
  // Hooks
105
+ export { useHasHover } from './hooks/useHasHover';
105
106
  export type { LanguageContextValue } from './hooks/useLanguage';
106
107
  export { LanguageContext, useLanguage } from './hooks/useLanguage';
107
108
 
@@ -34,7 +34,7 @@ primitive depending on the active color mode.
34
34
  Two payoffs:
35
35
 
36
36
  1. **Dark mode for free.** Add `color="fg.default"` and it's `gray.1300` in
37
- light and `gray.50` in dark — no `useColorMode`, no conditionals.
37
+ light and a light neutral in dark — no `useColorMode`, no conditionals.
38
38
  2. **Intent, not value.** `border.subtle` always reads as "a low-emphasis
39
39
  divider," whatever the mode. The intent is encoded once, in the theme.
40
40
 
@@ -49,21 +49,38 @@ have no semantic meaning.
49
49
  These cover text, surfaces, and borders — the bulk of any UI. Each flips
50
50
  between a light and a dark value.
51
51
 
52
+ > **Dark neutrals are desaturated.** The `Dark` columns below name the `gray`
53
+ > step each token mirrors, but in dark mode every neutral actually resolves to a
54
+ > _halved-saturation_ counterpart of that step (the blue-tinted `gray` ramp is
55
+ > right for light surfaces but muddy as a dark one). Light values are exactly the
56
+ > named `gray` step. You never reference the desaturated values directly — use
57
+ > the tokens.
58
+
52
59
  ### Foreground — `fg.*` (text & icons)
53
60
 
54
61
  | Token | Light | Dark | Use for |
55
62
  | --- | --- | --- | --- |
56
- | `fg.default` | `gray.1300` | `gray.200` | Primary text, headings, icons |
57
- | `fg.muted` | `gray.900` | `gray.300` | Secondary text, captions, supporting icons |
58
- | `fg.subtle` | `gray.700` | `gray.600` | Tertiary text, placeholders, disabled-ish labels |
63
+ | `fg.emphasized` | `gray.1300` | `gray.200` | Headings, titles, key figures, strong emphasis |
64
+ | `fg.default` | `gray.1000` | `gray.300` | Primary **body** text |
65
+ | `fg.muted` | `gray.900` | `gray.400` | Secondary text, captions, supporting icons |
66
+ | `fg.subtle` | `gray.700` | `gray.600`† | Tertiary text, placeholders, icons |
59
67
  | `fg.inverse` | `gray.0` | `gray.1400` | Text on an **inverse** surface (e.g. tooltip) |
60
68
 
69
+ † `fg.subtle`'s dark value is bumped one step lighter than the straight mirror so
70
+ helper/secondary text clears WCAG AA 4.5:1 on `bg.muted`.
71
+
72
+ > `fg.default` is the body weight; `fg.emphasized` is the strongest step (the
73
+ > near-black `gray.1300` `fg.default` used to be). Reach for `emphasized` only
74
+ > when you want maximum contrast — headings, key numbers.
75
+
61
76
  ### Background — `bg.*` (surfaces & fills)
62
77
 
63
78
  | Token | Light | Dark | Use for |
64
79
  | --- | --- | --- | --- |
65
80
  | `bg.canvas` | `gray.0` | `gray.1500` | App/page background |
66
81
  | `bg.surface` | `white` | `gray.1400` | Raised surface — cards, popovers, menus, modals |
82
+ | `bg.raised` | `white` | `gray.1100` | Strongly-raised neutral — one level above `surface` (e.g. a selected segment); tops the dark ramp so it reads as lifted |
83
+ | `bg.sunken` | `gray.50` | `gray.1500` | Sunken page wash for list/overview surfaces, so `bg.surface` cards read as raised above it |
67
84
  | `bg.subtle` | `gray.50` | `gray.1300` | Subtle fill, hover background, secondary surface |
68
85
  | `bg.muted` | `gray.100` | `gray.1200` | Muted fill, tertiary surface, neutral chips |
69
86
  | `bg.inverse` | `gray.1300` | `gray.50` | High-contrast/inverse surface (flips with mode) |
@@ -86,6 +103,30 @@ between a light and a dark value.
86
103
  </Box>
87
104
  ```
88
105
 
106
+ ### `slate.*` — foundational mode-aware neutral family
107
+
108
+ `slate.0`–`slate.1500` is a **first-class neutral palette**, sitting alongside
109
+ the raw `gray.*` primitives but **mode-aware**: `slate.N` is `gray.N` in light
110
+ and the desaturated counterpart in dark, so one token holds the same tonal level
111
+ in both modes (`slate.300` is a light divider in light and the matching dark
112
+ divider in dark — no `_dark={{…}}` at the call site). It lives under
113
+ `semanticTokens` only because Chakra requires that for the `_dark` flip;
114
+ conceptually it's a **foundation** (a tonal scale), not a **role**.
115
+
116
+ ```tsx
117
+ // Use a role token when one matches the intent (carries semantics + AA tuning):
118
+ <Text color="fg.muted" />
119
+
120
+ // Reach for slate when you need a specific neutral step no role names — the
121
+ // mode-aware equivalent of dropping to a raw gray.N:
122
+ <Box borderColor="slate.300" bg="slate.50" />
123
+ ```
124
+
125
+ > `slate` and the role tokens (`fg`/`bg`/`border`) are **distinct ramps** —
126
+ > `slate` is a mechanical light↔dark mirror, while the roles carry hand-tuned
127
+ > dark values (AA bumps, hierarchy). They share light values but **diverge in
128
+ > dark**, so they are not drop-in interchangeable. Pick by intent, not by value.
129
+
89
130
  ---
90
131
 
91
132
  ## The brand tokens
@@ -110,6 +151,18 @@ flips in dark mode:
110
151
  In dark mode these steps lighten so they keep contrast on dark surfaces — e.g.
111
152
  `primary.main` is `blue.500` in light and lifts to `blue.300` in dark.
112
153
 
154
+ ### Solid brand fills — `primary.fill` / `primary.fillStrong`
155
+
156
+ For a **solid blue surface with white text/icons on top** (modal headers, hero
157
+ banners, brand badges), use `primary.fill` (or `primary.fillStrong` for a deeper
158
+ shade) rather than `primary.main`. The `*.main`/`*.dark` steps lighten in dark —
159
+ right for foreground, but too light to carry white text as a fill — so the
160
+ `fill*` tokens stay deep blue in both modes.
161
+
162
+ ```tsx
163
+ <ModalHeader bg="primary.fill" color="white">Upgrade</ModalHeader>
164
+ ```
165
+
113
166
  ---
114
167
 
115
168
  ## Migration cheat-sheet
@@ -119,9 +172,10 @@ the nearest semantic token:
119
172
 
120
173
  | Was (primitive) | Use (semantic) |
121
174
  | --- | --- |
122
- | `gray.1000`–`gray.1500` as text | `fg.default` |
123
- | `gray.800`–`gray.900` as text | `fg.muted` |
124
- | `gray.500`–`gray.700` as text/icon | `fg.subtle` |
175
+ | `gray.1200`–`gray.1500` as heading/strong text | `fg.emphasized` |
176
+ | `gray.1000`–`gray.1100` as body text | `fg.default` |
177
+ | `gray.900` as secondary text | `fg.muted` |
178
+ | `gray.500`–`gray.800` as tertiary text/icon | `fg.subtle` |
125
179
  | `white` / `gray.0` surface | `bg.surface` / `bg.canvas` |
126
180
  | `gray.50` / `gray.100` fill | `bg.subtle` / `bg.muted` |
127
181
  | `gray.200` / `gray.300` border | `border.subtle` / `border.default` |
@@ -60,6 +60,37 @@
60
60
  * <Badge bgColor="success.lightest" color="success.dark" />
61
61
  * ```
62
62
  */
63
+ /**
64
+ * Halved-saturation counterpart of each primitive `gray.N` — same hue and
65
+ * lightness, HSL saturation cut by ~50% (e.g. `gray.1300` #1E2433 @ 26% →
66
+ * #23262E @ 13%). The blue-tinted `gray` ramp reads as the right neutral in
67
+ * light mode but turns muddy/over-chromatic as a dark surface, so every
68
+ * dark-mode neutral below (`slate.*`, and the `_dark` of `bg`/`fg`/`border`)
69
+ * resolves to this desaturated mirror instead of the raw `gray` step. Light
70
+ * mode is untouched — it keeps referencing `{colors.gray.*}` verbatim.
71
+ *
72
+ * Single source of truth: change a dark neutral here, not at each token.
73
+ */
74
+ const desaturatedGray = {
75
+ 0: '#FEFEFF',
76
+ 50: '#F8F9FB',
77
+ 100: '#F2F4F7',
78
+ 200: '#E5E8EC',
79
+ 300: '#D2D5DB',
80
+ 400: '#B6BAC3',
81
+ 500: '#A2A6B1',
82
+ 600: '#8E939F',
83
+ 700: '#7C818D',
84
+ 800: '#6A6F7C',
85
+ 900: '#595E6B',
86
+ 1000: '#4A4E5A',
87
+ 1100: '#3C404B',
88
+ 1200: '#30343C',
89
+ 1300: '#23262E',
90
+ 1400: '#181A20',
91
+ 1500: '#0E1014',
92
+ } as const;
93
+
63
94
  export const semanticTokens = {
64
95
  colors: {
65
96
  /**
@@ -97,6 +128,18 @@ export const semanticTokens = {
97
128
  darker: {
98
129
  value: { base: '{colors.blue.900}', _dark: '{colors.blue.100}' },
99
130
  }, // high-contrast text
131
+ // Solid brand-blue *fills* for surfaces with white text/icons on top
132
+ // (modal headers, hero/banner gradients, brand badges). `primary.main`/
133
+ // `primary.dark` lighten ~2 stops in dark — right for foreground, too
134
+ // light as a fill — so these stay a deep blue in dark instead. `base`
135
+ // repeats the old main/dark values, so light is unchanged. Bare accents
136
+ // (dots, progress bars) keep `primary.main`.
137
+ fill: {
138
+ value: { base: '{colors.blue.500}', _dark: '{colors.blue.700}' },
139
+ },
140
+ fillStrong: {
141
+ value: { base: '{colors.blue.700}', _dark: '{colors.blue.800}' },
142
+ },
100
143
  },
101
144
 
102
145
  /**
@@ -234,6 +277,77 @@ export const semanticTokens = {
234
277
  },
235
278
  },
236
279
 
280
+ /**
281
+ * `slate.*` — the foundational **mode-aware neutral family**.
282
+ *
283
+ * A first-class neutral palette alongside the raw `gray.*` primitives, but
284
+ * mode-aware: each `slate.N` resolves to `gray.N` in light and to the
285
+ * desaturated counterpart of the *mirrored* step in dark, so a single token
286
+ * carries the same tonal level in both modes (e.g. `slate.300` is a light
287
+ * divider in light and the equivalent dark divider in dark — no `_dark={{…}}`
288
+ * at the call site). It lives under `semanticTokens` only because Chakra
289
+ * requires that for the `_dark` flip; conceptually it is a *foundation*
290
+ * (a tonal scale), not a *role*.
291
+ *
292
+ * When to use which:
293
+ * - Prefer the **role tokens** (`fg`/`bg`/`border`) when one matches the
294
+ * intent — they carry semantics and AA-tuned dark values.
295
+ * - Reach for **`slate.N`** when you need a specific neutral tonal step that
296
+ * no role names (mirroring how you'd otherwise drop to a raw `gray.N`, but
297
+ * keeping the dark flip). `slate` and the role tokens are *distinct* ramps
298
+ * (slate is a mechanical mirror; roles are hand-tuned), so they are not
299
+ * interchangeable in dark mode.
300
+ *
301
+ * `600`/`700` are lifted off the straight mirror (#8E939F/#7C818D) so the
302
+ * secondary/muted text they most often carry clears WCAG AA 4.5:1 on the
303
+ * dark canvas/surface — the straight mirrors measured ~3.0–3.9 there.
304
+ */
305
+ slate: {
306
+ 0: { value: { base: '{colors.gray.0}', _dark: desaturatedGray[1500] } },
307
+ 50: { value: { base: '{colors.gray.50}', _dark: desaturatedGray[1400] } },
308
+ 100: {
309
+ value: { base: '{colors.gray.100}', _dark: desaturatedGray[1300] },
310
+ },
311
+ 200: {
312
+ value: { base: '{colors.gray.200}', _dark: desaturatedGray[1200] },
313
+ },
314
+ 300: {
315
+ value: { base: '{colors.gray.300}', _dark: desaturatedGray[1100] },
316
+ },
317
+ 400: {
318
+ value: { base: '{colors.gray.400}', _dark: desaturatedGray[1000] },
319
+ },
320
+ 500: {
321
+ value: { base: '{colors.gray.500}', _dark: desaturatedGray[900] },
322
+ },
323
+ 600: { value: { base: '{colors.gray.600}', _dark: '#898E99' } },
324
+ 700: { value: { base: '{colors.gray.700}', _dark: '#8D919D' } },
325
+ 800: {
326
+ value: { base: '{colors.gray.800}', _dark: desaturatedGray[600] },
327
+ },
328
+ 900: {
329
+ value: { base: '{colors.gray.900}', _dark: desaturatedGray[500] },
330
+ },
331
+ 1000: {
332
+ value: { base: '{colors.gray.1000}', _dark: desaturatedGray[400] },
333
+ },
334
+ 1100: {
335
+ value: { base: '{colors.gray.1100}', _dark: desaturatedGray[300] },
336
+ },
337
+ 1200: {
338
+ value: { base: '{colors.gray.1200}', _dark: desaturatedGray[200] },
339
+ },
340
+ 1300: {
341
+ value: { base: '{colors.gray.1300}', _dark: desaturatedGray[100] },
342
+ },
343
+ 1400: {
344
+ value: { base: '{colors.gray.1400}', _dark: desaturatedGray[50] },
345
+ },
346
+ 1500: {
347
+ value: { base: '{colors.gray.1500}', _dark: desaturatedGray[0] },
348
+ },
349
+ },
350
+
237
351
  /**
238
352
  * Neutral background tokens — map onto the gray.0–1500 scale.
239
353
  * Use for: page/canvas, raised surfaces (cards, menus), subtle/muted fills,
@@ -246,27 +360,55 @@ export const semanticTokens = {
246
360
  * - inverse: high-contrast surface (flips to light in dark mode)
247
361
  */
248
362
  bg: {
363
+ // Chakra's own global css paints `html { background: bg }` — the *plain*
364
+ // `bg` token, not `bg.canvas` — so this is the actual page background
365
+ // wherever no component paints over it. `_light` is pure white (Chakra's
366
+ // value, so light is untouched); `_dark` rejoins our neutral floor instead
367
+ // of Chakra's off-palette black.
368
+ DEFAULT: {
369
+ value: { _light: '{colors.white}', _dark: desaturatedGray[1500] },
370
+ },
249
371
  canvas: {
250
- value: { base: '{colors.gray.0}', _dark: '{colors.gray.1500}' },
372
+ value: { base: '{colors.gray.0}', _dark: desaturatedGray[1500] },
251
373
  },
252
374
  surface: {
253
- value: { base: '{colors.white}', _dark: '{colors.gray.1400}' },
375
+ value: { base: '{colors.white}', _dark: desaturatedGray[1400] },
376
+ },
377
+ // Strongly-raised neutral surface — one level above `surface` (e.g. the
378
+ // selected thumb of a SegmentedControl). In dark this is the *lightest*
379
+ // neutral bg token so a raised element reads as lifted toward the light,
380
+ // not recessed. (The `bg.*` dark ramp is otherwise compressed such that
381
+ // `surface` sits below `subtle`/`muted`; `raised` deliberately tops the
382
+ // scale so "raised" has a token that behaves correctly in dark.)
383
+ // NB: named `raised`, not Chakra's `emphasized` — that default token name
384
+ // cannot be overridden via semanticTokens in this setup (it keeps
385
+ // resolving to Chakra's own gray.200), whereas a fresh name is honoured.
386
+ raised: {
387
+ value: { base: '{colors.white}', _dark: desaturatedGray[1100] },
254
388
  },
255
389
  subtle: {
256
- value: { base: '{colors.gray.50}', _dark: '{colors.gray.1300}' },
390
+ value: { base: '{colors.gray.50}', _dark: desaturatedGray[1300] },
257
391
  },
258
392
  muted: {
259
- value: { base: '{colors.gray.100}', _dark: '{colors.gray.1200}' },
393
+ value: { base: '{colors.gray.100}', _dark: desaturatedGray[1200] },
260
394
  },
261
395
  inverse: {
262
- value: { base: '{colors.gray.1300}', _dark: '{colors.gray.50}' },
396
+ value: { base: '{colors.gray.1300}', _dark: desaturatedGray[50] },
397
+ },
398
+ // Sunken page wash for list/overview surfaces: a gray floor in light so
399
+ // `bg.surface` cards read as raised above it. In dark the `bg.*` ramp is
400
+ // compressed (`subtle` sits *lighter* than `surface`), which would invert
401
+ // that elevation — so the dark value drops to the canvas floor instead.
402
+ // Component-level fills (hover, chips, inner blocks) keep using `bg.subtle`.
403
+ sunken: {
404
+ value: { base: '{colors.gray.50}', _dark: desaturatedGray[1500] },
263
405
  },
264
406
  // Override Chakra's default `bg.panel` (whose `_dark` resolves to Chakra's
265
407
  // own gray.950 = #111111, off our slate palette). Light value is white —
266
408
  // identical to Chakra's default — so this only realigns dark overlay
267
409
  // surfaces (Menu / Modal / Popover / Toast) onto our gray scale.
268
410
  panel: {
269
- value: { base: '{colors.white}', _dark: '{colors.gray.1400}' },
411
+ value: { base: '{colors.white}', _dark: desaturatedGray[1400] },
270
412
  },
271
413
  /**
272
414
  * Row/selection state tints. Use these for selected rows,
@@ -300,26 +442,42 @@ export const semanticTokens = {
300
442
  * - inverse: text on inverse surfaces (flips with mode)
301
443
  */
302
444
  fg: {
445
+ // Plain `fg` is Chakra's html-level text color (`html { color: fg }`).
446
+ // `_light` repeats Chakra's value (black); `_dark` rejoins our desaturated
447
+ // neutral so legacy html-level text tracks `fg.default`.
448
+ DEFAULT: {
449
+ value: { _light: '{colors.black}', _dark: desaturatedGray[50] },
450
+ },
451
+ // Strongest text — headings, titles, key figures, emphasis. This is the
452
+ // near-black step that `fg.default` used to be; `default` is now re-pegged
453
+ // to a lighter body weight (see below), so reach for `emphasized` when you
454
+ // specifically want maximum contrast.
455
+ emphasized: {
456
+ value: { base: '{colors.gray.1300}', _dark: desaturatedGray[200] },
457
+ },
303
458
  default: {
304
- // _dark is gray.200 (not gray.50): near-white text on the dark canvas
305
- // ran ~18:1 brighter than the light baseline (~15:1) and close to pure
306
- // white, which causes glare/halation. gray.200 matches the light
307
- // contrast (~15.4:1) while staying AAA.
308
- value: { base: '{colors.gray.1300}', _dark: '{colors.gray.200}' },
459
+ // Primary *body* text. Re-pegged from gray.1300 gray.1000: near-black
460
+ // (gray.1300, ~14:1 on white) is unusually heavy for running copy, and
461
+ // real product usage clustered well below it. gray.1000 (~9:1) is a
462
+ // comfortable AAA body weight; the old near-black step lives on as
463
+ // `fg.emphasized`. _dark drops one step from emphasized for hierarchy.
464
+ value: { base: '{colors.gray.1000}', _dark: desaturatedGray[300] },
309
465
  },
310
466
  muted: {
311
- // _dark lifts gray.400 gray.300: secondary text read as too dim on the
312
- // dark canvas next to fg.default (gray.200). gray.300 sits one step under
313
- // default — restoring the light-mode hierarchy gap — while staying well
314
- // clear of AA (~12.8:1 on bg.canvas, ~11.7:1 on bg.surface). Light value
315
- // (gray.900) is unchanged.
316
- value: { base: '{colors.gray.900}', _dark: '{colors.gray.300}' },
467
+ // Secondary text. _dark sits one step below `default` (~9.5:1 on the dark
468
+ // canvas) to keep the default→muted hierarchy gap. Light value (gray.900)
469
+ // is unchanged.
470
+ value: { base: '{colors.gray.900}', _dark: desaturatedGray[400] },
317
471
  },
318
472
  subtle: {
319
- value: { base: '{colors.gray.700}', _dark: '{colors.gray.600}' },
473
+ // Tertiary / placeholder / icon text. _dark a11y-bumped from the straight
474
+ // mirror (desaturatedGray[600] #8E939F, ~4.06:1 on bg.muted) to #989DA9
475
+ // (~4.6:1) so it clears AA while staying below fg.muted. Light value
476
+ // (gray.700) is unchanged.
477
+ value: { base: '{colors.gray.700}', _dark: '#989DA9' },
320
478
  },
321
479
  inverse: {
322
- value: { base: '{colors.gray.0}', _dark: '{colors.gray.1400}' },
480
+ value: { base: '{colors.gray.0}', _dark: desaturatedGray[1400] },
323
481
  },
324
482
  },
325
483
 
@@ -331,14 +489,21 @@ export const semanticTokens = {
331
489
  * - strong: high-emphasis borders, focus outlines on neutral
332
490
  */
333
491
  border: {
492
+ // Plain `border` feeds Chakra's `--global-color-border` (the implicit
493
+ // default border color). Not a text color, so its `_dark` takes the
494
+ // straight halved-saturation mirror (no a11y bump). `_light` repeats
495
+ // Chakra's value so light is untouched.
496
+ DEFAULT: {
497
+ value: { _light: '{colors.gray.200}', _dark: desaturatedGray[800] },
498
+ },
334
499
  default: {
335
- value: { base: '{colors.gray.300}', _dark: '{colors.gray.1100}' },
500
+ value: { base: '{colors.gray.300}', _dark: desaturatedGray[1100] },
336
501
  },
337
502
  subtle: {
338
- value: { base: '{colors.gray.200}', _dark: '{colors.gray.1300}' },
503
+ value: { base: '{colors.gray.200}', _dark: desaturatedGray[1300] },
339
504
  },
340
505
  strong: {
341
- value: { base: '{colors.gray.500}', _dark: '{colors.gray.900}' },
506
+ value: { base: '{colors.gray.500}', _dark: desaturatedGray[900] },
342
507
  },
343
508
  },
344
509
  },
@@ -352,22 +517,47 @@ export const semanticTokens = {
352
517
  * by reviewers. It is intentionally hand-maintained alongside `semanticTokens`
353
518
  * so a rename here is a visible, reviewable diff.
354
519
  *
355
- * Note: `bg.panel` is deliberately omitted it is an internal realignment of a
356
- * Chakra default (for overlay surfaces), not part of the public migration
357
- * contract. App code should use `bg.surface`/`bg.canvas`.
520
+ * Note: `bg.panel` and the bare `bg`/`fg`/`border` DEFAULT tokens are
521
+ * deliberately omitted they are internal realignments of Chakra defaults
522
+ * (overlay surfaces and html-level globals), not part of the public migration
523
+ * contract. App code should use `bg.surface`/`bg.canvas`, `fg.default`, etc.
358
524
  */
359
525
  export type SemanticColorToken =
360
526
  | `bg.${
361
527
  | 'canvas'
362
528
  | 'surface'
529
+ | 'raised'
363
530
  | 'subtle'
364
531
  | 'muted'
532
+ | 'sunken'
365
533
  | 'inverse'
366
534
  | 'selected'
367
535
  | 'highlighted'}`
368
536
  | 'bg.invalid.subtle'
369
- | `fg.${'default' | 'muted' | 'subtle' | 'inverse'}`
537
+ | `fg.${'emphasized' | 'default' | 'muted' | 'subtle' | 'inverse'}`
370
538
  | `border.${'default' | 'subtle' | 'strong'}`
539
+ // `slate.*` — foundational mode-aware neutral family (a tonal scale, not a
540
+ // role). Prefer a `fg`/`bg`/`border` role token when one fits; reach for
541
+ // `slate.N` when you need a specific neutral step no role names.
542
+ | `slate.${
543
+ | 0
544
+ | 50
545
+ | 100
546
+ | 200
547
+ | 300
548
+ | 400
549
+ | 500
550
+ | 600
551
+ | 700
552
+ | 800
553
+ | 900
554
+ | 1000
555
+ | 1100
556
+ | 1200
557
+ | 1300
558
+ | 1400
559
+ | 1500}`
560
+ | `primary.${'fill' | 'fillStrong'}`
371
561
  | `${'primary' | 'secondary' | 'danger' | 'success' | 'warning'}.${
372
562
  | 'lightest'
373
563
  | 'extralight'
@@ -20,8 +20,8 @@ export const globalCss = defineGlobalStyles({
20
20
  // Dark mode body fallbacks. Only activates under the `.dark` class set by the
21
21
  // color-mode provider, so light-mode rendering is byte-for-byte identical.
22
22
  '.dark': {
23
- '--chakra-colors-chakra-body-text': '#E2E6F0', // gray.200 - Primary text (dark); softened from gray.50 to avoid near-white glare (matches fg.default)
24
- '--chakra-colors-chakra-body-bg': '#0B0E17', // gray.1500 - Background (dark)
23
+ '--chakra-colors-chakra-body-text': '#E5E8EC', // desaturated gray.200 - Primary text (dark); matches fg.default's _dark
24
+ '--chakra-colors-chakra-body-bg': '#0E1014', // desaturated gray.1500 - Background (dark); matches bg.canvas's _dark
25
25
  },
26
26
 
27
27
  html: {
@@ -49,6 +49,35 @@ export const globalCss = defineGlobalStyles({
49
49
  "body[data-lang='es']": {
50
50
  fontFamily: inter.style.fontFamily,
51
51
  },
52
+
53
+ // Global scrollbar styling. Without this, scrollbars fall back to the raw
54
+ // browser chrome — a bright, square, high-contrast track that stands out
55
+ // badly in dark mode. A thin, transparent-track, rounded thumb (mode-aware
56
+ // via the `slate` ramp) is unobtrusive in both modes. Components that opt out
57
+ // (e.g. hidden scrollbars) still override locally.
58
+ '*': {
59
+ scrollbarWidth: 'thin',
60
+ scrollbarColor: 'var(--chakra-colors-slate-300) transparent',
61
+ },
62
+ '::-webkit-scrollbar': {
63
+ width: '10px',
64
+ height: '10px',
65
+ },
66
+ '::-webkit-scrollbar-track': {
67
+ background: 'transparent',
68
+ },
69
+ '::-webkit-scrollbar-thumb': {
70
+ backgroundColor: 'var(--chakra-colors-slate-300)',
71
+ borderRadius: '9999px',
72
+ border: '2px solid transparent',
73
+ backgroundClip: 'content-box',
74
+ },
75
+ '::-webkit-scrollbar-thumb:hover': {
76
+ backgroundColor: 'var(--chakra-colors-slate-400)',
77
+ },
78
+ '::-webkit-scrollbar-corner': {
79
+ background: 'transparent',
80
+ },
52
81
  });
53
82
 
54
83
  // Legacy export for backwards compatibility