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/src/cli/index.ts CHANGED
@@ -5,10 +5,12 @@
5
5
  * BasaltConfig, builds a GuardConfig, walks the source roots, calls `checkSource` per file,
6
6
  * collects Finding[], groups/reports findings, and returns an exit code.
7
7
  *
8
- * `checkCoverage` asserts 8 invariants: guardKinds ⊆ GUARD_RULES, rule files on disk,
9
- * skill union ⊆ plugin.json, subpath-export-coverage, exports→SURFACES reverse, globs
10
- * required for non-empty forbiddenImports, headless Mantine-ban completeness, and
11
- * optionalPeers peerDependencies/peerDependenciesMeta presence.
8
+ * `checkCoverage` asserts 11 invariants: guardKinds ⊆ GUARD_RULES, rule files on disk, skill files
9
+ * on disk, subpath-export-coverage, exports→SURFACES reverse, globs required for non-empty
10
+ * forbiddenImports, headless Mantine-ban completeness, optionalPeers
11
+ * peerDependencies/peerDependenciesMeta presence, every plugin rule on exactly one surface, every
12
+ * guard kind on at least one, and the agent-layer line budgets. See `checkCoverage`'s own JSDoc for
13
+ * what each one buys.
12
14
  *
13
15
  * `init` / `sync` scaffold and reconcile the framework's *agentic* surface into a consumer repo:
14
16
  * Claude Code rules + skills, a managed CLAUDE.md block, a DESIGN.md seed, and the toolchain
@@ -26,6 +28,7 @@ import {
26
28
  mkdirSync,
27
29
  readdirSync,
28
30
  readFileSync,
31
+ rmdirSync,
29
32
  statSync,
30
33
  unlinkSync,
31
34
  writeFileSync,
@@ -47,8 +50,14 @@ import {
47
50
  } from '../guard'
48
51
  import type { Finding, GuardConfig, GuardKind, GuardSeverity } from '../guard'
49
52
  import { evaluateGuardHook } from '../guard/guard-hook'
50
- import { RULE_NAMES, SKILL_NAMES, SURFACES, TOKEN_LAYER_BOUNDARY_SURFACES } from '../surfaces'
51
- import type { DoctrineSpec, SurfaceSpec } from '../surfaces'
53
+ import {
54
+ PLUGIN_RULE_ID_LIST,
55
+ RULE_NAMES,
56
+ SKILL_NAMES,
57
+ SURFACES,
58
+ TOKEN_LAYER_BOUNDARY_SURFACES,
59
+ } from '../surfaces'
60
+ import type { DoctrineSpec, RuleName, SurfaceSpec } from '../surfaces'
52
61
  import { buildPaletteCss, deriveSpacing } from '../tokens'
53
62
 
54
63
  /** Shape of the optional `"basalt"` key in a consumer's package.json. */
@@ -1838,6 +1847,113 @@ function migrateLegacyOxfmt(cwd: string, pkgRoot: string, manifest: Manifest): v
1838
1847
  if (shipped !== null && onDisk === shipped) unlinkSync(legacyAbs)
1839
1848
  }
1840
1849
 
1850
+ /**
1851
+ * The two managed NAMESPACES whose membership is derived, not fixed: `RULE_NAMES` and `SKILL_NAMES`
1852
+ * decide which files `managedFiles()` places under them, so a rule or skill that is RETIRED
1853
+ * upstream leaves a file behind in every consumer that ever synced it — and Claude keeps loading it.
1854
+ *
1855
+ * Scoped to these two patterns on purpose, and never applied to the manifest wholesale: several
1856
+ * managed/seed entries are legitimately absent from `managedFiles()` for a run (`lefthook.yml` and
1857
+ * `check.yml` when the package is not the repo root, `src/query-client.ts` when it was relocated,
1858
+ * the scaffolds without their peers). Deleting a dest just because this run did not place it would
1859
+ * remove a consumer's CI workflow on a monorepo.
1860
+ */
1861
+ const RETIREABLE_DEST_PATTERNS: readonly RegExp[] = [
1862
+ /^\.claude\/rules\/basalt-[a-z0-9-]+\.md$/,
1863
+ /^\.claude\/skills\/basalt-[a-z0-9-]+\/SKILL\.md$/,
1864
+ ]
1865
+
1866
+ /** A tracked dest inside a derived namespace that this basalt version no longer ships. */
1867
+ export function retiredManagedDests(
1868
+ manifest: Manifest,
1869
+ files: readonly ManagedFile[],
1870
+ ): readonly string[] {
1871
+ const shipped = new Set(files.map((f) => f.dest))
1872
+ return Object.keys(manifest.files)
1873
+ .filter((dest) => !shipped.has(dest))
1874
+ .filter((dest) => RETIREABLE_DEST_PATTERNS.some((re) => re.test(dest)))
1875
+ .toSorted()
1876
+ }
1877
+
1878
+ /** Outcome of one prune pass — `removed` was deleted, `drifted` was locally edited and left alone. */
1879
+ type PruneResult = { removed: string[]; drifted: string[] }
1880
+
1881
+ /**
1882
+ * Report a prune pass. Always names the files: a doctrine file DISAPPEARING from a consumer's
1883
+ * `.claude/` is exactly the class of change the sync diff exists to be reviewed, and `9 removed`
1884
+ * with no names is what made the kept-files line a false green before 1.23.0.
1885
+ */
1886
+ function reportPrune(pruned: PruneResult): void {
1887
+ if (pruned.removed.length > 0) {
1888
+ console.log(
1889
+ `\nRetired by this basalt-ui version (deleted — they no longer ship):\n` +
1890
+ pruned.removed.map((dest) => ` · ${dest}`).join('\n'),
1891
+ )
1892
+ }
1893
+ if (pruned.drifted.length === 0) return
1894
+ console.log(
1895
+ `\nRetired but locally edited, so left in place — your agent still loads them. Delete them, ` +
1896
+ 'or run `basalt-ui sync --force`:\n' +
1897
+ pruned.drifted.map((dest) => ` · ${dest}`).join('\n'),
1898
+ )
1899
+ }
1900
+
1901
+ /**
1902
+ * Delete the managed rule/skill files this basalt version retired, and drop their manifest entries.
1903
+ *
1904
+ * The 13→6 rule merge (`docs/CONTROLS-SPEC.md` §7) is the case this exists for: nine
1905
+ * `.claude/rules/basalt-*.md` files stopped shipping in one minor, and without this a consumer's
1906
+ * agent would go on reading `basalt-router.md`'s superseded placement doctrine forever, with
1907
+ * `sync --check` reporting green because nothing in the ledger asks about a dest the run no longer
1908
+ * places.
1909
+ *
1910
+ * Same three-way discipline as every other managed unit, so a consumer edit is never discarded
1911
+ * silently: an untouched file (matching its recorded hash, raw or normalized) is deleted and its
1912
+ * entry dropped; a locally-edited one is LEFT in place, reported, and keeps its entry so
1913
+ * `sync --check` stays red and `--force` can still finish the job. `dryRun` is `--check`.
1914
+ */
1915
+ function pruneRetiredManagedFiles(
1916
+ cwd: string,
1917
+ manifest: Manifest,
1918
+ files: readonly ManagedFile[],
1919
+ opts: { dryRun?: boolean; force?: boolean } = {},
1920
+ ): PruneResult {
1921
+ const result: PruneResult = { removed: [], drifted: [] }
1922
+ for (const dest of retiredManagedDests(manifest, files)) {
1923
+ const abs = resolve(cwd, dest)
1924
+ const current = readIfExists(abs)
1925
+ const recorded = manifest.files[dest]
1926
+ const untouched =
1927
+ current === null ||
1928
+ sha256(current) === recorded ||
1929
+ sha256(normalizeForLedger(current)) === recorded
1930
+ if (!untouched && opts.force !== true) {
1931
+ result.drifted.push(dest)
1932
+ continue
1933
+ }
1934
+ result.removed.push(dest)
1935
+ if (opts.dryRun === true) continue
1936
+ delete manifest.files[dest]
1937
+ if (current !== null) unlinkSync(abs)
1938
+ removeEmptyParentDir(abs)
1939
+ }
1940
+ return result
1941
+ }
1942
+
1943
+ /**
1944
+ * Remove a now-empty `.claude/skills/basalt-x/` directory after its `SKILL.md` was pruned. Silent by
1945
+ * design — a leftover empty directory is cosmetic, and a consumer file sitting beside the skill is a
1946
+ * reason to keep it, not an error to report.
1947
+ */
1948
+ function removeEmptyParentDir(fileAbs: string): void {
1949
+ const dir = dirname(fileAbs)
1950
+ try {
1951
+ if (readdirSync(dir).length === 0) rmdirSync(dir)
1952
+ } catch {
1953
+ /* not empty, not readable, or already gone — nothing to clean up */
1954
+ }
1955
+ }
1956
+
1841
1957
  /** The frozen `> Managed by basalt-ui (1.9.0).` opener every pre-fix DESIGN.md seed carries. */
1842
1958
  const DESIGN_VERSION_LINE = /^(> Managed by basalt-ui) \(\d+\.\d+\.\d+\)\./m
1843
1959
 
@@ -2506,6 +2622,11 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
2506
2622
  writtenFiles.push(file.dest)
2507
2623
  }
2508
2624
 
2625
+ // A re-`init` over an existing install reconciles the derived namespaces too — otherwise the only
2626
+ // route out of a retired rule file is `sync`, and `init` is what a consumer reaches for after an
2627
+ // upgrade that changed the doctrine set.
2628
+ const pruned = pruneRetiredManagedFiles(cwd, manifest, files)
2629
+
2509
2630
  const mergeLint: MergeLintResult | null =
2510
2631
  scaffoldFlags.mergeLint === true ? mergeOxlintExtends(cwd, ctx.vars.OXLINT_PRESET_PATH) : null
2511
2632
 
@@ -2516,6 +2637,7 @@ export function init(cwd: string = process.cwd(), scaffoldFlags: ScaffoldFlags =
2516
2637
  console.log(
2517
2638
  `basalt-ui init: ${writtenFiles.length} written, ${keptFiles.length} kept, manifest at ${MANIFEST_PATH}`,
2518
2639
  )
2640
+ reportPrune(pruned)
2519
2641
  if (missingSources.length > 0) {
2520
2642
  console.log(
2521
2643
  `basalt-ui init: ${missingSources.length} shipped asset(s) not present, skipped: ${missingSources.join(', ')}`,
@@ -2825,6 +2947,16 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
2825
2947
  else created++
2826
2948
  }
2827
2949
 
2950
+ // Retired rules/skills: the derived namespaces (RULE_NAMES / SKILL_NAMES) are the only managed
2951
+ // sets whose MEMBERSHIP moves between versions, so this is where an upstream deletion reaches a
2952
+ // consumer. Counted into `staleForCheck` so `sync --check` is red until it is applied — a rule
2953
+ // file basalt stopped shipping is drift, and the agent goes on reading it otherwise.
2954
+ const pruned = pruneRetiredManagedFiles(cwd, manifest, files, {
2955
+ dryRun: opts.check === true,
2956
+ force: opts.force === true,
2957
+ })
2958
+ staleForCheck += pruned.removed.length + pruned.drifted.length
2959
+
2828
2960
  // Placement notices — informational, never affect the exit code (a skipped tooling seed or a
2829
2961
  // relocated scaffold is a legitimate consumer choice, not a sync failure).
2830
2962
  if (!placement.isPackageRepoRoot) {
@@ -2881,6 +3013,13 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
2881
3013
  console.error('basalt-ui sync --check: locally-drifted managed files:')
2882
3014
  for (const l of driftLines) console.error(l)
2883
3015
  }
3016
+ const retired = [...pruned.removed, ...pruned.drifted]
3017
+ if (retired.length > 0) {
3018
+ console.error(
3019
+ `basalt-ui sync --check: ${retired.length} retired rule/skill file(s) still present: ` +
3020
+ `${retired.join(', ')} — run \`basalt-ui sync\`.`,
3021
+ )
3022
+ }
2884
3023
  if (staleForCheck > 0) {
2885
3024
  console.error(`basalt-ui sync --check: ${staleForCheck} managed file(s) out of date.`)
2886
3025
  return 1
@@ -2895,8 +3034,9 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
2895
3034
 
2896
3035
  console.log(
2897
3036
  `basalt-ui sync: ${updated} updated, ${created} created, ${recreated} recreated, ` +
2898
- `${skippedDrift} skipped (drift).`,
3037
+ `${skippedDrift} skipped (drift), ${pruned.removed.length} retired.`,
2899
3038
  )
3039
+ reportPrune(pruned)
2900
3040
  if (driftLines.length > 0) {
2901
3041
  console.log('Locally-edited files were skipped (run with --force to overwrite):')
2902
3042
  for (const l of driftLines) console.log(l)
@@ -2910,14 +3050,160 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
2910
3050
  }
2911
3051
 
2912
3052
  // ──────────────────────────────────────────────────────────────────────────────
2913
- // check-coverage — 8-assertion coverage gate
3053
+ // coverage headers — the generated `<!-- basalt:coverage -->` block per rule file
3054
+ // ──────────────────────────────────────────────────────────────────────────────
3055
+
3056
+ /** The block's delimiters. Both are HTML comments, so the block renders as nothing in a viewer. */
3057
+ export const COVERAGE_BLOCK_OPEN = '<!-- basalt:coverage -->'
3058
+ export const COVERAGE_BLOCK_CLOSE = '<!-- /basalt:coverage -->'
3059
+
3060
+ const COVERAGE_BLOCK_RE = /<!-- basalt:coverage -->[\s\S]*?<!-- \/basalt:coverage -->/
3061
+
3062
+ /**
3063
+ * What actually enforces one rule file's doctrine, as the file itself should state it.
3064
+ *
3065
+ * Derived from SURFACES — the union over every doctrine surface carrying that `rule`, because
3066
+ * `rule` is many-to-one (`data` covers `./data`, `./data/table`, `./data/virtual`; `mantine` covers
3067
+ * `.` and `./connectivity`). Naming only one surface's coverage would understate the rule.
3068
+ */
3069
+ export function coverageFor(rule: RuleName): {
3070
+ guardKinds: string[]
3071
+ pluginRules: string[]
3072
+ advisoryLaws: string[]
3073
+ } {
3074
+ const specs = (Object.values(SURFACES) as SurfaceSpec[]).filter(
3075
+ (s): s is DoctrineSpec => s.kind === 'doctrine' && s.rule === rule,
3076
+ )
3077
+ const unique = (values: readonly string[]): string[] => [...new Set(values)].toSorted()
3078
+ return {
3079
+ guardKinds: unique(specs.flatMap((s) => [...s.guardKinds])),
3080
+ pluginRules: unique(specs.flatMap((s) => [...s.pluginRules])),
3081
+ // NOT sorted, and not deduped across surfaces by content: an advisory law is a sentence, and
3082
+ // its order is the order the spec argues it in.
3083
+ advisoryLaws: specs.flatMap((s) => [...(s.advisoryLaws ?? [])]),
3084
+ }
3085
+ }
3086
+
3087
+ /**
3088
+ * The generated block for one rule, verbatim — HTML comments only, one claim per line so a diff
3089
+ * points at the claim that changed rather than at a reflowed paragraph.
3090
+ *
3091
+ * `not guarded` is printed even when empty, and that is the point: a rule file whose block says
3092
+ * `not guarded: —` is claiming full coverage, which is a claim someone can check. A block that
3093
+ * simply omitted the section would read as "nothing to declare" whether or not anyone had looked
3094
+ * (D8 — see docs/CONTROLS-SPEC.md §6 "Honest coverage").
3095
+ */
3096
+ export function coverageBlock(rule: RuleName): string {
3097
+ const { guardKinds, pluginRules, advisoryLaws } = coverageFor(rule)
3098
+ const backedBy = [
3099
+ guardKinds.length > 0 ? `guard kinds — ${guardKinds.join(', ')}` : 'guard kinds — none',
3100
+ pluginRules.length > 0
3101
+ ? `oxlint rules — ${pluginRules.map((id) => `basalt/${id}`).join(', ')}`
3102
+ : 'oxlint rules — none',
3103
+ ].join(' · ')
3104
+ const lines = [
3105
+ COVERAGE_BLOCK_OPEN,
3106
+ '<!-- GENERATED from src/surfaces.ts — `basalt-ui check-coverage --write`. Do not hand-edit. -->',
3107
+ `<!-- backed by: ${backedBy} -->`,
3108
+ ]
3109
+ if (advisoryLaws.length === 0) lines.push('<!-- not guarded: — -->')
3110
+ else for (const law of advisoryLaws) lines.push(`<!-- not guarded: ${law} -->`)
3111
+ lines.push(COVERAGE_BLOCK_CLOSE)
3112
+ return lines.join('\n')
3113
+ }
3114
+
3115
+ /** The block a rule file currently carries, or null when it has none yet. */
3116
+ export function readCoverageBlock(text: string): string | null {
3117
+ return COVERAGE_BLOCK_RE.exec(text)?.[0] ?? null
3118
+ }
3119
+
3120
+ /**
3121
+ * `text` with `block` in it — replacing the existing block, or inserted directly below the YAML
3122
+ * frontmatter (the rule files' `paths:` header is load-bearing and must stay first).
3123
+ */
3124
+ export function applyCoverageBlock(text: string, block: string): string {
3125
+ if (COVERAGE_BLOCK_RE.test(text)) return text.replace(COVERAGE_BLOCK_RE, block)
3126
+ const frontmatter = /^---\n[\s\S]*?\n---\n/.exec(text)
3127
+ if (frontmatter === null) return `${block}\n\n${text}`
3128
+ const end = frontmatter[0].length
3129
+ return `${text.slice(0, end)}\n${block}\n${text.slice(end)}`
3130
+ }
3131
+
3132
+ /**
3133
+ * Reconcile every rule file's coverage block against SURFACES.
3134
+ *
3135
+ * `'write'` refreshes them; `'check'` is the CI gate. A rule file with NO block yet is REPORTED,
3136
+ * never failed — the blocks are inserted by the agent-layer wave that rewrites those files, and a
3137
+ * gate that failed before they exist would have to be landed disabled, which is how a gate stays
3138
+ * disabled. A block that exists and DISAGREES is a hard failure: that is drift, and drift in a
3139
+ * coverage claim is the defect the block was added for.
3140
+ */
3141
+ export function reconcileCoverageBlocks(
3142
+ pkgRoot: string,
3143
+ mode: 'write' | 'check',
3144
+ ): { failures: string[]; notes: string[] } {
3145
+ const failures: string[] = []
3146
+ const notes: string[] = []
3147
+ for (const rule of RULE_NAMES) {
3148
+ const rel = `agent/rules/basalt-${rule}.md`
3149
+ const path = resolve(pkgRoot, rel)
3150
+ if (!existsSync(path)) continue // assertion 2 already reports a missing rule file
3151
+ const text = readFileSync(path, 'utf8')
3152
+ const current = readCoverageBlock(text)
3153
+ const expected = coverageBlock(rule)
3154
+ if (mode === 'check') {
3155
+ if (current === null) notes.push(`${rel}: no <!-- basalt:coverage --> block yet`)
3156
+ else if (current !== expected) {
3157
+ failures.push(
3158
+ `${rel}: coverage block is out of sync with SURFACES (check-coverage --write)`,
3159
+ )
3160
+ }
3161
+ continue
3162
+ }
3163
+ if (current === expected) {
3164
+ notes.push(`${rel}: unchanged`)
3165
+ continue
3166
+ }
3167
+ writeFileSync(path, applyCoverageBlock(text, expected), 'utf8')
3168
+ notes.push(`${rel}: ${current === null ? 'block inserted' : 'block updated'}`)
3169
+ }
3170
+ return { failures, notes }
3171
+ }
3172
+
3173
+ // ──────────────────────────────────────────────────────────────────────────────
3174
+ // check-coverage — 10-assertion coverage gate
2914
3175
  // ──────────────────────────────────────────────────────────────────────────────
2915
3176
 
2916
3177
  /**
2917
- * Assert the 8 SURFACES invariants against the live SURFACES + GUARD_RULES + plugin.json.
2918
- * Returns 0 when all pass; 1 when any fail (console.error each failure).
3178
+ * Per-file line budgets for the shipped agent layer (`docs/CONTROLS-SPEC.md` §7's target table).
2919
3179
  *
2920
- * Eight assertions:
3180
+ * A budget, not a guideline: seven `src/**`-scoped rules loaded ~2,000 lines on any source edit
3181
+ * before the merge, and 55% of that text was doctrine nothing enforced. The numbers are the bar the
3182
+ * spec argued, so they live beside the gate that reads them rather than in prose.
3183
+ */
3184
+ const AGENT_LINE_BUDGETS: Readonly<Record<string, number>> = {
3185
+ 'agent/rules/basalt-tokens.md': 160,
3186
+ 'agent/rules/basalt-mantine.md': 180,
3187
+ 'agent/rules/basalt-charts.md': 140,
3188
+ 'agent/rules/basalt-state.md': 160,
3189
+ 'agent/rules/basalt-controls.md': 160,
3190
+ 'agent/rules/basalt-batteries.md': 220,
3191
+ 'agent/skills/basalt-app/SKILL.md': 100,
3192
+ 'agent/skills/basalt-design/SKILL.md': 100,
3193
+ 'agent/skills/basalt-charts/SKILL.md': 100,
3194
+ 'agent/templates/CLAUDE-block.md.tpl': 40,
3195
+ 'agent/templates/DESIGN.md.tpl': 45,
3196
+ }
3197
+
3198
+ /** The whole-layer bar — a per-file budget alone can be satisfied by adding a seventh rule file. */
3199
+ const AGENT_RULE_TOTAL_BUDGET = 1050
3200
+
3201
+ /**
3202
+ * Assert the 11 invariants against the live SURFACES + GUARD_RULES + the shipped agent layer, and
3203
+ * (with `--write` / `--check`) reconcile the generated `<!-- basalt:coverage -->` header of every
3204
+ * rule file against SURFACES. Returns 0 when all pass; 1 when any fail (console.error each failure).
3205
+ *
3206
+ * Eleven assertions:
2921
3207
  * 1. Every doctrine spec's guardKinds ⊆ keyof GUARD_RULES.
2922
3208
  * 2. Every doctrine rule (deduped) maps to agent/rules/basalt-{rule}.md on disk.
2923
3209
  * 3. Every doctrine skill (deduped) maps to agent/skills/{skill}/SKILL.md on disk.
@@ -2931,14 +3217,29 @@ export function sync(opts: SyncOptions = {}, invocationCwd: string = process.cwd
2931
3217
  * the assertion's own comment below for why); basalt's own CI does
2932
3218
  * (`tests/surfaces-coverage.test.ts`).
2933
3219
  * 8. Every doctrine optionalPeers entry exists in peerDependencies AND peerDependenciesMeta.
3220
+ * 9. Every registered oxlint plugin rule (PLUGIN_RULE_ID_LIST) maps to EXACTLY ONE surface's
3221
+ * `pluginRules` — so a new rule cannot ship homeless, and one rule cannot be counted twice as
3222
+ * coverage. Like assertion 7 this reads SURFACES only: whether the plugin really registers
3223
+ * those ids is asserted against the plugin itself in `configs/oxlint-plugin.test.ts`, which a
3224
+ * shipped CLI running from a consumer's node_modules cannot do.
3225
+ * 10. Every shipped agent-layer file is inside its line budget, and the six rules are inside the
3226
+ * whole-layer total (`AGENT_LINE_BUDGETS` / `AGENT_RULE_TOTAL_BUDGET`).
3227
+ * 11. Every `GUARD_RULES` kind appears on at least one doctrine surface — assertion 9's twin for
3228
+ * the text lane. Without it a kind could ship, be documented as a law in the spec, and be
3229
+ * omitted from every generated coverage header, which is how two wave-6 kinds left
3230
+ * `agent/rules/basalt-controls.md` printing "guard kinds — none" (D8). Unlike 9 this is
3231
+ * one-directional: a kind may legitimately appear on SEVERAL surfaces (`raw-hex` is on
3232
+ * `./charts` and `./tokens`), because a text scan is not partitioned by subpath the way a
3233
+ * plugin rule id is.
2934
3234
  *
2935
3235
  * Tooling surfaces are exempt from assertions 1–3 by the discriminant.
2936
3236
  * Synthetic #-keys participate in assertions 1 and 2 but feed assertion 3 only
2937
3237
  * via the deduped skill union (no independent per-#-surface skill row).
2938
3238
  */
2939
- export function checkCoverage(): number {
3239
+ export function checkCoverage(flags: readonly string[] = []): number {
2940
3240
  const pkgRoot = packageRoot()
2941
3241
  const failures: string[] = []
3242
+ const notes: string[] = []
2942
3243
 
2943
3244
  const allSpecs = Object.entries(SURFACES) as [string, SurfaceSpec][]
2944
3245
  const doctrineSpecs = (Object.values(SURFACES) as SurfaceSpec[]).filter(
@@ -3082,9 +3383,83 @@ export function checkCoverage(): number {
3082
3383
  }
3083
3384
  }
3084
3385
 
3386
+ // ── Assertion 9: every plugin rule maps to exactly one surface ───────────────
3387
+ // The plugin's own rules were the one half of the enforcement seam SURFACES did not model, so a
3388
+ // generated coverage header could name a guard kind and stay silent about the AST rule doing the
3389
+ // actual work (D8). Now every id has exactly one home, and "exactly" is both directions: an
3390
+ // unmapped id is a rule no doc claims, a doubly-mapped one is coverage counted twice.
3391
+ const surfacesPerPluginRule = new Map<string, string[]>()
3392
+ for (const [key, spec] of allSpecs) {
3393
+ if (spec.kind !== 'doctrine') continue
3394
+ for (const id of spec.pluginRules) {
3395
+ surfacesPerPluginRule.set(id, [...(surfacesPerPluginRule.get(id) ?? []), key])
3396
+ }
3397
+ }
3398
+ for (const id of PLUGIN_RULE_ID_LIST) {
3399
+ const owners = surfacesPerPluginRule.get(id) ?? []
3400
+ if (owners.length === 1) continue
3401
+ failures.push(
3402
+ owners.length === 0
3403
+ ? `Plugin rule 'basalt/${id}' maps to no surface — add it to one surface's pluginRules`
3404
+ : `Plugin rule 'basalt/${id}' maps to ${owners.length} surfaces (${owners.join(', ')}) — exactly one`,
3405
+ )
3406
+ }
3407
+
3408
+ // ── Assertion 10: the agent layer stays inside its line budgets ─────────────
3409
+ // The budgets ARE the doctrine (docs/CONTROLS-SPEC.md §7): thirteen rules reached 4,177 lines by
3410
+ // growing a few lines at a time, each addition locally reasonable, and the only thing that
3411
+ // notices is a number nobody is checking. A rule over budget is over budget in the commit that
3412
+ // did it, not four minors later.
3413
+ for (const [rel, budget] of Object.entries(AGENT_LINE_BUDGETS)) {
3414
+ const abs = resolve(pkgRoot, rel)
3415
+ if (!existsSync(abs)) continue // assertions 2/3 already report a missing rule or skill
3416
+ const lines = readFileSync(abs, 'utf8').split('\n').length - 1
3417
+ if (lines > budget) failures.push(`${rel}: ${lines} lines exceeds its ${budget}-line budget`)
3418
+ }
3419
+ const ruleTotal = RULE_NAMES.reduce((sum, rule) => {
3420
+ const abs = resolve(pkgRoot, `agent/rules/basalt-${rule}.md`)
3421
+ return existsSync(abs) ? sum + readFileSync(abs, 'utf8').split('\n').length - 1 : sum
3422
+ }, 0)
3423
+ if (ruleTotal > AGENT_RULE_TOTAL_BUDGET) {
3424
+ failures.push(
3425
+ `agent/rules/*.md: ${ruleTotal} lines total exceeds the ${AGENT_RULE_TOTAL_BUDGET}-line budget`,
3426
+ )
3427
+ }
3428
+
3429
+ // ── Assertion 11: every guard kind maps to at least one surface ──────────────
3430
+ // The guard-kind twin of assertion 9. Assertion 1 already checks the other direction (no surface
3431
+ // names a kind that does not exist); this one catches the kind nobody claims, whose coverage
3432
+ // header therefore under-reports the lane that actually enforces it.
3433
+ const surfacedGuardKinds = new Set<string>()
3434
+ for (const spec of doctrineSpecs) {
3435
+ for (const kind of spec.guardKinds) surfacedGuardKinds.add(kind)
3436
+ }
3437
+ for (const kind of Object.keys(GUARD_RULES)) {
3438
+ if (surfacedGuardKinds.has(kind)) continue
3439
+ failures.push(
3440
+ `Guard kind '${kind}' maps to no surface — add it to one surface's guardKinds so the ` +
3441
+ `generated coverage headers name it`,
3442
+ )
3443
+ }
3444
+
3445
+ // ── Coverage headers (--write / --check) ─────────────────────────────────────
3446
+ const write = flags.includes('--write')
3447
+ const check = flags.includes('--check')
3448
+ if (write && check) {
3449
+ failures.push('--write and --check are alternatives; pass one')
3450
+ } else if (write || check) {
3451
+ const result = reconcileCoverageBlocks(pkgRoot, write ? 'write' : 'check')
3452
+ failures.push(...result.failures)
3453
+ notes.push(...result.notes)
3454
+ }
3455
+
3085
3456
  // ── Report ──────────────────────────────────────────────────────────────────
3457
+ for (const note of notes) {
3458
+ console.log(` ${note}`)
3459
+ }
3460
+
3086
3461
  if (failures.length === 0) {
3087
- console.log('✓ check-coverage: all 8 assertions pass.')
3462
+ console.log('✓ check-coverage: all 11 assertions pass.')
3088
3463
  return 0
3089
3464
  }
3090
3465
 
@@ -4596,7 +4971,7 @@ const COMMAND_FLAGS: Record<string, readonly string[]> = {
4596
4971
  init: ['--with-router', '--with-query', '--merge-lint'],
4597
4972
  sync: ['--force', '--check', '--tokens-only', '--framework'],
4598
4973
  'check-theme': ['--audit-allows', '--tokens-only', '--framework'],
4599
- 'check-coverage': [],
4974
+ 'check-coverage': ['--write', '--check'],
4600
4975
  info: ['--json'],
4601
4976
  doctor: ['--tokens-only', '--framework'],
4602
4977
  'guard-hook': [],
@@ -4647,7 +5022,8 @@ function unknownFlag(cmd: string, flags: readonly string[]): string | null {
4647
5022
  const USAGE =
4648
5023
  'Usage: basalt-ui <--version | init [--with-router] [--with-query] [--merge-lint] |\n' +
4649
5024
  ' sync [--force] [--check] [--tokens-only|--framework] |\n' +
4650
- ' check-theme [--audit-allows] | check-coverage | info [--json] |\n' +
5025
+ ' check-theme [--audit-allows] | check-coverage [--write|--check] |\n' +
5026
+ ' info [--json] |\n' +
4651
5027
  ' doctor [--tokens-only|--framework] | guard-hook | tokens:css | fonts:css | help>\n\n' +
4652
5028
  'check-theme [--tokens-only|--framework] [--audit-allows]\n' +
4653
5029
  ' --audit-allows reports instead of scanning: every `theme-allow` annotation and every\n' +
@@ -4737,7 +5113,7 @@ export function run(argv: string[], cwd: string = process.cwd()): number | Promi
4737
5113
  case 'check-theme':
4738
5114
  return checkTheme(cwd, flags)
4739
5115
  case 'check-coverage':
4740
- return checkCoverage()
5116
+ return checkCoverage(flags)
4741
5117
  case 'info':
4742
5118
  return info(flags)
4743
5119
  case 'doctor':
@@ -4,8 +4,10 @@
4
4
  * - the legacy `oxfmt.json` → `.oxfmtrc.json` manifest/file migration
5
5
  * - the `basalt doctor` CLI-vs-installed version-mismatch warning
6
6
  * - the `basalt init` first-run hint to run `check-theme` and tune per-rule config
7
+ * - pruning managed rule/skill files a newer basalt-ui no longer ships
7
8
  */
8
9
  import { afterEach, beforeEach, describe, expect, it } from 'bun:test'
10
+ import { createHash } from 'node:crypto'
9
11
  import { existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'node:fs'
10
12
  import { tmpdir } from 'node:os'
11
13
  import { join, resolve } from 'node:path'
@@ -244,3 +246,98 @@ describe('seeds derive from basalt.roots', () => {
244
246
  expect(readFileSync(resolve(dir, 'DESIGN.md'), 'utf8')).toContain('custom/p.ts')
245
247
  })
246
248
  })
249
+
250
+ /**
251
+ * Retired managed files — the 13→6 rule merge (`docs/CONTROLS-SPEC.md` §7) is the case this exists
252
+ * for: nine `.claude/rules/basalt-*.md` stopped shipping in one minor, and a consumer that never
253
+ * loses them goes on reading superseded doctrine with every gate green.
254
+ */
255
+ describe('sync — retired rules and skills', () => {
256
+ const RETIRED_RULE = '.claude/rules/basalt-router.md'
257
+ const RETIRED_SKILL = '.claude/skills/basalt-legacy/SKILL.md'
258
+
259
+ /** Seed a real install, then plant a managed file this basalt version no longer ships. */
260
+ function plantRetired(dest: string, body: string, tracked = true): void {
261
+ writeFixture('package.json', JSON.stringify({ name: 'fixture' }))
262
+ capture(() => init(dir))
263
+ writeFixture(dest, body)
264
+ const manifestAbs = resolve(dir, MANIFEST_PATH)
265
+ const manifest = JSON.parse(readFileSync(manifestAbs, 'utf8')) as {
266
+ files: Record<string, string>
267
+ }
268
+ // The raw-hash ledger path — what a pre-normalization CLI recorded, and enough to prove
269
+ // "untouched since basalt wrote it" without reaching into the module's private normalizer.
270
+ if (tracked) manifest.files[dest] = createHash('sha256').update(body, 'utf8').digest('hex')
271
+ writeFixture(MANIFEST_PATH, `${JSON.stringify(manifest, null, 2)}\n`)
272
+ }
273
+
274
+ function manifestFiles(): Record<string, string> {
275
+ return (
276
+ JSON.parse(readFileSync(resolve(dir, MANIFEST_PATH), 'utf8')) as {
277
+ files: Record<string, string>
278
+ }
279
+ ).files
280
+ }
281
+
282
+ it('deletes an untouched retired rule file and drops its manifest entry', () => {
283
+ plantRetired(RETIRED_RULE, '# superseded by basalt-state.md\n')
284
+ const { code, log } = capture(() => sync({}, dir))
285
+ expect(code).toBe(0)
286
+ expect(existsSync(resolve(dir, RETIRED_RULE))).toBe(false)
287
+ expect(manifestFiles()[RETIRED_RULE]).toBeUndefined()
288
+ expect(log).toContain(RETIRED_RULE)
289
+ // The six live rules are untouched by the prune.
290
+ expect(existsSync(resolve(dir, '.claude/rules/basalt-state.md'))).toBe(true)
291
+ })
292
+
293
+ it('deletes a retired skill and removes the now-empty skill directory', () => {
294
+ plantRetired(RETIRED_SKILL, '---\nname: basalt-legacy\n---\n')
295
+ capture(() => sync({}, dir))
296
+ expect(existsSync(resolve(dir, RETIRED_SKILL))).toBe(false)
297
+ expect(existsSync(resolve(dir, '.claude/skills/basalt-legacy'))).toBe(false)
298
+ expect(existsSync(resolve(dir, '.claude/skills/basalt-app/SKILL.md'))).toBe(true)
299
+ })
300
+
301
+ it('leaves a locally-edited retired rule in place, keeps its entry, and says so', () => {
302
+ plantRetired(RETIRED_RULE, '# shipped body\n')
303
+ writeFixture(RETIRED_RULE, '# shipped body\n\nplus a local paragraph nobody else wrote\n')
304
+ const { code, log } = capture(() => sync({}, dir))
305
+ expect(code).toBe(0)
306
+ expect(existsSync(resolve(dir, RETIRED_RULE))).toBe(true)
307
+ expect(manifestFiles()[RETIRED_RULE]).toBeDefined()
308
+ expect(log).toContain('locally edited')
309
+ })
310
+
311
+ it('--force deletes a locally-edited retired rule', () => {
312
+ plantRetired(RETIRED_RULE, '# shipped body\n')
313
+ writeFixture(RETIRED_RULE, '# shipped body\n\nlocal edit\n')
314
+ capture(() => sync({ force: true }, dir))
315
+ expect(existsSync(resolve(dir, RETIRED_RULE))).toBe(false)
316
+ expect(manifestFiles()[RETIRED_RULE]).toBeUndefined()
317
+ })
318
+
319
+ it('--check reports a retired rule, exits 1, and writes nothing', () => {
320
+ plantRetired(RETIRED_RULE, '# superseded\n')
321
+ const { code, log } = capture(() => sync({ check: true }, dir))
322
+ expect(code).toBe(1)
323
+ expect(log).toContain('retired rule/skill file(s) still present')
324
+ expect(existsSync(resolve(dir, RETIRED_RULE))).toBe(true)
325
+ expect(manifestFiles()[RETIRED_RULE]).toBeDefined()
326
+ })
327
+
328
+ it('never prunes a tracked dest outside the rules/skills namespaces', () => {
329
+ // `src/routes/__root.tsx` is legitimately absent from `managedFiles()` whenever the router or
330
+ // query peer is missing — pruning on "this run did not place it" would delete a real scaffold.
331
+ plantRetired('src/routes/__root.tsx', 'export const Route = null\n')
332
+ capture(() => sync({}, dir))
333
+ expect(existsSync(resolve(dir, 'src/routes/__root.tsx'))).toBe(true)
334
+ expect(manifestFiles()['src/routes/__root.tsx']).toBeDefined()
335
+ })
336
+
337
+ it('init prunes a retired rule too', () => {
338
+ plantRetired(RETIRED_RULE, '# superseded\n')
339
+ const { log } = capture(() => init(dir))
340
+ expect(existsSync(resolve(dir, RETIRED_RULE))).toBe(false)
341
+ expect(log).toContain('no longer ship')
342
+ })
343
+ })