basalt-ui 1.24.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (467) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +25 -0
  3. package/MIGRATING.md +547 -21
  4. package/README.md +46 -33
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/query-state.d.ts +1 -1
  134. package/dist/dashboard/settings-section.d.ts +6 -4
  135. package/dist/dashboard/settings-section.d.ts.map +1 -1
  136. package/dist/dashboard/settings-section.js +23 -16
  137. package/dist/dashboard/settings-section.module.css +10 -0
  138. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  139. package/dist/dashboard/settings-section.test.d.ts +2 -0
  140. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  141. package/dist/dashboard/stat-card.d.ts +44 -9
  142. package/dist/dashboard/stat-card.d.ts.map +1 -1
  143. package/dist/dashboard/stat-card.js +80 -48
  144. package/dist/dashboard/stat-card.module.css +82 -0
  145. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  146. package/dist/data/data-table.d.ts +102 -20
  147. package/dist/data/data-table.d.ts.map +1 -1
  148. package/dist/data/data-table.js +166 -65
  149. package/dist/data/table.d.ts +1 -1
  150. package/dist/data/table.d.ts.map +1 -1
  151. package/dist/guard/grace.test.d.ts +20 -0
  152. package/dist/guard/grace.test.d.ts.map +1 -0
  153. package/dist/guard/index.d.ts +66 -3
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +82 -7
  156. package/dist/guard/types.d.ts +3 -3
  157. package/dist/guard/types.d.ts.map +1 -1
  158. package/dist/index.d.ts +12 -2
  159. package/dist/index.d.ts.map +1 -1
  160. package/dist/index.js +13 -10
  161. package/dist/nav/types.d.ts +78 -2
  162. package/dist/nav/types.d.ts.map +1 -1
  163. package/dist/notifications/bell.d.ts +4 -1
  164. package/dist/notifications/bell.d.ts.map +1 -1
  165. package/dist/notifications/index.d.ts +1 -1
  166. package/dist/router-tanstack/field.d.ts +14 -0
  167. package/dist/router-tanstack/field.d.ts.map +1 -0
  168. package/dist/router-tanstack/field.js +4 -0
  169. package/dist/router-tanstack/index.d.ts +6 -2
  170. package/dist/router-tanstack/index.d.ts.map +1 -1
  171. package/dist/router-tanstack/index.js +4 -0
  172. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  173. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  175. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  176. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  177. package/dist/router-tanstack/search-param-store.js +16 -45
  178. package/dist/router-tanstack/search-store.d.ts +81 -0
  179. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.js +187 -0
  181. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  182. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  183. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  184. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/use-nav.d.ts +0 -2
  186. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  187. package/dist/router-tanstack/use-nav.js +34 -12
  188. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  189. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  190. package/dist/section/index.d.ts +2 -0
  191. package/dist/section/index.d.ts.map +1 -0
  192. package/dist/section/index.js +4 -0
  193. package/dist/section/section.d.ts +24 -0
  194. package/dist/section/section.d.ts.map +1 -0
  195. package/dist/section/section.js +126 -0
  196. package/dist/section/section.module.css +104 -0
  197. package/dist/section/section.module.css.d.ts +11 -0
  198. package/dist/section/section.test.d.ts +2 -0
  199. package/dist/section/section.test.d.ts.map +1 -0
  200. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  201. package/dist/shell/app-breadcrumbs.js +18 -15
  202. package/dist/shell/app-header.module.css +77 -37
  203. package/dist/shell/app-header.module.css.d.ts +2 -1
  204. package/dist/shell/app-mobile-nav.d.ts +9 -2
  205. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  206. package/dist/shell/app-mobile-nav.js +98 -11
  207. package/dist/shell/app-mobile-nav.module.css +8 -0
  208. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  209. package/dist/shell/app-sidebar.d.ts +55 -28
  210. package/dist/shell/app-sidebar.d.ts.map +1 -1
  211. package/dist/shell/app-sidebar.js +116 -67
  212. package/dist/shell/app-sidebar.module.css +303 -20
  213. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  214. package/dist/shell/index.d.ts +55 -21
  215. package/dist/shell/index.d.ts.map +1 -1
  216. package/dist/shell/index.js +40 -18
  217. package/dist/shell/mobile-nav-model.d.ts +13 -2
  218. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  219. package/dist/shell/mobile-nav-model.js +15 -1
  220. package/dist/shell/nav-count-badge.d.ts +1 -1
  221. package/dist/shell/page-bar.d.ts +51 -0
  222. package/dist/shell/page-bar.d.ts.map +1 -0
  223. package/dist/shell/page-bar.js +154 -0
  224. package/dist/shell/page-bar.module.css +178 -0
  225. package/dist/shell/page-bar.module.css.d.ts +17 -0
  226. package/dist/shell/page-bar.test.d.ts +2 -0
  227. package/dist/shell/page-bar.test.d.ts.map +1 -0
  228. package/dist/shell/sidebar-block-model.d.ts +48 -0
  229. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  230. package/dist/shell/sidebar-block-model.js +39 -0
  231. package/dist/shell/sidebar-blocks.d.ts +32 -0
  232. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  233. package/dist/shell/sidebar-blocks.js +169 -0
  234. package/dist/shell/sidebar-search.d.ts +13 -1
  235. package/dist/shell/sidebar-search.d.ts.map +1 -1
  236. package/dist/shell/sidebar-search.js +34 -1
  237. package/dist/shell/sidebar-search.module.css +26 -0
  238. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  239. package/dist/state/fields.d.ts +392 -0
  240. package/dist/state/fields.d.ts.map +1 -0
  241. package/dist/state/fields.js +369 -0
  242. package/dist/state/fields.test.d.ts +2 -0
  243. package/dist/state/fields.test.d.ts.map +1 -0
  244. package/dist/state/persisted.d.ts +48 -0
  245. package/dist/state/persisted.d.ts.map +1 -0
  246. package/dist/state/persisted.js +105 -0
  247. package/dist/state.d.ts +11 -56
  248. package/dist/state.d.ts.map +1 -1
  249. package/dist/state.js +14 -120
  250. package/dist/styles.css +13 -0
  251. package/dist/surfaces.d.ts +106 -24
  252. package/dist/surfaces.d.ts.map +1 -1
  253. package/dist/surfaces.js +155 -19
  254. package/dist/theme/controls.module.css +16 -1
  255. package/dist/theme/ctl-theme.d.ts +32 -0
  256. package/dist/theme/ctl-theme.d.ts.map +1 -0
  257. package/dist/theme/ctl-theme.js +54 -0
  258. package/dist/theme/ctl-theme.test.d.ts +2 -0
  259. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  260. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  261. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  262. package/dist/theme/divider-law.test.d.ts +2 -0
  263. package/dist/theme/divider-law.test.d.ts.map +1 -0
  264. package/dist/theme/icon-slot.d.ts +44 -0
  265. package/dist/theme/icon-slot.d.ts.map +1 -0
  266. package/dist/theme/icon-slot.js +21 -0
  267. package/dist/theme/icon-slot.module.css +47 -0
  268. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  269. package/dist/theme/icon-slot.test.d.ts +2 -0
  270. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  271. package/dist/theme/index.d.ts +1 -0
  272. package/dist/theme/index.d.ts.map +1 -1
  273. package/dist/theme/index.js +82 -5
  274. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  275. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  276. package/dist/theme/segmented-control.module.css +20 -0
  277. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  278. package/dist/tokens/index.d.ts +12 -0
  279. package/dist/tokens/index.d.ts.map +1 -1
  280. package/dist/tokens/index.js +38 -8
  281. package/dist/tokens/palette.d.ts +97 -61
  282. package/dist/tokens/palette.d.ts.map +1 -1
  283. package/dist/tokens/palette.js +76 -38
  284. package/dist/tokens.css +10 -2
  285. package/dist/widget-header/delta-badge.d.ts +13 -0
  286. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  287. package/dist/widget-header/delta-badge.js +29 -0
  288. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  289. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  290. package/dist/widget-header/index.d.ts +3 -0
  291. package/dist/widget-header/index.d.ts.map +1 -0
  292. package/dist/widget-header/index.js +6 -0
  293. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  294. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  295. package/dist/widget-header/widget-header.d.ts +37 -0
  296. package/dist/widget-header/widget-header.d.ts.map +1 -0
  297. package/dist/widget-header/widget-header.js +77 -0
  298. package/dist/widget-header/widget-header.module.css +335 -0
  299. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  300. package/dist/widget-header/widget-header.test.d.ts +2 -0
  301. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  302. package/llms.txt +30 -17
  303. package/package.json +14 -1
  304. package/src/charts/cursor/resolve.test.ts +29 -1
  305. package/src/charts/cursor/resolve.ts +38 -6
  306. package/src/charts/cursor/scope.tsx +6 -3
  307. package/src/charts/cursor/store.test.ts +19 -1
  308. package/src/charts/cursor/store.ts +10 -5
  309. package/src/charts/hooks/useChartCursor.ts +16 -8
  310. package/src/charts/index.ts +2 -2
  311. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  312. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  313. package/src/charts/primitives/ChartCard.tsx +86 -146
  314. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  315. package/src/cli/check-coverage.test.ts +297 -0
  316. package/src/cli/check-theme.test.ts +38 -14
  317. package/src/cli/index.ts +392 -16
  318. package/src/cli/init-sync.test.ts +97 -0
  319. package/src/connectivity/connectivity-indicator.tsx +16 -9
  320. package/src/content/index.ts +16 -3
  321. package/src/content/prose.module.css +7 -28
  322. package/src/content/prose.module.css.test.ts +8 -24
  323. package/src/controls/actions.module.css +62 -0
  324. package/src/controls/actions.module.css.d.ts +13 -0
  325. package/src/controls/actions.test.tsx +252 -0
  326. package/src/controls/actions.tsx +655 -0
  327. package/src/controls/actions.type-guard.test.ts +63 -0
  328. package/src/controls/compare-filter.tsx +64 -0
  329. package/src/controls/control-group.test.tsx +229 -0
  330. package/src/controls/control-group.tsx +63 -0
  331. package/src/controls/controls.module.css +542 -0
  332. package/src/controls/controls.module.css.d.ts +35 -0
  333. package/src/controls/controls.router.test.tsx +637 -0
  334. package/src/controls/enum-filter.tsx +159 -0
  335. package/src/controls/filter-context.tsx +153 -0
  336. package/src/controls/filter-pill.tsx +120 -0
  337. package/src/controls/filter-set.test.tsx +472 -0
  338. package/src/controls/filter-set.tsx +225 -0
  339. package/src/controls/filter-sheet.tsx +285 -0
  340. package/src/controls/glyphs.tsx +81 -0
  341. package/src/controls/index.ts +48 -0
  342. package/src/controls/multi-select-filter.tsx +140 -0
  343. package/src/controls/peer-boundary.test.ts +85 -0
  344. package/src/controls/props-contract.test.tsx +106 -0
  345. package/src/controls/range-filter.tsx +184 -0
  346. package/src/controls/search-filter.tsx +123 -0
  347. package/src/controls/select-filter.tsx +89 -0
  348. package/src/controls/sync-button.module.css +63 -0
  349. package/src/controls/sync-button.module.css.d.ts +11 -0
  350. package/src/controls/sync-button.test.tsx +183 -0
  351. package/src/controls/sync-button.tsx +197 -0
  352. package/src/controls/toggle-filter.tsx +60 -0
  353. package/src/controls/view-tabs.tsx +110 -0
  354. package/src/controls-dates/date-range-picker.tsx +42 -0
  355. package/src/controls-dates/index.ts +8 -0
  356. package/src/dashboard/delta-badge.tsx +4 -74
  357. package/src/dashboard/query-state.tsx +1 -1
  358. package/src/dashboard/settings-section.module.css +10 -0
  359. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  360. package/src/dashboard/settings-section.test.tsx +56 -0
  361. package/src/dashboard/settings-section.tsx +33 -30
  362. package/src/dashboard/stat-card.module.css +82 -0
  363. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  364. package/src/dashboard/stat-card.test.tsx +67 -1
  365. package/src/dashboard/stat-card.tsx +209 -68
  366. package/src/data/data-table.test.tsx +282 -6
  367. package/src/data/data-table.tsx +429 -94
  368. package/src/data/table.ts +2 -0
  369. package/src/guard/check-source.test.ts +126 -13
  370. package/src/guard/grace.test.ts +149 -0
  371. package/src/guard/index.ts +172 -29
  372. package/src/guard/types.ts +4 -2
  373. package/src/index.ts +46 -5
  374. package/src/nav/types.ts +84 -2
  375. package/src/notifications/bell.tsx +6 -3
  376. package/src/notifications/index.ts +1 -1
  377. package/src/router-tanstack/field.ts +31 -0
  378. package/src/router-tanstack/index.ts +24 -2
  379. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  380. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  381. package/src/router-tanstack/search-param-store.ts +42 -159
  382. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  383. package/src/router-tanstack/search-store.test.ts +418 -0
  384. package/src/router-tanstack/search-store.ts +380 -0
  385. package/src/router-tanstack/use-nav.test.tsx +187 -0
  386. package/src/router-tanstack/use-nav.ts +72 -14
  387. package/src/section/index.ts +1 -0
  388. package/src/section/section.module.css +104 -0
  389. package/src/section/section.module.css.d.ts +11 -0
  390. package/src/section/section.test.tsx +204 -0
  391. package/src/section/section.tsx +220 -0
  392. package/src/shell/app-breadcrumbs.tsx +44 -31
  393. package/src/shell/app-header.module.css +77 -37
  394. package/src/shell/app-header.module.css.d.ts +2 -1
  395. package/src/shell/app-mobile-nav.module.css +8 -0
  396. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  397. package/src/shell/app-mobile-nav.tsx +147 -8
  398. package/src/shell/app-sidebar-account.tsx +2 -2
  399. package/src/shell/app-sidebar.module.css +303 -20
  400. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  401. package/src/shell/app-sidebar.test.tsx +456 -41
  402. package/src/shell/app-sidebar.tsx +276 -140
  403. package/src/shell/index.test.tsx +291 -5
  404. package/src/shell/index.tsx +137 -38
  405. package/src/shell/mobile-nav-model.test.ts +228 -7
  406. package/src/shell/mobile-nav-model.ts +53 -3
  407. package/src/shell/nav-count-badge.tsx +1 -1
  408. package/src/shell/page-bar.module.css +178 -0
  409. package/src/shell/page-bar.module.css.d.ts +17 -0
  410. package/src/shell/page-bar.test.tsx +316 -0
  411. package/src/shell/page-bar.tsx +315 -0
  412. package/src/shell/sidebar-block-model.ts +83 -0
  413. package/src/shell/sidebar-blocks.tsx +301 -0
  414. package/src/shell/sidebar-search.module.css +26 -0
  415. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  416. package/src/shell/sidebar-search.tsx +71 -2
  417. package/src/state/fields.test.tsx +258 -0
  418. package/src/state/fields.ts +892 -0
  419. package/src/state/persisted.ts +211 -0
  420. package/src/state.ts +46 -240
  421. package/src/styles.css +13 -0
  422. package/src/surfaces.ts +203 -36
  423. package/src/theme/controls.module.css +16 -1
  424. package/src/theme/ctl-theme.test.tsx +275 -0
  425. package/src/theme/ctl-theme.tsx +138 -0
  426. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  427. package/src/theme/density-relations.test.ts +106 -51
  428. package/src/theme/divider-law.test.ts +259 -0
  429. package/src/theme/icon-slot.module.css +47 -0
  430. package/src/theme/icon-slot.module.css.d.ts +8 -0
  431. package/src/theme/icon-slot.test.tsx +275 -0
  432. package/src/theme/icon-slot.tsx +59 -0
  433. package/src/theme/index.ts +165 -3
  434. package/src/theme/layout-rhythm-css.test.ts +72 -0
  435. package/src/theme/radius.test.ts +11 -1
  436. package/src/theme/segmented-control.module.css +20 -0
  437. package/src/theme/shadow-surfaces.test.ts +20 -7
  438. package/src/theme/spacing.test.ts +75 -27
  439. package/src/theme/use-basalt-spacing.ts +2 -2
  440. package/src/tokens/density.test.ts +7 -25
  441. package/src/tokens/index.ts +48 -8
  442. package/src/tokens/palette.ts +107 -73
  443. package/src/widget-header/delta-badge.test.tsx +60 -0
  444. package/src/widget-header/delta-badge.tsx +57 -0
  445. package/src/widget-header/index.ts +2 -0
  446. package/src/widget-header/no-mantine.test.ts +33 -0
  447. package/src/widget-header/widget-header.module.css +335 -0
  448. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  449. package/src/widget-header/widget-header.test.tsx +187 -0
  450. package/src/widget-header/widget-header.tsx +168 -0
  451. package/agent/rules/basalt-agent.md +0 -452
  452. package/agent/rules/basalt-app.md +0 -246
  453. package/agent/rules/basalt-commands.md +0 -320
  454. package/agent/rules/basalt-content.md +0 -409
  455. package/agent/rules/basalt-data.md +0 -269
  456. package/agent/rules/basalt-forms.md +0 -186
  457. package/agent/rules/basalt-notifications.md +0 -264
  458. package/agent/rules/basalt-query.md +0 -187
  459. package/agent/rules/basalt-router.md +0 -336
  460. package/dist/content/article-filter-bar.d.ts +0 -40
  461. package/dist/content/article-filter-bar.d.ts.map +0 -1
  462. package/dist/content/article-filter-bar.js +0 -56
  463. package/dist/shell/page-header.d.ts +0 -13
  464. package/dist/shell/page-header.d.ts.map +0 -1
  465. package/dist/shell/page-header.js +0 -23
  466. package/src/content/article-filter-bar.tsx +0 -88
  467. package/src/shell/page-header.tsx +0 -35
package/src/data/table.ts CHANGED
@@ -48,4 +48,6 @@ export {
48
48
  type ColumnHelper,
49
49
  type PaginationState,
50
50
  type ColumnPinningState,
51
+ // The type `onColumnFiltersChange` hands back — needed at the call site for server-side faceting.
52
+ type ColumnFiltersState,
51
53
  } from '@tanstack/react-table'
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * Unit tests for checkSource — the pure (text, relPath, cfg) → Finding[] core.
3
3
  *
4
- * Covers all 25 guard kinds. Co-located with the guard, excluded from tsc
4
+ * Covers all 27 guard kinds. Co-located with the guard, excluded from tsc
5
5
  * (tsconfig exclude: src/**\/*.test.ts), run via `bun test`.
6
6
  *
7
7
  * The walker/reporter half is covered by the integration test in
@@ -1916,15 +1916,16 @@ describe('severity', () => {
1916
1916
  expect(f.find((v) => v.kind === 'raw-spacing')?.severity).toBe('error')
1917
1917
  })
1918
1918
 
1919
- // The grace ledger. Five kinds landed warn-only in the round-4 guard minor; deleting an entry
1920
- // from GRACE_PERIOD_KINDS is the promotion, and it shows up here as an explicit diff.
1919
+ // The grace ledger. These five kinds landed warn-only in the round-4 guard minor and sat there
1920
+ // for five minors with nothing tracking the promise (D4) — GRACE_PERIOD_KINDS is now empty and
1921
+ // the C16 version gate in grace.test.ts is what stops a future entry sitting unpromoted again.
1921
1922
  it.each([
1922
1923
  'theme-allow-unscoped',
1923
1924
  'surface-shadow-override',
1924
1925
  'css-raw-surface',
1925
1926
  'inline-font-size',
1926
1927
  'hidden-inline-style',
1927
- ])('%s is still in its grace minor (warn)', (kind) => {
1928
+ ])('%s is promoted past its grace minor (error)', (kind) => {
1928
1929
  const cases: Record<string, [string, string]> = {
1929
1930
  'theme-allow-unscoped': ['const x = 1 // theme-allow', PATH],
1930
1931
  'surface-shadow-override': ['const s = { boxShadow: `0 0 0 2px ${VX.accent}` }', PATH],
@@ -1937,7 +1938,7 @@ describe('severity', () => {
1937
1938
  }
1938
1939
  const [src, path] = cases[kind] as [string, string]
1939
1940
  const hit = find(src, path).find((v) => v.kind === kind)
1940
- expect(hit?.severity).toBe('warn')
1941
+ expect(hit?.severity).toBe('error')
1941
1942
  })
1942
1943
 
1943
1944
  it('ships every OTHER kind past its grace period', () => {
@@ -2139,9 +2140,9 @@ describe('theme-allow scoping', () => {
2139
2140
  expect(f).toHaveLength(0)
2140
2141
  })
2141
2142
 
2142
- it('ships theme-allow-unscoped as a warning, not a build failure', () => {
2143
+ it('ships theme-allow-unscoped as an error (promoted, C16)', () => {
2143
2144
  const f = find(`const x = 1 // theme-allow`)
2144
- expect(f.every((v) => v.severity === 'warn')).toBe(true)
2145
+ expect(f.every((v) => v.severity === 'error')).toBe(true)
2145
2146
  })
2146
2147
 
2147
2148
  // The annotation itself lives in a comment; a `theme-allow` in real code must not waive anything.
@@ -2370,9 +2371,9 @@ describe('surface-shadow-override', () => {
2370
2371
  expect(kinds(f)).not.toContain('surface-shadow-override')
2371
2372
  })
2372
2373
 
2373
- it('ships as a warning for its grace minor', () => {
2374
+ it('is an error now that its grace minor has ended (promoted, C16)', () => {
2374
2375
  const f = find('const s = { boxShadow: `0 0 0 2px ${VX.accent}` }')
2375
- expect(f.find((v) => v.kind === 'surface-shadow-override')?.severity).toBe('warn')
2376
+ expect(f.find((v) => v.kind === 'surface-shadow-override')?.severity).toBe('error')
2376
2377
  })
2377
2378
  })
2378
2379
 
@@ -2435,6 +2436,114 @@ describe('inline-font-size', () => {
2435
2436
  })
2436
2437
  })
2437
2438
 
2439
+ // ── 26. in-body-page-title (the text lane of law C8) ─────────────────────────
2440
+
2441
+ describe('in-body-page-title', () => {
2442
+ it('flags <Title order={1}> in the body', () => {
2443
+ expect(kinds(find(`<Title order={1}>Analytics</Title>`))).toContain('in-body-page-title')
2444
+ })
2445
+
2446
+ it('flags order={2} too', () => {
2447
+ expect(kinds(find(`<Title order={2}>Analytics</Title>`))).toContain('in-body-page-title')
2448
+ })
2449
+
2450
+ // The formatted default — the reason this kind is a bounded TAG scan and not a line regex.
2451
+ it('flags a Title formatted across lines', () => {
2452
+ const f = find(`<Title\n order={1}\n mb="sm"\n>\n Analytics\n</Title>`)
2453
+ expect(kinds(f)).toContain('in-body-page-title')
2454
+ })
2455
+
2456
+ it('does NOT flag order={3} — a section heading', () => {
2457
+ expect(kinds(find(`<Title order={3}>Sessions</Title>`))).not.toContain('in-body-page-title')
2458
+ })
2459
+
2460
+ it('does NOT flag a Title with no order', () => {
2461
+ expect(kinds(find(`<Title>Sessions</Title>`))).not.toContain('in-body-page-title')
2462
+ })
2463
+
2464
+ it('does NOT flag anything under a content/ path segment', () => {
2465
+ const f = find(`<Title order={1}>Doc</Title>`, 'src/content/Guide.tsx')
2466
+ expect(kinds(f)).not.toContain('in-body-page-title')
2467
+ })
2468
+
2469
+ // File-scoped and coarser than the plugin's node-level ancestry, on purpose: a document heading
2470
+ // told to become a breadcrumb is the WRONG advice, so the text lane errs silent.
2471
+ it('does NOT flag a file that renders Prose', () => {
2472
+ const f = find(`<Prose>\n <Title order={1}>Doc</Title>\n</Prose>`)
2473
+ expect(kinds(f)).not.toContain('in-body-page-title')
2474
+ })
2475
+
2476
+ it('honours a theme-allow', () => {
2477
+ const f = find(
2478
+ `// theme-allow in-body-page-title — a shell-less print view\n<Title order={1}>P</Title>`,
2479
+ )
2480
+ expect(kinds(f)).not.toContain('in-body-page-title')
2481
+ })
2482
+
2483
+ it('lands warn while the grace entry stands (C16)', () => {
2484
+ const f = find(`<Title order={1}>Analytics</Title>`)
2485
+ expect(f.find((x) => x.kind === 'in-body-page-title')?.severity).toBe('warn')
2486
+ })
2487
+ })
2488
+
2489
+ // ── 27. raw-selection-control (the text lane of law C1) ──────────────────────
2490
+
2491
+ describe('raw-selection-control', () => {
2492
+ it.each(['SegmentedControl', 'Select', 'MultiSelect', 'NativeSelect', 'TagsInput', 'Chip.Group'])(
2493
+ 'flags a raw <%s>',
2494
+ (tag) => {
2495
+ expect(kinds(find(`<${tag} data={[]} />`))).toContain('raw-selection-control')
2496
+ },
2497
+ )
2498
+
2499
+ it('reports the tag it found, not the whole line', () => {
2500
+ const f = find(`<Select data={[]} />`)
2501
+ expect(f.find((x) => x.kind === 'raw-selection-control')?.token).toBe('<Select')
2502
+ })
2503
+
2504
+ it.each(['SettingsRow', 'Modal', 'Drawer', 'Menu.Dropdown'])(
2505
+ 'does NOT flag one inside a %s window',
2506
+ (host) => {
2507
+ const f = find(`<${host}>\n <Select data={[]} />\n</${host}>`)
2508
+ expect(kinds(f)).not.toContain('raw-selection-control')
2509
+ },
2510
+ )
2511
+
2512
+ // The window is 12 lines; past it the approximation would start swallowing the NEXT row's control.
2513
+ it('DOES flag one 20 lines below the host tag — the window is bounded', () => {
2514
+ const f = find(`<Modal>\n${' <Text>x</Text>\n'.repeat(20)} <Select data={[]} />\n</Modal>`)
2515
+ expect(kinds(f)).toContain('raw-selection-control')
2516
+ })
2517
+
2518
+ it('does NOT flag anything in a file importing @mantine/form', () => {
2519
+ const f = find(`import { useForm } from '@mantine/form'\n<Select data={[]} />`)
2520
+ expect(kinds(f)).not.toContain('raw-selection-control')
2521
+ })
2522
+
2523
+ it('does NOT flag the file that DEFINES a basalt control', () => {
2524
+ const f = find(`export function ViewTabs() {\n return <SegmentedControl data={[]} />\n}`)
2525
+ expect(kinds(f)).not.toContain('raw-selection-control')
2526
+ })
2527
+
2528
+ it('does NOT flag a bound basalt control', () => {
2529
+ expect(kinds(find(`<SelectFilter field={f} label="Channel" />`))).not.toContain(
2530
+ 'raw-selection-control',
2531
+ )
2532
+ })
2533
+
2534
+ it('honours a theme-allow', () => {
2535
+ const f = find(
2536
+ `{/* theme-allow raw-selection-control — a one-off admin picker */}\n<Select data={[]} />`,
2537
+ )
2538
+ expect(kinds(f)).not.toContain('raw-selection-control')
2539
+ })
2540
+
2541
+ it('lands warn while the grace entry stands (C16)', () => {
2542
+ const f = find(`<Select data={[]} />`)
2543
+ expect(f.find((x) => x.kind === 'raw-selection-control')?.severity).toBe('warn')
2544
+ })
2545
+ })
2546
+
2438
2547
  // ── 26. hidden-inline-style ──────────────────────────────────────────────────
2439
2548
 
2440
2549
  describe('hidden-inline-style', () => {
@@ -2475,12 +2584,12 @@ export const C = () => <div style={labelStyle} />`,
2475
2584
  expect(kinds(f)).not.toContain('hidden-inline-style')
2476
2585
  })
2477
2586
 
2478
- it('ships as a warning for its grace minor', () => {
2587
+ it('is an error now that its grace minor has ended (promoted, C16)', () => {
2479
2588
  const f = find(
2480
2589
  `const wrapperStyle = { position: 'relative', width: '100%' }
2481
2590
  export const C = () => <div style={wrapperStyle} />`,
2482
2591
  )
2483
- expect(f.find((v) => v.kind === 'hidden-inline-style')?.severity).toBe('warn')
2592
+ expect(f.find((v) => v.kind === 'hidden-inline-style')?.severity).toBe('error')
2484
2593
  })
2485
2594
  })
2486
2595
 
@@ -2638,7 +2747,7 @@ describe('markup files (index.html / *.webmanifest)', () => {
2638
2747
  * Round 9: `.astro` and `.vue` became scannable and fell through to the `ts` dialect, so `<!-- … -->`
2639
2748
  * was never stripped — a `theme-allow` written in an HTML comment waived nothing there, and a color
2640
2749
  * inside a commented-out block still reported. They resolve as `sfc` now: BOTH comment dialects,
2641
- * and the full 25-kind set (not `MARKUP_KINDS` — an `.astro` template is JSX-shaped and a `.vue`
2750
+ * and the full 27-kind set (not `MARKUP_KINDS` — an `.astro` template is JSX-shaped and a `.vue`
2642
2751
  * `<script setup>` is real TS).
2643
2752
  */
2644
2753
  describe('single-file components (.astro / .vue)', () => {
@@ -2770,11 +2879,15 @@ describe("profile: 'tokens-only'", () => {
2770
2879
  'css-raw-surface': false,
2771
2880
  'inline-font-size': false,
2772
2881
  'hidden-inline-style': true,
2882
+ // The two wave-6 control kinds: `<Title>` is a Mantine component, and the remedy for either is
2883
+ // a Mantine-rendered home (PageBar / WidgetHeader) or a basalt control over @mantine/core.
2884
+ 'in-body-page-title': true,
2885
+ 'raw-selection-control': true,
2773
2886
  }
2774
2887
 
2775
2888
  it('classifies every kind in the registry — the table is exhaustive', () => {
2776
2889
  expect(Object.keys(MANTINE_COUPLED).toSorted()).toEqual(Object.keys(GUARD_RULES).toSorted())
2777
- expect(Object.keys(MANTINE_COUPLED)).toHaveLength(25)
2890
+ expect(Object.keys(MANTINE_COUPLED)).toHaveLength(27)
2778
2891
  })
2779
2892
 
2780
2893
  it('the disabled set is exactly the Mantine-coupled half — a complete partition', () => {
@@ -0,0 +1,149 @@
1
+ /**
2
+ * The C16 version gate (`docs/CONTROLS-SPEC.md` §1): a `GRACE_PERIOD_KINDS` entry fails the build
3
+ * once `package.json`'s version reaches its `promote`, and every entry must declare `since` before
4
+ * `promote`. This is the mechanism that replaces the honor system — deleting an entry used to be
5
+ * the ONLY enforcement of "warn for one minor", and nothing checked that anyone had (D4:
6
+ * `docs/archive/CONTROLS-SYNTHESIS.md`). Five kinds landed together in the round-4 guard minor and sat
7
+ * at `warn` for five minors before this test existed.
8
+ *
9
+ * `assertGraceLedger` is tested directly against synthetic entries below rather than only through
10
+ * the real ledger: every pre-existing entry promoted or moved to `PLUGIN_RULE_ADVISORY`'s guard-side
11
+ * sibling, and an `it.each` over the empty ledger that left behind ran zero assertions, which proved
12
+ * nothing about the gate actually firing. The ledger now carries the two wave-6 control kinds
13
+ * (`in-body-page-title`, `raw-selection-control`, both `promote: '1.27.0'`), and the real-ledger
14
+ * check below runs against them.
15
+ *
16
+ * This gate measures the version already PUBLISHED, so it can only go red after the release that
17
+ * shipped a due entry. `scripts/check-grace.ts` is the other end — `scripts/release.sh` runs it
18
+ * against the version the dry run computed, before the release is cut.
19
+ *
20
+ * Excluded from tsc (tsconfig exclude: src/**\/*.test.ts), run via `bun test`.
21
+ */
22
+ import { describe, expect, it } from 'bun:test'
23
+ import { readFileSync } from 'node:fs'
24
+ import { resolve } from 'node:path'
25
+ import { GRACE_PERIOD_KINDS } from './index'
26
+
27
+ const PACKAGE_JSON_PATH = resolve(import.meta.dirname, '..', '..', 'package.json')
28
+ const CURRENT_VERSION = (JSON.parse(readFileSync(PACKAGE_JSON_PATH, 'utf8')) as { version: string })
29
+ .version
30
+
31
+ type GraceEntry = { since: string; promote: string; why: string }
32
+
33
+ /**
34
+ * Tiny local semver compare — no dependency. Assumes plain `x.y.z`, no pre-release identifiers
35
+ * (basalt-ui bans majors and ships no pre-releases). Returns negative when `a` sorts before `b`,
36
+ * positive when after, zero when equal. Compares each component numerically, not lexically, so
37
+ * `'1.9.0' < '1.10.0'` and `'1.3.0' < '1.26.0'` — a string compare would get both backwards.
38
+ */
39
+ export function compareSemver(a: string, b: string): number {
40
+ const partsA = a.split('.').map(Number)
41
+ const partsB = b.split('.').map(Number)
42
+ for (let i = 0; i < 3; i++) {
43
+ const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0)
44
+ if (diff !== 0) return diff > 0 ? 1 : -1
45
+ }
46
+ return 0
47
+ }
48
+
49
+ /**
50
+ * The C16 gate itself, extracted so it can be exercised against a synthetic ledger as well as the
51
+ * real one. Throws naming the first offending entry when either invariant breaks: `since` must
52
+ * precede `promote` for every entry, and no entry's `promote` may be `<=` the given `version`.
53
+ */
54
+ export function assertGraceLedger(ledger: Record<string, GraceEntry>, version: string): void {
55
+ for (const [id, entry] of Object.entries(ledger)) {
56
+ if (compareSemver(entry.since, entry.promote) >= 0) {
57
+ throw new Error(
58
+ `${id}: \`since\` (${entry.since}) must be before \`promote\` (${entry.promote}).`,
59
+ )
60
+ }
61
+ if (compareSemver(version, entry.promote) >= 0) {
62
+ throw new Error(
63
+ `${id}: package.json is at ${version}, which has reached its promote version ` +
64
+ `${entry.promote} — promote to error or extend \`promote\` with a reason.`,
65
+ )
66
+ }
67
+ }
68
+ }
69
+
70
+ describe('compareSemver', () => {
71
+ it('orders multi-digit components numerically, not lexically', () => {
72
+ expect(compareSemver('1.9.0', '1.10.0')).toBeLessThan(0)
73
+ expect(compareSemver('1.10.0', '1.9.0')).toBeGreaterThan(0)
74
+ expect(compareSemver('1.3.0', '1.26.0')).toBeLessThan(0)
75
+ expect(compareSemver('1.26.0', '1.3.0')).toBeGreaterThan(0)
76
+ })
77
+
78
+ it('treats equal versions as equal', () => {
79
+ expect(compareSemver('1.25.0', '1.25.0')).toBe(0)
80
+ })
81
+ })
82
+
83
+ describe('assertGraceLedger', () => {
84
+ it('does not throw on a well-formed entry not yet due', () => {
85
+ const ledger = { 'fake-kind': { since: '1.0.0', promote: '2.0.0', why: 'synthetic' } }
86
+ expect(() => assertGraceLedger(ledger, '1.25.0')).not.toThrow()
87
+ })
88
+
89
+ it('throws once the given version reaches promote', () => {
90
+ const ledger = { 'fake-kind': { since: '1.0.0', promote: '1.25.0', why: 'synthetic' } }
91
+ expect(() => assertGraceLedger(ledger, '1.25.0')).toThrow(/fake-kind/)
92
+ })
93
+
94
+ it('throws once the given version is past promote', () => {
95
+ const ledger = { 'fake-kind': { since: '1.0.0', promote: '1.9.0', why: 'synthetic' } }
96
+ expect(() => assertGraceLedger(ledger, '1.10.0')).toThrow(/fake-kind/)
97
+ })
98
+
99
+ it('throws when since does not precede promote', () => {
100
+ const ledger = { 'fake-kind': { since: '1.5.0', promote: '1.5.0', why: 'synthetic' } }
101
+ expect(() => assertGraceLedger(ledger, '1.0.0')).toThrow(/since/)
102
+ })
103
+ })
104
+
105
+ describe('GRACE_PERIOD_KINDS — version gate (C16)', () => {
106
+ it('the real ledger passes the gate against the real package version', () => {
107
+ expect(() =>
108
+ assertGraceLedger(GRACE_PERIOD_KINDS as Record<string, GraceEntry>, CURRENT_VERSION),
109
+ ).not.toThrow()
110
+ })
111
+ })
112
+
113
+ // ── the release-time half: scripts/check-grace.ts ────────────────────────────
114
+ // Exercised as a PROCESS, because that is how `scripts/release.sh` consumes it — the exit code is
115
+ // the whole interface, and a script that printed the right words with status 0 would gate nothing.
116
+
117
+ const CHECK_GRACE = resolve(import.meta.dirname, '..', '..', 'scripts', 'check-grace.ts')
118
+
119
+ function runCheckGrace(...args: string[]): { code: number; stderr: string; stdout: string } {
120
+ const result = Bun.spawnSync(['bun', CHECK_GRACE, ...args])
121
+ return {
122
+ code: result.exitCode,
123
+ stderr: result.stderr.toString(),
124
+ stdout: result.stdout.toString(),
125
+ }
126
+ }
127
+
128
+ describe('scripts/check-grace.ts', () => {
129
+ it('passes for a version below every promote', () => {
130
+ const { code, stdout } = runCheckGrace('1.0.0')
131
+ expect([code, stdout.includes('clear of all')]).toEqual([0, true])
132
+ })
133
+
134
+ it('refuses a version that has reached a promote, naming the entries', () => {
135
+ const { code, stderr } = runCheckGrace('9.9.9')
136
+ expect(code).toBe(1)
137
+ expect(stderr).toContain('raw-selection-control')
138
+ expect(stderr).toContain('basalt/control-outside-home')
139
+ })
140
+
141
+ it('refuses the version the wave-6 entries are due in (1.27.0)', () => {
142
+ expect(runCheckGrace('1.27.0').code).toBe(1)
143
+ })
144
+
145
+ it('exits 2 on a missing or malformed version rather than passing vacuously', () => {
146
+ expect(runCheckGrace().code).toBe(2)
147
+ expect(runCheckGrace('not-a-version').code).toBe(2)
148
+ })
149
+ })
@@ -1,7 +1,7 @@
1
1
  /**
2
2
  * ./guard — headless policy core. Mantine-free, dependency-free.
3
3
  *
4
- * GUARD_RULES: the closed registry of all 25 violation kinds.
4
+ * GUARD_RULES: the closed registry of all 27 violation kinds.
5
5
  * checkSource: pure (text, relPath, cfg) → Finding[]. No FS, no walk, no console.
6
6
  */
7
7
  import type { Finding, GuardConfig, GuardKind, GuardSeverity } from './types'
@@ -483,6 +483,52 @@ const RAW_FORM_CONTROL = /<(?:input|select|textarea)\b/g
483
483
  // for a sub-floor fontSize.
484
484
  const RAW_FORM_CONTROL_TAG = /<(?:input|select|textarea)\b(?:=>|[^>])*?>/g
485
485
 
486
+ // ── Control-home patterns (docs/CONTROLS-SPEC.md §6 — the text lane of laws C1/C8) ──────────────
487
+ //
488
+ // Bounded full-text tag scans, the CARD_SURFACE_TAG / CHART_ENTRY_POINT_TAG shape: a `<Title>` and a
489
+ // `<Select>` are both routinely formatted across lines, so a per-line regex would see neither.
490
+
491
+ /** A `<Title …>` opening tag, however it is wrapped. `order` is judged separately, on the tag text. */
492
+ const PAGE_TITLE_TAG = /<Title(?![\w.])(?:=>|[^>])*?>/g
493
+ /** `order={1}` / `order={2}` — the two orders that name a PAGE rather than a section. */
494
+ const PAGE_TITLE_ORDER_PROP = /\border\s*=\s*\{\s*([12])\s*\}/
495
+
496
+ /**
497
+ * A raw Mantine selection control's opening tag — the text-lane twin of the oxlint plugin's
498
+ * RAW_FILTER_TAGS. `Chip.Group` carries a dot, so the name class allows one.
499
+ */
500
+ const RAW_SELECTION_CONTROL_TAG =
501
+ /<(SegmentedControl|Select|MultiSelect|NativeSelect|DatePickerInput|DateInput|TagsInput|Chip\.Group)(?![\w])(?:=>|[^>])*?>/g
502
+
503
+ /**
504
+ * A tag whose presence makes an `order={1|2}` Title a DOCUMENT heading rather than a page title.
505
+ * File-scoped on purpose — see the scan's own comment for why the coarser test is the honest one.
506
+ */
507
+ const PROSE_CONTEXT_TAG = /<(?:Prose|ArticleLayout|Modal|Drawer)(?![\w])/
508
+
509
+ /** An opening tag for one of the declared non-homes — a settings row, an overlay, a composer. */
510
+ const CONTROL_HOST_TAG =
511
+ /<(?:SettingsRow|Modal|Drawer|Popover\.Dropdown|Menu\.Dropdown|Composer)(?![\w])/
512
+
513
+ /**
514
+ * How far ABOVE a control the host-tag window reaches, in lines.
515
+ *
516
+ * The text lane has no ancestry, so "inside a SettingsRow" is approximated by "a SettingsRow opens
517
+ * within the last N lines". 12 covers every row shape in the five consumer repos (label +
518
+ * description + the `control={` line); past that the window would start swallowing the next row's
519
+ * control, which is the direction that makes the kind silent rather than noisy. The plugin rule
520
+ * (`basalt/control-outside-home`) is the one that answers this exactly — this kind exists for the
521
+ * PreToolUse hook lane, which sees one file's TEXT and no AST at all.
522
+ */
523
+ const CONTROL_HOST_WINDOW_LINES = 12
524
+
525
+ /** A name whose DECLARATION means this file DEFINES a basalt control rather than consuming one. */
526
+ const CONTROL_OWNER_DEF =
527
+ /\b(?:function|const|class)\s+(?:RangeFilter|CompareFilter|SelectFilter|MultiSelectFilter|SearchFilter|ToggleFilter|ViewTabs|FilterSet|FilterPill|SyncButton|ActionGroup|OverflowMenu|CtlSlot)(?![\w])/
528
+
529
+ /** `@mantine/form` — a form is C1's third home, and its inputs are not filters. */
530
+ const MANTINE_FORM_IMPORT = /from\s+['"]@mantine\/form['"]/
531
+
486
532
  // A Mantine `styles={{ input: {...} }}` per-part style — the `input` key specifically targets the
487
533
  // rendered <input>/<textarea> part of TextInput/Select/Textarea/etc. Requires `input` be the FIRST
488
534
  // key in the styles object (see the sub-16-input-font doc comment for the scoping trade-off this
@@ -541,20 +587,32 @@ export const DEFAULT_GUARD_CONFIG: GuardConfig = {
541
587
  severity: {},
542
588
  }
543
589
 
590
+ /**
591
+ * A grace-ledger entry: `since` and `promote` are plain `x.y.z` semver strings (C16,
592
+ * `docs/CONTROLS-SPEC.md` §1). `grace.test.ts` fails the build once `package.json`'s version
593
+ * reaches `promote` while the entry is still here — the version-gate that replaces the honor
594
+ * system a bare promotion-note string used to run on. `since` must be strictly before `promote`,
595
+ * also asserted there.
596
+ */
597
+ export type GraceEntry = { since: string; promote: string; why: string }
598
+
544
599
  /**
545
600
  * Kinds still inside their grace minor — reported, not fatal, until the next minor promotes them.
546
601
  * See {@link GuardSeverity} for the doctrine and why basalt-ui in particular needs it.
547
602
  *
548
- * The value is the promotion note, so the entry carries its own expiry rather than relying on
549
- * someone remembering. Adding a kind here is part of shipping it; removing the entry IS the
550
- * promotion, and belongs in its own commit so the changelog says enforcement got stricter.
603
+ * Adding a kind here is part of shipping it; removing the entry IS the promotion, and belongs in
604
+ * its own commit so the changelog says enforcement got stricter. `grace.test.ts` is the other half
605
+ * of "belongs in its own commit": it fails once `package.json`'s version reaches an entry's
606
+ * `promote` while the entry is still here, so a kind can no longer sit at `warn` with nothing
607
+ * tracking it — which is exactly what happened to the five entries this ledger shipped in the
608
+ * round-4 guard minor (D4, `docs/CONTROLS-SPEC.md` §6): all five stayed `warn` for five minors
609
+ * because deleting the entry was the only enforcement, and nothing checked that anyone had.
551
610
  *
552
611
  * The runway is measured in consumer UPGRADES, not in version numbers: `mantine-shade-index`
553
612
  * (the table's first tenant, promoted and gone) sat here across two minors because 1.8.0 shipped
554
613
  * the same day as 1.7.0 and 1.9.0 carried the chart batch the same consumer was upgrading for —
555
614
  * bundling a build-breaking promotion into the minor they take for the fixes turns a routine bump
556
- * into an unplanned refactor, which is exactly what the grace minor exists to prevent. The five
557
- * entries below all landed together in the round-4 guard minor and all promote one minor later.
615
+ * into an unplanned refactor, which is exactly what the grace minor exists to prevent.
558
616
  *
559
617
  * **A `SCANNABLE_EXT` widening cannot be expressed here, and 1.23.2's was deliberately not.**
560
618
  * Adding `.astro`/`.jsx`/`.vue` widens the FILE SET for all 25 kinds at once — there is no kind to
@@ -572,29 +630,30 @@ export const DEFAULT_GUARD_CONFIG: GuardConfig = {
572
630
  * with `theme-allow`, `basalt.severity` and `exemptRules` all still available.
573
631
  *
574
632
  * @example
575
- * const GRACE: Partial<Record<GuardKind, string>> = {
576
- * 'raw-font-family': 'introduced 1.4.0 — promote to error in 1.5.0',
633
+ * const GRACE: Partial<Record<GuardKind, GraceEntry>> = {
634
+ * 'raw-font-family': { since: '1.4.0', promote: '1.5.0', why: 'introduced 1.4.0 — …' },
577
635
  * }
578
636
  */
579
- const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, string>> = {
580
- 'theme-allow-unscoped':
581
- 'introduced in the round-4 guard minor — promote to error one minor later. Every consumer has ' +
582
- 'bare `theme-allow` comments today (basalt itself has ~20), and landing this as an error would ' +
583
- 'fail every one of their builds on an upgrade they took for the fixes.',
584
- 'surface-shadow-override':
585
- 'introduced in the round-4 guard minor — promote to error one minor later. Catches the shape a ' +
586
- 'TOKEN-FLUENT consumer writes, so the code it rejects is code that looks correct and passed ' +
587
- 'every previous release.',
588
- 'css-raw-surface':
589
- 'introduced in the round-4 guard minor — promote to error one minor later. The surface kinds ' +
590
- 'reaching CSS at all is the "existing kind, new file type" case the doctrine names explicitly.',
591
- 'inline-font-size':
592
- 'introduced in the round-4 guard minor — promote to error one minor later. The oxlint plugin ' +
593
- 'has enforced the same thing as `basalt/no-raw-font-size` for a while, so a consumer running ' +
594
- 'both sees nothing new; one running check-theme alone gets a minor of runway.',
595
- 'hidden-inline-style':
596
- 'introduced in the round-4 guard minor — promote by DELETING the kind and folding its two ' +
597
- 'scans into `raw-html-layout`, which is where they belong once the widening has had its minor.',
637
+ export const GRACE_PERIOD_KINDS: Partial<Record<GuardKind, GraceEntry>> = {
638
+ 'in-body-page-title': {
639
+ since: '1.26.0',
640
+ promote: '1.27.0',
641
+ why:
642
+ 'new in the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C8). Its AST twin is the ' +
643
+ 'oxlint rule of the SAME id, whose PLUGIN_RULE_GRACE entry carries the same dates — one law, ' +
644
+ 'two lanes, one promotion. Warn for a minor because the fix is a route/breadcrumb change ' +
645
+ '(the page is named by staticData.title), not a prop edit, so a consumer needs to schedule it.',
646
+ },
647
+ 'raw-selection-control': {
648
+ since: '1.26.0',
649
+ promote: '1.27.0',
650
+ why:
651
+ 'new in the wave-6 control guards (docs/CONTROLS-SPEC.md §6, law C1). The text lane cannot ' +
652
+ 'see ancestry, so "no home" is approximated by a 12-line host-tag window — the loosest ' +
653
+ 'reading in the guard, and the reason this one lands warn rather than error even though its ' +
654
+ 'law is settled. Promotion is gated with the plugin half on the wave-7 consumer run (≤3 ' +
655
+ 'waivers), and `scripts/check-grace.ts` refuses the 1.27.0 release while the entry is still here.',
656
+ },
598
657
  }
599
658
 
600
659
  /** A kind's effective severity: consumer override first, then the grace table, then `error`. */
@@ -762,6 +821,16 @@ export const PLUGIN_RULE_IDS: ReadonlySet<string> = new Set([
762
821
  'hand-rolled-shell',
763
822
  'shadow-basalt-export',
764
823
  'raw-scroll-container',
824
+ 'hand-rolled-filter',
825
+ 'control-outside-home',
826
+ 'control-size-literal',
827
+ 'page-bar-budget',
828
+ 'responsive-twin',
829
+ 'search-literal-link',
830
+ 'use-search-from-literal',
831
+ // `in-body-page-title` is deliberately ABSENT: it is a plugin rule AND a guard kind under one id
832
+ // (one law, two lanes, one annotation), and this set is the ids OUTSIDE `checkSource`'s reach.
833
+ // `check-source.test.ts` asserts the two registries stay disjoint.
765
834
  'visx-boundary',
766
835
  'visx-tooltip',
767
836
  'token-layer-boundary',
@@ -1388,6 +1457,11 @@ export const TOKENS_ONLY_DISABLED_KINDS: ReadonlySet<GuardKind> = new Set([
1388
1457
  'chart-missing-aria-label',
1389
1458
  'raw-form-control',
1390
1459
  'sub-16-input-font',
1460
+ // Both wave-6 control kinds: `<Title>` is a Mantine component and the remedy for either one is a
1461
+ // Mantine-rendered home (PageBar / WidgetHeader) or a basalt control over @mantine/core. Neither
1462
+ // sentence is followable in a consumer that installs the token layer and nothing else.
1463
+ 'in-body-page-title',
1464
+ 'raw-selection-control',
1391
1465
  ])
1392
1466
 
1393
1467
  // A `/` opens a regex literal (not division) when the previous significant token is one of these
@@ -1608,7 +1682,7 @@ type GuardRule = {
1608
1682
  }
1609
1683
 
1610
1684
  /**
1611
- * The closed registry of all 25 guard kinds. The triad test asserts
1685
+ * The closed registry of all 27 guard kinds. The triad test asserts
1612
1686
  * `surface.guardKinds ⊆ keyof GUARD_RULES` at runtime.
1613
1687
  *
1614
1688
  * raw-surface, raw-html-layout, and sub-16-input-font are handled inline in checkSource
@@ -1827,7 +1901,23 @@ export const GUARD_RULES = {
1827
1901
  enabled: (cfg: GuardConfig) => cfg.rawHtmlLayout,
1828
1902
  appliesTo: (relPath) => !isChartFile(relPath) && !relPath.endsWith('.css'),
1829
1903
  message:
1830
- 'Raw HTML element with inline layout/surface styling that the line scan cannot see — the tag is formatted across lines, or the style object is hoisted to a const. Same violation as raw-html-layout: use a Mantine layout primitive (Box/Flex/Grid/Stack/Group). Its own kind for one minor so the widening lands as a warning; it merges into raw-html-layout at the promotion.',
1904
+ 'Raw HTML element with inline layout/surface styling that the line scan cannot see — the tag is formatted across lines, or the style object is hoisted to a const. Same violation as raw-html-layout: use a Mantine layout primitive (Box/Flex/Grid/Stack/Group). Promoted to error in the Unreleased minor (C16) with severity only — the kind stayed standalone rather than folding into raw-html-layout as originally planned; the merge is still open, tracked as a follow-up, not done here.',
1905
+ },
1906
+ 'in-body-page-title': {
1907
+ kind: 'in-body-page-title',
1908
+ pattern: PAGE_TITLE_TAG, // handled inline (full-text tag-scoped scan); entry keeps the registry complete
1909
+ // JSX-tag-shaped (`<Title order={1}>`) — never appears in CSS text.
1910
+ appliesTo: (relPath) => !relPath.endsWith('.css'),
1911
+ message:
1912
+ 'In-body page title — a page is named ONCE, by the breadcrumb (staticData.title) or by PageBar.title in a shell-less app, and every section/card/table title is a WidgetHeader (docs/CONTROLS-SPEC.md law C8). An <Title order={1|2}> in the body is a second name for the same page, and it drifts. Prose / ArticleLayout / an overlay and anything under a content/ path are document headings and never report. Same law the oxlint plugin enforces as basalt/in-body-page-title — one id, two lanes, so one theme-allow covers both.',
1913
+ },
1914
+ 'raw-selection-control': {
1915
+ kind: 'raw-selection-control',
1916
+ pattern: RAW_SELECTION_CONTROL_TAG, // handled inline (full-text tag-scoped scan + host window)
1917
+ // JSX-tag-shaped (`<Select …>`) — never appears in CSS text.
1918
+ appliesTo: (relPath) => !relPath.endsWith('.css'),
1919
+ message:
1920
+ 'Raw selection control with no home — a filter or tab belongs in a PageBar / Section / WidgetHeader slot and takes a `field` (a FieldHandle), so it owns the URL write and the localStorage mirror instead of carrying value/onChange (docs/CONTROLS-SPEC.md laws C1–C3). Use the bound control from basalt-ui/controls. A settings row, an overlay and an @mantine/form file are the declared non-homes and never report. This is the TEXT lane of basalt/control-outside-home, which answers the same question against the real AST — where the two disagree, the plugin is right.',
1831
1921
  },
1832
1922
  'inline-font-size': {
1833
1923
  kind: 'inline-font-size',
@@ -2249,6 +2339,59 @@ export function checkSource(text: string, relPath: string, cfg: GuardConfig): Fi
2249
2339
  }
2250
2340
  }
2251
2341
 
2342
+ // in-body-page-title — full-text tag scan (the card-with-border shape), because a `<Title>` with
2343
+ // its props on separate lines is the formatted default. `order` is judged on the tag's own text.
2344
+ //
2345
+ // Two file-level exemptions, both deliberately coarser than the plugin's node-level ones and both
2346
+ // false-NEGATIVE-only: anything under a `content/` path segment, and any file that renders
2347
+ // `<Prose>` / `<ArticleLayout>` / an overlay anywhere in it. A text scan has no ancestry, so
2348
+ // "under Prose" is not answerable per node — and a document heading told to become a breadcrumb is
2349
+ // the wrong advice, not merely noise. `basalt/in-body-page-title` (the AST lane, same id) is the
2350
+ // half that scopes this exactly.
2351
+ if (
2352
+ ruleApplies('in-body-page-title', relPath) &&
2353
+ !relPath.split('/').includes('content') &&
2354
+ !PROSE_CONTEXT_TAG.test(codeText)
2355
+ ) {
2356
+ for (const m of codeText.matchAll(PAGE_TITLE_TAG)) {
2357
+ const order = PAGE_TITLE_ORDER_PROP.exec(m[0])
2358
+ if (order === null) continue
2359
+ const startLine = codeText.slice(0, m.index ?? 0).split('\n').length
2360
+ const endLine = startLine + (m[0].split('\n').length - 1)
2361
+ if (isAllowedInRange(startLine, endLine, 'in-body-page-title')) continue
2362
+ findings.push({
2363
+ relPath,
2364
+ line: startLine,
2365
+ token: `<Title order={${order[1]}}`,
2366
+ kind: 'in-body-page-title',
2367
+ })
2368
+ }
2369
+ }
2370
+
2371
+ // raw-selection-control — full-text tag scan plus the host WINDOW (see CONTROL_HOST_WINDOW_LINES).
2372
+ // Two file-level exemptions mirror the plugin rule's, so the two lanes agree on the same file:
2373
+ // a file that DEFINES a basalt control cannot be told to use one, and a file importing
2374
+ // `@mantine/form` is a form — C1's third home, whose inputs are not filters.
2375
+ if (
2376
+ ruleApplies('raw-selection-control', relPath) &&
2377
+ !CONTROL_OWNER_DEF.test(codeText) &&
2378
+ !MANTINE_FORM_IMPORT.test(codeText)
2379
+ ) {
2380
+ for (const m of codeText.matchAll(RAW_SELECTION_CONTROL_TAG)) {
2381
+ const startLine = codeText.slice(0, m.index ?? 0).split('\n').length
2382
+ const windowStart = Math.max(0, startLine - 1 - CONTROL_HOST_WINDOW_LINES)
2383
+ if (CONTROL_HOST_TAG.test(codeLines.slice(windowStart, startLine).join('\n'))) continue
2384
+ const endLine = startLine + (m[0].split('\n').length - 1)
2385
+ if (isAllowedInRange(startLine, endLine, 'raw-selection-control')) continue
2386
+ findings.push({
2387
+ relPath,
2388
+ line: startLine,
2389
+ token: `<${m[1] ?? 'Select'}`,
2390
+ kind: 'raw-selection-control',
2391
+ })
2392
+ }
2393
+ }
2394
+
2252
2395
  // sub-16-input-font — two full-text scans (same shape as the tag-scoped scans above): (a) a raw
2253
2396
  // form-control's own inline `style={{ fontSize: N }}`, (b) a Mantine `styles={{ input: { fontSize:
2254
2397
  // N } }}` per-part style. Either shape is now DEAD CODE against the `!important` floor in