@eifi1/ui-kit 0.48.2 → 0.50.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 (377) hide show
  1. package/README.md +34 -16
  2. package/dist/admin/admin-action-confirm.js +1 -1
  3. package/dist/admin/admin-action-confirm.js.map +1 -1
  4. package/dist/admin/invitations-panel.js +5 -1
  5. package/dist/admin/invitations-panel.js.map +1 -1
  6. package/dist/admin/user-roster.d.ts +24 -12
  7. package/dist/admin/user-roster.js +21 -6
  8. package/dist/admin/user-roster.js.map +1 -1
  9. package/dist/auth/accept-invitation.js +2 -2
  10. package/dist/auth/accept-invitation.js.map +1 -1
  11. package/dist/auth/not-found-page.js +1 -1
  12. package/dist/auth/not-found-page.js.map +1 -1
  13. package/dist/auth/reset-password-form.js +2 -2
  14. package/dist/auth/reset-password-form.js.map +1 -1
  15. package/dist/auth/verify-email.d.ts +9 -1
  16. package/dist/auth/verify-email.js +5 -3
  17. package/dist/auth/verify-email.js.map +1 -1
  18. package/dist/billing/plan-card.js +2 -2
  19. package/dist/billing/plan-card.js.map +1 -1
  20. package/dist/components/amount-input.d.ts +2 -1
  21. package/dist/components/amount-input.js +3 -2
  22. package/dist/components/amount-input.js.map +1 -1
  23. package/dist/components/autocomplete.js +1 -0
  24. package/dist/components/autocomplete.js.map +1 -1
  25. package/dist/components/breadcrumbs.d.ts +19 -1
  26. package/dist/components/breadcrumbs.js +11 -4
  27. package/dist/components/breadcrumbs.js.map +1 -1
  28. package/dist/components/button-look.d.ts +37 -7
  29. package/dist/components/button-look.js +33 -6
  30. package/dist/components/button-look.js.map +1 -1
  31. package/dist/components/button.d.ts +4 -1
  32. package/dist/components/button.js +2 -2
  33. package/dist/components/button.js.map +1 -1
  34. package/dist/components/calendar-heatmap.js +2 -1
  35. package/dist/components/calendar-heatmap.js.map +1 -1
  36. package/dist/components/chart-edge.d.ts +10 -0
  37. package/dist/components/chart-edge.js +57 -0
  38. package/dist/components/chart-edge.js.map +1 -0
  39. package/dist/components/chart.js +2 -1
  40. package/dist/components/chart.js.map +1 -1
  41. package/dist/components/chip.d.ts +8 -1
  42. package/dist/components/chip.js +4 -1
  43. package/dist/components/chip.js.map +1 -1
  44. package/dist/components/combobox-core.js +1 -0
  45. package/dist/components/combobox-core.js.map +1 -1
  46. package/dist/components/combobox-suggestions.js +1 -0
  47. package/dist/components/combobox-suggestions.js.map +1 -1
  48. package/dist/components/confirm-context.d.ts +5 -0
  49. package/dist/components/confirm-context.js +7 -0
  50. package/dist/components/confirm-context.js.map +1 -0
  51. package/dist/components/confirm-dialog.js +3 -3
  52. package/dist/components/confirm-dialog.js.map +1 -1
  53. package/dist/components/currency-select.d.ts +28 -2
  54. package/dist/components/currency-select.js +56 -10
  55. package/dist/components/currency-select.js.map +1 -1
  56. package/dist/components/danger-confirm.d.ts +2 -2
  57. package/dist/components/danger-confirm.js +1 -1
  58. package/dist/components/danger-confirm.js.map +1 -1
  59. package/dist/components/data-table-fit.js +21 -3
  60. package/dist/components/data-table-fit.js.map +1 -1
  61. package/dist/components/data-table-mobile.d.ts +58 -3
  62. package/dist/components/data-table-mobile.js +147 -16
  63. package/dist/components/data-table-mobile.js.map +1 -1
  64. package/dist/components/data-table-row-actions.d.ts +2 -1
  65. package/dist/components/data-table-row-actions.js +7 -0
  66. package/dist/components/data-table-row-actions.js.map +1 -1
  67. package/dist/components/data-table-row.d.ts +1 -1
  68. package/dist/components/data-table-row.js.map +1 -1
  69. package/dist/components/data-table.d.ts +34 -1
  70. package/dist/components/data-table.js +37 -10
  71. package/dist/components/data-table.js.map +1 -1
  72. package/dist/components/dropdown.js +1 -0
  73. package/dist/components/dropdown.js.map +1 -1
  74. package/dist/components/error-boundary.js +2 -2
  75. package/dist/components/error-boundary.js.map +1 -1
  76. package/dist/components/floating-panel.d.ts +36 -13
  77. package/dist/components/floating-panel.js +27 -7
  78. package/dist/components/floating-panel.js.map +1 -1
  79. package/dist/components/form-actions.d.ts +1 -1
  80. package/dist/components/form-actions.js +1 -1
  81. package/dist/components/form-actions.js.map +1 -1
  82. package/dist/components/full-bleed-dialog.js +1 -0
  83. package/dist/components/full-bleed-dialog.js.map +1 -1
  84. package/dist/components/icon-button.d.ts +11 -0
  85. package/dist/components/icon-button.js +7 -5
  86. package/dist/components/icon-button.js.map +1 -1
  87. package/dist/components/modal.js +1 -0
  88. package/dist/components/modal.js.map +1 -1
  89. package/dist/components/nav-pills.d.ts +8 -1
  90. package/dist/components/nav-pills.js +61 -38
  91. package/dist/components/nav-pills.js.map +1 -1
  92. package/dist/components/number-input.d.ts +2 -1
  93. package/dist/components/number-input.js +6 -2
  94. package/dist/components/number-input.js.map +1 -1
  95. package/dist/components/numpad-sheet.js +2 -0
  96. package/dist/components/numpad-sheet.js.map +1 -1
  97. package/dist/components/page-header-context.d.ts +8 -0
  98. package/dist/components/page-header-context.js +7 -0
  99. package/dist/components/page-header-context.js.map +1 -0
  100. package/dist/components/page-header.d.ts +14 -2
  101. package/dist/components/page-header.js +10 -1
  102. package/dist/components/page-header.js.map +1 -1
  103. package/dist/components/picker-sheet.js +2 -0
  104. package/dist/components/picker-sheet.js.map +1 -1
  105. package/dist/components/pie-chart.js +2 -1
  106. package/dist/components/pie-chart.js.map +1 -1
  107. package/dist/components/popover.js +1 -0
  108. package/dist/components/popover.js.map +1 -1
  109. package/dist/components/progress-bar.js +5 -4
  110. package/dist/components/progress-bar.js.map +1 -1
  111. package/dist/components/reauth-dialog.js +1 -1
  112. package/dist/components/reauth-dialog.js.map +1 -1
  113. package/dist/components/row-actions-labels.js +3 -1
  114. package/dist/components/row-actions-labels.js.map +1 -1
  115. package/dist/components/row-actions.d.ts +43 -9
  116. package/dist/components/row-actions.js +78 -15
  117. package/dist/components/row-actions.js.map +1 -1
  118. package/dist/components/section-nav.d.ts +37 -0
  119. package/dist/components/section-nav.js +33 -0
  120. package/dist/components/section-nav.js.map +1 -0
  121. package/dist/components/series-chart.js +8 -3
  122. package/dist/components/series-chart.js.map +1 -1
  123. package/dist/components/share-card.js +1 -1
  124. package/dist/components/share-card.js.map +1 -1
  125. package/dist/components/signature-pad.js +1 -1
  126. package/dist/components/signature-pad.js.map +1 -1
  127. package/dist/components/sparkline.js +2 -1
  128. package/dist/components/sparkline.js.map +1 -1
  129. package/dist/components/stat-tile.js +14 -7
  130. package/dist/components/stat-tile.js.map +1 -1
  131. package/dist/components/status-dot.d.ts +5 -1
  132. package/dist/components/status-dot.js +4 -1
  133. package/dist/components/status-dot.js.map +1 -1
  134. package/dist/components/tooltip.js +1 -1
  135. package/dist/components/tooltip.js.map +1 -1
  136. package/dist/components/translation-review-editor.js +2 -2
  137. package/dist/components/translation-review-editor.js.map +1 -1
  138. package/dist/components/translation-review.js +2 -2
  139. package/dist/components/translation-review.js.map +1 -1
  140. package/dist/components/treemap.d.ts +5 -2
  141. package/dist/components/treemap.js +26 -6
  142. package/dist/components/treemap.js.map +1 -1
  143. package/dist/components/use-table-state.js +37 -6
  144. package/dist/components/use-table-state.js.map +1 -1
  145. package/dist/data-table.js.map +1 -1
  146. package/dist/demo/demo-banner.js +1 -1
  147. package/dist/demo/demo-banner.js.map +1 -1
  148. package/dist/demo/demo-ended.js +1 -1
  149. package/dist/demo/demo-ended.js.map +1 -1
  150. package/dist/demo/demo-start.js +1 -1
  151. package/dist/demo/demo-start.js.map +1 -1
  152. package/dist/eslint/index.js +1 -1
  153. package/dist/feedback/feedback-inbox.js +1 -1
  154. package/dist/feedback/feedback-inbox.js.map +1 -1
  155. package/dist/hooks/use-dismiss.d.ts +9 -1
  156. package/dist/hooks/use-dismiss.js +25 -5
  157. package/dist/hooks/use-dismiss.js.map +1 -1
  158. package/dist/hooks/use-document-title.d.ts +14 -0
  159. package/dist/hooks/use-document-title.js +22 -0
  160. package/dist/hooks/use-document-title.js.map +1 -0
  161. package/dist/i18n/defaults.js +2 -0
  162. package/dist/i18n/defaults.js.map +1 -1
  163. package/dist/i18n/german.js +20 -3
  164. package/dist/i18n/german.js.map +1 -1
  165. package/dist/i18n/kit-labels.d.ts +34 -1
  166. package/dist/i18n/kit-labels.js +43 -2
  167. package/dist/i18n/kit-labels.js.map +1 -1
  168. package/dist/i18n/locales/en.js +22 -3
  169. package/dist/i18n/locales/en.js.map +3 -3
  170. package/dist/i18n/locales/es.js +20 -3
  171. package/dist/i18n/locales/es.js.map +1 -1
  172. package/dist/i18n/locales/fr.js +20 -3
  173. package/dist/i18n/locales/fr.js.map +1 -1
  174. package/dist/i18n/locales/hu.js +20 -3
  175. package/dist/i18n/locales/hu.js.map +1 -1
  176. package/dist/i18n/locales/it.js +20 -3
  177. package/dist/i18n/locales/it.js.map +1 -1
  178. package/dist/i18n/locales/zh.js +20 -3
  179. package/dist/i18n/locales/zh.js.map +1 -1
  180. package/dist/i18n/review.js +3 -0
  181. package/dist/i18n/review.js.map +1 -1
  182. package/dist/i18n/shared-labels.js +1 -0
  183. package/dist/i18n/shared-labels.js.map +1 -1
  184. package/dist/index.d.ts +8 -1
  185. package/dist/index.js +11 -0
  186. package/dist/index.js.map +1 -1
  187. package/dist/landing/landing-actions.js +2 -2
  188. package/dist/landing/landing-actions.js.map +1 -1
  189. package/dist/landing/public-header.js +2 -2
  190. package/dist/landing/public-header.js.map +1 -1
  191. package/dist/lib/clipboard-minus.d.ts +27 -0
  192. package/dist/lib/clipboard-minus.js +63 -0
  193. package/dist/lib/clipboard-minus.js.map +1 -0
  194. package/dist/lib/data-attributes.d.ts +11 -0
  195. package/dist/lib/data-attributes.js +10 -0
  196. package/dist/lib/data-attributes.js.map +1 -0
  197. package/dist/lib/document-title.d.ts +17 -0
  198. package/dist/lib/document-title.js +29 -0
  199. package/dist/lib/document-title.js.map +1 -0
  200. package/dist/lib/float-clearance.js +8 -2
  201. package/dist/lib/float-clearance.js.map +1 -1
  202. package/dist/lib/format.d.ts +36 -10
  203. package/dist/lib/format.js +57 -11
  204. package/dist/lib/format.js.map +1 -1
  205. package/dist/lib/minus.d.ts +32 -0
  206. package/dist/lib/minus.js +14 -0
  207. package/dist/lib/minus.js.map +1 -0
  208. package/dist/lib/status-tone.d.ts +30 -0
  209. package/dist/lib/status-tone.js +17 -0
  210. package/dist/lib/status-tone.js.map +1 -0
  211. package/dist/settings/settings-layout.js +3 -2
  212. package/dist/settings/settings-layout.js.map +1 -1
  213. package/dist/shell/account-menu-language.d.ts +1 -1
  214. package/dist/shell/account-menu-language.js +9 -1
  215. package/dist/shell/account-menu-language.js.map +1 -1
  216. package/dist/shell/app-shell-labels.js +2 -1
  217. package/dist/shell/app-shell-labels.js.map +1 -1
  218. package/dist/shell/app-shell.d.ts +58 -16
  219. package/dist/shell/app-shell.js +151 -43
  220. package/dist/shell/app-shell.js.map +1 -1
  221. package/dist/shell/language-code.d.ts +3 -0
  222. package/dist/shell/language-code.js +8 -0
  223. package/dist/shell/language-code.js.map +1 -0
  224. package/dist/shell/topbar-controls.d.ts +14 -6
  225. package/dist/shell/topbar-controls.js +14 -17
  226. package/dist/shell/topbar-controls.js.map +1 -1
  227. package/dist/testing/css-variables.generated.d.ts +1 -1
  228. package/dist/testing/css-variables.generated.js +1 -1
  229. package/dist/testing/css-variables.generated.js.map +1 -1
  230. package/dist/testing/react.d.ts +25 -0
  231. package/dist/testing/react.js +19 -0
  232. package/dist/testing/react.js.map +1 -0
  233. package/dist/theme/chart-palette.d.ts +82 -15
  234. package/dist/theme/chart-palette.js +51 -24
  235. package/dist/theme/chart-palette.js.map +1 -1
  236. package/dist/theme/palette-presets.d.ts +9 -0
  237. package/dist/theme/palette-presets.js +77 -62
  238. package/dist/theme/palette-presets.js.map +1 -1
  239. package/dist/tour/tour-dock.d.ts +42 -0
  240. package/dist/tour/tour-dock.js +80 -0
  241. package/dist/tour/tour-dock.js.map +1 -0
  242. package/dist/tour/tour-gates.d.ts +71 -0
  243. package/dist/tour/tour-gates.js +114 -0
  244. package/dist/tour/tour-gates.js.map +1 -0
  245. package/dist/tour/tour-guide-button.d.ts +27 -0
  246. package/dist/tour/tour-guide-button.js +45 -0
  247. package/dist/tour/tour-guide-button.js.map +1 -0
  248. package/dist/tour/tour-guide-labels.d.ts +8 -0
  249. package/dist/tour/tour-guide-labels.js +9 -0
  250. package/dist/tour/tour-guide-labels.js.map +1 -0
  251. package/dist/tour/tour-labels.js +9 -1
  252. package/dist/tour/tour-labels.js.map +1 -1
  253. package/dist/tour/tour.d.ts +133 -9
  254. package/dist/tour/tour.js +594 -70
  255. package/dist/tour/tour.js.map +1 -1
  256. package/dist/tour/use-tour-url-sync.d.ts +5 -2
  257. package/dist/tour/use-tour-url-sync.js +7 -1
  258. package/dist/tour/use-tour-url-sync.js.map +1 -1
  259. package/dist/tour.d.ts +2 -0
  260. package/dist/tour.js +3 -0
  261. package/dist/tour.js.map +1 -1
  262. package/dist/wizard/stepper-nav.d.ts +1 -1
  263. package/dist/wizard/stepper-nav.js +18 -5
  264. package/dist/wizard/stepper-nav.js.map +1 -1
  265. package/dist/wizard/types.d.ts +11 -0
  266. package/dist/wizard/types.js.map +1 -1
  267. package/dist/wizard/use-wizard.js +11 -5
  268. package/dist/wizard/use-wizard.js.map +1 -1
  269. package/package.json +9 -1
  270. package/src/admin/admin-action-confirm.tsx +1 -1
  271. package/src/admin/invitations-panel.tsx +7 -3
  272. package/src/admin/user-roster.tsx +41 -14
  273. package/src/auth/accept-invitation.tsx +3 -3
  274. package/src/auth/not-found-page.tsx +1 -1
  275. package/src/auth/reset-password-form.tsx +3 -3
  276. package/src/auth/verify-email.tsx +13 -3
  277. package/src/billing/plan-card.tsx +2 -2
  278. package/src/components/amount-input.tsx +5 -4
  279. package/src/components/autocomplete.tsx +2 -0
  280. package/src/components/breadcrumbs.tsx +32 -4
  281. package/src/components/button-look.ts +86 -15
  282. package/src/components/button.tsx +6 -3
  283. package/src/components/calendar-heatmap.tsx +2 -1
  284. package/src/components/chart-edge.ts +73 -0
  285. package/src/components/chart.tsx +3 -1
  286. package/src/components/chip.tsx +10 -1
  287. package/src/components/combobox-core.tsx +2 -0
  288. package/src/components/combobox-suggestions.tsx +2 -0
  289. package/src/components/confirm-context.ts +7 -0
  290. package/src/components/confirm-dialog.tsx +3 -3
  291. package/src/components/currency-select.tsx +95 -6
  292. package/src/components/danger-confirm.tsx +3 -3
  293. package/src/components/data-table-fit.ts +41 -6
  294. package/src/components/data-table-mobile.tsx +259 -17
  295. package/src/components/data-table-row-actions.tsx +9 -1
  296. package/src/components/data-table-row.tsx +3 -0
  297. package/src/components/data-table.tsx +91 -19
  298. package/src/components/dropdown.tsx +2 -0
  299. package/src/components/error-boundary.tsx +2 -2
  300. package/src/components/floating-panel.tsx +67 -16
  301. package/src/components/form-actions.tsx +2 -2
  302. package/src/components/full-bleed-dialog.tsx +3 -0
  303. package/src/components/icon-button.tsx +21 -8
  304. package/src/components/modal.tsx +3 -0
  305. package/src/components/nav-pills.tsx +29 -2
  306. package/src/components/number-input.tsx +8 -3
  307. package/src/components/numpad-sheet.tsx +4 -0
  308. package/src/components/page-header-context.ts +8 -0
  309. package/src/components/page-header.tsx +34 -2
  310. package/src/components/picker-sheet.tsx +4 -0
  311. package/src/components/pie-chart.tsx +2 -1
  312. package/src/components/popover.tsx +2 -0
  313. package/src/components/progress-bar.tsx +6 -3
  314. package/src/components/reauth-dialog.tsx +1 -1
  315. package/src/components/row-actions-labels.ts +2 -0
  316. package/src/components/row-actions.tsx +154 -33
  317. package/src/components/section-nav.tsx +65 -0
  318. package/src/components/series-chart.tsx +12 -3
  319. package/src/components/share-card.tsx +1 -1
  320. package/src/components/signature-pad.tsx +1 -1
  321. package/src/components/sparkline.tsx +2 -1
  322. package/src/components/stat-tile.tsx +19 -7
  323. package/src/components/status-dot.tsx +7 -1
  324. package/src/components/tooltip.tsx +3 -1
  325. package/src/components/translation-review-editor.tsx +2 -2
  326. package/src/components/translation-review.tsx +2 -2
  327. package/src/components/treemap.tsx +34 -8
  328. package/src/components/use-table-state.ts +63 -8
  329. package/src/data-table.ts +3 -3
  330. package/src/demo/demo-banner.tsx +1 -1
  331. package/src/demo/demo-ended.tsx +1 -1
  332. package/src/demo/demo-start.tsx +1 -1
  333. package/src/feedback/feedback-inbox.tsx +1 -1
  334. package/src/hooks/use-dismiss.ts +43 -4
  335. package/src/hooks/use-document-title.ts +31 -0
  336. package/src/i18n/defaults.ts +2 -0
  337. package/src/i18n/german.ts +17 -0
  338. package/src/i18n/kit-labels.tsx +79 -1
  339. package/src/i18n/locales/es.ts +17 -0
  340. package/src/i18n/locales/fr.ts +17 -0
  341. package/src/i18n/locales/hu.ts +17 -0
  342. package/src/i18n/locales/it.ts +17 -0
  343. package/src/i18n/locales/zh.ts +17 -0
  344. package/src/i18n/review.ts +3 -0
  345. package/src/i18n/shared-labels.ts +1 -0
  346. package/src/index.ts +13 -0
  347. package/src/landing/landing-actions.tsx +2 -2
  348. package/src/landing/public-header.tsx +2 -2
  349. package/src/lib/clipboard-minus.ts +94 -0
  350. package/src/lib/data-attributes.ts +14 -0
  351. package/src/lib/document-title.ts +41 -0
  352. package/src/lib/float-clearance.ts +19 -3
  353. package/src/lib/format.ts +117 -30
  354. package/src/lib/minus.ts +50 -0
  355. package/src/lib/status-tone.ts +43 -0
  356. package/src/settings/settings-layout.tsx +3 -2
  357. package/src/shell/account-menu-language.tsx +14 -2
  358. package/src/shell/app-shell-labels.ts +1 -0
  359. package/src/shell/app-shell.tsx +291 -68
  360. package/src/shell/language-code.ts +5 -0
  361. package/src/shell/topbar-controls.tsx +35 -15
  362. package/src/testing/css-variables.generated.ts +6 -6
  363. package/src/testing/react.ts +59 -0
  364. package/src/theme/chart-palette.ts +102 -20
  365. package/src/theme/palette-presets.ts +121 -84
  366. package/src/tour/tour-dock.ts +132 -0
  367. package/src/tour/tour-gates.ts +195 -0
  368. package/src/tour/tour-guide-button.tsx +64 -0
  369. package/src/tour/tour-guide-labels.ts +15 -0
  370. package/src/tour/tour-labels.ts +8 -0
  371. package/src/tour/tour.tsx +949 -86
  372. package/src/tour/use-tour-url-sync.ts +14 -4
  373. package/src/tour.ts +3 -0
  374. package/src/wizard/stepper-nav.tsx +27 -7
  375. package/src/wizard/types.ts +12 -0
  376. package/src/wizard/use-wizard.ts +16 -9
  377. package/tokens.css +28 -18
@@ -0,0 +1,29 @@
1
+ "use client";
2
+ const DEFAULT_DOCUMENT_TITLE_FORMAT = "{page} \xB7 {section} \xB7 {app}";
3
+ const PLACEHOLDER = /\{(page|section|app)\}/g;
4
+ function formatDocumentTitle(format, parts) {
5
+ let prefix = "";
6
+ let out = "";
7
+ let pending = "";
8
+ let last = 0;
9
+ let seen = false;
10
+ for (const m of format.matchAll(PLACEHOLDER)) {
11
+ const literal = format.slice(last, m.index);
12
+ last = m.index + m[0].length;
13
+ if (!seen) prefix = literal;
14
+ else pending = out === "" ? "" : literal || pending;
15
+ seen = true;
16
+ const value = parts[m[1]]?.trim();
17
+ if (!value) continue;
18
+ out += (out === "" ? "" : pending) + value;
19
+ pending = "";
20
+ }
21
+ if (!seen) return format;
22
+ if (out === "") return "";
23
+ return prefix + out + format.slice(last);
24
+ }
25
+ export {
26
+ DEFAULT_DOCUMENT_TITLE_FORMAT,
27
+ formatDocumentTitle
28
+ };
29
+ //# sourceMappingURL=document-title.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/lib/document-title.ts"],"sourcesContent":["/**\n * The window's title from its parts (0.49 KIT-9): `{page}`, `{section}` and `{app}` in a\n * format, by default \"{page} · {section} · {app}\" — \"Invoices · Finance · Kastlan\".\n *\n * A part that is missing (undefined, null, empty or blank) is dropped WITH the separator\n * before it, so \"Invoices · Kastlan\" never reads \"Invoices · · Kastlan\". The text between\n * two parts is the separator; text before the first part and after the last is kept as\n * written (\"[{page} – {app}]\" → \"[KS]\"). No part at all gives \"\".\n */\nexport const DEFAULT_DOCUMENT_TITLE_FORMAT = \"{page} · {section} · {app}\";\n\n/** The parts {@link formatDocumentTitle} fills in. */\nexport interface DocumentTitleParts {\n page?: string | null;\n section?: string | null;\n app?: string | null;\n}\n\nconst PLACEHOLDER = /\\{(page|section|app)\\}/g;\n\nexport function formatDocumentTitle(format: string, parts: DocumentTitleParts): string {\n let prefix = \"\";\n let out = \"\";\n let pending = \"\";\n let last = 0;\n let seen = false;\n for (const m of format.matchAll(PLACEHOLDER)) {\n const literal = format.slice(last, m.index!);\n last = m.index! + m[0].length;\n if (!seen) prefix = literal;\n else pending = out === \"\" ? \"\" : literal || pending;\n seen = true;\n const value = parts[m[1] as keyof DocumentTitleParts]?.trim();\n if (!value) continue;\n out += (out === \"\" ? \"\" : pending) + value;\n pending = \"\";\n }\n if (!seen) return format;\n if (out === \"\") return \"\";\n return prefix + out + format.slice(last);\n}\n"],"mappings":";AASO,MAAM,gCAAgC;AAS7C,MAAM,cAAc;AAEb,SAAS,oBAAoB,QAAgB,OAAmC;AACrF,MAAI,SAAS;AACb,MAAI,MAAM;AACV,MAAI,UAAU;AACd,MAAI,OAAO;AACX,MAAI,OAAO;AACX,aAAW,KAAK,OAAO,SAAS,WAAW,GAAG;AAC5C,UAAM,UAAU,OAAO,MAAM,MAAM,EAAE,KAAM;AAC3C,WAAO,EAAE,QAAS,EAAE,CAAC,EAAE;AACvB,QAAI,CAAC,KAAM,UAAS;AAAA,QACf,WAAU,QAAQ,KAAK,KAAK,WAAW;AAC5C,WAAO;AACP,UAAM,QAAQ,MAAM,EAAE,CAAC,CAA6B,GAAG,KAAK;AAC5D,QAAI,CAAC,MAAO;AACZ,YAAQ,QAAQ,KAAK,KAAK,WAAW;AACrC,cAAU;AAAA,EACZ;AACA,MAAI,CAAC,KAAM,QAAO;AAClB,MAAI,QAAQ,GAAI,QAAO;AACvB,SAAO,SAAS,MAAM,OAAO,MAAM,IAAI;AACzC;","names":[]}
@@ -1,12 +1,18 @@
1
1
  "use client";
2
2
  import { useEffect } from "react";
3
3
  const claims = /* @__PURE__ */ new Map();
4
+ const NAV_OR_HOME_INDICATOR = "max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px))";
4
5
  function publish() {
5
6
  if (typeof document === "undefined") return;
6
7
  const root = document.documentElement;
7
8
  const values = [...claims.values()];
8
- if (values.length === 0) root.style.removeProperty("--app-float-clearance");
9
- else root.style.setProperty("--app-float-clearance", values.length === 1 ? values[0] : `max(${values.join(", ")})`);
9
+ if (values.length === 0) {
10
+ root.style.removeProperty("--app-float-clearance");
11
+ root.style.removeProperty("--kit-floating-inset");
12
+ return;
13
+ }
14
+ root.style.setProperty("--app-float-clearance", values.length === 1 ? values[0] : `max(${values.join(", ")})`);
15
+ root.style.setProperty("--kit-floating-inset", `calc(${NAV_OR_HOME_INDICATOR} + var(--app-float-clearance))`);
10
16
  }
11
17
  function claimFloatClearance(value) {
12
18
  const key = /* @__PURE__ */ Symbol("float-clearance");
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/lib/float-clearance.ts"],"sourcesContent":["import { useEffect } from \"react\";\n\n/**\n * The room the floating launchers take above the bottom nav, published as\n * `--app-float-clearance` on `<html>` so `AppShell` can pad `<main>` past them.\n *\n * keksdose dev#612 (UX-1.3, \"the help button covers content\"): the shell padded `<main>`\n * for the nav plus 1.5rem, so on a phone the last row of every page ended under the help\n * launcher — and on /transactions under the action group below it, exactly where the\n * row's amount is. keksdose patched it with its own `--help-launcher-clearance` and an\n * unlayered app.css rule. Here every kit launcher (`FloatingActionButton`, so\n * `FloatingPanel`'s too, and `FloatingActionGroup`) declares its own room while it is\n * mounted and shown, and the shell takes the largest.\n *\n * Declared, not measured: a launcher knows its `offset` and its 3rem height exactly, as\n * CSS lengths, and the variable then follows the nav and the text size by itself. A\n * `hidden` launcher keeps its claim: the room is its own while it is mounted, so the page\n * does not change length as it hides and shows (keksdose's launcher steps aside while an\n * editor covers its corner). Unmount it to give the room back.\n *\n * Internal: not exported from the barrel.\n */\n\nconst claims = new Map<symbol, string>();\n\nfunction publish() {\n if (typeof document === \"undefined\") return;\n const root = document.documentElement;\n const values = [...claims.values()];\n if (values.length === 0) root.style.removeProperty(\"--app-float-clearance\");\n else root.style.setProperty(\"--app-float-clearance\", values.length === 1 ? values[0] : `max(${values.join(\", \")})`);\n}\n\n/** Claim `value` (a CSS length: the launcher's top edge above the nav, plus a gap) until\n * the returned function is called. */\nexport function claimFloatClearance(value: string): () => void {\n const key = Symbol(\"float-clearance\");\n claims.set(key, value);\n publish();\n return () => {\n claims.delete(key);\n publish();\n };\n}\n\n/**\n * A launcher at `offset` above the nav claims its 3rem height plus a 0.5rem gap while\n * mounted (`extra`: what its frame adds, the group's 1px borders).\n */\nexport function useFloatClearance(offset: string, extra?: string): void {\n useEffect(() => {\n return claimFloatClearance(`calc(${offset} + 3.5rem${extra ? ` + ${extra}` : \"\"})`);\n }, [offset, extra]);\n}\n"],"mappings":";AAAA,SAAS,iBAAiB;AAuB1B,MAAM,SAAS,oBAAI,IAAoB;AAEvC,SAAS,UAAU;AACjB,MAAI,OAAO,aAAa,YAAa;AACrC,QAAM,OAAO,SAAS;AACtB,QAAM,SAAS,CAAC,GAAG,OAAO,OAAO,CAAC;AAClC,MAAI,OAAO,WAAW,EAAG,MAAK,MAAM,eAAe,uBAAuB;AAAA,MACrE,MAAK,MAAM,YAAY,yBAAyB,OAAO,WAAW,IAAI,OAAO,CAAC,IAAI,OAAO,OAAO,KAAK,IAAI,CAAC,GAAG;AACpH;AAIO,SAAS,oBAAoB,OAA2B;AAC7D,QAAM,MAAM,uBAAO,iBAAiB;AACpC,SAAO,IAAI,KAAK,KAAK;AACrB,UAAQ;AACR,SAAO,MAAM;AACX,WAAO,OAAO,GAAG;AACjB,YAAQ;AAAA,EACV;AACF;AAMO,SAAS,kBAAkB,QAAgB,OAAsB;AACtE,YAAU,MAAM;AACd,WAAO,oBAAoB,QAAQ,MAAM,YAAY,QAAQ,MAAM,KAAK,KAAK,EAAE,GAAG;AAAA,EACpF,GAAG,CAAC,QAAQ,KAAK,CAAC;AACpB;","names":[]}
1
+ {"version":3,"sources":["../../src/lib/float-clearance.ts"],"sourcesContent":["import { useEffect } from \"react\";\n\n/**\n * The room the floating launchers take above the bottom nav, published as\n * `--app-float-clearance` on `<html>` so `AppShell` can pad `<main>` past them, and with\n * the nav included as `--kit-floating-inset` (0.49 KIT-11, below).\n *\n * keksdose dev#612 (UX-1.3, \"the help button covers content\"): the shell padded `<main>`\n * for the nav plus 1.5rem, so on a phone the last row of every page ended under the help\n * launcher — and on /transactions under the action group below it, exactly where the\n * row's amount is. keksdose patched it with its own `--help-launcher-clearance` and an\n * unlayered app.css rule. Here every kit launcher (`FloatingActionButton`, so\n * `FloatingPanel`'s too, and `FloatingActionGroup`) declares its own room while it is\n * mounted and shown, and the shell takes the largest.\n *\n * Declared, not measured: a launcher knows its `offset` and its 3rem height exactly, as\n * CSS lengths, and the variable then follows the nav and the text size by itself. A\n * `hidden` launcher keeps its claim: the room is its own while it is mounted, so the page\n * does not change length as it hides and shows (keksdose's launcher steps aside while an\n * editor covers its corner). Unmount it to give the room back.\n *\n * Internal: not exported from the barrel.\n */\n\nconst claims = new Map<symbol, string>();\n\n/**\n * 0.49 KIT-11: `--kit-floating-inset` is the same room measured from the screen's bottom\n * edge — the phone's bottom nav (`--app-nav-h`, 0px from `md` up) or the home indicator,\n * whichever is taller, plus `--app-float-clearance` — so a page's own fixed or sticky\n * footer, or its last row, can clear the launchers with one length at every width.\n * Unset while no launcher is mounted, like `--app-float-clearance`: write\n * `var(--kit-floating-inset, var(--app-nav-h, 0px))`.\n */\nconst NAV_OR_HOME_INDICATOR = \"max(var(--app-nav-h, 0px), env(safe-area-inset-bottom, 0px))\";\n\nfunction publish() {\n if (typeof document === \"undefined\") return;\n const root = document.documentElement;\n const values = [...claims.values()];\n if (values.length === 0) {\n root.style.removeProperty(\"--app-float-clearance\");\n root.style.removeProperty(\"--kit-floating-inset\");\n return;\n }\n root.style.setProperty(\"--app-float-clearance\", values.length === 1 ? values[0] : `max(${values.join(\", \")})`);\n root.style.setProperty(\"--kit-floating-inset\", `calc(${NAV_OR_HOME_INDICATOR} + var(--app-float-clearance))`);\n}\n\n/** Claim `value` (a CSS length: the launcher's top edge above the nav, plus a gap) until\n * the returned function is called. */\nexport function claimFloatClearance(value: string): () => void {\n const key = Symbol(\"float-clearance\");\n claims.set(key, value);\n publish();\n return () => {\n claims.delete(key);\n publish();\n };\n}\n\n/**\n * A launcher at `offset` above the nav claims its 3rem height plus a 0.5rem gap while\n * mounted (`extra`: what its frame adds, the group's 1px borders).\n */\nexport function useFloatClearance(offset: string, extra?: string): void {\n useEffect(() => {\n return claimFloatClearance(`calc(${offset} + 3.5rem${extra ? ` + ${extra}` : \"\"})`);\n }, [offset, extra]);\n}\n"],"mappings":";AAAA,SAAS,iBAAiB;AAwB1B,MAAM,SAAS,oBAAI,IAAoB;AAUvC,MAAM,wBAAwB;AAE9B,SAAS,UAAU;AACjB,MAAI,OAAO,aAAa,YAAa;AACrC,QAAM,OAAO,SAAS;AACtB,QAAM,SAAS,CAAC,GAAG,OAAO,OAAO,CAAC;AAClC,MAAI,OAAO,WAAW,GAAG;AACvB,SAAK,MAAM,eAAe,uBAAuB;AACjD,SAAK,MAAM,eAAe,sBAAsB;AAChD;AAAA,EACF;AACA,OAAK,MAAM,YAAY,yBAAyB,OAAO,WAAW,IAAI,OAAO,CAAC,IAAI,OAAO,OAAO,KAAK,IAAI,CAAC,GAAG;AAC7G,OAAK,MAAM,YAAY,wBAAwB,QAAQ,qBAAqB,gCAAgC;AAC9G;AAIO,SAAS,oBAAoB,OAA2B;AAC7D,QAAM,MAAM,uBAAO,iBAAiB;AACpC,SAAO,IAAI,KAAK,KAAK;AACrB,UAAQ;AACR,SAAO,MAAM;AACX,WAAO,OAAO,GAAG;AACjB,YAAQ;AAAA,EACV;AACF;AAMO,SAAS,kBAAkB,QAAgB,OAAsB;AACtE,YAAU,MAAM;AACd,WAAO,oBAAoB,QAAQ,MAAM,YAAY,QAAQ,MAAM,KAAK,KAAK,EAAE,GAAG;AAAA,EACpF,GAAG,CAAC,QAAQ,KAAK,CAAC;AACpB;","names":[]}
@@ -1,3 +1,6 @@
1
+ import { type MinusStyle } from "./minus.js";
2
+ export type { MinusStyle } from "./minus.js";
3
+ export { formatWithMinus } from "./minus.js";
1
4
  /**
2
5
  * Number, money, percentage, date and relative-time formatting — `Intl`, in one place,
3
6
  * for the figures an app prints OUTSIDE a kit component.
@@ -14,9 +17,18 @@
14
17
  * functions bound to the `<UiKitProvider locale>`, so a page formats exactly like the
15
18
  * kit components on it.
16
19
  *
17
- * A missing value — `null`, `undefined`, `NaN`, an unparseable date — formats as
18
- * `empty` (default "—", the dash `StatTile` shows for no value), never as "NaN" or
19
- * "Invalid Date".
20
+ * A missing value — `null`, `undefined`, an unparseable date — formats as `empty`
21
+ * (default "—", the dash `StatTile` shows for no value), never as "NaN" or "Invalid
22
+ * Date". A number may also arrive as a numeric STRING (0.49, KIT-2) — "1850.00", "-12.5",
23
+ * "0": a Decimal field off a Python API — and is formatted like the number. Anything
24
+ * else (a non-numeric string, an object, `NaN`, ±`Infinity`) prints `empty` too, but is
25
+ * a bug upstream rather than a gap in the data, so a development build says so on the
26
+ * console, once per call site. Zero is a figure: `0` and `"0"` print "CHF 0.00", never
27
+ * the dash.
28
+ *
29
+ * A negative figure carries the typographic minus, U+2212 (0.49, KIT-1), in every
30
+ * locale — `Intl` writes a hyphen in most. `minus: "ascii"` for text a machine reads
31
+ * back (a CSV, the clipboard).
20
32
  */
21
33
  /** What every formatter here prints for a missing value, unless told otherwise. */
22
34
  export declare const EMPTY_FORMATTED_VALUE = "\u2014";
@@ -41,6 +53,14 @@ export interface FormatNumberBaseOptions extends BaseOptions {
41
53
  signDisplay?: Intl.NumberFormatOptions["signDisplay"];
42
54
  /** Any further `Intl.NumberFormat` options, merged over the ones above. */
43
55
  options?: Intl.NumberFormatOptions;
56
+ /**
57
+ * The minus sign of a negative figure (0.49, KIT-1). `"typographic"` (default):
58
+ * U+2212, as wide as "+" and the digits, as `SignedAmount` prints it — in every
59
+ * locale, also where `Intl` writes a hyphen. `"ascii"`: U+002D, for an export a
60
+ * machine parses back (a CSV download, the clipboard) — there, also pass
61
+ * `options: { useGrouping: false }` or write the raw number.
62
+ */
63
+ minus?: MinusStyle;
44
64
  }
45
65
  /** The options {@link formatNumber} takes: the shared ones plus a free-text `unit`. */
46
66
  export interface FormatNumberOptions extends FormatNumberBaseOptions {
@@ -71,13 +91,20 @@ export interface FormatPercentOptions extends FormatNumberBaseOptions {
71
91
  */
72
92
  ratio?: boolean;
73
93
  }
94
+ /**
95
+ * What the number formatters take (0.49, KIT-2): a number, a numeric string ("1850.00",
96
+ * "-12.5", "1e3" — what a Decimal arrives as in JSON), or `null` / `undefined` for no
97
+ * value. A string is read with `Number()`, so a figure with more significant digits than
98
+ * a double holds (about 15–17) is printed as its nearest double.
99
+ */
100
+ export type FormattableNumber = number | string | null | undefined;
74
101
  /** A number in `locale`: "1,234.5", "1.234,5", "1’234.5" — with `unit`, "3.400 N". */
75
- export declare function formatNumber(value: number | null | undefined, options?: FormatNumberOptions): string;
102
+ export declare function formatNumber(value: FormattableNumber, options?: FormatNumberOptions): string;
76
103
  /** An amount of `currency` (ISO 4217) in `locale`: "CHF 1’234.50", "1.234,50 €". */
77
- export declare function formatMoney(value: number | null | undefined, currency: string, options?: FormatMoneyOptions): string;
104
+ export declare function formatMoney(value: FormattableNumber, currency: string, options?: FormatMoneyOptions): string;
78
105
  /** A percentage in `locale` ("12%", "12 %", "+1.25%"). See {@link FormatPercentOptions.ratio}
79
106
  * for what `value` means. Default digits: at most one. */
80
- export declare function formatPercent(value: number | null | undefined, options?: FormatPercentOptions): string;
107
+ export declare function formatPercent(value: FormattableNumber, options?: FormatPercentOptions): string;
81
108
  /**
82
109
  * The shapes a date is printed in, each a set of `Intl.DateTimeFormat` options:
83
110
  * - `short` / `medium` / `long` / `full` — the date alone, at that `dateStyle`
@@ -148,9 +175,9 @@ type WithoutLocale<T> = Omit<T, "locale"> & {
148
175
  export interface KitFormat {
149
176
  /** The locale they format in — the provider's, or `undefined` (runtime default). */
150
177
  locale: string | undefined;
151
- formatNumber: (value: number | null | undefined, options?: WithoutLocale<FormatNumberOptions>) => string;
152
- formatMoney: (value: number | null | undefined, currency: string, options?: WithoutLocale<FormatMoneyOptions>) => string;
153
- formatPercent: (value: number | null | undefined, options?: WithoutLocale<FormatPercentOptions>) => string;
178
+ formatNumber: (value: FormattableNumber, options?: WithoutLocale<FormatNumberOptions>) => string;
179
+ formatMoney: (value: FormattableNumber, currency: string, options?: WithoutLocale<FormatMoneyOptions>) => string;
180
+ formatPercent: (value: FormattableNumber, options?: WithoutLocale<FormatPercentOptions>) => string;
154
181
  formatDate: (value: DateInput, style?: FormatDateStyle | Intl.DateTimeFormatOptions, options?: WithoutLocale<FormatDateOptions>) => string;
155
182
  formatRelativeTime: (value: DateInput, options?: WithoutLocale<FormatRelativeTimeOptions>) => string;
156
183
  }
@@ -193,4 +220,3 @@ export interface RoundToCurrencyOptions {
193
220
  * 0.1 + 0.2 - 0.3 → 0, -0.004 → 0.
194
221
  */
195
222
  export declare function roundToCurrency(value: number, currency: string | null | undefined, digits?: number | RoundToCurrencyOptions): number;
196
- export {};
@@ -2,7 +2,9 @@
2
2
  import { useMemo } from "react";
3
3
  import { useKitLocale, useKitTimeZone } from "../i18n/kit-labels.js";
4
4
  import { parseIsoDate } from "./dates.js";
5
+ import { formatWithMinus } from "./minus.js";
5
6
  import { roundHalfAway } from "./round.js";
7
+ import { formatWithMinus as formatWithMinus2 } from "./minus.js";
6
8
  const EMPTY_FORMATTED_VALUE = "\u2014";
7
9
  const UNIT_SEPARATOR = "\u202F";
8
10
  const numberFormatters = /* @__PURE__ */ new Map();
@@ -31,31 +33,74 @@ function numberOptions(o) {
31
33
  ...o.options
32
34
  };
33
35
  }
34
- function isMissing(value) {
35
- return value === null || value === void 0 || !Number.isFinite(value);
36
+ const NUMERIC_STRING = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[eE][+-]?\d+)?$/;
37
+ function devWarnings() {
38
+ return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
39
+ }
40
+ const warnedSites = /* @__PURE__ */ new Set();
41
+ const OWN_FRAME = /\b(?:formatNumber|formatMoney|formatPercent|toFormattable|warnInvalid|callSite)\b/;
42
+ function callSite() {
43
+ const frames = (new Error().stack ?? "").split("\n").slice(1);
44
+ return frames.find((frame) => !OWN_FRAME.test(frame))?.trim() ?? "";
45
+ }
46
+ function describeValue(value) {
47
+ if (typeof value === "number" || typeof value === "bigint" || typeof value === "boolean") return String(value);
48
+ try {
49
+ return JSON.stringify(value) ?? String(value);
50
+ } catch {
51
+ return Object.prototype.toString.call(value);
52
+ }
53
+ }
54
+ function warnInvalid(fn, value) {
55
+ if (!devWarnings()) return;
56
+ const site = callSite();
57
+ const key = `${fn}|${site || typeof value}`;
58
+ if (warnedSites.has(key)) return;
59
+ warnedSites.add(key);
60
+ const type = Array.isArray(value) ? "array" : typeof value;
61
+ console.warn(
62
+ `[ui-kit] ${fn} got ${describeValue(value)} (${type}), which is not a number: printed the empty value. Pass a number, a numeric string, or null/undefined for a missing value.` + (site ? `
63
+ at ${site.replace(/^at /, "")}` : "")
64
+ );
65
+ }
66
+ function toFormattable(fn, value) {
67
+ if (value === null || value === void 0) return void 0;
68
+ let n;
69
+ if (typeof value === "number") n = value;
70
+ else if (typeof value === "string" && NUMERIC_STRING.test(value.trim())) n = Number(value.trim());
71
+ if (n === void 0 || !Number.isFinite(n)) {
72
+ warnInvalid(fn, value);
73
+ return void 0;
74
+ }
75
+ return n + 0;
36
76
  }
37
77
  function formatNumber(value, options = {}) {
38
- if (isMissing(value)) return options.empty ?? EMPTY_FORMATTED_VALUE;
39
- const figure = numberFormatter(options.locale, numberOptions(options)).format(value);
78
+ const n = toFormattable("formatNumber", value);
79
+ if (n === void 0) return options.empty ?? EMPTY_FORMATTED_VALUE;
80
+ const figure = formatWithMinus(numberFormatter(options.locale, numberOptions(options)), n, options.minus);
40
81
  return options.unit ? `${figure}${UNIT_SEPARATOR}${options.unit}` : figure;
41
82
  }
42
83
  function formatMoney(value, currency, options = {}) {
43
- if (isMissing(value)) return options.empty ?? EMPTY_FORMATTED_VALUE;
44
- return numberFormatter(options.locale, {
84
+ const n = toFormattable("formatMoney", value);
85
+ if (n === void 0) return options.empty ?? EMPTY_FORMATTED_VALUE;
86
+ const formatter = numberFormatter(options.locale, {
45
87
  style: "currency",
46
88
  currency,
47
89
  ...options.currencyDisplay ? { currencyDisplay: options.currencyDisplay } : null,
48
90
  ...numberOptions(options)
49
- }).format(value);
91
+ });
92
+ return formatWithMinus(formatter, n, options.minus);
50
93
  }
51
94
  function formatPercent(value, options = {}) {
52
- if (isMissing(value)) return options.empty ?? EMPTY_FORMATTED_VALUE;
53
- const ratio = options.ratio === false ? value / 100 : value;
54
- return numberFormatter(options.locale, {
95
+ const n = toFormattable("formatPercent", value);
96
+ if (n === void 0) return options.empty ?? EMPTY_FORMATTED_VALUE;
97
+ const ratio = options.ratio === false ? n / 100 : n;
98
+ const formatter = numberFormatter(options.locale, {
55
99
  style: "percent",
56
100
  ...options.digits === void 0 ? { maximumFractionDigits: 1 } : null,
57
101
  ...numberOptions(options)
58
- }).format(ratio);
102
+ });
103
+ return formatWithMinus(formatter, ratio, options.minus);
59
104
  }
60
105
  const DATE_STYLES = {
61
106
  short: { dateStyle: "short" },
@@ -167,6 +212,7 @@ export {
167
212
  formatNumber,
168
213
  formatPercent,
169
214
  formatRelativeTime,
215
+ formatWithMinus2 as formatWithMinus,
170
216
  roundToCurrency,
171
217
  toDate,
172
218
  useKitFormat
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/lib/format.ts"],"sourcesContent":["import { useMemo } from \"react\";\nimport { useKitLocale, useKitTimeZone } from \"../i18n/kit-labels\";\nimport { parseIsoDate } from \"./dates\";\nimport { roundHalfAway } from \"./round\";\n\n/**\n * Number, money, percentage, date and relative-time formatting — `Intl`, in one place,\n * for the figures an app prints OUTSIDE a kit component.\n *\n * Each app grew its own: kastlan's `utils/formatters.ts` (a Swiss-pinned\n * `formatNumber`/`formatCurrency`, a date-fns `formatDate`, and a `formatRelativeTime`\n * whose \"5m ago\" / \"3h ago\" / \"2d ago\" stayed English in every language), its tenancy\n * `format.ts` (`formatChf`, a `formatPct` gluing a \"+\" onto `toFixed(2)`), keksdose's\n * own set. Every one of them is `Intl` with a locale bolted on, so the only thing\n * worth sharing is the locale — and the kit already has one, the provider's.\n *\n * The plain functions take a `locale` (none: the runtime default, as every `Intl` API\n * reads `undefined`). Inside a component, {@link useKitFormat} hands back the same\n * functions bound to the `<UiKitProvider locale>`, so a page formats exactly like the\n * kit components on it.\n *\n * A missing value — `null`, `undefined`, `NaN`, an unparseable date — formats as\n * `empty` (default \"—\", the dash `StatTile` shows for no value), never as \"NaN\" or\n * \"Invalid Date\".\n */\n\n/** What every formatter here prints for a missing value, unless told otherwise. */\nexport const EMPTY_FORMATTED_VALUE = \"—\";\n\n/** Fraction digits: a number pins both the minimum and the maximum (`2` → \"3.10\");\n * an object sets them apart (a meter reading, 1–3). */\nexport type FormatDigits = number | { min?: number; max?: number };\n\ninterface BaseOptions {\n /** BCP 47 tag. Left out: the runtime default (the hook: the provider's). */\n locale?: string;\n /** Printed for a missing value. Default {@link EMPTY_FORMATTED_VALUE}. */\n empty?: string;\n}\n\n/** The options every number formatter shares (money and percent carry their own unit). */\nexport interface FormatNumberBaseOptions extends BaseOptions {\n digits?: FormatDigits;\n /** Compact notation: 12 400 → \"12K\". */\n compact?: boolean;\n /** `\"exceptZero\"` for a change (\"+3\", \"−3\", \"0\"). Default: the minus only. */\n signDisplay?: Intl.NumberFormatOptions[\"signDisplay\"];\n /** Any further `Intl.NumberFormat` options, merged over the ones above. */\n options?: Intl.NumberFormatOptions;\n}\n\n/** The options {@link formatNumber} takes: the shared ones plus a free-text `unit`. */\nexport interface FormatNumberOptions extends FormatNumberBaseOptions {\n /**\n * A unit printed after the figure, joined by a narrow no-break space (U+202F):\n * \"3.400 N\", \"12 kg\", \"4,5 m²\". lenkbank (P8) prints forces, masses and lengths in\n * every table and summary, and each call site glued `${n} N` together with a plain\n * space — which wraps the \"N\" onto the next line in a narrow column and reads loose\n * beside the digit grouping. The narrow no-break space is the SI brochure's and the\n * Swiss typesetting rule's separator between value and unit, and it never breaks.\n *\n * Free text, not `Intl`'s `style: \"unit\"`: that one only knows the ECMA-402\n * sanctioned units (no \"N\", no \"kN\", no \"m³/h\"), so an app would still be gluing.\n * A missing value prints `empty` ALONE — \"— N\" says there is a newton-shaped\n * nothing, which is noise in a column that already heads its unit.\n */\n unit?: string;\n}\n\n/** Narrow no-break space (U+202F): between a figure and its {@link FormatNumberOptions.unit}. */\nconst UNIT_SEPARATOR = \"\\u202F\";\n\nexport interface FormatMoneyOptions extends FormatNumberBaseOptions {\n /** `\"symbol\"` (default), `\"narrowSymbol\"`, `\"code\"` (\"CHF 12.00\") or `\"name\"`. */\n currencyDisplay?: Intl.NumberFormatOptions[\"currencyDisplay\"];\n}\n\nexport interface FormatPercentOptions extends FormatNumberBaseOptions {\n /**\n * `true` (default): `value` is a RATIO, as `Intl` and `StatTile`'s percent delta read\n * it — `0.12` is 12 %. `false`: it is already a percentage — `12` is 12 %\n * (kastlan's rent `change_pct`).\n */\n ratio?: boolean;\n}\n\n// One formatter per option set: a table formats the same way thousands of times, and\n// constructing an Intl formatter is the expensive part.\nconst numberFormatters = new Map<string, Intl.NumberFormat>();\nfunction numberFormatter(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat {\n const key = `${locale ?? \"\"}|${JSON.stringify(options)}`;\n let f = numberFormatters.get(key);\n if (!f) {\n f = new Intl.NumberFormat(locale, options);\n numberFormatters.set(key, f);\n }\n return f;\n}\n\nfunction digitOptions(digits: FormatDigits | undefined): Intl.NumberFormatOptions {\n if (digits === undefined) return {};\n if (typeof digits === \"number\") return { minimumFractionDigits: digits, maximumFractionDigits: digits };\n return {\n ...(digits.min !== undefined ? { minimumFractionDigits: digits.min } : null),\n ...(digits.max !== undefined ? { maximumFractionDigits: digits.max } : null),\n };\n}\n\nfunction numberOptions(o: FormatNumberBaseOptions): Intl.NumberFormatOptions {\n return {\n ...digitOptions(o.digits),\n ...(o.compact ? { notation: \"compact\" } : null),\n ...(o.signDisplay ? { signDisplay: o.signDisplay } : null),\n ...o.options,\n };\n}\n\nfunction isMissing(value: number | null | undefined): value is null | undefined {\n // ±Infinity too (lenkbank, adopting 0.13): a division by zero upstream is a missing\n // figure, and \"∞\" printed in a money or measurement column reads like data.\n return value === null || value === undefined || !Number.isFinite(value);\n}\n\n/** A number in `locale`: \"1,234.5\", \"1.234,5\", \"1’234.5\" — with `unit`, \"3.400 N\". */\nexport function formatNumber(value: number | null | undefined, options: FormatNumberOptions = {}): string {\n if (isMissing(value)) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const figure = numberFormatter(options.locale, numberOptions(options)).format(value);\n return options.unit ? `${figure}${UNIT_SEPARATOR}${options.unit}` : figure;\n}\n\n/** An amount of `currency` (ISO 4217) in `locale`: \"CHF 1’234.50\", \"1.234,50 €\". */\nexport function formatMoney(\n value: number | null | undefined,\n currency: string,\n options: FormatMoneyOptions = {},\n): string {\n if (isMissing(value)) return options.empty ?? EMPTY_FORMATTED_VALUE;\n return numberFormatter(options.locale, {\n style: \"currency\",\n currency,\n ...(options.currencyDisplay ? { currencyDisplay: options.currencyDisplay } : null),\n ...numberOptions(options),\n }).format(value);\n}\n\n/** A percentage in `locale` (\"12%\", \"12 %\", \"+1.25%\"). See {@link FormatPercentOptions.ratio}\n * for what `value` means. Default digits: at most one. */\nexport function formatPercent(value: number | null | undefined, options: FormatPercentOptions = {}): string {\n if (isMissing(value)) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const ratio = options.ratio === false ? value / 100 : value;\n return numberFormatter(options.locale, {\n style: \"percent\",\n ...(options.digits === undefined ? { maximumFractionDigits: 1 } : null),\n ...numberOptions(options),\n }).format(ratio);\n}\n\n/* ── Dates ─────────────────────────────────────────────────────────────────── */\n\n/**\n * The shapes a date is printed in, each a set of `Intl.DateTimeFormat` options:\n * - `short` / `medium` / `long` / `full` — the date alone, at that `dateStyle`\n * (\"08/07/2026\", \"8 Jul 2026\", \"8 July 2026\", \"Wednesday, 8 July 2026\" in en-GB).\n * - `dateTime` — medium date and short time: a timestamp (\"8 Jul 2026, 14:05\").\n * - `time` — the short time alone (\"14:05\").\n * - `monthYear` — \"July 2026\".\n *\n * Or pass `Intl.DateTimeFormatOptions` for anything else. A date-fns PATTERN\n * (\"dd.MM.yyyy\") is deliberately not accepted: a pattern is one locale's order baked\n * into a string, which is how an English page ends up with German dates.\n */\nexport type FormatDateStyle = \"short\" | \"medium\" | \"long\" | \"full\" | \"dateTime\" | \"time\" | \"monthYear\";\n\nconst DATE_STYLES: Record<FormatDateStyle, Intl.DateTimeFormatOptions> = {\n short: { dateStyle: \"short\" },\n medium: { dateStyle: \"medium\" },\n long: { dateStyle: \"long\" },\n full: { dateStyle: \"full\" },\n dateTime: { dateStyle: \"medium\", timeStyle: \"short\" },\n time: { timeStyle: \"short\" },\n monthYear: { month: \"long\", year: \"numeric\" },\n};\n\nexport interface FormatDateOptions extends BaseOptions {\n /** An IANA zone (\"Europe/Zurich\") for a timestamp. Left out: the device's. A bare\n * \"YYYY-MM-DD\" ignores it: a calendar day is the day it names in every zone. */\n timeZone?: string;\n}\n\n/** A bare ISO calendar day, `\"YYYY-MM-DD\"`. */\nconst BARE_ISO_DAY = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/** What {@link formatDate} and {@link formatRelativeTime} accept. */\nexport type DateInput = string | number | Date | null | undefined;\n\n/**\n * `value` as a Date, or null. A bare \"YYYY-MM-DD\" is a CALENDAR day and is read on the\n * local calendar (`parseIsoDate`) — `new Date(\"2026-07-08\")` is UTC midnight, which is\n * the 7th anywhere west of Greenwich. Anything else (a full ISO timestamp, epoch\n * milliseconds, a Date) is an instant and goes through `Date` as is.\n */\nexport function toDate(value: DateInput): Date | null {\n if (value === null || value === undefined || value === \"\") return null;\n if (typeof value === \"string\" && BARE_ISO_DAY.test(value)) return parseIsoDate(value);\n const d = value instanceof Date ? value : new Date(value);\n return Number.isNaN(d.getTime()) ? null : d;\n}\n\nconst dateFormatters = new Map<string, Intl.DateTimeFormat>();\nfunction dateFormatter(locale: string | undefined, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {\n const key = `${locale ?? \"\"}|${JSON.stringify(options)}`;\n let f = dateFormatters.get(key);\n if (!f) {\n f = new Intl.DateTimeFormat(locale, options);\n dateFormatters.set(key, f);\n }\n return f;\n}\n\n/**\n * A date or timestamp in `locale`. Takes an ISO date (\"2026-07-08\", read on the local\n * calendar), an ISO timestamp (\"2026-07-08T14:05:00Z\"), epoch milliseconds or a Date.\n * `style` defaults to `medium`; see {@link FormatDateStyle}.\n *\n * `formatIsoDate` (`@eifi1/ui-kit/dates`) remains for a plain \"YYYY-MM-DD\" with an\n * explicit locale; this is its superset for timestamps and the named styles.\n */\nexport function formatDate(\n value: DateInput,\n style: FormatDateStyle | Intl.DateTimeFormatOptions = \"medium\",\n options: FormatDateOptions = {},\n): string {\n const d = toDate(value);\n if (!d) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const base = typeof style === \"string\" ? DATE_STYLES[style] : style;\n // A bare \"YYYY-MM-DD\" is a calendar day, which has no zone: it is the day it names\n // wherever it is read, so a `timeZone` (in `options` or the style) never moves it — the\n // device's local midnight shown in a zone west of it was the day before (round 0.45, N9).\n if (typeof value === \"string\" && BARE_ISO_DAY.test(value)) {\n const day = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));\n return dateFormatter(options.locale, { ...base, timeZone: \"UTC\" }).format(day);\n }\n return dateFormatter(options.locale, options.timeZone ? { ...base, timeZone: options.timeZone } : base).format(d);\n}\n\nexport interface FormatRelativeTimeOptions extends BaseOptions {\n /** The zone of the date printed past `absoluteAfterDays` (0.46). Left out: the\n * device's. */\n timeZone?: string;\n /** The moment it is measured from. Default: now. */\n now?: DateInput;\n /** `\"auto\"` (default): \"yesterday\", \"now\", \"next week\". `\"always\"`: \"1 day ago\". */\n numeric?: Intl.RelativeTimeFormatNumeric;\n /** `\"long\"` (default) \"3 hours ago\", `\"short\"` \"3 hr. ago\", `\"narrow\"` \"3h ago\". */\n style?: Intl.RelativeTimeFormatStyle;\n /**\n * Past this many days either way, print the date instead ({@link formatDate} in\n * `absoluteStyle`) — kastlan's feed switches to the date after a week, because \"5\n * weeks ago\" makes a reader do sums. Default: never.\n */\n absoluteAfterDays?: number;\n /** The style of that date. Default `medium`. */\n absoluteStyle?: FormatDateStyle | Intl.DateTimeFormatOptions;\n}\n\nconst SECOND = 1000;\nconst MINUTE = 60 * SECOND;\nconst HOUR = 60 * MINUTE;\nconst DAY = 24 * HOUR;\n\n/** [the largest |difference| a unit is used for, the unit, its length]. */\nconst RELATIVE_STEPS: Array<[number, Intl.RelativeTimeFormatUnit, number]> = [\n [45 * SECOND, \"second\", SECOND],\n [45 * MINUTE, \"minute\", MINUTE],\n [22 * HOUR, \"hour\", HOUR],\n [6.5 * DAY, \"day\", DAY],\n [26 * DAY, \"week\", 7 * DAY],\n [320 * DAY, \"month\", 30.44 * DAY],\n [Infinity, \"year\", 365.25 * DAY],\n];\n\nconst relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();\nfunction relativeFormatter(\n locale: string | undefined,\n numeric: Intl.RelativeTimeFormatNumeric,\n style: Intl.RelativeTimeFormatStyle,\n): Intl.RelativeTimeFormat {\n const key = `${locale ?? \"\"}|${numeric}|${style}`;\n let f = relativeFormatters.get(key);\n if (!f) {\n f = new Intl.RelativeTimeFormat(locale, { numeric, style });\n relativeFormatters.set(key, f);\n }\n return f;\n}\n\n/**\n * How long ago (or until) `value` is, in `locale`'s words, through\n * `Intl.RelativeTimeFormat`: \"3 hours ago\", \"vor 3 Stunden\", \"il y a 3 heures\",\n * \"in 2 days\", and under 45 seconds \"now\". The unit is picked by size — seconds,\n * minutes, hours, days, weeks, months, years — and the count is rounded.\n */\nexport function formatRelativeTime(value: DateInput, options: FormatRelativeTimeOptions = {}): string {\n const d = toDate(value);\n if (!d) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const now = toDate(options.now ?? new Date()) ?? new Date();\n const diff = d.getTime() - now.getTime();\n const abs = Math.abs(diff);\n if (options.absoluteAfterDays !== undefined && abs > options.absoluteAfterDays * DAY) {\n return formatDate(d, options.absoluteStyle ?? \"medium\", options);\n }\n const rtf = relativeFormatter(options.locale, options.numeric ?? \"auto\", options.style ?? \"long\");\n const [, unit, size] = RELATIVE_STEPS.find(([limit]) => abs < limit)!;\n // Under 45 seconds is \"now\" rather than \"12 seconds ago\": a feed that ticks through\n // the seconds is noise, and nothing reads a timestamp to the second.\n const count = unit === \"second\" ? 0 : Math.round(diff / size);\n // `+ 0` folds -0 into 0: `format(-0, …)` says \"0 seconds ago\" instead of \"now\".\n return rtf.format(count + 0, unit);\n}\n\n/* ── Bound to the provider ─────────────────────────────────────────────────── */\n\ntype WithoutLocale<T> = Omit<T, \"locale\"> & { locale?: string };\n\n/** What {@link useKitFormat} returns: the formatters above with the provider's locale\n * as their default (an explicit `locale` in the options still wins). */\nexport interface KitFormat {\n /** The locale they format in — the provider's, or `undefined` (runtime default). */\n locale: string | undefined;\n formatNumber: (value: number | null | undefined, options?: WithoutLocale<FormatNumberOptions>) => string;\n formatMoney: (\n value: number | null | undefined,\n currency: string,\n options?: WithoutLocale<FormatMoneyOptions>,\n ) => string;\n formatPercent: (value: number | null | undefined, options?: WithoutLocale<FormatPercentOptions>) => string;\n formatDate: (\n value: DateInput,\n style?: FormatDateStyle | Intl.DateTimeFormatOptions,\n options?: WithoutLocale<FormatDateOptions>,\n ) => string;\n formatRelativeTime: (value: DateInput, options?: WithoutLocale<FormatRelativeTimeOptions>) => string;\n}\n\n/**\n * The formatters, bound to the `<UiKitProvider locale>` (or to `locale`, which wins\n * over it — the same precedence as a component's `locale` prop):\n *\n * const { formatMoney, formatRelativeTime } = useKitFormat();\n * formatMoney(row.balance, \"CHF\");\n */\nexport function useKitFormat(locale?: string): KitFormat {\n const kitLocale = useKitLocale(locale);\n // 0.46 (§10.3): the provider's `timeZone` for timestamps; a call's own wins, and a bare\n // \"YYYY-MM-DD\" ignores it (formatDate).\n const timeZone = useKitTimeZone();\n return useMemo<KitFormat>(\n () => ({\n locale: kitLocale,\n formatNumber: (value, options) => formatNumber(value, { locale: kitLocale, ...options }),\n formatMoney: (value, currency, options) => formatMoney(value, currency, { locale: kitLocale, ...options }),\n formatPercent: (value, options) => formatPercent(value, { locale: kitLocale, ...options }),\n formatDate: (value, style, options) => formatDate(value, style, { locale: kitLocale, timeZone, ...options }),\n formatRelativeTime: (value, options) => formatRelativeTime(value, { locale: kitLocale, timeZone, ...options }),\n }),\n [kitLocale, timeZone],\n );\n}\n\n/* ── Currency minor units ──────────────────────────────────────────────────── */\n\n/**\n * The decimals a currency is written with — its minor unit: CHF and EUR 2, JPY 0,\n * KWD 3 — from `Intl`, or `undefined` for a code `Intl` does not accept. The rule\n * AmountInput settles a typed figure by, for a host that needs it outside the field.\n */\nexport function currencyMinorDigits(currency: string | null | undefined): number | undefined {\n if (!currency) return undefined;\n try {\n return new Intl.NumberFormat(\"en\", { style: \"currency\", currency }).resolvedOptions().maximumFractionDigits;\n } catch {\n return undefined;\n }\n}\n\n/** The options form of {@link roundToCurrency}'s third argument. */\nexport interface RoundToCurrencyOptions {\n /** Decimals to round to, whatever the currency — the plain `digits` argument. */\n digits?: number;\n /**\n * Decimals for a currency `Intl` cannot resolve — no code yet (`null`, `\"\"`), or a\n * malformed one (\"EURO\", \"€\"). Left out, such a value comes back unchanged, as\n * before. Note that `Intl` resolves ANY well-formed three-letter code, a made-up\n * \"XYZ\" included (to 2), so this is the case of a missing or broken code, not of a\n * rare currency.\n */\n fallbackDigits?: number;\n}\n\n/**\n * `value` rounded to the currency's minor unit (or to `digits`), half away from zero,\n * in decimal arithmetic: 93.4213 CHF → 93.42, 1.005 EUR → 1.01 (`Math.round(1.005 *\n * 100)` is 100, since 1.005 is 1.00499… in binary). A non-finite value, or a currency\n * `Intl` does not know without `digits`, comes back unchanged (keksdose live #356: an\n * FX estimate set into an AmountInput showed four decimals) — unless the options form\n * gives `fallbackDigits`: `roundToCurrency(v, code, { fallbackDigits: 2 })` rounds a\n * figure whose currency is not chosen yet to cents rather than leaving 0.1 + 0.2 as is.\n * A finite value never comes back NaN or -0, however small or large (BUG-11):\n * 0.1 + 0.2 - 0.3 → 0, -0.004 → 0.\n */\nexport function roundToCurrency(\n value: number,\n currency: string | null | undefined,\n digits?: number | RoundToCurrencyOptions,\n): number {\n const options = typeof digits === \"object\" && digits !== null ? digits : { digits };\n const places = options.digits ?? currencyMinorDigits(currency) ?? options.fallbackDigits;\n if (places === undefined) return value;\n return roundHalfAway(value, places);\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/lib/format.ts"],"sourcesContent":["import { useMemo } from \"react\";\nimport { useKitLocale, useKitTimeZone } from \"../i18n/kit-labels\";\nimport { parseIsoDate } from \"./dates\";\nimport { formatWithMinus, type MinusStyle } from \"./minus\";\nimport { roundHalfAway } from \"./round\";\n\nexport type { MinusStyle } from \"./minus\";\n// 0.50 (KIT-1 follow-up): the kit's minus for an app's OWN `Intl.NumberFormat` wrapper.\nexport { formatWithMinus } from \"./minus\";\n\n/**\n * Number, money, percentage, date and relative-time formatting — `Intl`, in one place,\n * for the figures an app prints OUTSIDE a kit component.\n *\n * Each app grew its own: kastlan's `utils/formatters.ts` (a Swiss-pinned\n * `formatNumber`/`formatCurrency`, a date-fns `formatDate`, and a `formatRelativeTime`\n * whose \"5m ago\" / \"3h ago\" / \"2d ago\" stayed English in every language), its tenancy\n * `format.ts` (`formatChf`, a `formatPct` gluing a \"+\" onto `toFixed(2)`), keksdose's\n * own set. Every one of them is `Intl` with a locale bolted on, so the only thing\n * worth sharing is the locale — and the kit already has one, the provider's.\n *\n * The plain functions take a `locale` (none: the runtime default, as every `Intl` API\n * reads `undefined`). Inside a component, {@link useKitFormat} hands back the same\n * functions bound to the `<UiKitProvider locale>`, so a page formats exactly like the\n * kit components on it.\n *\n * A missing value — `null`, `undefined`, an unparseable date — formats as `empty`\n * (default \"—\", the dash `StatTile` shows for no value), never as \"NaN\" or \"Invalid\n * Date\". A number may also arrive as a numeric STRING (0.49, KIT-2) — \"1850.00\", \"-12.5\",\n * \"0\": a Decimal field off a Python API — and is formatted like the number. Anything\n * else (a non-numeric string, an object, `NaN`, ±`Infinity`) prints `empty` too, but is\n * a bug upstream rather than a gap in the data, so a development build says so on the\n * console, once per call site. Zero is a figure: `0` and `\"0\"` print \"CHF 0.00\", never\n * the dash.\n *\n * A negative figure carries the typographic minus, U+2212 (0.49, KIT-1), in every\n * locale — `Intl` writes a hyphen in most. `minus: \"ascii\"` for text a machine reads\n * back (a CSV, the clipboard).\n */\n\n/** What every formatter here prints for a missing value, unless told otherwise. */\nexport const EMPTY_FORMATTED_VALUE = \"—\";\n\n/** Fraction digits: a number pins both the minimum and the maximum (`2` → \"3.10\");\n * an object sets them apart (a meter reading, 1–3). */\nexport type FormatDigits = number | { min?: number; max?: number };\n\ninterface BaseOptions {\n /** BCP 47 tag. Left out: the runtime default (the hook: the provider's). */\n locale?: string;\n /** Printed for a missing value. Default {@link EMPTY_FORMATTED_VALUE}. */\n empty?: string;\n}\n\n/** The options every number formatter shares (money and percent carry their own unit). */\nexport interface FormatNumberBaseOptions extends BaseOptions {\n digits?: FormatDigits;\n /** Compact notation: 12 400 → \"12K\". */\n compact?: boolean;\n /** `\"exceptZero\"` for a change (\"+3\", \"−3\", \"0\"). Default: the minus only. */\n signDisplay?: Intl.NumberFormatOptions[\"signDisplay\"];\n /** Any further `Intl.NumberFormat` options, merged over the ones above. */\n options?: Intl.NumberFormatOptions;\n /**\n * The minus sign of a negative figure (0.49, KIT-1). `\"typographic\"` (default):\n * U+2212, as wide as \"+\" and the digits, as `SignedAmount` prints it — in every\n * locale, also where `Intl` writes a hyphen. `\"ascii\"`: U+002D, for an export a\n * machine parses back (a CSV download, the clipboard) — there, also pass\n * `options: { useGrouping: false }` or write the raw number.\n */\n minus?: MinusStyle;\n}\n\n/** The options {@link formatNumber} takes: the shared ones plus a free-text `unit`. */\nexport interface FormatNumberOptions extends FormatNumberBaseOptions {\n /**\n * A unit printed after the figure, joined by a narrow no-break space (U+202F):\n * \"3.400 N\", \"12 kg\", \"4,5 m²\". lenkbank (P8) prints forces, masses and lengths in\n * every table and summary, and each call site glued `${n} N` together with a plain\n * space — which wraps the \"N\" onto the next line in a narrow column and reads loose\n * beside the digit grouping. The narrow no-break space is the SI brochure's and the\n * Swiss typesetting rule's separator between value and unit, and it never breaks.\n *\n * Free text, not `Intl`'s `style: \"unit\"`: that one only knows the ECMA-402\n * sanctioned units (no \"N\", no \"kN\", no \"m³/h\"), so an app would still be gluing.\n * A missing value prints `empty` ALONE — \"— N\" says there is a newton-shaped\n * nothing, which is noise in a column that already heads its unit.\n */\n unit?: string;\n}\n\n/** Narrow no-break space (U+202F): between a figure and its {@link FormatNumberOptions.unit}. */\nconst UNIT_SEPARATOR = \"\\u202F\";\n\nexport interface FormatMoneyOptions extends FormatNumberBaseOptions {\n /** `\"symbol\"` (default), `\"narrowSymbol\"`, `\"code\"` (\"CHF 12.00\") or `\"name\"`. */\n currencyDisplay?: Intl.NumberFormatOptions[\"currencyDisplay\"];\n}\n\nexport interface FormatPercentOptions extends FormatNumberBaseOptions {\n /**\n * `true` (default): `value` is a RATIO, as `Intl` and `StatTile`'s percent delta read\n * it — `0.12` is 12 %. `false`: it is already a percentage — `12` is 12 %\n * (kastlan's rent `change_pct`).\n */\n ratio?: boolean;\n}\n\n// One formatter per option set: a table formats the same way thousands of times, and\n// constructing an Intl formatter is the expensive part.\nconst numberFormatters = new Map<string, Intl.NumberFormat>();\nfunction numberFormatter(locale: string | undefined, options: Intl.NumberFormatOptions): Intl.NumberFormat {\n const key = `${locale ?? \"\"}|${JSON.stringify(options)}`;\n let f = numberFormatters.get(key);\n if (!f) {\n f = new Intl.NumberFormat(locale, options);\n numberFormatters.set(key, f);\n }\n return f;\n}\n\nfunction digitOptions(digits: FormatDigits | undefined): Intl.NumberFormatOptions {\n if (digits === undefined) return {};\n if (typeof digits === \"number\") return { minimumFractionDigits: digits, maximumFractionDigits: digits };\n return {\n ...(digits.min !== undefined ? { minimumFractionDigits: digits.min } : null),\n ...(digits.max !== undefined ? { maximumFractionDigits: digits.max } : null),\n };\n}\n\nfunction numberOptions(o: FormatNumberBaseOptions): Intl.NumberFormatOptions {\n return {\n ...digitOptions(o.digits),\n ...(o.compact ? { notation: \"compact\" } : null),\n ...(o.signDisplay ? { signDisplay: o.signDisplay } : null),\n ...o.options,\n };\n}\n\n/**\n * What the number formatters take (0.49, KIT-2): a number, a numeric string (\"1850.00\",\n * \"-12.5\", \"1e3\" — what a Decimal arrives as in JSON), or `null` / `undefined` for no\n * value. A string is read with `Number()`, so a figure with more significant digits than\n * a double holds (about 15–17) is printed as its nearest double.\n */\nexport type FormattableNumber = number | string | null | undefined;\n\n/** A plain decimal: optional sign, digits with an optional point, an optional exponent.\n * No grouping marks, no decimal comma, no currency — those are formatted text, and\n * reading them back is a locale's guess. */\nconst NUMERIC_STRING = /^[+-]?(?:\\d+\\.?\\d*|\\.\\d+)(?:[eE][+-]?\\d+)?$/;\n\n/** The kit's dev switch, as `lib/logger.ts` reads it: a development build, never a\n * production one (the consumer's bundler makes `import.meta.env.DEV` false) and never\n * under vitest. Read per call so a test can switch it on with `vi.stubEnv`. */\nfunction devWarnings(): boolean {\n return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;\n}\n\nconst warnedSites = new Set<string>();\n/** This module's own frames, skipped to find the caller (\"Object.formatMoney\" is a\n * `useKitFormat` wrapper). */\nconst OWN_FRAME = /\\b(?:formatNumber|formatMoney|formatPercent|toFormattable|warnInvalid|callSite)\\b/;\n\n/** The first stack frame outside the formatters: where the bad value was passed. */\nfunction callSite(): string {\n const frames = (new Error().stack ?? \"\").split(\"\\n\").slice(1);\n return frames.find((frame) => !OWN_FRAME.test(frame))?.trim() ?? \"\";\n}\n\nfunction describeValue(value: unknown): string {\n if (typeof value === \"number\" || typeof value === \"bigint\" || typeof value === \"boolean\") return String(value);\n try {\n return JSON.stringify(value) ?? String(value);\n } catch {\n return Object.prototype.toString.call(value);\n }\n}\n\n/** A development build's console note about an invalid value — once per call site. */\nfunction warnInvalid(fn: string, value: unknown): void {\n if (!devWarnings()) return;\n const site = callSite();\n const key = `${fn}|${site || typeof value}`;\n if (warnedSites.has(key)) return;\n warnedSites.add(key);\n const type = Array.isArray(value) ? \"array\" : typeof value;\n console.warn(\n `[ui-kit] ${fn} got ${describeValue(value)} (${type}), which is not a number: printed the empty value. ` +\n \"Pass a number, a numeric string, or null/undefined for a missing value.\" +\n (site ? `\\n at ${site.replace(/^at /, \"\")}` : \"\"),\n );\n}\n\n/**\n * `value` as a finite number to format, or `undefined` to print `empty`. `null` and\n * `undefined` are a missing value and pass quietly; anything that is neither a finite\n * number nor a numeric string is an invalid one — ±Infinity included (lenkbank, adopting\n * 0.13: a division by zero upstream; \"∞\" in a money column reads like data) — and a\n * development build warns. `-0` is folded into `0`: zero carries no sign.\n */\nfunction toFormattable(fn: string, value: unknown): number | undefined {\n if (value === null || value === undefined) return undefined;\n let n: number | undefined;\n if (typeof value === \"number\") n = value;\n else if (typeof value === \"string\" && NUMERIC_STRING.test(value.trim())) n = Number(value.trim());\n if (n === undefined || !Number.isFinite(n)) {\n warnInvalid(fn, value);\n return undefined;\n }\n return n + 0;\n}\n\n/** A number in `locale`: \"1,234.5\", \"1.234,5\", \"1’234.5\" — with `unit`, \"3.400 N\". */\nexport function formatNumber(value: FormattableNumber, options: FormatNumberOptions = {}): string {\n const n = toFormattable(\"formatNumber\", value);\n if (n === undefined) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const figure = formatWithMinus(numberFormatter(options.locale, numberOptions(options)), n, options.minus);\n return options.unit ? `${figure}${UNIT_SEPARATOR}${options.unit}` : figure;\n}\n\n/** An amount of `currency` (ISO 4217) in `locale`: \"CHF 1’234.50\", \"1.234,50 €\". */\nexport function formatMoney(value: FormattableNumber, currency: string, options: FormatMoneyOptions = {}): string {\n const n = toFormattable(\"formatMoney\", value);\n if (n === undefined) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const formatter = numberFormatter(options.locale, {\n style: \"currency\",\n currency,\n ...(options.currencyDisplay ? { currencyDisplay: options.currencyDisplay } : null),\n ...numberOptions(options),\n });\n return formatWithMinus(formatter, n, options.minus);\n}\n\n/** A percentage in `locale` (\"12%\", \"12 %\", \"+1.25%\"). See {@link FormatPercentOptions.ratio}\n * for what `value` means. Default digits: at most one. */\nexport function formatPercent(value: FormattableNumber, options: FormatPercentOptions = {}): string {\n const n = toFormattable(\"formatPercent\", value);\n if (n === undefined) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const ratio = options.ratio === false ? n / 100 : n;\n const formatter = numberFormatter(options.locale, {\n style: \"percent\",\n ...(options.digits === undefined ? { maximumFractionDigits: 1 } : null),\n ...numberOptions(options),\n });\n return formatWithMinus(formatter, ratio, options.minus);\n}\n\n/* ── Dates ─────────────────────────────────────────────────────────────────── */\n\n/**\n * The shapes a date is printed in, each a set of `Intl.DateTimeFormat` options:\n * - `short` / `medium` / `long` / `full` — the date alone, at that `dateStyle`\n * (\"08/07/2026\", \"8 Jul 2026\", \"8 July 2026\", \"Wednesday, 8 July 2026\" in en-GB).\n * - `dateTime` — medium date and short time: a timestamp (\"8 Jul 2026, 14:05\").\n * - `time` — the short time alone (\"14:05\").\n * - `monthYear` — \"July 2026\".\n *\n * Or pass `Intl.DateTimeFormatOptions` for anything else. A date-fns PATTERN\n * (\"dd.MM.yyyy\") is deliberately not accepted: a pattern is one locale's order baked\n * into a string, which is how an English page ends up with German dates.\n */\nexport type FormatDateStyle = \"short\" | \"medium\" | \"long\" | \"full\" | \"dateTime\" | \"time\" | \"monthYear\";\n\nconst DATE_STYLES: Record<FormatDateStyle, Intl.DateTimeFormatOptions> = {\n short: { dateStyle: \"short\" },\n medium: { dateStyle: \"medium\" },\n long: { dateStyle: \"long\" },\n full: { dateStyle: \"full\" },\n dateTime: { dateStyle: \"medium\", timeStyle: \"short\" },\n time: { timeStyle: \"short\" },\n monthYear: { month: \"long\", year: \"numeric\" },\n};\n\nexport interface FormatDateOptions extends BaseOptions {\n /** An IANA zone (\"Europe/Zurich\") for a timestamp. Left out: the device's. A bare\n * \"YYYY-MM-DD\" ignores it: a calendar day is the day it names in every zone. */\n timeZone?: string;\n}\n\n/** A bare ISO calendar day, `\"YYYY-MM-DD\"`. */\nconst BARE_ISO_DAY = /^\\d{4}-\\d{2}-\\d{2}$/;\n\n/** What {@link formatDate} and {@link formatRelativeTime} accept. */\nexport type DateInput = string | number | Date | null | undefined;\n\n/**\n * `value` as a Date, or null. A bare \"YYYY-MM-DD\" is a CALENDAR day and is read on the\n * local calendar (`parseIsoDate`) — `new Date(\"2026-07-08\")` is UTC midnight, which is\n * the 7th anywhere west of Greenwich. Anything else (a full ISO timestamp, epoch\n * milliseconds, a Date) is an instant and goes through `Date` as is.\n */\nexport function toDate(value: DateInput): Date | null {\n if (value === null || value === undefined || value === \"\") return null;\n if (typeof value === \"string\" && BARE_ISO_DAY.test(value)) return parseIsoDate(value);\n const d = value instanceof Date ? value : new Date(value);\n return Number.isNaN(d.getTime()) ? null : d;\n}\n\nconst dateFormatters = new Map<string, Intl.DateTimeFormat>();\nfunction dateFormatter(locale: string | undefined, options: Intl.DateTimeFormatOptions): Intl.DateTimeFormat {\n const key = `${locale ?? \"\"}|${JSON.stringify(options)}`;\n let f = dateFormatters.get(key);\n if (!f) {\n f = new Intl.DateTimeFormat(locale, options);\n dateFormatters.set(key, f);\n }\n return f;\n}\n\n/**\n * A date or timestamp in `locale`. Takes an ISO date (\"2026-07-08\", read on the local\n * calendar), an ISO timestamp (\"2026-07-08T14:05:00Z\"), epoch milliseconds or a Date.\n * `style` defaults to `medium`; see {@link FormatDateStyle}.\n *\n * `formatIsoDate` (`@eifi1/ui-kit/dates`) remains for a plain \"YYYY-MM-DD\" with an\n * explicit locale; this is its superset for timestamps and the named styles.\n */\nexport function formatDate(\n value: DateInput,\n style: FormatDateStyle | Intl.DateTimeFormatOptions = \"medium\",\n options: FormatDateOptions = {},\n): string {\n const d = toDate(value);\n if (!d) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const base = typeof style === \"string\" ? DATE_STYLES[style] : style;\n // A bare \"YYYY-MM-DD\" is a calendar day, which has no zone: it is the day it names\n // wherever it is read, so a `timeZone` (in `options` or the style) never moves it — the\n // device's local midnight shown in a zone west of it was the day before (round 0.45, N9).\n if (typeof value === \"string\" && BARE_ISO_DAY.test(value)) {\n const day = new Date(Date.UTC(d.getFullYear(), d.getMonth(), d.getDate()));\n return dateFormatter(options.locale, { ...base, timeZone: \"UTC\" }).format(day);\n }\n return dateFormatter(options.locale, options.timeZone ? { ...base, timeZone: options.timeZone } : base).format(d);\n}\n\nexport interface FormatRelativeTimeOptions extends BaseOptions {\n /** The zone of the date printed past `absoluteAfterDays` (0.46). Left out: the\n * device's. */\n timeZone?: string;\n /** The moment it is measured from. Default: now. */\n now?: DateInput;\n /** `\"auto\"` (default): \"yesterday\", \"now\", \"next week\". `\"always\"`: \"1 day ago\". */\n numeric?: Intl.RelativeTimeFormatNumeric;\n /** `\"long\"` (default) \"3 hours ago\", `\"short\"` \"3 hr. ago\", `\"narrow\"` \"3h ago\". */\n style?: Intl.RelativeTimeFormatStyle;\n /**\n * Past this many days either way, print the date instead ({@link formatDate} in\n * `absoluteStyle`) — kastlan's feed switches to the date after a week, because \"5\n * weeks ago\" makes a reader do sums. Default: never.\n */\n absoluteAfterDays?: number;\n /** The style of that date. Default `medium`. */\n absoluteStyle?: FormatDateStyle | Intl.DateTimeFormatOptions;\n}\n\nconst SECOND = 1000;\nconst MINUTE = 60 * SECOND;\nconst HOUR = 60 * MINUTE;\nconst DAY = 24 * HOUR;\n\n/** [the largest |difference| a unit is used for, the unit, its length]. */\nconst RELATIVE_STEPS: Array<[number, Intl.RelativeTimeFormatUnit, number]> = [\n [45 * SECOND, \"second\", SECOND],\n [45 * MINUTE, \"minute\", MINUTE],\n [22 * HOUR, \"hour\", HOUR],\n [6.5 * DAY, \"day\", DAY],\n [26 * DAY, \"week\", 7 * DAY],\n [320 * DAY, \"month\", 30.44 * DAY],\n [Infinity, \"year\", 365.25 * DAY],\n];\n\nconst relativeFormatters = new Map<string, Intl.RelativeTimeFormat>();\nfunction relativeFormatter(\n locale: string | undefined,\n numeric: Intl.RelativeTimeFormatNumeric,\n style: Intl.RelativeTimeFormatStyle,\n): Intl.RelativeTimeFormat {\n const key = `${locale ?? \"\"}|${numeric}|${style}`;\n let f = relativeFormatters.get(key);\n if (!f) {\n f = new Intl.RelativeTimeFormat(locale, { numeric, style });\n relativeFormatters.set(key, f);\n }\n return f;\n}\n\n/**\n * How long ago (or until) `value` is, in `locale`'s words, through\n * `Intl.RelativeTimeFormat`: \"3 hours ago\", \"vor 3 Stunden\", \"il y a 3 heures\",\n * \"in 2 days\", and under 45 seconds \"now\". The unit is picked by size — seconds,\n * minutes, hours, days, weeks, months, years — and the count is rounded.\n */\nexport function formatRelativeTime(value: DateInput, options: FormatRelativeTimeOptions = {}): string {\n const d = toDate(value);\n if (!d) return options.empty ?? EMPTY_FORMATTED_VALUE;\n const now = toDate(options.now ?? new Date()) ?? new Date();\n const diff = d.getTime() - now.getTime();\n const abs = Math.abs(diff);\n if (options.absoluteAfterDays !== undefined && abs > options.absoluteAfterDays * DAY) {\n return formatDate(d, options.absoluteStyle ?? \"medium\", options);\n }\n const rtf = relativeFormatter(options.locale, options.numeric ?? \"auto\", options.style ?? \"long\");\n const [, unit, size] = RELATIVE_STEPS.find(([limit]) => abs < limit)!;\n // Under 45 seconds is \"now\" rather than \"12 seconds ago\": a feed that ticks through\n // the seconds is noise, and nothing reads a timestamp to the second.\n const count = unit === \"second\" ? 0 : Math.round(diff / size);\n // `+ 0` folds -0 into 0: `format(-0, …)` says \"0 seconds ago\" instead of \"now\".\n return rtf.format(count + 0, unit);\n}\n\n/* ── Bound to the provider ─────────────────────────────────────────────────── */\n\ntype WithoutLocale<T> = Omit<T, \"locale\"> & { locale?: string };\n\n/** What {@link useKitFormat} returns: the formatters above with the provider's locale\n * as their default (an explicit `locale` in the options still wins). */\nexport interface KitFormat {\n /** The locale they format in — the provider's, or `undefined` (runtime default). */\n locale: string | undefined;\n formatNumber: (value: FormattableNumber, options?: WithoutLocale<FormatNumberOptions>) => string;\n formatMoney: (value: FormattableNumber, currency: string, options?: WithoutLocale<FormatMoneyOptions>) => string;\n formatPercent: (value: FormattableNumber, options?: WithoutLocale<FormatPercentOptions>) => string;\n formatDate: (\n value: DateInput,\n style?: FormatDateStyle | Intl.DateTimeFormatOptions,\n options?: WithoutLocale<FormatDateOptions>,\n ) => string;\n formatRelativeTime: (value: DateInput, options?: WithoutLocale<FormatRelativeTimeOptions>) => string;\n}\n\n/**\n * The formatters, bound to the `<UiKitProvider locale>` (or to `locale`, which wins\n * over it — the same precedence as a component's `locale` prop):\n *\n * const { formatMoney, formatRelativeTime } = useKitFormat();\n * formatMoney(row.balance, \"CHF\");\n */\nexport function useKitFormat(locale?: string): KitFormat {\n const kitLocale = useKitLocale(locale);\n // 0.46 (§10.3): the provider's `timeZone` for timestamps; a call's own wins, and a bare\n // \"YYYY-MM-DD\" ignores it (formatDate).\n const timeZone = useKitTimeZone();\n return useMemo<KitFormat>(\n () => ({\n locale: kitLocale,\n formatNumber: (value, options) => formatNumber(value, { locale: kitLocale, ...options }),\n formatMoney: (value, currency, options) => formatMoney(value, currency, { locale: kitLocale, ...options }),\n formatPercent: (value, options) => formatPercent(value, { locale: kitLocale, ...options }),\n formatDate: (value, style, options) => formatDate(value, style, { locale: kitLocale, timeZone, ...options }),\n formatRelativeTime: (value, options) => formatRelativeTime(value, { locale: kitLocale, timeZone, ...options }),\n }),\n [kitLocale, timeZone],\n );\n}\n\n/* ── Currency minor units ──────────────────────────────────────────────────── */\n\n/**\n * The decimals a currency is written with — its minor unit: CHF and EUR 2, JPY 0,\n * KWD 3 — from `Intl`, or `undefined` for a code `Intl` does not accept. The rule\n * AmountInput settles a typed figure by, for a host that needs it outside the field.\n */\nexport function currencyMinorDigits(currency: string | null | undefined): number | undefined {\n if (!currency) return undefined;\n try {\n return new Intl.NumberFormat(\"en\", { style: \"currency\", currency }).resolvedOptions().maximumFractionDigits;\n } catch {\n return undefined;\n }\n}\n\n/** The options form of {@link roundToCurrency}'s third argument. */\nexport interface RoundToCurrencyOptions {\n /** Decimals to round to, whatever the currency — the plain `digits` argument. */\n digits?: number;\n /**\n * Decimals for a currency `Intl` cannot resolve — no code yet (`null`, `\"\"`), or a\n * malformed one (\"EURO\", \"€\"). Left out, such a value comes back unchanged, as\n * before. Note that `Intl` resolves ANY well-formed three-letter code, a made-up\n * \"XYZ\" included (to 2), so this is the case of a missing or broken code, not of a\n * rare currency.\n */\n fallbackDigits?: number;\n}\n\n/**\n * `value` rounded to the currency's minor unit (or to `digits`), half away from zero,\n * in decimal arithmetic: 93.4213 CHF → 93.42, 1.005 EUR → 1.01 (`Math.round(1.005 *\n * 100)` is 100, since 1.005 is 1.00499… in binary). A non-finite value, or a currency\n * `Intl` does not know without `digits`, comes back unchanged (keksdose live #356: an\n * FX estimate set into an AmountInput showed four decimals) — unless the options form\n * gives `fallbackDigits`: `roundToCurrency(v, code, { fallbackDigits: 2 })` rounds a\n * figure whose currency is not chosen yet to cents rather than leaving 0.1 + 0.2 as is.\n * A finite value never comes back NaN or -0, however small or large (BUG-11):\n * 0.1 + 0.2 - 0.3 → 0, -0.004 → 0.\n */\nexport function roundToCurrency(\n value: number,\n currency: string | null | undefined,\n digits?: number | RoundToCurrencyOptions,\n): number {\n const options = typeof digits === \"object\" && digits !== null ? digits : { digits };\n const places = options.digits ?? currencyMinorDigits(currency) ?? options.fallbackDigits;\n if (places === undefined) return value;\n return roundHalfAway(value, places);\n}\n"],"mappings":"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","names":["formatWithMinus"]}
@@ -0,0 +1,32 @@
1
+ /**
2
+ * The one minus sign (0.49, KIT-1). `Intl.NumberFormat` writes a negative with the
3
+ * HYPHEN-MINUS (U+002D) in nearly every locale — en, de-CH, fr, it, es, hu, zh — and
4
+ * with the typographic MINUS SIGN (U+2212) in a few (sv, fi). The kit prints U+2212
5
+ * everywhere, as `SignedAmount` always has: it is as wide as "+" and the tabular
6
+ * digits, so a column of signed figures lines up, and it does not break a line the way
7
+ * a hyphen may.
8
+ *
9
+ * `"ascii"` is the other way round, for text a machine reads back — a CSV, the
10
+ * clipboard: every minus becomes U+002D, whatever the locale wrote, so `Number()` and a
11
+ * spreadsheet parse it.
12
+ */
13
+ export type MinusStyle = "typographic" | "ascii";
14
+ /** U+2212 MINUS SIGN. */
15
+ export declare const TYPOGRAPHIC_MINUS = "\u2212";
16
+ /**
17
+ * `formatter.format(value)` with every minus sign in `minus` style. Only the parts
18
+ * `Intl` marks as a minus are touched — a hyphen inside a currency's name
19
+ * ("Bosnia-Herzegovina convertible marks") stays — and only when the text holds the
20
+ * other sign at all, so a positive figure costs no `formatToParts`.
21
+ *
22
+ * Public since 0.50 (the KIT-1 follow-up): an app that keeps its OWN `Intl.NumberFormat`
23
+ * wrapper (a currency with app-specific digits, a quantity) calls it in place of
24
+ * `formatter.format(value)`, so its negatives match the kit's "−". Where the wrapper only
25
+ * re-states `formatMoney` / `formatNumber`, route it through those instead.
26
+ *
27
+ * @example
28
+ * const eur = new Intl.NumberFormat("en", { style: "currency", currency: "EUR" });
29
+ * formatWithMinus(eur, -150); // "−€150.00"
30
+ * formatWithMinus(eur, -150, "ascii"); // "-€150.00", for a CSV
31
+ */
32
+ export declare function formatWithMinus(formatter: Intl.NumberFormat, value: number, minus?: MinusStyle): string;
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ const TYPOGRAPHIC_MINUS = "\u2212";
3
+ const MINUS_PARTS = /* @__PURE__ */ new Set(["minusSign", "exponentMinusSign"]);
4
+ function formatWithMinus(formatter, value, minus = "typographic") {
5
+ const text = formatter.format(value);
6
+ const [wanted, other] = minus === "ascii" ? ["-", TYPOGRAPHIC_MINUS] : [TYPOGRAPHIC_MINUS, "-"];
7
+ if (!text.includes(other)) return text;
8
+ return formatter.formatToParts(value).map((part) => MINUS_PARTS.has(part.type) ? wanted : part.value).join("");
9
+ }
10
+ export {
11
+ TYPOGRAPHIC_MINUS,
12
+ formatWithMinus
13
+ };
14
+ //# sourceMappingURL=minus.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/lib/minus.ts"],"sourcesContent":["/**\n * The one minus sign (0.49, KIT-1). `Intl.NumberFormat` writes a negative with the\n * HYPHEN-MINUS (U+002D) in nearly every locale — en, de-CH, fr, it, es, hu, zh — and\n * with the typographic MINUS SIGN (U+2212) in a few (sv, fi). The kit prints U+2212\n * everywhere, as `SignedAmount` always has: it is as wide as \"+\" and the tabular\n * digits, so a column of signed figures lines up, and it does not break a line the way\n * a hyphen may.\n *\n * `\"ascii\"` is the other way round, for text a machine reads back — a CSV, the\n * clipboard: every minus becomes U+002D, whatever the locale wrote, so `Number()` and a\n * spreadsheet parse it.\n */\nexport type MinusStyle = \"typographic\" | \"ascii\";\n\n/** U+2212 MINUS SIGN. */\nexport const TYPOGRAPHIC_MINUS = \"\\u2212\";\n\n/** The parts `formatToParts` writes a minus in (the exponent's, under `notation:\n * \"scientific\"`, too). */\nconst MINUS_PARTS: ReadonlySet<string> = new Set([\"minusSign\", \"exponentMinusSign\"]);\n\n/**\n * `formatter.format(value)` with every minus sign in `minus` style. Only the parts\n * `Intl` marks as a minus are touched — a hyphen inside a currency's name\n * (\"Bosnia-Herzegovina convertible marks\") stays — and only when the text holds the\n * other sign at all, so a positive figure costs no `formatToParts`.\n *\n * Public since 0.50 (the KIT-1 follow-up): an app that keeps its OWN `Intl.NumberFormat`\n * wrapper (a currency with app-specific digits, a quantity) calls it in place of\n * `formatter.format(value)`, so its negatives match the kit's \"−\". Where the wrapper only\n * re-states `formatMoney` / `formatNumber`, route it through those instead.\n *\n * @example\n * const eur = new Intl.NumberFormat(\"en\", { style: \"currency\", currency: \"EUR\" });\n * formatWithMinus(eur, -150); // \"−€150.00\"\n * formatWithMinus(eur, -150, \"ascii\"); // \"-€150.00\", for a CSV\n */\nexport function formatWithMinus(\n formatter: Intl.NumberFormat,\n value: number,\n minus: MinusStyle = \"typographic\",\n): string {\n const text = formatter.format(value);\n const [wanted, other] = minus === \"ascii\" ? [\"-\", TYPOGRAPHIC_MINUS] : [TYPOGRAPHIC_MINUS, \"-\"];\n if (!text.includes(other)) return text;\n return formatter\n .formatToParts(value)\n .map((part) => (MINUS_PARTS.has(part.type) ? wanted : part.value))\n .join(\"\");\n}\n"],"mappings":";AAeO,MAAM,oBAAoB;AAIjC,MAAM,cAAmC,oBAAI,IAAI,CAAC,aAAa,mBAAmB,CAAC;AAkB5E,SAAS,gBACd,WACA,OACA,QAAoB,eACZ;AACR,QAAM,OAAO,UAAU,OAAO,KAAK;AACnC,QAAM,CAAC,QAAQ,KAAK,IAAI,UAAU,UAAU,CAAC,KAAK,iBAAiB,IAAI,CAAC,mBAAmB,GAAG;AAC9F,MAAI,CAAC,KAAK,SAAS,KAAK,EAAG,QAAO;AAClC,SAAO,UACJ,cAAc,KAAK,EACnB,IAAI,CAAC,SAAU,YAAY,IAAI,KAAK,IAAI,IAAI,SAAS,KAAK,KAAM,EAChE,KAAK,EAAE;AACZ;","names":[]}
@@ -0,0 +1,30 @@
1
+ /**
2
+ * What a status means, in five domain-free words, and the tone that says it (0.49 KIT-8).
3
+ *
4
+ * Every app paints its statuses by hand: kastlan's ~15 enums each carry a Chip tone
5
+ * (`shared/enums/enum-meta.ts`), keksdose's job and transaction states theirs, and the
6
+ * same meaning drifted into different colours — "unpaid" amber in one table, brand in
7
+ * the next. The kind is the decision ("does the reader have to act?"), the tone only
8
+ * its colour, so an app maps each of its states to a kind once and the colours agree
9
+ * everywhere:
10
+ *
11
+ * | kind | tone | for |
12
+ * |---|---|---|
13
+ * | `good` | `success` | done, settled, healthy: nothing to do (Paid, Verified) |
14
+ * | `attention` | `warning` | needs a look or an action soon, nothing broken yet (Unpaid, Due soon) |
15
+ * | `problem` | `danger` | something is wrong or late and needs action now (Overdue, Failed) |
16
+ * | `active` | `info` | in progress or current, neither good nor bad (Running, Sent) |
17
+ * | `inactive` | `neutral` | over, paused or not started: out of the way (Draft, Archived) |
18
+ *
19
+ * `brand` is not a status tone: it is the app's own colour, and a status in it reads as
20
+ * "this is the app" rather than as a state. Money (`income` / `expense`) is not one
21
+ * either: it says which way an amount flows, not how a record stands.
22
+ */
23
+ export type StatusKind = "good" | "attention" | "problem" | "active" | "inactive";
24
+ /** The tones a {@link StatusKind} maps to — a subset of `ChipTone` and `StatusDotTone`. */
25
+ export type StatusTone = "success" | "warning" | "danger" | "info" | "neutral";
26
+ /** The five kinds, in the order the table above lists them. */
27
+ export declare const STATUS_KINDS: readonly StatusKind[];
28
+ /** The tone for a status kind: good → success, attention → warning, problem → danger,
29
+ * active → info, inactive → neutral. See {@link StatusKind}. */
30
+ export declare function statusTone(kind: StatusKind): StatusTone;
@@ -0,0 +1,17 @@
1
+ "use client";
2
+ const STATUS_KINDS = ["good", "attention", "problem", "active", "inactive"];
3
+ const TONES = {
4
+ good: "success",
5
+ attention: "warning",
6
+ problem: "danger",
7
+ active: "info",
8
+ inactive: "neutral"
9
+ };
10
+ function statusTone(kind) {
11
+ return TONES[kind];
12
+ }
13
+ export {
14
+ STATUS_KINDS,
15
+ statusTone
16
+ };
17
+ //# sourceMappingURL=status-tone.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/lib/status-tone.ts"],"sourcesContent":["/**\n * What a status means, in five domain-free words, and the tone that says it (0.49 KIT-8).\n *\n * Every app paints its statuses by hand: kastlan's ~15 enums each carry a Chip tone\n * (`shared/enums/enum-meta.ts`), keksdose's job and transaction states theirs, and the\n * same meaning drifted into different colours — \"unpaid\" amber in one table, brand in\n * the next. The kind is the decision (\"does the reader have to act?\"), the tone only\n * its colour, so an app maps each of its states to a kind once and the colours agree\n * everywhere:\n *\n * | kind | tone | for |\n * |---|---|---|\n * | `good` | `success` | done, settled, healthy: nothing to do (Paid, Verified) |\n * | `attention` | `warning` | needs a look or an action soon, nothing broken yet (Unpaid, Due soon) |\n * | `problem` | `danger` | something is wrong or late and needs action now (Overdue, Failed) |\n * | `active` | `info` | in progress or current, neither good nor bad (Running, Sent) |\n * | `inactive` | `neutral` | over, paused or not started: out of the way (Draft, Archived) |\n *\n * `brand` is not a status tone: it is the app's own colour, and a status in it reads as\n * \"this is the app\" rather than as a state. Money (`income` / `expense`) is not one\n * either: it says which way an amount flows, not how a record stands.\n */\nexport type StatusKind = \"good\" | \"attention\" | \"problem\" | \"active\" | \"inactive\";\n\n/** The tones a {@link StatusKind} maps to — a subset of `ChipTone` and `StatusDotTone`. */\nexport type StatusTone = \"success\" | \"warning\" | \"danger\" | \"info\" | \"neutral\";\n\n/** The five kinds, in the order the table above lists them. */\nexport const STATUS_KINDS: readonly StatusKind[] = [\"good\", \"attention\", \"problem\", \"active\", \"inactive\"];\n\nconst TONES: Readonly<Record<StatusKind, StatusTone>> = {\n good: \"success\",\n attention: \"warning\",\n problem: \"danger\",\n active: \"info\",\n inactive: \"neutral\",\n};\n\n/** The tone for a status kind: good → success, attention → warning, problem → danger,\n * active → info, inactive → neutral. See {@link StatusKind}. */\nexport function statusTone(kind: StatusKind): StatusTone {\n return TONES[kind];\n}\n"],"mappings":";AA4BO,MAAM,eAAsC,CAAC,QAAQ,aAAa,WAAW,UAAU,UAAU;AAExG,MAAM,QAAkD;AAAA,EACtD,MAAM;AAAA,EACN,WAAW;AAAA,EACX,SAAS;AAAA,EACT,QAAQ;AAAA,EACR,UAAU;AACZ;AAIO,SAAS,WAAW,MAA8B;AACvD,SAAO,MAAM,IAAI;AACnB;","names":[]}
@@ -160,6 +160,7 @@ function SettingsLayout({
160
160
  PageHeader,
161
161
  {
162
162
  size: "compact",
163
+ documentTitle: false,
163
164
  title: open.title,
164
165
  description: open.help,
165
166
  breadcrumbs: /* @__PURE__ */ jsxs(
@@ -183,7 +184,7 @@ function SettingsLayout({
183
184
  ),
184
185
  cards(open.id)
185
186
  ] }) : /* @__PURE__ */ jsxs("div", { className: pageClass, "data-settings-view": "list", children: [
186
- /* @__PURE__ */ jsx(PageHeader, { size: "compact", title: pageTitle }),
187
+ /* @__PURE__ */ jsx(PageHeader, { size: "compact", documentTitle: false, title: pageTitle }),
187
188
  searchField,
188
189
  searching ? results : /* @__PURE__ */ jsx(Card, { className: "p-1", children: /* @__PURE__ */ jsx(List, { separator: "divider", density: "comfortable", "aria-label": labels.sections, children: route.groups.map((g) => /* @__PURE__ */ jsx(
189
190
  ListItem,
@@ -219,7 +220,7 @@ function SettingsLayout({
219
220
  /* @__PURE__ */ jsx("div", { "data-slot": "settings-sidebar-footer", children: sidebarFooter })
220
221
  ] });
221
222
  body = /* @__PURE__ */ jsxs("div", { className: pageClass, "data-settings-view": "desktop", children: [
222
- /* @__PURE__ */ jsx(PageHeader, { size: "compact", title: pageTitle }),
223
+ /* @__PURE__ */ jsx(PageHeader, { size: "compact", documentTitle: false, title: pageTitle }),
223
224
  searchField,
224
225
  /* @__PURE__ */ jsxs("div", { className: "md:grid md:grid-cols-[14rem_minmax(0,1fr)] md:gap-6", children: [
225
226
  withSidebarFooter(