basalt-ui 1.19.1 → 1.21.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 (129) hide show
  1. package/AGENTS.md +19 -1
  2. package/CHANGELOG.md +39 -0
  3. package/MIGRATING.md +326 -0
  4. package/README.md +87 -9
  5. package/agent/rules/basalt-app.md +84 -0
  6. package/agent/rules/basalt-charts.md +32 -17
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-mantine.md +25 -10
  9. package/agent/rules/basalt-router.md +79 -15
  10. package/agent/rules/basalt-state.md +21 -3
  11. package/agent/rules/basalt-tokens.md +132 -6
  12. package/agent/skills/basalt-app/SKILL.md +45 -6
  13. package/agent/skills/basalt-charts/SKILL.md +5 -3
  14. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  15. package/configs/check.yml +11 -4
  16. package/configs/lefthook.yml +59 -7
  17. package/configs/oxlint-plugin.js +633 -30
  18. package/configs/oxlint-plugin.test.ts +594 -15
  19. package/configs/oxlint.json +10 -1
  20. package/dist/agent/stick-to-bottom.js +1 -1
  21. package/dist/agent-chat/thread-detail-panel.js +1 -1
  22. package/dist/agent-chat/thread-message.js +2 -2
  23. package/dist/agent-chat/tool-chip.js +1 -1
  24. package/dist/charts/kinds/Bars.d.ts +1 -1
  25. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  26. package/dist/charts/kinds/Donut.d.ts +1 -1
  27. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  28. package/dist/charts/kinds/Donut.js +1 -1
  29. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  30. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  31. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  32. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  33. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  34. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  35. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  36. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  37. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  38. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  39. package/dist/charts/primitives/ChartLegend.js +2 -2
  40. package/dist/charts/primitives/ChartTooltip.js +1 -1
  41. package/dist/cli/index.d.ts +146 -20
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +901 -101
  44. package/dist/cli/toolchain-wiring.test.d.ts +2 -0
  45. package/dist/cli/toolchain-wiring.test.d.ts.map +1 -0
  46. package/dist/cli/waiver-audit.test.d.ts +2 -0
  47. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  48. package/dist/dashboard/delta-badge.js +2 -2
  49. package/dist/dashboard/stat-card.js +1 -1
  50. package/dist/data/virtual-list.js +5 -5
  51. package/dist/guard/index.d.ts +101 -2
  52. package/dist/guard/index.d.ts.map +1 -1
  53. package/dist/guard/index.js +474 -62
  54. package/dist/guard/types.d.ts +25 -5
  55. package/dist/guard/types.d.ts.map +1 -1
  56. package/dist/register.d.ts +25 -0
  57. package/dist/register.d.ts.map +1 -1
  58. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  59. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  60. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  61. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  62. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  63. package/dist/router-tanstack/search-param-store.js +31 -3
  64. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  65. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  66. package/dist/shell/app-mobile-nav.module.css +11 -3
  67. package/dist/shell/index.d.ts +12 -5
  68. package/dist/shell/index.d.ts.map +1 -1
  69. package/dist/shell/index.js +24 -6
  70. package/dist/surfaces.d.ts +1 -1
  71. package/dist/surfaces.js +1 -1
  72. package/dist/tokens/index.d.ts +5 -3
  73. package/dist/tokens/index.d.ts.map +1 -1
  74. package/dist/tokens/index.js +7 -1
  75. package/dist/tokens/palette.d.ts +35 -27
  76. package/dist/tokens/palette.d.ts.map +1 -1
  77. package/dist/tokens/palette.js +24 -18
  78. package/dist/tokens/text-scale.test.d.ts +2 -0
  79. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  80. package/dist/tokens.css +21 -17
  81. package/dist/vite-color-mix.js +2 -2
  82. package/dist/vite.d.ts +24 -2
  83. package/dist/vite.d.ts.map +1 -1
  84. package/dist/vite.js +54 -13
  85. package/llms.txt +3 -3
  86. package/package.json +4 -3
  87. package/src/agent/stick-to-bottom.tsx +1 -1
  88. package/src/agent-chat/thread-detail-panel.tsx +1 -1
  89. package/src/agent-chat/thread-message.tsx +3 -3
  90. package/src/agent-chat/tool-chip.tsx +1 -1
  91. package/src/charts/kinds/Bars.tsx +1 -1
  92. package/src/charts/kinds/Donut.tsx +3 -2
  93. package/src/charts/kinds/DualPanel.tsx +2 -2
  94. package/src/charts/kinds/Heatmap.tsx +1 -1
  95. package/src/charts/kinds/MultiLine.tsx +1 -1
  96. package/src/charts/kinds/StackedArea.tsx +1 -1
  97. package/src/charts/kinds/ZonedLine.tsx +1 -1
  98. package/src/charts/primitives/ChartLegend.tsx +2 -2
  99. package/src/charts/primitives/ChartTooltip.tsx +1 -1
  100. package/src/cli/index.ts +1614 -164
  101. package/src/cli/init-sync.test.ts +21 -3
  102. package/src/cli/template-ledger.test.ts +1 -1
  103. package/src/cli/tokens-css.test.ts +27 -14
  104. package/src/cli/toolchain-wiring.test.ts +856 -0
  105. package/src/cli/waiver-audit.test.ts +131 -0
  106. package/src/dashboard/delta-badge.tsx +3 -3
  107. package/src/dashboard/stat-card.tsx +1 -1
  108. package/src/data/virtual-list.tsx +8 -8
  109. package/src/guard/check-source.test.ts +1104 -12
  110. package/src/guard/index.ts +1106 -103
  111. package/src/guard/types.ts +29 -4
  112. package/src/register.ts +32 -0
  113. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  114. package/src/router-tanstack/search-param-store.test.ts +135 -0
  115. package/src/router-tanstack/search-param-store.ts +90 -8
  116. package/src/shell/app-mobile-nav.module.css +11 -3
  117. package/src/shell/app-mobile-nav.tsx +1 -1
  118. package/src/shell/index.test.tsx +52 -0
  119. package/src/shell/index.tsx +64 -13
  120. package/src/surfaces.ts +1 -1
  121. package/src/theme/density-relations.test.ts +8 -5
  122. package/src/theme/spacing.test.ts +2 -0
  123. package/src/theme-toggle/index.tsx +2 -2
  124. package/src/tokens/index.ts +28 -3
  125. package/src/tokens/palette.ts +24 -18
  126. package/src/tokens/text-scale.test.ts +51 -0
  127. package/src/vite-color-mix.ts +2 -2
  128. package/src/vite.test.ts +125 -6
  129. package/src/vite.ts +148 -20
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,16 @@ 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 and to that one
83
+ node/line. It must START its comment (after `//`, `/*`, `<!--`, a block gutter `*`, or whitespace),
84
+ and is honoured on the reported line, on a comment-only line directly above it (reaching the first
85
+ CODE line below, through the rest of its comment block), and — in CSS — from a trailing comment back
86
+ over the declaration it terminates. Whole-file scope is `theme-allow-file <id>… — <why>`; a bare
87
+ `theme-allow-file` waives nothing. A bare `theme-allow` still waives everything on its placement but
88
+ reports `theme-allow-unscoped`. A word in the id slot that names no rule waives nothing.
89
+ `.json`/`.webmanifest` use a `"basalt:theme-allow[-file]"` member. Audit them all with
90
+ `basalt-ui check-theme --audit-allows`, which exits 1 on a waiver that suppresses nothing.
91
+
80
92
  > **Framework-internal only** — `bunx basalt-ui check-coverage` is a self-consistency gate for the
81
93
  > basalt-ui repo itself (asserts SURFACES ↔ rule files ↔ skill files ↔ package.json exports). It
82
94
  > is not a consumer command and will skip assertion 3 outside the framework repo.
@@ -85,3 +97,9 @@ colors without a `theme-allow` comment.
85
97
 
86
98
  See `basalt-ui/llms.txt` (importable via the package specifier) for the full surface map: one
87
99
  entry per published subpath with import specifier, description, layer, and optional peer packages.
100
+
101
+ ## Upgrading across minors
102
+
103
+ `CHANGELOG.md` lists every release; `MIGRATING.md` lists what **broke** — removed and renamed
104
+ exports per minor, with the replacement. No majors ship here, so a minor bump can require code
105
+ changes. Read `MIGRATING.md` before jumping more than one minor.
package/CHANGELOG.md CHANGED
@@ -1,3 +1,42 @@
1
+ # [1.21.0](https://github.com/jkrumm/basalt-ui/compare/v1.20.0...v1.21.0) (2026-08-22)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * delete a waiver that stopped covering anything ([3984281](https://github.com/jkrumm/basalt-ui/commit/3984281fff6313c225bdf1e185de4a427d2ef4df))
7
+ * emit 0.1, not 0.10, from the shadow palette itself ([48adfff](https://github.com/jkrumm/basalt-ui/commit/48adfffcc9ad7713cacc07c1fc090b219fd175fa))
8
+ * let the real color scheme win, and keep <meta charset> in its window ([8708c85](https://github.com/jkrumm/basalt-ui/commit/8708c85f1488b25e6a059ee879dcadf6350278a2))
9
+ * make an annotation start its comment, and let it scope to one node ([a4cf78c](https://github.com/jkrumm/basalt-ui/commit/a4cf78c546d291c33a27bf94d7636758072a1005))
10
+ * persist sidebar collapse through basalt's own state primitive ([c576040](https://github.com/jkrumm/basalt-ui/commit/c576040172dfeb8d66b52909bbcf55b7b1f91c70))
11
+ * resolve the paths the wiring checks only matched, and audit the waivers ([c595181](https://github.com/jkrumm/basalt-ui/commit/c5951817ed01d7729f031bba2f3da50c3bf60666))
12
+ * restore the palette fixture to emitter bytes ([605e240](https://github.com/jkrumm/basalt-ui/commit/605e240387a417f57ae8070f97bad79d97545d60))
13
+
14
+
15
+ ### Features
16
+
17
+ * give the type ladder the two rungs consumers could only waive ([29b9ef3](https://github.com/jkrumm/basalt-ui/commit/29b9ef3767282dffb51fb52138d6ae4f1d24dd62))
18
+ * make the search-param store's reader reachable from the API ([6924970](https://github.com/jkrumm/basalt-ui/commit/6924970f410b82ba5cfc65fd1d5088124934fc8f))
19
+
20
+ # [1.20.0](https://github.com/jkrumm/basalt-ui/compare/v1.19.1...v1.20.0) (2026-08-22)
21
+
22
+
23
+ ### Bug Fixes
24
+
25
+ * exempt generated LINES, not files, and stop flagging prose as a typo'd id ([a83abfb](https://github.com/jkrumm/basalt-ui/commit/a83abfb77888f31528f44e5923b26d3710ddf005))
26
+ * give the mobile-nav-pill demo row its own glyph ([4620590](https://github.com/jkrumm/basalt-ui/commit/4620590204a99afd07ec27eb923cd229acdfce84))
27
+ * hold basalt to its own scoped theme-allow contract ([b67718c](https://github.com/jkrumm/basalt-ui/commit/b67718cecf8e7ce60b61b7481bcdbd4795f79d40))
28
+ * make the escape hatch and the generated-file marker fail closed ([526cdb7](https://github.com/jkrumm/basalt-ui/commit/526cdb7f11395f27183828e34297b0ac6bc846c8))
29
+ * make the mobile-nav active pill track density and survive an icon-less app ([0a7d756](https://github.com/jkrumm/basalt-ui/commit/0a7d7560f9eae6db4c02ff029ba47088a084e511))
30
+ * snapshot the tokens-only guard export ([b63dc24](https://github.com/jkrumm/basalt-ui/commit/b63dc242bfd46359476f7fcae80957db244f727f))
31
+
32
+
33
+ ### Features
34
+
35
+ * demo the mobile-nav active pill with and without an icon dependency ([9620905](https://github.com/jkrumm/basalt-ui/commit/9620905409efdf39bb10b40edaa128ce2b61c741))
36
+ * make the escape hatch usable and close the holes round 4 found ([5b8420d](https://github.com/jkrumm/basalt-ui/commit/5b8420d24647b84818dfbb65e9f0c52ea1e79c2c))
37
+ * scan the app shell, wire the tokens-only profile, ship __APP_VERSION__ ([61b1792](https://github.com/jkrumm/basalt-ui/commit/61b1792e8ea1bff3c59128ba65137b27d40df81a))
38
+ * stop the toolchain reporting green while enforcing nothing ([d3d8e34](https://github.com/jkrumm/basalt-ui/commit/d3d8e3434893516e62413acafdd166be13b86541))
39
+
1
40
  ## [1.19.1](https://github.com/jkrumm/basalt-ui/compare/v1.19.0...v1.19.1) (2026-08-20)
2
41
 
3
42
 
package/MIGRATING.md ADDED
@@ -0,0 +1,326 @@
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. **Every replacement below
10
+ was re-audited against the built declaration files at 1.20.0 (2026-08-22)** after round 5 caught one
11
+ row that was wrong. That pass corrected 4 table rows and 3 prose claims. The 1.20.1 section was
12
+ written against source, not against its commit messages. Check the types, not this table, if the two
13
+ disagree.
14
+
15
+ **No majors, by policy.** A rename or a removal ships as a plain `feat:` on the 1.x line, so a minor
16
+ bump can require code changes. Skipping several at once is the expensive case — read every section
17
+ between your version and the target.
18
+
19
+ > Reading the CHANGELOG: minors and majors are `#` (h1), patches are `##` (h2). Grepping for
20
+ > `## [1.` finds only the patches.
21
+
22
+ **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,
23
+ 1.10.0, 1.13.0, 1.14.0, 1.16.0, 1.18.0 — and every patch. Additive-only subpaths: `./tokens.css`
24
+ at 1.3.0, `./agent-chat` at 1.10.0.
25
+
26
+ ---
27
+
28
+ ## 1.20.1 — the `theme-allow` grammar
29
+
30
+ **No export removed or renamed.** One break, and it is in the escape hatch itself: **file scope must
31
+ now be spelled `theme-allow-file`.** At 1.20.0 an annotation that named a rule and gave a reason —
32
+ the exact shape the rule's own message asks for — was promoted to a whole-file declaration, which is
33
+ why per-node scoping never actually shipped. The two forms are now distinct:
34
+
35
+ ```text
36
+ theme-allow → this node/line, EVERY rule (reports theme-allow-unscoped)
37
+ theme-allow <id>[, <id>…] [— <why>] → this node/line, those rules
38
+ theme-allow-file <id>[, <id>…] — <why> → the WHOLE FILE, those rules; a bare one waives NOTHING
39
+ "basalt:theme-allow[-file]": "<id>… — <why>" → the same two, for JSON / .webmanifest
40
+ ```
41
+
42
+ **The migration is one word per file declaration.** Measured across the consumer sweep: linewatch
43
+ 0 → 11 findings, argo 0 → 6, rb 0 → 0 — all `warn`, so **no build changes colour**.
44
+
45
+ ```diff
46
+ -// theme-allow hand-rolled-plot — two panes over one x scale
47
+ +// theme-allow-file hand-rolled-plot — two panes over one x scale
48
+ ```
49
+
50
+ **An annotation must now START its comment** — after `//`, `/*`, `<!--`, a block-comment gutter `*`,
51
+ or nothing but whitespace. Both parsers used a bare substring search, so a comment that merely
52
+ _mentioned_ the token parsed as the legacy blanket form and switched every rule off on the line
53
+ below. linewatch documented its own waivers in a docblock and thereby disarmed the file — a false
54
+ NEGATIVE, and the reason this ships as a break rather than a grace period. Every annotation anyone
55
+ actually writes still qualifies; a sentence about the escape hatch no longer waives anything.
56
+
57
+ **A comment-only annotation now reaches the first CODE line below it**, walking through the rest of
58
+ its own comment block. Before, a reason that wrapped onto a second line, or a docblock's `*/`,
59
+ absorbed the waiver and the natural shape silently waived nothing — argo hit that three times in one
60
+ upgrade. A blank line still ends the block.
61
+
62
+ **`.json` / `.webmanifest` finally have a waiver.** They have been scanned since 1.20.0 and cannot
63
+ hold a comment, so their findings were unwaivable and the printed remedy prescribed something
64
+ impossible; both consumers fell back to a blanket `exemptRules`. Use a member key — but for a
65
+ manifest, `basaltAppPlugin` is the first remedy, since a hand-copied hex drifts from the palette:
66
+
67
+ ```json
68
+ { "basalt:theme-allow-file": "raw-hex — a PWA manifest theme_color must be a literal hex" }
69
+ ```
70
+
71
+ **Two API-shaped fixes worth acting on:**
72
+
73
+ | Change | What to do |
74
+ | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
75
+ | `createSearchParamStore` / `createMultiSearchParamStore` gain `linkSearch` | replace every nav link's `search: { <param>: '<literal>' }` with `search: <store>.linkSearch`, passed BY REFERENCE. A module-scope literal pins the fallback on every click — argo's reader had ZERO call sites, so "remember my window" had never worked |
76
+ | `BasaltShell` collapse moves to `createPersistedState` | the key is now `basalt:<storageKey>` holding `{ v, value }`; read it with `readPersistedValue(storageKey, 1)`. A one-time migration adopts the raw pre-1.20.1 value, so the sidebar does not re-expand on upgrade |
77
+
78
+ **New, additive:**
79
+
80
+ | Surface | Note |
81
+ | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
82
+ | `check-theme --audit-allows` | every waiver and every `exemptRules` entry with what it still suppresses, proved by re-running the guard with that one occurrence neutralized. Exits 1 on a dead waiver — wire it into CI |
83
+ | `basalt.exemptRules` takes paths, globs and a reason | relative paths and directory prefixes (`public/site.webmanifest`, `src/agent`), globs (`*` stops at `/`, `**` does not, a slash-free glob also matches the basename), and `{ paths, reason }`. Entries that suppress nothing are now reported |
84
+ | `doctor` → `lefthook-preset` | hard-fails a broken `extends` target. lefthook merges a missing target into ZERO commands and exits 0, so a stale path leaves a repo with no pre-commit gate and a clean `lefthook dump`. `sync` reports the same seam |
85
+ | `basaltAppPlugin({ colorScheme })` | `'dark'` (default) / `'light'` / `'auto'` / `false`. Set it to whatever the app passes as `defaultColorScheme` — before this, a light-scheme consumer got dark native controls permanently |
86
+ | `VX.text.nano` (10px) + `VX.text.display` (30px) | the two rungs `inline-font-size` could previously only be waived for. A 20px rung was rejected: 21/20 = 1.05 is below the ladder's 1.06×–1.17× band, so `h2` is the remedy |
87
+ | `sync` backfills `basalt.roots` | only `init` wrote it, so every existing consumer sat on the undeclared `src` default while `guard-scan` passed. A declared value is never overwritten |
88
+ | `basalt/shadow-basalt-export` reads all nine barrels | the charts layer included. Still exact-name-only — a **tripwire, not coverage** |
89
+
90
+ Also: `check-theme`'s `inline-spacing` no longer reads a unitless number in a plain options bag as
91
+ CSS (`fitBounds({ padding: 48 })` stops reporting); the shipped `oxfmt` pre-commit job drops back to
92
+ `*.{ts,tsx,js,jsx,css}` and gains `--no-error-on-unmatched-pattern`; `tokens:css` emits `0.1` rather
93
+ than `0.10`, so a committed sheet stops failing prettier — re-run the command to pick it up.
94
+
95
+ **The lefthook preset overrides YOU, not the other way round.** An `extends` target wins on a
96
+ colliding key: declare `pre-commit.commands.oxfmt.run` (or `glob:`) in your own file and **yours**
97
+ is the one silently discarded. Only keys the preset does not define merge in. The guard job runs
98
+ `${BASALT_BIN:-bunx --no-install basalt-ui}`; that shell default is the sanctioned seam, set via
99
+ `env:`, which does merge.
100
+
101
+ ## 1.20.0 — enforcement
102
+
103
+ **No export removed or renamed.** The whole delta is that things which used to pass now report. If
104
+ your build goes red on this upgrade, that is the release working. Every new **kind** and **rule**
105
+ lands `warn` — but two other changes in this release do fail a build: `basalt/raw-size-literal`
106
+ promotes to `error`, and the widened markup scan reads `index.html` / `.webmanifest`, where a raw
107
+ hex is an `error`-severity colour kind. Two consumers exited 1 on exactly that.
108
+
109
+ | Change | What you'll see | What to do |
110
+ | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
111
+ | `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 |
112
+ | 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` |
113
+ | Two new oxlint rules (`warn`) | `basalt/shadow-basalt-export`, `basalt/hand-rolled-shell` | import the shipped component instead of the fork |
114
+ | `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 |
115
+ | `doctor` `SKIPPED` exits non-zero, + 3 new hard checks | doctor goes red where it was green | that is the finding — see below |
116
+
117
+ **`theme-allow` has a new contract, and one comment shape stops waiving.** A bare `theme-allow`
118
+ still waives every kind, but now reports `theme-allow-unscoped`. Rescope it — and spell the id
119
+ right, because a word in the id slot that names no rule now waives NOTHING rather than degrading to
120
+ the blanket form:
121
+
122
+ ```diff
123
+ -// theme-allow
124
+ +// theme-allow raw-surface — third-party widget needs a literal corner
125
+ ```
126
+
127
+ **The break: a reason with no separator introducing it.** `// theme-allow legacy vendor asset` used
128
+ to waive the line; it now waives nothing and the un-suppressed finding reports at its own severity
129
+ (`error` for `raw-hex`). The first word after the token is read as a rule id, and an id that names
130
+ no rule fails closed — that is the whole point, since the alternative is one mistyped character
131
+ silently widening a scoped waiver into a blanket one. No annotation in any of the seven consumer
132
+ repos writes that shape; every one of them introduces its reason with `—`, `–`, `-` or `:`. Add a
133
+ separator, or an id:
134
+
135
+ ```diff
136
+ -// theme-allow legacy vendor asset
137
+ +// theme-allow raw-hex — legacy vendor asset
138
+ ```
139
+
140
+ Prose AFTER a resolved id is safe and needs no separator —
141
+ `// theme-allow raw-surface sub-scale legend corner` waives `raw-surface` and reports nothing. Only
142
+ a comma keeps the id list open past the first id.
143
+
144
+ Two placements that used to fail now work, both matching what the oxlint plugin always did: a
145
+ comment-ONLY line directly above the reported line (the only form JSX can express — the reported
146
+ line is usually a multi-line opening tag or a `{expr}` child), and in CSS a trailing annotation
147
+ reaching back over the declaration it terminates, which is what survives the shipped `oxfmt`
148
+ reflowing a long `background-color` so the hex lands above the comment.
149
+
150
+ **`basalt/hand-rolled-plot` waivers must now be written deliberately.** Every assembly node is
151
+ reported individually, and a waiver is no longer picked up off whatever comment happened to sit on
152
+ the file's first assembly node — it needs a written declaration naming the rule and giving a reason,
153
+ anywhere in the file: `// theme-allow hand-rolled-plot — two panes over one x scale`.
154
+
155
+ **But the waiver is still whole-file, and per-node scoping is not expressible at 1.20.0.** Naming
156
+ the rule AND giving a reason is what `hasFileDeclaration` matches, at any line — so a comment
157
+ intended for one node silences the file. Dropping the reason keeps it node-scoped in the oxlint
158
+ plugin, but then `check-theme` reports `theme-allow-unscoped ("no reason")`. The two halves of the
159
+ contract intersect at exactly one legal shape and that shape is whole-file. Write the declaration in
160
+ the component's docblock, where it reads as the file-level decision it is. **Fixed in 1.20.1** —
161
+ see that section; the declaration moves to `theme-allow-file`.
162
+
163
+ **`doctor` will go red.** `SKIPPED` is a third outcome beside pass/warn/fail and exits non-zero on
164
+ its own — "All checks passed" is only printable when every check RAN. Three new hard checks:
165
+ `basalt-resolves` (walks cwd → ancestors → workspace packages), `guard-scan` (would `check-theme`
166
+ cover more than zero files?), `oxlint-preset` (does `.oxlintrc.json` really extend the shipped
167
+ preset? JSONC is parsed, not rejected — `init` keeps an existing config, so one repo ran five minors
168
+ with the whole lint half off).
169
+
170
+ **New, additive:**
171
+
172
+ | Surface | Note |
173
+ | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
174
+ | `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 |
175
+ | `basalt.include: [...]` | scan a named file outside `roots` — and the only route to a `.json`, which is never blanket-scanned |
176
+ | `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 |
177
+ | `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 |
178
+ | `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` |
179
+ | `__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 |
180
+ | `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 |
181
+ | `@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 |
182
+
183
+ `check-theme` also resolves `.html` / `.webmanifest` / `.json` as markup (colour kinds only), and
184
+ each root's PARENT now contributes its `index.html` and `public/` tree.
185
+
186
+ ## 1.19.0 — nav
187
+
188
+ | Removed / renamed | Replacement | Note |
189
+ | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
190
+ | `NavLinkRenderer` (type) | `defineNav` + `{...useNav(NAV)}` (`basalt-ui/router-tanstack`), or `SidebarItem.Anchor` | basalt now paints every nav pixel |
191
+ | `BasaltShellProps.renderNavLink`, `AppSidebarProps.renderNavLink` | same | |
192
+ | `BreadcrumbLinkRenderer` (type) | `AppBreadcrumbs.parentAnchor` | |
193
+ | `BasaltShellProps.renderBreadcrumbLink` | same | |
194
+ | `BasaltShellProps.sidebarFooterExtra` | `mobileNav.moreExtra` | its only host was the mobile drawer; it rendered nowhere on desktop |
195
+ | `AppSidebarProps.footerExtra`, `AppSidebarProps.onClose` | — | the full-height mobile sidebar drawer is deleted |
196
+ | `MobileNavItem` (type) | `MobileNavSlot` | |
197
+ | `MobileNavSection` (type) | `MobileNavGroup` | |
198
+ | `MobileNavLinkRenderer` (type) | `MobileNavModel` | |
199
+ | `SidebarSection.mobileTab` | `SidebarSection.mobile?: false \| NavSectionMobile` — an OBJECT (`{ tab: true, label?, icon? }`), or `false` to hide the section | `'tab' \| 'more' \| 'hidden'` is `SidebarItem.mobile` (`NavMobilePlacement`, `true` ≡ `'tab'`, `false` ≡ `'hidden'`) — a different prop on a different type |
200
+
201
+ A consumer with no router needs no migration — `href` + `onClick` still work.
202
+
203
+ **Expect new type errors, and read them.** `renderNavLink` took `to` as an opaque value, which is
204
+ why consumers reached for `to={target.to as never}`. `defineNav` types it, so a destination missing
205
+ a required `search` now fails to compile — in one repo that surfaced two nav links that had been
206
+ shipping without required params. Fix with a click-time thunk (`search: () => Schema.parse({})`),
207
+ never `search: true`.
208
+
209
+ ## 1.17.0 — behaviour only, no export removed
210
+
211
+ | Changed | Effect | Opt out |
212
+ | ---------------------------------------------------- | ------------------------------------------------------------ | -------------------------------- |
213
+ | `resolveAxisDomain` clamps before padding, not after | the axis top moves on any chart with an `autoMaxFloor` | lower the floor, or pin `domain` |
214
+ | `ChartFrame` `role="img"` → `role="group"` | `role="img"` pruned the keyboard slider out of the a11y tree | — |
215
+
216
+ ## 1.15.0 — chart layer rebuilt
217
+
218
+ The largest delta in the 1.x line. No shims were shipped.
219
+
220
+ | Removed / renamed | Replacement | Note |
221
+ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
222
+ | `ChartHoverSync`, `ChartHoverSyncProps` | `globalCursorStore` / `createCursorStore` / `useChartCursor` / `useCursorState` — **no provider** | `ChartCursorScope` now _isolates_ a subtree; it is the inverse of the old provider, not a rename |
223
+ | `HoverContext`, `HoverCtx`, `useHoverSync`, `DEFAULT_NO_OP_SET_HOVER` | `useChartCursor`, `useCursorState`, `CursorState`, `CursorStore` | context → `useSyncExternalStore` |
224
+ | `ResponsiveChart`, `ResponsiveChartProps` | `CartesianChart` / `CartesianChartProps` (+ `autoMargin`, `PlotContext`, `PlotRect`) | now mandatory for every single-plot cartesian chart, enforced by `basalt/hand-rolled-plot` |
225
+ | `ChartTooltip` (tip-based), `useChartTooltip`, `useTooltipStyles` | `ChartTooltipFloat` + the `tooltip: CartesianTooltipConfig` prop | portal / flip / clamp done once |
226
+ | `BarsAxisConfig` | `AxisConfig` + `resolveAxisDomain` | |
227
+ | `ZonedLineTooltipLabel` | `tooltip` config on the kind | |
228
+ | `ZonedLine`/`MultiLine`: `yDomain`, `yAutoPad`, `yAutoMaxFloor`, `yAutoMinCeil`, `numTicksY`, `formatYTick` | one `y?: AxisConfig<T>` | `y` is **optional** where `yDomain` was required |
229
+ | `ZonedLine`/`MultiLine`: `tooltipLabel`, `renderExtraTooltipRows` | `tooltip.label`, `tooltip.extraRows` (`CartesianTooltipConfig`) | there is no `appendRows`; the field is `extraRows` |
230
+ | `ZonedLine`/`MultiLine`: `formatValue` | **`y.format`** — or per-series `ChartSeries.formatValue` for one row | `CartesianTooltipConfig` has no value formatter: the axis format IS the tooltip value format |
231
+ | `ZonedLine`/`MultiLine`: `numTicksX` | `xTicks` | `xZones` added alongside |
232
+ | `Bars`: `formatValue`, `hideBarTooltipRows`, `leftAxis`, `rightAxis`, `marginLeft`, `numTicksX`, `tooltipLabel`, `renderExtraTooltipRows`, `renderPrefixTooltipRows` | `y` / `y2` (`AxisConfig`), `xTicks`, `tooltip.{label,extraRows,prependRows}`, per-bar `BarsBar.formatValue` / `.tooltip`, measured `autoMargin` + `margin` | passing `y2` is what makes a chart dual-axis. `chartMargin({ rightAxis })` is **not** removed — it is still exported from `basalt-ui` and `basalt-ui/charts`; it is simply no longer needed, since the right gutter follows from measurement |
233
+ | `StackedArea`: `formatValue`, `numTicksX`, `numTicksY`, `yAutoMaxFloor`, `yLabel` | `y` (incl. `y.format`), `xTicks`; per-series `ChartSeries.formatValue` | `StackedAreaProps` has **no** `tooltip` prop — its tooltip is entirely derived from `series` |
234
+ | `Heatmap.width` | measures itself; takes `height` / `aspectRatio` / `fill` | |
235
+
236
+ ## 1.12.0 — agent-chat, behaviour only
237
+
238
+ | Changed | Effect |
239
+ | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------- |
240
+ | `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 |
241
+ | consumer `rehypePlugins` output is now sanitized, `clobberPrefix` is empty | a consumer injecting non-default elements must pass a matching `sanitizeSchema` |
242
+
243
+ ## 1.11.0 — agent parts
244
+
245
+ Semver-breaking in a minor; the commit body says so.
246
+
247
+ | Removed / renamed | Replacement | Note |
248
+ | -------------------------------------------------------- | ----------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------- |
249
+ | `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 |
250
+ | the error field named `error` | `errorText` | the SDK union has no `error` |
251
+ | flattened approval fields | nested `approval: ToolApproval`, carried verbatim | flattening dropped `isAutomatic` / `signature` |
252
+ | — | 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 |
253
+ | `ToolChip` threw `assertNever` on an unknown state | now falls back instead of throwing | |
254
+
255
+ ## 1.0.0 — the Mantine pivot
256
+
257
+ | Removed / renamed | Replacement | Note |
258
+ | --------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------- | -------------------------------------------------------------------------------- |
259
+ | `./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()` |
260
+ | `./starlight` subpath | — | no replacement |
261
+ | the OKLCH foundation palette, ShadCN/Tremor/Starlight doctrine | the three-tier `--vx-*` system | see `docs/FRAMEWORK-FREE.md` for the token-only route |
262
+
263
+ Names that have no 1.x equivalent: the `--chart-blue-1..8` sequential ramp (1.x is categorical
264
+ `--vx-fill-*` only), the default font stacks as a token (they live in `styles.css`), `purple` as a
265
+ text color, `black`, `blue-400`/`green-400`.
266
+
267
+ **The typefaces changed at 1.0, not just their delivery.** 0.4.2 shipped Instrument Sans; 1.x ships
268
+ Nunito Sans (body) + Hubot Sans (condensed headings), mono unchanged at JetBrains Mono. `fonts:css`
269
+ emits the 1.x stacks — for a 0.4.2 migrant that is a **rebrand**, not a restoration. It also emits
270
+ `--basalt-font-head-stretch: 88%`, tuned for Hubot Sans specifically; pointed at another face it
271
+ silently condenses it.
272
+
273
+ ## 1.0.1 — CLI binary renamed
274
+
275
+ `basalt` → **`basalt-ui`**. Never `bunx basalt`.
276
+
277
+ ---
278
+
279
+ ## Lint and guard rules that tightened
280
+
281
+ Two independent mechanisms, each with its own ledger — read the ledger, not this table, for what is
282
+ in grace TODAY. `GuardKind` severities (`basalt-ui check-theme`) default to `error` and are
283
+ downgraded only by `GRACE_PERIOD_KINDS` (`src/guard/index.ts`). `basalt/*` oxlint rule severities
284
+ live in `configs/oxlint.json`, and since 1.20.0 their grace is tracked by `PLUGIN_RULE_GRACE`, a
285
+ named export beside the plugin — a test asserts it against the shipped preset in both directions, so
286
+ deleting an entry forces the level flip in the same commit.
287
+
288
+ | Rule | Landed | Became `error` |
289
+ | ---------------------------------- | ---------------- | ------------------------------------------------------------------------ |
290
+ | `mantine-shade-index` (guard kind) | 1.7.0 as `warn` | **1.11.0** |
291
+ | `basalt/raw-scroll-container` | ≤1.2.0 as `off` | `warn` 1.10.0 → **`error` 1.13.0** |
292
+ | `basalt/ai-sdk-major` | 1.10.0 as `warn` | **1.13.0** |
293
+ | `basalt/agent-no-raw-usechat` | 1.10.0 as `warn` | **1.13.0** |
294
+ | `basalt/agent-resume-guard` | 1.10.0 as `warn` | **1.13.0** |
295
+ | `basalt/raw-size-literal` | 1.7.0 as `warn` | **1.20.0** |
296
+ | `basalt/hand-rolled-plot` | 1.15.0 as `warn` | still `warn` — widened again at 1.20.1; grace restarts with the widening |
297
+ | `basalt/chart-legend-literal` | 1.15.0 as `warn` | still `warn` — widened at 1.20.0 |
298
+ | `basalt/shadow-basalt-export` | 1.20.0 as `warn` | may stay `warn` permanently — widened at 1.20.1 |
299
+ | `basalt/hand-rolled-shell` | 1.20.0 as `warn` | — |
300
+
301
+ `card-with-border`, `inline-display`, `raw-html-layout`, `raw-form-control`, `raw-font-family` and
302
+ the other original guard kinds have been `error` since before 1.2.0 — they never had a grace minor.
303
+ Guard findings only gained a severity field at all in 1.4.0; before that every finding was fatal.
304
+
305
+ **Rule-id rename at 1.1.0:** `basalt/import-boundary` split into `basalt/visx-boundary`,
306
+ `basalt/visx-tooltip` and `basalt/token-layer-boundary` (the last is repo-local and deliberately not
307
+ in the shipped preset). A config still naming `import-boundary` after 1.1.0 disables nothing.
308
+
309
+ ## Deprecated, not yet removed
310
+
311
+ The 32 camelCase `--vx-*` aliases deprecated in 1.5.0 are **still emitted at 1.20.1**.
312
+ `buildPaletteCss({ legacyAliases: false })` / `tokens:css --no-legacy-aliases` opts out now; a later
313
+ minor flips the default.
314
+
315
+ ## Not verified
316
+
317
+ - Props declared inline on a component (`function X({ a }: { a: string })`) rather than on an
318
+ exported type are outside the diff method used here. Spot checks on the shell and charts modules
319
+ found none, but the negative is unproven.
320
+ - The 0.4.2 `./css` line counts in the 1.0.0 table (877 lines / 307 custom props / 19 utilities) come
321
+ from the removal commit, not from a re-read of the 0.4.2 tarball.
322
+
323
+ `AppBreadcrumbs.parentAnchor` was listed here through 1.20.0 and is now verified: it is declared on
324
+ the shipped `dist/shell/app-breadcrumbs.d.ts` (`parentAnchor?: NavAnchor`, with `parentHref` as the
325
+ no-router fallback). The type is inline on the component rather than an exported `AppBreadcrumbsProps`,
326
+ which is why the export-surface diff never saw it.
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,84 @@ 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 and backfilled by sync; 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",
202
+ "exemptRules": {
203
+ "inline-display": ["agent"],
204
+ "raw-hex": {
205
+ "paths": ["public/site.webmanifest"],
206
+ "reason": "a PWA manifest theme_color MUST be a literal hex — JSON cannot reference a CSS var"
207
+ }
208
+ }
190
209
  }
191
210
  }
192
211
  ```
193
212
 
194
213
  `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.
214
+ scanned files, and `doctor`'s `guard-scan` check agrees with it. Each root's **parent** also
215
+ contributes its `index.html` and its `public/` tree — the Vite layout `basaltViteConfig` assumes,
216
+ and where a raw `theme-color` or webmanifest `background_color` actually lives. `.json` is never
217
+ blanket-scanned; `include` names one explicitly and is the only route to it. `profile:
218
+ "tokens-only"` turns off the 17 kinds whose remedy is a Mantine component or prop, and must be
219
+ declared (or `--tokens-only`) — it is deliberately never inferred from a missing `@mantine/core`,
220
+ which would silence half the guard on any repo keeping Mantine in a different workspace package.
221
+ Other keys (`exempt`, `severity`, `spacingSteps`, `forbiddenAccents`, …) are documented on the
222
+ `BasaltConfig` type.
223
+
224
+ **`exemptRules`** turns one kind off along a path. A pattern is a relative path
225
+ (`public/site.webmanifest`, or `src/agent` for everything under it), a glob (`*` stops at `/`, `**`
226
+ does not, and a slash-free glob is also tried against the basename, so `*.module.css` works), or a
227
+ bare path segment — the legacy shape, and the one nobody guesses, since before 1.20.1 it was the
228
+ ONLY shape and a real relative path matched nothing and said nothing. The object form records the
229
+ reason, which is what a `theme-allow` always carried and this key could not. A pattern that
230
+ suppresses nothing is now reported by a normal run and fails `--audit-allows`.
231
+
232
+ `sync` **backfills `roots`** when the key is absent — only `init` used to write it, so every
233
+ existing consumer stayed on the undeclared `src` default while `guard-scan` passed anyway. A
234
+ declared value is never overwritten; a layout it does not cover is reported as one line, because
235
+ `roots` being a subset is the normal case in a monorepo, not drift.
236
+
237
+ Basalt-emitted LINES are skipped, not whole files. The file has to earn it first — a `.css` path,
238
+ the `@generated basalt-ui` header verbatim on line 1, the version + invocation line on line 2 — and
239
+ then each line does too: at brace depth 0 a selector or a self-closing comment, inside a block only
240
+ a `--vx-*` / `--basalt-*` declaration whose value carries no `;`, a `}`, or a comment. So the
241
+ stylesheet `tokens:css` just wrote is not reported back at you, pasting the marker into a `.tsx`
242
+ suppresses nothing, and an ordinary declaration smuggled into a sheet wearing the header is
243
+ reported on its own line.
244
+
245
+ **The escape hatch is scoped, and since 1.20.1 the two scopes are spelled apart:**
246
+
247
+ ```text
248
+ theme-allow → this node/line, EVERY rule (reports theme-allow-unscoped)
249
+ theme-allow <id>[, <id>…] [— <why>] → this node/line, those rules
250
+ theme-allow-file <id>[, <id>…] — <why> → the WHOLE FILE, those rules; a bare one waives NOTHING
251
+ "basalt:theme-allow[-file]": "<id>… — <why>" → the same two, for JSON / .webmanifest
252
+ ```
253
+
254
+ An annotation must **start** its comment (after `//`, `/*`, `<!--`, a block gutter `*`, or
255
+ whitespace) — before 1.20.1 any comment merely mentioning the token parsed as the bare blanket form,
256
+ so a file documenting its own waivers disarmed itself. Placements: the reported line, a comment-only
257
+ line directly above it (the only form JSX can express, and it reaches the first CODE line below,
258
+ walking through the rest of its comment block), or — in CSS — a trailing comment reaching back over
259
+ the declaration it terminates. The id slot is read strictly: a word there that names no rule waives
260
+ nothing, so a typo is never the blanket form.
261
+
262
+ `basalt-ui check-theme --audit-allows` reports every annotation and every `exemptRules` entry with
263
+ what it still suppresses, proved by re-running the guard with that one waiver neutralized. Exits 1
264
+ on a dead waiver, so it is usable as a CI gate.
197
265
 
198
266
  `doctor`'s `ai-major-parity` hard check fails a monorepo where workspace packages declare different
199
267
  `ai` package majors — unless the split is intentional and written down. A producer pinned to an
@@ -241,7 +309,7 @@ deleted rather than silently surviving into the next real skew.
241
309
  | `./connectivity` | coupled | `ConnectivityProvider` (aggregates browser/React-Query/SSE/health-check status), `useConnectivity`, `ConnectivityIndicator` — auto-mounted by `BasaltProvider` |
242
310
  | `./content` | coupled | `Prose`, `CodeBlock`, `Callout`, `TableOfContents`, `ReadingProgress`, `Markdown`, `MermaidDiagram`, `ArticleLayout`, `ArticleCard` / `ArticleGrid`, `GuideLink` / `GuideDrawer`, `mdxComponents` |
243
311
  | `./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 |
312
+ | `./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
313
  | `./configs/*` | — | Raw toolchain presets — oxlint, oxfmt, tsconfig (base/react-app/node), lefthook |
246
314
  | `./llms.txt` | — | Machine-readable surface map — one entry per published subpath with import specifier, description, layer, and optional peers |
247
315
 
@@ -606,11 +674,21 @@ bunx basalt-ui init # scaffold doctrine into a consumer repo
606
674
  bunx basalt-ui sync # three-way diff against .basalt/manifest.json after a basalt-ui upgrade
607
675
  bunx basalt-ui sync --check # CI drift gate — non-zero exit on any managed-file drift
608
676
  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/
677
+ 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/
678
+ bunx basalt-ui doctor --tokens-only # force the Mantine-free profile (auto-detected otherwise; --framework forces the full set)
679
+ bunx basalt-ui tokens:css --out src/tokens.css # emit the --vx-* stylesheet as a file you own
680
+ bunx basalt-ui fonts:css --out src/fonts.css # emit the shipped --basalt-font-* stacks
610
681
  bunx basalt-ui info # human-readable surface map: subpath, layer, rule, skills, optional peers
611
682
  bunx basalt-ui info --json # same map as stable JSON (InfoOutput shape)
683
+ bunx basalt-ui help # full usage; every subcommand also takes --help / -h
612
684
  ```
613
685
 
686
+ `doctor` reports **`SKIPPED`** as a third outcome beside pass/warn/fail and exits non-zero on it —
687
+ "All checks passed" is only printable when every check actually ran. `check-theme` and `doctor`
688
+ honour `BASALT_CWD`, and relocate to the single workspace package carrying a basalt config when
689
+ invoked from a repo root that has none (two candidates is reported as ambiguous, never guessed).
690
+ `tokens:css` and `fonts:css` both take `--check` as a CI drift gate.
691
+
614
692
  `sync` strategy per file: unchanged since last write → overwrite; locally edited → skip (show diff); missing → recreate. `--force` overwrites local edits.
615
693
 
616
694
  ---