basalt-ui 1.19.1 → 1.20.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 (78) hide show
  1. package/AGENTS.md +15 -1
  2. package/CHANGELOG.md +20 -0
  3. package/MIGRATING.md +228 -0
  4. package/README.md +53 -9
  5. package/agent/rules/basalt-app.md +43 -0
  6. package/agent/rules/basalt-charts.md +26 -14
  7. package/agent/rules/basalt-mantine.md +25 -10
  8. package/agent/rules/basalt-router.md +22 -10
  9. package/agent/rules/basalt-tokens.md +75 -6
  10. package/agent/skills/basalt-app/SKILL.md +42 -6
  11. package/configs/check.yml +11 -4
  12. package/configs/lefthook.yml +21 -2
  13. package/configs/oxlint-plugin.js +515 -25
  14. package/configs/oxlint-plugin.test.ts +383 -10
  15. package/configs/oxlint.json +10 -1
  16. package/dist/agent/stick-to-bottom.js +1 -1
  17. package/dist/agent-chat/thread-detail-panel.js +1 -1
  18. package/dist/agent-chat/thread-message.js +2 -2
  19. package/dist/agent-chat/tool-chip.js +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/Donut.js +1 -1
  22. package/dist/charts/primitives/ChartLegend.js +2 -2
  23. package/dist/charts/primitives/ChartTooltip.js +1 -1
  24. package/dist/cli/index.d.ts +107 -15
  25. package/dist/cli/index.d.ts.map +1 -1
  26. package/dist/cli/index.js +610 -72
  27. package/dist/cli/toolchain-wiring.test.d.ts +2 -0
  28. package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
  29. package/dist/dashboard/delta-badge.js +2 -2
  30. package/dist/dashboard/stat-card.js +1 -1
  31. package/dist/data/virtual-list.js +5 -5
  32. package/dist/guard/index.d.ts +56 -2
  33. package/dist/guard/index.d.ts.map +1 -1
  34. package/dist/guard/index.js +326 -59
  35. package/dist/guard/types.d.ts +25 -5
  36. package/dist/guard/types.d.ts.map +1 -1
  37. package/dist/register.d.ts +25 -0
  38. package/dist/register.d.ts.map +1 -1
  39. package/dist/shell/app-mobile-nav.module.css +11 -3
  40. package/dist/surfaces.d.ts +1 -1
  41. package/dist/surfaces.js +1 -1
  42. package/dist/tokens/index.d.ts +3 -3
  43. package/dist/tokens/index.d.ts.map +1 -1
  44. package/dist/tokens/index.js +2 -0
  45. package/dist/tokens/palette.d.ts +35 -27
  46. package/dist/tokens/palette.d.ts.map +1 -1
  47. package/dist/tokens/palette.js +24 -18
  48. package/dist/tokens.css +19 -17
  49. package/dist/vite-color-mix.js +2 -2
  50. package/llms.txt +3 -3
  51. package/package.json +4 -3
  52. package/src/agent/stick-to-bottom.tsx +1 -1
  53. package/src/agent-chat/thread-detail-panel.tsx +1 -1
  54. package/src/agent-chat/thread-message.tsx +3 -3
  55. package/src/agent-chat/tool-chip.tsx +1 -1
  56. package/src/charts/kinds/Donut.tsx +2 -1
  57. package/src/charts/primitives/ChartLegend.tsx +2 -2
  58. package/src/charts/primitives/ChartTooltip.tsx +1 -1
  59. package/src/cli/index.ts +1070 -99
  60. package/src/cli/init-sync.test.ts +19 -3
  61. package/src/cli/tokens-css.test.ts +27 -14
  62. package/src/cli/toolchain-wiring.test.ts +694 -0
  63. package/src/dashboard/delta-badge.tsx +3 -3
  64. package/src/dashboard/stat-card.tsx +1 -1
  65. package/src/data/virtual-list.tsx +8 -8
  66. package/src/guard/check-source.test.ts +663 -11
  67. package/src/guard/index.ts +727 -94
  68. package/src/guard/types.ts +29 -4
  69. package/src/register.ts +32 -0
  70. package/src/shell/app-mobile-nav.module.css +11 -3
  71. package/src/shell/app-mobile-nav.tsx +1 -1
  72. package/src/surfaces.ts +1 -1
  73. package/src/theme/density-relations.test.ts +8 -5
  74. package/src/theme/spacing.test.ts +2 -0
  75. package/src/theme-toggle/index.tsx +2 -2
  76. package/src/tokens/index.ts +5 -3
  77. package/src/tokens/palette.ts +24 -18
  78. package/src/vite-color-mix.ts +2 -2
package/AGENTS.md CHANGED
@@ -67,7 +67,9 @@ bunx basalt-ui init # FIRST STEP: scaffold .claude/rules/ + .claude
67
67
  bunx basalt-ui check-theme # fail on off-palette colors in consumer source
68
68
  bunx basalt-ui sync # reconcile managed files (.claude/rules/, .claude/skills/, CLAUDE.md block)
69
69
  bunx basalt-ui sync --check # CI freshness gate — exits non-zero if any managed file drifted
70
- bunx basalt-ui doctor # check consumer repo basalt integration health
70
+ bunx basalt-ui doctor # integration health — SKIPPED is a third outcome and exits non-zero
71
+ bunx basalt-ui tokens:css # emit the --vx-* stylesheet (no React, no Mantine, no bundler)
72
+ bunx basalt-ui fonts:css # emit the shipped --basalt-font-* stacks
71
73
  bunx basalt-ui info # print published surface map
72
74
  bunx basalt-ui info --json # stable JSON surface map
73
75
  bunx basalt-ui guard-hook # PreToolUse theme-guard adapter: reads a Write/Edit payload on stdin, denies off-palette writes
@@ -77,6 +79,12 @@ Register `guard-hook` in `.claude/settings.json` under `hooks.PreToolUse` with m
77
79
  `Write|Edit|MultiEdit` and command `bunx basalt-ui guard-hook` so the agent can't write off-palette
78
80
  colors without a `theme-allow` comment.
79
81
 
82
+ **The escape hatch is `theme-allow <rule-id> — <reason>`**, scoped to that one kind. It is honoured
83
+ on the reported line, on a comment-only line directly above it, and — in CSS — from a trailing
84
+ comment back over the declaration it terminates. A bare `theme-allow` still waives everything but
85
+ reports `theme-allow-unscoped`. A word in the id slot that names no rule waives nothing — only a
86
+ genuinely bare comment is the blanket form.
87
+
80
88
  > **Framework-internal only** — `bunx basalt-ui check-coverage` is a self-consistency gate for the
81
89
  > basalt-ui repo itself (asserts SURFACES ↔ rule files ↔ skill files ↔ package.json exports). It
82
90
  > is not a consumer command and will skip assertion 3 outside the framework repo.
@@ -85,3 +93,9 @@ colors without a `theme-allow` comment.
85
93
 
86
94
  See `basalt-ui/llms.txt` (importable via the package specifier) for the full surface map: one
87
95
  entry per published subpath with import specifier, description, layer, and optional peer packages.
96
+
97
+ ## Upgrading across minors
98
+
99
+ `CHANGELOG.md` lists every release; `MIGRATING.md` lists what **broke** — removed and renamed
100
+ exports per minor, with the replacement. No majors ship here, so a minor bump can require code
101
+ changes. Read `MIGRATING.md` before jumping more than one minor.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,23 @@
1
+ # [1.20.0](https://github.com/jkrumm/basalt-ui/compare/v1.19.1...v1.20.0) (2026-08-22)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * exempt generated LINES, not files, and stop flagging prose as a typo'd id ([a83abfb](https://github.com/jkrumm/basalt-ui/commit/a83abfb77888f31528f44e5923b26d3710ddf005))
7
+ * give the mobile-nav-pill demo row its own glyph ([4620590](https://github.com/jkrumm/basalt-ui/commit/4620590204a99afd07ec27eb923cd229acdfce84))
8
+ * hold basalt to its own scoped theme-allow contract ([b67718c](https://github.com/jkrumm/basalt-ui/commit/b67718cecf8e7ce60b61b7481bcdbd4795f79d40))
9
+ * make the escape hatch and the generated-file marker fail closed ([526cdb7](https://github.com/jkrumm/basalt-ui/commit/526cdb7f11395f27183828e34297b0ac6bc846c8))
10
+ * make the mobile-nav active pill track density and survive an icon-less app ([0a7d756](https://github.com/jkrumm/basalt-ui/commit/0a7d7560f9eae6db4c02ff029ba47088a084e511))
11
+ * snapshot the tokens-only guard export ([b63dc24](https://github.com/jkrumm/basalt-ui/commit/b63dc242bfd46359476f7fcae80957db244f727f))
12
+
13
+
14
+ ### Features
15
+
16
+ * demo the mobile-nav active pill with and without an icon dependency ([9620905](https://github.com/jkrumm/basalt-ui/commit/9620905409efdf39bb10b40edaa128ce2b61c741))
17
+ * make the escape hatch usable and close the holes round 4 found ([5b8420d](https://github.com/jkrumm/basalt-ui/commit/5b8420d24647b84818dfbb65e9f0c52ea1e79c2c))
18
+ * scan the app shell, wire the tokens-only profile, ship __APP_VERSION__ ([61b1792](https://github.com/jkrumm/basalt-ui/commit/61b1792e8ea1bff3c59128ba65137b27d40df81a))
19
+ * stop the toolchain reporting green while enforcing nothing ([d3d8e34](https://github.com/jkrumm/basalt-ui/commit/d3d8e3434893516e62413acafdd166be13b86541))
20
+
1
21
  ## [1.19.1](https://github.com/jkrumm/basalt-ui/compare/v1.19.0...v1.19.1) (2026-08-20)
2
22
 
3
23
 
package/MIGRATING.md ADDED
@@ -0,0 +1,228 @@
1
+ # Migrating basalt-ui
2
+
3
+ `CHANGELOG.md` ships in this package and lists every release. It cannot tell you what **broke**:
4
+ semantic-release writes one line per commit, and "make the mobile bar navigate, driven by one typed
5
+ nav definition" does not say `ChartHoverSync` was deleted. This file is that half — removed and
6
+ renamed exports per minor, with the replacement.
7
+
8
+ Reconstructed from `git diff` over the published export surface across `v1.0.0..v1.19.1`, then
9
+ cross-checked against the repo's `scripts/export-surface.json` snapshot. Verified 2026-08-22; the
10
+ 1.20.0 section below is written from the commits on `master` that produce it.
11
+
12
+ **No majors, by policy.** A rename or a removal ships as a plain `feat:` on the 1.x line, so a minor
13
+ bump can require code changes. Skipping several at once is the expensive case — read every section
14
+ between your version and the target.
15
+
16
+ > Reading the CHANGELOG: minors and majors are `#` (h1), patches are `##` (h2). Grepping for
17
+ > `## [1.` finds only the patches.
18
+
19
+ **Minors with no public API delta:** 1.1.0, 1.3.0, 1.4.0, 1.5.0, 1.6.0, 1.7.0, 1.8.0, 1.9.0,
20
+ 1.10.0, 1.13.0, 1.14.0, 1.16.0, 1.18.0 — and every patch. Additive-only subpaths: `./tokens.css`
21
+ at 1.3.0, `./agent-chat` at 1.10.0.
22
+
23
+ ---
24
+
25
+ ## 1.20.0 — enforcement
26
+
27
+ **No export removed or renamed.** The whole delta is that things which used to pass now report. If
28
+ your build goes red on this upgrade, that is the release working; every new kind and rule lands
29
+ `warn`, so nothing here fails a build on its own.
30
+
31
+ | Change | What you'll see | What to do |
32
+ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
33
+ | `basalt/raw-size-literal` `warn` → **`error`** | CSS-length strings on `size`/`fz`/`fontSize` now fail lint | use a token (`size="sm"`); `warn` since 1.7.0, zero violations across all seven consumers |
34
+ | Five new guard kinds (`warn`) | `theme-allow-unscoped`, `surface-shadow-override`, `css-raw-surface`, `inline-font-size`, `hidden-inline-style` | see below; promotion is tracked in `GRACE_PERIOD_KINDS` |
35
+ | Two new oxlint rules (`warn`) | `basalt/shadow-basalt-export`, `basalt/hand-rolled-shell` | import the shipped component instead of the fork |
36
+ | `basalt/hand-rolled-plot`, `basalt/chart-legend-literal` widened | more sites report; both stay `warn` | a widened rule does not promote in the minor that widens it |
37
+ | `doctor` `SKIPPED` exits non-zero, + 3 new hard checks | doctor goes red where it was green | that is the finding — see below |
38
+
39
+ **`theme-allow` has a new contract, and one comment shape stops waiving.** A bare `theme-allow`
40
+ still waives every kind, but now reports `theme-allow-unscoped`. Rescope it — and spell the id
41
+ right, because a word in the id slot that names no rule now waives NOTHING rather than degrading to
42
+ the blanket form:
43
+
44
+ ```diff
45
+ -// theme-allow
46
+ +// theme-allow raw-surface — third-party widget needs a literal corner
47
+ ```
48
+
49
+ **The break: a reason with no separator introducing it.** `// theme-allow legacy vendor asset` used
50
+ to waive the line; it now waives nothing and the un-suppressed finding reports at its own severity
51
+ (`error` for `raw-hex`). The first word after the token is read as a rule id, and an id that names
52
+ no rule fails closed — that is the whole point, since the alternative is one mistyped character
53
+ silently widening a scoped waiver into a blanket one. No annotation in any of the seven consumer
54
+ repos writes that shape; every one of them introduces its reason with `—`, `–`, `-` or `:`. Add a
55
+ separator, or an id:
56
+
57
+ ```diff
58
+ -// theme-allow legacy vendor asset
59
+ +// theme-allow raw-hex — legacy vendor asset
60
+ ```
61
+
62
+ Prose AFTER a resolved id is safe and needs no separator —
63
+ `// theme-allow raw-surface sub-scale legend corner` waives `raw-surface` and reports nothing. Only
64
+ a comma keeps the id list open past the first id.
65
+
66
+ Two placements that used to fail now work, both matching what the oxlint plugin always did: a
67
+ comment-ONLY line directly above the reported line (the only form JSX can express — the reported
68
+ line is usually a multi-line opening tag or a `{expr}` child), and in CSS a trailing annotation
69
+ reaching back over the declaration it terminates, which is what survives the shipped `oxfmt`
70
+ reflowing a long `background-color` so the hex lands above the comment.
71
+
72
+ **`basalt/hand-rolled-plot` no longer grants whole-file immunity.** Every assembly node is reported
73
+ and waived individually. A file-scoped exception now needs a written declaration naming the rule and
74
+ giving a reason, anywhere in the file: `// theme-allow hand-rolled-plot — two panes over one x scale`.
75
+
76
+ **`doctor` will go red.** `SKIPPED` is a third outcome beside pass/warn/fail and exits non-zero on
77
+ its own — "All checks passed" is only printable when every check RAN. Three new hard checks:
78
+ `basalt-resolves` (walks cwd → ancestors → workspace packages), `guard-scan` (would `check-theme`
79
+ cover more than zero files?), `oxlint-preset` (does `.oxlintrc.json` really extend the shipped
80
+ preset? JSONC is parsed, not rejected — `init` keeps an existing config, so one repo ran five minors
81
+ with the whole lint half off).
82
+
83
+ **New, additive:**
84
+
85
+ | Surface | Note |
86
+ | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
87
+ | `basalt.profile: 'tokens-only'` / `--tokens-only` | disables the 17 kinds whose remedy is a Mantine component, prop or the React theme factory. `check-theme` requires it DECLARED; `doctor` infers it, because its profile only changes advice, never enforcement |
88
+ | `basalt.include: [...]` | scan a named file outside `roots` — and the only route to a `.json`, which is never blanket-scanned |
89
+ | `basalt.roots` + a `lint:basalt` script | written by `init` from the real layout; `init` on an existing app is a lint-debt event, not a no-op |
90
+ | `tokens:css --check`, `--selector-class <c>` (+ `--light-class`) | drift gate; the Tailwind `<html class="dark">` convention. There is no `scheme: { class }` API — the class form is CLI-only |
91
+ | `fonts:css [--out] [--check]` | the shipped `--basalt-font-*` stacks as plain CSS, read out of `styles.css` — the only route to basalt's typefaces without the Mantine-coupled `styles.css` |
92
+ | `__APP_VERSION__` ambient declaration | ships via `src/register.ts`, re-exported by the root barrel: delete your hand-written ambient block. A subpath-only consumer does not get it |
93
+ | `BASALT_CWD` | `check-theme`/`doctor` honour it, and relocate to the single workspace package carrying a basalt config when invoked from a root that has none |
94
+ | `@generated basalt-ui` header | `tokens:css`/`fonts:css` output carries it on line 1, the version + invocation line on line 2, and `check-theme`, in a `.css` file with that exact header, skips the LINES that are basalt custom properties, selectors, `}` or self-closing comments — this is what fixed 116 violations reported inside the stylesheet `tokens:css` had just written. Committed output emitted by 1.19.1 has no header: re-run the command to get the skip |
95
+
96
+ `check-theme` also resolves `.html` / `.webmanifest` / `.json` as markup (colour kinds only), and
97
+ each root's PARENT now contributes its `index.html` and `public/` tree.
98
+
99
+ ## 1.19.0 — nav
100
+
101
+ | Removed / renamed | Replacement | Note |
102
+ | ----------------------------------------------------------------- | --------------------------------------------------------------------------------------- | ------------------------------------------------------------------- |
103
+ | `NavLinkRenderer` (type) | `defineNav` + `{...useNav(NAV)}` (`basalt-ui/router-tanstack`), or `SidebarItem.Anchor` | basalt now paints every nav pixel |
104
+ | `BasaltShellProps.renderNavLink`, `AppSidebarProps.renderNavLink` | same | |
105
+ | `BreadcrumbLinkRenderer` (type) | `AppBreadcrumbs.parentAnchor` | |
106
+ | `BasaltShellProps.renderBreadcrumbLink` | same | |
107
+ | `BasaltShellProps.sidebarFooterExtra` | `mobileNav.moreExtra` | its only host was the mobile drawer; it rendered nowhere on desktop |
108
+ | `AppSidebarProps.footerExtra`, `AppSidebarProps.onClose` | — | the full-height mobile sidebar drawer is deleted |
109
+ | `MobileNavItem` (type) | `MobileNavSlot` | |
110
+ | `MobileNavSection` (type) | `MobileNavGroup` | |
111
+ | `MobileNavLinkRenderer` (type) | `MobileNavModel` | |
112
+ | `SidebarSection.mobileTab` | `SidebarSection.mobile`: `'tab' \| 'more' \| 'hidden'` (`true`/`false` still accepted) | |
113
+
114
+ A consumer with no router needs no migration — `href` + `onClick` still work.
115
+
116
+ **Expect new type errors, and read them.** `renderNavLink` took `to` as an opaque value, which is
117
+ why consumers reached for `to={target.to as never}`. `defineNav` types it, so a destination missing
118
+ a required `search` now fails to compile — in one repo that surfaced two nav links that had been
119
+ shipping without required params. Fix with a click-time thunk (`search: () => Schema.parse({})`),
120
+ never `search: true`.
121
+
122
+ ## 1.17.0 — behaviour only, no export removed
123
+
124
+ | Changed | Effect | Opt out |
125
+ | ---------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
126
+ | `resolveAxisDomain` clamps before padding, not after | the axis top moves on any chart with an `autoMaxFloor` | lower the floor, or pin `domain` |
127
+ | `ChartFrame` `role="img"` → `role="group"` | `role="img"` pruned the keyboard slider out of the a11y tree | — |
128
+
129
+ ## 1.15.0 — chart layer rebuilt
130
+
131
+ The largest delta in the 1.x line. No shims were shipped.
132
+
133
+ | Removed / renamed | Replacement | Note |
134
+ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------ |
135
+ | `ChartHoverSync`, `ChartHoverSyncProps` | `globalCursorStore` / `createCursorStore` / `useChartCursor` / `useCursorState` — **no provider** | `ChartCursorScope` now _isolates_ a subtree; it is the inverse of the old provider, not a rename |
136
+ | `HoverContext`, `HoverCtx`, `useHoverSync`, `DEFAULT_NO_OP_SET_HOVER` | `useChartCursor`, `useCursorState`, `CursorState`, `CursorStore` | context → `useSyncExternalStore` |
137
+ | `ResponsiveChart`, `ResponsiveChartProps` | `CartesianChart` / `CartesianChartProps` (+ `autoMargin`, `PlotContext`, `PlotRect`) | now mandatory for every single-plot cartesian chart, enforced by `basalt/hand-rolled-plot` |
138
+ | `ChartTooltip` (tip-based), `useChartTooltip`, `useTooltipStyles` | `ChartTooltipFloat` + the `tooltip: CartesianTooltipConfig` prop | portal / flip / clamp done once |
139
+ | `BarsAxisConfig` | `AxisConfig` + `resolveAxisDomain` | |
140
+ | `ZonedLineTooltipLabel` | `tooltip` config on the kind | |
141
+ | `ZonedLine`/`MultiLine`: `yDomain`, `yAutoPad`, `yAutoMaxFloor`, `yAutoMinCeil`, `numTicksY`, `formatYTick` | one `y?: AxisConfig<T>` | `y` is **optional** where `yDomain` was required |
142
+ | `ZonedLine`/`MultiLine`: `formatValue`, `tooltipLabel`, `renderExtraTooltipRows` | `tooltip?: CartesianTooltipConfig` | **not** `y` — these are tooltip concerns |
143
+ | `ZonedLine`/`MultiLine`: `numTicksX` | `xTicks` | `xZones` added alongside |
144
+ | `Bars`: `formatValue`, `hideBarTooltipRows`, `leftAxis`, `rightAxis`, `marginLeft`, `numTicksX`, `tooltipLabel`, `renderExtraTooltipRows`, `renderPrefixTooltipRows` | `y` / `y2` (`AxisConfig`), `tooltip`, `SeriesStyle.tooltip`, measured `autoMargin` | passing `y2` is what makes a chart dual-axis; `chartMargin({ rightAxis })` is gone |
145
+ | `StackedArea`: `formatValue`, `numTicksX`, `numTicksY`, `yAutoMaxFloor`, `yLabel` | `y`, `tooltip`, `xTicks` | |
146
+ | `Heatmap.width` | measures itself; takes `height` / `aspectRatio` / `fill` | |
147
+
148
+ ## 1.12.0 — agent-chat, behaviour only
149
+
150
+ | Changed | Effect |
151
+ | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
152
+ | `streaming` no longer selects the image allowlist | **security-relevant.** Model output must now pass `contentTrust="untrusted"`; leaving it unset silently reopens prompt-injection image exfiltration |
153
+ | consumer `rehypePlugins` output is now sanitized, `clobberPrefix` is empty | a consumer injecting non-default elements must pass a matching `sanitizeSchema` |
154
+
155
+ ## 1.11.0 — agent parts
156
+
157
+ Semver-breaking in a minor; the commit body says so.
158
+
159
+ | Removed / renamed | Replacement | Note |
160
+ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
161
+ | `ToolCallPart` flat `{ type, toolName, input, output? }` | `ToolCallPart` as a 7-arm union discriminated on `state` (`input-streaming` … `output-error`) | mirrors the AI SDK v7 lifecycle |
162
+ | the error field named `error` | `errorText` | the SDK union has no `error` |
163
+ | flattened approval fields | nested `approval: ToolApproval`, carried verbatim | flattening dropped `isAutomatic` / `signature` |
164
+ | — | every `AgentPart` now extends `PartBase` with a **required `id`**; `withPartIds` mints them on drafts | a 1.10-shaped part rehydrated from localStorage no longer throws, but id-less parts collide |
165
+ | `ToolChip` threw `assertNever` on an unknown state | now falls back instead of throwing | |
166
+
167
+ ## 1.0.0 — the Mantine pivot
168
+
169
+ | Removed / renamed | Replacement | Note |
170
+ | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
171
+ | `./css` subpath (877 lines: 307 custom props, 19 utilities, a reset, the Tailwind entrypoint) | `basalt-ui tokens:css` / `basalt-ui/tokens.css` — **but only from 1.3.0** | between 1.0.0 and 1.2.x the only CSS door was `styles.css` + `buildPaletteCss()` |
172
+ | `./starlight` subpath | — | no replacement |
173
+ | the OKLCH foundation palette, ShadCN/Tremor/Starlight doctrine | the three-tier `--vx-*` system | see `docs/FRAMEWORK-FREE.md` for the token-only route |
174
+
175
+ Names that have no 1.x equivalent: the `--chart-blue-1..8` sequential ramp (1.x is categorical
176
+ `--vx-fill-*` only), the default font stacks as a token (they live in `styles.css`), `purple` as a
177
+ text color, `black`, `blue-400`/`green-400`.
178
+
179
+ ## 1.0.1 — CLI binary renamed
180
+
181
+ `basalt` → **`basalt-ui`**. Never `bunx basalt`.
182
+
183
+ ---
184
+
185
+ ## Lint and guard rules that tightened
186
+
187
+ Two independent mechanisms, each with its own ledger — read the ledger, not this table, for what is
188
+ in grace TODAY. `GuardKind` severities (`basalt-ui check-theme`) default to `error` and are
189
+ downgraded only by `GRACE_PERIOD_KINDS` (`src/guard/index.ts`). `basalt/*` oxlint rule severities
190
+ live in `configs/oxlint.json`, and since 1.20.0 their grace is tracked by `PLUGIN_RULE_GRACE`, a
191
+ named export beside the plugin — a test asserts it against the shipped preset in both directions, so
192
+ deleting an entry forces the level flip in the same commit.
193
+
194
+ | Rule | Landed | Became `error` |
195
+ | ---------------------------------- | ---------------- | ---------------------------------- |
196
+ | `mantine-shade-index` (guard kind) | 1.7.0 as `warn` | **1.11.0** |
197
+ | `basalt/raw-scroll-container` | ≤1.2.0 as `off` | `warn` 1.10.0 → **`error` 1.13.0** |
198
+ | `basalt/ai-sdk-major` | 1.10.0 as `warn` | **1.13.0** |
199
+ | `basalt/agent-no-raw-usechat` | 1.10.0 as `warn` | **1.13.0** |
200
+ | `basalt/agent-resume-guard` | 1.10.0 as `warn` | **1.13.0** |
201
+ | `basalt/raw-size-literal` | 1.7.0 as `warn` | **1.20.0** |
202
+ | `basalt/hand-rolled-plot` | 1.15.0 as `warn` | still `warn` — widened at 1.20.0 |
203
+ | `basalt/chart-legend-literal` | 1.15.0 as `warn` | still `warn` — widened at 1.20.0 |
204
+ | `basalt/shadow-basalt-export` | 1.20.0 as `warn` | may stay `warn` permanently |
205
+ | `basalt/hand-rolled-shell` | 1.20.0 as `warn` | — |
206
+
207
+ `card-with-border`, `inline-display`, `raw-html-layout`, `raw-form-control`, `raw-font-family` and
208
+ the other original guard kinds have been `error` since before 1.2.0 — they never had a grace minor.
209
+ Guard findings only gained a severity field at all in 1.4.0; before that every finding was fatal.
210
+
211
+ **Rule-id rename at 1.1.0:** `basalt/import-boundary` split into `basalt/visx-boundary`,
212
+ `basalt/visx-tooltip` and `basalt/token-layer-boundary` (the last is repo-local and deliberately not
213
+ in the shipped preset). A config still naming `import-boundary` after 1.1.0 disables nothing.
214
+
215
+ ## Deprecated, not yet removed
216
+
217
+ The 32 camelCase `--vx-*` aliases deprecated in 1.5.0 are **still emitted at 1.20.0**.
218
+ `buildPaletteCss({ legacyAliases: false })` / `tokens:css --no-legacy-aliases` opts out now; a later
219
+ minor flips the default.
220
+
221
+ ## Not verified
222
+
223
+ - Props declared inline on a component (`function X({ a }: { a: string })`) rather than on an
224
+ exported type are outside the diff method used here. Spot checks on the shell and charts modules
225
+ found none, but the negative is unproven.
226
+ - `AppBreadcrumbs.parentAnchor` as the replacement for `renderBreadcrumbLink` comes from the 1.19.0
227
+ commit body, not from a diffable exported type — `AppBreadcrumbsProps` is not in the public export
228
+ set at either tag.
package/README.md CHANGED
@@ -65,6 +65,15 @@ That's the whole install — there is no plugin, no marketplace, and no second v
65
65
  - A thin `DESIGN.md` seed — your app's deltas (series dictionary, identity, deviations)
66
66
  - Toolchain seeds: `.oxlintrc.json` and `lefthook.yml` as `extends` stubs into `node_modules/basalt-ui/configs/` (the presets auto-update with the package), plus `.oxfmtrc.json` and `.github/workflows/check.yml` as starting copies
67
67
  - `.basalt/manifest.json` — sha256 per managed file + the basalt-ui version, for `sync` three-way diff and `doctor`
68
+ - Two `package.json` patches: `basalt.roots` inferred from the real layout, and a `lint:basalt` script
69
+
70
+ `init` on an **existing** app is a lint-debt event, not a no-op: the shipped preset switches on
71
+ whole oxlint plugins the repo was never linted against, so previously-clean code lands with real
72
+ findings on the first run. `init` names those plugins, and names every file it kept along with what
73
+ keeping it costs — an `.oxlintrc.json` that does not extend the preset means the entire basalt lint
74
+ half is inert and nothing else says so (`--merge-lint` splices the `extends` in; it refuses on a
75
+ commented config rather than deleting the comments). Run `oxlint .` and triage before your next
76
+ commit.
68
77
 
69
78
  Every file is either **managed** (basalt owns it, `sync` refreshes it, local edits are skipped and
70
79
  reported — exactly the files Claude reads, which cannot load from `node_modules`) or a **seed**
@@ -175,25 +184,50 @@ half — see the `./vite` adapter battery under [Adapter batteries](#adapter-bat
175
184
 
176
185
  ```ts
177
186
  // Lint — theme guard is the teeth behind the token doctrine
178
- // package.json scripts:
187
+ // package.json scripts (init seeds this as "lint:basalt"):
179
188
  "lint": "oxlint . && basalt-ui check-theme"
180
189
  ```
181
190
 
182
- `check-theme` scans `src/` by default — a monorepo points it at its real roots via a `"basalt"`
183
- key in your `package.json`:
191
+ `check-theme` scans `src/` by default; `init` writes a real `"basalt"` key from your actual layout,
192
+ and everything derives from it — the guard's scan, the seeded CI `oxfmt` globs, the default scan
193
+ exemption:
184
194
 
185
195
  ```json
186
- // package.json
196
+ // package.json — `roots` is written by init; the rest are yours to add
187
197
  {
188
198
  "basalt": {
189
- "roots": ["src"]
199
+ "roots": ["apps/web/src"],
200
+ "include": ["app/manifest.json"],
201
+ "profile": "tokens-only"
190
202
  }
191
203
  }
192
204
  ```
193
205
 
194
206
  `check-theme` fails loudly (instead of a false `✓`) when the configured roots resolve to zero
195
- scanned files. Other `"basalt"` config keys (`exempt`,
196
- `spacingSteps`, `forbiddenAccents`, …) are documented on the `BasaltConfig` type.
207
+ scanned files, and `doctor`'s `guard-scan` check agrees with it. Each root's **parent** also
208
+ contributes its `index.html` and its `public/` tree — the Vite layout `basaltViteConfig` assumes,
209
+ and where a raw `theme-color` or webmanifest `background_color` actually lives. `.json` is never
210
+ blanket-scanned; `include` names one explicitly and is the only route to it. `profile:
211
+ "tokens-only"` turns off the 17 kinds whose remedy is a Mantine component or prop, and must be
212
+ declared (or `--tokens-only`) — it is deliberately never inferred from a missing `@mantine/core`,
213
+ which would silence half the guard on any repo keeping Mantine in a different workspace package.
214
+ Other keys (`exempt`, `severity`, `spacingSteps`, `forbiddenAccents`, …) are documented on the
215
+ `BasaltConfig` type.
216
+
217
+ Basalt-emitted LINES are skipped, not whole files. The file has to earn it first — a `.css` path,
218
+ the `@generated basalt-ui` header verbatim on line 1, the version + invocation line on line 2 — and
219
+ then each line does too: at brace depth 0 a selector or a self-closing comment, inside a block only
220
+ a `--vx-*` / `--basalt-*` declaration whose value carries no `;`, a `}`, or a comment. So the
221
+ stylesheet `tokens:css` just wrote is not reported back at you, pasting the marker into a `.tsx`
222
+ suppresses nothing, and an ordinary declaration smuggled into a sheet wearing the header is
223
+ reported on its own line.
224
+
225
+ **The escape hatch is scoped.** `theme-allow <rule-id> — <reason>` waives that one kind, on the
226
+ reported line, on a comment-only line directly above it (the only form JSX can express), or — in
227
+ CSS — from a trailing comment back over the declaration it terminates. A bare `theme-allow` still
228
+ waives everything but reports `theme-allow-unscoped`. The id slot is read strictly: a word there
229
+ that names no rule waives nothing, so an annotation covers exactly the ids it got right and a typo
230
+ is never the blanket form.
197
231
 
198
232
  `doctor`'s `ai-major-parity` hard check fails a monorepo where workspace packages declare different
199
233
  `ai` package majors — unless the split is intentional and written down. A producer pinned to an
@@ -241,7 +275,7 @@ deleted rather than silently surviving into the next real skew.
241
275
  | `./connectivity` | coupled | `ConnectivityProvider` (aggregates browser/React-Query/SSE/health-check status), `useConnectivity`, `ConnectivityIndicator` — auto-mounted by `BasaltProvider` |
242
276
  | `./content` | coupled | `Prose`, `CodeBlock`, `Callout`, `TableOfContents`, `ReadingProgress`, `Markdown`, `MermaidDiagram`, `ArticleLayout`, `ArticleCard` / `ArticleGrid`, `GuideLink` / `GuideDrawer`, `mdxComponents` |
243
277
  | `./styles.css` | — | `@layer basalt` base styles, iOS input safety net, font stack |
244
- | `./tokens.css` | **free** | Prebuilt `--vx-*` stylesheet — the default `buildPaletteCss()` output as a plain file, for a consumer with no bundler, React or Mantine; `basalt-ui tokens:css` re-emits it with a custom scheme selector |
278
+ | `./tokens.css` | **free** | Prebuilt `--vx-*` stylesheet — the default `buildPaletteCss()` output as a plain file, for a consumer with a bundler but no React or Mantine. **With no bundler, use the `basalt-ui tokens:css` CLI instead** and commit the emitted file: importing this subpath needs bare-specifier resolution. See [`docs/FRAMEWORK-FREE.md`](https://github.com/jkrumm/basalt-ui/blob/master/docs/FRAMEWORK-FREE.md) |
245
279
  | `./configs/*` | — | Raw toolchain presets — oxlint, oxfmt, tsconfig (base/react-app/node), lefthook |
246
280
  | `./llms.txt` | — | Machine-readable surface map — one entry per published subpath with import specifier, description, layer, and optional peers |
247
281
 
@@ -606,11 +640,21 @@ bunx basalt-ui init # scaffold doctrine into a consumer repo
606
640
  bunx basalt-ui sync # three-way diff against .basalt/manifest.json after a basalt-ui upgrade
607
641
  bunx basalt-ui sync --check # CI drift gate — non-zero exit on any managed-file drift
608
642
  bunx basalt-ui check-theme # palette guard — fails on colors that bypass the central --vx-* system
609
- bunx basalt-ui doctor # check consumer repo basalt integration health: manifest presence, node_modules-vs-manifest version, spacing-scale drift, and a cross-package `ai` major-version parity check (HARD failure — walks every workspace manifest, exemptable via `basalt.aiMajorSkewReason` — see above) + warn on missing basaltAppPlugin icon files under public/
643
+ bunx basalt-ui doctor # integration health: basalt-resolves, guard-scan, oxlint-preset, manifest presence, node_modules-vs-manifest version, spacing-scale drift, and a cross-package `ai` major-version parity check (HARD failure — walks every workspace manifest, exemptable via `basalt.aiMajorSkewReason` — see above) + warn on missing basaltAppPlugin icon files under public/
644
+ bunx basalt-ui doctor --tokens-only # force the Mantine-free profile (auto-detected otherwise; --framework forces the full set)
645
+ bunx basalt-ui tokens:css --out src/tokens.css # emit the --vx-* stylesheet as a file you own
646
+ bunx basalt-ui fonts:css --out src/fonts.css # emit the shipped --basalt-font-* stacks
610
647
  bunx basalt-ui info # human-readable surface map: subpath, layer, rule, skills, optional peers
611
648
  bunx basalt-ui info --json # same map as stable JSON (InfoOutput shape)
649
+ bunx basalt-ui help # full usage; every subcommand also takes --help / -h
612
650
  ```
613
651
 
652
+ `doctor` reports **`SKIPPED`** as a third outcome beside pass/warn/fail and exits non-zero on it —
653
+ "All checks passed" is only printable when every check actually ran. `check-theme` and `doctor`
654
+ honour `BASALT_CWD`, and relocate to the single workspace package carrying a basalt config when
655
+ invoked from a repo root that has none (two candidates is reported as ambiguous, never guessed).
656
+ `tokens:css` and `fonts:css` both take `--check` as a CI drift gate.
657
+
614
658
  `sync` strategy per file: unchanged since last write → overwrite; locally edited → skip (show diff); missing → recreate. `--force` overwrites local edits.
615
659
 
616
660
  ---
@@ -69,6 +69,49 @@ The one real caveat is a plugin that **emits files from its own `closeBundle`**:
69
69
  `vite-plugin-pwa`'s, those files are written too late to be precached (the `rollup-plugin-copy`
70
70
  class of bug). `vite-plugin-pwa` exposes `integration.closeBundleOrder` to sequence against it.
71
71
 
72
+ ## What `basalt-ui init` writes into `package.json`
73
+
74
+ `init` is not only a file-scaffolder. It also patches two keys, and on an existing app it is a
75
+ **lint-debt event, not a no-op** — the shipped preset switches on whole oxlint plugins the repo was
76
+ never linted against, so previously-clean code lands with real findings on the first run. `init`
77
+ prints the plugin list; run `oxlint .` and triage it before your next commit rather than
78
+ blanket-disabling a plugin.
79
+
80
+ - **`basalt.roots`** — inferred from the real layout (workspace packages depending on basalt or
81
+ Mantine, else every workspace `src/`, else `src`). Everything derives from it: `check-theme`'s
82
+ scan, the seeded CI `oxfmt` globs, the default scan exemption. Correct it if your sources live
83
+ elsewhere; without it a workspace repo scaffolds a guard that scans zero files.
84
+ - **`scripts["lint:basalt"]`** — `oxlint . && basalt-ui check-theme`. Previously the docs told you
85
+ to wire this and nothing wrote it.
86
+ - **`basalt.profile: 'tokens-only'`** — for a consumer on the `--vx-*` layer with no Mantine. It
87
+ must be DECLARED (or passed as `--tokens-only`); `check-theme` deliberately never infers it,
88
+ because inferring from a missing `@mantine/core` would silence 17 kinds on any repo that keeps
89
+ Mantine in a different workspace package. `doctor` does infer it, because its profile only changes
90
+ which ADVICE it prints and never what it enforces — and it names the key to write down.
91
+
92
+ `init` keeps an existing `.oxlintrc.json` rather than clobbering it, and now names what keeping it
93
+ costs (the whole basalt lint half stays off). `--merge-lint` splices the preset's `extends` in; it
94
+ refuses on a commented config rather than deleting the comments.
95
+
96
+ Run `basalt-ui doctor` afterwards to confirm the wiring took. A check that cannot RUN is reported as
97
+ `SKIPPED` and exits non-zero on its own — "All checks passed" is only printable when every check ran.
98
+
99
+ ## `index.html` and `public/` are scanned
100
+
101
+ `check-theme` resolves `.html` / `.webmanifest` / `.json` as markup (colour kinds only), and each
102
+ `basalt.roots` entry's PARENT contributes its `index.html` and its `public/` tree — exactly the Vite
103
+ layout above. So a raw hex in a `theme-color` meta or a webmanifest's `background_color` is a
104
+ finding now; those are the two colours nothing re-derives on a scheme change. Nothing else in the
105
+ parent is walked, and `.json` is never blanket-scanned — name one in `basalt.include`.
106
+
107
+ ## `__APP_VERSION__`
108
+
109
+ `basaltViteConfig` injects the `__APP_VERSION__` define, and since 1.20.0 ships the ambient
110
+ declaration with it (`src/register.ts` → `dist/register.d.ts`, re-exported by the root barrel). An
111
+ app that imports anything from `basalt-ui` has it in scope — delete your hand-written ambient block
112
+ and its `oxlint-disable`. A subpath-only consumer does not get it, which is the same set as the
113
+ consumers not on this Vite preset anyway.
114
+
72
115
  ## CSS layer order (Mantine)
73
116
 
74
117
  `basalt-ui/styles.css` must load AFTER every `@mantine/*/styles.layer.css` bundle — the layered
@@ -55,19 +55,31 @@ radial/matrix shape, is not optional — two `basalt` oxlint plugin rules
55
55
 
56
56
  - **`basalt/hand-rolled-plot`** — rendering a chart-assembly primitive (`AxisLeftNumeric`,
57
57
  `AxisRightNumeric`, `AxisBottomDate`, `HoverOverlay`, `Crosshair`) in a file that does not
58
- compose `CartesianChart` fails the build. Escape: a `theme-allow` comment on the first such
59
- site — that is how a genuinely non-single-plot shape (multi-pane, radial, matrix) declares
60
- itself; `DualPanel` carries the repo's one such comment. The file that DEFINES `CartesianChart`
61
- is exempt definitionally (detected by declaration, not by path), since a rule saying "compose X"
62
- cannot fire inside X.
63
- - **`basalt/chart-legend-literal`** — passing a hand-written array literal to `ChartLegend`'s
64
- `items` fails the build; the legend must be derived from the same `series` array the chart draws
65
- (`deriveLegend`, or just let `ChartFrame`/`CartesianChart` do it), so it cannot go stale and keep
58
+ compose `CartesianChart`. **Since 1.20.0 every assembly node is reported and waived on its own**
59
+ — one comment no longer grants the whole file permanent immunity, which is how a 604-line chart
60
+ file went unpoliced. A genuinely non-single-plot shape (multi-pane, radial, matrix) declares
61
+ itself with a written file declaration — a `theme-allow` that both names the rule and gives a
62
+ reason, anywhere in the file (`theme-allow hand-rolled-plot — two panes over one x scale`);
63
+ `DualPanel` carries the repo's only one. The file that DEFINES `CartesianChart` is exempt
64
+ definitionally (detected by declaration, not by path), since a rule saying "compose X" cannot
65
+ fire inside X.
66
+ - **`basalt/chart-legend-literal`** — a hand-written array literal passed to `ChartLegend`'s
67
+ `items`; **since 1.20.0 also a `.map()` over a non-`series` array**, because deriving from AN
68
+ array is not deriving from THE series. The legend must come from the same `series` the chart
69
+ draws (`deriveLegend`, or just let `ChartFrame`/`CartesianChart` do it), so it cannot go stale
66
70
  naming a series the plot no longer draws.
67
71
 
68
- Both ship at `warn` in the consumer preset (`configs/oxlint.json`) for one minor and `error`
69
- repo-local, per the "Shipping a stricter guard — the grace minor" doctrine in
70
- `packages/basalt-ui/CLAUDE.md`. They promote to `error` in the next minor.
72
+ Both ship `warn` in the consumer preset (`configs/oxlint.json`) and `error` repo-local, per the
73
+ "Shipping a stricter guard — the grace minor" doctrine in `packages/basalt-ui/CLAUDE.md`. 1.20.0
74
+ WIDENS both, so both restart their grace rather than promoting — the live ledger with the promotion
75
+ note per rule is `PLUGIN_RULE_GRACE`, exported beside the plugin in `configs/oxlint-plugin.js`, and
76
+ a test asserts it against the shipped preset in both directions.
77
+
78
+ **A documented limit:** `hand-rolled-plot` keys on those visx primitives, so a chart drawn with DOM
79
+ nodes (divs sized in percent, an SVG assembled by hand) is structurally invisible to it. That is a
80
+ deliberate trade — every alternative detector tried either flagged basalt's own `Donut`/`Heatmap` or
81
+ an icon in a card header, and a noisy shipped rule gets switched off. Compose `CartesianChart`
82
+ because it is the contract, not because lint will catch you.
71
83
 
72
84
  ## Maps are not charts
73
85
 
@@ -85,8 +97,8 @@ ships no map kind.
85
97
  an AST pass — it flags any `padding:`/`gap:`/`margin:` followed by a number in _any_ object
86
98
  literal, in any file. A map's pixel geometry (`fitBounds({ padding: 48 })`, marker offsets) trips
87
99
  it even though it isn't spacing, and hoisting the value to a module-scope const doesn't help — the
88
- regex sees the line, not the binding. Use a `theme-allow` line comment; that's the sanctioned
89
- answer, not a bug to file.
100
+ regex sees the line, not the binding. Scope a `theme-allow inline-spacing — map geometry` comment
101
+ to it; that's the sanctioned answer, not a bug to file.
90
102
 
91
103
  ## Every chart has
92
104
 
@@ -155,7 +167,7 @@ ships no map kind.
155
167
  in the geometry; and because the domain value must be unique, two events at the same instant
156
168
  collapse onto one position and one stops being drawn. `getX` returning a date string reads like a
157
169
  time axis and is not one. Tracked as issue #52; a chart whose x is a measured quantity currently
158
- needs a `basalt/hand-rolled-plot` exemption.
170
+ needs a `basalt/hand-rolled-plot` file declaration.
159
171
 
160
172
  6. **The cursor is shared by default.** No provider needed — `useChartCursor` reads a module-level
161
173
  external store (`useSyncExternalStore`), so every `CartesianChart`/`ChartFrame`-composed chart on
@@ -149,14 +149,26 @@ section divider, not card depth) are both fine.
149
149
  consumer code must compose Mantine layout/surface primitives — `Box`, `Flex`, `Grid`,
150
150
  `SimpleGrid`, `Stack`, `Group`, `Paper`, `Card` — instead of raw `<div>`/`<span>` with inline
151
151
  `style`. Raw HTML with inline layout/surface styling defeats the token system.
152
- - **Mechanical enforcement.** `basalt-ui check-theme` adds six surface guard kinds (each a config knob,
153
- default ON; `theme-allow` line-comment escape): `off-system-surface-var` (raw ramp-step vars),
154
- `card-with-border` (`withBorder` on a `Card`/`Paper`), `mantine-shade-index` (a shade-pinned color —
155
- see below), `raw-html-layout` (raw `<div>`/`<span>` with inline layout/surface styling),
156
- `inline-spacing` (inline spacing literals), `inline-display` (inline `display` literals). The
157
- Mantine-free `src/charts/**` is the only place raw `<div>` is allowed — and it must still use `VX.*`
158
- tokens. `oxlint` adds `basalt/card-inset`, which catches the other half of the same card drift: an
159
- explicit `p`/`padding`/`radius` on a `Card`/`Paper` instead of the xs/sm inset and the theme radius.
152
+ - **The second fix is a CSS Module, and it is the right one in list code.** What
153
+ `raw-html-layout` actually objects to is the inline `style` literal, not the `<div>`. Moving the
154
+ rule into a `.module.css` class that reads `var(--vx-*)` satisfies the guard, keeps the values
155
+ in the token system, and costs zero runtime — where a Mantine primitive costs one component
156
+ instance per row. In a row renderer that paints hundreds of items, or in a downstream library
157
+ that must not force `@mantine/core` on its consumers, take the CSS Module. `check-theme` scans
158
+ `.css` and `.module.css`, so the tokens stay policed there. The guard's fix text names only the
159
+ Mantine route; both are sanctioned.
160
+ - **Mechanical enforcement.** `basalt-ui check-theme` adds eight surface guard kinds (each a config
161
+ knob, default ON; scoped `theme-allow <rule-id> — <reason>` escape): `off-system-surface-var` (raw
162
+ ramp-step vars), `card-with-border` (`withBorder` on a `Card`/`Paper`), `mantine-shade-index` (a
163
+ shade-pinned color — see below), `raw-html-layout` (raw `<div>`/`<span>` with inline layout/surface
164
+ styling), `inline-spacing` (inline spacing literals), `inline-display` (inline `display` literals),
165
+ and since 1.20.0 `css-raw-surface` (the same surface kinds in their kebab CSS dialect, so
166
+ `border-radius: 4px` in a CSS module is no longer invisible beside a flagged `borderRadius: 2`) and
167
+ `hidden-inline-style` (`raw-html-layout`'s formatting-independent half — a multi-line opening tag,
168
+ or a style object hoisted to a const). The Mantine-free `src/charts/**` is the only place raw
169
+ `<div>` is allowed — and it must still use `VX.*` tokens. `oxlint` adds `basalt/card-inset`, which
170
+ catches the other half of the same card drift: an explicit `p`/`padding`/`radius` on a
171
+ `Card`/`Paper` instead of the xs/sm inset and the theme radius.
160
172
 
161
173
  **Never pin a shade index on a color.** `c="yellow.7"`, `bg="blue.4"`, `var(--mantine-color-red-6)` —
162
174
  each names one fixed swatch that is identical in light and dark, so a shade legible on the dark page
@@ -278,8 +290,11 @@ shadow-surfaces.test.ts` mechanically enumerates every site that applies either
278
290
 
279
291
  ## Composing the shell from sub-components
280
292
 
281
- `BasaltShell` is the canonical single-mount. When a fully custom layout is needed, the shell
282
- sub-components are available individually from `basalt-ui`:
293
+ `BasaltShell` is the canonical single-mount, and since 1.20.0 `basalt/hand-rolled-shell` (`warn`)
294
+ says so mechanically: `AppShell.*` parts or a `Burger` in a file that does not render `BasaltShell`
295
+ is the layer the 1.19 nav contract replaces. It is file-scoped and reported once — which shell a
296
+ file uses is one decision. When a fully custom layout is genuinely needed, the shell sub-components
297
+ are available individually from `basalt-ui`:
283
298
 
284
299
  - `AppSidebar` — desktop sidebar (sections, collapse, brand, settings/account extras)
285
300
  - `MobileNav` — the bottom tab bar (takes a `MobileNavModel` from `projectMobileNav`)