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
@@ -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)
@@ -608,7 +607,8 @@ export type BasaltDataTableProps<T> = BasaltProps &
608
607
  * list can never disagree with its table; a fixed card order is `initialSorting`, not a second
609
608
  * sort. Everything around the body stays: the header (title · count · toolbar), the bulk bar,
610
609
  * the pager, and `query` / `isLoading` / `emptyState`. `onRowActivate` makes each card
611
- * activatable exactly as it does a row (click, or Enter on the focused card). The selection
610
+ * activatable exactly as it does a row (click, or Enter on the focused card — never Space, the
611
+ * browser's own page scroll on a focused non-button). The selection
612
612
  * checkbox is not projected — a card list with a selection keeps its bulk bar but no ticks.
613
613
  *
614
614
  * An activatable card takes `tabIndex=0` + `data-activatable`, never `role="button"`: a card is
@@ -616,7 +616,9 @@ export type BasaltDataTableProps<T> = BasaltProps &
616
616
  * descendant presentational, so those nested controls would vanish from the accessibility tree.
617
617
  * The affordance is the cursor and the focus ring, as on the row. `maxHeight` caps the card list
618
618
  * in a `ScrollArea`; `minWidth` does not apply — it is a horizontal floor for columns, and a
619
- * card list has none.
619
+ * card list has none. `stickyHeader` has nothing to stick while the cards render. A card order
620
+ * with no user sort is `data`'s own order — sort it there (or `initialSorting`) for a domain
621
+ * order.
620
622
  *
621
623
  * Reach for it when the row carries more than the columns a phone can hold (a status + label +
622
624
  * age header, a clamped title, a prose line). A table that only needs to shed a column or two
@@ -761,6 +763,10 @@ export type BasaltDataTableProps<T> = BasaltProps &
761
763
  * its own card instead of widening the page. Reach for this only to hold a MINIMUM table width
762
764
  * — e.g. so eight columns keep their readable widths and scroll rather than compress.
763
765
  *
766
+ * Setting it also switches the column fold OFF (a declared floor is the caller saying "this
767
+ * table is at least this wide", which folding columns out would contradict), so a `minWidth`
768
+ * table scrolls where the same table without it folds. `maxHeight` alone keeps the fold.
769
+ *
764
770
  * The one table whose wrapper is MEASURED rather than declared is `stickyHeader` with neither
765
771
  * this nor `maxHeight`; see that prop and `useMeasuredContainment` in the implementation for
766
772
  * why, and pair it with `maxHeight` if the table must both scroll and stick.
@@ -1224,7 +1230,7 @@ const reportedBreaches = new Set<string>()
1224
1230
  function enforceManualPaginationContract(breaches: ManualPaginationBreach[]): void {
1225
1231
  if (breaches.length === 0) return
1226
1232
  const message = manualPaginationMessage(breaches)
1227
- if (isDev()) throw new Error(message)
1233
+ if (process.env.NODE_ENV !== 'production') throw new Error(message)
1228
1234
  if (reportedBreaches.has(message)) return
1229
1235
  reportedBreaches.add(message)
1230
1236
  console.error(message)
@@ -138,9 +138,12 @@
138
138
  gap: var(--vx-space-stack-xs);
139
139
  }
140
140
 
141
+ /* Bottom-aligned, not stretched: a trailing button beside a labelled FormRow keeps its own height
142
+ and lines up with the input, not with the label above it. */
141
143
  .groupControls[data-direction='row'] {
142
144
  flex-direction: row;
143
145
  flex-wrap: wrap;
146
+ align-items: flex-end;
144
147
  gap: var(--vx-space-control-gap);
145
148
  }
146
149
 
@@ -213,7 +213,12 @@ export type FormGroupProps = BasaltProps & {
213
213
  error?: ReactNode
214
214
  /** Marks the group required — same two-channel treatment as `FormRow`. */
215
215
  required?: boolean
216
- /** `column` stacks the options; `row` lays them out inline and wraps. @default 'column' */
216
+ /**
217
+ * `column` stacks the options; `row` lays them out inline, wraps, and bottom-aligns them, so a
218
+ * trailing button sits level with the inputs instead of stretching to the tallest child. Give a
219
+ * `FormRow` inside a `row` group (or any flex row) a definite width — it is an inline-size
220
+ * container, so its intrinsic width is zero and it collapses otherwise. @default 'column'
221
+ */
217
222
  direction?: 'row' | 'column'
218
223
  /** The cluster — checkboxes, radios, chips. */
219
224
  children: ReactNode
@@ -1,10 +1,11 @@
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 { BASALT_VX_NAMES } from './vx-names'
8
9
 
9
10
  export type { Finding, GuardConfig, GuardKind, GuardSeverity }
10
11
 
@@ -177,27 +178,23 @@ const CSS_NON_COARSE_POINTER_QUERY =
177
178
  * The `@container` names the container-class axis declares — a consumer container carrying any
178
179
  * other name has invented its own container-query breakpoint instead of using the framework's.
179
180
  * `basalt-card` is the one shipped today (`src/dashboard/section.module.css`);
180
- * `basalt-form`/`basalt-main`/`basalt-grid` are the declared remaining three, reserved here so a
181
- * consumer adopting one ahead of the framework does not immediately trip this guard.
181
+ * `basalt-form`/`basalt-grid` are the declared remaining two, reserved here so a consumer adopting
182
+ * one ahead of the framework does not immediately trip this guard. `basalt-main` was a third until
183
+ * 1.32.1 dropped it: nothing ever declared it, so the guard accepted a query that never matched.
182
184
  *
183
185
  * `basalt-grid` is also what `StatGroup`/`WidgetGrid` declare (`src/dashboard/*.module.css`) —
184
186
  * they shipped as `basalt-stat-group`/`basalt-widget-grid` until 1.32.0 moved them onto this table.
185
187
  */
186
- const DECLARED_CONTAINER_NAMES = new Set([
187
- 'basalt-card',
188
- 'basalt-form',
189
- 'basalt-main',
190
- 'basalt-grid',
191
- ])
188
+ const DECLARED_CONTAINER_NAMES = new Set(['basalt-card', 'basalt-form', 'basalt-grid'])
192
189
 
193
190
  /**
194
191
  * `CONTAINER_CLASSES` (`src/tokens/size-classes.ts`) boundaries, duplicated per the `CTL_THEME_TAGS`
195
192
  * convention in the oxlint plugin — this module is dependency-free by contract (file header) and
196
193
  * must not import `../tokens`. Pinned against the real table by `check-source.test.ts`. Judged only
197
- * against `basalt-card`: the other three declared names have no shipped numeric table yet. `0` is
198
- * the container's own floor, not a class boundary, but `(width >= 0px)` is a legitimate idiom for
199
- * "scope this rule to the container, whatever its width" (`widget-header.module.css`'s `cqi` clamp)
200
- * — allowed for that reason, not because it names a real `CONTAINER_CLASSES` step.
194
+ * against `basalt-card` and unnamed queries: the other declared names have no shipped numeric table
195
+ * yet. `0` is the container's own floor, not a class boundary, but `(width >= 0px)` is a legitimate
196
+ * idiom for "scope this rule to the container, whatever its width" (`widget-header.module.css`'s
197
+ * `cqi` clamp) — allowed for that reason, not because it names a real `CONTAINER_CLASSES` step.
201
198
  */
202
199
  const CONTAINER_CLASS_LITERALS_PX = new Set([0, 240, 239.9, 480, 479.9, 800, 799.9])
203
200
 
@@ -290,6 +287,62 @@ const INLINE_FONT_SIZE =
290
287
  const CARD_SURFACE_TAG = /<(?:Card|Paper)(?![\w.])(?:=>|[^>])*?>/g
291
288
  const WITH_BORDER_PROP = /\bwithBorder\b(?!\s*=\s*\{\s*false\s*\})/
292
289
 
290
+ /**
291
+ * A `var(--vx-*)` REFERENCE with a literal name. The name must end on an alphanumeric and must not
292
+ * run into a `${…}` interpolation, so `var(--vx-fill-${hue})` — a name computed at runtime — never
293
+ * matches a truncated `--vx-fill`.
294
+ */
295
+ const VX_VAR_REF = /var\(\s*(--vx-[A-Za-z0-9-]*[A-Za-z0-9])(?![A-Za-z0-9-]|\$\{)/g
296
+
297
+ /**
298
+ * A `--vx-*` name the file DECLARES — a consumer's own custom property, never unknown. Only a real
299
+ * declaration position counts, because the set is file-wide and one false hit silences every
300
+ * finding for that name in the file:
301
+ *
302
+ * - a CSS declaration (`--vx-x: …`, in a stylesheet or in CSS text inside a template literal),
303
+ * directly after a line start, `{` or `;`;
304
+ * - a quoted style-object key (`{ '--vx-x': … }`), directly after `{` or `,` — but not a TS
305
+ * member key (`'--vx-x'?: …`, or one typed `string`/`number`);
306
+ * - `setProperty('--vx-x', …)`.
307
+ *
308
+ * A quoted name used as an EXPRESSION — a ternary arm, a `case` label, an argument — is a reference,
309
+ * not a declaration, and never matches.
310
+ */
311
+ const VX_LOCAL_DECLARATION =
312
+ /(?:^|[{;])\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
313
+
314
+ /**
315
+ * `--vx-*` names basalt once emitted and no longer does, with the replacement MIGRATING names
316
+ * (`null`: deleted with no replacement). Hand-kept on purpose: it is history, it only grows, and
317
+ * `vx-names.test.ts` pins that none of these is live and every replacement is.
318
+ */
319
+ const REMOVED_VX_NAMES: Readonly<Record<string, string | null>> = {
320
+ '--vx-space-touch-target': '--vx-hit',
321
+ '--vx-space-touch-control-height': '--vx-hit',
322
+ '--vx-space-app-header-mobile-actions-height': '--vx-space-sticky-header-clearance',
323
+ '--vx-space-sticky-header-clearance-mobile': '--vx-space-sticky-header-clearance',
324
+ '--vx-space-sidebar-brand-inset-top': null,
325
+ }
326
+
327
+ const KNOWN_VX_NAMES: ReadonlySet<string> = new Set(BASALT_VX_NAMES)
328
+
329
+ /**
330
+ * The bare family roots basalt never emits — `--vx-space`, `--vx-surface`, `--vx-status`, … — derived
331
+ * from the `--vx-<family>-` prefixes of the emitted names, minus any root that is itself a token
332
+ * (`--vx-hit`, `--vx-accent`). Nobody emits one: basalt only ever writes `<root>-<key>`, and so does
333
+ * `groupTokens`. It is the one shape of unknown name basalt can judge WITHOUT seeing the consumer's
334
+ * palette.
335
+ *
336
+ * An unknown name INSIDE a family (`--vx-surface-2`) is deliberately not judged. `groupTokens(name,
337
+ * map)` takes any `name`, so `--vx-surface-custom` may be a consumer group declared in a file this
338
+ * scan cannot see, and a per-file guard has no honest way to tell that from a typo.
339
+ */
340
+ const BASALT_VX_FAMILY_ROOTS: ReadonlySet<string> = new Set(
341
+ BASALT_VX_NAMES.map((n) => /^(--vx-[A-Za-z0-9]+)-/.exec(n)?.[1]).filter(
342
+ (root): root is string => root !== undefined && !KNOWN_VX_NAMES.has(root),
343
+ ),
344
+ )
345
+
293
346
  // Raw Mantine ramp step used for surface color — gray/dark + a step digit.
294
347
  const OFF_SYSTEM_SURFACE_VAR = /var\(--mantine-color-(gray|dark)-\d/g
295
348
 
@@ -691,9 +744,12 @@ const PROSE_CONTEXT_TAG = /<(?:Prose|ArticleLayout|Modal|Drawer)(?![\w])/
691
744
  * read differently in the two lanes. The plugin gates them on the tag's basalt IMPORT; a 12-line
692
745
  * regex window has no import graph, so this lane matches by name and accepts the missed warn on a
693
746
  * consumer's own `PanelRow` — the same direction every approximation in this scan already leans.
747
+ *
748
+ * The name set is the plugin's `CONTROL_HOST_TAGS` ∪ `BASALT_HOST_TAGS`; `oxlint-plugin.test.ts` ›
749
+ * lane parity asserts the two agree (1.32.1 — `FormRow`/`FormGroup` had drifted out of this lane).
694
750
  */
695
751
  const CONTROL_HOST_TAG =
696
- /<(?:SettingsRow|Modal|Drawer|Popover\.Dropdown|Menu\.Dropdown|Composer|FilterSet|PageAside|PanelRow)(?![\w])/
752
+ /<(?:SettingsRow|FormRow|FormGroup|Modal|Drawer|Popover\.Dropdown|Menu\.Dropdown|Composer|FilterSet|PageAside|PanelRow)(?![\w])/
697
753
 
698
754
  /**
699
755
  * How far ABOVE a control the host-tag window reaches, in lines.
@@ -862,38 +918,14 @@ export type GraceEntry = { since: string; promote: string; why: string }
862
918
  * }
863
919
  */
864
920
  export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
865
- 'raw-selection-control': {
866
- since: '1.26.0',
867
- promote: '1.33.0',
868
- why:
869
- "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. " +
870
- '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. ' +
871
- 'new in the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C1). The text lane cannot ' +
872
- 'see ancestry, so "no home" is approximated by a 12-line host-tag window — the loosest ' +
873
- 'reading in the guard, and the reason this one lands warn rather than error even though its ' +
874
- 'law is settled. ONE LAW, TWO LANES, ONE PROMOTION: it moves with its AST twin, ' +
875
- '`basalt/control-outside-home`, whose PLUGIN_RULE_GRACE entry carries the full argument and ' +
876
- 'the measurement, and it must never be promoted or extended alone — a consumer waiving one ' +
877
- 'spelling and failing the other is the exact confusion `WAIVER_ID_ALIASES` exists to close. ' +
878
- 'RE-MEASURED 2026-09-09 across the whole fleet on 1.29.2: 29 AST-lane incumbents in five ' +
879
- "repos, plus three of basalt's own (data-table, notifications/center, theme-lab). Extended " +
880
- 'to 1.31.0 because 1.30.0 is the FIRST release in which either lane names a home a ' +
881
- 'provider-only consumer can reach — every home both messages listed was a shell slot, and ' +
882
- 'the shell-less `PageBar` sentence ships in this minor. Promoting in the same minor that ' +
883
- 'makes a rule satisfiable gives nobody a window to act. 1.31.0 is when the 29 are ' +
884
- 're-measured against that number.',
885
- },
886
- 'raw-media-query': {
887
- since: '1.31.0',
888
- promote: '1.33.0',
921
+ 'unknown-vx-token': {
922
+ since: '1.33.0',
923
+ promote: '1.34.0',
889
924
  why:
890
- "The CSS-text twin of the oxlint plugin's `basalt/raw-breakpoint` — ONE LAW, TWO LANES, ONE " +
891
- '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 ' +
892
- 'the wave-11 responsive/touch guards; reaching CSS is a NEW file type for this check, same ' +
893
- "reasoning as `css-raw-surface`, with incumbents in basalt's own tree at ship time, so it " +
894
- 'shipped warn. Extended 1.32.0 -> 1.33.0 with its twin: 1.31.0 named ' +
895
- 'no reachable container primitive, and 1.32.0 is the first minor that documents one ' +
896
- '(`CONTAINER_CLASSES` + the declared `@container` names, MIGRATING § `basalt/raw-breakpoint`).',
925
+ 'New kind (consumer-loop r2 B3): obsidian lost its 44px touch floor to a removed ' +
926
+ '`var(--vx-space-touch-target)` that nothing reported. A new kind rejects code every previous ' +
927
+ 'release accepted, so it lands warn for one minor. Measured at ship time: argo, linewatch, rb, ' +
928
+ 'rollhook, obsidian, weatherorb, image-share, email-gateway, image-gen 0.',
897
929
  },
898
930
  }
899
931
 
@@ -1133,6 +1165,13 @@ export const WAIVER_ID_ALIASES: Readonly<Record<string, readonly string[]>> = {
1133
1165
  'raw-selection-control': ['control-outside-home'],
1134
1166
  }
1135
1167
 
1168
+ /**
1169
+ * The ids that are a guard kind AND an oxlint plugin rule — one law, two lanes, one annotation.
1170
+ * {@link PLUGIN_RULE_IDS} leaves them out by design, so `--audit-allows` would otherwise never ask
1171
+ * oxlint about them. `waiver-audit.test.ts` pins this against the plugin's own rule table.
1172
+ */
1173
+ const DUAL_LANE_IDS: ReadonlySet<string> = new Set(['in-body-page-title'])
1174
+
1136
1175
  /** The shortest string accepted as a written reason — enough to exclude a stray separator. */
1137
1176
  const MIN_ALLOW_REASON_LENGTH = 4
1138
1177
 
@@ -1974,7 +2013,7 @@ type GuardRule = {
1974
2013
  }
1975
2014
 
1976
2015
  /**
1977
- * The closed registry of all 27 guard kinds. The triad test asserts
2016
+ * The closed registry of all 28 guard kinds. The triad test asserts
1978
2017
  * `surface.guardKinds ⊆ keyof GUARD_RULES` at runtime.
1979
2018
  *
1980
2019
  * raw-surface, raw-html-layout, and sub-16-input-font are handled inline in checkSource
@@ -2200,7 +2239,7 @@ export const GUARD_RULES = {
2200
2239
  // JSX-tag-shaped (`<Select …>`) — never appears in CSS text.
2201
2240
  appliesTo: (relPath) => !relPath.endsWith('.css'),
2202
2241
  message:
2203
- 'Raw selection control with no home — a filter or tab belongs in a PageBar / Section / WidgetHeader slot and takes a `field` (a FieldHandle), so it owns the URL write and the localStorage mirror instead of carrying value/onChange (docs/CONTROLS-SPEC.md laws C1–C3). Use the bound control from basalt-ui/controls. A settings row, an overlay and an @mantine/form file are the declared non-homes and never report. No shell? PageBar needs no BasaltShell: outside one it renders in flow, sticky, with its own title — so its slots are a reachable home for a provider-only app. This is the TEXT lane of basalt/control-outside-home, which answers the same question against the real AST — where the two disagree, the plugin is right.',
2242
+ 'Raw selection control with no home — a filter or tab belongs in a PageBar / Section / WidgetHeader slot and takes a `field` (a FieldHandle), so it owns the URL write and the localStorage mirror instead of carrying value/onChange (docs/CONTROLS-SPEC.md laws C1–C3). Use the bound control from basalt-ui/controls. A settings row, a FormRow / FormGroup, an overlay and an @mantine/form file are the declared non-homes and never report. No shell? PageBar needs no BasaltShell: outside one it renders in flow, sticky, with its own title — so its slots are a reachable home for a provider-only app. This is the TEXT lane of basalt/control-outside-home, which answers the same question against the real AST — where the two disagree, the plugin is right.',
2204
2243
  },
2205
2244
  'inline-font-size': {
2206
2245
  kind: 'inline-font-size',
@@ -2214,7 +2253,18 @@ export const GUARD_RULES = {
2214
2253
  // CSS-only: a width @media, an @container name/literal or a non-coarse pointer query has no JSX
2215
2254
  // spelling — the AST-side twin of this law is the oxlint plugin's basalt/raw-breakpoint.
2216
2255
  appliesTo: (relPath) => relPath.endsWith('.css'),
2217
- 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(' / ')} at a boundary in ${[...CONTAINER_CLASS_LITERALS_PX].map((px) => `${px}px`).join(' / ')}; 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.`,
2256
+ 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.`,
2257
+ },
2258
+ 'unknown-vx-token': {
2259
+ kind: 'unknown-vx-token',
2260
+ pattern: VX_VAR_REF, // handled inline (removed map + family roots); entry keeps the registry complete
2261
+ message: `var() names a --vx-* token basalt does not emit, so it resolves to its fallback (or to nothing) with no error anywhere. Two shapes are judged, the only two basalt can judge without seeing your palette: a REMOVED name — ${Object.entries(
2262
+ REMOVED_VX_NAMES,
2263
+ )
2264
+ .map(([from, to]) => `${from} → ${to ?? '(deleted, no replacement)'}`)
2265
+ .join(
2266
+ '; ',
2267
+ )} — and a bare family root nothing emits (${[...BASALT_VX_FAMILY_ROOTS].join(', ')}): name the token under it from basalt-ui/tokens.css. Any other unknown name is never judged, because defineSeries/groupTokens may declare it in a file this scan cannot see.`,
2218
2268
  },
2219
2269
  } as const satisfies Record<GuardKind, GuardRule>
2220
2270
 
@@ -2390,6 +2440,22 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2390
2440
  [...codeText.matchAll(STYLE_BOUND_IDENTIFIER)].map((m) => m[1] ?? ''),
2391
2441
  )
2392
2442
 
2443
+ /** `--vx-*` names this file declares — its own custom properties, which `unknown-vx-token` skips. */
2444
+ const locallyDeclaredVx = new Set(
2445
+ [...codeText.matchAll(VX_LOCAL_DECLARATION)].map((m) => m[1] ?? m[3] ?? m[4] ?? ''),
2446
+ )
2447
+
2448
+ // unknown-vx-token: a removed name, or a bare family root nothing emits — see the roots' doc.
2449
+ // Scanned over the whole comment-stripped text rather than per line, so a formatter-wrapped
2450
+ // `var(\n --vx-x\n)` is seen; the finding lands on the line the NAME is on.
2451
+ for (const m of codeText.matchAll(VX_VAR_REF)) {
2452
+ const name = m[1] ?? ''
2453
+ if (locallyDeclaredVx.has(name)) continue
2454
+ if (!Object.hasOwn(REMOVED_VX_NAMES, name) && !BASALT_VX_FAMILY_ROOTS.has(name)) continue
2455
+ const at = (m.index ?? 0) + m[0].length - name.length
2456
+ push('unknown-vx-token', lineStarts.findLastIndex((start) => start <= at) + 1, name)
2457
+ }
2458
+
2393
2459
  for (let i = 0; i < lines.length; i++) {
2394
2460
  const line = codeLines[i] ?? ''
2395
2461
 
@@ -2481,7 +2547,7 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2481
2547
  }
2482
2548
  // Literal boundaries are judged against `basalt-card`'s own table (see
2483
2549
  // `CONTAINER_CLASS_LITERALS_PX`'s doc) — an unnamed query has no OTHER table to judge
2484
- // against, so it is judged against this one too; the other three declared names stay
2550
+ // against, so it is judged against this one too; the other declared names stay
2485
2551
  // unjudged until they ship their own.
2486
2552
  if (name !== '' && name !== 'basalt-card') continue
2487
2553
  for (const lit of body.matchAll(CSS_CONTAINER_LITERAL)) {
@@ -2794,6 +2860,15 @@ export type AllowAnnotationSite = {
2794
2860
  readonly guardKinds: readonly GuardKind[]
2795
2861
  /** The subset of `rules` the oxlint plugin owns — outside `checkSource`'s reach. */
2796
2862
  readonly pluginRules: readonly string[]
2863
+ /**
2864
+ * Every id an `oxlint` run can prove live: `pluginRules`, plus a guard kind that is ALSO a plugin
2865
+ * rule (`in-body-page-title`) or aliases one ({@link WAIVER_ID_ALIASES}). A waiver is live if
2866
+ * EITHER lane still needs it — judging only `pluginRules` called rb's live `in-body-page-title`
2867
+ * waivers dead, and deleting them made oxlint exit 1. A bare LINE-scope annotation covers every
2868
+ * plugin rule (the plugin's `allowCovers`), so it lists all of them; a bare file-scope one waives
2869
+ * nothing in either lane and lists none.
2870
+ */
2871
+ readonly oxlintRules: readonly string[]
2797
2872
  /** Words that occupied the id slot but name no rule. Their presence makes the parse fail closed. */
2798
2873
  readonly unknownRules: readonly string[]
2799
2874
  /** A written reason follows the ids. */
@@ -2832,19 +2907,33 @@ export function findAllowAnnotations(
2832
2907
  cfg.allowComment,
2833
2908
  syntax,
2834
2909
  )
2835
- return declared.map(({ line, annotation }) => ({
2836
- line,
2837
- scope: annotation.scope,
2838
- rules: [...annotation.rules],
2839
- guardKinds: annotation.rules.filter((id): id is GuardKind => Object.hasOwn(GUARD_RULES, id)),
2840
- pluginRules: annotation.rules.filter(
2841
- (id) => !Object.hasOwn(GUARD_RULES, id) && PLUGIN_RULE_IDS.has(id),
2842
- ),
2843
- unknownRules: [...annotation.unknownRules],
2844
- hasReason: annotation.hasReason,
2845
- bare: annotation.rules.length === 0 && annotation.unknownRules.length === 0,
2846
- text: lines[line - 1] ?? '',
2847
- }))
2910
+ const everyOxlintRule = [...PLUGIN_RULE_IDS, ...DUAL_LANE_IDS]
2911
+ return declared.map(({ line, annotation }) => {
2912
+ const bare = annotation.rules.length === 0 && annotation.unknownRules.length === 0
2913
+ return {
2914
+ line,
2915
+ scope: annotation.scope,
2916
+ rules: [...annotation.rules],
2917
+ guardKinds: annotation.rules.filter((id): id is GuardKind => Object.hasOwn(GUARD_RULES, id)),
2918
+ pluginRules: annotation.rules.filter(
2919
+ (id) => !Object.hasOwn(GUARD_RULES, id) && PLUGIN_RULE_IDS.has(id),
2920
+ ),
2921
+ oxlintRules:
2922
+ bare && annotation.scope === 'line'
2923
+ ? everyOxlintRule
2924
+ : [
2925
+ ...new Set(
2926
+ annotation.rules
2927
+ .flatMap((id) => [id, ...(WAIVER_ID_ALIASES[id] ?? [])])
2928
+ .filter((id) => PLUGIN_RULE_IDS.has(id) || DUAL_LANE_IDS.has(id)),
2929
+ ),
2930
+ ],
2931
+ unknownRules: [...annotation.unknownRules],
2932
+ hasReason: annotation.hasReason,
2933
+ bare,
2934
+ text: lines[line - 1] ?? '',
2935
+ }
2936
+ })
2848
2937
  }
2849
2938
 
2850
2939
  /** The token a neutralized annotation is rewritten to. Shares no substring with `theme-allow`. */
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Guard types — Mantine-free, dependency-free (zero imports beyond TS types).
3
3
  *
4
- * GuardKind is the closed set of 27 violation kinds the theme guard can emit — `unframed-chart`
4
+ * GuardKind is the closed set of 28 violation kinds the theme guard can emit — `unframed-chart`
5
5
  * retired at the C2 consolidation wave: its only case (a hand-authored `<ChartLegend items={[...]}>`
6
6
  * array literal) is fully subsumed by the oxlint plugin's `basalt/chart-legend-literal`, which also
7
7
  * catches the `.map()`-over-a-non-series form the regex never could. `'unframed-chart'` stays a
@@ -11,7 +11,7 @@
11
11
  * GuardConfig is the per-run configuration that drives checkSource.
12
12
  */
13
13
 
14
- /** The 27 theme-guard violation kinds. */
14
+ /** The 28 theme-guard violation kinds. */
15
15
  export type GuardKind =
16
16
  | 'raw-hex'
17
17
  | 'raw-color-fn'
@@ -40,6 +40,7 @@ export type GuardKind =
40
40
  | 'in-body-page-title'
41
41
  | 'raw-selection-control'
42
42
  | 'raw-media-query'
43
+ | 'unknown-vx-token'
43
44
 
44
45
  /**
45
46
  * How hard a finding lands. `error` fails the build; `warn` reports and passes.