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.
- package/AGENTS.md +29 -12
- package/CHANGELOG.md +28 -0
- package/MIGRATING.md +207 -12
- package/README.md +74 -21
- package/agent/rules/basalt-app.md +33 -3
- package/agent/rules/basalt-charts.md +78 -7
- package/agent/rules/basalt-content.md +3 -3
- package/agent/rules/basalt-mantine.md +4 -4
- package/agent/rules/basalt-tokens.md +35 -6
- package/agent/skills/basalt-app/SKILL.md +25 -12
- package/agent/skills/basalt-charts/SKILL.md +27 -10
- package/agent/templates/CLAUDE-block.md.tpl +3 -2
- package/agent/templates/DESIGN.md.tpl +3 -1
- package/configs/oxlint-plugin.js +49 -8
- package/configs/oxlint-plugin.test.ts +88 -31
- package/dist/charts/hooks/useBandPlot.d.ts +138 -0
- package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
- package/dist/charts/hooks/useBandPlot.js +127 -0
- package/dist/charts/index.d.ts +4 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +14 -0
- package/dist/charts/kinds/BandStrip.d.ts +127 -0
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
- package/dist/charts/kinds/BandStrip.js +271 -0
- package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
- package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
- package/dist/charts/kinds/Bars.d.ts +5 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +2 -0
- package/dist/charts/kinds/MirroredBars.d.ts +95 -0
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
- package/dist/charts/kinds/MirroredBars.js +328 -0
- package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
- package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
- package/dist/charts/kinds/MultiLine.d.ts +5 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +2 -0
- package/dist/charts/kinds/StackedArea.d.ts +5 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +2 -0
- package/dist/charts/kinds/ZonedLine.d.ts +5 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +2 -0
- package/dist/charts/primitives/CartesianChart.d.ts +18 -2
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +5 -2
- package/dist/charts/primitives/HatchPattern.d.ts +31 -0
- package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
- package/dist/charts/primitives/HatchPattern.js +31 -0
- package/dist/cli/index.d.ts +2 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +228 -31
- package/dist/cli/shipped-versions.test.d.ts +2 -0
- package/dist/cli/shipped-versions.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +60 -13
- package/dist/vite.d.ts +42 -3
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +67 -30
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/hooks/useBandPlot.ts +302 -0
- package/src/charts/index.ts +14 -0
- package/src/charts/kinds/BandStrip.test.tsx +282 -0
- package/src/charts/kinds/BandStrip.tsx +457 -0
- package/src/charts/kinds/Bars.tsx +7 -1
- package/src/charts/kinds/DualPanel.test.tsx +10 -4
- package/src/charts/kinds/MirroredBars.test.tsx +232 -0
- package/src/charts/kinds/MirroredBars.tsx +491 -0
- package/src/charts/kinds/MultiLine.tsx +7 -1
- package/src/charts/kinds/StackedArea.tsx +7 -1
- package/src/charts/kinds/ZonedLine.tsx +7 -1
- package/src/charts/primitives/CartesianChart.test.tsx +86 -4
- package/src/charts/primitives/CartesianChart.tsx +28 -3
- package/src/charts/primitives/HatchPattern.tsx +55 -0
- package/src/cli/help.test.ts +74 -1
- package/src/cli/index.ts +436 -52
- package/src/cli/shipped-versions.test.ts +83 -0
- package/src/cli/tokens-css.test.ts +62 -0
- package/src/cli/toolchain-wiring.test.ts +171 -4
- package/src/cli/waiver-audit.test.ts +7 -3
- package/src/guard/check-source.test.ts +211 -42
- package/src/guard/index.ts +149 -23
- package/src/vite.test.ts +79 -0
- package/src/vite.ts +141 -39
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `MirroredBars` — the properties `DualPanel` provably cannot supply: two BAR panes (no line pane),
|
|
3
|
+
* two INDEPENDENT domains resolved from two accessors (not one signed one), and absence hatched
|
|
4
|
+
* across the full band rather than sitting on one side of a baseline.
|
|
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.
|
|
8
|
+
*/
|
|
9
|
+
import { fireEvent, render, screen } from '@testing-library/react'
|
|
10
|
+
import { describe, expect, test } from 'bun:test'
|
|
11
|
+
import { ChartCursorScope } from '../cursor/scope'
|
|
12
|
+
import type { ChartSeries } from '../series'
|
|
13
|
+
import { MirroredBars } from './MirroredBars'
|
|
14
|
+
|
|
15
|
+
type Point = { key: string; down: number | null; up: number | null; absent: number }
|
|
16
|
+
|
|
17
|
+
const SERIES: ChartSeries<Point>[] = [
|
|
18
|
+
{ key: 'down', label: 'Download', color: '#06c', mark: 'bar', getValue: (d) => d.down },
|
|
19
|
+
{ key: 'up', label: 'Upload', color: '#999', mark: 'bar', getValue: (d) => d.up },
|
|
20
|
+
{ key: 'absent', label: 'Not measured', color: '#555', mark: 'bar', getValue: () => null },
|
|
21
|
+
]
|
|
22
|
+
|
|
23
|
+
const DATA: Point[] = [
|
|
24
|
+
{ key: '2026-08-01', down: 800, up: 40, absent: 0 },
|
|
25
|
+
{ key: '2026-08-02', down: 400, up: 20, absent: 0 },
|
|
26
|
+
{ key: '2026-08-03', down: null, up: null, absent: 1 },
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
function renderChart(props: Partial<Parameters<typeof MirroredBars<Point>>[0]> = {}) {
|
|
30
|
+
return render(
|
|
31
|
+
<ChartCursorScope>
|
|
32
|
+
<MirroredBars<Point>
|
|
33
|
+
data={DATA}
|
|
34
|
+
chartId="mb"
|
|
35
|
+
getX={(d) => d.key}
|
|
36
|
+
series={SERIES}
|
|
37
|
+
up={{ key: 'up', format: (v) => `${v}u`, ticks: 2 }}
|
|
38
|
+
down={{ key: 'down', format: (v) => `${v}d`, ticks: 2 }}
|
|
39
|
+
getAbsentFraction={(d) => d.absent}
|
|
40
|
+
height={200}
|
|
41
|
+
{...props}
|
|
42
|
+
/>
|
|
43
|
+
</ChartCursorScope>,
|
|
44
|
+
)
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/** Tick label text of one left axis, in paint order (up pane first, down pane second). */
|
|
48
|
+
function axisText(container: HTMLElement, index: number): string {
|
|
49
|
+
return container.querySelectorAll('.visx-axis-left')[index]?.textContent ?? ''
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
describe('MirroredBars — two panes, two independent domains', () => {
|
|
53
|
+
test('each pane paints its OWN axis in its OWN units — a shared scale could print only one', () => {
|
|
54
|
+
const { container } = renderChart({
|
|
55
|
+
up: { key: 'up', format: (v) => `${v}u`, max: 100, ticks: 2 },
|
|
56
|
+
down: { key: 'down', format: (v) => `${v}d`, max: 1000, ticks: 2 },
|
|
57
|
+
})
|
|
58
|
+
expect(container.querySelectorAll('.visx-axis-left')).toHaveLength(2)
|
|
59
|
+
// Two axes, two domains, two unit suffixes — neither axis carries the other's numbers.
|
|
60
|
+
expect(axisText(container, 0)).toContain('100u')
|
|
61
|
+
expect(axisText(container, 0)).not.toContain('d')
|
|
62
|
+
expect(axisText(container, 1)).toContain('1000d')
|
|
63
|
+
expect(axisText(container, 1)).not.toContain('u')
|
|
64
|
+
})
|
|
65
|
+
|
|
66
|
+
test('an auto pane resolves from its OWN accessor: 40 up beside 800 down', () => {
|
|
67
|
+
const { container } = renderChart()
|
|
68
|
+
// `40` can only come from the up accessor's own maximum; on a shared [0, 800] scale d3 would
|
|
69
|
+
// never place a tick there.
|
|
70
|
+
expect(axisText(container, 0)).toContain('40u')
|
|
71
|
+
expect(axisText(container, 1)).not.toContain('40d')
|
|
72
|
+
})
|
|
73
|
+
|
|
74
|
+
test('a pinned `max` on one pane leaves the other pane’s auto domain alone', () => {
|
|
75
|
+
const { container } = renderChart({ up: { key: 'up', format: (v) => `${v}u`, max: 200 } })
|
|
76
|
+
expect(axisText(container, 0)).toContain('200u')
|
|
77
|
+
// Down stayed auto — its ticks still span its own 800 maximum, not the pinned 200.
|
|
78
|
+
expect(axisText(container, 1)).toContain('500d')
|
|
79
|
+
expect(axisText(container, 1)).not.toContain('200d')
|
|
80
|
+
})
|
|
81
|
+
|
|
82
|
+
test('`autoMaxFloor` widens a quiet pane without touching its sibling', () => {
|
|
83
|
+
const withoutFloor = renderChart()
|
|
84
|
+
expect(axisText(withoutFloor.container, 0)).toContain('40u')
|
|
85
|
+
withoutFloor.unmount()
|
|
86
|
+
|
|
87
|
+
const { container } = renderChart({
|
|
88
|
+
up: { key: 'up', format: (v) => `${v}u`, autoMaxFloor: 500, ticks: 2 },
|
|
89
|
+
})
|
|
90
|
+
// The floor lifted the up domain well past its 40 maximum...
|
|
91
|
+
expect(axisText(container, 0)).toContain('400u')
|
|
92
|
+
expect(axisText(container, 0)).not.toContain('40u')
|
|
93
|
+
// ...and the down pane is unmoved.
|
|
94
|
+
expect(axisText(container, 1)).toContain('500d')
|
|
95
|
+
})
|
|
96
|
+
})
|
|
97
|
+
|
|
98
|
+
describe('MirroredBars — geometry around the shared baseline', () => {
|
|
99
|
+
test('up bars grow upward from the baseline, down bars downward, and both are proportional', () => {
|
|
100
|
+
const { container } = renderChart()
|
|
101
|
+
const rects = [...container.querySelectorAll('rect')].filter(
|
|
102
|
+
(r) => r.getAttribute('fill')?.startsWith('color-mix') === true,
|
|
103
|
+
)
|
|
104
|
+
// Two measured points × two panes.
|
|
105
|
+
expect(rects).toHaveLength(4)
|
|
106
|
+
const [up1, down1, up2, down2] = rects.map((r) => ({
|
|
107
|
+
y: Number(r.getAttribute('y')),
|
|
108
|
+
h: Number(r.getAttribute('height')),
|
|
109
|
+
}))
|
|
110
|
+
// Both panes share ONE baseline: the up bar's bottom edge equals the down bar's top edge.
|
|
111
|
+
expect(up1!.y + up1!.h).toBeCloseTo(down1!.y, 5)
|
|
112
|
+
expect(up2!.y + up2!.h).toBeCloseTo(down2!.y, 5)
|
|
113
|
+
// Half the value, half the height — within each pane's own scale.
|
|
114
|
+
expect(down2!.h / down1!.h).toBeCloseTo(0.5, 5)
|
|
115
|
+
expect(up2!.h / up1!.h).toBeCloseTo(0.5, 5)
|
|
116
|
+
// ...and the two panes are NOT on one scale: 40 up and 800 down draw comparable heights.
|
|
117
|
+
expect(up1!.h).toBeGreaterThan(down1!.h * 0.2)
|
|
118
|
+
})
|
|
119
|
+
|
|
120
|
+
test('an unmeasured point hatches the FULL band, not one side of the baseline', () => {
|
|
121
|
+
const { container } = renderChart()
|
|
122
|
+
const hatched = [...container.querySelectorAll('rect')].filter(
|
|
123
|
+
(r) => r.getAttribute('fill') === 'url(#mb-mirrored-absent)',
|
|
124
|
+
)
|
|
125
|
+
expect(hatched).toHaveLength(1)
|
|
126
|
+
expect(Number(hatched[0]?.getAttribute('y'))).toBe(0)
|
|
127
|
+
const baselineLine = container.querySelector('line[stroke-width="1"]')
|
|
128
|
+
const baselineY = Number(baselineLine?.getAttribute('y1'))
|
|
129
|
+
expect(Number(hatched[0]?.getAttribute('height'))).toBeGreaterThan(baselineY)
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
test('a NaN value draws no bar at all rather than NaN geometry', () => {
|
|
133
|
+
// `deriveTooltipRows` skips only null, so a NaN reaching `scaleLinear` used to emit
|
|
134
|
+
// `y="NaN" height="NaN"` — a bar that silently fails to paint behind a React warning.
|
|
135
|
+
const { container } = renderChart({
|
|
136
|
+
data: [{ key: '2026-08-01', down: Number.NaN, up: Number.NaN, absent: 0 }],
|
|
137
|
+
})
|
|
138
|
+
expect(container.innerHTML).not.toContain('NaN')
|
|
139
|
+
const painted = [...container.querySelectorAll('rect')].filter(
|
|
140
|
+
(r) => r.getAttribute('fill')?.startsWith('color-mix') === true,
|
|
141
|
+
)
|
|
142
|
+
expect(painted).toHaveLength(0)
|
|
143
|
+
})
|
|
144
|
+
|
|
145
|
+
test('`getBarOpacity` dims a qualified measurement without changing its geometry', () => {
|
|
146
|
+
const { container } = renderChart({ getBarOpacity: (d) => (d.key === '2026-08-02' ? 0.45 : 1) })
|
|
147
|
+
const fills = [...container.querySelectorAll('rect')]
|
|
148
|
+
.map((r) => r.getAttribute('fill'))
|
|
149
|
+
.filter((f): f is string => f?.startsWith('color-mix') === true)
|
|
150
|
+
expect(fills).toContain('color-mix(in srgb, #06c 45%, transparent)')
|
|
151
|
+
expect(fills).toContain('color-mix(in srgb, #06c 100%, transparent)')
|
|
152
|
+
})
|
|
153
|
+
})
|
|
154
|
+
|
|
155
|
+
describe('MirroredBars — legend toggle removes a pane whole', () => {
|
|
156
|
+
test('hiding a pane drops its bars AND its axis together', () => {
|
|
157
|
+
const { container } = renderChart()
|
|
158
|
+
expect(container.querySelectorAll('.visx-axis-left')).toHaveLength(2)
|
|
159
|
+
|
|
160
|
+
const toggle = container.querySelector('[data-legend-key="up"]')
|
|
161
|
+
fireEvent.click(toggle as Element)
|
|
162
|
+
|
|
163
|
+
expect(container.querySelectorAll('.visx-axis-left')).toHaveLength(1)
|
|
164
|
+
expect(container.textContent).not.toContain('40u')
|
|
165
|
+
expect(axisText(container, 0)).toContain('500d')
|
|
166
|
+
})
|
|
167
|
+
})
|
|
168
|
+
|
|
169
|
+
describe('MirroredBars — tooltip rows are derived, each in its own pane’s units', () => {
|
|
170
|
+
test('a pane row falls back to that pane’s formatter, never the other pane’s', async () => {
|
|
171
|
+
renderChart({ ariaLabel: 'Carried' })
|
|
172
|
+
const slider = screen.getByRole('slider')
|
|
173
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
174
|
+
|
|
175
|
+
const tip = await screen.findByRole('tooltip')
|
|
176
|
+
expect(tip.textContent).toContain('800d')
|
|
177
|
+
expect(tip.textContent).toContain('40u')
|
|
178
|
+
// The legend-only absence entry has no value, so it never becomes a row.
|
|
179
|
+
expect(tip.textContent).not.toContain('Not measured')
|
|
180
|
+
})
|
|
181
|
+
|
|
182
|
+
test('an unmeasured point yields no pane rows at all rather than a fabricated zero', async () => {
|
|
183
|
+
renderChart({ ariaLabel: 'Carried' })
|
|
184
|
+
const slider = screen.getByRole('slider')
|
|
185
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
186
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
187
|
+
fireEvent.keyDown(slider, { key: 'ArrowRight' })
|
|
188
|
+
|
|
189
|
+
const tip = await screen.findByRole('tooltip')
|
|
190
|
+
expect(tip.textContent).not.toContain('Download')
|
|
191
|
+
expect(tip.textContent).not.toContain('Upload')
|
|
192
|
+
})
|
|
193
|
+
})
|
|
194
|
+
|
|
195
|
+
/**
|
|
196
|
+
* The `BandStrip` silent-drop class, in this kind's shape. An unresolvable pane key left the pane
|
|
197
|
+
* AND its axis unrendered — which on a chart whose point is that idle and unmeasured must stay
|
|
198
|
+
* distinguishable reads as "that half measured nothing".
|
|
199
|
+
*/
|
|
200
|
+
describe('MirroredBars — an unresolvable key can never read as a measured zero', () => {
|
|
201
|
+
test('a pane key naming no `series` entry throws instead of hiding the pane', () => {
|
|
202
|
+
expect(() => renderChart({ up: { key: 'uplaod', format: (v) => `${v}u`, ticks: 2 } })).toThrow(
|
|
203
|
+
/up.key "uplaod" names no `series` entry \(known: down, up, absent\)/,
|
|
204
|
+
)
|
|
205
|
+
})
|
|
206
|
+
|
|
207
|
+
test('it throws in production too — a pane key is a PROP, so a typo is always a wiring error', () => {
|
|
208
|
+
const previous = process.env['NODE_ENV']
|
|
209
|
+
process.env['NODE_ENV'] = 'production'
|
|
210
|
+
try {
|
|
211
|
+
expect(() =>
|
|
212
|
+
renderChart({ down: { key: 'donw', format: (v) => `${v}d`, ticks: 2 } }),
|
|
213
|
+
).toThrow(/down.key "donw"/)
|
|
214
|
+
} finally {
|
|
215
|
+
if (previous === undefined) delete process.env['NODE_ENV']
|
|
216
|
+
else process.env['NODE_ENV'] = previous
|
|
217
|
+
}
|
|
218
|
+
})
|
|
219
|
+
|
|
220
|
+
test('`absentState` naming no `series` entry throws rather than hatching in an unnamed neutral', () => {
|
|
221
|
+
expect(() => renderChart({ absentState: 'ghost' })).toThrow(
|
|
222
|
+
/absentState "ghost" names no `series` entry/,
|
|
223
|
+
)
|
|
224
|
+
})
|
|
225
|
+
|
|
226
|
+
test('a resolvable absentState still draws the hatch in that entry’s colour', () => {
|
|
227
|
+
const { container } = renderChart({ absentState: 'absent' })
|
|
228
|
+
expect(container.querySelector('pattern line')?.getAttribute('stroke')).toBe(
|
|
229
|
+
'color-mix(in srgb, #555 70%, transparent)',
|
|
230
|
+
)
|
|
231
|
+
})
|
|
232
|
+
})
|