basalt-ui 1.32.1 → 1.34.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 (185) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +65 -0
  3. package/MIGRATING.md +218 -25
  4. package/README.md +3 -0
  5. package/agent/rules/basalt-state.md +2 -2
  6. package/agent/rules/basalt-tokens.md +1 -1
  7. package/agent/skills/basalt-app/SKILL.md +5 -3
  8. package/configs/oxlint-basalt.json +33 -0
  9. package/configs/oxlint-plugin.js +89 -67
  10. package/configs/oxlint-plugin.test.ts +224 -19
  11. package/configs/oxlint.json +2 -30
  12. package/dist/agent/coalesce.d.ts.map +1 -1
  13. package/dist/agent/coalesce.js +1 -2
  14. package/dist/agent/merge.d.ts.map +1 -1
  15. package/dist/agent/merge.js +1 -2
  16. package/dist/agent-chat/relative-time.d.ts +9 -0
  17. package/dist/agent-chat/relative-time.d.ts.map +1 -1
  18. package/dist/agent-chat/relative-time.js +1 -2
  19. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  20. package/dist/agent-chat/thread-message.js +1 -2
  21. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  22. package/dist/charts/kinds/BandStrip.js +3 -3
  23. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  24. package/dist/charts/kinds/Bars.js +1 -2
  25. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  26. package/dist/charts/kinds/StackedArea.js +1 -2
  27. package/dist/charts/primitives/legend-disclosure.d.ts.map +1 -1
  28. package/dist/cli/audit-allows.d.ts +1 -1
  29. package/dist/cli/audit-allows.d.ts.map +1 -1
  30. package/dist/cli/audit-allows.js +4 -4
  31. package/dist/cli/check-theme.d.ts.map +1 -1
  32. package/dist/cli/check-theme.js +38 -38
  33. package/dist/cli/config.d.ts +197 -0
  34. package/dist/cli/config.d.ts.map +1 -0
  35. package/dist/cli/config.js +20 -0
  36. package/dist/cli/doctor.d.ts.map +1 -1
  37. package/dist/cli/doctor.js +4 -6
  38. package/dist/cli/guard-hook.d.ts.map +1 -1
  39. package/dist/cli/guard-hook.js +7 -41
  40. package/dist/cli/index.d.ts +31 -257
  41. package/dist/cli/index.d.ts.map +1 -1
  42. package/dist/cli/index.js +64 -37
  43. package/dist/cli/sync.d.ts.map +1 -1
  44. package/dist/cli/sync.js +7 -16
  45. package/dist/commands/define-commands.d.ts.map +1 -1
  46. package/dist/commands/define-commands.js +2 -2
  47. package/dist/commands/define-overlays.d.ts.map +1 -1
  48. package/dist/commands/define-overlays.js +1 -2
  49. package/dist/common/index.d.ts +0 -1
  50. package/dist/common/index.d.ts.map +1 -1
  51. package/dist/common/index.js +0 -2
  52. package/dist/common/validate.d.ts +2 -2
  53. package/dist/common/validate.d.ts.map +1 -1
  54. package/dist/common/validate.js +2 -3
  55. package/dist/common/vx-families.d.ts +7 -0
  56. package/dist/common/vx-families.d.ts.map +1 -0
  57. package/dist/common/vx-families.js +32 -0
  58. package/dist/common/vx-family.d.ts +11 -0
  59. package/dist/common/vx-family.d.ts.map +1 -0
  60. package/dist/common/vx-family.js +12 -0
  61. package/dist/content/article-card.d.ts.map +1 -1
  62. package/dist/content/article-card.js +4 -1
  63. package/dist/controls/enum-filter.d.ts.map +1 -1
  64. package/dist/controls/enum-filter.js +3 -13
  65. package/dist/controls/filter-pill.d.ts +8 -0
  66. package/dist/controls/filter-pill.d.ts.map +1 -1
  67. package/dist/controls/filter-pill.js +15 -0
  68. package/dist/controls/multi-select-filter.d.ts.map +1 -1
  69. package/dist/controls/multi-select-filter.js +3 -13
  70. package/dist/controls/number-filter.d.ts +3 -0
  71. package/dist/controls/number-filter.d.ts.map +1 -1
  72. package/dist/controls/number-filter.js +12 -4
  73. package/dist/controls/range-filter.d.ts.map +1 -1
  74. package/dist/controls/range-filter.js +1 -2
  75. package/dist/data/data-table.d.ts.map +1 -1
  76. package/dist/data/data-table.js +2 -3
  77. package/dist/guard/index.d.ts +17 -3
  78. package/dist/guard/index.d.ts.map +1 -1
  79. package/dist/guard/index.js +62 -23
  80. package/dist/guard/types.d.ts +3 -3
  81. package/dist/guard/types.d.ts.map +1 -1
  82. package/dist/guard/vx-names.d.ts +3 -0
  83. package/dist/guard/vx-names.d.ts.map +1 -0
  84. package/dist/guard/vx-names.js +257 -0
  85. package/dist/index.d.ts +1 -1
  86. package/dist/index.d.ts.map +1 -1
  87. package/dist/index.js +0 -2
  88. package/dist/notifications/mount-guard.d.ts.map +1 -1
  89. package/dist/notifications/mount-guard.js +1 -2
  90. package/dist/provider/index.d.ts.map +1 -1
  91. package/dist/provider/index.js +2 -3
  92. package/dist/provider/lab-theme.d.ts +5 -4
  93. package/dist/provider/lab-theme.d.ts.map +1 -1
  94. package/dist/provider/lab-theme.js +1 -3
  95. package/dist/router-tanstack/search-store.d.ts +18 -0
  96. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  97. package/dist/router-tanstack/search-store.js +33 -1
  98. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  99. package/dist/shell/app-mobile-nav.js +2 -1
  100. package/dist/shell/app-mobile-nav.module.css +12 -0
  101. package/dist/shell/app-sidebar.d.ts.map +1 -1
  102. package/dist/shell/app-sidebar.js +32 -36
  103. package/dist/shell/app-sidebar.module.css +6 -0
  104. package/dist/shell/index.d.ts +0 -1
  105. package/dist/shell/index.d.ts.map +1 -1
  106. package/dist/shell/index.js +0 -2
  107. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  108. package/dist/shell/mobile-nav-model.js +3 -4
  109. package/dist/shell/nav-glyph.d.ts +14 -0
  110. package/dist/shell/nav-glyph.d.ts.map +1 -0
  111. package/dist/shell/nav-glyph.js +21 -0
  112. package/dist/shell/nav-types.d.ts +1 -0
  113. package/dist/shell/nav-types.d.ts.map +1 -1
  114. package/dist/shell/page-bar.d.ts.map +1 -1
  115. package/dist/shell/page-bar.js +1 -2
  116. package/dist/shell/sidebar-search.d.ts.map +1 -1
  117. package/dist/shell/sidebar-search.js +1 -3
  118. package/dist/surfaces.d.ts +3 -3
  119. package/dist/surfaces.d.ts.map +1 -1
  120. package/dist/surfaces.js +4 -2
  121. package/dist/tokens/index.d.ts +1 -1
  122. package/dist/tokens/index.d.ts.map +1 -1
  123. package/dist/tokens/index.js +24 -2
  124. package/dist/tokens/size-classes.d.ts +25 -0
  125. package/dist/tokens/size-classes.d.ts.map +1 -1
  126. package/dist/tokens/size-classes.js +14 -0
  127. package/llms.txt +4 -4
  128. package/package.json +1 -1
  129. package/src/agent/coalesce.ts +1 -2
  130. package/src/agent/merge.ts +1 -2
  131. package/src/agent-chat/relative-time.ts +1 -2
  132. package/src/agent-chat/thread-message.tsx +2 -3
  133. package/src/charts/kinds/BandStrip.tsx +7 -3
  134. package/src/charts/kinds/Bars.tsx +1 -2
  135. package/src/charts/kinds/StackedArea.tsx +4 -2
  136. package/src/charts/primitives/legend-disclosure.tsx +1 -0
  137. package/src/cli/audit-allows.ts +10 -9
  138. package/src/cli/check-theme.ts +61 -39
  139. package/src/cli/config.ts +209 -0
  140. package/src/cli/doctor.ts +5 -7
  141. package/src/cli/guard-hook.ts +9 -42
  142. package/src/cli/index.ts +101 -294
  143. package/src/cli/sync.ts +7 -16
  144. package/src/commands/define-commands.ts +2 -2
  145. package/src/commands/define-overlays.ts +1 -2
  146. package/src/common/index.ts +0 -1
  147. package/src/common/validate.ts +4 -5
  148. package/src/common/vx-families.ts +36 -0
  149. package/src/common/vx-family.ts +22 -0
  150. package/src/content/article-card.tsx +1 -0
  151. package/src/controls/enum-filter.tsx +3 -13
  152. package/src/controls/filter-pill.tsx +20 -0
  153. package/src/controls/multi-select-filter.tsx +3 -13
  154. package/src/controls/number-filter.tsx +24 -3
  155. package/src/controls/range-filter.tsx +1 -2
  156. package/src/data/data-table.tsx +2 -3
  157. package/src/guard/index.ts +134 -47
  158. package/src/guard/types.ts +3 -2
  159. package/src/guard/vx-names.ts +257 -0
  160. package/src/index.ts +0 -2
  161. package/src/notifications/mount-guard.ts +1 -2
  162. package/src/provider/index.tsx +2 -3
  163. package/src/provider/lab-theme.ts +28 -21
  164. package/src/router-tanstack/search-store.ts +73 -1
  165. package/src/shell/app-mobile-nav.module.css +12 -0
  166. package/src/shell/app-mobile-nav.tsx +8 -1
  167. package/src/shell/app-sidebar.module.css +6 -0
  168. package/src/shell/app-sidebar.tsx +52 -50
  169. package/src/shell/index.tsx +0 -1
  170. package/src/shell/mobile-nav-model.ts +3 -5
  171. package/src/shell/nav-glyph.tsx +38 -0
  172. package/src/shell/nav-types.ts +1 -0
  173. package/src/shell/page-bar.tsx +1 -4
  174. package/src/shell/sidebar-search.tsx +1 -5
  175. package/src/surfaces.ts +4 -2
  176. package/src/tokens/index.ts +34 -2
  177. package/src/tokens/size-classes.ts +27 -0
  178. package/dist/common/is-dev.d.ts +0 -3
  179. package/dist/common/is-dev.d.ts.map +0 -1
  180. package/dist/common/is-dev.js +0 -6
  181. package/dist/shell/use-breakpoint.d.ts +0 -10
  182. package/dist/shell/use-breakpoint.d.ts.map +0 -1
  183. package/dist/shell/use-breakpoint.js +0 -11
  184. package/src/common/is-dev.ts +0 -4
  185. package/src/shell/use-breakpoint.ts +0 -37
@@ -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)
@@ -0,0 +1,36 @@
1
+ // GENERATED by scripts/gen-vx-names.ts — do not edit. `vx-names.test.ts` fails on drift.
2
+
3
+ /**
4
+ * The families every emitted `--vx-*` name lives in (`basaltVxFamilies`) — basalt-owned:
5
+ * `unknown-vx-token` judges every name inside one, and `groupTokens`/`seriesTokens` warn on a
6
+ * consumer ref there.
7
+ */
8
+ export const BASALT_VX_FAMILIES: readonly string[] = [
9
+ 'accent',
10
+ 'area',
11
+ 'axis',
12
+ 'bad',
13
+ 'crosshair',
14
+ 'divider',
15
+ 'dot',
16
+ 'faint',
17
+ 'fill',
18
+ 'good',
19
+ 'grid',
20
+ 'hit',
21
+ 'ink',
22
+ 'keyboard',
23
+ 'legend',
24
+ 'line',
25
+ 'muted',
26
+ 'neutral',
27
+ 'prose',
28
+ 'radius',
29
+ 'shadow',
30
+ 'space',
31
+ 'status',
32
+ 'surface',
33
+ 'text',
34
+ 'tooltip',
35
+ 'warn',
36
+ ]
@@ -0,0 +1,22 @@
1
+ /**
2
+ * A `--vx-*` name's FAMILY — its leading lowercase run: `--vx-surface-bg` → `surface`, `--vx-ink2`
3
+ * → `ink`, `--vx-tooltipBg` → `tooltip`, `--vx-fillHover-cyan` → `fill`. Dependency-free, because
4
+ * three sides must agree on it byte for byte: the generator that writes `BASALT_VX_FAMILIES`, the
5
+ * guard that judges every name inside one of those families, and the token factories that refuse a
6
+ * consumer name inside one — the refusal is what makes the guard's judgement honest.
7
+ */
8
+ export function vxFamilyOf(name: string): string {
9
+ return /^--vx-([a-z]*)/.exec(name)?.[1] ?? ''
10
+ }
11
+
12
+ /**
13
+ * Families a consumer extends without any factory: the theme emits `--vx-on-<color>` for EVERY
14
+ * `theme.colors` entry, a consumer's own colors included, so `on` is never basalt's alone.
15
+ */
16
+ const CONSUMER_EXTENSIBLE_FAMILIES: ReadonlySet<string> = new Set(['on'])
17
+
18
+ /** The basalt-owned families among `names` — run over every name basalt emits (`collectVxNames`). */
19
+ export function basaltVxFamilies(names: readonly string[]): string[] {
20
+ const families = new Set(names.map(vxFamilyOf))
21
+ return [...families].filter((f) => f !== '' && !CONSUMER_EXTENSIBLE_FAMILIES.has(f)).toSorted()
22
+ }
@@ -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)
@@ -1,10 +1,13 @@
1
1
  /**
2
2
  * ./guard — headless policy core. Mantine-free, dependency-free.
3
3
  *
4
- * GUARD_RULES: the closed registry of all 27 violation kinds.
4
+ * GUARD_RULES: the closed registry of all 28 violation kinds.
5
5
  * checkSource: pure (text, relPath, cfg) → Finding[]. No FS, no walk, no console.
6
6
  */
7
7
  import type { Finding, GuardConfig, GuardKind, GuardSeverity } from './types'
8
+ import { vxFamilyOf } from '../common/vx-family'
9
+ import { BASALT_VX_FAMILIES } from '../common/vx-families'
10
+ import { BASALT_VX_NAMES } from './vx-names'
8
11
 
9
12
  export type { Finding, GuardConfig, GuardKind, GuardSeverity }
10
13
 
@@ -286,6 +289,57 @@ const INLINE_FONT_SIZE =
286
289
  const CARD_SURFACE_TAG = /<(?:Card|Paper)(?![\w.])(?:=>|[^>])*?>/g
287
290
  const WITH_BORDER_PROP = /\bwithBorder\b(?!\s*=\s*\{\s*false\s*\})/
288
291
 
292
+ /**
293
+ * A `var(--vx-*)` REFERENCE with a literal name. The name must end on an alphanumeric and must not
294
+ * run into a `${…}` interpolation, so `var(--vx-fill-${hue})` — a name computed at runtime — never
295
+ * matches a truncated `--vx-fill`.
296
+ */
297
+ const VX_VAR_REF = /var\(\s*(--vx-[A-Za-z0-9-]*[A-Za-z0-9])(?![A-Za-z0-9-]|\$\{)/g
298
+
299
+ /**
300
+ * A `--vx-*` name the file DECLARES — a consumer's own custom property, never unknown. Only a real
301
+ * declaration position counts, because the set is file-wide and one false hit silences every
302
+ * finding for that name in the file:
303
+ *
304
+ * - a CSS declaration (`--vx-x: …`, in a stylesheet or in CSS text inside a template literal),
305
+ * directly after a line start, `{` or `;`;
306
+ * - a quoted style-object key (`{ '--vx-x': … }`), directly after `{` or `,` — but not a TS
307
+ * member key (`'--vx-x'?: …`, or one typed `string`/`number`);
308
+ * - `setProperty('--vx-x', …)`.
309
+ *
310
+ * A quoted name used as an EXPRESSION — a ternary arm, a `case` label, an argument — is a reference,
311
+ * not a declaration, and never matches.
312
+ */
313
+ const VX_LOCAL_DECLARATION =
314
+ /(?:^|[{;])\s*(--vx-[A-Za-z0-9-]*[A-Za-z0-9])\s*:|[{,]\s*(['"])(--vx-[A-Za-z0-9-]*[A-Za-z0-9])\2\s*:(?!\s*(?:string|number)\b)|\bsetProperty\(\s*['"](--vx-[A-Za-z0-9-]*[A-Za-z0-9])['"]/gm
315
+
316
+ /**
317
+ * `--vx-*` names basalt once emitted and no longer does, with the replacement MIGRATING names
318
+ * (`null`: deleted with no replacement). Hand-kept on purpose: it is history, it only grows, and
319
+ * `vx-names.test.ts` pins that none of these is live and every replacement is.
320
+ */
321
+ const REMOVED_VX_NAMES: Readonly<Record<string, string | null>> = {
322
+ '--vx-space-touch-target': '--vx-hit',
323
+ '--vx-space-touch-control-height': '--vx-hit',
324
+ '--vx-space-app-header-mobile-actions-height': '--vx-space-sticky-header-clearance',
325
+ '--vx-space-sticky-header-clearance-mobile': '--vx-space-sticky-header-clearance',
326
+ '--vx-space-sidebar-brand-inset-top': null,
327
+ }
328
+
329
+ const KNOWN_VX_NAMES: ReadonlySet<string> = new Set(BASALT_VX_NAMES)
330
+
331
+ /**
332
+ * The families basalt owns (`vxFamilyOf`: `surface`, `space`, `ink`, `keyboard`, …), which the
333
+ * generator reads off every name basalt emits. Every name inside one is judged against the exact
334
+ * emitted set, so `--vx-surface-2` and a bare root like `--vx-space` are both findings.
335
+ *
336
+ * That judgement is honest only because no consumer ref may point into these families:
337
+ * `groupTokens`/`seriesTokens` warn on a name inside one (and throw once this kind promotes). A name in any OTHER family (argo's
338
+ * `--vx-activity-*`, rb's `--vx-confidence-*`) may be a consumer group declared in a file this scan
339
+ * cannot see, and is never judged.
340
+ */
341
+ const BASALT_VX_FAMILY_SET: ReadonlySet<string> = new Set(BASALT_VX_FAMILIES)
342
+
289
343
  // Raw Mantine ramp step used for surface color — gray/dark + a step digit.
290
344
  const OFF_SYSTEM_SURFACE_VAR = /var\(--mantine-color-(gray|dark)-\d/g
291
345
 
@@ -861,38 +915,14 @@ export type GraceEntry = { since: string; promote: string; why: string }
861
915
  * }
862
916
  */
863
917
  export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
864
- 'raw-selection-control': {
865
- since: '1.26.0',
866
- promote: '1.33.0',
918
+ 'unknown-vx-token': {
919
+ since: '1.34.0',
920
+ promote: '1.35.0',
867
921
  why:
868
- "Extended 1.32.0 -> 1.33.0 at the 1.32.0 release, the last extension: basalt's own tree is at 0 (consumer-loop r1 dogfood) and argo is at 2, but RE-MEASURED 2026-09-30 the fleet carries ~33 (image-gen 11, rb 7, image-share 6, weatherorb 5, email-gateway 4, argo 2). The 1.32.0 fleet upgrade clears them while they warn, together with raw-breakpoint, so 1.33.0 promotes all four ids against a measured-clean fleet. " +
869
- 'Extended 1.31.0 -> 1.32.0 at the 1.31.0 release: the basalt tree still carries incumbents (data-table, notifications center, theme-lab) and the argo sweep has not run. ' +
870
- 'new in the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C1). The text lane cannot ' +
871
- 'see ancestry, so "no home" is approximated by a 12-line host-tag window — the loosest ' +
872
- 'reading in the guard, and the reason this one lands warn rather than error even though its ' +
873
- 'law is settled. ONE LAW, TWO LANES, ONE PROMOTION: it moves with its AST twin, ' +
874
- '`basalt/control-outside-home`, whose PLUGIN_RULE_GRACE entry carries the full argument and ' +
875
- 'the measurement, and it must never be promoted or extended alone — a consumer waiving one ' +
876
- 'spelling and failing the other is the exact confusion `WAIVER_ID_ALIASES` exists to close. ' +
877
- 'RE-MEASURED 2026-09-09 across the whole fleet on 1.29.2: 29 AST-lane incumbents in five ' +
878
- "repos, plus three of basalt's own (data-table, notifications/center, theme-lab). Extended " +
879
- 'to 1.31.0 because 1.30.0 is the FIRST release in which either lane names a home a ' +
880
- 'provider-only consumer can reach — every home both messages listed was a shell slot, and ' +
881
- 'the shell-less `PageBar` sentence ships in this minor. Promoting in the same minor that ' +
882
- 'makes a rule satisfiable gives nobody a window to act. 1.31.0 is when the 29 are ' +
883
- 're-measured against that number.',
884
- },
885
- 'raw-media-query': {
886
- since: '1.31.0',
887
- promote: '1.33.0',
888
- why:
889
- "The CSS-text twin of the oxlint plugin's `basalt/raw-breakpoint` — ONE LAW, TWO LANES, ONE " +
890
- 'PROMOTION, so it moves with that rule and never alone (`configs/oxlint-plugin.test.ts` › twin lanes share one promotion pins the pair). New in ' +
891
- 'the wave-11 responsive/touch guards; reaching CSS is a NEW file type for this check, same ' +
892
- "reasoning as `css-raw-surface`, with incumbents in basalt's own tree at ship time, so it " +
893
- 'shipped warn. Extended 1.32.0 -> 1.33.0 with its twin: 1.31.0 named ' +
894
- 'no reachable container primitive, and 1.32.0 is the first minor that documents one ' +
895
- '(`CONTAINER_CLASSES` + the declared `@container` names, MIGRATING § `basalt/raw-breakpoint`).',
922
+ 'New kind at 1.33.0 (consumer-loop r2 B3): obsidian lost its 44px touch floor to a removed ' +
923
+ '`var(--vx-space-touch-target)` that nothing reported. WIDENED at 1.34.0 (image-gen r4: ' +
924
+ '`var(--vx-surface-2)` rendered transparent, unreported) to every unemitted name inside a ' +
925
+ 'basalt-owned family, so the grace restarts: warn through 1.34.x, error at 1.35.0.',
896
926
  },
897
927
  }
898
928
 
@@ -1132,6 +1162,13 @@ export const WAIVER_ID_ALIASES: Readonly<Record<string, readonly string[]>> = {
1132
1162
  'raw-selection-control': ['control-outside-home'],
1133
1163
  }
1134
1164
 
1165
+ /**
1166
+ * The ids that are a guard kind AND an oxlint plugin rule — one law, two lanes, one annotation.
1167
+ * {@link PLUGIN_RULE_IDS} leaves them out by design, so `--audit-allows` would otherwise never ask
1168
+ * oxlint about them. `waiver-audit.test.ts` pins this against the plugin's own rule table.
1169
+ */
1170
+ const DUAL_LANE_IDS: ReadonlySet<string> = new Set(['in-body-page-title'])
1171
+
1135
1172
  /** The shortest string accepted as a written reason — enough to exclude a stray separator. */
1136
1173
  const MIN_ALLOW_REASON_LENGTH = 4
1137
1174
 
@@ -1973,7 +2010,7 @@ type GuardRule = {
1973
2010
  }
1974
2011
 
1975
2012
  /**
1976
- * The closed registry of all 27 guard kinds. The triad test asserts
2013
+ * The closed registry of all 28 guard kinds. The triad test asserts
1977
2014
  * `surface.guardKinds ⊆ keyof GUARD_RULES` at runtime.
1978
2015
  *
1979
2016
  * raw-surface, raw-html-layout, and sub-16-input-font are handled inline in checkSource
@@ -2065,7 +2102,7 @@ export const GUARD_RULES = {
2065
2102
  // JSX-tag-shaped (`<Card withBorder>`) — never appears in CSS text.
2066
2103
  appliesTo: (relPath) => !relPath.endsWith('.css'),
2067
2104
  message:
2068
- 'withBorder on a Card/Paper double-draws the edge — card depth is --vx-shadow-card, which already bakes a 1px ring into the shadow. Drop the prop (docs/DESIGN-SPEC.md doctrine inversion #1).',
2105
+ 'withBorder on a Card/Paper double-draws the edge — card depth is --vx-shadow-card, which already bakes a 1px ring into the shadow. Drop the prop (docs/DESIGN-SPEC.md doctrine inversion #1) — and fix the inset in the same edit: an explicit p/padding or radius on that Card/Paper is the other half of the idiom, which only the oxlint lane sees (basalt/card-inset); leave both to the theme or use py="xs" px="sm".',
2069
2106
  },
2070
2107
  'off-system-surface-var': {
2071
2108
  kind: 'off-system-surface-var',
@@ -2215,6 +2252,17 @@ export const GUARD_RULES = {
2215
2252
  appliesTo: (relPath) => relPath.endsWith('.css'),
2216
2253
  message: `Raw viewport/container breakpoint in CSS — a width @media belongs to shell/** against SIZE_CLASSES (useSizeClass() is the JS seam); an @container name must be one of ${[...DECLARED_CONTAINER_NAMES].join(' / ')}, and a basalt-card or unnamed @container query must sit at a boundary in ${[...CONTAINER_CLASS_LITERALS_PX].map((px) => `${px}px`).join(' / ')} (the other declared names have no numeric table yet, so their literals are not judged); and a pointer query other than (pointer: coarse) or (pointer: fine) has no framework meaning. Same law the oxlint plugin enforces as basalt/raw-breakpoint over JSX; this is the CSS half.`,
2217
2254
  },
2255
+ 'unknown-vx-token': {
2256
+ kind: 'unknown-vx-token',
2257
+ pattern: VX_VAR_REF, // handled inline (basalt-owned families); entry keeps the registry complete
2258
+ message: `var() names a --vx-* token basalt does not emit, so it resolves to its fallback (or to nothing) with no error anywhere. Judged: every name inside a basalt-owned family (${[...BASALT_VX_FAMILY_SET].join(', ')}) — pick the token from basalt-ui/tokens.css; your own series belong in a family of their own (groupTokens warns on one inside basalt's). Removed names and their replacements: ${Object.entries(
2259
+ REMOVED_VX_NAMES,
2260
+ )
2261
+ .map(([from, to]) => `${from} → ${to ?? '(deleted, no replacement)'}`)
2262
+ .join(
2263
+ '; ',
2264
+ )}. A name in any other family is never judged, because groupTokens may declare it in a file this scan cannot see.`,
2265
+ },
2218
2266
  } as const satisfies Record<GuardKind, GuardRule>
2219
2267
 
2220
2268
  /**
@@ -2389,6 +2437,22 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2389
2437
  [...codeText.matchAll(STYLE_BOUND_IDENTIFIER)].map((m) => m[1] ?? ''),
2390
2438
  )
2391
2439
 
2440
+ /** `--vx-*` names this file declares — its own custom properties, which `unknown-vx-token` skips. */
2441
+ const locallyDeclaredVx = new Set(
2442
+ [...codeText.matchAll(VX_LOCAL_DECLARATION)].map((m) => m[1] ?? m[3] ?? m[4] ?? ''),
2443
+ )
2444
+
2445
+ // unknown-vx-token: any unemitted name inside a basalt-owned family — see BASALT_VX_FAMILY_SET.
2446
+ // Scanned over the whole comment-stripped text rather than per line, so a formatter-wrapped
2447
+ // `var(\n --vx-x\n)` is seen; the finding lands on the line the NAME is on.
2448
+ for (const m of codeText.matchAll(VX_VAR_REF)) {
2449
+ const name = m[1] ?? ''
2450
+ if (locallyDeclaredVx.has(name)) continue
2451
+ if (KNOWN_VX_NAMES.has(name) || !BASALT_VX_FAMILY_SET.has(vxFamilyOf(name))) continue
2452
+ const at = (m.index ?? 0) + m[0].length - name.length
2453
+ push('unknown-vx-token', lineStarts.findLastIndex((start) => start <= at) + 1, name)
2454
+ }
2455
+
2392
2456
  for (let i = 0; i < lines.length; i++) {
2393
2457
  const line = codeLines[i] ?? ''
2394
2458
 
@@ -2793,6 +2857,15 @@ export type AllowAnnotationSite = {
2793
2857
  readonly guardKinds: readonly GuardKind[]
2794
2858
  /** The subset of `rules` the oxlint plugin owns — outside `checkSource`'s reach. */
2795
2859
  readonly pluginRules: readonly string[]
2860
+ /**
2861
+ * Every id an `oxlint` run can prove live: `pluginRules`, plus a guard kind that is ALSO a plugin
2862
+ * rule (`in-body-page-title`) or aliases one ({@link WAIVER_ID_ALIASES}). A waiver is live if
2863
+ * EITHER lane still needs it — judging only `pluginRules` called rb's live `in-body-page-title`
2864
+ * waivers dead, and deleting them made oxlint exit 1. A bare LINE-scope annotation covers every
2865
+ * plugin rule (the plugin's `allowCovers`), so it lists all of them; a bare file-scope one waives
2866
+ * nothing in either lane and lists none.
2867
+ */
2868
+ readonly oxlintRules: readonly string[]
2796
2869
  /** Words that occupied the id slot but name no rule. Their presence makes the parse fail closed. */
2797
2870
  readonly unknownRules: readonly string[]
2798
2871
  /** A written reason follows the ids. */
@@ -2831,19 +2904,33 @@ export function findAllowAnnotations(
2831
2904
  cfg.allowComment,
2832
2905
  syntax,
2833
2906
  )
2834
- return declared.map(({ line, annotation }) => ({
2835
- line,
2836
- scope: annotation.scope,
2837
- rules: [...annotation.rules],
2838
- guardKinds: annotation.rules.filter((id): id is GuardKind => Object.hasOwn(GUARD_RULES, id)),
2839
- pluginRules: annotation.rules.filter(
2840
- (id) => !Object.hasOwn(GUARD_RULES, id) && PLUGIN_RULE_IDS.has(id),
2841
- ),
2842
- unknownRules: [...annotation.unknownRules],
2843
- hasReason: annotation.hasReason,
2844
- bare: annotation.rules.length === 0 && annotation.unknownRules.length === 0,
2845
- text: lines[line - 1] ?? '',
2846
- }))
2907
+ const everyOxlintRule = [...PLUGIN_RULE_IDS, ...DUAL_LANE_IDS]
2908
+ return declared.map(({ line, annotation }) => {
2909
+ const bare = annotation.rules.length === 0 && annotation.unknownRules.length === 0
2910
+ return {
2911
+ line,
2912
+ scope: annotation.scope,
2913
+ rules: [...annotation.rules],
2914
+ guardKinds: annotation.rules.filter((id): id is GuardKind => Object.hasOwn(GUARD_RULES, id)),
2915
+ pluginRules: annotation.rules.filter(
2916
+ (id) => !Object.hasOwn(GUARD_RULES, id) && PLUGIN_RULE_IDS.has(id),
2917
+ ),
2918
+ oxlintRules:
2919
+ bare && annotation.scope === 'line'
2920
+ ? everyOxlintRule
2921
+ : [
2922
+ ...new Set(
2923
+ annotation.rules
2924
+ .flatMap((id) => [id, ...(WAIVER_ID_ALIASES[id] ?? [])])
2925
+ .filter((id) => PLUGIN_RULE_IDS.has(id) || DUAL_LANE_IDS.has(id)),
2926
+ ),
2927
+ ],
2928
+ unknownRules: [...annotation.unknownRules],
2929
+ hasReason: annotation.hasReason,
2930
+ bare,
2931
+ text: lines[line - 1] ?? '',
2932
+ }
2933
+ })
2847
2934
  }
2848
2935
 
2849
2936
  /** The token a neutralized annotation is rewritten to. Shares no substring with `theme-allow`. */