basalt-ui 1.32.0 → 1.33.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 (145) hide show
  1. package/CHANGELOG.md +50 -0
  2. package/MIGRATING.md +228 -20
  3. package/agent/rules/basalt-mantine.md +2 -2
  4. package/agent/rules/basalt-state.md +2 -2
  5. package/agent/rules/basalt-tokens.md +1 -1
  6. package/configs/oxlint-plugin.js +160 -89
  7. package/configs/oxlint-plugin.test.ts +273 -10
  8. package/configs/oxlint.json +2 -2
  9. package/dist/agent/coalesce.d.ts.map +1 -1
  10. package/dist/agent/coalesce.js +1 -2
  11. package/dist/agent/merge.d.ts.map +1 -1
  12. package/dist/agent/merge.js +1 -2
  13. package/dist/agent-chat/relative-time.d.ts +9 -0
  14. package/dist/agent-chat/relative-time.d.ts.map +1 -1
  15. package/dist/agent-chat/relative-time.js +1 -2
  16. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  17. package/dist/agent-chat/thread-message.js +1 -2
  18. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  19. package/dist/charts/kinds/BandStrip.js +3 -3
  20. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  21. package/dist/charts/kinds/Bars.js +1 -2
  22. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  23. package/dist/charts/kinds/StackedArea.js +1 -2
  24. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -1
  25. package/dist/cli/audit-allows.d.ts +1 -1
  26. package/dist/cli/audit-allows.d.ts.map +1 -1
  27. package/dist/cli/audit-allows.js +4 -4
  28. package/dist/cli/check-theme.d.ts.map +1 -1
  29. package/dist/cli/check-theme.js +1 -2
  30. package/dist/cli/config.d.ts +197 -0
  31. package/dist/cli/config.d.ts.map +1 -0
  32. package/dist/cli/config.js +20 -0
  33. package/dist/cli/doctor.js +1 -1
  34. package/dist/cli/guard-hook.d.ts.map +1 -1
  35. package/dist/cli/guard-hook.js +2 -8
  36. package/dist/cli/index.d.ts +2 -196
  37. package/dist/cli/index.d.ts.map +1 -1
  38. package/dist/cli/index.js +1 -18
  39. package/dist/commands/define-commands.d.ts.map +1 -1
  40. package/dist/commands/define-commands.js +2 -2
  41. package/dist/commands/define-overlays.d.ts.map +1 -1
  42. package/dist/commands/define-overlays.js +1 -2
  43. package/dist/common/index.d.ts +0 -1
  44. package/dist/common/index.d.ts.map +1 -1
  45. package/dist/common/index.js +0 -2
  46. package/dist/common/validate.d.ts +2 -2
  47. package/dist/common/validate.d.ts.map +1 -1
  48. package/dist/common/validate.js +2 -3
  49. package/dist/content/article-card.d.ts.map +1 -1
  50. package/dist/content/article-card.js +4 -1
  51. package/dist/controls/enum-filter.d.ts.map +1 -1
  52. package/dist/controls/enum-filter.js +3 -13
  53. package/dist/controls/filter-pill.d.ts +8 -0
  54. package/dist/controls/filter-pill.d.ts.map +1 -1
  55. package/dist/controls/filter-pill.js +15 -0
  56. package/dist/controls/multi-select-filter.d.ts.map +1 -1
  57. package/dist/controls/multi-select-filter.js +3 -13
  58. package/dist/controls/number-filter.d.ts +3 -0
  59. package/dist/controls/number-filter.d.ts.map +1 -1
  60. package/dist/controls/number-filter.js +12 -4
  61. package/dist/controls/range-filter.d.ts.map +1 -1
  62. package/dist/controls/range-filter.js +1 -2
  63. package/dist/data/data-table.d.ts +9 -2
  64. package/dist/data/data-table.d.ts.map +1 -1
  65. package/dist/data/data-table.js +2 -3
  66. package/dist/forms/form-layout.d.ts +6 -1
  67. package/dist/forms/form-layout.d.ts.map +1 -1
  68. package/dist/forms/form-layout.module.css +3 -0
  69. package/dist/guard/index.d.ts +18 -4
  70. package/dist/guard/index.d.ts.map +1 -1
  71. package/dist/guard/index.js +67 -31
  72. package/dist/guard/types.d.ts +3 -3
  73. package/dist/guard/types.d.ts.map +1 -1
  74. package/dist/guard/vx-names.d.ts +3 -0
  75. package/dist/guard/vx-names.d.ts.map +1 -0
  76. package/dist/guard/vx-names.js +257 -0
  77. package/dist/notifications/mount-guard.d.ts.map +1 -1
  78. package/dist/notifications/mount-guard.js +1 -2
  79. package/dist/provider/index.d.ts.map +1 -1
  80. package/dist/provider/index.js +2 -3
  81. package/dist/provider/lab-theme.d.ts +5 -4
  82. package/dist/provider/lab-theme.d.ts.map +1 -1
  83. package/dist/provider/lab-theme.js +1 -3
  84. package/dist/router-tanstack/search-store.d.ts +18 -0
  85. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  86. package/dist/router-tanstack/search-store.js +33 -1
  87. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  88. package/dist/shell/mobile-nav-model.js +3 -4
  89. package/dist/shell/page-bar.d.ts +24 -0
  90. package/dist/shell/page-bar.d.ts.map +1 -1
  91. package/dist/shell/page-bar.js +1 -2
  92. package/dist/surfaces.d.ts +1 -1
  93. package/dist/surfaces.d.ts.map +1 -1
  94. package/dist/surfaces.js +2 -0
  95. package/dist/tokens/index.d.ts +2 -0
  96. package/dist/tokens/index.d.ts.map +1 -1
  97. package/dist/tokens/index.js +10 -0
  98. package/dist/tokens/size-classes.d.ts +13 -0
  99. package/dist/tokens/size-classes.d.ts.map +1 -1
  100. package/dist/tokens/size-classes.js +8 -0
  101. package/llms.txt +2 -2
  102. package/package.json +1 -1
  103. package/src/agent/coalesce.ts +1 -2
  104. package/src/agent/merge.ts +1 -2
  105. package/src/agent-chat/relative-time.ts +1 -2
  106. package/src/agent-chat/thread-message.tsx +2 -3
  107. package/src/charts/kinds/BandStrip.tsx +7 -3
  108. package/src/charts/kinds/Bars.tsx +1 -2
  109. package/src/charts/kinds/StackedArea.tsx +4 -2
  110. package/src/charts/primitives/legend-disclosure.tsx +1 -0
  111. package/src/cli/audit-allows.ts +10 -9
  112. package/src/cli/check-theme.ts +1 -2
  113. package/src/cli/config.ts +209 -0
  114. package/src/cli/doctor.ts +2 -2
  115. package/src/cli/guard-hook.ts +2 -8
  116. package/src/cli/index.ts +2 -206
  117. package/src/commands/define-commands.ts +2 -2
  118. package/src/commands/define-overlays.ts +1 -2
  119. package/src/common/index.ts +0 -1
  120. package/src/common/validate.ts +4 -5
  121. package/src/content/article-card.tsx +1 -0
  122. package/src/controls/enum-filter.tsx +3 -13
  123. package/src/controls/filter-pill.tsx +20 -0
  124. package/src/controls/multi-select-filter.tsx +3 -13
  125. package/src/controls/number-filter.tsx +24 -3
  126. package/src/controls/range-filter.tsx +1 -2
  127. package/src/data/data-table.tsx +11 -5
  128. package/src/forms/form-layout.module.css +3 -0
  129. package/src/forms/form-layout.tsx +6 -1
  130. package/src/guard/index.ts +151 -62
  131. package/src/guard/types.ts +3 -2
  132. package/src/guard/vx-names.ts +257 -0
  133. package/src/notifications/mount-guard.ts +1 -2
  134. package/src/provider/index.tsx +2 -3
  135. package/src/provider/lab-theme.ts +28 -21
  136. package/src/router-tanstack/search-store.ts +73 -1
  137. package/src/shell/mobile-nav-model.ts +3 -5
  138. package/src/shell/page-bar.tsx +25 -4
  139. package/src/surfaces.ts +2 -0
  140. package/src/tokens/index.ts +10 -0
  141. package/src/tokens/size-classes.ts +14 -0
  142. package/dist/common/is-dev.d.ts +0 -3
  143. package/dist/common/is-dev.d.ts.map +0 -1
  144. package/dist/common/is-dev.js +0 -6
  145. package/src/common/is-dev.ts +0 -4
@@ -0,0 +1,209 @@
1
+ // The `basalt` config shape and helpers, shared by the dispatcher and `--audit-allows`. A leaf: it
2
+ // imports nothing from `./index`, so audit-allows → index → check-theme → audit-allows is no cycle.
3
+ import type { GuardKind, GuardSeverity } from '../guard'
4
+
5
+ /** Shape of the optional `"basalt"` key in a consumer's package.json. */
6
+ export type BasaltConfig = {
7
+ /** Source roots to scan. Default: `['src']`. Set explicitly for a monorepo layout. */
8
+ roots?: string[]
9
+ /** Files exempt from the scan (they ARE the palette source). Default: argo's exempt set. */
10
+ exempt?: string[]
11
+ /**
12
+ * Declares this package a tokens-only consumer: it uses the `--vx-*` layer and no Mantine. Turns
13
+ * off every guard kind whose remedy is a Mantine component, a Mantine prop or the React theme
14
+ * factory, leaving the color and typography kinds live. Default: `'mantine'`.
15
+ *
16
+ * Deliberately not inferred from a missing `@mantine/core`: silencing 17 rules (the live count is
17
+ * `TOKENS_ONLY_DISABLED_KINDS.size`, which the check-theme banner prints) is a decision, and
18
+ * a repo that keeps Mantine in a workspace package would otherwise switch off half its own guard
19
+ * without saying so. `basalt-ui doctor` detects the shape and tells you to set this.
20
+ */
21
+ profile?: 'mantine' | 'tokens-only'
22
+ /**
23
+ * Extra files to scan, named individually and relative to this package — for a design surface
24
+ * that lives outside every root and outside the `index.html` / `public/` conventions the walker
25
+ * already derives. Also the ONLY way a `.json` is ever scanned: the guard understands JSON as
26
+ * markup, but a consumer repo's JSON is overwhelmingly config, fixtures and lockfiles, so
27
+ * blanket-scanning it is a false-positive generator rather than a guard.
28
+ *
29
+ * @example
30
+ * { include: ['app/manifest.json', 'emails/template.html'] }
31
+ */
32
+ include?: string[]
33
+ /** Named spacing-scale steps (px) flagged when used as a raw spacing prop. Default: 10/12/16/20/32. */
34
+ spacingSteps?: number[]
35
+ /** Per-kind severity override — `'warn'` reports without failing, `'error'` fails the build.
36
+ * See `GuardSeverity` in ../guard/types for the grace-minor doctrine behind it. */
37
+ severity?: Partial<Record<GuardKind, GuardSeverity>>
38
+ /** Off-identity Mantine accent families forbidden as chrome accents. Default: argo's set. */
39
+ forbiddenAccents?: string[]
40
+ /** Earned accent hue recorded in DESIGN.md `{{ACCENT_HUE}}`. Default: `blue`. */
41
+ accentHue?: string
42
+ /**
43
+ * Flag any numeric radius prop literal (radius={6}) — use the radius token scale instead.
44
+ * Default: `true` (ON). Set `false` to disable the `raw-radius` check (e.g. a repo that DEFINES
45
+ * the radius primitives).
46
+ */
47
+ rawRadius?: boolean
48
+ /**
49
+ * Flag ad-hoc inline surface styling (`border`/`borderRadius`/`boxShadow` literals in a `style={{}}`)
50
+ * that bypasses the radius-token + `VX.surface.*` + `VX.shadowCard` system. Default: `true` (ON).
51
+ * Set `false` to disable the `raw-surface` check.
52
+ */
53
+ rawSurface?: boolean
54
+ /**
55
+ * Flag `withBorder` on a `Card` / `Paper`. Card depth is `--vx-shadow-card` — a whisper shadow
56
+ * with the 1px ring baked into the SAME value — so `withBorder` draws a SECOND, real border over
57
+ * that ring and the card reads heavy/boxed (docs/DESIGN-SPEC.md doctrine inversion #1).
58
+ * `withBorder={false}` and `<Card.Section withBorder>` (a section divider) both pass.
59
+ * Default: `true` (ON). Set `false` to disable the `card-with-border` check.
60
+ */
61
+ cardWithBorder?: boolean
62
+ /**
63
+ * Flag references to a raw Mantine ramp step used for surface color
64
+ * (`var(--mantine-color-gray-N)` / `var(--mantine-color-dark-N)`) — these bypass the basalt
65
+ * surface tokens. Default: `true` (ON). Set `false` to disable the `off-system-surface-var` check.
66
+ */
67
+ offSystemSurfaceVar?: boolean
68
+ /**
69
+ * Flag a SHADE-PINNED Mantine color — `c="yellow.7"`, `bg="blue.4"`,
70
+ * `var(--mantine-color-red-6)`. A pinned step is one fixed swatch in BOTH color schemes, so it
71
+ * cannot stay legible in either; route a verdict color through `VX.status.*` / `--vx-status-*`,
72
+ * or drop the index (`c="red"`) and let the theme resolve the shade per scheme. `gray-*`/`dark-*`
73
+ * in `var()` form belong to `off-system-surface-var` instead. Default: `true` (ON). Set `false`
74
+ * to disable the `mantine-shade-index` check.
75
+ */
76
+ mantineShadeIndex?: boolean
77
+ /**
78
+ * Flag raw lowercase JSX layout/surface elements (`div`/`span`/`section`/…) carrying an inline
79
+ * `style={{}}` with a layout/surface property — steer to a Mantine layout primitive
80
+ * (`Box`/`Flex`/`Grid`/`Stack`/`Group`/`SimpleGrid`/`Paper`). Default: `true` (ON). Set `false`
81
+ * to disable the `raw-html-layout` check.
82
+ */
83
+ rawHtmlLayout?: boolean
84
+ /**
85
+ * Flag spacing/sizing literals inside an inline `style={{}}` (`padding`/`margin`/`gap`/…) — the
86
+ * `raw-spacing` check only catches the Mantine prop syntax. Default: `true` (ON). Set `false` to
87
+ * disable the `inline-spacing` check.
88
+ */
89
+ inlineSpacing?: boolean
90
+ /**
91
+ * Flag `display: 'flex' | 'grid' | 'inline-flex' | 'inline-grid'` in an inline `style={{}}` —
92
+ * steer to `<Flex>`/`<Grid>`/`<Group>`. Default: `true` (ON). Set `false` to disable the
93
+ * `inline-display` check.
94
+ */
95
+ inlineDisplay?: boolean
96
+ /**
97
+ * Flag raw `<AxisLeft>` / `<AxisBottom>` / `<AxisRight>` visx JSX inside chart files (a path
98
+ * containing `/charts/`) — these bypass the `AxisLeftNumeric` / `AxisBottomDate` /
99
+ * `AxisRightNumeric` primitives that carry the theme tokens + smart ticks. The legitimate wrapper
100
+ * (`Axes.tsx`, which IS the primitive) is exempt. Default: `true` (ON). Set `false` to disable the
101
+ * `raw-visx-axis` check.
102
+ */
103
+ rawVisxAxis?: boolean
104
+ /**
105
+ * Flag a hardcoded duration/spring/ease literal inline in a `transition={{...}}` prop — route it
106
+ * through `MOTION_DURATION` / `MOTION_SPRING` / `MOTION_EASE_STANDARD` instead. Default: `true`
107
+ * (ON). Set `false` to disable the `raw-motion-value` check.
108
+ */
109
+ rawMotionValue?: boolean
110
+ /**
111
+ * Flag a chart entry-point JSX tag (`MultiLine`/`Bars`/`Donut`/`DualPanel`/`Heatmap`/`ZonedLine`/
112
+ * `StackedArea`/`LineSparkline`/`BarSparkline`) missing an `ariaLabel` prop. Default: `true`
113
+ * (ON). Set `false` to disable the `chart-missing-aria-label` check.
114
+ */
115
+ chartMissingAriaLabel?: boolean
116
+ /**
117
+ * Flag a raw lowercase `<input>`/`<select>`/`<textarea>` JSX element — it bypasses the entire
118
+ * theme, not just the iOS font-size floor. Use the Mantine equivalents (`TextInput`,
119
+ * `NumberInput`, `Select`, `Textarea`, …) instead. Default: `true` (ON). Set `false` to disable
120
+ * the `raw-form-control` check.
121
+ */
122
+ rawFormControl?: boolean
123
+ /**
124
+ * Flag a `fontSize`/`font-size` literal below 16 inside a `style={{…}}` on a raw form control,
125
+ * or a Mantine `styles={{ input: {…} }}` per-part style — the `styles.css` iOS floor is
126
+ * `!important`, so the override is dead code. Default: `true` (ON). Set `false` to disable the
127
+ * `sub-16-input-font` check.
128
+ */
129
+ sub16InputFont?: boolean
130
+ /**
131
+ * Path of the consumer's guard-exempt series file, for DESIGN.md `{{SERIES_MODULE_PATH}}`.
132
+ * Default: `<first basalt.root>/lib/series.ts` — see `resolveSeriesModulePath`.
133
+ */
134
+ seriesModulePath?: string
135
+ /**
136
+ * Per-rule, per-path exemptions — complements whole-file `exempt` (which skips ALL rules for a
137
+ * file) and the hardcoded per-kind `appliesTo` scoping (e.g. `raw-visx-axis` → chart files
138
+ * only). Each value is a list of patterns matched against a finding's relative path. A pattern
139
+ * may be a whole path segment (`'agent'` matches `src/agent/x.tsx`, not `src/agenting.ts`), a
140
+ * relative path (`'public/site.webmanifest'`), a directory prefix, or a glob — `*` stops at `/`,
141
+ * `**` does not, and a slash-free glob also matches the basename, so `'*.module.css'` works.
142
+ * A trailing `/` is stripped. An entry that suppresses nothing is reported, and
143
+ * `check-theme --audit-allows` exits 1 on it. Default: `{}` (no exemptions).
144
+ *
145
+ * Two forms per kind. The bare array is paths only. The object form adds the REASON, which is
146
+ * the half a `theme-allow` carries and this key could not: JSON has no comments, so a
147
+ * `.webmanifest` / `.json` finding's only escape is this key, and it was un-reviewable by
148
+ * construction — the rationale ended up in a CLAUDE.md paragraph nobody reads next to the diff.
149
+ * `check-theme --audit-allows` prints the reason (or names its absence) beside what the entry
150
+ * still suppresses. The array form stays supported and unchanged; nothing here is required.
151
+ *
152
+ * @example
153
+ * { exemptRules: { 'inline-display': ['agent'] } } // inline-display never fires under src/agent/**
154
+ * @example
155
+ * { exemptRules: { 'raw-hex': { paths: ['site.webmanifest'],
156
+ * reason: 'a PWA manifest theme_color MUST be a literal hex — JSON cannot reference a CSS var' } } }
157
+ */
158
+ exemptRules?: Partial<Record<GuardKind, ExemptRuleEntry>>
159
+ /**
160
+ * Declares an intentional `ai` package major-version skew across workspace packages, exempting
161
+ * `doctor`'s `ai-major-parity` hard check. The value IS the reason — a bare `true` is rejected,
162
+ * because the whole point of this key is that the pairing is written down, not just switched off.
163
+ * A written declaration is the pin `basalt/ai-sdk-major`'s own comment notes is otherwise missing.
164
+ *
165
+ * Semantics: absent (or present but not a non-empty string) → the skew still hard-fails exactly as
166
+ * without this key. A non-empty string with a skew present → `doctor` passes, echoing both the
167
+ * skew and this reason. A non-empty string with NO skew present → `doctor` warns that the
168
+ * exemption is stale and can be deleted (an exemption nobody re-checks is how a real, later skew
169
+ * slips through unnoticed).
170
+ *
171
+ * @example
172
+ * // apps/api streams on ai@5; apps/dashboard parses on ai@7; a producer-side TransformStream
173
+ * // neutralizes the one enum value that differs between the two majors.
174
+ * { aiMajorSkewReason: 'apps/api pinned to ai@5, apps/dashboard on ai@7 — the skew is neutralized '
175
+ * + 'by a producer-side TransformStream in apps/api/src/stream-transform.ts' }
176
+ */
177
+ aiMajorSkewReason?: string
178
+ }
179
+
180
+ /**
181
+ * One `basalt.exemptRules` value: the historical bare path list, or the same list with the reason
182
+ * it exists. See `BasaltConfig.exemptRules`.
183
+ */
184
+ export type ExemptRuleEntry = string[] | { paths: string[]; reason: string }
185
+
186
+ /** The paths half of an exemption entry, whichever form it was written in. */
187
+ export function exemptRulePaths(entry: ExemptRuleEntry | undefined): string[] {
188
+ if (entry === undefined) return []
189
+ return Array.isArray(entry) ? entry : entry.paths
190
+ }
191
+
192
+ /** The recorded reason for an exemption entry, or null for the bare-array form. */
193
+ export function exemptRuleReason(entry: ExemptRuleEntry | undefined): string | null {
194
+ if (entry === undefined || Array.isArray(entry)) return null
195
+ return entry.reason.trim().length > 0 ? entry.reason.trim() : null
196
+ }
197
+
198
+ export const DEFAULT_ROOT = 'src'
199
+ export const DEFAULT_ROOTS = [DEFAULT_ROOT]
200
+
201
+ /**
202
+ * The configured source roots, or the built-in default. The ONE resolution every roots-derived seed
203
+ * reads — an empty `roots: []` falls back rather than resolving to nothing, because a bare `??`
204
+ * would let `[]` through and render an empty oxfmt glob into the seeded CI, reproducing the exact
205
+ * "matches zero files" break this derivation exists to prevent.
206
+ */
207
+ export function resolveRoots(cfg: BasaltConfig): string[] {
208
+ return cfg.roots?.length ? cfg.roots : DEFAULT_ROOTS
209
+ }
package/src/cli/doctor.ts CHANGED
@@ -6,7 +6,7 @@
6
6
  import { existsSync } from 'node:fs'
7
7
  import { relative, resolve } from 'node:path'
8
8
 
9
- import type { BasaltConfig } from './index'
9
+ import type { BasaltConfig } from './config'
10
10
  import {
11
11
  MANIFEST_PATH,
12
12
  basaltBinCommand,
@@ -21,10 +21,10 @@ import {
21
21
  readIfExists,
22
22
  readManifest,
23
23
  resolveProjectDir,
24
- resolveRoots,
25
24
  scannableFiles,
26
25
  shippedAssetPath,
27
26
  } from './index'
27
+ import { resolveRoots } from './config'
28
28
  import { findManifestAbove, parentInstallAdvice } from './sync'
29
29
 
30
30
  export type DoctorResult = {
@@ -8,14 +8,8 @@ import { isAbsolute, relative, resolve } from 'node:path'
8
8
  import { DEFAULT_GUARD_CONFIG } from '../guard'
9
9
  import type { GuardConfig } from '../guard'
10
10
  import { evaluateGuardHook } from '../guard/guard-hook'
11
- import {
12
- DEFAULT_ROOTS,
13
- SKIP,
14
- declaredProfile,
15
- defaultExempt,
16
- readBasaltConfig,
17
- resolveExemptRules,
18
- } from './index'
11
+ import { SKIP, declaredProfile, defaultExempt, readBasaltConfig, resolveExemptRules } from './index'
12
+ import { DEFAULT_ROOTS } from './config'
19
13
 
20
14
  /**
21
15
  * guard-hook — PreToolUse stdin adapter.
package/src/cli/index.ts CHANGED
@@ -39,199 +39,8 @@ import { COMMAND_OPTIONS, USAGE, unknownFlag } from './help'
39
39
  import { init } from './init'
40
40
  import { sync } from './sync'
41
41
  import { fontsCss, normalizeColorFunctions, tokensCss } from './tokens-css'
42
-
43
- /** Shape of the optional `"basalt"` key in a consumer's package.json. */
44
- export type BasaltConfig = {
45
- /** Source roots to scan. Default: `['src']`. Set explicitly for a monorepo layout. */
46
- roots?: string[]
47
- /** Files exempt from the scan (they ARE the palette source). Default: argo's exempt set. */
48
- exempt?: string[]
49
- /**
50
- * Declares this package a tokens-only consumer: it uses the `--vx-*` layer and no Mantine. Turns
51
- * off every guard kind whose remedy is a Mantine component, a Mantine prop or the React theme
52
- * factory, leaving the color and typography kinds live. Default: `'mantine'`.
53
- *
54
- * Deliberately not inferred from a missing `@mantine/core`: silencing 17 rules (the live count is
55
- * `TOKENS_ONLY_DISABLED_KINDS.size`, which the check-theme banner prints) is a decision, and
56
- * a repo that keeps Mantine in a workspace package would otherwise switch off half its own guard
57
- * without saying so. `basalt-ui doctor` detects the shape and tells you to set this.
58
- */
59
- profile?: 'mantine' | 'tokens-only'
60
- /**
61
- * Extra files to scan, named individually and relative to this package — for a design surface
62
- * that lives outside every root and outside the `index.html` / `public/` conventions the walker
63
- * already derives. Also the ONLY way a `.json` is ever scanned: the guard understands JSON as
64
- * markup, but a consumer repo's JSON is overwhelmingly config, fixtures and lockfiles, so
65
- * blanket-scanning it is a false-positive generator rather than a guard.
66
- *
67
- * @example
68
- * { include: ['app/manifest.json', 'emails/template.html'] }
69
- */
70
- include?: string[]
71
- /** Named spacing-scale steps (px) flagged when used as a raw spacing prop. Default: 10/12/16/20/32. */
72
- spacingSteps?: number[]
73
- /** Per-kind severity override — `'warn'` reports without failing, `'error'` fails the build.
74
- * See `GuardSeverity` in ../guard/types for the grace-minor doctrine behind it. */
75
- severity?: Partial<Record<GuardKind, GuardSeverity>>
76
- /** Off-identity Mantine accent families forbidden as chrome accents. Default: argo's set. */
77
- forbiddenAccents?: string[]
78
- /** Earned accent hue recorded in DESIGN.md `{{ACCENT_HUE}}`. Default: `blue`. */
79
- accentHue?: string
80
- /**
81
- * Flag any numeric radius prop literal (radius={6}) — use the radius token scale instead.
82
- * Default: `true` (ON). Set `false` to disable the `raw-radius` check (e.g. a repo that DEFINES
83
- * the radius primitives).
84
- */
85
- rawRadius?: boolean
86
- /**
87
- * Flag ad-hoc inline surface styling (`border`/`borderRadius`/`boxShadow` literals in a `style={{}}`)
88
- * that bypasses the radius-token + `VX.surface.*` + `VX.shadowCard` system. Default: `true` (ON).
89
- * Set `false` to disable the `raw-surface` check.
90
- */
91
- rawSurface?: boolean
92
- /**
93
- * Flag `withBorder` on a `Card` / `Paper`. Card depth is `--vx-shadow-card` — a whisper shadow
94
- * with the 1px ring baked into the SAME value — so `withBorder` draws a SECOND, real border over
95
- * that ring and the card reads heavy/boxed (docs/DESIGN-SPEC.md doctrine inversion #1).
96
- * `withBorder={false}` and `<Card.Section withBorder>` (a section divider) both pass.
97
- * Default: `true` (ON). Set `false` to disable the `card-with-border` check.
98
- */
99
- cardWithBorder?: boolean
100
- /**
101
- * Flag references to a raw Mantine ramp step used for surface color
102
- * (`var(--mantine-color-gray-N)` / `var(--mantine-color-dark-N)`) — these bypass the basalt
103
- * surface tokens. Default: `true` (ON). Set `false` to disable the `off-system-surface-var` check.
104
- */
105
- offSystemSurfaceVar?: boolean
106
- /**
107
- * Flag a SHADE-PINNED Mantine color — `c="yellow.7"`, `bg="blue.4"`,
108
- * `var(--mantine-color-red-6)`. A pinned step is one fixed swatch in BOTH color schemes, so it
109
- * cannot stay legible in either; route a verdict color through `VX.status.*` / `--vx-status-*`,
110
- * or drop the index (`c="red"`) and let the theme resolve the shade per scheme. `gray-*`/`dark-*`
111
- * in `var()` form belong to `off-system-surface-var` instead. Default: `true` (ON). Set `false`
112
- * to disable the `mantine-shade-index` check.
113
- */
114
- mantineShadeIndex?: boolean
115
- /**
116
- * Flag raw lowercase JSX layout/surface elements (`div`/`span`/`section`/…) carrying an inline
117
- * `style={{}}` with a layout/surface property — steer to a Mantine layout primitive
118
- * (`Box`/`Flex`/`Grid`/`Stack`/`Group`/`SimpleGrid`/`Paper`). Default: `true` (ON). Set `false`
119
- * to disable the `raw-html-layout` check.
120
- */
121
- rawHtmlLayout?: boolean
122
- /**
123
- * Flag spacing/sizing literals inside an inline `style={{}}` (`padding`/`margin`/`gap`/…) — the
124
- * `raw-spacing` check only catches the Mantine prop syntax. Default: `true` (ON). Set `false` to
125
- * disable the `inline-spacing` check.
126
- */
127
- inlineSpacing?: boolean
128
- /**
129
- * Flag `display: 'flex' | 'grid' | 'inline-flex' | 'inline-grid'` in an inline `style={{}}` —
130
- * steer to `<Flex>`/`<Grid>`/`<Group>`. Default: `true` (ON). Set `false` to disable the
131
- * `inline-display` check.
132
- */
133
- inlineDisplay?: boolean
134
- /**
135
- * Flag raw `<AxisLeft>` / `<AxisBottom>` / `<AxisRight>` visx JSX inside chart files (a path
136
- * containing `/charts/`) — these bypass the `AxisLeftNumeric` / `AxisBottomDate` /
137
- * `AxisRightNumeric` primitives that carry the theme tokens + smart ticks. The legitimate wrapper
138
- * (`Axes.tsx`, which IS the primitive) is exempt. Default: `true` (ON). Set `false` to disable the
139
- * `raw-visx-axis` check.
140
- */
141
- rawVisxAxis?: boolean
142
- /**
143
- * Flag a hardcoded duration/spring/ease literal inline in a `transition={{...}}` prop — route it
144
- * through `MOTION_DURATION` / `MOTION_SPRING` / `MOTION_EASE_STANDARD` instead. Default: `true`
145
- * (ON). Set `false` to disable the `raw-motion-value` check.
146
- */
147
- rawMotionValue?: boolean
148
- /**
149
- * Flag a chart entry-point JSX tag (`MultiLine`/`Bars`/`Donut`/`DualPanel`/`Heatmap`/`ZonedLine`/
150
- * `StackedArea`/`LineSparkline`/`BarSparkline`) missing an `ariaLabel` prop. Default: `true`
151
- * (ON). Set `false` to disable the `chart-missing-aria-label` check.
152
- */
153
- chartMissingAriaLabel?: boolean
154
- /**
155
- * Flag a raw lowercase `<input>`/`<select>`/`<textarea>` JSX element — it bypasses the entire
156
- * theme, not just the iOS font-size floor. Use the Mantine equivalents (`TextInput`,
157
- * `NumberInput`, `Select`, `Textarea`, …) instead. Default: `true` (ON). Set `false` to disable
158
- * the `raw-form-control` check.
159
- */
160
- rawFormControl?: boolean
161
- /**
162
- * Flag a `fontSize`/`font-size` literal below 16 inside a `style={{…}}` on a raw form control,
163
- * or a Mantine `styles={{ input: {…} }}` per-part style — the `styles.css` iOS floor is
164
- * `!important`, so the override is dead code. Default: `true` (ON). Set `false` to disable the
165
- * `sub-16-input-font` check.
166
- */
167
- sub16InputFont?: boolean
168
- /**
169
- * Path of the consumer's guard-exempt series file, for DESIGN.md `{{SERIES_MODULE_PATH}}`.
170
- * Default: `<first basalt.root>/lib/series.ts` — see `resolveSeriesModulePath`.
171
- */
172
- seriesModulePath?: string
173
- /**
174
- * Per-rule, per-path exemptions — complements whole-file `exempt` (which skips ALL rules for a
175
- * file) and the hardcoded per-kind `appliesTo` scoping (e.g. `raw-visx-axis` → chart files
176
- * only). Each value is a list of patterns matched against a finding's relative path. A pattern
177
- * may be a whole path segment (`'agent'` matches `src/agent/x.tsx`, not `src/agenting.ts`), a
178
- * relative path (`'public/site.webmanifest'`), a directory prefix, or a glob — `*` stops at `/`,
179
- * `**` does not, and a slash-free glob also matches the basename, so `'*.module.css'` works.
180
- * A trailing `/` is stripped. An entry that suppresses nothing is reported, and
181
- * `check-theme --audit-allows` exits 1 on it. Default: `{}` (no exemptions).
182
- *
183
- * Two forms per kind. The bare array is paths only. The object form adds the REASON, which is
184
- * the half a `theme-allow` carries and this key could not: JSON has no comments, so a
185
- * `.webmanifest` / `.json` finding's only escape is this key, and it was un-reviewable by
186
- * construction — the rationale ended up in a CLAUDE.md paragraph nobody reads next to the diff.
187
- * `check-theme --audit-allows` prints the reason (or names its absence) beside what the entry
188
- * still suppresses. The array form stays supported and unchanged; nothing here is required.
189
- *
190
- * @example
191
- * { exemptRules: { 'inline-display': ['agent'] } } // inline-display never fires under src/agent/**
192
- * @example
193
- * { exemptRules: { 'raw-hex': { paths: ['site.webmanifest'],
194
- * reason: 'a PWA manifest theme_color MUST be a literal hex — JSON cannot reference a CSS var' } } }
195
- */
196
- exemptRules?: Partial<Record<GuardKind, ExemptRuleEntry>>
197
- /**
198
- * Declares an intentional `ai` package major-version skew across workspace packages, exempting
199
- * `doctor`'s `ai-major-parity` hard check. The value IS the reason — a bare `true` is rejected,
200
- * because the whole point of this key is that the pairing is written down, not just switched off.
201
- * A written declaration is the pin `basalt/ai-sdk-major`'s own comment notes is otherwise missing.
202
- *
203
- * Semantics: absent (or present but not a non-empty string) → the skew still hard-fails exactly as
204
- * without this key. A non-empty string with a skew present → `doctor` passes, echoing both the
205
- * skew and this reason. A non-empty string with NO skew present → `doctor` warns that the
206
- * exemption is stale and can be deleted (an exemption nobody re-checks is how a real, later skew
207
- * slips through unnoticed).
208
- *
209
- * @example
210
- * // apps/api streams on ai@5; apps/dashboard parses on ai@7; a producer-side TransformStream
211
- * // neutralizes the one enum value that differs between the two majors.
212
- * { aiMajorSkewReason: 'apps/api pinned to ai@5, apps/dashboard on ai@7 — the skew is neutralized '
213
- * + 'by a producer-side TransformStream in apps/api/src/stream-transform.ts' }
214
- */
215
- aiMajorSkewReason?: string
216
- }
217
-
218
- /**
219
- * One `basalt.exemptRules` value: the historical bare path list, or the same list with the reason
220
- * it exists. See `BasaltConfig.exemptRules`.
221
- */
222
- export type ExemptRuleEntry = string[] | { paths: string[]; reason: string }
223
-
224
- /** The paths half of an exemption entry, whichever form it was written in. */
225
- export function exemptRulePaths(entry: ExemptRuleEntry | undefined): string[] {
226
- if (entry === undefined) return []
227
- return Array.isArray(entry) ? entry : entry.paths
228
- }
229
-
230
- /** The recorded reason for an exemption entry, or null for the bare-array form. */
231
- export function exemptRuleReason(entry: ExemptRuleEntry | undefined): string | null {
232
- if (entry === undefined || Array.isArray(entry)) return null
233
- return entry.reason.trim().length > 0 ? entry.reason.trim() : null
234
- }
42
+ import { DEFAULT_ROOT, exemptRulePaths, resolveRoots } from './config'
43
+ import type { BasaltConfig } from './config'
235
44
 
236
45
  /**
237
46
  * The guard-shaped `exemptRules` — paths only. The reason the object form carries never reaches
@@ -247,19 +56,6 @@ export function resolveExemptRules(cfg: BasaltConfig): GuardConfig['exemptRules'
247
56
  return out
248
57
  }
249
58
 
250
- export const DEFAULT_ROOT = 'src'
251
- export const DEFAULT_ROOTS = [DEFAULT_ROOT]
252
-
253
- /**
254
- * The configured source roots, or the built-in default. The ONE resolution every roots-derived seed
255
- * reads — an empty `roots: []` falls back rather than resolving to nothing, because a bare `??`
256
- * would let `[]` through and render an empty oxfmt glob into the seeded CI, reproducing the exact
257
- * "matches zero files" break this derivation exists to prevent.
258
- */
259
- export function resolveRoots(cfg: BasaltConfig): string[] {
260
- return cfg.roots?.length ? cfg.roots : DEFAULT_ROOTS
261
- }
262
-
263
59
  /**
264
60
  * The one consumer series path — the DESIGN.md template's `{{SERIES_MODULE_PATH}}`, the default
265
61
  * scan exemption, and the skills' prose all resolve through this single function.
@@ -24,7 +24,6 @@
24
24
  */
25
25
  import type { ReactNode } from 'react'
26
26
  import type { Slot } from '../register'
27
- import { isDev } from '../common/is-dev'
28
27
 
29
28
  // ── Context ───────────────────────────────────────────────────────────────────
30
29
 
@@ -172,7 +171,8 @@ export function defineCommand(cmd: Command): Command {
172
171
  export function runCommand(id: CommandId, ctx?: CommandRunContext): void | Promise<void> {
173
172
  const cmd = activeCommands[id as string]
174
173
  if (cmd === undefined) {
175
- if (isDev()) console.warn(`[basalt] runCommand: no command registered for "${id}"`)
174
+ if (process.env.NODE_ENV !== 'production')
175
+ console.warn(`[basalt] runCommand: no command registered for "${id}"`)
176
176
  return
177
177
  }
178
178
  return cmd.run(ctx)
@@ -29,7 +29,6 @@ import type { ReactNode } from 'react'
29
29
  import { useEffect } from 'react'
30
30
  import { missingLayer } from '../common/errors'
31
31
  import type { Slot } from '../register'
32
- import { isDev } from '../common/is-dev'
33
32
 
34
33
  // ── Lazy @mantine/modals resolution ───────────────────────────────────────────
35
34
  // @mantine/modals is an OPTIONAL peer — never imported at module evaluation time, so importing
@@ -372,7 +371,7 @@ export const overlays = {
372
371
  open<K extends OverlayKey>(key: K, props: OverlayProps<K>): void {
373
372
  const spec = activeOverlays[key as string]
374
373
  if (spec === undefined) {
375
- if (isDev())
374
+ if (process.env.NODE_ENV !== 'production')
376
375
  console.warn(`[basalt] overlays.open: no overlay registered for "${String(key)}"`)
377
376
  return
378
377
  }
@@ -19,5 +19,4 @@ export {
19
19
  } from './errors'
20
20
  export { useValidateProps, assertRequiredProps, resetValidatedProps } from './validate'
21
21
  export { scrollParentOf, SCROLLPORT_ATTRIBUTE } from './scroll-parent'
22
- export { isDev } from './is-dev'
23
22
  export { MOTION_DURATION, MOTION_EASE_STANDARD, MOTION_SPRING } from './motion'
@@ -7,7 +7,6 @@
7
7
  * mean warns once in dev and renders on (`useValidateProps`).
8
8
  */
9
9
  import { useEffect } from 'react'
10
- import { isDev } from './is-dev'
11
10
  import { requiredProp } from './errors'
12
11
 
13
12
  /** A check returns the message(s) it has, or nothing. `null`/`undefined` entries are ignored. */
@@ -23,8 +22,8 @@ const reported = new Set<string>()
23
22
 
24
23
  /**
25
24
  * Dev-only prop validation. `console.error`s each message ONCE per `(component, message)` pair, and
26
- * is a no-op in production: `isDev()` reads `process.env.NODE_ENV`, which every bundler
27
- * constant-folds, so the whole body disappears from a production build.
25
+ * is a no-op in production: the gate is a literal `process.env.NODE_ENV` read, which every bundler
26
+ * constant-folds, so the whole body disappears from a production build (see `dev-gate.test.ts`).
28
27
  *
29
28
  * **The dedup key is `${component} ${message}`, and nothing else** — not the instance, not the
30
29
  * props. Two instances of one component producing the SAME message say it once between them, which
@@ -46,7 +45,7 @@ export function useValidateProps(
46
45
  deps: unknown[],
47
46
  ): void {
48
47
  useEffect(() => {
49
- if (!isDev()) return
48
+ if (process.env.NODE_ENV === 'production') return
50
49
  const result = check()
51
50
  const messages = Array.isArray(result) ? result : [result]
52
51
  for (const raw of messages) {
@@ -67,7 +66,7 @@ export function useValidateProps(
67
66
  * are about to win in the very next render.
68
67
  */
69
68
  export function reportOnce(component: string, message: string): void {
70
- if (!isDev()) return
69
+ if (process.env.NODE_ENV === 'production') return
71
70
  const key = `${component} ${message}`
72
71
  if (reported.has(key)) return
73
72
  reported.add(key)
@@ -166,6 +166,7 @@ export type ArticleGridProps = {
166
166
 
167
167
  export function ArticleGrid({ children, cols = 3 }: ArticleGridProps) {
168
168
  return (
169
+ // theme-allow raw-breakpoint — `cols` is documented per viewport `lg`; container keys would re-flow every consumer's grid, so that move ships opt-in
169
170
  <SimpleGrid cols={{ base: 1, sm: 2, lg: cols }} spacing="sm">
170
171
  {children}
171
172
  </SimpleGrid>
@@ -26,13 +26,13 @@
26
26
  * its doc). Both public wrappers still type `field` exactly, so a wrong field kind is still a type
27
27
  * error at the call site; the widening lives here, where nothing consumer-facing points at it.
28
28
  */
29
- import { Button, Radio, Stack } from '@mantine/core'
29
+ import { Radio, Stack } from '@mantine/core'
30
30
  import type { ReactNode } from 'react'
31
31
  import type { BasaltProps } from '../common/props'
32
32
  import type { FieldOption } from '../state'
33
33
  import classes from './controls.module.css'
34
34
  import { POPOVER_ROW_HIT_GAP, useFilterRegistration, useFilterSurface } from './filter-context'
35
- import { FilterPill } from './filter-pill'
35
+ import { FilterClearButton, FilterPill } from './filter-pill'
36
36
  import { useControlName } from './filter-sheet'
37
37
  import { PanelChoice, PanelRow } from './panel-row'
38
38
  import type { FilterOption } from './select-filter'
@@ -157,17 +157,7 @@ export function EnumFilter<T extends string>({
157
157
  />
158
158
  ))}
159
159
  </Stack>
160
- {clearable === true && !isDefault && (
161
- <Button
162
- variant="subtle"
163
- size="ctl"
164
- onClick={() => {
165
- field.clear()
166
- }}
167
- >
168
- Clear
169
- </Button>
170
- )}
160
+ {clearable === true && !isDefault && <FilterClearButton onClear={() => field.clear()} />}
171
161
  </Radio.Group>
172
162
  )
173
163
 
@@ -122,3 +122,23 @@ export function FilterPill({
122
122
  </Popover>
123
123
  )
124
124
  }
125
+
126
+ /**
127
+ * The popover's `Clear` — one button for `EnumFilter`, `NumberFilter` and `MultiSelectFilter`. Its
128
+ * mousedown keeps focus where it is: a stepper commits its draft on blur, so a press that blurred
129
+ * first would land the typed value in the URL a tick before the clear replaced it.
130
+ */
131
+ export function FilterClearButton({ onClear }: { readonly onClear: () => void }): ReactNode {
132
+ return (
133
+ <Button
134
+ variant="subtle"
135
+ size="ctl"
136
+ onMouseDown={(event) => {
137
+ event.preventDefault()
138
+ }}
139
+ onClick={onClear}
140
+ >
141
+ Clear
142
+ </Button>
143
+ )
144
+ }
@@ -22,7 +22,7 @@
22
22
  * // arrive as a map rather than baked into the labels the way the runtime catalogue does it.
23
23
  * <MultiSelectFilter field={shipments.field.origin} label="Origin" counts={countsByOrigin} />
24
24
  */
25
- import { Button, Checkbox, Stack } from '@mantine/core'
25
+ import { Checkbox, Stack } from '@mantine/core'
26
26
  import { useState } from 'react'
27
27
  import type { CSSProperties, ReactNode } from 'react'
28
28
  import type { BasaltProps } from '../common/props'
@@ -31,7 +31,7 @@ import type { FieldHandle, MultiField } from '../state'
31
31
  import type { FilterOption } from './select-filter'
32
32
  import classes from './controls.module.css'
33
33
  import { POPOVER_ROW_HIT_GAP, useFilterRegistration, useFilterSurface } from './filter-context'
34
- import { FilterPill } from './filter-pill'
34
+ import { FilterClearButton, FilterPill } from './filter-pill'
35
35
  import { CheckGlyph, useControlName } from './filter-sheet'
36
36
  import { PanelRow } from './panel-row'
37
37
 
@@ -153,17 +153,7 @@ export function MultiSelectFilter<T extends string>(props: MultiSelectFilterProp
153
153
  />
154
154
  ))}
155
155
  </Stack>
156
- {!isDefault && (
157
- <Button
158
- variant="subtle"
159
- size="ctl"
160
- onClick={() => {
161
- field.clear()
162
- }}
163
- >
164
- Clear
165
- </Button>
166
- )}
156
+ {!isDefault && <FilterClearButton onClear={() => field.clear()} />}
167
157
  </Checkbox.Group>
168
158
  )
169
159