basalt-ui 1.25.0 → 1.27.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 (476) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +26 -0
  3. package/MIGRATING.md +815 -23
  4. package/README.md +45 -32
  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 +160 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +118 -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 +1465 -36
  17. package/configs/oxlint-plugin.test.ts +1190 -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 +39 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +93 -0
  75. package/dist/controls/filter-context.d.ts +60 -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 +20 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +46 -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/number-filter.d.ts +26 -0
  99. package/dist/controls/number-filter.d.ts.map +1 -0
  100. package/dist/controls/number-filter.js +143 -0
  101. package/dist/controls/peer-boundary.test.d.ts +2 -0
  102. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  103. package/dist/controls/props-contract.test.d.ts +2 -0
  104. package/dist/controls/props-contract.test.d.ts.map +1 -0
  105. package/dist/controls/range-filter.d.ts +43 -0
  106. package/dist/controls/range-filter.d.ts.map +1 -0
  107. package/dist/controls/range-filter.js +135 -0
  108. package/dist/controls/range-filter.type-guard.test.d.ts +2 -0
  109. package/dist/controls/range-filter.type-guard.test.d.ts.map +1 -0
  110. package/dist/controls/search-filter.d.ts +10 -0
  111. package/dist/controls/search-filter.d.ts.map +1 -0
  112. package/dist/controls/search-filter.js +81 -0
  113. package/dist/controls/select-filter.d.ts +74 -0
  114. package/dist/controls/select-filter.d.ts.map +1 -0
  115. package/dist/controls/select-filter.js +10 -0
  116. package/dist/controls/sync-button.d.ts +38 -0
  117. package/dist/controls/sync-button.d.ts.map +1 -0
  118. package/dist/controls/sync-button.js +119 -0
  119. package/dist/controls/sync-button.module.css +63 -0
  120. package/dist/controls/sync-button.module.css.d.ts +11 -0
  121. package/dist/controls/sync-button.test.d.ts +2 -0
  122. package/dist/controls/sync-button.test.d.ts.map +1 -0
  123. package/dist/controls/toggle-filter.d.ts +9 -0
  124. package/dist/controls/toggle-filter.d.ts.map +1 -0
  125. package/dist/controls/toggle-filter.js +42 -0
  126. package/dist/controls/view-tabs.d.ts +23 -0
  127. package/dist/controls/view-tabs.d.ts.map +1 -0
  128. package/dist/controls/view-tabs.js +59 -0
  129. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  130. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  131. package/dist/controls-dates/date-range-picker.js +18 -0
  132. package/dist/controls-dates/index.d.ts +9 -0
  133. package/dist/controls-dates/index.d.ts.map +1 -0
  134. package/dist/controls-dates/index.js +4 -0
  135. package/dist/dashboard/delta-badge.d.ts +6 -12
  136. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  137. package/dist/dashboard/delta-badge.js +1 -50
  138. package/dist/dashboard/index.d.ts +1 -1
  139. package/dist/dashboard/index.d.ts.map +1 -1
  140. package/dist/dashboard/index.js +3 -1
  141. package/dist/dashboard/settings-section.d.ts +6 -4
  142. package/dist/dashboard/settings-section.d.ts.map +1 -1
  143. package/dist/dashboard/settings-section.js +23 -16
  144. package/dist/dashboard/settings-section.module.css +10 -0
  145. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  146. package/dist/dashboard/settings-section.test.d.ts +2 -0
  147. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  148. package/dist/dashboard/stat-card.d.ts +95 -9
  149. package/dist/dashboard/stat-card.d.ts.map +1 -1
  150. package/dist/dashboard/stat-card.js +108 -49
  151. package/dist/dashboard/stat-card.module.css +153 -0
  152. package/dist/dashboard/stat-card.module.css.d.ts +15 -0
  153. package/dist/data/data-table.d.ts +37 -15
  154. package/dist/data/data-table.d.ts.map +1 -1
  155. package/dist/data/data-table.js +96 -58
  156. package/dist/guard/grace.test.d.ts +20 -0
  157. package/dist/guard/grace.test.d.ts.map +1 -0
  158. package/dist/guard/index.d.ts +66 -3
  159. package/dist/guard/index.d.ts.map +1 -1
  160. package/dist/guard/index.js +78 -7
  161. package/dist/guard/types.d.ts +3 -3
  162. package/dist/guard/types.d.ts.map +1 -1
  163. package/dist/index.d.ts +13 -3
  164. package/dist/index.d.ts.map +1 -1
  165. package/dist/index.js +13 -10
  166. package/dist/nav/types.d.ts +78 -2
  167. package/dist/nav/types.d.ts.map +1 -1
  168. package/dist/notifications/bell.d.ts +4 -1
  169. package/dist/notifications/bell.d.ts.map +1 -1
  170. package/dist/notifications/index.d.ts +1 -1
  171. package/dist/router-tanstack/field.d.ts +14 -0
  172. package/dist/router-tanstack/field.d.ts.map +1 -0
  173. package/dist/router-tanstack/field.js +4 -0
  174. package/dist/router-tanstack/index.d.ts +6 -2
  175. package/dist/router-tanstack/index.d.ts.map +1 -1
  176. package/dist/router-tanstack/index.js +4 -0
  177. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  178. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  179. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  180. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  181. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  182. package/dist/router-tanstack/search-param-store.js +16 -45
  183. package/dist/router-tanstack/search-store.d.ts +84 -0
  184. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  185. package/dist/router-tanstack/search-store.js +271 -0
  186. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  187. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  188. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  189. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  190. package/dist/router-tanstack/use-nav.d.ts +0 -2
  191. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  192. package/dist/router-tanstack/use-nav.js +34 -12
  193. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  194. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  195. package/dist/section/index.d.ts +2 -0
  196. package/dist/section/index.d.ts.map +1 -0
  197. package/dist/section/index.js +4 -0
  198. package/dist/section/section.d.ts +24 -0
  199. package/dist/section/section.d.ts.map +1 -0
  200. package/dist/section/section.js +126 -0
  201. package/dist/section/section.module.css +104 -0
  202. package/dist/section/section.module.css.d.ts +11 -0
  203. package/dist/section/section.test.d.ts +2 -0
  204. package/dist/section/section.test.d.ts.map +1 -0
  205. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  206. package/dist/shell/app-breadcrumbs.js +18 -15
  207. package/dist/shell/app-header.module.css +77 -37
  208. package/dist/shell/app-header.module.css.d.ts +2 -1
  209. package/dist/shell/app-mobile-nav.d.ts +9 -2
  210. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  211. package/dist/shell/app-mobile-nav.js +98 -11
  212. package/dist/shell/app-mobile-nav.module.css +8 -0
  213. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  214. package/dist/shell/app-sidebar.d.ts +55 -28
  215. package/dist/shell/app-sidebar.d.ts.map +1 -1
  216. package/dist/shell/app-sidebar.js +116 -67
  217. package/dist/shell/app-sidebar.module.css +303 -20
  218. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  219. package/dist/shell/index.d.ts +55 -21
  220. package/dist/shell/index.d.ts.map +1 -1
  221. package/dist/shell/index.js +40 -18
  222. package/dist/shell/mobile-nav-model.d.ts +13 -2
  223. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  224. package/dist/shell/mobile-nav-model.js +15 -1
  225. package/dist/shell/nav-count-badge.d.ts +1 -1
  226. package/dist/shell/page-bar.d.ts +51 -0
  227. package/dist/shell/page-bar.d.ts.map +1 -0
  228. package/dist/shell/page-bar.js +154 -0
  229. package/dist/shell/page-bar.module.css +178 -0
  230. package/dist/shell/page-bar.module.css.d.ts +17 -0
  231. package/dist/shell/page-bar.test.d.ts +2 -0
  232. package/dist/shell/page-bar.test.d.ts.map +1 -0
  233. package/dist/shell/sidebar-block-model.d.ts +48 -0
  234. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  235. package/dist/shell/sidebar-block-model.js +39 -0
  236. package/dist/shell/sidebar-blocks.d.ts +32 -0
  237. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  238. package/dist/shell/sidebar-blocks.js +169 -0
  239. package/dist/shell/sidebar-search.d.ts +13 -1
  240. package/dist/shell/sidebar-search.d.ts.map +1 -1
  241. package/dist/shell/sidebar-search.js +34 -1
  242. package/dist/shell/sidebar-search.module.css +26 -0
  243. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  244. package/dist/state/fields.d.ts +565 -0
  245. package/dist/state/fields.d.ts.map +1 -0
  246. package/dist/state/fields.js +405 -0
  247. package/dist/state/fields.test.d.ts +2 -0
  248. package/dist/state/fields.test.d.ts.map +1 -0
  249. package/dist/state/persisted.d.ts +60 -0
  250. package/dist/state/persisted.d.ts.map +1 -0
  251. package/dist/state/persisted.js +123 -0
  252. package/dist/state/persisted.test.d.ts +2 -0
  253. package/dist/state/persisted.test.d.ts.map +1 -0
  254. package/dist/state.d.ts +11 -56
  255. package/dist/state.d.ts.map +1 -1
  256. package/dist/state.js +14 -120
  257. package/dist/styles.css +13 -0
  258. package/dist/surfaces.d.ts +106 -24
  259. package/dist/surfaces.d.ts.map +1 -1
  260. package/dist/surfaces.js +156 -19
  261. package/dist/theme/controls.module.css +16 -1
  262. package/dist/theme/ctl-theme.d.ts +32 -0
  263. package/dist/theme/ctl-theme.d.ts.map +1 -0
  264. package/dist/theme/ctl-theme.js +54 -0
  265. package/dist/theme/ctl-theme.test.d.ts +2 -0
  266. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  267. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  268. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  269. package/dist/theme/divider-law.test.d.ts +2 -0
  270. package/dist/theme/divider-law.test.d.ts.map +1 -0
  271. package/dist/theme/icon-slot.d.ts +44 -0
  272. package/dist/theme/icon-slot.d.ts.map +1 -0
  273. package/dist/theme/icon-slot.js +21 -0
  274. package/dist/theme/icon-slot.module.css +47 -0
  275. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  276. package/dist/theme/icon-slot.test.d.ts +2 -0
  277. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  278. package/dist/theme/index.d.ts +1 -0
  279. package/dist/theme/index.d.ts.map +1 -1
  280. package/dist/theme/index.js +82 -5
  281. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  282. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  283. package/dist/theme/segmented-control.module.css +20 -0
  284. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  285. package/dist/tokens/index.d.ts +12 -0
  286. package/dist/tokens/index.d.ts.map +1 -1
  287. package/dist/tokens/index.js +38 -8
  288. package/dist/tokens/palette.d.ts +97 -61
  289. package/dist/tokens/palette.d.ts.map +1 -1
  290. package/dist/tokens/palette.js +76 -38
  291. package/dist/tokens.css +10 -2
  292. package/dist/widget-header/delta-badge.d.ts +13 -0
  293. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  294. package/dist/widget-header/delta-badge.js +29 -0
  295. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  296. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  297. package/dist/widget-header/index.d.ts +3 -0
  298. package/dist/widget-header/index.d.ts.map +1 -0
  299. package/dist/widget-header/index.js +6 -0
  300. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  301. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  302. package/dist/widget-header/widget-header.d.ts +67 -0
  303. package/dist/widget-header/widget-header.d.ts.map +1 -0
  304. package/dist/widget-header/widget-header.js +89 -0
  305. package/dist/widget-header/widget-header.module.css +350 -0
  306. package/dist/widget-header/widget-header.module.css.d.ts +25 -0
  307. package/dist/widget-header/widget-header.test.d.ts +2 -0
  308. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  309. package/llms.txt +30 -17
  310. package/package.json +14 -1
  311. package/src/charts/cursor/resolve.test.ts +29 -1
  312. package/src/charts/cursor/resolve.ts +38 -6
  313. package/src/charts/cursor/scope.tsx +6 -3
  314. package/src/charts/cursor/store.test.ts +19 -1
  315. package/src/charts/cursor/store.ts +10 -5
  316. package/src/charts/hooks/useChartCursor.ts +16 -8
  317. package/src/charts/index.ts +2 -2
  318. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  319. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  320. package/src/charts/primitives/ChartCard.tsx +86 -146
  321. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  322. package/src/cli/check-coverage.test.ts +297 -0
  323. package/src/cli/check-theme.test.ts +38 -14
  324. package/src/cli/index.ts +392 -16
  325. package/src/cli/init-sync.test.ts +97 -0
  326. package/src/connectivity/connectivity-indicator.tsx +16 -9
  327. package/src/content/index.ts +16 -3
  328. package/src/content/prose.module.css +7 -28
  329. package/src/content/prose.module.css.test.ts +8 -24
  330. package/src/controls/actions.module.css +62 -0
  331. package/src/controls/actions.module.css.d.ts +13 -0
  332. package/src/controls/actions.test.tsx +252 -0
  333. package/src/controls/actions.tsx +655 -0
  334. package/src/controls/actions.type-guard.test.ts +63 -0
  335. package/src/controls/compare-filter.tsx +64 -0
  336. package/src/controls/control-group.test.tsx +229 -0
  337. package/src/controls/control-group.tsx +63 -0
  338. package/src/controls/controls.module.css +542 -0
  339. package/src/controls/controls.module.css.d.ts +35 -0
  340. package/src/controls/controls.router.test.tsx +1053 -0
  341. package/src/controls/enum-filter.tsx +170 -0
  342. package/src/controls/filter-context.tsx +158 -0
  343. package/src/controls/filter-pill.tsx +120 -0
  344. package/src/controls/filter-set.test.tsx +472 -0
  345. package/src/controls/filter-set.tsx +225 -0
  346. package/src/controls/filter-sheet.tsx +285 -0
  347. package/src/controls/glyphs.tsx +81 -0
  348. package/src/controls/index.ts +49 -0
  349. package/src/controls/multi-select-filter.tsx +140 -0
  350. package/src/controls/number-filter.tsx +291 -0
  351. package/src/controls/peer-boundary.test.ts +85 -0
  352. package/src/controls/props-contract.test.tsx +137 -0
  353. package/src/controls/range-filter.tsx +237 -0
  354. package/src/controls/range-filter.type-guard.test.tsx +122 -0
  355. package/src/controls/search-filter.tsx +123 -0
  356. package/src/controls/select-filter.tsx +89 -0
  357. package/src/controls/sync-button.module.css +63 -0
  358. package/src/controls/sync-button.module.css.d.ts +11 -0
  359. package/src/controls/sync-button.test.tsx +183 -0
  360. package/src/controls/sync-button.tsx +197 -0
  361. package/src/controls/toggle-filter.tsx +60 -0
  362. package/src/controls/view-tabs.tsx +110 -0
  363. package/src/controls-dates/date-range-picker.tsx +42 -0
  364. package/src/controls-dates/index.ts +8 -0
  365. package/src/dashboard/delta-badge.tsx +4 -74
  366. package/src/dashboard/index.ts +6 -1
  367. package/src/dashboard/settings-section.module.css +10 -0
  368. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  369. package/src/dashboard/settings-section.test.tsx +56 -0
  370. package/src/dashboard/settings-section.tsx +33 -30
  371. package/src/dashboard/stat-card.module.css +153 -0
  372. package/src/dashboard/stat-card.module.css.d.ts +15 -0
  373. package/src/dashboard/stat-card.test.tsx +298 -1
  374. package/src/dashboard/stat-card.tsx +289 -67
  375. package/src/data/data-table.test.tsx +96 -11
  376. package/src/data/data-table.tsx +228 -84
  377. package/src/guard/check-source.test.ts +159 -13
  378. package/src/guard/grace.test.ts +158 -0
  379. package/src/guard/index.ts +185 -29
  380. package/src/guard/types.ts +4 -2
  381. package/src/index.ts +51 -5
  382. package/src/nav/types.ts +84 -2
  383. package/src/notifications/bell.tsx +6 -3
  384. package/src/notifications/index.ts +1 -1
  385. package/src/router-tanstack/field.ts +35 -0
  386. package/src/router-tanstack/index.ts +28 -2
  387. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  388. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  389. package/src/router-tanstack/search-param-store.ts +42 -159
  390. package/src/router-tanstack/search-store.router.test.tsx +882 -0
  391. package/src/router-tanstack/search-store.test.ts +578 -0
  392. package/src/router-tanstack/search-store.ts +546 -0
  393. package/src/router-tanstack/use-nav.test.tsx +187 -0
  394. package/src/router-tanstack/use-nav.ts +72 -14
  395. package/src/section/index.ts +1 -0
  396. package/src/section/section.module.css +104 -0
  397. package/src/section/section.module.css.d.ts +11 -0
  398. package/src/section/section.test.tsx +204 -0
  399. package/src/section/section.tsx +220 -0
  400. package/src/shell/app-breadcrumbs.tsx +44 -31
  401. package/src/shell/app-header.module.css +77 -37
  402. package/src/shell/app-header.module.css.d.ts +2 -1
  403. package/src/shell/app-mobile-nav.module.css +8 -0
  404. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  405. package/src/shell/app-mobile-nav.tsx +147 -8
  406. package/src/shell/app-sidebar-account.tsx +2 -2
  407. package/src/shell/app-sidebar.module.css +303 -20
  408. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  409. package/src/shell/app-sidebar.test.tsx +456 -41
  410. package/src/shell/app-sidebar.tsx +276 -140
  411. package/src/shell/index.test.tsx +291 -5
  412. package/src/shell/index.tsx +137 -38
  413. package/src/shell/mobile-nav-model.test.ts +228 -7
  414. package/src/shell/mobile-nav-model.ts +53 -3
  415. package/src/shell/nav-count-badge.tsx +1 -1
  416. package/src/shell/page-bar.module.css +178 -0
  417. package/src/shell/page-bar.module.css.d.ts +17 -0
  418. package/src/shell/page-bar.test.tsx +316 -0
  419. package/src/shell/page-bar.tsx +315 -0
  420. package/src/shell/sidebar-block-model.ts +83 -0
  421. package/src/shell/sidebar-blocks.tsx +301 -0
  422. package/src/shell/sidebar-search.module.css +26 -0
  423. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  424. package/src/shell/sidebar-search.tsx +71 -2
  425. package/src/state/fields.test.tsx +512 -0
  426. package/src/state/fields.ts +1150 -0
  427. package/src/state/persisted.test.ts +91 -0
  428. package/src/state/persisted.ts +261 -0
  429. package/src/state.ts +52 -240
  430. package/src/styles.css +13 -0
  431. package/src/surfaces.ts +204 -36
  432. package/src/theme/controls.module.css +16 -1
  433. package/src/theme/ctl-theme.test.tsx +275 -0
  434. package/src/theme/ctl-theme.tsx +138 -0
  435. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  436. package/src/theme/density-relations.test.ts +106 -51
  437. package/src/theme/divider-law.test.ts +259 -0
  438. package/src/theme/icon-slot.module.css +47 -0
  439. package/src/theme/icon-slot.module.css.d.ts +8 -0
  440. package/src/theme/icon-slot.test.tsx +275 -0
  441. package/src/theme/icon-slot.tsx +59 -0
  442. package/src/theme/index.ts +165 -3
  443. package/src/theme/layout-rhythm-css.test.ts +72 -0
  444. package/src/theme/radius.test.ts +11 -1
  445. package/src/theme/segmented-control.module.css +20 -0
  446. package/src/theme/shadow-surfaces.test.ts +20 -7
  447. package/src/theme/spacing.test.ts +75 -27
  448. package/src/theme/use-basalt-spacing.ts +2 -2
  449. package/src/tokens/density.test.ts +7 -25
  450. package/src/tokens/index.ts +48 -8
  451. package/src/tokens/palette.ts +107 -73
  452. package/src/widget-header/delta-badge.test.tsx +60 -0
  453. package/src/widget-header/delta-badge.tsx +57 -0
  454. package/src/widget-header/index.ts +2 -0
  455. package/src/widget-header/no-mantine.test.ts +33 -0
  456. package/src/widget-header/widget-header.module.css +350 -0
  457. package/src/widget-header/widget-header.module.css.d.ts +25 -0
  458. package/src/widget-header/widget-header.test.tsx +187 -0
  459. package/src/widget-header/widget-header.tsx +213 -0
  460. package/agent/rules/basalt-agent.md +0 -452
  461. package/agent/rules/basalt-app.md +0 -246
  462. package/agent/rules/basalt-commands.md +0 -320
  463. package/agent/rules/basalt-content.md +0 -409
  464. package/agent/rules/basalt-data.md +0 -329
  465. package/agent/rules/basalt-forms.md +0 -186
  466. package/agent/rules/basalt-notifications.md +0 -264
  467. package/agent/rules/basalt-query.md +0 -187
  468. package/agent/rules/basalt-router.md +0 -336
  469. package/dist/content/article-filter-bar.d.ts +0 -40
  470. package/dist/content/article-filter-bar.d.ts.map +0 -1
  471. package/dist/content/article-filter-bar.js +0 -56
  472. package/dist/shell/page-header.d.ts +0 -13
  473. package/dist/shell/page-header.d.ts.map +0 -1
  474. package/dist/shell/page-header.js +0 -23
  475. package/src/content/article-filter-bar.tsx +0 -88
  476. package/src/shell/page-header.tsx +0 -35
package/MIGRATING.md CHANGED
@@ -32,13 +32,755 @@ 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
+ **Nothing removed. Two exports added (`NumberFilter`, and three members on the number handle),
36
+ one member added to every `FieldHandle` (`clear()`), four `StatCard` props added, and enforcement
37
+ tightened: five oxlint rules plus one guard kind became `error`.** The store half of this minor also
38
+ fixed four inference and notification defects consumers hit while porting, plus two ways a fallback
39
+ got pinned into localStorage — see § Stores below. Every level change honours the `theme-allow`
40
+ grammar unchanged; only the severity of an unwaived finding moves.
41
+
42
+ ### Stores — the `custom` flag, patched writes, lazy fallbacks, derived windows
43
+
44
+ **Additive, except one type that got narrower on purpose.** `field.range({ presets, fallback })`
45
+ written INLINE inside `createSearchStore({ fields })` used to infer `custom` as `boolean` — the
46
+ contextual `AnyField` return type won over the argument — so every value and search type carried a
47
+ `'custom'` preset the field itself rejects at runtime, and `RangeFilter field={…}` needed a cast or
48
+ an explicit `custom: false`. `field.range` is now three overloads: an omitted or `false` flag is
49
+ `false`, a literal `true` is `true`, a value typed `boolean` stays widened.
50
+
51
+ - **Delete the workaround, not the field.** An explicit `custom: false` still works (same
52
+ overload); a cast on `<RangeFilter field={…} />` can go, and `RangeFilterProps<P, C>` is generic
53
+ over the flag so a preset-only handle binds directly.
54
+ - **What can newly type-error:** code that read `'custom'` out of a range whose field never declared
55
+ it — `if (search.range === 'custom')` on a preset-only field, or a `RangeValue<P | 'custom'>`
56
+ annotation over one. That branch was always dead at runtime; the type just stopped pretending.
57
+ - **…and one more, in a consumer's own WRAPPER.** A prop typed `FieldHandle<RangeField<P>>` — `C`
58
+ defaulting to `boolean` — no longer accepts a handle from a `field.range` without `custom: true`,
59
+ because that handle's setter is now `RangeValue<P>`-typed and a setter is contravariant. Pin
60
+ `FieldHandle<RangeField<P, false>>` for the preset-only shape, `RangeField<P, true>` for a
61
+ picker-backed one, or make the wrapper generic over the flag
62
+ (`<P extends string, C extends boolean>` + `FieldHandle<RangeField<P, C>>`), which is what
63
+ `RangeFilter` does. An existing `as FieldHandle<RangeField<P>>` cast keeps compiling.
64
+
65
+ **`toWindow()` resolves derived presets.** `field.range({ window: { '3m': (now) => ({ from, to }) } })`
66
+ makes `toWindow({ preset: '3m' })` return `{ from, to }` (ISO dates, resolved at call time with the
67
+ current `Date`) while `3m` stays one preset in the URL. A preset with no resolver keeps
68
+ `{ window: preset }`, in the same field — which is what retires the last hand-rolled
69
+ `presetToParams` a consumer kept beside the store for `3m` / `6m` / `1y` / `ytd`.
70
+
71
+ **The resolved presets are gone from the return TYPE too**, which is the half that decides whether
72
+ the switch is deleted or the cast just moves: the field now carries its resolver keys (`RangeField`'s
73
+ fifth type parameter, inferred from `window`), and `toWindow` returns
74
+ `{ window: Exclude<P, W> } | { from; to }`. A `resolveWindow` whose target is
75
+ `{ window: '7d' | '30d' | '90d' | 'all' } | { from; to }` becomes a one-line delegation with no
76
+ `as`. One guard survives on a `custom: true` field: a `'custom'` preset that arrives WITHOUT dates
77
+ resolves to `{ window: 'custom' }`, so `'custom'` stays in the union by construction.
78
+
79
+ **A field setter takes an optional second argument.** `set(next, { patch })` merges extra search
80
+ params into the SAME navigate — URL lane only, for keys the store does not own (`{ patch: {
81
+ detailDate: undefined } }` clears a sibling param the page put there). The field's own params always
82
+ win over the patch, so it cannot corrupt the value being set. A local/memory-lane write has no
83
+ navigate to merge into and ignores it.
84
+
85
+ Two limits are now enforced rather than documented. A patch key **another field of the same store
86
+ owns** is refused — it would reach the URL while the store's own write path never touched the mirror,
87
+ so the next paramless visit and every `linkSearch` link resolve the OLD value: `createSearchStore`
88
+ throws in dev, and in production keeps the write and logs it once. Write that field through its own
89
+ setter instead. And a patched write **from a route that does not validate the field** persists (A1)
90
+ but drops the patch with it — there is no navigate to merge into — which now warns once per field in
91
+ dev instead of doing nothing.
92
+
93
+ **A fallback may be a thunk.** `field.*({ fallback: () => T })` (every kind — `field.number`'s codec
94
+ froze its thunk at store definition in the first cut of this minor and does not now) is resolved at
95
+ READ time and re-resolved while nothing is written, and is never persisted on its own — a write
96
+ stores what the control produced, as before. **Local and memory lanes only:** `createSearchStore` now
97
+ THROWS at definition for a thunk on a URL-lane field, because `validateSearch` would evaluate it on
98
+ every navigation and pin the result into the URL. Move the field off the URL lane (`{ url: false }`,
99
+ or `createLocalStore`) or pass a value.
100
+
101
+ **`FieldHandle` gained `clear()`, and a reset now UNSETS.** `useReset()` used to write
102
+ `entry.codec.fallback` into the mirror for every persisted field, and every control's reset called
103
+ `setValue(field.fallback)` — both of which pin a RESOLVED thunk: pressing `Reset all` over
104
+ `field.string({ fallback: () => todayIso() }, { url: false })` stored today's date, so tomorrow the
105
+ field read a value nobody chose and counted as active in `Filters (n)`. Both paths now delete the
106
+ key instead (the memory lane always did), and `clear()` is the per-field door: the persist lane
107
+ deletes its key, the memory lane drops its value, the URL lane navigates back to the fallback
108
+ params.
109
+
110
+ Nothing to change at a call site — every shipped control's reset calls it. A consumer that
111
+ hand-rolled a reset should swap `setValue(field.fallback)` for `field.clear()`; a hand-built
112
+ `FieldHandle` (a facet column, a test double) must add the member, which is the one type-error this
113
+ adds.
114
+
115
+ **`createPersistedState` notifies across instances (bug fix).** Two instances over one key — a
116
+ page's store and a widget's own state naming the same key, or a store re-exported from two modules —
117
+ kept per-instance listener sets, so a write through one left the other's components rendering the
118
+ stale value **in the same tab** while the cross-tab `storage` path worked. Subscribers are now
119
+ registered per storage key. No API change; if you were re-rendering something by hand to work around
120
+ this, delete it.
121
+
122
+ **`RangeFilter` warns once in dev** when the field declares `custom: true` and no `customPicker` was
123
+ injected: the custom window is then unreachable (no popover picker, no `Custom range…` row in the
124
+ sheet). Preset-only remains a legal configuration — the picker is injected precisely because
125
+ `@mantine/dates` may be absent — so this is a warning, not a type error.
126
+
127
+ ### Controls — `NumberFilter`, the `field.number` lane
128
+
129
+ **One export added, nothing removed.** `field.number` has existed since the store landed and had no
130
+ control, so both consumers that needed one wrote around it in opposite directions: linewatch kept a
131
+ raw `SegmentedControl` over `minDuration` (a `control-outside-home` warn, law C1), and argo widened
132
+ `nights` into a string enum — which puts `'3'` in the URL and makes every downstream comparison a
133
+ parse.
134
+
135
+ ```tsx
136
+ import { NumberFilter } from 'basalt-ui/controls'
137
+
138
+ // A preset set — a pill plus a radio list, the same body every enum filter renders.
139
+ <NumberFilter field={booking.field.nights} label="Nights"
140
+ options={[{ value: 1, label: '1 night' }, { value: 7, label: 'A week' }]} />
141
+
142
+ // No `options` → a pill whose popover holds a `ctl` NumberInput; the sheet renders it full-width.
143
+ <NumberFilter field={lines.field.minDuration} label="Min duration" step={30} />
144
+ ```
145
+
146
+ Three properties worth knowing before porting a call site:
147
+
148
+ - **The URL keeps a NUMBER.** `options` takes `{ value: number; label: string }`, and a numeral
149
+ STRING there is a type error — the whole reason to stop widening a threshold into an enum.
150
+ - **The stepper applies on blur or Enter, never per keystroke.** A number is typed digit by digit, so
151
+ a live write would navigate on `4`, `42`, `420` — three loader runs for one intended threshold,
152
+ two of them values nobody meant. `SearchFilter` debounces; a number has an explicit commit point.
153
+ - **`min`/`max`/`int` come off the HANDLE, not the call site.** They are deliberately not props: the
154
+ field is what validates the URL, and a second copy at the call site is a second answer to the same
155
+ question. The number handle republishes all three (see § Stores — the number handle below), so the
156
+ stepper stops at the field's limit and an `int` field refuses decimals outright. A TYPED value is
157
+ clamped to those bounds when it commits, so the box reads what the store holds even when the store
158
+ does not move — a second out-of-range `9999` against `max: 600` over a stored `600` writes nothing,
159
+ and the readout still corrects itself to `600`. The codec's clamp stays the backstop for a value
160
+ that never came through the box at all — a hand-typed URL, a stale deep link.
161
+
162
+ ### Stores — the number handle republishes its bounds
163
+
164
+ **Three members added to `FieldHandle<NumberField>`, nothing removed.** `min`, `max` and `int` now
165
+ sit on the handle, filled from the field declaration (`NumberHandleExtras`, the shape
166
+ `RangeHandleExtras`/`toWindow` already had). A control never sees the field descriptor — only the
167
+ handle — so before this the only way one could learn its own limits was for the call site to pass
168
+ them a second time.
169
+
170
+ ```ts
171
+ const nights = store.field.nights // field.number({ fallback: 2, min: 1, max: 14, int: true })
172
+ nights.min // 1 — `undefined` when the field declared none
173
+ nights.max // 14
174
+ nights.int // true — `false`, never `undefined`, on a number handle
175
+ ```
176
+
177
+ Non-number handles carry all three as `undefined`, the same way `toWindow` is `undefined` off a
178
+ range handle. That is a type-level fact, not just a runtime one, and it matters for one collision in
179
+ particular: `StringField` has its own `max`, which is NOT republished — a string handle's `max` is
180
+ `undefined`.
181
+
182
+ Nothing about the clamp changed. The codec has always clamped a number to the declared bounds on
183
+ write, and still does; the handle is a readout of that law, which is what lets `NumberFilter` bound
184
+ its stepper instead of letting the user watch the correction happen.
185
+
186
+ ### `StatCard` — `unit`, `breakdown` and the delta's own format
187
+
188
+ **Four props added, nothing removed or renamed; every one defaults to today's rendering when
189
+ omitted.**
190
+ `value` is a pre-formatted `string`, so a card had exactly one text channel under the hero row
191
+ (`subtitle`) and three consumers wanting a unit AND a basis AND a split hand-rolled the card instead
192
+ — which is the fork `shadow-basalt-export` reports as a `HeroCard`.
193
+
194
+ | Prop | Type | Renders |
195
+ | ------------- | ------------------------------------------------------------------ | ----------------------------------------------------------------------------- |
196
+ | `unit` | `string` | after the value on the hero row — muted, mono, `--vx-text-sm` |
197
+ | `breakdown` | `readonly { label: string; value: string; tone?: StatCardTone }[]` | compact rows under the hero, one `controlHeightTag` line each |
198
+ | `deltaFormat` | `(delta: number) => string` | the delta chip's label. Default unchanged: `Math.abs(delta).toFixed(1) + '%'` |
199
+ | `deltaGlyph` | `boolean` | the chip's ▲/▼. Default `true`; a zero delta never shows one |
200
+
201
+ `unit` is forwarded to `WidgetHeader`, which gained the same prop, so `ChartCard` and `Section`
202
+ headers get it too. It is NOT the basis: `412` + `TSS` is the pair, and `7-day rolling` is still
203
+ `subtitle`. A thousands separator, a currency symbol or a `%` still belong in `value`.
204
+
205
+ `breakdown` draws **no hairlines** — §2.1 puts a horizontal rule between OPTION rows and nowhere
206
+ else, and `theme/divider-law.test.ts` inventories every one basalt draws. The rows separate by
207
+ weight (muted label, mono ink value) instead. Keep it to two or three: past that the card is a
208
+ table, and a table is `BasaltDataTable` in a `Section`. A row's `tone` reads the same per-scheme
209
+ `--vx-status-*` solid the card's own rail does, and omitting it is untinted, never `'good'`.
210
+
211
+ `deltaFormat` exists because **a delta is not always a percentage, and `StatCard` claimed it was**:
212
+ `delta={0.3}` on a pace or speed card rendered `▲0.3%`, a wrong unit on a KPI — worse than no chip —
213
+ so the one consumer that needed it kept the card hand-rolled and took the `shadow-basalt-export` warn
214
+ instead. `DeltaBadge` had `format` all along; neither `WidgetHeader` nor `StatCard` forwarded it. Both now do.
215
+ `ChartCard`/`Section` are unchanged: each declares its own header props and forwards them one by
216
+ one, so a delta-bearing `ChartCard` or `Section` still prints the percentage — ask if you need it
217
+ there.
218
+
219
+ ```tsx
220
+ // A percentage, unchanged — no prop, no move.
221
+ <StatCard title="Volume" value="1,204" delta={4.2} deltaPeriod="WoW" />
222
+
223
+ // An absolute delta. The formatter gets the SIGNED number, so it may print the sign itself;
224
+ // `deltaGlyph={false}` then stops the ▼ saying the same thing twice. No `deltaLabel` prop exists —
225
+ // the function IS the escape hatch, and `delta` stays the number that drives the tone.
226
+ <StatCard title="Pace" value="5:31" unit="/km" delta={-12} deltaGlyph={false}
227
+ deltaFormat={(s) => `${s < 0 ? '−' : '+'}0:${String(Math.abs(s)).padStart(2, '0')} /km`} />
228
+ ```
229
+
230
+ ### Guards — the 1.27.0 promotions, one narrowing and one new exemption
231
+
232
+ **Five plugin rules and one guard kind became `error` in the shipped preset.** Their
233
+ `PLUGIN_RULE_GRACE` / `GRACE_PERIOD_KINDS` entries are deleted, which IS the promotion (C16). A
234
+ `theme-allow` written against any of them still works unchanged; only the severity of an UNWAIVED
235
+ finding moves.
236
+
237
+ | Promoted to `error` | Waiver, if the finding is deliberate |
238
+ | -------------------------------- | --------------------------------------------------------------------------------------------- |
239
+ | `basalt/control-size-literal` | `theme-allow control-size-literal — <why>` above the node, or `theme-allow-file` for the file |
240
+ | `basalt/in-body-page-title` | `theme-allow in-body-page-title — <why>` — ONE id, so it waives the guard kind's lane too |
241
+ | `in-body-page-title` (kind) | the same annotation; the two lanes share the id by construction |
242
+ | `basalt/responsive-twin` | `theme-allow responsive-twin — <why>`; the real fix is deleting one mount |
243
+ | `basalt/search-literal-link` | `theme-allow search-literal-link — <why>`; the real fix is `search: <store>.linkSearch` |
244
+ | `basalt/use-search-from-literal` | `theme-allow use-search-from-literal — <why>`; the real fix is the param as a prop |
245
+
246
+ The commonest in-body-title case that is NOT a defect: **a shell-less surface naming itself** — an
247
+ auth gate, an error boundary, a print view. There is no breadcrumb to carry the name, so write
248
+ `theme-allow in-body-page-title — shell-less surface`.
249
+
250
+ **`control-size-literal` also gained one exemption in the same minor, and it removes a waiver rather
251
+ than adding one: a slot owned by `ChartCard` no longer reports.** `ChartCard` lives inside the
252
+ Mantine-free chart layer, so its `actions` slot writes `data-basalt-tier` by hand and cannot mount
253
+ the tier theme at all — a `Switch` there with no `size` renders at Mantine's default, not at `ctl`.
254
+ The rule's message ("the HOME sets the tier, drop the prop") was simply false in that one slot, and
255
+ because the rule fires on the `size` ATTRIBUTE it could not tell a correct `size="ctl"` from the
256
+ `size="xs"` it exists to catch. Every other home still reports, and the owner test reads the
257
+ IMPORTED name so `ChartCard as Card` is exempt too. `hand-rolled-filter` is deliberately NOT
258
+ exempted there: a raw `Select` in `ChartCard.actions` is still a filter that should take a `field`.
259
+ A HOISTED binding is exempt only when EVERY basalt home it was handed to is `ChartCard` — one
260
+ `const acts = <Button size="xs"/>` given to both `<ChartCard actions>` and `<Section actions>` still
261
+ renders in a tiered slot, and keying the exemption on a single owner made the verdict depend on which
262
+ attribute came later in the file.
263
+
264
+ **`basalt/control-outside-home` and `raw-selection-control` did NOT promote. They are re-dated to
265
+ 1.28.0, and the reason is a measurement rather than caution:** the wave-7 consumer run left 9 warns
266
+ in argo, and every one is a control inside a modal/form module whose `<Modal>` is rendered by the
267
+ PARENT route — law C1's cross-file case, which is advisory by declaration because no scan of one
268
+ file can see it. Both lanes now exempt a file whose BASENAME matches
269
+ `*-{modal,drawer,popover,panel,form}.tsx`, which is the convention those nine already follow.
270
+ Outside the convention, an overlay body declares itself:
271
+ `theme-allow-file control-outside-home — overlay`. The trade is stated plainly: a whole file goes
272
+ unscanned on a naming convention, so a `Select` that genuinely belongs in a page bar goes unreported
273
+ if it lives in `filters-panel.tsx` — the same bargain the `@mantine/form` exemption already buys,
274
+ and a smaller one than promoting a rule with 9 known false positives.
275
+
276
+ **`basalt/shadow-basalt-export` narrowed, and it stays a permanent advisory `warn`.** An ALIAS hit is
277
+ now skipped when the file imports the basalt export it renames **and REFERENCES that binding as a
278
+ value**: a `HeroCard` that composes `StatCard` is a wrapper, not a fork, and it was already following
279
+ the advice the message gives. Composition, not import — three shapes stay reported, and they are the
280
+ ones a fork actually writes: a type-only `import type { StatCard }` (or an inline `type` specifier)
281
+ feeding `ComponentProps<typeof StatCard>`, a VALUE import used only in a type position, and a dead
282
+ import left behind after the body was re-rolled. None of them render anything. A reference does not
283
+ have to be a JSX tag — `component={StatCard}` and `createElement(StatCard)` compose it too. The
284
+ provenance test reads the IMPORTED name, so `import { StatCard as Base }` counts. The name-COLLISION
285
+ half is deliberately NOT exempted the same way — a local `StatCard` beside an
286
+ `import { StatCard as Base }` kept the name AND a piece of the original, which is the fork shape this
287
+ rule most wants to see.
288
+
289
+ ## 1.26.0 — the control tier, the page bar and the store
290
+
291
+ **One export removed and two deprecated — see § Stores below; two shell PROPS removed — see
292
+ § Sidebar. Two other behaviour changes: the grace ledgers changed shape, and nine
293
+ long-stale entries (D4, `docs/archive/CONTROLS-SYNTHESIS.md`) are promoted.** C16
294
+ (`docs/CONTROLS-SPEC.md` §1) is the new law behind both: a grace entry now carries `{ since,
295
+ promote, why }` (semver strings) instead of a bare promotion-note string, and a version-gated test
296
+ fails the build once `package.json`'s version reaches an entry's `promote` while the entry is still
297
+ in the ledger — and `make release` refuses to cut a release whose COMPUTED version has reached one,
298
+ which is the half that fires before the release rather than one minor after it. That is what stops a
299
+ rule sitting at `warn` for five minors with nothing tracking the promise, which is exactly what
300
+ happened to the nine rows below.
301
+
302
+ **`PLUGIN_RULE_GRACE`** (`configs/oxlint-plugin.js`) **and `GRACE_PERIOD_KINDS`**
303
+ (`src/guard/index.ts`) no longer carry any PRE-EXISTING entry — every one either promoted to `error`
304
+ (the table below) or moved to the new, permanent `PLUGIN_RULE_ADVISORY` ledger
305
+ (`shadow-basalt-export`). Both ledgers now hold only the wave-6 control guards listed under
306
+ § Guards — six plugin rules and two guard kinds, each `{ since: '1.26.0', promote: '1.27.0' }` —
307
+ which the C16 gate forced to promote or be deleted at 1.27.0. Five rules and one kind promoted there;
308
+ the C1 pair was re-dated to 1.28.0 against a measurement (see `## Unreleased` above). A theme-allow escape written against
309
+ any of these still works unchanged; only the SEVERITY of an unwaived finding moves from `warn` to
310
+ `error`.
311
+
312
+ | Rule / kind (promoted) | What now errors | Escape hatch |
313
+ | -------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------- |
314
+ | `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 |
315
+ | `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>` |
316
+ | `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 |
317
+ | `theme-allow-unscoped` (guard kind) | A bare `// theme-allow` with no rule id | Name the kind(s): `theme-allow <kind> — <why>` |
318
+ | `surface-shadow-override` (guard kind) | A hand-composed `boxShadow` reaching a `--vx-*` token | Use the shipped shadow tokens, or waive with a reason |
319
+ | `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 |
320
+ | `inline-font-size` (guard kind) | A hardcoded numeric font size in an inline `style` object | `VX.text.*` / `--vx-text-*`, or waive |
321
+ | `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 |
322
+
323
+ **`basalt/shadow-basalt-export` is unaffected** — it moves ledgers (`PLUGIN_RULE_GRACE` →
324
+ `PLUGIN_RULE_ADVISORY`), not severity. It stays `warn` in the shipped preset, permanently, and is
325
+ never subject to the C16 gate: see "Lint and guard rules that tightened" below for why.
326
+
327
+ Consumers who never overrode `basalt.severity` for any of the eight kinds/rules above and never
328
+ waived them see these findings for the first time as `error`; measure against the shipped preset
329
+ before upgrading if that's a concern (`basalt-ui check-theme --audit-allows`, `oxlint .`).
330
+
331
+ ### Composers — WidgetHeader-backed renames (docs/CONTROLS-SPEC.md §2.2)
332
+
333
+ `StatCard`, `ChartCard`, `Section` (new), `SettingsSection`/`DangerZone` and `BasaltDataTable` now
334
+ each compose the wave-1 `WidgetHeader` primitive for their title row instead of hand-rolling one —
335
+ several props renamed or were added to line up with it.
336
+
337
+ | Component | Removed / renamed | Replacement |
338
+ | -------------------------------- | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
339
+ | `StatCard` | `label` | `title` |
340
+ | `StatCard` | `menu` | `actions` (wrapped in `CtlSlot`) |
341
+ | `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`) |
342
+ | `ChartCard` | `tooltip` | `info` |
343
+ | `ChartCard` | `extra` | `actions` (carries `data-basalt-tier="widget"`, no `CtlSlot` — `./charts` stays Mantine-free) |
344
+ | `ChartCard` | `title: string` (required) | `title?: string` — the header now renders only when title/info/value/actions/icon/count is set |
345
+ | `ChartCard` | — | adds `icon`, `value`, `delta`, `deltaPeriod`, `count` |
346
+ | `SettingsSection` / `DangerZone` | `description` | `subtitle` |
347
+ | `SettingsSection` / `DangerZone` | — | adds `actions` (wrapped in `CtlSlot`) |
348
+ | `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 |
349
+ | `BasaltDataTable` | fixed `w={220/200/180/110}` on toolbar/pagination controls | removed — the controls size by content |
350
+ | `BasaltDataTable` | — | adds `title`, `icon`, `subtitle` — when `title` is set, renders `WidgetHeader tier="widget"` above the toolbar, `count` always `table.getRowCount()` (C11) |
351
+ | `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" |
352
+
353
+ New export: **`Section`** (`.`, Mantine-coupled) — `WidgetHeaderProps` minus `tier`, plus `tabs?`,
354
+ `collapsible?`, `persistKey?`, `defaultOpen?`, `summary?`, `id?`, `children`. No `variant`, no
355
+ border/background — one shaded container level per page belongs to the cards inside a `Section`, not
356
+ to `Section` itself. Fold state persists at `basalt:section:<persistKey>` via `createPersistedState`
357
+ when `persistKey` is given, else local `useState`; the header stays drawn when collapsed, only the
358
+ body unmounts. `defaultOpen` (default `true`) picks the state a FIRST visit lands on and is outranked
359
+ by a persisted value, so a section the reader closed stays closed. `summary` renders under the header
360
+ and survives a collapse — a folded section still states its headline figures, which is what makes
361
+ folding it a real option rather than a way to lose the numbers.
362
+
363
+ ### Homes — `PageBar` replaces the page-action portal
364
+
365
+ **Three exports removed with no deprecation window, one prop retyped, three tokens and one CSS
366
+ class deleted.** `PageActions` gave a page a `ReactNode` slot in the header and nothing else: no
367
+ second row, no overflow policy, no mobile projection. Every consumer then hand-rolled the rest —
368
+ a sticky filter row measured into a `--lw-header-h`, a horizontally scrolling action strip, a
369
+ responsive twin under `visibleFrom`/`hiddenFrom`. `PageBar` is that whole shape, typed
370
+ (`docs/CONTROLS-SPEC.md` §2.1).
371
+
372
+ | Removed / changed | Replacement |
373
+ | -------------------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------- |
374
+ | `PageActions` | `<PageBar actions={{ primary, secondary }} sync={…} tabs={…} filters={…} filtersEnd={…} />` |
375
+ | `PageActionsOutlet` | none — the outlet is internal to `BasaltShell` now |
376
+ | `PageHeaderProvider` | none — the provider is internal to `BasaltShell` now |
377
+ | `BasaltShellProps.globalActions: ReactNode` | `GlobalAction[]` — `{ key, node, mobile?: 'bar' \| 'more' \| 'hidden' }`; the first two default to `'bar'` |
378
+ | tokens `appShellHeaderMobileHeight`, `appHeaderMobileActionsHeight`, `stickyHeaderClearanceMobile` | `appShellHeaderHeight` / `stickyHeaderClearance` — one value at every viewport |
379
+ | vars `--vx-space-app-header-mobile-actions-height`, `--vx-space-sticky-header-clearance-mobile` | `--vx-space-sticky-header-clearance` |
380
+ | 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 |
381
+
382
+ What changes at runtime even if you only rename:
383
+
384
+ - **The app-shell header is 48px on a phone too.** It used to wrap to two rows (97px) and reserve
385
+ the second one on every route, empty or not (law C14). A page-level filter/tab row now renders
386
+ in the page FLOW, sticky under the header, so a route with no filters is 49px shorter on mobile.
387
+ - **The header never scrolls sideways.** Actions past three fold into a `More` menu on desktop and
388
+ into one kebab below `sm` — computed from `BarAction[]`, which is why the slot had to become data
389
+ (law C7). There is exactly ONE kebab per header, and `PageBar`'s ROW 1 owns it: the shell's
390
+ `mobile: 'more'` global actions land there, and so do `filtersEnd`'s items (row 2 shows those on
391
+ desktop only — below `sm` row 2 is tabs + the first pill + `Filters (n)`, per spec §2.1). A route
392
+ with no `PageBar` row-1 actions gets the kebab from the shell instead. An `ActionGroup` you mount
393
+ yourself — in a `Section` or `ChartCard` `actions` slot — never inherits the global rows.
394
+ - **`--basalt-page-bar-h` is published on `documentElement`** (ResizeObserver where available,
395
+ `height > 0` guard)
396
+ whenever a row 2 renders, and removed when it unmounts. It is written in the LAYOUT phase, so it
397
+ exists at the first paint — a cold load of `/page#anchor` lands below the bar rather than under
398
+ it. A `100dvh` body becomes
399
+ `calc(100dvh - var(--app-shell-header-height) - var(--basalt-page-bar-h, 0px))` — delete any
400
+ hand-rolled measure-and-publish effect and any hardcoded header-height fallback.
401
+ - **A shell-less app reads `title`/`icon`.** Inside a `BasaltShell` the breadcrumb names the page
402
+ and both are ignored; outside one they lead row 1, and the whole bar sticks at `top: 0`.
403
+ - **`className` lands on the bar ROOT in both forms** — the shell-less
404
+ `[data-basalt-page-bar="standalone"]`, or row 2's sticky wrapper `[data-basalt-page-bar="shell"]`.
405
+ It is the seam for the two things only the consumer's own layout knows: bleeding the sticky bar
406
+ across a container's gutters, and a hairline under it. Scope that CSS through the class; a global
407
+ `[data-basalt-page-bar]` rule reaches every page in the app, including the ones wanting neither.
408
+
409
+ `ActionGroup`, `OverflowMenu` and `SyncButton` ship on the new `basalt-ui/controls` subpath; only
410
+ the TYPES a `PageBar`/`BasaltShell` prop mentions (`BarAction`, `ActionGroupProps`, `GlobalAction`)
411
+ are re-exported from the root.
412
+
413
+ ### Stores — `createSearchStore` replaces the enum-only pair
414
+
415
+ `createSearchParamStore` and `createMultiSearchParamStore` still work: both are now `@deprecated`
416
+ thin wrappers over `createSearchStore`, returning the same four members
417
+ (`validateSearch`, `useStore`, `readStored`, `linkSearch`) with the same signatures and reading the
418
+ same single-value localStorage envelope. **They are removed in 1.29.0.**
419
+
420
+ | Removed / deprecated | Replacement |
421
+ | ---------------------------------------------------------- | ---------------------------------------------------------------------------------------------------------------- |
422
+ | `createSearchParamStore({ key, param, values, fallback })` | `createSearchStore({ key, fields: { <param>: field.enum(values, fallback) } })` — `@deprecated`, removed 1.29.0 |
423
+ | `createMultiSearchParamStore({ key, param, values })` | `createSearchStore({ key, fields: { <param>: field.multi(values, fallback) } })` — `@deprecated`, removed 1.29.0 |
424
+ | `<store>.useStore()` | `<store>.field.<name>.use()` — reads the URL FIRST, then the mirror; writes both lanes |
425
+ | `useOnlineStatus` (`.` and `./state`) | `useConnectivity()` (`.`, auto-mounted by `BasaltProvider`) — **removed, not deprecated** (A12) |
426
+ | `createSearchSchemaStore` | never shipped; `createSearchStore` is what those "planned" paragraphs were waiting for |
427
+
428
+ New on `./router-tanstack`: `createSearchStore`, `field` (`enum` / `multi` / `range` / `number` /
429
+ `boolean` / `string`), and the `FieldHandle` / `AnyField` / `FieldValue` / `RangeValue` / lane types.
430
+ New on `./state` (router-free, same field vocabulary): `createLocalStore`, `field`, those same types.
431
+ `FieldHandle` is what every 1.26.0 control takes instead of `value`/`onChange`
432
+ (`docs/CONTROLS-SPEC.md` §3–§4).
433
+
434
+ Three things to know when you migrate a real store:
435
+
436
+ - **A deep link now wins over the mirror.** `?range=7d` reads back `7d` even when localStorage says
437
+ `30d`. The old `useStore` read localStorage, so a shared link opened on the wrong window — the bug
438
+ three consumers shipped (A8). If you were compensating for that by hand, delete the compensation.
439
+ - **The storage layout changed for the NEW factory only.** `createSearchStore` keeps one entry per
440
+ STORE (`basalt:<key>` = `{ fieldName: value }`); the old factories kept one bare value per param.
441
+ A store migrated from a wrapper to `createSearchStore` therefore ignores whatever the browser
442
+ already had for that key and starts on its fallback once. The wrappers themselves keep the old
443
+ layout byte-for-byte, so an un-migrated consumer sees no reset — including a stored EMPTY multi
444
+ selection, which still reads back as empty: "an empty array means absent" is a URL rule in the
445
+ deprecated store, never a storage rule, so a deliberately cleared filter stays cleared.
446
+ - **A write from outside the owning route persists only** — it no longer needs a hand-rolled
447
+ `navigate` beside it, and it no longer silently writes a param the route does not validate.
448
+ `validateSearch` picks the persisted value up on the next visit (A1). The one combination with
449
+ nowhere to go — a `persist: false` field written from a route that does not validate it — warns
450
+ once per field in dev rather than dropping the write silently.
451
+
452
+ **Every navigate a store issues carries `resetScroll: false`** — a field write and `useReset`
453
+ alike. A filter lives halfway down a page as often as it lives in the bar, and the router treats a
454
+ same-route search write as a navigation, so the default scrolled the reader back to the top on every
455
+ change. Delete any `resetScroll: false` you were passing beside a hand-rolled `navigate`.
456
+
457
+ Lanes are declared once per field: `{ url: false }` is the local-only lane (per-chart selects, a
458
+ compact toggle — or use `createLocalStore`), `{ persist: false }` the URL-only lane (pagination, a
459
+ deliberately unpersisted threshold), `{ history: 'push' }` opts one field into a history entry.
460
+ **`{ url: false, persist: false }` is the IN-MEMORY lane** rather than the one combination that
461
+ dropped its write: shared across every mount of that store for the session, gone on reload, never
462
+ written to or read from localStorage. It behaves identically in both factories — `createSearchStore`
463
+ counts such a field in `useActiveCount()` and clears it in `useReset()`, and it never appears in
464
+ `useValues()`/`validateSearch`, which are the URL lane by definition. In a `createLocalStore` the
465
+ `url` flag is ignored, so `{ persist: false }` alone lands there. The dev warning about a write with
466
+ nowhere to go now fires only for its remaining real case: a `{ persist: false }` field that IS on the
467
+ URL lane, written from a route that does not validate the param. `createLocalStore` also carries **`labels()`**,
468
+ chainable and identical to `createSearchStore`'s, so `SelectFilter`/`ViewTabs` read option labels off
469
+ a local store too.
470
+ A `field.range` keeps THREE URL params (preset + `from` + `to`, renamable via `params`), so existing
471
+ deep links and loaders keep their shape, and `field.<name>.toWindow(v)` replaces a hand-rolled
472
+ `presetToParams`.
473
+
474
+ ### Sidebar — `sidebarBlocks` replaces the two `ReactNode` extras
475
+
476
+ **Two props removed with no deprecation window, and two widened.** `sidebarNavExtra` and
477
+ `mobileNav.moreExtra` were the same gap solved twice and badly: the first rendered on desktop only
478
+ and vanished in the collapsed rail, the second was one anonymous row at the bottom of the More
479
+ surface, and neither could be projected because basalt could not see inside a `ReactNode`. Every
480
+ consumer that wanted an "Awaiting action" list therefore wrote it twice, once per prop, and got no
481
+ rail badge either way. `SidebarBlock[]` is that whole shape as data (law C13,
482
+ `docs/CONTROLS-SPEC.md` §2.3).
483
+
484
+ | Removed / changed | Replacement |
485
+ | ---------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
486
+ | `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 |
487
+ | `AppSidebarProps.navExtra` | `blocks={[{ kind: 'custom', key, node }]}` |
488
+ | `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 |
489
+ | `useNav(NAV, { moreExtra })` | drop the option; declare the block on `BasaltShell` |
490
+ | `BasaltShellProps.brand` | widened to `BrandConfig & { menu?: AccountMenuItem[] }` — additive; `menu` makes the brand row a `Name ▾` workspace switcher |
491
+ | `BasaltShellProps.search` | widened to `SidebarSearchConfig & { actions?: SidebarSearchActions }` — additive; one or two icon-only buttons right of the ⌘K row |
492
+
493
+ What changes at runtime even if you declare no blocks at all:
494
+
495
+ - **Nav-section folds are now PERSISTED**, at `basalt:sidebar-section:<label-slug>` (e.g.
496
+ `basalt:sidebar-section:tools`). They were a `useState` keyed by label, so every reload re-opened
497
+ a section the user had closed. `defaultCollapsed` is now the SEED for that key rather than a value
498
+ that overrides the user on every mount. Block folds live at `basalt:sidebar-block:<key>`; both are
499
+ the standard `createPersistedState` envelope (`{ v: 1, value: boolean }`), readable with
500
+ `readPersistedValue` from `basalt-ui/state`. A "Show more" toggle is deliberately NOT persisted.
501
+ - **`settingsMenuItems` renders FLAT at three entries or fewer** — one link row each, instead of a
502
+ gear "Settings" menu you had to open to see two rows. Four or more keeps the menu. `brand.version`
503
+ rides the flat rows as a faint label and the dropdown as a `Menu.Label`, as before. New
504
+ `BasaltShellProps.settingsMenu?: 'auto' | 'flat' | 'menu'` (`AppSidebarProps` too, default
505
+ `'auto'` = that count rule) forces the form: pass `'menu'` when the three rows are CONTROLS rather
506
+ than destinations — a theme radio group and a devtools switch read as a widget pile flat, and the
507
+ count cannot tell them from three links.
508
+ - **The footer is one wrapper deeper.** `settingsMenuItems` without `account` used to render its
509
+ `Group` as the footer element itself; both now sit inside one footer `Stack` alongside any
510
+ `placement: 'bottom'` block. No visual change — relevant only to a stylesheet reaching in by
511
+ structure.
512
+ - **`useNav` now marks exactly one destination `active`** — the deepest matching route wins, instead
513
+ of every prefix-matching row (a parent AND its child) reading active at once. A parent on the
514
+ winner's path gets the new `SidebarItem.ancestor` instead: never active, never `aria-current`, only
515
+ a `data-ancestor` hook for CSS. A consumer that styled off two simultaneously-active rows will now
516
+ see one.
517
+
518
+ New types on `.`: `SidebarBlock`, `SidebarListBlock`, `SidebarProgressBlock`, `SidebarCustomBlock`,
519
+ `SidebarBlockItem`, `SidebarBlockTone`, `SidebarSearchActions`. `SidebarItem` gained `ancestor?:
520
+ boolean`.
521
+
522
+ ### `DeltaBadge` — plain-element DOM, same props
523
+
524
+ Nothing removed or renamed — a DOM contract change only, ahead of `WidgetHeader`
525
+ (`docs/CONTROLS-SPEC.md` §2.2) composing it Mantine-free.
526
+
527
+ | Component | What changed | Why it matters |
528
+ | ------------ | --------------------------------------------------------------------------------------- | ---------------------------------------------------------------------------------------------------- |
529
+ | `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 |
530
+
531
+ ### Controls — `ArticleFilterBar` replaced by store-bound controls
532
+
533
+ Two new subpaths, `basalt-ui/controls` and `basalt-ui/controls-dates`, and ONE removal. The removed
534
+ component was controlled (`value`/`onChange`, its own `visibleFrom`/`hiddenFrom` twin); its
535
+ replacements take a `FieldHandle` and own the URL write, the localStorage mirror and the responsive
536
+ swap themselves (`docs/CONTROLS-SPEC.md` §3, laws C2/C9).
537
+
538
+ | Removed | Replacement | Import |
539
+ | ----------------------- | --------------------------------------------------------------------- | ------------------------------------------------------------ |
540
+ | `ArticleFilterBar` | `FilterSet` + `ViewTabs` (category axis) + `MultiSelectFilter` (tags) | `basalt-ui/controls`, and unchanged from `basalt-ui/content` |
541
+ | `ArticleFilterBarProps` | `FilterSetProps` / `ViewTabsProps` / `MultiSelectFilterProps` | same |
542
+
543
+ All three replacements are re-exported from `basalt-ui/content` as well, so a content-only consumer
544
+ changes the component names and the props, not the import path.
545
+
546
+ Porting one call site:
547
+
548
+ ```tsx
549
+ // before — the page owned the state, the labels and the mobile swap
550
+ const [category, setCategory] = useState('all')
551
+ <ArticleFilterBar categories={CATEGORIES} category={category} onCategoryChange={setCategory}
552
+ tags={ARTICLE_TAGS} selectedTags={tags} onTagsChange={setTags} />
553
+
554
+ // after — one store, two controls, no useState and no responsive branch
555
+ const articles = createSearchStore({
556
+ key: 'articles',
557
+ fields: { category: field.enum(CATEGORY_VALUES, 'all'), tags: field.multi(ARTICLE_TAGS, []) },
558
+ }).labels({ category: CATEGORY_LABELS })
559
+
560
+ <ViewTabs field={articles.field.category} />
561
+ <FilterSet>
562
+ <MultiSelectFilter field={articles.field.tags} label="All tags" />
563
+ </FilterSet>
564
+ ```
565
+
566
+ New on `basalt-ui/controls`: `FilterSet`, `RangeFilter`, `CompareFilter`, `SelectFilter`,
567
+ `MultiSelectFilter`, `SearchFilter`, `ToggleFilter`, `ViewTabs`, `COMPARE_VALUES`, and the
568
+ action/sync family. New on `basalt-ui/controls-dates`: `DateRangePicker`.
569
+
570
+ **`SyncButton`'s `scope` now decides the SHAPE, not only where you mount it.** `scope: 'global'`
571
+ renders icon-only at every viewport (an `ActionIcon` with the spinning glyph, age and error in the
572
+ tooltip, `label` as the accessible name) — the shell header shares 48px with the breadcrumb and
573
+ `PageBar` row 1, and a labelled button there is what pushes a page's own actions into the kebab.
574
+ `scope: 'page'` is unchanged on desktop (labelled, age inline) and drops to icon-only below `sm`,
575
+ which is what `docs/CONTROLS-SPEC.md` §3 always specified. One mount either way, CSS-only swap (law
576
+ C9). Consequence to know: the accessible name is now `aria-label` in BOTH forms, so a test asserting
577
+ the name as `Sync 2m ago` (the age used to land in it) now reads `Sync`. Nothing to change at a call
578
+ site.
579
+
580
+ **`SelectFilter` and `MultiSelectFilter` take `options?: readonly FilterOption[]`** — a runtime
581
+ catalogue (`{ value, label, disabled? }`) that OVERRIDES `field.options` whole rather than merging,
582
+ so a value the catalogue has dropped loses its row. It is what a label carrying live data needs
583
+ (`EUR · 1.08`), and on `SelectFilter` it also opens a second field shape: a `FieldHandle<StringField>`
584
+ is legal WITH `options` — an id set no enum can close over, a project picker fed from a query — and
585
+ a type error without it. `FilterOption` is new on `basalt-ui/controls`. Additive; nothing to change.
586
+
587
+ **`FilterSet` renders the mobile `Filters (n)` pill only when a child is actually folded.** With
588
+ every child inside the `inline` budget (one filter at the default `inline: 1`) the sheet would hold a
589
+ copy of what is already on screen, so neither the trigger nor the drawer mounts. Nothing to change —
590
+ but a mobile test asserting the pill exists on a one-filter page now has to fold something
591
+ (`inline={0}`) or drop the assertion. `RangeFilter.label` (default `'Range'`) names the sheet heading
592
+ and the preset track's aria name; the pill itself still reads the VALUE.
593
+
594
+ **`data-numeric` is now a package-wide law, not a per-component rule.** `basalt-ui/styles.css`
595
+ declares `[data-numeric] { font-family: var(--basalt-font-mono); font-variant-numeric: tabular-nums }`
596
+ inside `@layer basalt`, so the attribute works on ANY element — `<Text data-numeric>`, a table cell,
597
+ a count tag — and not only on a `SegmentedControl`, where it was previously a module-scoped rule that
598
+ made the attribute look broken everywhere else. Nothing to change: adding the attribute is opt-in,
599
+ and the two module rules that also set a component `font-size` still apply on top. This retires the
600
+ per-consumer `theme-allow` + inline `fontFamily`/`fontSize` hack for numeric labels.
601
+
602
+ **`@mantine/dates` is a new OPTIONAL peer, and only `basalt-ui/controls-dates` needs it.**
603
+ `basalt-ui/controls` resolves and renders without it — proven from the packed tarball by
604
+ `scripts/pack-test.sh`. Pass the picker into `RangeFilter` rather than importing it from a shared
605
+ module:
606
+
607
+ ```tsx
608
+ import { DateRangePicker } from 'basalt-ui/controls-dates'
609
+ ;<RangeFilter field={analytics.field.range} customPicker={DateRangePicker} />
610
+ ```
611
+
612
+ A consumer that uses `DateRangePicker` installs `@mantine/dates` and imports
613
+ `@mantine/dates/styles.layer.css` with the other Mantine layer bundles, before `basalt-ui/styles.css`.
614
+
615
+ ### Guards — the control rules (docs/CONTROLS-SPEC.md §6)
616
+
617
+ **Nothing removed. Eight new oxlint rules and two new guard kinds. `Ships` below is the level as of
618
+ THIS minor** — eight land `error`, and the C1 pair (`control-outside-home` and its text lane) is the
619
+ only grace left, re-dated to 1.28.0 on a measurement (see § Guards — the promotions).\*\* Every one honours `theme-allow <id> — <why>` on the
620
+ node and `theme-allow-file <id> — <why>` on the file, the same grammar as the rest.
621
+
622
+ | Rule / kind | Fires on | Ships |
623
+ | ------------------------------------ | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------- |
624
+ | `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 |
625
+ | `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 |
626
+ | `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, in a file that DEFINES a basalt control, or in a file basenamed `*-{modal,drawer,popover,panel,form}.tsx` | warn → 1.28.0 |
627
+ | `basalt/control-size-literal` | `size`/`w`/`fullWidth`/`visibleFrom`/`hiddenFrom` on anything inside a home slot (the slot sets the tier) | **error** |
628
+ | `basalt/in-body-page-title` | `<Title order={1\|2}>` outside prose/overlay context and outside a `content/` path | **error** |
629
+ | `basalt/responsive-twin` | The same control mounted twice, one `visibleFrom="X"` and one `hiddenFrom="X"` | **error** |
630
+ | `basalt/search-literal-link` | A `search:` object literal in a `linkOptions()` inside `defineNav()`/`navGroup()` | **error** |
631
+ | `basalt/use-search-from-literal` | `useSearch({ from: '<route>' })` | **error** |
632
+ | `in-body-page-title` (guard kind) | The text lane of the same law — SAME id, so one annotation waives both lanes | **error** |
633
+ | `raw-selection-control` (guard kind) | The text lane of `control-outside-home`, approximated by a 12-line host-tag window, plus the same overlay-basename exemption | warn → 1.28.0 |
634
+
635
+ **A `SettingsRow`'s `control` is not a tiered slot, so nothing in this table fires inside one.**
636
+ It is law C1's third home — the form row — and a form keeps Mantine's `md` tier
637
+ (`controlHeight` 42, unchanged). A raw `Select`/`Switch`/`Button` bound to a setting is correct
638
+ there, and its `size` prop is what holds the row at the form tier, so neither `hand-rolled-filter`
639
+ nor `control-size-literal` reaches into it; `control-outside-home` treats a settings row as a home
640
+ and stays silent as well. Consumer settings pages need no change.
641
+
642
+ **Three scoping facts, because each one is a rule NOT firing on code you already have.** The home
643
+ tag must resolve to a component imported from `basalt-ui` (or a basalt subpath) — your own
644
+ `Section`, `PageBar` or `StatCard` is not a tiered home, and nothing in this table reads it as one;
645
+ the one accepted gap is basalt's `Section` re-exported through your own barrel, which the rules
646
+ cannot see. An OVERLAY colocated in a slot (`Modal`, `Drawer`, `Popover.Dropdown`, `Menu.Dropdown`,
647
+ `SettingsRow`) is exempt — a `New` button beside the `<Modal>` it opens is how both are written, and
648
+ the overlay's own controls are not in the bar. And `control-size-literal` only reaches what the
649
+ slot's theme actually re-tiers: the raw filters, the basalt controls, and Mantine's
650
+ `Button`/`ActionIcon`/`Input`/`TextInput`/`Select`/`MultiSelect`/`SegmentedControl`/`NativeSelect` —
651
+ an icon's `size`, a count `Badge`, a `Loader`, an `Avatar` or a `Modal size="lg"` never report.
652
+
653
+ **Two existing rules widened, neither with a new id.** `basalt/raw-scroll-container` now also fires
654
+ on `overflowX: 'auto' \| 'scroll'` and on `<ScrollArea scrollbars="x">` **inside a home SLOT**
655
+ (`actions`/`filters`/`tabs`/`sync`/`filtersEnd`) — law C7, overflow folds into a `More` menu or a
656
+ `Filters (n)` sheet rather than scrolling sideways. A home's BODY is out of scope: a wide table, a
657
+ pinned-column grid or a horizontally scrolling code block in a `Section`/`ChartCard` is page
658
+ content, not a sideways-scrolling row of controls, and outside a slot `overflowX` is still not
659
+ policed at all.
660
+ oxlint severities are per rule ID, not per branch, so this widening inherits the rule's existing
661
+ `error` level with no grace runway of its own — the honest limitation, and the reason it is scoped to
662
+ homes rather than shipped repo-wide. `basalt/shadow-basalt-export` gains a rename table
663
+ (`PageHeader`/`FilterBar` → `PageBar`, `WindowSelector`/`RangeSelector`/`DateFilter` →
664
+ `RangeFilter`, `PageSection`/`SectionTitle`/`SectionHeading` → `Section`, `ViewSwitch`/`ViewToggle`
665
+ → `ViewTabs`, `RefreshButton`/`SyncControl`/`SyncStatusButton` → `SyncButton`, `HeroCard`/`HeroStats`
666
+ → `StatCard`), so a fork that RENAMED the export is visible too. It stays a permanent advisory
667
+ `warn`.
668
+
669
+ **`profile: 'tokens-only'` now disables 19 kinds, not 17** — both new kinds are Mantine-coupled.
670
+
671
+ **`SURFACES` gains `pluginRules`** (`src/surfaces.ts`, internal — not a published subpath): every
672
+ doctrine surface names the oxlint rules that enforce it, every registered rule maps to exactly one
673
+ surface, every guard KIND maps to at least one, and `basalt-ui check-coverage` asserts all three —
674
+ so a generated coverage header can neither over- nor under-report its lane. `check-coverage` also
675
+ takes `--write` / `--check` for the generated `<!-- basalt:coverage -->` header of each
676
+ `agent/rules/*.md` file; a rule file with no block yet is reported, not failed.
677
+
678
+ ### Controls — the pill reads the VALUE, and four defaults moved
679
+
680
+ **A filter pill's TEXT is now always the selected option's label, at the field's default value too.**
681
+ It used to fall back to the filter's NAME while `field.isDefault(value)` held, which is how a bar
682
+ could read `Compare` over a field holding `'previous'` while the popover showed `Previous period`
683
+ selected. `label` is the popover/sheet heading and the accessible name; it is never printed on the
684
+ pill. Affects `SelectFilter`, `CompareFilter` and every control built on the shared enum body;
685
+ `RangeFilter` already read its value, and `MultiSelectFilter` keeps its own law (the group label
686
+ while the selection is empty or complete, `N <noun>` otherwise).
687
+
688
+ **Nothing to change at a call site.** What changes is the string a test or a screenshot sees: a
689
+ `SelectFilter field={currency} label="Currency"` at fallback `USD` now reads `USD`, not `Currency`.
690
+ If a bar reads badly at rest, the fix is the OPTION label (`store.labels()`), not the filter's name.
691
+
692
+ | Component / prop | Was | Now |
693
+ | ------------------------------------------------- | ------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
694
+ | `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. |
695
+ | `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). |
696
+ | `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. |
697
+ | `ViewTabs` phone `Select` (past 3 options) | greedy (216px measured, truncating a title) | capped at 9rem |
698
+
699
+ **New: `ControlGroup`** (`basalt-ui/controls`) — joins adjacent controls that act on ONE thing
700
+ (`‹ Today ›`, `− 1 +`) into a single box: no gap, one shared hairline per pair, radius on the outer
701
+ ends only. `gap?: 'none' | 'tight'`. `BarActionItem` gains `group?: true`, and a run of adjacent
702
+ `group: true` actions in an `ActionGroup` renders as one `ControlGroup`; a joined member that ships an
703
+ `icon` renders ICON-ONLY with its label demoted to the accessible name. `ActionGroup` also joins
704
+ adjacent ICON-ONLY entries on the mobile bar with no flag. `basalt/shadow-basalt-export` now names
705
+ `ButtonGroup` / `ButtonRow` / `JoinedButtons` as renamed forks of it.
706
+
707
+ ### The `ctl` tier now covers Radio, Checkbox and Switch
708
+
709
+ All three defaulted to Mantine's `sm` — a 20px indicator beside the tier's 13.5px option label — in
710
+ every filter popover and in the mobile sheet. They are now in `CTL_THEME` (with their `.Group`s) and
711
+ `cssVariablesResolver` declares their `-ctl` vars: 16px indicator, 18×30 switch.
712
+
713
+ **One consequence worth knowing.** Mantine resolves a Radio's size as `props.size ? props.size :
714
+ ctx.size`, and a theme `defaultProps.size` is indistinguishable from an explicit prop to that check
715
+ — so inside a `<CtlSlot>` (any home's slot) a `<Radio.Group size="lg">` no longer sizes its children;
716
+ they stay `ctl`. A group that genuinely needs another size belongs in a BODY, which no slot wraps.
717
+
718
+ `basalt/control-size-literal` widened to cover the six new tags, so a `size` on a Radio/Checkbox/
719
+ Switch inside a home slot now reports. It is still `warn` on its 1.26.0 → 1.27.0 runway (it has never
720
+ shipped at any level, so widening it restarts nothing).
721
+
722
+ ### Icons — one box, no call-site geometry
723
+
724
+ Every `icon` prop in the framework (`BarAction`, the filter pills and their trailing affordance,
725
+ `WidgetHeader`/`StatCard`, `SyncButton`, sidebar-block rows, the overflow fold's own glyphs) now
726
+ renders through ONE internal slot: a fixed `--vx-space-icon-size` (16px default) square that restates
727
+ the glyph's `width`/`height` at 100% and takes it off the text baseline. **No API change** — but a
728
+ consumer's `<svg width="24">` that used to set a control's row height now renders at 16px, and an
729
+ `<svg>` with no `width`/`viewBox` at all no longer paints at 300×150. Per-tier sizes come from the
730
+ box var (`WidgetHeader` sets 14px at `tier="widget"`), never from a prop.
731
+
732
+ ### Mobile nav — at most ONE slot is active
733
+
734
+ A slot's activeness now reads exactly the destinations it COVERS. It used to roll up over `children`
735
+ unconditionally while an item slot covered only its own key, so at `/dashboard/sessions` the
736
+ `Dashboard` tab lit through the rollup and the `More` tab lit for `Sessions` sitting in its overflow
737
+ — two `aria-current="page"` tabs at once. A SECTION tab still lights for a nested destination (it
738
+ covers its whole tree); an ITEM tab with children no longer does, and the overflow slot that can
739
+ actually reach the open route lights instead.
740
+
741
+ `useNav`'s anchors now pass `activeOptions: { exact: true }` to TanStack's `Link`. A `Link` spreads
742
+ `{ 'data-status': 'active', 'aria-current': 'page' }` last — after `activeProps` and after every
743
+ caller prop — so its own fuzzy match could not be overridden from outside and re-created the very
744
+ double-highlight `useNav`'s resolver exists to prevent. basalt's own law is unchanged: an item whose
745
+ exact route is never visited still lights on a descendant route.
746
+
747
+ ### Layout — two collisions and a gap
748
+
749
+ - **The app header**: exactly one side of row 1 is elastic, and it is the breadcrumb (the side whose
750
+ content can ellipsize). The control side is `flex: 0 0 auto` with no `min-width: 0` below it. Both
751
+ sides were shrinkable, and at 390px the sync button overflowed 30px INTO the page title, because a
752
+ `justify-content: flex-end` flex container overflows towards its start.
753
+ - **`WidgetHeader`'s hero row**: the value → `DeltaBadge` gap is the 8px rhythm step
754
+ (`--vx-space-stack-sm`), not 4px, and the badge is centred on the value rather than baseline-aligned
755
+ to it.
756
+ - **`WidgetHeader`'s title row** wraps below `sm`, so an `actions` slot too wide to sit beside the
757
+ title takes its own line instead of squeezing the heading to an ellipsis. A 30px kebab still fits
758
+ and still sits on the row. **Its metric row wraps too**: in a 2-up card grid at 390px
759
+ `$1294.9k ▲18.9%` rendered as `$129… ▲18.9%`, and truncating the number to protect the delta about
760
+ that number is the wrong way round. A short value still keeps its badge on the line.
761
+ - **`StatCard`'s `actions` slot is the 24px tier now, not 30px.** `CtlSlot` gained `tier?: 'ctl' |
762
+ 'widget'` (default `'ctl'`, so no existing call site changes), and `StatCard` mounts its header slot
763
+ at `'widget'` — the `size="icon"` step `--vx-space-control-height-widget` has always named as
764
+ "`WidgetHeader tier="widget"` actions". A raw `ActionIcon` there rendered at the 30px `ctl` tier
765
+ inside a 28px header row and grew it to 30, so a KPI card WITH a kebab sat 2px below the card
766
+ beside it and every number in it with it. A consumer passing `<ActionIcon size="ctl">` explicitly
767
+ still gets 30px — and its card is 2px taller than its neighbours again, which is now visible rather
768
+ than accidental. The slot's `data-basalt-tier` marker reads `widget` there, the same value
769
+ `ChartCard` already writes by hand.
770
+ - **The breadcrumb's ANCESTOR crumbs are hidden below `sm`** — only the page crumb renders. At 390px
771
+ the elastic side truncated `Overview / Dashboard` to `O… / D…`: two ellipses and a separator in
772
+ place of the one word that says where the reader is. CSS-only (law C9), so no flash and no hook.
773
+ `AppBreadcrumbs` gained one wrapper element around those crumbs; its props are unchanged.
774
+
775
+ ### Cursor — the shared cursor now partitions by x-domain kind, automatically
776
+
777
+ Nothing removed. `CursorState` gained one field.
778
+
779
+ | Component | What changed | Why it matters |
780
+ | ------------- | -------------------------------- | --------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
781
+ | `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. |
782
+
783
+ ## 1.24.0 — `QueryState`, table body chrome, four false greens
42
784
 
43
785
  **Nothing removed or renamed. Twelve new runtime exports on `.`/`./query`, plus table props.** Every
44
786
  API entry is additive; the behaviour changes are in the guard and the CLI, listed after them.
@@ -170,6 +912,32 @@ scaffold a second consumer: the refusal is keyed on the RESOLVED project and run
170
912
  and no other consumer holds a single `.astro`, `.vue` or `.jsx` file, so grace would have covered
171
913
  zero incumbent violations.
172
914
 
915
+ ## 1.25.0 — `manualPagination` imposes a contract
916
+
917
+ **Nothing removed or renamed — a behaviour change, additive props only.** `manualPagination` made
918
+ `data` one server page but left every other client-side control armed: sorting reordered that page
919
+ under a header chevron while "Showing 1–25 of 412" presented it as a sort of all 412 — a plausible,
920
+ wrong answer with nothing on screen to give it away. argo found it on 1.24.0 and worked around it
921
+ with an explicit `enableSorting={false}`.
922
+
923
+ Adopting `manualPagination` now imposes a contract, checked from props at render:
924
+
925
+ | With `manualPagination` | Resolve it with |
926
+ | ------------------------- | ------------------------------------------------------------------------------------ |
927
+ | the pagination bar itself | `enablePagination` (without it `manualPagination` is inert) + `rowCount`/`pageCount` |
928
+ | sorting | the new `manualSorting` + sort in `onSortingChange`, or `enableSorting={false}` |
929
+ | `enableGlobalFilter` | the new `manualFiltering` + filter in `onGlobalFilterChange` |
930
+ | `facets` | `manualFiltering` + the new `onColumnFiltersChange` |
931
+
932
+ Unresolved, it **throws in dev** naming every breach at once; a production bundle degrades instead —
933
+ no sort headers, no filter controls, no "of N" it cannot stand behind, plus one `console.error`. A
934
+ bare `<BasaltDataTable data columns />` (no `manualPagination`) is byte-identical — no opt-out
935
+ needed for a client-side table.
936
+
937
+ Sibling defect fixed with it: the empty-state branch keyed off `data.length`, so a search or page
938
+ index matching nothing left `data` non-empty and rendered a `<tbody>` with no rows AND no message.
939
+ It now keys off the rendered row model.
940
+
173
941
  ## 1.23.1 — the band-state throw, the tag gate, a CLI that answers
174
942
 
175
943
  **One type widened, nothing removed or renamed.** `BandStripSeries.formatValue` is now
@@ -231,7 +999,7 @@ instead of dumping help and letting the dump read like a choice.
231
999
  - **`tokens:css --check` blanks the provenance line (line 2) before comparing**, so a version bump
232
1000
  alone no longer forces a no-op commit in a tokens-only consumer, where the gate is byte-equality.
233
1001
  A stale provenance line is now a note on an otherwise-passing check. The `@generated` header is
234
- still emitted byte-identical — the line stays, it just stops gating. **Superseded in `Unreleased`:
1002
+ still emitted byte-identical — the line stays, it just stops gating. **Superseded at 1.26.0:
235
1003
  blanking the WHOLE line also stopped gating the regeneration command it carries. Only the version
236
1004
  token is neutralized now.**
237
1005
 
@@ -402,7 +1170,7 @@ icons — it is not yet **sufficient**. If you went hybrid over the 404s, droppi
402
1170
  half here costs you every icon unless your `public/` matches basalt's six filenames: at this
403
1171
  release `icons` was still `false | { dir?: string }` over those six, so an app whose icon is
404
1172
  `favicon.svg` could pick between a manifest naming two PNGs it never builds and a manifest with no
405
- `icons` member at all. The array form that fixes it is in `## Unreleased`. The option's JSDoc said
1173
+ `icons` member at all. The array form that fixes it is in `## 1.26.0`. The option's JSDoc said
406
1174
  "skips the head `<link>` icons"; it now says what the option does.
407
1175
 
408
1176
  **Two `theme-allow` comment shapes were silently broken and now work.** Plainly: this is the fourth
@@ -410,7 +1178,7 @@ hole found in this one contract in three rounds. A thirteen-shape matrix — eve
410
1178
  had been SEEN to write — is pinned in both halves, `src/guard/check-source.test.ts` and
411
1179
  `configs/oxlint-plugin.test.ts`. **The claim that followed it here, that the two parsers could
412
1180
  therefore no longer disagree, was false when it was written**: a list of collected anecdotes cannot
413
- close a contract. Three more holes were open at this release. See `## Unreleased`.
1181
+ close a contract. Three more holes were open at this release. See `## 1.26.0`.
414
1182
 
415
1183
  | Shape | What it did | Now |
416
1184
  | --------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------- | --------------------------------------- |
@@ -583,7 +1351,7 @@ with the whole lint half off).
583
1351
 
584
1352
  | Surface | Note |
585
1353
  | ---------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
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 |
1354
+ | `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
1355
  | `basalt.include: [...]` | scan a named file outside `roots` — and the only route to a `.json`, which is never blanket-scanned |
588
1356
  | `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
1357
  | `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 +1465,47 @@ live in `configs/oxlint.json`, and since 1.20.0 their grace is tracked by `PLUGI
697
1465
  named export beside the plugin — a test asserts it against the shipped preset in both directions, so
698
1466
  deleting an entry forces the level flip in the same commit.
699
1467
 
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` | — |
1468
+ | Rule | Landed | Became `error` |
1469
+ | -------------------------------------- | ---------------- | ------------------------------------------------------------------------------------------- |
1470
+ | `mantine-shade-index` (guard kind) | 1.7.0 as `warn` | **1.11.0** |
1471
+ | `basalt/raw-scroll-container` | ≤1.2.0 as `off` | `warn` 1.10.0 → **`error` 1.13.0** |
1472
+ | `basalt/ai-sdk-major` | 1.10.0 as `warn` | **1.13.0** |
1473
+ | `basalt/agent-no-raw-usechat` | 1.10.0 as `warn` | **1.13.0** |
1474
+ | `basalt/agent-resume-guard` | 1.10.0 as `warn` | **1.13.0** |
1475
+ | `basalt/raw-size-literal` | 1.7.0 as `warn` | **1.20.0** |
1476
+ | `basalt/hand-rolled-plot` | 1.21.0 as `warn` | **1.26.0** (grace had restarted at the 1.21.0 widening) |
1477
+ | `basalt/chart-legend-literal` | 1.20.0 as `warn` | **1.26.0** |
1478
+ | `basalt/shadow-basalt-export` | 1.20.0 as `warn` | ADVISORY — permanent `warn`, see below, never subject to the C16 gate |
1479
+ | `basalt/hand-rolled-shell` | 1.20.0 as `warn` | **1.26.0** |
1480
+ | `theme-allow-unscoped` (guard kind) | 1.20.0 as `warn` | **1.26.0** |
1481
+ | `surface-shadow-override` (guard kind) | 1.20.0 as `warn` | **1.26.0** |
1482
+ | `css-raw-surface` (guard kind) | 1.20.0 as `warn` | **1.26.0** |
1483
+ | `inline-font-size` (guard kind) | 1.20.0 as `warn` | **1.26.0** |
1484
+ | `hidden-inline-style` (guard kind) | 1.20.0 as `warn` | **1.26.0** |
1485
+ | `basalt/control-size-literal` | 1.26.0 as `warn` | **1.27.0** |
1486
+ | `basalt/in-body-page-title` | 1.26.0 as `warn` | **1.27.0** |
1487
+ | `in-body-page-title` (guard kind) | 1.26.0 as `warn` | **1.27.0** — same id as the rule above, so one waiver covers both |
1488
+ | `basalt/responsive-twin` | 1.26.0 as `warn` | **1.27.0** |
1489
+ | `basalt/search-literal-link` | 1.26.0 as `warn` | **1.27.0** |
1490
+ | `basalt/use-search-from-literal` | 1.26.0 as `warn` | **1.27.0** |
1491
+ | `basalt/control-outside-home` | 1.26.0 as `warn` | still `warn`, `promote: '1.28.0'` — re-dated on 9 measured cross-file overlay warns in argo |
1492
+ | `raw-selection-control` (guard kind) | 1.26.0 as `warn` | still `warn`, `promote: '1.28.0'` — the text lane of the row above |
712
1493
 
713
1494
  `card-with-border`, `inline-display`, `raw-html-layout`, `raw-form-control`, `raw-font-family` and
714
1495
  the other original guard kinds have been `error` since before 1.2.0 — they never had a grace minor.
715
1496
  Guard findings only gained a severity field at all in 1.4.0; before that every finding was fatal.
716
1497
 
1498
+ **Both ledgers changed shape at 1.26.0 (C16, `docs/CONTROLS-SPEC.md` §1).** An entry
1499
+ used to be a bare promotion-note string with no expiry a machine could check — which is exactly how
1500
+ the nine rows above sat at `warn` for five minors although the doctrine below says "one minor". Both
1501
+ `PLUGIN_RULE_GRACE` (`configs/oxlint-plugin.js`) and `GRACE_PERIOD_KINDS` (`src/guard/index.ts`) now
1502
+ hold `{ since, promote, why }` (semver strings), and a version-gated test fails the build once
1503
+ `package.json`'s version reaches an entry's `promote` while the entry is still there — see
1504
+ "1.26.0" above for the full change. `shadow-basalt-export` moved to a sibling ledger,
1505
+ `PLUGIN_RULE_ADVISORY`, which the gate never checks: it was already documented as a possible
1506
+ permanent `warn`, and the version-gate doctrine only fits an entry that has an honest promotion
1507
+ date.
1508
+
717
1509
  **Rule-id rename at 1.1.0:** `basalt/import-boundary` split into `basalt/visx-boundary`,
718
1510
  `basalt/visx-tooltip` and `basalt/token-layer-boundary` (the last is repo-local and deliberately not
719
1511
  in the shipped preset). A config still naming `import-boundary` after 1.1.0 disables nothing.