basalt-ui 1.23.1 → 1.25.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 (63) hide show
  1. package/AGENTS.md +11 -6
  2. package/CHANGELOG.md +21 -0
  3. package/MIGRATING.md +138 -4
  4. package/README.md +123 -32
  5. package/agent/rules/basalt-data.md +104 -12
  6. package/agent/rules/basalt-query.md +57 -0
  7. package/agent/skills/basalt-app/SKILL.md +7 -0
  8. package/dist/cli/index.d.ts +4 -2
  9. package/dist/cli/index.d.ts.map +1 -1
  10. package/dist/cli/index.js +101 -17
  11. package/dist/dashboard/empty-state.d.ts +6 -2
  12. package/dist/dashboard/empty-state.d.ts.map +1 -1
  13. package/dist/dashboard/empty-state.js +1 -1
  14. package/dist/dashboard/index.d.ts +1 -0
  15. package/dist/dashboard/index.d.ts.map +1 -1
  16. package/dist/dashboard/index.js +8 -0
  17. package/dist/dashboard/query-state.d.ts +96 -0
  18. package/dist/dashboard/query-state.d.ts.map +1 -0
  19. package/dist/dashboard/query-state.js +123 -0
  20. package/dist/dashboard/query-state.test.d.ts +2 -0
  21. package/dist/dashboard/query-state.test.d.ts.map +1 -0
  22. package/dist/data/data-table.d.ts +125 -8
  23. package/dist/data/data-table.d.ts.map +1 -1
  24. package/dist/data/data-table.js +121 -13
  25. package/dist/data/data-table.test.d.ts +2 -0
  26. package/dist/data/data-table.test.d.ts.map +1 -0
  27. package/dist/data/table.d.ts +2 -2
  28. package/dist/data/table.d.ts.map +1 -1
  29. package/dist/guard/index.d.ts.map +1 -1
  30. package/dist/guard/index.js +21 -6
  31. package/dist/index.d.ts +1 -1
  32. package/dist/index.d.ts.map +1 -1
  33. package/dist/index.js +7 -1
  34. package/dist/query/error-message.d.ts +41 -0
  35. package/dist/query/error-message.d.ts.map +1 -0
  36. package/dist/query/error-message.js +30 -0
  37. package/dist/query/error-message.test.d.ts +2 -0
  38. package/dist/query/error-message.test.d.ts.map +1 -0
  39. package/dist/query/index.d.ts +1 -19
  40. package/dist/query/index.d.ts.map +1 -1
  41. package/dist/query/index.js +2 -14
  42. package/dist/surfaces.d.ts +1 -1
  43. package/dist/surfaces.js +1 -1
  44. package/llms.txt +3 -3
  45. package/package.json +1 -1
  46. package/src/cli/check-theme.test.ts +34 -0
  47. package/src/cli/index.ts +222 -44
  48. package/src/cli/tokens-css.test.ts +73 -6
  49. package/src/cli/toolchain-wiring.test.ts +146 -0
  50. package/src/dashboard/empty-state.tsx +9 -3
  51. package/src/dashboard/index.ts +11 -0
  52. package/src/dashboard/query-state.test.tsx +152 -0
  53. package/src/dashboard/query-state.tsx +272 -0
  54. package/src/data/data-table.test.tsx +349 -0
  55. package/src/data/data-table.tsx +359 -42
  56. package/src/data/table.ts +8 -1
  57. package/src/guard/check-source.test.ts +141 -0
  58. package/src/guard/index.ts +102 -8
  59. package/src/index.ts +9 -0
  60. package/src/query/error-message.test.ts +67 -0
  61. package/src/query/error-message.ts +70 -0
  62. package/src/query/index.ts +2 -34
  63. package/src/surfaces.ts +1 -1
package/AGENTS.md CHANGED
@@ -8,7 +8,7 @@ Toolchain: Bun runtime, oxlint + oxfmt, conventional commits (empty scope), `mas
8
8
 
9
9
  | Subpath | Layer | Purpose |
10
10
  | --------------------------- | --------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
11
- | `basalt-ui` | mantine-coupled | BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, SettingsSection/SettingsRow/DangerZone) |
11
+ | `basalt-ui` | mantine-coupled | BasaltProvider, createBasaltTheme, BasaltShell + sidebar/mobile-nav/breadcrumbs, NavCountBadge, ThemeToggle, ThreadWorkspace + thread-chat components, dashboard composites (DeltaBadge, StatCard with threshold tone, EmptyState, QueryState/LoadingState/ErrorState, SettingsSection/SettingsRow/DangerZone) |
12
12
  | `basalt-ui/charts` | headless | CartesianChart + visx chart primitives, kinds, sparklines, hooks, and token re-exports (Mantine-free) |
13
13
  | `basalt-ui/tokens` | headless | VX token refs, buildPaletteCss, defineSeries, seriesTokens, groupTokens, alpha (Mantine-free) |
14
14
  | `basalt-ui/theme-lab` | mantine-coupled | ThemeLabControls, applyOverrides, COLOR_GROUPS — a low-level inspector for the non-derived structural tokens; identity/color tuning lives in DeriveControls |
@@ -63,7 +63,8 @@ duration/spring/ease literal in a `transition={{...}}` prop (enforced by `basalt
63
63
  ## Key commands
64
64
 
65
65
  `init` and `tokens:css`/`fonts:css` are the only commands with no install to resolve, so `bunx` is
66
- correct for them and wrong for everything else — see the note below.
66
+ correct for them **and only while basalt is not installed here** — once it is, they run through the
67
+ local bin like everything else. See the note below.
67
68
 
68
69
  ```bash
69
70
  bunx basalt-ui init # FIRST STEP: scaffold .claude/rules/ + .claude/skills/, managed CLAUDE.md block, DESIGN.md seed, toolchain seeds
@@ -72,8 +73,8 @@ basalt-ui check-theme # fail on off-palette colors in consumer source
72
73
  basalt-ui sync # reconcile managed files (.claude/rules/, .claude/skills/, CLAUDE.md block)
73
74
  basalt-ui sync --check # CI freshness gate — exits non-zero if any managed file drifted
74
75
  basalt-ui doctor # integration health — SKIPPED is a third outcome and exits non-zero
75
- bunx basalt-ui tokens:css # emit the --vx-* stylesheet (no React, no Mantine, no bundler)
76
- bunx basalt-ui fonts:css # emit the shipped --basalt-font-* stacks
76
+ bunx basalt-ui tokens:css # emit the --vx-* stylesheet (no React, no Mantine, no bundler) — bunx ONLY if basalt is not installed
77
+ bunx basalt-ui fonts:css # emit the shipped --basalt-font-* stacks — same exception
77
78
  basalt-ui info --json # stable JSON surface map
78
79
  basalt-ui guard-hook # PreToolUse theme-guard adapter: reads a Write/Edit payload on stdin, denies off-palette writes
79
80
  ```
@@ -83,8 +84,12 @@ so above the usage block. Neither is ever ignored — `doctor --json` used to ru
83
84
 
84
85
  `check-theme`, `doctor` and `sync` resolve their project identically: `BASALT_CWD` wins, then the
85
86
  cwd, then the workspace packages the root declares, then a two-level layout scan (which is what a
86
- repo with no `workspaces` field and the install one level down needs). Two candidates is ambiguous
87
- and reported, never guessed. `sync` on a `tokens-only` consumer prints `n/a` and exits 0.
87
+ repo with no `workspaces` field and the install one level down needs), then an **ascend** to the
88
+ nearest ancestor carrying a basalt project, bounded by the repo root. Both directions announce the
89
+ relocation in one sentence — `check-theme` used to fabricate `roots: ["src"]` on the ascend path and
90
+ pass silently. Two candidates is ambiguous and reported, never guessed. `sync` on a `tokens-only`
91
+ consumer prints `n/a` and exits 0; from a sub-package it refreshes the parent install rather than
92
+ refusing, and still will not scaffold a second consumer.
88
93
 
89
94
  **Run the local bin, not `bunx`.** `bunx` does not re-resolve a cached package, so it can execute
90
95
  the version you just upgraded away from — one consumer filed a P0 against a 1.20.0 cache while
package/CHANGELOG.md CHANGED
@@ -1,3 +1,24 @@
1
+ # [1.25.0](https://github.com/jkrumm/basalt-ui/compare/v1.24.0...v1.25.0) (2026-08-22)
2
+
3
+
4
+ ### Features
5
+
6
+ * make manualPagination refuse every prop that would misreport ([d18e5f1](https://github.com/jkrumm/basalt-ui/commit/d18e5f1d8091af82a589a140ece7484cca654084))
7
+
8
+ # [1.24.0](https://github.com/jkrumm/basalt-ui/compare/v1.23.1...v1.24.0) (2026-08-22)
9
+
10
+
11
+ ### Bug Fixes
12
+
13
+ * stop check-theme fabricating a config, and re-gate the provenance line ([99a45c0](https://github.com/jkrumm/basalt-ui/commit/99a45c0dc284850b4140a6ff7719dbade4795f09))
14
+ * stop raw-hex reading HTML entities, and teach the guard the SFC dialect ([f1b0562](https://github.com/jkrumm/basalt-ui/commit/f1b0562efeb718921509253f9ee215bf461c3c01)), closes [#123](https://github.com/jkrumm/basalt-ui/issues/123) [#125](https://github.com/jkrumm/basalt-ui/issues/125)
15
+
16
+
17
+ ### Features
18
+
19
+ * playground routes for QueryState and the table body chrome ([e150a00](https://github.com/jkrumm/basalt-ui/commit/e150a0051bf743137751a3d5c17f202c0984aaa7))
20
+ * ship the query loading/error branch and the table body chrome ([963bf04](https://github.com/jkrumm/basalt-ui/commit/963bf047cf9ac4fac3cecceecbd0c378ee37f79d))
21
+
1
22
  ## [1.23.1](https://github.com/jkrumm/basalt-ui/compare/v1.23.0...v1.23.1) (2026-08-22)
2
23
 
3
24
 
package/MIGRATING.md CHANGED
@@ -10,7 +10,7 @@ cross-checked against the repo's `scripts/export-surface.json` snapshot. **Every
10
10
  was re-audited against the built declaration files at 1.20.0 (2026-08-22)** after round 5 caught one
11
11
  row that was wrong. That pass corrected 4 table rows and 3 prose claims. Every section from 1.21.0
12
12
  on was written against source, not against its commit messages — which is not a formality: one
13
- 1.23.0 commit message describes a change it did not make (see `Unreleased` § Corrections). Check
13
+ 1.23.0 commit message describes a change it did not make (see `1.23.1` § Corrections). Check
14
14
  the types, not this table, if the two disagree.
15
15
 
16
16
  **The newest section is headed `## Unreleased`, and stays that way until npm serves it.** This file
@@ -40,6 +40,138 @@ version `CHANGELOG.md` does not record. This file is not in that set; the same d
40
40
  by convention. Rename the section at release, or leave it — a reader can resolve `Unreleased`
41
41
  against `CHANGELOG.md`, and could never resolve `1.21.1`.
42
42
 
43
+ **Nothing removed or renamed. Twelve new runtime exports on `.`/`./query`, plus table props.** Every
44
+ API entry is additive; the behaviour changes are in the guard and the CLI, listed after them.
45
+
46
+ ### `QueryState` — the branch precedence, shipped as a component
47
+
48
+ `QueryState`, `LoadingState`, `ErrorState` (+ `QueryStateProps`, `QueryStateLike`,
49
+ `QueryStateVariant`, `QueryEmptyCopy`, `LoadingStateProps`, `ErrorStateProps`) on the root barrel,
50
+ beside `EmptyState`. `toErrorMessage` and `errorStatus` on `basalt-ui/query`.
51
+
52
+ basalt owned both ends of this file — `EmptyState` and `toErrorMessage` — and nothing in between, so
53
+ a consumer wrote the four-way switch and got it wrong in the direction the shape suggested:
54
+ image-share's library rendered `No images` on a **500** and a share detail rendered
55
+ `Share not found` on a dropped connection, until 204 hand-rolled lines stopped it. Open since round
56
+ 4, re-reported in round 6.
57
+
58
+ - **A component, not a hook** — the product IS the precedence, and a hook returns the same four-way
59
+ switch to every call site.
60
+ - **It lives under `src/dashboard/`, not `src/query/`**: `check-dist-layering.mjs` asserts
61
+ `dist/query/index.js` reaches no `@mantine/*`, and this renders Mantine. `query` is typed as a
62
+ five-field structural subset (`QueryStateLike`), so basalt couples to no query-library version and
63
+ a hand-composed object is legal. The subset gives up the compiler, so the shape is asserted at
64
+ **runtime**: a missing `isError` throws naming the field, because a missing `isError` is precisely
65
+ the "500 renders _No images_" bug.
66
+ - `errorTitle` / `errorFallback` / `errorAction` apply to the no-cached-data error branch only; the
67
+ error-with-cached-data branch renders a fixed section banner above `children`.
68
+ - **`EmptyState.description` is now optional.** Five argo features wrapped the component solely
69
+ because a compact panel had to invent a second sentence. Existing calls are unaffected.
70
+ - `toErrorMessage(err, fallback?)` and `errorStatus(err)` had two live bugs the port found: an
71
+ opaque envelope rendered the literal `"{}"`, and `toErrorMessage(undefined)` returned the
72
+ `undefined` VALUE despite a `string` return type. Both fixed; a status is folded into the fallback
73
+ when the body decodes to nothing readable. Split into `src/query/error-message.ts` so the
74
+ dashboard decodes without importing the peer.
75
+
76
+ **Port result:** image-share, all 10 call sites plus a standalone `ErrorState`, **by changing one
77
+ import line each** — no renames, no prop changes, no casts. Total 2467 → 2221; code-only 2056 →
78
+ 1882, **−174**; `query-state.tsx` 204 → 0.
79
+
80
+ ### `BasaltDataTable` body chrome — and the honest number
81
+
82
+ New: `maxHeight`, `minWidth`, `stickyHeader`, `stickyHeaderOffset`, `verticalSpacing`,
83
+ `horizontalSpacing`, `withRowBorders`, `withTableBorder`, per-column `meta.align` and
84
+ `meta.numeral`; `striped` widens from `boolean` to `boolean | 'odd' | 'even'`. `withTableBorder`
85
+ defaults to `true` in basalt, overriding Mantine's `false`; every other one is a conditional
86
+ pass-through, so omitting it keeps Mantine's default.
87
+
88
+ **Porting argo's three tables onto them made them 341 → 370–379 lines. 29–38 LONGER.** argo named these
89
+ props as the reason the tables stayed hand-rolled, and adding them shortened nothing: column defs
90
+ cost more than JSX rows when every cell is bespoke — eight accessor blocks at 4–6 lines each against
91
+ an eight-`<Table.Td>` row at ~3. **The ask was mis-specified.** Adopt them for what they buy —
92
+ the `type="native"` footgun, alignment stated once instead of on both `th` and `td` six times in one
93
+ file, and sorting/filtering/pagination no longer consumer-owned — not for a line count. This is the
94
+ counterexample to the band kinds, and the reason the port-before-shipping rule earns its keep.
95
+
96
+ - `maxHeight` (or `minWidth`) renders **`Table.ScrollContainer type="native"`**, and
97
+ `agent/rules/basalt-data.md` now prescribes that same node for a bespoke table — so the blessed
98
+ lane and the escape cannot contradict, and `type="scrollarea"`, which breaks a sticky `thead`, is
99
+ unreachable through the props. (The prop's JSDoc claimed the docs already sanctioned it; they had
100
+ never named `Table.ScrollContainer` at all.)
101
+ - `meta.align` is a `ColumnMeta` module augmentation: a typo'd key is a tsc error, a wrong value
102
+ throws naming the column. `meta.numeral` is read only as `!== false` — an opt-OUT of the
103
+ mono-numeral cell style, never an opt-in.
104
+ - **Not shipped, known:** `emptyState` renders inside a `<td colSpan>` so the header row survives an
105
+ empty table — there is no `emptyState="replace"` mode. No per-column `enableSorting` of basalt's
106
+ own; TanStack's `ColumnDef.enableSorting` still reaches `getCanSort()` and works.
107
+
108
+ ### Four false greens
109
+
110
+ 1. **`check-theme` fabricated a config on the ascend path and passed silently.** From a package with
111
+ no `basalt` key it invented `roots: ["src"]` and reported the invention back under the name
112
+ `basalt.roots`. In `basalt-ui-obsidian`, run from `apps/demo`, that scanned **22 of 44** guarded
113
+ files and made `--audit-allows` report **0 live waivers in a repo carrying 1** — exit 0, no note.
114
+ The audit exists so `0 dead` cannot read as `0 dead anywhere`, and it could be made to say zero
115
+ by standing in the wrong directory. `resolveProjectDir` now ascends to the nearest ancestor
116
+ carrying a basalt project, bounded by the repo root, and announces it in the sentence descend
117
+ already used. **No `.git` above cwd means no ascend**, so a standalone consumer keeps the
118
+ built-in defaults exactly as before. After: 44 files, the real 1.
119
+ 2. **`tokens:css --check` stopped verifying the regeneration command.** 1.23.1 blanked all of line 2
120
+ so a version bump would stop forcing a no-op commit — but line 2 also carries the exact
121
+ invocation line 1 tells the reader to regenerate with. Rewriting `--only core` to `--only all` in
122
+ that line **passed clean**. Only the version token is neutralized now; a line that does not parse
123
+ as a provenance line is compared verbatim, so a deleted or reworded header fails. The success
124
+ message also **parses** the versions instead of asserting them — it used to claim the file "still
125
+ names an older basalt-ui" without reading it, so `0.0.1-nonsense` earned the same sentence.
126
+ 3. **`doctor`'s icons check was unreachable from the only directory where `doctor` exits 0.** On a
127
+ monorepo the root run omitted it with no `⊘ SKIPPED` line — the exact failure mode `SKIPPED` was
128
+ introduced to eliminate — while the app-package run failed on artefacts of standing in a
129
+ non-install package. It resolves the app package off `basalt.roots` now. No `basaltAppPlugin(`
130
+ anywhere and no `public/` is a pass that says so; a plugin call with no `public/` beside it is a
131
+ `⊘ SKIPPED`, which exits non-zero on its own.
132
+ 4. **`SCANNABLE_EXT` gained `.astro`, `.jsx` and `.vue`.** rollhook's marketing site is Astro and its
133
+ two `.astro` templates are its entire markup layer — unguarded, while `check-theme` reported a
134
+ clean 4-file scan. It scans 6 now.
135
+
136
+ **Behaviour change to name: `sync` ascends too.** It shares the resolver, so from a sub-package it
137
+ relocates to the parent install and refreshes it — announced — rather than refusing. It still cannot
138
+ scaffold a second consumer: the refusal is keyed on the RESOLVED project and runs before the
139
+ `basalt.roots` backfill.
140
+
141
+ ### Guard changes the widening exposed
142
+
143
+ - **`raw-hex` no longer matches inside an HTML numeric character reference.** `&#123;` — the escaped
144
+ brace a template writes to show a literal `${…}` in prose — read as the hex colour `#123`. The
145
+ hole was in the KIND, not the extension: the same string produced the same findings in `.html`,
146
+ `.tsx`, `.css` and `.vue`, so `.astro` only walked into it first. The fix is precise, not blanket:
147
+ `HEX` rejects a full reference (`&#`, digits, `;`), so **`color: red&#fff` still flags** and
148
+ nothing is exempted by file type. Every neighbouring raw-text kind was checked and structurally
149
+ cannot share the blind spot — a character reference contains no `(`, and the rest anchor on a
150
+ property name, `var(`, or a JSX `=`.
151
+ - **A fourth guard syntax, `sfc`, for `.astro`/`.vue`.** They used to fall through to the `ts`
152
+ dialect, so `<!-- … -->` was never stripped: a `theme-allow` written in an HTML comment waived
153
+ nothing and a colour inside a commented-out block still reported. `sfc` strips both regions —
154
+ markup first, so an HTML comment holding an unterminated `/*` cannot open one that runs to EOF —
155
+ and keeps the **full 25-kind set**. A `markup` classification would have dropped 22 of them: an
156
+ `.astro` template is JSX-shaped and a `.vue` `<script setup>` is real TS. `.jsx` needs no branch.
157
+ Both the scan and `--audit-allows` now share one `stripGuardComments`, so they cannot disagree
158
+ about what a comment is.
159
+ - **Two limits, asserted rather than left ambiguous, both false-negative-only:** `css-raw-surface`
160
+ does not fire inside a `<style>` fence, and stripping is region-blind, so a `<!--` inside a script
161
+ string over-strips.
162
+ - **A known non-fix, deliberate:** an all-hex URL fragment or SVG reference (`href="#cafe"`,
163
+ `fill="url(#abcdef)"`) still reports. It is text-indistinguishable from a colour, so a fix would
164
+ cost real findings; `theme-allow` is the escape.
165
+ - **The widening ships at `error` with no `GRACE_PERIOD_KINDS` entry, and that table cannot express
166
+ it.** The table is keyed per KIND; widening `SCANNABLE_EXT` widens the file set for all 25 at
167
+ once. An entry for `raw-hex` — the kind that actually fired — would demote basalt's most
168
+ load-bearing kind to `warn` across every `.tsx` and `.css` in all seven consumers to buy runway on
169
+ a file type one consumer has. Measured: rollhook's marketing site scans 6 files with 0 findings,
170
+ and no other consumer holds a single `.astro`, `.vue` or `.jsx` file, so grace would have covered
171
+ zero incumbent violations.
172
+
173
+ ## 1.23.1 — the band-state throw, the tag gate, a CLI that answers
174
+
43
175
  **One type widened, nothing removed or renamed.** `BandStripSeries.formatValue` is now
44
176
  `(d: T) => string | null`. `null` renders an em dash — an absent READING — which `''` never could:
45
177
  `''` is a state whose label is the whole row. Every existing `(d) => string` still typechecks.
@@ -99,7 +231,9 @@ instead of dumping help and letting the dump read like a choice.
99
231
  - **`tokens:css --check` blanks the provenance line (line 2) before comparing**, so a version bump
100
232
  alone no longer forces a no-op commit in a tokens-only consumer, where the gate is byte-equality.
101
233
  A stale provenance line is now a note on an otherwise-passing check. The `@generated` header is
102
- still emitted byte-identical — the line stays, it just stops gating.
234
+ still emitted byte-identical — the line stays, it just stops gating. **Superseded in `Unreleased`:
235
+ blanking the WHOLE line also stopped gating the regeneration command it carries. Only the version
236
+ token is neutralized now.**
103
237
 
104
238
  ### Corrections to the record
105
239
 
@@ -229,8 +363,8 @@ install it found above instead. The refusal runs before the `basalt.roots` backf
229
363
  the damage. Two ways to unblock it:
230
364
 
231
365
  ```bash
232
- cd <the package holding .basalt/manifest.json> && bunx basalt-ui sync
233
- BASALT_CWD=<that package> bunx basalt-ui sync # or from anywhere
366
+ cd <the package holding .basalt/manifest.json> && ./node_modules/.bin/basalt-ui sync
367
+ BASALT_CWD=<that package> ./node_modules/.bin/basalt-ui sync # or from anywhere
234
368
  ```
235
369
 
236
370
  The summary line gained a `created` counter. **`recreated` now means what it always claimed** — the
package/README.md CHANGED
@@ -292,31 +292,31 @@ deleted rather than silently surviving into the next real skew.
292
292
 
293
293
  ## Subpath exports
294
294
 
295
- | Subpath | Mantine? | Purpose |
296
- | ------------------- | -------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
297
- | `.` | coupled | `BasaltProvider`, `createBasaltTheme` / `baseTheme` / `cssVariablesResolver`, `BasaltShell` + sidebar / mobile-nav / breadcrumbs / page-header, `NavCountBadge`, `SidebarAccount` + the provider-agnostic account contract (`BasaltAccountProps`/`State`/`Actions`), `ThreadWorkspace` + thread-chat components, shell types — the root entry still re-exports the thread-chat components; `./agent-chat` takes the same components standalone, without `BasaltProvider`, the shell, the dashboard composites, or `./connectivity` |
298
- | `./charts` | **free** | `CartesianChart` + visx primitives, kinds, sparklines, hooks, token re-exports |
299
- | `./tokens` | **free** | `VX` token refs, `buildPaletteCss`, `defineSeries`, `seriesTokens`, `groupTokens`, `alpha`, `ColorPair` / `SeriesMap` types |
300
- | `./theme-lab` | coupled | `ThemeLabControls`, `applyOverrides`, `loadOverrides`, `COLOR_GROUPS` for live theme inspection |
301
- | `./vite` | — | `basaltViteConfig(opts)` — Vite preset for basalt-ui consumer apps; `basaltAppPlugin(opts)` — PWA head, manifest, and icon metadata derived from the token palette |
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` |
303
- | `./query` | **free** | `createBasaltQueryClient`, transport-agnostic `unwrap`, lazy `BasaltQueryDevtools` |
304
- | `./router-tanstack` | **free** | TanStack Router bridge: `useBasaltNav` (active route) + `useRouterBreadcrumbs` |
305
- | `./forms` | coupled | Mantine form adapter: `useBasaltForm`, `field`, `FormErrorSummary`, `useFormDraft` (Standard Schema) |
306
- | `./notifications` | coupled | Mantine notifications: `notify` helpers, typed registry, persisted history, `NotificationBell` |
307
- | `./commands` | coupled | Typed command bus + overlay controller, `toSpotlightActions`, `ShortcutsHelp`, `BasaltOverlays` |
308
- | `./data` | coupled | Convenience barrel pulling both peer groups: `BasaltDataTable`, `BasaltVirtualList` (Mantine-rendered) — prefer `./data/table` / `./data/virtual` for per-feature opt-in |
309
- | `./data/table` | coupled | `BasaltDataTable` — sortable data table over TanStack Table, rendered with Mantine |
310
- | `./data/virtual` | coupled | `BasaltVirtualList` — windowed virtual list over TanStack Virtual, rendered with Mantine |
311
- | `./agent` | **free** | Headless streaming layer (`useAgentStream`, `PartList`) + multi-thread `createThreadsStore` / `useAgentThreadRuns` / outcome seam |
312
- | `./agent-chat` | coupled | Mantine-styled thread-chat components over `./agent`: `ThreadWorkspace`, `ThreadFeed` (`variant`/`renderRow`), `ThreadFeedRow` (inline-expanding Slack row, lazily mounted + kept mounted), `ThreadOutcomeCard`, `ThreadDetailPanel`, `Composer`, `ThreadTranscript` (`groupConsecutive`/`affordances`/`virtualize` — windowed transcripts open at the newest message unless `initialScroll: 'start'`; `virtualize` is enabled by the optional `@tanstack/react-virtual` peer, absent it degrades to an unwindowed, height-bound pane), `threadPartRenderers` — **requires `motion`**, not merely an optional peer; see [Requirements](#requirements) |
313
- | `./state` | **free** | `createPersistedState` (versioned localStorage) + `useOnlineStatus` — Mantine-free state primitives |
314
- | `./connectivity` | coupled | `ConnectivityProvider` (aggregates browser/React-Query/SSE/health-check status), `useConnectivity`, `ConnectivityIndicator` — auto-mounted by `BasaltProvider` |
315
- | `./content` | coupled | `Prose`, `CodeBlock`, `Callout`, `TableOfContents`, `ReadingProgress`, `Markdown`, `MermaidDiagram`, `ArticleLayout`, `ArticleCard` / `ArticleGrid`, `GuideLink` / `GuideDrawer`, `mdxComponents` |
316
- | `./styles.css` | — | `@layer basalt` base styles, iOS input safety net, font stack |
317
- | `./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) |
318
- | `./configs/*` | — | Raw toolchain presets — oxlint, oxfmt, tsconfig (base/react-app/node), lefthook |
319
- | `./llms.txt` | — | Machine-readable surface map — one entry per published subpath with import specifier, description, layer, and optional peers |
295
+ | Subpath | Mantine? | Purpose |
296
+ | ------------------- | -------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
297
+ | `.` | coupled | `BasaltProvider`, `createBasaltTheme` / `baseTheme` / `cssVariablesResolver`, `BasaltShell` + sidebar / mobile-nav / breadcrumbs / page-header, `NavCountBadge`, `SidebarAccount` + the provider-agnostic account contract (`BasaltAccountProps`/`State`/`Actions`), `ThreadWorkspace` + thread-chat components, dashboard composites (`DeltaBadge`, `StatCard`, `EmptyState`, `SettingsSection`/`SettingsRow`/`DangerZone`) and the query-branch trio `QueryState` / `LoadingState` / `ErrorState`, shell types — the root entry still re-exports the thread-chat components; `./agent-chat` takes the same components standalone, without `BasaltProvider`, the shell, the dashboard composites, or `./connectivity` |
298
+ | `./charts` | **free** | `CartesianChart` + visx primitives, kinds, sparklines, hooks, token re-exports |
299
+ | `./tokens` | **free** | `VX` token refs, `buildPaletteCss`, `defineSeries`, `seriesTokens`, `groupTokens`, `alpha`, `ColorPair` / `SeriesMap` types |
300
+ | `./theme-lab` | coupled | `ThemeLabControls`, `applyOverrides`, `loadOverrides`, `COLOR_GROUPS` for live theme inspection |
301
+ | `./vite` | — | `basaltViteConfig(opts)` — Vite preset for basalt-ui consumer apps; `basaltAppPlugin(opts)` — PWA head, manifest, and icon metadata derived from the token palette |
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` |
303
+ | `./query` | **free** | `createBasaltQueryClient`, transport-agnostic `unwrap`, lazy `BasaltQueryDevtools`, `toErrorMessage` / `errorStatus` — the only route to those two; the root barrel does not re-export them |
304
+ | `./router-tanstack` | **free** | TanStack Router bridge: `useBasaltNav` (active route) + `useRouterBreadcrumbs` |
305
+ | `./forms` | coupled | Mantine form adapter: `useBasaltForm`, `field`, `FormErrorSummary`, `useFormDraft` (Standard Schema) |
306
+ | `./notifications` | coupled | Mantine notifications: `notify` helpers, typed registry, persisted history, `NotificationBell` |
307
+ | `./commands` | coupled | Typed command bus + overlay controller, `toSpotlightActions`, `ShortcutsHelp`, `BasaltOverlays` |
308
+ | `./data` | coupled | Convenience barrel pulling both peer groups: `BasaltDataTable`, `BasaltVirtualList` (Mantine-rendered) — prefer `./data/table` / `./data/virtual` for per-feature opt-in |
309
+ | `./data/table` | coupled | `BasaltDataTable` — sortable data table over TanStack Table, rendered with Mantine |
310
+ | `./data/virtual` | coupled | `BasaltVirtualList` — windowed virtual list over TanStack Virtual, rendered with Mantine |
311
+ | `./agent` | **free** | Headless streaming layer (`useAgentStream`, `PartList`) + multi-thread `createThreadsStore` / `useAgentThreadRuns` / outcome seam |
312
+ | `./agent-chat` | coupled | Mantine-styled thread-chat components over `./agent`: `ThreadWorkspace`, `ThreadFeed` (`variant`/`renderRow`), `ThreadFeedRow` (inline-expanding Slack row, lazily mounted + kept mounted), `ThreadOutcomeCard`, `ThreadDetailPanel`, `Composer`, `ThreadTranscript` (`groupConsecutive`/`affordances`/`virtualize` — windowed transcripts open at the newest message unless `initialScroll: 'start'`; `virtualize` is enabled by the optional `@tanstack/react-virtual` peer, absent it degrades to an unwindowed, height-bound pane), `threadPartRenderers` — **requires `motion`**, not merely an optional peer; see [Requirements](#requirements) |
313
+ | `./state` | **free** | `createPersistedState` (versioned localStorage) + `useOnlineStatus` — Mantine-free state primitives |
314
+ | `./connectivity` | coupled | `ConnectivityProvider` (aggregates browser/React-Query/SSE/health-check status), `useConnectivity`, `ConnectivityIndicator` — auto-mounted by `BasaltProvider` |
315
+ | `./content` | coupled | `Prose`, `CodeBlock`, `Callout`, `TableOfContents`, `ReadingProgress`, `Markdown`, `MermaidDiagram`, `ArticleLayout`, `ArticleCard` / `ArticleGrid`, `GuideLink` / `GuideDrawer`, `mdxComponents` |
316
+ | `./styles.css` | — | `@layer basalt` base styles, iOS input safety net, font stack |
317
+ | `./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) |
318
+ | `./configs/*` | — | Raw toolchain presets — oxlint, oxfmt, tsconfig (base/react-app/node), lefthook |
319
+ | `./llms.txt` | — | Machine-readable surface map — one entry per published subpath with import specifier, description, layer, and optional peers |
320
320
 
321
321
  Named exports only — no default exports.
322
322
 
@@ -479,6 +479,62 @@ const [client] = useState(() => createBasaltQueryClient())
479
479
  // queryFn: () => unwrap(api.users.get())
480
480
  ```
481
481
 
482
+ `toErrorMessage(err, fallback?)` and `errorStatus(err)` also live here — decode an unknown thrown
483
+ value into something renderable, folding an HTTP status into the fallback when the body decodes to
484
+ nothing usable (`''`, `'{}'`, `'[object Object]'`, …).
485
+
486
+ #### Rendering a query — `QueryState`
487
+
488
+ Imported from **`basalt-ui`**, not `basalt-ui/query`: it renders Mantine, and `./query` is a
489
+ guard-enforced Mantine-free entry.
490
+
491
+ ```tsx
492
+ import { QueryState } from 'basalt-ui'
493
+
494
+ function Library() {
495
+ const q = useQuery(imageQueries.list()) // { data, isError, error, fetchStatus, refetch }
496
+ return (
497
+ <QueryState
498
+ query={q}
499
+ variant="page" // or "section"
500
+ errorTitle="Could not load images"
501
+ errorFallback="The request failed."
502
+ errorAction={<Button onClick={openSupport}>Get help</Button>}
503
+ empty={{ title: 'No images yet', description: 'Upload one to get started.' }}
504
+ isEmpty={(d) => d.items.length === 0}
505
+ >
506
+ {(data) => <ImageGrid items={data.items} />}
507
+ </QueryState>
508
+ )
509
+ }
510
+ ```
511
+
512
+ **Ship it instead of writing the switch yourself.** Rendering only the empty branch is the mistake
513
+ this exists to retire: a consumer's library showed `No images` on a **500** and a share detail
514
+ showed `Share not found` on a dropped connection, because "nothing here" is what an app renders when
515
+ nobody wrote the other three branches. `LoadingState` and `ErrorState` ship beside it as the escape
516
+ hatch for a page that must place its branches in different DOM positions.
517
+
518
+ Branch precedence, in the order it is evaluated:
519
+
520
+ | Condition | Renders |
521
+ | ----------------------------------- | ------------------------------------------------------------------------- |
522
+ | `isError` and no `data` | `ErrorState` — `errorTitle` / `errorFallback` / `errorAction`, with Retry |
523
+ | no `data`, `fetchStatus === 'idle'` | the `empty` copy (or nothing, if `empty` is omitted) |
524
+ | no `data` | `loading`, else `LoadingState` |
525
+ | `data`, `isEmpty(data)` | the `empty` copy |
526
+ | `data` | `children` — a node, or `(data) => node` |
527
+ | `data` **and** `isError` | a section-variant _stale data_ banner above `children` |
528
+
529
+ The stale-data banner carries its own fixed copy (`Showing cached data`); `errorTitle` /
530
+ `errorFallback` / `errorAction` apply to the no-data error branch only.
531
+
532
+ `query` is typed as a five-field structural subset, not TanStack's `UseQueryResult` — a
533
+ hand-composed object is legal, and basalt couples to no query-library version. That subset removes
534
+ the compiler, so **the shape is checked at runtime and a missing `isError` throws**, naming the
535
+ field. That is deliberate: a missing `isError` is exactly the bug above, and degrading quietly would
536
+ reproduce it.
537
+
482
538
  ### `./router-tanstack` — TanStack Router bridge
483
539
 
484
540
  ```bash
@@ -547,9 +603,32 @@ bun add @tanstack/react-table @tanstack/react-virtual
547
603
  import { BasaltDataTable, BasaltVirtualList, createColumnHelper } from 'basalt-ui/data'
548
604
 
549
605
  const col = createColumnHelper<Row>()
550
- const columns = [col.accessor('name', { header: 'Name' })]
551
- // <BasaltDataTable data={rows} columns={columns} />
552
- ```
606
+ const columns = [
607
+ col.accessor('name', { header: 'Name' }),
608
+ col.accessor('amount', { header: 'Amount', meta: { align: 'right' } }),
609
+ ]
610
+ // <BasaltDataTable data={rows} columns={columns} maxHeight={320} stickyHeader />
611
+ ```
612
+
613
+ **Body chrome.** `maxHeight`, `minWidth`, `stickyHeader`, `stickyHeaderOffset`, `verticalSpacing`,
614
+ `horizontalSpacing`, `withRowBorders`, `withTableBorder` (basalt defaults this one to `true`),
615
+ `striped` (`boolean | 'odd' | 'even'`), and per-column `meta: { align, numeral }`.
616
+
617
+ - `maxHeight` (or `minWidth`) renders `Table.ScrollContainer type="native"` — the same node a
618
+ bespoke escape-hatch table should wrap itself in, so the two lanes produce identical DOM.
619
+ `type="native"` is required, not preferred: `ScrollArea`'s custom viewport is what a sticky
620
+ `<thead>` positions against, so the default type pins the header to the page viewport.
621
+ - `meta.align` is a `ColumnMeta` module augmentation: a typo'd key is a type error, and a value
622
+ outside `'left' | 'center' | 'right'` throws naming the column rather than left-aligning a money
623
+ column in silence. `meta: { numeral: false }` opts a numeric cell OUT of the mono-numeral style —
624
+ it is an opt-out only; `numeral: true` forces nothing.
625
+ - `emptyState` renders inside a `<td colSpan>`, so the header row survives an empty table. There is
626
+ no mode that replaces the whole table.
627
+
628
+ **Honest result:** porting argo's three tables onto these props made them 341 → **370–379** lines, 29–38
629
+ longer. Column defs cost more than JSX rows when every cell is bespoke. Adopt the props for what
630
+ they actually buy — the `type="native"` footgun, alignment stated once instead of on both `th` and
631
+ `td`, and sorting/filtering/pagination owned by basalt — not for a line count.
553
632
 
554
633
  #### `./data/table` — BasaltDataTable only
555
634
 
@@ -711,8 +790,8 @@ basalt-ui sync --check # CI drift gate — non-zero exit on any manage
711
790
  basalt-ui check-theme # palette guard — fails on colors that bypass the central --vx-* system
712
791
  basalt-ui doctor # integration health: basalt-resolves, guard-scan, oxlint-preset, lefthook-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 an icon file basaltAppPlugin references and public/ does not have
713
792
  basalt-ui doctor --tokens-only # force the Mantine-free profile (auto-detected otherwise; --framework forces the full set)
714
- bunx basalt-ui tokens:css --out src/tokens.css # emit the --vx-* stylesheet as a file you own
715
- bunx basalt-ui fonts:css --out src/fonts.css # emit the shipped --basalt-font-* stacks
793
+ bunx basalt-ui tokens:css --out src/tokens.css # emit the --vx-* stylesheet as a file you own — `bunx` ONLY if basalt is not installed here
794
+ bunx basalt-ui fonts:css --out src/fonts.css # emit the shipped --basalt-font-* stacks — same exception
716
795
  basalt-ui info --json # surface map as stable JSON (InfoOutput shape); bare `info` is the human-readable form
717
796
  basalt-ui help # full usage; every subcommand also takes --help / -h
718
797
  ```
@@ -740,6 +819,11 @@ script, CI step, hook and piece of doctor advice with the resolved `./node_modul
740
819
  (`BASALT_BIN` overrides it, and `configs/lefthook.yml` keeps `bunx --no-install` as a deliberately
741
820
  loud fallback). `basalt-ui --version` is the one-second check.
742
821
 
822
+ **Three commands are the sanctioned exception, and only when basalt is not installed in the repo:**
823
+ `init` (which predates the install by definition) and `tokens:css` / `fonts:css` (the framework-free
824
+ route, where there is nothing to install). In a repo that _does_ carry basalt — which includes every
825
+ tokens-only consumer that keeps it as a devDependency — run those through the local bin too.
826
+
743
827
  `sync` strategy per file: unchanged since last write → overwrite; locally edited → skip (show diff);
744
828
  missing → recreate. `--force` overwrites local edits. The summary counts `created` and `recreated`
745
829
  apart: `recreated` means the ledger placed that file once and it went missing. On a
@@ -778,13 +862,20 @@ The `./configs/*` export gives consumer apps raw presets to `extends` or copy vi
778
862
 
779
863
  ## `basalt-ui sync` in CI
780
864
 
781
- Wire the drift gate to catch doctrine falling behind after a basalt-ui upgrade:
865
+ Wire the drift gate to catch doctrine falling behind after a basalt-ui upgrade. **The local bin,
866
+ never `bunx`** — this is what `init` seeds (`configs/check.yml`, rendered from where basalt actually
867
+ installed):
782
868
 
783
869
  ```yaml
784
870
  # .github/workflows/check.yml (seeded by basalt-ui init)
785
- - run: bunx basalt-ui sync --check
871
+ - run: ./node_modules/.bin/basalt-ui sync --check
786
872
  ```
787
873
 
874
+ `sync --check` is the worst command to run from a stale `bunx` cache: it compares the consumer's
875
+ files against the **cached CLI's own** shipped doctrine, so it passes while the install has drifted.
876
+ Three consumer repos hand-corrected this exact line, and this snippet used to talk the next reader
877
+ straight back into it.
878
+
788
879
  ---
789
880
 
790
881
  ## Requirements