basalt-ui 1.24.0 → 1.26.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 (467) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +25 -0
  3. package/MIGRATING.md +547 -21
  4. package/README.md +46 -33
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/query-state.d.ts +1 -1
  134. package/dist/dashboard/settings-section.d.ts +6 -4
  135. package/dist/dashboard/settings-section.d.ts.map +1 -1
  136. package/dist/dashboard/settings-section.js +23 -16
  137. package/dist/dashboard/settings-section.module.css +10 -0
  138. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  139. package/dist/dashboard/settings-section.test.d.ts +2 -0
  140. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  141. package/dist/dashboard/stat-card.d.ts +44 -9
  142. package/dist/dashboard/stat-card.d.ts.map +1 -1
  143. package/dist/dashboard/stat-card.js +80 -48
  144. package/dist/dashboard/stat-card.module.css +82 -0
  145. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  146. package/dist/data/data-table.d.ts +102 -20
  147. package/dist/data/data-table.d.ts.map +1 -1
  148. package/dist/data/data-table.js +166 -65
  149. package/dist/data/table.d.ts +1 -1
  150. package/dist/data/table.d.ts.map +1 -1
  151. package/dist/guard/grace.test.d.ts +20 -0
  152. package/dist/guard/grace.test.d.ts.map +1 -0
  153. package/dist/guard/index.d.ts +66 -3
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +82 -7
  156. package/dist/guard/types.d.ts +3 -3
  157. package/dist/guard/types.d.ts.map +1 -1
  158. package/dist/index.d.ts +12 -2
  159. package/dist/index.d.ts.map +1 -1
  160. package/dist/index.js +13 -10
  161. package/dist/nav/types.d.ts +78 -2
  162. package/dist/nav/types.d.ts.map +1 -1
  163. package/dist/notifications/bell.d.ts +4 -1
  164. package/dist/notifications/bell.d.ts.map +1 -1
  165. package/dist/notifications/index.d.ts +1 -1
  166. package/dist/router-tanstack/field.d.ts +14 -0
  167. package/dist/router-tanstack/field.d.ts.map +1 -0
  168. package/dist/router-tanstack/field.js +4 -0
  169. package/dist/router-tanstack/index.d.ts +6 -2
  170. package/dist/router-tanstack/index.d.ts.map +1 -1
  171. package/dist/router-tanstack/index.js +4 -0
  172. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  173. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  175. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  176. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  177. package/dist/router-tanstack/search-param-store.js +16 -45
  178. package/dist/router-tanstack/search-store.d.ts +81 -0
  179. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.js +187 -0
  181. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  182. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  183. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  184. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/use-nav.d.ts +0 -2
  186. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  187. package/dist/router-tanstack/use-nav.js +34 -12
  188. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  189. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  190. package/dist/section/index.d.ts +2 -0
  191. package/dist/section/index.d.ts.map +1 -0
  192. package/dist/section/index.js +4 -0
  193. package/dist/section/section.d.ts +24 -0
  194. package/dist/section/section.d.ts.map +1 -0
  195. package/dist/section/section.js +126 -0
  196. package/dist/section/section.module.css +104 -0
  197. package/dist/section/section.module.css.d.ts +11 -0
  198. package/dist/section/section.test.d.ts +2 -0
  199. package/dist/section/section.test.d.ts.map +1 -0
  200. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  201. package/dist/shell/app-breadcrumbs.js +18 -15
  202. package/dist/shell/app-header.module.css +77 -37
  203. package/dist/shell/app-header.module.css.d.ts +2 -1
  204. package/dist/shell/app-mobile-nav.d.ts +9 -2
  205. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  206. package/dist/shell/app-mobile-nav.js +98 -11
  207. package/dist/shell/app-mobile-nav.module.css +8 -0
  208. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  209. package/dist/shell/app-sidebar.d.ts +55 -28
  210. package/dist/shell/app-sidebar.d.ts.map +1 -1
  211. package/dist/shell/app-sidebar.js +116 -67
  212. package/dist/shell/app-sidebar.module.css +303 -20
  213. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  214. package/dist/shell/index.d.ts +55 -21
  215. package/dist/shell/index.d.ts.map +1 -1
  216. package/dist/shell/index.js +40 -18
  217. package/dist/shell/mobile-nav-model.d.ts +13 -2
  218. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  219. package/dist/shell/mobile-nav-model.js +15 -1
  220. package/dist/shell/nav-count-badge.d.ts +1 -1
  221. package/dist/shell/page-bar.d.ts +51 -0
  222. package/dist/shell/page-bar.d.ts.map +1 -0
  223. package/dist/shell/page-bar.js +154 -0
  224. package/dist/shell/page-bar.module.css +178 -0
  225. package/dist/shell/page-bar.module.css.d.ts +17 -0
  226. package/dist/shell/page-bar.test.d.ts +2 -0
  227. package/dist/shell/page-bar.test.d.ts.map +1 -0
  228. package/dist/shell/sidebar-block-model.d.ts +48 -0
  229. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  230. package/dist/shell/sidebar-block-model.js +39 -0
  231. package/dist/shell/sidebar-blocks.d.ts +32 -0
  232. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  233. package/dist/shell/sidebar-blocks.js +169 -0
  234. package/dist/shell/sidebar-search.d.ts +13 -1
  235. package/dist/shell/sidebar-search.d.ts.map +1 -1
  236. package/dist/shell/sidebar-search.js +34 -1
  237. package/dist/shell/sidebar-search.module.css +26 -0
  238. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  239. package/dist/state/fields.d.ts +392 -0
  240. package/dist/state/fields.d.ts.map +1 -0
  241. package/dist/state/fields.js +369 -0
  242. package/dist/state/fields.test.d.ts +2 -0
  243. package/dist/state/fields.test.d.ts.map +1 -0
  244. package/dist/state/persisted.d.ts +48 -0
  245. package/dist/state/persisted.d.ts.map +1 -0
  246. package/dist/state/persisted.js +105 -0
  247. package/dist/state.d.ts +11 -56
  248. package/dist/state.d.ts.map +1 -1
  249. package/dist/state.js +14 -120
  250. package/dist/styles.css +13 -0
  251. package/dist/surfaces.d.ts +106 -24
  252. package/dist/surfaces.d.ts.map +1 -1
  253. package/dist/surfaces.js +155 -19
  254. package/dist/theme/controls.module.css +16 -1
  255. package/dist/theme/ctl-theme.d.ts +32 -0
  256. package/dist/theme/ctl-theme.d.ts.map +1 -0
  257. package/dist/theme/ctl-theme.js +54 -0
  258. package/dist/theme/ctl-theme.test.d.ts +2 -0
  259. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  260. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  261. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  262. package/dist/theme/divider-law.test.d.ts +2 -0
  263. package/dist/theme/divider-law.test.d.ts.map +1 -0
  264. package/dist/theme/icon-slot.d.ts +44 -0
  265. package/dist/theme/icon-slot.d.ts.map +1 -0
  266. package/dist/theme/icon-slot.js +21 -0
  267. package/dist/theme/icon-slot.module.css +47 -0
  268. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  269. package/dist/theme/icon-slot.test.d.ts +2 -0
  270. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  271. package/dist/theme/index.d.ts +1 -0
  272. package/dist/theme/index.d.ts.map +1 -1
  273. package/dist/theme/index.js +82 -5
  274. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  275. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  276. package/dist/theme/segmented-control.module.css +20 -0
  277. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  278. package/dist/tokens/index.d.ts +12 -0
  279. package/dist/tokens/index.d.ts.map +1 -1
  280. package/dist/tokens/index.js +38 -8
  281. package/dist/tokens/palette.d.ts +97 -61
  282. package/dist/tokens/palette.d.ts.map +1 -1
  283. package/dist/tokens/palette.js +76 -38
  284. package/dist/tokens.css +10 -2
  285. package/dist/widget-header/delta-badge.d.ts +13 -0
  286. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  287. package/dist/widget-header/delta-badge.js +29 -0
  288. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  289. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  290. package/dist/widget-header/index.d.ts +3 -0
  291. package/dist/widget-header/index.d.ts.map +1 -0
  292. package/dist/widget-header/index.js +6 -0
  293. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  294. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  295. package/dist/widget-header/widget-header.d.ts +37 -0
  296. package/dist/widget-header/widget-header.d.ts.map +1 -0
  297. package/dist/widget-header/widget-header.js +77 -0
  298. package/dist/widget-header/widget-header.module.css +335 -0
  299. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  300. package/dist/widget-header/widget-header.test.d.ts +2 -0
  301. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  302. package/llms.txt +30 -17
  303. package/package.json +14 -1
  304. package/src/charts/cursor/resolve.test.ts +29 -1
  305. package/src/charts/cursor/resolve.ts +38 -6
  306. package/src/charts/cursor/scope.tsx +6 -3
  307. package/src/charts/cursor/store.test.ts +19 -1
  308. package/src/charts/cursor/store.ts +10 -5
  309. package/src/charts/hooks/useChartCursor.ts +16 -8
  310. package/src/charts/index.ts +2 -2
  311. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  312. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  313. package/src/charts/primitives/ChartCard.tsx +86 -146
  314. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  315. package/src/cli/check-coverage.test.ts +297 -0
  316. package/src/cli/check-theme.test.ts +38 -14
  317. package/src/cli/index.ts +392 -16
  318. package/src/cli/init-sync.test.ts +97 -0
  319. package/src/connectivity/connectivity-indicator.tsx +16 -9
  320. package/src/content/index.ts +16 -3
  321. package/src/content/prose.module.css +7 -28
  322. package/src/content/prose.module.css.test.ts +8 -24
  323. package/src/controls/actions.module.css +62 -0
  324. package/src/controls/actions.module.css.d.ts +13 -0
  325. package/src/controls/actions.test.tsx +252 -0
  326. package/src/controls/actions.tsx +655 -0
  327. package/src/controls/actions.type-guard.test.ts +63 -0
  328. package/src/controls/compare-filter.tsx +64 -0
  329. package/src/controls/control-group.test.tsx +229 -0
  330. package/src/controls/control-group.tsx +63 -0
  331. package/src/controls/controls.module.css +542 -0
  332. package/src/controls/controls.module.css.d.ts +35 -0
  333. package/src/controls/controls.router.test.tsx +637 -0
  334. package/src/controls/enum-filter.tsx +159 -0
  335. package/src/controls/filter-context.tsx +153 -0
  336. package/src/controls/filter-pill.tsx +120 -0
  337. package/src/controls/filter-set.test.tsx +472 -0
  338. package/src/controls/filter-set.tsx +225 -0
  339. package/src/controls/filter-sheet.tsx +285 -0
  340. package/src/controls/glyphs.tsx +81 -0
  341. package/src/controls/index.ts +48 -0
  342. package/src/controls/multi-select-filter.tsx +140 -0
  343. package/src/controls/peer-boundary.test.ts +85 -0
  344. package/src/controls/props-contract.test.tsx +106 -0
  345. package/src/controls/range-filter.tsx +184 -0
  346. package/src/controls/search-filter.tsx +123 -0
  347. package/src/controls/select-filter.tsx +89 -0
  348. package/src/controls/sync-button.module.css +63 -0
  349. package/src/controls/sync-button.module.css.d.ts +11 -0
  350. package/src/controls/sync-button.test.tsx +183 -0
  351. package/src/controls/sync-button.tsx +197 -0
  352. package/src/controls/toggle-filter.tsx +60 -0
  353. package/src/controls/view-tabs.tsx +110 -0
  354. package/src/controls-dates/date-range-picker.tsx +42 -0
  355. package/src/controls-dates/index.ts +8 -0
  356. package/src/dashboard/delta-badge.tsx +4 -74
  357. package/src/dashboard/query-state.tsx +1 -1
  358. package/src/dashboard/settings-section.module.css +10 -0
  359. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  360. package/src/dashboard/settings-section.test.tsx +56 -0
  361. package/src/dashboard/settings-section.tsx +33 -30
  362. package/src/dashboard/stat-card.module.css +82 -0
  363. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  364. package/src/dashboard/stat-card.test.tsx +67 -1
  365. package/src/dashboard/stat-card.tsx +209 -68
  366. package/src/data/data-table.test.tsx +282 -6
  367. package/src/data/data-table.tsx +429 -94
  368. package/src/data/table.ts +2 -0
  369. package/src/guard/check-source.test.ts +126 -13
  370. package/src/guard/grace.test.ts +149 -0
  371. package/src/guard/index.ts +172 -29
  372. package/src/guard/types.ts +4 -2
  373. package/src/index.ts +46 -5
  374. package/src/nav/types.ts +84 -2
  375. package/src/notifications/bell.tsx +6 -3
  376. package/src/notifications/index.ts +1 -1
  377. package/src/router-tanstack/field.ts +31 -0
  378. package/src/router-tanstack/index.ts +24 -2
  379. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  380. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  381. package/src/router-tanstack/search-param-store.ts +42 -159
  382. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  383. package/src/router-tanstack/search-store.test.ts +418 -0
  384. package/src/router-tanstack/search-store.ts +380 -0
  385. package/src/router-tanstack/use-nav.test.tsx +187 -0
  386. package/src/router-tanstack/use-nav.ts +72 -14
  387. package/src/section/index.ts +1 -0
  388. package/src/section/section.module.css +104 -0
  389. package/src/section/section.module.css.d.ts +11 -0
  390. package/src/section/section.test.tsx +204 -0
  391. package/src/section/section.tsx +220 -0
  392. package/src/shell/app-breadcrumbs.tsx +44 -31
  393. package/src/shell/app-header.module.css +77 -37
  394. package/src/shell/app-header.module.css.d.ts +2 -1
  395. package/src/shell/app-mobile-nav.module.css +8 -0
  396. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  397. package/src/shell/app-mobile-nav.tsx +147 -8
  398. package/src/shell/app-sidebar-account.tsx +2 -2
  399. package/src/shell/app-sidebar.module.css +303 -20
  400. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  401. package/src/shell/app-sidebar.test.tsx +456 -41
  402. package/src/shell/app-sidebar.tsx +276 -140
  403. package/src/shell/index.test.tsx +291 -5
  404. package/src/shell/index.tsx +137 -38
  405. package/src/shell/mobile-nav-model.test.ts +228 -7
  406. package/src/shell/mobile-nav-model.ts +53 -3
  407. package/src/shell/nav-count-badge.tsx +1 -1
  408. package/src/shell/page-bar.module.css +178 -0
  409. package/src/shell/page-bar.module.css.d.ts +17 -0
  410. package/src/shell/page-bar.test.tsx +316 -0
  411. package/src/shell/page-bar.tsx +315 -0
  412. package/src/shell/sidebar-block-model.ts +83 -0
  413. package/src/shell/sidebar-blocks.tsx +301 -0
  414. package/src/shell/sidebar-search.module.css +26 -0
  415. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  416. package/src/shell/sidebar-search.tsx +71 -2
  417. package/src/state/fields.test.tsx +258 -0
  418. package/src/state/fields.ts +892 -0
  419. package/src/state/persisted.ts +211 -0
  420. package/src/state.ts +46 -240
  421. package/src/styles.css +13 -0
  422. package/src/surfaces.ts +203 -36
  423. package/src/theme/controls.module.css +16 -1
  424. package/src/theme/ctl-theme.test.tsx +275 -0
  425. package/src/theme/ctl-theme.tsx +138 -0
  426. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  427. package/src/theme/density-relations.test.ts +106 -51
  428. package/src/theme/divider-law.test.ts +259 -0
  429. package/src/theme/icon-slot.module.css +47 -0
  430. package/src/theme/icon-slot.module.css.d.ts +8 -0
  431. package/src/theme/icon-slot.test.tsx +275 -0
  432. package/src/theme/icon-slot.tsx +59 -0
  433. package/src/theme/index.ts +165 -3
  434. package/src/theme/layout-rhythm-css.test.ts +72 -0
  435. package/src/theme/radius.test.ts +11 -1
  436. package/src/theme/segmented-control.module.css +20 -0
  437. package/src/theme/shadow-surfaces.test.ts +20 -7
  438. package/src/theme/spacing.test.ts +75 -27
  439. package/src/theme/use-basalt-spacing.ts +2 -2
  440. package/src/tokens/density.test.ts +7 -25
  441. package/src/tokens/index.ts +48 -8
  442. package/src/tokens/palette.ts +107 -73
  443. package/src/widget-header/delta-badge.test.tsx +60 -0
  444. package/src/widget-header/delta-badge.tsx +57 -0
  445. package/src/widget-header/index.ts +2 -0
  446. package/src/widget-header/no-mantine.test.ts +33 -0
  447. package/src/widget-header/widget-header.module.css +335 -0
  448. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  449. package/src/widget-header/widget-header.test.tsx +187 -0
  450. package/src/widget-header/widget-header.tsx +168 -0
  451. package/agent/rules/basalt-agent.md +0 -452
  452. package/agent/rules/basalt-app.md +0 -246
  453. package/agent/rules/basalt-commands.md +0 -320
  454. package/agent/rules/basalt-content.md +0 -409
  455. package/agent/rules/basalt-data.md +0 -269
  456. package/agent/rules/basalt-forms.md +0 -186
  457. package/agent/rules/basalt-notifications.md +0 -264
  458. package/agent/rules/basalt-query.md +0 -187
  459. package/agent/rules/basalt-router.md +0 -336
  460. package/dist/content/article-filter-bar.d.ts +0 -40
  461. package/dist/content/article-filter-bar.d.ts.map +0 -1
  462. package/dist/content/article-filter-bar.js +0 -56
  463. package/dist/shell/page-header.d.ts +0 -13
  464. package/dist/shell/page-header.d.ts.map +0 -1
  465. package/dist/shell/page-header.js +0 -23
  466. package/src/content/article-filter-bar.tsx +0 -88
  467. package/src/shell/page-header.tsx +0 -35
package/MIGRATING.md CHANGED
@@ -32,13 +32,497 @@ at 1.3.0, `./agent-chat` at 1.10.0.
32
32
 
33
33
  ## Unreleased
34
34
 
35
- **The heading has no number on purpose.** Three rounds running, the newest heading here named a
36
- version npm never served — `1.20.1`, then `1.21.1` — because this file is written before
37
- `semantic-release` computes the number, and a `feat:` in the batch turns the guess into a phantom.
38
- `shipped-versions.test.ts` now fails any file `init`/`sync` copy into a consumer that names a
39
- version `CHANGELOG.md` does not record. This file is not in that set; the same discipline applies
40
- by convention. Rename the section at release, or leave it — a reader can resolve `Unreleased`
41
- against `CHANGELOG.md`, and could never resolve `1.21.1`.
35
+ **One export removed and two deprecated — see § Stores below; two shell PROPS removed — see
36
+ § Sidebar. Two other behaviour changes: the grace ledgers changed shape, and nine
37
+ long-stale entries (D4, `docs/archive/CONTROLS-SYNTHESIS.md`) are promoted.** C16
38
+ (`docs/CONTROLS-SPEC.md` §1) is the new law behind both: a grace entry now carries `{ since,
39
+ promote, why }` (semver strings) instead of a bare promotion-note string, and a version-gated test
40
+ fails the build once `package.json`'s version reaches an entry's `promote` while the entry is still
41
+ in the ledger — and `make release` refuses to cut a release whose COMPUTED version has reached one,
42
+ which is the half that fires before the release rather than one minor after it. That is what stops a
43
+ rule sitting at `warn` for five minors with nothing tracking the promise, which is exactly what
44
+ happened to the nine rows below.
45
+
46
+ **`PLUGIN_RULE_GRACE`** (`configs/oxlint-plugin.js`) **and `GRACE_PERIOD_KINDS`**
47
+ (`src/guard/index.ts`) no longer carry any PRE-EXISTING entry — every one either promoted to `error`
48
+ (the table below) or moved to the new, permanent `PLUGIN_RULE_ADVISORY` ledger
49
+ (`shadow-basalt-export`). Both ledgers now hold only the wave-6 control guards listed under
50
+ § Guards — six plugin rules and two guard kinds, each `{ since: '1.26.0', promote: '1.27.0' }` —
51
+ which the C16 gate forces to promote or be deleted at 1.27.0. A theme-allow escape written against
52
+ any of these still works unchanged; only the SEVERITY of an unwaived finding moves from `warn` to
53
+ `error`.
54
+
55
+ | Rule / kind (promoted) | What now errors | Escape hatch |
56
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
57
+ | `basalt/hand-rolled-plot` | An unwaived chart-assembly node (`AxisLeftNumeric`, `AxisRightNumeric`, `AxisBottomDate`, `HoverOverlay`, `Crosshair`) outside a file composing `CartesianChart` | `theme-allow hand-rolled-plot — <why>` per node, or `theme-allow-file hand-rolled-plot — <why>` for a declared exception |
58
+ | `basalt/chart-legend-literal` | A hand-written array literal passed to `ChartLegend`'s `items` | Derive the legend from `series` (`deriveLegend`, or let `ChartFrame`/`CartesianChart` do it), or `theme-allow chart-legend-literal — <why>` |
59
+ | `basalt/hand-rolled-shell` | A hand-rolled app-shell assembly node instead of `BasaltShell` | `theme-allow hand-rolled-shell — <why>`, or `theme-allow-file` for the whole file |
60
+ | `theme-allow-unscoped` (guard kind) | A bare `// theme-allow` with no rule id | Name the kind(s): `theme-allow <kind> — <why>` |
61
+ | `surface-shadow-override` (guard kind) | A hand-composed `boxShadow` reaching a `--vx-*` token | Use the shipped shadow tokens, or waive with a reason |
62
+ | `css-raw-surface` (guard kind) | A raw surface color in kebab CSS (`.module.css`) | Same remedy as the JS/TSX form — a `--vx-*` var, or waive |
63
+ | `inline-font-size` (guard kind) | A hardcoded numeric font size in an inline `style` object | `VX.text.*` / `--vx-text-*`, or waive |
64
+ | `hidden-inline-style` (guard kind) | A style object defined once and spread into `style={...}` (evades the guard's inline-object scan) | Inline the object at the call site, or waive |
65
+
66
+ **`basalt/shadow-basalt-export` is unaffected** — it moves ledgers (`PLUGIN_RULE_GRACE` →
67
+ `PLUGIN_RULE_ADVISORY`), not severity. It stays `warn` in the shipped preset, permanently, and is
68
+ never subject to the C16 gate: see "Lint and guard rules that tightened" below for why.
69
+
70
+ Consumers who never overrode `basalt.severity` for any of the eight kinds/rules above and never
71
+ waived them see these findings for the first time as `error`; measure against the shipped preset
72
+ before upgrading if that's a concern (`basalt-ui check-theme --audit-allows`, `oxlint .`).
73
+
74
+ ### Composers — WidgetHeader-backed renames (docs/CONTROLS-SPEC.md §2.2)
75
+
76
+ `StatCard`, `ChartCard`, `Section` (new), `SettingsSection`/`DangerZone` and `BasaltDataTable` now
77
+ each compose the wave-1 `WidgetHeader` primitive for their title row instead of hand-rolling one —
78
+ several props renamed or were added to line up with it.
79
+
80
+ | Component | Removed / renamed | Replacement |
81
+ | -------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
82
+ | `StatCard` | `label` | `title` |
83
+ | `StatCard` | `menu` | `actions` (wrapped in `CtlSlot`) |
84
+ | `StatCard` | — | adds `icon`, `info`, `subtitle` (forwarded to the composed `WidgetHeader` — the info glyph beside the title, the muted unit line under the hero row), `sparklinePlacement?: 'bleed' \| 'right'` (default `'bleed'`, collapses to it below `sm`) |
85
+ | `ChartCard` | `tooltip` | `info` |
86
+ | `ChartCard` | `extra` | `actions` (carries `data-basalt-tier="widget"`, no `CtlSlot` — `./charts` stays Mantine-free) |
87
+ | `ChartCard` | `title: string` (required) | `title?: string` — the header now renders only when title/info/value/actions/icon/count is set |
88
+ | `ChartCard` | — | adds `icon`, `value`, `delta`, `deltaPeriod`, `count` |
89
+ | `SettingsSection` / `DangerZone` | `description` | `subtitle` |
90
+ | `SettingsSection` / `DangerZone` | — | adds `actions` (wrapped in `CtlSlot`) |
91
+ | `BasaltDataTable` | `toolbarActions` | `actions?: ReactNode` — the toolbar (search + facets + `actions`) is now wrapped in `CtlSlot`. Typed `ReactNode`, not `BarAction[]` — a table toolbar is not a `PageBar`/`Section`, and `ActionGroup`'s shell-extras semantics (primary/secondary, mobile folding) must not leak into it |
92
+ | `BasaltDataTable` | fixed `w={220/200/180/110}` on toolbar/pagination controls | removed — the controls size by content |
93
+ | `BasaltDataTable` | — | adds `title`, `icon`, `subtitle` — when `title` is set, renders `WidgetHeader tier="widget"` above the toolbar, `count` always `table.getRowCount()` (C11) |
94
+ | `BasaltDataTable` | `facets` rendered as raw `Select`/`MultiSelect` | rendered as `EnumFilter`/`MultiSelectFilter` pills inside a `FilterSet` (`docs/CONTROLS-SPEC.md` §3) — a synthetic `"All"` option stands in for a single-select facet's cleared state, since a closed enum field has no member for "unset" |
95
+
96
+ New export: **`Section`** (`.`, Mantine-coupled) — `WidgetHeaderProps` minus `tier`, plus `tabs?`,
97
+ `collapsible?`, `persistKey?`, `defaultOpen?`, `summary?`, `id?`, `children`. No `variant`, no
98
+ border/background — one shaded container level per page belongs to the cards inside a `Section`, not
99
+ to `Section` itself. Fold state persists at `basalt:section:<persistKey>` via `createPersistedState`
100
+ when `persistKey` is given, else local `useState`; the header stays drawn when collapsed, only the
101
+ body unmounts. `defaultOpen` (default `true`) picks the state a FIRST visit lands on and is outranked
102
+ by a persisted value, so a section the reader closed stays closed. `summary` renders under the header
103
+ and survives a collapse — a folded section still states its headline figures, which is what makes
104
+ folding it a real option rather than a way to lose the numbers.
105
+
106
+ ### Homes — `PageBar` replaces the page-action portal
107
+
108
+ **Three exports removed with no deprecation window, one prop retyped, three tokens and one CSS
109
+ class deleted.** `PageActions` gave a page a `ReactNode` slot in the header and nothing else: no
110
+ second row, no overflow policy, no mobile projection. Every consumer then hand-rolled the rest —
111
+ a sticky filter row measured into a `--lw-header-h`, a horizontally scrolling action strip, a
112
+ responsive twin under `visibleFrom`/`hiddenFrom`. `PageBar` is that whole shape, typed
113
+ (`docs/CONTROLS-SPEC.md` §2.1).
114
+
115
+ | Removed / changed | Replacement |
116
+ | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
117
+ | `PageActions` | `<PageBar actions={{ primary, secondary }} sync={…} tabs={…} filters={…} filtersEnd={…} />` |
118
+ | `PageActionsOutlet` | none — the outlet is internal to `BasaltShell` now |
119
+ | `PageHeaderProvider` | none — the provider is internal to `BasaltShell` now |
120
+ | `BasaltShellProps.globalActions: ReactNode` | `GlobalAction[]` — `{ key, node, mobile?: 'bar' \| 'more' \| 'hidden' }`; the first two default to `'bar'` |
121
+ | tokens `appShellHeaderMobileHeight`, `appHeaderMobileActionsHeight`, `stickyHeaderClearanceMobile` | `appShellHeaderHeight` / `stickyHeaderClearance` — one value at every viewport |
122
+ | vars `--vx-space-app-header-mobile-actions-height`, `--vx-space-sticky-header-clearance-mobile` | `--vx-space-sticky-header-clearance` |
123
+ | CSS `.pageActions` (`shell/app-header.module.css`) and its `nowrap` override | none — the portal target no longer scrolls; overflow folds into `ActionGroup`'s `More`/kebab |
124
+
125
+ What changes at runtime even if you only rename:
126
+
127
+ - **The app-shell header is 48px on a phone too.** It used to wrap to two rows (97px) and reserve
128
+ the second one on every route, empty or not (law C14). A page-level filter/tab row now renders
129
+ in the page FLOW, sticky under the header, so a route with no filters is 49px shorter on mobile.
130
+ - **The header never scrolls sideways.** Actions past three fold into a `More` menu on desktop and
131
+ into one kebab below `sm` — computed from `BarAction[]`, which is why the slot had to become data
132
+ (law C7). There is exactly ONE kebab per header, and `PageBar`'s ROW 1 owns it: the shell's
133
+ `mobile: 'more'` global actions land there, and so do `filtersEnd`'s items (row 2 shows those on
134
+ desktop only — below `sm` row 2 is tabs + the first pill + `Filters (n)`, per spec §2.1). A route
135
+ with no `PageBar` row-1 actions gets the kebab from the shell instead. An `ActionGroup` you mount
136
+ yourself — in a `Section` or `ChartCard` `actions` slot — never inherits the global rows.
137
+ - **`--basalt-page-bar-h` is published on `documentElement`** (ResizeObserver where available,
138
+ `height > 0` guard)
139
+ whenever a row 2 renders, and removed when it unmounts. It is written in the LAYOUT phase, so it
140
+ exists at the first paint — a cold load of `/page#anchor` lands below the bar rather than under
141
+ it. A `100dvh` body becomes
142
+ `calc(100dvh - var(--app-shell-header-height) - var(--basalt-page-bar-h, 0px))` — delete any
143
+ hand-rolled measure-and-publish effect and any hardcoded header-height fallback.
144
+ - **A shell-less app reads `title`/`icon`.** Inside a `BasaltShell` the breadcrumb names the page
145
+ and both are ignored; outside one they lead row 1, and the whole bar sticks at `top: 0`.
146
+ - **`className` lands on the bar ROOT in both forms** — the shell-less
147
+ `[data-basalt-page-bar="standalone"]`, or row 2's sticky wrapper `[data-basalt-page-bar="shell"]`.
148
+ It is the seam for the two things only the consumer's own layout knows: bleeding the sticky bar
149
+ across a container's gutters, and a hairline under it. Scope that CSS through the class; a global
150
+ `[data-basalt-page-bar]` rule reaches every page in the app, including the ones wanting neither.
151
+
152
+ `ActionGroup`, `OverflowMenu` and `SyncButton` ship on the new `basalt-ui/controls` subpath; only
153
+ the TYPES a `PageBar`/`BasaltShell` prop mentions (`BarAction`, `ActionGroupProps`, `GlobalAction`)
154
+ are re-exported from the root.
155
+
156
+ ### Stores — `createSearchStore` replaces the enum-only pair
157
+
158
+ `createSearchParamStore` and `createMultiSearchParamStore` still work: both are now `@deprecated`
159
+ thin wrappers over `createSearchStore`, returning the same four members
160
+ (`validateSearch`, `useStore`, `readStored`, `linkSearch`) with the same signatures and reading the
161
+ same single-value localStorage envelope. **They are removed in 1.29.0.**
162
+
163
+ | Removed / deprecated | Replacement |
164
+ | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
165
+ | `createSearchParamStore({ key, param, values, fallback })` | `createSearchStore({ key, fields: { <param>: field.enum(values, fallback) } })` — `@deprecated`, removed 1.29.0 |
166
+ | `createMultiSearchParamStore({ key, param, values })` | `createSearchStore({ key, fields: { <param>: field.multi(values, fallback) } })` — `@deprecated`, removed 1.29.0 |
167
+ | `<store>.useStore()` | `<store>.field.<name>.use()` — reads the URL FIRST, then the mirror; writes both lanes |
168
+ | `useOnlineStatus` (`.` and `./state`) | `useConnectivity()` (`.`, auto-mounted by `BasaltProvider`) — **removed, not deprecated** (A12) |
169
+ | `createSearchSchemaStore` | never shipped; `createSearchStore` is what those "planned" paragraphs were waiting for |
170
+
171
+ New on `./router-tanstack`: `createSearchStore`, `field` (`enum` / `multi` / `range` / `number` /
172
+ `boolean` / `string`), and the `FieldHandle` / `AnyField` / `FieldValue` / `RangeValue` / lane types.
173
+ New on `./state` (router-free, same field vocabulary): `createLocalStore`, `field`, those same types.
174
+ `FieldHandle` is what every 1.26.0 control takes instead of `value`/`onChange`
175
+ (`docs/CONTROLS-SPEC.md` §3–§4).
176
+
177
+ Three things to know when you migrate a real store:
178
+
179
+ - **A deep link now wins over the mirror.** `?range=7d` reads back `7d` even when localStorage says
180
+ `30d`. The old `useStore` read localStorage, so a shared link opened on the wrong window — the bug
181
+ three consumers shipped (A8). If you were compensating for that by hand, delete the compensation.
182
+ - **The storage layout changed for the NEW factory only.** `createSearchStore` keeps one entry per
183
+ STORE (`basalt:<key>` = `{ fieldName: value }`); the old factories kept one bare value per param.
184
+ A store migrated from a wrapper to `createSearchStore` therefore ignores whatever the browser
185
+ already had for that key and starts on its fallback once. The wrappers themselves keep the old
186
+ layout byte-for-byte, so an un-migrated consumer sees no reset — including a stored EMPTY multi
187
+ selection, which still reads back as empty: "an empty array means absent" is a URL rule in the
188
+ deprecated store, never a storage rule, so a deliberately cleared filter stays cleared.
189
+ - **A write from outside the owning route persists only** — it no longer needs a hand-rolled
190
+ `navigate` beside it, and it no longer silently writes a param the route does not validate.
191
+ `validateSearch` picks the persisted value up on the next visit (A1). The one combination with
192
+ nowhere to go — a `persist: false` field written from a route that does not validate it — warns
193
+ once per field in dev rather than dropping the write silently.
194
+
195
+ **Every navigate a store issues carries `resetScroll: false`** — a field write and `useReset`
196
+ alike. A filter lives halfway down a page as often as it lives in the bar, and the router treats a
197
+ same-route search write as a navigation, so the default scrolled the reader back to the top on every
198
+ change. Delete any `resetScroll: false` you were passing beside a hand-rolled `navigate`.
199
+
200
+ Lanes are declared once per field: `{ url: false }` is the local-only lane (per-chart selects, a
201
+ compact toggle — or use `createLocalStore`), `{ persist: false }` the URL-only lane (pagination, a
202
+ deliberately unpersisted threshold), `{ history: 'push' }` opts one field into a history entry.
203
+ **`{ url: false, persist: false }` is the IN-MEMORY lane** rather than the one combination that
204
+ dropped its write: shared across every mount of that store for the session, gone on reload, never
205
+ written to or read from localStorage. It behaves identically in both factories — `createSearchStore`
206
+ counts such a field in `useActiveCount()` and clears it in `useReset()`, and it never appears in
207
+ `useValues()`/`validateSearch`, which are the URL lane by definition. In a `createLocalStore` the
208
+ `url` flag is ignored, so `{ persist: false }` alone lands there. The dev warning about a write with
209
+ nowhere to go now fires only for its remaining real case: a `{ persist: false }` field that IS on the
210
+ URL lane, written from a route that does not validate the param. `createLocalStore` also carries **`labels()`**,
211
+ chainable and identical to `createSearchStore`'s, so `SelectFilter`/`ViewTabs` read option labels off
212
+ a local store too.
213
+ A `field.range` keeps THREE URL params (preset + `from` + `to`, renamable via `params`), so existing
214
+ deep links and loaders keep their shape, and `field.<name>.toWindow(v)` replaces a hand-rolled
215
+ `presetToParams`.
216
+
217
+ ### Sidebar — `sidebarBlocks` replaces the two `ReactNode` extras
218
+
219
+ **Two props removed with no deprecation window, and two widened.** `sidebarNavExtra` and
220
+ `mobileNav.moreExtra` were the same gap solved twice and badly: the first rendered on desktop only
221
+ and vanished in the collapsed rail, the second was one anonymous row at the bottom of the More
222
+ surface, and neither could be projected because basalt could not see inside a `ReactNode`. Every
223
+ consumer that wanted an "Awaiting action" list therefore wrote it twice, once per prop, and got no
224
+ rail badge either way. `SidebarBlock[]` is that whole shape as data (law C13,
225
+ `docs/CONTROLS-SPEC.md` §2.3).
226
+
227
+ | Removed / changed | Replacement |
228
+ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
229
+ | `BasaltShellProps.sidebarNavExtra` | `sidebarBlocks={[{ kind: 'custom', key, node }]}` — same DOM position (last child of the nav scroll region), same CSS-only hiding in the rail, still desktop-only |
230
+ | `AppSidebarProps.navExtra` | `blocks={[{ kind: 'custom', key, node }]}` |
231
+ | `MobileNavConfig.moreExtra` | `sidebarBlocks={[{ kind: 'list', key, label, items }]}` — one More-sheet row (`Awaiting action · 3`) opening a nested sheet of its items, instead of one unlabelled row of raw content |
232
+ | `useNav(NAV, { moreExtra })` | drop the option; declare the block on `BasaltShell` |
233
+ | `BasaltShellProps.brand` | widened to `BrandConfig & { menu?: AccountMenuItem[] }` — additive; `menu` makes the brand row a `Name ▾` workspace switcher |
234
+ | `BasaltShellProps.search` | widened to `SidebarSearchConfig & { actions?: SidebarSearchActions }` — additive; one or two icon-only buttons right of the ⌘K row |
235
+
236
+ What changes at runtime even if you declare no blocks at all:
237
+
238
+ - **Nav-section folds are now PERSISTED**, at `basalt:sidebar-section:<label-slug>` (e.g.
239
+ `basalt:sidebar-section:tools`). They were a `useState` keyed by label, so every reload re-opened
240
+ a section the user had closed. `defaultCollapsed` is now the SEED for that key rather than a value
241
+ that overrides the user on every mount. Block folds live at `basalt:sidebar-block:<key>`; both are
242
+ the standard `createPersistedState` envelope (`{ v: 1, value: boolean }`), readable with
243
+ `readPersistedValue` from `basalt-ui/state`. A "Show more" toggle is deliberately NOT persisted.
244
+ - **`settingsMenuItems` renders FLAT at three entries or fewer** — one link row each, instead of a
245
+ gear "Settings" menu you had to open to see two rows. Four or more keeps the menu. `brand.version`
246
+ rides the flat rows as a faint label and the dropdown as a `Menu.Label`, as before. New
247
+ `BasaltShellProps.settingsMenu?: 'auto' | 'flat' | 'menu'` (`AppSidebarProps` too, default
248
+ `'auto'` = that count rule) forces the form: pass `'menu'` when the three rows are CONTROLS rather
249
+ than destinations — a theme radio group and a devtools switch read as a widget pile flat, and the
250
+ count cannot tell them from three links.
251
+ - **The footer is one wrapper deeper.** `settingsMenuItems` without `account` used to render its
252
+ `Group` as the footer element itself; both now sit inside one footer `Stack` alongside any
253
+ `placement: 'bottom'` block. No visual change — relevant only to a stylesheet reaching in by
254
+ structure.
255
+ - **`useNav` now marks exactly one destination `active`** — the deepest matching route wins, instead
256
+ of every prefix-matching row (a parent AND its child) reading active at once. A parent on the
257
+ winner's path gets the new `SidebarItem.ancestor` instead: never active, never `aria-current`, only
258
+ a `data-ancestor` hook for CSS. A consumer that styled off two simultaneously-active rows will now
259
+ see one.
260
+
261
+ New types on `.`: `SidebarBlock`, `SidebarListBlock`, `SidebarProgressBlock`, `SidebarCustomBlock`,
262
+ `SidebarBlockItem`, `SidebarBlockTone`, `SidebarSearchActions`. `SidebarItem` gained `ancestor?:
263
+ boolean`.
264
+
265
+ ### `DeltaBadge` — plain-element DOM, same props
266
+
267
+ Nothing removed or renamed — a DOM contract change only, ahead of `WidgetHeader`
268
+ (`docs/CONTROLS-SPEC.md` §2.2) composing it Mantine-free.
269
+
270
+ | Component | What changed | Why it matters |
271
+ | ------------ | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
272
+ | `DeltaBadge` | Renders a plain `<span>`, no `mantine-Badge-root`/`mantine-Badge-label` classes anymore | Same props, tone and format API — but a selector or snapshot keyed on Mantine's Badge classes breaks |
273
+
274
+ ### Controls — `ArticleFilterBar` replaced by store-bound controls
275
+
276
+ Two new subpaths, `basalt-ui/controls` and `basalt-ui/controls-dates`, and ONE removal. The removed
277
+ component was controlled (`value`/`onChange`, its own `visibleFrom`/`hiddenFrom` twin); its
278
+ replacements take a `FieldHandle` and own the URL write, the localStorage mirror and the responsive
279
+ swap themselves (`docs/CONTROLS-SPEC.md` §3, laws C2/C9).
280
+
281
+ | Removed | Replacement | Import |
282
+ | ----------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ |
283
+ | `ArticleFilterBar` | `FilterSet` + `ViewTabs` (category axis) + `MultiSelectFilter` (tags) | `basalt-ui/controls`, and unchanged from `basalt-ui/content` |
284
+ | `ArticleFilterBarProps` | `FilterSetProps` / `ViewTabsProps` / `MultiSelectFilterProps` | same |
285
+
286
+ All three replacements are re-exported from `basalt-ui/content` as well, so a content-only consumer
287
+ changes the component names and the props, not the import path.
288
+
289
+ Porting one call site:
290
+
291
+ ```tsx
292
+ // before — the page owned the state, the labels and the mobile swap
293
+ const [category, setCategory] = useState('all')
294
+ <ArticleFilterBar categories={CATEGORIES} category={category} onCategoryChange={setCategory}
295
+ tags={ARTICLE_TAGS} selectedTags={tags} onTagsChange={setTags} />
296
+
297
+ // after — one store, two controls, no useState and no responsive branch
298
+ const articles = createSearchStore({
299
+ key: 'articles',
300
+ fields: { category: field.enum(CATEGORY_VALUES, 'all'), tags: field.multi(ARTICLE_TAGS, []) },
301
+ }).labels({ category: CATEGORY_LABELS })
302
+
303
+ <ViewTabs field={articles.field.category} />
304
+ <FilterSet>
305
+ <MultiSelectFilter field={articles.field.tags} label="All tags" />
306
+ </FilterSet>
307
+ ```
308
+
309
+ New on `basalt-ui/controls`: `FilterSet`, `RangeFilter`, `CompareFilter`, `SelectFilter`,
310
+ `MultiSelectFilter`, `SearchFilter`, `ToggleFilter`, `ViewTabs`, `COMPARE_VALUES`, and the
311
+ action/sync family. New on `basalt-ui/controls-dates`: `DateRangePicker`.
312
+
313
+ **`SyncButton`'s `scope` now decides the SHAPE, not only where you mount it.** `scope: 'global'`
314
+ renders icon-only at every viewport (an `ActionIcon` with the spinning glyph, age and error in the
315
+ tooltip, `label` as the accessible name) — the shell header shares 48px with the breadcrumb and
316
+ `PageBar` row 1, and a labelled button there is what pushes a page's own actions into the kebab.
317
+ `scope: 'page'` is unchanged on desktop (labelled, age inline) and drops to icon-only below `sm`,
318
+ which is what `docs/CONTROLS-SPEC.md` §3 always specified. One mount either way, CSS-only swap (law
319
+ C9). Consequence to know: the accessible name is now `aria-label` in BOTH forms, so a test asserting
320
+ the name as `Sync 2m ago` (the age used to land in it) now reads `Sync`. Nothing to change at a call
321
+ site.
322
+
323
+ **`SelectFilter` and `MultiSelectFilter` take `options?: readonly FilterOption[]`** — a runtime
324
+ catalogue (`{ value, label, disabled? }`) that OVERRIDES `field.options` whole rather than merging,
325
+ so a value the catalogue has dropped loses its row. It is what a label carrying live data needs
326
+ (`EUR · 1.08`), and on `SelectFilter` it also opens a second field shape: a `FieldHandle<StringField>`
327
+ is legal WITH `options` — an id set no enum can close over, a project picker fed from a query — and
328
+ a type error without it. `FilterOption` is new on `basalt-ui/controls`. Additive; nothing to change.
329
+
330
+ **`FilterSet` renders the mobile `Filters (n)` pill only when a child is actually folded.** With
331
+ every child inside the `inline` budget (one filter at the default `inline: 1`) the sheet would hold a
332
+ copy of what is already on screen, so neither the trigger nor the drawer mounts. Nothing to change —
333
+ but a mobile test asserting the pill exists on a one-filter page now has to fold something
334
+ (`inline={0}`) or drop the assertion. `RangeFilter.label` (default `'Range'`) names the sheet heading
335
+ and the preset track's aria name; the pill itself still reads the VALUE.
336
+
337
+ **`data-numeric` is now a package-wide law, not a per-component rule.** `basalt-ui/styles.css`
338
+ declares `[data-numeric] { font-family: var(--basalt-font-mono); font-variant-numeric: tabular-nums }`
339
+ inside `@layer basalt`, so the attribute works on ANY element — `<Text data-numeric>`, a table cell,
340
+ a count tag — and not only on a `SegmentedControl`, where it was previously a module-scoped rule that
341
+ made the attribute look broken everywhere else. Nothing to change: adding the attribute is opt-in,
342
+ and the two module rules that also set a component `font-size` still apply on top. This retires the
343
+ per-consumer `theme-allow` + inline `fontFamily`/`fontSize` hack for numeric labels.
344
+
345
+ **`@mantine/dates` is a new OPTIONAL peer, and only `basalt-ui/controls-dates` needs it.**
346
+ `basalt-ui/controls` resolves and renders without it — proven from the packed tarball by
347
+ `scripts/pack-test.sh`. Pass the picker into `RangeFilter` rather than importing it from a shared
348
+ module:
349
+
350
+ ```tsx
351
+ import { DateRangePicker } from 'basalt-ui/controls-dates'
352
+ ;<RangeFilter field={analytics.field.range} customPicker={DateRangePicker} />
353
+ ```
354
+
355
+ A consumer that uses `DateRangePicker` installs `@mantine/dates` and imports
356
+ `@mantine/dates/styles.layer.css` with the other Mantine layer bundles, before `basalt-ui/styles.css`.
357
+
358
+ ### Guards — the control rules (docs/CONTROLS-SPEC.md §6)
359
+
360
+ **Nothing removed. Eight new oxlint rules and two new guard kinds; two of the ten land `error`, the
361
+ rest `warn` under a dated C16 grace entry.** Every one honours `theme-allow <id> — <why>` on the
362
+ node and `theme-allow-file <id> — <why>` on the file, the same grammar as the rest.
363
+
364
+ | Rule / kind | Fires on | Ships |
365
+ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
366
+ | `basalt/hand-rolled-filter` | A raw Mantine `Select`/`SegmentedControl`/… handed to a TIERED home slot (`actions`/`filters`/`tabs`/`sync`/`filtersEnd` on `PageBar`/`Section`/`WidgetHeader`/`ChartCard`/`StatCard`/`BasaltDataTable`/`SettingsSection`/`FilterSet`) | error |
367
+ | `basalt/page-bar-budget` | A second `PageBar` in the SAME returned tree, >4 `actions.secondary`, >3 `Section` actions, a second filled `Button`/`ActionIcon` in one slot (outside an overlay) | error |
368
+ | `basalt/control-outside-home` | The same raw control with no home at all — exempt under a settings row / overlay / composer, in an `@mantine/form` file, or in a file that DEFINES a basalt control | warn → 1.27.0 |
369
+ | `basalt/control-size-literal` | `size`/`w`/`fullWidth`/`visibleFrom`/`hiddenFrom` on anything inside a home slot (the slot sets the tier) | warn → 1.27.0 |
370
+ | `basalt/in-body-page-title` | `<Title order={1\|2}>` outside prose/overlay context and outside a `content/` path | warn → 1.27.0 |
371
+ | `basalt/responsive-twin` | The same control mounted twice, one `visibleFrom="X"` and one `hiddenFrom="X"` | warn → 1.27.0 |
372
+ | `basalt/search-literal-link` | A `search:` object literal in a `linkOptions()` inside `defineNav()`/`navGroup()` | warn → 1.27.0 |
373
+ | `basalt/use-search-from-literal` | `useSearch({ from: '<route>' })` | warn → 1.27.0 |
374
+ | `in-body-page-title` (guard kind) | The text lane of the same law — SAME id, so one annotation waives both lanes | warn → 1.27.0 |
375
+ | `raw-selection-control` (guard kind) | The text lane of `control-outside-home`, approximated by a 12-line host-tag window | warn → 1.27.0 |
376
+
377
+ **A `SettingsRow`'s `control` is not a tiered slot, so nothing in this table fires inside one.**
378
+ It is law C1's third home — the form row — and a form keeps Mantine's `md` tier
379
+ (`controlHeight` 42, unchanged). A raw `Select`/`Switch`/`Button` bound to a setting is correct
380
+ there, and its `size` prop is what holds the row at the form tier, so neither `hand-rolled-filter`
381
+ nor `control-size-literal` reaches into it; `control-outside-home` treats a settings row as a home
382
+ and stays silent as well. Consumer settings pages need no change.
383
+
384
+ **Three scoping facts, because each one is a rule NOT firing on code you already have.** The home
385
+ tag must resolve to a component imported from `basalt-ui` (or a basalt subpath) — your own
386
+ `Section`, `PageBar` or `StatCard` is not a tiered home, and nothing in this table reads it as one;
387
+ the one accepted gap is basalt's `Section` re-exported through your own barrel, which the rules
388
+ cannot see. An OVERLAY colocated in a slot (`Modal`, `Drawer`, `Popover.Dropdown`, `Menu.Dropdown`,
389
+ `SettingsRow`) is exempt — a `New` button beside the `<Modal>` it opens is how both are written, and
390
+ the overlay's own controls are not in the bar. And `control-size-literal` only reaches what the
391
+ slot's theme actually re-tiers: the raw filters, the basalt controls, and Mantine's
392
+ `Button`/`ActionIcon`/`Input`/`TextInput`/`Select`/`MultiSelect`/`SegmentedControl`/`NativeSelect` —
393
+ an icon's `size`, a count `Badge`, a `Loader`, an `Avatar` or a `Modal size="lg"` never report.
394
+
395
+ **Two existing rules widened, neither with a new id.** `basalt/raw-scroll-container` now also fires
396
+ on `overflowX: 'auto' \| 'scroll'` and on `<ScrollArea scrollbars="x">` **inside a home SLOT**
397
+ (`actions`/`filters`/`tabs`/`sync`/`filtersEnd`) — law C7, overflow folds into a `More` menu or a
398
+ `Filters (n)` sheet rather than scrolling sideways. A home's BODY is out of scope: a wide table, a
399
+ pinned-column grid or a horizontally scrolling code block in a `Section`/`ChartCard` is page
400
+ content, not a sideways-scrolling row of controls, and outside a slot `overflowX` is still not
401
+ policed at all.
402
+ oxlint severities are per rule ID, not per branch, so this widening inherits the rule's existing
403
+ `error` level with no grace runway of its own — the honest limitation, and the reason it is scoped to
404
+ homes rather than shipped repo-wide. `basalt/shadow-basalt-export` gains a rename table
405
+ (`PageHeader`/`FilterBar` → `PageBar`, `WindowSelector`/`RangeSelector`/`DateFilter` →
406
+ `RangeFilter`, `PageSection`/`SectionTitle`/`SectionHeading` → `Section`, `ViewSwitch`/`ViewToggle`
407
+ → `ViewTabs`, `RefreshButton`/`SyncControl`/`SyncStatusButton` → `SyncButton`, `HeroCard`/`HeroStats`
408
+ → `StatCard`), so a fork that RENAMED the export is visible too. It stays a permanent advisory
409
+ `warn`.
410
+
411
+ **`profile: 'tokens-only'` now disables 19 kinds, not 17** — both new kinds are Mantine-coupled.
412
+
413
+ **`SURFACES` gains `pluginRules`** (`src/surfaces.ts`, internal — not a published subpath): every
414
+ doctrine surface names the oxlint rules that enforce it, every registered rule maps to exactly one
415
+ surface, every guard KIND maps to at least one, and `basalt-ui check-coverage` asserts all three —
416
+ so a generated coverage header can neither over- nor under-report its lane. `check-coverage` also
417
+ takes `--write` / `--check` for the generated `<!-- basalt:coverage -->` header of each
418
+ `agent/rules/*.md` file; a rule file with no block yet is reported, not failed.
419
+
420
+ ### Controls — the pill reads the VALUE, and four defaults moved
421
+
422
+ **A filter pill's TEXT is now always the selected option's label, at the field's default value too.**
423
+ It used to fall back to the filter's NAME while `field.isDefault(value)` held, which is how a bar
424
+ could read `Compare` over a field holding `'previous'` while the popover showed `Previous period`
425
+ selected. `label` is the popover/sheet heading and the accessible name; it is never printed on the
426
+ pill. Affects `SelectFilter`, `CompareFilter` and every control built on the shared enum body;
427
+ `RangeFilter` already read its value, and `MultiSelectFilter` keeps its own law (the group label
428
+ while the selection is empty or complete, `N <noun>` otherwise).
429
+
430
+ **Nothing to change at a call site.** What changes is the string a test or a screenshot sees: a
431
+ `SelectFilter field={currency} label="Currency"` at fallback `USD` now reads `USD`, not `Currency`.
432
+ If a bar reads badly at rest, the fix is the OPTION label (`store.labels()`), not the filter's name.
433
+
434
+ | Component / prop | Was | Now |
435
+ | ------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
436
+ | `CompareFilter` option labels | the raw values until `store.labels()` | `No comparison` / `Previous period` / `Same period last year`, exported as `COMPARE_LABELS`. A label a consumer set (`label !== value`) still wins. |
437
+ | `BasaltDataTable` → `withTableBorder` | `true` | `false`. Pass `true` for a table that needs a frame. The head rule and the between-row rules are untouched; the LAST row's rule is now dropped (it was an outer border). |
438
+ | `BasaltDataTable` single-select facet's "all" row | `All` | `Any <facet.label lowercased>` — the pill now reads it, and three facets all reading `All` said nothing. |
439
+ | `ViewTabs` phone `Select` (past 3 options) | greedy (216px measured, truncating a title) | capped at 9rem |
440
+
441
+ **New: `ControlGroup`** (`basalt-ui/controls`) — joins adjacent controls that act on ONE thing
442
+ (`‹ Today ›`, `− 1 +`) into a single box: no gap, one shared hairline per pair, radius on the outer
443
+ ends only. `gap?: 'none' | 'tight'`. `BarActionItem` gains `group?: true`, and a run of adjacent
444
+ `group: true` actions in an `ActionGroup` renders as one `ControlGroup`; a joined member that ships an
445
+ `icon` renders ICON-ONLY with its label demoted to the accessible name. `ActionGroup` also joins
446
+ adjacent ICON-ONLY entries on the mobile bar with no flag. `basalt/shadow-basalt-export` now names
447
+ `ButtonGroup` / `ButtonRow` / `JoinedButtons` as renamed forks of it.
448
+
449
+ ### The `ctl` tier now covers Radio, Checkbox and Switch
450
+
451
+ All three defaulted to Mantine's `sm` — a 20px indicator beside the tier's 13.5px option label — in
452
+ every filter popover and in the mobile sheet. They are now in `CTL_THEME` (with their `.Group`s) and
453
+ `cssVariablesResolver` declares their `-ctl` vars: 16px indicator, 18×30 switch.
454
+
455
+ **One consequence worth knowing.** Mantine resolves a Radio's size as `props.size ? props.size :
456
+ ctx.size`, and a theme `defaultProps.size` is indistinguishable from an explicit prop to that check
457
+ — so inside a `<CtlSlot>` (any home's slot) a `<Radio.Group size="lg">` no longer sizes its children;
458
+ they stay `ctl`. A group that genuinely needs another size belongs in a BODY, which no slot wraps.
459
+
460
+ `basalt/control-size-literal` widened to cover the six new tags, so a `size` on a Radio/Checkbox/
461
+ Switch inside a home slot now reports. It is still `warn` on its 1.26.0 → 1.27.0 runway (it has never
462
+ shipped at any level, so widening it restarts nothing).
463
+
464
+ ### Icons — one box, no call-site geometry
465
+
466
+ Every `icon` prop in the framework (`BarAction`, the filter pills and their trailing affordance,
467
+ `WidgetHeader`/`StatCard`, `SyncButton`, sidebar-block rows, the overflow fold's own glyphs) now
468
+ renders through ONE internal slot: a fixed `--vx-space-icon-size` (16px default) square that restates
469
+ the glyph's `width`/`height` at 100% and takes it off the text baseline. **No API change** — but a
470
+ consumer's `<svg width="24">` that used to set a control's row height now renders at 16px, and an
471
+ `<svg>` with no `width`/`viewBox` at all no longer paints at 300×150. Per-tier sizes come from the
472
+ box var (`WidgetHeader` sets 14px at `tier="widget"`), never from a prop.
473
+
474
+ ### Mobile nav — at most ONE slot is active
475
+
476
+ A slot's activeness now reads exactly the destinations it COVERS. It used to roll up over `children`
477
+ unconditionally while an item slot covered only its own key, so at `/dashboard/sessions` the
478
+ `Dashboard` tab lit through the rollup and the `More` tab lit for `Sessions` sitting in its overflow
479
+ — two `aria-current="page"` tabs at once. A SECTION tab still lights for a nested destination (it
480
+ covers its whole tree); an ITEM tab with children no longer does, and the overflow slot that can
481
+ actually reach the open route lights instead.
482
+
483
+ `useNav`'s anchors now pass `activeOptions: { exact: true }` to TanStack's `Link`. A `Link` spreads
484
+ `{ 'data-status': 'active', 'aria-current': 'page' }` last — after `activeProps` and after every
485
+ caller prop — so its own fuzzy match could not be overridden from outside and re-created the very
486
+ double-highlight `useNav`'s resolver exists to prevent. basalt's own law is unchanged: an item whose
487
+ exact route is never visited still lights on a descendant route.
488
+
489
+ ### Layout — two collisions and a gap
490
+
491
+ - **The app header**: exactly one side of row 1 is elastic, and it is the breadcrumb (the side whose
492
+ content can ellipsize). The control side is `flex: 0 0 auto` with no `min-width: 0` below it. Both
493
+ sides were shrinkable, and at 390px the sync button overflowed 30px INTO the page title, because a
494
+ `justify-content: flex-end` flex container overflows towards its start.
495
+ - **`WidgetHeader`'s hero row**: the value → `DeltaBadge` gap is the 8px rhythm step
496
+ (`--vx-space-stack-sm`), not 4px, and the badge is centred on the value rather than baseline-aligned
497
+ to it.
498
+ - **`WidgetHeader`'s title row** wraps below `sm`, so an `actions` slot too wide to sit beside the
499
+ title takes its own line instead of squeezing the heading to an ellipsis. A 30px kebab still fits
500
+ and still sits on the row. **Its metric row wraps too**: in a 2-up card grid at 390px
501
+ `$1294.9k ▲18.9%` rendered as `$129… ▲18.9%`, and truncating the number to protect the delta about
502
+ that number is the wrong way round. A short value still keeps its badge on the line.
503
+ - **`StatCard`'s `actions` slot is the 24px tier now, not 30px.** `CtlSlot` gained `tier?: 'ctl' |
504
+ 'widget'` (default `'ctl'`, so no existing call site changes), and `StatCard` mounts its header slot
505
+ at `'widget'` — the `size="icon"` step `--vx-space-control-height-widget` has always named as
506
+ "`WidgetHeader tier="widget"` actions". A raw `ActionIcon` there rendered at the 30px `ctl` tier
507
+ inside a 28px header row and grew it to 30, so a KPI card WITH a kebab sat 2px below the card
508
+ beside it and every number in it with it. A consumer passing `<ActionIcon size="ctl">` explicitly
509
+ still gets 30px — and its card is 2px taller than its neighbours again, which is now visible rather
510
+ than accidental. The slot's `data-basalt-tier` marker reads `widget` there, the same value
511
+ `ChartCard` already writes by hand.
512
+ - **The breadcrumb's ANCESTOR crumbs are hidden below `sm`** — only the page crumb renders. At 390px
513
+ the elastic side truncated `Overview / Dashboard` to `O… / D…`: two ellipses and a separator in
514
+ place of the one word that says where the reader is. CSS-only (law C9), so no flash and no hook.
515
+ `AppBreadcrumbs` gained one wrapper element around those crumbs; its props are unchanged.
516
+
517
+ ### Cursor — the shared cursor now partitions by x-domain kind, automatically
518
+
519
+ Nothing removed. `CursorState` gained one field.
520
+
521
+ | Component | What changed | Why it matters |
522
+ | ------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
523
+ | `CursorState` | gains `kind: DomainKind \| null` | No consumer action needed. The shared cursor now classifies each chart's x-domain (`'time' \| 'linear' \| 'band'`, derived from its own keys) and a broadcast whose kind differs from a chart's own is invisible to it — a categorical chart can no longer be mistaken for a time chart. `ChartCursorScope` is no longer required to keep a categorical chart from following a time chart; it still isolates two charts that share the same domain kind but are semantically unrelated. |
524
+
525
+ ## 1.24.0 — `QueryState`, table body chrome, four false greens
42
526
 
43
527
  **Nothing removed or renamed. Twelve new runtime exports on `.`/`./query`, plus table props.** Every
44
528
  API entry is additive; the behaviour changes are in the guard and the CLI, listed after them.
@@ -85,7 +569,7 @@ New: `maxHeight`, `minWidth`, `stickyHeader`, `stickyHeaderOffset`, `verticalSpa
85
569
  defaults to `true` in basalt, overriding Mantine's `false`; every other one is a conditional
86
570
  pass-through, so omitting it keeps Mantine's default.
87
571
 
88
- **Porting argo's three tables onto them made them 341 → 370 lines. 29 LONGER.** argo named these
572
+ **Porting argo's three tables onto them made them 341 → 370–379 lines. 29–38 LONGER.** argo named these
89
573
  props as the reason the tables stayed hand-rolled, and adding them shortened nothing: column defs
90
574
  cost more than JSX rows when every cell is bespoke — eight accessor blocks at 4–6 lines each against
91
575
  an eight-`<Table.Td>` row at ~3. **The ask was mis-specified.** Adopt them for what they buy —
@@ -170,6 +654,32 @@ scaffold a second consumer: the refusal is keyed on the RESOLVED project and run
170
654
  and no other consumer holds a single `.astro`, `.vue` or `.jsx` file, so grace would have covered
171
655
  zero incumbent violations.
172
656
 
657
+ ## 1.25.0 — `manualPagination` imposes a contract
658
+
659
+ **Nothing removed or renamed — a behaviour change, additive props only.** `manualPagination` made
660
+ `data` one server page but left every other client-side control armed: sorting reordered that page
661
+ under a header chevron while "Showing 1–25 of 412" presented it as a sort of all 412 — a plausible,
662
+ wrong answer with nothing on screen to give it away. argo found it on 1.24.0 and worked around it
663
+ with an explicit `enableSorting={false}`.
664
+
665
+ Adopting `manualPagination` now imposes a contract, checked from props at render:
666
+
667
+ | With `manualPagination` | Resolve it with |
668
+ | ------------------------- | ------------------------------------------------------------------------------------ |
669
+ | the pagination bar itself | `enablePagination` (without it `manualPagination` is inert) + `rowCount`/`pageCount` |
670
+ | sorting | the new `manualSorting` + sort in `onSortingChange`, or `enableSorting={false}` |
671
+ | `enableGlobalFilter` | the new `manualFiltering` + filter in `onGlobalFilterChange` |
672
+ | `facets` | `manualFiltering` + the new `onColumnFiltersChange` |
673
+
674
+ Unresolved, it **throws in dev** naming every breach at once; a production bundle degrades instead —
675
+ no sort headers, no filter controls, no "of N" it cannot stand behind, plus one `console.error`. A
676
+ bare `<BasaltDataTable data columns />` (no `manualPagination`) is byte-identical — no opt-out
677
+ needed for a client-side table.
678
+
679
+ Sibling defect fixed with it: the empty-state branch keyed off `data.length`, so a search or page
680
+ index matching nothing left `data` non-empty and rendered a `<tbody>` with no rows AND no message.
681
+ It now keys off the rendered row model.
682
+
173
683
  ## 1.23.1 — the band-state throw, the tag gate, a CLI that answers
174
684
 
175
685
  **One type widened, nothing removed or renamed.** `BandStripSeries.formatValue` is now
@@ -583,7 +1093,7 @@ with the whole lint half off).
583
1093
 
584
1094
  | Surface | Note |
585
1095
  | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
586
- | `basalt.profile: 'tokens-only'` / `--tokens-only` | disables the 17 kinds whose remedy is a Mantine component, prop or the React theme factory. `check-theme` requires it DECLARED; `doctor` infers it, because its profile only changes advice, never enforcement |
1096
+ | `basalt.profile: 'tokens-only'` / `--tokens-only` | disables the 19 kinds whose remedy is a Mantine component, prop or the React theme factory. `check-theme` requires it DECLARED; `doctor` infers it, because its profile only changes advice, never enforcement |
587
1097
  | `basalt.include: [...]` | scan a named file outside `roots` — and the only route to a `.json`, which is never blanket-scanned |
588
1098
  | `basalt.roots` + a `lint:basalt` script | written by `init` from the real layout; `init` on an existing app is a lint-debt event, not a no-op |
589
1099
  | `tokens:css --check`, `--selector-class <c>` (+ `--light-class`) | drift gate; the Tailwind `<html class="dark">` convention. There is no `scheme: { class }` API — the class form is CLI-only |
@@ -697,23 +1207,39 @@ live in `configs/oxlint.json`, and since 1.20.0 their grace is tracked by `PLUGI
697
1207
  named export beside the plugin — a test asserts it against the shipped preset in both directions, so
698
1208
  deleting an entry forces the level flip in the same commit.
699
1209
 
700
- | Rule | Landed | Became `error` |
701
- | ---------------------------------- | ---------------- | ------------------------------------------------------------------------ |
702
- | `mantine-shade-index` (guard kind) | 1.7.0 as `warn` | **1.11.0** |
703
- | `basalt/raw-scroll-container` | ≤1.2.0 as `off` | `warn` 1.10.0 → **`error` 1.13.0** |
704
- | `basalt/ai-sdk-major` | 1.10.0 as `warn` | **1.13.0** |
705
- | `basalt/agent-no-raw-usechat` | 1.10.0 as `warn` | **1.13.0** |
706
- | `basalt/agent-resume-guard` | 1.10.0 as `warn` | **1.13.0** |
707
- | `basalt/raw-size-literal` | 1.7.0 as `warn` | **1.20.0** |
708
- | `basalt/hand-rolled-plot` | 1.15.0 as `warn` | still `warn` — widened again at 1.21.0; grace restarts with the widening |
709
- | `basalt/chart-legend-literal` | 1.15.0 as `warn` | still `warn` — widened at 1.20.0 |
710
- | `basalt/shadow-basalt-export` | 1.20.0 as `warn` | may stay `warn` permanently — widened at 1.21.0, narrowed at 1.22.0 |
711
- | `basalt/hand-rolled-shell` | 1.20.0 as `warn` | — |
1210
+ | Rule | Landed | Became `error` |
1211
+ | -------------------------------------- | ---------------- | --------------------------------------------------------------------- |
1212
+ | `mantine-shade-index` (guard kind) | 1.7.0 as `warn` | **1.11.0** |
1213
+ | `basalt/raw-scroll-container` | ≤1.2.0 as `off` | `warn` 1.10.0 → **`error` 1.13.0** |
1214
+ | `basalt/ai-sdk-major` | 1.10.0 as `warn` | **1.13.0** |
1215
+ | `basalt/agent-no-raw-usechat` | 1.10.0 as `warn` | **1.13.0** |
1216
+ | `basalt/agent-resume-guard` | 1.10.0 as `warn` | **1.13.0** |
1217
+ | `basalt/raw-size-literal` | 1.7.0 as `warn` | **1.20.0** |
1218
+ | `basalt/hand-rolled-plot` | 1.21.0 as `warn` | **Unreleased** (grace had restarted at the 1.21.0 widening) |
1219
+ | `basalt/chart-legend-literal` | 1.20.0 as `warn` | **Unreleased** |
1220
+ | `basalt/shadow-basalt-export` | 1.20.0 as `warn` | ADVISORY — permanent `warn`, see below, never subject to the C16 gate |
1221
+ | `basalt/hand-rolled-shell` | 1.20.0 as `warn` | **Unreleased** |
1222
+ | `theme-allow-unscoped` (guard kind) | 1.20.0 as `warn` | **Unreleased** |
1223
+ | `surface-shadow-override` (guard kind) | 1.20.0 as `warn` | **Unreleased** |
1224
+ | `css-raw-surface` (guard kind) | 1.20.0 as `warn` | **Unreleased** |
1225
+ | `inline-font-size` (guard kind) | 1.20.0 as `warn` | **Unreleased** |
1226
+ | `hidden-inline-style` (guard kind) | 1.20.0 as `warn` | **Unreleased** |
712
1227
 
713
1228
  `card-with-border`, `inline-display`, `raw-html-layout`, `raw-form-control`, `raw-font-family` and
714
1229
  the other original guard kinds have been `error` since before 1.2.0 — they never had a grace minor.
715
1230
  Guard findings only gained a severity field at all in 1.4.0; before that every finding was fatal.
716
1231
 
1232
+ **Both ledgers changed shape in the Unreleased minor (C16, `docs/CONTROLS-SPEC.md` §1).** An entry
1233
+ used to be a bare promotion-note string with no expiry a machine could check — which is exactly how
1234
+ the nine rows above sat at `warn` for five minors although the doctrine below says "one minor". Both
1235
+ `PLUGIN_RULE_GRACE` (`configs/oxlint-plugin.js`) and `GRACE_PERIOD_KINDS` (`src/guard/index.ts`) now
1236
+ hold `{ since, promote, why }` (semver strings), and a version-gated test fails the build once
1237
+ `package.json`'s version reaches an entry's `promote` while the entry is still there — see
1238
+ "Unreleased" above for the full change. `shadow-basalt-export` moved to a sibling ledger,
1239
+ `PLUGIN_RULE_ADVISORY`, which the gate never checks: it was already documented as a possible
1240
+ permanent `warn`, and the version-gate doctrine only fits an entry that has an honest promotion
1241
+ date.
1242
+
717
1243
  **Rule-id rename at 1.1.0:** `basalt/import-boundary` split into `basalt/visx-boundary`,
718
1244
  `basalt/visx-tooltip` and `basalt/token-layer-boundary` (the last is repo-local and deliberately not
719
1245
  in the shipped preset). A config still naming `import-boundary` after 1.1.0 disables nothing.