@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,309 @@
1
+ /**
2
+ * A tiny, dependency-free arithmetic evaluator for the numeric input fields.
3
+ * It lets users type a calculation (e.g. "12.50+3.20" or "100/3") straight into
4
+ * an amount field, or build one in the calculator popover, and have it resolve
5
+ * to a number. Deliberately NOT backed by `eval`/`Function` — it's a hand-rolled
6
+ * tokenizer + recursive-descent parser over a closed grammar (digits, decimals,
7
+ * `+ - * /`, unary minus, parentheses), so no arbitrary code can run.
8
+ */
9
+
10
+ type Token =
11
+ | { kind: "num"; value: number }
12
+ | { kind: "op"; value: "+" | "-" | "*" | "/" }
13
+ | { kind: "lparen" }
14
+ | { kind: "rparen" };
15
+
16
+ function tokenize(input: string): Token[] | null {
17
+ const tokens: Token[] = [];
18
+ let i = 0;
19
+ while (i < input.length) {
20
+ const ch = input[i];
21
+ if (ch === " ") {
22
+ i++;
23
+ continue;
24
+ }
25
+ if (ch === "+" || ch === "-" || ch === "*" || ch === "/") {
26
+ tokens.push({ kind: "op", value: ch });
27
+ i++;
28
+ continue;
29
+ }
30
+ if (ch === "(") {
31
+ tokens.push({ kind: "lparen" });
32
+ i++;
33
+ continue;
34
+ }
35
+ if (ch === ")") {
36
+ tokens.push({ kind: "rparen" });
37
+ i++;
38
+ continue;
39
+ }
40
+ if ((ch >= "0" && ch <= "9") || ch === ".") {
41
+ let j = i;
42
+ let dotSeen = false;
43
+ while (j < input.length && ((input[j] >= "0" && input[j] <= "9") || input[j] === ".")) {
44
+ if (input[j] === ".") {
45
+ if (dotSeen) return null; // two dots in one number → invalid
46
+ dotSeen = true;
47
+ }
48
+ j++;
49
+ }
50
+ const num = Number(input.slice(i, j));
51
+ if (!Number.isFinite(num)) return null;
52
+ tokens.push({ kind: "num", value: num });
53
+ i = j;
54
+ continue;
55
+ }
56
+ return null; // unknown character
57
+ }
58
+ return tokens;
59
+ }
60
+
61
+ /**
62
+ * Recursive-descent parse honouring `* /` over `+ -` and left-associativity.
63
+ * Returns `null` for any malformed input (the `null` sentinel is safe because a
64
+ * valid result of 0 is distinct from it). Division by zero is treated as invalid.
65
+ *
66
+ * expr := term (('+' | '-') term)*
67
+ * term := factor (('*' | '/') factor)*
68
+ * factor := ('+' | '-') factor | number | '(' expr ')'
69
+ */
70
+ function parse(tokens: Token[]): number | null {
71
+ let pos = 0;
72
+
73
+ function parseExpr(): number | null {
74
+ let value = parseTerm();
75
+ if (value === null) return null;
76
+ while (pos < tokens.length) {
77
+ const tok = tokens[pos];
78
+ if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
79
+ pos++;
80
+ const rhs = parseTerm();
81
+ if (rhs === null) return null;
82
+ value = tok.value === "+" ? value + rhs : value - rhs;
83
+ } else break;
84
+ }
85
+ return value;
86
+ }
87
+
88
+ function parseTerm(): number | null {
89
+ let value = parseFactor();
90
+ if (value === null) return null;
91
+ while (pos < tokens.length) {
92
+ const tok = tokens[pos];
93
+ if (tok.kind === "op" && (tok.value === "*" || tok.value === "/")) {
94
+ pos++;
95
+ const rhs = parseFactor();
96
+ if (rhs === null) return null;
97
+ if (tok.value === "/") {
98
+ if (rhs === 0) return null;
99
+ value = value / rhs;
100
+ } else {
101
+ value = value * rhs;
102
+ }
103
+ } else break;
104
+ }
105
+ return value;
106
+ }
107
+
108
+ function parseFactor(): number | null {
109
+ const tok = tokens[pos];
110
+ if (!tok) return null;
111
+ if (tok.kind === "op" && (tok.value === "+" || tok.value === "-")) {
112
+ pos++;
113
+ const operand = parseFactor();
114
+ if (operand === null) return null;
115
+ return tok.value === "-" ? -operand : operand;
116
+ }
117
+ if (tok.kind === "lparen") {
118
+ pos++;
119
+ const inner = parseExpr();
120
+ if (inner === null) return null;
121
+ if (tokens[pos]?.kind !== "rparen") return null;
122
+ pos++;
123
+ return inner;
124
+ }
125
+ if (tok.kind === "num") {
126
+ pos++;
127
+ return tok.value;
128
+ }
129
+ return null;
130
+ }
131
+
132
+ const result = parseExpr();
133
+ if (result === null) return null;
134
+ if (pos !== tokens.length) return null; // leftover tokens → invalid
135
+ return result;
136
+ }
137
+
138
+ /** Evaluate an arithmetic string, returning `null` if it isn't a valid sum.
139
+ * Accepts the on-screen operator glyphs (`× ÷ −`) and comma decimals too. */
140
+ export function evaluateExpression(expr: string): number | null {
141
+ const normalized = expr
142
+ .replace(/×/g, "*")
143
+ .replace(/÷/g, "/")
144
+ .replace(/[−–—]/g, "-") // unicode minus / en / em dash → ascii
145
+ .replace(/,/g, ".")
146
+ .trim();
147
+ if (normalized === "") return null;
148
+ const tokens = tokenize(normalized);
149
+ if (tokens === null || tokens.length === 0) return null;
150
+ const result = parse(tokens);
151
+ if (result === null || !Number.isFinite(result)) return null;
152
+ return result;
153
+ }
154
+
155
+ /** True when `s` carries arithmetic beyond a single leading minus — i.e. a plain
156
+ * number (or a lone negative like "-5") is NOT an expression, but "12+5" is.
157
+ * Used to decide whether a field's text should be evaluated on commit.
158
+ *
159
+ * The operator set is the one {@link evaluateExpression} accepts, on-screen glyphs
160
+ * included: the calculator keypad inserts `× ÷ −`, and a `−` that this said was not
161
+ * arithmetic went on to be read as a plain number — where the digits-only fallback
162
+ * in {@link commitExpression} silently ate it, turning "-42−−50" (= 8) into a bare
163
+ * 8 with no record that a sum had happened at all. */
164
+ export function looksLikeExpression(s: string): boolean {
165
+ const t = s.trim();
166
+ if (t === "") return false;
167
+ if (/^-?\d*[.,]?\d*$/.test(t)) return false; // plain (optionally negative) decimal
168
+ return /[+\-*/×÷−–—()]/.test(t.replace(/^-/, ""));
169
+ }
170
+
171
+ /** Render an evaluated number back to a field-friendly string, trimming binary
172
+ * float noise (so 0.1 + 0.2 reads "0.3", not "0.30000000000000004").
173
+ *
174
+ * FIXED notation, always, and that is the whole point of the function rather than a
175
+ * detail of it. This used to end in `parseFloat(n.toFixed(10)).toString()`, and
176
+ * `Number.prototype.toString` switches to EXPONENT notation below 1e-6 and at or
177
+ * above 1e21 — an alphabet containing `e`, `+` and `-`. Neither function that reads a
178
+ * field's text back accepts it: {@link tokenize} rejects `e` as an unknown character
179
+ * and {@link sanitizeLive} deletes it. So {@link commitExpression} fell through to
180
+ * its digits-only cleanup, which stripped the `e` and CONCATENATED the mantissa to
181
+ * the exponent. A field committed its own output as a different number:
182
+ *
183
+ * "1/10000000" -> "1e-7" -> "17" (AmountInput: "6")
184
+ * "999999999999*999999999999" -> "9.99999999998e+23" -> "9.9999999999823"
185
+ *
186
+ * Both controls commit on blur AND on Enter, so Enter-then-click-away was enough on
187
+ * its own, it was silent, and it was in a money field.
188
+ *
189
+ * The invariant, which the test asserts directly rather than by example:
190
+ * **`commitExpression(formatResult(n))` equals `formatResult(n)` for every finite
191
+ * `n`.** Anything rendered here has to survive being read back. */
192
+ export function formatResult(n: number): string {
193
+ if (!Number.isFinite(n)) return "";
194
+ // At or above 1e21 `toFixed` gives up and hands back `toString`'s exponent form —
195
+ // but every double that large is an integer, so BigInt renders it exactly.
196
+ if (Math.abs(n) >= 1e21) return BigInt(n).toString();
197
+ return trimTrailingZeros(n.toFixed(10));
198
+ }
199
+
200
+ /** "15.7000000000" -> "15.7", "0.0000000000" -> "0". The ten decimals are where the
201
+ * binary float noise gets rounded off; this is only about not showing the padding. */
202
+ function trimTrailingZeros(fixed: string): string {
203
+ if (!fixed.includes(".")) return fixed;
204
+ const trimmed = fixed.replace(/0+$/, "").replace(/\.$/, "");
205
+ // `toFixed` keeps the sign on a magnitude that rounded away to nothing
206
+ // ("-0.0000000000"); `toString` did not, and "-0" in an amount field is noise.
207
+ return trimmed === "-0" || trimmed === "" ? "0" : trimmed;
208
+ }
209
+
210
+ /** The single "on blur / Enter" transform a numeric field applies: evaluate the
211
+ * text if it's an expression, otherwise normalise it to a bare number string
212
+ * (comma → dot, dropping stray characters, non-leading minuses and anything after
213
+ * a second decimal point).
214
+ *
215
+ * The last clause is the backstop for Keksdose live #201. A field the user typed in
216
+ * cannot reach here malformed any more — {@link sanitizeLive} runs on every change,
217
+ * including a paste — but a value handed in from elsewhere (a recurring rule, an
218
+ * import, a test) still can, and "1.2.3" is not an expression (no operator), so it
219
+ * used to fall straight through unchanged and be read as NaN. Here the text is CUT
220
+ * at the second dot ("1.2") rather than joined ("1.23"): with no keystroke to
221
+ * ignore there is nothing to say which reading was meant, and the shorter one
222
+ * cannot silently invent a bigger number. */
223
+ export function commitExpression(raw: string): string {
224
+ const s = raw.trim();
225
+ if (looksLikeExpression(s)) {
226
+ const n = evaluateExpression(s);
227
+ if (n !== null) return formatResult(n);
228
+ // Unparseable (e.g. a half-typed "12+"): fall through to a digits-only clean.
229
+ }
230
+ // Global, like sanitizeLive's: these two are the two halves of one rule and must
231
+ // not appear to state it differently. (The `[^0-9.-]` strip on the same line
232
+ // already ate whatever commas a non-global replace missed, so this changes no
233
+ // behaviour — it removes the need for the next reader to run it to find that out.)
234
+ const cleaned = s.replace(/,/g, ".").replace(/[^0-9.-]/g, "").replace(/(?!^)-/g, "");
235
+ return cleaned.replace(/^(-?\d*\.?\d*).*$/, "$1");
236
+ }
237
+
238
+ /** The live, per-keystroke transform a numeric field applies: comma → dot and
239
+ * drop anything that isn't a digit, decimal point, arithmetic operator (incl.
240
+ * the on-screen `× ÷` glyphs) or paren — so a typed calculation survives
241
+ * keystroke-by-keystroke but stray characters can't be entered. A lone leading
242
+ * `-` is kept — in a plain field it is simply a negative amount; in a field whose
243
+ * caller owns the sign it is read off again by {@link splitLeadingSign}. The text
244
+ * is resolved to a plain number on blur/Enter via {@link commitExpression}. */
245
+ export function sanitizeLive(raw: string): string {
246
+ return dropExtraDots(raw.replace(/,/g, ".").replace(/[^0-9.+\-*/×÷()]/g, ""));
247
+ }
248
+
249
+ /**
250
+ * At most ONE decimal point per number (Keksdose live #201).
251
+ *
252
+ * Per NUMBER, not per string: "1.5+2.5" is a legitimate calculation with two dots
253
+ * in two operands, so the counter resets at every operator and parenthesis.
254
+ *
255
+ * A second dot inside one operand is a **no-op keystroke** — the digits after it
256
+ * keep landing, so "1.2" + "." + "3" is "1.23". That is what every calculator does
257
+ * with a repeated decimal point, and it is the only option that does not make the
258
+ * field feel broken ("my 3 disappeared"). This runs on paste as well as on typing,
259
+ * so the malformed value never reaches the rest of the app: everything downstream
260
+ * reads the field with `Number()`, NaN became a silent 0, and on the create form
261
+ * that 0 was painted with the income green — a figure that was not a number at all
262
+ * asserting money was coming in.
263
+ */
264
+ function dropExtraDots(s: string): string {
265
+ let out = "";
266
+ let dotInNumber = false;
267
+ for (const ch of s) {
268
+ if (ch === ".") {
269
+ if (dotInNumber) continue;
270
+ dotInNumber = true;
271
+ } else if (ch < "0" || ch > "9") {
272
+ // An operator or a paren ends the current operand.
273
+ dotInNumber = false;
274
+ }
275
+ out += ch;
276
+ }
277
+ return out;
278
+ }
279
+
280
+ /** A bare, unsigned amount: digits with at most one decimal separator and nothing
281
+ * else — "42", "42.", ".5", "0,5". NOT "" (an empty field has no figure to put a
282
+ * sign in front of), not "-42", not "12+5". It is the only shape a DISPLAYED sign
283
+ * prefix makes sense in front of; an expression carries its own arithmetic and has
284
+ * to survive keystroke-for-keystroke. */
285
+ export function isBareAmount(s: string): boolean {
286
+ const t = s.trim();
287
+ return t !== "" && /^\d*[.,]?\d*$/.test(t);
288
+ }
289
+
290
+ /**
291
+ * Split a LEADING sign off a plain signed number (Keksdose live #201 rework):
292
+ * `"-42"` → `{ sign: "-", rest: "42" }`, everything else → `{ sign: "", rest: text }`.
293
+ *
294
+ * Only in front of a bare amount, because a minus with arithmetic after it IS
295
+ * arithmetic: "12-30" is a subtraction, and "-12+5" is -7, not -(12+5). Those come
296
+ * back untouched and are resolved by {@link commitExpression} on blur — at which
297
+ * point the RESULT is a plain signed number and this function does get to speak.
298
+ *
299
+ * A lone "-" splits (rest ""): the sign is a statement about the direction the
300
+ * moment it is typed, before any digit exists.
301
+ */
302
+ export function splitLeadingSign(text: string): { sign: "" | "-" | "+"; rest: string } {
303
+ const trimmed = text.trimStart();
304
+ const sign = trimmed.charAt(0);
305
+ if (sign !== "-" && sign !== "+") return { sign: "", rest: text };
306
+ const rest = trimmed.slice(1);
307
+ if (rest !== "" && !isBareAmount(rest)) return { sign: "", rest: text };
308
+ return { sign, rest };
309
+ }
package/src/lib/cn.ts ADDED
@@ -0,0 +1,5 @@
1
+ import { clsx } from "clsx";
2
+ import { twMerge } from "tailwind-merge";
3
+
4
+ /** Merge class lists with Tailwind conflict resolution (later wins). */
5
+ export const cn = (...inputs: Parameters<typeof clsx>) => twMerge(clsx(inputs));
@@ -0,0 +1,187 @@
1
+ /**
2
+ * ISO date helpers, collected from copies that were scattered across the pages.
3
+ *
4
+ * **Every "what day is it" helper here reads the LOCAL calendar** — one family, not
5
+ * two. It used to be two: these all built a local Date and then read it back through
6
+ * `toISOString()`, which is UTC, so east of Greenwich they all returned YESTERDAY
7
+ * between local midnight and the UTC rollover. The old module note called that
8
+ * deliberate and warned against changing it. It was not defensible, and dev#471 is
9
+ * what it cost:
10
+ *
11
+ * > *"Just entered a tx now from scheduled. But it still shows as upcoming. Is there
12
+ * > a problem with timezones? It is currently 1 o clock middle european summer time."*
13
+ *
14
+ * There was: at 01:00 CEST `todayIso()` answered with the 14th, the row the scheduler
15
+ * had just booked was dated the 15th, and `isUpcoming` compares the two strings — so a
16
+ * transaction entered a second ago read as one still to come. The same slip moved
17
+ * `startOfMonthIso()` onto the last day of the PREVIOUS month for an hour every first
18
+ * of the month, which is a report range nobody would have questioned.
19
+ *
20
+ * A calendar day only means anything to the human reading it, and every caller here —
21
+ * the register's today, the date-range presets, the budget cursor, a new
22
+ * transaction's default date — is asking that question. There is no caller for whom
23
+ * "the UTC day" is the right answer; the server compares plain dates with no zone at
24
+ * all, so sending it the user's local day is also what it wants.
25
+ */
26
+
27
+ /** Zero-pad a number to two digits ("3" -> "03"). */
28
+ export function pad(n: number): string {
29
+ return String(n).padStart(2, "0");
30
+ }
31
+
32
+ /** Today as "YYYY-MM-DD" on the LOCAL calendar (see the module note). */
33
+ export function todayIso(): string {
34
+ return toLocalIso(new Date());
35
+ }
36
+
37
+ /** A Date as a local-time "YYYY-MM-DD" string. */
38
+ export function toLocalIso(d: Date): string {
39
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}`;
40
+ }
41
+
42
+ /** A Date as a local-time "YYYY-MM" month key. */
43
+ export function monthKey(d: Date): string {
44
+ return `${d.getFullYear()}-${pad(d.getMonth() + 1)}`;
45
+ }
46
+
47
+ // ---------- Date-range presets + calendar helpers ----------
48
+ // Local-calendar like todayIso (see the module note); used by the data-table date
49
+ // filter and its MiniCalendar. Each one mutates a local Date and must therefore READ
50
+ // it back locally — going out through toISOString() is what shifted every one of
51
+ // these by a day near midnight (dev#471).
52
+
53
+ /** Today shifted by `days`, as "YYYY-MM-DD". */
54
+ export function shiftIso(days: number): string {
55
+ const d = new Date();
56
+ d.setDate(d.getDate() + days);
57
+ return toLocalIso(d);
58
+ }
59
+
60
+ /** First day (Monday) of the current week, as "YYYY-MM-DD". */
61
+ export function startOfWeekIso(): string {
62
+ const d = new Date();
63
+ const day = d.getDay();
64
+ const diff = (day + 6) % 7; // monday = 0
65
+ d.setDate(d.getDate() - diff);
66
+ return toLocalIso(d);
67
+ }
68
+
69
+ /** First day of the month `offsetMonths` from now, as "YYYY-MM-DD". */
70
+ export function startOfMonthIso(offsetMonths = 0): string {
71
+ const d = new Date();
72
+ d.setDate(1);
73
+ d.setMonth(d.getMonth() + offsetMonths);
74
+ return toLocalIso(d);
75
+ }
76
+
77
+ /** Last day of the month `offsetMonths` from now, as "YYYY-MM-DD". */
78
+ export function endOfMonthIso(offsetMonths = 0): string {
79
+ const d = new Date();
80
+ d.setDate(1);
81
+ d.setMonth(d.getMonth() + offsetMonths + 1);
82
+ d.setDate(0);
83
+ return toLocalIso(d);
84
+ }
85
+
86
+ /** First day of the year `offsetYears` from now, as "YYYY-MM-DD". */
87
+ export function startOfYearIso(offsetYears = 0): string {
88
+ const d = new Date();
89
+ d.setMonth(0);
90
+ d.setDate(1);
91
+ d.setFullYear(d.getFullYear() + offsetYears);
92
+ return toLocalIso(d);
93
+ }
94
+
95
+ /** Last day of the year `offsetYears` from now, as "YYYY-MM-DD". */
96
+ export function endOfYearIso(offsetYears = 0): string {
97
+ const d = new Date();
98
+ d.setMonth(11);
99
+ d.setDate(31);
100
+ d.setFullYear(d.getFullYear() + offsetYears);
101
+ return toLocalIso(d);
102
+ }
103
+
104
+ /** Parse a "YYYY-MM-DD" string to a local-midnight Date, or null if malformed —
105
+ * where "malformed" includes a date that does not exist.
106
+ *
107
+ * The falsy check alone was not enough. `new Date(y, m - 1, d)` ROLLS OVER out-of-range
108
+ * components rather than refusing them, so "2026-13-45" came back as 2027-02-14 and
109
+ * "2026-02-30" as 2026-03-02: a valid-looking Date for a day that never happened. No
110
+ * caller could tell — {@link formatIsoDate} blanks only on `null`, so the invented
111
+ * date formatted and displayed exactly like a real one.
112
+ *
113
+ * The round-trip is the check: build the Date, then read the components back off it.
114
+ * A value that rolled over cannot answer with the numbers it was given. That is the
115
+ * same shape {@link sameYmd} already expresses, applied to a Date and its source. */
116
+ export function parseIsoDate(s: string): Date | null {
117
+ if (!s) return null;
118
+ const [y, m, d] = s.split("-").map(Number);
119
+ if (!y || !m || !d) return null;
120
+ if (!Number.isInteger(y) || !Number.isInteger(m) || !Number.isInteger(d)) return null;
121
+ const dt = new Date(y, m - 1, d);
122
+ if (Number.isNaN(dt.getTime())) return null;
123
+ if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return null;
124
+ return dt;
125
+ }
126
+
127
+ /** Format a "YYYY-MM-DD" string for display in `locale` (empty string when
128
+ * unparseable). Defaults to the locale's short numeric date; pass `options` to
129
+ * override (e.g. `{ dateStyle: "medium" }`). */
130
+ export function formatIsoDate(
131
+ iso: string,
132
+ locale: string,
133
+ options?: Intl.DateTimeFormatOptions,
134
+ ): string {
135
+ const d = parseIsoDate(iso);
136
+ return d ? d.toLocaleDateString(locale, options) : "";
137
+ }
138
+
139
+ /** A "YYYY-MM-DD" string shifted by `days` (negative shifts back), or the input
140
+ * unchanged when it isn't a parseable date.
141
+ *
142
+ * Local-time throughout — {@link parseIsoDate} builds a local-midnight Date and
143
+ * {@link toLocalIso} reads local components back, so this never slides a day the way
144
+ * a `toISOString()` round-trip does west of UTC. */
145
+ export function addDaysIso(iso: string, days: number): string {
146
+ const d = parseIsoDate(iso);
147
+ if (!d) return iso;
148
+ d.setDate(d.getDate() + days);
149
+ return toLocalIso(d);
150
+ }
151
+
152
+ /** Whether two Dates fall on the same local calendar day. */
153
+ export function sameYmd(a: Date, b: Date): boolean {
154
+ return (
155
+ a.getFullYear() === b.getFullYear() &&
156
+ a.getMonth() === b.getMonth() &&
157
+ a.getDate() === b.getDate()
158
+ );
159
+ }
160
+
161
+ export interface DateRangePreset {
162
+ key: string;
163
+ from: string;
164
+ to: string;
165
+ }
166
+
167
+ /**
168
+ * The named ranges offered by the data-table date filter (local-calendar, like
169
+ * {@link todayIso} — see the module note). `key` maps to the consumer's
170
+ * `table.preset_*` labels.
171
+ */
172
+ export function dateRangePresets(): DateRangePreset[] {
173
+ const mondayOffset = (new Date().getDay() + 6) % 7; // monday = 0
174
+ return [
175
+ { key: "today", from: todayIso(), to: todayIso() },
176
+ { key: "yesterday", from: shiftIso(-1), to: shiftIso(-1) },
177
+ { key: "this_week", from: startOfWeekIso(), to: todayIso() },
178
+ { key: "last_week", from: shiftIso(-7 - mondayOffset), to: shiftIso(-1 - mondayOffset) },
179
+ { key: "last_7_days", from: shiftIso(-6), to: todayIso() },
180
+ { key: "last_30_days", from: shiftIso(-29), to: todayIso() },
181
+ { key: "this_month", from: startOfMonthIso(0), to: todayIso() },
182
+ { key: "last_month", from: startOfMonthIso(-1), to: endOfMonthIso(-1) },
183
+ { key: "last_3_months", from: shiftIso(-90), to: todayIso() },
184
+ { key: "ytd", from: startOfYearIso(0), to: todayIso() },
185
+ { key: "last_year", from: startOfYearIso(-1), to: endOfYearIso(-1) },
186
+ ];
187
+ }
@@ -0,0 +1,73 @@
1
+ import type { StateCreator, StoreMutatorIdentifier } from "zustand";
2
+
3
+ import { readStored } from "./safe-storage";
4
+
5
+ /**
6
+ * zustand logging middleware.
7
+ *
8
+ * Logs every state transition (a store "action") to the DevTools console as a
9
+ * single grouped line showing the prev/next state. Enabled in dev (Vite
10
+ * `import.meta.env.DEV`) by default; override at runtime with
11
+ * `localStorage.setItem("store_log", "0")` to silence or `"1"` to force-on in a
12
+ * production build.
13
+ */
14
+ type Logger = <
15
+ T,
16
+ Mps extends [StoreMutatorIdentifier, unknown][] = [],
17
+ Mcs extends [StoreMutatorIdentifier, unknown][] = [],
18
+ >(
19
+ f: StateCreator<T, Mps, Mcs>,
20
+ name?: string,
21
+ ) => StateCreator<T, Mps, Mcs>;
22
+
23
+ type LoggerImpl = <T>(f: StateCreator<T, [], []>, name?: string) => StateCreator<T, [], []>;
24
+
25
+ /**
26
+ * Read ONCE, at module scope, not on every `set`.
27
+ *
28
+ * This used to run inside the wrapped `set`, which means a synchronous
29
+ * `localStorage.getItem` on every state transition of every logged store — in
30
+ * production builds too, because the override has to stay readable there by design.
31
+ * The stores this middleware wraps include ones that transition per pointer event.
32
+ *
33
+ * The override cannot change without someone opening devtools and reloading, so
34
+ * once per session is the same behaviour at a fraction of the cost. {@link setStoreLog}
35
+ * keeps the ability to flip it live from a console, which is the only thing the
36
+ * per-call read was actually buying.
37
+ */
38
+ let storeLog: boolean = (() => {
39
+ const override = readStored("store_log");
40
+ if (override === "0") return false;
41
+ if (override === "1") return true;
42
+ // NOT under vitest. `DEV` distinguishes a dev build from a production one; a test
43
+ // run is neither, and vitest sets `DEV=true` — so every transition of every logged
44
+ // store printed a prev/next object into the suite output. Measured on the consumer's
45
+ // suite: 822 blocks, several thousand lines, in a log whose whole job is to make one
46
+ // real `console.error` findable. `store_log=1` still forces it on.
47
+ // Optional-chained because `import.meta.env` is a Vite injection: it is
48
+ // undefined under plain Node ESM, and this package is published publicly,
49
+ // so a non-Vite consumer must be able to import it without throwing.
50
+ return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
51
+ })();
52
+
53
+ /** Turn store logging on or off for the rest of the session (devtools escape hatch). */
54
+ export function setStoreLog(enabled: boolean): void {
55
+ storeLog = enabled;
56
+ }
57
+
58
+ const loggerImpl: LoggerImpl = (f, name) => (set, get, store) => {
59
+ const loggedSet = ((...args: unknown[]) => {
60
+ const prev = get();
61
+ (set as (...a: unknown[]) => void)(...args);
62
+ if (storeLog) {
63
+ console.debug(
64
+ `%c[store${name ? `:${name}` : ""}]`,
65
+ "color:#0d9488",
66
+ { prev, next: get() },
67
+ );
68
+ }
69
+ }) as typeof set;
70
+ return f(loggedSet, get, store);
71
+ };
72
+
73
+ export const logger = loggerImpl as unknown as Logger;
@@ -0,0 +1,37 @@
1
+ /**
2
+ * `localStorage` that cannot take the page down with it.
3
+ *
4
+ * Accessing `window.localStorage` **throws** — rather than returning null — when a
5
+ * browser has site data blocked: Safari private browsing, Chrome with third-party
6
+ * site data disabled, a partitioned webview. Three places in this package already
7
+ * knew that and wrapped their calls in try/catch (`logger.ts`, and the DataTable's
8
+ * `loadPersisted`/`savePersisted`). One did not: `AppShell` read the sidebar's
9
+ * collapse flag straight out of a `useState` initialiser, so the throw happened
10
+ * during render of the application's top-level shell. That is not a lost preference
11
+ * — nothing mounts, in every consumer of this package at once.
12
+ *
13
+ * So the guard lives here, once, and the next storage-backed preference cannot
14
+ * forget it. A read that cannot happen is indistinguishable from a key that was
15
+ * never set, which is the right answer for every caller: the default.
16
+ */
17
+
18
+ /** The stored string for `key`, or null when absent, unreadable, or blocked. */
19
+ export function readStored(key: string): string | null {
20
+ if (typeof window === "undefined") return null;
21
+ try {
22
+ return window.localStorage.getItem(key);
23
+ } catch {
24
+ return null;
25
+ }
26
+ }
27
+
28
+ /** Store `value` under `key`, silently doing nothing when storage is unavailable
29
+ * or full. A preference that cannot be persisted is not worth an exception. */
30
+ export function writeStored(key: string, value: string): void {
31
+ if (typeof window === "undefined") return;
32
+ try {
33
+ window.localStorage.setItem(key, value);
34
+ } catch {
35
+ // ignore (private mode, quota, partitioned storage)
36
+ }
37
+ }