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
@@ -342,6 +342,24 @@ const basaltVariantColorResolver: VariantColorsResolver = (input) => {
342
342
  return autoContrast ? { ...resolved, color: on } : resolved
343
343
  }
344
344
 
345
+ // `subtle`: hover is a NEUTRAL ink tint, not Mantine's accent tint
346
+ // (`--mantine-color-{c}-light`). `docs/DESIGN-SPEC.md` §5 specifies the ghost idiom as "hover
347
+ // ink-6% + ink icon", and a subtle Button had never followed it — it hovered into a saturated
348
+ // accent block that read as a filled control appearing under the cursor. Fixing it HERE rather
349
+ // than in controls.module.css is what makes Button and ActionIcon agree: both resolve `subtle`
350
+ // through this one function, and §5's ghost icon button and a `variant="subtle"` Button are meant
351
+ // to be the same idiom at two sizes.
352
+ //
353
+ // Scoped to the DEFAULT color on purpose. An explicitly-colored subtle control (`color="red"` on a
354
+ // destructive text action) carries its hue as the whole signal, so neutralizing its hover would
355
+ // erase the one thing that marks it dangerous — those keep Mantine's own tinted hover.
356
+ if (input.variant === 'subtle') {
357
+ const isDefaultColor = input.color === undefined || input.color === input.theme.primaryColor
358
+ return isDefaultColor
359
+ ? { ...resolved, hover: `color-mix(in srgb, var(--vx-ink) 6%, transparent)` }
360
+ : resolved
361
+ }
362
+
345
363
  if (input.variant !== 'light') return resolved
346
364
  const ink = `var(--mantine-color-${colorName}-light-color)`
347
365
  return {
@@ -526,17 +544,26 @@ function buildTheme(data: PaletteData, options: BuildThemeOptions = {}): Mantine
526
544
  },
527
545
  }),
528
546
  // "Ink earns its color" — a nav selection is UI state, not the identity accent on the LABEL.
529
- // The active item is panel bg + `shadow-card` (forced here at the THEME level via NavLink's
530
- // `--nl-*` vars, so it holds for every render path — including a consumer's router `<Link>`
531
- // via `renderNavLink`, which never sees the shell CSS module); the active ICON is
532
- // accent-colored via `nav-link.module.css` (targets the `[data-position='left']` leftSection,
533
- // which the flat `vars`/`styles` API can't express conditionally on `[data-active]`).
547
+ // The active item is a SELECTED ROW, not a raised control: the same ink-tint idiom as the
548
+ // hover state (one step stronger, ink-9%), no panel fill and no `shadow-card` — a raised,
549
+ // panel-filled row read as a button sitting in the nav rather than as the current location.
550
+ // Selection is carried by the tint + ink text + weight 600 + the accent icon instead. Forced
551
+ // here at the THEME level via NavLink's `--nl-*` vars so it holds for every render path —
552
+ // including a consumer's router `<Link>` via `renderNavLink`, which never sees the shell CSS
553
+ // module; the active ICON is accent-colored via `nav-link.module.css` (targets the
554
+ // `[data-position='left']` leftSection, which the flat `vars`/`styles` API can't express
555
+ // conditionally on `[data-active]`).
556
+ //
557
+ // `--nl-hover` (ink-13%) only ever reaches an ACTIVE row in practice — the inactive hover is
558
+ // set at ink-6% by higher-specificity `:not([data-active])` rules in `nav-link.module.css` /
559
+ // `app-sidebar.module.css` — so it exists to keep the active row responding to the pointer
560
+ // now that its rest state sits in the same tint family as an inactive hover.
534
561
  NavLink: NavLink.extend({
535
562
  vars: () => ({
536
563
  root: {
537
- '--nl-bg': 'var(--vx-surface-panel)',
564
+ '--nl-bg': 'color-mix(in srgb, var(--vx-ink) 9%, transparent)',
538
565
  '--nl-color': 'var(--vx-ink)',
539
- '--nl-hover': 'color-mix(in srgb, var(--vx-ink) 6%, transparent)',
566
+ '--nl-hover': 'color-mix(in srgb, var(--vx-ink) 13%, transparent)',
540
567
  },
541
568
  children: {},
542
569
  }),
@@ -570,7 +597,7 @@ function buildTheme(data: PaletteData, options: BuildThemeOptions = {}): Mantine
570
597
  label: { fontSize: VX.text.md, lineHeight: 'var(--vx-space-row-line-height)' },
571
598
  },
572
599
  }),
573
- // Field idiom (docs/DESIGN-SPEC.md §5): field surface + `shadow-card` depth + faint placeholder,
600
+ // Field idiom (docs/DESIGN-SPEC.md §5): field surface + `shadow-raised` depth + faint placeholder,
574
601
  // accent border + subtle accent ring on focus — see controls.module.css. The two slots carry
575
602
  // different halves of it: the `wrapper` is where Mantine declares the `--input-*` vars, the
576
603
  // `input` is the box that actually paints the surface (bg + radius) and therefore the only
@@ -625,16 +652,19 @@ function buildTheme(data: PaletteData, options: BuildThemeOptions = {}): Mantine
625
652
  PillsInput: PillsInput.extend({
626
653
  classNames: { wrapper: controlsClasses.inputWrapper, input: controlsClasses.input },
627
654
  }),
628
- // Button / ActionIcon (`default` variant): same `shadow-card` depth as every other control
629
- // surface (docs/DESIGN-SPEC.md §5: "search trigger … panel + shadow-card", "icon button …
630
- // panel + shadow-card"; doctrine inversion #1). Background already resolves through the shared
655
+ // Button / ActionIcon: depth for EVERY variant that owns a box (docs/DESIGN-SPEC.md §5's
656
+ // Button idiom; doctrine inversion #1) — `default`/`filled`/`light` on the shared
657
+ // `shadow-raised`, `outline` on ring-free `shadow-ctrl`, `subtle` flat at rest. Emphasis stays
658
+ // in fill weight,
659
+ // never in z-height. Background for `default` already resolves through the shared
631
660
  // `--mantine-color-default` var (→ `--vx-surface-panel`, see cssVariablesResolver below) and
632
- // the border through `--mantine-color-default-border` (→ transparent, same resolver) — neither
661
+ // its border through `--mantine-color-default-border` (→ transparent, same resolver) — neither
633
662
  // component had a `.extend()` block at all before this, which is why they never picked up
634
- // either. Only the shadow needs adding here, in controls.module.css: Mantine's own
635
- // `[data-variant='default']` rule never declares a `box-shadow`, so no specificity fight is
636
- // needed to win one. `filled`/`subtle`/`light` variants are untouched — scoped by attribute
637
- // selector in the CSS, not here.
663
+ // either. Everything else — the per-variant depth and the disabled/reduced-motion grounding —
664
+ // lives in controls.module.css, where the variant scoping
665
+ // is an attribute selector: Mantine declares no `box-shadow` on either component at any
666
+ // specificity, so none of it needs a specificity fight. Nothing about depth is expressible in
667
+ // the flat `styles` objects here, which is the whole reason that file exists.
638
668
  //
639
669
  // Density coupling: mirrors Input's `--input-height` override above — Mantine's OWN
640
670
  // `--button-height-md`/`--ai-size` (via `--ai-size-md`) are global, non-retunable statics
@@ -679,7 +709,8 @@ function buildTheme(data: PaletteData, options: BuildThemeOptions = {}): Mantine
679
709
  // real attribute selectors ((0,2,0)) — Mantine's own rule wraps the same attribute in `:where()`
680
710
  // (0 specificity, landing at (0,1,0) total), so no `html[…]` prefix fight is needed here either.
681
711
  // Neither component sets a background of its own, so panel bg is added alongside the shadow —
682
- // the same panel-bg + shadow-card + no-border triad as Card/Paper.
712
+ // the same panel-bg + depth + no-border triad as Card/Paper — on `shadow-raised`, the shared
713
+ // CONTROL depth, not Card/Paper's `shadow-card`: a selection card is something you click.
683
714
  CheckboxCard: CheckboxCard.extend({
684
715
  classNames: { card: controlsClasses.checkboxCardRoot },
685
716
  }),
@@ -1099,8 +1130,8 @@ function buildCssVariablesResolver(data: PaletteData): CSSVariablesResolver {
1099
1130
  '--mantine-color-body': `var(--vx-surface-bg, ${SURFACE.bg.light})`, // page background
1100
1131
  '--mantine-color-default': `var(--vx-surface-panel, ${SURFACE.panel.light})`, // cards / default controls
1101
1132
  '--mantine-color-default-hover': `var(--vx-surface-elevated, ${SURFACE.elevated.light})`,
1102
- // THE central lever for doctrine inversion #1 on CONTROLS (docs/DESIGN-SPEC.md §8): depth is
1103
- // `shadow-card`, never a hairline, for every `variant="default"` control — not just Card/Paper.
1133
+ // THE central lever for doctrine inversion #1 on CONTROLS (docs/DESIGN-SPEC.md §8): depth is a
1134
+ // shadow, never a hairline, for every `variant="default"` control — not just Card/Paper.
1104
1135
  // `defaultVariantColorsResolver`'s `variant === 'default'` branch reads this ONE var for every
1105
1136
  // component that uses it (Button, ActionIcon, Chip, CheckboxCard, RadioCard, …), so pointing it
1106
1137
  // at `transparent` kills the stock 1px hairline everywhere at once. `transparent`, not `border:
@@ -1,19 +1,19 @@
1
1
  /*
2
- * Active nav row = heavier weight + card depth, applied at the THEME level via `classNames` so it
3
- * reaches EVERY NavLink render path — including a consumer's router `<Link>` mounted through
4
- * `renderNavLink`, which a shell-scoped CSS module never sees. Mantine's `styles` prop only takes
5
- * flat inline properties (a nested `&[data-active]` there throws a React warning), so the state
6
- * selectors live here. React Router's NavLink marks the active item via `aria-current` rather than
7
- * the `active` prop's `data-active`, so both states are matched.
2
+ * Active nav row = heavier weight over the ink tint `--nl-bg` carries (set in theme/index.ts,
3
+ * applied unconditionally by Mantine's own `[data-active]` CSS). NO depth: an active row is a
4
+ * SELECTED ROW, not a raised control — it deliberately reads as a stronger version of the hover
5
+ * tint rather than as a button, so the row never competes with the real filled controls around it.
8
6
  *
9
- * The row's background comes from `--nl-bg` (set in theme/index.ts, applies unconditionally via
10
- * Mantine's own `[data-active]` CSS) — `shadow-card` is added here because there's no `--nl-*` var
11
- * for depth.
7
+ * Applied at the THEME level via `classNames` so it reaches EVERY NavLink render path — including a
8
+ * consumer's router `<Link>` mounted through `renderNavLink`, which a shell-scoped CSS module never
9
+ * sees. Mantine's `styles` prop only takes flat inline properties (a nested `&[data-active]` there
10
+ * throws a React warning), so the state selectors live here. React Router's NavLink marks the
11
+ * active item via `aria-current` rather than the `active` prop's `data-active`, so both states are
12
+ * matched.
12
13
  */
13
14
  .root[data-active],
14
15
  .root[aria-current='page'] {
15
16
  font-weight: 600;
16
- box-shadow: var(--vx-shadow-card);
17
17
  }
18
18
 
19
19
  /*
@@ -2,7 +2,8 @@
2
2
  * Shadow-surface coverage — closes a class of bug `check-theme`'s regex scans cannot see: applying
3
3
  * the depth token to the WRONG box.
4
4
  *
5
- * `--vx-shadow-card` (and `--vx-shadow-ctrl`) bakes a 1px ring into the shadow value itself (see
5
+ * `--vx-shadow-card` (and `--vx-shadow-ctrl`/`--vx-shadow-raised`) bakes a 1px ring into the shadow
6
+ * value itself (see
6
7
  * `tokens/palette.ts`'s `SHADOW`). THE INVARIANT: an element applying the token must carry the
7
8
  * surface's OWN `border-radius` — the ring is drawn by the shadow's rendering box and follows THAT
8
9
  * box's own corners, not any other element's. We shipped a bug where `box-shadow: var(--vx-shadow-
@@ -16,10 +17,16 @@
16
17
  * mechanical inventory of every site that APPLIES the token would have caught it: a source-level
17
18
  * guard can only flag a bad value, never a bad ELEMENT.
18
19
  *
20
+ * `--vx-shadow-raised`'s ring is INSET rather than outset, which changes nothing about the
21
+ * invariant: an inset ring is still drawn by the shadow's rendering box and still follows that
22
+ * box's own corners, so a radius mismatch leaks the same way (nubs on the inside of the corner
23
+ * instead of the outside). It is registered here for exactly the same reason as the other two.
24
+ *
19
25
  * This test IS that inventory: it scans basalt-ui's own source for every place that applies
20
- * `--vx-shadow-card`/`--vx-shadow-ctrl` (a `box-shadow`/CSS-custom-property declaration in
21
- * `*.module.css`, or a `boxShadow` object property — literal, `VX.shadowCard`/`VX.shadowCtrl`, or a
22
- * template-literal composite of either — in `*.ts`/`*.tsx`) and asserts every one is registered in
26
+ * `--vx-shadow-card`/`--vx-shadow-ctrl`/`--vx-shadow-raised` (a `box-shadow`/CSS-custom-property
27
+ * declaration in `*.module.css`, or a `boxShadow` object property — literal, `VX.shadowCard`/
28
+ * `VX.shadowCtrl`/`VX.shadowRaised`, or a template-literal composite of any — in `*.ts`/`*.tsx`)
29
+ * and asserts every one is registered in
23
30
  * `SHADOW_SURFACES` below with a written `roundedBy` reason naming exactly where that box's
24
31
  * `border-radius` comes from. Mere mentions in comments/docstrings and the token DEFINITION itself
25
32
  * (`tokens/index.ts`, `tokens/palette.ts`) are excluded — this test only cares about APPLICATION.
@@ -34,8 +41,12 @@
34
41
  * so plainly; a square ring on a square surface is correct, not a gap.
35
42
  *
36
43
  * THIS TEST IS DELIBERATELY COUPLED TO OUR OWN SOURCE. Adding a new `box-shadow: var(--vx-shadow-
37
- * card)` (or `-ctrl`) anywhere SHOULD fail this test until it's registered — that failure IS the
38
- * point. Do NOT "fix" a future failure by loosening the scan or bulk-registering with a placeholder
44
+ * card)` (or `-ctrl`/`-raised`) anywhere SHOULD fail this test until it's registered — that failure
45
+ * IS the point. A NEW depth token is the one case the scan cannot fail loudly on: the two regexes
46
+ * below name their tokens explicitly, so an unlisted `--vx-shadow-*` slips through silently and
47
+ * vacuously. Adding a token to `SHADOW` therefore means adding it to BOTH regexes in the same
48
+ * commit — `raised` is the precedent.
49
+ * Do NOT "fix" a future failure by loosening the scan or bulk-registering with a placeholder
39
50
  * reason; open the file, find the element the shadow is declared on, prove (or fix) that IT carries
40
51
  * the surface's own border-radius, and write down where that radius comes from.
41
52
  *
@@ -95,7 +106,7 @@ function stripTsComments(text: string): string {
95
106
  // bare mention of `--vx-shadow-card` in a comment never matches — comments are stripped first, and
96
107
  // this requires an actual `property:` declaration.
97
108
  const CSS_SHADOW_TOKEN =
98
- /(?<![\w-])(?:box-shadow|--[\w-]+)\s*:\s*[^;]*var\(--vx-shadow-(?:card|ctrl)\)/
109
+ /(?<![\w-])(?:box-shadow|--[\w-]+)\s*:\s*[^;]*var\(--vx-shadow-(?:card|ctrl|raised)\)/
99
110
 
100
111
  // A real application in TS/TSX: a `boxShadow` object property whose value is the literal CSS var
101
112
  // string, `VX.shadowCard`/`VX.shadowCtrl`, or a template literal compositing either (e.g. the
@@ -103,7 +114,7 @@ const CSS_SHADOW_TOKEN =
103
114
  // `--vx-shadow-overlay` (a different, unrelated token) or a `boxShadow` string literal that doesn't
104
115
  // reference either token (data-table's scroll-shadow, guard/index.ts's own regex source, etc.).
105
116
  const TS_SHADOW_TOKEN =
106
- /\bboxShadow\s*:\s*(?:'[^']*var\(--vx-shadow-(?:card|ctrl)\)[^']*'|`[^`]*(?:var\(--vx-shadow-(?:card|ctrl)\)|VX\.shadow(?:Card|Ctrl))[^`]*`|VX\.shadow(?:Card|Ctrl))/
117
+ /\bboxShadow\s*:\s*(?:'[^']*var\(--vx-shadow-(?:card|ctrl|raised)\)[^']*'|`[^`]*(?:var\(--vx-shadow-(?:card|ctrl|raised)\)|VX\.shadow(?:Card|Ctrl|Raised))[^`]*`|VX\.shadow(?:Card|Ctrl|Raised))/
107
118
 
108
119
  type ShadowSite = {
109
120
  /** Path relative to `src/`, e.g. `theme/controls.module.css`. */
@@ -116,8 +127,13 @@ type ShadowSite = {
116
127
 
117
128
  /** Every rule in a CSS module whose declarations apply the token, keyed by the rule's own selector
118
129
  * (multi-line selectors are collapsed to one line). Assumes flat, non-nested CSS (no native
119
- * nesting) — the one `@media` block in this codebase sits after every shadow site, so a naive
120
- * brace-pair scan never needs to understand at-rule nesting to find them. */
130
+ * nesting). A rule nested one level inside an at-rule IS still found — the brace-pair regex matches
131
+ * INNERMOST pairs, so `@media (hover: hover) { .x:hover { … } }` resolves as the inner `.x:hover`
132
+ * rule. (No such site exists today — depth is static — but the scan must keep finding one if a
133
+ * future state-scoped depth rule lands inside an at-rule.) What the
134
+ * naive scan does NOT do is carry the at-rule condition into the site label: such a site registers
135
+ * under its bare inner selector, with the `@media` context stated in its `roundedBy` reason
136
+ * instead. That is a labeling limitation, never a coverage one. */
121
137
  function cssShadowSites(relPath: string, text: string): ShadowSite[] {
122
138
  const clean = stripCssComments(text)
123
139
  const sites: ShadowSite[] = []
@@ -173,7 +189,7 @@ function tsShadowSites(relPath: string, text: string): ShadowSite[] {
173
189
  return sites
174
190
  }
175
191
 
176
- /** Every application of `--vx-shadow-card`/`--vx-shadow-ctrl` across `src/**`. */
192
+ /** Every application of `--vx-shadow-card`/`--vx-shadow-ctrl`/`--vx-shadow-raised` across `src/**`. */
177
193
  function scanShadowSites(): ShadowSite[] {
178
194
  const files: string[] = []
179
195
  collectSourceFiles(SRC_DIR, files)
@@ -221,21 +237,55 @@ export const SHADOW_SURFACES: readonly ShadowSurfaceEntry[] = [
221
237
  '<input> element itself — the custom property inherits from wrapper to input, landing on the ' +
222
238
  'exact element the shadow lands on).',
223
239
  },
240
+ // The five Button/ActionIcon entries below all resolve their radius the same way, and all set the
241
+ // token on `--ctrl-depth` rather than on `box-shadow` directly — the single `box-shadow:
242
+ // var(--ctrl-depth)` declaration lives on the shared `.buttonRoot, .actionIconRoot` base rule
243
+ // (which is therefore NOT a token-application site itself and correctly goes unregistered). The
244
+ // indirection changes nothing about this invariant: the variable is consumed on the very same
245
+ // element these selectors target, so the ring is still drawn by a box carrying the control's own
246
+ // `--button-radius`/`--ai-radius`.
224
247
  {
225
248
  file: 'theme/controls.module.css',
226
- site: ".buttonRoot[data-variant='default']",
249
+ site:
250
+ ".buttonRoot[data-variant='default'], .buttonRoot[data-variant='filled'], " +
251
+ ".buttonRoot[data-variant='light']",
227
252
  roundedBy:
228
253
  "--button-radius (Mantine's own Button.css declares `border-radius: var(--button-radius, " +
229
254
  'var(--mantine-radius-default))` directly on the button root — the same element `.buttonRoot` ' +
230
- 'targets).',
255
+ 'targets). This is the raised family on the shared `shadow-raised`, and its ring is INSET — a ' +
256
+ 'radius mismatch would leak nubs on the INSIDE of each corner rather than the outside, so the ' +
257
+ 'invariant bites exactly as hard here as on an outset ring.',
231
258
  },
232
259
  {
233
260
  file: 'theme/controls.module.css',
234
- site: ".actionIconRoot[data-variant='default']",
261
+ site: ".buttonRoot[data-variant='outline']",
262
+ roundedBy:
263
+ '--button-radius (same chain as the raised-family entry above — Button.css declares ' +
264
+ '`border-radius: var(--button-radius, var(--mantine-radius-default))` on the button root; the ' +
265
+ 'variant changes only which depth token lands, never which element carries it). `outline` is ' +
266
+ 'the one raised variant on ring-FREE `shadow-ctrl`, because its real 1px accent border already ' +
267
+ 'IS its edge and an inset ring tucked just inside a border is muddy — so no ring is at risk ' +
268
+ 'here at all, and the registration exists to keep the inventory exhaustive.',
269
+ },
270
+ {
271
+ file: 'theme/controls.module.css',
272
+ site:
273
+ ".actionIconRoot[data-variant='default'], .actionIconRoot[data-variant='filled'], " +
274
+ ".actionIconRoot[data-variant='light']",
235
275
  roundedBy:
236
276
  "--ai-radius (Mantine's own ActionIcon.css declares `border-radius: var(--ai-radius, " +
237
277
  'var(--mantine-radius-default))` directly on the root — the same element `.actionIconRoot` ' +
238
- 'targets).',
278
+ 'targets). Same inset-ring reasoning as the Button raised-family entry above.',
279
+ },
280
+ {
281
+ file: 'theme/controls.module.css',
282
+ site: ".actionIconRoot[data-variant='outline']",
283
+ roundedBy:
284
+ '--ai-radius (same chain as the entry above — ActionIcon.css declares `border-radius: ' +
285
+ 'var(--ai-radius, var(--mantine-radius-default))` on the root). Ring-free `shadow-ctrl`, same ' +
286
+ "reason as Button's `outline`. ActionIcon has no `subtle` counterpart to Button's " +
287
+ "hover-materialized entry by design: its `subtle` IS the spec's separate ghost-icon-button " +
288
+ 'idiom (DESIGN-SPEC §5), whose hover state is an ink tint, not a raise.',
239
289
  },
240
290
  {
241
291
  file: 'theme/controls.module.css',
@@ -251,17 +301,8 @@ export const SHADOW_SURFACES: readonly ShadowSurfaceEntry[] = [
251
301
  "--chip-radius (Mantine's own Chip.css declares `border-radius: var(--chip-radius, 1000rem)` " +
252
302
  'directly on the label part — the same element `.chipLabel` targets).',
253
303
  },
254
- // ── theme/nav-link.module.css ────────────────────────────────────────────────────────────────
255
- {
256
- file: 'theme/nav-link.module.css',
257
- site: ".root[data-active], .root[aria-current='page']",
258
- roundedBy:
259
- "borderRadius: 6 (theme/index.ts's NavLink.extend styles.root — Mantine renders this as an " +
260
- 'inline style on the root part, wired via `classNames: { root: navLinkClasses.root }` to the ' +
261
- "exact `.root` element this rule's box-shadow lands on). Verified NOT a square surface: " +
262
- "Mantine's own NavLink.css declares no border-radius at all for .root — the 6px comes only " +
263
- 'from our own theme override.',
264
- },
304
+ // theme/nav-link.module.css applies no shadow token: the active nav row is a selected row (ink
305
+ // tint), never a raised control — see that file's header comment.
265
306
  // ── shell/app-mobile-nav.module.css ──────────────────────────────────────────────────────────
266
307
  {
267
308
  file: 'shell/app-mobile-nav.module.css',
@@ -326,11 +367,9 @@ export const SHADOW_SURFACES: readonly ShadowSurfaceEntry[] = [
326
367
  roundedBy: 'borderRadius: 8 (co-declared in the same TOOLTIP_STYLES object).',
327
368
  },
328
369
  // ── agent-chat ───────────────────────────────────────────────────────────────────────────────
329
- {
330
- file: 'agent-chat/composer.tsx',
331
- site: 'input',
332
- roundedBy: 'borderRadius: 8 (co-declared in the same styles.input object).',
333
- },
370
+ // agent-chat/composer.tsx applies no shadow token of its own: it takes the themed `.input` depth
371
+ // (theme/controls.module.css) so it also inherits that class's disabled grounding — an inline
372
+ // `boxShadow` in its `styles` object used to beat the class and leave a disabled composer raised.
334
373
  {
335
374
  file: 'agent-chat/thread-outcome-card.tsx',
336
375
  site: 'default',
@@ -380,7 +419,7 @@ describe('SHADOW_SURFACES is well-formed', () => {
380
419
  })
381
420
  })
382
421
 
383
- describe('every applied --vx-shadow-card / --vx-shadow-ctrl site is registered', () => {
422
+ describe('every applied --vx-shadow-card / -ctrl / -raised site is registered', () => {
384
423
  // The coverage assertion below is `expect(gaps).toEqual([])`, which passes VACUOUSLY if the scan
385
424
  // finds nothing. That is exactly the failure mode `border-coverage.test.ts` already had to guard
386
425
  // against once — this is the same tripwire: fail loudly here if the scan stops resolving real
@@ -408,7 +447,7 @@ describe('every applied --vx-shadow-card / --vx-shadow-ctrl site is registered',
408
447
  .filter((site) => !registered.has(siteKey(site)))
409
448
  .map(
410
449
  (site) =>
411
- `${site.file} (${site.site}): applies --vx-shadow-card/--vx-shadow-ctrl but has no ` +
450
+ `${site.file} (${site.site}): applies --vx-shadow-card/-ctrl/-raised but has no ` +
412
451
  'entry in SHADOW_SURFACES (src/theme/shadow-surfaces.test.ts). The ring baked into the ' +
413
452
  "token follows THIS box's own border-radius — find where this box's radius comes from " +
414
453
  '(a co-declared border-radius, or a Mantine CSS var/prop, verified against the shipped ' +
@@ -202,6 +202,9 @@ export const VX = {
202
202
  // Depth shadows — a whisper shadow + a 1px ring baked into the value (never a `border` prop).
203
203
  shadowCard: 'var(--vx-shadow-card)',
204
204
  shadowCtrl: 'var(--vx-shadow-ctrl)',
205
+ // Raised interactive controls — drop + a 1px INSET edge, the one depth every raised Button/
206
+ // ActionIcon variant shares (works over a fill, a tint or a panel alike; see SHADOW.raised).
207
+ shadowRaised: 'var(--vx-shadow-raised)',
205
208
  // Floating-layer elevation (menus, popovers, tooltips, modals, drawers) — a real drop shadow.
206
209
  shadowOverlay: 'var(--vx-shadow-overlay)',
207
210
 
@@ -333,6 +336,7 @@ function frameworkPrimitives(side: Side, data: PaletteData, legacyAliases: boole
333
336
  decl('divider', su.divider[side]),
334
337
  decl('shadow-card', SHADOW.card[side]),
335
338
  decl('shadow-ctrl', SHADOW.ctrl[side]),
339
+ decl('shadow-raised', SHADOW.raised[side]),
336
340
  decl('shadow-overlay', SHADOW.overlay[side]),
337
341
  decl('surface-bg', su.bg[side]),
338
342
  decl('surface-panel', su.panel[side]),
@@ -104,6 +104,37 @@ export const SHADOW = {
104
104
  light: '0 1px 2px rgba(28,25,23,0.12)',
105
105
  dark: '0 1px 2px rgba(0,0,0,0.35)',
106
106
  },
107
+ /**
108
+ * Interactive-control depth — the ONE value every raised Button/ActionIcon/Input variant shares
109
+ * (`docs/DESIGN-SPEC.md` §5's Button idiom). Exists rather than reusing `card` because `card`'s
110
+ * ring is OUTSET, and an outset ring paints in the color of whatever it is drawn over: fine
111
+ * between neutral panels, but on light scheme it resolves to `--vx-surface-hairline` and draws a
112
+ * pale grey outline around a saturated `filled` button, which reads as a rendering defect.
113
+ *
114
+ * THE TWO SCHEMES ARE SHAPED DIFFERENTLY, and the asymmetry is the whole design — do NOT "tidy"
115
+ * them into one shape. Both were arrived at by elimination, on screen:
116
+ *
117
+ * - DARK = drop + a full inset rim. Its edge is a LIGHTENING, so it cannot collide with the
118
+ * dark drop below the box; a uniform lightening on a dark surface reads as rim light wrapping
119
+ * a raised object, which is exactly right.
120
+ *
121
+ * - LIGHT = drop ONLY, no edge at all. A light-mode edge would have to be a DARKENING (there is
122
+ * nothing lighter than a near-white control to lighten with), and every position for one
123
+ * fails: a uniform dark ring does not read as depth, it reads as a BORDER — it made `default`
124
+ * and `light` look like outlined buttons and every form field look boxed. Moving it to the
125
+ * bottom edge alone then put a dark inner line directly above the dark drop, i.e. a visible
126
+ * DOUBLE BOTTOM BORDER. There is no third position, because a light-mode raised object's only
127
+ * honest cue is the shadow it casts. So light casts one and draws nothing.
128
+ *
129
+ * Consequence to know, on light only: a `default` control nested in a Card shares
130
+ * `--vx-surface-panel` with it and has no boundary but the drop. That is accepted — a wrong-
131
+ * looking border on every control in the app is worse than a soft edge in one nesting case. If it
132
+ * ever needs more, raise the DROP. Do not reintroduce an edge; both shapes have been tried.
133
+ */
134
+ raised: {
135
+ light: '0 1px 2px rgba(28,25,23,0.10)',
136
+ dark: '0 1px 2px rgba(0,0,0,0.35), inset 0 0 0 1px color-mix(in srgb, #ffffff 8%, transparent)',
137
+ },
107
138
  // Floating-layer elevation (menus, popovers, tooltips, modals, drawers) — a REAL drop shadow, a
108
139
  // step above `card`, so detached surfaces read as lifted off the page (`docs/DESIGN-SPEC.md` §5).
109
140
  // Unlike `card`, no embedded ring: floating surfaces carry a real 1px `--vx-surface-border` so
package/src/vite.ts CHANGED
@@ -13,8 +13,13 @@
13
13
  *
14
14
  * NEVER reference `import.meta.env` here — this file is invoked inside the consumer's vite config
15
15
  * at config-evaluation time (plain Node), where only `process.env` exists.
16
+ *
17
+ * It also carries the enforcement notice (`warnIfUnenforced`): this is the one basalt seam that runs
18
+ * on every dev start and every build of a consumer app, so it is the only place that can tell an app
19
+ * it installed the components and skipped `basalt-ui init`.
16
20
  */
17
- import { resolve } from 'node:path'
21
+ import { existsSync } from 'node:fs'
22
+ import { dirname, resolve } from 'node:path'
18
23
  import { searchForWorkspaceRoot } from 'vite'
19
24
  import type { HtmlTagDescriptor, Plugin, PluginOption, UserConfig } from 'vite'
20
25
  import { SURFACE } from './tokens/palette'
@@ -46,6 +51,55 @@ export type BasaltViteOptions = {
46
51
  basaltSrc?: string
47
52
  /** App version surfaced via the `__APP_VERSION__` define. Defaults to `0.0.0`. */
48
53
  version?: string
54
+ /**
55
+ * Print a one-time notice when `basalt-ui init` has never been run in this project (no
56
+ * `.basalt/manifest.json` found at or above the cwd) — see `warnIfUnenforced`. Default `true`.
57
+ * Set `false` to adopt the components without the toolchain and stop being told about it.
58
+ */
59
+ enforcementNotice?: boolean
60
+ }
61
+
62
+ /** How many directories up from `cwd` to look for `.basalt/manifest.json`. Three covers the common
63
+ * shapes: config at the app root, an app in `web/` or `apps/<name>/` under a repo root. */
64
+ const UNENFORCED_SEARCH_DEPTH = 3
65
+
66
+ /** Set once the notice has been printed, so a watch-mode restart doesn't reprint it every reload. */
67
+ let unenforcedNoticePrinted = false
68
+
69
+ /**
70
+ * Print one notice when basalt-ui is installed as a component library and nothing else.
71
+ *
72
+ * `basalt-ui init` is what places the oxlint preset, the theme-guard wiring, the lefthook stub and
73
+ * the agent rules. Skipping it leaves a consumer with the components and NONE of the enforcement —
74
+ * and, critically, no signal that this is the case. The failure is silent and cumulative: every
75
+ * hand-rolled `<Card withBorder radius="md" padding="lg">` renders perfectly, so the app drifts a
76
+ * card idiom at a time until someone looks at a screenshot and asks why the design system did
77
+ * nothing. The first real consumer reached seven such cards across six files that way.
78
+ *
79
+ * This is the only seam basalt owns that runs on every dev start and every build, which makes it
80
+ * the only place that can say so at the time it is still cheap to fix. It stays a notice, never an
81
+ * error: declining to adopt the toolchain is a legitimate choice, and a build that fails because a
82
+ * lint preset is absent would be a worse bug than the one this prevents.
83
+ */
84
+ function warnIfUnenforced(cwd: string): void {
85
+ if (unenforcedNoticePrinted) return
86
+
87
+ let dir = cwd
88
+ for (let i = 0; i <= UNENFORCED_SEARCH_DEPTH; i++) {
89
+ if (existsSync(resolve(dir, '.basalt/manifest.json'))) return
90
+ const parent = dirname(dir)
91
+ if (parent === dir) break
92
+ dir = parent
93
+ }
94
+
95
+ unenforcedNoticePrinted = true
96
+ // oxlint-disable-next-line no-console -- a build-time notice has no other channel
97
+ console.warn(
98
+ '\n[basalt-ui] Installed, but `basalt-ui init` has never run here — no oxlint preset, no theme\n' +
99
+ ' guard, no agent rules. You have the components and none of the enforcement.\n' +
100
+ ' Fix: `bunx basalt-ui init`, then add `oxlint . && basalt-ui check-theme` as your\n' +
101
+ ' lint script. Silence: pass `enforcementNotice: false` to basaltViteConfig().\n',
102
+ )
49
103
  }
50
104
 
51
105
  export function basaltViteConfig(opts: BasaltViteOptions): UserConfig {
@@ -55,6 +109,13 @@ export function basaltViteConfig(opts: BasaltViteOptions): UserConfig {
55
109
  // on without editing its vite config. process.env (not import.meta.env) — this runs in Node.
56
110
  const basaltSrc = opts.basaltSrc ?? process.env['BASALT_LOCAL']
57
111
 
112
+ // Skipped under basaltSrc/BASALT_LOCAL: that path means basalt-ui itself is being developed from
113
+ // a sibling checkout (the playground, or a consumer debugging against local source), where the
114
+ // enforcement config lives in basalt's own repo and a manifest is not expected.
115
+ if (opts.enforcementNotice !== false && basaltSrc === undefined) {
116
+ warnIfUnenforced(process.cwd())
117
+ }
118
+
58
119
  const config: UserConfig = {
59
120
  define: {
60
121
  __APP_VERSION__: JSON.stringify(process.env['BUILD_VERSION'] ?? version),