basalt-ui 1.29.2 → 1.30.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 (116) hide show
  1. package/CHANGELOG.md +9 -0
  2. package/MIGRATING.md +693 -368
  3. package/README.md +23 -2
  4. package/agent/rules/basalt-controls.md +34 -34
  5. package/agent/skills/basalt-app/SKILL.md +4 -2
  6. package/agent/templates/query-client.ts.tpl +18 -4
  7. package/configs/lefthook.yml +51 -19
  8. package/configs/oxlint-plugin.js +210 -84
  9. package/configs/oxlint-plugin.test.ts +182 -5
  10. package/configs/oxlint.json +4 -4
  11. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  12. package/dist/charts/primitives/ChartCard.js +2 -2
  13. package/dist/charts/primitives/ChartFrame.d.ts +23 -3
  14. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  15. package/dist/charts/primitives/ChartFrame.js +4 -4
  16. package/dist/charts/primitives/chart-frame-layout.d.ts +55 -0
  17. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -1
  18. package/dist/charts/primitives/chart-frame-layout.js +16 -0
  19. package/dist/cli/check-theme.d.ts.map +1 -1
  20. package/dist/cli/check-theme.js +6 -0
  21. package/dist/cli/doctor.d.ts +10 -4
  22. package/dist/cli/doctor.d.ts.map +1 -1
  23. package/dist/cli/doctor.js +28 -1
  24. package/dist/cli/help.d.ts.map +1 -1
  25. package/dist/cli/help.js +2 -1
  26. package/dist/cli/index.d.ts +12 -0
  27. package/dist/cli/index.d.ts.map +1 -1
  28. package/dist/cli/index.js +29 -0
  29. package/dist/cli/init.d.ts.map +1 -1
  30. package/dist/cli/init.js +4 -15
  31. package/dist/cli/sync.d.ts.map +1 -1
  32. package/dist/cli/sync.js +8 -14
  33. package/dist/controls/actions.d.ts.map +1 -1
  34. package/dist/controls/actions.js +12 -2
  35. package/dist/dashboard/section.module.css +15 -7
  36. package/dist/dashboard/stat-card.d.ts.map +1 -1
  37. package/dist/dashboard/stat-card.js +7 -2
  38. package/dist/dashboard/stat-group.d.ts +8 -2
  39. package/dist/dashboard/stat-group.d.ts.map +1 -1
  40. package/dist/dashboard/stat-group.js +23 -15
  41. package/dist/dashboard/stat-group.module.css +85 -9
  42. package/dist/dashboard/stat-group.module.css.d.ts +1 -0
  43. package/dist/dashboard/widget-grid.d.ts.map +1 -1
  44. package/dist/dashboard/widget-grid.js +4 -5
  45. package/dist/dashboard/widget-grid.module.css +64 -21
  46. package/dist/dashboard/widget-grid.module.css.d.ts +1 -0
  47. package/dist/guard/index.d.ts +19 -2
  48. package/dist/guard/index.d.ts.map +1 -1
  49. package/dist/guard/index.js +27 -4
  50. package/dist/index.d.ts +9 -0
  51. package/dist/index.d.ts.map +1 -1
  52. package/dist/index.js +2 -0
  53. package/dist/shell/app-brand.module.css +12 -3
  54. package/dist/shell/app-header.module.css +49 -9
  55. package/dist/shell/app-main.module.css +24 -4
  56. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  57. package/dist/shell/app-mobile-nav.js +98 -51
  58. package/dist/shell/app-mobile-nav.module.css +99 -11
  59. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  60. package/dist/shell/app-sidebar.module.css +55 -2
  61. package/dist/shell/index.d.ts.map +1 -1
  62. package/dist/shell/index.js +3 -3
  63. package/dist/shell/mobile-nav-model.d.ts +26 -7
  64. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  65. package/dist/shell/mobile-nav-model.js +12 -3
  66. package/dist/shell/page-aside.module.css +8 -6
  67. package/dist/shell/page-bar.d.ts +13 -0
  68. package/dist/shell/page-bar.d.ts.map +1 -1
  69. package/dist/shell/page-bar.js +13 -3
  70. package/dist/shell/page-bar.module.css +20 -12
  71. package/dist/shell/page-title.module.css +3 -1
  72. package/dist/theme/use-basalt-spacing.d.ts.map +1 -1
  73. package/dist/tokens/index.d.ts.map +1 -1
  74. package/dist/tokens/index.js +51 -7
  75. package/dist/tokens/palette.d.ts +98 -17
  76. package/dist/tokens/palette.d.ts.map +1 -1
  77. package/dist/tokens/palette.js +110 -18
  78. package/dist/tokens.css +17 -5
  79. package/llms.txt +2 -2
  80. package/package.json +1 -1
  81. package/src/charts/primitives/ChartCard.tsx +12 -7
  82. package/src/charts/primitives/ChartFrame.tsx +30 -15
  83. package/src/charts/primitives/chart-frame-layout.ts +86 -0
  84. package/src/cli/check-theme.ts +13 -0
  85. package/src/cli/doctor.ts +49 -8
  86. package/src/cli/help.ts +5 -3
  87. package/src/cli/index.ts +49 -0
  88. package/src/cli/init.ts +6 -27
  89. package/src/cli/sync.ts +11 -22
  90. package/src/controls/actions.tsx +51 -0
  91. package/src/dashboard/section.module.css +15 -7
  92. package/src/dashboard/stat-card.tsx +7 -2
  93. package/src/dashboard/stat-group.module.css +85 -9
  94. package/src/dashboard/stat-group.module.css.d.ts +1 -0
  95. package/src/dashboard/stat-group.tsx +40 -20
  96. package/src/dashboard/widget-grid.module.css +64 -21
  97. package/src/dashboard/widget-grid.module.css.d.ts +1 -0
  98. package/src/dashboard/widget-grid.tsx +30 -12
  99. package/src/guard/index.ts +79 -11
  100. package/src/index.ts +9 -0
  101. package/src/shell/app-brand.module.css +12 -3
  102. package/src/shell/app-header.module.css +49 -9
  103. package/src/shell/app-main.module.css +24 -4
  104. package/src/shell/app-mobile-nav.module.css +99 -11
  105. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  106. package/src/shell/app-mobile-nav.tsx +225 -83
  107. package/src/shell/app-sidebar.module.css +55 -2
  108. package/src/shell/index.tsx +26 -6
  109. package/src/shell/mobile-nav-model.ts +37 -9
  110. package/src/shell/page-aside.module.css +8 -6
  111. package/src/shell/page-bar.module.css +20 -12
  112. package/src/shell/page-bar.tsx +39 -5
  113. package/src/shell/page-title.module.css +3 -1
  114. package/src/theme/use-basalt-spacing.ts +8 -6
  115. package/src/tokens/index.ts +90 -6
  116. package/src/tokens/palette.ts +111 -18
package/MIGRATING.md CHANGED
@@ -13,9 +13,22 @@ on was written against source, not against its commit messages — which is not
13
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
- **The newest section is headed `## Unreleased`, and stays that way until npm serves it.** This file
17
- is written before `semantic-release` picks the number, so a number written here is a guess — and it
18
- was wrong three rounds running.
16
+ **The newest section is headed `## Unreleased`, and the RELEASE renames it to its own number.**
17
+ This file is written before `semantic-release` picks the number, so a number written here is a guess
18
+ — and it was wrong three rounds running. The rename is therefore a release-time step, not an
19
+ authoring-time one: the section is authored as `## Unreleased — <title>`, and the release swaps the
20
+ word `Unreleased` for the computed version, leaving the title alone; a fresh `## Unreleased` opens
21
+ above it. Doing it by hand is what failed three times running — `.releaserc.json`'s `prepareCmd`
22
+ already rewrites `package.json` and regenerates `llms.txt` at exactly that moment, so that is where
23
+ the swap belongs.
24
+
25
+ **Skipping that step is what this file's worst defect was.** 1.27.0, 1.28.0 and 1.29.0 all shipped
26
+ under one `## Unreleased` heading — ~1000 lines, two of its subsections still titled "targeting
27
+ 1.29.0" weeks after 1.29.0 was on npm. Four of five consumer migrations off 1.25.0/1.27.0 named it
28
+ their top finding independently: a reader cannot tell which minor introduced which change, and
29
+ "read every section between your version and the target" is unusable when the last three sections
30
+ claim not to be between them. One of them shipped a real defect through the gap. Restored to
31
+ per-minor sections below; keep it that way.
19
32
 
20
33
  **No majors, by policy.** A rename or a removal ships as a plain `feat:` on the 1.x line, so a minor
21
34
  bump can require code changes. Skipping several at once is the expensive case — read every section
@@ -28,43 +41,460 @@ between your version and the target.
28
41
  1.10.0, 1.13.0, 1.14.0, 1.16.0, 1.18.0 — and every patch. Additive-only subpaths: `./tokens.css`
29
42
  at 1.3.0, `./agent-chat` at 1.10.0.
30
43
 
44
+ ## What changed, by surface
45
+
46
+ **Read your row, then the sections it names — not the whole file.** A `tokens-only` consumer read
47
+ all 2294 lines of the previous revision to learn that four minors moved two things in the token
48
+ layer; this index is that answer in one line. It is a POINTER, not a second copy: every cell names
49
+ the section that carries the detail, and a surface with no row in a minor had no delta in it. The
50
+ `Unreleased` column takes the version number at the same moment the section below it does, and a new
51
+ column is added at the same time — one edit, in the release commit.
52
+
53
+ | Surface | 1.27.0 | 1.28.0 | 1.29.0 | Unreleased |
54
+ | --------------------------- | ------------------------------------------- | -------------------------------------------------------------------------------------- | -------------------------------------------------------------------------- | ----------------------------------- |
55
+ | **tokens** (`./tokens`) | — | `--vx-divider` → `rgba()` on both schemes (§ Shell) | `--vx-space-touch-target` added (§ Additions) | eight spacing numbers (§ Chrome) |
56
+ | **charts** (`./charts`) | — | phone tier, `curve`, formatters, `state`, log axis (§ preamble) | `ChartState.empty: string`, `AxisBottomNumeric` (§ Additions) | — |
57
+ | **shell** (`.`) | — | scrollport, region seams, **the brand left the sidebar** (§§ scrollport, brand, Shell) | `PageTitle`, `BasaltDevDock`, `useBreakpoint` (§ Additions) | More popover, aside pill (§ Chrome) |
58
+ | **controls** (`./controls`) | `NumberFilter`, `field.number` (§ Controls) | sheet renders panel rows; `PanelRow`, `SliderControl` (§ `basalt-ui/controls`) | — | — |
59
+ | **forms** (`./forms`) | — | `field` → `inputProps`, **`inputProps` drops `key`**, the layout tier (§ `inputProps`) | `field` DELETED — and the `--fix` recipe no longer works (§ Consolidation) | — |
60
+ | **data** (`./data/table`) | — | root is a `<div>`, row selection, `getItemKey` required (§ `basalt-ui/data`) | `./data` + `./query` + `./connectivity` dropped (§ Consolidation) | — |
61
+ | **CLI** (`basalt-ui`) | — | — | **resolver stops relocating; `doctor` loses 6 of 10 checks** (§ CLI) | — |
62
+ | **guards** | five rules → `error` (§ Guards) | six new ids at `warn` (§ preamble) | `query-dual-import` retired in 1.29.1 (§ Guards) | four rules → `error` (§ Guards) |
63
+
31
64
  ---
32
65
 
33
- ## Unreleased
66
+ ## Unreleased — the chrome wave
67
+
68
+ **Mostly CHROME, which removes and renames nothing: eight spacing numbers move, the mobile More
69
+ surface becomes a compact popover instead of a bottom sheet, and the aside's phone pill takes the
70
+ aside's own title.** Read § Chrome for the table and the opt-outs before you diff a screenshot.
71
+
72
+ **The one thing that can fail your build: four guard rules promote from `warn` to `error`** — and
73
+ all four were measured at ZERO incumbents across every consumer in the fleet before the promotion,
74
+ so the expected cost is nothing. § Guards has the measurement and the one rule that did NOT
75
+ promote.
76
+
77
+ ### Chrome — the spacing pass, the More popover, and the aside's pill
78
+
79
+ **A VISUAL change, shipping as a plain `feat:` on the 1.x line.** Nothing is removed, nothing is
80
+ renamed and no call site has to change — but eight spacing numbers, the mobile More surface and one
81
+ pill label all move, so an app pinned to a screenshot will see a diff. Majors are banned here
82
+ (`feat!:` and `BREAKING CHANGE:` footers are forbidden), so the version number cannot carry that
83
+ warning and this section is it. Each behaviour change below names its opt-out.
84
+
85
+ **Eight spacing numbers** — six moved, two new, all level-0 values and all density-tracked
86
+ (`tokens/palette.ts`; every entry carries its own reasoning, and `theme/spacing.test.ts`'s file
87
+ header carries the wave's). One report drove all eight: the sidebar and header band read as small
88
+ controls floating in dead air while the page content sat too close to the seam.
89
+
90
+ | Token | Was | Now | What it sizes |
91
+ | ------------------------ | ------- | --- | ------------------------------------------------------------------------------------------------------------------ |
92
+ | `controlHeightCtl` | 30 | 32 | the `ctl` tier — the whole chrome tier lands on ONE number now |
93
+ | `appShellHeaderHeight` | 48 | 44 | the app-header band, one token tall at every viewport |
94
+ | `pageBarRowHeight` | 40 | 36 | `PageBar` row 2's band — the secondary band stays a clear step under the primary |
95
+ | `sidebarSectionGap` | 12 | 16 | between nav sections |
96
+ | `sidebarSectionLabelGap` | 2 | 6 | a section label to its own list. The proximity pair was INVERTED, so every label read as heading the list ABOVE it |
97
+ | `sidebarRegionGap` | 10 | 12 | between the sidebar's regions — now visibly bigger than the gaps inside one |
98
+ | `appShellInset` | _(new)_ | 20 | the page gutter from `sm` up, replacing the `SPACE_SCALE.sm` (13) the main column used to borrow |
99
+ | `appShellInsetMobile` | _(new)_ | 8 | the page gutter below `sm` — at 13 a phone spent 6.7% of a 390px viewport on margin before a card was drawn |
100
+
101
+ Both new entries ARE emitted as CSS variables — `--vx-space-app-shell-inset` /
102
+ `--vx-space-app-shell-inset-mobile`, unlike their JS-only `appShell*` neighbours. **The page gutter
103
+ is now RESPONSIVE**: `BasaltShell` hands Mantine `padding={{ base: 8, sm: 20 }}` and gives
104
+ `AppShell.Header` the same pair, both stepping at `theme.breakpoints.sm`, so the breadcrumb's left
105
+ edge, the global actions' right edge and the page's first column share one gutter at every width —
106
+ and above `sm` they land on the vertical line the sidebar's nav icons already sat on.
107
+
108
+ **The opt-out, and its one sharp edge.** These are structural theme tokens, so the lever is
109
+ `createBasaltTheme(overrides, { density })` — an integer level that moves the whole scale by the
110
+ law — never a per-token override. Re-declaring `--vx-space-app-shell-inset` in your own CSS restyles
111
+ whatever READS that var, but NOT the shell's own gutter: that value reaches Mantine as a JS number
112
+ through `useBasaltSpacing()`, deliberately — a Mantine spacing KEY would resolve back to
113
+ `var(--mantine-spacing-sm)` and hand the gutter straight back to the 13 this replaces.
114
+
115
+ - **`useBasaltSpacing` is on the root barrel** (`basalt-ui`) — the derived spacing objects behind
116
+ those numbers, for a consumer computing geometry that has to track `density` rather than pinning a
117
+ literal. Additive; it was already the shell's own reader.
118
+
119
+ **The mobile More surface is a compact POPOVER, not a bottom sheet**, and `MOBILE_MENU_MAX_DEFAULT`
120
+ rises 6 → 12. `projectMobileNav` is unchanged in shape — the surface is still INFERRED from row
121
+ count (0 drops the slot, 1 is a plain link, ≤ `menuMax` is a `menu`, past it a `sheet`) — but a
122
+ 7-to-12-row slot now lands on `menu` where it used to raise a full-width Drawer. **The
123
+ never-below-the-fold guarantee moved off the constant and onto CSS**: the dropdown declares
124
+ `max-height: min(72dvh, 560px)` with `overflow-y: auto`, which binds at ANY row count, so the
125
+ constant is a taste bound on how tall one popover may get rather than the arithmetic it claimed to
126
+ be (that arithmetic — "6 × 44px against 415px of headroom" — was stale in nearly every term; the
127
+ rows are 40px). `menuMax` survives for the nav that has genuinely outgrown a popover. **Opt-out:
128
+ `mobileNav.menuMax`**, the unchanged prop — set it to `6` to restore the old cut-off, and it still
129
+ moves the line in both directions.
130
+
131
+ Riding along on the bar, all visible with no code change:
132
+
133
+ - **The bar paints `--vx-surface-panel`.** It used to resolve to `--vx-surface-bg` — the exact fill
134
+ the scrollport above it paints — so the only thing separating the bar from the page was the region
135
+ seam's 9%-alpha hairline, which is invisible on a phone. No new token, no shadow, no `withBorder`.
136
+ - **The dead taps.** Re-tapping the active tab scrolls to top and now REPORTS whether anything
137
+ actually scrolled, so an already-at-top retap navigates instead of doing nothing; `menuRow` and
138
+ `sheetRow` adopt the link row's activation rule (`aria-current`, the disabled guard) instead of
139
+ each answering it differently; a disabled row no longer sets `pointer-events: none`, which
140
+ swallowed the tap with no press state; the More tab is a real toggle, so a second tap closes it;
141
+ and a sheet's overlay stops being hit-testable over the other tabs after `onClose`.
142
+
143
+ **`PageBar`'s phone aside pill carries the aside's own `title`**, not the literal word `Panel`
144
+ (`docs/ASIDE-SPEC.md` §0 — the title names the CONTENT, never the region, which is the rule the
145
+ desktop header already obeyed). The separate `ariaLabel` went with it: it existed only to put the
146
+ real title back for a screen reader, and once the visible label IS the title the two said the same
147
+ thing twice. No opt-out and none is needed — the pill takes no label prop — but a test asserting the
148
+ string `Panel` now reads whatever your `PageAside` names itself. Still no count: an aside's children
149
+ are not a `FilterSet`, so unlike `Filters (n)` there is no census to derive one from.
150
+
151
+ - **One new dev warning, no behaviour change.** Below `sm` a `PageAside` can only project into
152
+ `PageBar` row 2, and that row exists ONLY when the bar has `tabs`, `filters` or a non-empty
153
+ `filtersEnd`. A page whose bar carries `actions` alone therefore renders the aside in flow at the
154
+ bottom of the page — deliberate, pinned by tests, and invisible on the desktop it was authored
155
+ on, so `PageAside` now says it once in dev. The remedy is to give that bar a row 2.
156
+
157
+ ### Guards — four rules promote to `error`, and the C1 pair does not
158
+
159
+ Every one of these six entries said in its own `why` that this release "is when the incumbent count
160
+ across argo, linewatch, image-share, rb, image-gen and the playground is re-measured" (law C16).
161
+ That measurement was taken 2026-09-09, with all seven consumers freshly migrated to 1.29.2 and
162
+ basalt built from `master`, by running `npx oxlint .` in each repo and counting by rule id:
163
+
164
+ | Rule | argo | image-gen | image-share | rb | linewatch | meteo | obsidian | playground | Verdict |
165
+ | ----------------------------------------------------------------- | ---- | --------- | ----------- | --- | --------- | ----- | -------- | ---------- | ------------ |
166
+ | `basalt/provider-above-router` | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | → `error` |
167
+ | `basalt/forms-field-key` | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | → `error` |
168
+ | `basalt/query-fn-unwrap` | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | → `error` |
169
+ | `basalt/bound-control-outside-home` | 0 | 0 | 0 | 0 | 0 | 0 | 0 | 0 | → `error` |
170
+ | `basalt/control-outside-home` + `raw-selection-control` (one law) | 2 | 10 | 6 | 8 | 0 | 0 | 0 | 3 | stays `warn` |
171
+
172
+ **The four promotions cost nothing to take.** They were measured at zero, in every consumer, before
173
+ being flipped — which is the entire point of a grace window: it is a period in which the count is
174
+ allowed to fall to zero on its own, not a timer that fires regardless. If one of them fires in your
175
+ app, it is finding something none of the fleet had, and the message names the remedy.
176
+
177
+ **The C1 pair is extended to 1.31.0, and the reason is not the count.** 29 incumbents is a large
178
+ number, but the argument is that **this release is the first in which either lane names a home those
179
+ 29 can reach.** Every home both messages listed was a shell slot — `PageBar` / `Section` /
180
+ `WidgetHeader` — so a consumer mounting `BasaltProvider` and no `BasaltShell` had no reachable home
181
+ at all: image-gen is a Tauri window with a hand-rolled header and carries 10 of the 29 by itself,
182
+ and `basalt/bound-control-outside-home` closed the `ViewTabs` escape it would otherwise have taken.
183
+ This release adds the missing sentence to both messages — a `PageBar` outside a shell renders in
184
+ flow, sticky, with its own `title`, which is what `PageBar` has always actually done — so the
185
+ remedy exists from now, not before. Promoting a rule to `error` in the same minor that first makes
186
+ it satisfiable hands you zero minors to act on the answer. **1.31.0 is when those 29 are measured
187
+ against this number.**
188
+
189
+ Riding along, and the reason a single waiver now works: `control-outside-home` (the AST lane) and
190
+ `raw-selection-control` (the text lane) are one law with two rule ids, and until now
191
+ `theme-allow control-outside-home` parsed in the text lane and waived nothing there. A waiver naming
192
+ either id now covers both, at line scope and at `theme-allow-file` scope, in both directions.
193
+
194
+ ## 1.29.2 — `unwrap` over a union-typed Eden response
195
+
196
+ A patch, one export's TYPE only. Nothing renamed, nothing removed.
34
197
 
35
- **Nothing removed, and ONE thing changed shape: `inputProps` no longer returns `key`** — a silent
36
- behaviour change at every field call site, caught by the new `basalt/forms-field-key` and described
37
- in its own § below. Read that one before upgrading; the rest of the minor is additive, with
38
- enforcement tightened: five oxlint rules plus one guard
39
- kind became `error`, and new rule ids shipped at `warn`. The store half of this minor also
40
- fixed four inference and notification defects consumers hit while porting, plus two ways a fallback
41
- got pinned into localStorage — see § Stores below. Every level change honours the `theme-allow`
42
- grammar unchanged; only the severity of an unwaived finding moves.
198
+ - **`unwrap` strips `null`/`undefined` from a union-typed Eden response again** — the single
199
+ conditional signature 1.29.1 landed still inferred `TData` off `Envelope<infer TData>`, and an Eden
200
+ Treaty response is a UNION of envelopes (`{ data: T; error: null } | { data: null; error:
201
+ EdenFetchError }`), not one envelope with a nullable `data`; inferring through the wrapper type
202
+ read `T | null` at every Eden call site. `UnwrapResult` now infers the raw `data` field and wraps
203
+ it in `NonNullable`, which distributes over a union `R` — `unwrap(await api.x.get())` resolves to
204
+ `T` again, with no `| null`. Runtime unchanged.
43
205
 
44
- **One behaviour change with a blast radius past this package: `AppShell.Main` is now the
45
- scrollport, and the WINDOW no longer scrolls inside a `BasaltShell`.** Nothing was removed or
46
- renamed, but four things a consumer may be doing stop working, and all four have a one-line remedy —
47
- see § The shell scrollport below.
206
+ ## 1.29.1 — the DevDock router prop, `unwrap` as a callback, one retired guard
48
207
 
49
- What is new, one line each — nothing here renames or removes anything:
208
+ A patch. Two type corrections to exports 1.29.0 introduced, and one lint rule retired
209
+ because 1.29.0 made its premise unreachable — see § Guards below.
50
210
 
51
211
  - **`BasaltDevDock`'s `router` prop now accepts a real TanStack `Router` instance with no cast** —
52
212
  the prior structural stand-in rejected a class instance under TS weak-type detection (no property
53
213
  overlap, no index signature), forcing `router={router as never}`. It is now `router?: object`:
54
214
  any non-null object is assignable, and the devtools panel remains the only reader.
55
215
  - **`unwrap` collapses to ONE generic signature with a conditional return**, replacing the two
56
- overloads this same minor introduced. The overload pair resolved `unwrap` to `unknown` whenever it
216
+ overloads 1.29.0 introduced. The overload pair resolved `unwrap` to `unknown` whenever it
57
217
  was passed as a bare callback (`api.threads.get().then(unwrap)`) — `.then()` reads the declared
58
218
  type of the reference rather than applying an overload, and TS cannot propagate generic inference
59
219
  across an overload set the way it can for a single signature. `.then(unwrap)` now infers `TData`
60
220
  again, alongside `unwrap(await p)` and `unwrap(p)` — nothing about the runtime behaviour changed.
61
- - **`unwrap` strips `null`/`undefined` from a union-typed Eden response again** — the single
62
- conditional signature above still inferred `TData` off `Envelope<infer TData>`, and an Eden
63
- Treaty response is a UNION of envelopes (`{ data: T; error: null } | { data: null; error:
64
- EdenFetchError }`), not one envelope with a nullable `data`; inferring through the wrapper type
65
- read `T | null` at every Eden call site. `UnwrapResult` now infers the raw `data` field and wraps
66
- it in `NonNullable`, which distributes over a union `R` — `unwrap(await api.x.get())` resolves to
67
- `T` again, with no `| null`. Runtime unchanged.
221
+
222
+ ### Guards — `basalt/query-dual-import` removed
223
+
224
+ **Retired, not merely relaxed** — its `RETIRED_RULE_IDS` entry (kept recognized, so an existing
225
+ `theme-allow query-dual-import` still reads as a dead waiver rather than a typo) and its
226
+ `PLUGIN_RULE_GRACE` ledger row are both deleted. Its whole premise — a raw `@tanstack/react-query`
227
+ import beside `basalt-ui/query` — went unreachable the moment `./query` dropped (see § Consolidation
228
+ in 1.29.0 below): a consumer now MUST import `@tanstack/react-query` directly, and the rule warned on every
229
+ one of those imports instead of the drift it was written to catch (90+ false positives on a single
230
+ consumer). `query-fn-unwrap` is unaffected — it is a different rule, still `warn` (grace → 1.30.0).
231
+
232
+ ## 1.29.0 — the consolidation: three subpaths dropped, every shim deleted, the CLI narrowed
233
+
234
+ **The removal minor.** Three published subpaths dropped, every `@deprecated` shim in the package
235
+ deleted at once, and the CLI narrowed to what a consumer actually runs — plus one additive wave (C5)
236
+ whose whole point is code you get to delete. Two of the three most expensive findings in the 1.25.0
237
+ → 1.29.2 fleet migration came out of this minor and neither had a row of its own: the CLI resolver
238
+ change (§ CLI below) and the `field` deletion breaking this file's own `--fix` recipe
239
+ (§ Consolidation below). Both have one now.
240
+
241
+ ### Consolidation — three subpaths dropped, every `@deprecated` shim deleted
242
+
243
+ **Dropped subpaths — three, all folded into `.`/`./provider`, none deleted outright.**
244
+
245
+ <!-- C1 symbol list -->
246
+
247
+ - `./query` — dropped. `createBasaltQueryClient`, `unwrap`, `BasaltQueryDevtools`,
248
+ `toErrorMessage`, `errorStatus` now on `.` (root barrel) — import from `basalt-ui` instead of
249
+ `basalt-ui/query`. The nine raw TanStack re-exports it carried (`QueryClientProvider`,
250
+ `QueryErrorResetBoundary`, `useQueryErrorResetBoundary`, `useQuery`, `useSuspenseQuery`,
251
+ `useMutation`, `useQueryClient`, `useInfiniteQuery`, `queryOptions`) are gone from basalt entirely —
252
+ import them from `@tanstack/react-query`, which the root entry already requires.
253
+ - `./data/table` — **narrowed, not dropped**: `BasaltDataTable`, its types and `createColumnHelper`
254
+ stay; the TanStack pass-throughs (`useReactTable`, `flexRender`, `getCoreRowModel`,
255
+ `getSortedRowModel`, `getFilteredRowModel`, `getPaginationRowModel`, `ColumnDef`, `SortingState`,
256
+ `ColumnHelper`, `PaginationState`, `ColumnPinningState`, `RowSelectionState`, `ColumnFiltersState`)
257
+ are imported from `@tanstack/react-table` directly.
258
+ - `./connectivity` — dropped. `ConnectivityProvider` (auto-mounted by `BasaltProvider`, no
259
+ consumer-side import needed), `useConnectivity`, `ConnectivityIndicator` now on `.`.
260
+ - `./data` (bare) — dropped. It never carried its own exports beyond re-exporting `./data/table`
261
+ and `./data/virtual` — import the narrow subpath you need directly; nothing renamed.
262
+ - `./controls-dates` — **unaffected**, stays a separate subpath (inlining it into `./controls`
263
+ would pull `@mantine/dates` into a subpath that must resolve without it).
264
+
265
+ **Deleted `@deprecated` shims** (every one slated for 1.29.0 removal in an earlier minor's
266
+ deprecation row — see that minor's own `MIGRATING.md` section for the original `@deprecated`
267
+ annotation and rationale):
268
+
269
+ - `state.ts`'s legacy connectivity export — use `./provider`'s `useConnectivity` instead.
270
+ - `BasaltProvider`'s flat `sseUrl` / `healthUrl` / `healthIntervalMs` props — use
271
+ `connectivity={{ sseUrl, healthUrl, healthIntervalMs }}`.
272
+ - `QueryStateVariant` and the `variant` prop on `QueryState`/`LoadingState`/`ErrorState`/`EmptyState`
273
+ — use `QueryStateTier` and `tier`. **Identical values, identical branch behaviour**, verified by
274
+ diffing `src/dashboard/query-state.tsx` at `v1.25.0` against 1.29.2: the union is `'page' |
275
+ 'section'` in both, and `'section'` takes the same bare/compact branch in both. A pure rename, so a
276
+ `variant="section"` becomes `tier="section"` with nothing to eyeball afterwards.
277
+ `QueryStateTier`'s own JSDoc says "`'section'` meant two different things across the two
278
+ spellings" — that is the NAMING rationale for picking `tier` (audit B #19), not a behaviour
279
+ warning, and it has been read as one twice: once by a fleet audit that told consumers to "eyeball
280
+ the rendered tier once after the change", once by a migration agent that stopped to diff the
281
+ sources. It does not describe a value-semantics change.
282
+ - `createSearchParamStore` / `createMultiSearchParamStore` (`basalt-ui/router-tanstack`) and the
283
+ legacy branches they kept alive in the field vocabulary — use `createSearchStore` with
284
+ `field.enum` / `field.multi`.
285
+ - `field` on `basalt-ui/forms` — use `inputProps(form, path)` + `fieldKey(form, path)`.
286
+ **The two-pass `oxlint --fix` recipe in 1.28.0's § `inputProps` DOES NOT WORK if you land here.**
287
+ Pass 1 (`basalt/deprecated-export` autofixing `field` → `inputProps as field`) never fires: the
288
+ row leaves `DEPRECATED_EXPORTS` in the same minor the export does, so at 1.29.x there is nothing
289
+ deprecated to nudge. Pass 2 (`basalt/forms-field-key`) reports the `field(` spread but does not
290
+ autofix it — its fixer only handles an `inputProps(` spread missing a `key`. So: **coming from
291
+ 1.28.x the recipe holds; coming from 1.27.x or earlier, every call site is a hand edit.** It is
292
+ two edits per field, and `field` at least fails to compile, unlike the `key` half:
293
+
294
+ ```tsx
295
+ /* 1.27 */ <TextInput {...field(form, 'email')} />
296
+ /* 1.29 */ <TextInput key={fieldKey(form, 'email')} {...inputProps(form, 'email')} />
297
+ ```
298
+
299
+ Find them all: `rg -n '\bfield\(\s*\w*[Ff]orm\b' --type ts` (`--type ts` covers `.tsx`).
300
+
301
+ - `BasaltNotifications` / `BasaltNotificationsProps` (`basalt-ui/notifications`) — mount
302
+ `<BasaltOverlays notifications />` from `basalt-ui/commands` instead. **Doctrine exception,
303
+ recorded**: this one never carried a `deprecated-export` row; it was superseded in 1.2x and its own
304
+ JSDoc warned against mounting it next to `BasaltOverlays`, so it leaves in the same minor as the
305
+ other shims. The `duplicate-notifications-mount` lint rule that only existed to catch the
306
+ double-mount is retired with it.
307
+ - `ConnectivityIndicatorProps` stays on the root barrel (it is `BasaltProps`).
308
+
309
+ - `ZonedLine`/`Bars`' `ZoneSpec` aliases — use the canonical `AxisConfig`-scoped zone type each
310
+ kind now exports directly.
311
+ - `inputProps`'s `key` return field (already shape-changed in 1.28.0 — see § `inputProps` no longer
312
+ returns `key` there) — fully removed this minor; use `fieldKey(form, name)` alongside
313
+ `inputProps(form, name)`.
314
+
315
+ **On notice for 1.30.0** — a public export with exactly one consumer (the playground) as of this
316
+ audit. Adopt-or-delete: gains a real consumer by 1.30.0 or is removed. Not a promise either way;
317
+ record your adoption in `docs/ARGO-MIGRATION-LEARNINGS.md` if you pick one up.
318
+
319
+ - **Threads/agent-chat composite tier**: `ThreadWorkspace`, `ThreadFeed`, `ThreadFeedRow`,
320
+ `ThreadDetailPanel`, `ThreadOutcomeCard`, `ThreadsStoreAdapter`, `createThreadsStore`,
321
+ `createAdapterThreadsStore`, `threadsStoreAdapterContract`, `threadPartRenderers`,
322
+ `definePartRenderers`.
323
+ - **Chart kind**: `Heatmap` only — `DualPanel` has an argo consumer and `MirroredBars`/`BandStrip`
324
+ are consumed by linewatch, so those three are NOT on notice.
325
+ - **Nav/shell**: `PageAside`, `PanelRow`, `SettingsRow` (playground-only as of this audit).
326
+ - **Controls**: `SliderControl`, `SearchFilter`, `ToggleFilter`, `CompareFilter`, `OverflowMenu`,
327
+ `ControlGroup`.
328
+
329
+ **The common primitives, as a unit.** `src/common` (shipped from the maturation round) is now the
330
+ one place the base prop / error / ref vocabulary lives: `BasaltProps`, `SlotStylesProps`, `cx`,
331
+ `mergeRefs`/`assignRef`, the prefixed `errors.ts` table (`toErrorMessage`/`errorStatus`),
332
+ `useValidateProps`, `assertRequiredProps`. Every basalt component extends `BasaltProps` for its own
333
+ prop type rather than redeclaring `className`/`style`; a consumer composing over a basalt component
334
+ should do the same.
335
+
336
+ ### Additions — what C5 lets a consumer delete
337
+
338
+ C5 consolidation — every item below names the argo lines it lets a consumer delete
339
+ (`.claude/maturation/consolidation-plan.md` §C5, `audit-f-argo-consumer.md`). All additive; nothing
340
+ in this block removes or renames an existing export. **Additive is not the same as ignorable**: two
341
+ of these REPLACE code a consumer is already hand-rolling — see § `basalt-ui/format` below, and
342
+ `PageTitle`, which is the answer to the `in-body-page-title` waiver a shell-less page needed.
343
+
344
+ - **`basalt-ui/format`** — new subpath, Mantine-free and React-free: `money`, `percent`, `integer`,
345
+ `compact`, `deltaPct`, `duration` (seconds → `"1h 02m"`; `{ unit: 'minutes' }` for a
346
+ minutes-based input), `durationClock` (`"1:02:03"`), `clock`, `relativeTime`, `weekday`, `km`,
347
+ `kcal` — plus the original chart-formatter names (`fmtCompact`/`fmtPercent`/`fmtCurrency`/
348
+ `fmtInt`/`fmtAxisDate`/`fmtTooltipDate`/`NON_FINITE`/`formatters`), now re-exported from here;
349
+ `./charts`' `utils/format.ts` is an internal re-export shim, not a second implementation.
350
+ - **`ChartState.empty`** widens to `boolean | string` — a string is both truthy and the label
351
+ `ChartEmpty` paints. `ChartCard` gains `state?: ChartState`, `placeholderHeight?: number`
352
+ (default 240) and `stateAction?: ReactNode`, and `children` is now optional so a title-less
353
+ pending card can be a route's own `<Suspense fallback>`. Resolution order is the same
354
+ `resolveChartState` precedence every kind already has — pending → error → empty — and
355
+ `state={{}}`/omitting `state` renders `children` unchanged. `ChartCenter`'s `width` widens to
356
+ `number | string` (`'100%'` for a non-measured block body).
357
+ - **`DualPanel`'s `fillBetween` gains `aboveFill?: string`** — the above-side fill color; omitted,
358
+ both sides keep sharing `fill` (unchanged behaviour). **`AxisBottomNumeric`** exported from
359
+ `./charts` — the numeric twin of `AxisLeftNumeric`, for a bespoke continuous-x plot.
360
+ - **`useBreakpoint(name, edge?, options?)`** on the root barrel — SSR/hydration-safe media query
361
+ over `theme.breakpoints`, built on `useSyncExternalStore` (not `@mantine/hooks`' `useMediaQuery`,
362
+ which mismatches during hydration). `edge` is `'min'` (default, "at least this wide") or `'max'`.
363
+ `page-aside.tsx` now composes this hook's own internals instead of a private duplicate.
364
+ - **`--vx-space-touch-target` / `VX.spaceTouchTarget`** — the WCAG touch-target floor (44px),
365
+ density-exempt. The one `SPACE_FIXED` member that IS emitted as a CSS var (every other one stays
366
+ JS-only) — a consumer's own `@media (pointer: coarse)` CSS-module rule reads it directly.
367
+ - **`SettingsMenuItem.active?: boolean`** — renders a trailing check glyph and `aria-current` in
368
+ every projection (the sidebar's flat rows and gear menu, the mobile More sheet).
369
+ - **`basalt-ui/commands` gains imperative shell handles**: `setColorScheme(scheme)` and
370
+ `toggleSidebar()`, wired automatically — `BasaltProvider` registers the Mantine color-scheme
371
+ setter, `BasaltShell` registers the collapse toggle, so a command's `run` calls either with zero
372
+ `__root.tsx` bridge wiring (unlike the hand-rolled `color-scheme-bridge.ts`/`sidebar-bridge.ts`
373
+ shape this seeds from). `Slot<K, Constraint>` (`register.ts`) changed its internal resolution
374
+ from a whole-interface mapped-type match to an indexed-access form — same public behaviour,
375
+ fixes the commands↔overlays same-file type cycle a command calling `overlays.open(...)` used to
376
+ hit while `defineCommands`'s own type was still being inferred.
377
+ - **`BasaltDevDock`** on the root barrel — a fixed, dev-only bottom drawer hosting TanStack Router
378
+ devtools, TanStack Query devtools and the theme lab, each peer lazy-loaded. New optional peer:
379
+ `@tanstack/react-router-devtools`.
380
+ - **`BasaltErrorBoundaryProps`** is now exported; `onError` is optional (defaults to the same
381
+ console-warn-in-dev fallback `BasaltProvider`'s own top-level `onError` uses); an omitted
382
+ `fallback` now renders a minimal built-in default (a `PageTitle` + reload button) instead of
383
+ `null` — pass `fallback={null}` explicitly for the old swallow-it behaviour. **`PageTitle`** is a
384
+ new shell-less page-title primitive on the root barrel (a plain `<h1>`, not Mantine's `<Title>`,
385
+ so it needs no `in-body-page-title` waiver) — the remedy for the `in-body-page-title` waiver a
386
+ shell-less error/auth page used to need.
387
+ - **`unwrap` gains a second overload** — the original took only a `Promise` of the `{ data, error }`
388
+ envelope; it now also accepts the ALREADY-RESOLVED envelope directly (`unwrap(await api.x.get())`,
389
+ or as a bare `.then(unwrap)` callback), inferring which overload from whether the argument IS a
390
+ `Promise`. The absence guard also now throws on `undefined` data, not just `null`.
391
+
392
+ ### CLI — one resolver, and six checks `doctor` stopped running
393
+
394
+ **Read this one if your app is not at the repo root.** A consumer migration reported it costing
395
+ more debugging than any of the five export removals in this minor, and until now it was one clause
396
+ inside a bullet.
397
+
398
+ **The resolver stopped relocating.** Every project-scoped command (`check-theme`, `doctor`, `sync`)
399
+ used to descend two levels and ascend to the nearest ancestor looking for a `basalt` config. It now
400
+ resolves ONE way — `BASALT_CWD` when set, else the invocation cwd — plus a declared `basalt.roots`,
401
+ and infers nothing from siblings or parents (`resolveProjectDir`, `src/cli/index.ts`). That is the
402
+ right call (the ascend existed to paper over `check-theme` FABRICATING `roots: ["src"]`), and it is
403
+ a silent break for every workspace consumer:
404
+
405
+ | Where you run it | Before 1.29.0 | 1.29.0+ |
406
+ | -------------------------------- | ----------------------------------- | ---------------------------------------------------------------- |
407
+ | repo root, config in `apps/web` | relocated to `apps/web` and said so | reads the root, finds no config, **`✖ 0 files scanned`, exit 1** |
408
+ | `apps/web` | unchanged | unchanged |
409
+ | repo root, `BASALT_CWD=apps/web` | unchanged | unchanged — this is now the only way |
410
+
411
+ **The consequence nobody wrote down: the SHIPPED lefthook preset broke itself.** Lefthook runs every
412
+ command at the repo root, so a monorepo consumer who did nothing but bump the version got a
413
+ pre-commit hook that scanned zero files and failed — from a preset they cannot override the `run:`
414
+ of. Its default `BASALT_BIN` fallback (`bunx --no-install basalt-ui`) also resolves to nothing at
415
+ the root under bun's isolated linker, which puts basalt beside the package that depends on it.
416
+ Two fixes, either one enough:
417
+
418
+ ```yaml
419
+ # <repo>/lefthook.yml — the one line that fixes the guard
420
+ pre-commit:
421
+ commands:
422
+ check-theme:
423
+ env: { BASALT_CWD: apps/web }
424
+ ```
425
+
426
+ or run the whole hook inside the package (`root: 'apps/web/'` per command — it merges, the preset
427
+ does not define it). `configs/lefthook.yml` now carries both as a MONOREPO RECIPE block and derives
428
+ its default bin from `BASALT_CWD`; a consumer on 1.29.x writes the `env:` block by hand.
429
+
430
+ **`doctor` lost six of its ten checks.** At 1.25.0 it printed ten lines; it prints four. Verified
431
+ against the shipped `dist/cli/doctor.js` — the code is deleted, not skipped, so nothing reports a
432
+ skip either:
433
+
434
+ | Check | 1.25.0 | 1.29.x | What you lose |
435
+ | ------------------------------------------------------------ | -------------- | ----------------- | -------------------------------------------------------------------------------- |
436
+ | `.basalt/manifest.json` exists | ✓ | ✓ | — |
437
+ | `basalt-ui` resolves from here, and at which version | ✓ | **gone** | the "you are silently running a `bunx`-downloaded copy" answer |
438
+ | installed `basalt-ui` matches the manifest's `basaltVersion` | ✓ | **gone** | the install/manifest skew a CI `doctor` step existed to catch — now a no-op |
439
+ | spacing scale matches the last `sync` | ✓ | **gone** | the token layer moving under a consumer between minors |
440
+ | running CLI version matches the installed package | ✓ | **gone** | the 1.20.0-cache-against-a-1.22.0-pin failure `basaltBinCommand` was written for |
441
+ | `guard-scan`: `basalt.roots` resolves to > 0 files | ✓ | **gone** | **the check that made a silently-scanning-nothing `check-theme` impossible** |
442
+ | `basaltAppPlugin({ icons })` files exist | ✓ | **gone** | a manifest referencing icons that were never added |
443
+ | oxlint preset extended | ✓ | ✓ | — |
444
+ | lefthook preset wired | ✓ | ✓ | — |
445
+ | `ai` major parity | workspace-wide | this package only | skew between two workspace packages |
446
+
447
+ The `guard-scan` row and the resolver change are the same failure from two directions: 1.29.0
448
+ deleted the ascend that used to find your config AND the check that would have told you it hadn't.
449
+ **If your CI runs `basalt-ui doctor` as a version-skew gate, that step no longer gates** — assert
450
+ the version yourself, or keep `check-theme` (which still fails loudly on zero roots) as the gate.
451
+
452
+ **Two commands are gone**: `info` (it graded a consumer's `vite.config.ts` by parsing it and nobody
453
+ read the output — no replacement) and `check-coverage` (repo-internal, now
454
+ `bun scripts/check-coverage.ts` inside this repo; it never had a consumer meaning). A consumer's own
455
+ docs or CI naming either will fail on an unknown command, which the CLI reports by name.
456
+
457
+ ### `basalt-ui/format` — the subpath that deletes your hand-rolled formatters
458
+
459
+ **An addition, but a MIGRATION**, and the reason it gets a row: it does not add a capability, it
460
+ replaces one every consumer already hand-rolled. Nobody greps a migration file for a thing that was
461
+ not taken away, so it is written down here as well as in § Additions.
462
+
463
+ ```tsx
464
+ /* before */ <Text>${(cents / 100).toFixed(2)}</Text>
465
+ /* after */ <Text>{money(cents / 100)}</Text> // import { money } from 'basalt-ui/format'
466
+ ```
467
+
468
+ `money`, `percent`, `integer`, `compact`, `deltaPct`, `duration`, `durationClock`, `clock`,
469
+ `relativeTime`, `weekday`, `km`, `kcal` — all `Intl`-backed with an explicit `locale` defaulting to
470
+ the runtime's, all returning an em dash for non-finite input instead of `NaN`/`∞`. Mantine-free and
471
+ React-free, so a chart axis, a table cell and a server-rendered string share one implementation. The
472
+ chart formatters (`fmtCompact`/`fmtPercent`/`fmtCurrency`/`fmtInt`/`fmtAxisDate`/`fmtTooltipDate`)
473
+ are re-exported from here and `./charts`' `utils/format.ts` is a shim over them, not a second copy —
474
+ so there is no "which one do I import" question to get wrong.
475
+
476
+ Find your call sites: `rg -n 'toFixed\(|toLocaleString\(|Intl\.NumberFormat' --type ts`
477
+
478
+ ## 1.28.0 — the aside, the shell scrollport, the form layer, and `inputProps` without `key`
479
+
480
+ **Nothing removed, and ONE thing changed shape: `inputProps` no longer returns `key`** — a silent
481
+ behaviour change at every field call site, caught by the new `basalt/forms-field-key` and described
482
+ in its own § below. Read that one before upgrading; the rest of the export delta is additive. Six
483
+ new oxlint rule ids ship at `warn`; **nothing is promoted to `error` in this minor** — the five
484
+ oxlint rules and one guard kind that went `error` were 1.27.0's, see § Guards there. Every level
485
+ honours the `theme-allow` grammar unchanged.
486
+
487
+ **And one silent DELETION that no compiler, linter or test reports: the brand left the sidebar.**
488
+ If your app suppresses the header region, it also lost its brand and its sidebar collapse toggle the
489
+ moment it bumped — see § Shell — the brand moved into the header (below).
490
+
491
+ **One behaviour change with a blast radius past this package: `AppShell.Main` is now the
492
+ scrollport, and the WINDOW no longer scrolls inside a `BasaltShell`.** Nothing was removed or
493
+ renamed, but four things a consumer may be doing stop working, and all four have a one-line remedy —
494
+ see § The shell scrollport below.
495
+
496
+ What is new, one line each — nothing here renames or removes anything:
497
+
68
498
  - **`scrollParentOf` / `SCROLLPORT_ATTRIBUTE`** (`basalt-ui`) — resolve which box actually scrolls an
69
499
  element (`null` = the document). The seam behind the shell scrollport change below.
70
500
  - **`PageAside`** (`basalt-ui`) — the right-hand aside REGION a route claims, with the panel filter
@@ -169,7 +599,9 @@ EdenFetchError }`), not one envelope with a nullable `data`; inferring through t
169
599
  flattened `BasaltProvider` connectivity props. Permanently `warn` — a deprecation is a schedule,
170
600
  not a defect. **On the `field` row, take the two fixes in order**: that rename lands the new
171
601
  `inputProps` shape, which has no `key`, and `basalt/forms-field-key` then reports the same line
172
- and inserts the `key`. Two `--fix` passes, or one manual edit writing both.
602
+ and inserts the `key`. Two `--fix` passes, or one manual edit writing both. **This works only
603
+ while `field` is still deprecated-but-shipped, i.e. on 1.28.x** — 1.29.0 deletes both the export
604
+ and its ledger row, and with them pass 1.
173
605
  - **Dev-only duplicate-mount warnings** — `BasaltProvider` now warns when a second instance mounts
174
606
  while the first is still mounted; `<BasaltOverlays notifications />` and `<BasaltNotifications />`
175
607
  now warn when BOTH are mounted at once (previously prose-only in both cases). Both are
@@ -371,7 +803,14 @@ mode: a form that stops resetting, with no signal anywhere.
371
803
  reports every `{...inputProps(…)}` spread whose element carries no sibling `key`, and it
372
804
  **autofixes** — inserting `key={fieldKey(<same args>)}` and adding `fieldKey` to the existing
373
805
  `basalt-ui/forms` import. Run `oxlint --fix` over the app and read the diff; the escape hatch for an
374
- element genuinely remounted by its parent is `theme-allow forms-field-key — <why>`.
806
+ element genuinely remounted by its parent is `theme-allow forms-field-key — <why>`. **Scope the
807
+ `--fix` to the paths you are migrating** (`oxlint --fix src/features/settings`): the shipped preset
808
+ enables autofixable stylistic rules, so a bare `oxlint --fix .` rewrites untouched files in the same
809
+ commit.
810
+
811
+ > **If you are landing on 1.29.x, not 1.28.x, the two-pass recipe below does not apply** — `field`
812
+ > is deleted there, so pass 1 has nothing to fix and the edit is manual. See 1.29.0 § Consolidation,
813
+ > the `field` row.
375
814
 
376
815
  **The deprecated `field` alias did NOT follow `inputProps` here.** It is no longer
377
816
  `export const field = inputProps`: it keeps the 1.27 return shape, `key` included, so
@@ -452,9 +891,9 @@ Two unrelated exports shared the name `field`: the forms adapter's `getInputProp
452
891
  the `field.enum/multi/range/number/boolean/string` store-field builder in `basalt-ui/state` and
453
892
  `basalt-ui/router-tanstack`. A page combining a form and a filter store had to alias one on import.
454
893
 
455
- | Removed / renamed | Replacement | Note |
456
- | --------------------------- | ------------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
457
- | `field` (`basalt-ui/forms`) | `inputProps` | Same signature — `inputProps(form, path)`. `field` still resolves from `basalt-ui/forms` as a `@deprecated` alias, so nothing breaks; the alias goes away the next time the forms surface changes, and that removal ships as a plain `feat:`, never a major. **Since the Unreleased minor the two are no longer the same function**: `inputProps` dropped `key` from its return and `field` did not — see § `inputProps` no longer returns `key`. |
894
+ | Removed / renamed | Replacement | Note |
895
+ | --------------------------- | ------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
896
+ | `field` (`basalt-ui/forms`) | `inputProps` | Same signature — `inputProps(form, path)`. `field` still resolves from `basalt-ui/forms` as a `@deprecated` alias in 1.28.x, so nothing breaks here; **it is deleted in 1.29.0** (see that minor's § Consolidation, which also explains why the `--fix` recipe stops working there). **In 1.28.0 the two are no longer the same function**: `inputProps` dropped `key` from its return and `field` did not — see § `inputProps` no longer returns `key`. |
458
897
 
459
898
  The `field.*` store builder (`basalt-ui/state`, `basalt-ui/router-tanstack`) is untouched.
460
899
 
@@ -500,59 +939,236 @@ redundant (Mantine's own default).
500
939
  hard `0` floor and their marks anchor at the baseline, so a log scale on either yields `NaN`
501
940
  geometry — don't opt in there. Additive; omitted stays linear.
502
941
 
503
- ### Stores — the `custom` flag, patched writes, lazy fallbacks, derived windows
942
+ ### `basalt-ui/data` — a typed facet id, and a required `getItemKey`
504
943
 
505
- **Additive, except one type that got narrower on purpose.** `field.range({ presets, fallback })`
506
- written INLINE inside `createSearchStore({ fields })` used to infer `custom` as `boolean` — the
507
- contextual `AnyField` return type won over the argument — so every value and search type carried a
508
- `'custom'` preset the field itself rejects at runtime, and `RangeFilter field={…}` needed a cast or
509
- an explicit `custom: false`. `field.range` is now three overloads: an omitted or `false` flag is
510
- `false`, a literal `true` is `true`, a value typed `boolean` stays widened.
944
+ **`BasaltDataTable`: a mistyped `facets[].columnId` used to render no pill, silently — `if
945
+ (!column) return null`.** It now throws in dev, naming the id and every known column id
946
+ (`facet columnId "…" matches no column`); production still degrades to no pill for that one facet.
947
+ `DataTableFacet` also gained a type parameter — `DataTableFacet<T = unknown>` — so `columnId` is
948
+ typed off the row shape wherever TanStack's `accessor`/manual `id` inference allows it
949
+ (`Extract<keyof T, string> | (string & {})`); the default keeps a bare `DataTableFacet[]`
950
+ compiling unchanged. `BasaltDataTableProps.facets` is now `DataTableFacet<T>[]`, and
951
+ `initialColumnPinning` moved from TanStack's own `ColumnPinningState` to a new, `T`-derived
952
+ `DataTableColumnPinning<T>` (structurally assignable to `ColumnPinningState`, so no cast is
953
+ needed at either end). Both are additive for existing call sites — an id that was already a real
954
+ column keeps compiling and rendering exactly as before.
511
955
 
512
- - **Delete the workaround, not the field.** An explicit `custom: false` still works (same
513
- overload); a cast on `<RangeFilter field={…} />` can go, and `RangeFilterProps<P, C>` is generic
514
- over the flag so a preset-only handle binds directly.
515
- - **What can newly type-error:** code that read `'custom'` out of a range whose field never declared
516
- it — `if (search.range === 'custom')` on a preset-only field, or a `RangeValue<P | 'custom'>`
517
- annotation over one. That branch was always dead at runtime; the type just stopped pretending.
518
- - **…and one more, in a consumer's own WRAPPER.** A prop typed `FieldHandle<RangeField<P>>` — `C`
519
- defaulting to `boolean` — no longer accepts a handle from a `field.range` without `custom: true`,
520
- because that handle's setter is now `RangeValue<P>`-typed and a setter is contravariant. Pin
521
- `FieldHandle<RangeField<P, false>>` for the preset-only shape, `RangeField<P, true>` for a
522
- picker-backed one, or make the wrapper generic over the flag
523
- (`<P extends string, C extends boolean>` + `FieldHandle<RangeField<P, C>>`), which is what
524
- `RangeFilter` does. An existing `as FieldHandle<RangeField<P>>` cast keeps compiling.
956
+ **`BasaltVirtualList.getItemKey` is now required.** The index fallback hid stale rows across a
957
+ mutation: an insert/delete shifts every index below it, so the OLD item kept rendering at each
958
+ shifted index until an unrelated re-render happened to catch it up — in the one component whose
959
+ job is a long mutable list. Pass a real per-item key (`(item) => item.id`); there is no
960
+ replacement for the index fallback.
525
961
 
526
- **`toWindow()` resolves derived presets.** `field.range({ window: { '3m': (now) => ({ from, to }) } })`
527
- makes `toWindow({ preset: '3m' })` return `{ from, to }` (ISO dates, resolved at call time with the
528
- current `Date`) while `3m` stays one preset in the URL. A preset with no resolver keeps
529
- `{ window: preset }`, in the same field — which is what retires the last hand-rolled
530
- `presetToParams` a consumer kept beside the store for `3m` / `6m` / `1y` / `ytd`.
962
+ **`BasaltVirtualList` gained a ref.** `ref?: Ref<BasaltVirtualListHandle>` exposes
963
+ `scrollToIndex`/`scrollToOffset`/`scrollToEnd` (delegating to the internal
964
+ `@tanstack/react-virtual` instance) plus `getVirtualizer()` as the full escape hatch — mirrors
965
+ `./data/table`'s `useReactTable` re-export as the table's own. Additive; omitting `ref` changes
966
+ nothing.
531
967
 
532
- **The resolved presets are gone from the return TYPE too**, which is the half that decides whether
533
- the switch is deleted or the cast just moves: the field now carries its resolver keys (`RangeField`'s
534
- fifth type parameter, inferred from `window`), and `toWindow` returns
535
- `{ window: Exclude<P, W> } | { from; to }`. A `resolveWindow` whose target is
536
- `{ window: '7d' | '30d' | '90d' | 'all' } | { from; to }` becomes a one-line delegation with no
537
- `as`. One guard survives on a `custom: true` field: a `'custom'` preset that arrives WITHOUT dates
538
- resolves to `{ window: 'custom' }`, so `'custom'` stays in the union by construction.
968
+ ### `basalt-ui/commands` — `BasaltOverlays` no longer WRAPS your app in `ModalsProvider`
539
969
 
540
- **A field setter takes an optional second argument.** `set(next, { patch })` merges extra search
541
- params into the SAME navigate — URL lane only, for keys the store does not own (`{ patch: {
542
- detailDate: undefined } }` clears a sibling param the page put there). The field's own params always
543
- win over the patch, so it cannot corrupt the value being set. A local/memory-lane write has no
544
- navigate to merge into and ignores it.
970
+ **Same props, same imperative API, one structural change: every layer — `ModalsProvider`
971
+ included — now renders as a SIBLING of `children` instead of an ancestor.** The old shape put the
972
+ whole app under a `React.lazy` Suspense boundary (`<Suspense fallback={<>{app}</>}>
973
+ <LazyModalsProvider>{app}</LazyModalsProvider></Suspense>`), and `React.lazy` suspends on its first
974
+ render even when the module is already warm. Two consequences, both real: the app's FIRST COMMIT was
975
+ deferred past a microtask — long enough for TanStack Router's async `loadMatches` to `setState` onto
976
+ a not-yet-mounted fiber ("Can't perform a React state update on a component that hasn't mounted
977
+ yet", once per full page load) — and the app tree mounted TWICE, once inside the fallback and once
978
+ resolved, so every effect in the app ran twice on boot.
545
979
 
546
- Two limits are now enforced rather than documented. A patch key **another field of the same store
547
- owns** is refused — it would reach the URL while the store's own write path never touched the mirror,
548
- so the next paramless visit and every `linkSearch` link resolve the OLD value: `createSearchStore`
549
- throws in dev, and in production keeps the write and logs it once. Write that field through its own
550
- setter instead. And a patched write **from a route that does not validate the field** persists (A1)
551
- but drops the patch with it — there is no navigate to merge into — which now warns once per field in
552
- dev instead of doing nothing.
980
+ Nothing to change for the overlay APIs. `modals.open` / `modals.openConfirmModal` (and so basalt's
981
+ own `overlays.open` / `overlays.close`) go through `@mantine/modals`' window CustomEvent bus, which
982
+ `ModalsProvider` subscribes to — being inside it was never a requirement, and the provider renders
983
+ its own managed `Modal` regardless of where it sits.
553
984
 
554
- **A fallback may be a thunk.** `field.*({ fallback: () => T })` (every kind — `field.number`'s codec
555
- froze its thunk at store definition in the first cut of this minor and does not now) is resolved at
985
+ **The one thing a sibling cannot serve is React CONTEXT**: `useModals()` from `@mantine/modals`
986
+ called inside your app now throws its "was called outside of context" error. If you use it (or
987
+ `openContextModal`, which needs the provider's `modals` map that `BasaltOverlays` never passed
988
+ anyway), mount your own provider and turn basalt's off — two providers would both answer the event
989
+ bus and open every modal twice:
990
+
991
+ ```tsx
992
+ <BasaltOverlays modals={false}>
993
+ <ModalsProvider modals={CONTEXT_MODALS}>
994
+ <App />
995
+ </ModalsProvider>
996
+ </BasaltOverlays>
997
+ ```
998
+
999
+ ### `basalt-ui/controls` — the mobile `Filters (n)` sheet renders panel rows, not its own list
1000
+
1001
+ **The sheet's `SheetOptionList` (44px rows, a hairline between them, one per option) is gone.**
1002
+ `useFilterSurface() === 'sheet'` now resolves to the SAME `PanelRow`/`PanelChoice`/facet-list body
1003
+ `'panel'` already renders (`docs/CONTROLS-SPEC.md` §3: "sheet = panel rows inside a Drawer") — label
1004
+ above, full-width control below, folding past `PANEL_TRACK_MAX` (3, a `Select`) or `max` (a
1005
+ `MultiSelectFilter`'s `Show N more`) the same way the aside already did. A set with many options no
1006
+ longer grows the sheet to a full column of option rows. `RangeFilter`'s sheet form is the panel's one
1007
+ `Select` (presets plus a `Custom range…` row revealing the injected picker) instead of a
1008
+ `SheetOptionList` with the picker behind a separate disclosure row.
1009
+
1010
+ Also fixed in the same pass: a `fullWidth` `SegmentedControl` (`PanelChoice`'s ≤3-option form, and
1011
+ `ViewTabs`) now splits its options EVENLY — Mantine's own `.control` carries no `min-width`
1012
+ override, so the initial `auto` floored each option at its content width and a `fullWidth` track
1013
+ split unevenly; a long label now ellipsizes inside its equal share instead of forcing the column
1014
+ wide. The active segment's fill is a second ink-mix step
1015
+ (`color-mix(in srgb, var(--vx-ink) 12%, var(--vx-surface-panel))`), not a flat `--vx-surface-panel`,
1016
+ so it stays visible against its own track when the whole control sits on a panel-coloured surface (a
1017
+ `PanelRow`), where a flat panel fill read as barely-there next to the 6%-tinted track around it.
1018
+
1019
+ | Removed | Replacement | Note |
1020
+ | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1021
+ | `SheetOptionList`, `SheetField`, `SheetDisclosure` (`basalt-ui/controls`) | `PanelRow` / `PanelChoice` (already exported) | Were never part of the public `./controls` barrel — internal to `filter-sheet.tsx` — so this only breaks a consumer test that imported them directly out of `basalt-ui/controls/filter-sheet` or hooked a `role="radio"` `<fieldset>` DOM shape inside the sheet (now a `PanelChoice` `SegmentedControl`, `role="radiogroup"`, or a `Select`). |
1022
+ | `SheetRow`, `sheetRowClassNames` (`basalt-ui/controls`) | none | Also internal-only. `ToggleFilter`'s sheet form is now a `PanelRow` whose `Switch` rides the label line, the same shape the panel surface always used. |
1023
+ | `.sheetOption` / `.sheetList` / `.sheetField` / `.sheetRow` / `.sheetDisclosureBody` / `.sheetLabel` (`controls.module.css`) | `panel-row.module.css`'s `.row`/`.head`/`.control`/`.label` | CSS-module classes were never a public contract; named for a consumer that vendored a copy of the sheet's old CSS. |
1024
+
1025
+ ### The shell scrollport — Main scrolls, not the window
1026
+
1027
+ `BasaltShell` dropped Mantine's `layout="alt"` (the header now spans the full viewport width, above
1028
+ the sidebar and the aside, instead of being inset beside the sidebar) and gave `AppShell.Main` a
1029
+ bounded height with `overflow: auto`. Main is therefore THE scrolling element: the document's
1030
+ `scrollHeight` equals the viewport height, the browser scrollbar sits on Main's own right edge
1031
+ (inside the aside, where the content is) instead of at the far edge of the window, and
1032
+ `window.scrollY` is pinned at 0.
1033
+
1034
+ Main carries two handles: `data-basalt-scrollport` (basalt's own, and what `scrollParentOf` looks
1035
+ for) and `data-scroll-restoration-id="basalt-main"` (the attribute `@tanstack/router-core`'s scroll
1036
+ restoration reads, so a router-driven app restores this element rather than the window).
1037
+
1038
+ What to change in a consumer:
1039
+
1040
+ | If your app… | Do this |
1041
+ | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1042
+ | reads `window.scrollY` / listens for `scroll` on `window` | `const port = scrollParentOf(el)` and read `port.scrollTop` / listen on `port` — `null` still means the window, so shell-less code is unchanged |
1043
+ | scrolls with `window.scrollTo` / `document.scrollingElement` | target `document.querySelector('[data-basalt-scrollport]')` first, with the document as the fallback |
1044
+ | uses window-based scroll restoration | point it at `[data-scroll-restoration-id="basalt-main"]`; TanStack Router picks it up with no config |
1045
+ | overrides `--vx-space-sticky-header-clearance` | the token NO LONGER includes the app-shell header height — it is `anchors.stackMd` (12px at level 0), pure breathing room, because BOTH the header and the page-bar band are regions outside the scrollport |
1046
+ | passes `BasaltDataTable stickyHeaderOffset` inside a shell | drop it entirely. Nothing overhangs the scrollport any more, so a sticky table head wants `top: 0`, which is the default |
1047
+ | computes a viewport-filling body from `--basalt-page-bar-h` | inside a shell that is just `100%` now — the band is outside Main rather than scrolling with it. The var is still published |
1048
+ | passes `mobileNav.getScrollElement` only to reach the page scroller | you can drop it — the default resolves the scrollport now |
1049
+
1050
+ Two more rendering changes ride along, both visible without any code change (the third,
1051
+ the brand relocation, is NOT always visible — it has its own § below):
1052
+
1053
+ - **`PageBar` row 2 is a shell-owned BAND**, not a row in the page: it portals into an outlet
1054
+ `BasaltShell` renders between the header and the scrollport, exactly as row 1 portals into the
1055
+ header. It spans Main's width, carries `--vx-surface-bg` and one `--vx-divider` hairline, and is a
1056
+ zero-height, seam-less box on a route with no `PageBar`. It is no longer sticky and no longer
1057
+ depends on where you wrote `<PageBar>` — nested in a `Stack` or written as Main's direct child now
1058
+ produce identical geometry. Its padding-block tightened from `--vx-space-stack-sm` to
1059
+ `--vx-space-stack-xs`, so the bar is 8px shorter.
1060
+ - **`BasaltDataTable` contains itself horizontally by default.** A table with neither `maxHeight`
1061
+ nor `minWidth` used to render a bare `<table>`, which sizes to its own min-content — a five-column
1062
+ table measured 448px inside a 390px viewport and dragged the whole page sideways. Every table now
1063
+ renders inside `Table.ScrollContainer type="native"` (`minWidth: 0` when you set none), so a wide
1064
+ table scrolls inside its own card. `stickyHeader` with neither prop cannot take that container —
1065
+ an `overflow-x` box computes `overflow-y` to `auto` and becomes the header's scrollport, and with
1066
+ no height cap it has no scroll range, so the header would go inert — so it takes a wrapper whose
1067
+ overflow is **measured** instead: while the table fits its container the wrapper declares no
1068
+ overflow at all and the page-sticky header sticks exactly as before; once the table is wider the
1069
+ wrapper flips to `overflow-x: auto`, the columns stay reachable, and the header is inert at that
1070
+ width. The flip is a live `ResizeObserver` reading in both directions, so the wrapper goes back to
1071
+ bare when the space returns (the window widened, the sidebar collapsed, the aside closed), and it
1072
+ publishes `data-contained="true|false"`. The dev warning on that shape now states the trade rather
1073
+ than a defect and points at `maxHeight` for a table that must both scroll and stick. Pinning takes
1074
+ the same wrapper: the `overflow-x: auto` `Box` a pinned, uncapped, sticky table used to get is
1075
+ gone, because it was the inert-header shape it was meant to avoid. **The TOOLBAR was the other
1076
+ half of the same defect and is fixed with it**: the toolbar `Group` is the header row's flex item
1077
+ (`CtlSlot` between them is `display: contents`) and carried `flex: 0 0 auto`, so a 220px search
1078
+ beside a 230px facet pill row was an unshrinkable 461px box in a 302px column — `AppShell.Main`
1079
+ measured `scrollWidth` 505 against `clientWidth` 390 at 390x844. It is now `flex: 0 1 auto` with
1080
+ `min-width: 0`, and the search's `w={220}` became a flex BASIS (`flex: 0 1 220px`, floored at
1081
+ `12ch`, capped at `100%`). The field still resolves to 220px wherever 220 fits and still never
1082
+ grows with the table; below that the search and the pills wrap onto their own lines.
1083
+
1084
+ ### Shell — the brand moved into the header, and that can DELETE it
1085
+
1086
+ **This one is filed separately because "visible without any code change" is exactly what it is
1087
+ not.** The header now spans the full viewport width, so the sidebar's `appShellHeaderHeight` brand
1088
+ row was painting a SECOND band under the header seam. The brand is the header's leading zone
1089
+ instead — `--app-shell-navbar-offset` wide, so it still tracks the rail collapse and still ends on
1090
+ the sidebar\|main seam — and the sidebar column starts at `SidebarSearch`. `brand`, `brand.menu` and
1091
+ the collapse toggle are unchanged; only the markup's home moved.
1092
+
1093
+ **If your app suppresses the header, the brand and the sidebar collapse toggle are now GONE.**
1094
+ Nothing type-checks differently, nothing lints, no test fails: the app simply boots without them.
1095
+ A phone-first consumer that zeroes the header (`--app-shell-header-height: 0`,
1096
+ `--app-shell-header-offset: 0`, or `display: none` on the region) hit exactly this, and found it by
1097
+ looking at the app. There is currently no supported way to suppress a shell region and no signal
1098
+ when a region's contents move underneath you — **so if you have ever styled a `mantine-AppShell-*`
1099
+ region away, re-check it against every minor that touches the shell.** The remedy is to stop
1100
+ suppressing the header (it is one token tall) or to render your own brand where the header used to
1101
+ be.
1102
+
1103
+ **Two more consequences, for a consumer mounting `AppSidebar` DIRECTLY** rather than through
1104
+ `BasaltShell` (which is the supported path): it no longer paints a brand row, and its
1105
+ `onToggleCollapse` prop is no longer read there — `BasaltShell` feeds it to the header instead. Both
1106
+ are silent in the same way.
1107
+
1108
+ Find out whether this reaches you: `rg -n "app-shell-(header|navbar)-(height|offset)|AppShell-header"`
1109
+
1110
+ ## 1.27.0 — the number lane, `StatCard`'s breakdown, and five promotions
1111
+
1112
+ **Nothing removed. Two exports added (`NumberFilter`, and three members on the number handle),
1113
+ one member added to every `FieldHandle` (`clear()`), four `StatCard` props added, and enforcement
1114
+ tightened: five oxlint rules plus one guard kind became `error`.** The store half of this minor also
1115
+ fixed four inference and notification defects consumers hit while porting, plus two ways a fallback
1116
+ got pinned into localStorage — see § Stores below. Every level change honours the `theme-allow`
1117
+ grammar unchanged; only the severity of an unwaived finding moves.
1118
+
1119
+ ### Stores — the `custom` flag, patched writes, lazy fallbacks, derived windows
1120
+
1121
+ **Additive, except one type that got narrower on purpose.** `field.range({ presets, fallback })`
1122
+ written INLINE inside `createSearchStore({ fields })` used to infer `custom` as `boolean` — the
1123
+ contextual `AnyField` return type won over the argument — so every value and search type carried a
1124
+ `'custom'` preset the field itself rejects at runtime, and `RangeFilter field={…}` needed a cast or
1125
+ an explicit `custom: false`. `field.range` is now three overloads: an omitted or `false` flag is
1126
+ `false`, a literal `true` is `true`, a value typed `boolean` stays widened.
1127
+
1128
+ - **Delete the workaround, not the field.** An explicit `custom: false` still works (same
1129
+ overload); a cast on `<RangeFilter field={…} />` can go, and `RangeFilterProps<P, C>` is generic
1130
+ over the flag so a preset-only handle binds directly.
1131
+ - **What can newly type-error:** code that read `'custom'` out of a range whose field never declared
1132
+ it — `if (search.range === 'custom')` on a preset-only field, or a `RangeValue<P | 'custom'>`
1133
+ annotation over one. That branch was always dead at runtime; the type just stopped pretending.
1134
+ - **…and one more, in a consumer's own WRAPPER.** A prop typed `FieldHandle<RangeField<P>>` — `C`
1135
+ defaulting to `boolean` — no longer accepts a handle from a `field.range` without `custom: true`,
1136
+ because that handle's setter is now `RangeValue<P>`-typed and a setter is contravariant. Pin
1137
+ `FieldHandle<RangeField<P, false>>` for the preset-only shape, `RangeField<P, true>` for a
1138
+ picker-backed one, or make the wrapper generic over the flag
1139
+ (`<P extends string, C extends boolean>` + `FieldHandle<RangeField<P, C>>`), which is what
1140
+ `RangeFilter` does. An existing `as FieldHandle<RangeField<P>>` cast keeps compiling.
1141
+
1142
+ **`toWindow()` resolves derived presets.** `field.range({ window: { '3m': (now) => ({ from, to }) } })`
1143
+ makes `toWindow({ preset: '3m' })` return `{ from, to }` (ISO dates, resolved at call time with the
1144
+ current `Date`) while `3m` stays one preset in the URL. A preset with no resolver keeps
1145
+ `{ window: preset }`, in the same field — which is what retires the last hand-rolled
1146
+ `presetToParams` a consumer kept beside the store for `3m` / `6m` / `1y` / `ytd`.
1147
+
1148
+ **The resolved presets are gone from the return TYPE too**, which is the half that decides whether
1149
+ the switch is deleted or the cast just moves: the field now carries its resolver keys (`RangeField`'s
1150
+ fifth type parameter, inferred from `window`), and `toWindow` returns
1151
+ `{ window: Exclude<P, W> } | { from; to }`. A `resolveWindow` whose target is
1152
+ `{ window: '7d' | '30d' | '90d' | 'all' } | { from; to }` becomes a one-line delegation with no
1153
+ `as`. One guard survives on a `custom: true` field: a `'custom'` preset that arrives WITHOUT dates
1154
+ resolves to `{ window: 'custom' }`, so `'custom'` stays in the union by construction.
1155
+
1156
+ **A field setter takes an optional second argument.** `set(next, { patch })` merges extra search
1157
+ params into the SAME navigate — URL lane only, for keys the store does not own (`{ patch: {
1158
+ detailDate: undefined } }` clears a sibling param the page put there). The field's own params always
1159
+ win over the patch, so it cannot corrupt the value being set. A local/memory-lane write has no
1160
+ navigate to merge into and ignores it.
1161
+
1162
+ Two limits are now enforced rather than documented. A patch key **another field of the same store
1163
+ owns** is refused — it would reach the URL while the store's own write path never touched the mirror,
1164
+ so the next paramless visit and every `linkSearch` link resolve the OLD value: `createSearchStore`
1165
+ throws in dev, and in production keeps the write and logs it once. Write that field through its own
1166
+ setter instead. And a patched write **from a route that does not validate the field** persists (A1)
1167
+ but drops the patch with it — there is no navigate to merge into — which now warns once per field in
1168
+ dev instead of doing nothing.
1169
+
1170
+ **A fallback may be a thunk.** `field.*({ fallback: () => T })` (every kind — `field.number`'s codec
1171
+ froze its thunk at store definition in the first cut of this minor and does not now) is resolved at
556
1172
  READ time and re-resolved while nothing is written, and is never persisted on its own — a write
557
1173
  stores what the control produced, as before. **Local and memory lanes only:** `createSearchStore` now
558
1174
  THROWS at definition for a thunk on a URL-lane field, because `validateSearch` would evaluate it on
@@ -747,297 +1363,6 @@ half is deliberately NOT exempted the same way — a local `StatCard` beside an
747
1363
  `import { StatCard as Base }` kept the name AND a piece of the original, which is the fork shape this
748
1364
  rule most wants to see.
749
1365
 
750
- ### Guards — `basalt/query-dual-import` removed
751
-
752
- **Retired, not merely relaxed** — its `RETIRED_RULE_IDS` entry (kept recognized, so an existing
753
- `theme-allow query-dual-import` still reads as a dead waiver rather than a typo) and its
754
- `PLUGIN_RULE_GRACE` ledger row are both deleted. Its whole premise — a raw `@tanstack/react-query`
755
- import beside `basalt-ui/query` — went unreachable the moment `./query` dropped (see § Consolidation
756
- above): a consumer now MUST import `@tanstack/react-query` directly, and the rule warned on every
757
- one of those imports instead of the drift it was written to catch (90+ false positives on a single
758
- consumer). `query-fn-unwrap` is unaffected — it is a different rule, still `warn` (grace → 1.30.0).
759
-
760
- ### `basalt-ui/data` — a typed facet id, and a required `getItemKey`
761
-
762
- **`BasaltDataTable`: a mistyped `facets[].columnId` used to render no pill, silently — `if
763
- (!column) return null`.** It now throws in dev, naming the id and every known column id
764
- (`facet columnId "…" matches no column`); production still degrades to no pill for that one facet.
765
- `DataTableFacet` also gained a type parameter — `DataTableFacet<T = unknown>` — so `columnId` is
766
- typed off the row shape wherever TanStack's `accessor`/manual `id` inference allows it
767
- (`Extract<keyof T, string> | (string & {})`); the default keeps a bare `DataTableFacet[]`
768
- compiling unchanged. `BasaltDataTableProps.facets` is now `DataTableFacet<T>[]`, and
769
- `initialColumnPinning` moved from TanStack's own `ColumnPinningState` to a new, `T`-derived
770
- `DataTableColumnPinning<T>` (structurally assignable to `ColumnPinningState`, so no cast is
771
- needed at either end). Both are additive for existing call sites — an id that was already a real
772
- column keeps compiling and rendering exactly as before.
773
-
774
- **`BasaltVirtualList.getItemKey` is now required.** The index fallback hid stale rows across a
775
- mutation: an insert/delete shifts every index below it, so the OLD item kept rendering at each
776
- shifted index until an unrelated re-render happened to catch it up — in the one component whose
777
- job is a long mutable list. Pass a real per-item key (`(item) => item.id`); there is no
778
- replacement for the index fallback.
779
-
780
- **`BasaltVirtualList` gained a ref.** `ref?: Ref<BasaltVirtualListHandle>` exposes
781
- `scrollToIndex`/`scrollToOffset`/`scrollToEnd` (delegating to the internal
782
- `@tanstack/react-virtual` instance) plus `getVirtualizer()` as the full escape hatch — mirrors
783
- `./data/table`'s `useReactTable` re-export as the table's own. Additive; omitting `ref` changes
784
- nothing.
785
-
786
- ### `basalt-ui/commands` — `BasaltOverlays` no longer WRAPS your app in `ModalsProvider`
787
-
788
- **Same props, same imperative API, one structural change: every layer — `ModalsProvider`
789
- included — now renders as a SIBLING of `children` instead of an ancestor.** The old shape put the
790
- whole app under a `React.lazy` Suspense boundary (`<Suspense fallback={<>{app}</>}>
791
- <LazyModalsProvider>{app}</LazyModalsProvider></Suspense>`), and `React.lazy` suspends on its first
792
- render even when the module is already warm. Two consequences, both real: the app's FIRST COMMIT was
793
- deferred past a microtask — long enough for TanStack Router's async `loadMatches` to `setState` onto
794
- a not-yet-mounted fiber ("Can't perform a React state update on a component that hasn't mounted
795
- yet", once per full page load) — and the app tree mounted TWICE, once inside the fallback and once
796
- resolved, so every effect in the app ran twice on boot.
797
-
798
- Nothing to change for the overlay APIs. `modals.open` / `modals.openConfirmModal` (and so basalt's
799
- own `overlays.open` / `overlays.close`) go through `@mantine/modals`' window CustomEvent bus, which
800
- `ModalsProvider` subscribes to — being inside it was never a requirement, and the provider renders
801
- its own managed `Modal` regardless of where it sits.
802
-
803
- **The one thing a sibling cannot serve is React CONTEXT**: `useModals()` from `@mantine/modals`
804
- called inside your app now throws its "was called outside of context" error. If you use it (or
805
- `openContextModal`, which needs the provider's `modals` map that `BasaltOverlays` never passed
806
- anyway), mount your own provider and turn basalt's off — two providers would both answer the event
807
- bus and open every modal twice:
808
-
809
- ```tsx
810
- <BasaltOverlays modals={false}>
811
- <ModalsProvider modals={CONTEXT_MODALS}>
812
- <App />
813
- </ModalsProvider>
814
- </BasaltOverlays>
815
- ```
816
-
817
- ### `basalt-ui/controls` — the mobile `Filters (n)` sheet renders panel rows, not its own list
818
-
819
- **The sheet's `SheetOptionList` (44px rows, a hairline between them, one per option) is gone.**
820
- `useFilterSurface() === 'sheet'` now resolves to the SAME `PanelRow`/`PanelChoice`/facet-list body
821
- `'panel'` already renders (`docs/CONTROLS-SPEC.md` §3: "sheet = panel rows inside a Drawer") — label
822
- above, full-width control below, folding past `PANEL_TRACK_MAX` (3, a `Select`) or `max` (a
823
- `MultiSelectFilter`'s `Show N more`) the same way the aside already did. A set with many options no
824
- longer grows the sheet to a full column of option rows. `RangeFilter`'s sheet form is the panel's one
825
- `Select` (presets plus a `Custom range…` row revealing the injected picker) instead of a
826
- `SheetOptionList` with the picker behind a separate disclosure row.
827
-
828
- Also fixed in the same pass: a `fullWidth` `SegmentedControl` (`PanelChoice`'s ≤3-option form, and
829
- `ViewTabs`) now splits its options EVENLY — Mantine's own `.control` carries no `min-width`
830
- override, so the initial `auto` floored each option at its content width and a `fullWidth` track
831
- split unevenly; a long label now ellipsizes inside its equal share instead of forcing the column
832
- wide. The active segment's fill is a second ink-mix step
833
- (`color-mix(in srgb, var(--vx-ink) 12%, var(--vx-surface-panel))`), not a flat `--vx-surface-panel`,
834
- so it stays visible against its own track when the whole control sits on a panel-coloured surface (a
835
- `PanelRow`), where a flat panel fill read as barely-there next to the 6%-tinted track around it.
836
-
837
- | Removed | Replacement | Note |
838
- | ---------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
839
- | `SheetOptionList`, `SheetField`, `SheetDisclosure` (`basalt-ui/controls`) | `PanelRow` / `PanelChoice` (already exported) | Were never part of the public `./controls` barrel — internal to `filter-sheet.tsx` — so this only breaks a consumer test that imported them directly out of `basalt-ui/controls/filter-sheet` or hooked a `role="radio"` `<fieldset>` DOM shape inside the sheet (now a `PanelChoice` `SegmentedControl`, `role="radiogroup"`, or a `Select`). |
840
- | `SheetRow`, `sheetRowClassNames` (`basalt-ui/controls`) | none | Also internal-only. `ToggleFilter`'s sheet form is now a `PanelRow` whose `Switch` rides the label line, the same shape the panel surface always used. |
841
- | `.sheetOption` / `.sheetList` / `.sheetField` / `.sheetRow` / `.sheetDisclosureBody` / `.sheetLabel` (`controls.module.css`) | `panel-row.module.css`'s `.row`/`.head`/`.control`/`.label` | CSS-module classes were never a public contract; named for a consumer that vendored a copy of the sheet's old CSS. |
842
-
843
- ### The shell scrollport — Main scrolls, not the window
844
-
845
- `BasaltShell` dropped Mantine's `layout="alt"` (the header now spans the full viewport width, above
846
- the sidebar and the aside, instead of being inset beside the sidebar) and gave `AppShell.Main` a
847
- bounded height with `overflow: auto`. Main is therefore THE scrolling element: the document's
848
- `scrollHeight` equals the viewport height, the browser scrollbar sits on Main's own right edge
849
- (inside the aside, where the content is) instead of at the far edge of the window, and
850
- `window.scrollY` is pinned at 0.
851
-
852
- Main carries two handles: `data-basalt-scrollport` (basalt's own, and what `scrollParentOf` looks
853
- for) and `data-scroll-restoration-id="basalt-main"` (the attribute `@tanstack/router-core`'s scroll
854
- restoration reads, so a router-driven app restores this element rather than the window).
855
-
856
- What to change in a consumer:
857
-
858
- | If your app… | Do this |
859
- | ------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
860
- | reads `window.scrollY` / listens for `scroll` on `window` | `const port = scrollParentOf(el)` and read `port.scrollTop` / listen on `port` — `null` still means the window, so shell-less code is unchanged |
861
- | scrolls with `window.scrollTo` / `document.scrollingElement` | target `document.querySelector('[data-basalt-scrollport]')` first, with the document as the fallback |
862
- | uses window-based scroll restoration | point it at `[data-scroll-restoration-id="basalt-main"]`; TanStack Router picks it up with no config |
863
- | overrides `--vx-space-sticky-header-clearance` | the token NO LONGER includes the app-shell header height — it is `anchors.stackMd` (12px at level 0), pure breathing room, because BOTH the header and the page-bar band are regions outside the scrollport |
864
- | passes `BasaltDataTable stickyHeaderOffset` inside a shell | drop it entirely. Nothing overhangs the scrollport any more, so a sticky table head wants `top: 0`, which is the default |
865
- | computes a viewport-filling body from `--basalt-page-bar-h` | inside a shell that is just `100%` now — the band is outside Main rather than scrolling with it. The var is still published |
866
- | passes `mobileNav.getScrollElement` only to reach the page scroller | you can drop it — the default resolves the scrollport now |
867
-
868
- Three rendering changes ride along, all visible without any code change:
869
-
870
- - **The brand moved out of the sidebar and into the header.** The header spans the full viewport
871
- width now, so the sidebar's `appShellHeaderHeight` brand row painted as a SECOND 48px band under
872
- the header seam. It is the header's leading zone instead — `--app-shell-navbar-offset` wide, so it
873
- still tracks the rail collapse and still ends on the sidebar\|main seam — and the sidebar column
874
- starts at `SidebarSearch`. `brand`, `brand.menu` and the collapse toggle are unchanged; only the
875
- markup's home moved. Two consequences for a consumer mounting `AppSidebar` DIRECTLY (rather than
876
- through `BasaltShell`, which is the supported path): it no longer paints a brand row, and its
877
- `onToggleCollapse` prop is no longer read there — `BasaltShell` feeds it to the header instead.
878
- - **`PageBar` row 2 is a shell-owned BAND**, not a row in the page: it portals into an outlet
879
- `BasaltShell` renders between the header and the scrollport, exactly as row 1 portals into the
880
- header. It spans Main's width, carries `--vx-surface-bg` and one `--vx-divider` hairline, and is a
881
- zero-height, seam-less box on a route with no `PageBar`. It is no longer sticky and no longer
882
- depends on where you wrote `<PageBar>` — nested in a `Stack` or written as Main's direct child now
883
- produce identical geometry. Its padding-block tightened from `--vx-space-stack-sm` to
884
- `--vx-space-stack-xs`, so the bar is 8px shorter.
885
- - **`BasaltDataTable` contains itself horizontally by default.** A table with neither `maxHeight`
886
- nor `minWidth` used to render a bare `<table>`, which sizes to its own min-content — a five-column
887
- table measured 448px inside a 390px viewport and dragged the whole page sideways. Every table now
888
- renders inside `Table.ScrollContainer type="native"` (`minWidth: 0` when you set none), so a wide
889
- table scrolls inside its own card. `stickyHeader` with neither prop cannot take that container —
890
- an `overflow-x` box computes `overflow-y` to `auto` and becomes the header's scrollport, and with
891
- no height cap it has no scroll range, so the header would go inert — so it takes a wrapper whose
892
- overflow is **measured** instead: while the table fits its container the wrapper declares no
893
- overflow at all and the page-sticky header sticks exactly as before; once the table is wider the
894
- wrapper flips to `overflow-x: auto`, the columns stay reachable, and the header is inert at that
895
- width. The flip is a live `ResizeObserver` reading in both directions, so the wrapper goes back to
896
- bare when the space returns (the window widened, the sidebar collapsed, the aside closed), and it
897
- publishes `data-contained="true|false"`. The dev warning on that shape now states the trade rather
898
- than a defect and points at `maxHeight` for a table that must both scroll and stick. Pinning takes
899
- the same wrapper: the `overflow-x: auto` `Box` a pinned, uncapped, sticky table used to get is
900
- gone, because it was the inert-header shape it was meant to avoid. **The TOOLBAR was the other
901
- half of the same defect and is fixed with it**: the toolbar `Group` is the header row's flex item
902
- (`CtlSlot` between them is `display: contents`) and carried `flex: 0 0 auto`, so a 220px search
903
- beside a 230px facet pill row was an unshrinkable 461px box in a 302px column — `AppShell.Main`
904
- measured `scrollWidth` 505 against `clientWidth` 390 at 390x844. It is now `flex: 0 1 auto` with
905
- `min-width: 0`, and the search's `w={220}` became a flex BASIS (`flex: 0 1 220px`, floored at
906
- `12ch`, capped at `100%`). The field still resolves to 220px wherever 220 fits and still never
907
- grows with the table; below that the search and the pills wrap onto their own lines.
908
-
909
- ### Consolidation (targeting 1.29.0)
910
-
911
- **Dropped subpaths — three, all folded into `.`/`./provider`, none deleted outright.**
912
-
913
- <!-- C1 symbol list -->
914
-
915
- - `./query` — dropped. `createBasaltQueryClient`, `unwrap`, `BasaltQueryDevtools`,
916
- `toErrorMessage`, `errorStatus` now on `.` (root barrel) — import from `basalt-ui` instead of
917
- `basalt-ui/query`. The nine raw TanStack re-exports it carried (`QueryClientProvider`,
918
- `QueryErrorResetBoundary`, `useQueryErrorResetBoundary`, `useQuery`, `useSuspenseQuery`,
919
- `useMutation`, `useQueryClient`, `useInfiniteQuery`, `queryOptions`) are gone from basalt entirely —
920
- import them from `@tanstack/react-query`, which the root entry already requires.
921
- - `./data/table` — **narrowed, not dropped**: `BasaltDataTable`, its types and `createColumnHelper`
922
- stay; the TanStack pass-throughs (`useReactTable`, `flexRender`, `getCoreRowModel`,
923
- `getSortedRowModel`, `getFilteredRowModel`, `getPaginationRowModel`, `ColumnDef`, `SortingState`,
924
- `ColumnHelper`, `PaginationState`, `ColumnPinningState`, `RowSelectionState`, `ColumnFiltersState`)
925
- are imported from `@tanstack/react-table` directly.
926
- - `./connectivity` — dropped. `ConnectivityProvider` (auto-mounted by `BasaltProvider`, no
927
- consumer-side import needed), `useConnectivity`, `ConnectivityIndicator` now on `.`.
928
- - `./data` (bare) — dropped. It never carried its own exports beyond re-exporting `./data/table`
929
- and `./data/virtual` — import the narrow subpath you need directly; nothing renamed.
930
- - `./controls-dates` — **unaffected**, stays a separate subpath (inlining it into `./controls`
931
- would pull `@mantine/dates` into a subpath that must resolve without it).
932
-
933
- **Deleted `@deprecated` shims** (every one slated for 1.29.0 removal in an earlier minor's
934
- deprecation row — see that minor's own `MIGRATING.md` section for the original `@deprecated`
935
- annotation and rationale):
936
-
937
- - `state.ts`'s legacy connectivity export — use `./provider`'s `useConnectivity` instead.
938
- - `BasaltProvider`'s flat `sseUrl` / `healthUrl` / `healthIntervalMs` props — use
939
- `connectivity={{ sseUrl, healthUrl, healthIntervalMs }}`.
940
- - `QueryStateVariant` and the `variant` prop on `QueryState`/`LoadingState`/`ErrorState`/`EmptyState`
941
- — use `QueryStateTier` and `tier` (identical values).
942
- - `createSearchParamStore` / `createMultiSearchParamStore` (`basalt-ui/router-tanstack`) and the
943
- legacy branches they kept alive in the field vocabulary — use `createSearchStore` with
944
- `field.enum` / `field.multi`.
945
- - `field` on `basalt-ui/forms` — use `inputProps(form, path)` + `fieldKey(form, path)`.
946
- - `BasaltNotifications` / `BasaltNotificationsProps` (`basalt-ui/notifications`) — mount
947
- `<BasaltOverlays notifications />` from `basalt-ui/commands` instead. **Doctrine exception,
948
- recorded**: this one never carried a `deprecated-export` row; it was superseded in 1.2x and its own
949
- JSDoc warned against mounting it next to `BasaltOverlays`, so it leaves in the same minor as the
950
- other shims. The `duplicate-notifications-mount` lint rule that only existed to catch the
951
- double-mount is retired with it.
952
- - `ConnectivityIndicatorProps` stays on the root barrel (it is `BasaltProps`).
953
-
954
- **CLI** (`basalt-ui`): `info` and `check-coverage` are gone — `check-coverage` is a repo-internal
955
- script (`bun scripts/check-coverage.ts`), not a consumer command; `info` graded a consumer's
956
- `vite.config.ts` by parsing it and nobody read the output. `doctor` checks THIS package only: the
957
- workspace-wide `ai` major-parity walk (with `!`-exclusions and `aiMajorSkewReason`) and the
958
- `basaltAppPlugin({ icons })` file check are dropped. One resolver remains for every command —
959
- `BASALT_CWD`, then cwd, plus a declared `basalt.roots`; nothing is inferred from siblings or parents.
960
-
961
- - `ZonedLine`/`Bars`' `ZoneSpec` aliases — use the canonical `AxisConfig`-scoped zone type each
962
- kind now exports directly.
963
- - `inputProps`'s `key` return field (already shape-changed in an earlier Unreleased entry above) —
964
- fully removed this minor; use `fieldKey(form, name)` alongside `inputProps(form, name)`.
965
-
966
- **On notice for 1.30.0** — a public export with exactly one consumer (the playground) as of this
967
- audit. Adopt-or-delete: gains a real consumer by 1.30.0 or is removed. Not a promise either way;
968
- record your adoption in `docs/ARGO-MIGRATION-LEARNINGS.md` if you pick one up.
969
-
970
- - **Threads/agent-chat composite tier**: `ThreadWorkspace`, `ThreadFeed`, `ThreadFeedRow`,
971
- `ThreadDetailPanel`, `ThreadOutcomeCard`, `ThreadsStoreAdapter`, `createThreadsStore`,
972
- `createAdapterThreadsStore`, `threadsStoreAdapterContract`, `threadPartRenderers`,
973
- `definePartRenderers`.
974
- - **Chart kind**: `Heatmap` only — `DualPanel` has an argo consumer and `MirroredBars`/`BandStrip`
975
- are consumed by linewatch, so those three are NOT on notice.
976
- - **Nav/shell**: `PageAside`, `PanelRow`, `SettingsRow` (playground-only as of this audit).
977
- - **Controls**: `SliderControl`, `SearchFilter`, `ToggleFilter`, `CompareFilter`, `OverflowMenu`,
978
- `ControlGroup`.
979
-
980
- **`common/**`— the public common primitives, as a unit.**`src/common/\*\*`(shipped from the
981
- maturation round) is now the one place base prop/error/ref vocabulary lives:`BasaltProps`,
982
- `SlotStylesProps`, `cx`, `mergeRefs`/`assignRef`, the prefixed `errors.ts` table
983
- (`toErrorMessage`/`errorStatus`), `useValidateProps`, `assertRequiredProps`. Every basalt component
984
- extends `BasaltProps`for its own prop type rather than redeclaring`className`/`style`; a consumer
985
- composing over a basalt component should do the same.
986
-
987
- ### Additions (targeting 1.29.0)
988
-
989
- C5 consolidation — every item below names the argo lines it lets a consumer delete
990
- (`.claude/maturation/consolidation-plan.md` §C5, `audit-f-argo-consumer.md`). All additive; nothing
991
- in this block removes or renames an existing export.
992
-
993
- - **`basalt-ui/format`** — new subpath, Mantine-free and React-free: `money`, `percent`, `integer`,
994
- `compact`, `deltaPct`, `duration` (seconds → `"1h 02m"`; `{ unit: 'minutes' }` for a
995
- minutes-based input), `durationClock` (`"1:02:03"`), `clock`, `relativeTime`, `weekday`, `km`,
996
- `kcal` — plus the original chart-formatter names (`fmtCompact`/`fmtPercent`/`fmtCurrency`/
997
- `fmtInt`/`fmtAxisDate`/`fmtTooltipDate`/`NON_FINITE`/`formatters`), now re-exported from here;
998
- `./charts`' `utils/format.ts` is an internal re-export shim, not a second implementation.
999
- - **`ChartState.empty`** widens to `boolean | string` — a string is both truthy and the label
1000
- `ChartEmpty` paints. `ChartCard` gains `state?: ChartState`, `placeholderHeight?: number`
1001
- (default 240) and `stateAction?: ReactNode`, and `children` is now optional so a title-less
1002
- pending card can be a route's own `<Suspense fallback>`. Resolution order is the same
1003
- `resolveChartState` precedence every kind already has — pending → error → empty — and
1004
- `state={{}}`/omitting `state` renders `children` unchanged. `ChartCenter`'s `width` widens to
1005
- `number | string` (`'100%'` for a non-measured block body).
1006
- - **`DualPanel`'s `fillBetween` gains `aboveFill?: string`** — the above-side fill color; omitted,
1007
- both sides keep sharing `fill` (unchanged behaviour). **`AxisBottomNumeric`** exported from
1008
- `./charts` — the numeric twin of `AxisLeftNumeric`, for a bespoke continuous-x plot.
1009
- - **`useBreakpoint(name, edge?, options?)`** on the root barrel — SSR/hydration-safe media query
1010
- over `theme.breakpoints`, built on `useSyncExternalStore` (not `@mantine/hooks`' `useMediaQuery`,
1011
- which mismatches during hydration). `edge` is `'min'` (default, "at least this wide") or `'max'`.
1012
- `page-aside.tsx` now composes this hook's own internals instead of a private duplicate.
1013
- - **`--vx-space-touch-target` / `VX.spaceTouchTarget`** — the WCAG touch-target floor (44px),
1014
- density-exempt. The one `SPACE_FIXED` member that IS emitted as a CSS var (every other one stays
1015
- JS-only) — a consumer's own `@media (pointer: coarse)` CSS-module rule reads it directly.
1016
- - **`SettingsMenuItem.active?: boolean`** — renders a trailing check glyph and `aria-current` in
1017
- every projection (the sidebar's flat rows and gear menu, the mobile More sheet).
1018
- - **`basalt-ui/commands` gains imperative shell handles**: `setColorScheme(scheme)` and
1019
- `toggleSidebar()`, wired automatically — `BasaltProvider` registers the Mantine color-scheme
1020
- setter, `BasaltShell` registers the collapse toggle, so a command's `run` calls either with zero
1021
- `__root.tsx` bridge wiring (unlike the hand-rolled `color-scheme-bridge.ts`/`sidebar-bridge.ts`
1022
- shape this seeds from). `Slot<K, Constraint>` (`register.ts`) changed its internal resolution
1023
- from a whole-interface mapped-type match to an indexed-access form — same public behaviour,
1024
- fixes the commands↔overlays same-file type cycle a command calling `overlays.open(...)` used to
1025
- hit while `defineCommands`'s own type was still being inferred.
1026
- - **`BasaltDevDock`** on the root barrel — a fixed, dev-only bottom drawer hosting TanStack Router
1027
- devtools, TanStack Query devtools and the theme lab, each peer lazy-loaded. New optional peer:
1028
- `@tanstack/react-router-devtools`.
1029
- - **`BasaltErrorBoundaryProps`** is now exported; `onError` is optional (defaults to the same
1030
- console-warn-in-dev fallback `BasaltProvider`'s own top-level `onError` uses); an omitted
1031
- `fallback` now renders a minimal built-in default (a `PageTitle` + reload button) instead of
1032
- `null` — pass `fallback={null}` explicitly for the old swallow-it behaviour. **`PageTitle`** is a
1033
- new shell-less page-title primitive on the root barrel (a plain `<h1>`, not Mantine's `<Title>`,
1034
- so it needs no `in-body-page-title` waiver) — the remedy for the `in-body-page-title` waiver a
1035
- shell-less error/auth page used to need.
1036
- - **`unwrap` gains a second overload** — the original took only a `Promise` of the `{ data, error }`
1037
- envelope; it now also accepts the ALREADY-RESOLVED envelope directly (`unwrap(await api.x.get())`,
1038
- or as a bare `.then(unwrap)` callback), inferring which overload from whether the argument IS a
1039
- `Promise`. The absence guard also now throws on `undefined` data, not just `null`.
1040
-
1041
1366
  ## 1.26.0 — the control tier, the page bar and the store
1042
1367
 
1043
1368
  **One export removed and two deprecated — see § Stores below; two shell PROPS removed — see
@@ -1057,7 +1382,7 @@ happened to the nine rows below.
1057
1382
  (`shadow-basalt-export`). Both ledgers now hold only the wave-6 control guards listed under
1058
1383
  § Guards — six plugin rules and two guard kinds, each `{ since: '1.26.0', promote: '1.27.0' }` —
1059
1384
  which the C16 gate forced to promote or be deleted at 1.27.0. Five rules and one kind promoted there;
1060
- the C1 pair was re-dated to 1.30.0 against a measurement (see `## Unreleased` above). A theme-allow escape written against
1385
+ the C1 pair was re-dated to 1.30.0 against a measurement (see 1.27.0 § Guards above). A theme-allow escape written against
1061
1386
  any of these still works unchanged; only the SEVERITY of an unwaived finding moves from `warn` to
1062
1387
  `error`.
1063
1388