basalt-ui 1.23.0 → 1.24.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 (81) hide show
  1. package/AGENTS.md +31 -17
  2. package/CHANGELOG.md +23 -0
  3. package/MIGRATING.md +222 -6
  4. package/README.md +159 -49
  5. package/agent/rules/basalt-app.md +8 -2
  6. package/agent/rules/basalt-charts.md +38 -2
  7. package/agent/rules/basalt-content.md +3 -3
  8. package/agent/rules/basalt-data.md +45 -13
  9. package/agent/rules/basalt-mantine.md +4 -4
  10. package/agent/rules/basalt-query.md +57 -0
  11. package/agent/rules/basalt-tokens.md +18 -5
  12. package/agent/skills/basalt-app/SKILL.md +32 -12
  13. package/agent/skills/basalt-charts/SKILL.md +4 -2
  14. package/agent/templates/CLAUDE-block.md.tpl +3 -2
  15. package/dist/charts/kinds/BandStrip.d.ts +20 -6
  16. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  17. package/dist/charts/kinds/BandStrip.js +59 -6
  18. package/dist/charts/kinds/MirroredBars.d.ts +4 -2
  19. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  20. package/dist/charts/kinds/MirroredBars.js +13 -0
  21. package/dist/cli/index.d.ts +4 -2
  22. package/dist/cli/index.d.ts.map +1 -1
  23. package/dist/cli/index.js +269 -27
  24. package/dist/dashboard/empty-state.d.ts +6 -2
  25. package/dist/dashboard/empty-state.d.ts.map +1 -1
  26. package/dist/dashboard/empty-state.js +1 -1
  27. package/dist/dashboard/index.d.ts +1 -0
  28. package/dist/dashboard/index.d.ts.map +1 -1
  29. package/dist/dashboard/index.js +8 -0
  30. package/dist/dashboard/query-state.d.ts +96 -0
  31. package/dist/dashboard/query-state.d.ts.map +1 -0
  32. package/dist/dashboard/query-state.js +123 -0
  33. package/dist/dashboard/query-state.test.d.ts +2 -0
  34. package/dist/dashboard/query-state.test.d.ts.map +1 -0
  35. package/dist/data/data-table.d.ts +61 -4
  36. package/dist/data/data-table.d.ts.map +1 -1
  37. package/dist/data/data-table.js +41 -4
  38. package/dist/data/data-table.test.d.ts +2 -0
  39. package/dist/data/data-table.test.d.ts.map +1 -0
  40. package/dist/data/table.d.ts +1 -1
  41. package/dist/data/table.d.ts.map +1 -1
  42. package/dist/guard/index.d.ts.map +1 -1
  43. package/dist/guard/index.js +50 -9
  44. package/dist/index.d.ts +1 -1
  45. package/dist/index.d.ts.map +1 -1
  46. package/dist/index.js +7 -1
  47. package/dist/query/error-message.d.ts +41 -0
  48. package/dist/query/error-message.d.ts.map +1 -0
  49. package/dist/query/error-message.js +30 -0
  50. package/dist/query/error-message.test.d.ts +2 -0
  51. package/dist/query/error-message.test.d.ts.map +1 -0
  52. package/dist/query/index.d.ts +1 -19
  53. package/dist/query/index.d.ts.map +1 -1
  54. package/dist/query/index.js +2 -14
  55. package/dist/surfaces.d.ts +1 -1
  56. package/dist/surfaces.js +1 -1
  57. package/llms.txt +3 -3
  58. package/package.json +1 -1
  59. package/src/charts/kinds/BandStrip.test.tsx +87 -5
  60. package/src/charts/kinds/BandStrip.tsx +105 -12
  61. package/src/charts/kinds/MirroredBars.test.tsx +39 -0
  62. package/src/charts/kinds/MirroredBars.tsx +38 -2
  63. package/src/cli/check-theme.test.ts +34 -0
  64. package/src/cli/help.test.ts +74 -1
  65. package/src/cli/index.ts +496 -41
  66. package/src/cli/tokens-css.test.ts +129 -0
  67. package/src/cli/toolchain-wiring.test.ts +146 -0
  68. package/src/dashboard/empty-state.tsx +9 -3
  69. package/src/dashboard/index.ts +11 -0
  70. package/src/dashboard/query-state.test.tsx +152 -0
  71. package/src/dashboard/query-state.tsx +272 -0
  72. package/src/data/data-table.test.tsx +158 -0
  73. package/src/data/data-table.tsx +123 -5
  74. package/src/data/table.ts +6 -1
  75. package/src/guard/check-source.test.ts +214 -0
  76. package/src/guard/index.ts +169 -15
  77. package/src/index.ts +9 -0
  78. package/src/query/error-message.test.ts +67 -0
  79. package/src/query/error-message.ts +70 -0
  80. package/src/query/index.ts +2 -34
  81. package/src/surfaces.ts +1 -1
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
 
@@ -450,6 +450,16 @@ bar panes over one x scale and one baseline, each in its own domain. Both fold t
450
450
  width, hatch the share of a slot nothing measured, and share the page cursor like every other
451
451
  chart.
452
452
 
453
+ On a strip, `series` IS the state set, so a `state` naming no entry has no colour, no legend entry
454
+ and no tooltip row. It **throws in dev**, naming the key and the valid set; in production it draws a
455
+ dashed neutral outline band — a treatment no legend entry and no state fill uses — plus an
456
+ `Unknown state` tooltip row. Drawing nothing was the old behaviour and the wrong one: on a
457
+ measured/not-measured strip a missing band claims a coverage gap. `absentState` and `MirroredBars`'
458
+ `up.key`/`down.key` are PROPS rather than data, so those throw in every environment; an
459
+ unresolvable pane key used to hide the pane and its axis, which reads as a measured zero.
460
+ `BandStripSeries.formatValue` returns `string | null`, `null` rendering an em dash — an absent
461
+ reading, distinct from `''`, which is a state whose label is the whole row.
462
+
453
463
  Which x ticks get painted is `xTickValues?: (keys, xMax) => readonly string[]`, on
454
464
  `CartesianChart` and forwarded by `Bars`/`MultiLine`/`StackedArea`/`ZonedLine` and both band kinds.
455
465
  It resolves ahead of the `xTicks` COUNT, which is unchanged; omit both and ticks are chosen to fit.
@@ -469,6 +479,62 @@ const [client] = useState(() => createBasaltQueryClient())
469
479
  // queryFn: () => unwrap(api.users.get())
470
480
  ```
471
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
+
472
538
  ### `./router-tanstack` — TanStack Router bridge
473
539
 
474
540
  ```bash
@@ -537,9 +603,32 @@ bun add @tanstack/react-table @tanstack/react-virtual
537
603
  import { BasaltDataTable, BasaltVirtualList, createColumnHelper } from 'basalt-ui/data'
538
604
 
539
605
  const col = createColumnHelper<Row>()
540
- const columns = [col.accessor('name', { header: 'Name' })]
541
- // <BasaltDataTable data={rows} columns={columns} />
542
- ```
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** lines, 29
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.
543
632
 
544
633
  #### `./data/table` — BasaltDataTable only
545
634
 
@@ -694,18 +783,23 @@ Current slots: `series` (read by `./charts` + `./tokens`), `commands` (read by `
694
783
  ## CLI
695
784
 
696
785
  ```bash
697
- bunx basalt-ui init # scaffold doctrine into a consumer repo
698
- bunx basalt-ui sync # three-way diff against .basalt/manifest.json after a basalt-ui upgrade
699
- bunx basalt-ui sync --check # CI drift gate — non-zero exit on any managed-file drift
700
- bunx basalt-ui check-theme # palette guard — fails on colors that bypass the central --vx-* system
701
- bunx basalt-ui doctor # integration health: basalt-resolves, guard-scan, oxlint-preset, manifest presence, node_modules-vs-manifest version, spacing-scale drift, and a cross-package `ai` major-version parity check (HARD failure — walks every workspace manifest, exemptable via `basalt.aiMajorSkewReason` — see above) + warn on missing basaltAppPlugin icon files under public/
702
- bunx basalt-ui doctor --tokens-only # force the Mantine-free profile (auto-detected otherwise; --framework forces the full set)
703
- bunx basalt-ui tokens:css --out src/tokens.css # emit the --vx-* stylesheet as a file you own
704
- bunx basalt-ui fonts:css --out src/fonts.css # emit the shipped --basalt-font-* stacks
705
- bunx basalt-ui info # human-readable surface map: subpath, layer, rule, skills, optional peers
706
- bunx basalt-ui info --json # same map as stable JSON (InfoOutput shape)
707
- bunx basalt-ui help # full usage; every subcommand also takes --help / -h
708
- ```
786
+ bunx basalt-ui init # scaffold doctrine into a consumer repo — the one command with no install to resolve
787
+ basalt-ui --version # one bare line, exit 0 (also -v / version) — which CLI is actually running
788
+ basalt-ui sync # three-way diff against .basalt/manifest.json after a basalt-ui upgrade
789
+ basalt-ui sync --check # CI drift gate — non-zero exit on any managed-file drift
790
+ basalt-ui check-theme # palette guard — fails on colors that bypass the central --vx-* system
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
792
+ basalt-ui doctor --tokens-only # force the Mantine-free profile (auto-detected otherwise; --framework forces the full set)
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
795
+ basalt-ui info --json # surface map as stable JSON (InfoOutput shape); bare `info` is the human-readable form
796
+ basalt-ui help # full usage; every subcommand also takes --help / -h
797
+ ```
798
+
799
+ **Nothing fails open.** `--version` resolves before dispatch, so it can never run a command to
800
+ answer which CLI ran. Every subcommand validates its flags and exits 1 naming the first one it does
801
+ not accept — `doctor --json` used to run doctor and exit **0**, and `check-theme --audit-allow`
802
+ scanned and reported success. An unknown command says so above the usage block.
709
803
 
710
804
  `doctor` reports **`SKIPPED`** as a third outcome beside pass/warn/fail and exits non-zero on it —
711
805
  "All checks passed" is only printable when every check actually ran. **`check-theme`, `doctor` and
@@ -715,13 +809,20 @@ ambiguous, never guessed. The scan is what a repo declaring **no `workspaces` fi
715
809
  the install one level down and nothing declaring it, every command answered about the root and
716
810
  found nothing there: `check-theme` printed "no off-palette colors" having scanned zero files, and
717
811
  `doctor` inferred `tokens-only` for a full Mantine consumer. `tokens:css` and `fonts:css` both take
718
- `--check` as a CI drift gate.
812
+ `--check` as a CI drift gate, comparing everything but the header's provenance line — so a
813
+ basalt-ui version bump alone never forces a no-op commit in a tokens-only consumer.
719
814
 
720
- **Verify an upgrade against the local bin, not `bunx`.** `bunx` does not re-resolve a package it
721
- has already cached, so a `bunx basalt-ui …` run can execute a version you upgraded away from —
722
- which is exactly how a fixed defect gets re-reported. Prefer `./node_modules/.bin/basalt-ui` (or
723
- `bun run` a script that does) when the point of the run is to check what the installed version
724
- does.
815
+ **Run the local bin, not `bunx`.** `bunx` does not re-resolve a package it has already cached, so a
816
+ `bunx basalt-ui …` run can execute a version you upgraded away from — which is how one consumer
817
+ filed a P0 against a 1.20.0 cache while pinned to 1.22.0. `init` and `sync` now render every seeded
818
+ script, CI step, hook and piece of doctor advice with the resolved `./node_modules/.bin/basalt-ui`
819
+ (`BASALT_BIN` overrides it, and `configs/lefthook.yml` keeps `bunx --no-install` as a deliberately
820
+ loud fallback). `basalt-ui --version` is the one-second check.
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.
725
826
 
726
827
  `sync` strategy per file: unchanged since last write → overwrite; locally edited → skip (show diff);
727
828
  missing → recreate. `--force` overwrites local edits. The summary counts `created` and `recreated`
@@ -736,7 +837,9 @@ release.
736
837
  `check-theme` and `doctor` do, and since 1.22.0 **exits 1** when the resolved project has no
737
838
  `.basalt/manifest.json`, naming the install it found above instead of scaffolding a second one
738
839
  beside it. Run it at the package that holds the manifest, or point `BASALT_CWD` at that package.
739
- Creating an install is `basalt-ui init`'s decision.
840
+ Creating an install is `basalt-ui init`'s decision — and `doctor` now says the same sentence from
841
+ the same directory, where it used to prescribe `init` and so recommend the very scaffold that
842
+ refusal exists to prevent.
740
843
 
741
844
  ---
742
845
 
@@ -759,13 +862,20 @@ The `./configs/*` export gives consumer apps raw presets to `extends` or copy vi
759
862
 
760
863
  ## `basalt-ui sync` in CI
761
864
 
762
- 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):
763
868
 
764
869
  ```yaml
765
870
  # .github/workflows/check.yml (seeded by basalt-ui init)
766
- - run: bunx basalt-ui sync --check
871
+ - run: ./node_modules/.bin/basalt-ui sync --check
767
872
  ```
768
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
+
769
879
  ---
770
880
 
771
881
  ## Requirements
@@ -193,7 +193,11 @@ web-app-manifest-512x512.png
193
193
 
194
194
  Generate the set with `@vite-pwa/assets-generator` (or realfavicongenerator.net) from a single
195
195
  source image — it lives in the CONSUMER's own devDependencies, not basalt-ui's. `basalt-ui doctor`
196
- warns (does not fail) when `public/` exists but is missing one of these files.
196
+ warns (does not fail) when `public/` exists but is missing one of these files — and it now
197
+ parses `basaltAppPlugin`'s `icons` option out of your vite config first, so it checks the files you
198
+ declared rather than the six it assumes. `icons: false` or an empty array is "nothing to check"; an
199
+ unparseable or absent config falls back to the six defaults. Adopting the array is no longer a way
200
+ to earn a warning.
197
201
 
198
202
  **If your `public/` does not match those six filenames, name your icons instead.** `icons` takes
199
203
  `false | { dir?: string } | readonly BasaltAppIcon[]`, and an array uses the manifest's own field
@@ -217,7 +221,9 @@ One SVG at `sizes: 'any'` is a complete, installable icon set for a single-page
217
221
  shape every non-scaffolded consumer already has. If you keep a hand-written `manifest.webmanifest`
218
222
  only because the plugin could not name your icon, delete it: the generated manifest reproduces it
219
223
  member for member, including the two hexes derived from `SURFACE.bg` rather than hand-copied, and it
220
- takes the permanent `theme-allow-file` on that file with it.
224
+ takes the permanent `theme-allow-file` on that file with it. **Delete that app's own
225
+ `<link rel="manifest">` in the same change** — the plugin injects its own, so leaving both ships two
226
+ manifest links pointing at different files, one of them a 404 the moment the hand-written file goes.
221
227
 
222
228
  `basaltAppPlugin` also emits `site.webmanifest` (served in dev too) with explicit `id`/`scope`/
223
229
  `start_url` — pass `manifest: false` to skip it, or `icons: false` to skip the icons. Since 1.22.0
@@ -14,8 +14,10 @@ primitives so we can build exactly the chart we want; the trade-off is that ever
14
14
  structure unless we compose shared building blocks. **`CartesianChart` plus a small kind set are
15
15
  the contract, not optional polish** — composing `CartesianChart` (single-plot) or `ChartFrame`
16
16
  directly (multi-pane/radial/matrix) is mandatory for every chart, mechanically enforced by
17
- `basalt/hand-rolled-plot` (see "Mechanical enforcement" below). `docs/CHARTS-SPEC.md` is the ground
18
- truth this doc reflects.
17
+ `basalt/hand-rolled-plot` (see "Mechanical enforcement" below). **This file is the contract you
18
+ receive** — the basalt-ui repo's `docs/CHARTS-SPEC.md` is the same argument at length, and it does
19
+ not ship in the package. Read it at
20
+ <https://github.com/jkrumm/basalt-ui/blob/master/docs/CHARTS-SPEC.md> or not at all.
19
21
 
20
22
  ## The boundary (lint-enforced)
21
23
 
@@ -348,6 +350,8 @@ per-series `getValue` accessors on `ChartSeries<T>`):
348
350
  hatch; `marker` carries a fact that must not be readable off the `fill` ramp. `cursorResolution`
349
351
  defaults `'leading'` here (a band IS a bucket), not `'nearest'`. Composes `ChartFrame` directly —
350
352
  `CartesianChart` renders `AxisLeftNumeric` unconditionally, so this shape could never use it.
353
+ `BandStripSeries.formatValue` is `(d) => string | null` — `null` renders an em dash, an absent
354
+ READING, which `''` cannot say (`''` is a state whose label is the whole row).
351
355
  - **`MirroredBars`** — two BAR panes over one x scale, mirrored around one baseline, each pane in
352
356
  its own domain. `up`/`down` take `{ key, max?, autoMaxFloor?, ticks?, format }`; `upFraction`
353
357
  (default 0.35) is the up pane's share of the band height, which is where the shared baseline sits;
@@ -360,6 +364,29 @@ Both band kinds fold their domain by width (`fold: { minBandPx?, merge }`), and
360
364
  exported so a consumer can test their merge against the grouping that will actually run.
361
365
  `HatchPattern` / `hatchFill` / `hatchSizeFor` ship the absence fill.
362
366
 
367
+ **`merge` does not run on a sub-cap dataset.** `foldBands` returns `[...data]` unchanged when
368
+ `data.length <= cap`, which on a wide viewport is the common case — so any bookkeeping the merge
369
+ adds (how many source buckets a slot folded, how many were unmeasured) is absent exactly where you
370
+ are least likely to look for it. Put that bookkeeping on the datum at CONSTRUCTION and have `merge`
371
+ sum it; do not compute it from `group.length` inside the merge. Summing is also the composable
372
+ form, and it keeps "every source bucket accounted for exactly once" true by construction.
373
+
374
+ **A band state naming no `series` entry throws in dev.** `series` IS the state set, so an
375
+ unresolvable `state` has no legend entry, no colour and no tooltip row — and the old behaviour,
376
+ skipping the band, drew a coverage GAP, which on a measured/not-measured strip is a false claim
377
+ about the data rather than an "unknown". Where the key comes from decides the treatment:
378
+
379
+ | Key comes off | Dev | Production |
380
+ | ------------------------------------------------------------- | ---------------------------------------- | ---------------------------------------------------------------------- |
381
+ | the DATUM — `BandSpan.state`, `marker.state` | throws, naming the key and the valid set | dashed neutral outline band + an `Unknown state` / `<key>` tooltip row |
382
+ | a PROP — `absentState`, `MirroredBars`' `up.key` / `down.key` | throws | throws |
383
+
384
+ A feed that grows a state basalt has never seen must degrade rather than take a dashboard down; a
385
+ typo, which is the same input, still fails loudly wherever it is written. The dashed neutral outline
386
+ is used by no legend entry and no state fill, so it can never be mistaken for data. Pane keys are
387
+ props, never data-driven — an unresolvable `up.key` used to hide the pane AND its axis, which reads
388
+ as "that half measured zero".
389
+
363
390
  `ZonedLine` and `MultiLine` also accept `xZones?: XZoneSpec[]` — the vertical counterpart to a
364
391
  kind's horizontal zone bands, for marking a time window rather than a value range. Each
365
392
  `{ from?, to?, fill, align? }` bound is a `getX` **domain key** (the label string the kind's
@@ -512,6 +539,15 @@ left, 12% right): uncapped, half a `DD.MM HH:MM` label is 48px of a 338px chart.
512
539
  probes real tick labels from both panes, so it takes the plain measured margin and neither the
513
540
  floor nor the cap applies.
514
541
 
542
+ **The height law is the number you actually have to compute.** A band row is
543
+ `height − legend − margin.top − margin.bottom`: `ChartFrame` reserves the measured legend band out
544
+ of the plot rect first, then the kind takes the margins out of what is left. There is no
545
+ `bandHeight` prop — to get a 44px band, hand the frame `44 + margins + legend`. `margin={{ top: 0 }}`
546
+ reclaims the whole top gutter, which a 1-D strip draws nothing in; the bottom is not reclaimable,
547
+ `autoMargin` floors it at `VX.margin.bottom`. Derive the constants from `VX.margin` rather than
548
+ restating them — a consumer that copies `30` into three chart files has three silent-drift sites the
549
+ moment the density scale is retuned.
550
+
515
551
  `chartMargin(opts?)` (`basalt-ui/tokens`, also re-exported from `basalt-ui/charts`) still exists,
516
552
  but only matters for a bespoke chart that composes `ChartFrame` directly (not `CartesianChart`) and
517
553
  needs a right-axis-sized margin without running its own measurement pass — `DualPanel` and `Heatmap`
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  source: basalt-ui
3
- description: Content/prose surface from basalt-ui/content — Prose, CodeBlock, Callout, TableOfContents, ReadingProgress, Markdown (streaming-aware), MermaidDiagram, MDX component map, ArticleLayout, ArticleCard/Grid, GuideLink/GuideDrawer, and the content-collections + TanStack Start recipe. Mantine-coupled (docs/CONTENT-SPEC.md in the basalt-ui repo).
3
+ description: Content/prose surface from basalt-ui/content — Prose, CodeBlock, Callout, TableOfContents, ReadingProgress, Markdown (streaming-aware), MermaidDiagram, MDX component map, ArticleLayout, ArticleCard/Grid, GuideLink/GuideDrawer, and the content-collections + TanStack Start recipe. Mantine-coupled.
4
4
  paths:
5
5
  - 'src/content/**'
6
6
  - 'apps/**/src/**/*content*'
@@ -253,7 +253,7 @@ step 3).
253
253
  surface (`basalt-ui/commands`) — the same tier as `toRouteActions`. It joins title, description,
254
254
  category, tags, and slug into a `keywords` string for substring search; it does not tokenize, rank,
255
255
  index, or persist. An app that outgrows substring matching over a client-side array brings its own
256
- search index — see docs/CONTENT-SPEC.md §9 (Non-goals) in the basalt-ui repo.
256
+ search index — see <https://github.com/jkrumm/basalt-ui/blob/master/docs/CONTENT-SPEC.md> §9 (Non-goals), which is not in the package.
257
257
 
258
258
  `ArticleGrid` stays a dumb layout wrapper — deliberate, so callers keep composition (sorting,
259
259
  filtering, and pagination all happen above it, not inside it).
@@ -406,4 +406,4 @@ const articleActions = toRouteActions(
406
406
 
407
407
  KaTeX/math, full mermaid grammar bundling (d2, pintora), Vega-Lite streamed specs, versioned docs/
408
408
  i18n routing, and a `create-basalt-docs` scaffold are all out of scope for 1.0 — see
409
- `docs/CONTENT-SPEC.md` §9 in the basalt-ui repo for the full non-goals list and rationale.
409
+ <https://github.com/jkrumm/basalt-ui/blob/master/docs/CONTENT-SPEC.md> §9 for the full non-goals list and rationale (not in the package).
@@ -59,6 +59,23 @@ const columns = [
59
59
  | `initialSorting` | `SortingState` | `[]` | Initial sort state; drives `useState` initializer |
60
60
  | `onSortingChange` | `(s: SortingState) => void` | — | Called whenever sorting changes; omit for uncontrolled |
61
61
 
62
+ Body chrome — all conditional pass-throughs to Mantine `Table` except `withTableBorder`, which
63
+ basalt defaults to `true`:
64
+
65
+ | Prop | Type | Notes |
66
+ | --------------------------------------- | ------------------------------- | -------------------------------------------------------------------- |
67
+ | `maxHeight` | `number \| string` | Caps the body height — renders `Table.ScrollContainer type="native"` |
68
+ | `minWidth` | `number \| string` | Same container; `maxHeight` alone passes `minWidth={0}` |
69
+ | `stickyHeader` / `stickyHeaderOffset` | `boolean` / `number \| string` | Forwarded to Mantine; pair with `maxHeight` |
70
+ | `verticalSpacing` / `horizontalSpacing` | `MantineSpacing` | Cell padding |
71
+ | `withRowBorders` / `withTableBorder` | `boolean` | `withTableBorder` defaults `true` here, overriding Mantine's `false` |
72
+ | `meta: { align }` (per column) | `'left' \| 'center' \| 'right'` | Sets `textAlign` on BOTH the `th` and the `td` |
73
+ | `meta: { numeral: false }` (per column) | `boolean` | Opts a numeric cell OUT of the mono-numeral style. Opt-out only |
74
+
75
+ `meta.align` is a `ColumnMeta` module augmentation, so a typo'd key is a tsc error and a value
76
+ outside the union **throws** naming the column — a money column never silently left-aligns. Set it
77
+ once on the column def instead of repeating `textAlign: 'right'` on the header and the cell.
78
+
62
79
  ### Controlled sorting and URL sync
63
80
 
64
81
  `initialSorting` seeds the internal `useState` and `onSortingChange` is called on every sort
@@ -122,18 +139,18 @@ col.display({ id: 'actions', header: 'Actions', cell: (ctx) => <Button>Delete {c
122
139
  `ColumnDef<T>` defaults `TValue` to `unknown`, not `any`. Never cast column defs to `any` — use
123
140
  the correct `ColumnDef<T>` type or `ColumnDef<T, unknown>`.
124
141
 
125
- ### Deferred for later versions
126
-
127
- The 1.0 table ships sorting only. Natural extensions are **not** in scope yet:
142
+ ### Also shipped, and easy to miss
128
143
 
129
- - Filtering (column or global search)
130
- - Pagination (client-side or server-side)
131
- - Row selection (checkboxes)
132
- - Row expansion / sub-rows
144
+ Global search (`enableGlobalFilter`, `globalFilterPlaceholder`, `searchIcon`,
145
+ `initialGlobalFilter`, `onGlobalFilterChange`), faceted filters (`facets`), a toolbar slot
146
+ (`toolbarActions`), pagination client- or server-side (`enablePagination`, `pageSizeOptions`,
147
+ `initialPagination`, `onPaginationChange`, `manualPagination`, `rowCount`, `pageCount`) and column
148
+ pinning (`enablePinning`, `initialColumnPinning`). **Don't hand-roll any of these** — read the props
149
+ on the type, they are all there.
133
150
 
134
- Add them when a concrete consumer need arises; keep the API additive. Controlled (fully external)
135
- sorting for `BasaltDataTable` itself is likewise deferred — `onSortingChange` above is the current
136
- sync seam.
151
+ Still deferred: row selection, row expansion / sub-rows, and fully controlled (external) sorting —
152
+ `onSortingChange` is the sync seam. Add them when a concrete consumer need arises; keep the API
153
+ additive.
137
154
 
138
155
  ### The blessed lane vs the raw escape hatch
139
156
 
@@ -166,9 +183,24 @@ const table = useReactTable({ data: rows, columns, getCoreRowModel: getCoreRowMo
166
183
  // table.getHeaderGroups() / table.getRowModel().rows, rendered with flexRender(...) and your own markup
167
184
  ```
168
185
 
169
- Reach for the escape hatch only when `BasaltDataTable`'s props genuinely can't express the shape —
170
- grid feature growth (pagination, server-side data, column visibility, filtering) is consumer-pulled
171
- future work, not a reason to bypass the blessed lane by default.
186
+ Reach for the escape hatch only when `BasaltDataTable`'s props genuinely can't express the shape.
187
+ Pagination, server-side data and filtering are **not** among those cases any more — see above.
188
+
189
+ **Both lanes scroll through the same node.** An escape-hatch table that needs a capped body wraps
190
+ it in `<Table.ScrollContainer type="native" minWidth={0} maxHeight={320}>` — and that is byte for
191
+ byte what `BasaltDataTable`'s `maxHeight`/`minWidth` render. The blessed lane and the escape cannot
192
+ contradict each other on scrolling, and neither should reach for `type="scrollarea"`: `ScrollArea`'s
193
+ custom viewport is the positioning context a sticky `<thead>` resolves against, so the default type
194
+ pins the header to the page viewport instead of the table's box. (`basalt/raw-scroll-container`
195
+ takes no view here — it steers raw `overflow: auto`, and `Table.ScrollContainer` is a Mantine
196
+ component, not a raw scroll box.)
197
+
198
+ **Adopt the blessed lane for ownership, not for a line count.** Porting one consumer's three tables
199
+ onto these props made them 341 → **370** lines, 29 longer: eight accessor blocks at 4–6 lines each
200
+ cost more than an eight-`<Table.Td>` row at ~3 when every cell is bespoke. What the port bought was
201
+ the `type="native"` footgun, alignment stated once instead of on both `th` and `td` six times over,
202
+ and sorting/filtering/pagination no longer being yours to maintain. Expect that trade, not a
203
+ shrink.
172
204
 
173
205
  ## BasaltVirtualList
174
206
 
@@ -30,15 +30,15 @@ renders.
30
30
 
31
31
  - Use `createBasaltTheme(overrides?)` for the theme — it merges your overrides over `baseTheme`
32
32
  (last-wins), so you can retune any field without forking. Don't hand-build `createTheme`.
33
- - The theme reskins **every** Mantine accent to the modern-zinc identity (`docs/DESIGN-SPEC.md` in
34
- the basalt-ui repo) —
33
+ - The theme reskins **every** Mantine accent to the modern-zinc identity (long form:
34
+ <https://github.com/jkrumm/basalt-ui/blob/master/docs/DESIGN-SPEC.md>, not shipped in the package) —
35
35
  **cool-neutral zinc** ramps on both light and dark — plus a **single saturated sky-blue accent**
36
36
  — split by role: as INK (links, active-nav icon, chart lines, focus ring) `#0077bd` light /
37
37
  `#8ec5ff` dark; as a FILLED SURFACE `#0077bd` in BOTH schemes with a white label — and binds
38
38
  Mantine's surface vars AND its color families to the same `--vx-*` tokens
39
39
  the charts use (`cssVariablesResolver`, pre-wired in `BasaltProvider`), so chrome and charts share
40
40
  one scheme-reactive identity. (Blueprint / Basalt zinc-charcoal are the historical hue-tuning
41
- ancestors; `docs/DESIGN-SPEC.md` in the basalt-ui repo supersedes both.) Don't add a second
41
+ ancestors; DESIGN-SPEC supersedes both.) Don't add a second
42
42
  `cssVariablesResolver`.
43
43
  - Color scheme: read/write via `useMantineColorScheme()`. **Never** read the color scheme from
44
44
  localStorage directly, and never `localStorage.getItem('theme')` — see basalt-state.md.
@@ -220,7 +220,7 @@ shape doctrine lives here; the spacing/radius/type **tokens** are in basalt-toke
220
220
  Mechanically enforced by `basalt-ui check-theme`'s `raw-surface` guard. Outer spacing comes from the
221
221
  parent `Stack`/`SimpleGrid` gap, not an intrinsic card margin.
222
222
  - **Depth = a whisper shadow with a 1px ring baked in, not a plain hairline** (see
223
- `docs/DESIGN-SPEC.md` in the basalt-ui repo, doctrine inversion #1 — this supersedes the old
223
+ <https://github.com/jkrumm/basalt-ui/blob/master/docs/DESIGN-SPEC.md>, doctrine inversion #1 — this supersedes the old
224
224
  "never a drop shadow" rule). **Three depth tiers, split control-vs-panel-vs-floating:**
225
225
  - **`shadow-card` — panels.** A surface that HOLDS content: `Card`, `Paper`, `Notification`,
226
226
  `ChartCard`, `SettingsSection`. Its light-mode ring is an **outset** 1px hairline; dark is a