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.
@@ -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 = /<(?:MultiLine|Bars|BandStrip|Donut|DualPanel|Heatmap|MirroredBars|ZonedLine|StackedArea|LineSparkline|BarSparkline)\b(?:<[^<>]*>)?(?:=>|[^>])*?>/g;
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
- if (GUARD_RULES["unframed-chart"].enabled(cfg) && ruleApplies("unframed-chart", relPath)) {
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 (GUARD_RULES["chart-missing-aria-label"].enabled(cfg) && ruleApplies("chart-missing-aria-label", relPath)) {
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.0
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.0
22
+ Version: 1.23.1
23
23
 
24
24
  ## basalt-ui
25
25
  Import: import { ... } from 'basalt-ui'
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "basalt-ui",
3
- "version": "1.23.0",
3
+ "version": "1.23.1",
4
4
  "description": "Opinionated framework for Mantine-based React apps — theme, app shell, visx charts, toolchain presets",
5
5
  "keywords": [
6
6
  "charts",
@@ -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
- test('a state key absent from `series` draws nothing rather than an unlegended mark', () => {
92
- const { container } = renderStrip({ getBand: () => ({ state: 'nope' }) })
93
- const painted = [...container.querySelectorAll('rect[fill]')].filter(
94
- (r) => r.getAttribute('fill') !== 'transparent',
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
- expect(painted).toHaveLength(0)
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
- /** The derived tooltip row's value for a band in this state. Omit for a state whose NAME is the
29
- * whole reading ("Not measured"), which then renders as a label with an empty value. */
30
- formatValue?: (d: T) => string
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. A key not present in `series` draws nothing — the loud failure is deliberate,
38
- * since the alternative is a mark no legend entry accounts for.
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
- if (style === undefined || hidden.has(span.state)) return
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 && markerColor !== undefined && measuredWidth > 0 && (
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
- if (style === undefined || style.tooltip === false || hidden.has(span.state)) return null
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.formatValue?.(datum) ?? '',
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
 
@@ -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 { existsSync, mkdirSync, mkdtempSync, readdirSync, rmSync, writeFileSync } from 'node:fs'
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
+ })