basalt-ui 1.22.0 → 1.23.1

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 (85) hide show
  1. package/AGENTS.md +29 -12
  2. package/CHANGELOG.md +28 -0
  3. package/MIGRATING.md +207 -12
  4. package/README.md +74 -21
  5. package/agent/rules/basalt-app.md +33 -3
  6. package/agent/rules/basalt-charts.md +78 -7
  7. package/agent/rules/basalt-content.md +3 -3
  8. package/agent/rules/basalt-mantine.md +4 -4
  9. package/agent/rules/basalt-tokens.md +35 -6
  10. package/agent/skills/basalt-app/SKILL.md +25 -12
  11. package/agent/skills/basalt-charts/SKILL.md +27 -10
  12. package/agent/templates/CLAUDE-block.md.tpl +3 -2
  13. package/agent/templates/DESIGN.md.tpl +3 -1
  14. package/configs/oxlint-plugin.js +49 -8
  15. package/configs/oxlint-plugin.test.ts +88 -31
  16. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  17. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  18. package/dist/charts/hooks/useBandPlot.js +127 -0
  19. package/dist/charts/index.d.ts +4 -0
  20. package/dist/charts/index.d.ts.map +1 -1
  21. package/dist/charts/index.js +14 -0
  22. package/dist/charts/kinds/BandStrip.d.ts +127 -0
  23. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  24. package/dist/charts/kinds/BandStrip.js +271 -0
  25. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  26. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  27. package/dist/charts/kinds/Bars.d.ts +5 -1
  28. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  29. package/dist/charts/kinds/Bars.js +2 -0
  30. package/dist/charts/kinds/MirroredBars.d.ts +95 -0
  31. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  32. package/dist/charts/kinds/MirroredBars.js +328 -0
  33. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  34. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  35. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  36. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  37. package/dist/charts/kinds/MultiLine.js +2 -0
  38. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  39. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  40. package/dist/charts/kinds/StackedArea.js +2 -0
  41. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  42. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  43. package/dist/charts/kinds/ZonedLine.js +2 -0
  44. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  45. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  46. package/dist/charts/primitives/CartesianChart.js +5 -2
  47. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  48. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  49. package/dist/charts/primitives/HatchPattern.js +31 -0
  50. package/dist/cli/index.d.ts +2 -0
  51. package/dist/cli/index.d.ts.map +1 -1
  52. package/dist/cli/index.js +228 -31
  53. package/dist/cli/shipped-versions.test.d.ts +2 -0
  54. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  55. package/dist/guard/index.d.ts.map +1 -1
  56. package/dist/guard/index.js +60 -13
  57. package/dist/vite.d.ts +42 -3
  58. package/dist/vite.d.ts.map +1 -1
  59. package/dist/vite.js +67 -30
  60. package/llms.txt +2 -2
  61. package/package.json +1 -1
  62. package/src/charts/hooks/useBandPlot.ts +302 -0
  63. package/src/charts/index.ts +14 -0
  64. package/src/charts/kinds/BandStrip.test.tsx +282 -0
  65. package/src/charts/kinds/BandStrip.tsx +457 -0
  66. package/src/charts/kinds/Bars.tsx +7 -1
  67. package/src/charts/kinds/DualPanel.test.tsx +10 -4
  68. package/src/charts/kinds/MirroredBars.test.tsx +232 -0
  69. package/src/charts/kinds/MirroredBars.tsx +491 -0
  70. package/src/charts/kinds/MultiLine.tsx +7 -1
  71. package/src/charts/kinds/StackedArea.tsx +7 -1
  72. package/src/charts/kinds/ZonedLine.tsx +7 -1
  73. package/src/charts/primitives/CartesianChart.test.tsx +86 -4
  74. package/src/charts/primitives/CartesianChart.tsx +28 -3
  75. package/src/charts/primitives/HatchPattern.tsx +55 -0
  76. package/src/cli/help.test.ts +74 -1
  77. package/src/cli/index.ts +436 -52
  78. package/src/cli/shipped-versions.test.ts +83 -0
  79. package/src/cli/tokens-css.test.ts +62 -0
  80. package/src/cli/toolchain-wiring.test.ts +171 -4
  81. package/src/cli/waiver-audit.test.ts +7 -3
  82. package/src/guard/check-source.test.ts +211 -42
  83. package/src/guard/index.ts +149 -23
  84. package/src/vite.test.ts +79 -0
  85. package/src/vite.ts +141 -39
@@ -0,0 +1,282 @@
1
+ /**
2
+ * `BandStrip` — the four properties that make it a KIND rather than a rect loop: no left axis at
3
+ * all (the reason `CartesianChart` cannot host it), the absence split a fold owes the reader, the
4
+ * derived tooltip row, and the fold arithmetic itself.
5
+ *
6
+ * Under this DOM harness there is no ResizeObserver, so `ChartFrame` falls back to its `minWidth`
7
+ * floor — `plot.width` is a fixed 200, which is what makes the fold/geometry assertions below
8
+ * deterministic.
9
+ */
10
+ import { fireEvent, render, screen } from '@testing-library/react'
11
+ import { describe, expect, test } from 'bun:test'
12
+ import { ChartCursorScope } from '../cursor/scope'
13
+ import { BandStrip } from './BandStrip'
14
+ import type { BandStripSeries } from './BandStrip'
15
+
16
+ type Slot = { key: string; loss: number | null; folded: number; unmeasured: number }
17
+
18
+ const slot = (key: string, loss: number | null): Slot => ({
19
+ key,
20
+ loss,
21
+ folded: 1,
22
+ unmeasured: loss === null ? 1 : 0,
23
+ })
24
+
25
+ const SERIES: BandStripSeries<Slot>[] = [
26
+ { key: 'clean', label: 'No loss', color: '#0a0', mark: 'bar', fillOpacity: 0.14 },
27
+ {
28
+ key: 'loss',
29
+ label: 'Packet loss',
30
+ color: '#a00',
31
+ mark: 'bar',
32
+ formatValue: (d) => `${d.loss?.toFixed(1)}%`,
33
+ },
34
+ { key: 'absent', label: 'Not measured', color: '#888', mark: 'bar', fillOpacity: 0.5 },
35
+ ]
36
+
37
+ const getBand = (d: Slot) => ({
38
+ state: d.loss === null ? 'absent' : d.loss > 0 ? 'loss' : 'clean',
39
+ ...(d.unmeasured > 0 && d.loss !== null && { absentFraction: d.unmeasured / d.folded }),
40
+ })
41
+
42
+ function renderStrip(props: Partial<Parameters<typeof BandStrip<Slot>>[0]> = {}) {
43
+ const data = props.data ?? [
44
+ slot('2026-08-01', 0),
45
+ slot('2026-08-02', 2.5),
46
+ slot('2026-08-03', null),
47
+ ]
48
+ return render(
49
+ <ChartCursorScope>
50
+ <BandStrip<Slot>
51
+ data={data}
52
+ chartId="strip"
53
+ getX={(d) => d.key}
54
+ series={SERIES}
55
+ getBand={getBand}
56
+ height={120}
57
+ {...props}
58
+ />
59
+ </ChartCursorScope>,
60
+ )
61
+ }
62
+
63
+ describe('BandStrip — no numeric y axis', () => {
64
+ test('renders a bottom axis and NO left axis — the one thing CartesianChart cannot express', () => {
65
+ const { container } = renderStrip()
66
+ expect(container.querySelector('.visx-axis-bottom')).not.toBeNull()
67
+ expect(container.querySelector('.visx-axis-left')).toBeNull()
68
+ expect(container.querySelector('.visx-axis-right')).toBeNull()
69
+ })
70
+ })
71
+
72
+ describe('BandStrip — fills derive from series', () => {
73
+ test('a band takes its state entry colour + fillOpacity; an explicit fill overrides only that band', () => {
74
+ const { container } = renderStrip({
75
+ getBand: (d: Slot) =>
76
+ d.loss !== null && d.loss > 0
77
+ ? { state: 'loss', fill: 'color-mix(in srgb, #a00 62%, transparent)' }
78
+ : getBand(d),
79
+ })
80
+ const fills = [...container.querySelectorAll('rect[fill]')]
81
+ .map((r) => r.getAttribute('fill'))
82
+ .filter((f): f is string => f !== null && f !== 'transparent')
83
+
84
+ // Derived: `alpha(color, fillOpacity)` for clean (0.14) and absent (0.5).
85
+ expect(fills).toContain('color-mix(in srgb, #0a0 14%, transparent)')
86
+ expect(fills).toContain('color-mix(in srgb, #888 50%, transparent)')
87
+ // Overridden: the ramp value, not the series default.
88
+ expect(fills).toContain('color-mix(in srgb, #a00 62%, transparent)')
89
+ })
90
+ })
91
+
92
+ /**
93
+ * The old contract here was "a state key absent from `series` draws nothing". On a strip whose
94
+ * whole vocabulary is measured/not-measured, a missing band is not "unknown" — it is a coverage
95
+ * gap, so a typo'd key silently asserted an absence nobody measured. These pin the replacement:
96
+ * loud where it is a bug being written, honest where it is a feed that grew a state.
97
+ */
98
+ /** Run `fn` with the production dev-gate, restoring whatever the runner had set. */
99
+ function inProd(fn: () => void): void {
100
+ const previous = process.env['NODE_ENV']
101
+ process.env['NODE_ENV'] = 'production'
102
+ try {
103
+ fn()
104
+ } finally {
105
+ if (previous === undefined) delete process.env['NODE_ENV']
106
+ else process.env['NODE_ENV'] = previous
107
+ }
108
+ }
109
+
110
+ describe('BandStrip — an unresolvable state key can never read as absence', () => {
111
+ test('a state naming no `series` entry THROWS in dev, naming the key and the known ones', () => {
112
+ expect(() => renderStrip({ getBand: () => ({ state: 'nope' }) })).toThrow(
113
+ /BandSpan.state "nope" names no `series` entry \(known: clean, loss, absent\)/,
114
+ )
115
+ })
116
+
117
+ test('a marker naming no `series` entry throws in dev too', () => {
118
+ expect(() =>
119
+ renderStrip({ getBand: (d: Slot) => ({ ...getBand(d), marker: { state: 'typo' } }) }),
120
+ ).toThrow(/BandSpan.marker.state "typo"/)
121
+ })
122
+
123
+ test('in production it draws a dashed neutral band — never nothing, never a state fill', () => {
124
+ inProd(() => {
125
+ const { container } = renderStrip({ getBand: () => ({ state: 'nope' }) })
126
+ const painted = [...container.querySelectorAll('rect[fill]')].filter(
127
+ (r) => r.getAttribute('fill') !== 'transparent',
128
+ )
129
+ expect(painted).toHaveLength(3)
130
+ // The unknown treatment belongs to no legend entry: dashed outline, neutral fill, and none of
131
+ // the three series colours.
132
+ for (const rect of painted) {
133
+ expect(rect.getAttribute('stroke-dasharray')).toBe('3 2')
134
+ expect(rect.getAttribute('fill')).toBe(
135
+ 'color-mix(in srgb, var(--vx-neutral) 20%, transparent)',
136
+ )
137
+ }
138
+ })
139
+ })
140
+
141
+ test('in production the tooltip names the unresolved key instead of a state the legend carries', async () => {
142
+ process.env['NODE_ENV'] = 'production'
143
+ try {
144
+ renderStrip({ getBand: () => ({ state: 'nope' }), ariaLabel: 'Availability' })
145
+ fireEvent.keyDown(screen.getByRole('slider'), { key: 'ArrowRight' })
146
+ const tip = await screen.findByRole('tooltip')
147
+ expect(tip.textContent).toContain('Unknown state')
148
+ expect(tip.textContent).toContain('nope')
149
+ expect(tip.textContent).not.toContain('Not measured')
150
+ } finally {
151
+ delete process.env['NODE_ENV']
152
+ }
153
+ })
154
+
155
+ test('`absentState` naming no `series` entry throws in EVERY environment — it is a prop, not a datum', () => {
156
+ inProd(() => {
157
+ expect(() => renderStrip({ absentState: 'ghost' })).toThrow(
158
+ /absentState "ghost" names no `series` entry/,
159
+ )
160
+ })
161
+ })
162
+ })
163
+
164
+ describe('BandStrip — absenceFraction splits a folded band', () => {
165
+ test('a part-measured band draws a measured rect + a hatch rect in proportion', () => {
166
+ const partial: Slot = { key: '2026-08-01', loss: 1, folded: 4, unmeasured: 3 }
167
+ const { container } = renderStrip({ data: [partial] })
168
+ const painted = [...container.querySelectorAll('rect[fill]')].filter(
169
+ (r) => r.getAttribute('fill') !== 'transparent',
170
+ )
171
+ expect(painted).toHaveLength(2)
172
+ const measured = Number(painted[0]?.getAttribute('width'))
173
+ const hatched = Number(painted[1]?.getAttribute('width'))
174
+ // 3 of 4 members unmeasured → the hatch owns three quarters of the band.
175
+ expect(hatched / (measured + hatched)).toBeCloseTo(0.75, 5)
176
+ expect(painted[1]?.getAttribute('fill')).toBe('url(#strip-band-absent)')
177
+ // The hatch starts where the measured share ends — not overlaid on it.
178
+ expect(Number(painted[1]?.getAttribute('x'))).toBeCloseTo(
179
+ Number(painted[0]?.getAttribute('x')) + measured,
180
+ 5,
181
+ )
182
+ })
183
+
184
+ test('a non-finite share (a 0/0 fold count) draws a full band, never a NaN width', () => {
185
+ const degenerate: Slot = { key: '2026-08-01', loss: 1, folded: 0, unmeasured: 0 }
186
+ const { container } = renderStrip({ data: [degenerate] })
187
+ expect(container.innerHTML).not.toContain('NaN')
188
+ const painted = [...container.querySelectorAll('rect[fill]')].filter(
189
+ (r) => r.getAttribute('fill') !== 'transparent',
190
+ )
191
+ expect(painted).toHaveLength(1)
192
+ expect(Number(painted[0]?.getAttribute('width'))).toBeGreaterThan(0)
193
+ })
194
+
195
+ test('a fully absent band is all hatch and no fill', () => {
196
+ const gone: Slot = { key: '2026-08-01', loss: 2, folded: 3, unmeasured: 3 }
197
+ const { container } = renderStrip({ data: [gone] })
198
+ const painted = [...container.querySelectorAll('rect[fill]')].filter(
199
+ (r) => r.getAttribute('fill') !== 'transparent',
200
+ )
201
+ expect(painted).toHaveLength(1)
202
+ expect(painted[0]?.getAttribute('fill')).toBe('url(#strip-band-absent)')
203
+ })
204
+ })
205
+
206
+ describe('BandStrip — fold', () => {
207
+ test('folds to the slots the measured width can draw, and the merge is the consumer’s', () => {
208
+ const many = Array.from({ length: 240 }, (_, i) =>
209
+ slot(`2026-08-${String((i % 28) + 1).padStart(2, '0')}T${String(i).padStart(3, '0')}`, i),
210
+ )
211
+ const merged: Slot[][] = []
212
+ const { container } = renderStrip({
213
+ data: many,
214
+ fold: {
215
+ merge: (group: Slot[]) => {
216
+ merged.push(group)
217
+ return {
218
+ ...group[0]!,
219
+ loss: Math.max(...group.map((g) => g.loss ?? 0)),
220
+ folded: group.length,
221
+ unmeasured: group.filter((g) => g.loss === null).length,
222
+ }
223
+ },
224
+ },
225
+ })
226
+ // plot.width 200 → plotWidth ~132 → cap = floor(132 / 3) = 44 slots.
227
+ const drawn = container.querySelectorAll('.visx-group rect[fill]:not([fill="transparent"])')
228
+ expect(drawn.length).toBeGreaterThan(0)
229
+ expect(drawn.length).toBeLessThan(many.length)
230
+ expect(merged.length).toBe(drawn.length)
231
+ // MAX, not mean — the merge the consumer wrote is the one that ran.
232
+ expect(merged[0]!.length).toBeGreaterThan(1)
233
+ })
234
+
235
+ test('data that already fits is drawn unfolded and `merge` is never called', () => {
236
+ let calls = 0
237
+ renderStrip({ fold: { merge: (g: Slot[]) => (calls++, g[0]!) } })
238
+ expect(calls).toBe(0)
239
+ })
240
+ })
241
+
242
+ describe('BandStrip — the tooltip row is DERIVED from the hovered band’s state', () => {
243
+ test('shows that state’s label + formatValue, and never a state it is not drawing', async () => {
244
+ renderStrip({ ariaLabel: 'Availability' })
245
+ const slider = screen.getByRole('slider')
246
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
247
+
248
+ const tip = await screen.findByRole('tooltip')
249
+ expect(tip.textContent).toContain('No loss')
250
+ expect(tip.textContent).not.toContain('Packet loss')
251
+ expect(tip.textContent).not.toContain('Not measured')
252
+
253
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
254
+ expect(await screen.findByText('2.5%')).toBeTruthy()
255
+ const tip2 = screen.getByRole('tooltip')
256
+ expect(tip2.textContent).toContain('Packet loss')
257
+ expect(tip2.textContent).not.toContain('No loss')
258
+ })
259
+
260
+ test('formatValue returning null renders an em dash, not an empty value', async () => {
261
+ const series: BandStripSeries<Slot>[] = SERIES.map((entry) =>
262
+ entry.key === 'absent'
263
+ ? { ...entry, formatValue: (d: Slot) => (d.loss === null ? null : `${d.loss}%`) }
264
+ : entry,
265
+ )
266
+ renderStrip({ series, ariaLabel: 'Availability' })
267
+ const slider = screen.getByRole('slider')
268
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
269
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
270
+ fireEvent.keyDown(slider, { key: 'ArrowRight' })
271
+
272
+ const tip = await screen.findByRole('tooltip')
273
+ expect(tip.textContent).toContain('Not measured')
274
+ // Distinct from `''`, which renders the label with no value at all.
275
+ expect(tip.textContent).toContain('\u2014')
276
+ })
277
+
278
+ test('the focusable overlay carries the chart’s own aria-label, not a generic one', () => {
279
+ renderStrip({ ariaLabel: 'Availability per bucket' })
280
+ expect(screen.getByRole('slider').getAttribute('aria-label')).toBe('Availability per bucket')
281
+ })
282
+ })