basalt-ui 1.26.0 → 1.27.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 (107) hide show
  1. package/AGENTS.md +1 -1
  2. package/CHANGELOG.md +8 -0
  3. package/MIGRATING.md +299 -33
  4. package/agent/rules/basalt-controls.md +17 -12
  5. package/agent/rules/basalt-state.md +5 -0
  6. package/configs/oxlint-plugin.js +251 -66
  7. package/configs/oxlint-plugin.test.ts +243 -0
  8. package/configs/oxlint.json +5 -5
  9. package/dist/controls/enum-filter.d.ts +11 -2
  10. package/dist/controls/enum-filter.d.ts.map +1 -1
  11. package/dist/controls/enum-filter.js +4 -2
  12. package/dist/controls/filter-context.d.ts +7 -2
  13. package/dist/controls/filter-context.d.ts.map +1 -1
  14. package/dist/controls/index.d.ts +1 -0
  15. package/dist/controls/index.d.ts.map +1 -1
  16. package/dist/controls/index.js +2 -0
  17. package/dist/controls/multi-select-filter.js +2 -2
  18. package/dist/controls/number-filter.d.ts +26 -0
  19. package/dist/controls/number-filter.d.ts.map +1 -0
  20. package/dist/controls/number-filter.js +143 -0
  21. package/dist/controls/range-filter.d.ts +19 -5
  22. package/dist/controls/range-filter.d.ts.map +1 -1
  23. package/dist/controls/range-filter.js +22 -7
  24. package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
  25. package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
  26. package/dist/controls/search-filter.js +1 -1
  27. package/dist/controls/toggle-filter.js +1 -1
  28. package/dist/dashboard/index.d.ts +1 -1
  29. package/dist/dashboard/index.d.ts.map +1 -1
  30. package/dist/dashboard/index.js +3 -1
  31. package/dist/dashboard/stat-card.d.ts +52 -1
  32. package/dist/dashboard/stat-card.d.ts.map +1 -1
  33. package/dist/dashboard/stat-card.js +42 -15
  34. package/dist/dashboard/stat-card.module.css +71 -0
  35. package/dist/dashboard/stat-card.module.css.d.ts +4 -0
  36. package/dist/data/data-table.d.ts.map +1 -1
  37. package/dist/data/data-table.js +8 -0
  38. package/dist/guard/grace.test.d.ts.map +1 -1
  39. package/dist/guard/index.d.ts +1 -1
  40. package/dist/guard/index.d.ts.map +1 -1
  41. package/dist/guard/index.js +6 -10
  42. package/dist/index.d.ts +2 -2
  43. package/dist/index.d.ts.map +1 -1
  44. package/dist/router-tanstack/field.d.ts +1 -1
  45. package/dist/router-tanstack/field.d.ts.map +1 -1
  46. package/dist/router-tanstack/index.d.ts +1 -1
  47. package/dist/router-tanstack/index.d.ts.map +1 -1
  48. package/dist/router-tanstack/search-store.d.ts +4 -1
  49. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  50. package/dist/router-tanstack/search-store.js +91 -7
  51. package/dist/shell/page-bar.d.ts +1 -1
  52. package/dist/state/fields.d.ts +209 -36
  53. package/dist/state/fields.d.ts.map +1 -1
  54. package/dist/state/fields.js +68 -32
  55. package/dist/state/persisted.d.ts +12 -0
  56. package/dist/state/persisted.d.ts.map +1 -1
  57. package/dist/state/persisted.js +36 -18
  58. package/dist/state/persisted.test.d.ts +2 -0
  59. package/dist/state/persisted.test.d.ts.map +1 -0
  60. package/dist/state.d.ts +1 -1
  61. package/dist/state.d.ts.map +1 -1
  62. package/dist/surfaces.d.ts +1 -1
  63. package/dist/surfaces.d.ts.map +1 -1
  64. package/dist/surfaces.js +4 -3
  65. package/dist/widget-header/widget-header.d.ts +31 -1
  66. package/dist/widget-header/widget-header.d.ts.map +1 -1
  67. package/dist/widget-header/widget-header.js +13 -1
  68. package/dist/widget-header/widget-header.module.css +15 -0
  69. package/dist/widget-header/widget-header.module.css.d.ts +1 -0
  70. package/llms.txt +3 -3
  71. package/package.json +1 -1
  72. package/src/controls/controls.router.test.tsx +416 -0
  73. package/src/controls/enum-filter.tsx +14 -3
  74. package/src/controls/filter-context.tsx +7 -2
  75. package/src/controls/index.ts +1 -0
  76. package/src/controls/multi-select-filter.tsx +2 -2
  77. package/src/controls/number-filter.tsx +291 -0
  78. package/src/controls/props-contract.test.tsx +31 -0
  79. package/src/controls/range-filter.tsx +64 -11
  80. package/src/controls/range-filter.type-guard.test.tsx +122 -0
  81. package/src/controls/search-filter.tsx +1 -1
  82. package/src/controls/toggle-filter.tsx +1 -1
  83. package/src/dashboard/index.ts +6 -1
  84. package/src/dashboard/stat-card.module.css +71 -0
  85. package/src/dashboard/stat-card.module.css.d.ts +4 -0
  86. package/src/dashboard/stat-card.test.tsx +231 -0
  87. package/src/dashboard/stat-card.tsx +81 -0
  88. package/src/data/data-table.tsx +8 -0
  89. package/src/guard/check-source.test.ts +35 -2
  90. package/src/guard/grace.test.ts +14 -5
  91. package/src/guard/index.ts +30 -17
  92. package/src/index.ts +5 -0
  93. package/src/router-tanstack/field.ts +4 -0
  94. package/src/router-tanstack/index.ts +4 -0
  95. package/src/router-tanstack/search-store.router.test.tsx +232 -11
  96. package/src/router-tanstack/search-store.test.ts +160 -0
  97. package/src/router-tanstack/search-store.ts +177 -11
  98. package/src/shell/page-bar.tsx +1 -1
  99. package/src/state/fields.test.tsx +254 -0
  100. package/src/state/fields.ts +328 -70
  101. package/src/state/persisted.test.ts +91 -0
  102. package/src/state/persisted.ts +76 -26
  103. package/src/state.ts +6 -0
  104. package/src/surfaces.ts +4 -3
  105. package/src/widget-header/widget-header.module.css +15 -0
  106. package/src/widget-header/widget-header.module.css.d.ts +1 -0
  107. package/src/widget-header/widget-header.tsx +46 -1
@@ -42,6 +42,77 @@
42
42
  min-width: 0;
43
43
  }
44
44
 
45
+ /*
46
+ * The `breakdown` rows — the two or three parts a hero number is made of, under it.
47
+ *
48
+ * NO hairline, anywhere in this block, and that is a law rather than a taste: `docs/CONTROLS-SPEC.md`
49
+ * §2.1 puts a horizontal rule between OPTION rows and nowhere else, and `theme/divider-law.test.ts`
50
+ * inventories every one basalt draws. Three rules inside a KPI card would read as a table wearing a
51
+ * card's clothes. The rows are separated by the stack rhythm and by weight — a muted label against
52
+ * an ink value — which is the same separation the sheet's own field groups use.
53
+ */
54
+ .breakdown {
55
+ display: flex;
56
+ flex-direction: column;
57
+ margin: var(--vx-space-stack-sm) 0 0;
58
+ padding: 0;
59
+ min-width: 0;
60
+ }
61
+
62
+ /*
63
+ * One row, one line, at the TAG tier (`controlHeightTag`, 20px at level 0) — the smallest control
64
+ * step basalt declares, and a token rather than a literal so the rows track the density knob with
65
+ * everything else. `justify-content: space-between` is what makes the pair read as a row without a
66
+ * grid: the label leads, the value trails, and neither needs a column to align against because
67
+ * every row in one card is the same box.
68
+ */
69
+ .breakdownRow {
70
+ display: flex;
71
+ align-items: center;
72
+ justify-content: space-between;
73
+ gap: var(--vx-space-control-gap);
74
+ min-height: var(--vx-space-control-height-tag);
75
+ min-width: 0;
76
+ }
77
+
78
+ .breakdownLabel {
79
+ overflow: hidden;
80
+ text-overflow: ellipsis;
81
+ white-space: nowrap;
82
+ font-size: var(--vx-text-sm);
83
+ color: var(--vx-muted);
84
+ }
85
+
86
+ /* Mono and ink — a breakdown value is a number, and it is the half of the row worth reading. Never
87
+ * shrinks: the LABEL is the prose half and the one that may ellipsize. */
88
+ .breakdownValue {
89
+ flex-shrink: 0;
90
+ margin: 0;
91
+ font-family: var(--basalt-font-mono);
92
+ font-size: var(--vx-text-sm);
93
+ font-weight: 500;
94
+ color: var(--vx-ink);
95
+ }
96
+
97
+ /*
98
+ * A row past a threshold. Same vocabulary and the same per-scheme solids as the card's own tone rail
99
+ * (`stat-card.tsx`'s `VX.status[tone]`), so a row saying `bad` and a rail saying `bad` are one claim
100
+ * at two scales. Colour is not the only carrier at the CARD level (the rail ships a
101
+ * `VisuallyHidden` verdict); at the ROW level the label is the carrier — a row reading
102
+ * `Paid · 1,204` states its own subject either way, and the tint only ranks it.
103
+ */
104
+ .breakdownRow[data-tone='good'] .breakdownValue {
105
+ color: var(--vx-status-good);
106
+ }
107
+
108
+ .breakdownRow[data-tone='warn'] .breakdownValue {
109
+ color: var(--vx-status-warn);
110
+ }
111
+
112
+ .breakdownRow[data-tone='bad'] .breakdownValue {
113
+ color: var(--vx-status-bad);
114
+ }
115
+
45
116
  .sparklineBleed {
46
117
  margin-top: auto;
47
118
  margin-inline: calc(-1 * var(--mantine-spacing-sm));
@@ -5,6 +5,10 @@
5
5
  declare const classes: {
6
6
  readonly body: string
7
7
  readonly header: string
8
+ readonly breakdown: string
9
+ readonly breakdownRow: string
10
+ readonly breakdownLabel: string
11
+ readonly breakdownValue: string
8
12
  readonly sparklineBleed: string
9
13
  readonly sparklineRight: string
10
14
  }
@@ -21,6 +21,8 @@
21
21
  */
22
22
  import { MantineProvider } from '@mantine/core'
23
23
  import { describe, expect, test } from 'bun:test'
24
+ import { readFileSync } from 'node:fs'
25
+ import { resolve } from 'node:path'
24
26
  import { renderToStaticMarkup } from 'react-dom/server'
25
27
  import { StatCard } from './stat-card'
26
28
  import type { StatCardTone } from './stat-card'
@@ -152,3 +154,232 @@ describe('sparklinePlacement', () => {
152
154
  expect(markup).toContain('data-placement="right"')
153
155
  })
154
156
  })
157
+
158
+ /**
159
+ * `unit` and `breakdown` — the two props that existed as hand-rolled cards in three consumers
160
+ * before they existed here. Both are asserted through the same static markup as the rail above; the
161
+ * CSS half (which cannot resolve under `bun test`, where a `.module.css` import is `undefined`) is
162
+ * pinned against the shipped stylesheet TEXT at the bottom of this file, the pattern
163
+ * `page-bar.test.tsx` uses.
164
+ */
165
+ describe('unit — the hero value is a number and a unit, not one string', () => {
166
+ function renderUnit(props: { unit?: string; value?: string }) {
167
+ return renderToStaticMarkup(
168
+ <MantineProvider>
169
+ <StatCard title="Training load" value="412" {...props} />
170
+ </MantineProvider>,
171
+ )
172
+ }
173
+
174
+ test('renders after the value, and both are present', () => {
175
+ const markup = renderUnit({ unit: 'TSS' })
176
+ expect(markup).toContain('412')
177
+ expect(markup).toContain('TSS')
178
+ // Order matters — a unit BEFORE the numeral reads as a currency prefix, which it is not.
179
+ expect(markup.indexOf('412')).toBeLessThan(markup.indexOf('TSS'))
180
+ })
181
+
182
+ test('omitting it renders nothing extra', () => {
183
+ expect(renderUnit({})).not.toContain('TSS')
184
+ })
185
+
186
+ test('it is not the same channel as `subtitle` — a card may carry both', () => {
187
+ const markup = renderToStaticMarkup(
188
+ <MantineProvider>
189
+ <StatCard title="Training load" value="412" unit="TSS" subtitle="7-day rolling" />
190
+ </MantineProvider>,
191
+ )
192
+ expect(markup).toContain('TSS')
193
+ expect(markup).toContain('7-day rolling')
194
+ })
195
+ })
196
+
197
+ /**
198
+ * `deltaFormat` — because not every delta is a percentage, and the default said it was.
199
+ *
200
+ * A pace card's trend is `0:12 /km` and a speed card's is `0.3 km/h`; both rendered as `0.3%`, which
201
+ * is a wrong unit on a KPI — the failure worse than showing no chip at all, and the reason the
202
+ * consumer that needed one kept its card hand-rolled (the `HeroCard` fork `shadow-basalt-export`
203
+ * reports). The prop is a FUNCTION over the signed number, not a label string: `delta` still drives
204
+ * the tone and the glyph, so there is one place the sign is decided.
205
+ */
206
+ describe('deltaFormat — a delta that is not a percentage', () => {
207
+ function renderDelta(props: {
208
+ delta?: number
209
+ deltaFormat?: (delta: number) => string
210
+ deltaGlyph?: boolean
211
+ deltaPeriod?: string
212
+ }) {
213
+ return renderToStaticMarkup(
214
+ <MantineProvider>
215
+ <StatCard title="Pace" value="5:31" unit="/km" {...props} />
216
+ </MantineProvider>,
217
+ )
218
+ }
219
+
220
+ test('the default is still a one-decimal percentage — no existing card moves', () => {
221
+ const markup = renderDelta({ delta: 0.3 })
222
+ expect(markup).toContain('0.3%')
223
+ })
224
+
225
+ test('a formatter replaces the label and the percent is gone', () => {
226
+ const markup = renderDelta({ delta: 0.3, deltaFormat: (v) => `${v.toFixed(1)} km/h` })
227
+ expect(markup).toContain('0.3 km/h')
228
+ expect(markup).not.toContain('0.3%')
229
+ })
230
+
231
+ test('the formatter receives the SIGNED value, so it can print an absolute delta itself', () => {
232
+ // argo's walking-pad pace card: the delta is seconds per km, faster is a negative number, and
233
+ // the sign belongs in the string. `deltaGlyph={false}` stops the ▼ saying it a second time.
234
+ const markup = renderDelta({
235
+ delta: -12,
236
+ deltaFormat: (s) => `${s < 0 ? '−' : '+'}0:${String(Math.abs(s)).padStart(2, '0')} /km`,
237
+ deltaGlyph: false,
238
+ })
239
+ // The chip's whole text, so nothing survives beside it — the glyph, or a stray `12.0%`.
240
+ expect(markup).toContain('>−0:12 /km</span>')
241
+ expect(markup).not.toContain('▼')
242
+ })
243
+
244
+ test('the glyph and the tone still come off the number, not the label', () => {
245
+ const markup = renderDelta({ delta: -12, deltaFormat: (s) => `${Math.abs(s)} s` })
246
+ // Negative reads as danger and keeps its ▼ — the formatter only owns the magnitude string.
247
+ expect(markup).toContain('▼')
248
+ expect(markup).toContain('var(--vx-status-bad)')
249
+ })
250
+
251
+ test('deltaPeriod still rides beside a formatted delta', () => {
252
+ const markup = renderDelta({
253
+ delta: 0.3,
254
+ deltaFormat: (v) => `${v.toFixed(1)} km/h`,
255
+ deltaPeriod: 'WoW',
256
+ })
257
+ expect(markup).toContain('0.3 km/h')
258
+ expect(markup).toContain('WoW')
259
+ })
260
+
261
+ test('a formatter with no delta renders no chip at all', () => {
262
+ expect(renderDelta({ deltaFormat: (v) => `${v} km/h` })).not.toContain('km/h')
263
+ })
264
+ })
265
+
266
+ describe('breakdown — the parts the hero number is made of', () => {
267
+ const ROWS = [
268
+ { label: 'Paid', value: '1,204' },
269
+ { label: 'Organic', value: '842' },
270
+ { label: 'Referral', value: '31', tone: 'bad' as const },
271
+ ]
272
+
273
+ function renderBreakdown(breakdown?: readonly { label: string; value: string }[]) {
274
+ return renderToStaticMarkup(
275
+ <MantineProvider>
276
+ <StatCard
277
+ title="Orders"
278
+ value="2,077"
279
+ {...(breakdown !== undefined ? { breakdown } : {})}
280
+ />
281
+ </MantineProvider>,
282
+ )
283
+ }
284
+
285
+ test('every row renders its label and its value', () => {
286
+ const markup = renderBreakdown(ROWS)
287
+ for (const row of ROWS) {
288
+ expect(markup).toContain(row.label)
289
+ expect(markup).toContain(row.value)
290
+ }
291
+ })
292
+
293
+ test('the rows are a description list — each row IS a term and its value', () => {
294
+ const markup = renderBreakdown(ROWS)
295
+ expect(markup).toContain('<dl')
296
+ expect((markup.match(/<dt/g) ?? []).length).toBe(3)
297
+ expect((markup.match(/<dd/g) ?? []).length).toBe(3)
298
+ })
299
+
300
+ test('a toned row carries data-tone; an untoned one carries nothing', () => {
301
+ const markup = renderBreakdown(ROWS)
302
+ expect(markup).toContain('data-tone="bad"')
303
+ // Omission is not `good`, exactly as it is not on the card itself.
304
+ expect(markup).not.toContain('data-tone="good"')
305
+ })
306
+
307
+ test('omitted, and an empty array, render no list at all', () => {
308
+ expect(renderBreakdown()).not.toContain('<dl')
309
+ expect(renderBreakdown([])).not.toContain('<dl')
310
+ })
311
+
312
+ // The reason the rows live INSIDE the header block rather than beside it: with
313
+ // `sparklinePlacement="right"` the card body is a flex ROW, so a third child there would sit next
314
+ // to the trend instead of under the number it splits.
315
+ test('the list sits inside the header block, before the sparkline slot', () => {
316
+ const markup = renderToStaticMarkup(
317
+ <MantineProvider>
318
+ <StatCard
319
+ title="Orders"
320
+ value="2,077"
321
+ breakdown={ROWS}
322
+ sparklinePlacement="right"
323
+ sparkline={<span>spark</span>}
324
+ />
325
+ </MantineProvider>,
326
+ )
327
+ expect(markup.indexOf('<dl')).toBeLessThan(markup.indexOf('spark'))
328
+ })
329
+ })
330
+
331
+ /**
332
+ * The CSS half, pinned against the shipped module text. A `.module.css` resolves to `undefined`
333
+ * under `bun test` (no bundler in the loop), so the class names never reach the markup and the only
334
+ * checkable thing is the stylesheet itself.
335
+ */
336
+ describe('stat-card.module.css — the breakdown block', () => {
337
+ const css = readFileSync(resolve(import.meta.dirname, 'stat-card.module.css'), 'utf8')
338
+ /** Declarations only — the comments above them discuss hairlines and borders by name. */
339
+ const decls = css.replace(/\/\*[\s\S]*?\*\//g, '')
340
+
341
+ function rule(selector: string): string {
342
+ const start = decls.indexOf(`${selector} {`)
343
+ expect(start).toBeGreaterThan(-1)
344
+ return decls.slice(start, decls.indexOf('}', start))
345
+ }
346
+
347
+ test('a row is one line at the TAG tier, from the token and never a literal', () => {
348
+ expect(rule('.breakdownRow')).toContain('min-height: var(--vx-space-control-height-tag)')
349
+ })
350
+
351
+ // THE law this block had to not break: `theme/divider-law.test.ts` inventories every horizontal
352
+ // hairline basalt draws, and §2.1 puts one between OPTION rows and nowhere else. Three rules
353
+ // inside a KPI card would read as a table wearing a card's clothes — and would fail that test.
354
+ test('no row draws a hairline — not a border, not a rule, not a divider', () => {
355
+ expect(decls).not.toMatch(/\.breakdown[^{]*\{[^}]*border/)
356
+ expect(decls).not.toContain('--vx-surface-hairline')
357
+ })
358
+
359
+ test('the label is muted prose and the value is mono ink — the separation is weight, not a line', () => {
360
+ expect(rule('.breakdownLabel')).toContain('color: var(--vx-muted)')
361
+ expect(rule('.breakdownValue')).toContain('font-family: var(--basalt-font-mono)')
362
+ expect(rule('.breakdownValue')).toContain('color: var(--vx-ink)')
363
+ })
364
+
365
+ test('a toned row reads the per-scheme status solid, never a hex', () => {
366
+ for (const tone of ['good', 'warn', 'bad']) {
367
+ expect(rule(`.breakdownRow[data-tone='${tone}'] .breakdownValue`)).toContain(
368
+ `color: var(--vx-status-${tone})`,
369
+ )
370
+ }
371
+ })
372
+
373
+ test('the unit beside the hero value is mono, muted and small — in widget-header, its owner', () => {
374
+ const headerCss = readFileSync(
375
+ resolve(import.meta.dirname, '..', 'widget-header', 'widget-header.module.css'),
376
+ 'utf8',
377
+ ).replace(/\/\*[\s\S]*?\*\//g, '')
378
+ const start = headerCss.indexOf('.unit {')
379
+ expect(start).toBeGreaterThan(-1)
380
+ const unit = headerCss.slice(start, headerCss.indexOf('}', start))
381
+ expect(unit).toContain('font-family: var(--basalt-font-mono)')
382
+ expect(unit).toContain('font-size: var(--vx-text-sm)')
383
+ expect(unit).toContain('color: var(--vx-muted)')
384
+ })
385
+ })
@@ -100,6 +100,20 @@ import classes from './stat-card.module.css'
100
100
  /** A threshold verdict on the card's value. `undefined` is never tinted — see the module docblock. */
101
101
  export type StatCardTone = 'good' | 'warn' | 'bad'
102
102
 
103
+ /**
104
+ * One row of a {@link StatCardProps.breakdown} — the two or three parts a hero number is made of.
105
+ *
106
+ * `value` is a pre-formatted `string` for the same reason the hero's is: the card cannot know the
107
+ * locale, the currency or the precision the number was measured at. `tone` marks ONE row as past a
108
+ * threshold and is the same vocabulary as the card's own `tone`, so a row saying `bad` and a rail
109
+ * saying `bad` are the same claim at two scales; omitting it is untinted, never `'good'`.
110
+ */
111
+ export type StatCardBreakdownRow = {
112
+ readonly label: string
113
+ readonly value: string
114
+ readonly tone?: StatCardTone
115
+ }
116
+
103
117
  /**
104
118
  * The `'right'` placement's slot width, in px — a flex BASIS, not a fixed size. 72 is the reference
105
119
  * KPI card's trend width and the slot asks for exactly that; when the hero row needs the space (a
@@ -187,10 +201,48 @@ export type StatCardProps = {
187
201
  info?: string
188
202
  /** Pre-formatted KPI value string (mono ~24px, weight 600, ink) — the hero-row value. */
189
203
  value: string
204
+ /** The value's unit, muted and mono at `--vx-text-sm`, immediately after it on the hero row —
205
+ * forwarded to `WidgetHeader`. `412` + `TSS`, not `"412 TSS"`: a unit painted at the hero's 24px
206
+ * reads as a second numeral. The BASIS (`7-day rolling`) is still `subtitle`; see
207
+ * `WidgetHeaderProps.unit`. */
208
+ unit?: string
209
+ /**
210
+ * The parts the hero number is made of — compact rows under it, one line each, no hairlines.
211
+ *
212
+ * It exists because three consumers wanted a KPI card that also SPLITS its number (revenue by
213
+ * channel, uptime by probe) and the only shapes basalt offered were a `subtitle` (one muted line,
214
+ * no second column) and a table. Both hand-rolled a card instead — which is the fork
215
+ * `shadow-basalt-export` reports as a `HeroCard`.
216
+ *
217
+ * Deliberately not a table and deliberately unruled: the divider law puts a hairline between
218
+ * OPTION rows and nowhere else (`docs/CONTROLS-SPEC.md` §2.1), and a KPI card that draws three
219
+ * of them stops reading as one card. Keep it to two or three rows — past that the card is a
220
+ * table, and a table is `BasaltDataTable` in a `Section`.
221
+ */
222
+ breakdown?: readonly StatCardBreakdownRow[]
190
223
  /** Signed delta rendered via `DeltaBadge`; omit to hide the trend chip entirely. */
191
224
  delta?: number
192
225
  /** Optional comparison timeframe shown after the delta (e.g. `MoM`) — forwarded to `DeltaBadge`. */
193
226
  deltaPeriod?: string
227
+ /**
228
+ * Formats `delta` into the chip's label — forwarded to `WidgetHeader` and on to `DeltaBadge`.
229
+ * Defaults to `${Math.abs(delta).toFixed(1)}%`.
230
+ *
231
+ * A delta is not always a percentage, and the default silently claimed it was: a pace card's trend
232
+ * (`0:12 /km`) and a speed card's (`0.3 km/h`) both rendered as `0.3%` — a wrong unit on a KPI is
233
+ * the one failure worse than no chip, and it is why the consumer that needed it kept the card
234
+ * hand-rolled instead (the `HeroCard` fork `shadow-basalt-export` reports).
235
+ *
236
+ * @example
237
+ * // An absolute delta that prints its own sign — no percent, no glyph saying it twice.
238
+ * <StatCard title="Pace" value="5:31" unit="/km" delta={-12}
239
+ * deltaFormat={(s) => `${s < 0 ? '−' : '+'}0:${String(Math.abs(s)).padStart(2, '0')} /km`}
240
+ * deltaGlyph={false} />
241
+ */
242
+ deltaFormat?: (delta: number) => string
243
+ /** Render the ▲/▼ glyph on the delta chip. Defaults to `true`; a zero delta never shows one. Pass
244
+ * `false` when `deltaFormat` prints the sign itself. */
245
+ deltaGlyph?: boolean
194
246
  /**
195
247
  * Optional trend visual. Either a node, or a RENDER PROP receiving the slot's measured box.
196
248
  *
@@ -223,8 +275,12 @@ export function StatCard({
223
275
  subtitle,
224
276
  info,
225
277
  value,
278
+ unit,
279
+ breakdown,
226
280
  delta,
227
281
  deltaPeriod,
282
+ deltaFormat,
283
+ deltaGlyph,
228
284
  sparkline,
229
285
  sparklinePlacement = 'bleed',
230
286
  actions,
@@ -291,8 +347,11 @@ export function StatCard({
291
347
  {...(subtitle !== undefined && { subtitle })}
292
348
  {...(info !== undefined && { info })}
293
349
  value={value}
350
+ {...(unit !== undefined && { unit })}
294
351
  {...(delta !== undefined && { delta })}
295
352
  {...(deltaPeriod !== undefined && { deltaPeriod })}
353
+ {...(deltaFormat !== undefined && { deltaFormat })}
354
+ {...(deltaGlyph !== undefined && { deltaGlyph })}
296
355
  // `tier="widget"`, not the default `ctl`: the header row this slot sits in is 28px
297
356
  // (`--vx-space-widget-header-height`) and a 30px `ctl` control grew it to 30, so a card
298
357
  // with a kebab sat 2px below a card without one in the same grid row. See
@@ -301,6 +360,28 @@ export function StatCard({
301
360
  actions: <CtlSlot tier="widget">{actions}</CtlSlot>,
302
361
  })}
303
362
  />
363
+
364
+ {breakdown !== undefined &&
365
+ breakdown.length > 0 && (
366
+ // A `<dl>`, not a table and not a stack of divs: each row IS a term and its value, which
367
+ // is the one semantic that survives a reader meeting the card out of context.
368
+ //
369
+ // INSIDE `.header`, not beside it: with `sparklinePlacement="right"` the body is a flex
370
+ // ROW, so a third child here would sit next to the trend rather than under the number
371
+ // it splits. The header block is the card's text column in both placements.
372
+ <dl className={classes.breakdown}>
373
+ {breakdown.map((row) => (
374
+ <div
375
+ key={row.label}
376
+ className={classes.breakdownRow}
377
+ {...(row.tone !== undefined && { 'data-tone': row.tone })}
378
+ >
379
+ <dt className={classes.breakdownLabel}>{row.label}</dt>
380
+ <dd className={classes.breakdownValue}>{row.value}</dd>
381
+ </div>
382
+ ))}
383
+ </dl>
384
+ )}
304
385
  </div>
305
386
 
306
387
  {sparkline !== undefined && (
@@ -189,6 +189,10 @@ function facetEnumHandle<T>(
189
189
  },
190
190
  ] as const
191
191
  },
192
+ // A facet column has no lane to unset: clearing the column filter IS its fallback state.
193
+ clear: () => {
194
+ column.setFilterValue(undefined)
195
+ },
192
196
  isDefault: (value) => value === FACET_ALL_VALUE,
193
197
  }
194
198
  }
@@ -215,6 +219,10 @@ function facetMultiHandle<T>(
215
219
  },
216
220
  ] as const
217
221
  },
222
+ // Same as the single-select facet: an empty column filter is this handle's fallback.
223
+ clear: () => {
224
+ column.setFilterValue(undefined)
225
+ },
218
226
  isDefault: (value) => value.length === 0,
219
227
  }
220
228
  }
@@ -2480,9 +2480,11 @@ describe('in-body-page-title', () => {
2480
2480
  expect(kinds(f)).not.toContain('in-body-page-title')
2481
2481
  })
2482
2482
 
2483
- it('lands warn while the grace entry stands (C16)', () => {
2483
+ // Promoted at 1.27.0 — its GRACE_PERIOD_KINDS entry is gone, and so is the plugin rule of the
2484
+ // same id from PLUGIN_RULE_GRACE. One law, two lanes, one promotion.
2485
+ it('lands error now that the grace entry is gone (C16)', () => {
2484
2486
  const f = find(`<Title order={1}>Analytics</Title>`)
2485
- expect(f.find((x) => x.kind === 'in-body-page-title')?.severity).toBe('warn')
2487
+ expect(f.find((x) => x.kind === 'in-body-page-title')?.severity).toBe('error')
2486
2488
  })
2487
2489
  })
2488
2490
 
@@ -2538,6 +2540,37 @@ describe('raw-selection-control', () => {
2538
2540
  expect(kinds(f)).not.toContain('raw-selection-control')
2539
2541
  })
2540
2542
 
2543
+ // The CROSS-FILE exemption — the same basename convention the plugin's `control-outside-home`
2544
+ // applies, so both lanes agree on the same file (one law, two lanes, one exemption). argo carried
2545
+ // 9 of these: a control in a modal/form module whose `<Modal>` is rendered by the parent, which is
2546
+ // outside the 12-line host window because the host tag is not in the file at all.
2547
+ it.each([
2548
+ 'src/edit-session-modal.tsx',
2549
+ 'src/filters-drawer.tsx',
2550
+ 'src/column-popover.tsx',
2551
+ 'src/detail-panel.tsx',
2552
+ 'src/booking-form.tsx',
2553
+ ])('does NOT flag %s — the overlay filename convention', (relPath) => {
2554
+ expect(kinds(find(`<Select data={[]} />`, relPath))).not.toContain('raw-selection-control')
2555
+ })
2556
+
2557
+ it('still flags a page module beside them', () => {
2558
+ expect(kinds(find(`<Select data={[]} />`, 'src/bookings-page.tsx'))).toContain(
2559
+ 'raw-selection-control',
2560
+ )
2561
+ })
2562
+
2563
+ // Basename only, and it needs the leading subject: a `modal/` DIRECTORY holds the page pieces
2564
+ // around the modals too, and a bare `modal.tsx` is a page module in every consumer that has one.
2565
+ it.each(['src/modal/session.tsx', 'src/modal.tsx'])(
2566
+ 'is a subject-prefixed BASENAME convention, not %s',
2567
+ (relPath) => {
2568
+ expect(kinds(find(`<Select data={[]} />`, relPath))).toContain('raw-selection-control')
2569
+ },
2570
+ )
2571
+
2572
+ // Still warn at 1.27.0 — this kind and its AST twin `basalt/control-outside-home` are the C1 pair
2573
+ // that moved to `promote: '1.28.0'` while the other wave-6 entries promoted.
2541
2574
  it('lands warn while the grace entry stands (C16)', () => {
2542
2575
  const f = find(`<Select data={[]} />`)
2543
2576
  expect(f.find((x) => x.kind === 'raw-selection-control')?.severity).toBe('warn')
@@ -9,9 +9,10 @@
9
9
  * `assertGraceLedger` is tested directly against synthetic entries below rather than only through
10
10
  * the real ledger: every pre-existing entry promoted or moved to `PLUGIN_RULE_ADVISORY`'s guard-side
11
11
  * sibling, and an `it.each` over the empty ledger that left behind ran zero assertions, which proved
12
- * nothing about the gate actually firing. The ledger now carries the two wave-6 control kinds
13
- * (`in-body-page-title`, `raw-selection-control`, both `promote: '1.27.0'`), and the real-ledger
14
- * check below runs against them.
12
+ * nothing about the gate actually firing. The ledger carries ONE wave-6 control kind at 1.27.0 —
13
+ * `raw-selection-control`, re-dated to `promote: '1.28.0'` with its AST twin
14
+ * `basalt/control-outside-home` (`in-body-page-title` promoted on schedule and its entry is gone) —
15
+ * and the real-ledger check below runs against it.
15
16
  *
16
17
  * This gate measures the version already PUBLISHED, so it can only go red after the release that
17
18
  * shipped a due entry. `scripts/check-grace.ts` is the other end — `scripts/release.sh` runs it
@@ -138,8 +139,16 @@ describe('scripts/check-grace.ts', () => {
138
139
  expect(stderr).toContain('basalt/control-outside-home')
139
140
  })
140
141
 
141
- it('refuses the version the wave-6 entries are due in (1.27.0)', () => {
142
- expect(runCheckGrace('1.27.0').code).toBe(1)
142
+ // 1.27.0 promoted five of the six wave-6 plugin rules and one of the two guard kinds; what is
143
+ // LEFT is the C1 pair (`raw-selection-control` + `basalt/control-outside-home`), re-dated to
144
+ // 1.28.0 against a measurement rather than a hunch — see either entry's `why`. So the release
145
+ // this file used to refuse now passes, and the refusal moved with the pair.
146
+ it('passes 1.27.0 — the five entries due there promoted', () => {
147
+ expect(runCheckGrace('1.27.0').code).toBe(0)
148
+ })
149
+
150
+ it('refuses the version the remaining C1 pair is due in (1.28.0)', () => {
151
+ expect(runCheckGrace('1.28.0').code).toBe(1)
143
152
  })
144
153
 
145
154
  it('exits 2 on a missing or malformed version rather than passing vacuously', () => {
@@ -522,9 +522,25 @@ const CONTROL_HOST_TAG =
522
522
  */
523
523
  const CONTROL_HOST_WINDOW_LINES = 12
524
524
 
525
+ /**
526
+ * A file whose BASENAME declares it is an overlay's or a form's own body — the cross-file half of
527
+ * {@link CONTROL_HOST_TAG}, and the same regex the plugin's `OVERLAY_CONVENTION_FILE` applies to
528
+ * `basalt/control-outside-home` (one law, two lanes, one exemption).
529
+ *
530
+ * Law C1's cross-file case is advisory by declaration (`docs/CONTROLS-SPEC.md` §6, "Honest
531
+ * coverage"), and this kind was paying for it: a `<Select>` inside `edit-session-modal.tsx` whose
532
+ * `<Modal>` is rendered by the parent route is outside BOTH the 12-line host window and any
533
+ * ancestry walk, because the host tag is not in the file at all. argo carried 9 of them.
534
+ *
535
+ * The trade is that a whole file goes unscanned on a naming convention — the same bargain
536
+ * {@link MANTINE_FORM_IMPORT} already buys, and a smaller one than promoting the kind with 9 known
537
+ * false positives. Basename only: a `modal/` DIRECTORY holds the page pieces around the modals too.
538
+ */
539
+ const OVERLAY_CONVENTION_FILE = /(?:^|\/)[^/]*-(?:modal|drawer|popover|panel|form)\.[jt]sx$/
540
+
525
541
  /** A name whose DECLARATION means this file DEFINES a basalt control rather than consuming one. */
526
542
  const CONTROL_OWNER_DEF =
527
- /\b(?:function|const|class)\s+(?:RangeFilter|CompareFilter|SelectFilter|MultiSelectFilter|SearchFilter|ToggleFilter|ViewTabs|FilterSet|FilterPill|SyncButton|ActionGroup|OverflowMenu|CtlSlot)(?![\w])/
543
+ /\b(?:function|const|class)\s+(?:RangeFilter|CompareFilter|SelectFilter|MultiSelectFilter|NumberFilter|SearchFilter|ToggleFilter|ViewTabs|FilterSet|FilterPill|SyncButton|ActionGroup|OverflowMenu|CtlSlot)(?![\w])/
528
544
 
529
545
  /** `@mantine/form` — a form is C1's third home, and its inputs are not filters. */
530
546
  const MANTINE_FORM_IMPORT = /from\s+['"]@mantine\/form['"]/
@@ -635,24 +651,19 @@ export type GraceEntry = { since: string; promote: string; why: string }
635
651
  * }
636
652
  */
637
653
  export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
638
- 'in-body-page-title': {
639
- since: '1.26.0',
640
- promote: '1.27.0',
641
- why:
642
- 'new in the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C8). Its AST twin is the ' +
643
- 'oxlint rule of the SAME id, whose PLUGIN_RULE_GRACE entry carries the same dates — one law, ' +
644
- 'two lanes, one promotion. Warn for a minor because the fix is a route/breadcrumb change ' +
645
- '(the page is named by staticData.title), not a prop edit, so a consumer needs to schedule it.',
646
- },
647
654
  'raw-selection-control': {
648
655
  since: '1.26.0',
649
- promote: '1.27.0',
656
+ promote: '1.28.0',
650
657
  why:
651
658
  'new in the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C1). The text lane cannot ' +
652
659
  'see ancestry, so "no home" is approximated by a 12-line host-tag window — the loosest ' +
653
660
  'reading in the guard, and the reason this one lands warn rather than error even though its ' +
654
- 'law is settled. Promotion is gated with the plugin half on the wave-7 consumer run (≤3 ' +
655
- 'waivers), and `scripts/check-grace.ts` refuses the 1.27.0 release while the entry is still here.',
661
+ 'law is settled. It moved from 1.27.0 to 1.28.0 with its AST twin, `basalt/control-outside-' +
662
+ 'home`, whose PLUGIN_RULE_GRACE entry carries the measurement: the wave-7 run left 9 warns ' +
663
+ 'in argo, all of them a control in a modal/form module whose `<Modal>` is rendered by the ' +
664
+ 'PARENT — law C1 cross-file, which neither lane can see. `OVERLAY_CONVENTION_FILE` (this ' +
665
+ 'file, and the same regex in the plugin) exempts that declared naming convention in both ' +
666
+ 'lanes; 1.28.0 is when the remainder is re-measured. One law, two lanes, one promotion.',
656
667
  },
657
668
  }
658
669
 
@@ -1901,7 +1912,7 @@ export const GUARD_RULES = {
1901
1912
  enabled: (cfg: GuardConfig) => cfg.rawHtmlLayout,
1902
1913
  appliesTo: (relPath) => !isChartFile(relPath) && !relPath.endsWith('.css'),
1903
1914
  message:
1904
- 'Raw HTML element with inline layout/surface styling that the line scan cannot see — the tag is formatted across lines, or the style object is hoisted to a const. Same violation as raw-html-layout: use a Mantine layout primitive (Box/Flex/Grid/Stack/Group). Promoted to error in the Unreleased minor (C16) with severity only — the kind stayed standalone rather than folding into raw-html-layout as originally planned; the merge is still open, tracked as a follow-up, not done here.',
1915
+ 'Raw HTML element with inline layout/surface styling that the line scan cannot see — the tag is formatted across lines, or the style object is hoisted to a const. Same violation as raw-html-layout: use a Mantine layout primitive (Box/Flex/Grid/Stack/Group). Promoted to error at 1.26.0 (C16) with severity only — the kind stayed standalone rather than folding into raw-html-layout as originally planned; the merge is still open, tracked as a follow-up, not done here.',
1905
1916
  },
1906
1917
  'in-body-page-title': {
1907
1918
  kind: 'in-body-page-title',
@@ -2369,11 +2380,13 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2369
2380
  }
2370
2381
 
2371
2382
  // raw-selection-control — full-text tag scan plus the host WINDOW (see CONTROL_HOST_WINDOW_LINES).
2372
- // Two file-level exemptions mirror the plugin rule's, so the two lanes agree on the same file:
2373
- // a file that DEFINES a basalt control cannot be told to use one, and a file importing
2374
- // `@mantine/form` is a form — C1's third home, whose inputs are not filters.
2383
+ // Three file-level exemptions mirror the plugin rule's, so the two lanes agree on the same file:
2384
+ // a file that DEFINES a basalt control cannot be told to use one, a file importing
2385
+ // `@mantine/form` is a form — C1's third home, whose inputs are not filters — and a file NAMED as
2386
+ // an overlay body carries its `<Modal>` in the parent, where no scan of this file can reach it.
2375
2387
  if (
2376
2388
  ruleApplies('raw-selection-control', relPath) &&
2389
+ !OVERLAY_CONVENTION_FILE.test(relPath) &&
2377
2390
  !CONTROL_OWNER_DEF.test(codeText) &&
2378
2391
  !MANTINE_FORM_IMPORT.test(codeText)
2379
2392
  ) {