basalt-ui 1.5.0 → 1.7.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 (68) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +25 -0
  3. package/agent/rules/basalt-mantine.md +80 -40
  4. package/agent/rules/basalt-tokens.md +3 -1
  5. package/agent/skills/basalt-design/SKILL.md +21 -7
  6. package/agent/templates/CLAUDE-block.md.tpl +3 -2
  7. package/agent/templates/DESIGN.md.tpl +2 -1
  8. package/configs/oxlint-plugin.js +75 -1
  9. package/configs/oxlint.json +1 -0
  10. package/dist/agent/stick-to-bottom.d.ts.map +1 -1
  11. package/dist/agent/stick-to-bottom.js +2 -1
  12. package/dist/agent-chat/composer.d.ts.map +1 -1
  13. package/dist/agent-chat/composer.js +5 -4
  14. package/dist/cli/index.d.ts +9 -0
  15. package/dist/cli/index.d.ts.map +1 -1
  16. package/dist/cli/index.js +2 -0
  17. package/dist/dashboard/index.d.ts +1 -1
  18. package/dist/dashboard/index.d.ts.map +1 -1
  19. package/dist/dashboard/stat-card.d.ts +6 -1
  20. package/dist/dashboard/stat-card.d.ts.map +1 -1
  21. package/dist/dashboard/stat-card.js +37 -4
  22. package/dist/guard/index.d.ts +7 -1
  23. package/dist/guard/index.d.ts.map +1 -1
  24. package/dist/guard/index.js +16 -1
  25. package/dist/guard/types.d.ts +18 -3
  26. package/dist/guard/types.d.ts.map +1 -1
  27. package/dist/index.d.ts +1 -1
  28. package/dist/index.d.ts.map +1 -1
  29. package/dist/shell/app-sidebar.module.css +4 -6
  30. package/dist/shell/sidebar-search.module.css +4 -2
  31. package/dist/surfaces.d.ts +2 -2
  32. package/dist/surfaces.d.ts.map +1 -1
  33. package/dist/surfaces.js +8 -2
  34. package/dist/theme/controls.module.css +167 -26
  35. package/dist/theme/index.d.ts.map +1 -1
  36. package/dist/theme/index.js +36 -19
  37. package/dist/theme/nav-link.module.css +10 -10
  38. package/dist/tokens/index.d.ts +1 -0
  39. package/dist/tokens/index.d.ts.map +1 -1
  40. package/dist/tokens/index.js +4 -0
  41. package/dist/tokens/palette.d.ts +31 -0
  42. package/dist/tokens/palette.d.ts.map +1 -1
  43. package/dist/tokens/palette.js +31 -0
  44. package/dist/tokens.css +2 -0
  45. package/dist/vite.d.ts +6 -0
  46. package/dist/vite.d.ts.map +1 -1
  47. package/dist/vite.js +21 -1
  48. package/llms.txt +3 -3
  49. package/package.json +1 -1
  50. package/src/agent/stick-to-bottom.tsx +2 -1
  51. package/src/agent-chat/composer.tsx +5 -4
  52. package/src/cli/index.ts +11 -0
  53. package/src/dashboard/index.ts +1 -1
  54. package/src/dashboard/stat-card.tsx +61 -2
  55. package/src/guard/check-source.test.ts +63 -1
  56. package/src/guard/index.ts +39 -3
  57. package/src/guard/types.ts +18 -2
  58. package/src/index.ts +1 -0
  59. package/src/shell/app-sidebar.module.css +4 -6
  60. package/src/shell/sidebar-search.module.css +4 -2
  61. package/src/surfaces.ts +8 -2
  62. package/src/theme/controls.module.css +167 -26
  63. package/src/theme/index.ts +50 -19
  64. package/src/theme/nav-link.module.css +10 -10
  65. package/src/theme/shadow-surfaces.test.ts +72 -33
  66. package/src/tokens/index.ts +4 -0
  67. package/src/tokens/palette.ts +31 -0
  68. package/src/vite.ts +62 -1
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Unit tests for checkSource — the pure (text, relPath, cfg) → Finding[] core.
3
3
  *
4
- * Covers all 19 guard kinds. Co-located with the guard, excluded from tsc
4
+ * Covers all 20 guard kinds. Co-located with the guard, excluded from tsc
5
5
  * (tsconfig exclude: src/**\/*.test.ts), run via `bun test`.
6
6
  *
7
7
  * The walker/reporter half is covered by the integration test in
@@ -154,6 +154,68 @@ describe('off-identity-accent', () => {
154
154
  })
155
155
  })
156
156
 
157
+ // ── 4b. mantine-shade-index ──────────────────────────────────────────────────
158
+
159
+ describe('mantine-shade-index', () => {
160
+ it('flags a shade-pinned text color (c="yellow.7")', () => {
161
+ const f = find(`<Text c="yellow.7">stale</Text>`)
162
+ expect(kinds(f)).toContain('mantine-shade-index')
163
+ })
164
+
165
+ it('flags bg="blue.4" and color="red.6"', () => {
166
+ expect(kinds(find(`<Box bg="blue.4" />`))).toContain('mantine-shade-index')
167
+ expect(kinds(find(`<ThemeIcon color="red.6" />`))).toContain('mantine-shade-index')
168
+ })
169
+
170
+ it('flags the var() form for a non-surface hue', () => {
171
+ const f = find(`const a = { color: 'var(--mantine-color-yellow-7)' }`)
172
+ expect(kinds(f)).toContain('mantine-shade-index')
173
+ })
174
+
175
+ it('flags the var() form in a .css file too — unlike the prop-only kinds', () => {
176
+ const f = find(`.a { color: var(--mantine-color-red-6); }`, 'src/app.css')
177
+ expect(kinds(f)).toContain('mantine-shade-index')
178
+ })
179
+
180
+ // The two kinds partition the space: gray/dark var() steps are surface color and belong to
181
+ // off-system-surface-var, so a single violation must never report under both.
182
+ it('leaves gray/dark var() steps to off-system-surface-var', () => {
183
+ const f = find(`const a = { background: 'var(--mantine-color-gray-3)' }`)
184
+ expect(kinds(f)).toContain('off-system-surface-var')
185
+ expect(kinds(f)).not.toContain('mantine-shade-index')
186
+ })
187
+
188
+ it('does NOT flag a bare status hue — it resolves per scheme, which is the point', () => {
189
+ expect(kinds(find(`<Text c="red">down</Text>`))).not.toContain('mantine-shade-index')
190
+ expect(kinds(find(`<ThemeIcon color="green" />`))).not.toContain('mantine-shade-index')
191
+ })
192
+
193
+ it('does NOT flag c="dimmed" or a token ref', () => {
194
+ expect(kinds(find(`<Text c="dimmed">age</Text>`))).not.toContain('mantine-shade-index')
195
+ expect(kinds(find(`<Text c={VX.status.warn}>age</Text>`))).not.toContain('mantine-shade-index')
196
+ })
197
+
198
+ it('does NOT flag a theme-allow line', () => {
199
+ const f = find(`<Text c="yellow.7" /> // theme-allow: design exception`)
200
+ expect(kinds(f)).not.toContain('mantine-shade-index')
201
+ })
202
+
203
+ it('is disabled by the mantineShadeIndex knob', () => {
204
+ const f = checkSource(`<Text c="yellow.7" />`, PATH, {
205
+ ...DEFAULT_GUARD_CONFIG,
206
+ mantineShadeIndex: false,
207
+ })
208
+ expect(kinds(f)).not.toContain('mantine-shade-index')
209
+ })
210
+
211
+ // The grace-minor doctrine (package CLAUDE.md): a kind that rejects previously-passing code
212
+ // lands as `warn` for one minor. Deleting its GRACE_PERIOD_KINDS entry in 1.7.0 flips this.
213
+ it('lands as a warning, not an error, for its grace minor', () => {
214
+ const f = find(`<Text c="yellow.7" />`)
215
+ expect(f.find((x) => x.kind === 'mantine-shade-index')?.severity).toBe('warn')
216
+ })
217
+ })
218
+
157
219
  // ── 5. raw-spacing ───────────────────────────────────────────────────────────
158
220
 
159
221
  describe('raw-spacing', () => {
@@ -58,6 +58,20 @@ const WITH_BORDER_PROP = /\bwithBorder\b(?!\s*=\s*\{\s*false\s*\})/
58
58
  // Raw Mantine ramp step used for surface color — gray/dark + a step digit.
59
59
  const OFF_SYSTEM_SURFACE_VAR = /var\(--mantine-color-(gray|dark)-\d/g
60
60
 
61
+ // A SHADE-PINNED Mantine color: the dotted JSX form (`c="yellow.7"`) or the var() form
62
+ // (`var(--mantine-color-red-6)`). Both name one fixed swatch that does NOT flip across color
63
+ // schemes — see the `mantineShadeIndex` doc in guard/types.ts for why the bare hue name is fine and
64
+ // the pinned step is not.
65
+ //
66
+ // The var() half deliberately excludes gray-/dark-, which `off-system-surface-var` already owns, so
67
+ // a surface violation reports once under one kind rather than twice under two.
68
+ //
69
+ // The JSX half is prop-shaped (`c="yellow.7"` needs the `=`), which CSS text has no syntax for, so
70
+ // it cannot fire in a `.css` file; the var() half is a real violation in CSS and is meant to fire
71
+ // there. That split is why this kind, unlike its prop-only neighbours, carries no `appliesTo` gate.
72
+ const MANTINE_SHADE_INDEX =
73
+ /\b(?:color|c|bg|backgroundColor|borderColor)\s*=\s*\{?\s*['"][a-z]+\.\d\b|var\(--mantine-color-(?!gray-|dark-)[a-z]+-\d/g
74
+
61
75
  // Raw lowercase JSX layout/surface element with inline style and layout/surface prop — line-scoped.
62
76
  const RAW_HTML_TAG = /<(?:div|span|section|header|nav|footer|aside|main|article|ul|ol)\b/
63
77
  const INLINE_STYLE = /style=\{\{/
@@ -241,6 +255,7 @@ export const DEFAULT_GUARD_CONFIG: GuardConfig = {
241
255
  spacingSteps: DEFAULT_SPACING_STEPS,
242
256
  rawRadius: true,
243
257
  forbiddenAccents: DEFAULT_FORBIDDEN_ACCENTS,
258
+ mantineShadeIndex: true,
244
259
  rawSurface: true,
245
260
  cardWithBorder: true,
246
261
  offSystemSurfaceVar: true,
@@ -266,14 +281,18 @@ export const DEFAULT_GUARD_CONFIG: GuardConfig = {
266
281
  * someone remembering. Adding a kind here is part of shipping it; removing the entry IS the
267
282
  * promotion, and belongs in its own commit so the changelog says enforcement got stricter.
268
283
  *
269
- * Empty today: every shipped kind is past its grace period. The next new kind goes here first.
284
+ * `mantine-shade-index` landed in 1.7.0 and is the sole entry — it rejects `c="yellow.7"`, which
285
+ * previously passed, and the one real consumer has several. Promote it in 1.8.0 by deleting the
286
+ * entry.
270
287
  *
271
288
  * @example
272
289
  * const GRACE: Partial<Record<GuardKind, string>> = {
273
290
  * 'raw-font-family': 'introduced 1.4.0 — promote to error in 1.5.0',
274
291
  * }
275
292
  */
276
- const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, string>> = {}
293
+ const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, string>> = {
294
+ 'mantine-shade-index': 'introduced 1.7.0 — promote to error in 1.8.0',
295
+ }
277
296
 
278
297
  /** A kind's effective severity: consumer override first, then the grace table, then `error`. */
279
298
  function severityOf(kind: GuardKind, cfg: GuardConfig): GuardSeverity {
@@ -561,7 +580,7 @@ type GuardRule = {
561
580
  }
562
581
 
563
582
  /**
564
- * The closed registry of all 19 guard kinds. The triad test asserts
583
+ * The closed registry of all 20 guard kinds. The triad test asserts
565
584
  * `surface.guardKinds ⊆ keyof GUARD_RULES` at runtime.
566
585
  *
567
586
  * raw-surface, raw-html-layout, and sub-16-input-font are handled inline in checkSource
@@ -612,6 +631,13 @@ export const GUARD_RULES = {
612
631
  appliesTo: (relPath) => !relPath.endsWith('.css'),
613
632
  message: 'For an off-identity accent use blue/gray or a status hue (red/green/orange/yellow).',
614
633
  },
634
+ 'mantine-shade-index': {
635
+ kind: 'mantine-shade-index',
636
+ pattern: MANTINE_SHADE_INDEX,
637
+ enabled: (cfg: GuardConfig) => cfg.mantineShadeIndex,
638
+ message:
639
+ 'Shade-pinned Mantine color — one fixed swatch in BOTH color schemes, so it cannot stay legible in either. Use VX.status.* / --vx-status-* for a verdict color, or the bare hue name (c="red") to let the theme resolve the shade per scheme.',
640
+ },
615
641
  'raw-spacing': {
616
642
  kind: 'raw-spacing',
617
643
  pattern: (cfg: GuardConfig) =>
@@ -842,6 +868,16 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
842
868
  }
843
869
  }
844
870
 
871
+ // mantine-shade-index — the sibling of off-system-surface-var above, partitioned by hue: that
872
+ // kind owns gray/dark (surface color), this one owns every other shade-pinned step (verdict and
873
+ // accent color). Both halves of the pattern (JSX prop + var()) run in every file type; see the
874
+ // pattern's own comment for why this kind carries no appliesTo gate.
875
+ if (GUARD_RULES['mantine-shade-index'].enabled!(cfg)) {
876
+ for (const m of line.matchAll(GUARD_RULES['mantine-shade-index'].pattern as RegExp)) {
877
+ findings.push({ relPath, line: i + 1, token: m[0], kind: 'mantine-shade-index' })
878
+ }
879
+ }
880
+
845
881
  // raw-html-layout: 3-condition conjunction on the same line — gated via GUARD_RULES entry.
846
882
  if (
847
883
  GUARD_RULES['raw-html-layout'].enabled!(cfg) &&
@@ -1,17 +1,18 @@
1
1
  /**
2
2
  * Guard types — Mantine-free, dependency-free (zero imports beyond TS types).
3
3
  *
4
- * GuardKind is the closed set of 19 violation kinds the theme guard can emit.
4
+ * GuardKind is the closed set of 20 violation kinds the theme guard can emit.
5
5
  * Finding is the structured result per violation, replacing the old `Violation` shape.
6
6
  * GuardConfig is the per-run configuration that drives checkSource.
7
7
  */
8
8
 
9
- /** The 19 theme-guard violation kinds. */
9
+ /** The 20 theme-guard violation kinds. */
10
10
  export type GuardKind =
11
11
  | 'raw-hex'
12
12
  | 'raw-color-fn'
13
13
  | 'localstorage-theme'
14
14
  | 'off-identity-accent'
15
+ | 'mantine-shade-index'
15
16
  | 'raw-spacing'
16
17
  | 'raw-radius'
17
18
  | 'raw-surface'
@@ -82,6 +83,21 @@ export type GuardConfig = {
82
83
  readonly rawRadius: boolean
83
84
  /** Off-identity Mantine accent families forbidden as chrome accents. */
84
85
  readonly forbiddenAccents: readonly string[]
86
+ /**
87
+ * Flag a SHADE-PINNED Mantine color — `c="yellow.7"`, `bg="blue.4"`, `var(--mantine-color-red-6)`.
88
+ *
89
+ * This is the sibling of `off-identity-accent`, and the two are deliberately disjoint. That kind
90
+ * polices WHICH hue you reach for and permits the bare status names (`c="red"`, `color="green"`)
91
+ * on purpose: they resolve through the theme, so they flip correctly across color schemes. This
92
+ * kind polices the SHADE INDEX, which does not — a pinned step is one fixed swatch in both
93
+ * schemes, so a `yellow.7` legible on the dark page is the one that fails contrast on the light
94
+ * one. Status color belongs on `VX.status.*` (`--vx-status-warn|bad|good|…`), which is emitted per
95
+ * scheme.
96
+ *
97
+ * `gray-*`/`dark-*` in `var()` form are excluded here because `off-system-surface-var` already
98
+ * owns them (surface color, its own message). Default true.
99
+ */
100
+ readonly mantineShadeIndex: boolean
85
101
  /** Flag ad-hoc inline surface styling (border/borderRadius/boxShadow literals). Default true. */
86
102
  readonly rawSurface: boolean
87
103
  /**
package/src/index.ts CHANGED
@@ -76,6 +76,7 @@ export {
76
76
  type DeltaBadgeProps,
77
77
  StatCard,
78
78
  type StatCardProps,
79
+ type StatCardTone,
79
80
  EmptyState,
80
81
  type EmptyStateProps,
81
82
  SettingsSection,
@@ -6,7 +6,7 @@
6
6
  * line is the child-list left guide (`--vx-divider`). Never reintroduce brand/section/footer
7
7
  * borders.
8
8
  *
9
- * The active fill, shadow-card depth and icon-accent color live in the Mantine theme
9
+ * The active tint and icon-accent color live in the Mantine theme
10
10
  * (`NavLink.extend` + `theme/nav-link.module.css`) so they apply to EVERY render path, including a
11
11
  * consumer's router `<Link>` via `renderNavLink` (which never sees this CSS module). This module
12
12
  * owns the shell-local layout (docs/DESIGN-SPEC.md §5) and the INACTIVE state (muted text, faint
@@ -50,7 +50,7 @@
50
50
  }
51
51
 
52
52
  /* Hover — ink-6% tint + ink text, for inactive rows only (an active row already carries the
53
- * theme's panel/shadow-card treatment and keeps its own hover via `--nl-hover`). */
53
+ * theme's stronger ink-9% tint and keeps its own hover via `--nl-hover`). */
54
54
  @media (hover: hover) {
55
55
  .link:not([data-active]):not([aria-current='page']):hover {
56
56
  background-color: color-mix(in srgb, var(--vx-ink) 6%, transparent) !important;
@@ -316,16 +316,14 @@
316
316
 
317
317
  /*
318
318
  * Active child = accent text, weight 600, NO fill (docs/DESIGN-SPEC.md §5) — unlike a top-level
319
- * active row (panel + shadow-card). Overrides the `--nl-*` custom properties the theme sets
320
- * inline on the SAME NavLink root, so `!important` is required; `box-shadow` cancels the theme
321
- * module's active shadow-card.
319
+ * active row (ink-9% tint). Overrides the `--nl-*` custom properties the theme sets inline on the
320
+ * SAME NavLink root, so `!important` is required.
322
321
  */
323
322
  .childList :global(.mantine-NavLink-root[data-active]),
324
323
  .childList :global(.mantine-NavLink-root[aria-current='page']) {
325
324
  --nl-color: var(--vx-accent) !important;
326
325
  --nl-bg: transparent !important;
327
326
  --nl-hover: color-mix(in srgb, var(--vx-ink) 5%, transparent) !important;
328
- box-shadow: none !important;
329
327
  }
330
328
 
331
329
  /* Child hover (inactive) — ink-5% + ink, one step quieter than the parent rows' 6%. Outranks the
@@ -1,6 +1,8 @@
1
1
  /*
2
2
  * Sidebar search trigger — replicates the former header search trigger (panel surface +
3
- * shadow-card, control radius, faint icon/label, mono Kbd) at full width inside the sidebar.
3
+ * shadow-raised, control radius, faint icon/label, mono Kbd) at full width inside the sidebar.
4
+ * `shadow-raised` is the shared CONTROL depth — §5 specifies this trigger and the header icon
5
+ * button as a matched pair, so the two must never drift onto different depth tokens.
4
6
  */
5
7
 
6
8
  .trigger {
@@ -12,7 +14,7 @@
12
14
  padding: 0 var(--vx-space-row-inset-x);
13
15
  border-radius: var(--vx-radius-ctrl);
14
16
  background-color: var(--vx-surface-panel);
15
- box-shadow: var(--vx-shadow-card);
17
+ box-shadow: var(--vx-shadow-raised);
16
18
  color: var(--vx-faint);
17
19
  }
18
20
 
package/src/surfaces.ts CHANGED
@@ -186,9 +186,15 @@ export const SURFACES = {
186
186
  layer: 'mantine-coupled',
187
187
  rule: 'mantine',
188
188
  skill: ['basalt-app', 'basalt-design'],
189
- guardKinds: ['raw-motion-value', 'card-with-border', 'raw-form-control', 'sub-16-input-font'],
189
+ guardKinds: [
190
+ 'raw-motion-value',
191
+ 'card-with-border',
192
+ 'raw-form-control',
193
+ 'sub-16-input-font',
194
+ 'mantine-shade-index',
195
+ ],
190
196
  description:
191
- 'BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard, EmptyState, SettingsSection/SettingsRow/DangerZone)',
197
+ 'BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, SettingsSection/SettingsRow/DangerZone)',
192
198
  optionalPeers: ['react-markdown', 'remark-gfm', 'shiki', 'beautiful-mermaid'],
193
199
  forbiddenImports: [], // the no-charts/tokens-reexport invariant is comment-only today; Phase-4 plugin
194
200
  },
@@ -6,10 +6,19 @@
6
6
 
7
7
  /*
8
8
  * Field idiom (docs/DESIGN-SPEC.md §5): inputs read slightly inset on the panel — field surface
9
- * (white on light, a page-bg step below panel on dark), depth via `shadow-card` (a whisper shadow
10
- * + ring baked into the one value — DESIGN-SPEC doctrine inversion #1: depth is `shadow-card`, not
11
- * a hairline; borders-as-borders stay reserved for layout dividers), faint placeholder, and an
9
+ * (white on light, a page-bg step below panel on dark), depth via `shadow-raised` (a whisper shadow
10
+ * + ring baked into the one value — DESIGN-SPEC doctrine inversion #1: depth is a shadow, not a
11
+ * hairline; borders-as-borders stay reserved for layout dividers), faint placeholder, and an
12
12
  * accent border on focus (Mantine's own `:focus` rule swaps `--input-bd` to `--input-bd-focus`).
13
+ *
14
+ * `shadow-raised` — the SAME token Button/ActionIcon take below, and that sharing is deliberate.
15
+ * A `size="md"` Input and a `size="md"` Button are pinned to identical heights by
16
+ * `--vx-space-control-height`, so they are read as one row of controls; when the buttons moved onto
17
+ * `shadow-raised` and the fields stayed on `shadow-card`, that row visibly split in two. Panels
18
+ * (Card/Paper/Notification/ChartCard/SettingsSection) keep `shadow-card` — the line is
19
+ * control-vs-panel, not component-by-component. Its inset ring also suits a field better than
20
+ * `card`'s outset one ever did: an outset ring draws a boundary AROUND the box, while an inset edge
21
+ * darkens the field's own rim, which is what "recessed into the panel" actually looks like.
13
22
  * The `--input-bd` box itself stays a TRANSPARENT 1px border (not `border: none`) so focus can
14
23
  * recolor the same box with no layout shift, and `[data-error]` still wins a real red border.
15
24
  *
@@ -30,13 +39,15 @@ html[data-mantine-color-scheme] .inputWrapper[data-variant='default']:not([data-
30
39
  }
31
40
 
32
41
  /*
33
- * Depth lands on the INPUT ELEMENT, never on the wrapper above — `--vx-shadow-card` bakes a 1px
42
+ * Depth lands on the INPUT ELEMENT, never on the wrapper above — `--vx-shadow-raised` bakes a 1px
34
43
  * ring into the shadow, and the ring follows the shadowed box's OWN `border-radius`, not any other
35
44
  * element's. Mantine's input wrapper (`.m_6c018570`) is a bare `position: relative` box with
36
45
  * `border-radius: 0`; the ring drawn there squares off around the rounded input inside it and leaks
37
46
  * visible corner nubs. The input element itself carries `border-radius: var(--input-radius)`
38
- * (Input.css), so the ring follows the rounded edge exactly. This is the general rule for the token
39
- * — see shadow-surfaces.test.ts.
47
+ * (Input.css), so the ring follows the rounded edge exactly. `raised`'s ring being INSET rather than
48
+ * outset does not soften this: an inset ring is drawn by the same box and follows the same corners,
49
+ * so a mismatch leaks nubs on the inside of the corner instead of the outside. This is the general
50
+ * rule for every depth token — see shadow-surfaces.test.ts.
40
51
  *
41
52
  * Mantine never declares a `box-shadow` on the input at any specificity, so a plain `.input` class
42
53
  * (0,1,0) wins uncontested. `--field-depth` carries the resting shadow so the `:focus` rule below
@@ -51,32 +62,162 @@ html[data-mantine-color-scheme] .inputWrapper[data-variant='default']:not([data-
51
62
  }
52
63
 
53
64
  .input[data-variant='default'] {
54
- --field-depth: var(--vx-shadow-card);
65
+ --field-depth: var(--vx-shadow-raised);
66
+ }
67
+
68
+ /*
69
+ * Button / ActionIcon depth (docs/DESIGN-SPEC.md §5's Button idiom; the header's search trigger and
70
+ * icon button are specified there as a matched pair and both land on `shadow-raised`, same as the
71
+ * field above).
72
+ *
73
+ * THE LAW: depth says "this is a control surface"; it is NOT the emphasis axis. Emphasis lives
74
+ * entirely in fill weight (`filled` > `light`/`outline` > `subtle`), exactly as every surveyed
75
+ * system resolves it — Material 3's filled button, Radix's `solid`, and Primer's primary all rank
76
+ * by fill and saturation, never by z-height. So every variant that OWNS A BOX gets the same
77
+ * resting depth, and only the tier that owns no box stays flat.
78
+ *
79
+ * This replaces a `default`-only rule that inverted the hierarchy in practice: `default` was the
80
+ * one variant carrying depth, so the SECONDARY button read as a physical object while the primary
81
+ * (`filled`) read as paint on the page. Raising the rest is what removes the inversion — flattening
82
+ * `default` instead would fix it too, but it is not open to us: DESIGN-SPEC doctrine inversion #1
83
+ * ("depth is a shadow, not a hairline") already raised Card/Paper/Input/Chip/CheckboxCard, so a flat
84
+ * Button would be the only flat control in a row of raised ones — most visibly beside a
85
+ * `size="md"` TextInput, which the `--vx-space-control-height` coupling in theme/index.ts pins to
86
+ * the exact same height.
87
+ *
88
+ * ONE depth value for the raised family, and it is `shadow-raised` — a drop plus a 1px INSET edge:
89
+ * - `default`/`filled`/`light` all take it, unmodified. The shared value is the point. An earlier
90
+ * revision split them (`default` on `shadow-card`, the rest on ring-free `shadow-ctrl`) and
91
+ * `default` visibly read a step more three-dimensional than the other two: it was the only one
92
+ * with a DEFINED EDGE, and on dark it also carried a wider blur. A soft drop alone reads as
93
+ * "something is under this"; a crisp boundary is what actually reads as an object. Landing them
94
+ * on one value is what closed that gap — `default` came down (no more 3px/0.4 dark blur, no
95
+ * outset ring), `filled`/`light` came up (they gained an edge at all).
96
+ * - The edge is INSET, which is exactly why one value can cover all three — see `SHADOW.raised`
97
+ * in tokens/palette.ts for the full reasoning. Short version: an outset ring paints in the color
98
+ * of what it is drawn over, so `card`'s pale hairline ring would read as a grey outline around a
99
+ * saturated `filled` button on light scheme; an inset edge is drawn over the control's OWN
100
+ * background and works on a fill, a 13% tint and a neutral panel alike. It also keeps `default`
101
+ * legible inside a Card, where button and card share `--vx-surface-panel` and the inset edge is
102
+ * the only thing separating them (its border is pinned `transparent` by
103
+ * `cssVariablesResolver`, which kills the stock hairline `defaultVariantColorsResolver` draws
104
+ * for EVERY default-variant control — so this edge really is its only one).
105
+ * - `outline` is the one raised variant NOT on it: it already carries a real 1px accent border,
106
+ * and an inset edge tucked just inside a border is muddy. It takes ring-free `shadow-ctrl`
107
+ * instead — the same token, for the same reason, that SegmentedControl's active segment uses to
108
+ * say "this is the lifted one". Its border is its edge, so it is not missing one.
109
+ *
110
+ * `subtle` is the one tier that owns no box — it is a text affordance, not an object, and every
111
+ * system surveyed keeps that tier flat. It stays flat in BOTH states here: its hover is Mantine's
112
+ * own background tint and nothing else, which is exactly how the spec's "ghost icon button" (§5:
113
+ * transparent, faint icon, hover ink-6% + ink icon) already behaved. Button's `subtle` and
114
+ * ActionIcon's therefore need no asymmetry between them. See the note below the variant rules for
115
+ * why an earlier hover-depth attempt was reverted.
116
+ *
117
+ * Mantine never declares a `box-shadow` on either component at ANY specificity, so a plain
118
+ * module-class + attribute selector wins uncontested — no `html[...]` prefix fight, unlike the
119
+ * input-variable overrides above. Depth rides `--ctrl-depth` rather than landing on `box-shadow`
120
+ * directly (the same indirection `.input` uses for `--field-depth`) so the disabled rule below
121
+ * moves ONE variable instead of restating a value per variant.
122
+ */
123
+ .buttonRoot,
124
+ .actionIconRoot {
125
+ --ctrl-depth: 0 0 transparent;
126
+
127
+ box-shadow: var(--ctrl-depth);
128
+ transition: box-shadow 120ms ease;
129
+ }
130
+
131
+ .buttonRoot[data-variant='default'],
132
+ .buttonRoot[data-variant='filled'],
133
+ .buttonRoot[data-variant='light'] {
134
+ --ctrl-depth: var(--vx-shadow-raised);
135
+ }
136
+
137
+ .actionIconRoot[data-variant='default'],
138
+ .actionIconRoot[data-variant='filled'],
139
+ .actionIconRoot[data-variant='light'] {
140
+ --ctrl-depth: var(--vx-shadow-raised);
141
+ }
142
+
143
+ .buttonRoot[data-variant='outline'] {
144
+ --ctrl-depth: var(--vx-shadow-ctrl);
145
+ }
146
+
147
+ .actionIconRoot[data-variant='outline'] {
148
+ --ctrl-depth: var(--vx-shadow-ctrl);
149
+ }
150
+
151
+ /*
152
+ * DEPTH HERE IS STATIC. No variant changes depth or position on hover or press — the raised variants
153
+ * already get hover feedback from Mantine's own background change, so anything layered on top just
154
+ * restates a signal that is already there. Two attempts at adding one were made and both reverted,
155
+ * for reasons worth keeping:
156
+ *
157
+ * - A 1px `translateY` lift on hover, reversed on press. It restated the background change AND
158
+ * cost something real: Floating UI measures a transformed rect, so a Tooltip or Popover anchored
159
+ * to the button visibly shifts with it.
160
+ * - `subtle` materializing into `shadow-raised` on hover. The theory was that `subtle` owns no box
161
+ * at rest, so gaining one under the cursor is the affordance rather than decoration. In practice
162
+ * `shadow-raised`'s INSET edge is a hard rim, and a rim appearing around a ghost button makes it
163
+ * read as an `outline` button rather than as a text affordance that lit up — the tint alone
164
+ * already says "pressable", and the rim says something else entirely. `subtle` is now flat in
165
+ * BOTH states, which also collapses an asymmetry that had to be documented twice: Button's
166
+ * `subtle` and ActionIcon's ghost icon button finally behave identically.
167
+ *
168
+ * DON'T REINTRODUCE EITHER. If a future pass wants `subtle` to gain depth on hover, the token to
169
+ * reach for is ring-free `shadow-ctrl`, not `shadow-raised` — the drop is the part that reads as
170
+ * lift, the rim is the part that read as wrong.
171
+ */
172
+
173
+ /*
174
+ * Disabled is flat and grounded — a raised control reads as pressable, which is the one thing a
175
+ * disabled control must not do. Mantine's own disabled rule (Button.css/ActionIcon.css) recolors
176
+ * background and text but never touches the shadow, so nothing here fights it; the `[data-loading]`
177
+ * exclusion mirrors that same rule, keeping a loading button's chrome intact while it works.
178
+ * Written as real (0,3,0) selectors rather than a `:where()` group so this outranks the (0,2,0)
179
+ * variant rules above on specificity alone, independent of source order.
180
+ */
181
+ .buttonRoot:disabled:not([data-loading]),
182
+ .buttonRoot[data-disabled]:not([data-loading]),
183
+ .actionIconRoot:disabled:not([data-loading]),
184
+ .actionIconRoot[data-disabled]:not([data-loading]) {
185
+ --ctrl-depth: 0 0 transparent;
55
186
  }
56
187
 
57
188
  /*
58
- * Button / ActionIcon (`default` variant): the SAME shadow-card depth as the field above and as
59
- * Card/Paper (docs/DESIGN-SPEC.md §5: "search trigger … panel + shadow-card", "icon button …
60
- * panel + shadow-card"). Background and border already resolve centrally, through
61
- * `--mantine-color-default`/`-border` (theme/index.ts's `cssVariablesResolver`) — the
62
- * `default-border` var is pinned to `transparent` there, which is what kills the stock 1px
63
- * hairline `defaultVariantColorsResolver`'s `variant === 'default'` branch draws for EVERY
64
- * default-variant control (Button, ActionIcon, Chip, CheckboxCard, RadioCard, …), not just these
65
- * two. Only the shadow is a fresh declaration (Mantine never sets one for `default`), so no
66
- * `html[...]` specificity fight is needed to win it — a plain module-class + attribute selector is
67
- * deterministic. `filled`/`subtle`/`light` variants are untouched by the attribute scope.
189
+ * Fields ground when disabled for the same reason, and this rule is why the field/button split had
190
+ * to close on BOTH sides: buttons got the treatment above when they moved onto `shadow-raised`,
191
+ * while inputs — which sat on `shadow-card` at the time — never had one at all, so a disabled
192
+ * TextInput stayed raised. Once fields joined the same depth family that gap became a visible
193
+ * inconsistency inside one form rather than a quiet oversight.
194
+ *
195
+ * `:disabled` covers the real `<input>`/`<textarea>`; `[data-disabled]` covers the composite fields
196
+ * (Select, MultiSelect, DatePicker) where Mantine disables a readonly input via the attribute
197
+ * instead. No `[data-loading]` exclusion here — inputs have no loading state, unlike Button.
68
198
  */
69
- .buttonRoot[data-variant='default'] {
70
- box-shadow: var(--vx-shadow-card);
199
+ .input:disabled,
200
+ .input[data-disabled] {
201
+ --field-depth: 0 0 transparent;
71
202
  }
72
203
 
73
- .actionIconRoot[data-variant='default'] {
74
- box-shadow: var(--vx-shadow-card);
204
+ /*
205
+ * Reduced motion drops the fade, never the shadow: a static shadow is not motion, and removing it
206
+ * would strip the very hierarchy this pass exists to establish from the users who asked for less
207
+ * animation. `subtle`'s hover depth still lands — it just lands instantly.
208
+ */
209
+ @media (prefers-reduced-motion: reduce) {
210
+ .buttonRoot,
211
+ .actionIconRoot {
212
+ transition: none;
213
+ }
75
214
  }
76
215
 
77
216
  /*
78
- * CheckboxCard / RadioCard (`withBorder`, the default): panel bg + `shadow-card`, no border — the
79
- * same triad as Card/Paper. Mantine's own border-color rule wraps `[data-with-border]` in
217
+ * CheckboxCard / RadioCard (`withBorder`, the default): panel bg + depth, no border — the same
218
+ * triad as Card/Paper, but on `shadow-raised`, since a selection card is something you click (the
219
+ * control-vs-panel line, see the header above). Mantine's own border-color rule wraps
220
+ * `[data-with-border]` in
80
221
  * `:where()` (0 specificity), so a plain class + real attribute selector ((0,2,0)) already wins;
81
222
  * no `html[…]` prefix fight needed, same reasoning as Button/ActionIcon above.
82
223
  */
@@ -84,7 +225,7 @@ html[data-mantine-color-scheme] .inputWrapper[data-variant='default']:not([data-
84
225
  .radioCardRoot[data-with-border] {
85
226
  background-color: var(--vx-surface-panel);
86
227
  border-color: transparent;
87
- box-shadow: var(--vx-shadow-card);
228
+ box-shadow: var(--vx-shadow-raised);
88
229
  }
89
230
 
90
231
  /*
@@ -96,12 +237,12 @@ html[data-mantine-color-scheme] .inputWrapper[data-variant='default']:not([data-
96
237
  */
97
238
  .chipLabel:not([data-checked]):not([data-disabled]) {
98
239
  background-color: var(--vx-surface-panel);
99
- box-shadow: var(--vx-shadow-card);
240
+ box-shadow: var(--vx-shadow-raised);
100
241
  }
101
242
 
102
243
  /*
103
244
  * Focus = accent border (via --input-bd-focus above) + a subtle accent ring layered over the
104
- * resting `--field-depth`, so the field keeps its shadow-card depth while focused.
245
+ * resting `--field-depth`, so the field keeps its `shadow-raised` depth while focused.
105
246
  */
106
247
  .input:focus,
107
248
  .input:focus-within {