basalt-ui 1.21.0 → 1.23.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/AGENTS.md +11 -1
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +205 -9
  4. package/README.md +63 -16
  5. package/agent/rules/basalt-app.md +39 -9
  6. package/agent/rules/basalt-charts.md +41 -6
  7. package/agent/rules/basalt-router.md +1 -1
  8. package/agent/rules/basalt-state.md +4 -4
  9. package/agent/rules/basalt-tokens.md +25 -4
  10. package/agent/skills/basalt-app/SKILL.md +4 -3
  11. package/agent/skills/basalt-charts/SKILL.md +23 -8
  12. package/agent/templates/DESIGN.md.tpl +3 -1
  13. package/configs/oxlint-plugin.js +86 -9
  14. package/configs/oxlint-plugin.test.ts +173 -0
  15. package/dist/charts/hooks/useBandPlot.d.ts +138 -0
  16. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
  17. package/dist/charts/hooks/useBandPlot.js +127 -0
  18. package/dist/charts/index.d.ts +4 -0
  19. package/dist/charts/index.d.ts.map +1 -1
  20. package/dist/charts/index.js +14 -0
  21. package/dist/charts/kinds/BandStrip.d.ts +113 -0
  22. package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
  23. package/dist/charts/kinds/BandStrip.js +218 -0
  24. package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
  25. package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
  26. package/dist/charts/kinds/Bars.d.ts +5 -1
  27. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  28. package/dist/charts/kinds/Bars.js +2 -0
  29. package/dist/charts/kinds/MirroredBars.d.ts +93 -0
  30. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
  31. package/dist/charts/kinds/MirroredBars.js +315 -0
  32. package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
  33. package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
  34. package/dist/charts/kinds/MultiLine.d.ts +5 -1
  35. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  36. package/dist/charts/kinds/MultiLine.js +2 -0
  37. package/dist/charts/kinds/StackedArea.d.ts +5 -1
  38. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  39. package/dist/charts/kinds/StackedArea.js +2 -0
  40. package/dist/charts/kinds/ZonedLine.d.ts +5 -1
  41. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  42. package/dist/charts/kinds/ZonedLine.js +2 -0
  43. package/dist/charts/primitives/CartesianChart.d.ts +18 -2
  44. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  45. package/dist/charts/primitives/CartesianChart.js +5 -2
  46. package/dist/charts/primitives/HatchPattern.d.ts +31 -0
  47. package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
  48. package/dist/charts/primitives/HatchPattern.js +31 -0
  49. package/dist/cli/index.d.ts +9 -1
  50. package/dist/cli/index.d.ts.map +1 -1
  51. package/dist/cli/index.js +275 -48
  52. package/dist/cli/shipped-versions.test.d.ts +2 -0
  53. package/dist/cli/shipped-versions.test.d.ts.map +1 -0
  54. package/dist/guard/index.d.ts +67 -0
  55. package/dist/guard/index.d.ts.map +1 -1
  56. package/dist/guard/index.js +70 -11
  57. package/dist/vite.d.ts +44 -2
  58. package/dist/vite.d.ts.map +1 -1
  59. package/dist/vite.js +71 -26
  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 +200 -0
  65. package/src/charts/kinds/BandStrip.tsx +364 -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 +193 -0
  69. package/src/charts/kinds/MirroredBars.tsx +455 -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/check-theme.test.ts +35 -0
  77. package/src/cli/help.test.ts +5 -1
  78. package/src/cli/index.ts +552 -79
  79. package/src/cli/shipped-versions.test.ts +83 -0
  80. package/src/cli/toolchain-wiring.test.ts +362 -6
  81. package/src/cli/waiver-audit.test.ts +88 -4
  82. package/src/guard/check-source.test.ts +293 -0
  83. package/src/guard/index.ts +209 -20
  84. package/src/vite.test.ts +112 -0
  85. package/src/vite.ts +147 -31
@@ -58,11 +58,13 @@ radial/matrix shape, is not optional — two `basalt` oxlint plugin rules
58
58
  compose `CartesianChart`. **Since 1.20.0 every assembly node is reported and waived on its own**
59
59
  — one comment no longer grants the whole file permanent immunity, which is how a 604-line chart
60
60
  file went unpoliced. To waive ONE node, put `theme-allow hand-rolled-plot — <why>` on it (or on a
61
- comment-only line above it). A genuinely non-single-plot shape (multi-pane, radial, matrix)
62
- declares the whole file with `theme-allow-file hand-rolled-plot — two panes over one x scale`,
63
- anywhere in it; `DualPanel` carries the repo's only one. **`theme-allow-file` is the 1.20.1
64
- spelling** — at 1.20.0 a node-scoped annotation was silently promoted to a file declaration, so
65
- per-node scoping was not expressible; move the one word. The file that DEFINES `CartesianChart` is
61
+ comment-only line above it). A genuinely non-single-plot shape (multi-pane, radial, matrix, or a
62
+ strip with no y dimension) declares the whole file with `theme-allow-file hand-rolled-plot —
63
+ <why>`, anywhere in it; basalt's own tree carries three, on `DualPanel`, `BandStrip` and
64
+ `MirroredBars`. `Donut` and `Heatmap` are non-single-plot too and carry none — they render no
65
+ assembly primitive, so the rule never fires and there is nothing to waive.
66
+ **`theme-allow-file` is the 1.21.0 spelling** — at 1.20.0 a node-scoped annotation was silently
67
+ promoted to a file declaration, so per-node scoping was not expressible; move the one word. The file that DEFINES `CartesianChart` is
66
68
  exempt definitionally (detected by declaration, not by path), since a rule saying "compose X"
67
69
  cannot fire inside X.
68
70
  - **`basalt/chart-legend-literal`** — a hand-written array literal passed to `ChartLegend`'s
@@ -95,7 +97,7 @@ ships no map kind.
95
97
  - **Want `@visx/geo` anyway?** Install it yourself — basalt doesn't pin it — and keep it under a
96
98
  `charts/` segment like every other visx import, same as any bespoke chart.
97
99
  - **Style map chrome with `--vx-*` tokens** (`VX.*`/`alpha()`) so it matches the rest of the app.
98
- - **The guard trap, narrowed at 1.20.1:** `check-theme`'s `inline-spacing` kind is a per-line regex
100
+ - **The guard trap, narrowed at 1.21.0:** `check-theme`'s `inline-spacing` kind is a per-line regex
99
101
  over raw source, not an AST pass. A **unitless** number now only reports inside a style-object
100
102
  context (a `style=`/`sx=`/`css=` attribute, or a const whose name or type says styles), so a map's
101
103
  pixel geometry — `fitBounds({ padding: 48 })`, marker offsets — no longer trips it: an options bag
@@ -160,6 +162,13 @@ ships no map kind.
160
162
  onto one domain value, silently dropping one from the plot (see #6 below for why that's the
161
163
  cursor's problem too). `Heatmap` is deliberately excluded: its existing `colLabel`/`rowLabel`
162
164
  already are that seam, and a second prop over one concern would fork them.
165
+ **Which x ticks get painted is a separate seam**, `xTickValues?: (keys, xMax) => readonly
166
+ string[]`, on `CartesianChart` and forwarded by `Bars`/`MultiLine`/`StackedArea`/`ZonedLine`
167
+ plus both band kinds. Resolution order is explicit VALUES → explicit COUNT (`xTicks`, unchanged)
168
+ → as many as fit (`smartTicks`); omit both and nothing moves. Reach for it on a dense time axis,
169
+ because **a count cannot express one**: `smartTicks`/`smartTicksEvery` append the final key
170
+ unconditionally, so any count that does not land exactly on the last index paints two labels on
171
+ top of each other at the right edge — at every count, not at an unlucky one.
163
172
  **The x axis is CATEGORICAL, and this is not inferable from the API.** `CartesianChart` builds
164
173
  its x scale as `scalePoint<string>`, so N points are N evenly spaced positions whatever the
165
174
  values behind the keys — there is no linear or time x scale. Correct and invisible for a domain
@@ -332,6 +341,24 @@ per-series `getValue` accessors on `ChartSeries<T>`):
332
341
  wrapper.
333
342
  - **`Donut`** — proportional donut with an optional center-content overlay. Composes `ChartFrame`
334
343
  directly (radial, not cartesian).
344
+ - **`BandStrip`** — 1-D categorical bands over a shared x axis, **no y dimension at all**: one rect
345
+ per slot, `getBand(d) => { state, fill?, absentFraction?, marker? }`. `series` IS the state set —
346
+ it drives the legend, each band's fill and the one derived tooltip row, so a strip cannot name a
347
+ state it does not draw. `absentFraction` paints the share of a folded slot nothing measured as a
348
+ hatch; `marker` carries a fact that must not be readable off the `fill` ramp. `cursorResolution`
349
+ defaults `'leading'` here (a band IS a bucket), not `'nearest'`. Composes `ChartFrame` directly —
350
+ `CartesianChart` renders `AxisLeftNumeric` unconditionally, so this shape could never use it.
351
+ - **`MirroredBars`** — two BAR panes over one x scale, mirrored around one baseline, each pane in
352
+ its own domain. `up`/`down` take `{ key, max?, autoMaxFloor?, ticks?, format }`; `upFraction`
353
+ (default 0.35) is the up pane's share of the band height, which is where the shared baseline sits;
354
+ `getAbsentFraction` hatches an unmeasured slot and `getBarOpacity` dims one. A sibling of
355
+ `DualPanel`, not an extension: `DualPanel`'s top pane is a LINE pane and its bottom takes one
356
+ SIGNED `getBar` over a symmetric domain. Two independent magnitudes are not one signed quantity.
357
+ Composes `ChartFrame` directly.
358
+
359
+ Both band kinds fold their domain by width (`fold: { minBandPx?, merge }`), and `foldBands` is
360
+ exported so a consumer can test their merge against the grouping that will actually run.
361
+ `HatchPattern` / `hatchFill` / `hatchSizeFor` ship the absence fill.
335
362
 
336
363
  `ZonedLine` and `MultiLine` also accept `xZones?: XZoneSpec[]` — the vertical counterpart to a
337
364
  kind's horizontal zone bands, for marking a time window rather than a value range. Each
@@ -477,6 +504,14 @@ margin. Passing `y2` to get a dual-axis chart no longer needs a manual margin nu
477
504
  The escape hatch is `margin={{ left: n }}` (`Partial<ChartMargin>`) on `CartesianChart` — a per-side
478
505
  override applied LAST, after measurement, so it always wins.
479
506
 
507
+ **A band plot has one extra law, because a band axis puts its FIRST tick on the plot's left edge.**
508
+ With no left axis to measure, `autoMargin` stops at `VX.margin.left` and half the first x label
509
+ hangs off the canvas — so `BandStrip` floors its left gutter at half the widest x label, mirroring
510
+ what `autoMargin` already does on the right. Both terminal gutters are then capped (14% of width
511
+ left, 12% right): uncapped, half a `DD.MM HH:MM` label is 48px of a 338px chart. `MirroredBars`
512
+ probes real tick labels from both panes, so it takes the plain measured margin and neither the
513
+ floor nor the cap applies.
514
+
480
515
  `chartMargin(opts?)` (`basalt-ui/tokens`, also re-exported from `basalt-ui/charts`) still exists,
481
516
  but only matters for a bespoke chart that composes `ChartFrame` directly (not `CartesianChart`) and
482
517
  needs a right-axis-sized margin without running its own measurement pass — `DualPanel` and `Heatmap`
@@ -83,7 +83,7 @@ against it.
83
83
  - Update via `navigate({ search: { window: 'all', from: undefined, to: undefined } })` — prefer plain
84
84
  objects over reducers to avoid `| undefined` assignability errors when the schema has defaults.
85
85
 
86
- ### Wire the reader, or the store does nothing (1.20.1)
86
+ ### Wire the reader, or the store does nothing (1.21.0)
87
87
 
88
88
  `validateSearch` is only half the store. It restores the value when you arrive with **no** param —
89
89
  it cannot do anything about a nav link that declares the param itself, and a link written
@@ -25,7 +25,7 @@ Pick the right home for each kind of state — don't dump everything into one st
25
25
  - **UI preferences that must survive navigation but aren't URL-worthy** (sidebar collapsed, panel
26
26
  layout, draft filters) → `createPersistedState` from `basalt-ui/state` — the framework's own
27
27
  versioned localStorage primitive (see below). `BasaltShell` persists its own collapse state
28
- through that same primitive since 1.20.1; use `createPersistedState` for app-level preferences.
28
+ through that same primitive since 1.21.0; use `createPersistedState` for app-level preferences.
29
29
  Reach for a third-party store only when complex cross-component state genuinely warrants it (see
30
30
  escape hatch below).
31
31
 
@@ -67,7 +67,7 @@ const [layout, setLayout] = usePanelLayout()
67
67
  Keys are namespaced `basalt:<key>` automatically and never collide with the theme-scheme guard.
68
68
  Pass a `schema` (Standard Schema) to validate persisted values and fall back to `initial` on mismatch.
69
69
 
70
- ### Mirroring `BasaltShell`'s collapse (changed key at 1.20.1)
70
+ ### Mirroring `BasaltShell`'s collapse (changed key at 1.21.0)
71
71
 
72
72
  `BasaltShell` used `@mantine/hooks`' `useLocalStorage` while `createPersistedState` was the
73
73
  documented house API — so a consumer reading the raw key was complying with the shipped component,
@@ -77,11 +77,11 @@ moves the key:
77
77
  ```ts
78
78
  import { readPersistedValue } from 'basalt-ui/state'
79
79
 
80
- // 1.20.1: localStorage['basalt:<storageKey>'] === '{"v":1,"value":true}'
80
+ // 1.21.0: localStorage['basalt:<storageKey>'] === '{"v":1,"value":true}'
81
81
  const collapsed = readPersistedValue('basalt-sidebar-collapsed', 1)
82
82
  ```
83
83
 
84
- A one-time migration adopts the raw pre-1.20.1 value, so an upgrade does not silently re-expand
84
+ A one-time migration adopts the raw pre-1.21.0 value, so an upgrade does not silently re-expand
85
85
  every sidebar. It is a bridge, not a format — do not write against it.
86
86
 
87
87
  ## Zustand escape hatch (complex cross-component stores)
@@ -43,7 +43,7 @@ The scan reaches slightly beyond `roots`: each root's PARENT contributes its `in
43
43
  meta and a webmanifest's `background_color` actually live. `.json` is never blanket-scanned — name a
44
44
  design-surface JSON in `basalt.include`, the only route to one.
45
45
 
46
- ## `theme-allow` — the 1.20.1 contract
46
+ ## `theme-allow` — the 1.21.0 contract
47
47
 
48
48
  The full grammar, in both engines:
49
49
 
@@ -63,7 +63,7 @@ An id is a guard kind (`raw-surface`, `inline-spacing`, …) or an oxlint plugin
63
63
  ```
64
64
 
65
65
  - **An annotation must START its comment** — after `//`, `/*`, `<!--`, a block-comment gutter `*`,
66
- or nothing but whitespace. Before 1.20.1 any comment that merely _mentioned_ the token parsed as
66
+ or nothing but whitespace. Before 1.21.0 any comment that merely _mentioned_ the token parsed as
67
67
  the bare blanket form and switched every rule off on the line below: linewatch documented its own
68
68
  waivers in a docblock and thereby disarmed the file. Prose about the escape hatch no longer waives
69
69
  anything; everything a consumer actually writes still qualifies.
@@ -71,7 +71,7 @@ An id is a guard kind (`raw-surface`, `inline-spacing`, …) or an oxlint plugin
71
71
  Plain `theme-allow` is the node/line waiver in both engines. At 1.20.0 the two halves of the
72
72
  contract intersected at exactly one legal shape and that shape was whole-file — naming a rule and
73
73
  giving a reason was read as a file declaration, which is why per-node scoping did not actually
74
- ship. **This is the consumer break in 1.20.1**: move each existing file declaration one word.
74
+ ship. **This is the consumer break in 1.21.0**: move each existing file declaration one word.
75
75
  - **A bare `theme-allow` still waives everything on its placement** but reports
76
76
  `theme-allow-unscoped` (`warn` for one minor, then `error`). A bare `theme-allow-file` waives
77
77
  nothing at all — whole-file blanket immunity off one unnamed comment is the thing this contract
@@ -96,6 +96,22 @@ An id is a guard kind (`raw-surface`, `inline-spacing`, …) or an oxlint plugin
96
96
  still ends the block; that is how you say "this comment is not about the next statement".
97
97
  - **`basalt/hand-rolled-plot` grants whole-file immunity only through `theme-allow-file`.** Every
98
98
  assembly node is otherwise waived on its own.
99
+ - **Eight shapes are asserted NOT to waive, in both halves.** They are pinned as unsupported rather
100
+ than merely left out, so "unsupported" and "silently broken" stop reading the same. Don't write
101
+ them:
102
+
103
+ | Shape | Why it does not waive |
104
+ | ---------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------- |
105
+ | a blank line between the annotation and the code — after `//`, after `{/* */}`, or after a `{/*` whose closer sits on its own line | a blank line is how you say "this comment is not about the next statement" |
106
+ | the token mid-sentence in a line comment, a docblock gutter, or a JSX expression comment | prose that MENTIONS the token is not an annotation — the reason it must START its comment |
107
+ | the token inside a string literal | same |
108
+ | above a multi-line OPENING tag, when the finding sits on a later attribute line | a waiver reaches the first line below its comment, not an arbitrary line further down — put it beside the attribute |
109
+
110
+ The whole grid — four axes, comment style × token position × where the closer falls × what
111
+ follows — is pinned row for row in `src/guard/check-source.test.ts` (37 supported + these 8) and
112
+ `configs/oxlint-plugin.test.ts` (32 + 8; five of the guard's rows are CSS/HTML/JSON dialects
113
+ oxlint never sees). Zero disagreements between the two, and no waiver tally moved in any of the
114
+ seven consumer repos.
99
115
 
100
116
  ### Audit them: `basalt-ui check-theme --audit-allows`
101
117
 
@@ -141,7 +157,12 @@ nothing is forked.
141
157
  - **Exact name match only** — the one limit that is structural. Round 5 confirmed the miss rate:
142
158
  linewatch's forks are named `Cell` and `Box`, rb's is `Stat`. Rename the fork and the rule goes
143
159
  quiet, which is what a fork's author naturally does.
144
- - **Scope: everything importable.** Since 1.20.1 it reads all nine published barrels, not just
160
+ - **Scope: basalt consumers only, and component-shaped declarations only.** Since 1.22.0 the rule
161
+ gates on `isBasaltScopedFile` like every other rule in the file, and needs a function, an arrow, a
162
+ `memo`/`forwardRef` wrapper or a class extending one. A plain data class sharing a name with a
163
+ shipped export is a collision, not a fork — it fired on a `SlugTracker` class in a React-free
164
+ package that cannot import basalt-ui.
165
+ - **Scope: everything importable.** Since 1.21.0 it reads all nine published barrels, not just
145
166
  `dist/index.d.ts` — so the `basalt-ui/charts` layer, which is where forks actually live, is finally
146
167
  covered. Names a barrel merely re-exports from a third party are skipped: `./charts` passes
147
168
  `Bar` / `Line` / `Pie` straight through from `@visx/shape`, and a local `Bar` is not a fork of
@@ -120,9 +120,10 @@ Lint wires as `oxlint . && basalt-ui check-theme` (seeded as the `lint:basalt` s
120
120
  guard is the teeth behind the token doctrine. Before writing any composite, check whether basalt
121
121
  already ships it: round 4 found ~15 forked copies of shipped components across seven repos, all
122
122
  passing every gate, because a fork by a token-fluent author uses exactly the right tokens.
123
- `basalt/shadow-basalt-export` reads all nine published barrels since 1.20.1 (the charts layer
124
- included), but it warns only on an **exact** name collision — round 5 found the renamed forks
125
- (`Cell`, `Box`, `Stat`) sail straight past it. It is a tripwire, not coverage; nothing catches the
123
+ `basalt/shadow-basalt-export` reads all nine published barrels since 1.21.0 (the charts layer
124
+ included), but it warns only on an **exact** name collision, in a basalt-scoped file, on a
125
+ component-shaped declaration — round 5 found the renamed forks (`Cell`, `Box`, `Stat`) sail straight
126
+ past it. It is a tripwire, not coverage; nothing catches the
126
127
  rest. Ask before you build; do not wait to be told.
127
128
 
128
129
  The consumer series file (`<first basalt.root>/lib/series.ts` by default — so `src/lib/series.ts` on
@@ -16,9 +16,9 @@ everything below assumes — composing `CartesianChart` (single-plot) or `ChartF
16
16
  Before writing chart code, decide which of three paths you are on:
17
17
 
18
18
  1. **Reuse a shipped kind.** basalt-ui ships `ZonedLine`, `Bars`, `StackedArea`, `MultiLine`,
19
- `DualPanel`, `Heatmap`, `Donut`. If the shape matches, use it — props are declarative (`data`,
20
- `chartId`, `getX`, `series`/`positiveBars`, one `AxisConfig` per axis, zones/thresholds/refLines
21
- as plain arrays). Done.
19
+ `DualPanel`, `Heatmap`, `Donut`, `BandStrip`, `MirroredBars`. If the shape matches, use it —
20
+ props are declarative (`data`, `chartId`, `getX`, `series`/`positiveBars`, one `AxisConfig` per
21
+ axis, zones/thresholds/refLines as plain arrays). Done.
22
22
  - **`ZonedLine`** — a single-series line with zone bands, thresholds, x-zones, refLines.
23
23
  - **`Bars`** — 1+ stacked positive/negative bar series, optional line overlays, dual-axis config.
24
24
  - **`StackedArea`** — opaque stacked bands, auto cumulative-top y-domain.
@@ -33,6 +33,13 @@ Before writing chart code, decide which of three paths you are on:
33
33
  - **`Heatmap`** — category×category intensity grid (`color-mix` alpha), per-cell tooltip,
34
34
  optional gradient legend strip. Replaces bespoke time-of-day.
35
35
  - **`Donut`** — proportional donut, optional `centerContent` overlay.
36
+ - **`BandStrip`** — 1-D categorical bands, no y dimension: one rect per slot,
37
+ `getBand(d) => { state, fill?, absentFraction?, marker? }`. `series` is the state set and
38
+ drives legend, fill and the one derived tooltip row. Replaces a bespoke state/availability
39
+ strip.
40
+ - **`MirroredBars`** — two BAR panes over one x scale and one baseline, each in its own domain
41
+ (`up`/`down` take `{ key, max?, autoMaxFloor?, ticks?, format }`). Not `DualPanel`, whose top
42
+ pane is a LINE and whose bottom takes one SIGNED accessor.
36
43
  2. **Extract a new kind (second instance of a pattern).** When you are about to build the _second_
37
44
  chart of a shape that has no shipped kind, extract a kind. Don't extract on the first; don't
38
45
  wait past the third. A new kind lives in the consumer's chart kinds dir (or, if generic enough
@@ -43,6 +50,12 @@ Before writing chart code, decide which of three paths you are on:
43
50
  genuinely different scales that don't fit any kind's config surface) composes `CartesianChart`
44
51
  or `ChartFrame` directly in the page's chart file — not in `kinds/`.
45
52
 
53
+ **Extracting a kind? Prove it by porting.** A kind is proven by moving a real consumer's call
54
+ sites onto it and reporting what it could NOT express — not by a demo page, which is written
55
+ against the API that already exists. The two band kinds were built that way (linewatch, 1884 → 957
56
+ source lines, 11 `hand-rolled-plot` waivers retired, two live `NaN`-geometry bugs found). Ship the
57
+ "still cannot express" list with the kind.
58
+
46
59
  > Anti-pattern: a single `<Chart type="..." config={...} />` god-component that switches by kind.
47
60
  > That is the Recharts trap. Prefer N small kinds. **If a chart doesn't fit the primitives, add a
48
61
  > kind — don't loosen the primitives.**
@@ -109,8 +122,9 @@ Theme tuning of these series happens in the theme lab — pass them as `groups`
109
122
  ## Compose the primitives (never hand-roll)
110
123
 
111
124
  Every chart — kind or bespoke — composes `CartesianChart` for a single plot rect, or `ChartFrame`
112
- directly for a multi-pane / non-cartesian shape (`DualPanel`'s two panes, `Heatmap`'s grid,
113
- `Donut`'s ring). Either way the contract is the same: the primitive owns margin, scales, grid,
125
+ directly for a multi-pane / non-cartesian shape — five of them ship: `DualPanel`'s two panes,
126
+ `MirroredBars`' two bar panes, `BandStrip`'s missing y dimension, `Heatmap`'s grid, `Donut`'s ring.
127
+ Either way the contract is the same: the primitive owns margin, scales, grid,
114
128
  axes, legend, the shared cursor, and the tooltip; you supply `series` and draw ONLY marks. This is
115
129
  mandatory, not a convention — `basalt/hand-rolled-plot` fails the build on a bypass (see "Respect
116
130
  the boundary" below); it is easier to compose it than to work around it.
@@ -212,9 +226,10 @@ import { ChartCursorScope } from 'basalt-ui/charts'
212
226
  primitive (`AxisLeftNumeric`, `AxisRightNumeric`, `AxisBottomDate`, `HoverOverlay`, `Crosshair`)
213
227
  rendered in a file that doesn't compose `CartesianChart`, per NODE. Escape:
214
228
  `theme-allow hand-rolled-plot — <why>` on the one node, or `theme-allow-file hand-rolled-plot —
215
- <why>` anywhere in the file — how a genuinely non-single-plot shape (`DualPanel`, `Heatmap`,
216
- `Donut`) declares itself. `-file` is the 1.20.1 spelling and it is required. The file that defines
217
- `CartesianChart` is exempt definitionally.
229
+ <why>` anywhere in the file — how a genuinely non-single-plot shape (`DualPanel`, `MirroredBars`,
230
+ `BandStrip`; `Heatmap` and `Donut` render no assembly primitive and need none) declares itself.
231
+ `-file` is the 1.21.0 spelling and it is required. The file that defines `CartesianChart` is
232
+ exempt definitionally.
218
233
  - **No hand-authored legends.** `basalt/chart-legend-literal` fails the build on a hand-written
219
234
  array literal passed to `ChartLegend`'s `items` — derive it from the same `series` the chart draws
220
235
  (`deriveLegend`, or let `ChartFrame`/`CartesianChart` do it) so it can't name a series the plot no
@@ -1,6 +1,8 @@
1
1
  # {{APP_NAME}} — Design
2
2
 
3
- > Managed by basalt-ui ({{BASALT_VERSION}}). This is a **thin** instantiation — it records this
3
+ > Managed by basalt-ui — run `basalt-ui doctor` for the version, never trust a number written into
4
+ > a file (this one is a seed: written once at `init`, then owned by you, so any version stamped
5
+ > here would be frozen at scaffold time). This is a **thin** instantiation — it records this
4
6
  > app's **deltas only** on top of the shipped `basalt-*` rules. The universal law (earned color,
5
7
  > neutral-by-default, three-tier `--vx-*` tokens, theme-is-data, the chart primitive contract, the
6
8
  > elevation/density/shape doctrine) lives in those rules and the `/basalt:design` skill, and is
@@ -245,8 +245,49 @@ function allComments(context) {
245
245
  }
246
246
 
247
247
  /**
248
- * True when a NODE-scoped `theme-allow` covering `ruleId` sits on `node`'s own source line or the
249
- * line above — or when the file carries a `theme-allow-file` declaration for it.
248
+ * Every comment that REACHES `nodeLine` — the comment on the node's own line, the one directly
249
+ * above it, and any further comment separated from that one by nothing but comment.
250
+ *
251
+ * This is the plugin's half of "which node does this annotation scope to", and it has to answer the
252
+ * same question `src/guard`'s forward walk answers: **an annotation reaches the first line below its
253
+ * comment that is not itself comment.** The test used to be a flat `end.line === nodeLine ||
254
+ * nodeLine - 1`, which is the same rule only when the annotation is the LAST comment above the node
255
+ * — so anything sitting between the annotation and the node, comment included, was reported here
256
+ * while the guard waived it:
257
+ *
258
+ * ```text
259
+ * // theme-allow raw-hex — the vendor brand hex, kept in sync with ← a reason that wrapped
260
+ * // the marketing site's own palette onto a second comment
261
+ * const BRAND = '#ff0000'
262
+ *
263
+ * {/* theme-allow card-inset — … *\/}
264
+ * {/* 3. the density row *\/} ← an unrelated note
265
+ * <Card p={0} />
266
+ * ```
267
+ *
268
+ * The wrapped-reason one is not hypothetical: it is pinned in the guard's tests as a shape argo
269
+ * writes. A blank line still separates, because a blank line is not comment — which is what makes
270
+ * "this comment is not about the next statement" expressible in both halves.
271
+ *
272
+ * Returned bottom-up, which is also the cheap order to walk: stop at the first gap.
273
+ */
274
+ function reachingComments(context, nodeLine) {
275
+ const comments = allComments(context).toSorted((a, b) => a.loc.start.line - b.loc.start.line)
276
+ const out = []
277
+ let reach = nodeLine
278
+ for (let i = comments.length - 1; i >= 0; i--) {
279
+ const comment = comments[i]
280
+ if (comment.loc.end.line > nodeLine) continue
281
+ if (comment.loc.end.line < reach - 1) break
282
+ out.push(comment)
283
+ reach = Math.min(reach, comment.loc.start.line)
284
+ }
285
+ return out
286
+ }
287
+
288
+ /**
289
+ * True when a NODE-scoped `theme-allow` covering `ruleId` reaches `node` (see
290
+ * {@link reachingComments}) — or when the file carries a `theme-allow-file` declaration for it.
250
291
  *
251
292
  * The `ruleId` argument is what stops one exemption from being a blanket one: a `theme-allow
252
293
  * raw-hex — …` written for a color no longer silently switches off `card-inset` on the same line.
@@ -254,13 +295,11 @@ function allComments(context) {
254
295
  */
255
296
  function hasThemeAllow(context, node, ruleId) {
256
297
  if (hasFileDeclaration(context, ruleId)) return true
257
- const nodeLine = node.loc.start.line
258
- return allComments(context).some((comment) => {
259
- if (comment.loc.end.line !== nodeLine && comment.loc.end.line !== nodeLine - 1) return false
260
- return parseThemeAllows(comment.value).some(
298
+ return reachingComments(context, node.loc.start.line).some((comment) =>
299
+ parseThemeAllows(comment.value).some(
261
300
  (allow) => allow.scope === 'line' && allowCovers(allow, ruleId),
262
- )
263
- })
301
+ ),
302
+ )
264
303
  }
265
304
 
266
305
  /**
@@ -535,10 +574,12 @@ const cardInset = {
535
574
  // ── Rule 3 — chart-in-raw-surface ───────────────────────────────────────────────────────────────
536
575
 
537
576
  const CHART_TAGS = new Set([
577
+ 'BandStrip',
538
578
  'Bars',
539
579
  'Donut',
540
580
  'DualPanel',
541
581
  'Heatmap',
582
+ 'MirroredBars',
542
583
  'MultiLine',
543
584
  'StackedArea',
544
585
  'ZonedLine',
@@ -1332,11 +1373,37 @@ const chartLegendLiteral = {
1332
1373
  const PACKAGE_ROOT = resolvePath(PLUGIN_DIR, '..')
1333
1374
 
1334
1375
  /**
1335
- * A component-shaped name: starts uppercase and carries a lowercase letter, which excludes the
1376
+ * A component-shaped NAME: starts uppercase and carries a lowercase letter, which excludes the
1336
1377
  * SCREAMING_CASE constants and the two-letter token namespaces in the same barrel.
1378
+ *
1379
+ * A name test alone is not a component test — see {@link isComponentInit}. Every PascalCase binding
1380
+ * passes this, which is how obsidian's `SlugTracker` (a plain data class in a React-free package)
1381
+ * became a "forked component" the moment 1.21.0 widened the rule to all nine barrels.
1337
1382
  */
1338
1383
  const COMPONENT_NAME = /^[A-Z][A-Za-z0-9]*[a-z][A-Za-z0-9]*$/
1339
1384
 
1385
+ /** The two wrappers a function component is legitimately declared inside. */
1386
+ const COMPONENT_WRAPPERS = new Set(['memo', 'forwardRef'])
1387
+
1388
+ /**
1389
+ * Is this initializer component-SHAPED — a function, an arrow, or one wrapped in `memo` /
1390
+ * `forwardRef`?
1391
+ *
1392
+ * The second half of the fix for obsidian's false positive. `const Foo = new Map()` and
1393
+ * `class SlugTracker {}` share a name with a basalt export and nothing else; a fork of a component
1394
+ * is a function (or, historically, a class that `extends` one). Narrowing here costs the rule
1395
+ * nothing it was ever meant to catch and removes the whole "PascalCase binding" false-positive
1396
+ * class, which the nine-barrel widening made large.
1397
+ */
1398
+ function isComponentInit(init) {
1399
+ if (init === null || init === undefined) return false
1400
+ if (init.type === 'ArrowFunctionExpression' || init.type === 'FunctionExpression') return true
1401
+ if (init.type !== 'CallExpression') return false
1402
+ const callee = init.callee
1403
+ const name = callee?.type === 'MemberExpression' ? callee.property?.name : callee?.name
1404
+ return typeof name === 'string' && COMPONENT_WRAPPERS.has(name)
1405
+ }
1406
+
1340
1407
  /**
1341
1408
  * The barrels a consumer can import a component FROM. The root one is not the whole surface: a
1342
1409
  * chart-layer fork (`ChartFrame`, `CartesianChart`, `ChartCard`, …) collided with nothing, because
@@ -1428,6 +1495,11 @@ const shadowBasaltExport = {
1428
1495
  // basalt's own source DEFINES these names; a consumer's does not.
1429
1496
  if (BASALT_EXPORTS.size === 0 || filename.startsWith(`${PACKAGE_ROOT}/`)) return {}
1430
1497
  if (isTestFile(context)) return {}
1498
+ // Scoped exactly like `ai-sdk-major`, and for the same reason: a package with no basalt-ui
1499
+ // dependency (or one outside a declared `basalt.roots`) cannot import the export it is accused
1500
+ // of forking, so the advice — "import it from 'basalt-ui' instead" — is not followable there.
1501
+ // obsidian's React-free `obsidian-vault-core` took the report and had to waive it.
1502
+ if (filename.length === 0 || !isBasaltScopedFile(filename)) return {}
1431
1503
 
1432
1504
  const report = (node, name) => {
1433
1505
  if (typeof name !== 'string' || !BASALT_EXPORTS.has(name)) return
@@ -1440,9 +1512,14 @@ const shadowBasaltExport = {
1440
1512
  report(node, node.id?.name)
1441
1513
  },
1442
1514
  VariableDeclarator(node) {
1515
+ if (!isComponentInit(node.init)) return
1443
1516
  report(node, node.id?.name)
1444
1517
  },
1518
+ // A class only counts when it EXTENDS something — a legacy class component. basalt ships no
1519
+ // class components, so a standalone `class X {}` sharing a name with one of its exports is a
1520
+ // collision between two unrelated things, not a fork.
1445
1521
  ClassDeclaration(node) {
1522
+ if (node.superClass === null || node.superClass === undefined) return
1446
1523
  report(node, node.id?.name)
1447
1524
  },
1448
1525
  }
@@ -44,6 +44,13 @@ beforeEach(() => {
44
44
  },
45
45
  }),
46
46
  )
47
+ // `shadow-basalt-export` and `ai-sdk-major` both scope through `isBasaltScopedFile`, so a fixture
48
+ // repo with NO manifest is out of scope for both and every other assertion here would pass
49
+ // vacuously. Written per-test by the `ai-sdk-major` block, which needs its own `ai` pin.
50
+ writeFileSync(
51
+ resolve(dir, 'package.json'),
52
+ JSON.stringify({ name: 'fixture', version: '0.0.0', dependencies: { 'basalt-ui': '^1.21.0' } }),
53
+ )
47
54
  })
48
55
 
49
56
  afterEach(() => {
@@ -918,6 +925,172 @@ describe('basalt/shadow-basalt-export', () => {
918
925
  expect(code).toBe(0)
919
926
  expect(rules).not.toContain('shadow-basalt-export')
920
927
  })
928
+
929
+ // The scoping fix, mirroring `ai-sdk-major`'s. obsidian's `packages/obsidian-vault-core` is
930
+ // React-free, carries no basalt-ui dependency, and sits outside the root's declared
931
+ // `basalt.roots` — so it CANNOT import the export it was told it had forked. The rule reported
932
+ // `SlugTracker` there anyway, because it was the one rule in this file that never asked.
933
+ it('does NOT flag a package that does not depend on basalt-ui at all', () => {
934
+ writeFileSync(resolve(dir, 'package.json'), JSON.stringify({ name: 'standalone' }))
935
+ const { code, rules } = run(`export function EmptyState() { return null }\n`)
936
+ expect(code).toBe(0)
937
+ expect(rules).not.toContain('shadow-basalt-export')
938
+ })
939
+
940
+ it('does NOT flag a file outside a declared basalt.roots', () => {
941
+ writeFileSync(
942
+ resolve(dir, 'package.json'),
943
+ JSON.stringify({
944
+ basalt: { roots: ['packages/ui/src'] },
945
+ devDependencies: { 'basalt-ui': '^1.21.0' },
946
+ }),
947
+ )
948
+ expect(
949
+ run(`export function EmptyState() { return null }\n`, 'packages/core/src/a.tsx').rules,
950
+ ).not.toContain('shadow-basalt-export')
951
+ // …and the declared root is still policed, so the narrowing costs no real coverage.
952
+ expect(
953
+ run(`export function EmptyState() { return null }\n`, 'packages/ui/src/a.tsx').rules,
954
+ ).toContain('shadow-basalt-export')
955
+ })
956
+
957
+ // The second half: a PascalCase name is not a component. The nine-barrel widening pulled every
958
+ // PascalCase value export into range, and a bare-PascalCase test then matched a plain data class
959
+ // (obsidian's `SlugTracker` — the same NAME basalt's `./content` exports, and nothing else).
960
+ it('does NOT flag a plain class that merely shares a name', () => {
961
+ const { code, rules } = run(`export class SlugTracker {}\n`, 'slug.ts')
962
+ expect(code).toBe(0)
963
+ expect(rules).not.toContain('shadow-basalt-export')
964
+ })
965
+
966
+ it('DOES still flag a legacy class component (one that extends)', () => {
967
+ const { rules } = run(`export class EmptyState extends Component {}\n`)
968
+ expect(rules).toContain('shadow-basalt-export')
969
+ })
970
+
971
+ it('does NOT flag a non-function binding that shares a name', () => {
972
+ const { code, rules } = run(`export const EmptyState = { label: 'none' }\n`, 'consts.ts')
973
+ expect(code).toBe(0)
974
+ expect(rules).not.toContain('shadow-basalt-export')
975
+ })
976
+
977
+ it('DOES still flag a memo/forwardRef-wrapped component', () => {
978
+ expect(run(`export const EmptyState = memo(() => null)\n`).rules).toContain(
979
+ 'shadow-basalt-export',
980
+ )
981
+ expect(run(`export const EmptyState = React.forwardRef(() => null)\n`).rules).toContain(
982
+ 'shadow-basalt-export',
983
+ )
984
+ })
985
+ })
986
+
987
+ // ── theme-allow shape grid (the plugin half of the annotation contract) ──────
988
+
989
+ /**
990
+ * The plugin's copy of the grid pinned in `src/guard/check-source.test.ts`, row name for row name.
991
+ *
992
+ * Five holes in four rounds, twice because the two parsers disagreed about what an annotation IS —
993
+ * so the shapes are enumerated over the axes that vary (comment style × token position × where the
994
+ * closer falls × what follows), not collected as anecdotes. Every row here has a twin there; the
995
+ * guard-only dialects (CSS continuation lines, HTML, the JSON member form) have no twin because
996
+ * oxlint never parses those files.
997
+ *
998
+ * `no-raw-font-size` is the vehicle — a JSXAttribute rule, so the reported node's line is exactly
999
+ * the line an annotation has to reach.
1000
+ */
1001
+ /** Wraps JSX children in a component, so a `{/* … *\/}` child is legal source. */
1002
+ const jsxShape = (body: string): string =>
1003
+ `export const C = () => (\n <div>\n${body}\n </div>\n)\n`
1004
+
1005
+ describe('theme-allow shape grid', () => {
1006
+ const REASON = 'deliberate legacy value'
1007
+ const A = `theme-allow no-raw-font-size — ${REASON}`
1008
+ const AF = `theme-allow-file no-raw-font-size — ${REASON}`
1009
+ const T = `<Text fz={10} />`
1010
+ const J = jsxShape
1011
+
1012
+ const waives = (source: string): boolean => !run(source).rules.has('no-raw-font-size')
1013
+
1014
+ it.each([
1015
+ ['// own line', `// ${A}\nexport const C = ${T}\n`],
1016
+ ['// indented', `function f() {\n // ${A}\n return ${T}\n}\n`],
1017
+ ['// no space after the marker', `//${A}\nexport const C = ${T}\n`],
1018
+ ['// trailing', `export const C = ${T} // ${A}\n`],
1019
+ [
1020
+ '// reason wrapped onto a second // comment',
1021
+ `// ${A},\n// continued here\nexport const C = ${T}\n`,
1022
+ ],
1023
+ ['/* */ own line', `/* ${A} */\nexport const C = ${T}\n`],
1024
+ ['/* */ trailing', `export const C = ${T} /* ${A} */\n`],
1025
+ ['/** */ own line (docblock opener)', `/** ${A} */\nexport const C = ${T}\n`],
1026
+ ['/* opener + token, closer on its own line', `/* ${A}\n*/\nexport const C = ${T}\n`],
1027
+ ['/* gutter token, closer on the token line', `/*\n ${A} */\nexport const C = ${T}\n`],
1028
+ ['/* gutter token, closer on its own line', `/*\n ${A}\n*/\nexport const C = ${T}\n`],
1029
+ ['/** star gutter, closer on the token line', `/**\n * ${A} */\nexport const C = ${T}\n`],
1030
+ ['/** star gutter, closer on its own line', `/**\n * ${A}\n */\nexport const C = ${T}\n`],
1031
+ ['/** token then 1 prose line', `/**\n * ${A}\n * more\n */\nexport const C = ${T}\n`],
1032
+ [
1033
+ '/** token then 12 prose lines',
1034
+ `/**\n * ${A}\n${' * p\n'.repeat(12)} */\nexport const C = ${T}\n`,
1035
+ ],
1036
+ [
1037
+ '/** prose first, token on a later gutter line',
1038
+ `/**\n * some prose\n * ${A}\n */\nexport const C = ${T}\n`,
1039
+ ],
1040
+ ['{/* */} own line', J(` {/* ${A} */}\n ${T}`)],
1041
+ ['{/* */} trailing on the target line', J(` ${T} {/* ${A} */}`)],
1042
+ ['{/* opener + token, closer on its own line', J(` {/* ${A}\n */}\n ${T}`)],
1043
+ ['{/* gutter token, closer on the token line', J(` {/*\n ${A} */}\n ${T}`)],
1044
+ ['{/* gutter token, closer on its own line', J(` {/*\n ${A}\n */}\n ${T}`)],
1045
+ ['{/** star gutter, closer on the token line', J(` {/**\n * ${A} */}\n ${T}`)],
1046
+ ['{/** star gutter, closer on its own line', J(` {/**\n * ${A}\n */}\n ${T}`)],
1047
+ [
1048
+ '{/** token then 6 prose lines',
1049
+ J(` {/**\n * ${A}\n${' * p\n'.repeat(6)} */}\n ${T}`),
1050
+ ],
1051
+ ['{/* target on the closer line', J(` {/*\n ${A}\n */}${T}`)],
1052
+ ['{/* closer alone, tab indented', J(`\t{/*\n\t ${A}\n\t*/}\n\t${T}`)],
1053
+ ['{/* closer alone, space between */ and }', J(` {/*\n ${A}\n */ }\n ${T}`)],
1054
+ [
1055
+ '{/* closer alone, two annotations in one block',
1056
+ J(` {/*\n ${A}\n theme-allow card-inset — ${REASON}\n */}\n ${T}`),
1057
+ ],
1058
+ [
1059
+ '/** */ then an unrelated // note',
1060
+ `/** ${A} */\n// an unrelated note\nexport const C = ${T}\n`,
1061
+ ],
1062
+ ['{/* */} then an unrelated {/* */}', J(` {/* ${A} */}\n {/* unrelated */}\n ${T}`)],
1063
+ ['theme-allow-file, {/* closer on its own line', J(` {/*\n ${AF}\n */}\n ${T}`)],
1064
+ ['theme-allow-file, /** star gutter', `/**\n * ${AF}\n */\nexport const C = ${T}\n`],
1065
+ ])('%s waives', (_name, source) => {
1066
+ expect(waives(source as string)).toBe(true)
1067
+ })
1068
+
1069
+ // ── UNSUPPORTED — asserted, so a hole can never pass for a design decision ──
1070
+ it.each([
1071
+ ['blank line after a // annotation', `// ${A}\n\nexport const C = ${T}\n`],
1072
+ ['blank line after a {/* */} annotation', J(` {/* ${A} */}\n\n ${T}`)],
1073
+ [
1074
+ 'blank line after a {/* closer on its own line',
1075
+ J(` {/*\n ${A}\n */}\n\n ${T}`),
1076
+ ],
1077
+ ['mid-sentence in a line comment', `// we normally write a ${A} here\nexport const C = ${T}\n`],
1078
+ [
1079
+ 'mid-sentence in a docblock gutter',
1080
+ `/**\n * Each value is escaped with a ${A} annotation.\n */\nexport const C = ${T}\n`,
1081
+ ],
1082
+ [
1083
+ 'mid-sentence in a JSX expression comment',
1084
+ J(` {/* the shape here is a ${A} comment */}\n ${T}`),
1085
+ ],
1086
+ [
1087
+ 'inside a string literal',
1088
+ `const doc = 'theme-allow no-raw-font-size'\nexport const C = ${T}\n`,
1089
+ ],
1090
+ ['above a multi-line opening tag', J(` {/* ${A} */}\n <Text\n fz={10}\n />`)],
1091
+ ])('%s does NOT waive', (_name, source) => {
1092
+ expect(waives(source as string)).toBe(false)
1093
+ })
921
1094
  })
922
1095
 
923
1096
  // ── hand-rolled-shell ────────────────────────────────────────────────────────