basalt-ui 1.27.0 → 1.29.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 (1245) hide show
  1. package/AGENTS.md +28 -35
  2. package/CHANGELOG.md +72 -0
  3. package/MIGRATING.md +759 -22
  4. package/README.md +56 -69
  5. package/agent/rules/basalt-batteries.md +60 -168
  6. package/agent/rules/basalt-charts.md +56 -75
  7. package/agent/rules/basalt-controls.md +81 -87
  8. package/agent/rules/basalt-mantine.md +64 -98
  9. package/agent/rules/basalt-state.md +42 -60
  10. package/agent/rules/basalt-tokens.md +46 -67
  11. package/agent/skills/basalt-app/SKILL.md +2 -0
  12. package/agent/templates/CLAUDE-block.md.tpl +14 -14
  13. package/configs/oxlint-plugin.js +964 -33
  14. package/configs/oxlint-plugin.test.ts +902 -2
  15. package/configs/oxlint.json +6 -0
  16. package/dist/agent/ai-sdk-transport.d.ts.map +1 -1
  17. package/dist/agent/ai-sdk-transport.js +7 -4
  18. package/dist/agent/coalesce.d.ts.map +1 -1
  19. package/dist/agent/coalesce.js +2 -1
  20. package/dist/agent/merge.d.ts.map +1 -1
  21. package/dist/agent/merge.js +2 -1
  22. package/dist/agent/part-list.d.ts.map +1 -1
  23. package/dist/agent/part-list.js +2 -0
  24. package/dist/agent/stick-to-bottom.d.ts +3 -6
  25. package/dist/agent/stick-to-bottom.d.ts.map +1 -1
  26. package/dist/agent/stick-to-bottom.js +6 -3
  27. package/dist/agent/transport.d.ts +13 -2
  28. package/dist/agent/transport.d.ts.map +1 -1
  29. package/dist/agent/use-agent-thread-runs.d.ts +12 -1
  30. package/dist/agent/use-agent-thread-runs.d.ts.map +1 -1
  31. package/dist/agent/use-agent-thread-runs.js +20 -6
  32. package/dist/agent-chat/composer.d.ts +6 -2
  33. package/dist/agent-chat/composer.d.ts.map +1 -1
  34. package/dist/agent-chat/composer.js +49 -33
  35. package/dist/agent-chat/index.d.ts +1 -1
  36. package/dist/agent-chat/index.d.ts.map +1 -1
  37. package/dist/agent-chat/relative-time.d.ts +0 -9
  38. package/dist/agent-chat/relative-time.d.ts.map +1 -1
  39. package/dist/agent-chat/relative-time.js +2 -1
  40. package/dist/agent-chat/thread-detail-panel.d.ts +3 -2
  41. package/dist/agent-chat/thread-detail-panel.d.ts.map +1 -1
  42. package/dist/agent-chat/thread-detail-panel.js +9 -6
  43. package/dist/agent-chat/thread-feed-row.d.ts +32 -6
  44. package/dist/agent-chat/thread-feed-row.d.ts.map +1 -1
  45. package/dist/agent-chat/thread-feed-row.js +61 -26
  46. package/dist/agent-chat/thread-feed.d.ts +13 -2
  47. package/dist/agent-chat/thread-feed.d.ts.map +1 -1
  48. package/dist/agent-chat/thread-feed.js +16 -7
  49. package/dist/agent-chat/thread-message.d.ts +4 -3
  50. package/dist/agent-chat/thread-message.d.ts.map +1 -1
  51. package/dist/agent-chat/thread-message.js +70 -31
  52. package/dist/agent-chat/thread-outcome-card.d.ts +3 -2
  53. package/dist/agent-chat/thread-outcome-card.d.ts.map +1 -1
  54. package/dist/agent-chat/thread-outcome-card.js +9 -2
  55. package/dist/agent-chat/thread-workspace.d.ts +7 -2
  56. package/dist/agent-chat/thread-workspace.d.ts.map +1 -1
  57. package/dist/agent-chat/thread-workspace.js +62 -29
  58. package/dist/agent-chat/tool-chip.d.ts +3 -2
  59. package/dist/agent-chat/tool-chip.d.ts.map +1 -1
  60. package/dist/agent-chat/tool-chip.js +7 -2
  61. package/dist/agent-chat/virtualize.d.ts +39 -0
  62. package/dist/agent-chat/virtualize.d.ts.map +1 -1
  63. package/dist/agent-chat/virtualize.js +15 -0
  64. package/dist/charts/hooks/useBandPlot.d.ts.map +1 -1
  65. package/dist/charts/hooks/useBandPlot.js +15 -8
  66. package/dist/charts/index.d.ts +11 -10
  67. package/dist/charts/index.d.ts.map +1 -1
  68. package/dist/charts/index.js +53 -9
  69. package/dist/charts/kinds/BandStrip.d.ts +10 -7
  70. package/dist/charts/kinds/BandStrip.d.ts.map +1 -1
  71. package/dist/charts/kinds/BandStrip.js +12 -7
  72. package/dist/charts/kinds/Bars.d.ts +13 -9
  73. package/dist/charts/kinds/Bars.d.ts.map +1 -1
  74. package/dist/charts/kinds/Bars.js +37 -11
  75. package/dist/charts/kinds/Donut.d.ts +6 -5
  76. package/dist/charts/kinds/Donut.d.ts.map +1 -1
  77. package/dist/charts/kinds/Donut.js +19 -2
  78. package/dist/charts/kinds/DualPanel.d.ts +9 -6
  79. package/dist/charts/kinds/DualPanel.d.ts.map +1 -1
  80. package/dist/charts/kinds/DualPanel.js +37 -9
  81. package/dist/charts/kinds/Heatmap.d.ts +10 -6
  82. package/dist/charts/kinds/Heatmap.d.ts.map +1 -1
  83. package/dist/charts/kinds/Heatmap.js +58 -31
  84. package/dist/charts/kinds/MirroredBars.d.ts +6 -5
  85. package/dist/charts/kinds/MirroredBars.d.ts.map +1 -1
  86. package/dist/charts/kinds/MirroredBars.js +9 -2
  87. package/dist/charts/kinds/MultiLine.d.ts +11 -6
  88. package/dist/charts/kinds/MultiLine.d.ts.map +1 -1
  89. package/dist/charts/kinds/MultiLine.js +24 -15
  90. package/dist/charts/kinds/StackedArea.d.ts +18 -8
  91. package/dist/charts/kinds/StackedArea.d.ts.map +1 -1
  92. package/dist/charts/kinds/StackedArea.js +29 -4
  93. package/dist/charts/kinds/ZonedLine.d.ts +13 -11
  94. package/dist/charts/kinds/ZonedLine.d.ts.map +1 -1
  95. package/dist/charts/kinds/ZonedLine.js +33 -23
  96. package/dist/charts/layout/auto-margin.d.ts +65 -8
  97. package/dist/charts/layout/auto-margin.d.ts.map +1 -1
  98. package/dist/charts/layout/auto-margin.js +45 -10
  99. package/dist/charts/layout/log-ticks.d.ts +27 -0
  100. package/dist/charts/layout/log-ticks.d.ts.map +1 -0
  101. package/dist/charts/layout/log-ticks.js +42 -0
  102. package/dist/charts/primitives/Axes.d.ts +32 -4
  103. package/dist/charts/primitives/Axes.d.ts.map +1 -1
  104. package/dist/charts/primitives/Axes.js +49 -7
  105. package/dist/charts/primitives/CartesianChart.d.ts +45 -7
  106. package/dist/charts/primitives/CartesianChart.d.ts.map +1 -1
  107. package/dist/charts/primitives/CartesianChart.js +86 -28
  108. package/dist/charts/primitives/ChartCard.d.ts +45 -4
  109. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  110. package/dist/charts/primitives/ChartCard.js +70 -21
  111. package/dist/charts/primitives/ChartFrame.d.ts +30 -4
  112. package/dist/charts/primitives/ChartFrame.d.ts.map +1 -1
  113. package/dist/charts/primitives/ChartFrame.js +52 -15
  114. package/dist/charts/primitives/ChartLegend.d.ts +13 -2
  115. package/dist/charts/primitives/ChartLegend.d.ts.map +1 -1
  116. package/dist/charts/primitives/ChartLegend.js +33 -13
  117. package/dist/charts/primitives/ChartPending.d.ts +85 -6
  118. package/dist/charts/primitives/ChartPending.d.ts.map +1 -1
  119. package/dist/charts/primitives/ChartPending.js +100 -6
  120. package/dist/charts/primitives/ChartTooltip.d.ts +7 -0
  121. package/dist/charts/primitives/ChartTooltip.d.ts.map +1 -1
  122. package/dist/charts/primitives/ChartTooltip.js +18 -5
  123. package/dist/charts/primitives/Crosshair.d.ts +3 -0
  124. package/dist/charts/primitives/Crosshair.d.ts.map +1 -1
  125. package/dist/charts/primitives/Crosshair.js +3 -1
  126. package/dist/charts/primitives/chart-frame-layout.d.ts +95 -0
  127. package/dist/charts/primitives/chart-frame-layout.d.ts.map +1 -0
  128. package/dist/charts/primitives/chart-frame-layout.js +89 -0
  129. package/dist/charts/primitives/chart-tier.d.ts +16 -0
  130. package/dist/charts/primitives/chart-tier.d.ts.map +1 -0
  131. package/dist/charts/primitives/chart-tier.js +20 -0
  132. package/dist/charts/series.d.ts +55 -1
  133. package/dist/charts/series.d.ts.map +1 -1
  134. package/dist/charts/series.js +27 -1
  135. package/dist/charts/sparklines/BarSparkline.d.ts +3 -3
  136. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  137. package/dist/charts/sparklines/BarSparkline.js +9 -3
  138. package/dist/charts/sparklines/LineSparkline.d.ts +3 -3
  139. package/dist/charts/sparklines/LineSparkline.d.ts.map +1 -1
  140. package/dist/charts/sparklines/LineSparkline.js +15 -3
  141. package/dist/charts/sparklines/index.d.ts +2 -2
  142. package/dist/charts/sparklines/index.d.ts.map +1 -1
  143. package/dist/charts/utils/format.d.ts +7 -4
  144. package/dist/charts/utils/format.d.ts.map +1 -1
  145. package/dist/charts/utils/format.js +17 -24
  146. package/dist/charts/utils/ticks.d.ts +81 -2
  147. package/dist/charts/utils/ticks.d.ts.map +1 -1
  148. package/dist/charts/utils/ticks.js +54 -4
  149. package/dist/cli/audit-allows.d.ts +24 -0
  150. package/dist/cli/audit-allows.d.ts.map +1 -0
  151. package/dist/cli/audit-allows.js +240 -0
  152. package/dist/cli/check-theme.d.ts +10 -0
  153. package/dist/cli/check-theme.d.ts.map +1 -0
  154. package/dist/cli/check-theme.js +139 -0
  155. package/dist/cli/doctor.d.ts +47 -0
  156. package/dist/cli/doctor.d.ts.map +1 -0
  157. package/dist/cli/doctor.js +197 -0
  158. package/dist/cli/guard-hook.d.ts +9 -0
  159. package/dist/cli/guard-hook.d.ts.map +1 -0
  160. package/dist/cli/guard-hook.js +93 -0
  161. package/dist/cli/help.d.ts +9 -0
  162. package/dist/cli/help.d.ts.map +1 -0
  163. package/dist/cli/help.js +53 -0
  164. package/dist/cli/index.d.ts +360 -270
  165. package/dist/cli/index.d.ts.map +1 -1
  166. package/dist/cli/index.js +85 -1901
  167. package/dist/cli/init.d.ts +4 -0
  168. package/dist/cli/init.d.ts.map +1 -0
  169. package/dist/cli/init.js +165 -0
  170. package/dist/cli/sync.d.ts +43 -0
  171. package/dist/cli/sync.d.ts.map +1 -0
  172. package/dist/cli/sync.js +206 -0
  173. package/dist/cli/tokens-css.d.ts +51 -0
  174. package/dist/cli/tokens-css.d.ts.map +1 -0
  175. package/dist/cli/tokens-css.js +165 -0
  176. package/dist/commands/ShortcutsHelp.d.ts +3 -2
  177. package/dist/commands/ShortcutsHelp.d.ts.map +1 -1
  178. package/dist/commands/ShortcutsHelp.js +8 -2
  179. package/dist/commands/define-commands.d.ts.map +1 -1
  180. package/dist/commands/define-commands.js +2 -2
  181. package/dist/commands/define-overlays.d.ts +102 -0
  182. package/dist/commands/define-overlays.d.ts.map +1 -1
  183. package/dist/commands/define-overlays.js +102 -2
  184. package/dist/commands/index.d.ts +12 -2
  185. package/dist/commands/index.d.ts.map +1 -1
  186. package/dist/commands/index.js +9 -1
  187. package/dist/commands/overlays-mount.d.ts +16 -11
  188. package/dist/commands/overlays-mount.d.ts.map +1 -1
  189. package/dist/commands/overlays-mount.js +23 -14
  190. package/dist/commands/shell-bridge.d.ts +57 -0
  191. package/dist/commands/shell-bridge.d.ts.map +1 -0
  192. package/dist/commands/shell-bridge.js +32 -0
  193. package/dist/common/errors.d.ts +69 -0
  194. package/dist/common/errors.d.ts.map +1 -0
  195. package/dist/common/errors.js +64 -0
  196. package/dist/common/index.d.ts +17 -0
  197. package/dist/common/index.d.ts.map +1 -0
  198. package/dist/common/index.js +35 -0
  199. package/dist/common/is-dev.d.ts +3 -0
  200. package/dist/common/is-dev.d.ts.map +1 -0
  201. package/dist/common/is-dev.js +6 -0
  202. package/dist/{motion/index.d.ts → common/motion.d.ts} +1 -1
  203. package/dist/common/motion.d.ts.map +1 -0
  204. package/dist/common/props.d.ts +63 -0
  205. package/dist/common/props.d.ts.map +1 -0
  206. package/dist/common/props.js +6 -0
  207. package/dist/common/query-state-like.d.ts +43 -0
  208. package/dist/common/query-state-like.d.ts.map +1 -0
  209. package/dist/common/query-state-like.js +20 -0
  210. package/dist/common/refs.d.ts +29 -0
  211. package/dist/common/refs.d.ts.map +1 -0
  212. package/dist/common/refs.js +29 -0
  213. package/dist/common/scroll-parent.d.ts +23 -0
  214. package/dist/common/scroll-parent.d.ts.map +1 -0
  215. package/dist/common/scroll-parent.js +21 -0
  216. package/dist/common/validate.d.ts +38 -0
  217. package/dist/common/validate.d.ts.map +1 -0
  218. package/dist/common/validate.js +36 -0
  219. package/dist/content/article-layout.module.css +11 -4
  220. package/dist/content/callout.d.ts +8 -4
  221. package/dist/content/callout.d.ts.map +1 -1
  222. package/dist/content/callout.js +15 -8
  223. package/dist/content/guide.d.ts +5 -4
  224. package/dist/content/guide.d.ts.map +1 -1
  225. package/dist/content/guide.js +13 -4
  226. package/dist/content/heading-anchor.d.ts +24 -2
  227. package/dist/content/heading-anchor.d.ts.map +1 -1
  228. package/dist/content/heading-anchor.js +23 -9
  229. package/dist/content/index.d.ts.map +1 -1
  230. package/dist/content/markdown.d.ts +3 -4
  231. package/dist/content/markdown.d.ts.map +1 -1
  232. package/dist/content/markdown.js +3 -4
  233. package/dist/content/prose.module.css +9 -7
  234. package/dist/content/reading-progress.d.ts +10 -4
  235. package/dist/content/reading-progress.d.ts.map +1 -1
  236. package/dist/content/reading-progress.js +26 -10
  237. package/dist/content/slug.d.ts +4 -0
  238. package/dist/content/slug.d.ts.map +1 -1
  239. package/dist/content/toc.d.ts +11 -0
  240. package/dist/content/toc.d.ts.map +1 -1
  241. package/dist/content/toc.js +23 -10
  242. package/dist/controls/actions.d.ts +50 -7
  243. package/dist/controls/actions.d.ts.map +1 -1
  244. package/dist/controls/actions.js +39 -8
  245. package/dist/controls/compare-filter.d.ts +3 -2
  246. package/dist/controls/compare-filter.d.ts.map +1 -1
  247. package/dist/controls/compare-filter.js +17 -2
  248. package/dist/controls/control-group.d.ts +3 -3
  249. package/dist/controls/control-group.d.ts.map +1 -1
  250. package/dist/controls/control-group.js +9 -2
  251. package/dist/controls/controls.module.css +169 -186
  252. package/dist/controls/controls.module.css.d.ts +9 -11
  253. package/dist/controls/enum-filter.d.ts +3 -2
  254. package/dist/controls/enum-filter.d.ts.map +1 -1
  255. package/dist/controls/enum-filter.js +28 -13
  256. package/dist/controls/filter-context.d.ts +8 -4
  257. package/dist/controls/filter-context.d.ts.map +1 -1
  258. package/dist/controls/filter-pill.d.ts +3 -3
  259. package/dist/controls/filter-pill.d.ts.map +1 -1
  260. package/dist/controls/filter-pill.js +4 -1
  261. package/dist/controls/filter-set.d.ts +3 -2
  262. package/dist/controls/filter-set.d.ts.map +1 -1
  263. package/dist/controls/filter-set.js +66 -41
  264. package/dist/controls/filter-sheet.d.ts +20 -80
  265. package/dist/controls/filter-sheet.d.ts.map +1 -1
  266. package/dist/controls/filter-sheet.js +9 -95
  267. package/dist/controls/index.d.ts +2 -0
  268. package/dist/controls/index.d.ts.map +1 -1
  269. package/dist/controls/index.js +4 -0
  270. package/dist/controls/multi-select-filter.d.ts +18 -2
  271. package/dist/controls/multi-select-filter.d.ts.map +1 -1
  272. package/dist/controls/multi-select-filter.js +122 -20
  273. package/dist/controls/number-filter.d.ts +7 -5
  274. package/dist/controls/number-filter.d.ts.map +1 -1
  275. package/dist/controls/number-filter.js +40 -11
  276. package/dist/controls/panel-row.d.ts +132 -0
  277. package/dist/controls/panel-row.d.ts.map +1 -0
  278. package/dist/controls/panel-row.js +173 -0
  279. package/dist/controls/panel-row.module.css +86 -0
  280. package/dist/controls/panel-row.module.css.d.ts +13 -0
  281. package/dist/controls/range-filter.d.ts +2 -1
  282. package/dist/controls/range-filter.d.ts.map +1 -1
  283. package/dist/controls/range-filter.js +57 -35
  284. package/dist/controls/search-filter.d.ts +3 -2
  285. package/dist/controls/search-filter.d.ts.map +1 -1
  286. package/dist/controls/search-filter.js +25 -10
  287. package/dist/controls/select-filter.d.ts +2 -1
  288. package/dist/controls/select-filter.d.ts.map +1 -1
  289. package/dist/controls/select-filter.js +4 -0
  290. package/dist/controls/slider-control.d.ts +36 -0
  291. package/dist/controls/slider-control.d.ts.map +1 -0
  292. package/dist/controls/slider-control.js +64 -0
  293. package/dist/controls/sync-button.d.ts +3 -2
  294. package/dist/controls/sync-button.d.ts.map +1 -1
  295. package/dist/controls/sync-button.js +16 -3
  296. package/dist/controls/toggle-filter.d.ts +3 -2
  297. package/dist/controls/toggle-filter.d.ts.map +1 -1
  298. package/dist/controls/toggle-filter.js +28 -11
  299. package/dist/controls/view-tabs.d.ts +3 -2
  300. package/dist/controls/view-tabs.d.ts.map +1 -1
  301. package/dist/controls/view-tabs.js +43 -11
  302. package/dist/controls-dates/date-range-picker.d.ts +1 -1
  303. package/dist/controls-dates/date-range-picker.d.ts.map +1 -1
  304. package/dist/controls-dates/date-range-picker.js +6 -1
  305. package/dist/dashboard/delta-badge.d.ts +18 -6
  306. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  307. package/dist/dashboard/delta-badge.js +33 -1
  308. package/dist/dashboard/empty-state.d.ts +8 -4
  309. package/dist/dashboard/empty-state.d.ts.map +1 -1
  310. package/dist/dashboard/empty-state.js +36 -6
  311. package/dist/dashboard/index.d.ts +9 -5
  312. package/dist/dashboard/index.d.ts.map +1 -1
  313. package/dist/dashboard/index.js +13 -1
  314. package/dist/dashboard/query-state.d.ts +39 -27
  315. package/dist/dashboard/query-state.d.ts.map +1 -1
  316. package/dist/dashboard/query-state.js +109 -58
  317. package/dist/dashboard/section.d.ts +58 -0
  318. package/dist/dashboard/section.d.ts.map +1 -0
  319. package/dist/dashboard/section.js +118 -0
  320. package/dist/{section → dashboard}/section.module.css +11 -0
  321. package/dist/dashboard/settings-section.d.ts +19 -9
  322. package/dist/dashboard/settings-section.d.ts.map +1 -1
  323. package/dist/dashboard/settings-section.js +75 -26
  324. package/dist/dashboard/stat-card.d.ts +44 -38
  325. package/dist/dashboard/stat-card.d.ts.map +1 -1
  326. package/dist/dashboard/stat-card.js +56 -43
  327. package/dist/dashboard/stat-card.module.css +46 -31
  328. package/dist/dashboard/stat-card.module.css.d.ts +1 -0
  329. package/dist/dashboard/stat-group.d.ts +41 -0
  330. package/dist/dashboard/stat-group.d.ts.map +1 -0
  331. package/dist/dashboard/stat-group.js +30 -0
  332. package/dist/dashboard/stat-group.module.css +66 -0
  333. package/dist/dashboard/stat-group.module.css.d.ts +8 -0
  334. package/dist/dashboard/widget-grid.d.ts +27 -0
  335. package/dist/dashboard/widget-grid.d.ts.map +1 -0
  336. package/dist/dashboard/widget-grid.js +41 -0
  337. package/dist/dashboard/widget-grid.module.css +53 -0
  338. package/dist/dashboard/widget-grid.module.css.d.ts +9 -0
  339. package/dist/dashboard/widget-header.d.ts +132 -0
  340. package/dist/dashboard/widget-header.d.ts.map +1 -0
  341. package/dist/{widget-header → dashboard}/widget-header.js +42 -27
  342. package/dist/{widget-header → dashboard}/widget-header.module.css +72 -7
  343. package/dist/data/data-table.d.ts +151 -12
  344. package/dist/data/data-table.d.ts.map +1 -1
  345. package/dist/data/data-table.js +385 -139
  346. package/dist/data/data-table.module.css +64 -0
  347. package/dist/data/data-table.module.css.d.ts +11 -0
  348. package/dist/data/query-branch.d.ts +28 -0
  349. package/dist/data/query-branch.d.ts.map +1 -0
  350. package/dist/data/query-branch.js +10 -0
  351. package/dist/data/table.d.ts +2 -2
  352. package/dist/data/table.d.ts.map +1 -1
  353. package/dist/data/table.js +2 -16
  354. package/dist/data/virtual-list.d.ts +65 -6
  355. package/dist/data/virtual-list.d.ts.map +1 -1
  356. package/dist/data/virtual-list.js +89 -38
  357. package/dist/data/virtual.d.ts +4 -4
  358. package/dist/data/virtual.d.ts.map +1 -1
  359. package/dist/dev-dock.d.ts +28 -0
  360. package/dist/dev-dock.d.ts.map +1 -0
  361. package/dist/dev-dock.js +85 -0
  362. package/dist/format/date.d.ts +62 -0
  363. package/dist/format/date.d.ts.map +1 -0
  364. package/dist/format/date.js +106 -0
  365. package/dist/format/index.d.ts +39 -0
  366. package/dist/format/index.d.ts.map +1 -0
  367. package/dist/format/index.js +54 -0
  368. package/dist/format/measure.d.ts +15 -0
  369. package/dist/format/measure.d.ts.map +1 -0
  370. package/dist/format/measure.js +16 -0
  371. package/dist/format/number.d.ts +69 -0
  372. package/dist/format/number.d.ts.map +1 -0
  373. package/dist/format/number.js +62 -0
  374. package/dist/format/shared.d.ts +27 -0
  375. package/dist/format/shared.d.ts.map +1 -0
  376. package/dist/format/shared.js +29 -0
  377. package/dist/forms/FormErrorSummary.d.ts +4 -3
  378. package/dist/forms/FormErrorSummary.d.ts.map +1 -1
  379. package/dist/forms/FormErrorSummary.js +6 -4
  380. package/dist/forms/create-form.d.ts +2 -2
  381. package/dist/forms/field.d.ts +33 -9
  382. package/dist/forms/field.d.ts.map +1 -1
  383. package/dist/forms/field.js +7 -6
  384. package/dist/forms/form-layout.d.ts +142 -0
  385. package/dist/forms/form-layout.d.ts.map +1 -0
  386. package/dist/forms/form-layout.js +110 -0
  387. package/dist/forms/form-layout.module.css +145 -0
  388. package/dist/forms/form-layout.module.css.d.ts +20 -0
  389. package/dist/forms/form-state.d.ts +42 -0
  390. package/dist/forms/form-state.d.ts.map +1 -0
  391. package/dist/forms/form-state.js +26 -0
  392. package/dist/forms/index.d.ts +22 -6
  393. package/dist/forms/index.d.ts.map +1 -1
  394. package/dist/forms/index.js +19 -4
  395. package/dist/forms/use-field-array.d.ts +60 -0
  396. package/dist/forms/use-field-array.d.ts.map +1 -0
  397. package/dist/forms/use-field-array.js +20 -0
  398. package/dist/forms/use-form-draft.d.ts +25 -10
  399. package/dist/forms/use-form-draft.d.ts.map +1 -1
  400. package/dist/forms/use-form-draft.js +55 -18
  401. package/dist/forms/use-form-submit.d.ts +88 -0
  402. package/dist/forms/use-form-submit.d.ts.map +1 -0
  403. package/dist/forms/use-form-submit.js +63 -0
  404. package/dist/guard/index.d.ts +2 -9
  405. package/dist/guard/index.d.ts.map +1 -1
  406. package/dist/guard/index.js +38 -28
  407. package/dist/guard/types.d.ts +8 -10
  408. package/dist/guard/types.d.ts.map +1 -1
  409. package/dist/index.d.ts +21 -14
  410. package/dist/index.d.ts.map +1 -1
  411. package/dist/index.js +40 -6
  412. package/dist/notifications/bell.d.ts +3 -2
  413. package/dist/notifications/bell.d.ts.map +1 -1
  414. package/dist/notifications/bell.js +73 -56
  415. package/dist/notifications/center.d.ts +15 -4
  416. package/dist/notifications/center.d.ts.map +1 -1
  417. package/dist/notifications/center.js +5 -2
  418. package/dist/notifications/define-notifications.d.ts +56 -14
  419. package/dist/notifications/define-notifications.d.ts.map +1 -1
  420. package/dist/notifications/define-notifications.js +2 -1
  421. package/dist/notifications/index.d.ts +11 -7
  422. package/dist/notifications/index.d.ts.map +1 -1
  423. package/dist/notifications/index.js +5 -3
  424. package/dist/notifications/mount-guard.d.ts +8 -0
  425. package/dist/notifications/mount-guard.d.ts.map +1 -0
  426. package/dist/notifications/mount-guard.js +19 -0
  427. package/dist/notifications/notify.d.ts +58 -1
  428. package/dist/notifications/notify.d.ts.map +1 -1
  429. package/dist/notifications/notify.js +46 -2
  430. package/dist/notifications/store.d.ts +7 -0
  431. package/dist/notifications/store.d.ts.map +1 -1
  432. package/dist/provider/connectivity-indicator.d.ts +13 -0
  433. package/dist/provider/connectivity-indicator.d.ts.map +1 -0
  434. package/dist/{connectivity → provider}/connectivity-indicator.js +3 -1
  435. package/dist/{connectivity → provider}/connectivity-provider.d.ts +1 -1
  436. package/dist/provider/connectivity-provider.d.ts.map +1 -0
  437. package/dist/{connectivity → provider}/connectivity-provider.js +4 -1
  438. package/dist/{connectivity/types.d.ts → provider/connectivity-types.d.ts} +3 -3
  439. package/dist/provider/connectivity-types.d.ts.map +1 -0
  440. package/dist/provider/error-fallback.module.css +21 -0
  441. package/dist/provider/error-fallback.module.css.d.ts +9 -0
  442. package/dist/provider/index.d.ts +29 -13
  443. package/dist/provider/index.d.ts.map +1 -1
  444. package/dist/provider/index.js +82 -34
  445. package/dist/provider/lab-theme.d.ts.map +1 -1
  446. package/dist/provider/lab-theme.js +2 -1
  447. package/dist/provider/use-connectivity.d.ts +3 -0
  448. package/dist/provider/use-connectivity.d.ts.map +1 -0
  449. package/dist/query-client.d.ts +63 -0
  450. package/dist/query-client.d.ts.map +1 -0
  451. package/dist/query-client.js +33 -0
  452. package/dist/{query/devtools.d.ts → query-devtools.d.ts} +2 -2
  453. package/dist/query-devtools.d.ts.map +1 -0
  454. package/dist/{query/devtools.js → query-devtools.js} +1 -0
  455. package/dist/register.d.ts +17 -3
  456. package/dist/register.d.ts.map +1 -1
  457. package/dist/router-tanstack/index.d.ts +4 -7
  458. package/dist/router-tanstack/index.d.ts.map +1 -1
  459. package/dist/router-tanstack/index.js +1 -9
  460. package/dist/router-tanstack/nav.d.ts +1 -1
  461. package/dist/router-tanstack/nav.d.ts.map +1 -1
  462. package/dist/router-tanstack/search-store.d.ts +3 -6
  463. package/dist/router-tanstack/search-store.d.ts.map +1 -1
  464. package/dist/router-tanstack/search-store.js +1 -1
  465. package/dist/router-tanstack/use-nav.d.ts +1 -1
  466. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  467. package/dist/router-tanstack/use-nav.js +1 -1
  468. package/dist/shell/account-types.d.ts +5 -2
  469. package/dist/shell/account-types.d.ts.map +1 -1
  470. package/dist/shell/app-brand.d.ts +13 -0
  471. package/dist/shell/app-brand.d.ts.map +1 -0
  472. package/dist/shell/app-brand.js +94 -0
  473. package/dist/shell/app-brand.module.css +109 -0
  474. package/dist/shell/app-brand.module.css.d.ts +14 -0
  475. package/dist/shell/app-breadcrumbs.d.ts +5 -3
  476. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  477. package/dist/shell/app-breadcrumbs.js +33 -22
  478. package/dist/shell/app-header.module.css +10 -5
  479. package/dist/shell/app-main.module.css +118 -0
  480. package/dist/shell/app-main.module.css.d.ts +11 -0
  481. package/dist/shell/app-mobile-nav.d.ts +4 -3
  482. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  483. package/dist/shell/app-mobile-nav.js +53 -17
  484. package/dist/shell/app-mobile-nav.module.css +48 -27
  485. package/dist/shell/app-mobile-nav.module.css.d.ts +0 -1
  486. package/dist/shell/app-sidebar-account.d.ts +2 -1
  487. package/dist/shell/app-sidebar-account.d.ts.map +1 -1
  488. package/dist/shell/app-sidebar-account.js +57 -22
  489. package/dist/shell/app-sidebar.d.ts +10 -4
  490. package/dist/shell/app-sidebar.d.ts.map +1 -1
  491. package/dist/shell/app-sidebar.js +78 -103
  492. package/dist/shell/app-sidebar.module.css +63 -96
  493. package/dist/shell/app-sidebar.module.css.d.ts +0 -5
  494. package/dist/shell/index.d.ts +24 -10
  495. package/dist/shell/index.d.ts.map +1 -1
  496. package/dist/shell/index.js +60 -29
  497. package/dist/shell/isomorphic-layout-effect.d.ts +14 -0
  498. package/dist/shell/isomorphic-layout-effect.d.ts.map +1 -0
  499. package/dist/shell/isomorphic-layout-effect.js +5 -0
  500. package/dist/shell/mobile-nav-model.d.ts +1 -1
  501. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  502. package/dist/shell/mobile-nav-model.js +27 -6
  503. package/dist/shell/nav-count-badge.d.ts +5 -3
  504. package/dist/shell/nav-count-badge.d.ts.map +1 -1
  505. package/dist/shell/nav-count-badge.js +3 -1
  506. package/dist/{nav/types.d.ts → shell/nav-types.d.ts} +16 -7
  507. package/dist/shell/nav-types.d.ts.map +1 -0
  508. package/dist/shell/page-aside.d.ts +43 -0
  509. package/dist/shell/page-aside.d.ts.map +1 -0
  510. package/dist/shell/page-aside.js +175 -0
  511. package/dist/shell/page-aside.module.css +174 -0
  512. package/dist/shell/page-aside.module.css.d.ts +14 -0
  513. package/dist/shell/page-bar.d.ts +55 -15
  514. package/dist/shell/page-bar.d.ts.map +1 -1
  515. package/dist/shell/page-bar.js +130 -41
  516. package/dist/shell/page-bar.module.css +107 -13
  517. package/dist/shell/page-bar.module.css.d.ts +4 -1
  518. package/dist/shell/page-title.d.ts +14 -0
  519. package/dist/shell/page-title.d.ts.map +1 -0
  520. package/dist/shell/page-title.js +25 -0
  521. package/dist/shell/page-title.module.css +33 -0
  522. package/dist/shell/page-title.module.css.d.ts +11 -0
  523. package/dist/shell/sidebar-block-model.d.ts +1 -1
  524. package/dist/shell/sidebar-block-model.d.ts.map +1 -1
  525. package/dist/shell/sidebar-blocks.d.ts +1 -1
  526. package/dist/shell/sidebar-blocks.d.ts.map +1 -1
  527. package/dist/shell/sidebar-search.d.ts +4 -2
  528. package/dist/shell/sidebar-search.d.ts.map +1 -1
  529. package/dist/shell/sidebar-search.js +9 -4
  530. package/dist/shell/sidebar-search.module.css +1 -1
  531. package/dist/shell/use-breakpoint.d.ts +11 -0
  532. package/dist/shell/use-breakpoint.d.ts.map +1 -0
  533. package/dist/shell/use-breakpoint.js +33 -0
  534. package/dist/state/fields.d.ts +26 -22
  535. package/dist/state/fields.d.ts.map +1 -1
  536. package/dist/state/fields.js +19 -18
  537. package/dist/state/persisted-or-local.d.ts +9 -0
  538. package/dist/state/persisted-or-local.d.ts.map +1 -0
  539. package/dist/state/persisted-or-local.js +24 -0
  540. package/dist/state.d.ts +1 -1
  541. package/dist/state.d.ts.map +1 -1
  542. package/dist/state.js +2 -6
  543. package/dist/styles.css +36 -0
  544. package/dist/surfaces.d.ts +25 -47
  545. package/dist/surfaces.d.ts.map +1 -1
  546. package/dist/surfaces.js +72 -55
  547. package/dist/theme/ctl-theme.d.ts.map +1 -1
  548. package/dist/theme/index.d.ts.map +1 -1
  549. package/dist/theme/index.js +102 -17
  550. package/dist/theme/nav-link.module.css +37 -26
  551. package/dist/theme/segmented-control.module.css +22 -0
  552. package/dist/theme/segmented-control.module.css.d.ts +1 -0
  553. package/dist/theme/theme-toggle.d.ts +9 -0
  554. package/dist/theme/theme-toggle.d.ts.map +1 -0
  555. package/dist/{theme-toggle/index.js → theme/theme-toggle.js} +9 -3
  556. package/dist/theme-lab/boot.d.ts +66 -0
  557. package/dist/theme-lab/boot.d.ts.map +1 -0
  558. package/dist/theme-lab/boot.js +82 -0
  559. package/dist/theme-lab/index.d.ts +4 -43
  560. package/dist/theme-lab/index.d.ts.map +1 -1
  561. package/dist/theme-lab/index.js +9 -69
  562. package/dist/tokens/index.d.ts +43 -0
  563. package/dist/tokens/index.d.ts.map +1 -1
  564. package/dist/tokens/index.js +45 -6
  565. package/dist/tokens/palette.d.ts +57 -25
  566. package/dist/tokens/palette.d.ts.map +1 -1
  567. package/dist/tokens/palette.js +44 -15
  568. package/dist/tokens.css +5 -5
  569. package/llms.txt +11 -26
  570. package/package.json +54 -34
  571. package/src/agent/ai-sdk-transport.ts +11 -4
  572. package/src/agent/coalesce.ts +2 -1
  573. package/src/agent/merge.ts +2 -1
  574. package/src/agent/part-list.tsx +2 -0
  575. package/src/agent/stick-to-bottom.tsx +9 -11
  576. package/src/agent/transport.ts +15 -2
  577. package/src/agent/use-agent-thread-runs.ts +65 -15
  578. package/src/agent-chat/composer.tsx +124 -103
  579. package/src/agent-chat/index.ts +6 -4
  580. package/src/agent-chat/relative-time.ts +2 -1
  581. package/src/agent-chat/thread-detail-panel.tsx +10 -6
  582. package/src/agent-chat/thread-feed-row.tsx +117 -34
  583. package/src/agent-chat/thread-feed.tsx +59 -27
  584. package/src/agent-chat/thread-message.tsx +48 -13
  585. package/src/agent-chat/thread-outcome-card.tsx +9 -1
  586. package/src/agent-chat/thread-workspace.tsx +72 -33
  587. package/src/agent-chat/tool-chip.tsx +8 -2
  588. package/src/agent-chat/virtualize.ts +48 -0
  589. package/src/charts/hooks/useBandPlot.ts +26 -8
  590. package/src/charts/index.ts +46 -14
  591. package/src/charts/kinds/BandStrip.tsx +36 -14
  592. package/src/charts/kinds/Bars.tsx +68 -20
  593. package/src/charts/kinds/Donut.tsx +30 -3
  594. package/src/charts/kinds/DualPanel.tsx +60 -13
  595. package/src/charts/kinds/Heatmap.tsx +90 -33
  596. package/src/charts/kinds/MirroredBars.tsx +21 -3
  597. package/src/charts/kinds/MultiLine.tsx +61 -18
  598. package/src/charts/kinds/StackedArea.tsx +77 -6
  599. package/src/charts/kinds/ZonedLine.tsx +72 -35
  600. package/src/charts/layout/auto-margin.ts +134 -21
  601. package/src/charts/layout/log-ticks.ts +90 -0
  602. package/src/charts/primitives/Axes.tsx +82 -5
  603. package/src/charts/primitives/CartesianChart.tsx +194 -41
  604. package/src/charts/primitives/ChartCard.tsx +111 -27
  605. package/src/charts/primitives/ChartFrame.tsx +111 -26
  606. package/src/charts/primitives/ChartLegend.tsx +43 -13
  607. package/src/charts/primitives/ChartPending.tsx +194 -10
  608. package/src/charts/primitives/ChartTooltip.tsx +37 -5
  609. package/src/charts/primitives/Crosshair.tsx +6 -3
  610. package/src/charts/primitives/chart-frame-layout.ts +212 -0
  611. package/src/charts/primitives/chart-tier.tsx +37 -0
  612. package/src/charts/series.ts +77 -1
  613. package/src/charts/sparklines/BarSparkline.tsx +11 -3
  614. package/src/charts/sparklines/LineSparkline.tsx +18 -4
  615. package/src/charts/sparklines/index.ts +2 -2
  616. package/src/charts/utils/format.ts +19 -27
  617. package/src/charts/utils/ticks.ts +149 -4
  618. package/src/cli/audit-allows.ts +371 -0
  619. package/src/cli/check-theme.ts +197 -0
  620. package/src/cli/doctor.ts +331 -0
  621. package/src/cli/guard-hook.ts +115 -0
  622. package/src/cli/help.ts +101 -0
  623. package/src/cli/index.ts +269 -3434
  624. package/src/cli/init.ts +247 -0
  625. package/src/cli/sync.ts +324 -0
  626. package/src/cli/tokens-css.ts +252 -0
  627. package/src/commands/ShortcutsHelp.tsx +10 -3
  628. package/src/commands/define-commands.ts +2 -2
  629. package/src/commands/define-overlays.ts +216 -1
  630. package/src/commands/index.ts +22 -1
  631. package/src/commands/overlays-mount.tsx +63 -31
  632. package/src/commands/shell-bridge.ts +86 -0
  633. package/src/common/errors.ts +143 -0
  634. package/src/common/index.ts +23 -0
  635. package/src/common/is-dev.ts +4 -0
  636. package/src/common/props.ts +71 -0
  637. package/src/common/query-state-like.ts +64 -0
  638. package/src/common/refs.ts +58 -0
  639. package/src/common/scroll-parent.ts +42 -0
  640. package/src/common/validate.ts +94 -0
  641. package/src/content/article-layout.module.css +11 -4
  642. package/src/content/callout.tsx +13 -7
  643. package/src/content/guide.tsx +14 -4
  644. package/src/content/heading-anchor.tsx +14 -3
  645. package/src/content/index.ts +2 -0
  646. package/src/content/markdown.tsx +6 -8
  647. package/src/content/prose.module.css +9 -7
  648. package/src/content/reading-progress.tsx +40 -15
  649. package/src/content/slug.ts +4 -0
  650. package/src/content/toc.tsx +47 -10
  651. package/src/controls/actions.tsx +90 -10
  652. package/src/controls/compare-filter.tsx +20 -3
  653. package/src/controls/control-group.tsx +11 -4
  654. package/src/controls/controls.module.css +169 -186
  655. package/src/controls/controls.module.css.d.ts +9 -11
  656. package/src/controls/enum-filter.tsx +32 -16
  657. package/src/controls/filter-context.tsx +14 -6
  658. package/src/controls/filter-pill.tsx +6 -3
  659. package/src/controls/filter-set.tsx +32 -13
  660. package/src/controls/filter-sheet.tsx +44 -194
  661. package/src/controls/index.ts +7 -0
  662. package/src/controls/multi-select-filter.tsx +177 -27
  663. package/src/controls/number-filter.tsx +61 -23
  664. package/src/controls/panel-row.module.css +86 -0
  665. package/src/controls/panel-row.module.css.d.ts +13 -0
  666. package/src/controls/panel-row.tsx +392 -0
  667. package/src/controls/range-filter.tsx +76 -39
  668. package/src/controls/search-filter.tsx +30 -12
  669. package/src/controls/select-filter.tsx +11 -1
  670. package/src/controls/slider-control.tsx +159 -0
  671. package/src/controls/sync-button.tsx +13 -3
  672. package/src/controls/toggle-filter.tsx +36 -16
  673. package/src/controls/view-tabs.tsx +63 -11
  674. package/src/controls-dates/date-range-picker.tsx +8 -1
  675. package/src/dashboard/delta-badge.tsx +75 -4
  676. package/src/dashboard/empty-state.tsx +67 -24
  677. package/src/dashboard/index.ts +24 -3
  678. package/src/dashboard/query-state.tsx +142 -85
  679. package/src/{section → dashboard}/section.module.css +11 -0
  680. package/src/dashboard/section.tsx +287 -0
  681. package/src/dashboard/settings-section.tsx +88 -32
  682. package/src/dashboard/stat-card.module.css +46 -31
  683. package/src/dashboard/stat-card.module.css.d.ts +1 -0
  684. package/src/dashboard/stat-card.tsx +186 -128
  685. package/src/dashboard/stat-group.module.css +66 -0
  686. package/src/dashboard/stat-group.module.css.d.ts +8 -0
  687. package/src/dashboard/stat-group.tsx +74 -0
  688. package/src/dashboard/widget-grid.module.css +53 -0
  689. package/src/dashboard/widget-grid.module.css.d.ts +9 -0
  690. package/src/dashboard/widget-grid.tsx +127 -0
  691. package/src/{widget-header → dashboard}/widget-header.module.css +72 -7
  692. package/src/dashboard/widget-header.tsx +292 -0
  693. package/src/data/data-table.module.css +64 -0
  694. package/src/data/data-table.module.css.d.ts +11 -0
  695. package/src/data/data-table.tsx +921 -348
  696. package/src/data/query-branch.ts +53 -0
  697. package/src/data/table.ts +9 -23
  698. package/src/data/virtual-list.tsx +190 -56
  699. package/src/data/virtual.ts +8 -4
  700. package/src/dev-dock.tsx +159 -0
  701. package/src/format/date.ts +184 -0
  702. package/src/format/index.ts +69 -0
  703. package/src/format/measure.ts +31 -0
  704. package/src/format/number.ts +129 -0
  705. package/src/format/shared.ts +63 -0
  706. package/src/forms/FormErrorSummary.tsx +13 -6
  707. package/src/forms/create-form.ts +2 -2
  708. package/src/forms/field.ts +42 -13
  709. package/src/forms/form-layout.module.css +145 -0
  710. package/src/forms/form-layout.module.css.d.ts +20 -0
  711. package/src/forms/form-layout.tsx +305 -0
  712. package/src/forms/form-state.tsx +94 -0
  713. package/src/forms/index.ts +37 -7
  714. package/src/forms/use-field-array.ts +94 -0
  715. package/src/forms/use-form-draft.ts +145 -53
  716. package/src/forms/use-form-submit.ts +199 -0
  717. package/src/guard/index.ts +99 -70
  718. package/src/guard/types.ts +7 -10
  719. package/src/index.ts +92 -19
  720. package/src/notifications/bell.tsx +18 -4
  721. package/src/notifications/center.tsx +18 -5
  722. package/src/notifications/define-notifications.ts +64 -18
  723. package/src/notifications/index.ts +17 -8
  724. package/src/notifications/mount-guard.ts +39 -0
  725. package/src/notifications/notify.tsx +122 -3
  726. package/src/notifications/store.ts +7 -0
  727. package/src/{connectivity → provider}/connectivity-indicator.tsx +7 -2
  728. package/src/{connectivity → provider}/connectivity-provider.tsx +5 -2
  729. package/src/{connectivity/types.ts → provider/connectivity-types.ts} +5 -2
  730. package/src/provider/error-fallback.module.css +21 -0
  731. package/src/provider/error-fallback.module.css.d.ts +9 -0
  732. package/src/provider/index.tsx +116 -28
  733. package/src/provider/lab-theme.ts +2 -1
  734. package/src/{connectivity → provider}/use-connectivity.ts +4 -3
  735. package/src/query-client.ts +100 -0
  736. package/src/{query/devtools.tsx → query-devtools.tsx} +6 -4
  737. package/src/register.ts +20 -4
  738. package/src/router-tanstack/index.ts +4 -15
  739. package/src/router-tanstack/nav.ts +1 -1
  740. package/src/router-tanstack/search-store.ts +6 -14
  741. package/src/router-tanstack/use-nav.ts +19 -14
  742. package/src/shell/account-types.ts +5 -2
  743. package/src/shell/app-brand.module.css +109 -0
  744. package/src/shell/app-brand.module.css.d.ts +14 -0
  745. package/src/shell/app-brand.tsx +140 -0
  746. package/src/shell/app-breadcrumbs.tsx +20 -10
  747. package/src/shell/app-header.module.css +10 -5
  748. package/src/shell/app-main.module.css +118 -0
  749. package/src/shell/app-main.module.css.d.ts +11 -0
  750. package/src/shell/app-mobile-nav.module.css +48 -27
  751. package/src/shell/app-mobile-nav.module.css.d.ts +0 -1
  752. package/src/shell/app-mobile-nav.tsx +71 -12
  753. package/src/shell/app-sidebar-account.tsx +29 -6
  754. package/src/shell/app-sidebar.module.css +63 -96
  755. package/src/shell/app-sidebar.module.css.d.ts +0 -5
  756. package/src/shell/app-sidebar.tsx +61 -105
  757. package/src/shell/index.tsx +179 -93
  758. package/src/shell/isomorphic-layout-effect.ts +14 -0
  759. package/src/shell/mobile-nav-model.ts +67 -11
  760. package/src/shell/nav-count-badge.tsx +6 -1
  761. package/src/{nav/types.ts → shell/nav-types.ts} +15 -6
  762. package/src/shell/page-aside.module.css +174 -0
  763. package/src/shell/page-aside.module.css.d.ts +14 -0
  764. package/src/shell/page-aside.tsx +327 -0
  765. package/src/shell/page-bar.module.css +107 -13
  766. package/src/shell/page-bar.module.css.d.ts +4 -1
  767. package/src/shell/page-bar.tsx +294 -94
  768. package/src/shell/page-title.module.css +33 -0
  769. package/src/shell/page-title.module.css.d.ts +11 -0
  770. package/src/shell/page-title.tsx +57 -0
  771. package/src/shell/sidebar-block-model.ts +1 -1
  772. package/src/shell/sidebar-blocks.tsx +1 -1
  773. package/src/shell/sidebar-search.module.css +1 -1
  774. package/src/shell/sidebar-search.tsx +13 -4
  775. package/src/shell/use-breakpoint.ts +73 -0
  776. package/src/state/fields.ts +50 -53
  777. package/src/state/persisted-or-local.ts +50 -0
  778. package/src/state.ts +0 -9
  779. package/src/styles.css +36 -0
  780. package/src/surfaces.ts +72 -57
  781. package/src/theme/ctl-theme.tsx +10 -0
  782. package/src/theme/index.ts +136 -16
  783. package/src/theme/nav-link.module.css +37 -26
  784. package/src/theme/segmented-control.module.css +22 -0
  785. package/src/theme/segmented-control.module.css.d.ts +1 -0
  786. package/src/{theme-toggle/index.tsx → theme/theme-toggle.tsx} +11 -4
  787. package/src/theme-lab/boot.ts +140 -0
  788. package/src/theme-lab/index.tsx +21 -112
  789. package/src/tokens/index.ts +60 -6
  790. package/src/tokens/palette.ts +70 -30
  791. package/dist/agent/adapter.test.d.ts +0 -2
  792. package/dist/agent/adapter.test.d.ts.map +0 -1
  793. package/dist/agent/ai-sdk-transport.test.d.ts +0 -2
  794. package/dist/agent/ai-sdk-transport.test.d.ts.map +0 -1
  795. package/dist/agent/coalesce.test.d.ts +0 -2
  796. package/dist/agent/coalesce.test.d.ts.map +0 -1
  797. package/dist/agent/foreign.test.d.ts +0 -2
  798. package/dist/agent/foreign.test.d.ts.map +0 -1
  799. package/dist/agent/id.test.d.ts +0 -2
  800. package/dist/agent/id.test.d.ts.map +0 -1
  801. package/dist/agent/merge.test.d.ts +0 -2
  802. package/dist/agent/merge.test.d.ts.map +0 -1
  803. package/dist/agent/part-list.test.d.ts +0 -2
  804. package/dist/agent/part-list.test.d.ts.map +0 -1
  805. package/dist/agent/parts.test.d.ts +0 -2
  806. package/dist/agent/parts.test.d.ts.map +0 -1
  807. package/dist/agent/thread.test.d.ts +0 -2
  808. package/dist/agent/thread.test.d.ts.map +0 -1
  809. package/dist/agent/transport.test.d.ts +0 -2
  810. package/dist/agent/transport.test.d.ts.map +0 -1
  811. package/dist/agent/use-agent-stream.test.d.ts +0 -2
  812. package/dist/agent/use-agent-stream.test.d.ts.map +0 -1
  813. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts +0 -2
  814. package/dist/agent/use-agent-thread-runs.foreign.test.d.ts.map +0 -1
  815. package/dist/agent/use-agent-thread-runs.resume.test.d.ts +0 -2
  816. package/dist/agent/use-agent-thread-runs.resume.test.d.ts.map +0 -1
  817. package/dist/agent/use-agent-thread-runs.test.d.ts +0 -2
  818. package/dist/agent/use-agent-thread-runs.test.d.ts.map +0 -1
  819. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts +0 -2
  820. package/dist/agent/use-agent-thread-runs.wedge.test.d.ts.map +0 -1
  821. package/dist/agent-chat/composer.test.d.ts +0 -2
  822. package/dist/agent-chat/composer.test.d.ts.map +0 -1
  823. package/dist/agent-chat/relative-time.test.d.ts +0 -2
  824. package/dist/agent-chat/relative-time.test.d.ts.map +0 -1
  825. package/dist/agent-chat/thread-feed-row.test.d.ts +0 -2
  826. package/dist/agent-chat/thread-feed-row.test.d.ts.map +0 -1
  827. package/dist/agent-chat/thread-feed.test.d.ts +0 -2
  828. package/dist/agent-chat/thread-feed.test.d.ts.map +0 -1
  829. package/dist/agent-chat/thread-message.test.d.ts +0 -2
  830. package/dist/agent-chat/thread-message.test.d.ts.map +0 -1
  831. package/dist/agent-chat/thread-workspace.test.d.ts +0 -2
  832. package/dist/agent-chat/thread-workspace.test.d.ts.map +0 -1
  833. package/dist/agent-chat/tool-chip.test.d.ts +0 -2
  834. package/dist/agent-chat/tool-chip.test.d.ts.map +0 -1
  835. package/dist/agent-chat/virtualize.type-guard.test.d.ts +0 -2
  836. package/dist/agent-chat/virtualize.type-guard.test.d.ts.map +0 -1
  837. package/dist/charts/cursor/resolve.test.d.ts +0 -2
  838. package/dist/charts/cursor/resolve.test.d.ts.map +0 -1
  839. package/dist/charts/cursor/store.test.d.ts +0 -2
  840. package/dist/charts/cursor/store.test.d.ts.map +0 -1
  841. package/dist/charts/kinds/BandStrip.test.d.ts +0 -2
  842. package/dist/charts/kinds/BandStrip.test.d.ts.map +0 -1
  843. package/dist/charts/kinds/Bars.test.d.ts +0 -2
  844. package/dist/charts/kinds/Bars.test.d.ts.map +0 -1
  845. package/dist/charts/kinds/DualPanel.test.d.ts +0 -2
  846. package/dist/charts/kinds/DualPanel.test.d.ts.map +0 -1
  847. package/dist/charts/kinds/MirroredBars.test.d.ts +0 -2
  848. package/dist/charts/kinds/MirroredBars.test.d.ts.map +0 -1
  849. package/dist/charts/kinds/MultiLine.test.d.ts +0 -2
  850. package/dist/charts/kinds/MultiLine.test.d.ts.map +0 -1
  851. package/dist/charts/kinds/StackedArea.test.d.ts +0 -2
  852. package/dist/charts/kinds/StackedArea.test.d.ts.map +0 -1
  853. package/dist/charts/kinds/ZonedLine.test.d.ts +0 -2
  854. package/dist/charts/kinds/ZonedLine.test.d.ts.map +0 -1
  855. package/dist/charts/layout/auto-margin.test.d.ts +0 -2
  856. package/dist/charts/layout/auto-margin.test.d.ts.map +0 -1
  857. package/dist/charts/primitives/CartesianChart.test.d.ts +0 -2
  858. package/dist/charts/primitives/CartesianChart.test.d.ts.map +0 -1
  859. package/dist/charts/primitives/ChartCard.test.d.ts +0 -2
  860. package/dist/charts/primitives/ChartCard.test.d.ts.map +0 -1
  861. package/dist/charts/primitives/ChartFrame.test.d.ts +0 -2
  862. package/dist/charts/primitives/ChartFrame.test.d.ts.map +0 -1
  863. package/dist/charts/primitives/ChartLegend.test.d.ts +0 -2
  864. package/dist/charts/primitives/ChartLegend.test.d.ts.map +0 -1
  865. package/dist/charts/primitives/ChartTooltip.test.d.ts +0 -2
  866. package/dist/charts/primitives/ChartTooltip.test.d.ts.map +0 -1
  867. package/dist/charts/primitives/XZoneRects.test.d.ts +0 -2
  868. package/dist/charts/primitives/XZoneRects.test.d.ts.map +0 -1
  869. package/dist/charts/series.test.d.ts +0 -2
  870. package/dist/charts/series.test.d.ts.map +0 -1
  871. package/dist/charts/utils/domain.test.d.ts +0 -2
  872. package/dist/charts/utils/domain.test.d.ts.map +0 -1
  873. package/dist/charts/utils/measure-text.test.d.ts +0 -2
  874. package/dist/charts/utils/measure-text.test.d.ts.map +0 -1
  875. package/dist/cli/check-coverage.test.d.ts +0 -2
  876. package/dist/cli/check-coverage.test.d.ts.map +0 -1
  877. package/dist/cli/check-theme.test.d.ts +0 -2
  878. package/dist/cli/check-theme.test.d.ts.map +0 -1
  879. package/dist/cli/help.test.d.ts +0 -2
  880. package/dist/cli/help.test.d.ts.map +0 -1
  881. package/dist/cli/init-sync.test.d.ts +0 -2
  882. package/dist/cli/init-sync.test.d.ts.map +0 -1
  883. package/dist/cli/placement-engine.test.d.ts +0 -2
  884. package/dist/cli/placement-engine.test.d.ts.map +0 -1
  885. package/dist/cli/shipped-versions.test.d.ts +0 -2
  886. package/dist/cli/shipped-versions.test.d.ts.map +0 -1
  887. package/dist/cli/tarball-parity.test.d.ts +0 -2
  888. package/dist/cli/tarball-parity.test.d.ts.map +0 -1
  889. package/dist/cli/template-format.test.d.ts +0 -2
  890. package/dist/cli/template-format.test.d.ts.map +0 -1
  891. package/dist/cli/template-ledger.test.d.ts +0 -2
  892. package/dist/cli/template-ledger.test.d.ts.map +0 -1
  893. package/dist/cli/tokens-css.test.d.ts +0 -2
  894. package/dist/cli/tokens-css.test.d.ts.map +0 -1
  895. package/dist/cli/toolchain-wiring.test.d.ts +0 -2
  896. package/dist/cli/toolchain-wiring.test.d.ts.map +0 -1
  897. package/dist/cli/waiver-audit.test.d.ts +0 -2
  898. package/dist/cli/waiver-audit.test.d.ts.map +0 -1
  899. package/dist/connectivity/connectivity-indicator.d.ts +0 -11
  900. package/dist/connectivity/connectivity-indicator.d.ts.map +0 -1
  901. package/dist/connectivity/connectivity-provider.d.ts.map +0 -1
  902. package/dist/connectivity/index.d.ts +0 -13
  903. package/dist/connectivity/index.d.ts.map +0 -1
  904. package/dist/connectivity/index.js +0 -8
  905. package/dist/connectivity/types.d.ts.map +0 -1
  906. package/dist/connectivity/use-connectivity.d.ts +0 -3
  907. package/dist/connectivity/use-connectivity.d.ts.map +0 -1
  908. package/dist/content/article-actions.test.d.ts +0 -2
  909. package/dist/content/article-actions.test.d.ts.map +0 -1
  910. package/dist/content/article-model.test.d.ts +0 -2
  911. package/dist/content/article-model.test.d.ts.map +0 -1
  912. package/dist/content/block-split.test.d.ts +0 -2
  913. package/dist/content/block-split.test.d.ts.map +0 -1
  914. package/dist/content/heading-components.test.d.ts +0 -2
  915. package/dist/content/heading-components.test.d.ts.map +0 -1
  916. package/dist/content/markdown.test.d.ts +0 -2
  917. package/dist/content/markdown.test.d.ts.map +0 -1
  918. package/dist/content/prose.module.css.test.d.ts +0 -2
  919. package/dist/content/prose.module.css.test.d.ts.map +0 -1
  920. package/dist/content/sanitize.test.d.ts +0 -2
  921. package/dist/content/sanitize.test.d.ts.map +0 -1
  922. package/dist/content/slug.test.d.ts +0 -2
  923. package/dist/content/slug.test.d.ts.map +0 -1
  924. package/dist/controls/actions.test.d.ts +0 -2
  925. package/dist/controls/actions.test.d.ts.map +0 -1
  926. package/dist/controls/actions.type-guard.test.d.ts +0 -2
  927. package/dist/controls/actions.type-guard.test.d.ts.map +0 -1
  928. package/dist/controls/control-group.test.d.ts +0 -2
  929. package/dist/controls/control-group.test.d.ts.map +0 -1
  930. package/dist/controls/controls.router.test.d.ts +0 -2
  931. package/dist/controls/controls.router.test.d.ts.map +0 -1
  932. package/dist/controls/filter-set.test.d.ts +0 -2
  933. package/dist/controls/filter-set.test.d.ts.map +0 -1
  934. package/dist/controls/peer-boundary.test.d.ts +0 -2
  935. package/dist/controls/peer-boundary.test.d.ts.map +0 -1
  936. package/dist/controls/props-contract.test.d.ts +0 -2
  937. package/dist/controls/props-contract.test.d.ts.map +0 -1
  938. package/dist/controls/range-filter.type-guard.test.d.ts +0 -2
  939. package/dist/controls/range-filter.type-guard.test.d.ts.map +0 -1
  940. package/dist/controls/sync-button.test.d.ts +0 -2
  941. package/dist/controls/sync-button.test.d.ts.map +0 -1
  942. package/dist/dashboard/query-state.test.d.ts +0 -2
  943. package/dist/dashboard/query-state.test.d.ts.map +0 -1
  944. package/dist/dashboard/settings-section.test.d.ts +0 -2
  945. package/dist/dashboard/settings-section.test.d.ts.map +0 -1
  946. package/dist/dashboard/stat-card.test.d.ts +0 -2
  947. package/dist/dashboard/stat-card.test.d.ts.map +0 -1
  948. package/dist/data/data-table.test.d.ts +0 -2
  949. package/dist/data/data-table.test.d.ts.map +0 -1
  950. package/dist/data/index.d.ts +0 -38
  951. package/dist/data/index.d.ts.map +0 -1
  952. package/dist/data/index.js +0 -2
  953. package/dist/guard/check-source.test.d.ts +0 -2
  954. package/dist/guard/check-source.test.d.ts.map +0 -1
  955. package/dist/guard/grace.test.d.ts +0 -20
  956. package/dist/guard/grace.test.d.ts.map +0 -1
  957. package/dist/guard/guard-hook.test.d.ts +0 -2
  958. package/dist/guard/guard-hook.test.d.ts.map +0 -1
  959. package/dist/motion/index.d.ts.map +0 -1
  960. package/dist/nav/types.d.ts.map +0 -1
  961. package/dist/notifications/overlay.d.ts +0 -20
  962. package/dist/notifications/overlay.d.ts.map +0 -1
  963. package/dist/notifications/overlay.js +0 -12
  964. package/dist/provider/index.test.d.ts +0 -2
  965. package/dist/provider/index.test.d.ts.map +0 -1
  966. package/dist/provider/lab-theme.test.d.ts +0 -2
  967. package/dist/provider/lab-theme.test.d.ts.map +0 -1
  968. package/dist/query/devtools.d.ts.map +0 -1
  969. package/dist/query/error-message.d.ts +0 -41
  970. package/dist/query/error-message.d.ts.map +0 -1
  971. package/dist/query/error-message.js +0 -30
  972. package/dist/query/error-message.test.d.ts +0 -2
  973. package/dist/query/error-message.test.d.ts.map +0 -1
  974. package/dist/query/index.d.ts +0 -47
  975. package/dist/query/index.d.ts.map +0 -1
  976. package/dist/query/index.js +0 -56
  977. package/dist/router-tanstack/field.d.ts +0 -14
  978. package/dist/router-tanstack/field.d.ts.map +0 -1
  979. package/dist/router-tanstack/field.js +0 -4
  980. package/dist/router-tanstack/multi-search-param-store.d.ts +0 -43
  981. package/dist/router-tanstack/multi-search-param-store.d.ts.map +0 -1
  982. package/dist/router-tanstack/multi-search-param-store.js +0 -30
  983. package/dist/router-tanstack/multi-search-param-store.test.d.ts +0 -2
  984. package/dist/router-tanstack/multi-search-param-store.test.d.ts.map +0 -1
  985. package/dist/router-tanstack/nav.test.d.ts +0 -2
  986. package/dist/router-tanstack/nav.test.d.ts.map +0 -1
  987. package/dist/router-tanstack/search-param-store.d.ts +0 -42
  988. package/dist/router-tanstack/search-param-store.d.ts.map +0 -1
  989. package/dist/router-tanstack/search-param-store.js +0 -25
  990. package/dist/router-tanstack/search-param-store.test.d.ts +0 -2
  991. package/dist/router-tanstack/search-param-store.test.d.ts.map +0 -1
  992. package/dist/router-tanstack/search-store.router.test.d.ts +0 -2
  993. package/dist/router-tanstack/search-store.router.test.d.ts.map +0 -1
  994. package/dist/router-tanstack/search-store.test.d.ts +0 -2
  995. package/dist/router-tanstack/search-store.test.d.ts.map +0 -1
  996. package/dist/router-tanstack/use-nav.test.d.ts +0 -2
  997. package/dist/router-tanstack/use-nav.test.d.ts.map +0 -1
  998. package/dist/section/index.d.ts +0 -2
  999. package/dist/section/index.d.ts.map +0 -1
  1000. package/dist/section/index.js +0 -4
  1001. package/dist/section/section.d.ts +0 -24
  1002. package/dist/section/section.d.ts.map +0 -1
  1003. package/dist/section/section.js +0 -126
  1004. package/dist/section/section.test.d.ts +0 -2
  1005. package/dist/section/section.test.d.ts.map +0 -1
  1006. package/dist/shell/app-mobile-nav.test.d.ts +0 -2
  1007. package/dist/shell/app-mobile-nav.test.d.ts.map +0 -1
  1008. package/dist/shell/app-sidebar.test.d.ts +0 -2
  1009. package/dist/shell/app-sidebar.test.d.ts.map +0 -1
  1010. package/dist/shell/index.test.d.ts +0 -2
  1011. package/dist/shell/index.test.d.ts.map +0 -1
  1012. package/dist/shell/mobile-nav-model.test.d.ts +0 -2
  1013. package/dist/shell/mobile-nav-model.test.d.ts.map +0 -1
  1014. package/dist/shell/page-bar.test.d.ts +0 -2
  1015. package/dist/shell/page-bar.test.d.ts.map +0 -1
  1016. package/dist/state/fields.test.d.ts +0 -2
  1017. package/dist/state/fields.test.d.ts.map +0 -1
  1018. package/dist/state/persisted.test.d.ts +0 -2
  1019. package/dist/state/persisted.test.d.ts.map +0 -1
  1020. package/dist/styles.floor.test.d.ts +0 -2
  1021. package/dist/styles.floor.test.d.ts.map +0 -1
  1022. package/dist/theme/border-coverage.test.d.ts +0 -18
  1023. package/dist/theme/border-coverage.test.d.ts.map +0 -1
  1024. package/dist/theme/contrast.test.d.ts +0 -2
  1025. package/dist/theme/contrast.test.d.ts.map +0 -1
  1026. package/dist/theme/ctl-theme.test.d.ts +0 -2
  1027. package/dist/theme/ctl-theme.test.d.ts.map +0 -1
  1028. package/dist/theme/ctl-tier-coverage.test.d.ts +0 -2
  1029. package/dist/theme/ctl-tier-coverage.test.d.ts.map +0 -1
  1030. package/dist/theme/density-option.test.d.ts +0 -2
  1031. package/dist/theme/density-option.test.d.ts.map +0 -1
  1032. package/dist/theme/density-relations.test.d.ts +0 -2
  1033. package/dist/theme/density-relations.test.d.ts.map +0 -1
  1034. package/dist/theme/derive-theme.test.d.ts +0 -2
  1035. package/dist/theme/derive-theme.test.d.ts.map +0 -1
  1036. package/dist/theme/divider-law.test.d.ts +0 -2
  1037. package/dist/theme/divider-law.test.d.ts.map +0 -1
  1038. package/dist/theme/fonts-option.test.d.ts +0 -2
  1039. package/dist/theme/fonts-option.test.d.ts.map +0 -1
  1040. package/dist/theme/icon-slot.test.d.ts +0 -2
  1041. package/dist/theme/icon-slot.test.d.ts.map +0 -1
  1042. package/dist/theme/layout-rhythm-css.test.d.ts +0 -2
  1043. package/dist/theme/layout-rhythm-css.test.d.ts.map +0 -1
  1044. package/dist/theme/radius-option.test.d.ts +0 -2
  1045. package/dist/theme/radius-option.test.d.ts.map +0 -1
  1046. package/dist/theme/radius.test.d.ts +0 -2
  1047. package/dist/theme/radius.test.d.ts.map +0 -1
  1048. package/dist/theme/shadow-surfaces.test.d.ts +0 -26
  1049. package/dist/theme/shadow-surfaces.test.d.ts.map +0 -1
  1050. package/dist/theme/spacing.test.d.ts +0 -2
  1051. package/dist/theme/spacing.test.d.ts.map +0 -1
  1052. package/dist/theme/use-basalt-spacing.test.d.ts +0 -2
  1053. package/dist/theme/use-basalt-spacing.test.d.ts.map +0 -1
  1054. package/dist/theme-lab/derive-controls.test.d.ts +0 -2
  1055. package/dist/theme-lab/derive-controls.test.d.ts.map +0 -1
  1056. package/dist/theme-lab/derive-state.test.d.ts +0 -2
  1057. package/dist/theme-lab/derive-state.test.d.ts.map +0 -1
  1058. package/dist/theme-lab/theme-lab.test.d.ts +0 -2
  1059. package/dist/theme-lab/theme-lab.test.d.ts.map +0 -1
  1060. package/dist/theme-toggle/index.d.ts +0 -8
  1061. package/dist/theme-toggle/index.d.ts.map +0 -1
  1062. package/dist/tokens/build-fonts-css.test.d.ts +0 -2
  1063. package/dist/tokens/build-fonts-css.test.d.ts.map +0 -1
  1064. package/dist/tokens/build-radius-css.test.d.ts +0 -2
  1065. package/dist/tokens/build-radius-css.test.d.ts.map +0 -1
  1066. package/dist/tokens/chart-margin.test.d.ts +0 -2
  1067. package/dist/tokens/chart-margin.test.d.ts.map +0 -1
  1068. package/dist/tokens/density.test.d.ts +0 -2
  1069. package/dist/tokens/density.test.d.ts.map +0 -1
  1070. package/dist/tokens/derive-radius.test.d.ts +0 -2
  1071. package/dist/tokens/derive-radius.test.d.ts.map +0 -1
  1072. package/dist/tokens/derive.test.d.ts +0 -2
  1073. package/dist/tokens/derive.test.d.ts.map +0 -1
  1074. package/dist/tokens/palette.test.d.ts +0 -2
  1075. package/dist/tokens/palette.test.d.ts.map +0 -1
  1076. package/dist/tokens/text-scale.test.d.ts +0 -2
  1077. package/dist/tokens/text-scale.test.d.ts.map +0 -1
  1078. package/dist/vite-color-mix.test.d.ts +0 -2
  1079. package/dist/vite-color-mix.test.d.ts.map +0 -1
  1080. package/dist/vite.test.d.ts +0 -2
  1081. package/dist/vite.test.d.ts.map +0 -1
  1082. package/dist/widget-header/delta-badge.d.ts +0 -13
  1083. package/dist/widget-header/delta-badge.d.ts.map +0 -1
  1084. package/dist/widget-header/delta-badge.js +0 -29
  1085. package/dist/widget-header/delta-badge.test.d.ts +0 -2
  1086. package/dist/widget-header/delta-badge.test.d.ts.map +0 -1
  1087. package/dist/widget-header/index.d.ts +0 -3
  1088. package/dist/widget-header/index.d.ts.map +0 -1
  1089. package/dist/widget-header/index.js +0 -6
  1090. package/dist/widget-header/no-mantine.test.d.ts +0 -2
  1091. package/dist/widget-header/no-mantine.test.d.ts.map +0 -1
  1092. package/dist/widget-header/widget-header.d.ts +0 -67
  1093. package/dist/widget-header/widget-header.d.ts.map +0 -1
  1094. package/dist/widget-header/widget-header.test.d.ts +0 -2
  1095. package/dist/widget-header/widget-header.test.d.ts.map +0 -1
  1096. package/src/agent/adapter.test.ts +0 -1640
  1097. package/src/agent/ai-sdk-transport.test.ts +0 -658
  1098. package/src/agent/coalesce.test.ts +0 -387
  1099. package/src/agent/foreign.test.ts +0 -51
  1100. package/src/agent/id.test.ts +0 -169
  1101. package/src/agent/merge.test.ts +0 -206
  1102. package/src/agent/part-list.test.tsx +0 -112
  1103. package/src/agent/parts.test.ts +0 -720
  1104. package/src/agent/thread.test.ts +0 -220
  1105. package/src/agent/transport.test.ts +0 -70
  1106. package/src/agent/use-agent-stream.test.tsx +0 -287
  1107. package/src/agent/use-agent-thread-runs.foreign.test.tsx +0 -138
  1108. package/src/agent/use-agent-thread-runs.resume.test.tsx +0 -294
  1109. package/src/agent/use-agent-thread-runs.test.tsx +0 -1120
  1110. package/src/agent/use-agent-thread-runs.wedge.test.tsx +0 -387
  1111. package/src/agent-chat/composer.test.tsx +0 -924
  1112. package/src/agent-chat/relative-time.test.ts +0 -95
  1113. package/src/agent-chat/thread-feed-row.test.tsx +0 -510
  1114. package/src/agent-chat/thread-feed.test.tsx +0 -241
  1115. package/src/agent-chat/thread-message.test.tsx +0 -1285
  1116. package/src/agent-chat/thread-workspace.test.tsx +0 -169
  1117. package/src/agent-chat/tool-chip.test.tsx +0 -139
  1118. package/src/agent-chat/virtualize.type-guard.test.ts +0 -49
  1119. package/src/charts/cursor/resolve.test.ts +0 -151
  1120. package/src/charts/cursor/store.test.ts +0 -64
  1121. package/src/charts/kinds/BandStrip.test.tsx +0 -282
  1122. package/src/charts/kinds/Bars.test.tsx +0 -183
  1123. package/src/charts/kinds/DualPanel.test.tsx +0 -527
  1124. package/src/charts/kinds/MirroredBars.test.tsx +0 -232
  1125. package/src/charts/kinds/MultiLine.test.tsx +0 -218
  1126. package/src/charts/kinds/StackedArea.test.tsx +0 -114
  1127. package/src/charts/kinds/ZonedLine.test.tsx +0 -147
  1128. package/src/charts/layout/auto-margin.test.ts +0 -42
  1129. package/src/charts/primitives/CartesianChart.test.tsx +0 -647
  1130. package/src/charts/primitives/ChartCard.test.tsx +0 -66
  1131. package/src/charts/primitives/ChartFrame.test.tsx +0 -142
  1132. package/src/charts/primitives/ChartLegend.test.tsx +0 -78
  1133. package/src/charts/primitives/ChartTooltip.test.tsx +0 -44
  1134. package/src/charts/primitives/XZoneRects.test.tsx +0 -123
  1135. package/src/charts/series.test.ts +0 -112
  1136. package/src/charts/utils/domain.test.ts +0 -54
  1137. package/src/charts/utils/measure-text.test.ts +0 -36
  1138. package/src/cli/check-coverage.test.ts +0 -297
  1139. package/src/cli/check-theme.test.ts +0 -837
  1140. package/src/cli/help.test.ts +0 -170
  1141. package/src/cli/init-sync.test.ts +0 -343
  1142. package/src/cli/placement-engine.test.ts +0 -298
  1143. package/src/cli/shipped-versions.test.ts +0 -83
  1144. package/src/cli/tarball-parity.test.ts +0 -44
  1145. package/src/cli/template-format.test.ts +0 -64
  1146. package/src/cli/template-ledger.test.ts +0 -219
  1147. package/src/cli/tokens-css.test.ts +0 -249
  1148. package/src/cli/toolchain-wiring.test.ts +0 -1358
  1149. package/src/cli/waiver-audit.test.ts +0 -215
  1150. package/src/connectivity/index.ts +0 -18
  1151. package/src/content/article-actions.test.ts +0 -78
  1152. package/src/content/article-model.test.ts +0 -119
  1153. package/src/content/block-split.test.ts +0 -75
  1154. package/src/content/heading-components.test.tsx +0 -65
  1155. package/src/content/markdown.test.tsx +0 -603
  1156. package/src/content/prose.module.css.test.ts +0 -39
  1157. package/src/content/sanitize.test.ts +0 -659
  1158. package/src/content/slug.test.ts +0 -62
  1159. package/src/controls/actions.test.tsx +0 -252
  1160. package/src/controls/actions.type-guard.test.ts +0 -63
  1161. package/src/controls/control-group.test.tsx +0 -229
  1162. package/src/controls/controls.router.test.tsx +0 -1053
  1163. package/src/controls/filter-set.test.tsx +0 -472
  1164. package/src/controls/peer-boundary.test.ts +0 -85
  1165. package/src/controls/props-contract.test.tsx +0 -137
  1166. package/src/controls/range-filter.type-guard.test.tsx +0 -122
  1167. package/src/controls/sync-button.test.tsx +0 -183
  1168. package/src/dashboard/query-state.test.tsx +0 -152
  1169. package/src/dashboard/settings-section.test.tsx +0 -56
  1170. package/src/dashboard/stat-card.test.tsx +0 -385
  1171. package/src/data/data-table.test.tsx +0 -434
  1172. package/src/data/index.ts +0 -41
  1173. package/src/guard/check-source.test.ts +0 -3388
  1174. package/src/guard/grace.test.ts +0 -158
  1175. package/src/guard/guard-hook.test.ts +0 -203
  1176. package/src/notifications/overlay.tsx +0 -64
  1177. package/src/provider/index.test.tsx +0 -101
  1178. package/src/provider/lab-theme.test.ts +0 -147
  1179. package/src/query/error-message.test.ts +0 -67
  1180. package/src/query/error-message.ts +0 -70
  1181. package/src/query/index.ts +0 -97
  1182. package/src/router-tanstack/field.ts +0 -35
  1183. package/src/router-tanstack/multi-search-param-store.test.ts +0 -124
  1184. package/src/router-tanstack/multi-search-param-store.ts +0 -106
  1185. package/src/router-tanstack/nav.test.ts +0 -111
  1186. package/src/router-tanstack/search-param-store.test.ts +0 -135
  1187. package/src/router-tanstack/search-param-store.ts +0 -100
  1188. package/src/router-tanstack/search-store.router.test.tsx +0 -882
  1189. package/src/router-tanstack/search-store.test.ts +0 -578
  1190. package/src/router-tanstack/use-nav.test.tsx +0 -187
  1191. package/src/section/index.ts +0 -1
  1192. package/src/section/section.test.tsx +0 -204
  1193. package/src/section/section.tsx +0 -220
  1194. package/src/shell/app-mobile-nav.test.tsx +0 -403
  1195. package/src/shell/app-sidebar.test.tsx +0 -509
  1196. package/src/shell/index.test.tsx +0 -428
  1197. package/src/shell/mobile-nav-model.test.ts +0 -517
  1198. package/src/shell/page-bar.test.tsx +0 -316
  1199. package/src/state/fields.test.tsx +0 -512
  1200. package/src/state/persisted.test.ts +0 -91
  1201. package/src/styles.floor.test.ts +0 -36
  1202. package/src/theme/border-coverage.test.ts +0 -272
  1203. package/src/theme/contrast.test.ts +0 -354
  1204. package/src/theme/ctl-theme.test.tsx +0 -275
  1205. package/src/theme/ctl-tier-coverage.test.ts +0 -170
  1206. package/src/theme/density-option.test.ts +0 -96
  1207. package/src/theme/density-relations.test.ts +0 -506
  1208. package/src/theme/derive-theme.test.ts +0 -101
  1209. package/src/theme/divider-law.test.ts +0 -259
  1210. package/src/theme/fonts-option.test.ts +0 -75
  1211. package/src/theme/icon-slot.test.tsx +0 -275
  1212. package/src/theme/layout-rhythm-css.test.ts +0 -72
  1213. package/src/theme/radius-option.test.ts +0 -86
  1214. package/src/theme/radius.test.ts +0 -142
  1215. package/src/theme/shadow-surfaces.test.ts +0 -488
  1216. package/src/theme/spacing.test.ts +0 -495
  1217. package/src/theme/use-basalt-spacing.test.tsx +0 -55
  1218. package/src/theme-lab/derive-controls.test.ts +0 -94
  1219. package/src/theme-lab/derive-state.test.ts +0 -71
  1220. package/src/theme-lab/theme-lab.test.ts +0 -46
  1221. package/src/tokens/build-fonts-css.test.ts +0 -52
  1222. package/src/tokens/build-radius-css.test.ts +0 -25
  1223. package/src/tokens/chart-margin.test.ts +0 -39
  1224. package/src/tokens/density.test.ts +0 -259
  1225. package/src/tokens/derive-radius.test.ts +0 -62
  1226. package/src/tokens/derive.test.ts +0 -30
  1227. package/src/tokens/palette.test.ts +0 -114
  1228. package/src/tokens/text-scale.test.ts +0 -51
  1229. package/src/vite-color-mix.test.ts +0 -46
  1230. package/src/vite.test.ts +0 -423
  1231. package/src/widget-header/delta-badge.test.tsx +0 -60
  1232. package/src/widget-header/delta-badge.tsx +0 -57
  1233. package/src/widget-header/index.ts +0 -2
  1234. package/src/widget-header/no-mantine.test.ts +0 -33
  1235. package/src/widget-header/widget-header.test.tsx +0 -187
  1236. package/src/widget-header/widget-header.tsx +0 -213
  1237. /package/dist/{motion/index.js → common/motion.js} +0 -0
  1238. /package/dist/{section → dashboard}/section.module.css.d.ts +0 -0
  1239. /package/dist/{widget-header → dashboard}/widget-header.module.css.d.ts +0 -0
  1240. /package/dist/{connectivity/types.js → provider/connectivity-types.js} +0 -0
  1241. /package/dist/{connectivity → provider}/use-connectivity.js +0 -0
  1242. /package/dist/{nav/types.js → shell/nav-types.js} +0 -0
  1243. /package/src/{motion/index.ts → common/motion.ts} +0 -0
  1244. /package/src/{section → dashboard}/section.module.css.d.ts +0 -0
  1245. /package/src/{widget-header → dashboard}/widget-header.module.css.d.ts +0 -0
@@ -1,3388 +0,0 @@
1
- /**
2
- * Unit tests for checkSource — the pure (text, relPath, cfg) → Finding[] core.
3
- *
4
- * Covers all 27 guard kinds. Co-located with the guard, excluded from tsc
5
- * (tsconfig exclude: src/**\/*.test.ts), run via `bun test`.
6
- *
7
- * The walker/reporter half is covered by the integration test in
8
- * src/cli/check-theme.test.ts (temp-dir + exit-code contract).
9
- */
10
- import { describe, expect, it } from 'bun:test'
11
- import { pxRem } from '../tokens'
12
- import { SPACE_SCALE } from '../tokens/palette'
13
- import {
14
- checkSource,
15
- DEFAULT_GUARD_CONFIG,
16
- findAllowAnnotations,
17
- GENERATED_HEADER_LINE,
18
- guardKindRemedy,
19
- guardWaiverHint,
20
- GUARD_RULES,
21
- neutralizeAllowAnnotation,
22
- NEUTRALIZED_ALLOW_TOKEN,
23
- PLUGIN_RULE_IDS,
24
- TOKENS_ONLY_DISABLED_KINDS,
25
- unmatchedExemptPatterns,
26
- } from './index'
27
- import type { Finding, GuardConfig, GuardKind } from './types'
28
-
29
- const PATH = 'src/Dashboard.tsx'
30
- const CHART_PATH = 'src/charts/kinds/Foo.tsx'
31
-
32
- function kinds(findings: Finding[]): GuardKind[] {
33
- return findings.map((f) => f.kind)
34
- }
35
-
36
- function find(text: string, relPath = PATH) {
37
- return checkSource(text, relPath, DEFAULT_GUARD_CONFIG)
38
- }
39
-
40
- // ── 1. raw-hex ────────────────────────────────────────────────────────────────
41
-
42
- describe('raw-hex', () => {
43
- it('flags a 6-digit hex literal', () => {
44
- const f = find(`const c = '#a3b4c5'`)
45
- expect(kinds(f)).toContain('raw-hex')
46
- expect(f.find((x) => x.kind === 'raw-hex')?.token).toBe('#a3b4c5')
47
- })
48
-
49
- it('flags a 3-digit hex literal', () => {
50
- const f = find(`const c = '#abc'`)
51
- expect(kinds(f)).toContain('raw-hex')
52
- })
53
-
54
- it('reports correct line number', () => {
55
- const f = find(`line one\nconst c = '#ff0000'\nline three`)
56
- const hit = f.find((x) => x.kind === 'raw-hex')
57
- expect(hit?.line).toBe(2)
58
- })
59
-
60
- it('does NOT flag a line with a theme-allow comment', () => {
61
- const f = find(`const c = '#ff0000' // theme-allow: legacy`)
62
- expect(kinds(f)).not.toContain('raw-hex')
63
- })
64
-
65
- it('does NOT flag a pure line comment mentioning a hex', () => {
66
- const f = find(`// use #ff0000 from the palette`)
67
- expect(kinds(f)).not.toContain('raw-hex')
68
- })
69
-
70
- /**
71
- * Round 9, rollhook: `{` / `}` — the escaped braces an Astro template writes to show a
72
- * literal `${…}` in prose — were read as the hex colors `#123` / `#125`, and 4 findings blocked a
73
- * release on a page with no color in it at all. The hole was in the KIND, not in the extension:
74
- * the same string produced the same two findings in `.html`, `.tsx`, `.css` and `.vue`.
75
- */
76
- describe('HTML numeric character references', () => {
77
- const ENTITIES = `<p>image: $&#123;IMAGE_TAG&#125;</p>`
78
-
79
- for (const rel of [
80
- 'index.html',
81
- 'src/App.tsx',
82
- 'src/a.css',
83
- 'src/Hero.vue',
84
- 'src/pages/index.astro',
85
- ]) {
86
- it(`does NOT read &#123;/&#125; as a hex color in ${rel}`, () => {
87
- expect(kinds(find(ENTITIES, rel))).not.toContain('raw-hex')
88
- })
89
- }
90
-
91
- // The exemption is the ENTITY, never the file type — a template that hardcodes a color is
92
- // exactly what making `.astro`/`.vue` scannable was for.
93
- it('still flags a real hex in an .astro file', () => {
94
- const f = find(`<div style="color: #ff0000">x</div>`, 'src/pages/index.astro')
95
- expect(kinds(f)).toContain('raw-hex')
96
- expect(f.find((x) => x.kind === 'raw-hex')?.token).toBe('#ff0000')
97
- })
98
-
99
- it('still flags a real hex in a .vue file', () => {
100
- const f = find(`<style>\n .hero { color: #ff0000; }\n</style>`, 'src/Hero.vue')
101
- expect(kinds(f)).toContain('raw-hex')
102
- expect(f.find((x) => x.kind === 'raw-hex')?.token).toBe('#ff0000')
103
- })
104
-
105
- it('flags a hex after a bare & — an entity needs its terminating semicolon', () => {
106
- expect(kinds(find(`<p>a&#fff b</p>`, 'index.html'))).toContain('raw-hex')
107
- })
108
-
109
- it('flags a plain #123 with no entity around it', () => {
110
- expect(kinds(find(`const c = '#123'`))).toContain('raw-hex')
111
- })
112
-
113
- // The neighbouring raw-text kinds were checked for the same blind spot and do NOT share it: a
114
- // character reference contains no `(`, so `raw-color-fn`'s `rgba?|hsla?\(` anchor cannot occur
115
- // inside one, and every other raw-text kind anchors on a property name, `var(`, or a JSX `=`.
116
- it('leaves the sibling color kinds unchanged — nothing else fires inside an entity', () => {
117
- const src = `<p>&amp; &mdash; &nbsp; &#8212; &hellip; &#x1F600; &copy;</p>`
118
- expect(find(src, 'index.html')).toHaveLength(0)
119
- })
120
- })
121
- })
122
-
123
- // ── 2. raw-color-fn ──────────────────────────────────────────────────────────
124
-
125
- describe('raw-color-fn', () => {
126
- it('flags rgba()', () => {
127
- const f = find(`const c = rgba(255, 0, 0, 0.5)`)
128
- expect(kinds(f)).toContain('raw-color-fn')
129
- expect(f.find((x) => x.kind === 'raw-color-fn')?.token).toBe('rgba(')
130
- })
131
-
132
- it('flags rgb()', () => {
133
- const f = find(`const c = rgb(10, 20, 30)`)
134
- expect(kinds(f)).toContain('raw-color-fn')
135
- })
136
-
137
- it('flags hsl()', () => {
138
- const f = find(`fill: hsl(200, 50%, 50%)`)
139
- expect(kinds(f)).toContain('raw-color-fn')
140
- })
141
-
142
- it('flags hsla()', () => {
143
- const f = find(`fill: hsla(200, 50%, 50%, 0.5)`)
144
- expect(kinds(f)).toContain('raw-color-fn')
145
- })
146
-
147
- it('does NOT flag a theme-allow line', () => {
148
- const f = find(`const c = rgba(0,0,0,0.5) // theme-allow`)
149
- expect(kinds(f)).not.toContain('raw-color-fn')
150
- })
151
-
152
- it('does NOT flag rgba() mentioned inside a real block comment', () => {
153
- const f = find('/*\n * use rgba() for opacity\n */')
154
- expect(kinds(f)).not.toContain('raw-color-fn')
155
- })
156
- })
157
-
158
- // ── 3. localstorage-theme ────────────────────────────────────────────────────
159
-
160
- describe('localstorage-theme', () => {
161
- it('flags localStorage.getItem("theme")', () => {
162
- const f = find(`const t = localStorage.getItem('theme')`)
163
- expect(kinds(f)).toContain('localstorage-theme')
164
- })
165
-
166
- it('flags with double quotes', () => {
167
- const f = find(`const t = localStorage.getItem("theme")`)
168
- expect(kinds(f)).toContain('localstorage-theme')
169
- })
170
-
171
- it('flags with whitespace around getItem call', () => {
172
- const f = find(`localStorage . getItem ( 'theme' )`)
173
- expect(kinds(f)).toContain('localstorage-theme')
174
- })
175
-
176
- it('does NOT flag localStorage.getItem("other-key")', () => {
177
- const f = find(`localStorage.getItem('colorMode')`)
178
- expect(kinds(f)).not.toContain('localstorage-theme')
179
- })
180
-
181
- it('does NOT flag a theme-allow line', () => {
182
- const f = find(`localStorage.getItem('theme') // theme-allow: legacy`)
183
- expect(kinds(f)).not.toContain('localstorage-theme')
184
- })
185
- })
186
-
187
- // ── 4. off-identity-accent ───────────────────────────────────────────────────
188
-
189
- describe('off-identity-accent', () => {
190
- it('flags a forbidden color prop (teal)', () => {
191
- const f = find(`<Badge color="teal" />`)
192
- expect(kinds(f)).toContain('off-identity-accent')
193
- expect(f.find((x) => x.kind === 'off-identity-accent')?.token).toBe('teal')
194
- })
195
-
196
- it('flags c="violet"', () => {
197
- const f = find(`<Button c="violet" />`)
198
- expect(kinds(f)).toContain('off-identity-accent')
199
- })
200
-
201
- it('flags bg="grape"', () => {
202
- const f = find(`<Box bg="grape" />`)
203
- expect(kinds(f)).toContain('off-identity-accent')
204
- })
205
-
206
- it('flags backgroundColor="indigo"', () => {
207
- const f = find(`<Box backgroundColor="indigo" />`)
208
- expect(kinds(f)).toContain('off-identity-accent')
209
- })
210
-
211
- it('does NOT flag color="blue" (allowed)', () => {
212
- const f = find(`<Badge color="blue" />`)
213
- expect(kinds(f)).not.toContain('off-identity-accent')
214
- })
215
-
216
- it('does NOT flag a theme-allow line', () => {
217
- const f = find(`<Badge color="teal" /> // theme-allow: design exception`)
218
- expect(kinds(f)).not.toContain('off-identity-accent')
219
- })
220
- })
221
-
222
- // ── 4b. mantine-shade-index ──────────────────────────────────────────────────
223
-
224
- describe('mantine-shade-index', () => {
225
- it('flags a shade-pinned text color (c="yellow.7")', () => {
226
- const f = find(`<Text c="yellow.7">stale</Text>`)
227
- expect(kinds(f)).toContain('mantine-shade-index')
228
- })
229
-
230
- it('flags bg="blue.4" and color="red.6"', () => {
231
- expect(kinds(find(`<Box bg="blue.4" />`))).toContain('mantine-shade-index')
232
- expect(kinds(find(`<ThemeIcon color="red.6" />`))).toContain('mantine-shade-index')
233
- })
234
-
235
- it('flags the var() form for a non-surface hue', () => {
236
- const f = find(`const a = { color: 'var(--mantine-color-yellow-7)' }`)
237
- expect(kinds(f)).toContain('mantine-shade-index')
238
- })
239
-
240
- it('flags the var() form in a .css file too — unlike the prop-only kinds', () => {
241
- const f = find(`.a { color: var(--mantine-color-red-6); }`, 'src/app.css')
242
- expect(kinds(f)).toContain('mantine-shade-index')
243
- })
244
-
245
- // The two kinds partition the space: gray/dark var() steps are surface color and belong to
246
- // off-system-surface-var, so a single violation must never report under both.
247
- it('leaves gray/dark var() steps to off-system-surface-var', () => {
248
- const f = find(`const a = { background: 'var(--mantine-color-gray-3)' }`)
249
- expect(kinds(f)).toContain('off-system-surface-var')
250
- expect(kinds(f)).not.toContain('mantine-shade-index')
251
- })
252
-
253
- it('does NOT flag a bare status hue — it resolves per scheme, which is the point', () => {
254
- expect(kinds(find(`<Text c="red">down</Text>`))).not.toContain('mantine-shade-index')
255
- expect(kinds(find(`<ThemeIcon color="green" />`))).not.toContain('mantine-shade-index')
256
- })
257
-
258
- it('does NOT flag c="dimmed" or a token ref', () => {
259
- expect(kinds(find(`<Text c="dimmed">age</Text>`))).not.toContain('mantine-shade-index')
260
- expect(kinds(find(`<Text c={VX.status.warn}>age</Text>`))).not.toContain('mantine-shade-index')
261
- })
262
-
263
- it('does NOT flag a theme-allow line', () => {
264
- const f = find(`<Text c="yellow.7" /> // theme-allow: design exception`)
265
- expect(kinds(f)).not.toContain('mantine-shade-index')
266
- })
267
-
268
- it('is disabled by the mantineShadeIndex knob', () => {
269
- const f = checkSource(`<Text c="yellow.7" />`, PATH, {
270
- ...DEFAULT_GUARD_CONFIG,
271
- mantineShadeIndex: false,
272
- })
273
- expect(kinds(f)).not.toContain('mantine-shade-index')
274
- })
275
-
276
- // The grace-minor doctrine (package CLAUDE.md): a kind that rejects previously-passing code
277
- // lands as `warn` for one minor, then its GRACE_PERIOD_KINDS entry is deleted and it becomes an
278
- // error. This one ran its grace across FOUR minors — introduced 1.7.0, then deferred by 1.8.0
279
- // (shipped the same day as 1.7.0), 1.9.0 (carried the chart-layer batch the same consumer was
280
- // waiting on) and 1.10.0 (shipped without the promotion at all). Promoted in 1.11.0, verified
281
- // against the only consumer first: argo's `check-theme` reports zero violations of any kind, so
282
- // the promotion breaks nothing that was passing.
283
- it('is an error now that its grace period has ended', () => {
284
- const f = find(`<Text c="yellow.7" />`)
285
- expect(f.find((x) => x.kind === 'mantine-shade-index')?.severity).toBe('error')
286
- })
287
- })
288
-
289
- // ── 5. raw-spacing ───────────────────────────────────────────────────────────
290
-
291
- describe('raw-spacing', () => {
292
- // Interpolated from the real scale, never typed as literals: the previous versions of these tests
293
- // hardcoded 16 and 12 and so PASSED against a guard whose step list had gone stale — they encoded
294
- // the drift instead of catching it.
295
- it('flags a prop value equal to a scale step', () => {
296
- expect(kinds(find(`<Box p={${SPACE_SCALE.md}} />`))).toContain('raw-spacing')
297
- expect(kinds(find(`<Stack gap={${SPACE_SCALE.sm}} />`))).toContain('raw-spacing')
298
- })
299
-
300
- it('does NOT flag p={8} (sub-scale micro-spacing, no token to prefer)', () => {
301
- const f = find(`<Box p={8} />`)
302
- expect(kinds(f)).not.toContain('raw-spacing')
303
- })
304
-
305
- it('does NOT flag p="md" (string token)', () => {
306
- const f = find(`<Box p="md" />`)
307
- expect(kinds(f)).not.toContain('raw-spacing')
308
- })
309
-
310
- it('does NOT flag a theme-allow line', () => {
311
- const f = find(`<Box p={${SPACE_SCALE.md}} /> // theme-allow`)
312
- expect(kinds(f)).not.toContain('raw-spacing')
313
- })
314
- })
315
-
316
- // ── 6. raw-radius ────────────────────────────────────────────────────────────
317
-
318
- describe('raw-radius', () => {
319
- it('flags radius={8}', () => {
320
- const f = find(`<Card radius={8} />`)
321
- expect(kinds(f)).toContain('raw-radius')
322
- })
323
-
324
- it('flags radius="4"', () => {
325
- const f = find(`<Card radius="4" />`)
326
- expect(kinds(f)).toContain('raw-radius')
327
- })
328
-
329
- it('does NOT flag radius="md"', () => {
330
- const f = find(`<Card radius="md" />`)
331
- expect(kinds(f)).not.toContain('raw-radius')
332
- })
333
-
334
- it('does NOT flag a theme-allow line', () => {
335
- const f = find(`<Card radius={8} /> // theme-allow`)
336
- expect(kinds(f)).not.toContain('raw-radius')
337
- })
338
- })
339
-
340
- // ── 7. raw-surface ───────────────────────────────────────────────────────────
341
-
342
- describe('raw-surface', () => {
343
- it('flags an inline border literal', () => {
344
- const f = find(`<div style={{ border: '1px solid #ccc' }} />`)
345
- expect(kinds(f)).toContain('raw-surface')
346
- })
347
-
348
- it('flags a numeric borderRadius', () => {
349
- const f = find(`<div style={{ borderRadius: 8 }} />`)
350
- expect(kinds(f)).toContain('raw-surface')
351
- })
352
-
353
- it('flags a boxShadow literal', () => {
354
- const f = find(`<div style={{ boxShadow: '0 1px 3px black' }} />`)
355
- expect(kinds(f)).toContain('raw-surface')
356
- })
357
-
358
- it('does NOT flag a var(--…) border token', () => {
359
- const f = find(`<Box style={{ border: '1px solid var(--vx-surface-border)' }} />`)
360
- expect(kinds(f)).not.toContain('raw-surface')
361
- })
362
-
363
- it('does NOT flag a var(--…) boxShadow token', () => {
364
- const f = find(`<div style={{ boxShadow: 'var(--mantine-shadow-sm)' }} />`)
365
- expect(kinds(f)).not.toContain('raw-surface')
366
- })
367
-
368
- it('does NOT flag a ${…}-composed border (token-driven, e.g. VX.divider)', () => {
369
- const f = find('const s = { borderLeft: `2px solid ${VX.divider}` }')
370
- expect(kinds(f)).not.toContain('raw-surface')
371
- })
372
-
373
- it('does NOT flag a ${…}-composed boxShadow (VX.shadowCard + a token ring)', () => {
374
- const f = find(
375
- 'const s = { boxShadow: `${VX.shadowCard}, 0 0 0 1px ${alpha(VX.status.bad, 0.25)}` }',
376
- )
377
- expect(kinds(f)).not.toContain('raw-surface')
378
- })
379
-
380
- it('does NOT flag a border reset keyword (none/transparent)', () => {
381
- expect(kinds(find(`<button style={{ border: 'none' }} />`))).not.toContain('raw-surface')
382
- expect(kinds(find(`<div style={{ border: 'transparent' }} />`))).not.toContain('raw-surface')
383
- })
384
-
385
- it('STILL flags a genuinely raw ${…}-free literal border/shadow', () => {
386
- // A template literal escapes only because its color is separately caught by raw-hex/raw-color-fn;
387
- // a plain quoted literal has no such backstop, so it must still fire.
388
- expect(kinds(find(`<div style={{ border: '2px solid red' }} />`))).toContain('raw-surface')
389
- expect(kinds(find(`<div style={{ boxShadow: '0 2px 4px rgba(0,0,0,.1)' }} />`))).toContain(
390
- 'raw-surface',
391
- )
392
- })
393
-
394
- it('does NOT flag when rawSurface is false', () => {
395
- const f = checkSource(`<div style={{ borderRadius: 8 }} />`, PATH, {
396
- ...DEFAULT_GUARD_CONFIG,
397
- rawSurface: false,
398
- })
399
- expect(kinds(f)).not.toContain('raw-surface')
400
- })
401
-
402
- it('does NOT flag a theme-allow line', () => {
403
- const f = find(`<div style={{ borderRadius: 8 }} /> // theme-allow`)
404
- expect(kinds(f)).not.toContain('raw-surface')
405
- })
406
- })
407
-
408
- // ── 8. off-system-surface-var ─────────────────────────────────────────────────
409
-
410
- describe('off-system-surface-var', () => {
411
- it('flags a raw Mantine gray ramp step', () => {
412
- const f = find(`<Box style={{ color: 'var(--mantine-color-gray-3)' }} />`)
413
- expect(kinds(f)).toContain('off-system-surface-var')
414
- })
415
-
416
- it('flags a raw Mantine dark ramp step', () => {
417
- const f = find(`<Box bg="var(--mantine-color-dark-6)" />`)
418
- expect(kinds(f)).toContain('off-system-surface-var')
419
- })
420
-
421
- it('does NOT flag a --vx-surface-* token', () => {
422
- const f = find(`<Box bg="var(--vx-surface-panel)" />`)
423
- expect(kinds(f)).not.toContain('off-system-surface-var')
424
- })
425
-
426
- it('does NOT flag a named Mantine var (not a ramp step)', () => {
427
- const f = find(`<Box bg="var(--mantine-color-default-border)" />`)
428
- expect(kinds(f)).not.toContain('off-system-surface-var')
429
- })
430
-
431
- it('does NOT flag when offSystemSurfaceVar is false', () => {
432
- const f = checkSource(`<Box bg="var(--mantine-color-gray-3)" />`, PATH, {
433
- ...DEFAULT_GUARD_CONFIG,
434
- offSystemSurfaceVar: false,
435
- })
436
- expect(kinds(f)).not.toContain('off-system-surface-var')
437
- })
438
-
439
- it('does NOT flag a theme-allow line', () => {
440
- const f = find(`<Box bg="var(--mantine-color-gray-3)" /> // theme-allow`)
441
- expect(kinds(f)).not.toContain('off-system-surface-var')
442
- })
443
- })
444
-
445
- // ── 9. raw-html-layout ───────────────────────────────────────────────────────
446
-
447
- describe('raw-html-layout', () => {
448
- it('flags a raw <div> with inline display:flex', () => {
449
- const f = find(`<div style={{ display: 'flex', gap: 8 }} />`)
450
- expect(kinds(f)).toContain('raw-html-layout')
451
- })
452
-
453
- it('flags a raw <section> with inline padding', () => {
454
- const f = find(`<section style={{ padding: 16 }}>x</section>`)
455
- expect(kinds(f)).toContain('raw-html-layout')
456
- })
457
-
458
- it('does NOT flag a bare <div> without style', () => {
459
- const f = find(`<div ref={r} className="x" />`)
460
- expect(kinds(f)).not.toContain('raw-html-layout')
461
- })
462
-
463
- it('does NOT flag an <img> (not a layout element)', () => {
464
- const f = find(`<img style={{ width: 40 }} src="a.png" />`)
465
- expect(kinds(f)).not.toContain('raw-html-layout')
466
- })
467
-
468
- it('does NOT flag when rawHtmlLayout is false', () => {
469
- const f = checkSource(`<div style={{ display: 'flex' }} />`, PATH, {
470
- ...DEFAULT_GUARD_CONFIG,
471
- rawHtmlLayout: false,
472
- inlineDisplay: false,
473
- })
474
- expect(kinds(f)).not.toContain('raw-html-layout')
475
- })
476
-
477
- it('does NOT flag a theme-allow line', () => {
478
- const f = find(`<div style={{ display: 'flex' }} /> // theme-allow`)
479
- expect(kinds(f)).not.toContain('raw-html-layout')
480
- })
481
-
482
- it('does NOT flag a raw <div> with inline display:flex in a chart file — the Flex/Grid/Group remedy is @mantine/*, banned in the Mantine-free chart layer', () => {
483
- const f = find(`<div style={{ display: 'flex', gap: 8 }} />`, CHART_PATH)
484
- expect(kinds(f)).not.toContain('raw-html-layout')
485
- })
486
-
487
- it('still flags the same literal in a non-chart path (the check is not globally weakened)', () => {
488
- const f = find(`<div style={{ display: 'flex', gap: 8 }} />`, PATH)
489
- expect(kinds(f)).toContain('raw-html-layout')
490
- })
491
- })
492
-
493
- // ── 10. inline-spacing ───────────────────────────────────────────────────────
494
-
495
- describe('inline-spacing', () => {
496
- it('flags padding: 16', () => {
497
- const f = find(`<Box style={{ padding: 16 }} />`)
498
- expect(kinds(f)).toContain('inline-spacing')
499
- })
500
-
501
- it('flags marginTop: "12px"', () => {
502
- const f = find(`<Box style={{ marginTop: '12px' }} />`)
503
- expect(kinds(f)).toContain('inline-spacing')
504
- })
505
-
506
- it('does NOT flag padding: 0', () => {
507
- const f = find(`<Box style={{ padding: 0 }} />`)
508
- expect(kinds(f)).not.toContain('inline-spacing')
509
- })
510
-
511
- it('does NOT flag a var(--…) gap token', () => {
512
- const f = find(`<Box style={{ gap: 'var(--mantine-spacing-md)' }} />`)
513
- expect(kinds(f)).not.toContain('inline-spacing')
514
- })
515
-
516
- it('does NOT flag when inlineSpacing is false', () => {
517
- const f = checkSource(`<Box style={{ padding: 16 }} />`, PATH, {
518
- ...DEFAULT_GUARD_CONFIG,
519
- inlineSpacing: false,
520
- })
521
- expect(kinds(f)).not.toContain('inline-spacing')
522
- })
523
-
524
- it('does NOT flag a theme-allow line', () => {
525
- const f = find(`<Box style={{ padding: 16 }} /> // theme-allow`)
526
- expect(kinds(f)).not.toContain('inline-spacing')
527
- })
528
- })
529
-
530
- // ── 10b. Finding.text ─────────────────────────────────────────────────────────
531
- //
532
- // `token` stays the regex match (e.g. the unit-less numeric part of a spacing declaration);
533
- // `text` is the full trimmed source line, so a human reading the report sees `padding: 0.75rem;`
534
- // rather than the confusing partial match `padding: 0.75`.
535
-
536
- describe('Finding.text', () => {
537
- it('reports text as the full trimmed source line while token stays the truncated match', () => {
538
- const f = checkSource(
539
- '.foo {\n padding: 0.75rem;\n}\n',
540
- 'src/Card.module.css',
541
- DEFAULT_GUARD_CONFIG,
542
- )
543
- const hit = f.find((x) => x.kind === 'inline-spacing')
544
- expect(hit?.token).toBe('padding: 0.75')
545
- expect(hit?.text).toBe('padding: 0.75rem;')
546
- })
547
-
548
- it('reports the whole declaration including the unit for a CSS padding-left finding', () => {
549
- const f = checkSource('padding-left: 1.25rem;', 'src/Card.module.css', DEFAULT_GUARD_CONFIG)
550
- const hit = f.find((x) => x.kind === 'inline-spacing')
551
- expect(hit?.token).toBe('padding-left: 1.25')
552
- expect(hit?.text).toBe('padding-left: 1.25rem;')
553
- })
554
-
555
- it('truncates a line over 100 characters with a trailing …', () => {
556
- const filler = 'x'.repeat(200)
557
- const f = checkSource(
558
- `padding: 16px; /* ${filler} */`,
559
- 'src/Card.module.css',
560
- DEFAULT_GUARD_CONFIG,
561
- )
562
- const hit = f.find((x) => x.kind === 'inline-spacing')
563
- expect(hit?.text.length).toBe(101)
564
- expect(hit?.text.endsWith('…')).toBe(true)
565
- })
566
-
567
- it('trims a leading-indented line', () => {
568
- const f = checkSource(' padding: 16px;', 'src/Card.module.css', DEFAULT_GUARD_CONFIG)
569
- const hit = f.find((x) => x.kind === 'inline-spacing')
570
- expect(hit?.text).toBe('padding: 16px;')
571
- })
572
- })
573
-
574
- // ── 11. inline-display ───────────────────────────────────────────────────────
575
-
576
- describe('inline-display', () => {
577
- it('flags display: "flex"', () => {
578
- const f = find(`<Box style={{ display: 'flex' }} />`)
579
- expect(kinds(f)).toContain('inline-display')
580
- })
581
-
582
- it('flags display: "grid"', () => {
583
- const f = find(`<Box style={{ display: "grid" }} />`)
584
- expect(kinds(f)).toContain('inline-display')
585
- })
586
-
587
- it('flags display: "inline-flex"', () => {
588
- const f = find(`<Box style={{ display: 'inline-flex' }} />`)
589
- expect(kinds(f)).toContain('inline-display')
590
- })
591
-
592
- it('does NOT flag display: "block"', () => {
593
- const f = find(`<Box style={{ display: 'block' }} />`)
594
- expect(kinds(f)).not.toContain('inline-display')
595
- })
596
-
597
- it('does NOT flag when inlineDisplay is false', () => {
598
- const f = checkSource(`<Box style={{ display: 'flex' }} />`, PATH, {
599
- ...DEFAULT_GUARD_CONFIG,
600
- inlineDisplay: false,
601
- })
602
- expect(kinds(f)).not.toContain('inline-display')
603
- })
604
-
605
- it('does NOT flag a theme-allow line', () => {
606
- const f = find(`<Box style={{ display: 'flex' }} /> // theme-allow`)
607
- expect(kinds(f)).not.toContain('inline-display')
608
- })
609
-
610
- it('does NOT flag display: "flex" in a chart file — the Flex/Grid/Group remedy is @mantine/*, banned in the Mantine-free chart layer', () => {
611
- const f = find(`<Box style={{ display: 'flex' }} />`, CHART_PATH)
612
- expect(kinds(f)).not.toContain('inline-display')
613
- })
614
-
615
- it('still flags the same literal in a non-chart path (the check is not globally weakened)', () => {
616
- const f = find(`<Box style={{ display: 'flex' }} />`, PATH)
617
- expect(kinds(f)).toContain('inline-display')
618
- })
619
- })
620
-
621
- // ── 12. raw-visx-axis ────────────────────────────────────────────────────────
622
-
623
- describe('raw-visx-axis', () => {
624
- it('flags <AxisLeft> in a chart file', () => {
625
- const f = find(`<AxisLeft scale={s} numTicks={4} />`, CHART_PATH)
626
- expect(kinds(f)).toContain('raw-visx-axis')
627
- expect(f.find((x) => x.kind === 'raw-visx-axis')?.token).toBe('<AxisLeft')
628
- })
629
-
630
- it('flags <AxisBottom> in a chart file', () => {
631
- const f = find(`<AxisBottom scale={x} />`, CHART_PATH)
632
- expect(kinds(f)).toContain('raw-visx-axis')
633
- })
634
-
635
- it('flags <AxisRight> in a chart file', () => {
636
- const f = find(`<AxisRight scale={y} />`, CHART_PATH)
637
- expect(kinds(f)).toContain('raw-visx-axis')
638
- })
639
-
640
- it('does NOT flag <AxisLeft> in a NON-chart path', () => {
641
- const f = find(`<AxisLeft scale={s} />`, PATH)
642
- expect(kinds(f)).not.toContain('raw-visx-axis')
643
- })
644
-
645
- it('does NOT flag <AxisLeftNumeric> in a chart file (not a raw axis)', () => {
646
- const f = find(`<AxisLeftNumeric scale={s} />`, CHART_PATH)
647
- expect(kinds(f)).not.toContain('raw-visx-axis')
648
- })
649
-
650
- it('does NOT flag <AxisLeft> in Axes.tsx (the wrapper primitive)', () => {
651
- const f = find(
652
- `export const AxisLeftNumeric = (p) => <AxisLeft {...p} />`,
653
- 'src/charts/primitives/Axes.tsx',
654
- )
655
- expect(kinds(f)).not.toContain('raw-visx-axis')
656
- })
657
-
658
- it('does NOT flag when rawVisxAxis is false', () => {
659
- const f = checkSource(`<AxisLeft scale={s} />`, CHART_PATH, {
660
- ...DEFAULT_GUARD_CONFIG,
661
- rawVisxAxis: false,
662
- })
663
- expect(kinds(f)).not.toContain('raw-visx-axis')
664
- })
665
-
666
- it('does NOT flag a theme-allow line in a chart file', () => {
667
- const f = find(`<AxisLeft scale={s} /> // theme-allow: bespoke`, CHART_PATH)
668
- expect(kinds(f)).not.toContain('raw-visx-axis')
669
- })
670
- })
671
-
672
- // ── 13. raw-motion-value ─────────────────────────────────────────────────────
673
-
674
- describe('raw-motion-value', () => {
675
- it('flags a hardcoded duration in transition={{}}', () => {
676
- const f = find(`<motion.div transition={{ duration: 0.3 }} />`)
677
- expect(kinds(f)).toContain('raw-motion-value')
678
- })
679
-
680
- it('flags a hardcoded spring stiffness in transition={{}}', () => {
681
- const f = find(`<motion.div transition={{ type: 'spring', stiffness: 400 }} />`)
682
- expect(kinds(f)).toContain('raw-motion-value')
683
- })
684
-
685
- it('flags a hardcoded ease bezier array in transition={{}}', () => {
686
- const f = find(`<motion.div transition={{ ease: [0.4, 0, 0.2, 1] }} />`)
687
- expect(kinds(f)).toContain('raw-motion-value')
688
- })
689
-
690
- it('does NOT flag a transition referencing the shared token', () => {
691
- const f = find(`<motion.div transition={MOTION_SPRING} />`)
692
- expect(kinds(f)).not.toContain('raw-motion-value')
693
- })
694
-
695
- it('does NOT flag a named easing string (not a magic number)', () => {
696
- const f = find(`<motion.div transition={{ ease: 'easeInOut' }} />`)
697
- expect(kinds(f)).not.toContain('raw-motion-value')
698
- })
699
-
700
- it('does NOT flag when rawMotionValue is false', () => {
701
- const f = checkSource(`<motion.div transition={{ duration: 0.3 }} />`, PATH, {
702
- ...DEFAULT_GUARD_CONFIG,
703
- rawMotionValue: false,
704
- })
705
- expect(kinds(f)).not.toContain('raw-motion-value')
706
- })
707
-
708
- it('does NOT flag a theme-allow line', () => {
709
- const f = find(`<motion.div transition={{ duration: 0.3 }} /> // theme-allow: bespoke`)
710
- expect(kinds(f)).not.toContain('raw-motion-value')
711
- })
712
- })
713
-
714
- // ── 14. unframed-chart ───────────────────────────────────────────────────────
715
-
716
- describe('unframed-chart', () => {
717
- it('flags a hand-rolled <ChartLegend items={[...]}> on one line', () => {
718
- const f = find(`<ChartLegend items={[{ key: 'a', label: 'A', color: '#fff' }]} />`)
719
- expect(kinds(f)).toContain('unframed-chart')
720
- })
721
-
722
- it('flags a hand-rolled legend array literal formatted across multiple lines', () => {
723
- const text = [
724
- '<ChartLegend',
725
- " items={[{ key: 'a', label: 'A', color: VX.line }]}",
726
- ' placement="bottom"',
727
- '/>',
728
- ].join('\n')
729
- const f = find(text)
730
- expect(kinds(f)).toContain('unframed-chart')
731
- })
732
-
733
- it('reports the line carrying the items={[ token, not the tag-open line', () => {
734
- const text = ['<ChartLegend', " items={[{ key: 'a', label: 'A' }]}", '/>'].join('\n')
735
- const f = find(text)
736
- const hit = f.find((x) => x.kind === 'unframed-chart')
737
- expect(hit?.line).toBe(2)
738
- })
739
-
740
- it('does NOT flag ChartFrame composing its own derived legend (call expression)', () => {
741
- const text = [
742
- '<ChartLegend',
743
- ' items={deriveLegend(series)}',
744
- ' placement={placement}',
745
- '/>',
746
- ].join('\n')
747
- const f = find(text)
748
- expect(kinds(f)).not.toContain('unframed-chart')
749
- })
750
-
751
- it('does NOT flag an unrelated items={[...]} prop on a different component', () => {
752
- const f = find(`<Menu items={[{ label: 'a' }]} />`)
753
- expect(kinds(f)).not.toContain('unframed-chart')
754
- })
755
-
756
- it('does NOT flag when unframedChart is false', () => {
757
- const f = checkSource(`<ChartLegend items={[{ key: 'a', label: 'A' }]} />`, PATH, {
758
- ...DEFAULT_GUARD_CONFIG,
759
- unframedChart: false,
760
- })
761
- expect(kinds(f)).not.toContain('unframed-chart')
762
- })
763
-
764
- it('does NOT flag a theme-allow line', () => {
765
- const f = find(
766
- `<ChartLegend items={[{ key: 'a', label: 'A' }]} /> // theme-allow: bespoke legend`,
767
- )
768
- expect(kinds(f)).not.toContain('unframed-chart')
769
- })
770
- })
771
-
772
- // ── 15. chart-missing-aria-label ─────────────────────────────────────────────
773
-
774
- describe('chart-missing-aria-label', () => {
775
- it('flags a chart kind tag without ariaLabel', () => {
776
- const f = find(`<ZonedLine data={points} height={240} chartId="x" />`)
777
- expect(kinds(f)).toContain('chart-missing-aria-label')
778
- })
779
-
780
- it('does NOT flag a tag carrying ariaLabel', () => {
781
- const f = find(`<ZonedLine ariaLabel="HRV trend" data={points} height={240} />`)
782
- expect(kinds(f)).not.toContain('chart-missing-aria-label')
783
- })
784
-
785
- it('sees ariaLabel PAST an arrow function in an earlier prop (=> must not end the tag)', () => {
786
- const f = find(`<ZonedLine data={points} getX={(d) => d.date} ariaLabel="HRV trend" />`)
787
- expect(kinds(f)).not.toContain('chart-missing-aria-label')
788
- })
789
-
790
- it('sees ariaLabel past an explicit JSX generic argument', () => {
791
- const text = [
792
- '<MultiLine<ChartPoint>',
793
- ' data={points}',
794
- ' getX={(d) => d.date}',
795
- ' ariaLabel="Body weight trend"',
796
- '/>',
797
- ].join('\n')
798
- const f = find(text)
799
- expect(kinds(f)).not.toContain('chart-missing-aria-label')
800
- })
801
-
802
- it('still flags a generic + arrow-function tag genuinely missing ariaLabel', () => {
803
- const f = find(`<MultiLine<ChartPoint> data={points} getX={(d) => d.date} />`)
804
- expect(kinds(f)).toContain('chart-missing-aria-label')
805
- })
806
-
807
- it('does NOT flag raw-radius when rawRadius is false (framework-internal repos)', () => {
808
- const f = checkSource(`<Paper radius={6} />`, PATH, {
809
- ...DEFAULT_GUARD_CONFIG,
810
- rawRadius: false,
811
- })
812
- expect(kinds(f)).not.toContain('raw-radius')
813
- })
814
-
815
- it('does NOT flag when chartMissingAriaLabel is false', () => {
816
- const f = checkSource(`<ZonedLine data={points} />`, PATH, {
817
- ...DEFAULT_GUARD_CONFIG,
818
- chartMissingAriaLabel: false,
819
- })
820
- expect(kinds(f)).not.toContain('chart-missing-aria-label')
821
- })
822
-
823
- // ── reported line: the tag's OPENING line, not the end of the match ─────────
824
- //
825
- // The scan used to compute its line from the END of the matched tag, so on a multi-line-
826
- // formatted chart element the reported line was the closing `/>` rather than the `<Bars` line
827
- // that is actually missing the prop. Invisible while the report only printed `token`; visible
828
- // the moment `Finding.text` started quoting the reported source line back.
829
-
830
- it('reports the OPENING tag line (and quotes it in text) for a multi-line-formatted chart element', () => {
831
- const text = ['<Bars', ' data={points}', ' height={240}', '/>'].join('\n')
832
- const f = find(text)
833
- const hit = f.find((x) => x.kind === 'chart-missing-aria-label')
834
- expect(hit?.line).toBe(1)
835
- expect(hit?.text).toBe('<Bars')
836
- })
837
-
838
- it('reports the same line as before for a single-line chart element (no regression)', () => {
839
- const f = find(`<ZonedLine data={points} height={240} chartId="x" />`)
840
- const hit = f.find((x) => x.kind === 'chart-missing-aria-label')
841
- expect(hit?.line).toBe(1)
842
- })
843
-
844
- it('a theme-allow comment on the CLOSING line of a multi-line tag still suppresses the finding (back-compat)', () => {
845
- const text = ['<Bars', ' data={points}', ' height={240}', '/> // theme-allow'].join('\n')
846
- const f = find(text)
847
- expect(kinds(f)).not.toContain('chart-missing-aria-label')
848
- })
849
-
850
- it('a theme-allow comment on the OPENING line of a multi-line tag also suppresses the finding', () => {
851
- const text = ['<Bars // theme-allow', ' data={points}', ' height={240}', '/>'].join('\n')
852
- const f = find(text)
853
- expect(kinds(f)).not.toContain('chart-missing-aria-label')
854
- })
855
-
856
- // ── tag provenance ─────────────────────────────────────────────────────────
857
- //
858
- // The rule keys on a tag NAME, so a consumer's OWN component sharing a shipped kind's name
859
- // collected an unactionable `ariaLabel` demand (reported against 1.23.0: a hand-composed local
860
- // `MirroredBars`, 235 lines, nothing to do with the kind). The gate is a one-directional
861
- // narrowing — skip only what this file DEFINES and does not import from basalt-ui.
862
-
863
- it('does NOT flag a tag this file DEFINES itself (a local component sharing a kind name)', () => {
864
- const text = [
865
- 'function MirroredBars({ data }: Props) {',
866
- ' return <svg />',
867
- '}',
868
- 'export function Panel() {',
869
- ' return <MirroredBars data={points} />',
870
- '}',
871
- ].join('\n')
872
- expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
873
- })
874
-
875
- it('skips a locally defined kind name however it is declared (const/memo, not just function)', () => {
876
- const text = ['const BandStrip = memo(Inner)', '<BandStrip data={rows} />'].join('\n')
877
- expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
878
- })
879
-
880
- it('STILL flags when the same file also imports that name from basalt-ui (a real shadow)', () => {
881
- const text = [
882
- "import { MirroredBars } from 'basalt-ui/charts'",
883
- 'function Panel() {',
884
- ' return <MirroredBars data={points} />',
885
- '}',
886
- ].join('\n')
887
- expect(kinds(find(text))).toContain('chart-missing-aria-label')
888
- })
889
-
890
- it('STILL flags a tag imported from a consumer BARREL that re-exports the kind', () => {
891
- const text = ["import { MultiLine } from '../charts'", '<MultiLine data={points} />'].join('\n')
892
- expect(kinds(find(text))).toContain('chart-missing-aria-label')
893
- })
894
-
895
- it('STILL flags a file with no imports and no local definition (coverage is not import-gated)', () => {
896
- expect(kinds(find('<Donut data={slices} />'))).toContain('chart-missing-aria-label')
897
- })
898
-
899
- it('honours an aliased basalt import, since the alias is the tag the JSX writes', () => {
900
- const text = [
901
- "import { Bars as BasaltBars } from 'basalt-ui/charts'",
902
- 'const Bars = () => null',
903
- '<Bars data={points} />',
904
- ].join('\n')
905
- // `Bars` is defined locally here and only `BasaltBars` came from basalt — the local one is skipped.
906
- expect(kinds(find(text))).not.toContain('chart-missing-aria-label')
907
- })
908
- })
909
-
910
- describe('unframed-chart — the same provenance gate', () => {
911
- it('does NOT flag a consumer’s OWN ChartLegend taking an items array', () => {
912
- const text = [
913
- 'function ChartLegend({ items }: Props) {',
914
- ' return <ul />',
915
- '}',
916
- 'const view = <ChartLegend items={[{ label: "a" }]} />',
917
- ].join('\n')
918
- expect(kinds(find(text))).not.toContain('unframed-chart')
919
- })
920
-
921
- it('still flags the shipped ChartLegend built from an inline array', () => {
922
- const text = [
923
- "import { ChartLegend } from 'basalt-ui/charts'",
924
- 'const view = <ChartLegend items={[{ label: "a" }]} />',
925
- ].join('\n')
926
- expect(kinds(find(text))).toContain('unframed-chart')
927
- })
928
- })
929
-
930
- // ── 16. card-with-border ─────────────────────────────────────────────────────
931
-
932
- describe('card-with-border', () => {
933
- it('flags withBorder on a Card', () => {
934
- const f = find(`<Card padding="md" withBorder h="100%">{children}</Card>`)
935
- expect(kinds(f)).toContain('card-with-border')
936
- })
937
-
938
- it('flags withBorder on a Paper', () => {
939
- const f = find(`<Paper withBorder p="sm" />`)
940
- expect(kinds(f)).toContain('card-with-border')
941
- })
942
-
943
- it('flags withBorder in a multi-line-formatted tag, reporting the prop line', () => {
944
- const f = find(`<Card\n padding="md"\n withBorder\n h="100%"\n>`)
945
- const finding = f.find((v) => v.kind === 'card-with-border')
946
- expect(finding?.line).toBe(3)
947
- })
948
-
949
- it('does NOT flag a Card with no withBorder', () => {
950
- const f = find(`<Card padding="md" h="100%">{children}</Card>`)
951
- expect(kinds(f)).not.toContain('card-with-border')
952
- })
953
-
954
- it('does NOT flag an explicit withBorder={false} opt-out', () => {
955
- const f = find(`<Paper withBorder={false} p="sm" />`)
956
- expect(kinds(f)).not.toContain('card-with-border')
957
- })
958
-
959
- it('does NOT flag Card.Section withBorder (a section divider, not card depth)', () => {
960
- const f = find(`<Card.Section withBorder inheritPadding py="xs">{header}</Card.Section>`)
961
- expect(kinds(f)).not.toContain('card-with-border')
962
- })
963
-
964
- it('does NOT flag withBorder on a non-card surface (Table keeps its own borders)', () => {
965
- const f = find(`<Table withBorder striped />`)
966
- expect(kinds(f)).not.toContain('card-with-border')
967
- })
968
-
969
- it('does NOT flag a component whose name merely starts with Card', () => {
970
- const f = find(`<CardHeader withBorder />`)
971
- expect(kinds(f)).not.toContain('card-with-border')
972
- })
973
-
974
- it('does NOT flag when cardWithBorder is false', () => {
975
- const f = checkSource(`<Card withBorder />`, PATH, {
976
- ...DEFAULT_GUARD_CONFIG,
977
- cardWithBorder: false,
978
- })
979
- expect(kinds(f)).not.toContain('card-with-border')
980
- })
981
- })
982
-
983
- // ── 17. raw-form-control ─────────────────────────────────────────────────────
984
-
985
- describe('raw-form-control', () => {
986
- it('flags a raw <input>', () => {
987
- const f = find(`<input type="text" value={v} onChange={onChange} />`)
988
- expect(kinds(f)).toContain('raw-form-control')
989
- })
990
-
991
- it('flags a raw <select>', () => {
992
- const f = find(`<select value={v}><option value="a">A</option></select>`)
993
- expect(kinds(f)).toContain('raw-form-control')
994
- })
995
-
996
- it('flags a raw <textarea>', () => {
997
- const f = find(`<textarea value={v} onChange={onChange} />`)
998
- expect(kinds(f)).toContain('raw-form-control')
999
- })
1000
-
1001
- it('does NOT flag a Mantine <TextInput>', () => {
1002
- const f = find(`<TextInput label="Name" value={v} onChange={onChange} />`)
1003
- expect(kinds(f)).not.toContain('raw-form-control')
1004
- })
1005
-
1006
- it('does NOT flag a Mantine <Select>', () => {
1007
- const f = find(`<Select data={options} value={v} />`)
1008
- expect(kinds(f)).not.toContain('raw-form-control')
1009
- })
1010
-
1011
- it('does NOT flag a component whose name merely starts with the tag name (inputRef)', () => {
1012
- const f = find(`<inputRef.current.focus() />`)
1013
- expect(kinds(f)).not.toContain('raw-form-control')
1014
- })
1015
-
1016
- it('does NOT flag a theme-allow line', () => {
1017
- const f = find(`<input type="text" /> // theme-allow: legacy widget`)
1018
- expect(kinds(f)).not.toContain('raw-form-control')
1019
- })
1020
-
1021
- it('does NOT flag when rawFormControl is false', () => {
1022
- const f = checkSource(`<input type="text" />`, PATH, {
1023
- ...DEFAULT_GUARD_CONFIG,
1024
- rawFormControl: false,
1025
- })
1026
- expect(kinds(f)).not.toContain('raw-form-control')
1027
- })
1028
- })
1029
-
1030
- // ── 18. sub-16-input-font ────────────────────────────────────────────────────
1031
-
1032
- describe('sub-16-input-font', () => {
1033
- it('flags a sub-16 fontSize inline style on a raw <input>', () => {
1034
- const f = find(`<input style={{ fontSize: 13, border: 'none' }} />`)
1035
- expect(kinds(f)).toContain('sub-16-input-font')
1036
- })
1037
-
1038
- it('flags a sub-16 fontSize inline style on a raw <textarea>', () => {
1039
- const f = find(`<textarea style={{ fontSize: 12 }} />`)
1040
- expect(kinds(f)).toContain('sub-16-input-font')
1041
- })
1042
-
1043
- it('flags a quoted "12px" fontSize value', () => {
1044
- const f = find(`<input style={{ fontSize: '12px' }} />`)
1045
- expect(kinds(f)).toContain('sub-16-input-font')
1046
- })
1047
-
1048
- it('flags a Mantine styles={{ input: { fontSize } }} per-part override', () => {
1049
- const f = find(`<TextInput styles={{ input: { fontSize: 12 } }} />`)
1050
- expect(kinds(f)).toContain('sub-16-input-font')
1051
- })
1052
-
1053
- it('does NOT flag a fontSize of 16 or above on a raw input (already at/above the floor)', () => {
1054
- const f = find(`<input style={{ fontSize: 16 }} />`)
1055
- expect(kinds(f)).not.toContain('sub-16-input-font')
1056
- })
1057
-
1058
- it('does NOT flag a fontSize below 16 on a <Text> (not a form control)', () => {
1059
- const f = find(`<Text style={{ fontSize: 12 }}>caption</Text>`)
1060
- expect(kinds(f)).not.toContain('sub-16-input-font')
1061
- })
1062
-
1063
- it('does NOT flag a fontSize below 16 on a <span> (not a form control)', () => {
1064
- const f = find(`<span style={{ fontSize: 11 }}>micro-label</span>`)
1065
- expect(kinds(f)).not.toContain('sub-16-input-font')
1066
- })
1067
-
1068
- it('does NOT flag a fontSize below 16 on a <Code> chart label (not a form control)', () => {
1069
- const f = find(`<Code style={{ fontSize: 10 }}>{value}</Code>`)
1070
- expect(kinds(f)).not.toContain('sub-16-input-font')
1071
- })
1072
-
1073
- it('does NOT flag a rem-unit fontSize (ambiguous relative to a px floor, deliberately not matched)', () => {
1074
- const f = find(`<input style={{ fontSize: '0.8rem' }} />`)
1075
- expect(kinds(f)).not.toContain('sub-16-input-font')
1076
- })
1077
-
1078
- it('does NOT flag a raw <input> with no style at all', () => {
1079
- const f = find(`<input type="text" value={v} />`)
1080
- expect(kinds(f)).not.toContain('sub-16-input-font')
1081
- })
1082
-
1083
- it('does NOT flag a Mantine styles={{ root: {...} }} override (targets the wrapper, not the input)', () => {
1084
- const f = find(`<TextInput styles={{ root: { fontSize: 12 } }} />`)
1085
- expect(kinds(f)).not.toContain('sub-16-input-font')
1086
- })
1087
-
1088
- it('does NOT flag a theme-allow line', () => {
1089
- const f = find(`<input style={{ fontSize: 12 }} /> // theme-allow: legacy widget`)
1090
- expect(kinds(f)).not.toContain('sub-16-input-font')
1091
- })
1092
-
1093
- it('does NOT flag when sub16InputFont is false', () => {
1094
- const f = checkSource(`<input style={{ fontSize: 12 }} />`, PATH, {
1095
- ...DEFAULT_GUARD_CONFIG,
1096
- sub16InputFont: false,
1097
- })
1098
- expect(kinds(f)).not.toContain('sub-16-input-font')
1099
- })
1100
- })
1101
-
1102
- // ── 19. raw-font-family ──────────────────────────────────────────────────────
1103
-
1104
- describe('raw-font-family', () => {
1105
- it('flags a quoted fontFamily object property', () => {
1106
- const f = find(`const s = { fontFamily: 'Inter, sans-serif' }`)
1107
- expect(kinds(f)).toContain('raw-font-family')
1108
- })
1109
-
1110
- it('flags a quoted fontFamily JSX prop', () => {
1111
- const f = find(`<Text style={{ fontFamily: "Arial" }}>hi</Text>`)
1112
- expect(kinds(f)).toContain('raw-font-family')
1113
- })
1114
-
1115
- it('flags a bare kebab-case font-family CSS declaration', () => {
1116
- const f = find(`font-family: Inter;`)
1117
- expect(kinds(f)).toContain('raw-font-family')
1118
- })
1119
-
1120
- it('does NOT flag a fontFamily bound to a var(--basalt-font-…) reference', () => {
1121
- const f = find(`fontFamily: 'var(--basalt-font-sans, ui-sans-serif, system-ui, sans-serif)',`)
1122
- expect(kinds(f)).not.toContain('raw-font-family')
1123
- })
1124
-
1125
- it('does NOT flag a kebab-case font-family bound to var(…)', () => {
1126
- const f = find(`font-family: var(--basalt-font-mono);`)
1127
- expect(kinds(f)).not.toContain('raw-font-family')
1128
- })
1129
-
1130
- it('does NOT flag an unquoted fontFamily identifier reference (a token ref, not a literal)', () => {
1131
- const f = find(`fontFamily: LABEL_FONT_FAMILY,`)
1132
- expect(kinds(f)).not.toContain('raw-font-family')
1133
- })
1134
-
1135
- it('does NOT flag a theme-allow line', () => {
1136
- const f = find(`fontFamily: 'Inter, sans-serif', // theme-allow: legacy widget`)
1137
- expect(kinds(f)).not.toContain('raw-font-family')
1138
- })
1139
-
1140
- it('flags a quoted kebab-case font-family CSS value (the (a) bypass)', () => {
1141
- const f = find(`font-family: 'Inter', sans-serif;`)
1142
- expect(kinds(f)).toContain('raw-font-family')
1143
- })
1144
-
1145
- // A var() REFERENCE is not a literal, whichever custom property it names. The escape used to be
1146
- // restricted to `--basalt-font-*` / `--mantine-font-family-*`, which reported a framework-free
1147
- // consumer's `font-family: var(--font-sans)` as "a hardcoded fontFamily literal" and pointed the
1148
- // fix at the React-only `createBasaltTheme`. The single-entry-point invariant is doctrine for
1149
- // basalt's own theme layer; this regex cannot tell it apart from a consumer's own indirection.
1150
- it('does NOT flag a fontFamily bound to a var(...) reference outside basalt’s own prefixes', () => {
1151
- const f = find(`fontFamily: 'var(--some-other-var)'`)
1152
- expect(kinds(f)).not.toContain('raw-font-family')
1153
- })
1154
-
1155
- it('does NOT flag a kebab-case font-family bound to a consumer var(...) reference', () => {
1156
- const f = find(`font-family: var(--font-sans);`)
1157
- expect(kinds(f)).not.toContain('raw-font-family')
1158
- })
1159
-
1160
- it('does NOT flag a kebab-case font-family bound to var(--mantine-font-family-…)', () => {
1161
- const f = find(`fontFamily: 'var(--mantine-font-family-monospace)',`)
1162
- expect(kinds(f)).not.toContain('raw-font-family')
1163
- })
1164
-
1165
- it('does NOT flag the CSS-wide keyword inherit (camelCase, quoted)', () => {
1166
- const f = find(`fontFamily: 'inherit'`)
1167
- expect(kinds(f)).not.toContain('raw-font-family')
1168
- })
1169
-
1170
- it('does NOT flag the CSS-wide keyword initial (kebab-case, bare)', () => {
1171
- const f = find(`font-family: initial;`)
1172
- expect(kinds(f)).not.toContain('raw-font-family')
1173
- })
1174
-
1175
- it('does NOT flag the CSS-wide keyword unset', () => {
1176
- const f = find(`font-family: unset;`)
1177
- expect(kinds(f)).not.toContain('raw-font-family')
1178
- })
1179
-
1180
- it('does NOT flag the CSS-wide keyword revert', () => {
1181
- const f = find(`font-family: revert;`)
1182
- expect(kinds(f)).not.toContain('raw-font-family')
1183
- })
1184
- })
1185
-
1186
- // ── theme-allow skip ─────────────────────────────────────────────────────────
1187
-
1188
- describe('theme-allow skip', () => {
1189
- it('skips the entire line when it contains the allow comment', () => {
1190
- const text = `
1191
- const a = '#ff0000' // theme-allow
1192
- const b = '#00ff00'
1193
- `.trim()
1194
- const f = find(text)
1195
- // only the second line should fire
1196
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1197
- expect(f.find((x) => x.kind === 'raw-hex')?.line).toBe(2)
1198
- })
1199
-
1200
- it('supports a custom allowComment value', () => {
1201
- const f = checkSource(`const c = '#ff0000' // ok-to-use-raw`, PATH, {
1202
- ...DEFAULT_GUARD_CONFIG,
1203
- allowComment: 'ok-to-use-raw',
1204
- })
1205
- expect(kinds(f)).not.toContain('raw-hex')
1206
- })
1207
- })
1208
-
1209
- // ── pure-comment skip ─────────────────────────────────────────────────────────
1210
-
1211
- describe('pure-comment skip', () => {
1212
- it('skips a // line comment', () => {
1213
- const f = find(`// const c = '#ff0000'`)
1214
- expect(kinds(f)).not.toContain('raw-hex')
1215
- })
1216
-
1217
- it('skips a JSDoc body line that is genuinely inside a block comment', () => {
1218
- const f = find('/**\n * Use rgba() for opacity, not raw hex\n */')
1219
- expect(kinds(f)).not.toContain('raw-color-fn')
1220
- })
1221
-
1222
- it('skips a /* start-of-block line', () => {
1223
- const f = find(`/* const c = '#aabbcc' */`)
1224
- expect(kinds(f)).not.toContain('raw-hex')
1225
- })
1226
-
1227
- it('still flags a code line that follows a comment', () => {
1228
- const text = `// this is a comment\nconst c = '#ff0000'`
1229
- const f = find(text)
1230
- expect(kinds(f)).toContain('raw-hex')
1231
- })
1232
- })
1233
-
1234
- // ── stripComments pre-pass: real block/line-comment awareness ───────────────────
1235
- //
1236
- // checkSource used to decide "is this a comment?" per line, from that line's OWN leading prefix
1237
- // (`//` / `*` / `/*`). A block-comment CONTINUATION line that didn't happen to start with `*` (the
1238
- // common "wrapped prose" style, e.g. styles.css) slipped straight through as code — a real
1239
- // false-positive, not a hypothetical one (see the 3 real sites this fixed, asserted in
1240
- // index.test.ts / reproduced below). These cases prove the replacement — an actual comment-
1241
- // stripping pre-pass — neither under- nor over-strips.
1242
-
1243
- describe('stripComments pre-pass', () => {
1244
- it('does NOT flag a hex on a block-comment CONTINUATION line with no leading *', () => {
1245
- // This is exactly the styles.css shape: a /* ... */ block whose middle lines are wrapped
1246
- // prose, not `* `-prefixed. The old per-line prefix heuristic missed this entirely.
1247
- const text = ['/* line one', ' #e5e5e5 in the middle, no leading star', 'line three */'].join(
1248
- '\n',
1249
- )
1250
- const f = find(text)
1251
- expect(kinds(f)).not.toContain('raw-hex')
1252
- })
1253
-
1254
- it('flags a real hex that follows a same-line /* */ comment', () => {
1255
- const f = find(`/* note */ const c = '#ff0000'`)
1256
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1257
- })
1258
-
1259
- it('flags a hex inside a single-quoted string with a trailing // comment', () => {
1260
- const f = find(`const c = '#ff0000' // trailing note`)
1261
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1262
- expect(f.find((x) => x.kind === 'raw-hex')?.token).toBe('#ff0000')
1263
- })
1264
-
1265
- it('does not mangle a URL hash-fragment inside a quoted string via // handling', () => {
1266
- // The `//` in `https://` sits INSIDE a single-quoted string, so it must not be treated as a
1267
- // line-comment opener. The guard's raw-hex regex has no URL awareness (never did — that's
1268
- // unrelated to this fix), so it still matches the `#aabbcc` fragment literally: the real-code
1269
- // semantics here are "1 finding", proving the string content survived the strip untouched
1270
- // rather than being blanked out by an over-eager `//` rule.
1271
- const f = find(`const u = 'https://example.com/#aabbcc'`)
1272
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1273
- })
1274
-
1275
- it('preserves the exact original line number across a preceding multi-line block comment', () => {
1276
- const text = ['/*', 'line2', 'line3', 'line4', 'end */', '', "const c = '#ff0000'"].join('\n')
1277
- const f = find(text)
1278
- const hit = f.find((x) => x.kind === 'raw-hex')
1279
- expect(hit?.line).toBe(7)
1280
- })
1281
-
1282
- it('still honors the theme-allow escape on a code line', () => {
1283
- const f = find(`const c = '#ff0000' // theme-allow: legacy`)
1284
- expect(kinds(f)).not.toContain('raw-hex')
1285
- })
1286
-
1287
- it('does not crash on an unterminated block comment at EOF, and strips everything after it', () => {
1288
- const text = `const before = '#ff0000'\n/* unterminated\nconst c = '#00ff00'`
1289
- const f = find(text)
1290
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1291
- expect(f.find((x) => x.kind === 'raw-hex')?.token).toBe('#ff0000')
1292
- })
1293
-
1294
- it('still finds a hex inside a backtick template literal containing ${}', () => {
1295
- const f = find('const s = `value ${x} is #ff0000`')
1296
- expect(kinds(f)).toContain('raw-hex')
1297
- })
1298
-
1299
- it('CSS-shaped input: flags only the real declaration, not the comment mention', () => {
1300
- const text = '/* comment with #e5e5e5 */\n.a { color: #ff0000; }'
1301
- const f = find(text)
1302
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1303
- expect(f.find((x) => x.kind === 'raw-hex')?.token).toBe('#ff0000')
1304
- })
1305
-
1306
- // ── regex-literal disambiguation (BUG 1: a `/*`- or `//`-shaped sequence INSIDE a regex body
1307
- // must never be misread as opening a comment — the char-class case ran away to EOF and silently
1308
- // deleted guard coverage for the rest of the file) ──────────────────────────────────────────────
1309
-
1310
- it('does not treat a `/*` inside a regex character class as a block-comment opener (BUG 1 repro)', () => {
1311
- const text = "const RE = /[/*]/\nconst c = '#ff0000'"
1312
- const f = find(text)
1313
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1314
- })
1315
-
1316
- it('does not treat two `/` inside a regex character class as a line-comment opener', () => {
1317
- const text = "const RE = /[//]/\nconst c = '#ff0000'"
1318
- const f = find(text)
1319
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1320
- })
1321
-
1322
- it('treats a `/` between identifiers as division, not a regex open', () => {
1323
- const text = "const a = x / y\nconst c = '#ff0000'"
1324
- const f = find(text)
1325
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1326
- })
1327
-
1328
- it('treats a `/` as division and still finds a REAL // comment later on the same line', () => {
1329
- const text = "const a = x / y // #aabbcc\nconst c = '#ff0000'"
1330
- const f = find(text)
1331
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1332
- })
1333
-
1334
- it('consumes a regex literal with an escaped slash without closing early', () => {
1335
- const text = "const RE = /a\\/b/\nconst c = '#ff0000'"
1336
- const f = find(text)
1337
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1338
- })
1339
-
1340
- it('still recognizes a real block comment right after a regex literal (regex handling did not break comment handling)', () => {
1341
- const text = 'const RE = /abc/\n/* #ff0000 */'
1342
- const f = find(text)
1343
- expect(kinds(f)).not.toContain('raw-hex')
1344
- })
1345
-
1346
- // ── CSS vs TS syntax split (BUG 2: an unquoted `//` in CSS, e.g. inside `url(https://…)`,
1347
- // is real CSS text, not a comment opener — CSS has no `//` comment syntax at all) ───────────────
1348
-
1349
- it('does not treat an unquoted "//" inside a CSS url() as a comment opener (BUG 2 repro)', () => {
1350
- const f = checkSource(
1351
- '.a { background: url(https://x.com/a.png); color: #ff0000; }',
1352
- 'src/styles.css',
1353
- DEFAULT_GUARD_CONFIG,
1354
- )
1355
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1356
- })
1357
-
1358
- it('still strips a real /* */ block comment in a .css file alongside an unquoted url()', () => {
1359
- const f = checkSource(
1360
- '.a { background: url(https://x.com/a.png); /* #aabbcc */ color: #ff0000; }',
1361
- 'src/styles.css',
1362
- DEFAULT_GUARD_CONFIG,
1363
- )
1364
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1365
- expect(f.find((x) => x.kind === 'raw-hex')?.token).toBe('#ff0000')
1366
- })
1367
-
1368
- it('resolves a .module.css path to CSS syntax too (suffix match, not a naive split)', () => {
1369
- const f = checkSource(
1370
- '.a { background: url(https://x.com/a.png); color: #ff0000; }',
1371
- 'src/Card.module.css',
1372
- DEFAULT_GUARD_CONFIG,
1373
- )
1374
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1375
- })
1376
-
1377
- it('a .ts file still strips a real // line comment (the CSS/TS syntax split did not disable TS)', () => {
1378
- const f = checkSource('// #ff0000\nconst ok = 1', 'src/x.ts', DEFAULT_GUARD_CONFIG)
1379
- expect(kinds(f)).not.toContain('raw-hex')
1380
- })
1381
- })
1382
-
1383
- // ── isChartFile gates raw-visx-axis ──────────────────────────────────────────
1384
-
1385
- describe('isChartFile path gate', () => {
1386
- it('fires raw-visx-axis in src/charts/kinds/k.tsx', () => {
1387
- const f = checkSource(`<AxisLeft scale={s} />`, 'src/charts/kinds/k.tsx', DEFAULT_GUARD_CONFIG)
1388
- expect(kinds(f)).toContain('raw-visx-axis')
1389
- })
1390
-
1391
- it('does NOT fire raw-visx-axis in src/page.tsx', () => {
1392
- const f = checkSource(`<AxisLeft scale={s} />`, 'src/page.tsx', DEFAULT_GUARD_CONFIG)
1393
- expect(kinds(f)).not.toContain('raw-visx-axis')
1394
- })
1395
- })
1396
-
1397
- // ── CSS applicability — the CLI walker now scans .css, so checkSource's per-kind gating over
1398
- // CSS-shaped input matters for real: color-meaningful kinds must still fire, JSX/tag-shaped kinds
1399
- // must not false-positive on genuine CSS syntax ──────────────────────────────────────────────────
1400
-
1401
- describe('CSS applicability', () => {
1402
- it('flags a raw hex in a real CSS declaration (proves .css is genuinely scanned)', () => {
1403
- const f = checkSource(
1404
- '.input {\n color: #ff0000;\n}\n',
1405
- 'src/Card.module.css',
1406
- DEFAULT_GUARD_CONFIG,
1407
- )
1408
- expect(kinds(f)).toContain('raw-hex')
1409
- })
1410
-
1411
- it('does NOT false-positive inline-display/raw-html-layout/raw-surface on genuine CSS shapes', () => {
1412
- const f = checkSource(
1413
- '.foo {\n display: flex;\n padding: 8px;\n border-radius: 8px;\n}\n',
1414
- 'src/Card.module.css',
1415
- DEFAULT_GUARD_CONFIG,
1416
- )
1417
- expect(kinds(f)).not.toContain('inline-display')
1418
- expect(kinds(f)).not.toContain('raw-html-layout')
1419
- expect(kinds(f)).not.toContain('raw-surface')
1420
- })
1421
-
1422
- it('still flags off-system-surface-var on a raw Mantine ramp step inside CSS', () => {
1423
- const f = checkSource(
1424
- '.foo {\n background: var(--mantine-color-gray-3);\n}\n',
1425
- 'src/Card.module.css',
1426
- DEFAULT_GUARD_CONFIG,
1427
- )
1428
- expect(kinds(f)).toContain('off-system-surface-var')
1429
- })
1430
-
1431
- it('still flags raw-font-family on a kebab-case CSS font-family declaration', () => {
1432
- const f = checkSource(
1433
- '.foo {\n font-family: system-ui;\n}\n',
1434
- 'src/Card.module.css',
1435
- DEFAULT_GUARD_CONFIG,
1436
- )
1437
- expect(kinds(f)).toContain('raw-font-family')
1438
- })
1439
-
1440
- it('still flags inline-spacing on a genuine CSS padding/margin/gap declaration (the pattern is CSS-shaped, not JSX-only, despite the name)', () => {
1441
- const f = checkSource(
1442
- '.foo {\n padding: 18px;\n margin: 13px;\n gap: 26px;\n}\n',
1443
- 'src/Card.module.css',
1444
- DEFAULT_GUARD_CONFIG,
1445
- )
1446
- expect(kinds(f).filter((k) => k === 'inline-spacing')).toHaveLength(3)
1447
- })
1448
-
1449
- // ── The CSS-only sub-scale escape ───────────────────────────────────────────
1450
- //
1451
- // basalt-tokens.md blesses 2-8px micro-spacing raw, but that permission only ever held for JSX
1452
- // PROPS: `pl={4}` is not a `prop: value` pair and never matched the pattern. In CSS every
1453
- // declaration is `prop: value`, so a consumer's 4px cluster gap was flagged with nothing legal to
1454
- // do about it — no token exists below the scale floor, and `exemptRules` matches whole path
1455
- // segments so it cannot express `*.module.css`.
1456
-
1457
- const cssSpacing = (decl: string): GuardKind[] =>
1458
- kinds(
1459
- checkSource(`.foo {\n ${decl};\n}\n`, 'src/Card.module.css', DEFAULT_GUARD_CONFIG),
1460
- ).filter((k) => k === 'inline-spacing')
1461
-
1462
- it('does NOT flag sub-scale micro-spacing in CSS — there is no token to prefer below the floor', () => {
1463
- for (const decl of ['gap: 2px', 'padding: 4px', 'margin-bottom: 6px', 'padding: 8px']) {
1464
- expect(cssSpacing(decl)).toEqual([])
1465
- }
1466
- })
1467
-
1468
- it('judges a multi-value shorthand as a whole', () => {
1469
- expect(cssSpacing('padding: 4px 8px')).toEqual([])
1470
- // 16px has a scale stop; one non-micro component makes the whole declaration a finding.
1471
- expect(cssSpacing('padding: 4px 16px')).toEqual(['inline-spacing'])
1472
- })
1473
-
1474
- it('drops var() components before judging — a mixed value is already tokenized where it counts', () => {
1475
- expect(cssSpacing('padding: 4px var(--vx-space-stack-xs)')).toEqual([])
1476
- })
1477
-
1478
- it('does not extend the escape to other units — the doctrine is stated in px', () => {
1479
- expect(cssSpacing('padding: 1.5rem')).toEqual(['inline-spacing'])
1480
- expect(cssSpacing('padding: 2em')).toEqual(['inline-spacing'])
1481
- })
1482
-
1483
- it('does NOT read a unitless fraction — that is a ratio, not a length', () => {
1484
- // visx band padding. Ten of these in one consumer's chart files the day the leading-decimal fix
1485
- // shipped: `padding` here is dimensionless 0–1, and "use p/m/gap with xs..xl" cannot be done to
1486
- // it. Admitting `.75rem` and admitting `0.3` are the same regex change; the unit tells them
1487
- // apart. Scoping inline-spacing out of chart files would also have cleared it, and would have
1488
- // dropped real coverage inside charts/ while leaving the false positive live everywhere else.
1489
- const scale = (src: string): GuardKind[] =>
1490
- kinds(checkSource(src, 'src/charts/kinds/Bars.tsx', DEFAULT_GUARD_CONFIG)).filter(
1491
- (k) => k === 'inline-spacing',
1492
- )
1493
- expect(
1494
- scale('const s = scalePoint<string>({ domain: d, range: [0, w], padding: 0.3 })'),
1495
- ).toEqual([])
1496
- expect(scale('const s = scaleBand({ padding: .2 })')).toEqual([])
1497
- // A unitless INTEGER is still 4px by React's convention, and still a finding.
1498
- expect(scale('<Box style={{ padding: 4 }} />')).toEqual(['inline-spacing'])
1499
- // A fraction WITH a unit is a real length and still flags — above the sub-scale ceiling, since
1500
- // 0.3rem is 4.8px and would be excused by the micro-spacing escape for that reason instead.
1501
- expect(cssSpacing('padding: 0.7rem')).toEqual(['inline-spacing'])
1502
- })
1503
-
1504
- it('reads a CSS number without its integer part, and one carrying an explicit +', () => {
1505
- // `.75rem` IS `0.75rem` and `+12px` IS `12px`. Requiring a leading digit skipped both — the
1506
- // same silent skip the `0.` guard was fixed for, one spelling over.
1507
- expect(cssSpacing('padding: .75rem')).toEqual(['inline-spacing'])
1508
- expect(cssSpacing('padding: +12px')).toEqual(['inline-spacing'])
1509
- // …and the sub-scale escape resolves them the same way: .5rem is 8px, below the floor.
1510
- expect(cssSpacing('padding: .5rem')).toEqual([])
1511
- })
1512
-
1513
- it('does NOT read a custom property whose NAME ends in a spacing word', () => {
1514
- // A `\b` boundary sits inside a hyphenated identifier, so these matched as declarations. The
1515
- // first is real generated output from the token layer; the second is the shape a consumer
1516
- // writes. A custom property is a definition, not a rendered spacing decision.
1517
- expect(cssSpacing('--vx-space-article-header-padding-bottom: 20px')).toEqual([])
1518
- expect(cssSpacing('--card-padding-inline: 20px')).toEqual([])
1519
- expect(cssSpacing('--card-gap: 20px')).toEqual([])
1520
- // The declaration itself still flags — the fix narrows the boundary, it does not disarm it.
1521
- expect(cssSpacing('padding-bottom: 20px')).toEqual(['inline-spacing'])
1522
- expect(cssSpacing('padding-inline: 20px')).toEqual(['inline-spacing'])
1523
- })
1524
-
1525
- it('stays case-sensitive — neither dialect spells a property `Padding-Top`', () => {
1526
- // Pins the decision documented above BOX_SIDE_KEBAB: unifying the two spellings with an `i`
1527
- // flag would be the short way and would also match casings that are not properties at all.
1528
- expect(cssSpacing('Padding-Top: 18px')).toEqual([])
1529
- expect(cssSpacing('PADDING: 18px')).toEqual([])
1530
- })
1531
-
1532
- it('keeps the default spacing steps in lockstep with the real scale', () => {
1533
- // The guard's copy of the xs..xl ladder drifted once already (it still read 10/12/16/20/32
1534
- // after the level-0 retune, so `p={16}` was flagged in favour of a `p="md"` worth 18, and the
1535
- // genuine `p={18}` went unflagged). The copy exists to keep the headless core one file; this
1536
- // asserts it stays honest.
1537
- expect(DEFAULT_GUARD_CONFIG.spacingSteps).toEqual(Object.values(SPACE_SCALE))
1538
- })
1539
-
1540
- it('resolves rem against the same root the token layer converts against', () => {
1541
- // The guard keeps its own copy of the 16px root to stay dependency-free. The spacing-steps copy
1542
- // beside it drifted in production once; this pins the relationship rather than the number, so a
1543
- // token layer that ever moved off a 16px root would fail here instead of silently disagreeing.
1544
- // 10px is the ceiling; 12px is the first value above it. Expressed through `pxRem` so the
1545
- // literals here follow the token layer's conversion rather than restating it.
1546
- expect(cssSpacing(`padding: ${pxRem(10)}`)).toEqual([])
1547
- expect(cssSpacing(`padding: ${pxRem(12)}`)).toEqual(['inline-spacing'])
1548
- // The px spellings of the same two values must land identically.
1549
- expect(cssSpacing('padding: 10px')).toEqual([])
1550
- expect(cssSpacing('padding: 12px')).toEqual(['inline-spacing'])
1551
- })
1552
-
1553
- it('keeps the ceiling below the smallest scale stop, so it can never strand a real token', () => {
1554
- // The relationship, not the number: if a density retune moves SPACE_SCALE.xs, this still holds.
1555
- expect(cssSpacing(`gap: ${SPACE_SCALE.xs}px`)).toEqual(['inline-spacing'])
1556
- expect(cssSpacing(`gap: ${SPACE_SCALE.xs - 1}px`)).toEqual([])
1557
- })
1558
-
1559
- it('leaves TSX untouched — an inline style object still has a prop form to prefer', () => {
1560
- // `pl={4}` is a prop and never matched; `style={{ padding: 4 }}` did, and still should.
1561
- expect(
1562
- kinds(checkSource('<Box style={{ padding: 4 }} />', PATH, DEFAULT_GUARD_CONFIG)),
1563
- ).toContain('inline-spacing')
1564
- expect(kinds(checkSource('<Box pl={4} />', PATH, DEFAULT_GUARD_CONFIG))).not.toContain(
1565
- 'inline-spacing',
1566
- )
1567
- })
1568
-
1569
- it('does NOT false-positive raw-form-control on a CSS element selector (input, select, textarea)', () => {
1570
- const f = checkSource(
1571
- 'input, select, textarea {\n color: red;\n}\n',
1572
- 'src/Card.module.css',
1573
- DEFAULT_GUARD_CONFIG,
1574
- )
1575
- expect(kinds(f)).not.toContain('raw-form-control')
1576
- })
1577
-
1578
- it('does NOT false-positive raw-spacing/raw-radius/off-identity-accent/raw-motion-value on CSS property syntax', () => {
1579
- const f = checkSource(
1580
- '.foo {\n border-radius: 6px;\n transition: all 0.3s ease;\n color: teal;\n gap: 16px;\n}\n',
1581
- 'src/Card.module.css',
1582
- DEFAULT_GUARD_CONFIG,
1583
- )
1584
- expect(kinds(f)).not.toContain('raw-radius')
1585
- expect(kinds(f)).not.toContain('raw-motion-value')
1586
- expect(kinds(f)).not.toContain('off-identity-accent')
1587
- })
1588
-
1589
- it('does NOT false-positive card-with-border/unframed-chart/chart-missing-aria-label/raw-visx-axis on CSS class selectors that echo JSX names', () => {
1590
- const f = checkSource(
1591
- '.Card {\n border: 1px solid red;\n}\n.ChartLegend {\n display: flex;\n}\n.AxisLeft {\n color: red;\n}\n',
1592
- 'src/charts/kinds/Card.module.css',
1593
- DEFAULT_GUARD_CONFIG,
1594
- )
1595
- expect(kinds(f)).not.toContain('card-with-border')
1596
- expect(kinds(f)).not.toContain('unframed-chart')
1597
- expect(kinds(f)).not.toContain('chart-missing-aria-label')
1598
- expect(kinds(f)).not.toContain('raw-visx-axis')
1599
- })
1600
-
1601
- it('does NOT false-positive localstorage-theme on CSS text', () => {
1602
- const f = checkSource(
1603
- "/* localStorage.getItem('theme') example */\n.foo { color: red; }\n",
1604
- 'src/Card.module.css',
1605
- DEFAULT_GUARD_CONFIG,
1606
- )
1607
- expect(kinds(f)).not.toContain('localstorage-theme')
1608
- })
1609
-
1610
- // ── Kebab longhands and logical properties ──────────────────────────────────
1611
- //
1612
- // The alternation was camelCase-only, so a CSS module got the scan for `padding` and not for
1613
- // `padding-top` beside it. Nothing decided that; it fell out of a pattern written for TSX inline
1614
- // styles. Logical properties are included because that is what modern CSS in this repo writes.
1615
-
1616
- it('flags kebab longhands at scale values', () => {
1617
- expect(cssSpacing(`padding-top: ${SPACE_SCALE.md}px`)).toEqual(['inline-spacing'])
1618
- expect(cssSpacing(`margin-bottom: ${SPACE_SCALE.lg}px`)).toEqual(['inline-spacing'])
1619
- expect(cssSpacing(`row-gap: ${SPACE_SCALE.lg}px`)).toEqual(['inline-spacing'])
1620
- expect(cssSpacing(`column-gap: ${SPACE_SCALE.xl}px`)).toEqual(['inline-spacing'])
1621
- })
1622
-
1623
- it('flags logical properties, including the -start/-end forms', () => {
1624
- for (const prop of [
1625
- 'padding-block',
1626
- 'padding-inline',
1627
- 'margin-block',
1628
- 'margin-inline',
1629
- 'padding-inline-start',
1630
- 'margin-block-end',
1631
- ]) {
1632
- expect(cssSpacing(`${prop}: ${SPACE_SCALE.xl}px`)).toEqual(['inline-spacing'])
1633
- }
1634
- })
1635
-
1636
- it('extends the sub-scale escape to the longhands too — same rule, both spellings', () => {
1637
- expect(cssSpacing('padding-top: 4px')).toEqual([])
1638
- expect(cssSpacing('margin-inline: 8px')).toEqual([])
1639
- })
1640
-
1641
- it('flags the camelCase logical properties in a TSX inline style', () => {
1642
- expect(
1643
- kinds(checkSource('<Box style={{ paddingInlineStart: 18 }} />', PATH, DEFAULT_GUARD_CONFIG)),
1644
- ).toContain('inline-spacing')
1645
- })
1646
-
1647
- // ── The `0.` blind spot ─────────────────────────────────────────────────────
1648
- //
1649
- // The zero-guard was `(?!0\b)`, and `\b` sits between `0` and `.` — so every value starting `0.`
1650
- // matched the guard meant for a bare zero and dropped out of the scan entirely.
1651
-
1652
- it('sees values starting `0.` at all', () => {
1653
- // 12px — a real spacing value that was previously invisible, not merely tolerated.
1654
- expect(cssSpacing('padding: 0.75rem')).toEqual(['inline-spacing'])
1655
- })
1656
-
1657
- it('still excuses a genuine zero', () => {
1658
- expect(cssSpacing('padding: 0')).toEqual([])
1659
- expect(cssSpacing('padding: 0px')).toEqual([])
1660
- })
1661
-
1662
- it('resolves rem against the 16px root, so both spellings of one value agree', () => {
1663
- // 0.25rem IS 4px. Accepting one spelling and flagging the other is the same arbitrariness the
1664
- // kebab gap produced.
1665
- expect(cssSpacing('padding: 0.25rem')).toEqual([])
1666
- expect(cssSpacing('padding: 4px')).toEqual([])
1667
- // The ceiling lands in the same place either way: 0.625rem = 10px, 0.75rem = 12px.
1668
- expect(cssSpacing('padding: 0.625rem')).toEqual([])
1669
- expect(cssSpacing('padding: 0.75rem')).toEqual(['inline-spacing'])
1670
- })
1671
-
1672
- it('leaves units it cannot resolve without layout context alone', () => {
1673
- for (const value of ['2em', '5%', '3ch', '2vw']) {
1674
- expect(cssSpacing(`padding: ${value}`)).toEqual(['inline-spacing'])
1675
- }
1676
- })
1677
-
1678
- it('keeps the mixed-value and var() rules unchanged under the wider pattern', () => {
1679
- expect(cssSpacing('padding: 4px 16px')).toEqual(['inline-spacing'])
1680
- expect(cssSpacing('padding-inline: 4px var(--vx-space-stack-xs)')).toEqual([])
1681
- })
1682
- })
1683
-
1684
- // ── exemptRules — per-rule, per-path guard exemptions ────────────────────────
1685
-
1686
- describe('exemptRules', () => {
1687
- const TEXT = `<div style={{ display: 'flex' }} />`
1688
-
1689
- it('drops a finding of an exempted kind at a matching path', () => {
1690
- const f = checkSource(TEXT, 'src/agent/x.tsx', {
1691
- ...DEFAULT_GUARD_CONFIG,
1692
- exemptRules: { 'inline-display': ['agent'] },
1693
- })
1694
- expect(kinds(f)).not.toContain('inline-display')
1695
- })
1696
-
1697
- it('still fires at a non-matching path (path-scoped, not global)', () => {
1698
- const f = checkSource(TEXT, 'src/dashboard/x.tsx', {
1699
- ...DEFAULT_GUARD_CONFIG,
1700
- exemptRules: { 'inline-display': ['agent'] },
1701
- })
1702
- expect(kinds(f)).toContain('inline-display')
1703
- })
1704
-
1705
- it('a trailing-slash pattern matches identically to the bare segment', () => {
1706
- const f = checkSource(TEXT, 'src/agent/x.tsx', {
1707
- ...DEFAULT_GUARD_CONFIG,
1708
- exemptRules: { 'inline-display': ['agent/'] },
1709
- })
1710
- expect(kinds(f)).not.toContain('inline-display')
1711
- })
1712
-
1713
- it('does NOT match a substring of a segment (not a whole-segment match)', () => {
1714
- const f = checkSource(TEXT, 'src/agent/x.tsx', {
1715
- ...DEFAULT_GUARD_CONFIG,
1716
- exemptRules: { 'inline-display': ['age'] },
1717
- })
1718
- expect(kinds(f)).toContain('inline-display')
1719
- })
1720
-
1721
- // ── Relative paths and globs ────────────────────────────────────────────────
1722
- //
1723
- // Only the bare-segment shape existed, and it is the one nobody guesses. rollhook wrote the
1724
- // obvious, correct `"public/site.webmanifest"`, matched nothing, got no diagnostic, and kept
1725
- // reporting — while `exemptRules` had just become the ONLY waiver route for a whole file class.
1726
-
1727
- const HEX_MANIFEST = '{ "theme_color": "#27272a" }'
1728
- const exemptedAt = (relPath: string, patterns: string[]): GuardKind[] =>
1729
- kinds(
1730
- checkSource(HEX_MANIFEST, relPath, {
1731
- ...DEFAULT_GUARD_CONFIG,
1732
- exemptRules: { 'raw-hex': patterns },
1733
- }),
1734
- )
1735
-
1736
- it('matches a real relative path to one file', () => {
1737
- expect(exemptedAt('public/site.webmanifest', ['public/site.webmanifest'])).not.toContain(
1738
- 'raw-hex',
1739
- )
1740
- })
1741
-
1742
- it('matches a relative directory prefix', () => {
1743
- expect(exemptedAt('public/icons/theme.json', ['public/icons'])).not.toContain('raw-hex')
1744
- })
1745
-
1746
- it("normalizes a leading './' and a trailing '/'", () => {
1747
- expect(exemptedAt('public/site.webmanifest', ['./public/'])).not.toContain('raw-hex')
1748
- })
1749
-
1750
- it('matches a glob, with `*` stopping at a slash', () => {
1751
- expect(exemptedAt('public/site.webmanifest', ['public/*.webmanifest'])).not.toContain('raw-hex')
1752
- expect(exemptedAt('public/nested/site.webmanifest', ['public/*.webmanifest'])).toContain(
1753
- 'raw-hex',
1754
- )
1755
- })
1756
-
1757
- it('matches a `**` glob across slashes', () => {
1758
- expect(exemptedAt('src/a/b/theme.json', ['src/**/theme.json'])).not.toContain('raw-hex')
1759
- })
1760
-
1761
- it('matches a slash-free glob against the BASENAME, like a bare segment does', () => {
1762
- const f = checkSource(`.a { padding: 18px }`, 'src/features/Card.module.css', {
1763
- ...DEFAULT_GUARD_CONFIG,
1764
- exemptRules: { 'inline-spacing': ['*.module.css'] },
1765
- })
1766
- expect(kinds(f)).not.toContain('inline-spacing')
1767
- })
1768
-
1769
- it('still fires at a path the path/glob does not match', () => {
1770
- expect(exemptedAt('public/other.webmanifest', ['public/site.webmanifest'])).toContain('raw-hex')
1771
- })
1772
-
1773
- // The other half of the fix. An exemption is a CLAIM, and a claim that resolves to nothing reads
1774
- // as coverage in a config review while enforcing exactly as much as an empty object.
1775
- describe('unmatchedExemptPatterns', () => {
1776
- const scanned = ['src/App.tsx', 'public/site.webmanifest']
1777
-
1778
- it('reports a pattern that matches no scanned file', () => {
1779
- const cfg = { ...DEFAULT_GUARD_CONFIG, exemptRules: { 'raw-hex': ['public/manifest.json'] } }
1780
- expect(unmatchedExemptPatterns(cfg, scanned)).toEqual([
1781
- { kind: 'raw-hex', pattern: 'public/manifest.json', reason: 'no-match' },
1782
- ])
1783
- })
1784
-
1785
- it('says nothing about a pattern that DOES match', () => {
1786
- const cfg = {
1787
- ...DEFAULT_GUARD_CONFIG,
1788
- exemptRules: { 'raw-hex': ['public/site.webmanifest'] },
1789
- }
1790
- expect(unmatchedExemptPatterns(cfg, scanned)).toEqual([])
1791
- })
1792
-
1793
- it("reports a typo'd KIND — an exemption for a rule that does not exist", () => {
1794
- const cfg = {
1795
- ...DEFAULT_GUARD_CONFIG,
1796
- exemptRules: { 'raw-hexx': ['src'] } as unknown as GuardConfig['exemptRules'],
1797
- }
1798
- expect(unmatchedExemptPatterns(cfg, scanned)).toEqual([
1799
- { kind: 'raw-hexx', pattern: 'src', reason: 'unknown-kind' },
1800
- ])
1801
- })
1802
-
1803
- it('reports the exact shape rollhook wrote before the path matcher existed', () => {
1804
- const cfg = { ...DEFAULT_GUARD_CONFIG, exemptRules: { 'raw-hex': ['site.webmanifest'] } }
1805
- expect(unmatchedExemptPatterns(cfg, ['public/site.webmanifest'])).toEqual([])
1806
- expect(unmatchedExemptPatterns(cfg, ['src/App.tsx'])).toHaveLength(1)
1807
- })
1808
-
1809
- it('is silent on an empty config', () => {
1810
- expect(unmatchedExemptPatterns(DEFAULT_GUARD_CONFIG, scanned)).toEqual([])
1811
- })
1812
- })
1813
-
1814
- it('does NOT suppress a different kind on the same line/path', () => {
1815
- const f = checkSource(`const c = '#ff0000'; ${TEXT}`, 'src/agent/x.tsx', {
1816
- ...DEFAULT_GUARD_CONFIG,
1817
- exemptRules: { 'inline-display': ['agent'] },
1818
- })
1819
- expect(kinds(f)).not.toContain('inline-display')
1820
- expect(kinds(f)).toContain('raw-hex')
1821
- })
1822
-
1823
- it('exempts raw-html-layout, an INLINE-handled kind — proves the post-filter covers those too', () => {
1824
- const f = checkSource(TEXT, 'src/agent/x.tsx', {
1825
- ...DEFAULT_GUARD_CONFIG,
1826
- exemptRules: { 'raw-html-layout': ['agent'] },
1827
- })
1828
- expect(kinds(f)).not.toContain('raw-html-layout')
1829
- // inline-display isn't exempted here, so it still fires from the same text.
1830
- expect(kinds(f)).toContain('inline-display')
1831
- })
1832
-
1833
- it('default (no exemptRules) behavior is unchanged', () => {
1834
- const f = find(TEXT, 'src/agent/x.tsx')
1835
- expect(kinds(f)).toContain('inline-display')
1836
- expect(kinds(f)).toContain('raw-html-layout')
1837
- })
1838
-
1839
- // ── EXEMPT_RULE_ALIASES: a grace-minor WIDENING inherits its parent's exemption ───────────────
1840
- // A kind that exists only to widen an established one is the same rule to a consumer, so an
1841
- // exemption already written for the parent has to cover it — otherwise the widening arrives as
1842
- // noise in exactly the paths someone decided the rule does not apply to.
1843
-
1844
- const HIDDEN =
1845
- "const s = { display: 'flex', padding: 18 }\nexport const D = () => <div style={s} />"
1846
- const SHADOW = 'const s = { boxShadow: `0 0 0 2px ${VX.accent}` }'
1847
- const CSS_SURFACE = '.card { border-radius: 12px; }'
1848
-
1849
- it("hidden-inline-style inherits raw-html-layout's exemption", () => {
1850
- expect(kinds(find(HIDDEN, 'src/agent/x.tsx'))).toContain('hidden-inline-style')
1851
- const f = checkSource(HIDDEN, 'src/agent/x.tsx', {
1852
- ...DEFAULT_GUARD_CONFIG,
1853
- exemptRules: { 'raw-html-layout': ['agent'] },
1854
- })
1855
- expect(kinds(f)).not.toContain('hidden-inline-style')
1856
- })
1857
-
1858
- it("surface-shadow-override and css-raw-surface inherit raw-surface's exemption", () => {
1859
- expect(kinds(find(SHADOW, 'src/agent/x.tsx'))).toContain('surface-shadow-override')
1860
- expect(kinds(find(CSS_SURFACE, 'src/agent/x.css'))).toContain('css-raw-surface')
1861
- const cfg = { ...DEFAULT_GUARD_CONFIG, exemptRules: { 'raw-surface': ['agent'] } }
1862
- expect(kinds(checkSource(SHADOW, 'src/agent/x.tsx', cfg))).not.toContain(
1863
- 'surface-shadow-override',
1864
- )
1865
- expect(kinds(checkSource(CSS_SURFACE, 'src/agent/x.css', cfg))).not.toContain('css-raw-surface')
1866
- })
1867
-
1868
- it('the inheritance is one-way — exempting the CHILD leaves the parent enforced', () => {
1869
- const f = checkSource(HIDDEN, 'src/agent/x.tsx', {
1870
- ...DEFAULT_GUARD_CONFIG,
1871
- exemptRules: { 'hidden-inline-style': ['agent'] },
1872
- })
1873
- expect(kinds(f)).not.toContain('hidden-inline-style')
1874
- expect(
1875
- kinds(
1876
- checkSource(TEXT, 'src/agent/x.tsx', {
1877
- ...DEFAULT_GUARD_CONFIG,
1878
- exemptRules: { 'hidden-inline-style': ['agent'] },
1879
- }),
1880
- ),
1881
- ).toContain('raw-html-layout')
1882
- })
1883
-
1884
- it('an alias does not exempt a path the parent exemption never named', () => {
1885
- const f = checkSource(SHADOW, 'src/charts/x.tsx', {
1886
- ...DEFAULT_GUARD_CONFIG,
1887
- exemptRules: { 'raw-surface': ['agent'] },
1888
- })
1889
- expect(kinds(f)).toContain('surface-shadow-override')
1890
- })
1891
- })
1892
-
1893
- // ── 20. severity ─────────────────────────────────────────────────────────────
1894
-
1895
- describe('severity', () => {
1896
- it('stamps every finding, defaulting to error', () => {
1897
- const f = find(`<Box style={{ color: '#ff0000' }} />`)
1898
- expect(f.length).toBeGreaterThan(0)
1899
- expect(f.every((v) => v.severity === 'error')).toBe(true)
1900
- })
1901
-
1902
- it('honours a per-kind consumer override without suppressing the finding', () => {
1903
- // Turning a kind down must still REPORT it — severity is not an off switch. The per-kind
1904
- // booleans and `exemptRules` are the off switches, and they stay separate on purpose.
1905
- const cfg = { ...DEFAULT_GUARD_CONFIG, severity: { 'raw-hex': 'warn' as const } }
1906
- const f = checkSource(`<Box style={{ color: '#ff0000' }} />`, PATH, cfg)
1907
- const hex = f.filter((v) => v.kind === 'raw-hex')
1908
- expect(hex).toHaveLength(1)
1909
- expect(hex[0]?.severity).toBe('warn')
1910
- })
1911
-
1912
- it('scopes the override to the named kind only', () => {
1913
- const cfg = { ...DEFAULT_GUARD_CONFIG, severity: { 'raw-hex': 'warn' as const } }
1914
- const f = checkSource(`<Box p={${SPACE_SCALE.md}} style={{ color: '#ff0000' }} />`, PATH, cfg)
1915
- expect(f.find((v) => v.kind === 'raw-hex')?.severity).toBe('warn')
1916
- expect(f.find((v) => v.kind === 'raw-spacing')?.severity).toBe('error')
1917
- })
1918
-
1919
- // The grace ledger. These five kinds landed warn-only in the round-4 guard minor and sat there
1920
- // for five minors with nothing tracking the promise (D4) — GRACE_PERIOD_KINDS is now empty and
1921
- // the C16 version gate in grace.test.ts is what stops a future entry sitting unpromoted again.
1922
- it.each([
1923
- 'theme-allow-unscoped',
1924
- 'surface-shadow-override',
1925
- 'css-raw-surface',
1926
- 'inline-font-size',
1927
- 'hidden-inline-style',
1928
- ])('%s is promoted past its grace minor (error)', (kind) => {
1929
- const cases: Record<string, [string, string]> = {
1930
- 'theme-allow-unscoped': ['const x = 1 // theme-allow', PATH],
1931
- 'surface-shadow-override': ['const s = { boxShadow: `0 0 0 2px ${VX.accent}` }', PATH],
1932
- 'css-raw-surface': ['.a { border-radius: 6px; }', 'src/a.module.css'],
1933
- 'inline-font-size': [`<div style={{ fontSize: 11 }} />`, PATH],
1934
- 'hidden-inline-style': [
1935
- `const rowStyle = { display: 'flex', gap: 12 }\nexport const C = () => <div style={rowStyle} />`,
1936
- PATH,
1937
- ],
1938
- }
1939
- const [src, path] = cases[kind] as [string, string]
1940
- const hit = find(src, path).find((v) => v.kind === kind)
1941
- expect(hit?.severity).toBe('error')
1942
- })
1943
-
1944
- it('ships every OTHER kind past its grace period', () => {
1945
- // Not a constraint, a LEDGER: when a new kind lands warn-only, this expectation changes in the
1946
- // same commit, and changing it back is the promotion. A grace entry that outlives its minor
1947
- // shows up here as an unexplained diff.
1948
- const cfg = DEFAULT_GUARD_CONFIG
1949
- const everyKind = checkSource(
1950
- `<Box p={${SPACE_SCALE.md}} radius={4} style={{ color: '#ff0000', gap: 3 }} />`,
1951
- PATH,
1952
- cfg,
1953
- )
1954
- expect(everyKind.length).toBeGreaterThan(0)
1955
- expect(everyKind.every((v) => v.severity === 'error')).toBe(true)
1956
- })
1957
- })
1958
-
1959
- // ── 21. theme-allow placement ────────────────────────────────────────────────
1960
-
1961
- describe('theme-allow placement', () => {
1962
- it('honours a standalone comment on the PRECEDING line (the JSX-legal form)', () => {
1963
- const f = find(
1964
- `export const C = () => (
1965
- {/* theme-allow raw-hex — brand asset, fixed by the vendor */}
1966
- <Box style={{ color: '#ff0000' }} />
1967
- )`,
1968
- )
1969
- expect(kinds(f)).not.toContain('raw-hex')
1970
- })
1971
-
1972
- // A TRAILING comment stays scoped to its own line: `const a = '#f00' // theme-allow` must not
1973
- // silently waive the next line as well.
1974
- it('does NOT let a trailing comment waive the line below it', () => {
1975
- const f = find(`const a = '#ff0000' // theme-allow raw-hex — vendor brand\nconst b = '#00ff00'`)
1976
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
1977
- })
1978
-
1979
- // rb's defect: the shipped oxfmt reflows a long declaration and lands the comment on a different
1980
- // line than the value, so a same-line-only escape silently stops working. Verified against real
1981
- // oxfmt output — the hex ends up ABOVE the comment, which is why preceding-line support alone
1982
- // does not fix it and the walk goes backwards over the declaration.
1983
- it('reaches back over an oxfmt-reflowed CSS declaration', () => {
1984
- const f = find(
1985
- `.root {
1986
- background-color: var(
1987
- --mantine-color-body,
1988
- #232326
1989
- ); /* theme-allow raw-hex — Mantine body fallback for the pre-paint value */
1990
- }`,
1991
- 'src/styles.css',
1992
- )
1993
- expect(kinds(f)).not.toContain('raw-hex')
1994
- })
1995
-
1996
- it('stops the backward walk at the previous declaration', () => {
1997
- const f = find(
1998
- `.root {
1999
- color: #112233;
2000
- background: var(
2001
- #232326
2002
- ); /* theme-allow raw-hex — fallback */
2003
- }`,
2004
- 'src/styles.css',
2005
- )
2006
- expect(f.filter((x) => x.kind === 'raw-hex')).toHaveLength(1)
2007
- expect(f.find((x) => x.kind === 'raw-hex')?.line).toBe(2)
2008
- })
2009
- })
2010
-
2011
- // ── 22. theme-allow rule scoping + accountability ────────────────────────────
2012
-
2013
- describe('theme-allow scoping', () => {
2014
- it('a scoped annotation waives only the kind it names', () => {
2015
- const f = find(
2016
- `<Box p={${SPACE_SCALE.md}} style={{ color: '#ff0000' }} /> // theme-allow raw-hex — vendor brand`,
2017
- )
2018
- expect(kinds(f)).not.toContain('raw-hex')
2019
- expect(kinds(f)).toContain('raw-spacing')
2020
- })
2021
-
2022
- it('a bare annotation still waives everything (back-compat) but is reported', () => {
2023
- const f = find(`<Box p={${SPACE_SCALE.md}} style={{ color: '#ff0000' }} /> // theme-allow`)
2024
- expect(kinds(f)).not.toContain('raw-hex')
2025
- expect(kinds(f)).not.toContain('raw-spacing')
2026
- expect(kinds(f)).toContain('theme-allow-unscoped')
2027
- })
2028
-
2029
- it('reports a reason-less scoped annotation too', () => {
2030
- const f = find(`<Box style={{ color: '#ff0000' }} /> // theme-allow raw-hex`)
2031
- expect(kinds(f)).toContain('theme-allow-unscoped')
2032
- })
2033
-
2034
- it('accepts the accountable form silently', () => {
2035
- const f = find(
2036
- `<Box style={{ color: '#ff0000' }} /> // theme-allow raw-hex — vendor brand asset`,
2037
- )
2038
- expect(f).toHaveLength(0)
2039
- })
2040
-
2041
- it('accepts a basalt/-prefixed plugin rule id as accountable', () => {
2042
- const f = find(`const x = 1 // theme-allow basalt/hand-rolled-plot — multi-pane, not one plot`)
2043
- expect(f).toHaveLength(0)
2044
- })
2045
-
2046
- // A prose reason is introduced with a separator — the shape every annotation in the wild
2047
- // already has (`theme-allow: …`, `theme-allow — …`). That is what keeps prose out of the rule-id
2048
- // slot without having to guess whether an unknown word is a reason or a typo.
2049
- it('reads a separated reason as a reason, not as rule ids', () => {
2050
- for (const annotation of ['theme-allow: legacy vendor asset', 'theme-allow — legacy vendor']) {
2051
- const f = find(`<Box style={{ color: '#ff0000' }} /> // ${annotation}`)
2052
- expect(kinds(f)).not.toContain('raw-hex')
2053
- }
2054
- })
2055
-
2056
- // ── fail closed: a waiver that names a rule id must never widen into a blanket one ────────────
2057
-
2058
- it('waives NOTHING when the named rule id is a typo', () => {
2059
- const f = find(
2060
- `<Box p={${SPACE_SCALE.md}} style={{ color: '#ff0000' }} /> // theme-allow raw-hexx — vendor brand`,
2061
- )
2062
- // The old parse consumed no id, fell through to the empty-`rules` branch, and read the whole
2063
- // thing as the LEGACY BLANKET form: one mistyped character silenced every kind on the line.
2064
- expect(kinds(f)).toContain('raw-hex')
2065
- expect(kinds(f)).toContain('raw-spacing')
2066
- })
2067
-
2068
- it('names the unknown id rather than reporting it as "no rule id"', () => {
2069
- const f = find(`const c = '#ff0000' // theme-allow raw-hexx — vendor brand`)
2070
- const unscoped = f.find((v) => v.kind === 'theme-allow-unscoped')
2071
- expect(unscoped?.token).toContain("unknown rule id 'raw-hexx'")
2072
- })
2073
-
2074
- it('a typo is never more permissive than the same annotation spelled correctly', () => {
2075
- const correct = find(`const c = '#ff0000' // theme-allow raw-hex — vendor brand`)
2076
- const typo = find(`const c = '#ff0000' // theme-allow raw-hexx — vendor brand`)
2077
- expect(typo.length).toBeGreaterThanOrEqual(correct.length)
2078
- })
2079
-
2080
- it('still waives the ids it got right in a mixed list', () => {
2081
- const f = find(
2082
- `<Box p={${SPACE_SCALE.md}} style={{ color: '#ff0000' }} /> // theme-allow raw-hex, raw-spacingg — mixed`,
2083
- )
2084
- expect(kinds(f)).not.toContain('raw-hex')
2085
- expect(kinds(f)).toContain('raw-spacing')
2086
- })
2087
-
2088
- it('says "not waived", not "waives nothing", when an id in the list DID resolve', () => {
2089
- const f = find(`const c = '#ff0000' // theme-allow raw-hex, raw-spacingg — mixed`)
2090
- const unscoped = f.find((v) => v.kind === 'theme-allow-unscoped')
2091
- expect(unscoped?.token).toBe("theme-allow (unknown rule id 'raw-spacingg' — not waived)")
2092
- // The claim has to match what the same run actually did: raw-hex IS waived here.
2093
- expect(kinds(f)).not.toContain('raw-hex')
2094
- })
2095
-
2096
- // ── prose after a resolved id is prose, not a typo ────────────────────────────────────────────
2097
- //
2098
- // The id slot closes at the first space no comma opened. `theme-allow raw-surface sub-scale …`
2099
- // is one em-dash away from annotations this package itself ships (see ChartLegend), and reading
2100
- // `sub-scale` as an attempted id made a scoped, reasoned waiver report as unscoped.
2101
-
2102
- it('reads an unknown word after a resolved id as the reason', () => {
2103
- const f = find(
2104
- 'const s = { borderRadius: 3 } // theme-allow raw-surface sub-scale legend corner',
2105
- )
2106
- expect(f).toHaveLength(0)
2107
- })
2108
-
2109
- it('still fails closed on a typo in the FIRST id slot, separator or not', () => {
2110
- for (const annotation of [
2111
- 'theme-allow raw-hexx — vendor',
2112
- 'theme-allow raw-hexx vendor asset',
2113
- ]) {
2114
- expect(kinds(find(`const c = '#ff0000' // ${annotation}`))).toContain('raw-hex')
2115
- }
2116
- })
2117
-
2118
- // Documented break, not a regression: a reason with NO separator introducing it sits in the id
2119
- // slot, names no rule, and therefore waives nothing. Kept as a test so the behaviour is pinned
2120
- // rather than rediscovered — MIGRATING.md carries the consumer-facing half.
2121
- it('does not waive a reason written with no separator introducing it', () => {
2122
- const f = find(`const c = '#ff0000' // theme-allow legacy vendor asset`)
2123
- expect(f.find((v) => v.kind === 'raw-hex')?.severity).toBe('error')
2124
- expect(f.find((v) => v.kind === 'theme-allow-unscoped')?.token).toContain(
2125
- "unknown rule id 'legacy' — waives nothing",
2126
- )
2127
- })
2128
-
2129
- // `in` walks the prototype chain, so `'constructor' in GUARD_RULES` was true and a reason
2130
- // starting with that word scoped the waiver to a rule that does not exist.
2131
- it('does not resolve an Object.prototype key as a rule id', () => {
2132
- const f = find(`const c = '#ff0000' // theme-allow constructor — inherited key, not a rule`)
2133
- expect(kinds(f)).toContain('raw-hex')
2134
- })
2135
-
2136
- it('accepts an id belonging to a plugin rule that does not honour theme-allow', () => {
2137
- // Not a guard kind and not theme-allow-aware — but a REAL id, so it parses as accountable
2138
- // rather than being read as a typo (or, before, as a blanket waiver).
2139
- const f = find(`const x = 1 // theme-allow ai-sdk-major — intentional producer/consumer skew`)
2140
- expect(f).toHaveLength(0)
2141
- })
2142
-
2143
- it('ships theme-allow-unscoped as an error (promoted, C16)', () => {
2144
- const f = find(`const x = 1 // theme-allow`)
2145
- expect(f.every((v) => v.severity === 'error')).toBe(true)
2146
- })
2147
-
2148
- // The annotation itself lives in a comment; a `theme-allow` in real code must not waive anything.
2149
- it('ignores the token when it is not inside a comment', () => {
2150
- const f = find(`const label = 'theme-allow'\nconst c = '#ff0000'`)
2151
- expect(kinds(f)).toContain('raw-hex')
2152
- })
2153
- })
2154
-
2155
- // ── 22b. theme-allow — the prefix rule and the two scopes, exhaustively ──────
2156
- //
2157
- // Three holes in one contract across two rounds, so this is a matrix rather than a case.
2158
-
2159
- describe('theme-allow — annotation vs prose (the prefix rule)', () => {
2160
- const HEX = `const c = '#ff0000'`
2161
-
2162
- /** `comment` on its own line directly above a raw hex — the placement that waives the line below. */
2163
- function above(comment: string): GuardKind[] {
2164
- return kinds(find(`${comment}\n${HEX}`))
2165
- }
2166
-
2167
- // ── ACCEPTED — every shape a consumer actually writes ──────────────────────
2168
-
2169
- it.each([
2170
- '// theme-allow raw-hex — vendor brand',
2171
- '//theme-allow raw-hex — no space after the marker',
2172
- ' // theme-allow raw-hex — indented',
2173
- '/* theme-allow raw-hex — a block comment */',
2174
- '{/* theme-allow raw-hex — a JSX expression comment */}',
2175
- '/**\n * theme-allow raw-hex — a docblock gutter line\n */',
2176
- '// theme-allow',
2177
- ])('%s is an annotation and waives the line below', (comment) => {
2178
- expect([comment, above(comment).includes('raw-hex')]).toEqual([comment, false])
2179
- })
2180
-
2181
- it('a trailing annotation waives its own line', () => {
2182
- expect(kinds(find(`${HEX} // theme-allow raw-hex — vendor brand`))).not.toContain('raw-hex')
2183
- })
2184
-
2185
- // The reason wrapping to a second line — or a docblock's own `*/` — used to absorb the whole
2186
- // waiver, so the natural multi-line shape silently waived nothing. argo hit it three times in one
2187
- // upgrade, each time on a comment that looked correct.
2188
- it('a wrapped reason still reaches the code line under it', () => {
2189
- const f = find(
2190
- `// theme-allow raw-hex — the vendor brand hex, kept in sync with
2191
- // the marketing site's own palette
2192
- ${HEX}`,
2193
- )
2194
- expect(kinds(f)).not.toContain('raw-hex')
2195
- })
2196
-
2197
- it('a blank line ends the block — the waiver does not jump it', () => {
2198
- expect(
2199
- kinds(
2200
- find(`// theme-allow raw-hex — about something else
2201
-
2202
- ${HEX}`),
2203
- ),
2204
- ).toContain('raw-hex')
2205
- })
2206
-
2207
- it('a trailing annotation does NOT walk forward — it stays on its own line', () => {
2208
- expect(kinds(find(`const ok = 1 // theme-allow raw-hex — about this line\n${HEX}`))).toContain(
2209
- 'raw-hex',
2210
- )
2211
- })
2212
-
2213
- // ── REJECTED — prose that MENTIONS the token ──────────────────────────────
2214
-
2215
- // The linewatch defect, and the one that made this a false NEGATIVE: a file documenting its own
2216
- // waivers disarmed itself. Every shape here is lifted from real consumer or basalt source.
2217
- it.each([
2218
- '// see the `theme-allow` contract for how exceptions are written',
2219
- '/* Since 1.20.0 a `theme-allow` that names a rule declares the file */',
2220
- '/**\n * A `theme-allow` with a reason declares the whole FILE wherever it is written.\n */',
2221
- '// each chart carries a theme-allow saying why',
2222
- '// documented at theme-allow raw-hex — this sentence is prose, not a waiver',
2223
- ])('%s is prose and waives nothing', (comment) => {
2224
- expect([comment, above(comment).includes('raw-hex')]).toEqual([comment, true])
2225
- })
2226
-
2227
- // A kind name written in prose used to consume no id, fall through to `rules: []`, and read as
2228
- // the blanket form — so a sentence about the reporting rule switched every rule off.
2229
- it('a comment starting with the longer word `theme-allow-unscoped` is not an annotation', () => {
2230
- expect(above('// theme-allow-unscoped is what reports a bare waiver')).toContain('raw-hex')
2231
- })
2232
-
2233
- it('the token inside a string literal is code, not an annotation', () => {
2234
- expect(kinds(find(`const doc = 'theme-allow raw-hex — x'\n${HEX}`))).toContain('raw-hex')
2235
- })
2236
-
2237
- // Prose is not an annotation for REPORTING either — otherwise every sentence documenting the
2238
- // escape hatch becomes a theme-allow-unscoped finding (this package's own guard/types.ts).
2239
- it('prose mentioning the token is not reported as an unscoped waiver', () => {
2240
- expect(above('// see the `theme-allow` contract')).not.toContain('theme-allow-unscoped')
2241
- })
2242
- })
2243
-
2244
- describe('theme-allow-file — whole-file scope', () => {
2245
- const SRC = [`const a = '#ff0000'`, `const b = '#00ff00'`, `const c = '#0000ff'`].join('\n')
2246
-
2247
- it('waives every line in the file, not just the one below it', () => {
2248
- const f = find(`// theme-allow-file raw-hex — the vendor brand sheet\n${SRC}`)
2249
- expect(kinds(f)).not.toContain('raw-hex')
2250
- })
2251
-
2252
- it('reaches BACKWARDS too — a declaration at the bottom covers the top', () => {
2253
- const f = find(`${SRC}\n// theme-allow-file raw-hex — the vendor brand sheet`)
2254
- expect(kinds(f)).not.toContain('raw-hex')
2255
- })
2256
-
2257
- it('waives only the kinds it names', () => {
2258
- const f = find(
2259
- `// theme-allow-file raw-hex — vendor brand\n${SRC}\n<Box style={{ display: 'flex' }} />`,
2260
- )
2261
- expect(kinds(f)).not.toContain('raw-hex')
2262
- expect(kinds(f)).toContain('inline-display')
2263
- })
2264
-
2265
- // File scope is the widest waiver in the contract, so it is the one that has to name what it
2266
- // waives. A bare `theme-allow-file` is `exemptRules` without the config review.
2267
- it('a BARE theme-allow-file waives nothing, and is reported as unscoped', () => {
2268
- const f = find(`// theme-allow-file — everything in here is bespoke\n${SRC}`)
2269
- expect(kinds(f)).toContain('raw-hex')
2270
- expect(kinds(f)).toContain('theme-allow-unscoped')
2271
- })
2272
-
2273
- it("a theme-allow-file naming a typo'd id waives nothing", () => {
2274
- const f = find(`// theme-allow-file raw-hexx — vendor brand\n${SRC}`)
2275
- expect(kinds(f)).toContain('raw-hex')
2276
- expect(kinds(f)).toContain('theme-allow-unscoped')
2277
- })
2278
-
2279
- it('an id-and-reason theme-allow-file is accountable — no unscoped finding', () => {
2280
- const f = find(`// theme-allow-file raw-hex — the vendor brand sheet\n${SRC}`)
2281
- expect(kinds(f)).not.toContain('theme-allow-unscoped')
2282
- })
2283
-
2284
- // The complement, and the half 1.20.0 did not deliver: naming a rule AND giving a reason used to
2285
- // promote a waiver to the whole file in the oxlint plugin, so whole-file was the only expressible
2286
- // scope. The guard's plain `theme-allow` has always been line-scoped, and stays that way.
2287
- it('a plain theme-allow with an id and a reason stays line-scoped', () => {
2288
- const f = find(`// theme-allow raw-hex — vendor brand\n${SRC}`)
2289
- expect(kinds(f).filter((k) => k === 'raw-hex')).toHaveLength(2)
2290
- })
2291
- })
2292
-
2293
- /** A two-hex manifest with `extra` (an annotation member, or nothing) inserted at the top. */
2294
- const MANIFEST = (extra: string) =>
2295
- `{\n${extra} "theme_color": "#27272a",\n "background_color": "#27272a"\n}\n`
2296
-
2297
- describe('theme-allow in JSON — the "basalt:theme-allow" member', () => {
2298
- it('a webmanifest hex reports with no annotation — the 1.20.0 baseline', () => {
2299
- const f = checkSource(MANIFEST(''), 'public/site.webmanifest', DEFAULT_GUARD_CONFIG)
2300
- expect(kinds(f).filter((k) => k === 'raw-hex')).toHaveLength(2)
2301
- })
2302
-
2303
- // JSON has no comments, so from 1.20.0 the whole file class was permanently unwaivable and the
2304
- // printed remedy prescribed a comment the file cannot carry. Both consumers that hit it fell back
2305
- // to a blanket `exemptRules` entry — the shape the release was spent retiring.
2306
- it('a "basalt:theme-allow-file" member declares the file', () => {
2307
- const f = checkSource(
2308
- MANIFEST(' "basalt:theme-allow-file": "raw-hex — a manifest cannot read a CSS variable",\n'),
2309
- 'public/site.webmanifest',
2310
- DEFAULT_GUARD_CONFIG,
2311
- )
2312
- expect(kinds(f)).not.toContain('raw-hex')
2313
- })
2314
-
2315
- it('a "basalt:theme-allow" member is line-scoped — its own line and the one below', () => {
2316
- const f = checkSource(
2317
- MANIFEST(' "basalt:theme-allow": "raw-hex — the theme color only",\n'),
2318
- 'public/site.webmanifest',
2319
- DEFAULT_GUARD_CONFIG,
2320
- )
2321
- expect(kinds(f).filter((k) => k === 'raw-hex')).toHaveLength(1)
2322
- })
2323
-
2324
- it('the member must name its ids — a bare one waives nothing', () => {
2325
- const f = checkSource(
2326
- MANIFEST(' "basalt:theme-allow-file": "everything in here is bespoke",\n'),
2327
- 'public/site.webmanifest',
2328
- DEFAULT_GUARD_CONFIG,
2329
- )
2330
- expect(kinds(f)).toContain('raw-hex')
2331
- })
2332
-
2333
- it('works in a plain .json too, not only a manifest', () => {
2334
- const f = checkSource(
2335
- MANIFEST(' "basalt:theme-allow-file": "raw-hex — vendor brand data",\n'),
2336
- 'src/brand.json',
2337
- DEFAULT_GUARD_CONFIG,
2338
- )
2339
- expect(kinds(f)).not.toContain('raw-hex')
2340
- })
2341
-
2342
- // The member is JSON-only: in TS that string is a value, and a value that could switch off a rule
2343
- // is the hole `stripComments` exists to close.
2344
- it('the same string in a TS file is code, not an annotation', () => {
2345
- const f = find(`const cfg = { "basalt:theme-allow-file": "raw-hex — x" }\nconst c = '#ff0000'`)
2346
- expect(kinds(f)).toContain('raw-hex')
2347
- })
2348
- })
2349
-
2350
- // ── 23. surface-shadow-override ──────────────────────────────────────────────
2351
-
2352
- describe('surface-shadow-override', () => {
2353
- it('flags a token-composed shadow that REPLACES card depth', () => {
2354
- const f = find('const s = { boxShadow: `0 0 0 2px ${VX.accent}` }')
2355
- expect(kinds(f)).toContain('surface-shadow-override')
2356
- })
2357
-
2358
- it('does NOT flag one that composes WITH card depth', () => {
2359
- const f = find('const s = { boxShadow: `${VX.shadowCard}, 0 0 0 2px ${VX.accent}` }')
2360
- expect(kinds(f)).not.toContain('surface-shadow-override')
2361
- })
2362
-
2363
- it('does NOT flag a var(--vx-shadow-…) reference', () => {
2364
- const f = find(`const s = { boxShadow: 'var(--vx-shadow-card)' }`)
2365
- expect(kinds(f)).not.toContain('surface-shadow-override')
2366
- })
2367
-
2368
- it('still reports a fully literal shadow as raw-surface, not the new kind', () => {
2369
- const f = find(`const s = { boxShadow: '0 1px 3px black' }`)
2370
- expect(kinds(f)).toContain('raw-surface')
2371
- expect(kinds(f)).not.toContain('surface-shadow-override')
2372
- })
2373
-
2374
- it('is an error now that its grace minor has ended (promoted, C16)', () => {
2375
- const f = find('const s = { boxShadow: `0 0 0 2px ${VX.accent}` }')
2376
- expect(f.find((v) => v.kind === 'surface-shadow-override')?.severity).toBe('error')
2377
- })
2378
- })
2379
-
2380
- // ── 24. css-raw-surface ──────────────────────────────────────────────────────
2381
-
2382
- describe('css-raw-surface', () => {
2383
- it('flags a kebab-case border-radius literal in CSS', () => {
2384
- const f = find('.a { border-radius: 4px; }', 'src/a.module.css')
2385
- expect(kinds(f)).toContain('css-raw-surface')
2386
- })
2387
-
2388
- it('does NOT flag a var(--vx-radius-…) reference', () => {
2389
- const f = find('.a { border-radius: var(--vx-radius-card); }', 'src/a.module.css')
2390
- expect(kinds(f)).not.toContain('css-raw-surface')
2391
- })
2392
-
2393
- it('does NOT flag a sub-scale micro-corner below the 4px floor', () => {
2394
- const f = find('.a { border-radius: 2px; }', 'src/a.module.css')
2395
- expect(kinds(f)).not.toContain('css-raw-surface')
2396
- })
2397
-
2398
- it('does NOT flag the circle/pill shape values', () => {
2399
- const f = find('.a { border-radius: 50%; }\n.b { border-radius: 9999px; }', 'src/a.module.css')
2400
- expect(kinds(f)).not.toContain('css-raw-surface')
2401
- })
2402
-
2403
- it('flags a literal box-shadow in CSS', () => {
2404
- const f = find('.a { box-shadow: 0 1px 3px rgba(0,0,0,0.2); }', 'src/a.module.css')
2405
- expect(kinds(f)).toContain('css-raw-surface')
2406
- })
2407
-
2408
- it('never fires in TSX (the camelCase dialect is raw-surface’s)', () => {
2409
- const f = find(`<div style={{ borderRadius: 8 }} />`)
2410
- expect(kinds(f)).not.toContain('css-raw-surface')
2411
- expect(kinds(f)).toContain('raw-surface')
2412
- })
2413
- })
2414
-
2415
- // ── 25. inline-font-size ─────────────────────────────────────────────────────
2416
-
2417
- describe('inline-font-size', () => {
2418
- it('flags a unitless inline fontSize (the check-theme-only CI gap)', () => {
2419
- const f = find(`<div style={{ padding: '6px 8px', fontSize: 11 }} />`)
2420
- expect(kinds(f)).toContain('inline-font-size')
2421
- })
2422
-
2423
- it('flags a px font-size in CSS', () => {
2424
- const f = find('.a { font-size: 13px; }', 'src/a.module.css')
2425
- expect(kinds(f)).toContain('inline-font-size')
2426
- })
2427
-
2428
- it('does NOT flag a token reference', () => {
2429
- const f = find(`<div style={{ fontSize: VX.text.sm }} />`)
2430
- expect(kinds(f)).not.toContain('inline-font-size')
2431
- })
2432
-
2433
- it('does NOT flag a relative unit', () => {
2434
- const f = find('.a { font-size: 0.8rem; }', 'src/a.module.css')
2435
- expect(kinds(f)).not.toContain('inline-font-size')
2436
- })
2437
- })
2438
-
2439
- // ── 26. in-body-page-title (the text lane of law C8) ─────────────────────────
2440
-
2441
- describe('in-body-page-title', () => {
2442
- it('flags <Title order={1}> in the body', () => {
2443
- expect(kinds(find(`<Title order={1}>Analytics</Title>`))).toContain('in-body-page-title')
2444
- })
2445
-
2446
- it('flags order={2} too', () => {
2447
- expect(kinds(find(`<Title order={2}>Analytics</Title>`))).toContain('in-body-page-title')
2448
- })
2449
-
2450
- // The formatted default — the reason this kind is a bounded TAG scan and not a line regex.
2451
- it('flags a Title formatted across lines', () => {
2452
- const f = find(`<Title\n order={1}\n mb="sm"\n>\n Analytics\n</Title>`)
2453
- expect(kinds(f)).toContain('in-body-page-title')
2454
- })
2455
-
2456
- it('does NOT flag order={3} — a section heading', () => {
2457
- expect(kinds(find(`<Title order={3}>Sessions</Title>`))).not.toContain('in-body-page-title')
2458
- })
2459
-
2460
- it('does NOT flag a Title with no order', () => {
2461
- expect(kinds(find(`<Title>Sessions</Title>`))).not.toContain('in-body-page-title')
2462
- })
2463
-
2464
- it('does NOT flag anything under a content/ path segment', () => {
2465
- const f = find(`<Title order={1}>Doc</Title>`, 'src/content/Guide.tsx')
2466
- expect(kinds(f)).not.toContain('in-body-page-title')
2467
- })
2468
-
2469
- // File-scoped and coarser than the plugin's node-level ancestry, on purpose: a document heading
2470
- // told to become a breadcrumb is the WRONG advice, so the text lane errs silent.
2471
- it('does NOT flag a file that renders Prose', () => {
2472
- const f = find(`<Prose>\n <Title order={1}>Doc</Title>\n</Prose>`)
2473
- expect(kinds(f)).not.toContain('in-body-page-title')
2474
- })
2475
-
2476
- it('honours a theme-allow', () => {
2477
- const f = find(
2478
- `// theme-allow in-body-page-title — a shell-less print view\n<Title order={1}>P</Title>`,
2479
- )
2480
- expect(kinds(f)).not.toContain('in-body-page-title')
2481
- })
2482
-
2483
- // Promoted at 1.27.0 — its GRACE_PERIOD_KINDS entry is gone, and so is the plugin rule of the
2484
- // same id from PLUGIN_RULE_GRACE. One law, two lanes, one promotion.
2485
- it('lands error now that the grace entry is gone (C16)', () => {
2486
- const f = find(`<Title order={1}>Analytics</Title>`)
2487
- expect(f.find((x) => x.kind === 'in-body-page-title')?.severity).toBe('error')
2488
- })
2489
- })
2490
-
2491
- // ── 27. raw-selection-control (the text lane of law C1) ──────────────────────
2492
-
2493
- describe('raw-selection-control', () => {
2494
- it.each(['SegmentedControl', 'Select', 'MultiSelect', 'NativeSelect', 'TagsInput', 'Chip.Group'])(
2495
- 'flags a raw <%s>',
2496
- (tag) => {
2497
- expect(kinds(find(`<${tag} data={[]} />`))).toContain('raw-selection-control')
2498
- },
2499
- )
2500
-
2501
- it('reports the tag it found, not the whole line', () => {
2502
- const f = find(`<Select data={[]} />`)
2503
- expect(f.find((x) => x.kind === 'raw-selection-control')?.token).toBe('<Select')
2504
- })
2505
-
2506
- it.each(['SettingsRow', 'Modal', 'Drawer', 'Menu.Dropdown'])(
2507
- 'does NOT flag one inside a %s window',
2508
- (host) => {
2509
- const f = find(`<${host}>\n <Select data={[]} />\n</${host}>`)
2510
- expect(kinds(f)).not.toContain('raw-selection-control')
2511
- },
2512
- )
2513
-
2514
- // The window is 12 lines; past it the approximation would start swallowing the NEXT row's control.
2515
- it('DOES flag one 20 lines below the host tag — the window is bounded', () => {
2516
- const f = find(`<Modal>\n${' <Text>x</Text>\n'.repeat(20)} <Select data={[]} />\n</Modal>`)
2517
- expect(kinds(f)).toContain('raw-selection-control')
2518
- })
2519
-
2520
- it('does NOT flag anything in a file importing @mantine/form', () => {
2521
- const f = find(`import { useForm } from '@mantine/form'\n<Select data={[]} />`)
2522
- expect(kinds(f)).not.toContain('raw-selection-control')
2523
- })
2524
-
2525
- it('does NOT flag the file that DEFINES a basalt control', () => {
2526
- const f = find(`export function ViewTabs() {\n return <SegmentedControl data={[]} />\n}`)
2527
- expect(kinds(f)).not.toContain('raw-selection-control')
2528
- })
2529
-
2530
- it('does NOT flag a bound basalt control', () => {
2531
- expect(kinds(find(`<SelectFilter field={f} label="Channel" />`))).not.toContain(
2532
- 'raw-selection-control',
2533
- )
2534
- })
2535
-
2536
- it('honours a theme-allow', () => {
2537
- const f = find(
2538
- `{/* theme-allow raw-selection-control — a one-off admin picker */}\n<Select data={[]} />`,
2539
- )
2540
- expect(kinds(f)).not.toContain('raw-selection-control')
2541
- })
2542
-
2543
- // The CROSS-FILE exemption — the same basename convention the plugin's `control-outside-home`
2544
- // applies, so both lanes agree on the same file (one law, two lanes, one exemption). argo carried
2545
- // 9 of these: a control in a modal/form module whose `<Modal>` is rendered by the parent, which is
2546
- // outside the 12-line host window because the host tag is not in the file at all.
2547
- it.each([
2548
- 'src/edit-session-modal.tsx',
2549
- 'src/filters-drawer.tsx',
2550
- 'src/column-popover.tsx',
2551
- 'src/detail-panel.tsx',
2552
- 'src/booking-form.tsx',
2553
- ])('does NOT flag %s — the overlay filename convention', (relPath) => {
2554
- expect(kinds(find(`<Select data={[]} />`, relPath))).not.toContain('raw-selection-control')
2555
- })
2556
-
2557
- it('still flags a page module beside them', () => {
2558
- expect(kinds(find(`<Select data={[]} />`, 'src/bookings-page.tsx'))).toContain(
2559
- 'raw-selection-control',
2560
- )
2561
- })
2562
-
2563
- // Basename only, and it needs the leading subject: a `modal/` DIRECTORY holds the page pieces
2564
- // around the modals too, and a bare `modal.tsx` is a page module in every consumer that has one.
2565
- it.each(['src/modal/session.tsx', 'src/modal.tsx'])(
2566
- 'is a subject-prefixed BASENAME convention, not %s',
2567
- (relPath) => {
2568
- expect(kinds(find(`<Select data={[]} />`, relPath))).toContain('raw-selection-control')
2569
- },
2570
- )
2571
-
2572
- // Still warn at 1.27.0 — this kind and its AST twin `basalt/control-outside-home` are the C1 pair
2573
- // that moved to `promote: '1.28.0'` while the other wave-6 entries promoted.
2574
- it('lands warn while the grace entry stands (C16)', () => {
2575
- const f = find(`<Select data={[]} />`)
2576
- expect(f.find((x) => x.kind === 'raw-selection-control')?.severity).toBe('warn')
2577
- })
2578
- })
2579
-
2580
- // ── 26. hidden-inline-style ──────────────────────────────────────────────────
2581
-
2582
- describe('hidden-inline-style', () => {
2583
- it('flags a multi-line-formatted raw div the line scan cannot see', () => {
2584
- const f = find(
2585
- `export const C = () => (
2586
- <div
2587
- className="x"
2588
- style={{
2589
- display: 'flex',
2590
- padding: 12,
2591
- }}
2592
- />
2593
- )`,
2594
- )
2595
- expect(kinds(f)).toContain('hidden-inline-style')
2596
- })
2597
-
2598
- it('flags a hoisted style const passed by identifier', () => {
2599
- const f = find(
2600
- `const wrapperStyle = { position: 'relative', width: '100%' }
2601
- export const C = () => <div className="x" style={wrapperStyle} />`,
2602
- )
2603
- expect(kinds(f)).toContain('hidden-inline-style')
2604
- })
2605
-
2606
- it('does NOT double-report what raw-html-layout already owns', () => {
2607
- const f = find(`<div style={{ display: 'flex', padding: 12 }} />`)
2608
- expect(kinds(f)).toContain('raw-html-layout')
2609
- expect(kinds(f)).not.toContain('hidden-inline-style')
2610
- })
2611
-
2612
- it('does NOT flag a hoisted const with no layout/surface property', () => {
2613
- const f = find(
2614
- `const labelStyle = { fontWeight: 500 }
2615
- export const C = () => <div style={labelStyle} />`,
2616
- )
2617
- expect(kinds(f)).not.toContain('hidden-inline-style')
2618
- })
2619
-
2620
- it('is an error now that its grace minor has ended (promoted, C16)', () => {
2621
- const f = find(
2622
- `const wrapperStyle = { position: 'relative', width: '100%' }
2623
- export const C = () => <div style={wrapperStyle} />`,
2624
- )
2625
- expect(f.find((v) => v.kind === 'hidden-inline-style')?.severity).toBe('error')
2626
- })
2627
- })
2628
-
2629
- // ── 27. raw-color-fn on computed colors ──────────────────────────────────────
2630
-
2631
- describe('raw-color-fn — computed values', () => {
2632
- it('does NOT flag a color function whose first channel is interpolated', () => {
2633
- const f = find('const swatch = `rgb(${px[0]}, ${px[1]}, ${px[2]})`')
2634
- expect(kinds(f)).not.toContain('raw-color-fn')
2635
- })
2636
-
2637
- it('still flags a literal color with only a variable alpha', () => {
2638
- const f = find('const s = `rgba(0, 0, 0, ${opacity})`')
2639
- expect(kinds(f)).toContain('raw-color-fn')
2640
- })
2641
- })
2642
-
2643
- // ── 28. generated-file marker ────────────────────────────────────────────────
2644
-
2645
- describe('@generated basalt-ui marker', () => {
2646
- const HEADER = `${GENERATED_HEADER_LINE}\n/* basalt-ui 1.20.0 — \`basalt-ui tokens:css --only core\` */\n`
2647
- const BODY = ':root {\n --vx-fill-gray: #717176;\n --vx-axis: rgba(255, 255, 255, 0.6);\n}\n'
2648
- const EMITTED = `${HEADER}${BODY}`
2649
-
2650
- it('skips a file basalt itself emitted', () => {
2651
- expect(find(EMITTED, 'src/styles/basalt-tokens.css')).toHaveLength(0)
2652
- })
2653
-
2654
- // ── the forgery half: the marker was a whole-file bypass anyone could hand-write ──────────────
2655
-
2656
- it('ignores the marker entirely outside a .css file', () => {
2657
- const forged = `${HEADER}export const C = () => <div style={{ color: '#ff0000', padding: 18 }} />\n`
2658
- expect(kinds(find(forged, 'src/a.tsx'))).toContain('raw-hex')
2659
- expect(kinds(find(forged, 'src/a.tsx'))).toContain('inline-spacing')
2660
- })
2661
-
2662
- it('does not exempt hand-written CSS wearing the header', () => {
2663
- const forged = `${HEADER}.btn { color: #ff0000; }\n`
2664
- expect(kinds(find(forged, 'src/a.css'))).toContain('raw-hex')
2665
- })
2666
-
2667
- it('does not exempt a generated sheet with an ordinary rule smuggled onto the end', () => {
2668
- const smuggled = `${EMITTED}.btn { color: #ff0000; }\n`
2669
- expect(kinds(find(smuggled, 'src/tokens.css'))).toContain('raw-hex')
2670
- })
2671
-
2672
- it('requires the provenance line, not just the marker', () => {
2673
- const bare = `${GENERATED_HEADER_LINE}\n${BODY}`
2674
- expect(kinds(find(bare, 'src/tokens.css'))).toContain('raw-hex')
2675
- const loose = `/* @generated basalt-ui */\n${BODY}`
2676
- expect(kinds(find(loose, 'src/tokens.css'))).toContain('raw-hex')
2677
- })
2678
-
2679
- // ── the header WINDOW: exactly the two lines the emitter writes, and no more ──────────────────
2680
-
2681
- it('honours the header only on lines 1-2', () => {
2682
- const onLineOne = `${HEADER}${BODY}`
2683
- expect(find(onLineOne, 'src/tokens.css')).toHaveLength(0)
2684
- // One blank line ahead of it is already outside the window — the boundary is exact, not "the
2685
- // first few lines", which is what let a marker be pasted in above unrelated content.
2686
- const shifted = `\n${HEADER}${BODY}`
2687
- expect(kinds(find(shifted, 'src/tokens.css'))).toContain('raw-hex')
2688
- const buried = `${'\n'.repeat(8)}${HEADER}${BODY}`
2689
- expect(kinds(find(buried, 'src/tokens.css'))).toContain('raw-hex')
2690
- })
2691
-
2692
- // ── the second forgery round: the line-shape allowlist itself was forgeable ───────────────────
2693
- //
2694
- // Both of the first two were demonstrated against the real `checkSource`, and both were valid,
2695
- // browser-effective CSS bought a WHOLE-FILE exemption. Every case below must report — a passing
2696
- // `toHaveLength(0)` here would mean the hole is open again.
2697
-
2698
- const forgeries: ReadonlyArray<readonly [string, string]> = [
2699
- // A `;` inside a custom-property value smuggled two ordinary declarations onto a line the
2700
- // allowlist read as "a basalt custom property".
2701
- [
2702
- 'a semicolon inside a custom-property value',
2703
- `${HEADER}:root {\n --vx-pad: 0; box-shadow: 0 0 0 1px #ff0000; border-radius: 11px;\n}\n`,
2704
- ],
2705
- // The comment branch was `\/\*.*` — it never required the comment to CLOSE, so anything after
2706
- // `*/` rode along.
2707
- ['a comment that never closes', `${HEADER}/* x */ .btn { color: #ff0000; }\n`],
2708
- // A comment reopened mid-line: closing and reopening keeps the line comment-SHAPED at both
2709
- // ends while the middle is a live declaration.
2710
- ['a comment reopened mid-line', `${HEADER}:root {\n /* a */ color: #ff0000 /* b */\n}\n`],
2711
- // The first line of a multi-line declaration ends in `,`, which is also how a selector list
2712
- // continues — the depth check is what tells them apart.
2713
- [
2714
- 'a multi-line declaration whose first line ends in a comma',
2715
- `${HEADER}.btn {\n box-shadow: 0 0 0 1px #ff0000,\n 0 0 2px #00ff00;\n}\n`,
2716
- ],
2717
- // The at-rule branch permitted `;`, so a declaration could ride behind `@…;`.
2718
- ['a declaration behind an at-rule', `${HEADER}@media all; color: #ff0000;\n`],
2719
- // No trailing `;` anywhere, so nothing to split on.
2720
- ['a whole rule on one line with no semicolon', `${HEADER}.btn { color: #ff0000 }\n`],
2721
- [
2722
- 'a rule reopened after a closing brace',
2723
- `${HEADER}:root {\n --vx-a: #fff;\n} .btn { color: #ff0000; }\n`,
2724
- ],
2725
- ['a nested block', `${HEADER}:root {\n .btn {\n color: #ff0000;\n }\n}\n`],
2726
- ]
2727
-
2728
- for (const [what, source] of forgeries) {
2729
- it(`reports ${what}`, () => {
2730
- expect(find(source, 'src/tokens.css').length).toBeGreaterThan(0)
2731
- })
2732
- }
2733
-
2734
- it('never skips a line carrying theme-allow, so a hidden waiver still reports', () => {
2735
- // Without this the annotation would waive the line below AND its own unscoped report, because
2736
- // a bare `/* theme-allow */` is comment-SHAPED and would otherwise be a skippable line.
2737
- const smuggled = `${HEADER}/* theme-allow */\n.btn { color: #ff0000; }\n`
2738
- expect(kinds(find(smuggled, 'src/tokens.css'))).toContain('theme-allow-unscoped')
2739
- })
2740
-
2741
- it('exempts LINES, not the file — a smuggled rule reports without burying it in token values', () => {
2742
- const smuggled = `${EMITTED}.btn { color: #ff0000; }\n`
2743
- const f = find(smuggled, 'src/tokens.css')
2744
- expect(f.map((x) => x.line)).toEqual([7])
2745
- expect(kinds(f)).toEqual(['raw-hex'])
2746
- })
2747
- })
2748
-
2749
- // ── 29. markup files ─────────────────────────────────────────────────────────
2750
-
2751
- describe('markup files (index.html / *.webmanifest)', () => {
2752
- it('flags raw hex in index.html', () => {
2753
- const f = find(`<meta name="theme-color" content="#EDEFF2" />`, 'index.html')
2754
- expect(kinds(f)).toContain('raw-hex')
2755
- })
2756
-
2757
- it('flags raw hex in a webmanifest', () => {
2758
- const f = find(`{ "theme_color": "#242424" }`, 'public/site.webmanifest')
2759
- expect(kinds(f)).toContain('raw-hex')
2760
- })
2761
-
2762
- it('does not apply the JSX/CSS-in-JS kinds there', () => {
2763
- const f = find(`<div style="padding: 18px"><input /></div>`, 'index.html')
2764
- expect(kinds(f)).not.toContain('raw-form-control')
2765
- expect(kinds(f)).not.toContain('inline-spacing')
2766
- })
2767
-
2768
- it('honours a theme-allow inside an HTML comment', () => {
2769
- const f = find(
2770
- `<!-- theme-allow raw-hex — favicon mask color -->\n<meta content="#EDEFF2" />`,
2771
- 'index.html',
2772
- )
2773
- expect(kinds(f)).not.toContain('raw-hex')
2774
- })
2775
- })
2776
-
2777
- // ── 29b. single-file components (.astro / .vue) ───────────────────────────────
2778
-
2779
- /**
2780
- * Round 9: `.astro` and `.vue` became scannable and fell through to the `ts` dialect, so `<!-- … -->`
2781
- * was never stripped — a `theme-allow` written in an HTML comment waived nothing there, and a color
2782
- * inside a commented-out block still reported. They resolve as `sfc` now: BOTH comment dialects,
2783
- * and the full 27-kind set (not `MARKUP_KINDS` — an `.astro` template is JSX-shaped and a `.vue`
2784
- * `<script setup>` is real TS).
2785
- */
2786
- describe('single-file components (.astro / .vue)', () => {
2787
- const ASTRO = 'src/pages/index.astro'
2788
- const VUE = 'src/Hero.vue'
2789
-
2790
- for (const rel of [ASTRO, VUE]) {
2791
- it(`strips an HTML comment in ${rel}`, () => {
2792
- expect(kinds(find(`<!-- <div style="color: #ff0000" /> -->`, rel))).not.toContain('raw-hex')
2793
- })
2794
-
2795
- it(`honours a theme-allow inside an HTML comment in ${rel}`, () => {
2796
- const f = find(
2797
- `<!-- theme-allow raw-hex — brand asset -->\n<div style="color: #ff0000" />`,
2798
- rel,
2799
- )
2800
- expect(kinds(f)).not.toContain('raw-hex')
2801
- })
2802
-
2803
- it(`honours a theme-allow-file inside an HTML comment in ${rel}`, () => {
2804
- const f = find(
2805
- `<!-- theme-allow-file raw-hex — brand asset -->\n<b style="color: #ff0000" />`,
2806
- rel,
2807
- )
2808
- expect(kinds(f)).not.toContain('raw-hex')
2809
- })
2810
-
2811
- it(`honours a multi-line HTML comment annotation in ${rel}`, () => {
2812
- const f = find(
2813
- `<!--\n theme-allow raw-hex — brand asset\n-->\n<b style="color: #ff0000" />`,
2814
- rel,
2815
- )
2816
- expect(kinds(f)).not.toContain('raw-hex')
2817
- })
2818
-
2819
- it(`still strips the JS dialect in ${rel} — an SFC carries both`, () => {
2820
- expect(kinds(find(`// const c = '#ff0000'`, rel))).not.toContain('raw-hex')
2821
- expect(kinds(find(`/* const c = '#ff0000' */`, rel))).not.toContain('raw-hex')
2822
- })
2823
-
2824
- it(`honours a theme-allow in the script fence of ${rel}`, () => {
2825
- const f = find(`// theme-allow raw-hex — brand asset\nconst c = '#ff0000'`, rel)
2826
- expect(kinds(f)).not.toContain('raw-hex')
2827
- })
2828
-
2829
- it(`keeps the non-markup kinds — an SFC is not MARKUP_KINDS-restricted in ${rel}`, () => {
2830
- expect(kinds(find(`const s = { border: '1px solid #ccc' }`, rel))).toContain('raw-surface')
2831
- expect(kinds(find(`<div style={{ padding: 18 }} />`, rel))).toContain('inline-spacing')
2832
- })
2833
- }
2834
-
2835
- it('does not let an HTML comment open a runaway JS block comment', () => {
2836
- const f = find(`<!-- a /* unclosed -->\n<div style="color: #ff0000" />`, ASTRO)
2837
- expect(kinds(f)).toContain('raw-hex')
2838
- })
2839
-
2840
- /**
2841
- * Asserted LIMITS, so "unsupported" and "silently broken" don't read the same. Both are
2842
- * false-negative-only — the direction a release-blocking false positive says to take.
2843
- */
2844
- describe('known limits', () => {
2845
- it('does not fire the kebab-CSS surface kinds inside a <style> fence', () => {
2846
- const src = `<style>\n .card { border-radius: 8px; }\n</style>`
2847
- expect(kinds(find(src, ASTRO))).not.toContain('css-raw-surface')
2848
- // The same text in a real .css file still reports — the limit is the SFC dialect, not the rule.
2849
- expect(kinds(find(src.replace(/<\/?style>/g, ''), 'src/a.css'))).toContain('css-raw-surface')
2850
- })
2851
-
2852
- it('over-strips a `<!--` that lives inside a script string', () => {
2853
- expect(
2854
- kinds(find(`const s = '<!--'\nconst c = '#ff0000'\nconst e = '-->'`, ASTRO)),
2855
- ).not.toContain('raw-hex')
2856
- })
2857
-
2858
- it('over-strips after an unquoted `https://` in template prose', () => {
2859
- expect(
2860
- kinds(find(`<p>see https://x.test <b style="color: #ff0000" /></p>`, ASTRO)),
2861
- ).not.toContain('raw-hex')
2862
- })
2863
- })
2864
- })
2865
-
2866
- // ── 30. tokens-only profile ──────────────────────────────────────────────────
2867
-
2868
- describe("profile: 'tokens-only'", () => {
2869
- const TOKENS_ONLY = { ...DEFAULT_GUARD_CONFIG, profile: 'tokens-only' as const }
2870
-
2871
- it('drops the kinds whose remedy is a Mantine component', () => {
2872
- const src = `<input /> // a Mantine-free consumer has no TextInput to reach for`
2873
- expect(kinds(checkSource(src, PATH, DEFAULT_GUARD_CONFIG))).toContain('raw-form-control')
2874
- expect(kinds(checkSource(src, PATH, TOKENS_ONLY))).not.toContain('raw-form-control')
2875
- })
2876
-
2877
- it('keeps the color kinds — the token layer IS what they consume', () => {
2878
- const f = checkSource(`.a { color: #ff0000; }`, 'src/a.css', TOKENS_ONLY)
2879
- expect(kinds(f)).toContain('raw-hex')
2880
- })
2881
-
2882
- /**
2883
- * Every kind, classified by hand: is its remedy a Mantine component, a Mantine prop, or the React
2884
- * theme factory? The exhaustive literal IS the test — the previous spot check of four kinds is
2885
- * how `hidden-inline-style` shipped ENABLED under the profile, telling a Mantine-free app to
2886
- * import Box/Flex in a message copied word for word from `raw-html-layout`, which is disabled.
2887
- * A new kind fails the first assertion until it is classified here.
2888
- */
2889
- const MANTINE_COUPLED: Record<GuardKind, boolean> = {
2890
- 'raw-hex': false,
2891
- 'raw-color-fn': false,
2892
- 'localstorage-theme': true,
2893
- 'off-identity-accent': true,
2894
- 'mantine-shade-index': true,
2895
- 'raw-spacing': true,
2896
- 'raw-radius': true,
2897
- 'raw-surface': false,
2898
- 'card-with-border': true,
2899
- 'off-system-surface-var': true,
2900
- 'raw-html-layout': true,
2901
- 'inline-spacing': true,
2902
- 'inline-display': true,
2903
- 'raw-visx-axis': true,
2904
- 'raw-motion-value': true,
2905
- 'unframed-chart': true,
2906
- 'chart-missing-aria-label': true,
2907
- 'raw-form-control': true,
2908
- 'sub-16-input-font': true,
2909
- 'raw-font-family': false,
2910
- 'theme-allow-unscoped': false,
2911
- 'surface-shadow-override': false,
2912
- 'css-raw-surface': false,
2913
- 'inline-font-size': false,
2914
- 'hidden-inline-style': true,
2915
- // The two wave-6 control kinds: `<Title>` is a Mantine component, and the remedy for either is
2916
- // a Mantine-rendered home (PageBar / WidgetHeader) or a basalt control over @mantine/core.
2917
- 'in-body-page-title': true,
2918
- 'raw-selection-control': true,
2919
- }
2920
-
2921
- it('classifies every kind in the registry — the table is exhaustive', () => {
2922
- expect(Object.keys(MANTINE_COUPLED).toSorted()).toEqual(Object.keys(GUARD_RULES).toSorted())
2923
- expect(Object.keys(MANTINE_COUPLED)).toHaveLength(27)
2924
- })
2925
-
2926
- it('the disabled set is exactly the Mantine-coupled half — a complete partition', () => {
2927
- for (const [kind, coupled] of Object.entries(MANTINE_COUPLED) as [GuardKind, boolean][]) {
2928
- expect([kind, TOKENS_ONLY_DISABLED_KINDS.has(kind)]).toEqual([kind, coupled])
2929
- }
2930
- expect(TOKENS_ONLY_DISABLED_KINDS.size).toBe(
2931
- Object.values(MANTINE_COUPLED).filter(Boolean).length,
2932
- )
2933
- })
2934
-
2935
- it('no surviving kind tells a Mantine-free app to reach for a Mantine component', () => {
2936
- const MANTINE_REMEDY =
2937
- /@mantine\/|\b(?:Box|Flex|Grid|Stack|Group|TextInput|NumberInput|Select|Textarea)\b/
2938
- for (const kind of Object.keys(GUARD_RULES) as GuardKind[]) {
2939
- if (TOKENS_ONLY_DISABLED_KINDS.has(kind)) continue
2940
- expect([kind, MANTINE_REMEDY.test(GUARD_RULES[kind].message)]).toEqual([kind, false])
2941
- }
2942
- })
2943
-
2944
- it("hidden-inline-style is off — its remedy is raw-html-layout's, word for word", () => {
2945
- const src =
2946
- "const s = { display: 'flex', padding: 18 }\nexport const D = () => <div style={s} />"
2947
- expect(kinds(checkSource(src, PATH, DEFAULT_GUARD_CONFIG))).toContain('hidden-inline-style')
2948
- expect(kinds(checkSource(src, PATH, TOKENS_ONLY))).not.toContain('hidden-inline-style')
2949
- })
2950
- })
2951
-
2952
- // ── 31. remedy + waiver hint — what a report actually PRINTS ─────────────────
2953
-
2954
- describe('guardKindRemedy', () => {
2955
- // `check-theme`'s `Fix:` epilogue read a hand-duplicated subset of these, and the subset was
2956
- // missing every kind added at 1.20.0. All five of those ship `warn` under grace, so the findings
2957
- // whose whole argument is "this looks correct and is not" arrived with no argument — only "add a
2958
- // `theme-allow`", which reads as advice to waive. Sourcing it from the registry retires the
2959
- // duplicate instead of extending it, and this test is what stops a new kind shipping without one.
2960
- it('gives every kind a remedy, prefixed with the kind id', () => {
2961
- for (const kind of Object.keys(GUARD_RULES) as GuardKind[]) {
2962
- const remedy = guardKindRemedy(kind)
2963
- expect([kind, remedy.startsWith(`${kind}: `)]).toEqual([kind, true])
2964
- expect([kind, remedy.length > kind.length + 20]).toEqual([kind, true])
2965
- }
2966
- })
2967
-
2968
- it('covers the five 1.20.0 kinds that the duplicated table did not', () => {
2969
- for (const kind of [
2970
- 'surface-shadow-override',
2971
- 'css-raw-surface',
2972
- 'inline-font-size',
2973
- 'hidden-inline-style',
2974
- 'theme-allow-unscoped',
2975
- ] as GuardKind[]) {
2976
- expect([kind, guardKindRemedy(kind)]).toEqual([kind, `${kind}: ${GUARD_RULES[kind].message}`])
2977
- }
2978
- })
2979
-
2980
- // argo: the kind fires on a fixed dock replacing card depth, where the right answer is the
2981
- // overlay tier — and `SHADOW_KEEPS_CARD_DEPTH` accepts any `--vx-shadow-*`, so the message was
2982
- // narrower than the check and pointed away from the correct token.
2983
- it('surface-shadow-override names the overlay tier, not only the card one', () => {
2984
- expect(guardKindRemedy('surface-shadow-override')).toContain('--vx-shadow-overlay')
2985
- })
2986
- })
2987
-
2988
- describe('guardWaiverHint', () => {
2989
- // The `Fix:` closer prescribed a `theme-allow` COMMENT for every file class, including the one
2990
- // 1.20.0 had just started scanning and which cannot hold a comment at all.
2991
- it('never prescribes a comment to a file that cannot carry one', () => {
2992
- for (const relPath of ['public/site.webmanifest', 'src/brand.json']) {
2993
- expect([relPath, /theme-allow[^-]/.test(guardWaiverHint(relPath))]).toEqual([relPath, false])
2994
- expect([relPath, guardWaiverHint(relPath)]).toEqual([
2995
- relPath,
2996
- expect.stringContaining('basalt:theme-allow-file'),
2997
- ])
2998
- }
2999
- })
3000
-
3001
- // rollhook's sharper point: a manifest hex can never be RIGHT, because a manifest cannot
3002
- // reference a CSS variable — so the value is a hand-copy that drifts. Both consumers that hit it
3003
- // found a dead colour. The remedy is to stop hand-writing the file.
3004
- it('points a manifest at basaltAppPlugin first, and the annotation second', () => {
3005
- const hint = guardWaiverHint('public/site.webmanifest')
3006
- expect(hint).toContain('basaltAppPlugin')
3007
- expect(hint.indexOf('basaltAppPlugin')).toBeLessThan(hint.indexOf('basalt:theme-allow-file'))
3008
- })
3009
-
3010
- it('prescribes the comment form everywhere a comment exists', () => {
3011
- for (const relPath of ['src/App.tsx', 'src/app.css', 'index.html']) {
3012
- expect([relPath, guardWaiverHint(relPath)]).toEqual([
3013
- relPath,
3014
- expect.stringContaining('theme-allow <rule-id>'),
3015
- ])
3016
- }
3017
- })
3018
-
3019
- it('names both scopes, so the narrow one is the one a reader reaches for first', () => {
3020
- const hint = guardWaiverHint('src/App.tsx')
3021
- expect(hint.indexOf('theme-allow <rule-id>')).toBeLessThan(hint.indexOf('theme-allow-file'))
3022
- })
3023
-
3024
- // Every hint has to name a mechanism that EXISTS — the defect it replaces was a hint that did
3025
- // not. These two assert the guard actually honours what each hint prescribes.
3026
- it('the JSON hint names a member the guard honours', () => {
3027
- const f = checkSource(
3028
- '{\n "basalt:theme-allow-file": "raw-hex — a manifest cannot read a CSS variable",\n "theme_color": "#27272a"\n}\n',
3029
- 'public/site.webmanifest',
3030
- DEFAULT_GUARD_CONFIG,
3031
- )
3032
- expect(kinds(f)).not.toContain('raw-hex')
3033
- })
3034
-
3035
- it('the comment hint names a form the guard honours', () => {
3036
- expect(kinds(find(`// theme-allow raw-hex — vendor brand\nconst c = '#ff0000'`))).not.toContain(
3037
- 'raw-hex',
3038
- )
3039
- expect(
3040
- kinds(find(`// theme-allow-file raw-hex — vendor brand\nconst c = '#ff0000'`)),
3041
- ).not.toContain('raw-hex')
3042
- })
3043
- })
3044
-
3045
- // ── 32. inline-spacing — a property bag that never reaches CSS ───────────────
3046
-
3047
- describe('inline-spacing — the style-object test', () => {
3048
- const inlineSpacing = (src: string): GuardKind[] =>
3049
- kinds(find(src)).filter((k) => k === 'inline-spacing')
3050
-
3051
- // argo: `const FIT_BOUNDS_OPTIONS = { padding: 48, duration: 0 }` is a maplibre `fitBounds`
3052
- // viewport inset measured in MAP pixels. There is no Mantine token that could express it, and
3053
- // "use p/m/gap with xs..xl" is not a thing that can be done to it. The class recurs in any
3054
- // consumer wrapping a map or a canvas library — basalt's own shell carries one (a floating-ui
3055
- // `shift: { padding: 8 }`), which is how it stayed invisible.
3056
- it('does NOT flag a unitless number in a plain options bag', () => {
3057
- expect(inlineSpacing('const FIT_BOUNDS_OPTIONS = { padding: 48, duration: 0 }')).toEqual([])
3058
- })
3059
-
3060
- it('does NOT flag a unitless number in a call argument', () => {
3061
- expect(inlineSpacing('map.fitBounds(bounds, { padding: 48 })')).toEqual([])
3062
- })
3063
-
3064
- it('does NOT flag a nested non-style bag (floating-ui middleware)', () => {
3065
- expect(inlineSpacing('const M = { flip: false, shift: { padding: 8 } }')).toEqual([])
3066
- })
3067
-
3068
- // Everything the kind was built for still reports.
3069
- it.each([
3070
- '<Box style={{ padding: 16 }} />',
3071
- '<Box\n style={{\n padding: 16,\n }}\n/>',
3072
- '<Input styles={{ input: { paddingLeft: 12 } }} />',
3073
- 'const wrapperStyle = { padding: 16 }',
3074
- 'const s: CSSProperties = { marginTop: 12 }',
3075
- ])('still flags %s', (src) => {
3076
- expect([src, inlineSpacing(src)]).toEqual([src, ['inline-spacing']])
3077
- })
3078
-
3079
- // The hoisted form the name alone does not give away — resolved from the `style={…}` use site,
3080
- // the same seam `hidden-inline-style` already walks.
3081
- it('flags a hoisted object the file hands to a style prop', () => {
3082
- expect(
3083
- inlineSpacing('const s = { padding: 16 }\nexport const D = () => <div style={s} />'),
3084
- ).toEqual(['inline-spacing'])
3085
- })
3086
-
3087
- // A UNIT is evidence the number is CSS wherever it was written, so it never consults the context.
3088
- it.each([`const OPTS = { padding: '16px' }`, `const OPTS = { marginTop: '1.5rem' }`])(
3089
- '%s carries a unit and is flagged regardless of context',
3090
- (src) => {
3091
- expect([src, inlineSpacing(src)]).toEqual([src, ['inline-spacing']])
3092
- },
3093
- )
3094
-
3095
- it('CSS is untouched by the test — there is no object context to consult', () => {
3096
- const f = checkSource('.a { padding: 18px }', 'src/Card.module.css', DEFAULT_GUARD_CONFIG)
3097
- expect(kinds(f)).toContain('inline-spacing')
3098
- })
3099
- })
3100
-
3101
- // ── theme-allow shape grid ───────────────────────────────────────────────────────────────────────
3102
-
3103
- /**
3104
- * The whole shape space of an annotation, pinned cell by cell.
3105
- *
3106
- * FIVE holes have been found in this one contract in four rounds — two false positives, one false
3107
- * negative, and twice the guard and the oxlint plugin disagreeing about what an annotation IS. Each
3108
- * was found by a consumer writing a shape nobody had thought to test, and round 6's answer (a
3109
- * thirteen-shape list of "every shape a consumer actually writes") missed the next three anyway,
3110
- * because a list of observed shapes cannot cover the ones not yet observed.
3111
- *
3112
- * So this is a GRID over the four things that actually vary, not a list of anecdotes:
3113
- *
3114
- * | axis | values |
3115
- * |-|-|
3116
- * | comment style | `//` · `/* *\/` · `/** *\/` · `{/* *\/}` · `{/** *\/}` · `<!-- -->` · JSON member |
3117
- * | token position | opener line · gutter line · trailing after code |
3118
- * | where the closer falls | the token's line · its own line · N lines below · n/a |
3119
- * | what follows | code · blank then code · comment then code · code on the closer's line |
3120
- *
3121
- * `configs/oxlint-plugin.test.ts` pins the same grid, under the same row names, for the subset the
3122
- * plugin can judge (TS/JSX — it never sees CSS, HTML or JSON). The two halves must agree on every
3123
- * shared cell; add a row to one and it goes in the other in the same commit.
3124
- *
3125
- * The unsupported cells are asserted too. "Unsupported" and "silently broken" read identically to a
3126
- * consumer, and the whole point of the grid is to make them different things.
3127
- */
3128
- /** Wraps JSX children in a component, so a `{/* … *\/}` child is legal source. */
3129
- const jsxShape = (body: string): string =>
3130
- `export const C = () => (\n <div>\n${body}\n </div>\n)\n`
3131
-
3132
- /** Does the file waive its `raw-hex`? — the one question every grid row asks. */
3133
- const shapeWaives = (relPath: string, src: string): boolean =>
3134
- checkSource(src, relPath, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex').length === 0
3135
-
3136
- describe('theme-allow shape grid', () => {
3137
- const REASON = 'deliberate legacy value'
3138
- const A = `theme-allow raw-hex — ${REASON}`
3139
- const AF = `theme-allow-file raw-hex — ${REASON}`
3140
- /** The statement the guard reports `raw-hex` on. */
3141
- const T = `const a = '#f00'`
3142
- /** The JSX child the guard reports `raw-hex` on. */
3143
- const JT = `<span style={{ color: '#f00' }} />`
3144
- const J = jsxShape
3145
- const waives = shapeWaives
3146
-
3147
- // ── SUPPORTED — every cell of the grid that waives ────────────────────────
3148
- it.each([
3149
- // `//` — no closer to place.
3150
- ['// own line', PATH, `// ${A}\n${T}\n`],
3151
- ['// indented', PATH, `function f() {\n // ${A}\n ${T}\n}\n`],
3152
- ['// no space after the marker', PATH, `//${A}\n${T}\n`],
3153
- ['// trailing', PATH, `${T} // ${A}\n`],
3154
- ['// reason wrapped onto a second // comment', PATH, `// ${A},\n// continued here\n${T}\n`],
3155
- // `/* */` and `/** */` at statement level, closer in each of its three places.
3156
- ['/* */ own line', PATH, `/* ${A} */\n${T}\n`],
3157
- ['/* */ trailing', PATH, `${T} /* ${A} */\n`],
3158
- ['/** */ own line (docblock opener)', PATH, `/** ${A} */\n${T}\n`],
3159
- ['/* opener + token, closer on its own line', PATH, `/* ${A}\n*/\n${T}\n`],
3160
- ['/* gutter token, closer on the token line', PATH, `/*\n ${A} */\n${T}\n`],
3161
- ['/* gutter token, closer on its own line', PATH, `/*\n ${A}\n*/\n${T}\n`],
3162
- ['/** star gutter, closer on the token line', PATH, `/**\n * ${A} */\n${T}\n`],
3163
- ['/** star gutter, closer on its own line', PATH, `/**\n * ${A}\n */\n${T}\n`],
3164
- // Prose between the token and the closer — any amount of it.
3165
- ['/** token then 1 prose line', PATH, `/**\n * ${A}\n * more\n */\n${T}\n`],
3166
- ['/** token then 12 prose lines', PATH, `/**\n * ${A}\n${' * p\n'.repeat(12)} */\n${T}\n`],
3167
- [
3168
- '/** prose first, token on a later gutter line',
3169
- PATH,
3170
- `/**\n * some prose\n * ${A}\n */\n${T}\n`,
3171
- ],
3172
- // JSX expression containers — the family every disagreement so far has come from.
3173
- ['{/* */} own line', PATH, J(` {/* ${A} */}\n ${JT}`)],
3174
- ['{/* */} trailing on the target line', PATH, J(` ${JT} {/* ${A} */}`)],
3175
- ['{/* opener + token, closer on its own line', PATH, J(` {/* ${A}\n */}\n ${JT}`)],
3176
- ['{/* gutter token, closer on the token line', PATH, J(` {/*\n ${A} */}\n ${JT}`)],
3177
- [
3178
- '{/* gutter token, closer on its own line',
3179
- PATH,
3180
- J(` {/*\n ${A}\n */}\n ${JT}`),
3181
- ],
3182
- ['{/** star gutter, closer on the token line', PATH, J(` {/**\n * ${A} */}\n ${JT}`)],
3183
- [
3184
- '{/** star gutter, closer on its own line',
3185
- PATH,
3186
- J(` {/**\n * ${A}\n */}\n ${JT}`),
3187
- ],
3188
- [
3189
- '{/** token then 6 prose lines',
3190
- PATH,
3191
- J(` {/**\n * ${A}\n${' * p\n'.repeat(6)} */}\n ${JT}`),
3192
- ],
3193
- ['{/* target on the closer line', PATH, J(` {/*\n ${A}\n */}${JT}`)],
3194
- ['{/* closer alone, tab indented', PATH, J(`\t{/*\n\t ${A}\n\t*/}\n\t${JT}`)],
3195
- [
3196
- '{/* closer alone, space between */ and }',
3197
- PATH,
3198
- J(` {/*\n ${A}\n */ }\n ${JT}`),
3199
- ],
3200
- [
3201
- '{/* closer alone, two annotations in one block',
3202
- PATH,
3203
- J(` {/*\n ${A}\n theme-allow raw-color-fn — ${REASON}\n */}\n ${JT}`),
3204
- ],
3205
- // A further comment between the annotation and the code is walked through; a blank line is not.
3206
- ['/** */ then an unrelated // note', PATH, `/** ${A} */\n// an unrelated note\n${T}\n`],
3207
- [
3208
- '{/* */} then an unrelated {/* */}',
3209
- PATH,
3210
- J(` {/* ${A} */}\n {/* unrelated */}\n ${JT}`),
3211
- ],
3212
- // The file-declaration form, which is position-independent — pinned in two placements anyway,
3213
- // because "position-independent" is a claim the parser has to keep making.
3214
- [
3215
- 'theme-allow-file, {/* closer on its own line',
3216
- PATH,
3217
- J(` {/*\n ${AF}\n */}\n ${JT}`),
3218
- ],
3219
- ['theme-allow-file, /** star gutter', PATH, `/**\n * ${AF}\n */\n${T}\n`],
3220
- // Dialects the plugin never sees — CSS continuation lines, HTML, and the JSON member form.
3221
- ['css trailing', 'src/a.css', `a { color: #f00; /* ${A} */ }\n`],
3222
- [
3223
- 'css reflowed onto continuation lines',
3224
- 'src/a.css',
3225
- `a {\n color: var(\n --x,\n #f00\n ); /* ${A} */\n}\n`,
3226
- ],
3227
- ['html comment own line', 'index.html', `<!-- ${A} -->\n<b style="color: #f00"></b>\n`],
3228
- ['html trailing comment', 'index.html', `<b style="color: #f00"></b> <!-- ${A} -->\n`],
3229
- [
3230
- 'JSON member form',
3231
- 'public/site.webmanifest',
3232
- `{\n "basalt:theme-allow-file": "raw-hex — ${REASON}",\n "theme_color": "#f00"\n}\n`,
3233
- ],
3234
- ])('%s waives', (_name, relPath, src) => {
3235
- expect(waives(relPath as string, src as string)).toBe(true)
3236
- })
3237
-
3238
- // ── UNSUPPORTED — asserted, so a hole can never pass for a design decision ──
3239
- it.each([
3240
- // A blank line is the separation people use for "this comment is not about the next statement".
3241
- ['blank line after a // annotation', PATH, `// ${A}\n\n${T}\n`],
3242
- ['blank line after a {/* */} annotation', PATH, J(` {/* ${A} */}\n\n ${JT}`)],
3243
- [
3244
- 'blank line after a {/* closer on its own line',
3245
- PATH,
3246
- J(` {/*\n ${A}\n */}\n\n ${JT}`),
3247
- ],
3248
- // Prose that MENTIONS the token is not an annotation — the one false NEGATIVE in the set, and
3249
- // the reason the token has to START its comment.
3250
- ['mid-sentence in a line comment', PATH, `// we normally write a ${A} here\n${T}\n`],
3251
- [
3252
- 'mid-sentence in a docblock gutter',
3253
- PATH,
3254
- `/**\n * Each value is escaped with a ${A} annotation.\n */\n${T}\n`,
3255
- ],
3256
- [
3257
- 'mid-sentence in a JSX expression comment',
3258
- PATH,
3259
- J(` {/* the shape here is a ${A} comment */}\n ${JT}`),
3260
- ],
3261
- ['inside a string literal', PATH, `const doc = 'theme-allow raw-hex'\n${T}\n`],
3262
- // An annotation reaches the first line below its comment — not an arbitrary line further down.
3263
- // A multi-line opening tag therefore needs the annotation beside the attribute, not above the
3264
- // tag. Both halves agree, and both report rather than silently waiving the wrong node.
3265
- [
3266
- 'above a multi-line opening tag',
3267
- PATH,
3268
- J(` {/* ${A} */}\n <span\n style={{ color: '#f00' }}\n />`),
3269
- ],
3270
- ])('%s does NOT waive', (_name, relPath, src) => {
3271
- expect(waives(relPath as string, src as string)).toBe(false)
3272
- })
3273
-
3274
- // The narrowness of the `*/}` exception: a `}` that is REAL code keeps its trailing
3275
- // classification, so the annotation does not reach the statement below it.
3276
- it('a trailing annotation on a real closing brace stays on its own line', () => {
3277
- const src = `function f() {\n const a = 1\n} // ${A}\nconst b = '#0f0'\n`
3278
- expect(
3279
- checkSource(src, PATH, DEFAULT_GUARD_CONFIG)
3280
- .filter((f) => f.kind === 'raw-hex')
3281
- .map((f) => f.line),
3282
- ).toEqual([4])
3283
- })
3284
-
3285
- // A trailing annotation is scoped to its OWN line — otherwise `const a = '#f00' // theme-allow`
3286
- // would silently waive the statement below it too.
3287
- it('a trailing annotation does not reach the line below', () => {
3288
- const src = `const a = '#f00' // ${A}\nconst b = '#0f0'\n`
3289
- expect(
3290
- checkSource(src, PATH, DEFAULT_GUARD_CONFIG).filter((f) => f.kind === 'raw-hex'),
3291
- ).toHaveLength(1)
3292
- })
3293
- })
3294
-
3295
- // ── findAllowAnnotations / neutralizeAllowAnnotation ─────────────────────────────────────────────
3296
-
3297
- describe('findAllowAnnotations', () => {
3298
- const cfg = DEFAULT_GUARD_CONFIG
3299
-
3300
- it('classifies each named id by whose reach it falls in', () => {
3301
- const src = [
3302
- `// theme-allow raw-hex — a guard kind`,
3303
- `const a = '#f00'`,
3304
- `// theme-allow hand-rolled-plot — a plugin rule, outside checkSource's reach`,
3305
- `const b = 1`,
3306
- `// theme-allow raw-hexx — a typo`,
3307
- `const c = 2`,
3308
- `// theme-allow`,
3309
- `const d = 3`,
3310
- ].join('\n')
3311
- const sites = findAllowAnnotations(src, PATH, cfg)
3312
-
3313
- expect(sites.map((s) => s.line)).toEqual([1, 3, 5, 7])
3314
- expect(sites[0]).toMatchObject({ guardKinds: ['raw-hex'], pluginRules: [], bare: false })
3315
- expect(sites[1]).toMatchObject({ guardKinds: [], pluginRules: ['hand-rolled-plot'] })
3316
- expect(sites[2]).toMatchObject({ rules: [], unknownRules: ['raw-hexx'], bare: false })
3317
- expect(sites[3]).toMatchObject({ bare: true, hasReason: false })
3318
- })
3319
-
3320
- it('records the file-declaration form once, at the line it is written on', () => {
3321
- const src = `// theme-allow-file raw-hex — the whole palette fixture\nconst a = '#f00'\n`
3322
- expect(findAllowAnnotations(src, PATH, cfg)).toMatchObject([
3323
- { line: 1, scope: 'file', guardKinds: ['raw-hex'] },
3324
- ])
3325
- })
3326
-
3327
- it('carries the source line verbatim, and finds nothing in a file with no annotation', () => {
3328
- expect(findAllowAnnotations(`const a = '#f00'\n`, PATH, cfg)).toEqual([])
3329
- expect(
3330
- findAllowAnnotations(` // theme-allow raw-hex — why not\nconst a = 1\n`, PATH, cfg)[0]?.text,
3331
- ).toBe(' // theme-allow raw-hex — why not')
3332
- })
3333
-
3334
- // It shares `collectAllowAnnotations` with `checkSource`, so it cannot list a line the scan does
3335
- // not honour — the mirrored regex `--audit-allows` carried was already one alternation behind.
3336
- it('agrees with checkSource about what is prose and what is an annotation', () => {
3337
- expect(
3338
- findAllowAnnotations(`// a theme-allow raw-hex goes here\nconst a = 1\n`, PATH, cfg),
3339
- ).toEqual([])
3340
- expect(
3341
- findAllowAnnotations(`/** theme-allow raw-hex — deliberate */\nconst a = 1\n`, PATH, cfg),
3342
- ).toHaveLength(1)
3343
- })
3344
-
3345
- it('finds the JSON member form in a manifest', () => {
3346
- const src = `{\n "basalt:theme-allow-file": "raw-hex — brand color"\n}\n`
3347
- expect(findAllowAnnotations(src, 'public/site.webmanifest', cfg)).toMatchObject([
3348
- { line: 2, scope: 'file', guardKinds: ['raw-hex'] },
3349
- ])
3350
- })
3351
- })
3352
-
3353
- describe('neutralizeAllowAnnotation', () => {
3354
- const cfg = DEFAULT_GUARD_CONFIG
3355
-
3356
- it('neutralizes ONE annotation and reveals exactly what it suppressed', () => {
3357
- const src = [
3358
- `const a = '#f00' // theme-allow raw-hex — first`,
3359
- `const b = '#0f0' // theme-allow raw-hex — second`,
3360
- ].join('\n')
3361
- expect(checkSource(src, PATH, cfg).filter((f) => f.kind === 'raw-hex')).toEqual([])
3362
-
3363
- const probe = neutralizeAllowAnnotation(src, 1, cfg)
3364
- expect(probe).toContain(NEUTRALIZED_ALLOW_TOKEN)
3365
- expect(
3366
- checkSource(probe, PATH, cfg)
3367
- .filter((f) => f.kind === 'raw-hex')
3368
- .map((f) => f.line),
3369
- ).toEqual([1])
3370
- })
3371
-
3372
- it('leaves a line with no token, and an out-of-range line, untouched', () => {
3373
- const src = `const a = 1\nconst b = 2\n`
3374
- expect(neutralizeAllowAnnotation(src, 1, cfg)).toBe(src)
3375
- expect(neutralizeAllowAnnotation(src, 99, cfg)).toBe(src)
3376
- })
3377
-
3378
- it('the neutralized token shares no substring with the annotation it replaces', () => {
3379
- expect(NEUTRALIZED_ALLOW_TOKEN).not.toContain(cfg.allowComment)
3380
- expect(NEUTRALIZED_ALLOW_TOKEN.includes('theme-allow')).toBe(false)
3381
- })
3382
- })
3383
-
3384
- describe('PLUGIN_RULE_IDS', () => {
3385
- it('names no guard kind — the two registries are disjoint by construction', () => {
3386
- for (const id of PLUGIN_RULE_IDS) expect(Object.hasOwn(GUARD_RULES, id)).toBe(false)
3387
- })
3388
- })