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.
- package/AGENTS.md +11 -1
- package/CHANGELOG.md +33 -0
- package/MIGRATING.md +205 -9
- package/README.md +63 -16
- package/agent/rules/basalt-app.md +39 -9
- package/agent/rules/basalt-charts.md +41 -6
- package/agent/rules/basalt-router.md +1 -1
- package/agent/rules/basalt-state.md +4 -4
- package/agent/rules/basalt-tokens.md +25 -4
- package/agent/skills/basalt-app/SKILL.md +4 -3
- package/agent/skills/basalt-charts/SKILL.md +23 -8
- package/agent/templates/DESIGN.md.tpl +3 -1
- package/configs/oxlint-plugin.js +86 -9
- package/configs/oxlint-plugin.test.ts +173 -0
- package/dist/charts/hooks/useBandPlot.d.ts +138 -0
- package/dist/charts/hooks/useBandPlot.d.ts.map +1 -0
- package/dist/charts/hooks/useBandPlot.js +127 -0
- package/dist/charts/index.d.ts +4 -0
- package/dist/charts/index.d.ts.map +1 -1
- package/dist/charts/index.js +14 -0
- package/dist/charts/kinds/BandStrip.d.ts +113 -0
- package/dist/charts/kinds/BandStrip.d.ts.map +1 -0
- package/dist/charts/kinds/BandStrip.js +218 -0
- package/dist/charts/kinds/BandStrip.test.d.ts +2 -0
- package/dist/charts/kinds/BandStrip.test.d.ts.map +1 -0
- package/dist/charts/kinds/Bars.d.ts +5 -1
- package/dist/charts/kinds/Bars.d.ts.map +1 -1
- package/dist/charts/kinds/Bars.js +2 -0
- package/dist/charts/kinds/MirroredBars.d.ts +93 -0
- package/dist/charts/kinds/MirroredBars.d.ts.map +1 -0
- package/dist/charts/kinds/MirroredBars.js +315 -0
- package/dist/charts/kinds/MirroredBars.test.d.ts +2 -0
- package/dist/charts/kinds/MirroredBars.test.d.ts.map +1 -0
- package/dist/charts/kinds/MultiLine.d.ts +5 -1
- package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
- package/dist/charts/kinds/MultiLine.js +2 -0
- package/dist/charts/kinds/StackedArea.d.ts +5 -1
- package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
- package/dist/charts/kinds/StackedArea.js +2 -0
- package/dist/charts/kinds/ZonedLine.d.ts +5 -1
- package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
- package/dist/charts/kinds/ZonedLine.js +2 -0
- package/dist/charts/primitives/CartesianChart.d.ts +18 -2
- package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
- package/dist/charts/primitives/CartesianChart.js +5 -2
- package/dist/charts/primitives/HatchPattern.d.ts +31 -0
- package/dist/charts/primitives/HatchPattern.d.ts.map +1 -0
- package/dist/charts/primitives/HatchPattern.js +31 -0
- package/dist/cli/index.d.ts +9 -1
- package/dist/cli/index.d.ts.map +1 -1
- package/dist/cli/index.js +275 -48
- package/dist/cli/shipped-versions.test.d.ts +2 -0
- package/dist/cli/shipped-versions.test.d.ts.map +1 -0
- package/dist/guard/index.d.ts +67 -0
- package/dist/guard/index.d.ts.map +1 -1
- package/dist/guard/index.js +70 -11
- package/dist/vite.d.ts +44 -2
- package/dist/vite.d.ts.map +1 -1
- package/dist/vite.js +71 -26
- package/llms.txt +2 -2
- package/package.json +1 -1
- package/src/charts/hooks/useBandPlot.ts +302 -0
- package/src/charts/index.ts +14 -0
- package/src/charts/kinds/BandStrip.test.tsx +200 -0
- package/src/charts/kinds/BandStrip.tsx +364 -0
- package/src/charts/kinds/Bars.tsx +7 -1
- package/src/charts/kinds/DualPanel.test.tsx +10 -4
- package/src/charts/kinds/MirroredBars.test.tsx +193 -0
- package/src/charts/kinds/MirroredBars.tsx +455 -0
- package/src/charts/kinds/MultiLine.tsx +7 -1
- package/src/charts/kinds/StackedArea.tsx +7 -1
- package/src/charts/kinds/ZonedLine.tsx +7 -1
- package/src/charts/primitives/CartesianChart.test.tsx +86 -4
- package/src/charts/primitives/CartesianChart.tsx +28 -3
- package/src/charts/primitives/HatchPattern.tsx +55 -0
- package/src/cli/check-theme.test.ts +35 -0
- package/src/cli/help.test.ts +5 -1
- package/src/cli/index.ts +552 -79
- package/src/cli/shipped-versions.test.ts +83 -0
- package/src/cli/toolchain-wiring.test.ts +362 -6
- package/src/cli/waiver-audit.test.ts +88 -4
- package/src/guard/check-source.test.ts +293 -0
- package/src/guard/index.ts +209 -20
- package/src/vite.test.ts +112 -0
- 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 —
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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:
|
|
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.
|
|
124
|
-
included), but it warns only on an **exact** name collision
|
|
125
|
-
(`Cell`, `Box`, `Stat`) sail straight
|
|
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 —
|
|
20
|
-
`chartId`, `getX`, `series`/`positiveBars`, one `AxisConfig` per
|
|
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
|
|
113
|
-
`
|
|
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`, `
|
|
216
|
-
`
|
|
217
|
-
`
|
|
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
|
|
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
|
package/configs/oxlint-plugin.js
CHANGED
|
@@ -245,8 +245,49 @@ function allComments(context) {
|
|
|
245
245
|
}
|
|
246
246
|
|
|
247
247
|
/**
|
|
248
|
-
*
|
|
249
|
-
*
|
|
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
|
-
|
|
258
|
-
|
|
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
|
|
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 ────────────────────────────────────────────────────────
|