basalt-ui 1.20.0 → 1.22.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 (89) hide show
  1. package/AGENTS.md +11 -5
  2. package/CHANGELOG.md +33 -0
  3. package/MIGRATING.md +231 -50
  4. package/README.md +57 -10
  5. package/agent/rules/basalt-app.md +49 -2
  6. package/agent/rules/basalt-charts.md +15 -12
  7. package/agent/rules/basalt-content.md +4 -0
  8. package/agent/rules/basalt-router.md +57 -5
  9. package/agent/rules/basalt-state.md +21 -3
  10. package/agent/rules/basalt-tokens.md +78 -16
  11. package/agent/skills/basalt-app/SKILL.md +5 -1
  12. package/agent/skills/basalt-charts/SKILL.md +5 -3
  13. package/agent/templates/CLAUDE-block.md.tpl +11 -5
  14. package/configs/lefthook.yml +48 -15
  15. package/configs/oxlint-plugin.js +218 -69
  16. package/configs/oxlint-plugin.test.ts +331 -9
  17. package/dist/charts/kinds/Bars.d.ts +1 -1
  18. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  19. package/dist/charts/kinds/Donut.d.ts +1 -1
  20. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  21. package/dist/charts/kinds/DualPanel.d.ts +1 -1
  22. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  23. package/dist/charts/kinds/Heatmap.d.ts +1 -1
  24. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  25. package/dist/charts/kinds/MultiLine.d.ts +1 -1
  26. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  27. package/dist/charts/kinds/StackedArea.d.ts +1 -1
  28. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  29. package/dist/charts/kinds/ZonedLine.d.ts +1 -1
  30. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  31. package/dist/cli/index.d.ts +55 -15
  32. package/dist/cli/index.d.ts.map +1 -1
  33. package/dist/cli/index.js +521 -71
  34. package/dist/cli/waiver-audit.test.d.ts +2 -0
  35. package/dist/cli/waiver-audit.test.d.ts.map +1 -0
  36. package/dist/guard/index.d.ts +114 -2
  37. package/dist/guard/index.d.ts.map +1 -1
  38. package/dist/guard/index.js +216 -33
  39. package/dist/router-tanstack/multi-search-param-store.d.ts +13 -0
  40. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  41. package/dist/router-tanstack/multi-search-param-store.js +4 -1
  42. package/dist/router-tanstack/search-param-store.d.ts +29 -5
  43. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  44. package/dist/router-tanstack/search-param-store.js +31 -3
  45. package/dist/router-tanstack/search-param-store.test.d.ts +2 -0
  46. package/dist/router-tanstack/search-param-store.test.d.ts.map +1 -0
  47. package/dist/shell/index.d.ts +12 -5
  48. package/dist/shell/index.d.ts.map +1 -1
  49. package/dist/shell/index.js +24 -6
  50. package/dist/tokens/index.d.ts +3 -1
  51. package/dist/tokens/index.d.ts.map +1 -1
  52. package/dist/tokens/index.js +5 -1
  53. package/dist/tokens/palette.d.ts +2 -2
  54. package/dist/tokens/palette.js +2 -2
  55. package/dist/tokens/text-scale.test.d.ts +2 -0
  56. package/dist/tokens/text-scale.test.d.ts.map +1 -0
  57. package/dist/tokens.css +4 -2
  58. package/dist/vite.d.ts +28 -3
  59. package/dist/vite.d.ts.map +1 -1
  60. package/dist/vite.js +75 -26
  61. package/llms.txt +2 -2
  62. package/package.json +1 -1
  63. package/src/charts/kinds/Bars.tsx +1 -1
  64. package/src/charts/kinds/Donut.tsx +1 -1
  65. package/src/charts/kinds/DualPanel.tsx +2 -2
  66. package/src/charts/kinds/Heatmap.tsx +1 -1
  67. package/src/charts/kinds/MultiLine.tsx +1 -1
  68. package/src/charts/kinds/StackedArea.tsx +1 -1
  69. package/src/charts/kinds/ZonedLine.tsx +1 -1
  70. package/src/cli/check-theme.test.ts +35 -0
  71. package/src/cli/help.test.ts +5 -1
  72. package/src/cli/index.ts +978 -133
  73. package/src/cli/init-sync.test.ts +2 -0
  74. package/src/cli/template-ledger.test.ts +1 -1
  75. package/src/cli/toolchain-wiring.test.ts +352 -1
  76. package/src/cli/waiver-audit.test.ts +211 -0
  77. package/src/guard/check-source.test.ts +638 -1
  78. package/src/guard/index.ts +556 -63
  79. package/src/router-tanstack/multi-search-param-store.ts +16 -1
  80. package/src/router-tanstack/search-param-store.test.ts +135 -0
  81. package/src/router-tanstack/search-param-store.ts +90 -8
  82. package/src/shell/app-mobile-nav.tsx +1 -1
  83. package/src/shell/index.test.tsx +52 -0
  84. package/src/shell/index.tsx +64 -13
  85. package/src/tokens/index.ts +24 -1
  86. package/src/tokens/palette.ts +2 -2
  87. package/src/tokens/text-scale.test.ts +51 -0
  88. package/src/vite.test.ts +158 -6
  89. package/src/vite.ts +176 -34
package/AGENTS.md CHANGED
@@ -79,11 +79,17 @@ Register `guard-hook` in `.claude/settings.json` under `hooks.PreToolUse` with m
79
79
  `Write|Edit|MultiEdit` and command `bunx basalt-ui guard-hook` so the agent can't write off-palette
80
80
  colors without a `theme-allow` comment.
81
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.
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. It judges
91
+ plugin-rule waivers too (since 1.21.1), by re-running oxlint — so it needs oxlint reachable;
92
+ otherwise the verdict is "cannot judge", never "dead".
87
93
 
88
94
  > **Framework-internal only** — `bunx basalt-ui check-coverage` is a self-consistency gate for the
89
95
  > basalt-ui repo itself (asserts SURFACES ↔ rule files ↔ skill files ↔ package.json exports). It
package/CHANGELOG.md CHANGED
@@ -1,3 +1,36 @@
1
+ # [1.22.0](https://github.com/jkrumm/basalt-ui/compare/v1.21.0...v1.22.0) (2026-08-22)
2
+
3
+
4
+ ### Bug Fixes
5
+
6
+ * let `icons: false` reach the manifest it was only skipping in the head ([58abe27](https://github.com/jkrumm/basalt-ui/commit/58abe27be10e5baf7c4659aa79904a7646ff092d))
7
+ * scope the shadow-export tripwire to basalt consumers, and to components ([60716d4](https://github.com/jkrumm/basalt-ui/commit/60716d4ba00908c1a157f5568e192c6c50e438ec))
8
+ * stop sync scaffolding a second consumer, and give the audit the oxlint half ([45cf3d3](https://github.com/jkrumm/basalt-ui/commit/45cf3d340f081e61f3f10f852d12f79928993d2a))
9
+
10
+
11
+ ### Features
12
+
13
+ * give the waiver audit an annotation reader, and fix two shapes ([0dde850](https://github.com/jkrumm/basalt-ui/commit/0dde85052269780a61ead0688005db16e24f009a))
14
+
15
+ # [1.21.0](https://github.com/jkrumm/basalt-ui/compare/v1.20.0...v1.21.0) (2026-08-22)
16
+
17
+
18
+ ### Bug Fixes
19
+
20
+ * delete a waiver that stopped covering anything ([3984281](https://github.com/jkrumm/basalt-ui/commit/3984281fff6313c225bdf1e185de4a427d2ef4df))
21
+ * emit 0.1, not 0.10, from the shadow palette itself ([48adfff](https://github.com/jkrumm/basalt-ui/commit/48adfffcc9ad7713cacc07c1fc090b219fd175fa))
22
+ * let the real color scheme win, and keep <meta charset> in its window ([8708c85](https://github.com/jkrumm/basalt-ui/commit/8708c85f1488b25e6a059ee879dcadf6350278a2))
23
+ * make an annotation start its comment, and let it scope to one node ([a4cf78c](https://github.com/jkrumm/basalt-ui/commit/a4cf78c546d291c33a27bf94d7636758072a1005))
24
+ * persist sidebar collapse through basalt's own state primitive ([c576040](https://github.com/jkrumm/basalt-ui/commit/c576040172dfeb8d66b52909bbcf55b7b1f91c70))
25
+ * resolve the paths the wiring checks only matched, and audit the waivers ([c595181](https://github.com/jkrumm/basalt-ui/commit/c5951817ed01d7729f031bba2f3da50c3bf60666))
26
+ * restore the palette fixture to emitter bytes ([605e240](https://github.com/jkrumm/basalt-ui/commit/605e240387a417f57ae8070f97bad79d97545d60))
27
+
28
+
29
+ ### Features
30
+
31
+ * give the type ladder the two rungs consumers could only waive ([29b9ef3](https://github.com/jkrumm/basalt-ui/commit/29b9ef3767282dffb51fb52138d6ae4f1d24dd62))
32
+ * make the search-param store's reader reachable from the API ([6924970](https://github.com/jkrumm/basalt-ui/commit/6924970f410b82ba5cfc65fd1d5088124934fc8f))
33
+
1
34
  # [1.20.0](https://github.com/jkrumm/basalt-ui/compare/v1.19.1...v1.20.0) (2026-08-22)
2
35
 
3
36
 
package/MIGRATING.md CHANGED
@@ -6,8 +6,11 @@ nav definition" does not say `ChartHoverSync` was deleted. This file is that hal
6
6
  renamed exports per minor, with the replacement.
7
7
 
8
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.
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.21.0 and 1.21.1
12
+ sections were written against source, not against their commit messages. Check the types, not this
13
+ table, if the two disagree.
11
14
 
12
15
  **No majors, by policy.** A rename or a removal ships as a plain `feat:` on the 1.x line, so a minor
13
16
  bump can require code changes. Skipping several at once is the expensive case — read every section
@@ -17,16 +20,174 @@ between your version and the target.
17
20
  > `## [1.` finds only the patches.
18
21
 
19
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,
20
- 1.10.0, 1.13.0, 1.14.0, 1.16.0, 1.18.0 — and every patch. Additive-only subpaths: `./tokens.css`
23
+ 1.10.0, 1.13.0, 1.14.0, 1.16.0, 1.18.0 — and every patch except 1.21.1. Additive-only subpaths: `./tokens.css`
21
24
  at 1.3.0, `./agent-chat` at 1.10.0.
22
25
 
23
26
  ---
24
27
 
28
+ ## 1.21.1 — the toolchain stops overclaiming
29
+
30
+ **No export removed or renamed; four added.** Nothing you wrote changed. Every entry below is a
31
+ check that reported an answer it had not earned, so a green CI on 1.21.0 was, for several of them,
32
+ green over an empty set.
33
+
34
+ **`sync` refuses where it used to scaffold.** Run from a sub-package that depends on basalt-ui but
35
+ holds no `.basalt/manifest.json` — `apps/dashboard`, `apps/web` — it printed `0 updated, 20
36
+ recreated`, wrote a second `basalt` key, and stood up a complete competing install beside the real
37
+ one at the repo root. argo and rb both reverted it by hand. It now resolves its project exactly as
38
+ `check-theme` and `doctor` do, and **exits 1** when the resolved project has no manifest, naming the
39
+ install it found above instead. The refusal runs before the `basalt.roots` backfill, which was half
40
+ the damage. Two ways to unblock it:
41
+
42
+ ```bash
43
+ cd <the package holding .basalt/manifest.json> && bunx basalt-ui sync
44
+ BASALT_CWD=<that package> bunx basalt-ui sync # or from anywhere
45
+ ```
46
+
47
+ The summary line gained a `created` counter. **`recreated` now means what it always claimed** — the
48
+ ledger placed that file once and it went missing. Twenty first-time writes were never that.
49
+
50
+ **`--audit-allows` judges plugin-rule annotations.** They used to print "not a check-theme kind" and
51
+ drop out of the exit-1 gate, so the gate covered an empty set wherever the waivers were plugin
52
+ rules: argo's whole tally was `0 live, 0 dead, 8 outside reach`, and 11 of linewatch's 14 went the
53
+ same way. Each is now probed by re-running oxlint over that one file with the annotation
54
+ neutralized. Judged now: argo 8 of 8, linewatch 14 of 14, basalt's own tree 23 of 23.
55
+
56
+ **It requires oxlint to be reachable.** The probe writes one neutralized sibling file and re-runs
57
+ oxlint over it (oxlint has no stdin mode), removing it in a `finally`. Where oxlint cannot run, the
58
+ annotation is reported as **"cannot judge"**, never as dead. The report now also prints the scope it
59
+ audited — `0 dead` over `basalt.roots` was reading as `0 dead anywhere`.
60
+
61
+ **`doctor`'s `lefthook-preset` check asks a different question.** It tested whether the config text
62
+ contained the `extends` string. It now asks whether the gate EXISTS, via `lefthook dump`, which
63
+ resolves `extends`, `include` and per-command `root:`. linewatch wires all three jobs with
64
+ `root: 'web/'` precisely because `extends` merges commands _without_ their working directory — it
65
+ was correctly configured, got warned, and the old advice would have broken it. Three outcomes: a
66
+ broken `extends` target is still a hard fail, a provably absent gate is a warn, and where
67
+ `lefthook dump` could not run you get an advisory warning naming what it could not see.
68
+
69
+ **`basalt/shadow-basalt-export` narrowed twice.** It now gates on `isBasaltScopedFile` like every
70
+ other rule in the file, and needs a component-SHAPED declaration — a function, an arrow, a
71
+ `memo`/`forwardRef` wrapper, or a class extending one. It had been firing on a `SlugTracker` class
72
+ in a React-free package carrying no basalt-ui dependency, telling it to import from basalt-ui. The
73
+ stated limit is unchanged: exact-name-only, a tripwire, not coverage.
74
+
75
+ **`basaltAppPlugin({ icons: false })` now omits the manifest's `icons` member too.** It skipped the
76
+ head `<link>` icons and emitted the two PNG manifest entries anyway, so `{ manifest: true, icons:
77
+ false }` shipped an installable app pointing at two 404s. **If you went hybrid over this** — the
78
+ plugin for the head, a hand-written manifest for the rest — **you can drop the hand-written half.**
79
+ The option's JSDoc said "skips the head `<link>` icons"; it now says what the option does.
80
+
81
+ **Two `theme-allow` comment shapes were silently broken and now work.** Plainly: this is the fourth
82
+ hole found in this one contract in three rounds. The full thirteen-shape matrix is now pinned in
83
+ both halves — `src/guard/check-source.test.ts` and `configs/oxlint-plugin.test.ts` — so the two
84
+ parsers can no longer disagree about what an annotation IS.
85
+
86
+ | Shape | What it did | Now |
87
+ | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
88
+ | `/** theme-allow <id> — <why> */` on one line | waived under oxlint, reported under `check-theme` | both halves honour it |
89
+ | `{/*` + token on the next line + `*/}` | comment-stripping left a bare `}`, so the line read as CODE and the annotation was classified trailing — scoped to a brace | scoped to the node below it, as written |
90
+
91
+ **linewatch writes that wrapped shape for every hand-composed chart axis**, so any _guard_ kind
92
+ annotated that way was silently unwaivable. It only appeared to work because the rules it names
93
+ (`hand-rolled-plot`) live in the oxlint plugin, whose placement test is comment-node-based and never
94
+ saw the brace.
95
+
96
+ **New, additive — `./guard` gains four runtime exports**, the reader half of the audit:
97
+
98
+ | Export | Note |
99
+ | -------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
100
+ | `findAllowAnnotations(text, relPath, cfg)` | every annotation as written, ids split into `guardKinds` / `pluginRules` / `unknownRules`. Shares `collectAllowAnnotations` with `checkSource`, so it cannot list a line the scan does not honour |
101
+ | `neutralizeAllowAnnotation(text, line, cfg)` | one annotation rewritten to the token below, every other left intact — the probe half of an audit |
102
+ | `NEUTRALIZED_ALLOW_TOKEN` | so a guard probe and an oxlint probe neutralize identically |
103
+ | `PLUGIN_RULE_IDS` | the ids only oxlint can judge |
104
+
105
+ The `AllowAnnotationSite` type ships alongside them.
106
+
107
+ **Known gaps, reported and not fixed:** `--audit-allows` says nothing about `basalt.exempt` — a
108
+ whole file removed from the scan, the broadest exception the config surface has — and its
109
+ `scoped to …` line does not distinguish `theme-allow` from `theme-allow-file`.
110
+
111
+ ## 1.21.0 — the `theme-allow` grammar
112
+
113
+ **No export removed or renamed.** One break, and it is in the escape hatch itself: **file scope must
114
+ now be spelled `theme-allow-file`.** At 1.20.0 an annotation that named a rule and gave a reason —
115
+ the exact shape the rule's own message asks for — was promoted to a whole-file declaration, which is
116
+ why per-node scoping never actually shipped. The two forms are now distinct:
117
+
118
+ ```text
119
+ theme-allow → this node/line, EVERY rule (reports theme-allow-unscoped)
120
+ theme-allow <id>[, <id>…] [— <why>] → this node/line, those rules
121
+ theme-allow-file <id>[, <id>…] — <why> → the WHOLE FILE, those rules; a bare one waives NOTHING
122
+ "basalt:theme-allow[-file]": "<id>… — <why>" → the same two, for JSON / .webmanifest
123
+ ```
124
+
125
+ **The migration is one word per file declaration.** Measured across the consumer sweep: linewatch
126
+ 0 → 11 findings, argo 0 → 6, rb 0 → 0 — all `warn`, so **no build changes colour**.
127
+
128
+ ```diff
129
+ -// theme-allow hand-rolled-plot — two panes over one x scale
130
+ +// theme-allow-file hand-rolled-plot — two panes over one x scale
131
+ ```
132
+
133
+ **An annotation must now START its comment** — after `//`, `/*`, `<!--`, a block-comment gutter `*`,
134
+ or nothing but whitespace. Both parsers used a bare substring search, so a comment that merely
135
+ _mentioned_ the token parsed as the legacy blanket form and switched every rule off on the line
136
+ below. linewatch documented its own waivers in a docblock and thereby disarmed the file — a false
137
+ NEGATIVE, and the reason this ships as a break rather than a grace period. Every annotation anyone
138
+ actually writes still qualifies; a sentence about the escape hatch no longer waives anything.
139
+
140
+ **A comment-only annotation now reaches the first CODE line below it**, walking through the rest of
141
+ its own comment block. Before, a reason that wrapped onto a second line, or a docblock's `*/`,
142
+ absorbed the waiver and the natural shape silently waived nothing — argo hit that three times in one
143
+ upgrade. A blank line still ends the block.
144
+
145
+ **`.json` / `.webmanifest` finally have a waiver.** They have been scanned since 1.20.0 and cannot
146
+ hold a comment, so their findings were unwaivable and the printed remedy prescribed something
147
+ impossible; both consumers fell back to a blanket `exemptRules`. Use a member key — but for a
148
+ manifest, `basaltAppPlugin` is the first remedy, since a hand-copied hex drifts from the palette:
149
+
150
+ ```json
151
+ { "basalt:theme-allow-file": "raw-hex — a PWA manifest theme_color must be a literal hex" }
152
+ ```
153
+
154
+ **Two API-shaped fixes worth acting on:**
155
+
156
+ | Change | What to do |
157
+ | -------------------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
158
+ | `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 |
159
+ | `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.21.0 value, so the sidebar does not re-expand on upgrade |
160
+
161
+ **New, additive:**
162
+
163
+ | Surface | Note |
164
+ | ---------------------------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
165
+ | `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 |
166
+ | `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 |
167
+ | `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 |
168
+ | `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 |
169
+ | `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 |
170
+ | `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 |
171
+ | `basalt/shadow-basalt-export` reads all nine barrels | the charts layer included. Still exact-name-only — a **tripwire, not coverage** |
172
+
173
+ Also: `check-theme`'s `inline-spacing` no longer reads a unitless number in a plain options bag as
174
+ CSS (`fitBounds({ padding: 48 })` stops reporting); the shipped `oxfmt` pre-commit job drops back to
175
+ `*.{ts,tsx,js,jsx,css}` and gains `--no-error-on-unmatched-pattern`; `tokens:css` emits `0.1` rather
176
+ than `0.10`, so a committed sheet stops failing prettier — re-run the command to pick it up.
177
+
178
+ **The lefthook preset overrides YOU, not the other way round.** An `extends` target wins on a
179
+ colliding key: declare `pre-commit.commands.oxfmt.run` (or `glob:`) in your own file and **yours**
180
+ is the one silently discarded. Only keys the preset does not define merge in. The guard job runs
181
+ `${BASALT_BIN:-bunx --no-install basalt-ui}`; that shell default is the sanctioned seam, set via
182
+ `env:`, which does merge.
183
+
25
184
  ## 1.20.0 — enforcement
26
185
 
27
186
  **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.
187
+ your build goes red on this upgrade, that is the release working. Every new **kind** and **rule**
188
+ lands `warn` — but two other changes in this release do fail a build: `basalt/raw-size-literal`
189
+ promotes to `error`, and the widened markup scan reads `index.html` / `.webmanifest`, where a raw
190
+ hex is an `error`-severity colour kind. Two consumers exited 1 on exactly that.
30
191
 
31
192
  | Change | What you'll see | What to do |
32
193
  | ---------------------------------------------------------------- | --------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------- |
@@ -69,9 +230,18 @@ line is usually a multi-line opening tag or a `{expr}` child), and in CSS a trai
69
230
  reaching back over the declaration it terminates, which is what survives the shipped `oxfmt`
70
231
  reflowing a long `background-color` so the hex lands above the comment.
71
232
 
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`.
233
+ **`basalt/hand-rolled-plot` waivers must now be written deliberately.** Every assembly node is
234
+ reported individually, and a waiver is no longer picked up off whatever comment happened to sit on
235
+ the file's first assembly node — it needs a written declaration naming the rule and giving a reason,
236
+ anywhere in the file: `// theme-allow hand-rolled-plot — two panes over one x scale`.
237
+
238
+ **But the waiver is still whole-file, and per-node scoping is not expressible at 1.20.0.** Naming
239
+ the rule AND giving a reason is what `hasFileDeclaration` matches, at any line — so a comment
240
+ intended for one node silences the file. Dropping the reason keeps it node-scoped in the oxlint
241
+ plugin, but then `check-theme` reports `theme-allow-unscoped ("no reason")`. The two halves of the
242
+ contract intersect at exactly one legal shape and that shape is whole-file. Write the declaration in
243
+ the component's docblock, where it reads as the file-level decision it is. **Fixed in 1.21.0** —
244
+ see that section; the declaration moves to `theme-allow-file`.
75
245
 
76
246
  **`doctor` will go red.** `SKIPPED` is a third outcome beside pass/warn/fail and exits non-zero on
77
247
  its own — "All checks passed" is only printable when every check RAN. Three new hard checks:
@@ -98,18 +268,18 @@ each root's PARENT now contributes its `index.html` and `public/` tree.
98
268
 
99
269
  ## 1.19.0 — nav
100
270
 
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) | |
271
+ | Removed / renamed | Replacement | Note |
272
+ | ----------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------- |
273
+ | `NavLinkRenderer` (type) | `defineNav` + `{...useNav(NAV)}` (`basalt-ui/router-tanstack`), or `SidebarItem.Anchor` | basalt now paints every nav pixel |
274
+ | `BasaltShellProps.renderNavLink`, `AppSidebarProps.renderNavLink` | same | |
275
+ | `BreadcrumbLinkRenderer` (type) | `AppBreadcrumbs.parentAnchor` | |
276
+ | `BasaltShellProps.renderBreadcrumbLink` | same | |
277
+ | `BasaltShellProps.sidebarFooterExtra` | `mobileNav.moreExtra` | its only host was the mobile drawer; it rendered nowhere on desktop |
278
+ | `AppSidebarProps.footerExtra`, `AppSidebarProps.onClose` | — | the full-height mobile sidebar drawer is deleted |
279
+ | `MobileNavItem` (type) | `MobileNavSlot` | |
280
+ | `MobileNavSection` (type) | `MobileNavGroup` | |
281
+ | `MobileNavLinkRenderer` (type) | `MobileNavModel` | |
282
+ | `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 |
113
283
 
114
284
  A consumer with no router needs no migration — `href` + `onClick` still work.
115
285
 
@@ -130,20 +300,21 @@ never `search: true`.
130
300
 
131
301
  The largest delta in the 1.x line. No shims were shipped.
132
302
 
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` | |
303
+ | Removed / renamed | Replacement | Note |
304
+ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
305
+ | `ChartHoverSync`, `ChartHoverSyncProps` | `globalCursorStore` / `createCursorStore` / `useChartCursor` / `useCursorState` — **no provider** | `ChartCursorScope` now _isolates_ a subtree; it is the inverse of the old provider, not a rename |
306
+ | `HoverContext`, `HoverCtx`, `useHoverSync`, `DEFAULT_NO_OP_SET_HOVER` | `useChartCursor`, `useCursorState`, `CursorState`, `CursorStore` | context → `useSyncExternalStore` |
307
+ | `ResponsiveChart`, `ResponsiveChartProps` | `CartesianChart` / `CartesianChartProps` (+ `autoMargin`, `PlotContext`, `PlotRect`) | now mandatory for every single-plot cartesian chart, enforced by `basalt/hand-rolled-plot` |
308
+ | `ChartTooltip` (tip-based), `useChartTooltip`, `useTooltipStyles` | `ChartTooltipFloat` + the `tooltip: CartesianTooltipConfig` prop | portal / flip / clamp done once |
309
+ | `BarsAxisConfig` | `AxisConfig` + `resolveAxisDomain` | |
310
+ | `ZonedLineTooltipLabel` | `tooltip` config on the kind | |
311
+ | `ZonedLine`/`MultiLine`: `yDomain`, `yAutoPad`, `yAutoMaxFloor`, `yAutoMinCeil`, `numTicksY`, `formatYTick` | one `y?: AxisConfig<T>` | `y` is **optional** where `yDomain` was required |
312
+ | `ZonedLine`/`MultiLine`: `tooltipLabel`, `renderExtraTooltipRows` | `tooltip.label`, `tooltip.extraRows` (`CartesianTooltipConfig`) | there is no `appendRows`; the field is `extraRows` |
313
+ | `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 |
314
+ | `ZonedLine`/`MultiLine`: `numTicksX` | `xTicks` | `xZones` added alongside |
315
+ | `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 |
316
+ | `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` |
317
+ | `Heatmap.width` | measures itself; takes `height` / `aspectRatio` / `fill` | |
147
318
 
148
319
  ## 1.12.0 — agent-chat, behaviour only
149
320
 
@@ -176,6 +347,12 @@ Names that have no 1.x equivalent: the `--chart-blue-1..8` sequential ramp (1.x
176
347
  `--vx-fill-*` only), the default font stacks as a token (they live in `styles.css`), `purple` as a
177
348
  text color, `black`, `blue-400`/`green-400`.
178
349
 
350
+ **The typefaces changed at 1.0, not just their delivery.** 0.4.2 shipped Instrument Sans; 1.x ships
351
+ Nunito Sans (body) + Hubot Sans (condensed headings), mono unchanged at JetBrains Mono. `fonts:css`
352
+ emits the 1.x stacks — for a 0.4.2 migrant that is a **rebrand**, not a restoration. It also emits
353
+ `--basalt-font-head-stretch: 88%`, tuned for Hubot Sans specifically; pointed at another face it
354
+ silently condenses it.
355
+
179
356
  ## 1.0.1 — CLI binary renamed
180
357
 
181
358
  `basalt` → **`basalt-ui`**. Never `bunx basalt`.
@@ -191,18 +368,18 @@ live in `configs/oxlint.json`, and since 1.20.0 their grace is tracked by `PLUGI
191
368
  named export beside the plugin — a test asserts it against the shipped preset in both directions, so
192
369
  deleting an entry forces the level flip in the same commit.
193
370
 
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` | — |
371
+ | Rule | Landed | Became `error` |
372
+ | ---------------------------------- | ---------------- | ------------------------------------------------------------------------ |
373
+ | `mantine-shade-index` (guard kind) | 1.7.0 as `warn` | **1.11.0** |
374
+ | `basalt/raw-scroll-container` | ≤1.2.0 as `off` | `warn` 1.10.0 → **`error` 1.13.0** |
375
+ | `basalt/ai-sdk-major` | 1.10.0 as `warn` | **1.13.0** |
376
+ | `basalt/agent-no-raw-usechat` | 1.10.0 as `warn` | **1.13.0** |
377
+ | `basalt/agent-resume-guard` | 1.10.0 as `warn` | **1.13.0** |
378
+ | `basalt/raw-size-literal` | 1.7.0 as `warn` | **1.20.0** |
379
+ | `basalt/hand-rolled-plot` | 1.15.0 as `warn` | still `warn` — widened again at 1.21.0; grace restarts with the widening |
380
+ | `basalt/chart-legend-literal` | 1.15.0 as `warn` | still `warn` — widened at 1.20.0 |
381
+ | `basalt/shadow-basalt-export` | 1.20.0 as `warn` | may stay `warn` permanently — widened at 1.21.0, narrowed at 1.21.1 |
382
+ | `basalt/hand-rolled-shell` | 1.20.0 as `warn` | — |
206
383
 
207
384
  `card-with-border`, `inline-display`, `raw-html-layout`, `raw-form-control`, `raw-font-family` and
208
385
  the other original guard kinds have been `error` since before 1.2.0 — they never had a grace minor.
@@ -214,7 +391,7 @@ in the shipped preset). A config still naming `import-boundary` after 1.1.0 disa
214
391
 
215
392
  ## Deprecated, not yet removed
216
393
 
217
- The 32 camelCase `--vx-*` aliases deprecated in 1.5.0 are **still emitted at 1.20.0**.
394
+ The 32 camelCase `--vx-*` aliases deprecated in 1.5.0 are **still emitted at 1.21.0**.
218
395
  `buildPaletteCss({ legacyAliases: false })` / `tokens:css --no-legacy-aliases` opts out now; a later
219
396
  minor flips the default.
220
397
 
@@ -223,6 +400,10 @@ minor flips the default.
223
400
  - Props declared inline on a component (`function X({ a }: { a: string })`) rather than on an
224
401
  exported type are outside the diff method used here. Spot checks on the shell and charts modules
225
402
  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.
403
+ - The 0.4.2 `./css` line counts in the 1.0.0 table (877 lines / 307 custom props / 19 utilities) come
404
+ from the removal commit, not from a re-read of the 0.4.2 tarball.
405
+
406
+ `AppBreadcrumbs.parentAnchor` was listed here through 1.20.0 and is now verified: it is declared on
407
+ the shipped `dist/shell/app-breadcrumbs.d.ts` (`parentAnchor?: NavAnchor`, with `parentHref` as the
408
+ no-router fallback). The type is inline on the component rather than an exported `AppBreadcrumbsProps`,
409
+ which is why the export-surface diff never saw it.
package/README.md CHANGED
@@ -193,12 +193,19 @@ and everything derives from it — the guard's scan, the seeded CI `oxfmt` globs
193
193
  exemption:
194
194
 
195
195
  ```json
196
- // package.json — `roots` is written by init; the rest are yours to add
196
+ // package.json — `roots` is written by init and backfilled by sync; the rest are yours to add
197
197
  {
198
198
  "basalt": {
199
199
  "roots": ["apps/web/src"],
200
200
  "include": ["app/manifest.json"],
201
- "profile": "tokens-only"
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
+ }
202
209
  }
203
210
  }
204
211
  ```
@@ -214,6 +221,19 @@ which would silence half the guard on any repo keeping Mantine in a different wo
214
221
  Other keys (`exempt`, `severity`, `spacingSteps`, `forbiddenAccents`, …) are documented on the
215
222
  `BasaltConfig` type.
216
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.21.0 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
+
217
237
  Basalt-emitted LINES are skipped, not whole files. The file has to earn it first — a `.css` path,
218
238
  the `@generated basalt-ui` header verbatim on line 1, the version + invocation line on line 2 — and
219
239
  then each line does too: at brace depth 0 a selector or a self-closing comment, inside a block only
@@ -222,12 +242,31 @@ stylesheet `tokens:css` just wrote is not reported back at you, pasting the mark
222
242
  suppresses nothing, and an ordinary declaration smuggled into a sheet wearing the header is
223
243
  reported on its own line.
224
244
 
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.
245
+ **The escape hatch is scoped, and since 1.21.0 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.21.0 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 scan with that one waiver neutralized. Exits 1 on
264
+ a dead waiver, so it is usable as a CI gate. Since 1.21.1 it runs **both** halves — `checkSource` for
265
+ a guard kind, oxlint over one neutralized sibling file for a plugin rule — so a waiver naming
266
+ `hand-rolled-plot` is judged rather than skipped. **It needs oxlint reachable**; where it is not, the
267
+ verdict is "cannot judge", never "dead". The report prints the scope it audited, because `0 dead`
268
+ over `basalt.roots` is not `0 dead anywhere`. Two known gaps: `basalt.exempt` is not audited at all,
269
+ and the `scoped to …` line does not distinguish `theme-allow` from `theme-allow-file`.
231
270
 
232
271
  `doctor`'s `ai-major-parity` hard check fails a monorepo where workspace packages declare different
233
272
  `ai` package majors — unless the split is intentional and written down. A producer pinned to an
@@ -260,7 +299,7 @@ deleted rather than silently surviving into the next real skew.
260
299
  | `./tokens` | **free** | `VX` token refs, `buildPaletteCss`, `defineSeries`, `seriesTokens`, `groupTokens`, `alpha`, `ColorPair` / `SeriesMap` types |
261
300
  | `./theme-lab` | coupled | `ThemeLabControls`, `applyOverrides`, `loadOverrides`, `COLOR_GROUPS` for live theme inspection |
262
301
  | `./vite` | — | `basaltViteConfig(opts)` — Vite preset for basalt-ui consumer apps; `basaltAppPlugin(opts)` — PWA head, manifest, and icon metadata derived from the token palette |
263
- | `./guard` | **free** | `checkSource`, `GUARD_RULES`, `Finding` types — the headless theme-guard core |
302
+ | `./guard` | **free** | `checkSource`, `GUARD_RULES`, `Finding` types — the headless theme-guard core; plus the annotation reader `--audit-allows` is built on: `findAllowAnnotations`, `neutralizeAllowAnnotation`, `NEUTRALIZED_ALLOW_TOKEN`, `PLUGIN_RULE_IDS`, `AllowAnnotationSite` |
264
303
  | `./query` | **free** | `createBasaltQueryClient`, transport-agnostic `unwrap`, lazy `BasaltQueryDevtools` |
265
304
  | `./router-tanstack` | **free** | TanStack Router bridge: `useBasaltNav` (active route) + `useRouterBreadcrumbs` |
266
305
  | `./forms` | coupled | Mantine form adapter: `useBasaltForm`, `field`, `FormErrorSummary`, `useFormDraft` (Standard Schema) |
@@ -655,7 +694,15 @@ honour `BASALT_CWD`, and relocate to the single workspace package carrying a bas
655
694
  invoked from a repo root that has none (two candidates is reported as ambiguous, never guessed).
656
695
  `tokens:css` and `fonts:css` both take `--check` as a CI drift gate.
657
696
 
658
- `sync` strategy per file: unchanged since last write → overwrite; locally edited → skip (show diff); missing → recreate. `--force` overwrites local edits.
697
+ `sync` strategy per file: unchanged since last write → overwrite; locally edited → skip (show diff);
698
+ missing → recreate. `--force` overwrites local edits. The summary counts `created` and `recreated`
699
+ apart: `recreated` means the ledger placed that file once and it went missing.
700
+
701
+ **`sync` refreshes an existing install; it does not create one.** It resolves its project exactly as
702
+ `check-theme` and `doctor` do, and since 1.21.1 **exits 1** when the resolved project has no
703
+ `.basalt/manifest.json`, naming the install it found above instead of scaffolding a second one
704
+ beside it. Run it at the package that holds the manifest, or point `BASALT_CWD` at that package.
705
+ Creating an install is `basalt-ui init`'s decision.
659
706
 
660
707
  ---
661
708