@eifi1/ui-kit 0.4.0 → 0.5.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 (410) hide show
  1. package/LICENSE +32 -0
  2. package/README.md +498 -57
  3. package/dist/chart.d.ts +10 -0
  4. package/dist/chart.js +10 -0
  5. package/dist/chart.js.map +1 -0
  6. package/dist/components/account-settings.d.ts +11 -0
  7. package/dist/components/account-settings.js +19 -12
  8. package/dist/components/account-settings.js.map +1 -1
  9. package/dist/components/alert-banner.d.ts +10 -6
  10. package/dist/components/alert-banner.js +20 -16
  11. package/dist/components/alert-banner.js.map +1 -1
  12. package/dist/components/amount-input.d.ts +30 -4
  13. package/dist/components/amount-input.js +36 -14
  14. package/dist/components/amount-input.js.map +1 -1
  15. package/dist/components/calculator.d.ts +24 -7
  16. package/dist/components/calculator.js +37 -20
  17. package/dist/components/calculator.js.map +1 -1
  18. package/dist/components/chart-zoom.d.ts +156 -0
  19. package/dist/components/chart-zoom.js +262 -0
  20. package/dist/components/chart-zoom.js.map +1 -0
  21. package/dist/components/chart.d.ts +28 -3
  22. package/dist/components/chart.js +71 -32
  23. package/dist/components/chart.js.map +1 -1
  24. package/dist/components/checkbox.d.ts +51 -0
  25. package/dist/components/checkbox.js +123 -0
  26. package/dist/components/checkbox.js.map +1 -0
  27. package/dist/components/chip.d.ts +128 -0
  28. package/dist/components/chip.js +357 -0
  29. package/dist/components/chip.js.map +1 -0
  30. package/dist/components/choice-parts.d.ts +24 -0
  31. package/dist/components/choice-parts.js +18 -0
  32. package/dist/components/choice-parts.js.map +1 -0
  33. package/dist/components/combobox-core.d.ts +52 -9
  34. package/dist/components/combobox-core.js +91 -20
  35. package/dist/components/combobox-core.js.map +1 -1
  36. package/dist/components/combobox.d.ts +43 -33
  37. package/dist/components/combobox.js +409 -340
  38. package/dist/components/combobox.js.map +1 -1
  39. package/dist/components/currency-select.d.ts +48 -8
  40. package/dist/components/currency-select.js +107 -65
  41. package/dist/components/currency-select.js.map +1 -1
  42. package/dist/components/data-table-filter-popover.d.ts +15 -3
  43. package/dist/components/data-table-filter-popover.js +23 -17
  44. package/dist/components/data-table-filter-popover.js.map +1 -1
  45. package/dist/components/data-table-filters.d.ts +1 -1
  46. package/dist/components/data-table-filters.js +3 -2
  47. package/dist/components/data-table-filters.js.map +1 -1
  48. package/dist/components/data-table-labels.d.ts +53 -2
  49. package/dist/components/data-table-labels.js +31 -2
  50. package/dist/components/data-table-labels.js.map +1 -1
  51. package/dist/components/data-table-pagination.d.ts +3 -1
  52. package/dist/components/data-table-pagination.js +16 -10
  53. package/dist/components/data-table-pagination.js.map +1 -1
  54. package/dist/components/data-table-sort.js +1 -0
  55. package/dist/components/data-table-sort.js.map +1 -1
  56. package/dist/components/data-table.d.ts +1 -1
  57. package/dist/components/data-table.js +201 -85
  58. package/dist/components/data-table.js.map +1 -1
  59. package/dist/components/date-picker.d.ts +25 -9
  60. package/dist/components/date-picker.js +232 -94
  61. package/dist/components/date-picker.js.map +1 -1
  62. package/dist/components/dropdown.d.ts +96 -20
  63. package/dist/components/dropdown.js +77 -33
  64. package/dist/components/dropdown.js.map +1 -1
  65. package/dist/components/entity-combobox.d.ts +5 -4
  66. package/dist/components/entity-combobox.js +107 -76
  67. package/dist/components/entity-combobox.js.map +1 -1
  68. package/dist/components/facing-pair.d.ts +36 -0
  69. package/dist/components/facing-pair.js +40 -0
  70. package/dist/components/facing-pair.js.map +1 -0
  71. package/dist/components/field-sync.d.ts +164 -0
  72. package/dist/components/field-sync.js +264 -0
  73. package/dist/components/field-sync.js.map +1 -0
  74. package/dist/components/file-dropzone.d.ts +38 -6
  75. package/dist/components/file-dropzone.js +34 -9
  76. package/dist/components/file-dropzone.js.map +1 -1
  77. package/dist/components/full-bleed-dialog.d.ts +37 -26
  78. package/dist/components/full-bleed-dialog.js +20 -5
  79. package/dist/components/full-bleed-dialog.js.map +1 -1
  80. package/dist/components/grouped-picker.d.ts +21 -10
  81. package/dist/components/grouped-picker.js +66 -52
  82. package/dist/components/grouped-picker.js.map +1 -1
  83. package/dist/components/hover-menu.d.ts +20 -4
  84. package/dist/components/hover-menu.js +15 -4
  85. package/dist/components/hover-menu.js.map +1 -1
  86. package/dist/components/mini-calendar.d.ts +102 -14
  87. package/dist/components/mini-calendar.js +256 -70
  88. package/dist/components/mini-calendar.js.map +1 -1
  89. package/dist/components/modal.d.ts +9 -3
  90. package/dist/components/modal.js +34 -45
  91. package/dist/components/modal.js.map +1 -1
  92. package/dist/components/month-picker.d.ts +79 -0
  93. package/dist/components/month-picker.js +342 -0
  94. package/dist/components/month-picker.js.map +1 -0
  95. package/dist/components/multi-entity-combobox.d.ts +7 -6
  96. package/dist/components/multi-entity-combobox.js +112 -72
  97. package/dist/components/multi-entity-combobox.js.map +1 -1
  98. package/dist/components/multi-select.d.ts +13 -11
  99. package/dist/components/multi-select.js +161 -84
  100. package/dist/components/multi-select.js.map +1 -1
  101. package/dist/components/number-field.d.ts +92 -0
  102. package/dist/components/number-field.js +111 -0
  103. package/dist/components/number-field.js.map +1 -0
  104. package/dist/components/number-input.d.ts +28 -2
  105. package/dist/components/number-input.js +12 -2
  106. package/dist/components/number-input.js.map +1 -1
  107. package/dist/components/numpad-sheet.d.ts +32 -4
  108. package/dist/components/numpad-sheet.js +30 -13
  109. package/dist/components/numpad-sheet.js.map +1 -1
  110. package/dist/components/page-contents.d.ts +90 -0
  111. package/dist/components/page-contents.js +170 -0
  112. package/dist/components/page-contents.js.map +1 -0
  113. package/dist/components/password-strength.d.ts +106 -0
  114. package/dist/components/password-strength.js +132 -0
  115. package/dist/components/password-strength.js.map +1 -0
  116. package/dist/components/picker-sheet.d.ts +23 -15
  117. package/dist/components/picker-sheet.js +34 -14
  118. package/dist/components/picker-sheet.js.map +1 -1
  119. package/dist/components/popover.d.ts +62 -4
  120. package/dist/components/popover.js +52 -2
  121. package/dist/components/popover.js.map +1 -1
  122. package/dist/components/search-field.d.ts +25 -7
  123. package/dist/components/search-field.js +7 -5
  124. package/dist/components/search-field.js.map +1 -1
  125. package/dist/components/series-chart-labels.d.ts +14 -0
  126. package/dist/components/series-chart-labels.js +11 -0
  127. package/dist/components/series-chart-labels.js.map +1 -0
  128. package/dist/components/series-chart.d.ts +207 -0
  129. package/dist/components/series-chart.js +223 -0
  130. package/dist/components/series-chart.js.map +1 -0
  131. package/dist/components/settings-fields.d.ts +14 -4
  132. package/dist/components/settings-fields.js +19 -8
  133. package/dist/components/settings-fields.js.map +1 -1
  134. package/dist/components/signature-pad.d.ts +119 -0
  135. package/dist/components/signature-pad.js +405 -0
  136. package/dist/components/signature-pad.js.map +1 -0
  137. package/dist/components/slider.d.ts +80 -0
  138. package/dist/components/slider.js +155 -0
  139. package/dist/components/slider.js.map +1 -0
  140. package/dist/components/sparkline.d.ts +93 -0
  141. package/dist/components/sparkline.js +205 -0
  142. package/dist/components/sparkline.js.map +1 -0
  143. package/dist/components/stat-tile.d.ts +151 -0
  144. package/dist/components/stat-tile.js +236 -0
  145. package/dist/components/stat-tile.js.map +1 -0
  146. package/dist/components/swipeable-row.d.ts +25 -2
  147. package/dist/components/swipeable-row.js +31 -0
  148. package/dist/components/swipeable-row.js.map +1 -1
  149. package/dist/components/switch.d.ts +60 -0
  150. package/dist/components/switch.js +81 -0
  151. package/dist/components/switch.js.map +1 -0
  152. package/dist/components/time-input.d.ts +58 -0
  153. package/dist/components/time-input.js +50 -0
  154. package/dist/components/time-input.js.map +1 -0
  155. package/dist/components/toggle-group.d.ts +18 -3
  156. package/dist/components/toggle-group.js +10 -6
  157. package/dist/components/toggle-group.js.map +1 -1
  158. package/dist/components/toggle-legend.d.ts +115 -0
  159. package/dist/components/toggle-legend.js +114 -0
  160. package/dist/components/toggle-legend.js.map +1 -0
  161. package/dist/components/tooltip.d.ts +40 -11
  162. package/dist/components/tooltip.js +77 -20
  163. package/dist/components/tooltip.js.map +1 -1
  164. package/dist/components/treemap.d.ts +132 -0
  165. package/dist/components/treemap.js +179 -0
  166. package/dist/components/treemap.js.map +1 -0
  167. package/dist/components/ui.d.ts +159 -46
  168. package/dist/components/ui.js +220 -115
  169. package/dist/components/ui.js.map +1 -1
  170. package/dist/components/use-mobile-reveal.js +1 -0
  171. package/dist/components/use-mobile-reveal.js.map +1 -1
  172. package/dist/components/use-table-state.d.ts +1 -1
  173. package/dist/components/use-table-state.js +38 -17
  174. package/dist/components/use-table-state.js.map +1 -1
  175. package/dist/components/user-avatar.d.ts +15 -4
  176. package/dist/components/user-avatar.js +5 -4
  177. package/dist/components/user-avatar.js.map +1 -1
  178. package/dist/components/wizard-stepper.js +3 -2
  179. package/dist/components/wizard-stepper.js.map +1 -1
  180. package/dist/{data-table-filters-aXyf0Xub.d.ts → data-table-filters-DM1auUfH.d.ts} +27 -5
  181. package/dist/data-table.d.ts +7 -0
  182. package/dist/data-table.js +12 -0
  183. package/dist/data-table.js.map +1 -0
  184. package/dist/feedback/feedback-attachment.js +12 -11
  185. package/dist/feedback/feedback-attachment.js.map +1 -1
  186. package/dist/feedback/feedback-dialog.js +2 -1
  187. package/dist/feedback/feedback-dialog.js.map +1 -1
  188. package/dist/feedback/feedback-inbox.d.ts +33 -7
  189. package/dist/feedback/feedback-inbox.js +49 -37
  190. package/dist/feedback/feedback-inbox.js.map +1 -1
  191. package/dist/feedback.d.ts +5 -0
  192. package/dist/feedback.js +5 -0
  193. package/dist/feedback.js.map +1 -0
  194. package/dist/hooks/use-anchored-panel.js +1 -0
  195. package/dist/hooks/use-anchored-panel.js.map +1 -1
  196. package/dist/hooks/use-anchored-rect.js +1 -0
  197. package/dist/hooks/use-anchored-rect.js.map +1 -1
  198. package/dist/hooks/use-announce.d.ts +46 -0
  199. package/dist/hooks/use-announce.js +37 -0
  200. package/dist/hooks/use-announce.js.map +1 -0
  201. package/dist/hooks/use-body-scroll-lock.js +1 -0
  202. package/dist/hooks/use-body-scroll-lock.js.map +1 -1
  203. package/dist/hooks/use-close-transition.js +1 -0
  204. package/dist/hooks/use-close-transition.js.map +1 -1
  205. package/dist/hooks/use-dismiss.js +1 -0
  206. package/dist/hooks/use-dismiss.js.map +1 -1
  207. package/dist/hooks/use-focus-trap.d.ts +16 -0
  208. package/dist/hooks/use-focus-trap.js +67 -0
  209. package/dist/hooks/use-focus-trap.js.map +1 -0
  210. package/dist/hooks/use-media-query.js +1 -0
  211. package/dist/hooks/use-media-query.js.map +1 -1
  212. package/dist/hooks/use-overlay-history.js +27 -1
  213. package/dist/hooks/use-overlay-history.js.map +1 -1
  214. package/dist/hooks/use-row-swipe.js +1 -0
  215. package/dist/hooks/use-row-swipe.js.map +1 -1
  216. package/dist/i18n/defaults.d.ts +31 -0
  217. package/dist/i18n/defaults.js +63 -0
  218. package/dist/i18n/defaults.js.map +1 -0
  219. package/dist/i18n/kit-labels.d.ts +250 -0
  220. package/dist/i18n/kit-labels.js +184 -0
  221. package/dist/i18n/kit-labels.js.map +1 -0
  222. package/dist/index.d.ts +47 -22
  223. package/dist/index.js +28 -0
  224. package/dist/index.js.map +1 -1
  225. package/dist/lib/calc.js +1 -0
  226. package/dist/lib/calc.js.map +1 -1
  227. package/dist/lib/cn.js +1 -0
  228. package/dist/lib/cn.js.map +1 -1
  229. package/dist/lib/dates.js +1 -0
  230. package/dist/lib/dates.js.map +1 -1
  231. package/dist/lib/logger.js +1 -0
  232. package/dist/lib/logger.js.map +1 -1
  233. package/dist/lib/safe-storage.js +1 -0
  234. package/dist/lib/safe-storage.js.map +1 -1
  235. package/dist/search/command-palette.d.ts +11 -1
  236. package/dist/search/command-palette.js +21 -15
  237. package/dist/search/command-palette.js.map +1 -1
  238. package/dist/search.d.ts +2 -0
  239. package/dist/search.js +3 -0
  240. package/dist/search.js.map +1 -0
  241. package/dist/shell/app-shell.d.ts +33 -4
  242. package/dist/shell/app-shell.js +275 -78
  243. package/dist/shell/app-shell.js.map +1 -1
  244. package/dist/shell/option-switcher-menu.js +3 -2
  245. package/dist/shell/option-switcher-menu.js.map +1 -1
  246. package/dist/shell/role-switcher.d.ts +6 -0
  247. package/dist/shell/role-switcher.js +7 -4
  248. package/dist/shell/role-switcher.js.map +1 -1
  249. package/dist/shell/top-bar.d.ts +15 -7
  250. package/dist/shell/top-bar.js +3 -5
  251. package/dist/shell/top-bar.js.map +1 -1
  252. package/dist/shell/topbar-action-menu.js +3 -2
  253. package/dist/shell/topbar-action-menu.js.map +1 -1
  254. package/dist/shell/topbar-controls.d.ts +5 -2
  255. package/dist/shell/topbar-controls.js +20 -15
  256. package/dist/shell/topbar-controls.js.map +1 -1
  257. package/dist/shell.d.ts +13 -0
  258. package/dist/shell.js +8 -0
  259. package/dist/shell.js.map +1 -0
  260. package/dist/theme/chart-palette.d.ts +19 -12
  261. package/dist/theme/chart-palette.js +24 -30
  262. package/dist/theme/chart-palette.js.map +1 -1
  263. package/dist/theme/color.d.ts +78 -0
  264. package/dist/theme/color.js +148 -0
  265. package/dist/theme/color.js.map +1 -0
  266. package/dist/theme/palette-derive.d.ts +140 -0
  267. package/dist/theme/palette-derive.js +376 -0
  268. package/dist/theme/palette-derive.js.map +1 -0
  269. package/dist/theme/palette-presets.d.ts +27 -5
  270. package/dist/theme/palette-presets.js +231 -33
  271. package/dist/theme/palette-presets.js.map +1 -1
  272. package/dist/theme/palette-store.js +1 -0
  273. package/dist/theme/palette-store.js.map +1 -1
  274. package/dist/theme/theme-store.js +1 -0
  275. package/dist/theme/theme-store.js.map +1 -1
  276. package/dist/tour/tour.d.ts +2 -1
  277. package/dist/tour/tour.js +15 -9
  278. package/dist/tour/tour.js.map +1 -1
  279. package/dist/tour.d.ts +2 -0
  280. package/dist/tour.js +3 -0
  281. package/dist/tour.js.map +1 -0
  282. package/dist/wizard/stepper-nav.js +10 -8
  283. package/dist/wizard/stepper-nav.js.map +1 -1
  284. package/dist/wizard/types.d.ts +18 -2
  285. package/dist/wizard/types.js +4 -1
  286. package/dist/wizard/types.js.map +1 -1
  287. package/dist/wizard/use-wizard.js +12 -4
  288. package/dist/wizard/use-wizard.js.map +1 -1
  289. package/dist/wizard/validation.js +1 -0
  290. package/dist/wizard/validation.js.map +1 -1
  291. package/dist/wizard/wizard-context.js +1 -0
  292. package/dist/wizard/wizard-context.js.map +1 -1
  293. package/dist/wizard/wizard-step.js +1 -0
  294. package/dist/wizard/wizard-step.js.map +1 -1
  295. package/dist/wizard/wizard-summary.js +5 -3
  296. package/dist/wizard/wizard-summary.js.map +1 -1
  297. package/dist/wizard.d.ts +8 -0
  298. package/dist/wizard.js +9 -0
  299. package/dist/wizard.js.map +1 -0
  300. package/package.json +67 -14
  301. package/src/chart.ts +22 -0
  302. package/src/components/account-settings.tsx +275 -0
  303. package/src/components/alert-banner.tsx +75 -0
  304. package/src/components/amount-input.tsx +471 -0
  305. package/src/components/calculator.tsx +252 -0
  306. package/src/components/chart-zoom.tsx +499 -0
  307. package/src/components/chart.tsx +363 -0
  308. package/src/components/checkbox.tsx +222 -0
  309. package/src/components/chip.tsx +570 -0
  310. package/src/components/choice-parts.ts +33 -0
  311. package/src/components/combobox-core.tsx +540 -0
  312. package/src/components/combobox.tsx +1026 -0
  313. package/src/components/currency-select.tsx +269 -0
  314. package/src/components/data-table-filter-popover.tsx +252 -0
  315. package/src/components/data-table-filters.ts +176 -0
  316. package/src/components/data-table-labels.ts +190 -0
  317. package/src/components/data-table-pagination.tsx +126 -0
  318. package/src/components/data-table-sort.ts +70 -0
  319. package/src/components/data-table.tsx +1740 -0
  320. package/src/components/date-picker.tsx +631 -0
  321. package/src/components/dropdown.tsx +341 -0
  322. package/src/components/entity-combobox.tsx +215 -0
  323. package/src/components/facing-pair.ts +86 -0
  324. package/src/components/field-sync.tsx +472 -0
  325. package/src/components/file-dropzone.tsx +181 -0
  326. package/src/components/full-bleed-dialog.tsx +179 -0
  327. package/src/components/grouped-picker.tsx +157 -0
  328. package/src/components/hover-menu.tsx +183 -0
  329. package/src/components/mini-calendar.tsx +554 -0
  330. package/src/components/modal.tsx +287 -0
  331. package/src/components/month-picker.tsx +549 -0
  332. package/src/components/multi-entity-combobox.tsx +226 -0
  333. package/src/components/multi-select.tsx +303 -0
  334. package/src/components/number-field.tsx +225 -0
  335. package/src/components/number-input.tsx +211 -0
  336. package/src/components/numpad-sheet.tsx +264 -0
  337. package/src/components/page-contents.tsx +289 -0
  338. package/src/components/password-strength.tsx +258 -0
  339. package/src/components/picker-sheet.tsx +251 -0
  340. package/src/components/popover.tsx +195 -0
  341. package/src/components/search-field.tsx +146 -0
  342. package/src/components/series-chart-labels.ts +26 -0
  343. package/src/components/series-chart.tsx +481 -0
  344. package/src/components/settings-fields.tsx +68 -0
  345. package/src/components/signature-pad.tsx +630 -0
  346. package/src/components/slider.tsx +313 -0
  347. package/src/components/sparkline.tsx +329 -0
  348. package/src/components/stat-tile.tsx +448 -0
  349. package/src/components/swipeable-row.tsx +271 -0
  350. package/src/components/switch.tsx +170 -0
  351. package/src/components/time-input.tsx +107 -0
  352. package/src/components/toggle-group.tsx +153 -0
  353. package/src/components/toggle-legend.tsx +229 -0
  354. package/src/components/tooltip.tsx +477 -0
  355. package/src/components/treemap.tsx +390 -0
  356. package/src/components/ui.tsx +1284 -0
  357. package/src/components/use-mobile-reveal.ts +54 -0
  358. package/src/components/use-table-state.ts +295 -0
  359. package/src/components/user-avatar.tsx +61 -0
  360. package/src/components/wizard-stepper.tsx +37 -0
  361. package/src/data-table.ts +19 -0
  362. package/src/feedback/feedback-attachment.tsx +246 -0
  363. package/src/feedback/feedback-dialog.tsx +209 -0
  364. package/src/feedback/feedback-inbox.tsx +615 -0
  365. package/src/feedback.ts +8 -0
  366. package/src/hooks/use-anchored-panel.ts +169 -0
  367. package/src/hooks/use-anchored-rect.ts +78 -0
  368. package/src/hooks/use-announce.ts +85 -0
  369. package/src/hooks/use-body-scroll-lock.ts +94 -0
  370. package/src/hooks/use-close-transition.ts +99 -0
  371. package/src/hooks/use-dismiss.ts +77 -0
  372. package/src/hooks/use-focus-trap.ts +160 -0
  373. package/src/hooks/use-media-query.ts +23 -0
  374. package/src/hooks/use-overlay-history.ts +345 -0
  375. package/src/hooks/use-row-swipe.ts +212 -0
  376. package/src/i18n/defaults.ts +68 -0
  377. package/src/i18n/kit-labels.tsx +447 -0
  378. package/src/index.ts +169 -0
  379. package/src/lib/calc.ts +309 -0
  380. package/src/lib/cn.ts +5 -0
  381. package/src/lib/dates.ts +187 -0
  382. package/src/lib/logger.ts +73 -0
  383. package/src/lib/safe-storage.ts +37 -0
  384. package/src/search/command-palette.tsx +342 -0
  385. package/src/search.ts +3 -0
  386. package/src/shell/app-shell.tsx +772 -0
  387. package/src/shell/option-switcher-menu.tsx +72 -0
  388. package/src/shell/role-switcher.tsx +46 -0
  389. package/src/shell/top-bar.tsx +39 -0
  390. package/src/shell/topbar-action-menu.tsx +91 -0
  391. package/src/shell/topbar-controls.tsx +229 -0
  392. package/src/shell.ts +10 -0
  393. package/src/theme/chart-palette.ts +145 -0
  394. package/src/theme/color.ts +266 -0
  395. package/src/theme/palette-derive.ts +681 -0
  396. package/src/theme/palette-presets.ts +760 -0
  397. package/src/theme/palette-store.ts +94 -0
  398. package/src/theme/theme-store.ts +145 -0
  399. package/src/tour/tour.tsx +586 -0
  400. package/src/tour.ts +3 -0
  401. package/src/vite-env.d.ts +26 -0
  402. package/src/wizard/stepper-nav.tsx +221 -0
  403. package/src/wizard/types.ts +178 -0
  404. package/src/wizard/use-wizard.ts +345 -0
  405. package/src/wizard/validation.ts +37 -0
  406. package/src/wizard/wizard-context.tsx +29 -0
  407. package/src/wizard/wizard-step.tsx +14 -0
  408. package/src/wizard/wizard-summary.tsx +69 -0
  409. package/src/wizard.ts +14 -0
  410. package/tokens.css +222 -20
@@ -0,0 +1,72 @@
1
+ import type { ReactNode } from "react";
2
+ import { Check } from "lucide-react";
3
+ import { HoverMenu } from "../components/hover-menu";
4
+ import { TOPBAR_MENU_ITEM_CLASS, TOPBAR_TRIGGER_CLASS } from "./topbar-controls";
5
+ import { Tooltip } from "../components/tooltip";
6
+
7
+ export interface OptionSwitcherOption<T extends string> {
8
+ value: T;
9
+ label: ReactNode;
10
+ }
11
+
12
+ /**
13
+ * A top-bar hover menu that switches between a small set of options, with a
14
+ * check on the active one and an optional heading. Generic glue over
15
+ * {@link HoverMenu} — e.g. a dev role/impersonation switcher, an environment
16
+ * picker. Controlled: the app owns `value` and `onSelect`.
17
+ */
18
+ export function OptionSwitcherMenu<T extends string>({
19
+ icon,
20
+ ariaLabel,
21
+ title,
22
+ heading,
23
+ options,
24
+ value,
25
+ onSelect,
26
+ }: {
27
+ icon: ReactNode;
28
+ ariaLabel: string;
29
+ title?: string;
30
+ heading?: string;
31
+ options: OptionSwitcherOption<T>[];
32
+ value: T;
33
+ onSelect: (value: T) => void;
34
+ }) {
35
+ return (
36
+ <HoverMenu
37
+ ariaLabel={ariaLabel}
38
+ trigger={({ toggle }) => (
39
+ <Tooltip label={title} portal>
40
+ <button type="button" onClick={toggle} aria-label={ariaLabel} className={TOPBAR_TRIGGER_CLASS}>
41
+ {icon}
42
+ </button>
43
+ </Tooltip>
44
+ )}
45
+ >
46
+ {(close) => (
47
+ <ul className="py-1">
48
+ {heading && (
49
+ <li className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--text-placeholder)]">
50
+ {heading}
51
+ </li>
52
+ )}
53
+ {options.map((opt) => (
54
+ <li key={opt.value}>
55
+ <button
56
+ type="button"
57
+ onClick={() => {
58
+ onSelect(opt.value);
59
+ close();
60
+ }}
61
+ className={TOPBAR_MENU_ITEM_CLASS}
62
+ >
63
+ <span>{opt.label}</span>
64
+ {value === opt.value && <Check className="size-4 text-[var(--text-secondary)]" />}
65
+ </button>
66
+ </li>
67
+ ))}
68
+ </ul>
69
+ )}
70
+ </HoverMenu>
71
+ );
72
+ }
@@ -0,0 +1,46 @@
1
+ import { FlaskConical } from "lucide-react";
2
+ import { OptionSwitcherMenu } from "./option-switcher-menu";
3
+ import { DEFAULT_TOP_BAR_LABELS, useKitLabels } from "../i18n/kit-labels";
4
+
5
+ export interface RoleSwitcherProps<R extends string> {
6
+ roles: readonly R[];
7
+ value: R;
8
+ onChange: (role: R) => void;
9
+ /** Default: `topBar.switchRole` from the {@link UiKitProvider}, else English. */
10
+ ariaLabel?: string;
11
+ heading?: string;
12
+ /** The trigger's tooltip. Default: `topBar.role(value)`. */
13
+ title?: string;
14
+ }
15
+
16
+ /**
17
+ * A reusable user-role switcher (feedback #337): a thin wrapper over
18
+ * {@link OptionSwitcherMenu} with the standard flask icon, so an app drops it in
19
+ * the top bar with just the role list + current value + a handler. Typically
20
+ * dev-only / UI-only, but the app decides where and whether to render it.
21
+ *
22
+ * The flask wears `--warning` rather than a fixed `amber-500`: it is a caution
23
+ * marker — the app is not showing you your own role — and the token carries that
24
+ * amber across both themes instead of pinning one hue onto every palette.
25
+ */
26
+ export function RoleSwitcher<R extends string>({
27
+ roles,
28
+ value,
29
+ onChange,
30
+ ariaLabel,
31
+ heading,
32
+ title,
33
+ }: RoleSwitcherProps<R>) {
34
+ const labels = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS, { switchRole: ariaLabel });
35
+ return (
36
+ <OptionSwitcherMenu<R>
37
+ ariaLabel={labels.switchRole}
38
+ title={title ?? labels.role(value)}
39
+ heading={heading}
40
+ icon={<FlaskConical className="size-5 text-[var(--warning)]" />}
41
+ options={roles.map((r) => ({ value: r, label: r }))}
42
+ value={value}
43
+ onSelect={onChange}
44
+ />
45
+ );
46
+ }
@@ -0,0 +1,39 @@
1
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
2
+ import { cn } from "../lib/cn";
3
+
4
+ /**
5
+ * `extends ComponentPropsWithoutRef<"header">` because this IS the `<header>`, and it is
6
+ * the `sticky top-0` element itself: a consumer cannot wrap it to add an attribute
7
+ * without the sticking moving to the wrapper. The one that matters most is `aria-label`
8
+ * — a `<header>` is a `banner` landmark, and a landmark with no name is how two of them
9
+ * become indistinguishable in a screen reader's landmark list.
10
+ */
11
+ export interface TopBarProps extends ComponentPropsWithoutRef<"header"> {
12
+ brand: ReactNode;
13
+ actions?: ReactNode;
14
+ className?: string;
15
+ }
16
+
17
+ /**
18
+ * The sticky app header frame: a `brand` slot on the left and an `actions` slot
19
+ * on the right (theme/palette/language controls, account menu, etc.). Domain-free
20
+ * — each app composes its own actions from the shared controls plus its own menus.
21
+ */
22
+ export function TopBar({ brand, actions, className, ...rest }: TopBarProps) {
23
+ return (
24
+ <header
25
+ {...rest}
26
+ className={cn(
27
+ // z-40 keeps the app header above sticky page content — notably the
28
+ // expanded transaction editor (z-30), which on mobile shares the page
29
+ // scroll and otherwise slid over the header while scrolling (feedback
30
+ // #320). Modals/tour (z-50/60) still sit above it.
31
+ "sticky top-0 z-40 flex h-12 items-center justify-between gap-2 border-b border-[var(--border)] bg-[var(--bg-surface)] px-3 md:px-6",
32
+ className,
33
+ )}
34
+ >
35
+ <div className="flex min-w-0 items-center gap-2">{brand}</div>
36
+ <div className="flex items-center gap-1">{actions}</div>
37
+ </header>
38
+ );
39
+ }
@@ -0,0 +1,91 @@
1
+ import type { ReactNode } from "react";
2
+ import { Link } from "react-router";
3
+ import { cn } from "../lib/cn";
4
+ import { HoverMenu } from "../components/hover-menu";
5
+ import { TOPBAR_MENU_ITEM_CLASS, TOPBAR_TRIGGER_CLASS } from "./topbar-controls";
6
+
7
+ /**
8
+ * One row of a {@link TopBarActionMenu}. An `action` (default) runs `onSelect`, a
9
+ * `link` navigates via react-router, and a `divider` draws a rule. Non-divider
10
+ * rows close the menu automatically when chosen. `kind` may be omitted for
11
+ * actions, keeping the common case terse.
12
+ */
13
+ export type TopBarMenuEntry =
14
+ | { kind?: "action"; key: string; icon?: ReactNode; label: ReactNode; trailing?: ReactNode; onSelect: () => void }
15
+ | { kind: "link"; key: string; icon?: ReactNode; label: ReactNode; trailing?: ReactNode; to: string }
16
+ | { kind: "divider"; key: string };
17
+
18
+ /**
19
+ * A top-bar icon button that opens a {@link HoverMenu} of actions and/or links,
20
+ * with an optional uppercase heading and divider rules. Generic glue over the
21
+ * shared top-bar chrome — e.g. the feedback launcher or the guided-tours menu —
22
+ * so those call sites describe their rows as data instead of rebuilding the
23
+ * trigger + list markup. For a checkmarked value-switcher use
24
+ * {@link OptionSwitcherMenu} instead.
25
+ */
26
+ export function TopBarActionMenu({
27
+ icon,
28
+ ariaLabel,
29
+ heading,
30
+ entries,
31
+ panelClassName,
32
+ }: {
33
+ icon: ReactNode;
34
+ ariaLabel: string;
35
+ heading?: string;
36
+ entries: TopBarMenuEntry[];
37
+ panelClassName?: string;
38
+ }) {
39
+ return (
40
+ <HoverMenu
41
+ ariaLabel={ariaLabel}
42
+ trigger={({ toggle }) => (
43
+ <button type="button" onClick={toggle} aria-label={ariaLabel} className={TOPBAR_TRIGGER_CLASS}>
44
+ {icon}
45
+ </button>
46
+ )}
47
+ >
48
+ {(close) => (
49
+ <ul className={cn("py-1", panelClassName)}>
50
+ {heading && (
51
+ <li className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--text-placeholder)]">
52
+ {heading}
53
+ </li>
54
+ )}
55
+ {entries.map((entry) => {
56
+ if (entry.kind === "divider") {
57
+ return <li key={entry.key} className="my-1 border-t border-[var(--border)]" />;
58
+ }
59
+ const content = (
60
+ <span className="flex w-full items-center gap-2">
61
+ {entry.icon}
62
+ {entry.label}
63
+ {entry.trailing && <span className="ml-auto pl-2">{entry.trailing}</span>}
64
+ </span>
65
+ );
66
+ return (
67
+ <li key={entry.key}>
68
+ {entry.kind === "link" ? (
69
+ <Link to={entry.to} onClick={close} className={TOPBAR_MENU_ITEM_CLASS}>
70
+ {content}
71
+ </Link>
72
+ ) : (
73
+ <button
74
+ type="button"
75
+ onClick={() => {
76
+ entry.onSelect();
77
+ close();
78
+ }}
79
+ className={TOPBAR_MENU_ITEM_CLASS}
80
+ >
81
+ {content}
82
+ </button>
83
+ )}
84
+ </li>
85
+ );
86
+ })}
87
+ </ul>
88
+ )}
89
+ </HoverMenu>
90
+ );
91
+ }
@@ -0,0 +1,229 @@
1
+ import { Check, Globe, Moon, Palette, Sun } from "lucide-react";
2
+ import { cn } from "../lib/cn";
3
+ import { HoverMenu } from "../components/hover-menu";
4
+ import type { PalettePreset } from "../theme/palette-presets";
5
+ import type { ThemeMode } from "../theme/theme-store";
6
+ import { Tooltip } from "../components/tooltip";
7
+ import { DEFAULT_TOP_BAR_LABELS, useKitLabels } from "../i18n/kit-labels";
8
+
9
+ /** Shared square icon-button styling for top-bar triggers, so app-owned controls
10
+ * (account/feedback menus) line up with the shared ones. */
11
+ export const TOPBAR_TRIGGER_CLASS =
12
+ "size-9 rounded-md flex items-center justify-center text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-hover)]";
13
+
14
+ /** Shared row styling for the top-bar hover-menu items. */
15
+ export const TOPBAR_MENU_ITEM_CLASS =
16
+ "flex w-full items-center justify-between gap-3 px-3 py-2 text-sm text-left text-[var(--text-secondary)] hover:bg-[var(--bg-hover)]";
17
+
18
+ /** Sun/Moon toggle for the light/dark theme. Controlled: the app owns the mode. */
19
+ export function ThemeToggle({
20
+ mode,
21
+ onToggle,
22
+ ariaLabel,
23
+ title,
24
+ className,
25
+ }: {
26
+ mode: ThemeMode;
27
+ onToggle: () => void;
28
+ /** Default: `topBar.theme` from the {@link UiKitProvider}, else English. */
29
+ ariaLabel?: string;
30
+ title?: string;
31
+ className?: string;
32
+ }) {
33
+ // This had no default at all, so a toggle rendered without `ariaLabel` was an
34
+ // icon button with no name — a reader heard "button" and nothing else.
35
+ const labels = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS, { theme: ariaLabel });
36
+ const Icon = mode === "dark" ? Moon : Sun;
37
+ return (
38
+ <Tooltip label={title} portal>
39
+ <button
40
+ type="button"
41
+ onClick={onToggle}
42
+ aria-label={labels.theme}
43
+ className={cn(TOPBAR_TRIGGER_CLASS, className)}
44
+ >
45
+ <Icon className="size-5" />
46
+ </button>
47
+ </Tooltip>
48
+ );
49
+ }
50
+
51
+ /** Appearance-preset switcher: a hover menu of palette presets with per-theme
52
+ * colour swatches. Controlled — the app owns the active id and applies it. */
53
+ export function PaletteMenu({
54
+ palettes,
55
+ activeId,
56
+ mode,
57
+ onSelect,
58
+ ariaLabel,
59
+ heading,
60
+ }: {
61
+ palettes: PalettePreset[];
62
+ activeId: string;
63
+ mode: ThemeMode;
64
+ onSelect: (id: string) => void;
65
+ /** Default: `topBar.palette` from the {@link UiKitProvider}, else English. */
66
+ ariaLabel?: string;
67
+ /** Optional small heading row above the list. */
68
+ heading?: string;
69
+ }) {
70
+ const labels = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS, { palette: ariaLabel });
71
+ const activeName = palettes.find((p) => p.id === activeId)?.name ?? activeId;
72
+ return (
73
+ <HoverMenu
74
+ ariaLabel={labels.palette}
75
+ trigger={({ toggle }) => (
76
+ <Tooltip label={activeName} portal>
77
+ <button
78
+ type="button"
79
+ onClick={toggle}
80
+ aria-label={labels.palette}
81
+ className={TOPBAR_TRIGGER_CLASS}
82
+ >
83
+ {/* A DATA colour, not a semantic one: the palette mark is the
84
+ "colour" control, so it carries a colour, and which hue is
85
+ arbitrary. `--chart-1` is the violet end of the CVD-safe
86
+ categorical ramp and flips per theme — a fixed `violet-500` did
87
+ not, and stayed a cool violet island on a warm page. */}
88
+ <Palette className="size-5 text-[var(--chart-1)]" />
89
+ </button>
90
+ </Tooltip>
91
+ )}
92
+ >
93
+ {(close) => (
94
+ <ul className="w-60 py-1">
95
+ {heading && (
96
+ <li className="px-3 py-1 text-[10px] font-semibold uppercase tracking-wide text-[var(--text-placeholder)]">
97
+ {heading}
98
+ </li>
99
+ )}
100
+ {palettes.map((p) => {
101
+ const ts = p[mode];
102
+ const swatches = [ts.bgPage, ts.brand, ts.moneyIncome, ts.moneyExpense, ts.moneyNet];
103
+ return (
104
+ <li key={p.id}>
105
+ <button
106
+ type="button"
107
+ onClick={() => {
108
+ onSelect(p.id);
109
+ close();
110
+ }}
111
+ className={TOPBAR_MENU_ITEM_CLASS}
112
+ >
113
+ <span className="flex min-w-0 items-center gap-2">
114
+ <span className="flex shrink-0 gap-0.5">
115
+ {/* Deliberately a neutral at 10% and not a token: this hairline
116
+ sits on an ARBITRARY preset colour, so it has to read against
117
+ whatever that preset paints, which no themed border does. */}
118
+ {swatches.map((c, i) => (
119
+ <span
120
+ key={i}
121
+ className="size-3.5 rounded-sm ring-1 ring-inset ring-black/10 dark:ring-white/10"
122
+ style={{ background: c }}
123
+ />
124
+ ))}
125
+ </span>
126
+ <span className="flex min-w-0 flex-col text-left">
127
+ <span className="truncate">{p.name}</span>
128
+ <span className="truncate text-[11px] text-[var(--text-placeholder)]">
129
+ {p.blurb}
130
+ </span>
131
+ </span>
132
+ </span>
133
+ {activeId === p.id && (
134
+ <Check className="size-4 shrink-0 text-[var(--text-secondary)]" />
135
+ )}
136
+ </button>
137
+ </li>
138
+ );
139
+ })}
140
+ </ul>
141
+ )}
142
+ </HoverMenu>
143
+ );
144
+ }
145
+
146
+ export interface LanguageOption {
147
+ code: string;
148
+ label: string;
149
+ /** ISO 3166-1 alpha-2 country code (lowercase) for the flag-icons flag. */
150
+ country: string;
151
+ }
152
+
153
+ /** Language switcher: a hover menu of options with flags and a check on the
154
+ * active one. Controlled — the app owns the current code and change handler.
155
+ *
156
+ * The trigger is the *active* option's flag, not an icon: lucide's `Languages`
157
+ * glyph (文A) is the same mark Chrome puts in the omnibox for "translate this
158
+ * page", so it read as machine-translation rather than "pick the UI language"
159
+ * (Keksdose feedback #121). The flag also shows which language is currently on,
160
+ * which no icon can, and costs the same width on a crowded mobile bar. A globe
161
+ * covers the case where `current` matches nothing in `options`. */
162
+ export function LanguageMenu({
163
+ options,
164
+ current,
165
+ onChange,
166
+ ariaLabel,
167
+ }: {
168
+ options: LanguageOption[];
169
+ current: string | undefined;
170
+ onChange: (code: string) => void;
171
+ /** Default: `topBar.language` from the {@link UiKitProvider}, else English. */
172
+ ariaLabel?: string;
173
+ }) {
174
+ const labels = useKitLabels("topBar", DEFAULT_TOP_BAR_LABELS, { language: ariaLabel });
175
+ const active = options.find((o) => o.code === current);
176
+ return (
177
+ <HoverMenu
178
+ ariaLabel={labels.language}
179
+ trigger={({ toggle }) => (
180
+ <Tooltip label={active?.label ?? labels.language} portal>
181
+ <button
182
+ type="button"
183
+ onClick={toggle}
184
+ aria-label={labels.language}
185
+ className={TOPBAR_TRIGGER_CLASS}
186
+ >
187
+ {active ? (
188
+ <span
189
+ aria-hidden
190
+ className={`fi fi-${active.country} inline-block h-[15px] w-5 shrink-0 rounded-sm shadow-[0_0_0_1px_rgba(0,0,0,0.08)]`}
191
+ />
192
+ ) : (
193
+ <Globe className="size-5" />
194
+ )}
195
+ </button>
196
+ </Tooltip>
197
+ )}
198
+ >
199
+ {(close) => (
200
+ <ul className="py-1">
201
+ {options.map((lang) => {
202
+ const isActive = lang.code === current;
203
+ return (
204
+ <li key={lang.code}>
205
+ <button
206
+ type="button"
207
+ onClick={() => {
208
+ onChange(lang.code);
209
+ close();
210
+ }}
211
+ className={TOPBAR_MENU_ITEM_CLASS}
212
+ >
213
+ <span className="flex items-center gap-2">
214
+ <span
215
+ aria-hidden
216
+ className={`fi fi-${lang.country} inline-block h-[15px] w-5 shrink-0 rounded-sm shadow-[0_0_0_1px_rgba(0,0,0,0.08)]`}
217
+ />
218
+ {lang.label}
219
+ </span>
220
+ {isActive && <Check className="size-4 text-[var(--text-secondary)]" />}
221
+ </button>
222
+ </li>
223
+ );
224
+ })}
225
+ </ul>
226
+ )}
227
+ </HoverMenu>
228
+ );
229
+ }
package/src/shell.ts ADDED
@@ -0,0 +1,10 @@
1
+ // `@eifi1/ui-kit/shell` — the composable app chrome.
2
+ //
3
+ // A re-slicing of the main barrel, not a new API. This entry point requires
4
+ // `react-router`: AppShell renders NavLink and TopBarActionMenu renders Link.
5
+ export * from "./shell/topbar-controls";
6
+ export * from "./shell/top-bar";
7
+ export * from "./shell/app-shell";
8
+ export * from "./shell/option-switcher-menu";
9
+ export * from "./shell/role-switcher";
10
+ export * from "./shell/topbar-action-menu";
@@ -0,0 +1,145 @@
1
+ // Chart colour system. Single source of truth lives in the design tokens
2
+ // (:root / .dark in tokens.css): brand = indigo, money = teal/amber/purple,
3
+ // chart-1..9 = Paul Tol "Muted" (CVD-safe across protan/deutan/tritan).
4
+ // Returning CSS vars here makes every Recharts series theme-aware for free — the
5
+ // SVG fill/stroke resolves --chart-N against the current light/dark token set.
6
+
7
+ import { contrast, parseHex as parseRgbHex } from "./color";
8
+
9
+ const PALETTE_SIZE = 9;
10
+
11
+ /** CVD-safe categorical colour for series `index`, as a theme-aware CSS var. */
12
+ export function paletteFor(index: number): string {
13
+ return `var(--chart-${(index % PALETTE_SIZE) + 1})`;
14
+ }
15
+
16
+ /** Semantic money colours, theme-aware via tokens. Pair with +/- and arrow
17
+ cues at the call site so colour is never the only signal (CVD). */
18
+ export const CHART_COLORS = {
19
+ income: "var(--money-income)",
20
+ expense: "var(--money-expense)",
21
+ net: "var(--money-net)",
22
+ assigned: "var(--brand)",
23
+ activity: "var(--money-expense)",
24
+ } as const;
25
+
26
+ // ── Raw hex stops, by theme ────────────────────────────────────────────────
27
+ // The heatmaps interpolate colours in JS (CSS vars can't be lerped), so they
28
+ // need concrete hex per theme — i.e. the same colours DEFAULT_PRESET already
29
+ // holds, written down a second time.
30
+ //
31
+ // They are duplicated rather than imported because these two objects are the whole
32
+ // of the `@eifi1/ui-kit/chart` subpath's colour layer, and importing the preset bank
33
+ // would pull every preset's full token set into that chunk to read two of them. What
34
+ // keeps the copies together is `chart-palette.test.ts` — "has not drifted from
35
+ // DEFAULT_PRESET" — which is not optional: they HAD drifted, five of the six light
36
+ // heat stops, and the light `neutral` was still Tailwind's slate-100, a cool grey on
37
+ // the warm cream page this theme has used since #328.
38
+ export const PALETTE_HEX = {
39
+ light: ["#332288", "#88ccee", "#44aa99", "#117733", "#999933", "#ddcc77", "#cc6677", "#882255", "#aa4499"],
40
+ dark: ["#7e72d6", "#9fd8f2", "#5fc4b0", "#3fa45f", "#bfbf5e", "#e8dda0", "#e08c9a", "#c25a78", "#cc78be"],
41
+ } as const;
42
+
43
+ /** A heatmap's interpolation stops for one theme. Open string fields so any
44
+ palette preset (not just the built-in default) can supply its own stops. */
45
+ export interface HeatStops {
46
+ neutral: string;
47
+ under: string;
48
+ over: string;
49
+ seqLow: string;
50
+ seqHigh: string;
51
+ empty: string;
52
+ }
53
+
54
+ /** Diverging (variance: under←0→over) and sequential (spending intensity)
55
+ heatmap stops, per theme. Teal = under/good, amber = over/spend = bad. */
56
+ export const HEATMAP_HEX = {
57
+ light: {
58
+ neutral: "#f1e7d2", // the cream surface itself, not a cool grey
59
+ under: "#0b6650", // DEFAULT_PRESET.light.moneyIncome
60
+ over: "#9c6418", // DEFAULT_PRESET.light.moneyExpense, warmed to the page
61
+ seqLow: "#f6ecd0",
62
+ seqHigh: "#9c6418",
63
+ empty: "rgba(148,163,184,0.14)",
64
+ },
65
+ dark: {
66
+ neutral: "#363a5a", // one step above --border on the indigo-noir surface
67
+ under: "#22c3b6", // DEFAULT_PRESET.dark.moneyIncome
68
+ // Brighter than dark.moneyExpense (#e4b035) on purpose: a gradient endpoint is
69
+ // meant to be the loudest thing in its scale, and it is a fill, not a figure.
70
+ over: "#fbbf24",
71
+ seqLow: "#3a2c14", // dim amber, sits just above the page
72
+ seqHigh: "#fbbf24",
73
+ empty: "rgba(148,163,184,0.10)",
74
+ },
75
+ } as const;
76
+
77
+ /** Linear interpolate two #rrggbb hexes; `t` in [0,1]. Returns #rrggbb. */
78
+ export function lerpHex(a: string, b: string, t: number): string {
79
+ const pa = parseHex(a) ?? UNREADABLE;
80
+ const pb = parseHex(b) ?? UNREADABLE;
81
+ const c = (i: number) => Math.round(pa[i] + (pb[i] - pa[i]) * clamp01(t));
82
+ return `#${[c(0), c(1), c(2)].map((v) => v.toString(16).padStart(2, "0")).join("")}`;
83
+ }
84
+
85
+ /** The two inks a cell/treemap label can be set in. Near-black and near-white rather
86
+ than #000/#fff: at full black-on-full-white the label stops looking like part of the
87
+ same type system as the rest of the page. */
88
+ const INK_DARK = "#1a1a1a";
89
+ const INK_LIGHT = "#f8fafc";
90
+
91
+ /** Pick a legible text colour (near-black / near-white) for a solid hex fill, by
92
+ MEASURING both against it and keeping the one that wins.
93
+
94
+ This used to flip on Rec.601 luma > 0.6, which is a brightness heuristic and not a
95
+ contrast measure — the two disagree on six of the eighteen default chart hues, a
96
+ third of the ramp, and on each of those the label got the worse of the two inks.
97
+ `#44aa99` is the plainest case: luma calls it dark, so it took white, which measures
98
+ 2.69:1 on it; near-black measures 6.18:1. keksdose measured this too and forked the
99
+ function locally for its treemap, leaving its other two call sites on this one.
100
+
101
+ A fill this cannot read gets `currentColor` — inherit, i.e. whatever the
102
+ surrounding text already uses. That is the only answer that is right on both
103
+ themes, because an unreadable fill is usually a translucent one (`HEATMAP_HEX.*
104
+ .empty` is an `rgba()` string, and the variance heatmap does pass it here for any
105
+ category with zero assigned and positive activity — a refund). Picking a constant
106
+ instead is what produced the earlier bug: the unparsed fill fell through to the
107
+ light ink, near-white on a near-white cell, and the figure was invisible. */
108
+ export function textOn(hex: string): string {
109
+ const rgb = parseRgbHex(hex);
110
+ if (!rgb) return "currentColor";
111
+ // Ties go to the dark ink: at equal measured contrast it is the one that keeps a
112
+ // small label looking like type rather than like a glow.
113
+ return contrast(rgb, INK_DARK) >= contrast(rgb, INK_LIGHT) ? INK_DARK : INK_LIGHT;
114
+ }
115
+
116
+ /** Mid-grey, for a stop {@link lerpHex} has to interpolate but cannot read. It has
117
+ * to return SOME valid colour, and a grey is the one that misleads least. */
118
+ const UNREADABLE: [number, number, number] = [0x80, 0x80, 0x80];
119
+
120
+ /**
121
+ * `#rgb` or `#rrggbb` to components, or **null** when it is neither.
122
+ *
123
+ * A tuple-shaped shim over `color.ts`'s parser, which is the same function and is
124
+ * already the one `contrast` validates with — so {@link lerpHex} and {@link textOn}
125
+ * cannot disagree about what counts as a colour.
126
+ *
127
+ * The **null** is the whole point of it. This used to `parseInt` whatever it was given
128
+ * and hand the NaNs on, and both callers propagated them silently: {@link lerpHex}
129
+ * produced the literal string `"#nannannan"`, and {@link textOn} fell through to the
130
+ * LIGHT label colour.
131
+ *
132
+ * That is not hypothetical — a non-hex value ships in this very module.
133
+ * `HEATMAP_HEX.*.empty` is an `rgba(…)` string, and the variance heatmap paints a
134
+ * figure on an `empty` cell whenever a category has zero assigned and positive
135
+ * activity (a refund). On the light theme the cell is near-white and the label came
136
+ * back near-white: the number was there, and unreadable.
137
+ */
138
+ function parseHex(hex: string): [number, number, number] | null {
139
+ const rgb = parseRgbHex(hex);
140
+ return rgb ? [rgb.r, rgb.g, rgb.b] : null;
141
+ }
142
+
143
+ function clamp01(t: number): number {
144
+ return t < 0 ? 0 : t > 1 ? 1 : t;
145
+ }