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
@@ -12,8 +12,15 @@ import { mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from 'nod
12
12
  import { tmpdir } from 'node:os'
13
13
  import { dirname, resolve } from 'node:path'
14
14
  // eslint-disable-next-line -- the plugin is plain JS; the ledger + id set are named exports beside it
15
- import basaltPlugin, { KNOWN_RULE_IDS, PLUGIN_RULE_GRACE } from './oxlint-plugin.js'
16
- import { GUARD_RULES } from '../src/guard/index.ts'
15
+ import basaltPlugin, {
16
+ CTL_THEME_TAGS,
17
+ KNOWN_RULE_IDS,
18
+ PLUGIN_RULE_ADVISORY,
19
+ PLUGIN_RULE_GRACE,
20
+ } from './oxlint-plugin.js'
21
+ import { CTL_THEME } from '../src/theme/ctl-theme'
22
+ import { GUARD_RULES, PLUGIN_RULE_IDS } from '../src/guard/index.ts'
23
+ import { PLUGIN_RULE_ID_LIST, SURFACES } from '../src/surfaces.ts'
17
24
 
18
25
  const PLUGIN_PATH = resolve(import.meta.dirname, 'oxlint-plugin.js')
19
26
  const OXLINT_BIN = resolve(import.meta.dirname, '..', '..', '..', 'node_modules', '.bin', 'oxlint')
@@ -35,6 +42,15 @@ beforeEach(() => {
35
42
  'basalt/chart-legend-literal': 'error',
36
43
  'basalt/shadow-basalt-export': 'error',
37
44
  'basalt/hand-rolled-shell': 'error',
45
+ 'basalt/raw-scroll-container': 'error',
46
+ 'basalt/hand-rolled-filter': 'error',
47
+ 'basalt/control-outside-home': 'error',
48
+ 'basalt/control-size-literal': 'error',
49
+ 'basalt/page-bar-budget': 'error',
50
+ 'basalt/in-body-page-title': 'error',
51
+ 'basalt/responsive-twin': 'error',
52
+ 'basalt/search-literal-link': 'error',
53
+ 'basalt/use-search-from-literal': 'error',
38
54
  'basalt/visx-boundary': 'error',
39
55
  'basalt/visx-tooltip': 'error',
40
56
  'basalt/token-layer-boundary': 'error',
@@ -1152,32 +1168,134 @@ export const C = () => <NavLink label="Settings" />\n`,
1152
1168
 
1153
1169
  // ── grace ledger ↔ shipped preset ────────────────────────────────────────────
1154
1170
 
1171
+ type GraceEntry = { since: string; promote: string; why: string }
1172
+
1173
+ /**
1174
+ * Tiny local semver compare — no dependency. Assumes plain `x.y.z`, no pre-release identifiers.
1175
+ * Compares each component numerically, not lexically, so `'1.9.0' < '1.10.0'` and
1176
+ * `'1.3.0' < '1.26.0'` — a string compare would get both backwards.
1177
+ */
1178
+ function compareSemver(a: string, b: string): number {
1179
+ const partsA = a.split('.').map(Number)
1180
+ const partsB = b.split('.').map(Number)
1181
+ for (let i = 0; i < 3; i++) {
1182
+ const diff = (partsA[i] ?? 0) - (partsB[i] ?? 0)
1183
+ if (diff !== 0) return diff > 0 ? 1 : -1
1184
+ }
1185
+ return 0
1186
+ }
1187
+
1188
+ /**
1189
+ * The C16 gate itself (docs/CONTROLS-SPEC.md §1), extracted so it can be exercised against a
1190
+ * synthetic ledger as well as the real `PLUGIN_RULE_GRACE` — an `it.each` over the real ledger ran
1191
+ * zero assertions while it was empty, which proved nothing about the gate actually firing. Throws
1192
+ * naming the first offending entry when either invariant breaks: `since` must precede `promote`
1193
+ * for every entry, and no entry's `promote` may be `<=` the given version.
1194
+ *
1195
+ * `version` is `package.json`'s, i.e. the version already published, so this half can only fail
1196
+ * AFTER the release that shipped a due entry — `release.yml` runs no tests and the
1197
+ * `chore: release … [skip ci]` commit skips CI. The pre-release half is
1198
+ * `scripts/check-grace.ts`, run by `scripts/release.sh` against the version the dry run computed.
1199
+ */
1200
+ function assertGraceLedger(ledger: Record<string, GraceEntry>, version: string): void {
1201
+ for (const [id, entry] of Object.entries(ledger)) {
1202
+ if (compareSemver(entry.since, entry.promote) >= 0) {
1203
+ throw new Error(
1204
+ `${id}: \`since\` (${entry.since}) must be before \`promote\` (${entry.promote}).`,
1205
+ )
1206
+ }
1207
+ if (compareSemver(version, entry.promote) >= 0) {
1208
+ throw new Error(
1209
+ `basalt/${id}: shipped preset is at ${version}, which has reached its promote version ` +
1210
+ `${entry.promote} — promote to error or extend \`promote\` with a reason.`,
1211
+ )
1212
+ }
1213
+ }
1214
+ }
1215
+
1216
+ describe('compareSemver', () => {
1217
+ it('orders multi-digit components numerically, not lexically', () => {
1218
+ expect(compareSemver('1.9.0', '1.10.0')).toBeLessThan(0)
1219
+ expect(compareSemver('1.10.0', '1.9.0')).toBeGreaterThan(0)
1220
+ expect(compareSemver('1.3.0', '1.26.0')).toBeLessThan(0)
1221
+ expect(compareSemver('1.26.0', '1.3.0')).toBeGreaterThan(0)
1222
+ })
1223
+
1224
+ it('treats equal versions as equal', () => {
1225
+ expect(compareSemver('1.25.0', '1.25.0')).toBe(0)
1226
+ })
1227
+ })
1228
+
1229
+ describe('assertGraceLedger', () => {
1230
+ it('does not throw on a well-formed entry not yet due', () => {
1231
+ const ledger = { 'fake-rule': { since: '1.0.0', promote: '2.0.0', why: 'synthetic' } }
1232
+ expect(() => assertGraceLedger(ledger, '1.25.0')).not.toThrow()
1233
+ })
1234
+
1235
+ it('throws once the given version reaches promote', () => {
1236
+ const ledger = { 'fake-rule': { since: '1.0.0', promote: '1.25.0', why: 'synthetic' } }
1237
+ expect(() => assertGraceLedger(ledger, '1.25.0')).toThrow(/fake-rule/)
1238
+ })
1239
+
1240
+ it('throws once the given version is past promote', () => {
1241
+ const ledger = { 'fake-rule': { since: '1.0.0', promote: '1.9.0', why: 'synthetic' } }
1242
+ expect(() => assertGraceLedger(ledger, '1.10.0')).toThrow(/fake-rule/)
1243
+ })
1244
+
1245
+ it('throws when since does not precede promote', () => {
1246
+ const ledger = { 'fake-rule': { since: '1.5.0', promote: '1.5.0', why: 'synthetic' } }
1247
+ expect(() => assertGraceLedger(ledger, '1.0.0')).toThrow(/since/)
1248
+ })
1249
+ })
1250
+
1155
1251
  describe('PLUGIN_RULE_GRACE', () => {
1156
1252
  const shipped = JSON.parse(readFileSync(resolve(import.meta.dirname, 'oxlint.json'), 'utf8')) as {
1157
1253
  rules: Record<string, unknown>
1158
1254
  }
1159
- const graceIds = Object.keys(PLUGIN_RULE_GRACE)
1255
+ const pkgVersion = (
1256
+ JSON.parse(readFileSync(resolve(import.meta.dirname, '..', 'package.json'), 'utf8')) as {
1257
+ version: string
1258
+ }
1259
+ ).version
1260
+ const graceEntries = Object.entries(PLUGIN_RULE_GRACE) as [string, GraceEntry][]
1261
+ const advisoryEntries = Object.entries(PLUGIN_RULE_ADVISORY) as [
1262
+ string,
1263
+ { since: string; why: string },
1264
+ ][]
1265
+ const graceIds = graceEntries.map(([id]) => id)
1266
+ const advisoryIds = advisoryEntries.map(([id]) => id)
1160
1267
  const shippedBasaltIds = Object.keys(shipped.rules).filter((id) => id.startsWith('basalt/'))
1161
1268
 
1162
1269
  // The mechanism whose ABSENCE let three rules sit at `warn` for up to twelve minors with nothing
1163
1270
  // tracking them. Deleting a ledger entry IS the promotion, and this test makes flipping the
1164
1271
  // shipped level part of the same commit.
1165
- it('every ledger entry is warn in the shipped preset', () => {
1166
- for (const id of graceIds) expect(shipped.rules[`basalt/${id}`]).toBe('warn')
1272
+ it('every grace and advisory entry is warn in the shipped preset', () => {
1273
+ for (const id of [...graceIds, ...advisoryIds])
1274
+ expect(shipped.rules[`basalt/${id}`]).toBe('warn')
1167
1275
  })
1168
1276
 
1169
- it('every shipped rule NOT in the ledger is error', () => {
1277
+ it('every shipped rule NOT in either ledger is error', () => {
1170
1278
  for (const id of shippedBasaltIds) {
1171
- if (graceIds.includes(id.slice('basalt/'.length))) continue
1279
+ const bareId = id.slice('basalt/'.length)
1280
+ if (graceIds.includes(bareId) || advisoryIds.includes(bareId)) continue
1172
1281
  expect([id, shipped.rules[id]]).toEqual([id, 'error'])
1173
1282
  }
1174
1283
  })
1175
1284
 
1176
- it('every ledger entry carries a written promotion note', () => {
1177
- for (const id of graceIds) {
1178
- expect((PLUGIN_RULE_GRACE as Record<string, string>)[id]?.length ?? 0).toBeGreaterThan(40)
1285
+ it('every entry carries a written why', () => {
1286
+ for (const [id, entry] of [...graceEntries, ...advisoryEntries]) {
1287
+ if (entry.why.length <= 40)
1288
+ throw new Error(`${id}: \`why\` must be longer than 40 characters.`)
1289
+ expect(entry.why.length).toBeGreaterThan(40)
1179
1290
  }
1180
1291
  })
1292
+
1293
+ // The C16 version gate, run against the REAL ledger and REAL package version — covers `since` <
1294
+ // `promote` and "no entry has reached its promote version" in one call. ADVISORY entries are
1295
+ // exempt by design (see `assertGraceLedger`'s synthetic coverage above for the gate itself).
1296
+ it('passes the C16 gate against the real ledger and package version', () => {
1297
+ expect(() => assertGraceLedger(Object.fromEntries(graceEntries), pkgVersion)).not.toThrow()
1298
+ })
1181
1299
  })
1182
1300
 
1183
1301
  // ── chart-legend-literal: the .map() half ────────────────────────────────────
@@ -1287,10 +1405,29 @@ describe('KNOWN_RULE_IDS', () => {
1287
1405
  }
1288
1406
  })
1289
1407
 
1408
+ // Deduped, because ONE id is deliberately in both registries: `in-body-page-title` is a plugin
1409
+ // rule AND a guard kind (law C8 has an AST half and a text half), so one `theme-allow
1410
+ // in-body-page-title — <why>` waives both lanes rather than needing a different word per lane.
1290
1411
  it('contains nothing else — it is exactly the union of the two registries', () => {
1291
- const expected = [...Object.keys(basaltPlugin.rules), ...Object.keys(GUARD_RULES)].toSorted()
1412
+ const expected = [
1413
+ ...new Set([...Object.keys(basaltPlugin.rules), ...Object.keys(GUARD_RULES)]),
1414
+ ].toSorted()
1292
1415
  expect([...KNOWN_RULE_IDS].toSorted()).toEqual(expected)
1293
1416
  })
1417
+
1418
+ it('shares exactly one id between the two registries — the C8 pair', () => {
1419
+ const shared = Object.keys(basaltPlugin.rules).filter((id) => Object.hasOwn(GUARD_RULES, id))
1420
+ expect(shared).toEqual(['in-body-page-title'])
1421
+ })
1422
+
1423
+ // The guard's own copy (`PLUGIN_RULE_IDS`) is what `check-theme --audit-allows` classifies an
1424
+ // annotation against: an id in neither registry reads as a typo and is reported unscoped. A new
1425
+ // plugin rule missing from it would make every waiver naming it look like a mistake.
1426
+ it('every plugin rule is classifiable by the guard — its own id set or a guard kind', () => {
1427
+ for (const id of Object.keys(basaltPlugin.rules)) {
1428
+ expect([id, PLUGIN_RULE_IDS.has(id) || Object.hasOwn(GUARD_RULES, id)]).toEqual([id, true])
1429
+ }
1430
+ })
1294
1431
  })
1295
1432
 
1296
1433
  // ── theme-allow fails closed on a typo ─────────────────────────────────────────────────────────
@@ -1335,3 +1472,1045 @@ describe('theme-allow with an unrecognized rule id', () => {
1335
1472
  expect(code).toBe(0)
1336
1473
  })
1337
1474
  })
1475
+
1476
+ // ── the control-home rules (docs/CONTROLS-SPEC.md §6) ────────────────────────
1477
+
1478
+ const MANTINE_IMPORT = `import { Button, ScrollArea, SegmentedControl, Select } from '@mantine/core'\n`
1479
+
1480
+ /**
1481
+ * The home components, imported from basalt — the OWNER-tag provenance every slot rule now requires.
1482
+ *
1483
+ * Not decoration: `Section` is one of the most common local component names there is, and matching
1484
+ * the bare tag made a consumer's own `<Section actions={…}>` an `error`-level tiered home. A fixture
1485
+ * that renders `<PageBar>` out of thin air proves nothing a real consumer file would hit, so the
1486
+ * fixtures import what they render.
1487
+ */
1488
+ const BASALT_IMPORT = `import { ChartCard, PageBar, Section, SettingsSection } from 'basalt-ui'\n`
1489
+
1490
+ describe('basalt/hand-rolled-filter', () => {
1491
+ it('flags a raw Mantine Select handed to a PageBar filters slot', () => {
1492
+ const { code, rules } = run(
1493
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar filters={<Select data={[]} />} />\n`,
1494
+ )
1495
+ expect(code).toBe(1)
1496
+ expect(rules).toContain('hand-rolled-filter')
1497
+ })
1498
+
1499
+ it('flags it through a hoisted binding — the argo headerExtra shape', () => {
1500
+ const { code, rules } = run(
1501
+ `${BASALT_IMPORT}${MANTINE_IMPORT}const pills = <Select data={[]} />\n` +
1502
+ `export const C = () => <PageBar filters={pills} />\n`,
1503
+ )
1504
+ expect(code).toBe(1)
1505
+ expect(rules).toContain('hand-rolled-filter')
1506
+ })
1507
+
1508
+ it('flags one inside an ARRAY slot value (filtersEnd={[…]})', () => {
1509
+ const { rules } = run(
1510
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar filtersEnd={[<Select key="a" data={[]} />]} />\n`,
1511
+ )
1512
+ expect(rules).toContain('hand-rolled-filter')
1513
+ })
1514
+
1515
+ // The body form is the whole reason the walk stops at the slot ATTRIBUTE: a Section's children
1516
+ // are a page region, not a control home, and a form there is C1's third home.
1517
+ // `control-outside-home` DOES report this one (a Section body is not a home either) — which is
1518
+ // the point of the split: the body form is a warn about placement, never an error about the slot.
1519
+ it("does NOT flag a Select in a Section's CHILDREN — the body form", () => {
1520
+ const { rules } = run(
1521
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <Section title="Filters">\n <Select data={[]} />\n </Section>\n)\n`,
1522
+ )
1523
+ expect(rules).not.toContain('hand-rolled-filter')
1524
+ })
1525
+
1526
+ it("does NOT flag a slot prop on a tag that is not a home (a consumer's own Toolbar)", () => {
1527
+ const { rules } = run(
1528
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <Toolbar filters={<Select data={[]} />} />\n`,
1529
+ )
1530
+ expect(rules).not.toContain('hand-rolled-filter')
1531
+ })
1532
+
1533
+ it('does NOT flag a same-named component that is not the Mantine binding', () => {
1534
+ const { code, rules } = run(
1535
+ `${BASALT_IMPORT}import { Select } from './my-select'\nexport const C = () => <PageBar filters={<Select data={[]} />} />\n`,
1536
+ )
1537
+ expect(code).toBe(0)
1538
+ expect(rules).not.toContain('hand-rolled-filter')
1539
+ })
1540
+
1541
+ it('does NOT flag a bound basalt control in the same slot', () => {
1542
+ const { code, rules } = run(
1543
+ `${BASALT_IMPORT}export const C = () => <PageBar filters={<RangeFilter field={f} />} />\n`,
1544
+ )
1545
+ expect(code).toBe(0)
1546
+ expect(rules).not.toContain('hand-rolled-filter')
1547
+ })
1548
+
1549
+ // The settled reading of law C1's THIRD home (docs/CONTROLS-SPEC.md §5/§6): a form row keeps
1550
+ // Mantine's `md` tier, so a raw input bound to a setting is right there — and `control` /
1551
+ // `SettingsRow` are absent from the slot set for exactly that reason.
1552
+ it("does NOT flag a raw Select in a SettingsRow's control — the form-row home", () => {
1553
+ const { code, rules } = run(
1554
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <SettingsSection title="S">\n <SettingsRow label="Channel" control={<Select data={[]} />} />\n </SettingsSection>\n)\n`,
1555
+ )
1556
+ expect(code).toBe(0)
1557
+ expect(rules).not.toContain('hand-rolled-filter')
1558
+ expect(rules).not.toContain('control-outside-home')
1559
+ })
1560
+
1561
+ it("still flags one in a SettingsSection's actions header slot — that IS tiered", () => {
1562
+ const { rules } = run(
1563
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <SettingsSection title="S" actions={<Select data={[]} />} />\n`,
1564
+ )
1565
+ expect(rules).toContain('hand-rolled-filter')
1566
+ })
1567
+
1568
+ it('flags a member tag whose ROOT binding is Mantine (Chip.Group)', () => {
1569
+ const { rules } = run(
1570
+ `${BASALT_IMPORT}import { Chip } from '@mantine/core'\nexport const C = () => <PageBar filters={<Chip.Group multiple />} />\n`,
1571
+ )
1572
+ expect(rules).toContain('hand-rolled-filter')
1573
+ })
1574
+
1575
+ it('honors a scoped theme-allow', () => {
1576
+ const { rules } = run(
1577
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <PageBar\n filters={\n // theme-allow hand-rolled-filter — not a store field\n <Select data={[]} />\n }\n />\n)\n`,
1578
+ )
1579
+ expect(rules).not.toContain('hand-rolled-filter')
1580
+ })
1581
+
1582
+ it('honors a theme-allow-file declaration', () => {
1583
+ const { rules } = run(
1584
+ `// theme-allow-file hand-rolled-filter — a legacy page, migrating next sprint\n` +
1585
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar filters={<Select data={[]} />} />\n`,
1586
+ )
1587
+ expect(rules).not.toContain('hand-rolled-filter')
1588
+ })
1589
+
1590
+ // Colocating a trigger with its overlay in the slot is how a "create" modal and a "custom range"
1591
+ // popover are written. The overlay's contents portal out of the slot and are already declared
1592
+ // non-homes by `control-outside-home`, so the same code was exempt outside a slot and an `error`
1593
+ // inside one.
1594
+ it('does NOT flag a Select inside a Modal colocated in an actions slot', () => {
1595
+ const { code, rules } = run(
1596
+ `${BASALT_IMPORT}import { Button, Modal, Select } from '@mantine/core'\n` +
1597
+ `export const C = () => (\n <WidgetHeader\n actions={\n <>\n <Button onClick={open}>New</Button>\n <Modal opened>\n <Select data={[]} />\n </Modal>\n </>\n }\n />\n)\n`,
1598
+ )
1599
+ expect(code).toBe(0)
1600
+ expect(rules).not.toContain('hand-rolled-filter')
1601
+ })
1602
+
1603
+ it('does NOT flag one inside a Popover.Dropdown colocated in a filters slot', () => {
1604
+ const { rules } = run(
1605
+ `${BASALT_IMPORT}import { Button, Popover, Select } from '@mantine/core'\n` +
1606
+ `export const C = () => (\n <Section\n title="T"\n filters={\n <Popover>\n <Popover.Target>\n <Button>Custom range</Button>\n </Popover.Target>\n <Popover.Dropdown>\n <Select data={[]} />\n </Popover.Dropdown>\n </Popover>\n }\n />\n)\n`,
1607
+ )
1608
+ expect(rules).not.toContain('hand-rolled-filter')
1609
+ })
1610
+
1611
+ it('STILL flags a bare sibling Select in the same slot as an exempt overlay', () => {
1612
+ const { rules } = run(
1613
+ `${BASALT_IMPORT}import { Modal, Select } from '@mantine/core'\n` +
1614
+ `export const C = () => (\n <PageBar\n filters={\n <>\n <Select data={[]} />\n <Modal opened>\n <Select data={[]} />\n </Modal>\n </>\n }\n />\n)\n`,
1615
+ )
1616
+ expect(rules).toContain('hand-rolled-filter')
1617
+ })
1618
+
1619
+ // `Select as MantineSelect` is the canonical way a consumer wraps a Mantine component — the exact
1620
+ // case the provenance comment worried about — and keying membership on the LOCAL name made it
1621
+ // invisible.
1622
+ it('flags an ALIASED Mantine import in a slot', () => {
1623
+ const { code, rules } = run(
1624
+ `${BASALT_IMPORT}import { Select as MantineSelect } from '@mantine/core'\n` +
1625
+ `export const C = () => <PageBar filters={<MantineSelect data={[]} />} />\n`,
1626
+ )
1627
+ expect(code).toBe(1)
1628
+ expect(rules).toContain('hand-rolled-filter')
1629
+ })
1630
+
1631
+ it('flags a NAMESPACE-imported Mantine control in a slot', () => {
1632
+ const { rules } = run(
1633
+ `${BASALT_IMPORT}import * as M from '@mantine/core'\n` +
1634
+ `export const C = () => <PageBar filters={<M.SegmentedControl data={[]} />} />\n`,
1635
+ )
1636
+ expect(rules).toContain('hand-rolled-filter')
1637
+ })
1638
+
1639
+ it('flags an aliased MEMBER tag (Chip as C → C.Group)', () => {
1640
+ const { rules } = run(
1641
+ `${BASALT_IMPORT}import { Chip as Ch } from '@mantine/core'\n` +
1642
+ `export const C = () => <PageBar filters={<Ch.Group multiple />} />\n`,
1643
+ )
1644
+ expect(rules).toContain('hand-rolled-filter')
1645
+ })
1646
+
1647
+ // `control-outside-home` DOES report this one — a local `Section` is not a home, so the control
1648
+ // has none. Only this rule's silence is asserted; the exit code belongs to that warn.
1649
+ it("does NOT flag a slot on a consumer's own Section — provenance is on the OWNER too", () => {
1650
+ const { rules } = run(
1651
+ `import { Section } from './layout/section'\n${MANTINE_IMPORT}` +
1652
+ `export const C = () => <Section actions={<Select data={[]} />} />\n`,
1653
+ )
1654
+ expect(rules).not.toContain('hand-rolled-filter')
1655
+ })
1656
+ })
1657
+
1658
+ describe('basalt/control-outside-home', () => {
1659
+ it('flags a raw Mantine Select in no home at all', () => {
1660
+ const { code, rules } = run(
1661
+ `${MANTINE_IMPORT}export const C = () => <div><Select data={[]} /></div>\n`,
1662
+ )
1663
+ expect(code).toBe(1)
1664
+ expect(rules).toContain('control-outside-home')
1665
+ })
1666
+
1667
+ it('does NOT flag one inside a home slot — that is hand-rolled-filter s business', () => {
1668
+ const { rules } = run(
1669
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar filters={<Select data={[]} />} />\n`,
1670
+ )
1671
+ expect(rules).not.toContain('control-outside-home')
1672
+ })
1673
+
1674
+ it.each(['SettingsRow', 'Modal', 'Drawer', 'Composer'])(
1675
+ 'does NOT flag one under a declared non-home (%s)',
1676
+ (host) => {
1677
+ const { rules } = run(
1678
+ `${MANTINE_IMPORT}export const C = () => <${host}><Select data={[]} /></${host}>\n`,
1679
+ )
1680
+ expect(rules).not.toContain('control-outside-home')
1681
+ },
1682
+ )
1683
+
1684
+ it('does NOT flag one under a member-expression non-home (Menu.Dropdown)', () => {
1685
+ const { rules } = run(
1686
+ `${MANTINE_IMPORT}export const C = () => <Menu.Dropdown><Select data={[]} /></Menu.Dropdown>\n`,
1687
+ )
1688
+ expect(rules).not.toContain('control-outside-home')
1689
+ })
1690
+
1691
+ it('does NOT flag anything in a file that imports @mantine/form — a form is the third home', () => {
1692
+ const { code, rules } = run(
1693
+ `${MANTINE_IMPORT}import { useForm } from '@mantine/form'\n` +
1694
+ `export const C = () => <div><Select data={[]} /></div>\n`,
1695
+ )
1696
+ expect(code).toBe(0)
1697
+ expect(rules).not.toContain('control-outside-home')
1698
+ })
1699
+
1700
+ // The owner exemption, `hand-rolled-plot`'s shape: the module that IS the bound control cannot be
1701
+ // told to use one.
1702
+ // `shadow-basalt-export` reports the same fixture (a local `ViewTabs` collides with the shipped
1703
+ // one), so only this rule's own silence is asserted — the exit code belongs to that rule.
1704
+ it('does NOT flag the module that DEFINES a basalt control', () => {
1705
+ const { rules } = run(
1706
+ `${MANTINE_IMPORT}export function ViewTabs() {\n return <SegmentedControl data={[]} />\n}\n`,
1707
+ )
1708
+ expect(rules).not.toContain('control-outside-home')
1709
+ })
1710
+
1711
+ it('flags an ALIASED Mantine import with no home', () => {
1712
+ const { code, rules } = run(
1713
+ `import { Select as MantineSelect } from '@mantine/core'\n` +
1714
+ `export const C = () => <div><MantineSelect data={[]} /></div>\n`,
1715
+ )
1716
+ expect(code).toBe(1)
1717
+ expect(rules).toContain('control-outside-home')
1718
+ })
1719
+
1720
+ it('flags a NAMESPACE-imported Mantine control with no home', () => {
1721
+ const { rules } = run(
1722
+ `import * as M from '@mantine/core'\n` +
1723
+ `export const C = () => <div><M.SegmentedControl data={[]} /></div>\n`,
1724
+ )
1725
+ expect(rules).toContain('control-outside-home')
1726
+ })
1727
+
1728
+ it('honors a scoped theme-allow', () => {
1729
+ const { rules } = run(
1730
+ `${MANTINE_IMPORT}export const C = () => (\n <div>\n {/* theme-allow control-outside-home — a one-off admin picker */}\n <Select data={[]} />\n </div>\n)\n`,
1731
+ )
1732
+ expect(rules).not.toContain('control-outside-home')
1733
+ })
1734
+
1735
+ /**
1736
+ * The CROSS-FILE case, exempted by a naming convention rather than by ancestry — argo carried 9 of
1737
+ * these, each a control in a modal/form module whose `<Modal>` is rendered by the parent route.
1738
+ * Nothing in the flagged file can see the host, so the ancestry walk never could have.
1739
+ */
1740
+ describe('the overlay filename convention', () => {
1741
+ const SOURCE = `${MANTINE_IMPORT}export const C = () => <div><Select data={[]} /></div>\n`
1742
+
1743
+ it.each([
1744
+ 'src/edit-session-modal.tsx',
1745
+ 'src/filters-drawer.tsx',
1746
+ 'src/column-popover.tsx',
1747
+ 'src/detail-panel.tsx',
1748
+ 'src/booking-form.tsx',
1749
+ ])('does NOT flag %s — its host lives in the parent', (filename) => {
1750
+ const { code, rules } = run(SOURCE, filename)
1751
+ expect(code).toBe(0)
1752
+ expect(rules).not.toContain('control-outside-home')
1753
+ })
1754
+
1755
+ it('still flags a plain page module beside them', () => {
1756
+ const { rules } = run(SOURCE, 'src/bookings-page.tsx')
1757
+ expect(rules).toContain('control-outside-home')
1758
+ })
1759
+
1760
+ // Basename only: a directory of modals holds the page pieces around them too, so a whole
1761
+ // `modal/` tree going silent is the version of this exemption that WOULD switch the rule off.
1762
+ it('is a BASENAME convention, not a directory one', () => {
1763
+ const { rules } = run(SOURCE, 'src/modal/session.tsx')
1764
+ expect(rules).toContain('control-outside-home')
1765
+ })
1766
+
1767
+ // `modal.tsx` with no leading segment is a page module in every consumer that has one; the
1768
+ // convention is `<subject>-modal.tsx`, and the regex says so.
1769
+ it('needs the leading subject — a bare modal.tsx is not the convention', () => {
1770
+ const { rules } = run(SOURCE, 'src/modal.tsx')
1771
+ expect(rules).toContain('control-outside-home')
1772
+ })
1773
+ })
1774
+ })
1775
+
1776
+ describe('basalt/control-size-literal', () => {
1777
+ it.each(['size="xs"', 'w={200}', 'fullWidth', 'visibleFrom="sm"', 'hiddenFrom="sm"'])(
1778
+ 'flags %s on an element inside a home slot',
1779
+ (prop) => {
1780
+ const { code, rules } = run(
1781
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar actions={<Button ${prop}>Go</Button>} />\n`,
1782
+ )
1783
+ expect(code).toBe(1)
1784
+ expect(rules).toContain('control-size-literal')
1785
+ },
1786
+ )
1787
+
1788
+ it('flags it through a hoisted slot binding too', () => {
1789
+ const { rules } = run(
1790
+ `${BASALT_IMPORT}${MANTINE_IMPORT}const acts = <Button size="xs">Go</Button>\n` +
1791
+ `export const C = () => <PageBar actions={acts} />\n`,
1792
+ )
1793
+ expect(rules).toContain('control-size-literal')
1794
+ })
1795
+
1796
+ it("does NOT flag a size prop in a SettingsRow's control — the form tier is Mantine's md", () => {
1797
+ const { code, rules } = run(
1798
+ `${MANTINE_IMPORT}export const C = () => (\n <SettingsRow label="L" control={<Button size="compact-sm">Go</Button>} />\n)\n`,
1799
+ )
1800
+ expect(code).toBe(0)
1801
+ expect(rules).not.toContain('control-size-literal')
1802
+ })
1803
+
1804
+ /**
1805
+ * `ChartCard` is the one slot owner whose slot cannot mount the tier theme — it lives inside the
1806
+ * Mantine-free chart layer, so it writes `data-basalt-tier` by hand and the control there really
1807
+ * does have to state its own size. The rule fires on the `size` ATTRIBUTE and cannot tell a
1808
+ * correct `ctl` from the `xs` it exists to catch, so this is an exemption, not a waiver.
1809
+ */
1810
+ describe('ChartCard.actions — the one slot with no tier theme', () => {
1811
+ it('does NOT flag a size prop there', () => {
1812
+ const { code, rules } = run(
1813
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <ChartCard title="T" actions={<Button size="ctl">Go</Button>} />
1814
+ `,
1815
+ )
1816
+ expect(code).toBe(0)
1817
+ expect(rules).not.toContain('control-size-literal')
1818
+ })
1819
+
1820
+ // The owner test reads the IMPORTED name, so the alias every wrapper writes still resolves.
1821
+ it('does NOT flag it under an aliased ChartCard import', () => {
1822
+ const { rules } = run(
1823
+ `import { ChartCard as Card } from 'basalt-ui'
1824
+ ${MANTINE_IMPORT}` +
1825
+ `export const C = () => <Card title="T" actions={<Button size="xs">Go</Button>} />
1826
+ `,
1827
+ )
1828
+ expect(rules).not.toContain('control-size-literal')
1829
+ })
1830
+
1831
+ // The positive control: every OTHER home does mount the theme, so the rule still fires there.
1832
+ it('STILL flags the same prop in a Section slot', () => {
1833
+ const { code, rules } = run(
1834
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <Section title="T" actions={<Button size="xs">Go</Button>} />
1835
+ `,
1836
+ )
1837
+ expect(code).toBe(1)
1838
+ expect(rules).toContain('control-size-literal')
1839
+ })
1840
+
1841
+ // The exemption is keyed on the OWNER, and one hoisted binding can be handed to several — so it
1842
+ // holds only when EVERY home it reached is tierless. A last-writer-wins owner map made the
1843
+ // verdict depend on which attribute came later in the file: same code, opposite answer.
1844
+ it('STILL flags a hoisted binding shared with a Section slot — ChartCard last', () => {
1845
+ const { rules } = run(
1846
+ `${BASALT_IMPORT}${MANTINE_IMPORT}const acts = <Button size="xs">Go</Button>\n` +
1847
+ `export const C = () => (\n <>\n <Section title="S" actions={acts} />\n <ChartCard title="T" actions={acts} />\n </>\n)\n`,
1848
+ )
1849
+ expect(rules).toContain('control-size-literal')
1850
+ })
1851
+
1852
+ it('STILL flags a hoisted binding shared with a Section slot — ChartCard first', () => {
1853
+ const { rules } = run(
1854
+ `${BASALT_IMPORT}${MANTINE_IMPORT}const acts = <Button size="xs">Go</Button>\n` +
1855
+ `export const C = () => (\n <>\n <ChartCard title="T" actions={acts} />\n <Section title="S" actions={acts} />\n </>\n)\n`,
1856
+ )
1857
+ expect(rules).toContain('control-size-literal')
1858
+ })
1859
+
1860
+ it('does NOT flag a hoisted binding handed only to ChartCard', () => {
1861
+ const { code, rules } = run(
1862
+ `${BASALT_IMPORT}${MANTINE_IMPORT}const acts = <Button size="ctl">Go</Button>\n` +
1863
+ `export const C = () => <ChartCard title="T" actions={acts} />\n`,
1864
+ )
1865
+ expect(code).toBe(0)
1866
+ expect(rules).not.toContain('control-size-literal')
1867
+ })
1868
+
1869
+ // A consumer's OWN `ChartCard` is not basalt's, so it is not a home at all and nothing fires —
1870
+ // the same provenance rule every other home tag follows.
1871
+ it('hand-rolled-filter is NOT exempted there — the tier is not what makes a filter wrong', () => {
1872
+ const { rules } = run(
1873
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <ChartCard title="T" actions={<Select data={[]} />} />
1874
+ `,
1875
+ )
1876
+ expect(rules).toContain('hand-rolled-filter')
1877
+ })
1878
+ })
1879
+
1880
+ it('does NOT flag a size prop on the HOME itself', () => {
1881
+ const { code, rules } = run(
1882
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar w={200} actions={<Button>Go</Button>} />\n`,
1883
+ )
1884
+ expect(code).toBe(0)
1885
+ expect(rules).not.toContain('control-size-literal')
1886
+ })
1887
+
1888
+ it("does NOT flag a size prop in a Section's CHILDREN", () => {
1889
+ const { code, rules } = run(
1890
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <Section title="T">\n <Button size="xs">Go</Button>\n </Section>\n)\n`,
1891
+ )
1892
+ expect(code).toBe(0)
1893
+ expect(rules).not.toContain('control-size-literal')
1894
+ })
1895
+
1896
+ // The rule only reaches what the slot's own MantineThemeProvider re-tiers. An icon's `size` IS
1897
+ // its only sizing API and the slot theme cannot touch an SVG, so "drop the prop" produced a 24px
1898
+ // icon — on the shape the spec's own reference example writes.
1899
+ it("does NOT flag an icon's size inside a slot — the slot theme cannot size an SVG", () => {
1900
+ const { code, rules } = run(
1901
+ `${BASALT_IMPORT}${MANTINE_IMPORT}import { IconDownload } from '@tabler/icons-react'\n` +
1902
+ `export const C = () => (\n <PageBar actions={<Button leftSection={<IconDownload size={14} />}>Export</Button>} />\n)\n`,
1903
+ )
1904
+ expect(code).toBe(0)
1905
+ expect(rules).not.toContain('control-size-literal')
1906
+ })
1907
+
1908
+ it('does NOT flag a Badge or Loader size in a Section actions slot', () => {
1909
+ const { code, rules } = run(
1910
+ `${BASALT_IMPORT}import { Badge, Loader } from '@mantine/core'\n` +
1911
+ `export const C = ({ n, busy }) => (\n <Section title="T" actions={[<Badge key="c" size="sm">{n}</Badge>, busy ? <Loader key="l" size={14} /> : null]} />\n)\n`,
1912
+ )
1913
+ expect(code).toBe(0)
1914
+ expect(rules).not.toContain('control-size-literal')
1915
+ })
1916
+
1917
+ it("does NOT flag a Modal's own size, or an input's size inside that Modal", () => {
1918
+ const { code, rules } = run(
1919
+ `${BASALT_IMPORT}import { Button, Modal, TextInput } from '@mantine/core'\n` +
1920
+ `export const C = () => (\n <WidgetHeader\n actions={\n <>\n <Button>New</Button>\n <Modal opened size="lg">\n <TextInput size="md" />\n </Modal>\n </>\n }\n />\n)\n`,
1921
+ )
1922
+ expect(code).toBe(0)
1923
+ expect(rules).not.toContain('control-size-literal')
1924
+ })
1925
+
1926
+ it('STILL flags a raw filter size in the slot beside those exemptions', () => {
1927
+ const { rules } = run(
1928
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <PageBar filters={<Select size="xs" data={[]} />} />\n)\n`,
1929
+ )
1930
+ expect(rules).toContain('control-size-literal')
1931
+ })
1932
+
1933
+ it('flags an ALIASED Mantine Button size in a slot', () => {
1934
+ const { rules } = run(
1935
+ `${BASALT_IMPORT}import { Button as Btn } from '@mantine/core'\n` +
1936
+ `export const C = () => <PageBar actions={<Btn size="xs">Go</Btn>} />\n`,
1937
+ )
1938
+ expect(rules).toContain('control-size-literal')
1939
+ })
1940
+
1941
+ it("does NOT flag a size prop in a slot on a consumer's own Section", () => {
1942
+ const { code, rules } = run(
1943
+ `import { Section } from './layout/section'\n${MANTINE_IMPORT}` +
1944
+ `export const C = () => <Section actions={<Button size="sm">Go</Button>} />\n`,
1945
+ )
1946
+ expect(code).toBe(0)
1947
+ expect(rules).not.toContain('control-size-literal')
1948
+ })
1949
+
1950
+ it('honors a scoped theme-allow', () => {
1951
+ const { rules } = run(
1952
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <PageBar\n actions={\n <Button\n // theme-allow control-size-literal — a deliberate 24px tag\n size="xs"\n >\n Go\n </Button>\n }\n />\n)\n`,
1953
+ )
1954
+ expect(rules).not.toContain('control-size-literal')
1955
+ })
1956
+ })
1957
+
1958
+ // `control-size-literal`'s scope is a copy of the theme's own re-tiering list, because a plain-JS
1959
+ // plugin shipped to consumers cannot import Mantine-coupled TS. This is the pin that makes the copy
1960
+ // safe: a component added to (or dropped from) CTL_THEME without the rule following is a rule
1961
+ // telling someone to drop a prop nothing else sets — or silently ignoring one that matters.
1962
+ describe('CTL_THEME_TAGS ↔ CTL_THEME', () => {
1963
+ it('is exactly the component map the slot theme re-tiers', () => {
1964
+ expect([...(CTL_THEME_TAGS as Set<string>)].toSorted()).toEqual(
1965
+ Object.keys(CTL_THEME.components ?? {}).toSorted(),
1966
+ )
1967
+ })
1968
+ })
1969
+
1970
+ describe('basalt/page-bar-budget', () => {
1971
+ it('flags two PageBars in the SAME returned tree — the pair that mounts together', () => {
1972
+ const { code, rules } = run(
1973
+ `${BASALT_IMPORT}export const C = () => (\n <>\n <PageBar title="A" />\n <PageBar title="B" />\n </>\n)\n`,
1974
+ )
1975
+ expect(code).toBe(1)
1976
+ expect(rules).toContain('page-bar-budget')
1977
+ })
1978
+
1979
+ // The two false positives the per-FILE count produced. Neither renders two bars at once, so
1980
+ // nothing races for the portal node — and the only escape was a theme-allow on correct code.
1981
+ it('does NOT flag an early-return loading state and the loaded bar', () => {
1982
+ const { code, rules } = run(
1983
+ `${BASALT_IMPORT}export const C = ({ data }) => {\n if (!data) return <PageBar title="Jobs" />\n return <PageBar title="Jobs" actions={{ primary: { key: 'r', label: 'Run' } }} />\n}\n`,
1984
+ )
1985
+ expect(code).toBe(0)
1986
+ expect(rules).not.toContain('page-bar-budget')
1987
+ })
1988
+
1989
+ it('does NOT flag two page components exported from one file', () => {
1990
+ const { code, rules } = run(
1991
+ `${BASALT_IMPORT}export const ListPage = () => <PageBar title="Jobs" />\nexport const DetailPage = () => <PageBar title="Job" />\n`,
1992
+ )
1993
+ expect(code).toBe(0)
1994
+ expect(rules).not.toContain('page-bar-budget')
1995
+ })
1996
+
1997
+ it("does NOT flag a consumer's own PageBar-named component", () => {
1998
+ const { code, rules } = run(
1999
+ `import { PageBar } from './chrome/page-bar'\nexport const C = () => (\n <>\n <PageBar title="A" />\n <PageBar title="B" />\n </>\n)\n`,
2000
+ )
2001
+ expect(code).toBe(0)
2002
+ expect(rules).not.toContain('page-bar-budget')
2003
+ })
2004
+
2005
+ it('flags a 5-entry actions.secondary array', () => {
2006
+ const five = [1, 2, 3, 4, 5].map((n) => `{ key: '${n}', label: '${n}' }`).join(', ')
2007
+ const { code, rules } = run(
2008
+ `${BASALT_IMPORT}export const C = () => <PageBar actions={{ secondary: [${five}] }} />\n`,
2009
+ )
2010
+ expect(code).toBe(1)
2011
+ expect(rules).toContain('page-bar-budget')
2012
+ })
2013
+
2014
+ it('does NOT flag a 4-entry actions.secondary array', () => {
2015
+ const four = [1, 2, 3, 4].map((n) => `{ key: '${n}', label: '${n}' }`).join(', ')
2016
+ const { code, rules } = run(
2017
+ `${BASALT_IMPORT}export const C = () => <PageBar actions={{ secondary: [${four}] }} />\n`,
2018
+ )
2019
+ expect(code).toBe(0)
2020
+ expect(rules).not.toContain('page-bar-budget')
2021
+ })
2022
+
2023
+ it('flags a 4-entry Section actions array (budget 3)', () => {
2024
+ const four = [1, 2, 3, 4].map((n) => `{ key: '${n}', label: '${n}' }`).join(', ')
2025
+ const { rules } = run(
2026
+ `${BASALT_IMPORT}export const C = () => <Section actions={[${four}]} />\n`,
2027
+ )
2028
+ expect(rules).toContain('page-bar-budget')
2029
+ })
2030
+
2031
+ it('flags a second variant="filled" inside one slot', () => {
2032
+ const { rules } = run(
2033
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <PageBar actions={<><Button variant="filled">A</Button><Button variant="filled">B</Button></>} />\n)\n`,
2034
+ )
2035
+ expect(rules).toContain('page-bar-budget')
2036
+ })
2037
+
2038
+ it('does NOT flag one filled action beside a default one', () => {
2039
+ const { code, rules } = run(
2040
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <PageBar actions={<><Button variant="filled">A</Button><Button variant="default">B</Button></>} />\n)\n`,
2041
+ )
2042
+ expect(code).toBe(0)
2043
+ expect(rules).not.toContain('page-bar-budget')
2044
+ })
2045
+
2046
+ // The `filled` count is a PRIMARY-ACTION count: a filled Badge is Mantine's default Badge
2047
+ // variant and the ordinary way a header states a count (law C11 asks for one), and an overlay's
2048
+ // submit button is not in the bar at all.
2049
+ it('does NOT flag a filled Badge beside the one filled Button', () => {
2050
+ const { code, rules } = run(
2051
+ `${BASALT_IMPORT}import { Badge, Button } from '@mantine/core'\n` +
2052
+ `export const C = () => (\n <Section title="T" actions={<><Button variant="filled">Run</Button><Badge variant="filled">3</Badge></>} />\n)\n`,
2053
+ )
2054
+ expect(code).toBe(0)
2055
+ expect(rules).not.toContain('page-bar-budget')
2056
+ })
2057
+
2058
+ it('does NOT flag a filled ThemeIcon beside the one filled Button', () => {
2059
+ const { rules } = run(
2060
+ `${BASALT_IMPORT}import { Button, ThemeIcon } from '@mantine/core'\n` +
2061
+ `export const C = () => (\n <Section title="T" actions={<><Button variant="filled">Run</Button><ThemeIcon variant="filled" /></>} />\n)\n`,
2062
+ )
2063
+ expect(rules).not.toContain('page-bar-budget')
2064
+ })
2065
+
2066
+ it("does NOT flag a Modal's own filled submit button colocated in the slot", () => {
2067
+ const { rules } = run(
2068
+ `${BASALT_IMPORT}import { Button, Modal } from '@mantine/core'\n` +
2069
+ `export const C = () => (\n <PageBar actions={<><Button variant="filled">New</Button><Modal opened><Button variant="filled">Save</Button></Modal></>} />\n)\n`,
2070
+ )
2071
+ expect(rules).not.toContain('page-bar-budget')
2072
+ })
2073
+
2074
+ it('honors a scoped theme-allow', () => {
2075
+ const { rules } = run(
2076
+ `${BASALT_IMPORT}export const C = () => (\n <>\n <PageBar title="A" />\n {/* theme-allow page-bar-budget — the print-only variant */}\n <PageBar title="B" />\n </>\n)\n`,
2077
+ )
2078
+ expect(rules).not.toContain('page-bar-budget')
2079
+ })
2080
+ })
2081
+
2082
+ describe('basalt/in-body-page-title', () => {
2083
+ it.each([1, 2])('flags <Title order={%i}> in the body', (order) => {
2084
+ const { code, rules } = run(`export const C = () => <Title order={${order}}>Page</Title>\n`)
2085
+ expect(code).toBe(1)
2086
+ expect(rules).toContain('in-body-page-title')
2087
+ })
2088
+
2089
+ it('does NOT flag order={3} — a section heading', () => {
2090
+ const { code, rules } = run(`export const C = () => <Title order={3}>Section</Title>\n`)
2091
+ expect(code).toBe(0)
2092
+ expect(rules).not.toContain('in-body-page-title')
2093
+ })
2094
+
2095
+ it.each(['Prose', 'ArticleLayout', 'Modal', 'Drawer'])(
2096
+ 'does NOT flag one under %s — that is a document heading',
2097
+ (host) => {
2098
+ const { rules } = run(
2099
+ `export const C = () => <${host}><Title order={1}>Doc</Title></${host}>\n`,
2100
+ )
2101
+ expect(rules).not.toContain('in-body-page-title')
2102
+ },
2103
+ )
2104
+
2105
+ it('does NOT flag one under a content/ path segment', () => {
2106
+ const { code, rules } = run(
2107
+ `export const C = () => <Title order={1}>Doc</Title>\n`,
2108
+ 'content/page.tsx',
2109
+ )
2110
+ expect(code).toBe(0)
2111
+ expect(rules).not.toContain('in-body-page-title')
2112
+ })
2113
+
2114
+ it('honors a scoped theme-allow', () => {
2115
+ const { rules } = run(
2116
+ `// theme-allow in-body-page-title — a shell-less print view\nexport const C = () => <Title order={1}>Page</Title>\n`,
2117
+ )
2118
+ expect(rules).not.toContain('in-body-page-title')
2119
+ })
2120
+ })
2121
+
2122
+ describe('basalt/responsive-twin', () => {
2123
+ it('flags the same control mounted under visibleFrom and hiddenFrom', () => {
2124
+ const { code, rules } = run(
2125
+ `export const C = () => (\n <div>\n <SegmentedControl visibleFrom="sm" data={[]} />\n <SegmentedControl hiddenFrom="sm" data={[]} />\n </div>\n)\n`,
2126
+ )
2127
+ expect(code).toBe(1)
2128
+ expect(rules).toContain('responsive-twin')
2129
+ })
2130
+
2131
+ it('flags it when the control sits inside each half rather than being it', () => {
2132
+ const { rules } = run(
2133
+ `export const C = () => (\n <div>\n <Group visibleFrom="sm"><ViewTabs field={f} /></Group>\n <Stack hiddenFrom="sm"><ViewTabs field={f} /></Stack>\n </div>\n)\n`,
2134
+ )
2135
+ expect(rules).toContain('responsive-twin')
2136
+ })
2137
+
2138
+ it('does NOT flag two halves on DIFFERENT breakpoints', () => {
2139
+ const { code, rules } = run(
2140
+ `export const C = () => (\n <div>\n <SegmentedControl visibleFrom="sm" data={[]} />\n <SegmentedControl hiddenFrom="md" data={[]} />\n </div>\n)\n`,
2141
+ )
2142
+ expect(code).toBe(0)
2143
+ expect(rules).not.toContain('responsive-twin')
2144
+ })
2145
+
2146
+ it('does NOT flag a pair holding DIFFERENT controls', () => {
2147
+ const { rules } = run(
2148
+ `export const C = () => (\n <div>\n <SegmentedControl visibleFrom="sm" data={[]} />\n <Select hiddenFrom="sm" data={[]} />\n </div>\n)\n`,
2149
+ )
2150
+ expect(rules).not.toContain('responsive-twin')
2151
+ })
2152
+
2153
+ // Same fixture caveat as control-outside-home's owner test: the local `ViewTabs` name is itself a
2154
+ // `shadow-basalt-export` finding, so only this rule's silence is asserted.
2155
+ it('does NOT flag the module that DEFINES a basalt control — the CSS swap lives there', () => {
2156
+ const { rules } = run(
2157
+ `export function ViewTabs() {\n return (\n <div>\n <SegmentedControl visibleFrom="sm" data={[]} />\n <SegmentedControl hiddenFrom="sm" data={[]} />\n </div>\n )\n}\n`,
2158
+ )
2159
+ expect(rules).not.toContain('responsive-twin')
2160
+ })
2161
+
2162
+ it('honors a scoped theme-allow', () => {
2163
+ const { rules } = run(
2164
+ `export const C = () => (\n <div>\n <SegmentedControl visibleFrom="sm" data={[]} />\n {/* theme-allow responsive-twin — two genuinely different data sets */}\n <SegmentedControl hiddenFrom="sm" data={[]} />\n </div>\n)\n`,
2165
+ )
2166
+ expect(rules).not.toContain('responsive-twin')
2167
+ })
2168
+ })
2169
+
2170
+ describe('basalt/search-literal-link', () => {
2171
+ const NAV = (search: string) =>
2172
+ `export const nav = defineNav({\n sections: [\n { id: 'a', label: 'A', link: linkOptions({ to: '/dashboard'${search} }) },\n ],\n})\n`
2173
+
2174
+ it('flags a `search` object literal inside defineNav', () => {
2175
+ const { code, rules } = run(NAV(", search: { window: '30d' }"), 'nav.ts')
2176
+ expect(code).toBe(1)
2177
+ expect(rules).toContain('search-literal-link')
2178
+ })
2179
+
2180
+ it('flags it inside navGroup too', () => {
2181
+ const { rules } = run(
2182
+ `export const g = navGroup({ id: 'g', label: 'G' }, [\n { id: 'x', label: 'X', link: linkOptions({ to: '/d', search: { window: '30d' } }) },\n])\n`,
2183
+ 'nav.ts',
2184
+ )
2185
+ expect(rules).toContain('search-literal-link')
2186
+ })
2187
+
2188
+ it('does NOT flag `search: store.linkSearch` passed by reference', () => {
2189
+ const { code, rules } = run(NAV(', search: dashboardFilters.linkSearch'), 'nav.ts')
2190
+ expect(code).toBe(0)
2191
+ expect(rules).not.toContain('search-literal-link')
2192
+ })
2193
+
2194
+ it('does NOT flag a linkOptions literal OUTSIDE a nav definition', () => {
2195
+ const { code, rules } = run(
2196
+ `export const target = linkOptions({ to: '/d', search: { window: '30d' } })\n`,
2197
+ 'nav.ts',
2198
+ )
2199
+ expect(code).toBe(0)
2200
+ expect(rules).not.toContain('search-literal-link')
2201
+ })
2202
+
2203
+ it('honors a scoped theme-allow', () => {
2204
+ const { rules } = run(
2205
+ `export const nav = defineNav({\n sections: [\n {\n id: 'a',\n label: 'A',\n link: linkOptions({\n to: '/d',\n // theme-allow search-literal-link — a fixed report link, deliberately pinned\n search: { window: '30d' },\n }),\n },\n ],\n})\n`,
2206
+ 'nav.ts',
2207
+ )
2208
+ expect(rules).not.toContain('search-literal-link')
2209
+ })
2210
+ })
2211
+
2212
+ describe('basalt/use-search-from-literal', () => {
2213
+ it('flags useSearch({ from: <literal> })', () => {
2214
+ const { code, rules } = run(
2215
+ `export const useRange = () => useSearch({ from: '/dashboard' })\n`,
2216
+ 'hooks.ts',
2217
+ )
2218
+ expect(code).toBe(1)
2219
+ expect(rules).toContain('use-search-from-literal')
2220
+ })
2221
+
2222
+ it('does NOT flag useSearch({ strict: false })', () => {
2223
+ const { code, rules } = run(
2224
+ `export const useRange = () => useSearch({ strict: false })\n`,
2225
+ 'hooks.ts',
2226
+ )
2227
+ expect(code).toBe(0)
2228
+ expect(rules).not.toContain('use-search-from-literal')
2229
+ })
2230
+
2231
+ it('honors a scoped theme-allow', () => {
2232
+ const { rules } = run(
2233
+ `export const useRange = () =>\n useSearch({\n // theme-allow use-search-from-literal — a route-owned loader, never rendered elsewhere\n from: '/dashboard',\n })\n`,
2234
+ 'hooks.ts',
2235
+ )
2236
+ expect(rules).not.toContain('use-search-from-literal')
2237
+ })
2238
+ })
2239
+
2240
+ // ── raw-scroll-container — the C7 widening ───────────────────────────────────
2241
+
2242
+ describe('basalt/raw-scroll-container — the home widening (C7)', () => {
2243
+ it('flags overflowX inside a home slot', () => {
2244
+ const { rules } = run(
2245
+ `${BASALT_IMPORT}export const C = () => <PageBar filters={<div style={{ overflowX: 'auto' }} />} />\n`,
2246
+ )
2247
+ expect(rules).toContain('raw-scroll-container')
2248
+ })
2249
+
2250
+ // The C7 widening is about a sideways-scrolling row of CONTROLS, which only ever lives in a slot.
2251
+ // Counting the body made every wide table and every horizontally scrolling code block on a page
2252
+ // an `error` carrying that message — the exact pattern this rule's own comment calls legitimate.
2253
+ it('does NOT flag overflowX in a Section BODY — page content, not a control row', () => {
2254
+ const { code, rules } = run(
2255
+ `${BASALT_IMPORT}export const C = () => (\n <Section title="Raw rows">\n <div style={{ overflowX: 'scroll' }}>\n <table />\n </div>\n </Section>\n)\n`,
2256
+ )
2257
+ expect(code).toBe(0)
2258
+ expect(rules).not.toContain('raw-scroll-container')
2259
+ })
2260
+
2261
+ it('does NOT flag <ScrollArea scrollbars="x"> around a wide table in a ChartCard body', () => {
2262
+ const { rules } = run(
2263
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => (\n <ChartCard title="T">\n <ScrollArea scrollbars="x">\n <table />\n </ScrollArea>\n </ChartCard>\n)\n`,
2264
+ )
2265
+ expect(rules).not.toContain('raw-scroll-container')
2266
+ })
2267
+
2268
+ it("does NOT flag a slot on a consumer's own Section — not a basalt home", () => {
2269
+ const { code, rules } = run(
2270
+ `import { Section } from './layout/section'\n${MANTINE_IMPORT}` +
2271
+ `export const C = () => <Section actions={<ScrollArea scrollbars="x" />} />\n`,
2272
+ )
2273
+ expect(code).toBe(0)
2274
+ expect(rules).not.toContain('raw-scroll-container')
2275
+ })
2276
+
2277
+ it("does NOT flag a consumer's own ScrollArea in a basalt slot", () => {
2278
+ const { rules } = run(
2279
+ `${BASALT_IMPORT}import { ScrollArea } from './ui/scroll-area'\n` +
2280
+ `export const C = () => <PageBar filters={<ScrollArea scrollbars="x" />} />\n`,
2281
+ )
2282
+ expect(rules).not.toContain('raw-scroll-container')
2283
+ })
2284
+
2285
+ it('flags <ScrollArea scrollbars="x"> inside a home slot', () => {
2286
+ const { rules } = run(
2287
+ `${BASALT_IMPORT}${MANTINE_IMPORT}export const C = () => <PageBar filters={<ScrollArea scrollbars="x" />} />\n`,
2288
+ )
2289
+ expect(rules).toContain('raw-scroll-container')
2290
+ })
2291
+
2292
+ // The original doctrine, unchanged: a horizontal bar outside a home reserves no gutter width, so
2293
+ // a code block or a pinned-column table stays its own legitimate pattern.
2294
+ it('does NOT flag overflowX outside a home', () => {
2295
+ const { code, rules } = run(`const s = { overflowX: 'auto' }\n`, 'lib.ts')
2296
+ expect(code).toBe(0)
2297
+ expect(rules).not.toContain('raw-scroll-container')
2298
+ })
2299
+
2300
+ it('does NOT flag <ScrollArea scrollbars="x"> outside a home', () => {
2301
+ const { code, rules } = run(
2302
+ `${MANTINE_IMPORT}export const C = () => <ScrollArea scrollbars="x" />\n`,
2303
+ )
2304
+ expect(code).toBe(0)
2305
+ expect(rules).not.toContain('raw-scroll-container')
2306
+ })
2307
+
2308
+ it('honors a scoped theme-allow on the widened half', () => {
2309
+ const { rules } = run(
2310
+ `${BASALT_IMPORT}export const C = () => (\n <Section\n title="T"\n actions={\n // theme-allow raw-scroll-container — a pinned-column table owns its own scroll node\n <div style={{ overflowX: 'auto' }} />\n }\n />\n)\n`,
2311
+ )
2312
+ expect(rules).not.toContain('raw-scroll-container')
2313
+ })
2314
+ })
2315
+
2316
+ // ── shadow-basalt-export — the rename table ──────────────────────────────────
2317
+
2318
+ describe('basalt/shadow-basalt-export — SHADOW_ALIASES', () => {
2319
+ it.each([
2320
+ ['PageHeader', 'PageBar'],
2321
+ ['WindowSelector', 'RangeFilter'],
2322
+ ['SectionTitle', 'Section'],
2323
+ ['RefreshButton', 'SyncButton'],
2324
+ ['HeroCard', 'StatCard'],
2325
+ ])('flags a local %s and names %s as what it forks', (local, canonical) => {
2326
+ const { code, rules, output } = run(`export function ${local}() {\n return null\n}\n`)
2327
+ expect(code).toBe(1)
2328
+ expect(rules).toContain('shadow-basalt-export')
2329
+ expect(output).toContain(canonical)
2330
+ })
2331
+
2332
+ it('flags the const form too', () => {
2333
+ const { rules } = run(`export const FilterBar = () => null\n`)
2334
+ expect(rules).toContain('shadow-basalt-export')
2335
+ })
2336
+
2337
+ it('does NOT flag a name that is neither an export nor an alias', () => {
2338
+ const { code, rules } = run(`export const RangePickerish = () => null\n`)
2339
+ expect(code).toBe(0)
2340
+ expect(rules).not.toContain('shadow-basalt-export')
2341
+ })
2342
+
2343
+ it('is waivable with a scoped theme-allow', () => {
2344
+ const { rules } = run(
2345
+ `// theme-allow shadow-basalt-export — a marketing hero, unrelated to StatCard\nexport const HeroCard = () => null\n`,
2346
+ )
2347
+ expect(rules).not.toContain('shadow-basalt-export')
2348
+ })
2349
+ })
2350
+
2351
+ /**
2352
+ * The provenance half of the alias table: a file that COMPOSES the export it renames is a wrapper,
2353
+ * not a fork. argo's three hero cards each imported `StatCard`, wrapped it, and each took a warn
2354
+ * telling them to import the thing they were already importing.
2355
+ *
2356
+ * Both directions are asserted, because the exemption is only worth having if the rule still fires
2357
+ * WITHOUT the import — a wrapper is evidence, and silence on every `HeroCard` in the repo would be
2358
+ * the rule switching itself off.
2359
+ */
2360
+ describe('basalt/shadow-basalt-export — an alias that composes what it renames', () => {
2361
+ it('fires on a HeroCard that does not import StatCard', () => {
2362
+ const { rules, output } = run(
2363
+ `export function HeroCard() {\n return null\n}\n`,
2364
+ 'src/hero-card.tsx',
2365
+ )
2366
+ expect(rules).toContain('shadow-basalt-export')
2367
+ expect(output).toContain('StatCard')
2368
+ })
2369
+
2370
+ it('is silent on a HeroCard that composes the imported StatCard', () => {
2371
+ const { code, rules } = run(
2372
+ `import { StatCard } from 'basalt-ui'\n` +
2373
+ `export function HeroCard() {\n return <StatCard title="x" value="1" />\n}\n`,
2374
+ 'src/hero-card.tsx',
2375
+ )
2376
+ expect(code).toBe(0)
2377
+ expect(rules).not.toContain('shadow-basalt-export')
2378
+ })
2379
+
2380
+ // The provenance test reads the IMPORTED name, not the local one — `StatCard as BaseStatCard` is
2381
+ // the canonical way a wrapper avoids shadowing its own import, and a local-name set would answer
2382
+ // `BaseStatCard` and miss it.
2383
+ it('is silent when the composed export is imported under an alias', () => {
2384
+ const { rules } = run(
2385
+ `import { StatCard as BaseStatCard } from 'basalt-ui'\n` +
2386
+ `export function HeroCard() {\n return <BaseStatCard title="x" value="1" />\n}\n`,
2387
+ 'src/hero-card.tsx',
2388
+ )
2389
+ expect(rules).not.toContain('shadow-basalt-export')
2390
+ })
2391
+
2392
+ it('is silent from a basalt SUBPATH too, not just the root barrel', () => {
2393
+ const { rules } = run(
2394
+ `import { RangeFilter } from 'basalt-ui/controls'\n` +
2395
+ `export function WindowSelector() {\n return <RangeFilter field={{}} />\n}\n`,
2396
+ 'src/window-selector.tsx',
2397
+ )
2398
+ expect(rules).not.toContain('shadow-basalt-export')
2399
+ })
2400
+
2401
+ // Importing SOMETHING from basalt is not the exemption — importing the thing you renamed is.
2402
+ it('still fires when the file imports a DIFFERENT basalt export', () => {
2403
+ const { rules } = run(
2404
+ `import { Section } from 'basalt-ui'\n` +
2405
+ `export function HeroCard() {\n return <Section title="x" />\n}\n`,
2406
+ 'src/hero-card.tsx',
2407
+ )
2408
+ expect(rules).toContain('shadow-basalt-export')
2409
+ })
2410
+
2411
+ // The COLLISION half is deliberately not exempted: a local `StatCard` beside an
2412
+ // `import { StatCard as Base }` kept the name AND a piece of the original, which is the fork
2413
+ // shape this rule most wants to see.
2414
+ it('still fires on a name COLLISION even when the export is imported under an alias', () => {
2415
+ const { rules } = run(
2416
+ `import { StatCard as Base } from 'basalt-ui'\n` +
2417
+ `export function StatCard() {\n return <Base title="x" value="1" />\n}\n`,
2418
+ 'src/stat-card.tsx',
2419
+ )
2420
+ expect(rules).toContain('shadow-basalt-export')
2421
+ })
2422
+
2423
+ // COMPOSITION, not import. A type-only import is erased at compile time, so the shape a
2424
+ // props-copying fork actually writes — `ComponentProps<typeof StatCard>` off an
2425
+ // `import type` — composes nothing and used to exempt the whole file.
2426
+ it('still fires on a type-only import of the export it renames', () => {
2427
+ const { rules } = run(
2428
+ `import type { StatCard } from 'basalt-ui'\n` +
2429
+ `export function HeroCard() {\n return <div />\n}\n`,
2430
+ 'src/hero-card.tsx',
2431
+ )
2432
+ expect(rules).toContain('shadow-basalt-export')
2433
+ })
2434
+
2435
+ it('still fires on an inline `type` specifier', () => {
2436
+ const { rules } = run(
2437
+ `import { type StatCard } from 'basalt-ui'\n` +
2438
+ `export function HeroCard() {\n return <div />\n}\n`,
2439
+ 'src/hero-card.tsx',
2440
+ )
2441
+ expect(rules).toContain('shadow-basalt-export')
2442
+ })
2443
+
2444
+ // `typeof StatCard` is a `TSTypeQuery` around a plain Identifier, so a VALUE import used only in
2445
+ // a type position has to be rejected too — otherwise the props-copying fork just drops `type`.
2446
+ it('still fires when a value import is only used in a type position', () => {
2447
+ const { rules } = run(
2448
+ `import { StatCard } from 'basalt-ui'\n` +
2449
+ `type P = { card: typeof StatCard }\n` +
2450
+ `export function HeroCard(props: P) {\n return <div>{String(props)}</div>\n}\n`,
2451
+ 'src/hero-card.tsx',
2452
+ )
2453
+ expect(rules).toContain('shadow-basalt-export')
2454
+ })
2455
+
2456
+ it('still fires on a dead import left behind after the body was re-rolled', () => {
2457
+ const { rules } = run(
2458
+ `import { StatCard } from 'basalt-ui'\n` +
2459
+ `export function HeroCard() {\n return <div />\n}\n`,
2460
+ 'src/hero-card.tsx',
2461
+ )
2462
+ expect(rules).toContain('shadow-basalt-export')
2463
+ })
2464
+
2465
+ // A JSX tag is not the only way to compose: a wrapper that hands the component on as a prop or to
2466
+ // `createElement` composes it just as much, and a JSX-only test would report both.
2467
+ it('is silent when the import is passed on as a prop rather than rendered', () => {
2468
+ const { code, rules } = run(
2469
+ `import { StatCard } from 'basalt-ui'\n` +
2470
+ `export function HeroCard() {\n return <Slot component={StatCard} />\n}\n`,
2471
+ 'src/hero-card.tsx',
2472
+ )
2473
+ expect(code).toBe(0)
2474
+ expect(rules).not.toContain('shadow-basalt-export')
2475
+ })
2476
+ })
2477
+
2478
+ // ── SURFACES ↔ the plugin registry ───────────────────────────────────────────
2479
+
2480
+ describe('PLUGIN_RULE_ID_LIST ↔ the plugin', () => {
2481
+ // surfaces.ts cannot import the plugin (it is dependency-free by contract, and the plugin is a
2482
+ // standalone .js loaded out of a consumer's node_modules), so the list is hand-maintained and
2483
+ // THIS is the gate that keeps it honest.
2484
+ it('is exactly the plugin s registered rule ids', () => {
2485
+ expect([...PLUGIN_RULE_ID_LIST].toSorted()).toEqual(Object.keys(basaltPlugin.rules).toSorted())
2486
+ })
2487
+
2488
+ it('maps every plugin rule to exactly one surface', () => {
2489
+ const owners = new Map<string, string[]>()
2490
+ for (const [key, spec] of Object.entries(SURFACES)) {
2491
+ if (spec.kind !== 'doctrine') continue
2492
+ for (const id of spec.pluginRules) owners.set(id, [...(owners.get(id) ?? []), key])
2493
+ }
2494
+ for (const id of PLUGIN_RULE_ID_LIST) {
2495
+ expect([id, owners.get(id)?.length ?? 0]).toEqual([id, 1])
2496
+ }
2497
+ // …and no surface claims an id the plugin does not register (the type already forbids it; this
2498
+ // catches a list entry deleted without its surface).
2499
+ for (const id of owners.keys()) {
2500
+ expect([id, (PLUGIN_RULE_ID_LIST as readonly string[]).includes(id)]).toEqual([id, true])
2501
+ }
2502
+ })
2503
+
2504
+ it("puts the shell rule on the root barrel's surface (wave 6)", () => {
2505
+ expect(SURFACES['.'].pluginRules).toContain('hand-rolled-shell')
2506
+ })
2507
+
2508
+ it('gives ./controls the control-tier rules', () => {
2509
+ expect([...SURFACES['./controls'].pluginRules].toSorted()).toEqual([
2510
+ 'control-outside-home',
2511
+ 'control-size-literal',
2512
+ 'hand-rolled-filter',
2513
+ 'responsive-twin',
2514
+ ])
2515
+ })
2516
+ })