basalt-ui 1.32.1 → 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 (137) hide show
  1. package/CHANGELOG.md +39 -0
  2. package/MIGRATING.md +124 -24
  3. package/agent/rules/basalt-state.md +2 -2
  4. package/agent/rules/basalt-tokens.md +1 -1
  5. package/configs/oxlint-plugin.js +68 -61
  6. package/configs/oxlint-plugin.test.ts +126 -9
  7. package/configs/oxlint.json +2 -2
  8. package/dist/agent/coalesce.d.ts.map +1 -1
  9. package/dist/agent/coalesce.js +1 -2
  10. package/dist/agent/merge.d.ts.map +1 -1
  11. package/dist/agent/merge.js +1 -2
  12. package/dist/agent-chat/relative-time.d.ts +9 -0
  13. package/dist/agent-chat/relative-time.d.ts.map +1 -1
  14. package/dist/agent-chat/relative-time.js +1 -2
  15. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  16. package/dist/agent-chat/thread-message.js +1 -2
  17. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  18. package/dist/charts/kinds/BandStrip.js +3 -3
  19. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  20. package/dist/charts/kinds/Bars.js +1 -2
  21. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  22. package/dist/charts/kinds/StackedArea.js +1 -2
  23. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -1
  24. package/dist/cli/audit-allows.d.ts +1 -1
  25. package/dist/cli/audit-allows.d.ts.map +1 -1
  26. package/dist/cli/audit-allows.js +4 -4
  27. package/dist/cli/check-theme.d.ts.map +1 -1
  28. package/dist/cli/check-theme.js +1 -2
  29. package/dist/cli/config.d.ts +197 -0
  30. package/dist/cli/config.d.ts.map +1 -0
  31. package/dist/cli/config.js +20 -0
  32. package/dist/cli/doctor.js +1 -1
  33. package/dist/cli/guard-hook.d.ts.map +1 -1
  34. package/dist/cli/guard-hook.js +2 -8
  35. package/dist/cli/index.d.ts +2 -196
  36. package/dist/cli/index.d.ts.map +1 -1
  37. package/dist/cli/index.js +1 -18
  38. package/dist/commands/define-commands.d.ts.map +1 -1
  39. package/dist/commands/define-commands.js +2 -2
  40. package/dist/commands/define-overlays.d.ts.map +1 -1
  41. package/dist/commands/define-overlays.js +1 -2
  42. package/dist/common/index.d.ts +0 -1
  43. package/dist/common/index.d.ts.map +1 -1
  44. package/dist/common/index.js +0 -2
  45. package/dist/common/validate.d.ts +2 -2
  46. package/dist/common/validate.d.ts.map +1 -1
  47. package/dist/common/validate.js +2 -3
  48. package/dist/content/article-card.d.ts.map +1 -1
  49. package/dist/content/article-card.js +4 -1
  50. package/dist/controls/enum-filter.d.ts.map +1 -1
  51. package/dist/controls/enum-filter.js +3 -13
  52. package/dist/controls/filter-pill.d.ts +8 -0
  53. package/dist/controls/filter-pill.d.ts.map +1 -1
  54. package/dist/controls/filter-pill.js +15 -0
  55. package/dist/controls/multi-select-filter.d.ts.map +1 -1
  56. package/dist/controls/multi-select-filter.js +3 -13
  57. package/dist/controls/number-filter.d.ts +3 -0
  58. package/dist/controls/number-filter.d.ts.map +1 -1
  59. package/dist/controls/number-filter.js +12 -4
  60. package/dist/controls/range-filter.d.ts.map +1 -1
  61. package/dist/controls/range-filter.js +1 -2
  62. package/dist/data/data-table.d.ts.map +1 -1
  63. package/dist/data/data-table.js +2 -3
  64. package/dist/guard/index.d.ts +16 -2
  65. package/dist/guard/index.d.ts.map +1 -1
  66. package/dist/guard/index.js +63 -22
  67. package/dist/guard/types.d.ts +3 -3
  68. package/dist/guard/types.d.ts.map +1 -1
  69. package/dist/guard/vx-names.d.ts +3 -0
  70. package/dist/guard/vx-names.d.ts.map +1 -0
  71. package/dist/guard/vx-names.js +257 -0
  72. package/dist/notifications/mount-guard.d.ts.map +1 -1
  73. package/dist/notifications/mount-guard.js +1 -2
  74. package/dist/provider/index.d.ts.map +1 -1
  75. package/dist/provider/index.js +2 -3
  76. package/dist/provider/lab-theme.d.ts +5 -4
  77. package/dist/provider/lab-theme.d.ts.map +1 -1
  78. package/dist/provider/lab-theme.js +1 -3
  79. package/dist/router-tanstack/search-store.d.ts +18 -0
  80. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  81. package/dist/router-tanstack/search-store.js +33 -1
  82. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  83. package/dist/shell/mobile-nav-model.js +3 -4
  84. package/dist/shell/page-bar.d.ts.map +1 -1
  85. package/dist/shell/page-bar.js +1 -2
  86. package/dist/surfaces.d.ts +1 -1
  87. package/dist/surfaces.d.ts.map +1 -1
  88. package/dist/surfaces.js +2 -0
  89. package/dist/tokens/index.d.ts +1 -1
  90. package/dist/tokens/index.d.ts.map +1 -1
  91. package/dist/tokens/index.js +7 -1
  92. package/dist/tokens/size-classes.d.ts +13 -0
  93. package/dist/tokens/size-classes.d.ts.map +1 -1
  94. package/dist/tokens/size-classes.js +8 -0
  95. package/llms.txt +2 -2
  96. package/package.json +1 -1
  97. package/src/agent/coalesce.ts +1 -2
  98. package/src/agent/merge.ts +1 -2
  99. package/src/agent-chat/relative-time.ts +1 -2
  100. package/src/agent-chat/thread-message.tsx +2 -3
  101. package/src/charts/kinds/BandStrip.tsx +7 -3
  102. package/src/charts/kinds/Bars.tsx +1 -2
  103. package/src/charts/kinds/StackedArea.tsx +4 -2
  104. package/src/charts/primitives/legend-disclosure.tsx +1 -0
  105. package/src/cli/audit-allows.ts +10 -9
  106. package/src/cli/check-theme.ts +1 -2
  107. package/src/cli/config.ts +209 -0
  108. package/src/cli/doctor.ts +2 -2
  109. package/src/cli/guard-hook.ts +2 -8
  110. package/src/cli/index.ts +2 -206
  111. package/src/commands/define-commands.ts +2 -2
  112. package/src/commands/define-overlays.ts +1 -2
  113. package/src/common/index.ts +0 -1
  114. package/src/common/validate.ts +4 -5
  115. package/src/content/article-card.tsx +1 -0
  116. package/src/controls/enum-filter.tsx +3 -13
  117. package/src/controls/filter-pill.tsx +20 -0
  118. package/src/controls/multi-select-filter.tsx +3 -13
  119. package/src/controls/number-filter.tsx +24 -3
  120. package/src/controls/range-filter.tsx +1 -2
  121. package/src/data/data-table.tsx +2 -3
  122. package/src/guard/index.ts +136 -46
  123. package/src/guard/types.ts +3 -2
  124. package/src/guard/vx-names.ts +257 -0
  125. package/src/notifications/mount-guard.ts +1 -2
  126. package/src/provider/index.tsx +2 -3
  127. package/src/provider/lab-theme.ts +28 -21
  128. package/src/router-tanstack/search-store.ts +73 -1
  129. package/src/shell/mobile-nav-model.ts +3 -5
  130. package/src/shell/page-bar.tsx +1 -4
  131. package/src/surfaces.ts +2 -0
  132. package/src/tokens/index.ts +6 -1
  133. package/src/tokens/size-classes.ts +14 -0
  134. package/dist/common/is-dev.d.ts +0 -3
  135. package/dist/common/is-dev.d.ts.map +0 -1
  136. package/dist/common/is-dev.js +0 -6
  137. package/src/common/is-dev.ts +0 -4
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
 
@@ -51,6 +51,12 @@
51
51
  * // An open threshold — a pill holding a stepper, applied on blur or Enter.
52
52
  * // minDuration: field.number({ fallback: 0, min: 0, max: 600 })
53
53
  * <NumberFilter field={lines.field.minDuration} label="Min duration" step={30} />
54
+ *
55
+ * @example
56
+ * // "No filter" is the fallback, not a row: `clearable` writes it back, and the pill reads the
57
+ * // filter's name while no preset is picked.
58
+ * // difficulty: field.number({ fallback: 0, min: 0, max: 5, int: true })
59
+ * <NumberFilter field={library.field.difficulty} label="Difficulty" options={LEVELS} clearable />
54
60
  */
55
61
  import { NumberInput } from '@mantine/core'
56
62
  import { useEffect, useRef, useState } from 'react'
@@ -62,7 +68,7 @@ import classes from './controls.module.css'
62
68
  import { EnumFilter } from './enum-filter'
63
69
  import type { ChoiceHandle } from './enum-filter'
64
70
  import { useFilterRegistration, useFilterSurface } from './filter-context'
65
- import { FilterPill } from './filter-pill'
71
+ import { FilterClearButton, FilterPill } from './filter-pill'
66
72
  import { useControlName } from './filter-sheet'
67
73
  import { PanelRow } from './panel-row'
68
74
 
@@ -89,6 +95,9 @@ export type NumberFilterProps = BasaltProps & {
89
95
  * declared none. Absent on both, the input keeps Mantine's own default of 1.
90
96
  */
91
97
  readonly step?: number
98
+ /** Adds a `Clear` action that clears the field — the same `field.clear()` `Reset all` calls.
99
+ * Parity with `SelectFilter`/`EnumFilter`, in both forms. */
100
+ readonly clearable?: boolean
92
101
  }
93
102
 
94
103
  export function NumberFilter(props: NumberFilterProps): ReactNode {
@@ -97,7 +106,7 @@ export function NumberFilter(props: NumberFilterProps): ReactNode {
97
106
  assertRequiredProps('NumberFilter', props, ['field'], {
98
107
  field: 'bind it to a store field (`store.field.<name>`), never a value/onChange pair.',
99
108
  })
100
- const { field, label, icon, options, step, className, style } = props
109
+ const { field, label, icon, options, step, clearable, className, style } = props
101
110
  // Two BRANCHES, two components, and never a conditional hook: the radio form's state lives in
102
111
  // `EnumFilter` and the stepper's in `NumberStepper`, so which one mounts is decided once by a prop
103
112
  // that does not change across a mount's life.
@@ -107,6 +116,7 @@ export function NumberFilter(props: NumberFilterProps): ReactNode {
107
116
  field={field}
108
117
  label={label}
109
118
  options={options}
119
+ {...(clearable === true && { clearable: true })}
110
120
  {...(icon !== undefined && { icon })}
111
121
  {...(className !== undefined && { className })}
112
122
  {...(style !== undefined && { style })}
@@ -120,6 +130,7 @@ export function NumberFilter(props: NumberFilterProps): ReactNode {
120
130
  <NumberStepper
121
131
  field={field}
122
132
  label={label}
133
+ {...(clearable === true && { clearable: true })}
123
134
  {...(icon !== undefined && { icon })}
124
135
  {...(grain !== undefined && { step: grain })}
125
136
  {...(className !== undefined && { className })}
@@ -147,6 +158,7 @@ function NumberChoice({
147
158
  label,
148
159
  icon,
149
160
  options,
161
+ clearable,
150
162
  className,
151
163
  style,
152
164
  }: BasaltProps & {
@@ -154,6 +166,7 @@ function NumberChoice({
154
166
  readonly label: string
155
167
  readonly icon?: ReactNode
156
168
  readonly options: readonly NumberFilterOption[]
169
+ readonly clearable?: boolean
157
170
  }): ReactNode {
158
171
  const [value, setValue] = field.use()
159
172
  const rows = options.map((option) => ({ value: String(option.value), label: option.label }))
@@ -182,6 +195,7 @@ function NumberChoice({
182
195
  label={label}
183
196
  numeric
184
197
  options={rows}
198
+ {...(clearable === true && { clearable: true })}
185
199
  {...(icon !== undefined && { icon })}
186
200
  {...(className !== undefined && { className })}
187
201
  {...(style !== undefined && { style })}
@@ -195,6 +209,7 @@ function NumberStepper({
195
209
  label,
196
210
  icon,
197
211
  step,
212
+ clearable,
198
213
  className,
199
214
  style,
200
215
  }: BasaltProps & {
@@ -202,6 +217,7 @@ function NumberStepper({
202
217
  readonly label: string
203
218
  readonly icon?: ReactNode
204
219
  readonly step?: number
220
+ readonly clearable?: boolean
205
221
  }): ReactNode {
206
222
  const [value, setValue] = field.use()
207
223
  const surface = useFilterSurface()
@@ -265,7 +281,12 @@ function NumberStepper({
265
281
  {...(className !== undefined && { className })}
266
282
  {...(style !== undefined && { style })}
267
283
  >
268
- <div className={classes.optionList}>{input}</div>
284
+ <div className={classes.optionList}>
285
+ {input}
286
+ {/* Same placement as `EnumFilter`'s: popover only — the sheet and panel rows reset through
287
+ the `FilterSet` registration above. The draft follows the clear via its effect. */}
288
+ {clearable === true && !isDefault && <FilterClearButton onClear={() => field.clear()} />}
289
+ </div>
269
290
  </FilterPill>
270
291
  )
271
292
  }
@@ -32,7 +32,6 @@ import { useEffect, useRef, useState } from 'react'
32
32
  import type { ComponentType, ReactNode } from 'react'
33
33
  import type { BasaltProps } from '../common/props'
34
34
  import { assertRequiredProps } from '../common/validate'
35
- import { isDev } from '../common/is-dev'
36
35
  import type { FieldHandle, RangeField, RangeValue } from '../state'
37
36
  import classes from './controls.module.css'
38
37
  import { useFilterRegistration, useFilterSurface } from './filter-context'
@@ -60,7 +59,7 @@ const noPickerWarned = new Set<string>()
60
59
  * the type cannot tell "deliberately preset-only" from "forgot the import".
61
60
  */
62
61
  function warnCustomWithoutPicker(label: string, unreachable: boolean): void {
63
- if (!unreachable || !isDev() || noPickerWarned.has(label)) return
62
+ if (!unreachable || process.env.NODE_ENV === 'production' || noPickerWarned.has(label)) return
64
63
  noPickerWarned.add(label)
65
64
  // oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
66
65
  console.warn(
@@ -98,7 +98,6 @@ import {
98
98
  useRowDisclosure,
99
99
  } from './data-table-state'
100
100
  import { dataQueryBranch } from './query-branch'
101
- import { isDev } from '../common/is-dev'
102
101
  import classes from './data-table.module.css'
103
102
 
104
103
  // ── Column alignment ──────────────────────────────────────────────────────────
@@ -303,7 +302,7 @@ function resolveFacetColumn<T>(
303
302
  ): Column<T, unknown> | undefined {
304
303
  const column = table.getColumn(facet.columnId)
305
304
  if (column) return column
306
- if (isDev()) {
305
+ if (process.env.NODE_ENV !== 'production') {
307
306
  const knownIds = table
308
307
  .getAllColumns()
309
308
  .map((known) => known.id)
@@ -1231,7 +1230,7 @@ const reportedBreaches = new Set<string>()
1231
1230
  function enforceManualPaginationContract(breaches: ManualPaginationBreach[]): void {
1232
1231
  if (breaches.length === 0) return
1233
1232
  const message = manualPaginationMessage(breaches)
1234
- if (isDev()) throw new Error(message)
1233
+ if (process.env.NODE_ENV !== 'production') throw new Error(message)
1235
1234
  if (reportedBreaches.has(message)) return
1236
1235
  reportedBreaches.add(message)
1237
1236
  console.error(message)