basalt-ui 1.23.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 +22 -13
- package/CHANGELOG.md +9 -0
- package/MIGRATING.md +86 -4
- package/README.md +35 -16
- package/agent/rules/basalt-app.md +8 -2
- package/agent/rules/basalt-charts.md +38 -2
- package/agent/rules/basalt-content.md +3 -3
- package/agent/rules/basalt-mantine.md +4 -4
- package/agent/rules/basalt-tokens.md +18 -5
- package/agent/skills/basalt-app/SKILL.md +25 -12
- package/agent/skills/basalt-charts/SKILL.md +4 -2
- package/agent/templates/CLAUDE-block.md.tpl +3 -2
- package/dist/charts/kinds/BandStrip.d.ts +20 -6
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
- package/dist/charts/kinds/BandStrip.js +59 -6
- package/dist/charts/kinds/MirroredBars.d.ts +4 -2
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
- package/dist/charts/kinds/MirroredBars.js +13 -0
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +182 -24
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +29 -3
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/kinds/BandStrip.test.tsx +87 -5
- package/src/charts/kinds/BandStrip.tsx +105 -12
- package/src/charts/kinds/MirroredBars.test.tsx +39 -0
- package/src/charts/kinds/MirroredBars.tsx +38 -2
- package/src/cli/help.test.ts +74 -1
- package/src/cli/index.ts +312 -35
- package/src/cli/tokens-css.test.ts +62 -0
- package/src/guard/check-source.test.ts +73 -0
- package/src/guard/index.ts +67 -7
package/dist/guard/index.js
CHANGED
|
@@ -108,8 +108,28 @@ const RADIUS_PROP_RE = /\bradius=(?:\{[0-9]+\}|"[0-9]+")/g;
|
|
|
108
108
|
const MOTION_TRANSITION_NUMERIC = /\btransition\s*=\s*\{\{[^}]*\b(?:duration|stiffness|damping|mass)\s*:\s*-?\d/g;
|
|
109
109
|
const MOTION_TRANSITION_EASE_ARRAY = /\btransition\s*=\s*\{\{[^}]*\bease\s*:\s*\[/g;
|
|
110
110
|
const RAW_CHART_LEGEND_ARRAY = /<ChartLegend\b[^>]*?\bitems\s*=\s*\{\s*\[/g;
|
|
111
|
-
const CHART_ENTRY_POINT_TAG = /<(
|
|
111
|
+
const CHART_ENTRY_POINT_TAG = /<(MultiLine|Bars|BandStrip|Donut|DualPanel|Heatmap|MirroredBars|ZonedLine|StackedArea|LineSparkline|BarSparkline)\b(?:<[^<>]*>)?(?:=>|[^>])*?>/g;
|
|
112
112
|
const HAS_ARIA_LABEL_PROP = /\bariaLabel\s*=/;
|
|
113
|
+
const LOCAL_COMPONENT_DEF = /(?:^|[\n;}])\s*(?:export\s+)?(?:default\s+)?(?:async\s+)?(?:function|class|const|let|var)\s+([A-Z]\w*)/g;
|
|
114
|
+
const BASALT_NAMED_IMPORT = /import\s+(?:type\s+)?\{([^}]*)\}\s+from\s+['"]basalt-ui(?:\/[^'"]*)?['"]/g;
|
|
115
|
+
function localComponentNames(codeText) {
|
|
116
|
+
const out = /* @__PURE__ */ new Set();
|
|
117
|
+
for (const m of codeText.matchAll(LOCAL_COMPONENT_DEF)) out.add(m[1]);
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
120
|
+
function basaltImportedNames(codeText) {
|
|
121
|
+
const out = /* @__PURE__ */ new Set();
|
|
122
|
+
for (const m of codeText.matchAll(BASALT_NAMED_IMPORT)) {
|
|
123
|
+
for (const raw of m[1].split(",")) {
|
|
124
|
+
const spec = raw.trim().replace(/^type\s+/, "");
|
|
125
|
+
if (spec.length === 0) continue;
|
|
126
|
+
const parts = spec.split(/\s+as\s+/);
|
|
127
|
+
const bound = parts[parts.length - 1].trim();
|
|
128
|
+
if (bound.length > 0) out.add(bound);
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return out;
|
|
132
|
+
}
|
|
113
133
|
const RAW_FORM_CONTROL = /<(?:input|select|textarea)\b/g;
|
|
114
134
|
const RAW_FORM_CONTROL_TAG = /<(?:input|select|textarea)\b(?:=>|[^>])*?>/g;
|
|
115
135
|
const STYLES_INPUT_PART = /\bstyles\s*=\s*\{\{\s*input\s*:\s*\{([^}]*)\}/g;
|
|
@@ -964,15 +984,21 @@ function checkSource(text, relPath, cfg) {
|
|
|
964
984
|
});
|
|
965
985
|
}
|
|
966
986
|
}
|
|
967
|
-
|
|
987
|
+
const unframedChartRuns = GUARD_RULES["unframed-chart"].enabled(cfg) && ruleApplies("unframed-chart", relPath);
|
|
988
|
+
const ariaLabelRuns = GUARD_RULES["chart-missing-aria-label"].enabled(cfg) && ruleApplies("chart-missing-aria-label", relPath);
|
|
989
|
+
const localNames = unframedChartRuns || ariaLabelRuns ? localComponentNames(codeText) : /* @__PURE__ */ new Set();
|
|
990
|
+
const basaltNames = unframedChartRuns || ariaLabelRuns ? basaltImportedNames(codeText) : /* @__PURE__ */ new Set();
|
|
991
|
+
const isShippedTag = (tag) => basaltNames.has(tag) || !localNames.has(tag);
|
|
992
|
+
if (unframedChartRuns && isShippedTag("ChartLegend")) {
|
|
968
993
|
for (const m of codeText.matchAll(RAW_CHART_LEGEND_ARRAY)) {
|
|
969
994
|
const lineNo = codeText.slice(0, (m.index ?? 0) + m[0].length).split("\n").length;
|
|
970
995
|
push("unframed-chart", lineNo, "items={[");
|
|
971
996
|
}
|
|
972
997
|
}
|
|
973
|
-
if (
|
|
998
|
+
if (ariaLabelRuns) {
|
|
974
999
|
for (const m of codeText.matchAll(CHART_ENTRY_POINT_TAG)) {
|
|
975
1000
|
const tagText = m[0];
|
|
1001
|
+
if (!isShippedTag(m[1])) continue;
|
|
976
1002
|
if (HAS_ARIA_LABEL_PROP.test(tagText)) continue;
|
|
977
1003
|
const startLine = codeText.slice(0, m.index ?? 0).split("\n").length;
|
|
978
1004
|
const endLine = startLine + (tagText.split("\n").length - 1);
|
package/llms.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
# basalt-ui v1.23.
|
|
1
|
+
# basalt-ui v1.23.1
|
|
2
2
|
# Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets
|
|
3
3
|
#
|
|
4
4
|
# basalt-ui is an opinionated framework for Mantine-based React apps.
|
|
@@ -19,7 +19,7 @@
|
|
|
19
19
|
# ./tokens.css subpath. Guide: docs/FRAMEWORK-FREE.md in the repo.
|
|
20
20
|
|
|
21
21
|
Package: basalt-ui
|
|
22
|
-
Version: 1.23.
|
|
22
|
+
Version: 1.23.1
|
|
23
23
|
|
|
24
24
|
## basalt-ui
|
|
25
25
|
Import: import { ... } from 'basalt-ui'
|
package/package.json
CHANGED
|
@@ -87,13 +87,77 @@ describe('BandStrip — fills derive from series', () => {
|
|
|
87
87
|
// Overridden: the ramp value, not the series default.
|
|
88
88
|
expect(fills).toContain('color-mix(in srgb, #a00 62%, transparent)')
|
|
89
89
|
})
|
|
90
|
+
})
|
|
90
91
|
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
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\)/,
|
|
95
114
|
)
|
|
96
|
-
|
|
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
|
+
})
|
|
97
161
|
})
|
|
98
162
|
})
|
|
99
163
|
|
|
@@ -193,6 +257,24 @@ describe('BandStrip — the tooltip row is DERIVED from the hovered band’s sta
|
|
|
193
257
|
expect(tip2.textContent).not.toContain('No loss')
|
|
194
258
|
})
|
|
195
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
|
+
|
|
196
278
|
test('the focusable overlay carries the chart’s own aria-label, not a generic one', () => {
|
|
197
279
|
renderStrip({ ariaLabel: 'Availability per bucket' })
|
|
198
280
|
expect(screen.getByRole('slider').getAttribute('aria-label')).toBe('Availability per bucket')
|
|
@@ -25,17 +25,30 @@ import { fmtAxisDate } from '../utils/format'
|
|
|
25
25
|
* numeric value to plot — `formatValue` reads the datum directly.
|
|
26
26
|
*/
|
|
27
27
|
export type BandStripSeries<T> = SeriesStyle & {
|
|
28
|
-
/**
|
|
29
|
-
*
|
|
30
|
-
|
|
28
|
+
/**
|
|
29
|
+
* The derived tooltip row's value for a band in this state.
|
|
30
|
+
*
|
|
31
|
+
* Return `null` for a datum in this state whose reading is ABSENT — the row renders
|
|
32
|
+
* {@link NO_READING} instead, which is the escape `ChartSeries.getValue` has and this shape did
|
|
33
|
+
* not: with no value accessor, an absent reading had to come back as `''` and render as a label
|
|
34
|
+
* with an empty value, indistinguishable from a state whose name is the whole reading.
|
|
35
|
+
*
|
|
36
|
+
* Omit the function entirely for a state whose NAME *is* the whole reading ("Not measured").
|
|
37
|
+
*/
|
|
38
|
+
formatValue?: (d: T) => string | null
|
|
31
39
|
}
|
|
32
40
|
|
|
33
41
|
/** One drawn band — which state it is in, and the three ways a state can be qualified. */
|
|
34
42
|
export type BandSpan = {
|
|
35
43
|
/**
|
|
36
44
|
* Series key naming this band's state: the tie to its legend entry, its fill, and its derived
|
|
37
|
-
* tooltip row.
|
|
38
|
-
*
|
|
45
|
+
* tooltip row.
|
|
46
|
+
*
|
|
47
|
+
* A key not present in `series` is a bug TypeScript cannot catch (this is a `string`), and it
|
|
48
|
+
* used to draw NOTHING — which on a strip whose vocabulary is measured/not-measured is
|
|
49
|
+
* indistinguishable from a real coverage gap, so a typo silently asserted "not measured". It now
|
|
50
|
+
* throws in development and draws an explicit unknown band in production; see
|
|
51
|
+
* {@link unknownStateError} for why the two differ.
|
|
39
52
|
*/
|
|
40
53
|
state: string
|
|
41
54
|
/**
|
|
@@ -99,7 +112,8 @@ export type BandStripProps<T> = {
|
|
|
99
112
|
legend?: ChartLegendConfig | false
|
|
100
113
|
/** Colour of the absence hatch. Names a `series` entry, so absence is legended like any other
|
|
101
114
|
* state — a hatched band is the one mark a reader cannot decode from an axis. Default
|
|
102
|
-
* `VX.neutral`, unnamed.
|
|
115
|
+
* `VX.neutral`, unnamed. A key naming no `series` entry throws (a PROP, so a typo can only be a
|
|
116
|
+
* wiring error) rather than falling back to the unnamed default. */
|
|
103
117
|
absentState?: string
|
|
104
118
|
/** Per-side overrides of the measured margins — applied last. */
|
|
105
119
|
margin?: Partial<ChartMargin>
|
|
@@ -112,6 +126,52 @@ export type BandStripProps<T> = {
|
|
|
112
126
|
|
|
113
127
|
const DEFAULT_MARKER_INSET = 6
|
|
114
128
|
|
|
129
|
+
/** Rendered by a `formatValue` that returned `null` — an absent reading, told apart from a state
|
|
130
|
+
* whose name is the whole reading (which renders no value at all). */
|
|
131
|
+
const NO_READING = '\u2014'
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* The one band treatment that belongs to no legend entry. A dashed neutral outline appears nowhere
|
|
135
|
+
* else on a strip — not as a state fill, not as the absence hatch — so an unresolvable key reads as
|
|
136
|
+
* "unnamed", never as data.
|
|
137
|
+
*/
|
|
138
|
+
const UNKNOWN_MARK = {
|
|
139
|
+
fill: alpha(VX.neutral, 0.2),
|
|
140
|
+
stroke: VX.neutral,
|
|
141
|
+
strokeWidth: 1,
|
|
142
|
+
strokeDasharray: '3 2',
|
|
143
|
+
} as const
|
|
144
|
+
|
|
145
|
+
/** The house dev gate (`src/provider`, `src/agent-chat` use the same expression): `basaltViteConfig`
|
|
146
|
+
* defines `process.env.NODE_ENV`, so a production bundle constant-folds this to `false` and drops
|
|
147
|
+
* the throw. Read per call, never hoisted to a module const, so a test can flip it. */
|
|
148
|
+
function isDev(): boolean {
|
|
149
|
+
return process.env['NODE_ENV'] !== 'production'
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
/**
|
|
153
|
+
* A `state` (or `marker.state`) naming no `series` entry.
|
|
154
|
+
*
|
|
155
|
+
* Dev throws, production draws {@link UNKNOWN_MARK}: `state` comes off the DATUM, so a feed that
|
|
156
|
+
* grows a state basalt has never seen must degrade to an honest unknown rather than take the page
|
|
157
|
+
* down — while a typo, which is the same input, still fails loudly everywhere it is being written.
|
|
158
|
+
* The one outcome ruled out in both is the old one: dropping the band, which renders as absence.
|
|
159
|
+
*/
|
|
160
|
+
function unknownStateError(what: string, key: string, known: Iterable<string>): Error {
|
|
161
|
+
return new Error(
|
|
162
|
+
`BandStrip: ${what} "${key}" names no \`series\` entry (known: ${[...known].join(', ')}) — so ` +
|
|
163
|
+
'it has no legend entry, no colour and no tooltip row. Add the state to `series`, or map the ' +
|
|
164
|
+
'datum onto one that is there.',
|
|
165
|
+
)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** A band's tooltip value: `null` from `formatValue` is an absent reading, `undefined` (no
|
|
169
|
+
* `formatValue` at all) is a state whose label is the whole row. */
|
|
170
|
+
function formatBandValue<T>(style: BandStripSeries<T>, datum: T): string {
|
|
171
|
+
if (style.formatValue === undefined) return ''
|
|
172
|
+
return style.formatValue(datum) ?? NO_READING
|
|
173
|
+
}
|
|
174
|
+
|
|
115
175
|
/**
|
|
116
176
|
* A 1-D categorical band strip: one rect per slot over a shared x axis, no y dimension at all.
|
|
117
177
|
*
|
|
@@ -187,6 +247,9 @@ function BandStripPlot<T>(props: BandStripPlotProps<T>) {
|
|
|
187
247
|
const { bands, margin, plotWidth, scale, cursor, point, crosshairX, step, bandWidth } = band
|
|
188
248
|
const stripHeight = Math.max(plot.height - margin.top - margin.bottom, 1)
|
|
189
249
|
const hatchId = `${chartId}-band-absent`
|
|
250
|
+
if (absentState !== undefined && !styleByKey.has(absentState)) {
|
|
251
|
+
throw unknownStateError('absentState', absentState, styleByKey.keys())
|
|
252
|
+
}
|
|
190
253
|
const absentColor =
|
|
191
254
|
(absentState === undefined ? undefined : styleByKey.get(absentState)?.color) ?? VX.neutral
|
|
192
255
|
|
|
@@ -195,16 +258,35 @@ function BandStripPlot<T>(props: BandStripPlotProps<T>) {
|
|
|
195
258
|
bands.forEach((d, i) => {
|
|
196
259
|
const span = getBand(d)
|
|
197
260
|
const style = styleByKey.get(span.state)
|
|
198
|
-
|
|
261
|
+
const x = i * step
|
|
262
|
+
if (style === undefined) {
|
|
263
|
+
if (isDev()) throw unknownStateError('BandSpan.state', span.state, styleByKey.keys())
|
|
264
|
+
out.push(
|
|
265
|
+
<rect
|
|
266
|
+
key={getX(d)}
|
|
267
|
+
{...UNKNOWN_MARK}
|
|
268
|
+
x={x}
|
|
269
|
+
y={0}
|
|
270
|
+
width={bandWidth}
|
|
271
|
+
height={stripHeight}
|
|
272
|
+
rx={1}
|
|
273
|
+
pointerEvents="none"
|
|
274
|
+
/>,
|
|
275
|
+
)
|
|
276
|
+
return
|
|
277
|
+
}
|
|
278
|
+
if (hidden.has(span.state)) return
|
|
199
279
|
// Clamped AND finite-checked. `foldedFrom` is carried by the consumer's datum, so a
|
|
200
280
|
// 0/0 on an un-folded slot arrives here as NaN — which `Math.min`/`Math.max` propagate
|
|
201
281
|
// straight into `width="NaN"`, a band that silently fails to paint.
|
|
202
282
|
const absent = clampFraction(span.absentFraction)
|
|
203
283
|
const measuredWidth = bandWidth * (1 - absent)
|
|
204
284
|
const hatchWidth = bandWidth - measuredWidth
|
|
205
|
-
const x = i * step
|
|
206
285
|
const markerKey = span.marker?.state ?? span.state
|
|
207
286
|
const markerColor = styleByKey.get(markerKey)?.color
|
|
287
|
+
if (span.marker !== undefined && markerColor === undefined && isDev()) {
|
|
288
|
+
throw unknownStateError('BandSpan.marker.state', markerKey, styleByKey.keys())
|
|
289
|
+
}
|
|
208
290
|
const inset = span.marker?.inset ?? DEFAULT_MARKER_INSET
|
|
209
291
|
out.push(
|
|
210
292
|
<g key={getX(d)}>
|
|
@@ -230,14 +312,14 @@ function BandStripPlot<T>(props: BandStripPlotProps<T>) {
|
|
|
230
312
|
pointerEvents="none"
|
|
231
313
|
/>
|
|
232
314
|
)}
|
|
233
|
-
{span.marker !== undefined &&
|
|
315
|
+
{span.marker !== undefined && measuredWidth > 0 && (
|
|
234
316
|
<rect
|
|
317
|
+
{...(markerColor === undefined ? UNKNOWN_MARK : { fill: markerColor })}
|
|
235
318
|
x={x}
|
|
236
319
|
y={inset}
|
|
237
320
|
width={measuredWidth}
|
|
238
321
|
height={Math.max(stripHeight - 2 * inset, 2)}
|
|
239
322
|
rx={1}
|
|
240
|
-
fill={markerColor}
|
|
241
323
|
pointerEvents="none"
|
|
242
324
|
/>
|
|
243
325
|
)}
|
|
@@ -347,11 +429,22 @@ function deriveBandRow<T>(
|
|
|
347
429
|
datum: T,
|
|
348
430
|
): { color: string; label: string; value: string; shape: 'line' | 'bar'; dashed: boolean } | null {
|
|
349
431
|
const style = styleByKey.get(span.state)
|
|
350
|
-
|
|
432
|
+
// Production-only: dev threw while drawing. The row names the KEY rather than a state, so the
|
|
433
|
+
// tooltip cannot read back a state the legend does not carry.
|
|
434
|
+
if (style === undefined) {
|
|
435
|
+
return {
|
|
436
|
+
color: VX.neutral,
|
|
437
|
+
label: 'Unknown state',
|
|
438
|
+
value: span.state,
|
|
439
|
+
shape: 'bar',
|
|
440
|
+
dashed: true,
|
|
441
|
+
}
|
|
442
|
+
}
|
|
443
|
+
if (style.tooltip === false || hidden.has(span.state)) return null
|
|
351
444
|
return {
|
|
352
445
|
color: style.color,
|
|
353
446
|
label: style.label,
|
|
354
|
-
value: style
|
|
447
|
+
value: formatBandValue(style, datum),
|
|
355
448
|
shape: style.mark === 'line' ? 'line' : 'bar',
|
|
356
449
|
dashed: style.dash === 'dashed',
|
|
357
450
|
}
|
|
@@ -191,3 +191,42 @@ describe('MirroredBars — tooltip rows are derived, each in its own pane’s un
|
|
|
191
191
|
expect(tip.textContent).not.toContain('Upload')
|
|
192
192
|
})
|
|
193
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
|
+
})
|
|
@@ -25,7 +25,8 @@ import { fmtAxisDate } from '../utils/format'
|
|
|
25
25
|
|
|
26
26
|
/** One of the two panes. Each resolves its own domain — that is the whole point of the kind. */
|
|
27
27
|
export type MirroredBarPane = {
|
|
28
|
-
/** Key of the `series` entry this pane draws: its colour, its legend entry, its tooltip row.
|
|
28
|
+
/** Key of the `series` entry this pane draws: its colour, its legend entry, its tooltip row.
|
|
29
|
+
* A key naming no entry throws — see {@link assertPaneKey}. */
|
|
29
30
|
key: string
|
|
30
31
|
/**
|
|
31
32
|
* The pane's upper bound. `'auto'` (default) is its own visible maximum; a number pins it.
|
|
@@ -85,7 +86,8 @@ export type MirroredBarsProps<T> = {
|
|
|
85
86
|
cursorResolution?: CursorResolution
|
|
86
87
|
tooltip?: BandTooltipConfig<T> | false
|
|
87
88
|
legend?: ChartLegendConfig | false
|
|
88
|
-
/** Series key whose colour the absence hatch is drawn in. Default `VX.neutral`.
|
|
89
|
+
/** Series key whose colour the absence hatch is drawn in. Default `VX.neutral`. A key naming no
|
|
90
|
+
* `series` entry throws rather than falling back to the unnamed default. */
|
|
89
91
|
absentState?: string
|
|
90
92
|
margin?: Partial<ChartMargin>
|
|
91
93
|
ariaLabel?: string
|
|
@@ -95,6 +97,31 @@ export type MirroredBarsProps<T> = {
|
|
|
95
97
|
const DEFAULT_UP_FRACTION = 0.35
|
|
96
98
|
const DEFAULT_PANE_TICKS = 3
|
|
97
99
|
|
|
100
|
+
/**
|
|
101
|
+
* A pane key naming no `series` entry.
|
|
102
|
+
*
|
|
103
|
+
* This is `BandStrip`'s silent-drop bug in the shape this kind has: an unresolvable `up.key` left
|
|
104
|
+
* `upSeries` undefined, which made `upVisible` false, which hid the pane AND its axis — rendering
|
|
105
|
+
* as "that half measured nothing", the measured-and-idle state the whole kind exists to keep
|
|
106
|
+
* distinguishable from absence. Unlike `BandSpan.state`, a pane key is a PROP and never comes off a
|
|
107
|
+
* datum, so a typo can only ever be a wiring error: this throws in every environment, with no
|
|
108
|
+
* production fallback, because there is no honest way to draw a pane whose colour and legend entry
|
|
109
|
+
* do not exist.
|
|
110
|
+
*/
|
|
111
|
+
function assertPaneKey<T>(
|
|
112
|
+
side: 'up' | 'down',
|
|
113
|
+
key: string,
|
|
114
|
+
resolved: ChartSeries<T> | undefined,
|
|
115
|
+
series: readonly ChartSeries<T>[],
|
|
116
|
+
): void {
|
|
117
|
+
if (resolved !== undefined) return
|
|
118
|
+
throw new Error(
|
|
119
|
+
`MirroredBars: ${side}.key "${key}" names no \`series\` entry ` +
|
|
120
|
+
`(known: ${series.map((s) => s.key).join(', ')}) — the pane would silently vanish, which ` +
|
|
121
|
+
'reads as a measured zero. Add the series, or point the pane at one that exists.',
|
|
122
|
+
)
|
|
123
|
+
}
|
|
124
|
+
|
|
98
125
|
/**
|
|
99
126
|
* Two bar panes over ONE x scale and one cursor, mirrored around a shared baseline, each in its
|
|
100
127
|
* own independent domain.
|
|
@@ -159,6 +186,8 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
|
|
|
159
186
|
const seriesByKey = useMemo(() => new Map(series.map((s) => [s.key, s])), [series])
|
|
160
187
|
const upSeries = seriesByKey.get(up.key)
|
|
161
188
|
const downSeries = seriesByKey.get(down.key)
|
|
189
|
+
assertPaneKey('up', up.key, upSeries, series)
|
|
190
|
+
assertPaneKey('down', down.key, downSeries, series)
|
|
162
191
|
const upVisible = upSeries !== undefined && !hidden.has(up.key)
|
|
163
192
|
const downVisible = downSeries !== undefined && !hidden.has(down.key)
|
|
164
193
|
|
|
@@ -221,6 +250,13 @@ function MirroredBarsPlot<T>(props: MirroredBarsPlotProps<T>) {
|
|
|
221
250
|
)
|
|
222
251
|
|
|
223
252
|
const hatchId = `${chartId}-mirrored-absent`
|
|
253
|
+
if (absentState !== undefined && !seriesByKey.has(absentState)) {
|
|
254
|
+
throw new Error(
|
|
255
|
+
`MirroredBars: absentState "${absentState}" names no \`series\` entry ` +
|
|
256
|
+
`(known: ${series.map((s) => s.key).join(', ')}) — the hatch would fall back to an unnamed ` +
|
|
257
|
+
'neutral, and a hatched bar is the one mark a reader cannot decode from either axis.',
|
|
258
|
+
)
|
|
259
|
+
}
|
|
224
260
|
const absentColor =
|
|
225
261
|
(absentState === undefined ? undefined : seriesByKey.get(absentState)?.color) ?? VX.neutral
|
|
226
262
|
|
package/src/cli/help.test.ts
CHANGED
|
@@ -4,7 +4,15 @@
|
|
|
4
4
|
* matched `cmd === 'sync'` first and `--help` was just another item in `flags`, ignored).
|
|
5
5
|
*/
|
|
6
6
|
import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
|
|
7
|
-
import {
|
|
7
|
+
import {
|
|
8
|
+
existsSync,
|
|
9
|
+
mkdirSync,
|
|
10
|
+
mkdtempSync,
|
|
11
|
+
readdirSync,
|
|
12
|
+
readFileSync,
|
|
13
|
+
rmSync,
|
|
14
|
+
writeFileSync,
|
|
15
|
+
} from 'node:fs'
|
|
8
16
|
import { tmpdir } from 'node:os'
|
|
9
17
|
import { resolve } from 'node:path'
|
|
10
18
|
|
|
@@ -95,3 +103,68 @@ describe('run() --help short-circuits before dispatch', () => {
|
|
|
95
103
|
expect(existsSync(resolve(dir, MANIFEST_PATH))).toBe(true)
|
|
96
104
|
})
|
|
97
105
|
})
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* `--version` and the unknown-flag gate — one failure mode, two halves.
|
|
109
|
+
*
|
|
110
|
+
* Six consumer repos in one upgrade round reached for `basalt-ui --version` to prove WHICH CLI ran
|
|
111
|
+
* (a `bunx` cache does not re-resolve, so a pinned version and the version that gates you can
|
|
112
|
+
* differ) and got a usage dump with no version in it. The other half is the same shape: an
|
|
113
|
+
* unrecognized FLAG was silently ignored and the command exited 0, so a mistyped gate read as a
|
|
114
|
+
* passing gate.
|
|
115
|
+
*/
|
|
116
|
+
describe('run() --version and the unknown-flag gate', () => {
|
|
117
|
+
const version = (
|
|
118
|
+
JSON.parse(readFileSync(resolve(import.meta.dir, '../../package.json'), 'utf8')) as {
|
|
119
|
+
version: string
|
|
120
|
+
}
|
|
121
|
+
).version
|
|
122
|
+
|
|
123
|
+
it('`--version` prints the bare resolved version and exits 0', () => {
|
|
124
|
+
const { code, log } = capture(() => run(['--version'], dir))
|
|
125
|
+
expect(code).toBe(0)
|
|
126
|
+
expect(log.trim()).toBe(version)
|
|
127
|
+
})
|
|
128
|
+
|
|
129
|
+
it('`-v` and `version` are the same thing', () => {
|
|
130
|
+
expect(capture(() => run(['-v'], dir)).log.trim()).toBe(version)
|
|
131
|
+
expect(capture(() => run(['version'], dir)).log.trim()).toBe(version)
|
|
132
|
+
})
|
|
133
|
+
|
|
134
|
+
it('the printed line is greppable on its own — no usage block around it', () => {
|
|
135
|
+
const { log } = capture(() => run(['--version'], dir))
|
|
136
|
+
expect(log).not.toContain('Usage: basalt')
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
it('an unrecognized flag exits 1 and NAMES it, on a command that would otherwise pass', () => {
|
|
140
|
+
const { code, log } = capture(() => run(['doctor', '--json'], dir))
|
|
141
|
+
expect(code).toBe(1)
|
|
142
|
+
expect(log).toContain("unrecognized flag '--json'")
|
|
143
|
+
expect(log).toContain('--tokens-only')
|
|
144
|
+
})
|
|
145
|
+
|
|
146
|
+
it('a near-miss flag does not slip through as a silent no-op', () => {
|
|
147
|
+
const { code, log } = capture(() => run(['check-theme', '--audit-allow'], dir))
|
|
148
|
+
expect(code).toBe(1)
|
|
149
|
+
expect(log).toContain("unrecognized flag '--audit-allow'")
|
|
150
|
+
})
|
|
151
|
+
|
|
152
|
+
it('a value-taking flag does not make its VALUE look like an unknown flag', () => {
|
|
153
|
+
const out = resolve(dir, 'tokens.css')
|
|
154
|
+
const { code } = capture(() => run(['tokens:css', '--out', out, '--only', 'core'], dir))
|
|
155
|
+
expect(code).toBe(0)
|
|
156
|
+
})
|
|
157
|
+
|
|
158
|
+
it('an unknown COMMAND names what it did not understand, above the usage block', () => {
|
|
159
|
+
const { code, log } = capture(() => run(['check-theme --audit-allows'], dir))
|
|
160
|
+
expect(code).toBe(1)
|
|
161
|
+
expect(log).toContain("unknown command 'check-theme --audit-allows'")
|
|
162
|
+
expect(log).toContain('Usage: basalt')
|
|
163
|
+
})
|
|
164
|
+
|
|
165
|
+
it('`--help` still wins over the flag gate, so a reader can always find out what is accepted', () => {
|
|
166
|
+
const { code, log } = capture(() => run(['doctor', '--json', '--help'], dir))
|
|
167
|
+
expect(code).toBe(0)
|
|
168
|
+
expect(log).toContain('Usage: basalt')
|
|
169
|
+
})
|
|
170
|
+
})
|