basalt-ui 1.24.0 → 1.26.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (467) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +25 -0
  3. package/MIGRATING.md +547 -21
  4. package/README.md +46 -33
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +155 -0
  8. package/agent/rules/basalt-mantine.md +146 -439
  9. package/agent/rules/basalt-state.md +113 -96
  10. package/agent/rules/basalt-tokens.md +126 -288
  11. package/agent/skills/basalt-app/SKILL.md +70 -195
  12. package/agent/skills/basalt-charts/SKILL.md +54 -234
  13. package/agent/skills/basalt-design/SKILL.md +56 -248
  14. package/agent/templates/CLAUDE-block.md.tpl +31 -40
  15. package/agent/templates/DESIGN.md.tpl +22 -51
  16. package/configs/oxlint-plugin.js +1275 -31
  17. package/configs/oxlint-plugin.test.ts +947 -11
  18. package/configs/oxlint.json +11 -3
  19. package/dist/charts/cursor/resolve.d.ts +18 -0
  20. package/dist/charts/cursor/resolve.d.ts.map +1 -1
  21. package/dist/charts/cursor/resolve.js +15 -6
  22. package/dist/charts/cursor/scope.d.ts +6 -3
  23. package/dist/charts/cursor/scope.d.ts.map +1 -1
  24. package/dist/charts/cursor/store.d.ts +5 -1
  25. package/dist/charts/cursor/store.d.ts.map +1 -1
  26. package/dist/charts/cursor/store.js +4 -4
  27. package/dist/charts/hooks/useChartCursor.d.ts.map +1 -1
  28. package/dist/charts/hooks/useChartCursor.js +9 -8
  29. package/dist/charts/index.d.ts +2 -2
  30. package/dist/charts/index.d.ts.map +1 -1
  31. package/dist/charts/primitives/ChartCard.d.ts +34 -9
  32. package/dist/charts/primitives/ChartCard.d.ts.map +1 -1
  33. package/dist/charts/primitives/ChartCard.js +29 -114
  34. package/dist/charts/primitives/ChartCard.test.d.ts +2 -0
  35. package/dist/charts/primitives/ChartCard.test.d.ts.map +1 -0
  36. package/dist/charts/sparklines/BarSparkline.d.ts +26 -2
  37. package/dist/charts/sparklines/BarSparkline.d.ts.map +1 -1
  38. package/dist/charts/sparklines/BarSparkline.js +18 -4
  39. package/dist/cli/check-coverage.test.d.ts +2 -0
  40. package/dist/cli/check-coverage.test.d.ts.map +1 -0
  41. package/dist/cli/index.d.ts +67 -4
  42. package/dist/cli/index.d.ts.map +1 -1
  43. package/dist/cli/index.js +225 -7
  44. package/dist/connectivity/connectivity-indicator.d.ts +4 -1
  45. package/dist/connectivity/connectivity-indicator.d.ts.map +1 -1
  46. package/dist/connectivity/connectivity-indicator.js +1 -1
  47. package/dist/content/index.d.ts +5 -3
  48. package/dist/content/index.d.ts.map +1 -1
  49. package/dist/content/index.js +4 -2
  50. package/dist/content/prose.module.css +7 -28
  51. package/dist/controls/actions.d.ts +169 -0
  52. package/dist/controls/actions.d.ts.map +1 -0
  53. package/dist/controls/actions.js +287 -0
  54. package/dist/controls/actions.module.css +62 -0
  55. package/dist/controls/actions.module.css.d.ts +13 -0
  56. package/dist/controls/actions.test.d.ts +2 -0
  57. package/dist/controls/actions.test.d.ts.map +1 -0
  58. package/dist/controls/actions.type-guard.test.d.ts +2 -0
  59. package/dist/controls/actions.type-guard.test.d.ts.map +1 -0
  60. package/dist/controls/compare-filter.d.ts +41 -0
  61. package/dist/controls/compare-filter.d.ts.map +1 -0
  62. package/dist/controls/compare-filter.js +23 -0
  63. package/dist/controls/control-group.d.ts +19 -0
  64. package/dist/controls/control-group.d.ts.map +1 -0
  65. package/dist/controls/control-group.js +20 -0
  66. package/dist/controls/control-group.test.d.ts +2 -0
  67. package/dist/controls/control-group.test.d.ts.map +1 -0
  68. package/dist/controls/controls.module.css +542 -0
  69. package/dist/controls/controls.module.css.d.ts +35 -0
  70. package/dist/controls/controls.router.test.d.ts +2 -0
  71. package/dist/controls/controls.router.test.d.ts.map +1 -0
  72. package/dist/controls/enum-filter.d.ts +30 -0
  73. package/dist/controls/enum-filter.d.ts.map +1 -0
  74. package/dist/controls/enum-filter.js +91 -0
  75. package/dist/controls/filter-context.d.ts +55 -0
  76. package/dist/controls/filter-context.d.ts.map +1 -0
  77. package/dist/controls/filter-context.js +69 -0
  78. package/dist/controls/filter-pill.d.ts +28 -0
  79. package/dist/controls/filter-pill.d.ts.map +1 -0
  80. package/dist/controls/filter-pill.js +70 -0
  81. package/dist/controls/filter-set.d.ts +13 -0
  82. package/dist/controls/filter-set.d.ts.map +1 -0
  83. package/dist/controls/filter-set.js +125 -0
  84. package/dist/controls/filter-set.test.d.ts +2 -0
  85. package/dist/controls/filter-set.test.d.ts.map +1 -0
  86. package/dist/controls/filter-sheet.d.ts +117 -0
  87. package/dist/controls/filter-sheet.d.ts.map +1 -0
  88. package/dist/controls/filter-sheet.js +146 -0
  89. package/dist/controls/glyphs.d.ts +27 -0
  90. package/dist/controls/glyphs.d.ts.map +1 -0
  91. package/dist/controls/glyphs.js +49 -0
  92. package/dist/controls/index.d.ts +19 -0
  93. package/dist/controls/index.d.ts.map +1 -0
  94. package/dist/controls/index.js +44 -0
  95. package/dist/controls/multi-select-filter.d.ts +23 -0
  96. package/dist/controls/multi-select-filter.d.ts.map +1 -0
  97. package/dist/controls/multi-select-filter.js +89 -0
  98. package/dist/controls/peer-boundary.test.d.ts +2 -0
  99. package/dist/controls/peer-boundary.test.d.ts.map +1 -0
  100. package/dist/controls/props-contract.test.d.ts +2 -0
  101. package/dist/controls/props-contract.test.d.ts.map +1 -0
  102. package/dist/controls/range-filter.d.ts +29 -0
  103. package/dist/controls/range-filter.d.ts.map +1 -0
  104. package/dist/controls/range-filter.js +120 -0
  105. package/dist/controls/search-filter.d.ts +10 -0
  106. package/dist/controls/search-filter.d.ts.map +1 -0
  107. package/dist/controls/search-filter.js +81 -0
  108. package/dist/controls/select-filter.d.ts +74 -0
  109. package/dist/controls/select-filter.d.ts.map +1 -0
  110. package/dist/controls/select-filter.js +10 -0
  111. package/dist/controls/sync-button.d.ts +38 -0
  112. package/dist/controls/sync-button.d.ts.map +1 -0
  113. package/dist/controls/sync-button.js +119 -0
  114. package/dist/controls/sync-button.module.css +63 -0
  115. package/dist/controls/sync-button.module.css.d.ts +11 -0
  116. package/dist/controls/sync-button.test.d.ts +2 -0
  117. package/dist/controls/sync-button.test.d.ts.map +1 -0
  118. package/dist/controls/toggle-filter.d.ts +9 -0
  119. package/dist/controls/toggle-filter.d.ts.map +1 -0
  120. package/dist/controls/toggle-filter.js +42 -0
  121. package/dist/controls/view-tabs.d.ts +23 -0
  122. package/dist/controls/view-tabs.d.ts.map +1 -0
  123. package/dist/controls/view-tabs.js +59 -0
  124. package/dist/controls-dates/date-range-picker.d.ts +4 -0
  125. package/dist/controls-dates/date-range-picker.d.ts.map +1 -0
  126. package/dist/controls-dates/date-range-picker.js +18 -0
  127. package/dist/controls-dates/index.d.ts +9 -0
  128. package/dist/controls-dates/index.d.ts.map +1 -0
  129. package/dist/controls-dates/index.js +4 -0
  130. package/dist/dashboard/delta-badge.d.ts +6 -12
  131. package/dist/dashboard/delta-badge.d.ts.map +1 -1
  132. package/dist/dashboard/delta-badge.js +1 -50
  133. package/dist/dashboard/query-state.d.ts +1 -1
  134. package/dist/dashboard/settings-section.d.ts +6 -4
  135. package/dist/dashboard/settings-section.d.ts.map +1 -1
  136. package/dist/dashboard/settings-section.js +23 -16
  137. package/dist/dashboard/settings-section.module.css +10 -0
  138. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  139. package/dist/dashboard/settings-section.test.d.ts +2 -0
  140. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  141. package/dist/dashboard/stat-card.d.ts +44 -9
  142. package/dist/dashboard/stat-card.d.ts.map +1 -1
  143. package/dist/dashboard/stat-card.js +80 -48
  144. package/dist/dashboard/stat-card.module.css +82 -0
  145. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  146. package/dist/data/data-table.d.ts +102 -20
  147. package/dist/data/data-table.d.ts.map +1 -1
  148. package/dist/data/data-table.js +166 -65
  149. package/dist/data/table.d.ts +1 -1
  150. package/dist/data/table.d.ts.map +1 -1
  151. package/dist/guard/grace.test.d.ts +20 -0
  152. package/dist/guard/grace.test.d.ts.map +1 -0
  153. package/dist/guard/index.d.ts +66 -3
  154. package/dist/guard/index.d.ts.map +1 -1
  155. package/dist/guard/index.js +82 -7
  156. package/dist/guard/types.d.ts +3 -3
  157. package/dist/guard/types.d.ts.map +1 -1
  158. package/dist/index.d.ts +12 -2
  159. package/dist/index.d.ts.map +1 -1
  160. package/dist/index.js +13 -10
  161. package/dist/nav/types.d.ts +78 -2
  162. package/dist/nav/types.d.ts.map +1 -1
  163. package/dist/notifications/bell.d.ts +4 -1
  164. package/dist/notifications/bell.d.ts.map +1 -1
  165. package/dist/notifications/index.d.ts +1 -1
  166. package/dist/router-tanstack/field.d.ts +14 -0
  167. package/dist/router-tanstack/field.d.ts.map +1 -0
  168. package/dist/router-tanstack/field.js +4 -0
  169. package/dist/router-tanstack/index.d.ts +6 -2
  170. package/dist/router-tanstack/index.d.ts.map +1 -1
  171. package/dist/router-tanstack/index.js +4 -0
  172. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  173. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  175. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  176. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  177. package/dist/router-tanstack/search-param-store.js +16 -45
  178. package/dist/router-tanstack/search-store.d.ts +81 -0
  179. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.js +187 -0
  181. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  182. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  183. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  184. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  185. package/dist/router-tanstack/use-nav.d.ts +0 -2
  186. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  187. package/dist/router-tanstack/use-nav.js +34 -12
  188. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  189. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  190. package/dist/section/index.d.ts +2 -0
  191. package/dist/section/index.d.ts.map +1 -0
  192. package/dist/section/index.js +4 -0
  193. package/dist/section/section.d.ts +24 -0
  194. package/dist/section/section.d.ts.map +1 -0
  195. package/dist/section/section.js +126 -0
  196. package/dist/section/section.module.css +104 -0
  197. package/dist/section/section.module.css.d.ts +11 -0
  198. package/dist/section/section.test.d.ts +2 -0
  199. package/dist/section/section.test.d.ts.map +1 -0
  200. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  201. package/dist/shell/app-breadcrumbs.js +18 -15
  202. package/dist/shell/app-header.module.css +77 -37
  203. package/dist/shell/app-header.module.css.d.ts +2 -1
  204. package/dist/shell/app-mobile-nav.d.ts +9 -2
  205. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  206. package/dist/shell/app-mobile-nav.js +98 -11
  207. package/dist/shell/app-mobile-nav.module.css +8 -0
  208. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  209. package/dist/shell/app-sidebar.d.ts +55 -28
  210. package/dist/shell/app-sidebar.d.ts.map +1 -1
  211. package/dist/shell/app-sidebar.js +116 -67
  212. package/dist/shell/app-sidebar.module.css +303 -20
  213. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  214. package/dist/shell/index.d.ts +55 -21
  215. package/dist/shell/index.d.ts.map +1 -1
  216. package/dist/shell/index.js +40 -18
  217. package/dist/shell/mobile-nav-model.d.ts +13 -2
  218. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  219. package/dist/shell/mobile-nav-model.js +15 -1
  220. package/dist/shell/nav-count-badge.d.ts +1 -1
  221. package/dist/shell/page-bar.d.ts +51 -0
  222. package/dist/shell/page-bar.d.ts.map +1 -0
  223. package/dist/shell/page-bar.js +154 -0
  224. package/dist/shell/page-bar.module.css +178 -0
  225. package/dist/shell/page-bar.module.css.d.ts +17 -0
  226. package/dist/shell/page-bar.test.d.ts +2 -0
  227. package/dist/shell/page-bar.test.d.ts.map +1 -0
  228. package/dist/shell/sidebar-block-model.d.ts +48 -0
  229. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  230. package/dist/shell/sidebar-block-model.js +39 -0
  231. package/dist/shell/sidebar-blocks.d.ts +32 -0
  232. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  233. package/dist/shell/sidebar-blocks.js +169 -0
  234. package/dist/shell/sidebar-search.d.ts +13 -1
  235. package/dist/shell/sidebar-search.d.ts.map +1 -1
  236. package/dist/shell/sidebar-search.js +34 -1
  237. package/dist/shell/sidebar-search.module.css +26 -0
  238. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  239. package/dist/state/fields.d.ts +392 -0
  240. package/dist/state/fields.d.ts.map +1 -0
  241. package/dist/state/fields.js +369 -0
  242. package/dist/state/fields.test.d.ts +2 -0
  243. package/dist/state/fields.test.d.ts.map +1 -0
  244. package/dist/state/persisted.d.ts +48 -0
  245. package/dist/state/persisted.d.ts.map +1 -0
  246. package/dist/state/persisted.js +105 -0
  247. package/dist/state.d.ts +11 -56
  248. package/dist/state.d.ts.map +1 -1
  249. package/dist/state.js +14 -120
  250. package/dist/styles.css +13 -0
  251. package/dist/surfaces.d.ts +106 -24
  252. package/dist/surfaces.d.ts.map +1 -1
  253. package/dist/surfaces.js +155 -19
  254. package/dist/theme/controls.module.css +16 -1
  255. package/dist/theme/ctl-theme.d.ts +32 -0
  256. package/dist/theme/ctl-theme.d.ts.map +1 -0
  257. package/dist/theme/ctl-theme.js +54 -0
  258. package/dist/theme/ctl-theme.test.d.ts +2 -0
  259. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  260. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  261. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  262. package/dist/theme/divider-law.test.d.ts +2 -0
  263. package/dist/theme/divider-law.test.d.ts.map +1 -0
  264. package/dist/theme/icon-slot.d.ts +44 -0
  265. package/dist/theme/icon-slot.d.ts.map +1 -0
  266. package/dist/theme/icon-slot.js +21 -0
  267. package/dist/theme/icon-slot.module.css +47 -0
  268. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  269. package/dist/theme/icon-slot.test.d.ts +2 -0
  270. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  271. package/dist/theme/index.d.ts +1 -0
  272. package/dist/theme/index.d.ts.map +1 -1
  273. package/dist/theme/index.js +82 -5
  274. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  275. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  276. package/dist/theme/segmented-control.module.css +20 -0
  277. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  278. package/dist/tokens/index.d.ts +12 -0
  279. package/dist/tokens/index.d.ts.map +1 -1
  280. package/dist/tokens/index.js +38 -8
  281. package/dist/tokens/palette.d.ts +97 -61
  282. package/dist/tokens/palette.d.ts.map +1 -1
  283. package/dist/tokens/palette.js +76 -38
  284. package/dist/tokens.css +10 -2
  285. package/dist/widget-header/delta-badge.d.ts +13 -0
  286. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  287. package/dist/widget-header/delta-badge.js +29 -0
  288. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  289. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  290. package/dist/widget-header/index.d.ts +3 -0
  291. package/dist/widget-header/index.d.ts.map +1 -0
  292. package/dist/widget-header/index.js +6 -0
  293. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  294. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  295. package/dist/widget-header/widget-header.d.ts +37 -0
  296. package/dist/widget-header/widget-header.d.ts.map +1 -0
  297. package/dist/widget-header/widget-header.js +77 -0
  298. package/dist/widget-header/widget-header.module.css +335 -0
  299. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  300. package/dist/widget-header/widget-header.test.d.ts +2 -0
  301. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  302. package/llms.txt +30 -17
  303. package/package.json +14 -1
  304. package/src/charts/cursor/resolve.test.ts +29 -1
  305. package/src/charts/cursor/resolve.ts +38 -6
  306. package/src/charts/cursor/scope.tsx +6 -3
  307. package/src/charts/cursor/store.test.ts +19 -1
  308. package/src/charts/cursor/store.ts +10 -5
  309. package/src/charts/hooks/useChartCursor.ts +16 -8
  310. package/src/charts/index.ts +2 -2
  311. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  312. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  313. package/src/charts/primitives/ChartCard.tsx +86 -146
  314. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  315. package/src/cli/check-coverage.test.ts +297 -0
  316. package/src/cli/check-theme.test.ts +38 -14
  317. package/src/cli/index.ts +392 -16
  318. package/src/cli/init-sync.test.ts +97 -0
  319. package/src/connectivity/connectivity-indicator.tsx +16 -9
  320. package/src/content/index.ts +16 -3
  321. package/src/content/prose.module.css +7 -28
  322. package/src/content/prose.module.css.test.ts +8 -24
  323. package/src/controls/actions.module.css +62 -0
  324. package/src/controls/actions.module.css.d.ts +13 -0
  325. package/src/controls/actions.test.tsx +252 -0
  326. package/src/controls/actions.tsx +655 -0
  327. package/src/controls/actions.type-guard.test.ts +63 -0
  328. package/src/controls/compare-filter.tsx +64 -0
  329. package/src/controls/control-group.test.tsx +229 -0
  330. package/src/controls/control-group.tsx +63 -0
  331. package/src/controls/controls.module.css +542 -0
  332. package/src/controls/controls.module.css.d.ts +35 -0
  333. package/src/controls/controls.router.test.tsx +637 -0
  334. package/src/controls/enum-filter.tsx +159 -0
  335. package/src/controls/filter-context.tsx +153 -0
  336. package/src/controls/filter-pill.tsx +120 -0
  337. package/src/controls/filter-set.test.tsx +472 -0
  338. package/src/controls/filter-set.tsx +225 -0
  339. package/src/controls/filter-sheet.tsx +285 -0
  340. package/src/controls/glyphs.tsx +81 -0
  341. package/src/controls/index.ts +48 -0
  342. package/src/controls/multi-select-filter.tsx +140 -0
  343. package/src/controls/peer-boundary.test.ts +85 -0
  344. package/src/controls/props-contract.test.tsx +106 -0
  345. package/src/controls/range-filter.tsx +184 -0
  346. package/src/controls/search-filter.tsx +123 -0
  347. package/src/controls/select-filter.tsx +89 -0
  348. package/src/controls/sync-button.module.css +63 -0
  349. package/src/controls/sync-button.module.css.d.ts +11 -0
  350. package/src/controls/sync-button.test.tsx +183 -0
  351. package/src/controls/sync-button.tsx +197 -0
  352. package/src/controls/toggle-filter.tsx +60 -0
  353. package/src/controls/view-tabs.tsx +110 -0
  354. package/src/controls-dates/date-range-picker.tsx +42 -0
  355. package/src/controls-dates/index.ts +8 -0
  356. package/src/dashboard/delta-badge.tsx +4 -74
  357. package/src/dashboard/query-state.tsx +1 -1
  358. package/src/dashboard/settings-section.module.css +10 -0
  359. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  360. package/src/dashboard/settings-section.test.tsx +56 -0
  361. package/src/dashboard/settings-section.tsx +33 -30
  362. package/src/dashboard/stat-card.module.css +82 -0
  363. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  364. package/src/dashboard/stat-card.test.tsx +67 -1
  365. package/src/dashboard/stat-card.tsx +209 -68
  366. package/src/data/data-table.test.tsx +282 -6
  367. package/src/data/data-table.tsx +429 -94
  368. package/src/data/table.ts +2 -0
  369. package/src/guard/check-source.test.ts +126 -13
  370. package/src/guard/grace.test.ts +149 -0
  371. package/src/guard/index.ts +172 -29
  372. package/src/guard/types.ts +4 -2
  373. package/src/index.ts +46 -5
  374. package/src/nav/types.ts +84 -2
  375. package/src/notifications/bell.tsx +6 -3
  376. package/src/notifications/index.ts +1 -1
  377. package/src/router-tanstack/field.ts +31 -0
  378. package/src/router-tanstack/index.ts +24 -2
  379. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  380. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  381. package/src/router-tanstack/search-param-store.ts +42 -159
  382. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  383. package/src/router-tanstack/search-store.test.ts +418 -0
  384. package/src/router-tanstack/search-store.ts +380 -0
  385. package/src/router-tanstack/use-nav.test.tsx +187 -0
  386. package/src/router-tanstack/use-nav.ts +72 -14
  387. package/src/section/index.ts +1 -0
  388. package/src/section/section.module.css +104 -0
  389. package/src/section/section.module.css.d.ts +11 -0
  390. package/src/section/section.test.tsx +204 -0
  391. package/src/section/section.tsx +220 -0
  392. package/src/shell/app-breadcrumbs.tsx +44 -31
  393. package/src/shell/app-header.module.css +77 -37
  394. package/src/shell/app-header.module.css.d.ts +2 -1
  395. package/src/shell/app-mobile-nav.module.css +8 -0
  396. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  397. package/src/shell/app-mobile-nav.tsx +147 -8
  398. package/src/shell/app-sidebar-account.tsx +2 -2
  399. package/src/shell/app-sidebar.module.css +303 -20
  400. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  401. package/src/shell/app-sidebar.test.tsx +456 -41
  402. package/src/shell/app-sidebar.tsx +276 -140
  403. package/src/shell/index.test.tsx +291 -5
  404. package/src/shell/index.tsx +137 -38
  405. package/src/shell/mobile-nav-model.test.ts +228 -7
  406. package/src/shell/mobile-nav-model.ts +53 -3
  407. package/src/shell/nav-count-badge.tsx +1 -1
  408. package/src/shell/page-bar.module.css +178 -0
  409. package/src/shell/page-bar.module.css.d.ts +17 -0
  410. package/src/shell/page-bar.test.tsx +316 -0
  411. package/src/shell/page-bar.tsx +315 -0
  412. package/src/shell/sidebar-block-model.ts +83 -0
  413. package/src/shell/sidebar-blocks.tsx +301 -0
  414. package/src/shell/sidebar-search.module.css +26 -0
  415. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  416. package/src/shell/sidebar-search.tsx +71 -2
  417. package/src/state/fields.test.tsx +258 -0
  418. package/src/state/fields.ts +892 -0
  419. package/src/state/persisted.ts +211 -0
  420. package/src/state.ts +46 -240
  421. package/src/styles.css +13 -0
  422. package/src/surfaces.ts +203 -36
  423. package/src/theme/controls.module.css +16 -1
  424. package/src/theme/ctl-theme.test.tsx +275 -0
  425. package/src/theme/ctl-theme.tsx +138 -0
  426. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  427. package/src/theme/density-relations.test.ts +106 -51
  428. package/src/theme/divider-law.test.ts +259 -0
  429. package/src/theme/icon-slot.module.css +47 -0
  430. package/src/theme/icon-slot.module.css.d.ts +8 -0
  431. package/src/theme/icon-slot.test.tsx +275 -0
  432. package/src/theme/icon-slot.tsx +59 -0
  433. package/src/theme/index.ts +165 -3
  434. package/src/theme/layout-rhythm-css.test.ts +72 -0
  435. package/src/theme/radius.test.ts +11 -1
  436. package/src/theme/segmented-control.module.css +20 -0
  437. package/src/theme/shadow-surfaces.test.ts +20 -7
  438. package/src/theme/spacing.test.ts +75 -27
  439. package/src/theme/use-basalt-spacing.ts +2 -2
  440. package/src/tokens/density.test.ts +7 -25
  441. package/src/tokens/index.ts +48 -8
  442. package/src/tokens/palette.ts +107 -73
  443. package/src/widget-header/delta-badge.test.tsx +60 -0
  444. package/src/widget-header/delta-badge.tsx +57 -0
  445. package/src/widget-header/index.ts +2 -0
  446. package/src/widget-header/no-mantine.test.ts +33 -0
  447. package/src/widget-header/widget-header.module.css +335 -0
  448. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  449. package/src/widget-header/widget-header.test.tsx +187 -0
  450. package/src/widget-header/widget-header.tsx +168 -0
  451. package/agent/rules/basalt-agent.md +0 -452
  452. package/agent/rules/basalt-app.md +0 -246
  453. package/agent/rules/basalt-commands.md +0 -320
  454. package/agent/rules/basalt-content.md +0 -409
  455. package/agent/rules/basalt-data.md +0 -269
  456. package/agent/rules/basalt-forms.md +0 -186
  457. package/agent/rules/basalt-notifications.md +0 -264
  458. package/agent/rules/basalt-query.md +0 -187
  459. package/agent/rules/basalt-router.md +0 -336
  460. package/dist/content/article-filter-bar.d.ts +0 -40
  461. package/dist/content/article-filter-bar.d.ts.map +0 -1
  462. package/dist/content/article-filter-bar.js +0 -56
  463. package/dist/shell/page-header.d.ts +0 -13
  464. package/dist/shell/page-header.d.ts.map +0 -1
  465. package/dist/shell/page-header.js +0 -23
  466. package/src/content/article-filter-bar.tsx +0 -88
  467. package/src/shell/page-header.tsx +0 -35
@@ -1,449 +1,156 @@
1
1
  ---
2
2
  source: basalt-ui
3
- description: Mantine v9 usage conventions for basalt-ui apps — provider order, the Vx bridge, component defaults, and interaction feedback. Partly enforced by-construction via createBasaltTheme.
3
+ description: Mantine v9 conventions for basalt-ui apps — provider and overlay mount order, the shell's props, surfaces and depth, scroll regions, motion. Mostly enforced by construction via createBasaltTheme.
4
4
  paths:
5
5
  - 'src/**'
6
6
  - 'apps/**/src/**'
7
7
  ---
8
8
 
9
- # Basalt Mantine — Usage Conventions
10
-
11
- basalt-ui is a framework for Mantine v9 apps. `createBasaltTheme` and `BasaltProvider` encode most of
12
- the theming opinion **by construction** — this rule covers the conventions the framework can't enforce
13
- in code.
14
-
15
- ## Provider order
16
-
17
- Mount `BasaltProvider` (which wraps `MantineProvider` + injects the palette `<style>` + bridges the Vx
18
- tokens) at the top of the tree, before the router. A typical `main.tsx` stack, top to bottom:
19
-
20
- 1. `BasaltProvider` (Mantine + palette CSS + Vx bridge; pass `theme={createBasaltTheme(overrides)}`)
21
- 2. `Notifications` (from `@mantine/notifications`) — optional
22
- 3. `ModalsProvider` (from `@mantine/modals`) — optional
23
- 4. data layer (e.g. `QueryClientProvider`)
24
- 5. `RouterProvider`
25
-
26
- Never wrap `BasaltProvider` inside the router — Mantine's context must be available before any route
27
- renders.
28
-
29
- ## Theming
30
-
31
- - Use `createBasaltTheme(overrides?)` for the theme — it merges your overrides over `baseTheme`
32
- (last-wins), so you can retune any field without forking. Don't hand-build `createTheme`.
33
- - The theme reskins **every** Mantine accent to the modern-zinc identity (long form:
34
- <https://github.com/jkrumm/basalt-ui/blob/master/docs/DESIGN-SPEC.md>, not shipped in the package) —
35
- **cool-neutral zinc** ramps on both light and dark — plus a **single saturated sky-blue accent**
36
- — split by role: as INK (links, active-nav icon, chart lines, focus ring) `#0077bd` light /
37
- `#8ec5ff` dark; as a FILLED SURFACE `#0077bd` in BOTH schemes with a white label — and binds
38
- Mantine's surface vars AND its color families to the same `--vx-*` tokens
39
- the charts use (`cssVariablesResolver`, pre-wired in `BasaltProvider`), so chrome and charts share
40
- one scheme-reactive identity. (Blueprint / Basalt zinc-charcoal are the historical hue-tuning
41
- ancestors; DESIGN-SPEC supersedes both.) Don't add a second
42
- `cssVariablesResolver`.
43
- - Color scheme: read/write via `useMantineColorScheme()`. **Never** read the color scheme from
44
- localStorage directly, and never `localStorage.getItem('theme')` — see basalt-state.md.
45
- - Owned `spacing`/`radius` scales and the `fontWeights` ladder live in the theme — consume them as
46
- tokens (`p="md"`, `radius="sm"`, `fw="semibold"`), not raw numbers (see basalt-tokens.md).
47
-
48
- ## Color & accent restraint
49
-
50
- Restraint is the #1 lever — neutrals carry the surface, the accent only points. The palette is
51
- **one saturated sky-blue accent** over cool-neutral zinc; the neutrals do ~90% of the work
52
- (60/30/10, pushed toward 90/10).
53
-
54
- - **The accent appears ONLY on**: the primary data series, active-nav **icons** and active **child**
55
- labels, links, primary buttons, focus rings, and the leader bar in meters. It does **NOT** appear
56
- on borders, large fills, every icon, or secondary/routine buttons. Don't flood blue — "ink earns
57
- its color."
58
- - **Active nav = an ink-9% tint** + an **accent-colored icon** + weight-600 ink text — **no panel
59
- fill and no shadow**. It is a _selected row_, not a raised control; a panel-filled, shadowed row
60
- reads as a button sitting in the nav rather than as the current location. Inactive = muted text,
61
- faint icon, hover ink-6%; an active row hovers one step further to ink-13%. This is baked into
62
- the theme's NavLink defaults (`--nl-*` vars) and holds for _every_ render path, including a
63
- consumer's router `<Link>` hosted through `SidebarItem.Anchor`. Child items indent with a 1px
64
- `divider` left border; active child = accent text, weight 600. The MOBILE bar is the one
65
- deliberate exception to the accent-icon half of this rule — see "Mobile bar" below.
66
- - **Buttons**: the primary action = filled accent (`variant="filled"`), **exactly one per view**.
67
- Every other/secondary action = `variant="default"` (neutral). Do **not** use a colored
68
- `variant="light"` for routine actions.
69
- - **Status hues stay muted/forest.** Positive deltas use `color="green"` (forest green), never
70
- `color="teal"` (vivid turquoise) or a saturated emerald. Status (`red`/`green`/`orange`/`yellow`)
71
- is for signal only, kept muted — never raw Material/AntD.
72
- - **Dark mode** is cool-neutral zinc (`#27272a`-family panels on a darker `#18181b`-mixed page) —
73
- not steel-blue, not pure black. Lift elevation via the depth tokens' whisper shadow + inset ring;
74
- the accent uses its lighter shade (`#8ec5ff`) on dark to avoid glow/bleed **as INK** — links,
75
- active-nav icons, chart lines, focus ring. A FILLED control is the opposite case: it keeps the
76
- deep `#0077bd` fill and a white label in both schemes, because a light fill cannot carry white
77
- text and a darker one fades into the dark page (<3:1). Fill with `--vx-accent-fill`, never
78
- `--vx-accent`.
79
- - **Light mode** is never pure-white page + pure-black text (harsh halation). Page is a
80
- slightly-darker zinc mix (`#ececee`-ish) than the panel (`#f4f4f5`) so cards lift subtly off it;
81
- text = near-black ink (`#262626`). Depth comes from a whisper shadow with a 1px ring baked in
82
- (`shadow-card` for panels, `shadow-raised` for controls — see "Elevation, density & shape"), not
83
- a plain hairline border.
84
-
85
- ## Bridging Mantine ↔ charts
86
-
87
- Charts are Mantine-free, but their color scheme must track Mantine's toggle. `BasaltProvider` already
88
- bridges this. If you compose the providers manually, one — and only one — file may import both
89
- `@mantine/core` and `basalt-ui/charts` to read the scheme and feed `VxThemeProvider`:
90
-
91
- ```tsx
92
- import { useMantineColorScheme } from '@mantine/core'
93
- import { VxThemeProvider } from 'basalt-ui/charts'
94
-
95
- export function VxBridge({ children }: { children: React.ReactNode }) {
96
- const { colorScheme } = useMantineColorScheme()
97
- const resolved = colorScheme === 'auto' ? 'dark' : colorScheme
98
- return <VxThemeProvider colorScheme={resolved}>{children}</VxThemeProvider>
99
- }
100
- ```
101
-
102
- Route components import chart primitives directly from `basalt-ui/charts` — never from the bridge file.
103
-
104
- ## Component conventions
105
-
106
- - **Shell**: `BasaltShell` wraps every page (compose it once at the root). Don't add another shell
107
- inside a page. Wire nav badges with `NavCountBadge`.
108
- - **Layout**: prefer `Stack`/`Group`/`SimpleGrid` over manual flex/grid CSS.
109
- - **Notifications**: `notifications.show(...)` from `@mantine/notifications` — never roll a custom toast.
110
- - **Modals**: `modals.openConfirmModal(...)` for destructive actions; `modals.open(...)` for forms.
111
- - **Inputs**: from `@mantine/core` (`TextInput`, `Select`, `Combobox`, …). Don't use native `<select>`.
112
- The theme defaults inputs to `md` (16px font) so iOS Safari never zooms on focus. The floor
113
- (`styles.css`) is `!important` and mechanically enforced: `raw-form-control` flags any raw
114
- `<input>`/`<select>`/`<textarea>`, `sub-16-input-font` flags a sub-16 `fontSize` on a form control
115
- as dead code against the floor.
116
- - **DatePickerInput**: from `@mantine/dates` (if you separately install it). In v9 it uses string
117
- values (`YYYY-MM-DD`) for `value`/`onChange` — no `Date` conversion.
118
- - **Responsive chart sizing**: charts measure themselves — `CartesianChart`/`ChartFrame` do it,
119
- including reserving the legend band, so pass `height` / `aspectRatio` / `fill` and nothing else
120
- (see basalt-charts.md). `useChartSize` from `basalt-ui/charts` is for a non-chart box that needs
121
- measuring. Never `useElementSize` from `@mantine/hooks` in a chart file (Mantine is
122
- banned inside `charts/**`), and never raw `@visx/responsive` outside `charts/**`.
123
-
124
- ## Strict surfaces & layout primitives
125
-
126
- The theme runs a **strict surface system**: Mantine components don't chain through
127
- `--mantine-color-default-*` — each reads a **raw ramp step** directly — so `cssVariablesResolver`
128
- **collapses the ramp steps onto the basalt tokens**. Light borders (`--mantine-color-gray-2/3/4`)
129
- and dark borders (`--mantine-color-dark-4`) both resolve to `--vx-surface-border`; light
130
- hover/subtle steps (`--mantine-color-gray-0/1`) resolve to the `--vx-surface-subtle` token;
131
- `Card`/`Paper` backgrounds are forced to `--vx-surface-panel` via theme `styles` (Mantine otherwise
132
- defaults card bg to the page body color, so cards blend into the page); `--app-shell-border-color`
133
- is pinned to `--vx-surface-border`. Net: **one border shade, one card background, one radius
134
- (`--vx-radius-card`) across every surface** — AppShell, Table, Input, Divider, Tabs, Popover,
135
- Accordion, cards. The agent cannot reintroduce a divergent border/bg (no more off-system
136
- `#aba59c`). Surfaces must come from `VX.surface.*` + the radius token — **never** inline
137
- `border`/`borderRadius`/`backgroundColor`/`boxShadow`, and never a raw Mantine ramp-step var
138
- (`var(--mantine-color-gray-N)`).
139
-
140
- **Never pass `withBorder` to a `Card`/`Paper`.** Card depth is `--vx-shadow-card`, which bakes its
141
- 1px ring into the shadow value itself; the theme's `styles.root` pins bg/shadow/radius but does not
142
- clear `border`, so `withBorder` draws a _second_, real edge on top of the ring and the card reads
143
- heavy and boxed. This is the most common way a migrated app silently keeps its old hairline
144
- identity — the prop is on-token (its color resolves to `--vx-surface-border`), so only the
145
- `card-with-border` guard kind catches it. `withBorder={false}` and `<Card.Section withBorder>` (a
146
- section divider, not card depth) are both fine.
147
-
148
- - **Use Mantine primitives, not raw HTML.** To keep padding/spacing/radius/colors consistent,
149
- consumer code must compose Mantine layout/surface primitives — `Box`, `Flex`, `Grid`,
150
- `SimpleGrid`, `Stack`, `Group`, `Paper`, `Card` — instead of raw `<div>`/`<span>` with inline
151
- `style`. Raw HTML with inline layout/surface styling defeats the token system.
152
- - **The second fix is a CSS Module, and it is the right one in list code.** What
153
- `raw-html-layout` actually objects to is the inline `style` literal, not the `<div>`. Moving the
154
- rule into a `.module.css` class that reads `var(--vx-*)` satisfies the guard, keeps the values
155
- in the token system, and costs zero runtime — where a Mantine primitive costs one component
156
- instance per row. In a row renderer that paints hundreds of items, or in a downstream library
157
- that must not force `@mantine/core` on its consumers, take the CSS Module. `check-theme` scans
158
- `.css` and `.module.css`, so the tokens stay policed there. The guard's fix text names only the
159
- Mantine route; both are sanctioned.
160
- - **Mechanical enforcement.** `basalt-ui check-theme` adds eight surface guard kinds (each a config
161
- knob, default ON; scoped `theme-allow <rule-id> — <reason>` escape): `off-system-surface-var` (raw
162
- ramp-step vars), `card-with-border` (`withBorder` on a `Card`/`Paper`), `mantine-shade-index` (a
163
- shade-pinned color — see below), `raw-html-layout` (raw `<div>`/`<span>` with inline layout/surface
164
- styling), `inline-spacing` (inline spacing literals), `inline-display` (inline `display` literals),
165
- and since 1.20.0 `css-raw-surface` (the same surface kinds in their kebab CSS dialect, so
166
- `border-radius: 4px` in a CSS module is no longer invisible beside a flagged `borderRadius: 2`) and
167
- `hidden-inline-style` (`raw-html-layout`'s formatting-independent half — a multi-line opening tag,
168
- or a style object hoisted to a const). The Mantine-free `src/charts/**` is the only place raw
169
- `<div>` is allowed — and it must still use `VX.*` tokens. `oxlint` adds `basalt/card-inset`, which
170
- catches the other half of the same card drift: an explicit `p`/`padding`/`radius` on a
171
- `Card`/`Paper` instead of the xs/sm inset and the theme radius.
172
-
173
- **Never pin a shade index on a color.** `c="yellow.7"`, `bg="blue.4"`, `var(--mantine-color-red-6)` —
174
- each names one fixed swatch that is identical in light and dark, so a shade legible on the dark page
175
- is the one that fails contrast on the light one. Two correct forms: `VX.status.*` /
176
- `--vx-status-warn|bad|good` for a verdict color (emitted per scheme, contrast guaranteed by the
177
- derivation), or the bare hue name (`c="red"`, `color="green"`) to let the theme pick the step. The
178
- `mantine-shade-index` guard kind catches the pinned form; `gray`/`dark` steps in `var()` belong to
179
- `off-system-surface-var` instead. Note the distinction from `off-identity-accent`, which polices
180
- _which hue_ you reach for and permits the bare status names on purpose — this kind polices the index.
9
+ <!-- basalt:coverage -->
10
+ <!-- GENERATED from src/surfaces.ts — `basalt-ui check-coverage --write`. Do not hand-edit. -->
11
+ <!-- backed by: guard kinds — card-with-border, hidden-inline-style, in-body-page-title, inline-display, inline-spacing, mantine-shade-index, raw-form-control, raw-html-layout, raw-motion-value, raw-spacing, sub-16-input-font · oxlint rules — basalt/card-inset, basalt/hand-rolled-shell, basalt/in-body-page-title, basalt/page-bar-budget, basalt/raw-scroll-container, basalt/shadow-basalt-export -->
12
+ <!-- not guarded: — -->
13
+ <!-- /basalt:coverage -->
14
+
15
+ # Basalt Mantine — provider, shell, surfaces
16
+
17
+ `createBasaltTheme` + `BasaltProvider` encode the theming opinion **by construction**; this rule is
18
+ the part the framework cannot enforce in code. The identity itself and every color/spacing/type law
19
+ live in basalt-tokens.md — not restated here.
20
+
21
+ ## Provider and overlay mount
22
+
23
+ Mount `BasaltProvider` at the top of the tree, **above the router** (Mantine's context must exist
24
+ before any route renders), with `theme={createBasaltTheme(overrides)}`. Then the data layer, then
25
+ `RouterProvider`. Don't hand-build `createTheme` and don't add a second `cssVariablesResolver`.
26
+
27
+ **Overlays mount exactly once, through `BasaltOverlays`** (`basalt-ui/commands`), inside
28
+ `BasaltProvider`: it composes `ModalsProvider`, Spotlight (against basalt's own store), the command
29
+ hotkeys and `<Notifications>` in one place, each disableable with `false`. A standalone
30
+ `<BasaltNotifications />` (`basalt-ui/notifications`) is the alternative for an app with no commands
31
+ layer — **never both in one tree**, which double-mounts `<Notifications>`. Import the **layered**
32
+ style bundles for every `@mantine/*` battery, then `basalt-ui/styles.css` last; an unlayered Mantine
33
+ import outranks `@layer basalt` regardless of specificity.
34
+
35
+ Color scheme is read and written through `useMantineColorScheme()` only — never a client store,
36
+ never `localStorage.getItem('theme')` (guard-enforced; see basalt-state.md).
37
+
38
+ ## The shell — one mount, declared data
39
+
40
+ `BasaltShell` composes `AppSidebar` / `MobileNav` / `AppBreadcrumbs` / `PageBar` and is the canonical
41
+ single mount (`basalt/hand-rolled-shell` fires on `AppShell.*` parts or a `Burger` in a file that
42
+ does not render it). The sub-components are exported for a genuinely divergent layout; prefer the
43
+ shell, which already wires collapse persistence, the mobile breakpoint and the breadcrumb.
44
+
45
+ Every extension point is **declared data, not a `ReactNode` slot**, so basalt owns the mobile
46
+ projection:
47
+
48
+ | Prop | Shape | What basalt then owns |
49
+ | ------------------- | ------------------------------------ | -------------------------------------------------------------------------------------- |
50
+ | `sections` | `SidebarSection[]` | the desktop sidebar AND the mobile bar (`projectMobileNav`) — one definition |
51
+ | `globalActions` | `GlobalAction[]` | header placement; the first two ride the mobile bar, the rest fold into ONE kebab |
52
+ | `sidebarBlocks` | `SidebarBlock[]` | rail projection (dot / ring), fold persistence, one More-sheet row per block (law C13) |
53
+ | `brand` | `BrandConfig & { menu? }` | a `Name ▾` workspace switcher when `menu` is present |
54
+ | `search` | `SidebarSearchConfig & { actions? }` | the ⌘K row plus one or two icon buttons (a tuple by type) |
55
+ | `settingsMenuItems` | `SettingsMenuItem[]` | flat footer rows at three or fewer, one gear menu above that |
56
+ | `account` | `BasaltAccountProps` | the footer row, below the settings menu |
57
+
58
+ - **The router seam is ONE component, not a render callback.** `SidebarItem.Anchor` (a `NavAnchor`)
59
+ is the consumer's `<Link>`; basalt renders every pixel around it — desktop row, mobile slot, sheet
60
+ row. Absent, the row falls back to `<a href>` + `onClick`. The breadcrumb's equivalent is
61
+ `parentAnchor`. There is no `renderNavLink` / `renderBreadcrumbLink` surface.
62
+ - **`AppBreadcrumbs` is prop-driven** (`section` / `parent` / `parentAnchor` / `parentHref` /
63
+ `page`) and renders `null` without `page`. `BasaltShell` derives those props from the active item;
64
+ `useRouterBreadcrumbs` (basalt-state.md) is the router-side source for a custom trail.
65
+ - **Collapse persists through basalt's own `createPersistedState`**, keyed `basalt:<storageKey>` —
66
+ read it with `readPersistedValue`, never a bare `localStorage.getItem`. Pass `collapsed` +
67
+ `onCollapsedChange` to own it instead (e.g. from your own hotkey — basalt binds none).
68
+ - **The `.` entry is router-agnostic; a router adapter DOES ship**, at `./router-tanstack`. Badge /
69
+ active / navigate wiring stays consumer-side either way; `NavCountBadge` is the count pattern.
70
+ - **The account row is presentational over a provider-agnostic contract** — basalt has no auth
71
+ dependency and ships no `./auth`. The authenticated row leads with initials derived from
72
+ `identity.name` (never an avatar image); the unauthenticated row falls back to a person glyph.
73
+ No separating hairline: the row's own top padding is the separation. The email is hidden unless
74
+ `showEmail` is passed.
75
+
76
+ **Mobile is a tab bar, not a menu.** A slot is a destination, so a tap navigates with nothing to
77
+ dismiss; there is no full-height mobile drawer, and everything it used to hold reaches the trailing
78
+ More slot. `projectMobileNav` is a PURE projection and infers the surface from row count — 0 drops
79
+ the slot, 1 collapses to a plain link (a group of one IS a destination), up to `menuMax` is a menu,
80
+ more is a bottom sheet. `SidebarItem.mobile` (`'tab'` / `'more'` / `'hidden'`) is the per-destination
81
+ placement; `MobileNavConfig` is the escape hatch, not the interface. Two bar rules that look like
82
+ bugs and are not: the active indicator is a **neutral** ink tint behind the ICON only (never the
83
+ identity blue, never a full-tab fill — the desktop sidebar's accent-icon rule does not apply here),
84
+ and the bar carries **no** `env(safe-area-inset-bottom)` padding, because Mantine's own
85
+ `AppShell.Footer` rule already grows the box by the inset. Search lives in the SIDEBAR, not the
86
+ header; the header carries no bottom rule. "Zero horizontal rules" is not true of the mobile bar,
87
+ which has a top border by design.
88
+
89
+ ## Surfaces, depth and shape
90
+
91
+ - **Never pass `withBorder` to a `Card`/`Paper`.** Card depth is `VX.shadowCard`, which bakes its 1px
92
+ ring into the shadow value; the theme pins bg/shadow/radius but does not clear `border`, so
93
+ `withBorder` draws a second real edge and the card reads heavy. It is on-token, so only the
94
+ `card-with-border` guard kind catches it. `<Card.Section withBorder>` is a section divider and fine.
95
+ - **Three depth tiers, split control-vs-panel-vs-floating**, and the dividing line is that split —
96
+ never component-by-component. `shadowCard` for surfaces that HOLD content (Card, Paper,
97
+ Notification, ChartCard, SettingsSection): an outset ring on light, a deeper drop + inset rim on
98
+ dark. `shadowRaised` for anything you click or type into: **never an outset ring**, because an
99
+ outset ring paints in the color of whatever it is drawn over, so one value can ride a saturated
100
+ fill, a tint and a panel alike. `shadowOverlay` for detached floating surfaces (Tooltip, Popover,
101
+ Menu, Combobox, Modal, Drawer). Focus is a fourth tier and LAYERS over the resting depth.
102
+ - **Emphasis is fill weight, not depth.** Every box-owning Button/ActionIcon variant takes the same
103
+ resting depth; `outline` takes the ring-free `shadowCtrl` (its border is its edge) and `subtle` is
104
+ **flat in both states** — a text affordance, with a neutral ink tint as its hover and nothing more.
105
+ **Depth is static**: no variant lifts or gains a shadow on hover or press. A hover lift and a
106
+ materializing `subtle` shadow were both tried and reverted.
107
+ - **The ring must land on the box that carries the surface's `border-radius`** — it is drawn by that
108
+ box's own corners. `ChartCard` legitimately puts the shadow on a box with no background (an inner
109
+ box paints the fill, because `overflow: hidden` on the shadowed box would clip the shadow).
110
+ - **Use Mantine primitives, not raw HTML** — `Box`/`Flex`/`Grid`/`SimpleGrid`/`Stack`/`Group`/
111
+ `Paper`/`Card` instead of a `<div>` with an inline `style`. **The second sanctioned fix is a CSS
112
+ module**, and in row code it is the right one: what the guard objects to is the inline literal, not
113
+ the `<div>`, and a class reading `var(--vx-*)` costs no component instance per row. `check-theme`
114
+ scans `.css`, so the tokens stay policed there. Only the Mantine-free `src/charts/**` may use raw
115
+ `<div>` freely — still with `VX.*`.
116
+ - **Inputs come from `@mantine/core`**, never a native `<select>`; the theme defaults them to the
117
+ form tier, whose font size clears the iOS zoom threshold, and the floor in `styles.css` is
118
+ `!important` (`sub-16-input-font` flags a smaller one as dead code against it).
119
+ - **Charts measure themselves** — pass `height` / `aspectRatio` / `fill` and nothing else. Never
120
+ `useElementSize` inside a chart file (Mantine is banned there), never raw `@visx/responsive`
121
+ outside `charts/**`.
181
122
 
182
123
  ## Scroll regions
183
124
 
184
- **A scroll region inside app chrome is a Mantine `ScrollArea`, not a raw `overflow: auto` box.**
185
- ScrollArea draws its own bar absolutely inside a `position: relative; overflow: hidden` root, so the
186
- bar floats over the content; a native bar reserves gutter width and reflows the column the moment
187
- the content overflows. `AppSidebar`'s nav is the reference pattern — `<ScrollArea type="hover"
188
- scrollbars="y" scrollbarSize={9}>` with the flex fill (`flex: 1; min-height: 0`) on the ScrollArea
189
- root and the content classes on the inner `Stack`, so child-combinator spacing rules still see the
190
- content's own children through the wrapper.
191
-
192
- `styles.css` re-hides the native bar on `.mantine-ScrollArea-viewport`: basalt's global
193
- `*::-webkit-scrollbar` theming otherwise re-exposes the bar ScrollArea hides, yielding two bars plus
194
- an always-on gutter. That fix makes **one overlay bar** the framework-wide outcome for every
195
- ScrollArea consumer — do not undo it, and do not re-theme the viewport's native bar.
196
-
197
- A raw `overflow: auto` element is still correct where a library owns the scroll container and needs
198
- a real scrollable node: `BasaltStickToBottom` (scroll anchoring) and `BasaltVirtualList` (TanStack
199
- Virtual measures the scroll element). That is why this is a documented standard rather than a lint
200
- rule — the legitimate exceptions are context-dependent, not syntactic. Prefer ScrollArea for
201
- everything else: sidebars, panels, menus, palettes, and any chrome that scrolls.
202
-
203
- ## Elevation, density & shape
204
-
205
- basalt-ui targets dense, professional surfaces (a terminal, not a marketing page). The depth and
206
- shape doctrine lives here; the spacing/radius/type **tokens** are in basalt-tokens.md.
207
-
208
- - **Density is the point** (Linear/Notion). Sections separate by surface change and shadow/hairline,
209
- not by large air — default to the tighter spacing step. The shipped dense defaults: shell header
210
- **48px**, navbar width **216** (collapsed **64**) with **`sm` (12px)** padding; sidebar section
211
- gap **`sm`**, compact nav rows (`--nl-padding: 6px 8px`, `min-height: 30px`, `sm` font), brand row
212
- 32px. Pages: KPI/card padding **~17–19px**, page `Stack`/`SimpleGrid` gaps **`sm`**; ChartCard
213
- header/body padding raised to ~16–18px.
214
- - **Surface single-source.** All cards — Mantine `Card`/`Paper` **and** the Mantine-free
215
- `ChartCard` — resolve to **one radius token** (`--vx-radius-card` = **7px**) and
216
- **one depth token** (`shadow-card` — a whisper shadow + 1px ring, the ring lives IN the shadow, no
217
- separate `border` property). Cards must never diverge: same radius, same shadow. Never
218
- inline-override `border`/`borderRadius`/`boxShadow`/`backgroundColor` on a surface — use the
219
- radius token (`radius="md"` / `var(--vx-radius-card)`) + `VX.shadowCard` + `VX.surface.*`.
220
- Mechanically enforced by `basalt-ui check-theme`'s `raw-surface` guard. Outer spacing comes from the
221
- parent `Stack`/`SimpleGrid` gap, not an intrinsic card margin.
222
- - **Depth = a whisper shadow with a 1px ring baked in, not a plain hairline** (see
223
- <https://github.com/jkrumm/basalt-ui/blob/master/docs/DESIGN-SPEC.md>, doctrine inversion #1 — this supersedes the old
224
- "never a drop shadow" rule). **Three depth tiers, split control-vs-panel-vs-floating:**
225
- - **`shadow-card` — panels.** A surface that HOLDS content: `Card`, `Paper`, `Notification`,
226
- `ChartCard`, `SettingsSection`. Its light-mode ring is an **outset** 1px hairline; dark is a
227
- deeper drop + an inset 4%-white rim.
228
- - **`shadow-raised` — interactive controls.** Anything you click or type into: Button,
229
- ActionIcon, the field idiom (Input/TextInput/PillsInput/…), the sidebar search trigger, header
230
- icon buttons, `Chip`, `CheckboxCard`/`RadioCard`, the agent composer. **Never an outset ring** —
231
- dark is drop + an inset 8%-white rim, light is drop only. That is what lets ONE value ride a
232
- saturated fill, a 13% tint and a neutral panel alike: an outset ring paints in the color of
233
- whatever it is drawn over, so `shadow-card`'s pale hairline would read as a grey outline around
234
- a colored button, while an inset edge is drawn over the control's own background. The
235
- light/dark asymmetry is deliberate — a light-mode edge would have to be a _darkening_, which
236
- reads as a border (uniform) or a double bottom rule (bottom-only); both were tried and
237
- rejected. Don't tidy the two schemes into one shape.
238
- - **`shadow-overlay` — detached floating surfaces.** Tooltip, Popover, Menu, Combobox, Modal,
239
- Drawer — they sit _above_ the page rather than on it. Resolved in `theme/index.ts`; not
240
- Mantine's own shadow scale.
241
-
242
- Focus is a fourth tier and **layers**: a 2px accent ring drawn OVER the resting depth, never
243
- replacing it.
244
-
245
- **The dividing line is control-vs-panel, not component-by-component.** Two controls sitting side
246
- by side must never land on different tokens — a `size="md"` Input beside a `size="md"` Button
247
- (pinned to the same height by `--vx-space-control-height`), or the header's search trigger and
248
- icon button. Closing exactly that split is why `shadow-raised` exists.
249
-
250
- **Emphasis is fill weight, not depth.** Every Button/ActionIcon variant that owns a box takes the
251
- SAME resting depth: `default` / `filled` / `light` → `shadow-raised` unmodified; `outline` →
252
- ring-free `shadow-ctrl` (its real 1px accent border already is its edge); `subtle` → **flat in
253
- both states** (a text affordance, not an object — its hover is a neutral ink-6% tint and nothing
254
- more). **Depth is static — no variant moves or changes depth on hover or press.** A hover lift
255
- and a materializing `subtle` shadow were both tried and reverted; don't reintroduce either.
256
- Disabled is flat and grounded. Reduced motion drops the transition, never the resting depth.
257
-
258
- Mechanics: every default-variant control renders its bg + depth with a _transparent_ 1px border
259
- box (never `border: none` — the box stays so focus/error can recolor it with no layout shift).
260
- `--mantine-color-default-border` is pinned to `transparent` in `cssVariablesResolver`, the ONE
261
- lever that kills the stock hairline for every default-variant component at once
262
- (`defaultVariantColorsResolver`'s `variant === 'default'` branch reads that single var); the
263
- shadow itself is added per-component in `controls.module.css`, since Mantine never declares one
264
- for `default`. `PillsInput` reuses the Input field idiom's `classNames` wiring directly (it
265
- renders `InputBase` under its own `__staticSelector`, so the base `Input` theme entry never
266
- reached it). Layout dividers (header bottom border, sidebar section separators) still use plain
267
- borders — only card/control depth moved to shadow. A dedicated test,
268
- `src/theme/border-coverage.test.ts`, mechanically enumerates every `@mantine/core` component
269
- whose shipped CSS declares a border and asserts it's either a themed `baseTheme.components` key
270
- or a reasoned `BORDER_ALLOWLIST` entry — this is what catches the NEXT unthemed bordered
271
- component (Button/ActionIcon shipped with no `.extend()` block at all, which no consumer-source
272
- guard can ever see).
273
-
274
- - **The ring must land on the box that carries the surface's `border-radius`.**
275
- `shadow-card`/`shadow-raised` bakes a 1px ring into the shadow value itself, and the ring is drawn
276
- by the shadowed box's OWN corners — it never reads correctly against a different element's radius
277
- (the Input wrapper bug: a `position: relative` box with `border-radius: 0` drew a square ring
278
- around the 8px-rounded `<input>` inside it). Background usually co-locates with the radius since
279
- most surfaces declare both on the same box, but it's the radius the ring is contractually bound
280
- to, not the background — `ChartCard.tsx` legitimately carries the shadow on a box with NO
281
- background (an inner box, sharing its exact radius, paints the fill instead, because `overflow:
282
- hidden` on the same box as the shadow would clip the shadow itself) and is correct. `src/theme/
283
- shadow-surfaces.test.ts` mechanically enumerates every site that applies either token and requires
284
- a written `roundedBy` reason naming where that box's radius comes from.
285
- - **Tight radii read precise/technical** (Linear): `sm` for controls, `md` for cards, `pill` for
286
- badges. Consume the token, never a raw number (basalt-tokens.md).
287
- - **Type is carried by the three-font system** (sans body / head condensed headings / mono
288
- numerals — basalt-tokens.md), numbers in the mono tabular stack so metric columns align (a
289
- Coinbase pattern).
290
-
291
- ## Composing the shell from sub-components
292
-
293
- `BasaltShell` is the canonical single-mount, and since 1.20.0 `basalt/hand-rolled-shell` (`warn`)
294
- says so mechanically: `AppShell.*` parts or a `Burger` in a file that does not render `BasaltShell`
295
- is the layer the 1.19 nav contract replaces. It is file-scoped and reported once — which shell a
296
- file uses is one decision. When a fully custom layout is genuinely needed, the shell sub-components
297
- are available individually from `basalt-ui`:
298
-
299
- - `AppSidebar` — desktop sidebar (sections, collapse, brand, settings/account extras)
300
- - `MobileNav` — the bottom tab bar (takes a `MobileNavModel` from `projectMobileNav`)
301
- - `AppBreadcrumbs` — breadcrumb bar (reads from `useRouterBreadcrumbs` or a manual trail)
302
- - `PageHeaderProvider` + `PageActions` + `PageActionsOutlet` — portal-based page action slots
303
- - `NavCountBadge` — count badge for sidebar nav items
304
-
305
- ```tsx
306
- import {
307
- AppSidebar,
308
- MobileNav,
309
- AppBreadcrumbs,
310
- PageHeaderProvider,
311
- PageActionsOutlet,
312
- NavCountBadge,
313
- projectMobileNav,
314
- } from 'basalt-ui'
315
-
316
- function CustomShell({ children }: { children: React.ReactNode }) {
317
- return (
318
- <PageHeaderProvider>
319
- <AppSidebar sections={sections} brand={brand} />
320
- <MobileNav model={projectMobileNav(sections)} />
321
- <main>
322
- <AppBreadcrumbs />
323
- <PageActionsOutlet />
324
- {children}
325
- </main>
326
- </PageHeaderProvider>
327
- )
328
- }
329
- ```
330
-
331
- Prefer `BasaltShell` unless the layout genuinely diverges — it already composes all of the above
332
- with the correct wiring (collapse persistence, mobile breakpoint, breadcrumb integration).
333
-
334
- ## Mobile bar
335
-
336
- The bottom bar is a **tab bar, not a menu**: a slot is a DESTINATION, so a tap navigates instantly
337
- with nothing to dismiss. There is no full-height mobile sidebar drawer — everything it used to hold
338
- (nav, account, settings, theme switcher) is reachable from the trailing More slot instead.
339
-
340
- `BasaltShell` builds the whole thing from the same `SidebarSection[]` the desktop sidebar renders:
341
- **`projectMobileNav`** projects those sections onto the bar and **`MobileNav`** paints the result.
342
- There is no second nav model to maintain, and in the common case no configuration at all.
343
-
344
- **Surface inference — the part you do not configure.** For any slot holding `n` rows:
345
-
346
- | `n` | surface |
347
- | ----------------------- | ------------------------------------------------------------------------- |
348
- | 0 | the slot is dropped |
349
- | 1 | a plain navigation link — a group of one IS a destination |
350
- | ≤ `menuMax` (default 6) | a Mantine `Menu` that pops upward out of the tab, content-sized, no scrim |
351
- | > `menuMax` | a bottom `Drawer` sheet with a grabber and an internal `ScrollArea` |
352
-
353
- Six is arithmetic, not taste: 6 × 44px + 8px padding = 272px against 415px of headroom on the
354
- smallest supported viewport, which is why the menu is configured `flip: false` and can never render
355
- below the fold. Move a destination in or out of the More slot and the surface changes itself.
356
-
357
- **Placement** is one field per destination — `SidebarItem`'s `mobile`: `'tab'` gives it a slot of
358
- its own, `'more'` (the default) makes it an overflow row, `'hidden'` drops it from mobile entirely.
359
- A whole section claims one slot with `mobile: { tab: true }` (its destinations then open per the
360
- table above) or leaves mobile with `mobile: false`. Configure nothing and the first four non-disabled
361
- top-level destinations take slots — a nav that works before anyone has thought about mobile.
362
-
363
- **`MobileNavConfig`** (the `mobileNav` prop) is the escape hatch, not the interface: `tabs` for an
364
- explicit slot order by key when the bar order must differ from the sidebar's, `maxTabs` (5),
365
- `menuMax` (6), `moreLabel`, `moreExtra`, and `getScrollElement` for re-tap-to-top when the page
366
- scrolls inside a container rather than the document.
367
-
368
- Design rules specific to the bar, each one load-bearing:
369
-
370
- - **The active indicator is a neutral pill behind the ICON only** — `--vx-ink` at 10% plus
371
- `--vx-radius-ctrl`, the Material-3 idiom. Never the identity blue, and never a full-tab fill: a
372
- filled tab reads as chrome instead of as a tab bar. This is the exception to "active nav = accent
373
- icon" in the palette rules above; the desktop sidebar keeps that rule unchanged.
374
- - **A count renders as an 8px accent dot** on the slot icon, not a number — a count glyph is
375
- unreadable at 56px. `SidebarItem.count` drives it; `SidebarItem.badge` (a rich node) stays
376
- desktop-only.
377
- - **Press feedback is the icon, not the hit box**: `.tab:active` scales the icon to 0.9 over 140ms.
378
- `touch-action: manipulation` + `-webkit-tap-highlight-color: transparent` kill the 300ms
379
- double-tap delay and the blue flash.
380
- - **Every `:hover` rule sits behind `@media (hover: hover)`.** Ungated, a tapped row stays lit until
381
- something else is tapped.
382
- - **Re-tapping the ACTIVE slot scrolls to top** instead of pushing a redundant history entry.
383
- - **Never add `padding-bottom: env(safe-area-inset-bottom)` to the bar.** Mantine's own
384
- `AppShell.Footer` rule already grows the footer box by the inset and pads its content; adding it
385
- again double-counts. The one real gap is `AppShell.Main`, whose padding is computed from an offset
386
- var that excludes the inset — the shell closes that itself.
387
- - **Reduced motion** is read explicitly via `useReducedMotion` (Mantine's
388
- `respectReducedMotion` defaults to `false` and basalt does not change it) and zeroes both the menu
389
- and sheet transitions.
390
-
391
- Accessibility, all handled by the shell: the bar is `<nav aria-label="Primary">`, the active slot
392
- carries `aria-current="page"`, every slot carries `aria-label` (the visible text is the short
393
- label), menus get `menuItemTabIndex={0}` for the WAI-ARIA disclosure pattern, and the row height
394
- floors at 44px with the bar at 48px regardless of the `density` level. **Do not hand-set
395
- `aria-haspopup` / `aria-expanded`** on a `Menu.Target` child — Mantine clones them on.
396
-
397
- ## Icons
398
-
399
- basalt-ui takes icons as `ReactNode` (no icon dependency shipped). Use a single icon library across the
400
- app — `@tabler/icons-react` pairs cleanly with Mantine. Size via the icon's `size` prop (`size={16}`),
401
- not CSS.
402
-
403
- ## Interaction feedback (micro-animations)
404
-
405
- Every action button (Save, Log, Add, Delete, Toggle) must give immediate visual confirmation — silent
406
- success feels broken. Three layers, applied together:
407
-
408
- 1. **In-flight state** — `loading` prop on `<Button>` (built-in spinner). Disables clicks automatically.
409
- 2. **Post-success state on the trigger itself** — flip the button briefly: swap label `Save → Saved`,
410
- set `color="green"` (the success status hue — never `teal`/`violet`, which the theme guard rejects as
411
- off-identity), reveal an `IconCheck` via `<Transition mounted={justSaved} transition="pop"
412
- duration={180}>`. Hold ~1.2–1.5s, then reset. The user's eye is on the thing they clicked — feedback
413
- lands there, not only in a corner toast.
414
- 3. **Toast (`notifications.show`)** — only for outcomes the user might miss (writes that succeed
415
- off-screen, errors, async background work). Don't toast trivial UI toggles. `autoClose` ≤ 2000ms for
416
- success; errors stay until dismissed.
417
-
418
- Guidelines:
419
-
420
- - Durations: 120–200ms for state flips, 180–250ms for `Transition`, never above 300ms (feels laggy).
421
- - Never animate layout-shifting properties (height, width) on buttons — animate `transform`, `opacity`,
422
- `color`, `background`.
423
- - A button that turns into a check **is** the confirmation; the toast is for context the user can't see.
424
- - Destructive actions: `modals.openConfirmModal` first, then the same in-button success pattern after.
425
- - Form errors: prefer inline field errors over toast; toast only for submit-level failures.
426
-
427
- ## Motion (`motion`)
428
-
429
- basalt-ui ships `motion` (motion.dev, formerly framer-motion) as a bundled dependency — the same
430
- precedent as `@visx/*`: an internal implementation detail, not a peer the consumer opts into.
431
- `ThemeToggle` is the first consumer (icon morph + direct-select popover reveal).
432
-
433
- - **Reach for Mantine's own `<Transition>`** (above) for simple mount/unmount fades/pops — it's
434
- already there, needs no import. **Reach for `motion`** when the interaction needs something
435
- `Transition` can't do: crossfade between two different elements (not just show/hide one),
436
- spring physics, or a gesture-driven reveal.
437
- - **Import from `motion/react`, never the raw `framer-motion` package** — `motion` re-exports it,
438
- but a direct `framer-motion` import bypasses the pinned/vetted specifier. Enforced by oxlint
439
- `no-restricted-imports` (repo-local and the shipped consumer preset).
440
- - **Never a hardcoded duration/spring/ease literal in a `transition={{...}}` prop.** Import
441
- `MOTION_DURATION` / `MOTION_SPRING` / `MOTION_EASE_STANDARD` from `basalt-ui` instead — the
442
- motion analog of routing color through `VX.*`. `MOTION_DURATION` is capped at 0.3s (300ms),
443
- matching the interaction-feedback ceiling above. Mechanically enforced by `basalt-ui check-theme`'s
444
- `raw-motion-value` guard (default ON; `theme-allow` line-comment escape).
445
- - **Always branch on `useReducedMotion`** (`@mantine/hooks`) — render a real unanimated code path
446
- (no `motion.*` wrapper at all), not just `transition={{ duration: 0 }}`. See `ThemeToggle`'s
447
- `SchemeGlyph` for the pattern.
448
- - **Restraint applies to motion too.** Subtle and purposeful — a state change earns a transition,
449
- idle chrome does not. Never a looping/pulsing idle animation.
125
+ **A scroll region inside app chrome is a Mantine `ScrollArea`, not a raw `overflow: auto` box** — it
126
+ draws its own bar inside a `position: relative; overflow: hidden` root, so the bar floats instead of
127
+ reserving gutter width and reflowing the column. `AppSidebar`'s nav is the reference (`type="hover"`,
128
+ `scrollbars="y"`, the flex fill on the ScrollArea root). `styles.css` re-hides the native bar on the
129
+ viewport, so **one overlay bar** is the framework-wide outcome — do not re-theme it.
130
+
131
+ Raw `overflow: auto` stays correct where a library owns the scroll node (`BasaltStickToBottom`,
132
+ `BasaltVirtualList`) and for a table body, which is `Table.ScrollContainer type="native"` — a
133
+ `ScrollArea` viewport is the positioning context a sticky `<thead>` resolves against, so the default
134
+ type pins the header to the page viewport instead of the table's box.
135
+
136
+ ## Interaction feedback and motion
137
+
138
+ Every action button must confirm itself — silent success reads as broken. Three layers, together:
139
+ the `loading` prop while in flight; a brief flip on the trigger itself (label `Save → Saved`,
140
+ `color="green"`, a check revealed through Mantine's `<Transition>`), held about a second; and a toast
141
+ ONLY for an outcome the user might miss (an off-screen write, an error, background work). The user's
142
+ eye is on the thing they clicked. Destructive actions confirm through `modals.openConfirmModal`
143
+ first; form errors are inline, and a toast covers only submit-level failure.
144
+
145
+ - **Timing is a token, never a literal.** `MOTION_DURATION` / `MOTION_SPRING` /
146
+ `MOTION_EASE_STANDARD` from `basalt-ui`; the duration cap is the same interaction-feedback ceiling
147
+ above (`raw-motion-value` enforces it). Never animate a layout-shifting property — move
148
+ `transform`, `opacity`, `color`, `background`.
149
+ - **Import from `motion/react`, never `framer-motion`** (`no-restricted-imports`, repo and shipped).
150
+ `motion` is an exact-pinned optional PEER, not a bundled dependency. Reach for Mantine's own
151
+ `<Transition>` for a simple mount fade; reach for `motion` when the interaction needs a crossfade
152
+ between two elements, spring physics or a gesture.
153
+ - **Always branch on `useReducedMotion`** (`@mantine/hooks`) with a real unanimated code path — no
154
+ `motion.*` wrapper at all, not just `duration: 0`. `ThemeToggle` is the pattern.
155
+ - **Restraint applies to motion too.** A state change earns a transition; idle chrome does not.
156
+ Never a looping or pulsing idle animation.