@eifi1/ui-kit 0.34.0 → 0.36.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 (446) hide show
  1. package/README.md +115 -41
  2. package/dist/admin/role-select.d.ts +3 -2
  3. package/dist/admin/role-select.js +2 -0
  4. package/dist/admin/role-select.js.map +1 -1
  5. package/dist/billing/billing-banner.d.ts +5 -2
  6. package/dist/billing/billing-banner.js.map +1 -1
  7. package/dist/billing/plan-limit-notice.d.ts +5 -2
  8. package/dist/billing/plan-limit-notice.js.map +1 -1
  9. package/dist/chart.js.map +1 -1
  10. package/dist/components/amount-input.d.ts +2 -5
  11. package/dist/components/amount-input.js +2 -2
  12. package/dist/components/amount-input.js.map +1 -1
  13. package/dist/components/autocomplete.d.ts +2 -2
  14. package/dist/components/autocomplete.js +2 -1
  15. package/dist/components/autocomplete.js.map +1 -1
  16. package/dist/components/button-look.d.ts +1 -1
  17. package/dist/components/button-look.js +9 -4
  18. package/dist/components/button-look.js.map +1 -1
  19. package/dist/components/calculator.d.ts +1 -4
  20. package/dist/components/calculator.js +1 -3
  21. package/dist/components/calculator.js.map +1 -1
  22. package/dist/components/chart-zoom.d.ts +0 -37
  23. package/dist/components/chart-zoom.js +1 -28
  24. package/dist/components/chart-zoom.js.map +1 -1
  25. package/dist/components/checkbox-group.d.ts +5 -2
  26. package/dist/components/checkbox-group.js +15 -4
  27. package/dist/components/checkbox-group.js.map +1 -1
  28. package/dist/components/chip-input.d.ts +0 -4
  29. package/dist/components/chip-input.js +1 -5
  30. package/dist/components/chip-input.js.map +1 -1
  31. package/dist/components/column-mapper.d.ts +5 -3
  32. package/dist/components/column-mapper.js +2 -0
  33. package/dist/components/column-mapper.js.map +1 -1
  34. package/dist/components/combobox.d.ts +3 -3
  35. package/dist/components/combobox.js +2 -1
  36. package/dist/components/combobox.js.map +1 -1
  37. package/dist/components/copy-button.d.ts +5 -1
  38. package/dist/components/copy-button.js.map +1 -1
  39. package/dist/components/country-select.d.ts +3 -3
  40. package/dist/components/country-select.js +3 -0
  41. package/dist/components/country-select.js.map +1 -1
  42. package/dist/components/currency-option-list.d.ts +3 -8
  43. package/dist/components/currency-option-list.js +1 -29
  44. package/dist/components/currency-option-list.js.map +1 -1
  45. package/dist/components/currency-select.d.ts +3 -2
  46. package/dist/components/currency-select.js +10 -1
  47. package/dist/components/currency-select.js.map +1 -1
  48. package/dist/components/danger-confirm.d.ts +3 -3
  49. package/dist/components/danger-confirm.js.map +1 -1
  50. package/dist/components/data-table-filter-popover.d.ts +1 -1
  51. package/dist/components/data-table-filter-popover.js +1 -1
  52. package/dist/components/data-table-filter-popover.js.map +1 -1
  53. package/dist/components/data-table-labels.d.ts +1 -42
  54. package/dist/components/data-table-labels.js +2 -31
  55. package/dist/components/data-table-labels.js.map +1 -1
  56. package/dist/components/data-table-shared.d.ts +17 -1
  57. package/dist/components/data-table-shared.js +22 -0
  58. package/dist/components/data-table-shared.js.map +1 -1
  59. package/dist/components/data-table.d.ts +12 -2
  60. package/dist/components/data-table.js +18 -14
  61. package/dist/components/data-table.js.map +1 -1
  62. package/dist/components/date-field.d.ts +2 -1
  63. package/dist/components/date-field.js.map +1 -1
  64. package/dist/components/date-picker.js +3 -0
  65. package/dist/components/date-picker.js.map +1 -1
  66. package/dist/components/date-range-picker.js +3 -0
  67. package/dist/components/date-range-picker.js.map +1 -1
  68. package/dist/components/dialog-frame.d.ts +3 -1
  69. package/dist/components/dialog-frame.js.map +1 -1
  70. package/dist/components/dropdown.d.ts +18 -15
  71. package/dist/components/dropdown.js +11 -3
  72. package/dist/components/dropdown.js.map +1 -1
  73. package/dist/components/entity-combobox.d.ts +3 -3
  74. package/dist/components/entity-combobox.js +2 -1
  75. package/dist/components/entity-combobox.js.map +1 -1
  76. package/dist/components/field-base.d.ts +0 -5
  77. package/dist/components/field-base.js +0 -5
  78. package/dist/components/field-base.js.map +1 -1
  79. package/dist/components/field-parts.d.ts +69 -22
  80. package/dist/components/field-parts.js +33 -19
  81. package/dist/components/field-parts.js.map +1 -1
  82. package/dist/components/field-strip.d.ts +9 -23
  83. package/dist/components/field-strip.js +26 -23
  84. package/dist/components/field-strip.js.map +1 -1
  85. package/dist/components/field-sync.d.ts +0 -4
  86. package/dist/components/field-sync.js +0 -4
  87. package/dist/components/field-sync.js.map +1 -1
  88. package/dist/components/field.d.ts +7 -6
  89. package/dist/components/field.js +24 -15
  90. package/dist/components/field.js.map +1 -1
  91. package/dist/components/full-bleed-dialog.js +2 -2
  92. package/dist/components/full-bleed-dialog.js.map +1 -1
  93. package/dist/components/help-popover.d.ts +9 -7
  94. package/dist/components/help-popover.js.map +1 -1
  95. package/dist/components/hover-menu.d.ts +6 -13
  96. package/dist/components/hover-menu.js +3 -3
  97. package/dist/components/hover-menu.js.map +1 -1
  98. package/dist/components/icon-picker.d.ts +3 -2
  99. package/dist/components/icon-picker.js +3 -0
  100. package/dist/components/icon-picker.js.map +1 -1
  101. package/dist/components/inline-entity-combobox.d.ts +3 -3
  102. package/dist/components/inline-entity-combobox.js +2 -1
  103. package/dist/components/inline-entity-combobox.js.map +1 -1
  104. package/dist/components/input.d.ts +2 -2
  105. package/dist/components/input.js +2 -1
  106. package/dist/components/input.js.map +1 -1
  107. package/dist/components/modal.d.ts +9 -1
  108. package/dist/components/modal.js +7 -4
  109. package/dist/components/modal.js.map +1 -1
  110. package/dist/components/month-picker.d.ts +2 -1
  111. package/dist/components/month-picker.js +8 -1
  112. package/dist/components/month-picker.js.map +1 -1
  113. package/dist/components/multi-entity-combobox.d.ts +2 -2
  114. package/dist/components/multi-entity-combobox.js +2 -1
  115. package/dist/components/multi-entity-combobox.js.map +1 -1
  116. package/dist/components/multi-select.d.ts +7 -18
  117. package/dist/components/multi-select.js +8 -10
  118. package/dist/components/multi-select.js.map +1 -1
  119. package/dist/components/number-input.d.ts +2 -5
  120. package/dist/components/number-input.js +2 -4
  121. package/dist/components/number-input.js.map +1 -1
  122. package/dist/components/numeric-field.d.ts +5 -2
  123. package/dist/components/numeric-field.js +4 -1
  124. package/dist/components/numeric-field.js.map +1 -1
  125. package/dist/components/one-time-code-input.d.ts +2 -1
  126. package/dist/components/one-time-code-input.js +2 -1
  127. package/dist/components/one-time-code-input.js.map +1 -1
  128. package/dist/components/phone-input.d.ts +1 -1
  129. package/dist/components/phone-input.js +4 -0
  130. package/dist/components/phone-input.js.map +1 -1
  131. package/dist/components/popover.js +3 -3
  132. package/dist/components/popover.js.map +1 -1
  133. package/dist/components/select.d.ts +4 -3
  134. package/dist/components/select.js +2 -1
  135. package/dist/components/select.js.map +1 -1
  136. package/dist/components/series-chart.js.map +1 -1
  137. package/dist/components/server-wake.d.ts +0 -6
  138. package/dist/components/server-wake.js.map +1 -1
  139. package/dist/components/settings-fields.d.ts +4 -2
  140. package/dist/components/settings-fields.js.map +1 -1
  141. package/dist/components/slider.d.ts +3 -2
  142. package/dist/components/slider.js +7 -1
  143. package/dist/components/slider.js.map +1 -1
  144. package/dist/components/stat-tile.d.ts +8 -2
  145. package/dist/components/stat-tile.js +2 -11
  146. package/dist/components/stat-tile.js.map +1 -1
  147. package/dist/components/swatch-picker.d.ts +3 -2
  148. package/dist/components/swatch-picker.js +3 -0
  149. package/dist/components/swatch-picker.js.map +1 -1
  150. package/dist/components/swipeable-row.d.ts +2 -17
  151. package/dist/components/swipeable-row.js +11 -16
  152. package/dist/components/swipeable-row.js.map +1 -1
  153. package/dist/components/text-link.js +3 -1
  154. package/dist/components/text-link.js.map +1 -1
  155. package/dist/components/textarea.d.ts +2 -1
  156. package/dist/components/textarea.js +9 -3
  157. package/dist/components/textarea.js.map +1 -1
  158. package/dist/components/toggle-group.d.ts +2 -2
  159. package/dist/components/toggle-group.js +7 -3
  160. package/dist/components/toggle-group.js.map +1 -1
  161. package/dist/components/tooltip.d.ts +9 -1
  162. package/dist/components/tooltip.js +20 -5
  163. package/dist/components/tooltip.js.map +1 -1
  164. package/dist/components/translation-review.d.ts +10 -2
  165. package/dist/components/translation-review.js +11 -3
  166. package/dist/components/translation-review.js.map +1 -1
  167. package/dist/components/use-table-state.d.ts +31 -3
  168. package/dist/components/use-table-state.js +53 -8
  169. package/dist/components/use-table-state.js.map +1 -1
  170. package/dist/components/user-avatar.d.ts +2 -2
  171. package/dist/components/user-avatar.js.map +1 -1
  172. package/dist/components/wizard-stepper.d.ts +1 -4
  173. package/dist/components/wizard-stepper.js +2 -3
  174. package/dist/components/wizard-stepper.js.map +1 -1
  175. package/dist/currencies.d.ts +23 -0
  176. package/dist/currencies.js +38 -0
  177. package/dist/currencies.js.map +1 -0
  178. package/dist/demo/demo-banner.d.ts +7 -2
  179. package/dist/demo/demo-banner.js.map +1 -1
  180. package/dist/eslint/index.d.ts +83 -0
  181. package/dist/eslint/index.js +69 -0
  182. package/dist/eslint/index.js.map +1 -0
  183. package/dist/eslint/role-utilities.d.ts +48 -0
  184. package/dist/eslint/role-utilities.generated.d.ts +2 -0
  185. package/dist/eslint/role-utilities.generated.js +143 -0
  186. package/dist/eslint/role-utilities.generated.js.map +1 -0
  187. package/dist/eslint/role-utilities.js +43 -0
  188. package/dist/eslint/role-utilities.js.map +1 -0
  189. package/dist/feedback/feedback-menu.d.ts +2 -2
  190. package/dist/feedback/feedback-menu.js.map +1 -1
  191. package/dist/feedback/feedback-thread.d.ts +3 -8
  192. package/dist/feedback/feedback-thread.js +0 -2
  193. package/dist/feedback/feedback-thread.js.map +1 -1
  194. package/dist/hooks/use-dismiss.d.ts +16 -3
  195. package/dist/hooks/use-dismiss.js +14 -2
  196. package/dist/hooks/use-dismiss.js.map +1 -1
  197. package/dist/i18n/defaults.js +5 -1
  198. package/dist/i18n/defaults.js.map +1 -1
  199. package/dist/i18n/german.js +97 -1
  200. package/dist/i18n/german.js.map +1 -1
  201. package/dist/i18n/kit-labels.d.ts +29 -4
  202. package/dist/i18n/kit-labels.js +21 -1
  203. package/dist/i18n/kit-labels.js.map +1 -1
  204. package/dist/i18n/locales/en.js +104 -3
  205. package/dist/i18n/locales/en.js.map +3 -3
  206. package/dist/i18n/locales/es.js +95 -1
  207. package/dist/i18n/locales/es.js.map +1 -1
  208. package/dist/i18n/locales/fr.js +99 -3
  209. package/dist/i18n/locales/fr.js.map +1 -1
  210. package/dist/i18n/locales/hu.js +95 -1
  211. package/dist/i18n/locales/hu.js.map +1 -1
  212. package/dist/i18n/locales/it.js +99 -3
  213. package/dist/i18n/locales/it.js.map +1 -1
  214. package/dist/i18n/locales/zh.js +95 -1
  215. package/dist/i18n/locales/zh.js.map +1 -1
  216. package/dist/i18n/review.js +13 -0
  217. package/dist/i18n/review.js.map +1 -1
  218. package/dist/i18n/shared-labels.js +3 -1
  219. package/dist/i18n/shared-labels.js.map +1 -1
  220. package/dist/index.d.ts +23 -1
  221. package/dist/index.js +40 -2
  222. package/dist/index.js.map +1 -1
  223. package/dist/lib/device-settings.d.ts +72 -0
  224. package/dist/lib/device-settings.js +64 -0
  225. package/dist/lib/device-settings.js.map +1 -0
  226. package/dist/lib/plain-text.d.ts +11 -0
  227. package/dist/lib/plain-text.js +16 -0
  228. package/dist/lib/plain-text.js.map +1 -0
  229. package/dist/lib/refs.d.ts +13 -0
  230. package/dist/lib/refs.js +15 -0
  231. package/dist/lib/refs.js.map +1 -0
  232. package/dist/lib/safe-storage.d.ts +6 -0
  233. package/dist/lib/safe-storage.js +11 -0
  234. package/dist/lib/safe-storage.js.map +1 -1
  235. package/dist/notifications/notification-parts.d.ts +39 -0
  236. package/dist/notifications/notification-parts.js +47 -0
  237. package/dist/notifications/notification-parts.js.map +1 -0
  238. package/dist/notifications/notification-types-setting.d.ts +62 -0
  239. package/dist/notifications/notification-types-setting.js +192 -0
  240. package/dist/notifications/notification-types-setting.js.map +1 -0
  241. package/dist/notifications/notification-types.d.ts +75 -0
  242. package/dist/notifications/notification-types.js +2 -0
  243. package/dist/notifications/notification-types.js.map +1 -0
  244. package/dist/notifications/notifications-labels.d.ts +131 -0
  245. package/dist/notifications/notifications-labels.js +83 -0
  246. package/dist/notifications/notifications-labels.js.map +1 -0
  247. package/dist/notifications/push-device-setting.d.ts +37 -0
  248. package/dist/notifications/push-device-setting.js +164 -0
  249. package/dist/notifications/push-device-setting.js.map +1 -0
  250. package/dist/notifications/push-devices-setting.d.ts +44 -0
  251. package/dist/notifications/push-devices-setting.js +203 -0
  252. package/dist/notifications/push-devices-setting.js.map +1 -0
  253. package/dist/notifications/quiet-hours-setting.d.ts +47 -0
  254. package/dist/notifications/quiet-hours-setting.js +131 -0
  255. package/dist/notifications/quiet-hours-setting.js.map +1 -0
  256. package/dist/notifications/time-zone-select.d.ts +50 -0
  257. package/dist/notifications/time-zone-select.js +76 -0
  258. package/dist/notifications/time-zone-select.js.map +1 -0
  259. package/dist/notifications/unsubscribe-panel.d.ts +53 -0
  260. package/dist/notifications/unsubscribe-panel.js +139 -0
  261. package/dist/notifications/unsubscribe-panel.js.map +1 -0
  262. package/dist/notifications/use-push-device.d.ts +122 -0
  263. package/dist/notifications/use-push-device.js +230 -0
  264. package/dist/notifications/use-push-device.js.map +1 -0
  265. package/dist/rhf/fields.d.ts +36 -29
  266. package/dist/rhf/fields.js +51 -22
  267. package/dist/rhf/fields.js.map +1 -1
  268. package/dist/rhf/form-core.d.ts +5 -4
  269. package/dist/rhf/form-core.js.map +1 -1
  270. package/dist/rhf/form.d.ts +7 -36
  271. package/dist/rhf/form.js +1 -16
  272. package/dist/rhf/form.js.map +1 -1
  273. package/dist/rhf/line-items.d.ts +1 -1
  274. package/dist/rhf/line-items.js.map +1 -1
  275. package/dist/rhf/use-rhf-wizard-step.js.map +1 -1
  276. package/dist/rhf.js.map +1 -1
  277. package/dist/settings/device-reset-setting-labels.d.ts +3 -0
  278. package/dist/settings/device-reset-setting-labels.js +15 -0
  279. package/dist/settings/device-reset-setting-labels.js.map +1 -0
  280. package/dist/settings/device-reset-setting.d.ts +58 -0
  281. package/dist/settings/device-reset-setting.js +51 -0
  282. package/dist/settings/device-reset-setting.js.map +1 -0
  283. package/dist/shell/app-shell.js +5 -0
  284. package/dist/shell/app-shell.js.map +1 -1
  285. package/dist/shell/option-switcher-menu.d.ts +5 -17
  286. package/dist/shell/option-switcher-menu.js +1 -3
  287. package/dist/shell/option-switcher-menu.js.map +1 -1
  288. package/dist/shell/role-switcher.d.ts +1 -4
  289. package/dist/shell/role-switcher.js +2 -3
  290. package/dist/shell/role-switcher.js.map +1 -1
  291. package/dist/shell/topbar-action-menu.d.ts +7 -19
  292. package/dist/shell/topbar-action-menu.js +1 -3
  293. package/dist/shell/topbar-action-menu.js.map +1 -1
  294. package/dist/shell/topbar-controls.d.ts +3 -12
  295. package/dist/shell/topbar-controls.js +6 -9
  296. package/dist/shell/topbar-controls.js.map +1 -1
  297. package/dist/testing/index.d.ts +1 -1
  298. package/dist/testing/index.js +2 -0
  299. package/dist/testing/index.js.map +1 -1
  300. package/dist/testing/viewport.d.ts +25 -2
  301. package/dist/testing/viewport.js +61 -10
  302. package/dist/testing/viewport.js.map +1 -1
  303. package/dist/theme/contrast-tokens.d.ts +42 -4
  304. package/dist/theme/contrast-tokens.js +56 -9
  305. package/dist/theme/contrast-tokens.js.map +1 -1
  306. package/dist/theme/contrast.d.ts +4 -2
  307. package/dist/theme/contrast.js +13 -0
  308. package/dist/theme/contrast.js.map +1 -1
  309. package/dist/theme/palette-derive.d.ts +15 -1
  310. package/dist/theme/palette-derive.js +37 -8
  311. package/dist/theme/palette-derive.js.map +1 -1
  312. package/dist/theme/palette-presets.d.ts +2 -8
  313. package/dist/theme/palette-presets.js +5 -284
  314. package/dist/theme/palette-presets.js.map +1 -1
  315. package/dist/theme/palette-store.js +6 -0
  316. package/dist/theme/palette-store.js.map +1 -1
  317. package/dist/theme/text-size.js +6 -0
  318. package/dist/theme/text-size.js.map +1 -1
  319. package/dist/theme/theme-store.js +6 -0
  320. package/dist/theme/theme-store.js.map +1 -1
  321. package/dist/wizard/types.d.ts +0 -5
  322. package/dist/wizard/types.js +1 -5
  323. package/dist/wizard/types.js.map +1 -1
  324. package/package.json +9 -1
  325. package/src/admin/role-select.tsx +4 -1
  326. package/src/billing/billing-banner.tsx +5 -2
  327. package/src/billing/plan-limit-notice.tsx +5 -2
  328. package/src/chart.ts +5 -3
  329. package/src/components/amount-input.tsx +4 -7
  330. package/src/components/autocomplete.tsx +5 -2
  331. package/src/components/button-look.ts +15 -5
  332. package/src/components/calculator.tsx +2 -7
  333. package/src/components/chart-zoom.tsx +4 -47
  334. package/src/components/checkbox-group.tsx +22 -4
  335. package/src/components/chip-input.tsx +0 -7
  336. package/src/components/column-mapper.tsx +6 -2
  337. package/src/components/combobox.tsx +6 -3
  338. package/src/components/copy-button.tsx +5 -1
  339. package/src/components/country-select.tsx +6 -1
  340. package/src/components/currency-option-list.tsx +8 -42
  341. package/src/components/currency-select.tsx +12 -3
  342. package/src/components/danger-confirm.tsx +3 -3
  343. package/src/components/data-table-filter-popover.tsx +2 -1
  344. package/src/components/data-table-labels.ts +2 -84
  345. package/src/components/data-table-shared.tsx +55 -2
  346. package/src/components/data-table.tsx +29 -17
  347. package/src/components/date-field.tsx +2 -2
  348. package/src/components/date-picker.tsx +3 -0
  349. package/src/components/date-range-picker.tsx +3 -0
  350. package/src/components/dialog-frame.tsx +3 -1
  351. package/src/components/dropdown.tsx +42 -18
  352. package/src/components/entity-combobox.tsx +6 -3
  353. package/src/components/field-base.tsx +0 -11
  354. package/src/components/field-parts.tsx +112 -38
  355. package/src/components/field-strip.tsx +12 -27
  356. package/src/components/field-sync.tsx +0 -7
  357. package/src/components/field.tsx +32 -19
  358. package/src/components/full-bleed-dialog.tsx +4 -3
  359. package/src/components/help-popover.tsx +9 -7
  360. package/src/components/hover-menu.tsx +12 -16
  361. package/src/components/icon-picker.tsx +8 -3
  362. package/src/components/inline-entity-combobox.tsx +6 -3
  363. package/src/components/input.tsx +4 -2
  364. package/src/components/modal.tsx +20 -4
  365. package/src/components/month-picker.tsx +10 -3
  366. package/src/components/multi-entity-combobox.tsx +5 -2
  367. package/src/components/multi-select.tsx +19 -31
  368. package/src/components/number-input.tsx +5 -10
  369. package/src/components/numeric-field.ts +10 -2
  370. package/src/components/one-time-code-input.tsx +5 -3
  371. package/src/components/phone-input.tsx +4 -0
  372. package/src/components/popover.tsx +8 -4
  373. package/src/components/select.tsx +6 -3
  374. package/src/components/series-chart.tsx +1 -1
  375. package/src/components/server-wake.tsx +0 -6
  376. package/src/components/settings-fields.tsx +4 -2
  377. package/src/components/slider.tsx +10 -3
  378. package/src/components/stat-tile.tsx +11 -20
  379. package/src/components/swatch-picker.tsx +9 -4
  380. package/src/components/swipeable-row.tsx +15 -37
  381. package/src/components/text-link.tsx +3 -1
  382. package/src/components/textarea.tsx +11 -4
  383. package/src/components/toggle-group.tsx +21 -5
  384. package/src/components/tooltip.tsx +37 -6
  385. package/src/components/translation-review.tsx +25 -7
  386. package/src/components/use-table-state.ts +91 -10
  387. package/src/components/user-avatar.tsx +2 -2
  388. package/src/components/wizard-stepper.tsx +2 -6
  389. package/src/currencies.ts +70 -0
  390. package/src/demo/demo-banner.tsx +7 -2
  391. package/src/eslint/index.ts +161 -0
  392. package/src/eslint/role-utilities.generated.ts +146 -0
  393. package/src/eslint/role-utilities.ts +99 -0
  394. package/src/feedback/feedback-menu.tsx +2 -2
  395. package/src/feedback/feedback-thread.tsx +3 -8
  396. package/src/hooks/use-dismiss.ts +39 -4
  397. package/src/i18n/defaults.ts +4 -0
  398. package/src/i18n/german.ts +113 -0
  399. package/src/i18n/kit-labels.tsx +66 -6
  400. package/src/i18n/locales/en.ts +6 -0
  401. package/src/i18n/locales/es.ts +106 -0
  402. package/src/i18n/locales/fr.ts +109 -2
  403. package/src/i18n/locales/hu.ts +104 -0
  404. package/src/i18n/locales/it.ts +108 -2
  405. package/src/i18n/locales/zh.ts +96 -0
  406. package/src/i18n/review.ts +13 -0
  407. package/src/i18n/shared-labels.ts +2 -0
  408. package/src/index.ts +53 -1
  409. package/src/lib/device-settings.ts +146 -0
  410. package/src/lib/plain-text.ts +22 -0
  411. package/src/lib/refs.ts +22 -0
  412. package/src/lib/safe-storage.ts +16 -0
  413. package/src/notifications/notification-parts.tsx +83 -0
  414. package/src/notifications/notification-types-setting.tsx +309 -0
  415. package/src/notifications/notification-types.ts +80 -0
  416. package/src/notifications/notifications-labels.ts +238 -0
  417. package/src/notifications/push-device-setting.tsx +224 -0
  418. package/src/notifications/push-devices-setting.tsx +306 -0
  419. package/src/notifications/quiet-hours-setting.tsx +185 -0
  420. package/src/notifications/time-zone-select.tsx +126 -0
  421. package/src/notifications/unsubscribe-panel.tsx +203 -0
  422. package/src/notifications/use-push-device.ts +392 -0
  423. package/src/rhf/fields.tsx +87 -39
  424. package/src/rhf/form-core.tsx +5 -4
  425. package/src/rhf/form.tsx +7 -43
  426. package/src/rhf/line-items.tsx +1 -1
  427. package/src/rhf/use-rhf-wizard-step.ts +4 -4
  428. package/src/rhf.ts +3 -2
  429. package/src/settings/device-reset-setting-labels.ts +20 -0
  430. package/src/settings/device-reset-setting.tsx +119 -0
  431. package/src/shell/app-shell.tsx +8 -0
  432. package/src/shell/option-switcher-menu.tsx +5 -22
  433. package/src/shell/role-switcher.tsx +2 -6
  434. package/src/shell/topbar-action-menu.tsx +7 -24
  435. package/src/shell/topbar-controls.tsx +6 -18
  436. package/src/testing/index.ts +3 -1
  437. package/src/testing/viewport.ts +125 -19
  438. package/src/theme/contrast-tokens.ts +106 -12
  439. package/src/theme/contrast.ts +30 -4
  440. package/src/theme/palette-derive.ts +68 -7
  441. package/src/theme/palette-presets.ts +16 -299
  442. package/src/theme/palette-store.ts +12 -0
  443. package/src/theme/text-size.ts +8 -0
  444. package/src/theme/theme-store.ts +10 -0
  445. package/src/wizard/types.ts +0 -8
  446. package/tokens.css +38 -4
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/theme/palette-presets.ts"],"sourcesContent":["// Appearance presets — the single source of truth for the \"palette switcher\"\n// (feedback #307). Each preset is a full set of design-token values for BOTH\n// light and dark, covering the page/surface backgrounds, the brand accent, the\n// semantic money trio and the 9-colour categorical chart palette, plus the\n// heatmap stops. A preset is applied at runtime by writing these as CSS custom\n// properties onto <html> (see palette-store.ts), which instantly re-skins every\n// token-driven surface and chart. The two heatmaps and the treemap interpolate\n// concrete hex in JS, so they read the same values from here via the hooks in\n// palette-store.\n//\n// The alternative presets below were designed + accessibility-vetted as a set\n// (each is CVD-safe across protan/deutan/tritan, money is never red/green, and\n// text/background contrast clears WCAG AA on both themes). To promote one to the\n// app-wide default, copy its values into the :root/.dark blocks in tokens.css\n// (the default preset here mirrors those exactly).\n\nimport type { HeatStops } from \"./chart-palette\";\nimport { contrastStep, rememberTokenSet } from \"./contrast-tokens\";\n\nexport interface TokenSet {\n // Backgrounds & chrome\n bgPage: string;\n bgSurface: string;\n bgSurface2: string;\n border: string;\n textPrimary: string;\n // The two quieter text roles. Derived per preset by blending `textPrimary` toward\n // that preset's own surfaces until the contrast against the WORST of bgPage /\n // bgSurface / bgSurface2 hits 7:1 and 4.8:1 — so both clear WCAG AA for normal text\n // (4.5:1) on every surface they can land on, and both inherit the preset's warmth\n // instead of importing a cool grey from Tailwind's palette.\n textSecondary: string;\n textMuted: string;\n // Interactive brand accent\n brand: string;\n brandHover: string;\n brandContrast: string;\n // Semantic money (never red/green — CVD-safe, paired with +/- cues elsewhere)\n moneyIncome: string;\n moneyExpense: string;\n moneyNet: string;\n moneyNeutral: string;\n // Categorical chart series (Paul Tol \"Muted\" by default — CVD-safe), exactly 9\n chart: string[];\n // Heatmap interpolation stops (variance diverging + spending sequential)\n heat: HeatStops;\n}\n\nexport interface PalettePreset {\n id: string;\n name: string;\n blurb: string;\n light: TokenSet;\n dark: TokenSet;\n}\n\n// The shipped default (feedback #328): SOLAR-DUSK warm light + INDIGO-NOIR deep\n// dark surfaces — the two curated presets the user picked per mode — but with a\n// SINGLE unified brand accent (indigo) across both modes rather than each\n// preset's own accent (the user asked for the brand accent to stay the same).\n// So the light block is Solar Dusk with its teal brand overridden to indigo, and\n// the dark block is Indigo Noir (already indigo). The money trio\n// (income/expense/net) drives the chart bars and stays palette-driven so those\n// semantic colours adapt to each mode's contrast (best practice — feedback #328).\nexport const DEFAULT_PRESET: PalettePreset = {\n id: \"default\",\n name: \"Default\",\n blurb: \"Solar-dusk light · indigo-noir dark\",\n light: {\n // Solar Dusk (light) surfaces, with the brand unified to indigo (see above).\n bgPage: \"#ede3cf\",\n bgSurface: \"#f6ecd9\",\n // The well is the page in light (0.33, k26): #f3ead6 stood 1.02:1 from the card it\n // is inset in. Every light preset does the same; see SURFACE_L in palette-derive.ts.\n bgSurface2: \"#ede3cf\",\n border: \"#e2d6bd\",\n textPrimary: \"#463f33\",\n textSecondary: \"#50493c\",\n textMuted: \"#696153\",\n brand: \"#4f46e5\",\n brandHover: \"#4338ca\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#0b6650\",\n moneyExpense: \"#905902\",\n moneyNet: \"#7a4fa3\",\n moneyNeutral: \"#6b6453\",\n chart: [\"#332288\", \"#88ccee\", \"#44aa99\", \"#117733\", \"#999933\", \"#ddcc77\", \"#cc6677\", \"#882255\", \"#aa4499\"],\n heat: {\n neutral: \"#f1e7d2\",\n under: \"#0b6650\",\n over: \"#9c6418\",\n seqLow: \"#f6ecd0\",\n seqHigh: \"#9c6418\",\n empty: \"rgba(148,163,184,0.14)\",\n },\n },\n dark: {\n // Indigo Noir (dark)\n bgPage: \"#0e0f1c\",\n bgSurface: \"#181a2c\",\n bgSurface2: \"#21243a\",\n border: \"#2e3252\",\n textPrimary: \"#e6e8f4\",\n textSecondary: \"#adafbe\",\n textMuted: \"#8d90a0\",\n brand: \"#8b93f8\",\n brandHover: \"#aab1fb\",\n brandContrast: \"#16182b\",\n moneyIncome: \"#22c3b6\",\n // Amber-400 (#fbbf24) sat at OKLab L 0.84 while every other dark token lives\n // at 0.68–0.74, so outflows outshouted the inflows they are paired with. Same\n // hue and chroma, pulled to L 0.785 (dev#434). The heat stops below keep the\n // brighter amber on purpose: a gradient endpoint is meant to be the loudest\n // thing in its scale, and it is a fill, not a figure.\n moneyExpense: \"#e4b035\",\n moneyNet: \"#b39aef\",\n moneyNeutral: \"#9aa1bd\",\n chart: [\"#7e72d6\", \"#9fd8f2\", \"#5fc4b0\", \"#3fa45f\", \"#bfbf5e\", \"#e8dda0\", \"#e08c9a\", \"#c25a78\", \"#cc78be\"],\n heat: {\n neutral: \"#363a5a\",\n under: \"#22c3b6\",\n over: \"#fbbf24\",\n seqLow: \"#3a2c14\",\n seqHigh: \"#fbbf24\",\n empty: \"rgba(148,163,184,0.10)\",\n },\n },\n};\n\n// Vetted alternatives to preview (CVD-safe, WCAG-AA on both themes).\n/**\n * @deprecated 0.34 — `PALETTES`, the presets `presetById` and the palette store know, or\n * `derivePalette` for a palette of the app's own; removed in 0.35 (refactor audit 2026-10,\n * API-6c). A reference bank only: `presetById` resolves none of these ids, and no app\n * imports it (deferred since the 2026-09-22 audit).\n */\nexport const ALTERNATIVE_PRESETS: PalettePreset[] = [\n {\n id: \"warm-paper\",\n name: \"Warm Paper\",\n blurb: \"Cream paper · warm charcoal\",\n light: {\n bgPage: \"#ece5d6\",\n bgSurface: \"#f5efe3\",\n bgSurface2: \"#ece5d6\",\n border: \"#e2dcce\",\n textPrimary: \"#2b2622\",\n textSecondary: \"#4f4a44\",\n textMuted: \"#68625b\",\n brand: \"#9a5b34\",\n brandHover: \"#824827\",\n brandContrast: \"#fdf8f2\",\n moneyIncome: \"#0e7490\",\n moneyExpense: \"#8f3f0a\",\n moneyNet: \"#7c4dab\",\n moneyNeutral: \"#6b6258\",\n chart: [\"#332288\", \"#88ccee\", \"#44aa99\", \"#117733\", \"#999933\", \"#ddcc77\", \"#cc6677\", \"#882255\", \"#aa4499\"],\n heat: {\n neutral: \"#efe9dc\",\n under: \"#0e7490\",\n over: \"#b45309\",\n seqLow: \"#fbeed2\",\n seqHigh: \"#b45309\",\n empty: \"rgba(148,163,184,0.14)\",\n },\n },\n dark: {\n bgPage: \"#211e1a\",\n bgSurface: \"#2a2622\",\n bgSurface2: \"#322d27\",\n border: \"#3d372f\",\n textPrimary: \"#e8e2d6\",\n textSecondary: \"#bfb9af\",\n textMuted: \"#9e998f\",\n brand: \"#e0986a\",\n brandHover: \"#ecae85\",\n brandContrast: \"#2a1c10\",\n moneyIncome: \"#4dd4c4\",\n moneyExpense: \"#fbbf3a\",\n moneyNet: \"#bfa0e0\",\n moneyNeutral: \"#a39a8c\",\n chart: [\"#7e72d6\", \"#9fd8f2\", \"#5fc4b0\", \"#3fa45f\", \"#bfbf5e\", \"#e8dda0\", \"#e08c9a\", \"#cf6488\", \"#cc78be\"],\n heat: {\n neutral: \"#3a352e\",\n under: \"#4dd4c4\",\n over: \"#fbbf3a\",\n seqLow: \"#2c2014\",\n seqHigh: \"#fbbf3a\",\n empty: \"rgba(148,163,184,0.10)\",\n },\n },\n },\n {\n id: \"nord-frost\",\n name: \"Nord Frost\",\n blurb: \"Cool snow · polar night\",\n light: {\n bgPage: \"#e2e7ef\",\n bgSurface: \"#f0f3f8\",\n bgSurface2: \"#e2e7ef\",\n border: \"#d3dae6\",\n textPrimary: \"#2e3440\",\n textSecondary: \"#454b56\",\n textMuted: \"#5e636e\",\n brand: \"#3b6ea5\",\n brandHover: \"#305a86\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#0f8060\",\n moneyExpense: \"#b8431a\",\n moneyNet: \"#6b4fa0\",\n moneyNeutral: \"#5b6577\",\n chart: [\"#3a6ea5\", \"#56b6c2\", \"#2f7d4f\", \"#cdb52f\", \"#d05f7a\", \"#9a3f93\", \"#4d4bbf\", \"#dd6a2c\", \"#7a8aa8\"],\n heat: {\n neutral: \"#e1e6ee\",\n under: \"#0f8060\",\n over: \"#b8431a\",\n seqLow: \"#f0f3f7\",\n seqHigh: \"#a14708\",\n empty: \"rgba(148,163,184,0.14)\",\n },\n },\n dark: {\n bgPage: \"#242933\",\n bgSurface: \"#2e3440\",\n bgSurface2: \"#3b4252\",\n border: \"#434c5e\",\n textPrimary: \"#eceff4\",\n textSecondary: \"#d4d7de\",\n textMuted: \"#afb3bc\",\n brand: \"#88c0d0\",\n brandHover: \"#a3d4e0\",\n brandContrast: \"#1c2027\",\n moneyIncome: \"#4ecf9c\",\n moneyExpense: \"#e8a24f\",\n moneyNet: \"#caa0e0\",\n moneyNeutral: \"#9aa5b8\",\n chart: [\"#7fb4e6\", \"#5ec9d6\", \"#5cc483\", \"#dccb6b\", \"#e87e96\", \"#bd6fce\", \"#9389f2\", \"#ef8f4a\", \"#8693ad\"],\n heat: {\n neutral: \"#3b4252\",\n under: \"#4ecf9c\",\n over: \"#e8a24f\",\n seqLow: \"#33373f\",\n seqHigh: \"#e8a24f\",\n empty: \"rgba(148,163,184,0.10)\",\n },\n },\n },\n {\n id: \"indigo-noir\",\n name: \"Indigo Noir\",\n blurb: \"Crisp light · deep indigo\",\n light: {\n bgPage: \"#e8eaf2\",\n bgSurface: \"#f6f7fb\",\n bgSurface2: \"#e8eaf2\",\n border: \"#dadfeb\",\n textPrimary: \"#1b1f2e\",\n textSecondary: \"#494d5a\",\n textMuted: \"#626572\",\n brand: \"#4338ca\",\n brandHover: \"#3730a3\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#0c6478\",\n moneyExpense: \"#b45309\",\n moneyNet: \"#7c3aed\",\n moneyNeutral: \"#5b6478\",\n chart: [\"#332288\", \"#88ccee\", \"#44aa99\", \"#117733\", \"#999933\", \"#ddcc77\", \"#cc6677\", \"#882255\", \"#aa4499\"],\n heat: {\n neutral: \"#eef1f7\",\n under: \"#0c6478\",\n over: \"#b45309\",\n seqLow: \"#fdeed4\",\n seqHigh: \"#b45309\",\n empty: \"rgba(148,163,184,0.14)\",\n },\n },\n dark: {\n bgPage: \"#0e0f1c\",\n bgSurface: \"#181a2c\",\n bgSurface2: \"#21243a\",\n border: \"#2e3252\",\n textPrimary: \"#e6e8f4\",\n textSecondary: \"#adafbe\",\n textMuted: \"#8d90a0\",\n brand: \"#8b93f8\",\n brandHover: \"#aab1fb\",\n brandContrast: \"#16182b\",\n moneyIncome: \"#22c3b6\",\n // Kept byte-identical to DEFAULT_PRESET.dark, which IS this preset (dev#434).\n moneyExpense: \"#e4b035\",\n moneyNet: \"#b39aef\",\n moneyNeutral: \"#9aa1bd\",\n chart: [\"#7e72d6\", \"#9fd8f2\", \"#5fc4b0\", \"#3fa45f\", \"#bfbf5e\", \"#e8dda0\", \"#e08c9a\", \"#c25a78\", \"#cc78be\"],\n heat: {\n neutral: \"#363a5a\",\n under: \"#22c3b6\",\n over: \"#fbbf24\",\n seqLow: \"#3a2c14\",\n seqHigh: \"#fbbf24\",\n empty: \"rgba(148,163,184,0.10)\",\n },\n },\n },\n {\n id: \"solar-dusk\",\n name: \"Solar Dusk\",\n blurb: \"Solarized warmth, easy on the eyes\",\n light: {\n bgPage: \"#ede3cf\",\n bgSurface: \"#f6ecd9\",\n bgSurface2: \"#ede3cf\",\n border: \"#e2d6bd\",\n textPrimary: \"#463f33\",\n textSecondary: \"#50493c\",\n textMuted: \"#696153\",\n brand: \"#2c6979\",\n brandHover: \"#1f515f\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#0b6650\",\n moneyExpense: \"#905902\",\n moneyNet: \"#7a4fa3\",\n moneyNeutral: \"#6b6453\",\n chart: [\"#332288\", \"#88ccee\", \"#44aa99\", \"#117733\", \"#999933\", \"#ddcc77\", \"#cc6677\", \"#882255\", \"#aa4499\"],\n heat: {\n neutral: \"#f1e7d2\",\n under: \"#0b6650\",\n over: \"#9c6418\",\n seqLow: \"#f6ecd0\",\n seqHigh: \"#9c6418\",\n empty: \"rgba(148,163,184,0.14)\",\n },\n },\n dark: {\n bgPage: \"#0e1a1f\",\n bgSurface: \"#15272e\",\n bgSurface2: \"#1d333c\",\n border: \"#2a4750\",\n textPrimary: \"#d6cdb8\",\n textSecondary: \"#c2bdab\",\n textMuted: \"#9c9d91\",\n brand: \"#5fb8cc\",\n brandHover: \"#82cfe0\",\n brandContrast: \"#0a161a\",\n moneyIncome: \"#22b884\",\n moneyExpense: \"#f0b94e\",\n moneyNet: \"#bfa0e0\",\n moneyNeutral: \"#9aa3a0\",\n chart: [\"#7e72d6\", \"#9fd8f2\", \"#5fc4b0\", \"#3fa45f\", \"#bfbf5e\", \"#e8dda0\", \"#e08c9a\", \"#c25a78\", \"#c873c0\"],\n heat: {\n neutral: \"#26424b\",\n under: \"#22b884\",\n over: \"#f0b94e\",\n seqLow: \"#1f3138\",\n seqHigh: \"#f0b94e\",\n empty: \"rgba(148,163,184,0.10)\",\n },\n },\n },\n {\n id: \"zinc-mono\",\n name: \"Zinc Mono\",\n blurb: \"True-neutral chrome · data pops\",\n light: {\n bgPage: \"#ececec\",\n bgSurface: \"#f7f7f7\",\n bgSurface2: \"#ececec\",\n border: \"#e4e4e7\",\n textPrimary: \"#18181b\",\n textSecondary: \"#4e4e50\",\n textMuted: \"#676769\",\n brand: \"#1d4ed8\",\n brandHover: \"#1e40af\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#0e7490\",\n moneyExpense: \"#cf4310\",\n moneyNet: \"#7c3aed\",\n moneyNeutral: \"#52525b\",\n chart: [\"#332288\", \"#88ccee\", \"#44aa99\", \"#117733\", \"#999933\", \"#ddcc77\", \"#cc6677\", \"#882255\", \"#aa4499\"],\n heat: {\n neutral: \"#f4f4f5\",\n under: \"#0e7490\",\n over: \"#cf4310\",\n seqLow: \"#fdeede\",\n seqHigh: \"#cf4310\",\n empty: \"rgba(148,163,184,0.14)\",\n },\n },\n dark: {\n bgPage: \"#09090b\",\n bgSurface: \"#18181b\",\n bgSurface2: \"#27272a\",\n border: \"#3f3f46\",\n textPrimary: \"#f4f4f5\",\n textSecondary: \"#b2b2b3\",\n textMuted: \"#929294\",\n brand: \"#60a5fa\",\n brandHover: \"#93c5fd\",\n brandContrast: \"#0b1220\",\n moneyIncome: \"#22d3ee\",\n moneyExpense: \"#f97316\",\n moneyNet: \"#c4b5fd\",\n moneyNeutral: \"#a1a1aa\",\n chart: [\"#7e72d6\", \"#9fd8f2\", \"#5fc4b0\", \"#3fa45f\", \"#bfbf5e\", \"#e8dda0\", \"#e08c9a\", \"#c25a78\", \"#cc78be\"],\n heat: {\n neutral: \"#3f3f46\",\n under: \"#22d3ee\",\n over: \"#f97316\",\n seqLow: \"#3a2410\",\n seqHigh: \"#f97316\",\n empty: \"rgba(148,163,184,0.10)\",\n },\n },\n },\n];\n\n// ── \"Imprint\" chart hues (feedback #119) ──────────────────────────────────────\n// anyplot's categorical palette (https://anyplot.ai/palette), by a colleague of\n// Marcel's. 8 hues + an amber semantic anchor, derived from Wong 2011 by a\n// Petroff-style max-min ΔE search in CAM02-UCS with Machado 2009 CVD simulation —\n// the same lineage as our default Paul Tol \"Muted\", one generation on, and tuned\n// for exactly the warm cream paper our light theme uses.\n//\n// This preset is DEFAULT_PRESET with ONLY the 9 categorical chart hues swapped;\n// surfaces, brand and the semantic money trio stay put, because those were their\n// own deliberate decisions (#307/#328) and are not what the palette is for. The\n// heatmap stops likewise stay teal/amber: they encode under/over, and imprint's\n// own diverging ramp is red↔blue, which would fight the money semantics.\n//\n// Measured on our surfaces (CIEDE2000, Machado severity-100, 8 like-for-like\n// slots on the cream light surface #f6ecd9) — neither palette dominates:\n// Paul Tol Muted min-contrast 1.38 · worst-CVD ΔE00 11.8 · normal ΔE00 16.1\n// imprint min-contrast 1.95 · worst-CVD ΔE00 7.2 · normal ΔE00 22.2\n// i.e. imprint is punchier and has no near-invisible pale slot (Tol's #88ccee\n// sits at 1.5:1 on cream), while Tol separates better under colour-vision\n// deficiency. So this ships as a CHOICE, not as a new default.\n//\n// The dark set keeps imprint's hues verbatim except matte-red and rose, whose\n// lightness is lifted (oklch L 0.50→0.55) because verbatim they land at 2.67:1\n// and 2.76:1 on our deep-indigo dark surface — under WCAG 2.1 SC 1.4.11's 3:1\n// non-text minimum. Lifted they clear it at 3.30:1 / 3.28:1. anyplot documents\n// the same shortfall on their own dark ground and answers it with a stroke halo;\n// lifting keeps the fix in the token layer instead of every chart component.\nconst IMPRINT_HUES_LIGHT = [\n \"#009E73\", // brand green — first series\n \"#C475FD\", // lavender — creative\n \"#4467A3\", // blue — cool / info\n \"#BD8233\", // ochre — earth / commodity\n \"#AE3030\", // matte red — bad / loss / error\n \"#2ABCCD\", // cyan — sky / tech-cool\n \"#954477\", // rose — wellness / health\n \"#99B314\", // lime — growth / nature\n \"#DDCC77\", // amber — anyplot's semantic anchor, used here as slot 9\n];\nconst IMPRINT_HUES_DARK = [\n \"#009E73\",\n \"#C475FD\",\n \"#4467A3\",\n \"#BD8233\",\n \"#BF413E\", // matte red, L-lifted for our dark surface\n \"#2ABCCD\",\n \"#A25083\", // rose, L-lifted for our dark surface\n \"#99B314\",\n \"#DDCC77\",\n];\n\nexport const IMPRINT_PRESET: PalettePreset = {\n id: \"imprint\",\n name: \"Imprint\",\n blurb: \"Colourblind-safe chart hues by anyplot.ai\",\n light: { ...DEFAULT_PRESET.light, chart: IMPRINT_HUES_LIGHT },\n dark: { ...DEFAULT_PRESET.dark, chart: IMPRINT_HUES_DARK },\n};\n\n// The full-theme switcher from #307 stayed retired (feedback #328) — the default\n// is still the single source of truth for surfaces, brand and money, and\n// ALTERNATIVE_PRESETS above remains a reference bank only. What IS selectable is\n// the categorical chart ramp (feedback #119), so these two presets are the live\n// set; presetById falls back to the default for any stale persisted id.\n/**\n * The categorical chart ramp, shared by every derived preset below.\n *\n * Paul Tol's \"Muted\" set, unchanged, and deliberately NOT re-derived per preset. A\n * categorical ramp solves a different problem from a UI palette — separability among\n * nine unknown siblings, rather than contrast against one known surface — and a set\n * optimised against real dichromat confusion lines beats one spaced evenly by formula.\n * `auditChartRamp` in palette-derive.ts measures it: 0.059 minimum separation in the\n * light variant, which is the number MIN_SEPARATION was calibrated under.\n *\n * It pays for that in contrast: four of the nine light hues sit below the 3:1 WCAG\n * 1.4.11 asks of a filled shape. That is not fixable by darkening them — see\n * SINGLE_LIGHTNESS_MAX_SERIES — so a chart with more than four series must carry a\n * second channel (a direct label, a pattern, a legend adjacent to the mark).\n */\nconst PAUL_TOL = {\n light: DEFAULT_PRESET.light.chart,\n dark: DEFAULT_PRESET.dark.chart,\n} as const;\n\n/**\n * Presets DERIVED rather than picked, by `derivePalette` from one brand colour each.\n *\n * Every text role here was solved to a contrast target against the worst of the three\n * surfaces it can land on, every semantic colour against both the page and its own badge\n * tint, and the whole set is re-checked by `__tests__/palette-audit.test.ts` (\"derived\n * presets\") — which re-runs the deriver and fails if these committed values drift from\n * what it now produces.\n *\n * They are committed rather than derived at import because deriving eight token sets\n * costs ~34ms, and charging every consumer that at startup for palettes most of them\n * will never select is the wrong trade. The test is what keeps the two in agreement.\n *\n * Regenerated in 0.34 (round 0.34 BUG-8): the solve now measures the hex it ships rather\n * than the float before rounding, and the audit no longer lets a hex pair pass 0.05 under\n * its threshold. 52 values moved one step (66 counting the heat stops that repeat them);\n * Contrast's neutrals lost half their slate tint, its brand having half the chroma at\n * which the neutrals carry a hue in full.\n */\nexport const DERIVED_PRESETS: PalettePreset[] = [\n {\n id: \"ink\",\n name: \"Ink\",\n blurb: \"Cool blue-grey ground · solved for AAA body text\",\n light: {\n bgPage: \"#dce2ef\",\n bgSurface: \"#e6edfa\",\n bgSurface2: \"#dce2ef\",\n border: \"#b0bace\",\n textPrimary: \"#2e3139\",\n textSecondary: \"#454850\",\n textMuted: \"#5f636c\",\n brand: \"#2f5fd0\",\n brandHover: \"#1f4cbc\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#00725f\",\n moneyExpense: \"#8e5900\",\n moneyNet: \"#7356a2\",\n moneyNeutral: \"#61646c\",\n chart: PAUL_TOL.light,\n heat: {\n neutral: \"#dce2ef\",\n under: \"#00725f\",\n over: \"#8e5900\",\n seqLow: \"#fee3c5\",\n seqHigh: \"#8e5900\",\n empty: \"rgba(120,110,95,0.14)\",\n },\n },\n dark: {\n bgPage: \"#090d15\",\n bgSurface: \"#131720\",\n bgSurface2: \"#1d212b\",\n border: \"#363e4f\",\n textPrimary: \"#c8ccd6\",\n textSecondary: \"#a7abb5\",\n textMuted: \"#858a92\",\n brand: \"#3466d7\",\n brandHover: \"#2353c3\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#009a80\",\n moneyExpense: \"#b77b29\",\n moneyNet: \"#9679c9\",\n moneyNeutral: \"#a1a5ac\",\n chart: PAUL_TOL.dark,\n heat: {\n neutral: \"#1d212b\",\n under: \"#009a80\",\n over: \"#b77b29\",\n seqLow: \"#331f05\",\n seqHigh: \"#b77b29\",\n empty: \"rgba(160,170,190,0.10)\",\n },\n },\n },\n {\n id: \"moss\",\n name: \"Moss\",\n blurb: \"Green ground · solved for AAA body text\",\n light: {\n bgPage: \"#d8e6dd\",\n bgSurface: \"#e3f1e8\",\n bgSurface2: \"#d8e6dd\",\n border: \"#abbfb2\",\n textPrimary: \"#2c332e\",\n textSecondary: \"#424b45\",\n textMuted: \"#5d6660\",\n brand: \"#2f7a52\",\n brandHover: \"#196842\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#00725f\",\n moneyExpense: \"#8e5900\",\n moneyNet: \"#7356a2\",\n moneyNeutral: \"#5f6662\",\n chart: PAUL_TOL.light,\n heat: {\n neutral: \"#d8e6dd\",\n under: \"#00725f\",\n over: \"#8e5900\",\n seqLow: \"#fee3c5\",\n seqHigh: \"#8e5900\",\n empty: \"rgba(120,110,95,0.14)\",\n },\n },\n dark: {\n bgPage: \"#060f0a\",\n bgSurface: \"#101a14\",\n bgSurface2: \"#1a241e\",\n border: \"#314337\",\n textPrimary: \"#c6cfc9\",\n textSecondary: \"#a5aea8\",\n textMuted: \"#838c86\",\n brand: \"#317b53\",\n brandHover: \"#1c6943\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#009a80\",\n moneyExpense: \"#b87c2a\",\n moneyNet: \"#977ac9\",\n moneyNeutral: \"#9fa7a2\",\n chart: PAUL_TOL.dark,\n heat: {\n neutral: \"#1a241e\",\n under: \"#009a80\",\n over: \"#b87c2a\",\n seqLow: \"#331f05\",\n seqHigh: \"#b87c2a\",\n empty: \"rgba(160,170,190,0.10)\",\n },\n },\n },\n {\n id: \"plum\",\n name: \"Plum\",\n blurb: \"Violet ground · solved for AAA body text\",\n light: {\n bgPage: \"#e7dfea\",\n bgSurface: \"#f2e9f5\",\n bgSurface2: \"#e7dfea\",\n border: \"#c2b5c6\",\n textPrimary: \"#352f36\",\n textSecondary: \"#4c464e\",\n textMuted: \"#676169\",\n brand: \"#7b3f8f\",\n brandHover: \"#692d7d\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#00715e\",\n moneyExpense: \"#8d5800\",\n moneyNet: \"#7355a2\",\n moneyNeutral: \"#676269\",\n chart: PAUL_TOL.light,\n heat: {\n neutral: \"#e7dfea\",\n under: \"#00715e\",\n over: \"#8d5800\",\n seqLow: \"#fee3c5\",\n seqHigh: \"#8d5800\",\n empty: \"rgba(120,110,95,0.14)\",\n },\n },\n dark: {\n bgPage: \"#100b12\",\n bgSurface: \"#1b151d\",\n bgSurface2: \"#251f27\",\n border: \"#453b48\",\n textPrimary: \"#d0cad2\",\n textSecondary: \"#afa9b1\",\n textMuted: \"#8d8790\",\n brand: \"#9154a6\",\n brandHover: \"#7f4293\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#009980\",\n moneyExpense: \"#b77b29\",\n moneyNet: \"#9679c9\",\n moneyNeutral: \"#a8a2aa\",\n chart: PAUL_TOL.dark,\n heat: {\n neutral: \"#251f27\",\n under: \"#009980\",\n over: \"#b77b29\",\n seqLow: \"#331f05\",\n seqHigh: \"#b77b29\",\n empty: \"rgba(160,170,190,0.10)\",\n },\n },\n },\n {\n id: \"contrast\",\n name: \"High contrast\",\n blurb: \"Near-neutral ground · text solved to 13:1 / 8.5:1 / 5.5:1\",\n light: {\n bgPage: \"#e1e2e3\",\n bgSurface: \"#ecedee\",\n bgSurface2: \"#e1e2e3\",\n border: \"#b8babc\",\n textPrimary: \"#1b1d21\",\n textSecondary: \"#3a3c40\",\n textMuted: \"#55585c\",\n brand: \"#1f2937\",\n brandHover: \"#111b28\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#00715e\",\n moneyExpense: \"#8d5800\",\n moneyNet: \"#7255a1\",\n moneyNeutral: \"#616467\",\n chart: PAUL_TOL.light,\n heat: {\n neutral: \"#e1e2e3\",\n under: \"#00715e\",\n over: \"#8d5800\",\n seqLow: \"#fee3c5\",\n seqHigh: \"#8d5800\",\n empty: \"rgba(120,110,95,0.14)\",\n },\n },\n dark: {\n bgPage: \"#0c0d0e\",\n bgSurface: \"#171718\",\n bgSurface2: \"#212122\",\n border: \"#3d3e3f\",\n textPrimary: \"#e4e8ec\",\n textSecondary: \"#b9bdc1\",\n textMuted: \"#95979c\",\n brand: \"#556c8e\",\n brandHover: \"#455b7c\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#009a80\",\n moneyExpense: \"#b77b29\",\n moneyNet: \"#9679c9\",\n moneyNeutral: \"#a2a5a8\",\n chart: PAUL_TOL.dark,\n heat: {\n neutral: \"#212122\",\n under: \"#009a80\",\n over: \"#b77b29\",\n seqLow: \"#331f05\",\n seqHigh: \"#b77b29\",\n empty: \"rgba(160,170,190,0.10)\",\n },\n },\n },\n];\n\nexport const PALETTES: PalettePreset[] = [DEFAULT_PRESET, IMPRINT_PRESET, ...DERIVED_PRESETS];\n\nexport function presetById(id: string): PalettePreset {\n return PALETTES.find((p) => p.id === id) ?? DEFAULT_PRESET;\n}\n\n/**\n * Write a token set onto an element's inline style as CSS custom properties.\n *\n * This is the only writer, and there is no un-writer: `presetById` falls back to\n * `DEFAULT_PRESET` and this then writes the full set, so returning to the default\n * APPLIES it rather than clearing anything. A `clearTokenSet` and the hand-kept\n * 24-name `TOKEN_VARS` list it looped over used to live here, both exported, both\n * called by nobody in either consumer — a second copy of these property names with\n * nothing comparing the two, so adding a token here and forgetting it there would\n * have been silent and only a dead function would have noticed. The list is gone;\n * the test beside this asserts instead that every field of a `TokenSet` reaches the\n * element, which is what that list was standing in for.\n *\n * **More contrast (0.32, docs/text-size-harmonization.md §5, §10.5).** With `contrast`\n * the text and border tokens are written one step stronger, and `--text-placeholder` —\n * otherwise derived in tokens.css — is written inline too; without it that inline\n * placeholder is removed again, so the stylesheet's formula applies. The step is made\n * HERE because these tokens are inline: no `[data-contrast=more]` rule could beat them.\n *\n * `contrast` defaults to the element's own `data-contrast=\"more\"`, which the contrast\n * layer (`applyContrast`, `useApplyContrast`) sets first. That is how the palette and the\n * contrast cooperate without knowing each other: a palette change re-applies through\n * here and keeps the contrast, and a contrast change re-applies the set remembered here.\n */\nexport function applyTokenSet(\n el: HTMLElement,\n t: TokenSet,\n contrast: boolean = el.getAttribute(\"data-contrast\") === \"more\",\n): void {\n rememberTokenSet(el, t);\n const step = contrast ? contrastStep(t) : null;\n el.style.setProperty(\"--bg-page\", t.bgPage);\n el.style.setProperty(\"--bg-surface\", t.bgSurface);\n el.style.setProperty(\"--bg-surface-2\", t.bgSurface2);\n el.style.setProperty(\"--border\", step?.border ?? t.border);\n el.style.setProperty(\"--text-primary\", t.textPrimary);\n el.style.setProperty(\"--text-secondary\", step?.textSecondary ?? t.textSecondary);\n el.style.setProperty(\"--text-muted\", step?.textMuted ?? t.textMuted);\n if (step) el.style.setProperty(\"--text-placeholder\", step.textPlaceholder);\n else el.style.removeProperty(\"--text-placeholder\");\n el.style.setProperty(\"--brand\", t.brand);\n el.style.setProperty(\"--brand-hover\", t.brandHover);\n el.style.setProperty(\"--brand-contrast\", t.brandContrast);\n el.style.setProperty(\"--money-income\", t.moneyIncome);\n el.style.setProperty(\"--money-expense\", t.moneyExpense);\n el.style.setProperty(\"--money-net\", t.moneyNet);\n el.style.setProperty(\"--money-neutral\", t.moneyNeutral);\n t.chart.forEach((c, i) => el.style.setProperty(`--chart-${i + 1}`, c));\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/theme/palette-presets.ts"],"sourcesContent":["// Appearance presets — the single source of truth for the \"palette switcher\"\n// (feedback #307). Each preset is a full set of design-token values for BOTH\n// light and dark, covering the page/surface backgrounds, the brand accent, the\n// semantic money trio and the 9-colour categorical chart palette, plus the\n// heatmap stops. A preset is applied at runtime by writing these as CSS custom\n// properties onto <html> (see palette-store.ts), which instantly re-skins every\n// token-driven surface and chart. The two heatmaps and the treemap interpolate\n// concrete hex in JS, so they read the same values from here via the hooks in\n// palette-store.\n//\n// The default preset here mirrors the :root/.dark blocks in tokens.css exactly. The\n// five hand-picked alternatives of #307 (`ALTERNATIVE_PRESETS`, a reference bank that\n// `presetById` never resolved) were removed in 0.35 (refactor audit 2026-10, API-6c;\n// deprecated in 0.34): `PALETTES` is what an app offers, and `derivePalette` makes a\n// palette of its own.\n\nimport type { HeatStops } from \"./chart-palette\";\nimport { contrastStep, rememberTokenSet } from \"./contrast-tokens\";\n\nexport interface TokenSet {\n // Backgrounds & chrome\n bgPage: string;\n bgSurface: string;\n bgSurface2: string;\n border: string;\n textPrimary: string;\n // The two quieter text roles. Derived per preset by blending `textPrimary` toward\n // that preset's own surfaces until the contrast against the WORST of bgPage /\n // bgSurface / bgSurface2 hits 7:1 and 4.8:1 — so both clear WCAG AA for normal text\n // (4.5:1) on every surface they can land on, and both inherit the preset's warmth\n // instead of importing a cool grey from Tailwind's palette.\n textSecondary: string;\n textMuted: string;\n // Interactive brand accent\n brand: string;\n brandHover: string;\n brandContrast: string;\n // Semantic money (never red/green — CVD-safe, paired with +/- cues elsewhere)\n moneyIncome: string;\n moneyExpense: string;\n moneyNet: string;\n moneyNeutral: string;\n // Categorical chart series (Paul Tol \"Muted\" by default — CVD-safe), exactly 9\n chart: string[];\n // Heatmap interpolation stops (variance diverging + spending sequential)\n heat: HeatStops;\n}\n\nexport interface PalettePreset {\n id: string;\n name: string;\n blurb: string;\n light: TokenSet;\n dark: TokenSet;\n}\n\n// The shipped default (feedback #328): SOLAR-DUSK warm light + INDIGO-NOIR deep\n// dark surfaces — the two curated presets the user picked per mode — but with a\n// SINGLE unified brand accent (indigo) across both modes rather than each\n// preset's own accent (the user asked for the brand accent to stay the same).\n// So the light block is Solar Dusk with its teal brand overridden to indigo, and\n// the dark block is Indigo Noir (already indigo). The money trio\n// (income/expense/net) drives the chart bars and stays palette-driven so those\n// semantic colours adapt to each mode's contrast (best practice — feedback #328).\nexport const DEFAULT_PRESET: PalettePreset = {\n id: \"default\",\n name: \"Default\",\n blurb: \"Solar-dusk light · indigo-noir dark\",\n light: {\n // Solar Dusk (light) surfaces, with the brand unified to indigo (see above).\n bgPage: \"#ede3cf\",\n bgSurface: \"#f6ecd9\",\n // The well is the page in light (0.33, k26): #f3ead6 stood 1.02:1 from the card it\n // is inset in. Every light preset does the same; see SURFACE_L in palette-derive.ts.\n bgSurface2: \"#ede3cf\",\n border: \"#e2d6bd\",\n textPrimary: \"#463f33\",\n textSecondary: \"#50493c\",\n textMuted: \"#696153\",\n brand: \"#4f46e5\",\n brandHover: \"#4338ca\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#0b6650\",\n moneyExpense: \"#905902\",\n moneyNet: \"#7a4fa3\",\n moneyNeutral: \"#6b6453\",\n chart: [\"#332288\", \"#88ccee\", \"#44aa99\", \"#117733\", \"#999933\", \"#ddcc77\", \"#cc6677\", \"#882255\", \"#aa4499\"],\n heat: {\n neutral: \"#f1e7d2\",\n under: \"#0b6650\",\n over: \"#9c6418\",\n seqLow: \"#f6ecd0\",\n seqHigh: \"#9c6418\",\n empty: \"rgba(148,163,184,0.14)\",\n },\n },\n dark: {\n // Indigo Noir (dark)\n bgPage: \"#0e0f1c\",\n bgSurface: \"#181a2c\",\n bgSurface2: \"#21243a\",\n border: \"#2e3252\",\n textPrimary: \"#e6e8f4\",\n textSecondary: \"#adafbe\",\n textMuted: \"#8d90a0\",\n brand: \"#8b93f8\",\n brandHover: \"#aab1fb\",\n brandContrast: \"#16182b\",\n moneyIncome: \"#22c3b6\",\n // Amber-400 (#fbbf24) sat at OKLab L 0.84 while every other dark token lives\n // at 0.68–0.74, so outflows outshouted the inflows they are paired with. Same\n // hue and chroma, pulled to L 0.785 (dev#434). The heat stops below keep the\n // brighter amber on purpose: a gradient endpoint is meant to be the loudest\n // thing in its scale, and it is a fill, not a figure.\n moneyExpense: \"#e4b035\",\n moneyNet: \"#b39aef\",\n moneyNeutral: \"#9aa1bd\",\n chart: [\"#7e72d6\", \"#9fd8f2\", \"#5fc4b0\", \"#3fa45f\", \"#bfbf5e\", \"#e8dda0\", \"#e08c9a\", \"#c25a78\", \"#cc78be\"],\n heat: {\n neutral: \"#363a5a\",\n under: \"#22c3b6\",\n over: \"#fbbf24\",\n seqLow: \"#3a2c14\",\n seqHigh: \"#fbbf24\",\n empty: \"rgba(148,163,184,0.10)\",\n },\n },\n};\n\n// ── \"Imprint\" chart hues (feedback #119) ──────────────────────────────────────\n// anyplot's categorical palette (https://anyplot.ai/palette), by a colleague of\n// Marcel's. 8 hues + an amber semantic anchor, derived from Wong 2011 by a\n// Petroff-style max-min ΔE search in CAM02-UCS with Machado 2009 CVD simulation —\n// the same lineage as our default Paul Tol \"Muted\", one generation on, and tuned\n// for exactly the warm cream paper our light theme uses.\n//\n// This preset is DEFAULT_PRESET with ONLY the 9 categorical chart hues swapped;\n// surfaces, brand and the semantic money trio stay put, because those were their\n// own deliberate decisions (#307/#328) and are not what the palette is for. The\n// heatmap stops likewise stay teal/amber: they encode under/over, and imprint's\n// own diverging ramp is red↔blue, which would fight the money semantics.\n//\n// Measured on our surfaces (CIEDE2000, Machado severity-100, 8 like-for-like\n// slots on the cream light surface #f6ecd9) — neither palette dominates:\n// Paul Tol Muted min-contrast 1.38 · worst-CVD ΔE00 11.8 · normal ΔE00 16.1\n// imprint min-contrast 1.95 · worst-CVD ΔE00 7.2 · normal ΔE00 22.2\n// i.e. imprint is punchier and has no near-invisible pale slot (Tol's #88ccee\n// sits at 1.5:1 on cream), while Tol separates better under colour-vision\n// deficiency. So this ships as a CHOICE, not as a new default.\n//\n// The dark set keeps imprint's hues verbatim except matte-red and rose, whose\n// lightness is lifted (oklch L 0.50→0.55) because verbatim they land at 2.67:1\n// and 2.76:1 on our deep-indigo dark surface — under WCAG 2.1 SC 1.4.11's 3:1\n// non-text minimum. Lifted they clear it at 3.30:1 / 3.28:1. anyplot documents\n// the same shortfall on their own dark ground and answers it with a stroke halo;\n// lifting keeps the fix in the token layer instead of every chart component.\nconst IMPRINT_HUES_LIGHT = [\n \"#009E73\", // brand green — first series\n \"#C475FD\", // lavender — creative\n \"#4467A3\", // blue — cool / info\n \"#BD8233\", // ochre — earth / commodity\n \"#AE3030\", // matte red — bad / loss / error\n \"#2ABCCD\", // cyan — sky / tech-cool\n \"#954477\", // rose — wellness / health\n \"#99B314\", // lime — growth / nature\n \"#DDCC77\", // amber — anyplot's semantic anchor, used here as slot 9\n];\nconst IMPRINT_HUES_DARK = [\n \"#009E73\",\n \"#C475FD\",\n \"#4467A3\",\n \"#BD8233\",\n \"#BF413E\", // matte red, L-lifted for our dark surface\n \"#2ABCCD\",\n \"#A25083\", // rose, L-lifted for our dark surface\n \"#99B314\",\n \"#DDCC77\",\n];\n\nexport const IMPRINT_PRESET: PalettePreset = {\n id: \"imprint\",\n name: \"Imprint\",\n blurb: \"Colourblind-safe chart hues by anyplot.ai\",\n light: { ...DEFAULT_PRESET.light, chart: IMPRINT_HUES_LIGHT },\n dark: { ...DEFAULT_PRESET.dark, chart: IMPRINT_HUES_DARK },\n};\n\n// The full-theme switcher from #307 stayed retired (feedback #328) — the default\n// is still the single source of truth for surfaces, brand and money (its five\n// alternatives are gone, see the header). What IS selectable is\n// the categorical chart ramp (feedback #119), so these two presets are the live\n// set; presetById falls back to the default for any stale persisted id.\n/**\n * The categorical chart ramp, shared by every derived preset below.\n *\n * Paul Tol's \"Muted\" set, unchanged, and deliberately NOT re-derived per preset. A\n * categorical ramp solves a different problem from a UI palette — separability among\n * nine unknown siblings, rather than contrast against one known surface — and a set\n * optimised against real dichromat confusion lines beats one spaced evenly by formula.\n * `auditChartRamp` in palette-derive.ts measures it: 0.059 minimum separation in the\n * light variant, which is the number MIN_SEPARATION was calibrated under.\n *\n * It pays for that in contrast: four of the nine light hues sit below the 3:1 WCAG\n * 1.4.11 asks of a filled shape. That is not fixable by darkening them — see\n * SINGLE_LIGHTNESS_MAX_SERIES — so a chart with more than four series must carry a\n * second channel (a direct label, a pattern, a legend adjacent to the mark).\n */\nconst PAUL_TOL = {\n light: DEFAULT_PRESET.light.chart,\n dark: DEFAULT_PRESET.dark.chart,\n} as const;\n\n/**\n * Presets DERIVED rather than picked, by `derivePalette` from one brand colour each.\n *\n * Every text role here was solved to a contrast target against the worst of the three\n * surfaces it can land on, every semantic colour against both the page and its own badge\n * tint, and the whole set is re-checked by `__tests__/palette-audit.test.ts` (\"derived\n * presets\") — which re-runs the deriver and fails if these committed values drift from\n * what it now produces.\n *\n * They are committed rather than derived at import because deriving eight token sets\n * costs ~34ms, and charging every consumer that at startup for palettes most of them\n * will never select is the wrong trade. The test is what keeps the two in agreement.\n *\n * Regenerated in 0.34 (round 0.34 BUG-8): the solve now measures the hex it ships rather\n * than the float before rounding, and the audit no longer lets a hex pair pass 0.05 under\n * its threshold. 52 values moved one step (66 counting the heat stops that repeat them);\n * Contrast's neutrals lost half their slate tint, its brand having half the chroma at\n * which the neutrals carry a hue in full.\n */\nexport const DERIVED_PRESETS: PalettePreset[] = [\n {\n id: \"ink\",\n name: \"Ink\",\n blurb: \"Cool blue-grey ground · solved for AAA body text\",\n light: {\n bgPage: \"#dce2ef\",\n bgSurface: \"#e6edfa\",\n bgSurface2: \"#dce2ef\",\n border: \"#b0bace\",\n textPrimary: \"#2e3139\",\n textSecondary: \"#454850\",\n textMuted: \"#5f636c\",\n brand: \"#2f5fd0\",\n brandHover: \"#1f4cbc\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#00725f\",\n moneyExpense: \"#8e5900\",\n moneyNet: \"#7356a2\",\n moneyNeutral: \"#61646c\",\n chart: PAUL_TOL.light,\n heat: {\n neutral: \"#dce2ef\",\n under: \"#00725f\",\n over: \"#8e5900\",\n seqLow: \"#fee3c5\",\n seqHigh: \"#8e5900\",\n empty: \"rgba(120,110,95,0.14)\",\n },\n },\n dark: {\n bgPage: \"#090d15\",\n bgSurface: \"#131720\",\n bgSurface2: \"#1d212b\",\n border: \"#363e4f\",\n textPrimary: \"#c8ccd6\",\n textSecondary: \"#a7abb5\",\n textMuted: \"#858a92\",\n brand: \"#3466d7\",\n brandHover: \"#2353c3\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#009a80\",\n moneyExpense: \"#b77b29\",\n moneyNet: \"#9679c9\",\n moneyNeutral: \"#a1a5ac\",\n chart: PAUL_TOL.dark,\n heat: {\n neutral: \"#1d212b\",\n under: \"#009a80\",\n over: \"#b77b29\",\n seqLow: \"#331f05\",\n seqHigh: \"#b77b29\",\n empty: \"rgba(160,170,190,0.10)\",\n },\n },\n },\n {\n id: \"moss\",\n name: \"Moss\",\n blurb: \"Green ground · solved for AAA body text\",\n light: {\n bgPage: \"#d8e6dd\",\n bgSurface: \"#e3f1e8\",\n bgSurface2: \"#d8e6dd\",\n border: \"#abbfb2\",\n textPrimary: \"#2c332e\",\n textSecondary: \"#424b45\",\n textMuted: \"#5d6660\",\n brand: \"#2f7a52\",\n brandHover: \"#196842\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#00725f\",\n moneyExpense: \"#8e5900\",\n moneyNet: \"#7356a2\",\n moneyNeutral: \"#5f6662\",\n chart: PAUL_TOL.light,\n heat: {\n neutral: \"#d8e6dd\",\n under: \"#00725f\",\n over: \"#8e5900\",\n seqLow: \"#fee3c5\",\n seqHigh: \"#8e5900\",\n empty: \"rgba(120,110,95,0.14)\",\n },\n },\n dark: {\n bgPage: \"#060f0a\",\n bgSurface: \"#101a14\",\n bgSurface2: \"#1a241e\",\n border: \"#314337\",\n textPrimary: \"#c6cfc9\",\n textSecondary: \"#a5aea8\",\n textMuted: \"#838c86\",\n brand: \"#317b53\",\n brandHover: \"#1c6943\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#009a80\",\n moneyExpense: \"#b87c2a\",\n moneyNet: \"#977ac9\",\n moneyNeutral: \"#9fa7a2\",\n chart: PAUL_TOL.dark,\n heat: {\n neutral: \"#1a241e\",\n under: \"#009a80\",\n over: \"#b87c2a\",\n seqLow: \"#331f05\",\n seqHigh: \"#b87c2a\",\n empty: \"rgba(160,170,190,0.10)\",\n },\n },\n },\n {\n id: \"plum\",\n name: \"Plum\",\n blurb: \"Violet ground · solved for AAA body text\",\n light: {\n bgPage: \"#e7dfea\",\n bgSurface: \"#f2e9f5\",\n bgSurface2: \"#e7dfea\",\n border: \"#c2b5c6\",\n textPrimary: \"#352f36\",\n textSecondary: \"#4c464e\",\n textMuted: \"#676169\",\n brand: \"#7b3f8f\",\n brandHover: \"#692d7d\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#00715e\",\n moneyExpense: \"#8d5800\",\n moneyNet: \"#7355a2\",\n moneyNeutral: \"#676269\",\n chart: PAUL_TOL.light,\n heat: {\n neutral: \"#e7dfea\",\n under: \"#00715e\",\n over: \"#8d5800\",\n seqLow: \"#fee3c5\",\n seqHigh: \"#8d5800\",\n empty: \"rgba(120,110,95,0.14)\",\n },\n },\n dark: {\n bgPage: \"#100b12\",\n bgSurface: \"#1b151d\",\n bgSurface2: \"#251f27\",\n border: \"#453b48\",\n textPrimary: \"#d0cad2\",\n textSecondary: \"#afa9b1\",\n textMuted: \"#8d8790\",\n brand: \"#9154a6\",\n brandHover: \"#7f4293\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#009980\",\n moneyExpense: \"#b77b29\",\n moneyNet: \"#9679c9\",\n moneyNeutral: \"#a8a2aa\",\n chart: PAUL_TOL.dark,\n heat: {\n neutral: \"#251f27\",\n under: \"#009980\",\n over: \"#b77b29\",\n seqLow: \"#331f05\",\n seqHigh: \"#b77b29\",\n empty: \"rgba(160,170,190,0.10)\",\n },\n },\n },\n {\n id: \"contrast\",\n name: \"High contrast\",\n blurb: \"Near-neutral ground · text solved to 13:1 / 8.5:1 / 5.5:1\",\n light: {\n bgPage: \"#e1e2e3\",\n bgSurface: \"#ecedee\",\n bgSurface2: \"#e1e2e3\",\n border: \"#b8babc\",\n textPrimary: \"#1b1d21\",\n textSecondary: \"#3a3c40\",\n textMuted: \"#55585c\",\n brand: \"#1f2937\",\n brandHover: \"#111b28\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#00715e\",\n moneyExpense: \"#8d5800\",\n moneyNet: \"#7255a1\",\n moneyNeutral: \"#616467\",\n chart: PAUL_TOL.light,\n heat: {\n neutral: \"#e1e2e3\",\n under: \"#00715e\",\n over: \"#8d5800\",\n seqLow: \"#fee3c5\",\n seqHigh: \"#8d5800\",\n empty: \"rgba(120,110,95,0.14)\",\n },\n },\n dark: {\n bgPage: \"#0c0d0e\",\n bgSurface: \"#171718\",\n bgSurface2: \"#212122\",\n border: \"#3d3e3f\",\n textPrimary: \"#e4e8ec\",\n textSecondary: \"#b9bdc1\",\n textMuted: \"#95979c\",\n brand: \"#556c8e\",\n brandHover: \"#455b7c\",\n brandContrast: \"#ffffff\",\n moneyIncome: \"#009a80\",\n moneyExpense: \"#b77b29\",\n moneyNet: \"#9679c9\",\n moneyNeutral: \"#a2a5a8\",\n chart: PAUL_TOL.dark,\n heat: {\n neutral: \"#212122\",\n under: \"#009a80\",\n over: \"#b77b29\",\n seqLow: \"#331f05\",\n seqHigh: \"#b77b29\",\n empty: \"rgba(160,170,190,0.10)\",\n },\n },\n },\n];\n\nexport const PALETTES: PalettePreset[] = [DEFAULT_PRESET, IMPRINT_PRESET, ...DERIVED_PRESETS];\n\nexport function presetById(id: string): PalettePreset {\n return PALETTES.find((p) => p.id === id) ?? DEFAULT_PRESET;\n}\n\n/**\n * Write a token set onto an element's inline style as CSS custom properties.\n *\n * This is the only writer, and there is no un-writer: `presetById` falls back to\n * `DEFAULT_PRESET` and this then writes the full set, so returning to the default\n * APPLIES it rather than clearing anything. A `clearTokenSet` and the hand-kept\n * 24-name `TOKEN_VARS` list it looped over used to live here, both exported, both\n * called by nobody in either consumer — a second copy of these property names with\n * nothing comparing the two, so adding a token here and forgetting it there would\n * have been silent and only a dead function would have noticed. The list is gone;\n * the test beside this asserts instead that every field of a `TokenSet` reaches the\n * element, which is what that list was standing in for.\n *\n * **More contrast (0.32, docs/text-size-harmonization.md §5, §10.5).** With `contrast`\n * the text and border tokens are written one step stronger — in a light set the well and\n * the four money colours too (0.36, docs/colour-roles-harmonization.md §13) — and `--text-placeholder` —\n * otherwise derived in tokens.css — is written inline too; without it that inline\n * placeholder is removed again, so the stylesheet's formula applies. The step is made\n * HERE because these tokens are inline: no `[data-contrast=more]` rule could beat them.\n *\n * `contrast` defaults to the element's own `data-contrast=\"more\"`, which the contrast\n * layer (`applyContrast`, `useApplyContrast`) sets first. That is how the palette and the\n * contrast cooperate without knowing each other: a palette change re-applies through\n * here and keeps the contrast, and a contrast change re-applies the set remembered here.\n */\nexport function applyTokenSet(\n el: HTMLElement,\n t: TokenSet,\n contrast: boolean = el.getAttribute(\"data-contrast\") === \"more\",\n): void {\n rememberTokenSet(el, t);\n const step = contrast ? contrastStep(t) : null;\n el.style.setProperty(\"--bg-page\", t.bgPage);\n el.style.setProperty(\"--bg-surface\", t.bgSurface);\n // The light well and money step under More contrast (0.36, colour-roles §13); in dark\n // and at Standard `well` is null and the set's own are written.\n el.style.setProperty(\"--bg-surface-2\", step?.well?.bgSurface2 ?? t.bgSurface2);\n el.style.setProperty(\"--border\", step?.border ?? t.border);\n el.style.setProperty(\"--text-primary\", t.textPrimary);\n el.style.setProperty(\"--text-secondary\", step?.textSecondary ?? t.textSecondary);\n el.style.setProperty(\"--text-muted\", step?.textMuted ?? t.textMuted);\n if (step) el.style.setProperty(\"--text-placeholder\", step.textPlaceholder);\n else el.style.removeProperty(\"--text-placeholder\");\n el.style.setProperty(\"--brand\", t.brand);\n el.style.setProperty(\"--brand-hover\", t.brandHover);\n el.style.setProperty(\"--brand-contrast\", t.brandContrast);\n el.style.setProperty(\"--money-income\", step?.well?.moneyIncome ?? t.moneyIncome);\n el.style.setProperty(\"--money-expense\", step?.well?.moneyExpense ?? t.moneyExpense);\n el.style.setProperty(\"--money-net\", step?.well?.moneyNet ?? t.moneyNet);\n el.style.setProperty(\"--money-neutral\", step?.well?.moneyNeutral ?? t.moneyNeutral);\n t.chart.forEach((c, i) => el.style.setProperty(`--chart-${i + 1}`, c));\n}\n"],"mappings":"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","names":[]}
@@ -3,6 +3,7 @@ import { useEffect } from "react";
3
3
  import { create } from "zustand";
4
4
  import { persist } from "zustand/middleware";
5
5
  import { logger } from "../lib/logger.js";
6
+ import { registerDeviceSetting } from "../lib/device-settings.js";
6
7
  import { applyTokenSet, DEFAULT_PRESET, presetById } from "./palette-presets.js";
7
8
  function applyPersistedPalette(storageKey, mode) {
8
9
  let id = DEFAULT_PRESET.id;
@@ -26,6 +27,11 @@ function createPaletteStore(storageKey, useTheme) {
26
27
  "palette"
27
28
  )
28
29
  );
30
+ registerDeviceSetting({
31
+ key: storageKey,
32
+ kind: "appearance",
33
+ reset: () => usePalette.getState().setId(DEFAULT_PRESET.id)
34
+ });
29
35
  function useApplyPalette() {
30
36
  const id = usePalette((s) => s.id);
31
37
  const mode = useTheme((s) => s.mode);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/theme/palette-store.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport type { StoreApi, UseBoundStore } from \"zustand\";\nimport { logger } from \"../lib/logger\";\nimport type { HeatStops } from \"./chart-palette\";\nimport { applyTokenSet, DEFAULT_PRESET, presetById, type TokenSet } from \"./palette-presets\";\nimport type { ThemeState, ThemeMode } from \"./theme-store\";\n\n/**\n * Pre-hydration counterpart to `applyPersistedTheme`: read the palette id\n * persisted under `storageKey` straight from localStorage and apply that preset's\n * tokens for the given resolved `mode` to <html>, BEFORE React mounts (no flash of\n * the default palette). Falls back to the default preset for missing/malformed\n * state, so an app entrypoint never re-types the persist envelope.\n */\nexport function applyPersistedPalette(storageKey: string, mode: ThemeMode): void {\n let id = DEFAULT_PRESET.id;\n try {\n const blob = JSON.parse(localStorage.getItem(storageKey) ?? \"{}\") as {\n state?: { id?: string };\n };\n if (blob.state?.id) id = blob.state.id;\n } catch {\n /* malformed storage → default preset */\n }\n applyTokenSet(document.documentElement, presetById(id)[mode]);\n}\n\n// The palette layer flips the whole app between candidate appearance presets at\n// runtime (feedback #307), re-skinning every token-driven surface and chart.\n// Persisted like the theme so a reload keeps the selection.\ninterface PaletteState {\n id: string;\n setId: (id: string) => void;\n}\n\n/**\n * Build a palette store bound to a consumer-supplied localStorage key, wired to\n * that app's theme store (for the active light/dark mode). Returns the palette\n * hook plus the effect + selector hooks that read the active preset's tokens.\n */\nexport function createPaletteStore(\n storageKey: string,\n useTheme: UseBoundStore<StoreApi<ThemeState>>,\n) {\n const usePalette = create<PaletteState>()(\n logger(\n persist(\n (set) => ({\n id: DEFAULT_PRESET.id,\n setId: (id) => set({ id }),\n }),\n { name: storageKey, version: 1 },\n ),\n \"palette\",\n ),\n );\n\n /** Apply the active preset's tokens to <html> as inline CSS custom properties,\n * re-applying when the preset or the light/dark theme changes. Tailwind v4\n * strips a bare root token block from the bundle, so this inline write from\n * the TS source of truth is what actually drives the colours.\n *\n * 0.32: keeps More contrast. `applyTokenSet` reads `<html data-contrast>`, which the\n * contrast store sets, and steps the tokens itself; the contrast store re-applies\n * the set written here when the contrast changes (docs/text-size-harmonization.md\n * §10.5). Neither store needs the other. */\n function useApplyPalette(): void {\n const id = usePalette((s) => s.id);\n const mode = useTheme((s) => s.mode);\n useEffect(() => {\n applyTokenSet(document.documentElement, presetById(id)[mode]);\n }, [id, mode]);\n }\n\n /** The active preset's token set for the current theme — the source for the\n * JS-interpolated charts (treemap fills, heatmap stops) that can't read CSS\n * vars. */\n function useActiveTokenSet(): TokenSet {\n const id = usePalette((s) => s.id);\n const mode = useTheme((s) => s.mode);\n return presetById(id)[mode];\n }\n\n /** The 9 categorical chart hues for the active preset/theme (treemap cells). */\n function useChartHex(): string[] {\n return useActiveTokenSet().chart;\n }\n\n /** The active preset/theme heatmap interpolation stops. */\n function useHeatStops(): HeatStops {\n return useActiveTokenSet().heat;\n }\n\n return { usePalette, useApplyPalette, useActiveTokenSet, useChartHex, useHeatStops };\n}\n\nexport type PaletteStore = ReturnType<typeof createPaletteStore>;\n"],"mappings":";AAAA,SAAS,iBAAiB;AAC1B,SAAS,cAAc;AACvB,SAAS,eAAe;AAExB,SAAS,cAAc;AAEvB,SAAS,eAAe,gBAAgB,kBAAiC;AAUlE,SAAS,sBAAsB,YAAoB,MAAuB;AAC/E,MAAI,KAAK,eAAe;AACxB,MAAI;AACF,UAAM,OAAO,KAAK,MAAM,aAAa,QAAQ,UAAU,KAAK,IAAI;AAGhE,QAAI,KAAK,OAAO,GAAI,MAAK,KAAK,MAAM;AAAA,EACtC,QAAQ;AAAA,EAER;AACA,gBAAc,SAAS,iBAAiB,WAAW,EAAE,EAAE,IAAI,CAAC;AAC9D;AAeO,SAAS,mBACd,YACA,UACA;AACA,QAAM,aAAa,OAAqB;AAAA,IACtC;AAAA,MACE;AAAA,QACE,CAAC,SAAS;AAAA,UACR,IAAI,eAAe;AAAA,UACnB,OAAO,CAAC,OAAO,IAAI,EAAE,GAAG,CAAC;AAAA,QAC3B;AAAA,QACA,EAAE,MAAM,YAAY,SAAS,EAAE;AAAA,MACjC;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAWA,WAAS,kBAAwB;AAC/B,UAAM,KAAK,WAAW,CAAC,MAAM,EAAE,EAAE;AACjC,UAAM,OAAO,SAAS,CAAC,MAAM,EAAE,IAAI;AACnC,cAAU,MAAM;AACd,oBAAc,SAAS,iBAAiB,WAAW,EAAE,EAAE,IAAI,CAAC;AAAA,IAC9D,GAAG,CAAC,IAAI,IAAI,CAAC;AAAA,EACf;AAKA,WAAS,oBAA8B;AACrC,UAAM,KAAK,WAAW,CAAC,MAAM,EAAE,EAAE;AACjC,UAAM,OAAO,SAAS,CAAC,MAAM,EAAE,IAAI;AACnC,WAAO,WAAW,EAAE,EAAE,IAAI;AAAA,EAC5B;AAGA,WAAS,cAAwB;AAC/B,WAAO,kBAAkB,EAAE;AAAA,EAC7B;AAGA,WAAS,eAA0B;AACjC,WAAO,kBAAkB,EAAE;AAAA,EAC7B;AAEA,SAAO,EAAE,YAAY,iBAAiB,mBAAmB,aAAa,aAAa;AACrF;","names":[]}
1
+ {"version":3,"sources":["../../src/theme/palette-store.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport type { StoreApi, UseBoundStore } from \"zustand\";\nimport { logger } from \"../lib/logger\";\nimport { registerDeviceSetting } from \"../lib/device-settings\";\nimport type { HeatStops } from \"./chart-palette\";\nimport { applyTokenSet, DEFAULT_PRESET, presetById, type TokenSet } from \"./palette-presets\";\nimport type { ThemeState, ThemeMode } from \"./theme-store\";\n\n/**\n * Pre-hydration counterpart to `applyPersistedTheme`: read the palette id\n * persisted under `storageKey` straight from localStorage and apply that preset's\n * tokens for the given resolved `mode` to <html>, BEFORE React mounts (no flash of\n * the default palette). Falls back to the default preset for missing/malformed\n * state, so an app entrypoint never re-types the persist envelope.\n */\nexport function applyPersistedPalette(storageKey: string, mode: ThemeMode): void {\n let id = DEFAULT_PRESET.id;\n try {\n const blob = JSON.parse(localStorage.getItem(storageKey) ?? \"{}\") as {\n state?: { id?: string };\n };\n if (blob.state?.id) id = blob.state.id;\n } catch {\n /* malformed storage → default preset */\n }\n applyTokenSet(document.documentElement, presetById(id)[mode]);\n}\n\n// The palette layer flips the whole app between candidate appearance presets at\n// runtime (feedback #307), re-skinning every token-driven surface and chart.\n// Persisted like the theme so a reload keeps the selection.\ninterface PaletteState {\n id: string;\n setId: (id: string) => void;\n}\n\n/**\n * Build a palette store bound to a consumer-supplied localStorage key, wired to\n * that app's theme store (for the active light/dark mode). Returns the palette\n * hook plus the effect + selector hooks that read the active preset's tokens.\n */\nexport function createPaletteStore(\n storageKey: string,\n useTheme: UseBoundStore<StoreApi<ThemeState>>,\n) {\n const usePalette = create<PaletteState>()(\n logger(\n persist(\n (set) => ({\n id: DEFAULT_PRESET.id,\n setId: (id) => set({ id }),\n }),\n { name: storageKey, version: 1 },\n ),\n \"palette\",\n ),\n );\n // Reset this device's settings (docs/settings-harmonization.md §11.4): the Default\n // palette, STATE ONLY. `useApplyPalette`, where the app mounts it, re-applies through\n // its effect. Never an imperative `applyTokenSet` here: it would remember a token set\n // on <html>, and an app without a palette layer (Kurvenschmiede) would then take the\n // contrast store's palette branch, where a theme switch no longer re-writes the stepped\n // tokens (KS 3.3).\n registerDeviceSetting({\n key: storageKey,\n kind: \"appearance\",\n reset: () => usePalette.getState().setId(DEFAULT_PRESET.id),\n });\n\n /** Apply the active preset's tokens to <html> as inline CSS custom properties,\n * re-applying when the preset or the light/dark theme changes. Tailwind v4\n * strips a bare root token block from the bundle, so this inline write from\n * the TS source of truth is what actually drives the colours.\n *\n * 0.32: keeps More contrast. `applyTokenSet` reads `<html data-contrast>`, which the\n * contrast store sets, and steps the tokens itself; the contrast store re-applies\n * the set written here when the contrast changes (docs/text-size-harmonization.md\n * §10.5). Neither store needs the other. */\n function useApplyPalette(): void {\n const id = usePalette((s) => s.id);\n const mode = useTheme((s) => s.mode);\n useEffect(() => {\n applyTokenSet(document.documentElement, presetById(id)[mode]);\n }, [id, mode]);\n }\n\n /** The active preset's token set for the current theme — the source for the\n * JS-interpolated charts (treemap fills, heatmap stops) that can't read CSS\n * vars. */\n function useActiveTokenSet(): TokenSet {\n const id = usePalette((s) => s.id);\n const mode = useTheme((s) => s.mode);\n return presetById(id)[mode];\n }\n\n /** The 9 categorical chart hues for the active preset/theme (treemap cells). */\n function useChartHex(): string[] {\n return useActiveTokenSet().chart;\n }\n\n /** The active preset/theme heatmap interpolation stops. */\n function useHeatStops(): HeatStops {\n return useActiveTokenSet().heat;\n }\n\n return { usePalette, useApplyPalette, useActiveTokenSet, useChartHex, useHeatStops };\n}\n\nexport type PaletteStore = ReturnType<typeof createPaletteStore>;\n"],"mappings":";AAAA,SAAS,iBAAiB;AAC1B,SAAS,cAAc;AACvB,SAAS,eAAe;AAExB,SAAS,cAAc;AACvB,SAAS,6BAA6B;AAEtC,SAAS,eAAe,gBAAgB,kBAAiC;AAUlE,SAAS,sBAAsB,YAAoB,MAAuB;AAC/E,MAAI,KAAK,eAAe;AACxB,MAAI;AACF,UAAM,OAAO,KAAK,MAAM,aAAa,QAAQ,UAAU,KAAK,IAAI;AAGhE,QAAI,KAAK,OAAO,GAAI,MAAK,KAAK,MAAM;AAAA,EACtC,QAAQ;AAAA,EAER;AACA,gBAAc,SAAS,iBAAiB,WAAW,EAAE,EAAE,IAAI,CAAC;AAC9D;AAeO,SAAS,mBACd,YACA,UACA;AACA,QAAM,aAAa,OAAqB;AAAA,IACtC;AAAA,MACE;AAAA,QACE,CAAC,SAAS;AAAA,UACR,IAAI,eAAe;AAAA,UACnB,OAAO,CAAC,OAAO,IAAI,EAAE,GAAG,CAAC;AAAA,QAC3B;AAAA,QACA,EAAE,MAAM,YAAY,SAAS,EAAE;AAAA,MACjC;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAOA,wBAAsB;AAAA,IACpB,KAAK;AAAA,IACL,MAAM;AAAA,IACN,OAAO,MAAM,WAAW,SAAS,EAAE,MAAM,eAAe,EAAE;AAAA,EAC5D,CAAC;AAWD,WAAS,kBAAwB;AAC/B,UAAM,KAAK,WAAW,CAAC,MAAM,EAAE,EAAE;AACjC,UAAM,OAAO,SAAS,CAAC,MAAM,EAAE,IAAI;AACnC,cAAU,MAAM;AACd,oBAAc,SAAS,iBAAiB,WAAW,EAAE,EAAE,IAAI,CAAC;AAAA,IAC9D,GAAG,CAAC,IAAI,IAAI,CAAC;AAAA,EACf;AAKA,WAAS,oBAA8B;AACrC,UAAM,KAAK,WAAW,CAAC,MAAM,EAAE,EAAE;AACjC,UAAM,OAAO,SAAS,CAAC,MAAM,EAAE,IAAI;AACnC,WAAO,WAAW,EAAE,EAAE,IAAI;AAAA,EAC5B;AAGA,WAAS,cAAwB;AAC/B,WAAO,kBAAkB,EAAE;AAAA,EAC7B;AAGA,WAAS,eAA0B;AACjC,WAAO,kBAAkB,EAAE;AAAA,EAC7B;AAEA,SAAO,EAAE,YAAY,iBAAiB,mBAAmB,aAAa,aAAa;AACrF;","names":[]}
@@ -4,6 +4,7 @@ import { create } from "zustand";
4
4
  import { persist } from "zustand/middleware";
5
5
  import { logger } from "../lib/logger.js";
6
6
  import { readStored } from "../lib/safe-storage.js";
7
+ import { registerDeviceSetting } from "../lib/device-settings.js";
7
8
  const TEXT_SIZES = ["normal", "large", "xlarge"];
8
9
  const TEXT_SCALE = { normal: 1, large: 1.25, xlarge: 1.5 };
9
10
  const TEXT_SIZE_ATTRIBUTE = "data-text-size";
@@ -95,6 +96,11 @@ function createTextSizeStore(storageKey) {
95
96
  "text-size"
96
97
  )
97
98
  );
99
+ registerDeviceSetting({
100
+ key: storageKey,
101
+ kind: "appearance",
102
+ reset: () => useTextSizeStore.getState().setSize(null)
103
+ });
98
104
  function useApplyTextSize({ account } = {}) {
99
105
  const device = useTextSizeStore((s) => s.size);
100
106
  const size = resolveTextSize(device, account);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/theme/text-size.ts"],"sourcesContent":["import { useEffect, useMemo, useSyncExternalStore } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport { logger } from \"../lib/logger\";\nimport { readStored } from \"../lib/safe-storage\";\n\n/**\n * The text size (docs/text-size-harmonization.md §2.1, §3.1, §10.3, §10.6).\n *\n * ONE scale on `<html>`: `data-text-size=\"normal|large|xlarge\"`, and `tokens.css` turns\n * it into `font-size: 100% / 125% / 150%` — percent, never px, so \"Normal\" keeps\n * whatever size the person set in the browser. Tailwind's spacing and type are rem, so\n * about 90 % of every screen grows with that one declaration; the breakpoints follow too\n * (tokens.css redefines `sm:` … `3xl:` per size, {@link useBreakpoint} answers the same\n * in JS).\n *\n * The names below are the mechanism's public vocabulary and are FROZEN: an app's\n * storage, its `/auth/me` column (server-kit's `TEXT_SIZES`) and the inline boot snippet\n * all spell them this way.\n */\n\n/** The three steps, in order (§2.1, §10.6): server-kit's `TEXT_SIZES`, value for value. */\nexport const TEXT_SIZES = [\"normal\", \"large\", \"xlarge\"] as const;\n\nexport type TextSize = (typeof TEXT_SIZES)[number];\n\n/** The root font size each step sets, as a factor of the browser's own (tokens.css). For\n * what CSS cannot reach: a canvas or SVG chart's px constants, a windowed list's row\n * estimate, a popover width in px (§3.2). */\nexport const TEXT_SCALE: Readonly<Record<TextSize, number>> = { normal: 1, large: 1.25, xlarge: 1.5 };\n\n/** The attribute on `<html>` that carries the size. */\nconst TEXT_SIZE_ATTRIBUTE = \"data-text-size\";\n\nexport function isTextSize(value: unknown): value is TextSize {\n return value === \"normal\" || value === \"large\" || value === \"xlarge\";\n}\n\n/**\n * The size in force: **this device's own choice → the account's → Normal** (§2.2, §2.6).\n * Each is taken only when it is one of {@link TEXT_SIZES}; anything else (a stale value,\n * `null` = never chosen) falls through to the next. The one order the pre-paint call, the\n * apply hook and `useAccountAppearance` all use, so the three can never disagree.\n */\nexport function resolveTextSize(device: unknown, account?: unknown): TextSize {\n if (isTextSize(device)) return device;\n if (isTextSize(account)) return account;\n return \"normal\";\n}\n\n/* ── <html data-text-size> and the subscribers that read it ─────────────── */\n\nconst listeners = new Set<() => void>();\nlet observer: MutationObserver | null = null;\n\nfunction notify(): void {\n for (const listener of listeners) listener();\n}\n\nfunction subscribe(listener: () => void): () => void {\n listeners.add(listener);\n // Also catches writers that are not this module — the inline boot snippet, an app\n // setting the attribute by hand, a test.\n if (!observer && typeof MutationObserver !== \"undefined\" && typeof document !== \"undefined\") {\n observer = new MutationObserver(notify);\n observer.observe(document.documentElement, { attributes: true, attributeFilter: [TEXT_SIZE_ATTRIBUTE] });\n }\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) {\n observer?.disconnect();\n observer = null;\n }\n };\n}\n\n/** The size `<html>` carries now; Normal without one (or outside a browser). */\nexport function readTextSize(): TextSize {\n if (typeof document === \"undefined\") return \"normal\";\n const value = document.documentElement.getAttribute(TEXT_SIZE_ATTRIBUTE);\n return isTextSize(value) ? value : \"normal\";\n}\n\n/** Put `size` on `<html>`. Every {@link useTextSize} re-renders with it. */\nexport function applyTextSize(size: TextSize): void {\n if (typeof document === \"undefined\") return;\n const root = document.documentElement;\n if (root.getAttribute(TEXT_SIZE_ATTRIBUTE) === size) return;\n root.setAttribute(TEXT_SIZE_ATTRIBUTE, size);\n notify();\n}\n\nexport interface TextSizeInfo {\n size: TextSize;\n /** {@link TEXT_SCALE} for `size`: 1, 1.25 or 1.5. */\n scale: number;\n}\n\n/**\n * The size in force on this page, and its scale (§3.1) — read from `<html>`, so it needs\n * no store and works in every kit component: whoever applied the size (the app's store,\n * the pre-paint call, the inline snippet), this follows it.\n *\n * Only for what CSS cannot do. A class that should differ at Large is a `large:`\n * variant; a layout switch is a breakpoint, which already scales.\n */\nexport function useTextSize(): TextSizeInfo {\n const size = useSyncExternalStore(subscribe, readTextSize, () => \"normal\" as const);\n return useMemo(() => ({ size, scale: TEXT_SCALE[size] }), [size]);\n}\n\n/* ── Storage: the frozen format, the pre-paint call ─────────────────────── */\n\n/**\n * What a text-size store persists under its key — zustand's persist envelope, and FROZEN\n * (§10.3) because an app's inline `<head>` snippet reads it before any module loads:\n *\n * {\"state\":{\"size\":\"large\"},\"version\":1}\n *\n * `size` is one of {@link TEXT_SIZES}, or `null` / absent when this device never chose.\n * A future change adds a key; it never renames `state.size` or changes its values.\n *\n * A hand-written seed (a test, a screenshot script) must carry `\"version\":1`: the store\n * drops a stored state of another version as a migration it doesn't know, silently\n * (keksdose's 0.32 report). The inline snippet reads `state.size` whatever the version,\n * so the two would disagree. In the kit's showcase, `?text-size=` needs no seed.\n */\nexport interface PersistedTextSize {\n state?: { size?: TextSize | null };\n version?: number;\n}\n\n/** This device's own choice under `storageKey`, or `null` when it never chose (or the\n * storage is blocked, malformed, or holds a value that is not a size). */\nexport function readPersistedTextSize(storageKey: string): TextSize | null {\n try {\n const blob = JSON.parse(readStored(storageKey) ?? \"null\") as PersistedTextSize | null;\n const size = blob?.state?.size;\n return isTextSize(size) ? size : null;\n } catch {\n return null;\n }\n}\n\nexport interface PersistedAppearanceOptions {\n /**\n * The account's last known value, for a device without a choice of its own (§10.3):\n * what the app persisted with its user (keksdose keeps `/auth/me` in localStorage), so\n * a cold boot paints the account's size instead of Normal and then jumping — offline\n * too. Called once, guarded: a throw counts as \"no account value\".\n */\n account?: () => string | null | undefined;\n}\n\nfunction readAccount(account: PersistedAppearanceOptions[\"account\"]): unknown {\n if (!account) return undefined;\n try {\n return account();\n } catch {\n return undefined;\n }\n}\n\n/**\n * Pre-paint (§3.1, §10.3): read this device's choice under `storageKey` — else the\n * account's last known value — and put it on `<html>` BEFORE `createRoot`, next to\n * `applyPersistedTheme`, so the first paint already has the right size. Returns the size\n * applied.\n *\n * ```ts\n * applyPersistedTextSize(\"keksdose-text-size\", {\n * account: () => readPersistedUser()?.text_size,\n * });\n * ```\n */\nexport function applyPersistedTextSize(storageKey: string, options: PersistedAppearanceOptions = {}): TextSize {\n const size = resolveTextSize(readPersistedTextSize(storageKey), readAccount(options.account));\n applyTextSize(size);\n return size;\n}\n\n/**\n * The same pre-paint as {@link applyPersistedTextSize}, as an inline `<head>` script for\n * a boot splash painted from `index.html` before any module runs (§10.3). A function\n * EXPRESSION: call it with the storage key and, optionally, a function returning the\n * account's last known value.\n *\n * ```html\n * <script>\n * (function(k,a){…})(\"keksdose-text-size\", function () {\n * return JSON.parse(localStorage.getItem(\"keksdose-auth\")).state.user.text_size;\n * });\n * </script>\n * ```\n *\n * Paste the constant's value in place of `(function(k,a){…})`. It reads the frozen\n * {@link PersistedTextSize} format, never throws, and sets only `data-text-size`.\n *\n * The splash sizes in rem, and needs the root rules inline beside it, since the app's\n * stylesheet is a separate request and arrives after the splash paints (0.32.1,\n * keksdose's 0.32 report: that is every app, not an edge case):\n *\n * ```html\n * <style>html[data-text-size=large]{font-size:125%}html[data-text-size=xlarge]{font-size:150%}</style>\n * ```\n *\n * Under a hash-based Content Security Policy (`script-src 'sha256-…'`) this is a second\n * inline script with its own hash: add it to the policy, and recompute it whenever the\n * storage key or the account function in the call changes (keksdose added it to both\n * its Caddyfiles, and its guard test hashes every inline script).\n */\nexport const TEXT_SIZE_INLINE_SCRIPT =\n \"(function(k,a){function ok(v){return v==='normal'||v==='large'||v==='xlarge'}\" +\n \"var v=null;try{var s=JSON.parse(localStorage.getItem(k)||'null');v=s&&s.state&&s.state.size}catch(e){}\" +\n \"if(!ok(v)){v=null;try{v=a?a():null}catch(e){}}\" +\n \"try{document.documentElement.setAttribute('data-text-size',ok(v)?v:'normal')}catch(e){}})\";\n\n/* ── The store ──────────────────────────────────────────────────────────── */\n\nexport interface TextSizeState {\n /** This device's own choice (persisted), or `null` when it never chose — then the\n * account's value applies (§2.2). */\n size: TextSize | null;\n /** Store this device's choice; `null` forgets it, so the account's applies again. */\n setSize: (size: TextSize | null) => void;\n}\n\nexport interface ApplyTextSizeOptions {\n /** The account's value (`/auth/me`'s `text_size`): it applies while this device has no\n * choice of its own. Leave it out where there is no account. */\n account?: string | null;\n}\n\n/**\n * Build a text-size store bound to a consumer-supplied localStorage key, shaped like\n * `createThemeStore` (§6). It holds the DEVICE's choice only; the account's value comes\n * in through `useApplyTextSize({ account })` and `useAccountAppearance`, and the size in\n * force is always {@link resolveTextSize} of the two.\n *\n * - `useTextSizeStore` — the zustand hook: `size` (device choice or null) and `setSize`.\n * - `useApplyTextSize({ account })` — the effect that puts the size in force on `<html>`.\n * Mount it once near the root, beside `useApplyTheme`.\n *\n * The persisted format is {@link PersistedTextSize}, and frozen.\n */\nexport function createTextSizeStore(storageKey: string) {\n const useTextSizeStore = create<TextSizeState>()(\n logger(\n persist(\n (set) => ({\n size: null,\n setSize: (size) => set({ size: isTextSize(size) ? size : null }),\n }),\n {\n name: storageKey,\n version: 1,\n partialize: (s) => ({ size: s.size }),\n // A hand-edited or future value must not reach <html> as an unknown size.\n merge: (persisted, current) => ({\n ...current,\n size: isTextSize((persisted as { size?: unknown } | null)?.size)\n ? ((persisted as { size: TextSize }).size)\n : null,\n }),\n },\n ),\n \"text-size\",\n ),\n );\n\n function useApplyTextSize({ account }: ApplyTextSizeOptions = {}): TextSize {\n const device = useTextSizeStore((s) => s.size);\n const size = resolveTextSize(device, account);\n useEffect(() => {\n applyTextSize(size);\n }, [size]);\n return size;\n }\n\n return { useTextSizeStore, useApplyTextSize };\n}\n\nexport type TextSizeStore = ReturnType<typeof createTextSizeStore>;\n"],"mappings":";AAAA,SAAS,WAAW,SAAS,4BAA4B;AACzD,SAAS,cAAc;AACvB,SAAS,eAAe;AACxB,SAAS,cAAc;AACvB,SAAS,kBAAkB;AAkBpB,MAAM,aAAa,CAAC,UAAU,SAAS,QAAQ;AAO/C,MAAM,aAAiD,EAAE,QAAQ,GAAG,OAAO,MAAM,QAAQ,IAAI;AAGpG,MAAM,sBAAsB;AAErB,SAAS,WAAW,OAAmC;AAC5D,SAAO,UAAU,YAAY,UAAU,WAAW,UAAU;AAC9D;AAQO,SAAS,gBAAgB,QAAiB,SAA6B;AAC5E,MAAI,WAAW,MAAM,EAAG,QAAO;AAC/B,MAAI,WAAW,OAAO,EAAG,QAAO;AAChC,SAAO;AACT;AAIA,MAAM,YAAY,oBAAI,IAAgB;AACtC,IAAI,WAAoC;AAExC,SAAS,SAAe;AACtB,aAAW,YAAY,UAAW,UAAS;AAC7C;AAEA,SAAS,UAAU,UAAkC;AACnD,YAAU,IAAI,QAAQ;AAGtB,MAAI,CAAC,YAAY,OAAO,qBAAqB,eAAe,OAAO,aAAa,aAAa;AAC3F,eAAW,IAAI,iBAAiB,MAAM;AACtC,aAAS,QAAQ,SAAS,iBAAiB,EAAE,YAAY,MAAM,iBAAiB,CAAC,mBAAmB,EAAE,CAAC;AAAA,EACzG;AACA,SAAO,MAAM;AACX,cAAU,OAAO,QAAQ;AACzB,QAAI,UAAU,SAAS,GAAG;AACxB,gBAAU,WAAW;AACrB,iBAAW;AAAA,IACb;AAAA,EACF;AACF;AAGO,SAAS,eAAyB;AACvC,MAAI,OAAO,aAAa,YAAa,QAAO;AAC5C,QAAM,QAAQ,SAAS,gBAAgB,aAAa,mBAAmB;AACvE,SAAO,WAAW,KAAK,IAAI,QAAQ;AACrC;AAGO,SAAS,cAAc,MAAsB;AAClD,MAAI,OAAO,aAAa,YAAa;AACrC,QAAM,OAAO,SAAS;AACtB,MAAI,KAAK,aAAa,mBAAmB,MAAM,KAAM;AACrD,OAAK,aAAa,qBAAqB,IAAI;AAC3C,SAAO;AACT;AAgBO,SAAS,cAA4B;AAC1C,QAAM,OAAO,qBAAqB,WAAW,cAAc,MAAM,QAAiB;AAClF,SAAO,QAAQ,OAAO,EAAE,MAAM,OAAO,WAAW,IAAI,EAAE,IAAI,CAAC,IAAI,CAAC;AAClE;AAyBO,SAAS,sBAAsB,YAAqC;AACzE,MAAI;AACF,UAAM,OAAO,KAAK,MAAM,WAAW,UAAU,KAAK,MAAM;AACxD,UAAM,OAAO,MAAM,OAAO;AAC1B,WAAO,WAAW,IAAI,IAAI,OAAO;AAAA,EACnC,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAYA,SAAS,YAAY,SAAyD;AAC5E,MAAI,CAAC,QAAS,QAAO;AACrB,MAAI;AACF,WAAO,QAAQ;AAAA,EACjB,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAcO,SAAS,uBAAuB,YAAoB,UAAsC,CAAC,GAAa;AAC7G,QAAM,OAAO,gBAAgB,sBAAsB,UAAU,GAAG,YAAY,QAAQ,OAAO,CAAC;AAC5F,gBAAc,IAAI;AAClB,SAAO;AACT;AAgCO,MAAM,0BACX;AAiCK,SAAS,oBAAoB,YAAoB;AACtD,QAAM,mBAAmB,OAAsB;AAAA,IAC7C;AAAA,MACE;AAAA,QACE,CAAC,SAAS;AAAA,UACR,MAAM;AAAA,UACN,SAAS,CAAC,SAAS,IAAI,EAAE,MAAM,WAAW,IAAI,IAAI,OAAO,KAAK,CAAC;AAAA,QACjE;AAAA,QACA;AAAA,UACE,MAAM;AAAA,UACN,SAAS;AAAA,UACT,YAAY,CAAC,OAAO,EAAE,MAAM,EAAE,KAAK;AAAA;AAAA,UAEnC,OAAO,CAAC,WAAW,aAAa;AAAA,YAC9B,GAAG;AAAA,YACH,MAAM,WAAY,WAAyC,IAAI,IACzD,UAAiC,OACnC;AAAA,UACN;AAAA,QACF;AAAA,MACF;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAEA,WAAS,iBAAiB,EAAE,QAAQ,IAA0B,CAAC,GAAa;AAC1E,UAAM,SAAS,iBAAiB,CAAC,MAAM,EAAE,IAAI;AAC7C,UAAM,OAAO,gBAAgB,QAAQ,OAAO;AAC5C,cAAU,MAAM;AACd,oBAAc,IAAI;AAAA,IACpB,GAAG,CAAC,IAAI,CAAC;AACT,WAAO;AAAA,EACT;AAEA,SAAO,EAAE,kBAAkB,iBAAiB;AAC9C;","names":[]}
1
+ {"version":3,"sources":["../../src/theme/text-size.ts"],"sourcesContent":["import { useEffect, useMemo, useSyncExternalStore } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport { logger } from \"../lib/logger\";\nimport { readStored } from \"../lib/safe-storage\";\nimport { registerDeviceSetting } from \"../lib/device-settings\";\n\n/**\n * The text size (docs/text-size-harmonization.md §2.1, §3.1, §10.3, §10.6).\n *\n * ONE scale on `<html>`: `data-text-size=\"normal|large|xlarge\"`, and `tokens.css` turns\n * it into `font-size: 100% / 125% / 150%` — percent, never px, so \"Normal\" keeps\n * whatever size the person set in the browser. Tailwind's spacing and type are rem, so\n * about 90 % of every screen grows with that one declaration; the breakpoints follow too\n * (tokens.css redefines `sm:` … `3xl:` per size, {@link useBreakpoint} answers the same\n * in JS).\n *\n * The names below are the mechanism's public vocabulary and are FROZEN: an app's\n * storage, its `/auth/me` column (server-kit's `TEXT_SIZES`) and the inline boot snippet\n * all spell them this way.\n */\n\n/** The three steps, in order (§2.1, §10.6): server-kit's `TEXT_SIZES`, value for value. */\nexport const TEXT_SIZES = [\"normal\", \"large\", \"xlarge\"] as const;\n\nexport type TextSize = (typeof TEXT_SIZES)[number];\n\n/** The root font size each step sets, as a factor of the browser's own (tokens.css). For\n * what CSS cannot reach: a canvas or SVG chart's px constants, a windowed list's row\n * estimate, a popover width in px (§3.2). */\nexport const TEXT_SCALE: Readonly<Record<TextSize, number>> = { normal: 1, large: 1.25, xlarge: 1.5 };\n\n/** The attribute on `<html>` that carries the size. */\nconst TEXT_SIZE_ATTRIBUTE = \"data-text-size\";\n\nexport function isTextSize(value: unknown): value is TextSize {\n return value === \"normal\" || value === \"large\" || value === \"xlarge\";\n}\n\n/**\n * The size in force: **this device's own choice → the account's → Normal** (§2.2, §2.6).\n * Each is taken only when it is one of {@link TEXT_SIZES}; anything else (a stale value,\n * `null` = never chosen) falls through to the next. The one order the pre-paint call, the\n * apply hook and `useAccountAppearance` all use, so the three can never disagree.\n */\nexport function resolveTextSize(device: unknown, account?: unknown): TextSize {\n if (isTextSize(device)) return device;\n if (isTextSize(account)) return account;\n return \"normal\";\n}\n\n/* ── <html data-text-size> and the subscribers that read it ─────────────── */\n\nconst listeners = new Set<() => void>();\nlet observer: MutationObserver | null = null;\n\nfunction notify(): void {\n for (const listener of listeners) listener();\n}\n\nfunction subscribe(listener: () => void): () => void {\n listeners.add(listener);\n // Also catches writers that are not this module — the inline boot snippet, an app\n // setting the attribute by hand, a test.\n if (!observer && typeof MutationObserver !== \"undefined\" && typeof document !== \"undefined\") {\n observer = new MutationObserver(notify);\n observer.observe(document.documentElement, { attributes: true, attributeFilter: [TEXT_SIZE_ATTRIBUTE] });\n }\n return () => {\n listeners.delete(listener);\n if (listeners.size === 0) {\n observer?.disconnect();\n observer = null;\n }\n };\n}\n\n/** The size `<html>` carries now; Normal without one (or outside a browser). */\nexport function readTextSize(): TextSize {\n if (typeof document === \"undefined\") return \"normal\";\n const value = document.documentElement.getAttribute(TEXT_SIZE_ATTRIBUTE);\n return isTextSize(value) ? value : \"normal\";\n}\n\n/** Put `size` on `<html>`. Every {@link useTextSize} re-renders with it. */\nexport function applyTextSize(size: TextSize): void {\n if (typeof document === \"undefined\") return;\n const root = document.documentElement;\n if (root.getAttribute(TEXT_SIZE_ATTRIBUTE) === size) return;\n root.setAttribute(TEXT_SIZE_ATTRIBUTE, size);\n notify();\n}\n\nexport interface TextSizeInfo {\n size: TextSize;\n /** {@link TEXT_SCALE} for `size`: 1, 1.25 or 1.5. */\n scale: number;\n}\n\n/**\n * The size in force on this page, and its scale (§3.1) — read from `<html>`, so it needs\n * no store and works in every kit component: whoever applied the size (the app's store,\n * the pre-paint call, the inline snippet), this follows it.\n *\n * Only for what CSS cannot do. A class that should differ at Large is a `large:`\n * variant; a layout switch is a breakpoint, which already scales.\n */\nexport function useTextSize(): TextSizeInfo {\n const size = useSyncExternalStore(subscribe, readTextSize, () => \"normal\" as const);\n return useMemo(() => ({ size, scale: TEXT_SCALE[size] }), [size]);\n}\n\n/* ── Storage: the frozen format, the pre-paint call ─────────────────────── */\n\n/**\n * What a text-size store persists under its key — zustand's persist envelope, and FROZEN\n * (§10.3) because an app's inline `<head>` snippet reads it before any module loads:\n *\n * {\"state\":{\"size\":\"large\"},\"version\":1}\n *\n * `size` is one of {@link TEXT_SIZES}, or `null` / absent when this device never chose.\n * A future change adds a key; it never renames `state.size` or changes its values.\n *\n * A hand-written seed (a test, a screenshot script) must carry `\"version\":1`: the store\n * drops a stored state of another version as a migration it doesn't know, silently\n * (keksdose's 0.32 report). The inline snippet reads `state.size` whatever the version,\n * so the two would disagree. In the kit's showcase, `?text-size=` needs no seed.\n */\nexport interface PersistedTextSize {\n state?: { size?: TextSize | null };\n version?: number;\n}\n\n/** This device's own choice under `storageKey`, or `null` when it never chose (or the\n * storage is blocked, malformed, or holds a value that is not a size). */\nexport function readPersistedTextSize(storageKey: string): TextSize | null {\n try {\n const blob = JSON.parse(readStored(storageKey) ?? \"null\") as PersistedTextSize | null;\n const size = blob?.state?.size;\n return isTextSize(size) ? size : null;\n } catch {\n return null;\n }\n}\n\nexport interface PersistedAppearanceOptions {\n /**\n * The account's last known value, for a device without a choice of its own (§10.3):\n * what the app persisted with its user (keksdose keeps `/auth/me` in localStorage), so\n * a cold boot paints the account's size instead of Normal and then jumping — offline\n * too. Called once, guarded: a throw counts as \"no account value\".\n */\n account?: () => string | null | undefined;\n}\n\nfunction readAccount(account: PersistedAppearanceOptions[\"account\"]): unknown {\n if (!account) return undefined;\n try {\n return account();\n } catch {\n return undefined;\n }\n}\n\n/**\n * Pre-paint (§3.1, §10.3): read this device's choice under `storageKey` — else the\n * account's last known value — and put it on `<html>` BEFORE `createRoot`, next to\n * `applyPersistedTheme`, so the first paint already has the right size. Returns the size\n * applied.\n *\n * ```ts\n * applyPersistedTextSize(\"keksdose-text-size\", {\n * account: () => readPersistedUser()?.text_size,\n * });\n * ```\n */\nexport function applyPersistedTextSize(storageKey: string, options: PersistedAppearanceOptions = {}): TextSize {\n const size = resolveTextSize(readPersistedTextSize(storageKey), readAccount(options.account));\n applyTextSize(size);\n return size;\n}\n\n/**\n * The same pre-paint as {@link applyPersistedTextSize}, as an inline `<head>` script for\n * a boot splash painted from `index.html` before any module runs (§10.3). A function\n * EXPRESSION: call it with the storage key and, optionally, a function returning the\n * account's last known value.\n *\n * ```html\n * <script>\n * (function(k,a){…})(\"keksdose-text-size\", function () {\n * return JSON.parse(localStorage.getItem(\"keksdose-auth\")).state.user.text_size;\n * });\n * </script>\n * ```\n *\n * Paste the constant's value in place of `(function(k,a){…})`. It reads the frozen\n * {@link PersistedTextSize} format, never throws, and sets only `data-text-size`.\n *\n * The splash sizes in rem, and needs the root rules inline beside it, since the app's\n * stylesheet is a separate request and arrives after the splash paints (0.32.1,\n * keksdose's 0.32 report: that is every app, not an edge case):\n *\n * ```html\n * <style>html[data-text-size=large]{font-size:125%}html[data-text-size=xlarge]{font-size:150%}</style>\n * ```\n *\n * Under a hash-based Content Security Policy (`script-src 'sha256-…'`) this is a second\n * inline script with its own hash: add it to the policy, and recompute it whenever the\n * storage key or the account function in the call changes (keksdose added it to both\n * its Caddyfiles, and its guard test hashes every inline script).\n */\nexport const TEXT_SIZE_INLINE_SCRIPT =\n \"(function(k,a){function ok(v){return v==='normal'||v==='large'||v==='xlarge'}\" +\n \"var v=null;try{var s=JSON.parse(localStorage.getItem(k)||'null');v=s&&s.state&&s.state.size}catch(e){}\" +\n \"if(!ok(v)){v=null;try{v=a?a():null}catch(e){}}\" +\n \"try{document.documentElement.setAttribute('data-text-size',ok(v)?v:'normal')}catch(e){}})\";\n\n/* ── The store ──────────────────────────────────────────────────────────── */\n\nexport interface TextSizeState {\n /** This device's own choice (persisted), or `null` when it never chose — then the\n * account's value applies (§2.2). */\n size: TextSize | null;\n /** Store this device's choice; `null` forgets it, so the account's applies again. */\n setSize: (size: TextSize | null) => void;\n}\n\nexport interface ApplyTextSizeOptions {\n /** The account's value (`/auth/me`'s `text_size`): it applies while this device has no\n * choice of its own. Leave it out where there is no account. */\n account?: string | null;\n}\n\n/**\n * Build a text-size store bound to a consumer-supplied localStorage key, shaped like\n * `createThemeStore` (§6). It holds the DEVICE's choice only; the account's value comes\n * in through `useApplyTextSize({ account })` and `useAccountAppearance`, and the size in\n * force is always {@link resolveTextSize} of the two.\n *\n * - `useTextSizeStore` — the zustand hook: `size` (device choice or null) and `setSize`.\n * - `useApplyTextSize({ account })` — the effect that puts the size in force on `<html>`.\n * Mount it once near the root, beside `useApplyTheme`.\n *\n * The persisted format is {@link PersistedTextSize}, and frozen.\n */\nexport function createTextSizeStore(storageKey: string) {\n const useTextSizeStore = create<TextSizeState>()(\n logger(\n persist(\n (set) => ({\n size: null,\n setSize: (size) => set({ size: isTextSize(size) ? size : null }),\n }),\n {\n name: storageKey,\n version: 1,\n partialize: (s) => ({ size: s.size }),\n // A hand-edited or future value must not reach <html> as an unknown size.\n merge: (persisted, current) => ({\n ...current,\n size: isTextSize((persisted as { size?: unknown } | null)?.size)\n ? ((persisted as { size: TextSize }).size)\n : null,\n }),\n },\n ),\n \"text-size\",\n ),\n );\n // Reset this device's settings (docs/settings-harmonization.md §11.4): no choice on\n // this device, so `useApplyTextSize` re-resolves to the account's size, else Normal.\n registerDeviceSetting({\n key: storageKey,\n kind: \"appearance\",\n reset: () => useTextSizeStore.getState().setSize(null),\n });\n\n function useApplyTextSize({ account }: ApplyTextSizeOptions = {}): TextSize {\n const device = useTextSizeStore((s) => s.size);\n const size = resolveTextSize(device, account);\n useEffect(() => {\n applyTextSize(size);\n }, [size]);\n return size;\n }\n\n return { useTextSizeStore, useApplyTextSize };\n}\n\nexport type TextSizeStore = ReturnType<typeof createTextSizeStore>;\n"],"mappings":"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","names":[]}
@@ -3,6 +3,7 @@ import { useEffect } from "react";
3
3
  import { create } from "zustand";
4
4
  import { persist } from "zustand/middleware";
5
5
  import { logger } from "../lib/logger.js";
6
+ import { registerDeviceSetting } from "../lib/device-settings.js";
6
7
  function systemPreference() {
7
8
  try {
8
9
  return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
@@ -68,6 +69,11 @@ function createThemeStore(storageKey) {
68
69
  "theme"
69
70
  )
70
71
  );
72
+ registerDeviceSetting({
73
+ key: storageKey,
74
+ kind: "appearance",
75
+ reset: () => useTheme.getState().setPreference("system")
76
+ });
71
77
  function useApplyTheme() {
72
78
  const mode = useTheme((s) => s.mode);
73
79
  const preference = useTheme((s) => s.preference);
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/theme/theme-store.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport { logger } from \"../lib/logger\";\n\nexport type ThemeMode = \"light\" | \"dark\";\n/** What the user chose; \"system\" tracks the OS `prefers-color-scheme`. */\nexport type ThemePreference = \"light\" | \"dark\" | \"system\";\n\nexport interface ThemeState {\n /** The user's choice (persisted). */\n preference: ThemePreference;\n /** The RESOLVED light/dark actually in effect (system → OS). Read this for\n * anything that needs the concrete theme (e.g. token selection, toasts). */\n mode: ThemeMode;\n setPreference: (preference: ThemePreference) => void;\n /** Set an explicit light/dark choice (preference follows). */\n setMode: (mode: ThemeMode) => void;\n /** Flip the effective theme to the opposite explicit choice. */\n toggle: () => void;\n}\n\nfunction systemPreference(): ThemeMode {\n try {\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\";\n } catch {\n return \"light\";\n }\n}\n\nfunction resolve(preference: ThemePreference): ThemeMode {\n return preference === \"system\" ? systemPreference() : preference;\n}\n\n/** Normalize a persisted theme-state blob to a valid preference. Older blobs\n * persisted the concrete `mode` (light/dark); carry that over as an explicit\n * preference so the choice survives. Shared by the store's `migrate` and the\n * pre-hydration {@link applyPersistedTheme}. */\nfunction preferenceFromState(\n s: { mode?: string; preference?: string } | null | undefined,\n): ThemePreference {\n if (s?.preference === \"light\" || s?.preference === \"dark\" || s?.preference === \"system\") {\n return s.preference;\n }\n if (s?.mode === \"light\" || s?.mode === \"dark\") return s.mode;\n return \"system\";\n}\n\n/** Toggle the `.dark` class on <html> for a resolved light/dark mode. */\nfunction applyThemeClass(mode: ThemeMode): void {\n const root = document.documentElement;\n if (mode === \"dark\") root.classList.add(\"dark\");\n else root.classList.remove(\"dark\");\n}\n\n/**\n * Pre-hydration: read the theme PREFERENCE persisted under `storageKey` straight\n * from localStorage, resolve it to a concrete light/dark mode, and toggle the\n * `.dark` class on <html> — BEFORE React and the store mount, so the first paint\n * already has the right theme (no flash). Returns the resolved mode so the caller\n * can chain {@link applyPersistedPalette}. Encapsulates the zustand-persist\n * envelope + the legacy `mode`→`preference` fallback so apps never re-type the\n * storage shape in their entrypoint.\n */\nexport function applyPersistedTheme(storageKey: string): ThemeMode {\n let preference: ThemePreference = \"system\";\n try {\n const blob = JSON.parse(localStorage.getItem(storageKey) ?? \"{}\") as {\n state?: { mode?: string; preference?: string };\n };\n preference = preferenceFromState(blob.state);\n } catch {\n /* malformed storage → system */\n }\n const mode = resolve(preference);\n applyThemeClass(mode);\n return mode;\n}\n\n/**\n * Build a theme store bound to a consumer-supplied localStorage key. Tracks a\n * light/dark/system PREFERENCE (feedback #330) and exposes the resolved `mode`.\n * Returns the `useTheme` hook plus a `useApplyTheme` effect that toggles the\n * `.dark` class on <html> and, while on \"system\", follows OS theme changes live.\n */\nexport function createThemeStore(storageKey: string) {\n const useTheme = create<ThemeState>()(\n logger(\n persist(\n (set, get) => ({\n preference: \"system\" as ThemePreference,\n mode: systemPreference(),\n setPreference: (preference) => set({ preference, mode: resolve(preference) }),\n setMode: (mode) => set({ preference: mode, mode }),\n toggle: () => {\n const next: ThemeMode = get().mode === \"dark\" ? \"light\" : \"dark\";\n set({ preference: next, mode: next });\n },\n }),\n {\n name: storageKey,\n version: 2,\n // Only the preference is persisted; `mode` is derived on load.\n partialize: (s) => ({ preference: s.preference }),\n migrate: (persistedState): { preference: ThemePreference } => ({\n preference: preferenceFromState(\n persistedState as { mode?: string; preference?: string } | null,\n ),\n }),\n onRehydrateStorage: () => (state) => {\n // The persisted preference decides the concrete mode at load.\n if (state) state.mode = resolve(state.preference);\n },\n },\n ),\n \"theme\",\n ),\n );\n\n function useApplyTheme(): void {\n const mode = useTheme((s) => s.mode);\n const preference = useTheme((s) => s.preference);\n useEffect(() => {\n applyThemeClass(mode);\n }, [mode]);\n // While tracking the system, follow OS theme changes live.\n useEffect(() => {\n if (preference !== \"system\") return;\n let mq: MediaQueryList;\n try {\n mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n } catch {\n return;\n }\n const onChange = () => useTheme.setState({ mode: mq.matches ? \"dark\" : \"light\" });\n onChange();\n mq.addEventListener?.(\"change\", onChange);\n return () => mq.removeEventListener?.(\"change\", onChange);\n }, [preference]);\n }\n\n return { useTheme, useApplyTheme };\n}\n\nexport type ThemeStore = ReturnType<typeof createThemeStore>;\n"],"mappings":";AAAA,SAAS,iBAAiB;AAC1B,SAAS,cAAc;AACvB,SAAS,eAAe;AACxB,SAAS,cAAc;AAmBvB,SAAS,mBAA8B;AACrC,MAAI;AACF,WAAO,OAAO,WAAW,8BAA8B,EAAE,UAAU,SAAS;AAAA,EAC9E,QAAQ;AACN,WAAO;AAAA,EACT;AACF;AAEA,SAAS,QAAQ,YAAwC;AACvD,SAAO,eAAe,WAAW,iBAAiB,IAAI;AACxD;AAMA,SAAS,oBACP,GACiB;AACjB,MAAI,GAAG,eAAe,WAAW,GAAG,eAAe,UAAU,GAAG,eAAe,UAAU;AACvF,WAAO,EAAE;AAAA,EACX;AACA,MAAI,GAAG,SAAS,WAAW,GAAG,SAAS,OAAQ,QAAO,EAAE;AACxD,SAAO;AACT;AAGA,SAAS,gBAAgB,MAAuB;AAC9C,QAAM,OAAO,SAAS;AACtB,MAAI,SAAS,OAAQ,MAAK,UAAU,IAAI,MAAM;AAAA,MACzC,MAAK,UAAU,OAAO,MAAM;AACnC;AAWO,SAAS,oBAAoB,YAA+B;AACjE,MAAI,aAA8B;AAClC,MAAI;AACF,UAAM,OAAO,KAAK,MAAM,aAAa,QAAQ,UAAU,KAAK,IAAI;AAGhE,iBAAa,oBAAoB,KAAK,KAAK;AAAA,EAC7C,QAAQ;AAAA,EAER;AACA,QAAM,OAAO,QAAQ,UAAU;AAC/B,kBAAgB,IAAI;AACpB,SAAO;AACT;AAQO,SAAS,iBAAiB,YAAoB;AACnD,QAAM,WAAW,OAAmB;AAAA,IAClC;AAAA,MACE;AAAA,QACE,CAAC,KAAK,SAAS;AAAA,UACb,YAAY;AAAA,UACZ,MAAM,iBAAiB;AAAA,UACvB,eAAe,CAAC,eAAe,IAAI,EAAE,YAAY,MAAM,QAAQ,UAAU,EAAE,CAAC;AAAA,UAC5E,SAAS,CAAC,SAAS,IAAI,EAAE,YAAY,MAAM,KAAK,CAAC;AAAA,UACjD,QAAQ,MAAM;AACZ,kBAAM,OAAkB,IAAI,EAAE,SAAS,SAAS,UAAU;AAC1D,gBAAI,EAAE,YAAY,MAAM,MAAM,KAAK,CAAC;AAAA,UACtC;AAAA,QACF;AAAA,QACA;AAAA,UACE,MAAM;AAAA,UACN,SAAS;AAAA;AAAA,UAET,YAAY,CAAC,OAAO,EAAE,YAAY,EAAE,WAAW;AAAA,UAC/C,SAAS,CAAC,oBAAqD;AAAA,YAC7D,YAAY;AAAA,cACV;AAAA,YACF;AAAA,UACF;AAAA,UACA,oBAAoB,MAAM,CAAC,UAAU;AAEnC,gBAAI,MAAO,OAAM,OAAO,QAAQ,MAAM,UAAU;AAAA,UAClD;AAAA,QACF;AAAA,MACF;AAAA,MACA;AAAA,IACF;AAAA,EACF;AAEA,WAAS,gBAAsB;AAC7B,UAAM,OAAO,SAAS,CAAC,MAAM,EAAE,IAAI;AACnC,UAAM,aAAa,SAAS,CAAC,MAAM,EAAE,UAAU;AAC/C,cAAU,MAAM;AACd,sBAAgB,IAAI;AAAA,IACtB,GAAG,CAAC,IAAI,CAAC;AAET,cAAU,MAAM;AACd,UAAI,eAAe,SAAU;AAC7B,UAAI;AACJ,UAAI;AACF,aAAK,OAAO,WAAW,8BAA8B;AAAA,MACvD,QAAQ;AACN;AAAA,MACF;AACA,YAAM,WAAW,MAAM,SAAS,SAAS,EAAE,MAAM,GAAG,UAAU,SAAS,QAAQ,CAAC;AAChF,eAAS;AACT,SAAG,mBAAmB,UAAU,QAAQ;AACxC,aAAO,MAAM,GAAG,sBAAsB,UAAU,QAAQ;AAAA,IAC1D,GAAG,CAAC,UAAU,CAAC;AAAA,EACjB;AAEA,SAAO,EAAE,UAAU,cAAc;AACnC;","names":[]}
1
+ {"version":3,"sources":["../../src/theme/theme-store.ts"],"sourcesContent":["import { useEffect } from \"react\";\nimport { create } from \"zustand\";\nimport { persist } from \"zustand/middleware\";\nimport { logger } from \"../lib/logger\";\nimport { registerDeviceSetting } from \"../lib/device-settings\";\n\nexport type ThemeMode = \"light\" | \"dark\";\n/** What the user chose; \"system\" tracks the OS `prefers-color-scheme`. */\nexport type ThemePreference = \"light\" | \"dark\" | \"system\";\n\nexport interface ThemeState {\n /** The user's choice (persisted). */\n preference: ThemePreference;\n /** The RESOLVED light/dark actually in effect (system → OS). Read this for\n * anything that needs the concrete theme (e.g. token selection, toasts). */\n mode: ThemeMode;\n setPreference: (preference: ThemePreference) => void;\n /** Set an explicit light/dark choice (preference follows). */\n setMode: (mode: ThemeMode) => void;\n /** Flip the effective theme to the opposite explicit choice. */\n toggle: () => void;\n}\n\nfunction systemPreference(): ThemeMode {\n try {\n return window.matchMedia(\"(prefers-color-scheme: dark)\").matches ? \"dark\" : \"light\";\n } catch {\n return \"light\";\n }\n}\n\nfunction resolve(preference: ThemePreference): ThemeMode {\n return preference === \"system\" ? systemPreference() : preference;\n}\n\n/** Normalize a persisted theme-state blob to a valid preference. Older blobs\n * persisted the concrete `mode` (light/dark); carry that over as an explicit\n * preference so the choice survives. Shared by the store's `migrate` and the\n * pre-hydration {@link applyPersistedTheme}. */\nfunction preferenceFromState(\n s: { mode?: string; preference?: string } | null | undefined,\n): ThemePreference {\n if (s?.preference === \"light\" || s?.preference === \"dark\" || s?.preference === \"system\") {\n return s.preference;\n }\n if (s?.mode === \"light\" || s?.mode === \"dark\") return s.mode;\n return \"system\";\n}\n\n/** Toggle the `.dark` class on <html> for a resolved light/dark mode. */\nfunction applyThemeClass(mode: ThemeMode): void {\n const root = document.documentElement;\n if (mode === \"dark\") root.classList.add(\"dark\");\n else root.classList.remove(\"dark\");\n}\n\n/**\n * Pre-hydration: read the theme PREFERENCE persisted under `storageKey` straight\n * from localStorage, resolve it to a concrete light/dark mode, and toggle the\n * `.dark` class on <html> — BEFORE React and the store mount, so the first paint\n * already has the right theme (no flash). Returns the resolved mode so the caller\n * can chain {@link applyPersistedPalette}. Encapsulates the zustand-persist\n * envelope + the legacy `mode`→`preference` fallback so apps never re-type the\n * storage shape in their entrypoint.\n */\nexport function applyPersistedTheme(storageKey: string): ThemeMode {\n let preference: ThemePreference = \"system\";\n try {\n const blob = JSON.parse(localStorage.getItem(storageKey) ?? \"{}\") as {\n state?: { mode?: string; preference?: string };\n };\n preference = preferenceFromState(blob.state);\n } catch {\n /* malformed storage → system */\n }\n const mode = resolve(preference);\n applyThemeClass(mode);\n return mode;\n}\n\n/**\n * Build a theme store bound to a consumer-supplied localStorage key. Tracks a\n * light/dark/system PREFERENCE (feedback #330) and exposes the resolved `mode`.\n * Returns the `useTheme` hook plus a `useApplyTheme` effect that toggles the\n * `.dark` class on <html> and, while on \"system\", follows OS theme changes live.\n */\nexport function createThemeStore(storageKey: string) {\n const useTheme = create<ThemeState>()(\n logger(\n persist(\n (set, get) => ({\n preference: \"system\" as ThemePreference,\n mode: systemPreference(),\n setPreference: (preference) => set({ preference, mode: resolve(preference) }),\n setMode: (mode) => set({ preference: mode, mode }),\n toggle: () => {\n const next: ThemeMode = get().mode === \"dark\" ? \"light\" : \"dark\";\n set({ preference: next, mode: next });\n },\n }),\n {\n name: storageKey,\n version: 2,\n // Only the preference is persisted; `mode` is derived on load.\n partialize: (s) => ({ preference: s.preference }),\n migrate: (persistedState): { preference: ThemePreference } => ({\n preference: preferenceFromState(\n persistedState as { mode?: string; preference?: string } | null,\n ),\n }),\n onRehydrateStorage: () => (state) => {\n // The persisted preference decides the concrete mode at load.\n if (state) state.mode = resolve(state.preference);\n },\n },\n ),\n \"theme\",\n ),\n );\n // Reset this device's settings (docs/settings-harmonization.md §11.4): back to\n // \"System\" through the setter, which re-resolves `mode`; `useApplyTheme` puts the\n // class back and follows the OS again, and the persisted blob is what the pre-paint\n // `applyPersistedTheme` reads next load.\n registerDeviceSetting({\n key: storageKey,\n kind: \"appearance\",\n reset: () => useTheme.getState().setPreference(\"system\"),\n });\n\n function useApplyTheme(): void {\n const mode = useTheme((s) => s.mode);\n const preference = useTheme((s) => s.preference);\n useEffect(() => {\n applyThemeClass(mode);\n }, [mode]);\n // While tracking the system, follow OS theme changes live.\n useEffect(() => {\n if (preference !== \"system\") return;\n let mq: MediaQueryList;\n try {\n mq = window.matchMedia(\"(prefers-color-scheme: dark)\");\n } catch {\n return;\n }\n const onChange = () => useTheme.setState({ mode: mq.matches ? \"dark\" : \"light\" });\n onChange();\n mq.addEventListener?.(\"change\", onChange);\n return () => mq.removeEventListener?.(\"change\", onChange);\n }, [preference]);\n }\n\n return { useTheme, useApplyTheme };\n}\n\nexport type ThemeStore = ReturnType<typeof createThemeStore>;\n"],"mappings":"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","names":[]}
@@ -235,8 +235,3 @@ export interface WizardLabels {
235
235
  done?: string;
236
236
  }
237
237
  export { DEFAULT_WIZARD_LABELS };
238
- /** @deprecated 0.34 — `useKitLabels("wizard", DEFAULT_WIZARD_LABELS, labels)`, which the
239
- * provider reaches too, or StepperNav's and WizardSummary's own `labels`; removed in 0.35
240
- * (refactor audit 2026-10, API-6a). A merge from before the provider, which no kit
241
- * component calls. */
242
- export declare function resolveWizardLabels(labels?: Partial<WizardLabels>): WizardLabels;
@@ -1,10 +1,6 @@
1
1
  "use client";
2
2
  import { DEFAULT_WIZARD_LABELS } from "./wizard-labels.js";
3
- function resolveWizardLabels(labels) {
4
- return labels ? { ...DEFAULT_WIZARD_LABELS, ...labels } : DEFAULT_WIZARD_LABELS;
5
- }
6
3
  export {
7
- DEFAULT_WIZARD_LABELS,
8
- resolveWizardLabels
4
+ DEFAULT_WIZARD_LABELS
9
5
  };
10
6
  //# sourceMappingURL=types.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/wizard/types.ts"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { DEFAULT_WIZARD_LABELS } from \"./wizard-labels\";\n\nexport type FieldErrors = Record<string, string>;\n\nexport type ValidateResult = boolean | { ok: boolean; errors?: FieldErrors };\n\nexport interface WizardStepConfig {\n /** Unique step identifier. */\n id: string;\n /**\n * What the step is called in the indicator.\n *\n * A `ReactNode`, not a translation key: the engine never resolves strings.\n * An app with i18n maps its own keys at the `useWizard` call — the same split\n * `TourStep` and `DataTableLabels` already use.\n */\n label: ReactNode;\n /** Whether the step can be skipped. */\n optional?: boolean;\n /**\n * Validation gate before advancing.\n * - Return `true` / `false` to just block.\n * - Return `{ ok: false, errors: { fieldName: \"message\" } }` to also surface\n * per-field error messages via `wizard.fieldErrors`.\n */\n validate?: () => ValidateResult | Promise<ValidateResult>;\n /**\n * The forward button's label on THIS step, in place of `labels.next` — \"Analyse\",\n * \"Import\", \"Send code\". On the committing step (see {@link commits}) it labels\n * Finish instead of `labels.finish`; `labels.submitting` still replaces it while\n * the commit runs. Like `label`, a node the app has already translated.\n */\n nextLabel?: ReactNode;\n /**\n * This step's forward button COMMITS: it runs `onComplete` (via `finish`) instead\n * of just advancing. Without the flag the last step commits, as it always has.\n *\n * Flag a step before the last and every step after it becomes a post-commit step —\n * an import's \"here is what happened, set up recurring rules?\" page:\n * - a successful `onComplete` advances onto the next step (a failed one stays, with\n * `wizard.error`, exactly like a last-step Finish);\n * - from there Back, the indicator and `goBack`/`goToStep` cannot cross back over\n * the commit, and Cancel is hidden — there is nothing left to discard;\n * - the last post-commit step's button is Done, which calls `onDone` (and is not\n * shown when there is no `onDone`: the step then carries its own way out).\n *\n * Only the FIRST flagged step counts. Steps after it may still validate, skip and\n * use `nextLabel` like any other.\n */\n commits?: boolean;\n}\n\n/**\n * Where the active step lives. `true` (the default) mirrors it to `?step=N` through\n * react-router, as every wizard before 0.8 did; `{ param }` renames that param (two\n * wizards on one page, or a page that already uses `step`); `false` keeps it in\n * memory only and needs no router at all.\n *\n * Read once, at mount: switching it on a mounted wizard has no effect.\n */\nexport type WizardUrlSync = boolean | { param?: string };\n\nexport interface UseWizardOptions<TData extends Record<string, unknown>> {\n steps: WizardStepConfig[];\n initialData?: Partial<TData>;\n onComplete?: (data: TData) => void | Promise<void>;\n onCancel?: () => void;\n /**\n * Leave the wizard BACKWARDS: when given, Back is shown on the first step too and\n * calls this — keksdose's import panel, whose first step's Back returns to the\n * dropzone the file came from. No confirm dialog: going back is not discarding.\n */\n onExit?: () => void;\n /**\n * The Done button on the last step after a committing step (see\n * {@link WizardStepConfig.commits}). Without it no Done button is rendered, and the\n * post-commit step is expected to carry its own way out.\n */\n onDone?: () => void;\n /**\n * Whether the chrome offers Cancel at all. Default `true`. `false` hides the button\n * (`wizard.canCancel` is false); `wizard.cancel()` still works if the app calls it.\n */\n cancellable?: boolean;\n /**\n * Whether Cancel asks first (\"Discard this form?\"). Default `true`. `false` makes\n * `cancel()` call `onCancel` straight away — for a wizard that has nothing to lose\n * yet, or one whose data is kept elsewhere.\n */\n confirmCancel?: boolean;\n /**\n * Mirror the active step to the URL. Default `true` (`?step=N`, needs a router).\n * See {@link WizardUrlSync}.\n *\n * The param is WRITTEN only, never read back (see the note in `use-wizard.ts`). It\n * is removed again when the wizard is left through the kit: a confirmed Cancel,\n * `onExit` from the first step's Back, and Done — each before the app's callback\n * runs, so an app that navigates away navigates from a clean URL. It is NOT removed\n * after `onComplete` on a last step: an app navigating away there would otherwise\n * be pulled back by the kit's own URL write. An app that stays on the page after a\n * final Finish drops the param itself, or uses `urlSync: false`.\n */\n urlSync?: WizardUrlSync;\n /**\n * Shown when a step blocks with no per-field detail — an RHF step surfacing\n * its own inline messages, say — so the user learns why the wizard did not\n * advance. Falls back to `wizard.missingRequired` from the {@link UiKitProvider},\n * then to English.\n */\n missingRequiredMessage?: string;\n /**\n * How to surface {@link missingRequiredMessage}. Defaults to the kit's `toast.error`\n * (sonner, an optional peer, is loaded only when a toast is shown). Pass your own to\n * route it somewhere else, or to a no-op to silence it.\n */\n onValidationFailed?: (message: string) => void;\n}\n\nexport type StepStatus = \"upcoming\" | \"active\" | \"completed\" | \"skipped\";\n\nexport interface UseWizardReturn<TData extends Record<string, unknown>> {\n // Navigation\n currentStepIndex: number;\n currentStep: WizardStepConfig;\n steps: WizardStepConfig[];\n isFirstStep: boolean;\n isLastStep: boolean;\n /** True once every step before the committing one (the last, unless a step is\n * flagged `commits`) has been completed or skipped. */\n canFinish: boolean;\n /** The step whose forward button commits: the first `commits` step, else the last. */\n commitStepIndex: number;\n /** The active step is {@link commitStepIndex}: the chrome shows Finish, not Next. */\n isCommitStep: boolean;\n /** `onComplete` has resolved. Once the active step is past the committing one, Back\n * cannot cross it and Cancel is hidden. */\n committed: boolean;\n /** Whether Back is offered here: not on the first step unless `onExit` was given,\n * and never back across a commit. (Disabled, not hidden, while `isSubmitting`.) */\n canGoBack: boolean;\n /** Whether Cancel is offered: `cancellable`, and not after a commit. */\n canCancel: boolean;\n /** Whether Done is offered: the last step, past a commit, with an `onDone`. */\n canDone: boolean;\n /** Whether the indicator may jump to `index` — a completed/skipped/active step on\n * the right side of any commit. `goToStep` refuses anything this rejects. */\n canGoToStep: (index: number) => boolean;\n goNext: () => Promise<void>;\n /** One step back — or, on the first step, `onExit`. Refused while submitting. */\n goBack: () => void;\n goToStep: (index: number) => void;\n skip: () => void;\n cancel: () => void;\n finish: () => Promise<void>;\n /** Leave after the post-commit steps: drops the URL param, then calls `onDone`. */\n done: () => void;\n /** Register a validator for the mounted step (used by useRhfWizardStep). */\n registerStepValidate: (\n fn: () => ValidateResult | Promise<ValidateResult>,\n ) => () => void;\n /** True when the mounted step has blocked forward navigation (Next/Skip disabled). */\n nextBlocked: boolean;\n /** Set by the active step (via useWizardNextGate) to disable/enable Next+Skip. */\n setNextBlocked: (blocked: boolean) => void;\n\n // Step state\n stepStatus: (index: number) => StepStatus;\n completedSteps: Set<number>;\n\n // Data\n data: TData;\n updateData: (partial: Partial<TData>) => void;\n\n // Loading / error\n isSubmitting: boolean;\n /** A `goNext`/`finish` is running the step's validators (an async server check,\n * say). The chrome shows Next pending — spinner, `aria-busy`, disabled. */\n isValidating: boolean;\n error: string | null;\n clearError: () => void;\n\n // Per-field validation errors (populated by the last failed goNext)\n fieldErrors: FieldErrors;\n clearFieldErrors: () => void;\n\n // Cancel dialog\n showCancelDialog: boolean;\n confirmCancel: () => void;\n dismissCancel: () => void;\n}\n\nexport interface SummaryItem {\n label: ReactNode;\n value: ReactNode;\n}\n\nexport interface SummarySection {\n label: ReactNode;\n /**\n * Which step the section's edit button jumps back to. Leave it out for a section\n * that belongs to no step — file-level counts, a total — and the card has no edit\n * button, instead of borrowing one from whichever step it was parked under.\n */\n stepIndex?: number;\n items: SummaryItem[];\n}\n\n/**\n * Every string the wizard chrome shows. All optional with English defaults, so a\n * monolingual app can mount `StepperNav` bare; a translated app passes the whole\n * object once (typically from a thin app-side wrapper).\n */\nexport interface WizardLabels {\n cancel: string;\n back: string;\n next: string;\n skip: string;\n finish: string;\n /** Shown on Finish while `isSubmitting`. */\n submitting: string;\n /** `aria-label` for the step indicator's `<nav>`. */\n steps: string;\n /** The mobile \"Step 2 of 5\" line. */\n step: (current: number, total: number) => string;\n /** Heading of the cancel-confirmation dialog. */\n cancelTitle: string;\n /** Body of the cancel-confirmation dialog. */\n confirmCancel: string;\n /** Confirm button in the cancel dialog. */\n cancelConfirmLabel: string;\n /** Dismiss button in the cancel dialog (\"keep editing\"). */\n cancelDismissLabel: string;\n /** Heading above a {@link WizardSummary}. */\n reviewTitle: string;\n /** `aria-label` on a summary section's edit button. */\n edit: string;\n /**\n * Toasted (or handed to `onValidationFailed`) when a step blocks Next without\n * saying which field is at fault. `useWizard`'s `missingRequiredMessage` wins.\n *\n * This and `genericError` are OPTIONAL, unlike every key above, for the reason\n * `TwoFactorSettingLabels.qrAlt` is: apps annotate a full translation as\n * `const DE: WizardLabels = {…}`, and a new required key would be a compile error\n * in each of them on the next update. The defaults below always carry both.\n */\n missingRequired?: string;\n /** `wizard.error` when `onComplete` rejects with nothing readable — a thrown\n * non-Error, or an Error whose message is empty. Optional: see above. */\n genericError?: string;\n /** The button on the last step after a committing step (`onDone`). Optional: see\n * `missingRequired`. */\n done?: string;\n}\n\n// The English words live in ./wizard-labels, a module of words (round 0.34).\nexport { DEFAULT_WIZARD_LABELS };\n\n/** @deprecated 0.34 — `useKitLabels(\"wizard\", DEFAULT_WIZARD_LABELS, labels)`, which the\n * provider reaches too, or StepperNav's and WizardSummary's own `labels`; removed in 0.35\n * (refactor audit 2026-10, API-6a). A merge from before the provider, which no kit\n * component calls. */\nexport function resolveWizardLabels(labels?: Partial<WizardLabels>): WizardLabels {\n return labels ? { ...DEFAULT_WIZARD_LABELS, ...labels } : DEFAULT_WIZARD_LABELS;\n}\n"],"mappings":";AACA,SAAS,6BAA6B;AAqQ/B,SAAS,oBAAoB,QAA8C;AAChF,SAAO,SAAS,EAAE,GAAG,uBAAuB,GAAG,OAAO,IAAI;AAC5D;","names":[]}
1
+ {"version":3,"sources":["../../src/wizard/types.ts"],"sourcesContent":["import type { ReactNode } from \"react\";\nimport { DEFAULT_WIZARD_LABELS } from \"./wizard-labels\";\n\nexport type FieldErrors = Record<string, string>;\n\nexport type ValidateResult = boolean | { ok: boolean; errors?: FieldErrors };\n\nexport interface WizardStepConfig {\n /** Unique step identifier. */\n id: string;\n /**\n * What the step is called in the indicator.\n *\n * A `ReactNode`, not a translation key: the engine never resolves strings.\n * An app with i18n maps its own keys at the `useWizard` call — the same split\n * `TourStep` and `DataTableLabels` already use.\n */\n label: ReactNode;\n /** Whether the step can be skipped. */\n optional?: boolean;\n /**\n * Validation gate before advancing.\n * - Return `true` / `false` to just block.\n * - Return `{ ok: false, errors: { fieldName: \"message\" } }` to also surface\n * per-field error messages via `wizard.fieldErrors`.\n */\n validate?: () => ValidateResult | Promise<ValidateResult>;\n /**\n * The forward button's label on THIS step, in place of `labels.next` — \"Analyse\",\n * \"Import\", \"Send code\". On the committing step (see {@link commits}) it labels\n * Finish instead of `labels.finish`; `labels.submitting` still replaces it while\n * the commit runs. Like `label`, a node the app has already translated.\n */\n nextLabel?: ReactNode;\n /**\n * This step's forward button COMMITS: it runs `onComplete` (via `finish`) instead\n * of just advancing. Without the flag the last step commits, as it always has.\n *\n * Flag a step before the last and every step after it becomes a post-commit step —\n * an import's \"here is what happened, set up recurring rules?\" page:\n * - a successful `onComplete` advances onto the next step (a failed one stays, with\n * `wizard.error`, exactly like a last-step Finish);\n * - from there Back, the indicator and `goBack`/`goToStep` cannot cross back over\n * the commit, and Cancel is hidden — there is nothing left to discard;\n * - the last post-commit step's button is Done, which calls `onDone` (and is not\n * shown when there is no `onDone`: the step then carries its own way out).\n *\n * Only the FIRST flagged step counts. Steps after it may still validate, skip and\n * use `nextLabel` like any other.\n */\n commits?: boolean;\n}\n\n/**\n * Where the active step lives. `true` (the default) mirrors it to `?step=N` through\n * react-router, as every wizard before 0.8 did; `{ param }` renames that param (two\n * wizards on one page, or a page that already uses `step`); `false` keeps it in\n * memory only and needs no router at all.\n *\n * Read once, at mount: switching it on a mounted wizard has no effect.\n */\nexport type WizardUrlSync = boolean | { param?: string };\n\nexport interface UseWizardOptions<TData extends Record<string, unknown>> {\n steps: WizardStepConfig[];\n initialData?: Partial<TData>;\n onComplete?: (data: TData) => void | Promise<void>;\n onCancel?: () => void;\n /**\n * Leave the wizard BACKWARDS: when given, Back is shown on the first step too and\n * calls this — keksdose's import panel, whose first step's Back returns to the\n * dropzone the file came from. No confirm dialog: going back is not discarding.\n */\n onExit?: () => void;\n /**\n * The Done button on the last step after a committing step (see\n * {@link WizardStepConfig.commits}). Without it no Done button is rendered, and the\n * post-commit step is expected to carry its own way out.\n */\n onDone?: () => void;\n /**\n * Whether the chrome offers Cancel at all. Default `true`. `false` hides the button\n * (`wizard.canCancel` is false); `wizard.cancel()` still works if the app calls it.\n */\n cancellable?: boolean;\n /**\n * Whether Cancel asks first (\"Discard this form?\"). Default `true`. `false` makes\n * `cancel()` call `onCancel` straight away — for a wizard that has nothing to lose\n * yet, or one whose data is kept elsewhere.\n */\n confirmCancel?: boolean;\n /**\n * Mirror the active step to the URL. Default `true` (`?step=N`, needs a router).\n * See {@link WizardUrlSync}.\n *\n * The param is WRITTEN only, never read back (see the note in `use-wizard.ts`). It\n * is removed again when the wizard is left through the kit: a confirmed Cancel,\n * `onExit` from the first step's Back, and Done — each before the app's callback\n * runs, so an app that navigates away navigates from a clean URL. It is NOT removed\n * after `onComplete` on a last step: an app navigating away there would otherwise\n * be pulled back by the kit's own URL write. An app that stays on the page after a\n * final Finish drops the param itself, or uses `urlSync: false`.\n */\n urlSync?: WizardUrlSync;\n /**\n * Shown when a step blocks with no per-field detail — an RHF step surfacing\n * its own inline messages, say — so the user learns why the wizard did not\n * advance. Falls back to `wizard.missingRequired` from the {@link UiKitProvider},\n * then to English.\n */\n missingRequiredMessage?: string;\n /**\n * How to surface {@link missingRequiredMessage}. Defaults to the kit's `toast.error`\n * (sonner, an optional peer, is loaded only when a toast is shown). Pass your own to\n * route it somewhere else, or to a no-op to silence it.\n */\n onValidationFailed?: (message: string) => void;\n}\n\nexport type StepStatus = \"upcoming\" | \"active\" | \"completed\" | \"skipped\";\n\nexport interface UseWizardReturn<TData extends Record<string, unknown>> {\n // Navigation\n currentStepIndex: number;\n currentStep: WizardStepConfig;\n steps: WizardStepConfig[];\n isFirstStep: boolean;\n isLastStep: boolean;\n /** True once every step before the committing one (the last, unless a step is\n * flagged `commits`) has been completed or skipped. */\n canFinish: boolean;\n /** The step whose forward button commits: the first `commits` step, else the last. */\n commitStepIndex: number;\n /** The active step is {@link commitStepIndex}: the chrome shows Finish, not Next. */\n isCommitStep: boolean;\n /** `onComplete` has resolved. Once the active step is past the committing one, Back\n * cannot cross it and Cancel is hidden. */\n committed: boolean;\n /** Whether Back is offered here: not on the first step unless `onExit` was given,\n * and never back across a commit. (Disabled, not hidden, while `isSubmitting`.) */\n canGoBack: boolean;\n /** Whether Cancel is offered: `cancellable`, and not after a commit. */\n canCancel: boolean;\n /** Whether Done is offered: the last step, past a commit, with an `onDone`. */\n canDone: boolean;\n /** Whether the indicator may jump to `index` — a completed/skipped/active step on\n * the right side of any commit. `goToStep` refuses anything this rejects. */\n canGoToStep: (index: number) => boolean;\n goNext: () => Promise<void>;\n /** One step back — or, on the first step, `onExit`. Refused while submitting. */\n goBack: () => void;\n goToStep: (index: number) => void;\n skip: () => void;\n cancel: () => void;\n finish: () => Promise<void>;\n /** Leave after the post-commit steps: drops the URL param, then calls `onDone`. */\n done: () => void;\n /** Register a validator for the mounted step (used by useRhfWizardStep). */\n registerStepValidate: (\n fn: () => ValidateResult | Promise<ValidateResult>,\n ) => () => void;\n /** True when the mounted step has blocked forward navigation (Next/Skip disabled). */\n nextBlocked: boolean;\n /** Set by the active step (via useWizardNextGate) to disable/enable Next+Skip. */\n setNextBlocked: (blocked: boolean) => void;\n\n // Step state\n stepStatus: (index: number) => StepStatus;\n completedSteps: Set<number>;\n\n // Data\n data: TData;\n updateData: (partial: Partial<TData>) => void;\n\n // Loading / error\n isSubmitting: boolean;\n /** A `goNext`/`finish` is running the step's validators (an async server check,\n * say). The chrome shows Next pending — spinner, `aria-busy`, disabled. */\n isValidating: boolean;\n error: string | null;\n clearError: () => void;\n\n // Per-field validation errors (populated by the last failed goNext)\n fieldErrors: FieldErrors;\n clearFieldErrors: () => void;\n\n // Cancel dialog\n showCancelDialog: boolean;\n confirmCancel: () => void;\n dismissCancel: () => void;\n}\n\nexport interface SummaryItem {\n label: ReactNode;\n value: ReactNode;\n}\n\nexport interface SummarySection {\n label: ReactNode;\n /**\n * Which step the section's edit button jumps back to. Leave it out for a section\n * that belongs to no step — file-level counts, a total — and the card has no edit\n * button, instead of borrowing one from whichever step it was parked under.\n */\n stepIndex?: number;\n items: SummaryItem[];\n}\n\n/**\n * Every string the wizard chrome shows. All optional with English defaults, so a\n * monolingual app can mount `StepperNav` bare; a translated app passes the whole\n * object once (typically from a thin app-side wrapper).\n */\nexport interface WizardLabels {\n cancel: string;\n back: string;\n next: string;\n skip: string;\n finish: string;\n /** Shown on Finish while `isSubmitting`. */\n submitting: string;\n /** `aria-label` for the step indicator's `<nav>`. */\n steps: string;\n /** The mobile \"Step 2 of 5\" line. */\n step: (current: number, total: number) => string;\n /** Heading of the cancel-confirmation dialog. */\n cancelTitle: string;\n /** Body of the cancel-confirmation dialog. */\n confirmCancel: string;\n /** Confirm button in the cancel dialog. */\n cancelConfirmLabel: string;\n /** Dismiss button in the cancel dialog (\"keep editing\"). */\n cancelDismissLabel: string;\n /** Heading above a {@link WizardSummary}. */\n reviewTitle: string;\n /** `aria-label` on a summary section's edit button. */\n edit: string;\n /**\n * Toasted (or handed to `onValidationFailed`) when a step blocks Next without\n * saying which field is at fault. `useWizard`'s `missingRequiredMessage` wins.\n *\n * This and `genericError` are OPTIONAL, unlike every key above, for the reason\n * `TwoFactorSettingLabels.qrAlt` is: apps annotate a full translation as\n * `const DE: WizardLabels = {…}`, and a new required key would be a compile error\n * in each of them on the next update. The defaults below always carry both.\n */\n missingRequired?: string;\n /** `wizard.error` when `onComplete` rejects with nothing readable — a thrown\n * non-Error, or an Error whose message is empty. Optional: see above. */\n genericError?: string;\n /** The button on the last step after a committing step (`onDone`). Optional: see\n * `missingRequired`. */\n done?: string;\n}\n\n// The English words live in ./wizard-labels, a module of words (round 0.34).\nexport { DEFAULT_WIZARD_LABELS };\n"],"mappings":";AACA,SAAS,6BAA6B;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.34.0",
3
+ "version": "0.36.0",
4
4
  "type": "module",
5
5
  "description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
6
6
  "_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
@@ -48,6 +48,14 @@
48
48
  "types": "./dist/table-text.d.ts",
49
49
  "import": "./dist/table-text.js"
50
50
  },
51
+ "./currencies": {
52
+ "types": "./dist/currencies.d.ts",
53
+ "import": "./dist/currencies.js"
54
+ },
55
+ "./eslint": {
56
+ "types": "./dist/eslint/index.d.ts",
57
+ "import": "./dist/eslint/index.js"
58
+ },
51
59
  "./testing": {
52
60
  "types": "./dist/testing/index.d.ts",
53
61
  "import": "./dist/testing/index.js"
@@ -7,6 +7,7 @@ import type { CheckboxGroupOption } from "../components/checkbox-group";
7
7
  import { Select } from "../components/select";
8
8
  import type { SelectProps } from "../components/select";
9
9
  import type { CommitScope } from "../components/write-lock";
10
+ import type { FieldHelpProps } from "../components/field-parts";
10
11
  import { DEFAULT_ROLE_SELECT_LABELS } from "./role-select-labels";
11
12
 
12
13
  /**
@@ -185,7 +186,7 @@ export function RoleSelect<R extends string = string>({
185
186
 
186
187
  /* ── RolesEditor ────────────────────────────────────────────────────────────── */
187
188
 
188
- export interface RolesEditorProps<R extends string = string> {
189
+ export interface RolesEditorProps<R extends string = string> extends FieldHelpProps {
189
190
  /** The roles ticked — the draft the app holds while its dialog is open. */
190
191
  value: readonly R[];
191
192
  roles: RoleVocabulary<R>;
@@ -236,6 +237,7 @@ export function RolesEditor<R extends string = string>({
236
237
  legendVisibility,
237
238
  required,
238
239
  hint,
240
+ help,
239
241
  error,
240
242
  columns,
241
243
  commit,
@@ -267,6 +269,7 @@ export function RolesEditor<R extends string = string>({
267
269
  onChange={onChange}
268
270
  required={required}
269
271
  hint={hint}
272
+ help={help}
270
273
  error={error}
271
274
  columns={columns}
272
275
  commit={commit}
@@ -1,4 +1,4 @@
1
- import type { ComponentPropsWithoutRef, ReactNode } from "react";
1
+ import type { ComponentPropsWithRef, ReactNode } from "react";
2
2
  import { CreditCard, Hourglass, PencilOff } from "lucide-react";
3
3
 
4
4
  import type { LabelOverride } from "../i18n/kit-labels";
@@ -29,7 +29,10 @@ export type BillingBannerKind =
29
29
  | "guest"
30
30
  | "processing";
31
31
 
32
- interface BillingBannerBaseProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "onClick"> {
32
+ /** A `div`'s props, `ref` included since 0.35 (round 0.35 R4, the follow-up to API-2): they
33
+ * go on to the {@link AlertBanner}, so the ref is the banner's `<div>` — with an × too,
34
+ * since the banner has no `href` or `onClick` that would make its row a link or a button. */
35
+ interface BillingBannerBaseProps extends Omit<ComponentPropsWithRef<"div">, "children" | "onClick"> {
33
36
  /**
34
37
  * Over the kind's tone: `trial-ending` and `grant-ending` are `info` until the last
35
38
  * three days and `warning` in them; `payment-failed` is `danger` (money did not
@@ -1,4 +1,4 @@
1
- import type { ComponentPropsWithoutRef } from "react";
1
+ import type { ComponentPropsWithRef } from "react";
2
2
 
3
3
  import { useKitLocale } from "../i18n/kit-labels";
4
4
  import type { LabelOverride } from "../i18n/kit-labels";
@@ -22,7 +22,10 @@ export function planLimitMailto(email: string, subject: string): string {
22
22
  return `mailto:${to}?subject=${encodeStrict(subject)}`;
23
23
  }
24
24
 
25
- interface PlanLimitNoticeBaseProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "onClick"> {
25
+ /** A `div`'s props, `ref` included since 0.35 (round 0.35 R4, the follow-up to API-2): they
26
+ * go on to the {@link AlertBanner}, so the ref is the notice's `<div>` — its `href` is the
27
+ * action button's, never the banner's, so the frame is never a link. */
28
+ interface PlanLimitNoticeBaseProps extends Omit<ComponentPropsWithRef<"div">, "children" | "onClick"> {
26
29
  /** The dimension as the refusal names it (`"budgets"`, `"seats"`) — `isPlanLimit(err)`'s
27
30
  * `dimension`. Carried as `data-dimension`. */
28
31
  dimension: string;
package/src/chart.ts CHANGED
@@ -20,10 +20,12 @@ export * from "./components/treemap";
20
20
  export * from "./components/series-chart";
21
21
  // 0.32.1 (keksdose's 0.32 report): the height every chart takes, for an app's wrappers.
22
22
  export type { ChartHeight } from "./components/chart-height";
23
+ // SharedXZoom and the zoom's types: the public face of ./chart-zoom-core, whose arithmetic
24
+ // stays the kit's (refactor audit 2026-10, API-6b; off the surface in 0.35).
23
25
  export * from "./components/chart-zoom";
24
- // The zoom's own module since 0.34: SharedXZoom and the drag layer (recharts) live here,
25
- // ./chart-zoom is their public face. Named, so the arithmetic it also holds stays the
26
- // kit's (refactor audit 2026-10, API-6b) — the same binding the line above re-exports.
26
+ // The same binding, named from the module that holds it and the drag layer (recharts): the
27
+ // packaging contract reads this entry's own imports to know which modules may import
28
+ // recharts, so chart-zoom-core has to be one of them (MOD-6).
27
29
  export { SharedXZoom } from "./components/chart-zoom-core";
28
30
  export * from "./components/toggle-legend";
29
31
  export * from "./components/facing-pair";