basalt-ui 1.25.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 (462) hide show
  1. package/AGENTS.md +8 -6
  2. package/CHANGELOG.md +18 -0
  3. package/MIGRATING.md +546 -20
  4. package/README.md +45 -32
  5. package/agent/rules/basalt-batteries.md +205 -0
  6. package/agent/rules/basalt-charts.md +125 -555
  7. package/agent/rules/basalt-controls.md +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/settings-section.d.ts +6 -4
  134. package/dist/dashboard/settings-section.d.ts.map +1 -1
  135. package/dist/dashboard/settings-section.js +23 -16
  136. package/dist/dashboard/settings-section.module.css +10 -0
  137. package/dist/dashboard/settings-section.module.css.d.ts +1 -0
  138. package/dist/dashboard/settings-section.test.d.ts +2 -0
  139. package/dist/dashboard/settings-section.test.d.ts.map +1 -0
  140. package/dist/dashboard/stat-card.d.ts +44 -9
  141. package/dist/dashboard/stat-card.d.ts.map +1 -1
  142. package/dist/dashboard/stat-card.js +80 -48
  143. package/dist/dashboard/stat-card.module.css +82 -0
  144. package/dist/dashboard/stat-card.module.css.d.ts +11 -0
  145. package/dist/data/data-table.d.ts +37 -15
  146. package/dist/data/data-table.d.ts.map +1 -1
  147. package/dist/data/data-table.js +88 -58
  148. package/dist/guard/grace.test.d.ts +20 -0
  149. package/dist/guard/grace.test.d.ts.map +1 -0
  150. package/dist/guard/index.d.ts +66 -3
  151. package/dist/guard/index.d.ts.map +1 -1
  152. package/dist/guard/index.js +82 -7
  153. package/dist/guard/types.d.ts +3 -3
  154. package/dist/guard/types.d.ts.map +1 -1
  155. package/dist/index.d.ts +12 -2
  156. package/dist/index.d.ts.map +1 -1
  157. package/dist/index.js +13 -10
  158. package/dist/nav/types.d.ts +78 -2
  159. package/dist/nav/types.d.ts.map +1 -1
  160. package/dist/notifications/bell.d.ts +4 -1
  161. package/dist/notifications/bell.d.ts.map +1 -1
  162. package/dist/notifications/index.d.ts +1 -1
  163. package/dist/router-tanstack/field.d.ts +14 -0
  164. package/dist/router-tanstack/field.d.ts.map +1 -0
  165. package/dist/router-tanstack/field.js +4 -0
  166. package/dist/router-tanstack/index.d.ts +6 -2
  167. package/dist/router-tanstack/index.d.ts.map +1 -1
  168. package/dist/router-tanstack/index.js +4 -0
  169. package/dist/router-tanstack/multi-search-param-store.d.ts +4 -52
  170. package/dist/router-tanstack/multi-search-param-store.d.ts.map +1 -1
  171. package/dist/router-tanstack/multi-search-param-store.js +19 -27
  172. package/dist/router-tanstack/search-param-store.d.ts +3 -87
  173. package/dist/router-tanstack/search-param-store.d.ts.map +1 -1
  174. package/dist/router-tanstack/search-param-store.js +16 -45
  175. package/dist/router-tanstack/search-store.d.ts +81 -0
  176. package/dist/router-tanstack/search-store.d.ts.map +1 -0
  177. package/dist/router-tanstack/search-store.js +187 -0
  178. package/dist/router-tanstack/search-store.router.test.d.ts +2 -0
  179. package/dist/router-tanstack/search-store.router.test.d.ts.map +1 -0
  180. package/dist/router-tanstack/search-store.test.d.ts +2 -0
  181. package/dist/router-tanstack/search-store.test.d.ts.map +1 -0
  182. package/dist/router-tanstack/use-nav.d.ts +0 -2
  183. package/dist/router-tanstack/use-nav.d.ts.map +1 -1
  184. package/dist/router-tanstack/use-nav.js +34 -12
  185. package/dist/router-tanstack/use-nav.test.d.ts +2 -0
  186. package/dist/router-tanstack/use-nav.test.d.ts.map +1 -0
  187. package/dist/section/index.d.ts +2 -0
  188. package/dist/section/index.d.ts.map +1 -0
  189. package/dist/section/index.js +4 -0
  190. package/dist/section/section.d.ts +24 -0
  191. package/dist/section/section.d.ts.map +1 -0
  192. package/dist/section/section.js +126 -0
  193. package/dist/section/section.module.css +104 -0
  194. package/dist/section/section.module.css.d.ts +11 -0
  195. package/dist/section/section.test.d.ts +2 -0
  196. package/dist/section/section.test.d.ts.map +1 -0
  197. package/dist/shell/app-breadcrumbs.d.ts.map +1 -1
  198. package/dist/shell/app-breadcrumbs.js +18 -15
  199. package/dist/shell/app-header.module.css +77 -37
  200. package/dist/shell/app-header.module.css.d.ts +2 -1
  201. package/dist/shell/app-mobile-nav.d.ts +9 -2
  202. package/dist/shell/app-mobile-nav.d.ts.map +1 -1
  203. package/dist/shell/app-mobile-nav.js +98 -11
  204. package/dist/shell/app-mobile-nav.module.css +8 -0
  205. package/dist/shell/app-mobile-nav.module.css.d.ts +1 -0
  206. package/dist/shell/app-sidebar.d.ts +55 -28
  207. package/dist/shell/app-sidebar.d.ts.map +1 -1
  208. package/dist/shell/app-sidebar.js +116 -67
  209. package/dist/shell/app-sidebar.module.css +303 -20
  210. package/dist/shell/app-sidebar.module.css.d.ts +19 -1
  211. package/dist/shell/index.d.ts +55 -21
  212. package/dist/shell/index.d.ts.map +1 -1
  213. package/dist/shell/index.js +40 -18
  214. package/dist/shell/mobile-nav-model.d.ts +13 -2
  215. package/dist/shell/mobile-nav-model.d.ts.map +1 -1
  216. package/dist/shell/mobile-nav-model.js +15 -1
  217. package/dist/shell/nav-count-badge.d.ts +1 -1
  218. package/dist/shell/page-bar.d.ts +51 -0
  219. package/dist/shell/page-bar.d.ts.map +1 -0
  220. package/dist/shell/page-bar.js +154 -0
  221. package/dist/shell/page-bar.module.css +178 -0
  222. package/dist/shell/page-bar.module.css.d.ts +17 -0
  223. package/dist/shell/page-bar.test.d.ts +2 -0
  224. package/dist/shell/page-bar.test.d.ts.map +1 -0
  225. package/dist/shell/sidebar-block-model.d.ts +48 -0
  226. package/dist/shell/sidebar-block-model.d.ts.map +1 -0
  227. package/dist/shell/sidebar-block-model.js +39 -0
  228. package/dist/shell/sidebar-blocks.d.ts +32 -0
  229. package/dist/shell/sidebar-blocks.d.ts.map +1 -0
  230. package/dist/shell/sidebar-blocks.js +169 -0
  231. package/dist/shell/sidebar-search.d.ts +13 -1
  232. package/dist/shell/sidebar-search.d.ts.map +1 -1
  233. package/dist/shell/sidebar-search.js +34 -1
  234. package/dist/shell/sidebar-search.module.css +26 -0
  235. package/dist/shell/sidebar-search.module.css.d.ts +2 -0
  236. package/dist/state/fields.d.ts +392 -0
  237. package/dist/state/fields.d.ts.map +1 -0
  238. package/dist/state/fields.js +369 -0
  239. package/dist/state/fields.test.d.ts +2 -0
  240. package/dist/state/fields.test.d.ts.map +1 -0
  241. package/dist/state/persisted.d.ts +48 -0
  242. package/dist/state/persisted.d.ts.map +1 -0
  243. package/dist/state/persisted.js +105 -0
  244. package/dist/state.d.ts +11 -56
  245. package/dist/state.d.ts.map +1 -1
  246. package/dist/state.js +14 -120
  247. package/dist/styles.css +13 -0
  248. package/dist/surfaces.d.ts +106 -24
  249. package/dist/surfaces.d.ts.map +1 -1
  250. package/dist/surfaces.js +155 -19
  251. package/dist/theme/controls.module.css +16 -1
  252. package/dist/theme/ctl-theme.d.ts +32 -0
  253. package/dist/theme/ctl-theme.d.ts.map +1 -0
  254. package/dist/theme/ctl-theme.js +54 -0
  255. package/dist/theme/ctl-theme.test.d.ts +2 -0
  256. package/dist/theme/ctl-theme.test.d.ts.map +1 -0
  257. package/dist/theme/ctl-tier-coverage.test.d.ts +2 -0
  258. package/dist/theme/ctl-tier-coverage.test.d.ts.map +1 -0
  259. package/dist/theme/divider-law.test.d.ts +2 -0
  260. package/dist/theme/divider-law.test.d.ts.map +1 -0
  261. package/dist/theme/icon-slot.d.ts +44 -0
  262. package/dist/theme/icon-slot.d.ts.map +1 -0
  263. package/dist/theme/icon-slot.js +21 -0
  264. package/dist/theme/icon-slot.module.css +47 -0
  265. package/dist/theme/icon-slot.module.css.d.ts +8 -0
  266. package/dist/theme/icon-slot.test.d.ts +2 -0
  267. package/dist/theme/icon-slot.test.d.ts.map +1 -0
  268. package/dist/theme/index.d.ts +1 -0
  269. package/dist/theme/index.d.ts.map +1 -1
  270. package/dist/theme/index.js +82 -5
  271. package/dist/theme/layout-rhythm-css.test.d.ts +2 -0
  272. package/dist/theme/layout-rhythm-css.test.d.ts.map +1 -0
  273. package/dist/theme/segmented-control.module.css +20 -0
  274. package/dist/theme/shadow-surfaces.test.d.ts.map +1 -1
  275. package/dist/tokens/index.d.ts +12 -0
  276. package/dist/tokens/index.d.ts.map +1 -1
  277. package/dist/tokens/index.js +38 -8
  278. package/dist/tokens/palette.d.ts +97 -61
  279. package/dist/tokens/palette.d.ts.map +1 -1
  280. package/dist/tokens/palette.js +76 -38
  281. package/dist/tokens.css +10 -2
  282. package/dist/widget-header/delta-badge.d.ts +13 -0
  283. package/dist/widget-header/delta-badge.d.ts.map +1 -0
  284. package/dist/widget-header/delta-badge.js +29 -0
  285. package/dist/widget-header/delta-badge.test.d.ts +2 -0
  286. package/dist/widget-header/delta-badge.test.d.ts.map +1 -0
  287. package/dist/widget-header/index.d.ts +3 -0
  288. package/dist/widget-header/index.d.ts.map +1 -0
  289. package/dist/widget-header/index.js +6 -0
  290. package/dist/widget-header/no-mantine.test.d.ts +2 -0
  291. package/dist/widget-header/no-mantine.test.d.ts.map +1 -0
  292. package/dist/widget-header/widget-header.d.ts +37 -0
  293. package/dist/widget-header/widget-header.d.ts.map +1 -0
  294. package/dist/widget-header/widget-header.js +77 -0
  295. package/dist/widget-header/widget-header.module.css +335 -0
  296. package/dist/widget-header/widget-header.module.css.d.ts +24 -0
  297. package/dist/widget-header/widget-header.test.d.ts +2 -0
  298. package/dist/widget-header/widget-header.test.d.ts.map +1 -0
  299. package/llms.txt +30 -17
  300. package/package.json +14 -1
  301. package/src/charts/cursor/resolve.test.ts +29 -1
  302. package/src/charts/cursor/resolve.ts +38 -6
  303. package/src/charts/cursor/scope.tsx +6 -3
  304. package/src/charts/cursor/store.test.ts +19 -1
  305. package/src/charts/cursor/store.ts +10 -5
  306. package/src/charts/hooks/useChartCursor.ts +16 -8
  307. package/src/charts/index.ts +2 -2
  308. package/src/charts/primitives/CartesianChart.test.tsx +124 -0
  309. package/src/charts/primitives/ChartCard.test.tsx +66 -0
  310. package/src/charts/primitives/ChartCard.tsx +86 -146
  311. package/src/charts/sparklines/BarSparkline.tsx +43 -5
  312. package/src/cli/check-coverage.test.ts +297 -0
  313. package/src/cli/check-theme.test.ts +38 -14
  314. package/src/cli/index.ts +392 -16
  315. package/src/cli/init-sync.test.ts +97 -0
  316. package/src/connectivity/connectivity-indicator.tsx +16 -9
  317. package/src/content/index.ts +16 -3
  318. package/src/content/prose.module.css +7 -28
  319. package/src/content/prose.module.css.test.ts +8 -24
  320. package/src/controls/actions.module.css +62 -0
  321. package/src/controls/actions.module.css.d.ts +13 -0
  322. package/src/controls/actions.test.tsx +252 -0
  323. package/src/controls/actions.tsx +655 -0
  324. package/src/controls/actions.type-guard.test.ts +63 -0
  325. package/src/controls/compare-filter.tsx +64 -0
  326. package/src/controls/control-group.test.tsx +229 -0
  327. package/src/controls/control-group.tsx +63 -0
  328. package/src/controls/controls.module.css +542 -0
  329. package/src/controls/controls.module.css.d.ts +35 -0
  330. package/src/controls/controls.router.test.tsx +637 -0
  331. package/src/controls/enum-filter.tsx +159 -0
  332. package/src/controls/filter-context.tsx +153 -0
  333. package/src/controls/filter-pill.tsx +120 -0
  334. package/src/controls/filter-set.test.tsx +472 -0
  335. package/src/controls/filter-set.tsx +225 -0
  336. package/src/controls/filter-sheet.tsx +285 -0
  337. package/src/controls/glyphs.tsx +81 -0
  338. package/src/controls/index.ts +48 -0
  339. package/src/controls/multi-select-filter.tsx +140 -0
  340. package/src/controls/peer-boundary.test.ts +85 -0
  341. package/src/controls/props-contract.test.tsx +106 -0
  342. package/src/controls/range-filter.tsx +184 -0
  343. package/src/controls/search-filter.tsx +123 -0
  344. package/src/controls/select-filter.tsx +89 -0
  345. package/src/controls/sync-button.module.css +63 -0
  346. package/src/controls/sync-button.module.css.d.ts +11 -0
  347. package/src/controls/sync-button.test.tsx +183 -0
  348. package/src/controls/sync-button.tsx +197 -0
  349. package/src/controls/toggle-filter.tsx +60 -0
  350. package/src/controls/view-tabs.tsx +110 -0
  351. package/src/controls-dates/date-range-picker.tsx +42 -0
  352. package/src/controls-dates/index.ts +8 -0
  353. package/src/dashboard/delta-badge.tsx +4 -74
  354. package/src/dashboard/settings-section.module.css +10 -0
  355. package/src/dashboard/settings-section.module.css.d.ts +1 -0
  356. package/src/dashboard/settings-section.test.tsx +56 -0
  357. package/src/dashboard/settings-section.tsx +33 -30
  358. package/src/dashboard/stat-card.module.css +82 -0
  359. package/src/dashboard/stat-card.module.css.d.ts +11 -0
  360. package/src/dashboard/stat-card.test.tsx +67 -1
  361. package/src/dashboard/stat-card.tsx +209 -68
  362. package/src/data/data-table.test.tsx +96 -11
  363. package/src/data/data-table.tsx +220 -84
  364. package/src/guard/check-source.test.ts +126 -13
  365. package/src/guard/grace.test.ts +149 -0
  366. package/src/guard/index.ts +172 -29
  367. package/src/guard/types.ts +4 -2
  368. package/src/index.ts +46 -5
  369. package/src/nav/types.ts +84 -2
  370. package/src/notifications/bell.tsx +6 -3
  371. package/src/notifications/index.ts +1 -1
  372. package/src/router-tanstack/field.ts +31 -0
  373. package/src/router-tanstack/index.ts +24 -2
  374. package/src/router-tanstack/multi-search-param-store.test.ts +71 -3
  375. package/src/router-tanstack/multi-search-param-store.ts +46 -77
  376. package/src/router-tanstack/search-param-store.ts +42 -159
  377. package/src/router-tanstack/search-store.router.test.tsx +661 -0
  378. package/src/router-tanstack/search-store.test.ts +418 -0
  379. package/src/router-tanstack/search-store.ts +380 -0
  380. package/src/router-tanstack/use-nav.test.tsx +187 -0
  381. package/src/router-tanstack/use-nav.ts +72 -14
  382. package/src/section/index.ts +1 -0
  383. package/src/section/section.module.css +104 -0
  384. package/src/section/section.module.css.d.ts +11 -0
  385. package/src/section/section.test.tsx +204 -0
  386. package/src/section/section.tsx +220 -0
  387. package/src/shell/app-breadcrumbs.tsx +44 -31
  388. package/src/shell/app-header.module.css +77 -37
  389. package/src/shell/app-header.module.css.d.ts +2 -1
  390. package/src/shell/app-mobile-nav.module.css +8 -0
  391. package/src/shell/app-mobile-nav.module.css.d.ts +1 -0
  392. package/src/shell/app-mobile-nav.tsx +147 -8
  393. package/src/shell/app-sidebar-account.tsx +2 -2
  394. package/src/shell/app-sidebar.module.css +303 -20
  395. package/src/shell/app-sidebar.module.css.d.ts +19 -1
  396. package/src/shell/app-sidebar.test.tsx +456 -41
  397. package/src/shell/app-sidebar.tsx +276 -140
  398. package/src/shell/index.test.tsx +291 -5
  399. package/src/shell/index.tsx +137 -38
  400. package/src/shell/mobile-nav-model.test.ts +228 -7
  401. package/src/shell/mobile-nav-model.ts +53 -3
  402. package/src/shell/nav-count-badge.tsx +1 -1
  403. package/src/shell/page-bar.module.css +178 -0
  404. package/src/shell/page-bar.module.css.d.ts +17 -0
  405. package/src/shell/page-bar.test.tsx +316 -0
  406. package/src/shell/page-bar.tsx +315 -0
  407. package/src/shell/sidebar-block-model.ts +83 -0
  408. package/src/shell/sidebar-blocks.tsx +301 -0
  409. package/src/shell/sidebar-search.module.css +26 -0
  410. package/src/shell/sidebar-search.module.css.d.ts +2 -0
  411. package/src/shell/sidebar-search.tsx +71 -2
  412. package/src/state/fields.test.tsx +258 -0
  413. package/src/state/fields.ts +892 -0
  414. package/src/state/persisted.ts +211 -0
  415. package/src/state.ts +46 -240
  416. package/src/styles.css +13 -0
  417. package/src/surfaces.ts +203 -36
  418. package/src/theme/controls.module.css +16 -1
  419. package/src/theme/ctl-theme.test.tsx +275 -0
  420. package/src/theme/ctl-theme.tsx +138 -0
  421. package/src/theme/ctl-tier-coverage.test.ts +170 -0
  422. package/src/theme/density-relations.test.ts +106 -51
  423. package/src/theme/divider-law.test.ts +259 -0
  424. package/src/theme/icon-slot.module.css +47 -0
  425. package/src/theme/icon-slot.module.css.d.ts +8 -0
  426. package/src/theme/icon-slot.test.tsx +275 -0
  427. package/src/theme/icon-slot.tsx +59 -0
  428. package/src/theme/index.ts +165 -3
  429. package/src/theme/layout-rhythm-css.test.ts +72 -0
  430. package/src/theme/radius.test.ts +11 -1
  431. package/src/theme/segmented-control.module.css +20 -0
  432. package/src/theme/shadow-surfaces.test.ts +20 -7
  433. package/src/theme/spacing.test.ts +75 -27
  434. package/src/theme/use-basalt-spacing.ts +2 -2
  435. package/src/tokens/density.test.ts +7 -25
  436. package/src/tokens/index.ts +48 -8
  437. package/src/tokens/palette.ts +107 -73
  438. package/src/widget-header/delta-badge.test.tsx +60 -0
  439. package/src/widget-header/delta-badge.tsx +57 -0
  440. package/src/widget-header/index.ts +2 -0
  441. package/src/widget-header/no-mantine.test.ts +33 -0
  442. package/src/widget-header/widget-header.module.css +335 -0
  443. package/src/widget-header/widget-header.module.css.d.ts +24 -0
  444. package/src/widget-header/widget-header.test.tsx +187 -0
  445. package/src/widget-header/widget-header.tsx +168 -0
  446. package/agent/rules/basalt-agent.md +0 -452
  447. package/agent/rules/basalt-app.md +0 -246
  448. package/agent/rules/basalt-commands.md +0 -320
  449. package/agent/rules/basalt-content.md +0 -409
  450. package/agent/rules/basalt-data.md +0 -329
  451. package/agent/rules/basalt-forms.md +0 -186
  452. package/agent/rules/basalt-notifications.md +0 -264
  453. package/agent/rules/basalt-query.md +0 -187
  454. package/agent/rules/basalt-router.md +0 -336
  455. package/dist/content/article-filter-bar.d.ts +0 -40
  456. package/dist/content/article-filter-bar.d.ts.map +0 -1
  457. package/dist/content/article-filter-bar.js +0 -56
  458. package/dist/shell/page-header.d.ts +0 -13
  459. package/dist/shell/page-header.d.ts.map +0 -1
  460. package/dist/shell/page-header.js +0 -23
  461. package/src/content/article-filter-bar.tsx +0 -88
  462. package/src/shell/page-header.tsx +0 -35
@@ -0,0 +1,380 @@
1
+ /**
2
+ * `createSearchStore` — one factory over typed fields, replacing the enum-only
3
+ * `createSearchParamStore` / `createMultiSearchParamStore` pair.
4
+ *
5
+ * The URL is the truth; the localStorage mirror is a fallback UNDER it. Every field resolves the
6
+ * same way — URL ⊳ localStorage ⊳ fallback (C4) — and declares its lanes once, at definition.
7
+ *
8
+ * Ground truth for the placement/persistence law this implements: `docs/CONTROLS-SPEC.md` §4.
9
+ * What used to be a 100-line recipe in this module's sibling is that spec plus `agent/rules/
10
+ * basalt-state.md`; the API below is what those paragraphs were waiting for.
11
+ *
12
+ * Two behaviours worth knowing before reading the code:
13
+ *
14
+ * - **A deep link wins over the mirror.** `?range=7d` reads back `7d` even when localStorage says
15
+ * `30d`. The enum-only pair could not do this: its reader hook was the localStorage state, so a
16
+ * shared link opened on the wrong window (A8).
17
+ * - **A field with neither lane is in memory, not nowhere.** `{ url: false, persist: false }` is
18
+ * the memory-only lane: session-scoped, shared across every mount of the store, gone on reload —
19
+ * the same lane `createLocalStore` gives such a field, through the same store-core helper. It is
20
+ * the honest home for a value a reader should not be handed back tomorrow.
21
+ * - **A write from outside the owning route persists only.** `use()`'s setter navigates only when
22
+ * the matched route validates the param — detected by the param key being PRESENT in the current
23
+ * search, which a route running this store's `validateSearch` always is (it returns every
24
+ * URL-lane param unconditionally) and a foreign route is not. `validateSearch` then picks the
25
+ * persisted value up on the next visit (A1). The honest limit of that detection: two DIFFERENT
26
+ * stores using the same param name on overlapping routes read as one owner. Presence is what a
27
+ * store can see without the router telling it which route declared what, and the alternative — a
28
+ * context flag — would need a provider per store, which is the thing this API does not have.
29
+ *
30
+ * Headless — no Mantine, no JSX. Same tier as `useBasaltNav` and `useRouterBreadcrumbs`.
31
+ */
32
+ import { useNavigate, useSearch } from '@tanstack/react-router'
33
+ import { createStoreCore } from '../state'
34
+ import type { AnyField, FieldHandle, SearchValues, StoreEntry, StoredValues } from '../state'
35
+
36
+ // ── Types ──────────────────────────────────────────────────────────────────
37
+
38
+ export type CreateSearchStoreOptions<S extends Record<string, AnyField>> = {
39
+ /** localStorage key (namespaced `basalt:<key>` automatically). One entry per store. */
40
+ key: string
41
+ /** The fields, built with `field.*`. The key of each is its default URL param name. */
42
+ fields: S
43
+ /** Envelope version — bump when the field set changes to discard stale localStorage. */
44
+ version?: number
45
+ }
46
+
47
+ export type SearchStore<S extends Record<string, AnyField>> = {
48
+ /**
49
+ * Pass directly to `createFileRoute({ validateSearch })`. Returns every URL-lane param, resolved
50
+ * URL ⊳ localStorage ⊳ fallback — a route wired to it therefore always has the params, which is
51
+ * what makes `use()`'s "does this route own the field" check trivial and exact.
52
+ */
53
+ validateSearch: (raw: Record<string, unknown>) => SearchValues<S>
54
+ /**
55
+ * The click-time `search:` value for a nav link. Pass it BY REFERENCE
56
+ * (`search: store.linkSearch`) inside `defineNav` — never a literal object, which pins the
57
+ * fallback on every click and is why one consumer's reader had zero call sites (C10, A2).
58
+ */
59
+ linkSearch: () => SearchValues<S>
60
+ /** Plain read for non-React contexts (a redirect, a loader, a guard, a test). */
61
+ readStored: () => Partial<StoredValues<S>>
62
+ /** The per-field handles every basalt control takes instead of `value`/`onChange` (C2). */
63
+ field: { [K in keyof S]: FieldHandle<S[K]> }
64
+ /** Option labels for enum / multi / range fields. Chainable — call it once, at definition. */
65
+ labels: (map: Partial<{ [K in keyof S]: Record<string, string> }>) => SearchStore<S>
66
+ /** Every URL-lane param, resolved — the object a page hands to its query. */
67
+ useValues: () => SearchValues<S>
68
+ /** How many fields differ from their fallback — the `n` in a `Filters (n)` pill. */
69
+ useActiveCount: () => number
70
+ /** Resets every field to its fallback: one navigate, one storage write. */
71
+ useReset: () => () => void
72
+ }
73
+
74
+ /**
75
+ * Dev-only, once per store: the URL is pinning `fallback` while something else is persisted, and
76
+ * neither reader has ever been called. That combination has exactly one realistic cause — a nav
77
+ * link declaring the param as a literal — and it is invisible otherwise, because every individual
78
+ * piece looks correct and the feature merely never remembers anything.
79
+ *
80
+ * Deliberately NOT a timer over "was this store ever read": that fires in every test that builds a
81
+ * store, and it cannot say what to do about it. This fires only in the broken state, and names the
82
+ * fix.
83
+ */
84
+ function warnLinkPinsFallback(input: {
85
+ key: string
86
+ param: string
87
+ urlValue: string
88
+ storedValue: string
89
+ }): void {
90
+ const { key, param, urlValue, storedValue } = input
91
+ // oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
92
+ console.warn(
93
+ `[basalt-ui] createSearchStore('${key}'): the URL pinned \`${param}=${urlValue}\` ` +
94
+ '(the fallback) ' +
95
+ `while '${storedValue}' was persisted, and nothing has read this store back. A nav link is ` +
96
+ `almost certainly declaring \`search: { ${param}: '${urlValue}' }\` at module scope, which ` +
97
+ "overrides the stored value on every click — pass the store's own reader instead: " +
98
+ '`search: <store>.linkSearch`. (dev only)',
99
+ )
100
+ }
101
+
102
+ // ── Implementation ─────────────────────────────────────────────────────────
103
+
104
+ /** Reads the merged search of every matched route, so a child or sibling needs no `from` (A3). */
105
+ function useCurrentSearch(): Record<string, unknown> {
106
+ const search = useSearch({ strict: false }) as unknown
107
+ return typeof search === 'object' && search !== null ? (search as Record<string, unknown>) : {}
108
+ }
109
+
110
+ function describeValue(value: unknown): string {
111
+ return typeof value === 'string' ? value : JSON.stringify(value)
112
+ }
113
+
114
+ /**
115
+ * Dev-only, once per field: a `persist: false` field was written from a route that does not
116
+ * validate it, so the write had nowhere to go — no URL (the route owns no such param) and no mirror
117
+ * (the field opted out of one). Silence is the wrong answer here: every other lane combination does
118
+ * something, so a control that appears to work and changes nothing reads as a basalt bug.
119
+ */
120
+ function warnWriteHasNowhereToGo(input: { key: string; name: string; param: string }): void {
121
+ const { key, name, param } = input
122
+ // oxlint-disable-next-line no-console -- a dev-time wiring warning has no other channel
123
+ console.warn(
124
+ `[basalt-ui] createSearchStore('${key}'): wrote field '${name}' from a route that does not ` +
125
+ `validate \`${param}\`, and the field is \`persist: false\` — so the write was dropped. ` +
126
+ 'Either render this control on the route wired to `validateSearch`, or give the field the ' +
127
+ 'localStorage lane (drop `persist: false`) so a write from elsewhere survives until the ' +
128
+ 'owning route reads it. (dev only)',
129
+ )
130
+ }
131
+
132
+ /**
133
+ * The public store plus the one member only the deprecated wrappers need: `markReaderWired`, so a
134
+ * consumer whose only reader is the wrapper's own `useStore()` still counts as wired and never sees
135
+ * the pinned-link warning it never used to see.
136
+ *
137
+ * @internal — absent from `SearchStore`, so it is invisible on `createSearchStore`'s return type.
138
+ */
139
+ export type InternalSearchStore<S extends Record<string, AnyField>> = SearchStore<S> & {
140
+ markReaderWired: () => void
141
+ }
142
+
143
+ /** The memory-only lane: neither the URL nor the mirror, so the value lives in the store's own
144
+ * session-scoped external store (`StoreCore.memoryUse`) — the lane `createLocalStore` also uses. */
145
+ function isMemoryLane(entry: StoreEntry): boolean {
146
+ return !entry.codec.lane.url && !entry.codec.lane.persist
147
+ }
148
+
149
+ /**
150
+ * The store, plus the one knob the deprecated wrappers need: `legacyValueField` keeps the
151
+ * enum-only stores' single-value storage layout so an already-persisted selection still resolves.
152
+ * Not exported from the barrel — `createSearchStore` is the public door.
153
+ */
154
+ export function buildSearchStore<const S extends Record<string, AnyField>>(
155
+ o: CreateSearchStoreOptions<S> & { legacyValueField?: string },
156
+ ): InternalSearchStore<S> {
157
+ const core = createStoreCore(o)
158
+
159
+ // `validateSearch` reads storage on every navigation, which says nothing about whether the
160
+ // CONSUMER wired a reader. Everything a consumer can read THROUGH sets this: `linkSearch`,
161
+ // `readStored`, and `field.<name>.use()` — the documented reader, and the one a page that never
162
+ // touches the other two uses exclusively. Missing it there made the pinned-link warning fire on
163
+ // correctly wired pages.
164
+ let readerWired = false
165
+ let warned = false
166
+ const markReaderWired = (): void => {
167
+ readerWired = true
168
+ }
169
+ const nowhereWarned = new Set<string>()
170
+
171
+ const flatten = (
172
+ search: Record<string, unknown> | null,
173
+ record: Record<string, unknown>,
174
+ ): Record<string, unknown> => {
175
+ const out: Record<string, unknown> = {}
176
+ for (const entry of core.urlEntries) {
177
+ Object.assign(out, entry.codec.toSearch(core.resolve(entry, search, record)))
178
+ }
179
+ return out
180
+ }
181
+
182
+ const maybeWarn = (raw: Record<string, unknown>, record: Record<string, unknown>): void => {
183
+ if (warned || readerWired || process.env['NODE_ENV'] === 'production') return
184
+ for (const entry of core.urlEntries) {
185
+ const fromUrl = entry.codec.fromSearch(raw)
186
+ if (fromUrl === null || !entry.codec.equals(fromUrl, entry.codec.fallback)) continue
187
+ const stored = entry.codec.decode(record[entry.name])
188
+ if (stored === null || entry.codec.equals(stored, fromUrl)) continue
189
+ // Describe both sides as the URL spells them (a range prints `30d`, not its whole value
190
+ // object) — the message quotes a `search:` literal, so it has to be URL-shaped to be advice.
191
+ warned = true
192
+ warnLinkPinsFallback({
193
+ key: o.key,
194
+ param: entry.codec.primary,
195
+ urlValue: describeValue(entry.codec.toSearch(fromUrl)[entry.codec.primary]),
196
+ storedValue: describeValue(entry.codec.toSearch(stored)[entry.codec.primary]),
197
+ })
198
+ return
199
+ }
200
+ }
201
+
202
+ const validateSearch = (raw: Record<string, unknown>): SearchValues<S> => {
203
+ const record = core.readRecord()
204
+ maybeWarn(raw, record)
205
+ return flatten(raw, record) as SearchValues<S>
206
+ }
207
+
208
+ const linkSearch = (): SearchValues<S> => {
209
+ readerWired = true
210
+ return flatten(null, core.readRecord()) as SearchValues<S>
211
+ }
212
+
213
+ const readStored = (): Partial<StoredValues<S>> => {
214
+ readerWired = true
215
+ return core.readStoredFlat() as Partial<StoredValues<S>>
216
+ }
217
+
218
+ const urlUse = (entry: StoreEntry) => (): readonly [unknown, (next: unknown) => void] => {
219
+ const search = useCurrentSearch()
220
+ const navigate = useNavigate()
221
+ const [record, setRecord] = core.usePersistedRecord()
222
+ markReaderWired()
223
+ const value = core.resolve(entry, search, record)
224
+ // The matched route validates this field iff its primary param is present (see the module
225
+ // header). A foreign route gets the persist-only lane.
226
+ const validated = Object.hasOwn(search, entry.codec.primary)
227
+
228
+ return [
229
+ value,
230
+ (next: unknown) => {
231
+ if (validated) {
232
+ navigate({
233
+ to: '.',
234
+ search: (prev: Record<string, unknown>) => ({ ...prev, ...entry.codec.toSearch(next) }),
235
+ replace: entry.codec.lane.history === 'replace',
236
+ // A filter lives halfway down a page as often as it lives in the bar, and the router's
237
+ // default scroll restoration treats this same-route search write as a navigation — so
238
+ // without this a select two screens down jumps the reader back to the top on every
239
+ // change. Never a prop: there is no filter write that WANTS the page to scroll.
240
+ resetScroll: false,
241
+ })
242
+ }
243
+ if (entry.codec.lane.persist) {
244
+ core.writeField(setRecord, entry.name, next)
245
+ return
246
+ }
247
+ if (validated || nowhereWarned.has(entry.name)) return
248
+ if (process.env['NODE_ENV'] === 'production') return
249
+ nowhereWarned.add(entry.name)
250
+ warnWriteHasNowhereToGo({ key: o.key, name: entry.name, param: entry.codec.primary })
251
+ },
252
+ ] as const
253
+ }
254
+
255
+ const localUse = (entry: StoreEntry) => (): readonly [unknown, (next: unknown) => void] => {
256
+ const [record, setRecord] = core.usePersistedRecord()
257
+ markReaderWired()
258
+ return [
259
+ core.resolve(entry, null, record),
260
+ (next: unknown) => core.writeField(setRecord, entry.name, next),
261
+ ] as const
262
+ }
263
+
264
+ // Three lanes, decided once per field at definition: URL (+ optional mirror), mirror-only, and
265
+ // memory-only. The third used to be the one combination that dropped its write.
266
+ const memoryEntries = core.entries.filter(isMemoryLane)
267
+
268
+ const field = {} as { [K in keyof S]: FieldHandle<S[K]> }
269
+ for (const entry of core.entries) {
270
+ const use = entry.codec.lane.url
271
+ ? urlUse(entry)
272
+ : entry.codec.lane.persist
273
+ ? localUse(entry)
274
+ : core.memoryUse(entry)
275
+ field[entry.name as keyof S] = core.handle(entry, use) as unknown as FieldHandle<S[keyof S]>
276
+ }
277
+
278
+ const useValues = (): SearchValues<S> => {
279
+ const search = useCurrentSearch()
280
+ const [record] = core.usePersistedRecord()
281
+ markReaderWired()
282
+ return flatten(search, record) as SearchValues<S>
283
+ }
284
+
285
+ const useActiveCount = (): number => {
286
+ const search = useCurrentSearch()
287
+ const [record] = core.usePersistedRecord()
288
+ // A memory field is as much a filter as any other, so the `Filters (n)` pill has to count it —
289
+ // which means subscribing to the lane, or the count would go stale the moment one is written.
290
+ core.useMemoryVersion()
291
+ let count = 0
292
+ for (const entry of core.entries) {
293
+ const value = isMemoryLane(entry)
294
+ ? core.readMemoryValue(entry)
295
+ : core.resolve(entry, entry.codec.lane.url ? search : null, record)
296
+ if (!entry.codec.equals(value, entry.codec.fallback)) count += 1
297
+ }
298
+ return count
299
+ }
300
+
301
+ const useReset = (): (() => void) => {
302
+ const search = useCurrentSearch()
303
+ const navigate = useNavigate()
304
+ const [, setRecord] = core.usePersistedRecord()
305
+
306
+ return () => {
307
+ // Same "does this route own the field" gate as `urlUse` — only reset the params a foreign
308
+ // route's `validateSearch` actually validates, or the reset navigates there and pollutes it.
309
+ const validatedEntries = core.urlEntries.filter((entry) =>
310
+ Object.hasOwn(search, entry.codec.primary),
311
+ )
312
+ if (validatedEntries.length > 0) {
313
+ const patch: Record<string, unknown> = {}
314
+ for (const entry of validatedEntries) {
315
+ Object.assign(patch, entry.codec.toSearch(entry.codec.fallback))
316
+ }
317
+ navigate({
318
+ to: '.',
319
+ search: (prev: Record<string, unknown>) => ({ ...prev, ...patch }),
320
+ replace: true,
321
+ // Same reason as a single field's write above — a `Reset all` pressed from the mobile
322
+ // sheet must not also scroll the page it was pressed on.
323
+ resetScroll: false,
324
+ })
325
+ }
326
+ if (memoryEntries.length > 0) core.resetMemory()
327
+ if (!core.anyPersisted) return
328
+ const next = { ...core.readRecord() }
329
+ for (const entry of core.entries) {
330
+ if (entry.codec.lane.persist) next[entry.name] = entry.codec.fallback
331
+ }
332
+ setRecord(next)
333
+ }
334
+ }
335
+
336
+ const store: InternalSearchStore<S> = {
337
+ validateSearch,
338
+ linkSearch,
339
+ readStored,
340
+ field,
341
+ labels: (map) => {
342
+ core.setLabels(map as Record<string, Record<string, string> | undefined>)
343
+ return store
344
+ },
345
+ useValues,
346
+ useActiveCount,
347
+ useReset,
348
+ markReaderWired,
349
+ }
350
+
351
+ return store
352
+ }
353
+
354
+ /**
355
+ * One store per page (or per feature), over typed fields. `key` is the localStorage key; each
356
+ * field's map key is its default URL param name.
357
+ *
358
+ * @example
359
+ * export const analytics = createSearchStore({
360
+ * key: 'analytics',
361
+ * fields: {
362
+ * range: field.range({ presets: ['7d', '30d', '90d'], fallback: '30d', custom: true }),
363
+ * compare: field.enum(['none', 'previous'], 'none'),
364
+ * channels: field.multi(CHANNELS, []),
365
+ * },
366
+ * }).labels({ range: { '7d': 'Last 7 days', '30d': 'Last 30 days', '90d': 'Last 90 days' } })
367
+ *
368
+ * export const Route = createFileRoute('/analytics')({
369
+ * validateSearch: analytics.validateSearch,
370
+ * loaderDeps: ({ search }) => search,
371
+ * })
372
+ *
373
+ * // in the page: <RangeFilter field={analytics.field.range} />
374
+ * // in the nav: linkOptions({ to: '/analytics', search: analytics.linkSearch })
375
+ */
376
+ export function createSearchStore<const S extends Record<string, AnyField>>(
377
+ o: CreateSearchStoreOptions<S>,
378
+ ): SearchStore<S> {
379
+ return buildSearchStore(o)
380
+ }
@@ -0,0 +1,187 @@
1
+ /**
2
+ * `useNav`'s active-resolution pass, under a REAL TanStack memory router — the half that cannot be
3
+ * tested headlessly (`matchRoute`/`useRouterState` both need a live router). The fixture below
4
+ * mirrors every collision shape the playground's own `demo/nav-model.tsx` actually has: a root `/`
5
+ * item, a `/dashboard` parent with three children, flat siblings, a same-path duplicate
6
+ * (`reports`/`components`, mirroring the playground's disabled-placeholder shape), and a
7
+ * search-bearing link (mirroring `dashboardFilters.linkSearch`-style destinations).
8
+ *
9
+ * The harness is modeled on `controls/controls.router.test.tsx`'s `mountPage`, narrowed to a
10
+ * headless probe: `useNav` renders no JSX itself (`./router-tanstack` is a no-JSX barrel), so the
11
+ * probe component only reads its return value into a sink — no `MantineProvider` needed.
12
+ */
13
+ import { cleanup, render, waitFor } from '@testing-library/react'
14
+ import { describe, expect, test } from 'bun:test'
15
+ import type { ReactNode } from 'react'
16
+ import {
17
+ Outlet,
18
+ RouterProvider,
19
+ createMemoryHistory,
20
+ createRootRoute,
21
+ createRoute,
22
+ createRouter,
23
+ } from '@tanstack/react-router'
24
+ import type { SidebarItem, SidebarSection } from '../nav/types'
25
+ import type { AnyNavItem } from './nav'
26
+ import { defineNav, navGroup } from './nav'
27
+ import { useNav } from './use-nav'
28
+
29
+ const NAV = defineNav({
30
+ groups: [
31
+ navGroup({ id: 'main', label: 'Main' }, [
32
+ { id: 'home', label: 'Home', link: { to: '/' } },
33
+ {
34
+ id: 'dashboard',
35
+ label: 'Dashboard',
36
+ // A store-backed destination (law C10's shape) — `search` rides the link like every
37
+ // other field; `matchOf` below never reads it, so it must not affect matching either.
38
+ link: { to: '/dashboard', search: { window: '30d' } },
39
+ children: [
40
+ { id: 'dashboard-sessions', label: 'Sessions', link: { to: '/dashboard/sessions' } },
41
+ { id: 'dashboard-traffic', label: 'Traffic', link: { to: '/dashboard/traffic' } },
42
+ { id: 'dashboard-revenue', label: 'Revenue', link: { to: '/dashboard/revenue' } },
43
+ ],
44
+ },
45
+ { id: 'mobile-nav-pill', label: 'Mobile nav pill', link: { to: '/mobile-nav-pill' } },
46
+ {
47
+ id: 'controls-mobile',
48
+ label: 'Controls (mobile)',
49
+ link: { to: '/controls-mobile' },
50
+ },
51
+ { id: 'components', label: 'Components', link: { to: '/components' } },
52
+ { id: 'charts-page', label: 'Charts', link: { to: '/charts' } },
53
+ // The playground's `reports` shape: a disabled placeholder that deliberately borrows an
54
+ // already-covered route rather than inventing one. Declared AFTER `components`, so the
55
+ // definition-order tie-break keeps `components` the winner at `/components`.
56
+ { id: 'reports', label: 'Reports', disabled: true, link: { to: '/components' } },
57
+ ]),
58
+ ],
59
+ })
60
+
61
+ const ROUTE_PATHS = [
62
+ '/',
63
+ '/dashboard',
64
+ '/dashboard/sessions',
65
+ '/dashboard/traffic',
66
+ '/dashboard/revenue',
67
+ '/mobile-nav-pill',
68
+ '/controls-mobile',
69
+ '/components',
70
+ '/charts',
71
+ ]
72
+
73
+ type NavProbeOpts = { isActive?: (item: AnyNavItem) => boolean }
74
+ type NavResult = { sections: SidebarSection[] }
75
+ type Sink = { current: NavResult | null }
76
+
77
+ /** Depth-first flatten of the rendered `SidebarSection[]` — the same shape every downstream reader
78
+ * (the sidebar, the mobile bar, the breadcrumb) walks. */
79
+ function flattenItems(sections: SidebarSection[]): SidebarItem[] {
80
+ const out: SidebarItem[] = []
81
+ const walk = (items: SidebarItem[]): void => {
82
+ for (const item of items) {
83
+ out.push(item)
84
+ if (item.children) walk(item.children)
85
+ }
86
+ }
87
+ for (const section of sections) walk(section.items)
88
+ return out
89
+ }
90
+
91
+ async function mountNav(entry: string, opts?: NavProbeOpts): Promise<Sink> {
92
+ const sink: Sink = { current: null }
93
+
94
+ function NavProbe(): ReactNode {
95
+ const nav = useNav(NAV, opts)
96
+ sink.current = { sections: nav.sections }
97
+ return null
98
+ }
99
+
100
+ const rootRoute = createRootRoute({
101
+ component: () => (
102
+ <>
103
+ <NavProbe />
104
+ <Outlet />
105
+ </>
106
+ ),
107
+ })
108
+ const children = ROUTE_PATHS.map((path) =>
109
+ createRoute({ getParentRoute: () => rootRoute, path, component: () => null }),
110
+ )
111
+ const router = createRouter({
112
+ routeTree: rootRoute.addChildren(children),
113
+ history: createMemoryHistory({ initialEntries: [entry] }),
114
+ })
115
+
116
+ render(<RouterProvider router={router} />)
117
+ await waitFor(() => {
118
+ expect(router.state.status).toBe('idle')
119
+ })
120
+ return sink
121
+ }
122
+
123
+ describe('useNav — exclusivity', () => {
124
+ test('exactly one row is active at every route in the definition', async () => {
125
+ for (const path of ROUTE_PATHS) {
126
+ const sink = await mountNav(path)
127
+ const items = flattenItems(sink.current!.sections)
128
+ const activeCount = items.filter((item) => item.active).length
129
+ expect(activeCount).toBe(1)
130
+ cleanup()
131
+ }
132
+ })
133
+
134
+ test('the deepest match wins — a child route does not also light its parent', async () => {
135
+ const sink = await mountNav('/dashboard/sessions')
136
+ const items = flattenItems(sink.current!.sections)
137
+ const dashboard = items.find((item) => item.key === 'dashboard')
138
+ const sessions = items.find((item) => item.key === 'dashboard-sessions')
139
+ expect(dashboard?.active).toBe(false)
140
+ expect(sessions?.active).toBe(true)
141
+ })
142
+
143
+ test('the parent of the active row is an ancestor, not active', async () => {
144
+ const sink = await mountNav('/dashboard/sessions')
145
+ const items = flattenItems(sink.current!.sections)
146
+ const dashboard = items.find((item) => item.key === 'dashboard')
147
+ expect(dashboard?.ancestor).toBe(true)
148
+ expect(dashboard?.active).toBe(false)
149
+ })
150
+
151
+ test('a same-path duplicate destination is neither active nor an ancestor', async () => {
152
+ const sink = await mountNav('/components')
153
+ const items = flattenItems(sink.current!.sections)
154
+ const components = items.find((item) => item.key === 'components')
155
+ const reports = items.find((item) => item.key === 'reports')
156
+ expect(components?.active).toBe(true)
157
+ expect(reports?.active).toBe(false)
158
+ expect(reports?.ancestor).toBeFalsy()
159
+ })
160
+
161
+ test("'/' stays exact — the root never lights on a nested route", async () => {
162
+ const sink = await mountNav('/dashboard')
163
+ const items = flattenItems(sink.current!.sections)
164
+ const home = items.find((item) => item.key === 'home')
165
+ expect(home?.active).toBe(false)
166
+ expect(home?.ancestor).toBeFalsy()
167
+ })
168
+
169
+ test('an item whose exact route is never visited still lights on a descendant route', async () => {
170
+ // `/dashboard` itself is never the visited entry in this test — only its descendant is —
171
+ // proving the fix kept `fuzzy: true` as the default rather than flipping every item to exact.
172
+ const sink = await mountNav('/dashboard/traffic')
173
+ const items = flattenItems(sink.current!.sections)
174
+ const dashboard = items.find((item) => item.key === 'dashboard')
175
+ expect(dashboard?.ancestor).toBe(true)
176
+ expect(dashboard?.active).toBe(false)
177
+ })
178
+
179
+ test('an isActive override cannot light two rows either', async () => {
180
+ const sink = await mountNav('/', {
181
+ isActive: (item) => item.id === 'components' || item.id === 'charts-page',
182
+ })
183
+ const items = flattenItems(sink.current!.sections)
184
+ const activeCount = items.filter((item) => item.active).length
185
+ expect(activeCount).toBe(1)
186
+ })
187
+ })