@eifi1/ui-kit 0.51.7 → 0.52.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 (746) hide show
  1. package/README.md +141 -23
  2. package/dist/account/account-parts.d.ts +10 -8
  3. package/dist/account/account-parts.js +1 -18
  4. package/dist/account/account-parts.js.map +1 -1
  5. package/dist/account/data-export-setting-labels.js +1 -1
  6. package/dist/account/data-export-setting-labels.js.map +1 -1
  7. package/dist/account/delete-account-setting-labels.js +1 -1
  8. package/dist/account/delete-account-setting-labels.js.map +1 -1
  9. package/dist/account/email-change-setting-labels.js +1 -1
  10. package/dist/account/email-change-setting-labels.js.map +1 -1
  11. package/dist/account/passkeys-setting.d.ts +9 -2
  12. package/dist/account/passkeys-setting.js +41 -29
  13. package/dist/account/passkeys-setting.js.map +1 -1
  14. package/dist/account/sessions-setting.d.ts +3 -2
  15. package/dist/account/sessions-setting.js +10 -8
  16. package/dist/account/sessions-setting.js.map +1 -1
  17. package/dist/admin/admin-action-confirm.js +1 -3
  18. package/dist/admin/admin-action-confirm.js.map +1 -1
  19. package/dist/admin/admin-parts.d.ts +6 -3
  20. package/dist/admin/admin-parts.js +6 -21
  21. package/dist/admin/admin-parts.js.map +1 -1
  22. package/dist/admin/mail-test-card-labels.js +1 -1
  23. package/dist/admin/mail-test-card-labels.js.map +1 -1
  24. package/dist/admin/plan-change-labels.d.ts +2 -2
  25. package/dist/admin/plan-change-labels.js +1 -17
  26. package/dist/admin/plan-change-labels.js.map +1 -1
  27. package/dist/admin/plan-change.d.ts +3 -42
  28. package/dist/admin/plan-change.js +1 -1
  29. package/dist/admin/plan-change.js.map +1 -1
  30. package/dist/admin/reviewer-scope-editor.js +3 -3
  31. package/dist/admin/reviewer-scope-editor.js.map +1 -1
  32. package/dist/auth/auth-errors-labels.d.ts +11 -0
  33. package/dist/auth/auth-errors-labels.js +13 -0
  34. package/dist/auth/auth-errors-labels.js.map +1 -0
  35. package/dist/auth/auth-errors.d.ts +7 -23
  36. package/dist/auth/auth-errors.js +2 -15
  37. package/dist/auth/auth-errors.js.map +1 -1
  38. package/dist/auth/complete-name-dialog.js +2 -1
  39. package/dist/auth/complete-name-dialog.js.map +1 -1
  40. package/dist/auth/forgot-password-form-labels.js +1 -1
  41. package/dist/auth/forgot-password-form-labels.js.map +1 -1
  42. package/dist/auth/forgot-password-form.js +7 -2
  43. package/dist/auth/forgot-password-form.js.map +1 -1
  44. package/dist/auth/register-form-labels.js +1 -1
  45. package/dist/auth/register-form-labels.js.map +1 -1
  46. package/dist/auth/reset-password-form-labels.js +1 -1
  47. package/dist/auth/reset-password-form-labels.js.map +1 -1
  48. package/dist/auth/reset-password-form.js +5 -1
  49. package/dist/auth/reset-password-form.js.map +1 -1
  50. package/dist/auth/sign-in-form-labels.js +1 -1
  51. package/dist/auth/sign-in-form-labels.js.map +1 -1
  52. package/dist/auth/status-parts.d.ts +0 -3
  53. package/dist/auth/status-parts.js +0 -4
  54. package/dist/auth/status-parts.js.map +1 -1
  55. package/dist/auth/verify-email-labels.js +1 -1
  56. package/dist/auth/verify-email-labels.js.map +1 -1
  57. package/dist/auth/verify-email.js +7 -2
  58. package/dist/auth/verify-email.js.map +1 -1
  59. package/dist/billing/cancel-subscription.d.ts +20 -8
  60. package/dist/billing/cancel-subscription.js +7 -5
  61. package/dist/billing/cancel-subscription.js.map +1 -1
  62. package/dist/billing/dialog-close.d.ts +29 -0
  63. package/dist/billing/dialog-close.js +8 -0
  64. package/dist/billing/dialog-close.js.map +1 -0
  65. package/dist/billing/plan-card.d.ts +14 -4
  66. package/dist/billing/plan-card.js +15 -8
  67. package/dist/billing/plan-card.js.map +1 -1
  68. package/dist/billing/plan-change-labels.d.ts +42 -0
  69. package/dist/billing/plan-change-labels.js +22 -0
  70. package/dist/billing/plan-change-labels.js.map +1 -0
  71. package/dist/billing/plan-switch.d.ts +19 -7
  72. package/dist/billing/plan-switch.js +8 -4
  73. package/dist/billing/plan-switch.js.map +1 -1
  74. package/dist/chart.d.ts +12 -0
  75. package/dist/chart.js +16 -1
  76. package/dist/chart.js.map +1 -1
  77. package/dist/components/account-chips.d.ts +5 -5
  78. package/dist/components/account-chips.js +11 -13
  79. package/dist/components/account-chips.js.map +1 -1
  80. package/dist/components/alert-banner.d.ts +16 -5
  81. package/dist/components/alert-banner.js +46 -1
  82. package/dist/components/alert-banner.js.map +1 -1
  83. package/dist/components/amount-input.d.ts +13 -0
  84. package/dist/components/amount-input.js +1 -0
  85. package/dist/components/amount-input.js.map +1 -1
  86. package/dist/components/autocomplete.js +57 -65
  87. package/dist/components/autocomplete.js.map +1 -1
  88. package/dist/components/bulk-action-bar.d.ts +12 -2
  89. package/dist/components/bulk-action-bar.js +65 -21
  90. package/dist/components/bulk-action-bar.js.map +1 -1
  91. package/dist/components/button-look.d.ts +5 -5
  92. package/dist/components/button-look.js.map +1 -1
  93. package/dist/components/button.d.ts +2 -2
  94. package/dist/components/button.js.map +1 -1
  95. package/dist/components/chart-edge.d.ts +16 -0
  96. package/dist/components/chart-edge.js +14 -1
  97. package/dist/components/chart-edge.js.map +1 -1
  98. package/dist/components/chart-figure.d.ts +13 -0
  99. package/dist/components/chart-figure.js +15 -0
  100. package/dist/components/chart-figure.js.map +1 -0
  101. package/dist/components/chart-zoom-core.js +1 -4
  102. package/dist/components/chart-zoom-core.js.map +1 -1
  103. package/dist/components/chart.d.ts +8 -1
  104. package/dist/components/chart.js +19 -10
  105. package/dist/components/chart.js.map +1 -1
  106. package/dist/components/combobox-core.d.ts +111 -2
  107. package/dist/components/combobox-core.js +148 -42
  108. package/dist/components/combobox-core.js.map +1 -1
  109. package/dist/components/combobox.d.ts +1 -1
  110. package/dist/components/combobox.js +141 -130
  111. package/dist/components/combobox.js.map +1 -1
  112. package/dist/components/copy-button.d.ts +6 -5
  113. package/dist/components/copy-button.js +5 -3
  114. package/dist/components/copy-button.js.map +1 -1
  115. package/dist/components/country-select.js +2 -2
  116. package/dist/components/country-select.js.map +1 -1
  117. package/dist/components/data-table-column-settings.d.ts +1 -1
  118. package/dist/components/data-table-column-settings.js +33 -7
  119. package/dist/components/data-table-column-settings.js.map +1 -1
  120. package/dist/components/data-table-filters.d.ts +4 -2
  121. package/dist/components/data-table-filters.js +2 -2
  122. package/dist/components/data-table-filters.js.map +1 -1
  123. package/dist/components/data-table-fit.d.ts +6 -4
  124. package/dist/components/data-table-fit.js +4 -1
  125. package/dist/components/data-table-fit.js.map +1 -1
  126. package/dist/components/data-table-groups.d.ts +1 -1
  127. package/dist/components/data-table-groups.js.map +1 -1
  128. package/dist/components/data-table-head.d.ts +1 -1
  129. package/dist/components/data-table-head.js +15 -12
  130. package/dist/components/data-table-head.js.map +1 -1
  131. package/dist/components/data-table-mobile.d.ts +12 -7
  132. package/dist/components/data-table-mobile.js +43 -30
  133. package/dist/components/data-table-mobile.js.map +1 -1
  134. package/dist/components/data-table-pagination.js +11 -6
  135. package/dist/components/data-table-pagination.js.map +1 -1
  136. package/dist/components/data-table-resize.d.ts +1 -1
  137. package/dist/components/data-table-resize.js.map +1 -1
  138. package/dist/components/data-table-row-actions.d.ts +2 -2
  139. package/dist/components/data-table-row-actions.js.map +1 -1
  140. package/dist/components/data-table-row.d.ts +6 -2
  141. package/dist/components/data-table-row.js +17 -10
  142. package/dist/components/data-table-row.js.map +1 -1
  143. package/dist/components/data-table-selection.d.ts +1 -1
  144. package/dist/components/data-table-selection.js.map +1 -1
  145. package/dist/components/data-table-shared.d.ts +10 -2
  146. package/dist/components/data-table-shared.js +18 -0
  147. package/dist/components/data-table-shared.js.map +1 -1
  148. package/dist/components/data-table-sort.d.ts +21 -2
  149. package/dist/components/data-table-sort.js +24 -0
  150. package/dist/components/data-table-sort.js.map +1 -1
  151. package/dist/components/data-table-virtual.d.ts +1 -1
  152. package/dist/components/data-table-virtual.js +18 -6
  153. package/dist/components/data-table-virtual.js.map +1 -1
  154. package/dist/components/data-table.d.ts +13 -4
  155. package/dist/components/data-table.js +139 -36
  156. package/dist/components/data-table.js.map +1 -1
  157. package/dist/components/date-field.d.ts +24 -2
  158. package/dist/components/date-field.js +33 -8
  159. package/dist/components/date-field.js.map +1 -1
  160. package/dist/components/date-picker.d.ts +15 -2
  161. package/dist/components/date-picker.js +55 -26
  162. package/dist/components/date-picker.js.map +1 -1
  163. package/dist/components/date-range-picker.d.ts +47 -20
  164. package/dist/components/date-range-picker.js +23 -7
  165. package/dist/components/date-range-picker.js.map +1 -1
  166. package/dist/components/error-boundary.js +37 -30
  167. package/dist/components/error-boundary.js.map +1 -1
  168. package/dist/components/{ui-labels.d.ts → field-base-labels.d.ts} +0 -2
  169. package/dist/components/{ui-labels.js → field-base-labels.js} +2 -9
  170. package/dist/components/field-base-labels.js.map +1 -0
  171. package/dist/components/field-base.d.ts +4 -3
  172. package/dist/components/field-base.js +3 -3
  173. package/dist/components/field-base.js.map +1 -1
  174. package/dist/components/field-sync-labels.d.ts +1 -0
  175. package/dist/components/field-sync-labels.js.map +1 -1
  176. package/dist/components/field-sync.d.ts +23 -1
  177. package/dist/components/field-sync.js.map +1 -1
  178. package/dist/components/form-actions.js +11 -5
  179. package/dist/components/form-actions.js.map +1 -1
  180. package/dist/components/hover-menu.d.ts +3 -0
  181. package/dist/components/hover-menu.js +5 -3
  182. package/dist/components/hover-menu.js.map +1 -1
  183. package/dist/components/icon-button.js +14 -9
  184. package/dist/components/icon-button.js.map +1 -1
  185. package/dist/components/inline-confirm.d.ts +43 -0
  186. package/dist/components/inline-confirm.js +67 -0
  187. package/dist/components/inline-confirm.js.map +1 -0
  188. package/dist/components/inline-entity-combobox.d.ts +4 -3
  189. package/dist/components/inline-entity-combobox.js +148 -140
  190. package/dist/components/inline-entity-combobox.js.map +1 -1
  191. package/dist/components/input.js +1 -1
  192. package/dist/components/input.js.map +1 -1
  193. package/dist/components/jump-calendar.js +28 -112
  194. package/dist/components/jump-calendar.js.map +1 -1
  195. package/dist/components/legal-page.d.ts +3 -1
  196. package/dist/components/legal-page.js +3 -2
  197. package/dist/components/legal-page.js.map +1 -1
  198. package/dist/components/legal.d.ts +4 -2
  199. package/dist/components/legal.js.map +1 -1
  200. package/dist/components/lightbox.d.ts +2 -2
  201. package/dist/components/lightbox.js +17 -6
  202. package/dist/components/lightbox.js.map +1 -1
  203. package/dist/components/list.d.ts +6 -2
  204. package/dist/components/list.js +3 -2
  205. package/dist/components/list.js.map +1 -1
  206. package/dist/components/measured-grid.d.ts +1 -1
  207. package/dist/components/measured-grid.js +2 -1
  208. package/dist/components/measured-grid.js.map +1 -1
  209. package/dist/components/mini-calendar.js +3 -5
  210. package/dist/components/mini-calendar.js.map +1 -1
  211. package/dist/components/modal.js +3 -1
  212. package/dist/components/modal.js.map +1 -1
  213. package/dist/components/month-grid.d.ts +55 -0
  214. package/dist/components/month-grid.js +148 -0
  215. package/dist/components/month-grid.js.map +1 -0
  216. package/dist/components/month-picker.d.ts +36 -4
  217. package/dist/components/month-picker.js +123 -146
  218. package/dist/components/month-picker.js.map +1 -1
  219. package/dist/components/multi-select.d.ts +2 -2
  220. package/dist/components/multi-select.js +11 -6
  221. package/dist/components/multi-select.js.map +1 -1
  222. package/dist/components/picker-sheet.js +2 -1
  223. package/dist/components/picker-sheet.js.map +1 -1
  224. package/dist/components/pie-chart-labels.d.ts +7 -0
  225. package/dist/components/pie-chart-labels.js +1 -0
  226. package/dist/components/pie-chart-labels.js.map +1 -1
  227. package/dist/components/pie-chart.d.ts +5 -3
  228. package/dist/components/pie-chart.js +7 -22
  229. package/dist/components/pie-chart.js.map +1 -1
  230. package/dist/components/popover.d.ts +3 -0
  231. package/dist/components/popover.js +7 -1
  232. package/dist/components/popover.js.map +1 -1
  233. package/dist/components/row-actions.js +2 -1
  234. package/dist/components/row-actions.js.map +1 -1
  235. package/dist/components/section-nav.d.ts +7 -0
  236. package/dist/components/section-nav.js.map +1 -1
  237. package/dist/components/series-chart-axes.d.ts +3 -3
  238. package/dist/components/series-chart-axes.js.map +1 -1
  239. package/dist/components/series-chart-labels.d.ts +7 -4
  240. package/dist/components/series-chart-labels.js.map +1 -1
  241. package/dist/components/series-chart.d.ts +31 -7
  242. package/dist/components/series-chart.js +72 -29
  243. package/dist/components/series-chart.js.map +1 -1
  244. package/dist/components/share-card.js +3 -3
  245. package/dist/components/share-card.js.map +1 -1
  246. package/dist/components/sign-chip-labels.d.ts +1 -0
  247. package/dist/components/sign-chip-labels.js.map +1 -1
  248. package/dist/components/sign-chip.d.ts +10 -1
  249. package/dist/components/sign-chip.js.map +1 -1
  250. package/dist/components/tabs-labels.d.ts +2 -0
  251. package/dist/components/tabs-labels.js +11 -0
  252. package/dist/components/tabs-labels.js.map +1 -0
  253. package/dist/components/tabs.d.ts +1 -1
  254. package/dist/components/tabs.js +1 -1
  255. package/dist/components/tabs.js.map +1 -1
  256. package/dist/components/toggle-legend.d.ts +2 -2
  257. package/dist/components/toggle-legend.js +6 -6
  258. package/dist/components/toggle-legend.js.map +1 -1
  259. package/dist/components/translation-review-editor.d.ts +4 -2
  260. package/dist/components/translation-review-editor.js +2 -3
  261. package/dist/components/translation-review-editor.js.map +1 -1
  262. package/dist/components/translation-review.d.ts +2 -1
  263. package/dist/components/translation-review.js +2 -2
  264. package/dist/components/translation-review.js.map +1 -1
  265. package/dist/components/treemap-labels.d.ts +26 -0
  266. package/dist/components/treemap-labels.js +10 -0
  267. package/dist/components/treemap-labels.js.map +1 -0
  268. package/dist/components/treemap-layout.d.ts +62 -0
  269. package/dist/components/treemap-layout.js +126 -0
  270. package/dist/components/treemap-layout.js.map +1 -0
  271. package/dist/components/treemap.d.ts +140 -17
  272. package/dist/components/treemap.js +324 -97
  273. package/dist/components/treemap.js.map +1 -1
  274. package/dist/components/use-table-state.d.ts +5 -5
  275. package/dist/components/use-table-state.js +4 -1
  276. package/dist/components/use-table-state.js.map +1 -1
  277. package/dist/data-table.d.ts +6 -2
  278. package/dist/data-table.js +7 -2
  279. package/dist/data-table.js.map +1 -1
  280. package/dist/eslint/index.js +1 -1
  281. package/dist/eslint/role-utilities.d.ts +36 -7
  282. package/dist/eslint/role-utilities.js +42 -9
  283. package/dist/eslint/role-utilities.js.map +1 -1
  284. package/dist/feedback/feedback-dialog.d.ts +25 -16
  285. package/dist/feedback/feedback-dialog.js +6 -2
  286. package/dist/feedback/feedback-dialog.js.map +1 -1
  287. package/dist/feedback/feedback-mutation.d.ts +30 -0
  288. package/dist/feedback/feedback-mutation.js +16 -0
  289. package/dist/feedback/feedback-mutation.js.map +1 -0
  290. package/dist/feedback/feedback-page-link.d.ts +53 -0
  291. package/dist/feedback/feedback-page-link.js +37 -0
  292. package/dist/feedback/feedback-page-link.js.map +1 -0
  293. package/dist/feedback/feedback-public-list.d.ts +12 -3
  294. package/dist/feedback/feedback-public-list.js +2 -1
  295. package/dist/feedback/feedback-public-list.js.map +1 -1
  296. package/dist/feedback/feedback-replies.d.ts +3 -1
  297. package/dist/feedback/feedback-replies.js +5 -4
  298. package/dist/feedback/feedback-replies.js.map +1 -1
  299. package/dist/feedback/feedback-row-detail-labels.js +5 -0
  300. package/dist/feedback/feedback-row-detail-labels.js.map +1 -1
  301. package/dist/feedback/feedback-row-detail.d.ts +64 -28
  302. package/dist/feedback/feedback-row-detail.js +30 -28
  303. package/dist/feedback/feedback-row-detail.js.map +1 -1
  304. package/dist/feedback/feedback-status-undo.d.ts +30 -11
  305. package/dist/feedback/feedback-status-undo.js +10 -15
  306. package/dist/feedback/feedback-status-undo.js.map +1 -1
  307. package/dist/feedback/feedback-submit-dialog.d.ts +1 -1
  308. package/dist/feedback/feedback-submit-dialog.js +1 -1
  309. package/dist/feedback/feedback-submit-dialog.js.map +1 -1
  310. package/dist/feedback/feedback-submit.js +9 -3
  311. package/dist/feedback/feedback-submit.js.map +1 -1
  312. package/dist/feedback/feedback-swipe.d.ts +1 -1
  313. package/dist/feedback/feedback-swipe.js.map +1 -1
  314. package/dist/feedback/feedback-table.d.ts +21 -9
  315. package/dist/feedback/feedback-table.js +45 -25
  316. package/dist/feedback/feedback-table.js.map +1 -1
  317. package/dist/feedback/feedback-thread-labels.d.ts +2 -2
  318. package/dist/feedback/feedback-thread-labels.js.map +1 -1
  319. package/dist/feedback/feedback-thread.d.ts +24 -23
  320. package/dist/feedback/feedback-thread.js +2 -1
  321. package/dist/feedback/feedback-thread.js.map +1 -1
  322. package/dist/feedback.d.ts +34 -0
  323. package/dist/feedback.js +42 -0
  324. package/dist/feedback.js.map +1 -1
  325. package/dist/hooks/use-focus-trap.d.ts +35 -0
  326. package/dist/hooks/use-focus-trap.js +6 -4
  327. package/dist/hooks/use-focus-trap.js.map +1 -1
  328. package/dist/hooks/use-hotkey.d.ts +5 -2
  329. package/dist/hooks/use-hotkey.js +5 -4
  330. package/dist/hooks/use-hotkey.js.map +1 -1
  331. package/dist/hooks/use-long-press.d.ts +7 -4
  332. package/dist/hooks/use-long-press.js +3 -0
  333. package/dist/hooks/use-long-press.js.map +1 -1
  334. package/dist/hooks/use-media-query.js +12 -4
  335. package/dist/hooks/use-media-query.js.map +1 -1
  336. package/dist/hooks/use-mounted.d.ts +22 -0
  337. package/dist/hooks/use-mounted.js +24 -0
  338. package/dist/hooks/use-mounted.js.map +1 -0
  339. package/dist/hooks/use-overlay-history.js +19 -3
  340. package/dist/hooks/use-overlay-history.js.map +1 -1
  341. package/dist/i18n/defaults.js +4 -5
  342. package/dist/i18n/defaults.js.map +1 -1
  343. package/dist/i18n/german.js +7 -1
  344. package/dist/i18n/german.js.map +1 -1
  345. package/dist/i18n/kit-labels.d.ts +9 -2
  346. package/dist/i18n/kit-labels.js.map +1 -1
  347. package/dist/i18n/locales/en.js +20 -41
  348. package/dist/i18n/locales/en.js.map +3 -3
  349. package/dist/i18n/locales/es.js +7 -1
  350. package/dist/i18n/locales/es.js.map +1 -1
  351. package/dist/i18n/locales/fr.js +20 -10
  352. package/dist/i18n/locales/fr.js.map +1 -1
  353. package/dist/i18n/locales/hu.js +9 -1
  354. package/dist/i18n/locales/hu.js.map +1 -1
  355. package/dist/i18n/locales/it.js +7 -1
  356. package/dist/i18n/locales/it.js.map +1 -1
  357. package/dist/i18n/locales/zh.js +8 -1
  358. package/dist/i18n/locales/zh.js.map +1 -1
  359. package/dist/i18n/review.js +5 -0
  360. package/dist/i18n/review.js.map +1 -1
  361. package/dist/index.d.ts +64 -17
  362. package/dist/index.js +77 -12
  363. package/dist/index.js.map +1 -1
  364. package/dist/landing/routing.d.ts +3 -1
  365. package/dist/landing/routing.js +4 -7
  366. package/dist/landing/routing.js.map +1 -1
  367. package/dist/lib/broadcast.d.ts +17 -0
  368. package/dist/lib/broadcast.js +30 -0
  369. package/dist/lib/broadcast.js.map +1 -0
  370. package/dist/lib/date-format.d.ts +37 -2
  371. package/dist/lib/date-format.js +34 -8
  372. package/dist/lib/date-format.js.map +1 -1
  373. package/dist/lib/day-format.d.ts +46 -0
  374. package/dist/lib/day-format.js +46 -0
  375. package/dist/lib/day-format.js.map +1 -0
  376. package/dist/lib/describe-failure.d.ts +14 -0
  377. package/dist/lib/describe-failure.js +9 -0
  378. package/dist/lib/describe-failure.js.map +1 -0
  379. package/dist/lib/direction.d.ts +16 -0
  380. package/dist/lib/direction.js +9 -1
  381. package/dist/lib/direction.js.map +1 -1
  382. package/dist/lib/focus-left.d.ts +63 -0
  383. package/dist/lib/focus-left.js +67 -0
  384. package/dist/lib/focus-left.js.map +1 -0
  385. package/dist/lib/format.js +5 -14
  386. package/dist/lib/format.js.map +1 -1
  387. package/dist/lib/http-status.d.ts +24 -0
  388. package/dist/lib/http-status.js +14 -0
  389. package/dist/lib/http-status.js.map +1 -0
  390. package/dist/lib/intl-cache.d.ts +14 -0
  391. package/dist/lib/intl-cache.js +15 -0
  392. package/dist/lib/intl-cache.js.map +1 -0
  393. package/dist/lib/isin.d.ts +10 -0
  394. package/dist/lib/isin.js.map +1 -1
  395. package/dist/lib/iso-day.d.ts +11 -0
  396. package/dist/lib/iso-day.js +8 -0
  397. package/dist/lib/iso-day.js.map +1 -0
  398. package/dist/lib/lazy-with-fallback.d.ts +31 -0
  399. package/dist/lib/lazy-with-fallback.js +40 -0
  400. package/dist/lib/lazy-with-fallback.js.map +1 -0
  401. package/dist/lib/path-pattern.d.ts +34 -0
  402. package/dist/lib/path-pattern.js +24 -0
  403. package/dist/lib/path-pattern.js.map +1 -0
  404. package/dist/lib/reduced-motion.d.ts +2 -1
  405. package/dist/lib/reduced-motion.js.map +1 -1
  406. package/dist/lib/round.d.ts +2 -1
  407. package/dist/lib/round.js +2 -1
  408. package/dist/lib/round.js.map +1 -1
  409. package/dist/lib/safe-storage.d.ts +24 -5
  410. package/dist/lib/safe-storage.js +27 -2
  411. package/dist/lib/safe-storage.js.map +1 -1
  412. package/dist/lib/session-refresh.js +1 -1
  413. package/dist/lib/session-refresh.js.map +1 -1
  414. package/dist/lib/share-format.d.ts +13 -0
  415. package/dist/lib/share-format.js +10 -0
  416. package/dist/lib/share-format.js.map +1 -0
  417. package/dist/lib/text-case.d.ts +17 -0
  418. package/dist/lib/text-case.js +8 -0
  419. package/dist/lib/text-case.js.map +1 -0
  420. package/dist/lib/text-match.d.ts +29 -0
  421. package/dist/lib/text-match.js +20 -2
  422. package/dist/lib/text-match.js.map +1 -1
  423. package/dist/lib/thenable.d.ts +13 -0
  424. package/dist/lib/thenable.js +8 -0
  425. package/dist/lib/thenable.js.map +1 -0
  426. package/dist/lib/translation-review.d.ts +7 -2
  427. package/dist/lib/translation-review.js +4 -4
  428. package/dist/lib/translation-review.js.map +1 -1
  429. package/dist/notifications/device-zone-report.js +1 -1
  430. package/dist/notifications/device-zone-report.js.map +1 -1
  431. package/dist/notifications/notification-bell.d.ts +2 -1
  432. package/dist/notifications/notification-bell.js +3 -3
  433. package/dist/notifications/notification-bell.js.map +1 -1
  434. package/dist/notifications/notification-inbox.d.ts +10 -6
  435. package/dist/notifications/notification-inbox.js +26 -48
  436. package/dist/notifications/notification-inbox.js.map +1 -1
  437. package/dist/notifications/notification-parts.d.ts +3 -2
  438. package/dist/notifications/notification-parts.js +1 -3
  439. package/dist/notifications/notification-parts.js.map +1 -1
  440. package/dist/notifications/notification-types-setting.js +1 -1
  441. package/dist/notifications/notification-types-setting.js.map +1 -1
  442. package/dist/notifications/notifications-labels.d.ts +6 -0
  443. package/dist/notifications/notifications-labels.js +1 -0
  444. package/dist/notifications/notifications-labels.js.map +1 -1
  445. package/dist/notifications/push-device-setting.js +1 -1
  446. package/dist/notifications/push-device-setting.js.map +1 -1
  447. package/dist/notifications/push-devices-setting.d.ts +2 -1
  448. package/dist/notifications/push-devices-setting.js +27 -49
  449. package/dist/notifications/push-devices-setting.js.map +1 -1
  450. package/dist/notifications/quiet-hours-setting.d.ts +10 -3
  451. package/dist/notifications/quiet-hours-setting.js +65 -26
  452. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  453. package/dist/notifications/unsubscribe-panel.js +4 -7
  454. package/dist/notifications/unsubscribe-panel.js.map +1 -1
  455. package/dist/notifications/use-push-device.d.ts +39 -1
  456. package/dist/notifications/use-push-device.js +28 -7
  457. package/dist/notifications/use-push-device.js.map +1 -1
  458. package/dist/offline/query-persistence.d.ts +3 -1
  459. package/dist/offline/query-persistence.js +19 -2
  460. package/dist/offline/query-persistence.js.map +1 -1
  461. package/dist/rhf/fields.d.ts +11 -4
  462. package/dist/rhf/fields.js.map +1 -1
  463. package/dist/search/command-palette.js +1 -1
  464. package/dist/search/command-palette.js.map +1 -1
  465. package/dist/search/global-search.js +19 -9
  466. package/dist/search/global-search.js.map +1 -1
  467. package/dist/search/use-command-key.d.ts +12 -1
  468. package/dist/search/use-command-key.js +3 -12
  469. package/dist/search/use-command-key.js.map +1 -1
  470. package/dist/search.d.ts +7 -0
  471. package/dist/search.js +10 -0
  472. package/dist/search.js.map +1 -1
  473. package/dist/settings/use-settings-focus.js +1 -0
  474. package/dist/settings/use-settings-focus.js.map +1 -1
  475. package/dist/shell/account-menu-language.d.ts +21 -5
  476. package/dist/shell/account-menu-language.js +3 -1
  477. package/dist/shell/account-menu-language.js.map +1 -1
  478. package/dist/shell/account-menu-text-size.d.ts +26 -9
  479. package/dist/shell/account-menu-text-size.js +3 -2
  480. package/dist/shell/account-menu-text-size.js.map +1 -1
  481. package/dist/shell/dev-menu.js +5 -18
  482. package/dist/shell/dev-menu.js.map +1 -1
  483. package/dist/shell/role-switcher.d.ts +7 -0
  484. package/dist/shell/role-switcher.js.map +1 -1
  485. package/dist/shell/top-bar-actions.d.ts +1 -1
  486. package/dist/shell/top-bar-actions.js.map +1 -1
  487. package/dist/shell/top-bar-parts-labels.d.ts +0 -2
  488. package/dist/shell/top-bar-parts-labels.js +0 -1
  489. package/dist/shell/top-bar-parts-labels.js.map +1 -1
  490. package/dist/shell.d.ts +27 -8
  491. package/dist/shell.js +28 -7
  492. package/dist/shell.js.map +1 -1
  493. package/dist/sync/engine.d.ts +30 -4
  494. package/dist/sync/engine.js +172 -52
  495. package/dist/sync/engine.js.map +1 -1
  496. package/dist/sync.d.ts +6 -2
  497. package/dist/sync.js +6 -1
  498. package/dist/sync.js.map +1 -1
  499. package/dist/testing/css-variables.js +3 -8
  500. package/dist/testing/css-variables.js.map +1 -1
  501. package/dist/theme/contrast.d.ts +2 -1
  502. package/dist/theme/contrast.js +3 -8
  503. package/dist/theme/contrast.js.map +1 -1
  504. package/dist/theme/palette-store.js +6 -6
  505. package/dist/theme/palette-store.js.map +1 -1
  506. package/dist/theme/text-size.js +3 -8
  507. package/dist/theme/text-size.js.map +1 -1
  508. package/dist/theme/theme-store.js +3 -6
  509. package/dist/theme/theme-store.js.map +1 -1
  510. package/dist/tour/tour-catalogue.d.ts +7 -4
  511. package/dist/tour/tour-catalogue.js +12 -10
  512. package/dist/tour/tour-catalogue.js.map +1 -1
  513. package/dist/tour/tour-dock.d.ts +0 -2
  514. package/dist/tour/tour-dock.js +0 -2
  515. package/dist/tour/tour-dock.js.map +1 -1
  516. package/dist/tour/tour-gates.d.ts +4 -0
  517. package/dist/tour/tour-gates.js +14 -0
  518. package/dist/tour/tour-gates.js.map +1 -1
  519. package/dist/tour/tour-overlay.d.ts +27 -0
  520. package/dist/tour/tour-overlay.js +741 -0
  521. package/dist/tour/tour-overlay.js.map +1 -0
  522. package/dist/tour/tour.d.ts +13 -3
  523. package/dist/tour/tour.js +59 -781
  524. package/dist/tour/tour.js.map +1 -1
  525. package/dist/tour/use-tour-progress.js +1 -1
  526. package/dist/tour/use-tour-progress.js.map +1 -1
  527. package/dist/wizard/stepper-nav.d.ts +5 -1
  528. package/dist/wizard/stepper-nav.js.map +1 -1
  529. package/dist/wizard/types.d.ts +6 -2
  530. package/dist/wizard/types.js.map +1 -1
  531. package/dist/wizard/use-wizard.d.ts +3 -1
  532. package/dist/wizard/use-wizard.js.map +1 -1
  533. package/dist/wizard/validation.d.ts +3 -1
  534. package/dist/wizard/validation.js.map +1 -1
  535. package/package.json +10 -6
  536. package/src/account/account-parts.ts +5 -25
  537. package/src/account/data-export-setting-labels.ts +1 -1
  538. package/src/account/delete-account-setting-labels.ts +1 -1
  539. package/src/account/email-change-setting-labels.ts +1 -1
  540. package/src/account/passkeys-setting.tsx +60 -38
  541. package/src/account/sessions-setting.tsx +18 -12
  542. package/src/admin/admin-action-confirm.tsx +1 -5
  543. package/src/admin/admin-parts.ts +11 -25
  544. package/src/admin/mail-test-card-labels.ts +1 -1
  545. package/src/admin/plan-change-labels.ts +5 -23
  546. package/src/admin/plan-change.tsx +7 -44
  547. package/src/admin/reviewer-scope-editor.tsx +3 -3
  548. package/src/auth/auth-errors-labels.ts +23 -0
  549. package/src/auth/auth-errors.ts +11 -38
  550. package/src/auth/complete-name-dialog.tsx +4 -1
  551. package/src/auth/forgot-password-form-labels.ts +1 -1
  552. package/src/auth/forgot-password-form.tsx +11 -6
  553. package/src/auth/register-form-labels.ts +1 -1
  554. package/src/auth/reset-password-form-labels.ts +1 -1
  555. package/src/auth/reset-password-form.tsx +8 -4
  556. package/src/auth/sign-in-form-labels.ts +1 -1
  557. package/src/auth/status-parts.tsx +0 -6
  558. package/src/auth/verify-email-labels.ts +1 -1
  559. package/src/auth/verify-email.tsx +11 -6
  560. package/src/billing/cancel-subscription.tsx +26 -12
  561. package/src/billing/dialog-close.ts +34 -0
  562. package/src/billing/plan-card.tsx +36 -11
  563. package/src/billing/plan-change-labels.ts +66 -0
  564. package/src/billing/plan-switch.tsx +26 -12
  565. package/src/chart.ts +21 -3
  566. package/src/components/account-chips.tsx +20 -23
  567. package/src/components/alert-banner.tsx +82 -5
  568. package/src/components/amount-input.tsx +18 -7
  569. package/src/components/autocomplete.tsx +64 -70
  570. package/src/components/bulk-action-bar.tsx +70 -2
  571. package/src/components/button-look.ts +5 -5
  572. package/src/components/button.tsx +2 -2
  573. package/src/components/chart-edge.ts +34 -1
  574. package/src/components/chart-figure.tsx +35 -0
  575. package/src/components/chart-zoom-core.tsx +6 -5
  576. package/src/components/chart.tsx +26 -5
  577. package/src/components/combobox-core.tsx +293 -62
  578. package/src/components/combobox.tsx +183 -153
  579. package/src/components/copy-button.tsx +34 -23
  580. package/src/components/country-select.tsx +16 -11
  581. package/src/components/data-table-column-settings.tsx +52 -10
  582. package/src/components/data-table-filters.ts +6 -4
  583. package/src/components/data-table-fit.ts +19 -5
  584. package/src/components/data-table-groups.tsx +1 -1
  585. package/src/components/data-table-head.tsx +32 -22
  586. package/src/components/data-table-mobile.tsx +61 -26
  587. package/src/components/data-table-pagination.tsx +21 -6
  588. package/src/components/data-table-resize.ts +1 -1
  589. package/src/components/data-table-row-actions.tsx +3 -3
  590. package/src/components/data-table-row.tsx +25 -14
  591. package/src/components/data-table-selection.ts +1 -1
  592. package/src/components/data-table-shared.tsx +33 -1
  593. package/src/components/data-table-sort.ts +57 -2
  594. package/src/components/data-table-virtual.ts +32 -8
  595. package/src/components/data-table.tsx +239 -68
  596. package/src/components/date-field.tsx +65 -8
  597. package/src/components/date-picker.tsx +125 -48
  598. package/src/components/date-range-picker.tsx +82 -31
  599. package/src/components/error-boundary.tsx +53 -39
  600. package/src/components/field-base-labels.ts +20 -0
  601. package/src/components/field-base.tsx +13 -7
  602. package/src/components/field-sync-labels.ts +1 -0
  603. package/src/components/field-sync.tsx +23 -1
  604. package/src/components/form-actions.tsx +20 -5
  605. package/src/components/hover-menu.tsx +18 -8
  606. package/src/components/icon-button.tsx +14 -9
  607. package/src/components/inline-confirm.tsx +116 -0
  608. package/src/components/inline-entity-combobox.tsx +197 -153
  609. package/src/components/input.tsx +1 -1
  610. package/src/components/jump-calendar.tsx +30 -146
  611. package/src/components/legal-page.tsx +10 -7
  612. package/src/components/legal.tsx +4 -2
  613. package/src/components/lightbox.tsx +21 -8
  614. package/src/components/list.tsx +10 -4
  615. package/src/components/measured-grid.tsx +8 -4
  616. package/src/components/mini-calendar.tsx +6 -5
  617. package/src/components/modal.tsx +6 -1
  618. package/src/components/month-grid.tsx +229 -0
  619. package/src/components/month-picker.tsx +192 -200
  620. package/src/components/multi-select.tsx +21 -9
  621. package/src/components/picker-sheet.tsx +12 -9
  622. package/src/components/pie-chart-labels.ts +8 -0
  623. package/src/components/pie-chart.tsx +22 -28
  624. package/src/components/popover.tsx +18 -1
  625. package/src/components/row-actions.tsx +2 -1
  626. package/src/components/section-nav.tsx +7 -0
  627. package/src/components/series-chart-axes.ts +3 -3
  628. package/src/components/series-chart-labels.ts +7 -4
  629. package/src/components/series-chart.tsx +178 -69
  630. package/src/components/share-card.tsx +6 -6
  631. package/src/components/sign-chip-labels.ts +1 -0
  632. package/src/components/sign-chip.tsx +10 -1
  633. package/src/components/tabs-labels.ts +13 -0
  634. package/src/components/tabs.tsx +2 -2
  635. package/src/components/toggle-legend.tsx +16 -11
  636. package/src/components/translation-review-editor.tsx +6 -5
  637. package/src/components/translation-review.tsx +4 -3
  638. package/src/components/treemap-labels.ts +39 -0
  639. package/src/components/treemap-layout.ts +210 -0
  640. package/src/components/treemap.tsx +722 -133
  641. package/src/components/use-table-state.ts +12 -9
  642. package/src/data-table.ts +9 -2
  643. package/src/eslint/role-utilities.ts +97 -19
  644. package/src/feedback/feedback-dialog.tsx +30 -19
  645. package/src/feedback/feedback-mutation.ts +41 -0
  646. package/src/feedback/feedback-page-link.ts +90 -0
  647. package/src/feedback/feedback-public-list.tsx +17 -5
  648. package/src/feedback/feedback-replies.tsx +13 -6
  649. package/src/feedback/feedback-row-detail-labels.ts +5 -0
  650. package/src/feedback/feedback-row-detail.tsx +103 -67
  651. package/src/feedback/feedback-status-undo.ts +41 -27
  652. package/src/feedback/feedback-submit-dialog.tsx +2 -2
  653. package/src/feedback/feedback-submit.tsx +21 -7
  654. package/src/feedback/feedback-swipe.tsx +1 -1
  655. package/src/feedback/feedback-table.tsx +86 -38
  656. package/src/feedback/feedback-thread-labels.ts +2 -2
  657. package/src/feedback/feedback-thread.tsx +27 -25
  658. package/src/feedback.ts +42 -0
  659. package/src/hooks/use-focus-trap.ts +8 -24
  660. package/src/hooks/use-hotkey.ts +24 -7
  661. package/src/hooks/use-long-press.ts +16 -2
  662. package/src/hooks/use-media-query.ts +20 -4
  663. package/src/hooks/use-mounted.ts +39 -0
  664. package/src/hooks/use-overlay-history.ts +32 -3
  665. package/src/i18n/defaults.ts +4 -5
  666. package/src/i18n/german.ts +8 -2
  667. package/src/i18n/kit-labels.tsx +9 -2
  668. package/src/i18n/locales/es.ts +8 -2
  669. package/src/i18n/locales/fr.ts +21 -13
  670. package/src/i18n/locales/hu.ts +10 -2
  671. package/src/i18n/locales/it.ts +8 -2
  672. package/src/i18n/locales/zh.ts +9 -2
  673. package/src/i18n/review.ts +5 -0
  674. package/src/index.ts +103 -13
  675. package/src/landing/routing.tsx +14 -10
  676. package/src/lib/broadcast.ts +52 -0
  677. package/src/lib/date-format.ts +89 -13
  678. package/src/lib/day-format.ts +96 -0
  679. package/src/lib/describe-failure.ts +18 -0
  680. package/src/lib/direction.ts +39 -0
  681. package/src/lib/focus-left.ts +156 -0
  682. package/src/lib/format.ts +5 -17
  683. package/src/lib/http-status.ts +38 -0
  684. package/src/lib/intl-cache.ts +25 -0
  685. package/src/lib/isin.ts +10 -0
  686. package/src/lib/iso-day.ts +13 -0
  687. package/src/lib/lazy-with-fallback.ts +82 -0
  688. package/src/lib/path-pattern.ts +55 -0
  689. package/src/lib/reduced-motion.ts +2 -1
  690. package/src/lib/round.ts +7 -2
  691. package/src/lib/safe-storage.ts +48 -6
  692. package/src/lib/session-refresh.ts +1 -1
  693. package/src/lib/share-format.ts +18 -0
  694. package/src/lib/text-case.ts +19 -0
  695. package/src/lib/text-match.ts +57 -2
  696. package/src/lib/thenable.ts +19 -0
  697. package/src/lib/translation-review.ts +13 -5
  698. package/src/notifications/device-zone-report.ts +1 -1
  699. package/src/notifications/notification-bell.tsx +7 -4
  700. package/src/notifications/notification-inbox.tsx +40 -58
  701. package/src/notifications/notification-parts.tsx +3 -4
  702. package/src/notifications/notification-types-setting.tsx +1 -1
  703. package/src/notifications/notifications-labels.ts +7 -0
  704. package/src/notifications/push-device-setting.tsx +1 -1
  705. package/src/notifications/push-devices-setting.tsx +30 -55
  706. package/src/notifications/quiet-hours-setting.tsx +93 -31
  707. package/src/notifications/unsubscribe-panel.tsx +4 -7
  708. package/src/notifications/use-push-device.ts +76 -10
  709. package/src/offline/query-persistence.ts +35 -3
  710. package/src/rhf/fields.tsx +11 -4
  711. package/src/search/command-palette.tsx +2 -4
  712. package/src/search/global-search.tsx +36 -15
  713. package/src/search/use-command-key.ts +13 -12
  714. package/src/search.ts +9 -0
  715. package/src/settings/use-settings-focus.ts +3 -0
  716. package/src/shell/account-menu-language.tsx +27 -5
  717. package/src/shell/account-menu-text-size.tsx +32 -10
  718. package/src/shell/dev-menu.tsx +7 -21
  719. package/src/shell/role-switcher.tsx +7 -0
  720. package/src/shell/top-bar-actions.tsx +1 -1
  721. package/src/shell/top-bar-parts-labels.ts +0 -3
  722. package/src/shell.ts +40 -9
  723. package/src/sync/engine.ts +339 -61
  724. package/src/sync.ts +8 -1
  725. package/src/testing/css-variables.ts +8 -9
  726. package/src/theme/contrast.ts +8 -9
  727. package/src/theme/palette-store.ts +9 -9
  728. package/src/theme/text-size.ts +6 -8
  729. package/src/theme/theme-store.ts +7 -9
  730. package/src/tour/tour-catalogue.tsx +26 -15
  731. package/src/tour/tour-dock.ts +0 -2
  732. package/src/tour/tour-gates.ts +23 -0
  733. package/src/tour/tour-overlay.tsx +1170 -0
  734. package/src/tour/tour.tsx +108 -1202
  735. package/src/tour/use-tour-progress.ts +1 -1
  736. package/src/wizard/stepper-nav.tsx +3 -1
  737. package/src/wizard/types.ts +11 -2
  738. package/src/wizard/use-wizard.ts +2 -1
  739. package/src/wizard/validation.ts +8 -2
  740. package/tokens.css +2 -1
  741. package/dist/components/ui-labels.js.map +0 -1
  742. package/dist/shell/dev-switchers.d.ts +0 -6
  743. package/dist/shell/dev-switchers.js +0 -12
  744. package/dist/shell/dev-switchers.js.map +0 -1
  745. package/src/components/ui-labels.ts +0 -24
  746. package/src/shell/dev-switchers.tsx +0 -18
@@ -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\";\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":[]}
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 { readStoredJson } 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/** The frozen format holding a size this kit knows — the shape `readStoredJson` admits. */\nconst holdsTextSize = (blob: unknown): blob is { state: { size: TextSize } } =>\n isTextSize((blob as PersistedTextSize | null)?.state?.size);\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 return readStoredJson(storageKey, holdsTextSize)?.state.size ?? null;\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 { readStoredJson } from "../lib/safe-storage.js";
6
7
  import { registerDeviceSetting } from "../lib/device-settings.js";
7
8
  function systemPreference() {
8
9
  try {
@@ -27,13 +28,9 @@ function applyThemeClass(mode) {
27
28
  else root.classList.remove("dark");
28
29
  root.style.colorScheme = mode;
29
30
  }
31
+ const isEnvelope = (blob) => typeof blob === "object" && blob !== null;
30
32
  function applyPersistedTheme(storageKey) {
31
- let preference = "system";
32
- try {
33
- const blob = JSON.parse(localStorage.getItem(storageKey) ?? "{}");
34
- preference = preferenceFromState(blob.state);
35
- } catch {
36
- }
33
+ const preference = preferenceFromState(readStoredJson(storageKey, isEnvelope)?.state);
37
34
  const mode = resolve(preference);
38
35
  applyThemeClass(mode);
39
36
  return mode;
@@ -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\";\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(s: { mode?: string; preference?: string } | null | undefined): 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/**\n * Toggle the `.dark` class on <html> for a resolved light/dark mode, and say the same\n * to the BROWSER through `color-scheme` (0.47, keksdose dev #600): the class switches\n * the kit's tokens, but everything the browser paints itself — a native `<select>`'s\n * open list, a date input's popup, scrollbars, autofill — follows `color-scheme` only,\n * and stayed light on the dark theme. Inline on <html>, so it holds whatever an app's\n * stylesheet says, and it is set by the pre-paint {@link applyPersistedTheme} too.\n */\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 root.style.colorScheme = mode;\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 (and `color-scheme`) on <html> — BEFORE React and the store mount,\n * so the first paint already has the right theme (no flash). Returns the resolved\n * mode so the caller can chain {@link applyPersistedPalette}. Encapsulates the\n * zustand-persist 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(persistedState as { mode?: string; preference?: string } | null),\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":[]}
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 { readStoredJson } from \"../lib/safe-storage\";\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(s: { mode?: string; preference?: string } | null | undefined): 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/**\n * Toggle the `.dark` class on <html> for a resolved light/dark mode, and say the same\n * to the BROWSER through `color-scheme` (0.47, keksdose dev #600): the class switches\n * the kit's tokens, but everything the browser paints itself — a native `<select>`'s\n * open list, a date input's popup, scrollbars, autofill — follows `color-scheme` only,\n * and stayed light on the dark theme. Inline on <html>, so it holds whatever an app's\n * stylesheet says, and it is set by the pre-paint {@link applyPersistedTheme} too.\n */\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 root.style.colorScheme = mode;\n}\n\n/** zustand's envelope, any object; `preferenceFromState` reads only the two names it knows. */\nconst isEnvelope = (blob: unknown): blob is { state?: { mode?: string; preference?: string } } =>\n typeof blob === \"object\" && blob !== null;\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 (and `color-scheme`) on <html> — BEFORE React and the store mount,\n * so the first paint already has the right theme (no flash). Returns the resolved\n * mode so the caller can chain {@link applyPersistedPalette}. Encapsulates the\n * zustand-persist 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 // Absent, blocked or malformed storage → \"system\", through `preferenceFromState`.\n const preference = preferenceFromState(readStoredJson(storageKey, isEnvelope)?.state);\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(persistedState as { mode?: string; preference?: string } | null),\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":[]}
@@ -15,10 +15,13 @@ export interface TourCatalogueCardProps {
15
15
  /** "Mark as not done". Without it, the link is not drawn. */
16
16
  onReset?: (id: string) => void;
17
17
  /**
18
- * Formats the completion and update days. Default: the kit's medium date in the provider's locale.
19
- * It gets the completion's ISO instant and the update's bare `"YYYY-MM-DD"` (`updated.date`): read
20
- * the bare day on its calendar (the kit's `formatDate`, `parseIsoDate`), never `new Date(day)`,
21
- * which is UTC midnight and the day before west of UTC (round 0.45, N9).
18
+ * Formats the completion and update days. Default (0.52): the `<UiKitProvider formatDate>`
19
+ * when the app set one — its date preference, no weekday: the day sits in a sentence —
20
+ * else the kit's medium date in the provider's locale and `timeZone`. It gets the
21
+ * completion's ISO instant and the update's bare `"YYYY-MM-DD"` (`updated.date`): read the
22
+ * bare day on its calendar (the kit's `formatDate`, `parseIsoDate`), never `new Date(day)`,
23
+ * which is UTC midnight and the day before west of UTC (round 0.45, N9). An empty answer
24
+ * falls back to the kit's.
22
25
  */
23
26
  formatDate?: (iso: string) => string;
24
27
  labels?: LabelOverride<TourCatalogueLabels>;
@@ -3,9 +3,9 @@ import { jsx, jsxs } from "react/jsx-runtime";
3
3
  import { useId } from "react";
4
4
  import { Circle, CircleCheck, Clock, Play } from "lucide-react";
5
5
  import { cn } from "../lib/cn.js";
6
- import { useKitFormat } from "../lib/format.js";
7
- import { foldForSearch } from "../lib/text-match.js";
8
- import { useKitLabels } from "../i18n/kit-labels.js";
6
+ import { useDayFormatter } from "../lib/day-format.js";
7
+ import { matchesSearch, searchWords } from "../lib/text-match.js";
8
+ import { useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
9
9
  import { Button } from "../components/button.js";
10
10
  import { Card } from "../components/card.js";
11
11
  import { Chip } from "../components/chip.js";
@@ -27,8 +27,7 @@ function TourCatalogueCard({
27
27
  className
28
28
  }) {
29
29
  const words = useKitLabels("tourCatalogue", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);
30
- const { formatDate } = useKitFormat();
31
- const day = formatDateProp ?? ((iso) => formatDate(iso, "medium"));
30
+ const day = useDayFormatter(formatDateProp);
32
31
  const titleId = useId();
33
32
  const done = isTourDone(tour, completedAt);
34
33
  const reset = isTourReset(tour, completedAt);
@@ -95,21 +94,24 @@ function TourCatalogue({
95
94
  }) {
96
95
  const words = useKitLabels("tourCatalogue", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);
97
96
  const uid = useId();
98
- const { locale: kitLocale } = useKitFormat();
99
- const locale = localeProp ?? kitLocale;
97
+ const locale = useKitLocale(localeProp);
100
98
  const group = groups.some((g) => g.id === groupProp) ? groupProp : null;
101
99
  const state = STATE_FILTERS.includes(stateProp) ? stateProp : "all";
102
100
  const listed = tours.filter((t) => groups.some((g) => g.id === t.group));
103
101
  const done = (tour) => isTourDone(tour, completed[tour.id]);
104
- const needle = foldForSearch(query.trim(), locale);
105
- const matchesQuery = (tour) => !needle || [tour.title, tour.description, tour.about].some((text) => !!text && foldForSearch(text, locale).includes(needle));
102
+ const queryWords = searchWords(query, locale);
103
+ const matchesQuery = (tour) => matchesSearch(
104
+ queryWords,
105
+ [tour.title || void 0, tour.description || void 0, tour.about || void 0],
106
+ locale
107
+ );
106
108
  const inState = (tour) => state === "all" ? true : state === "done" ? done(tour) : !done(tour);
107
109
  const inGroup = (tour, id = group) => id === null || tour.group === id;
108
110
  const found = listed.filter((t) => matchesQuery(t) && inGroup(t));
109
111
  const foundDone = found.filter(done).length;
110
112
  const visible = found.filter(inState);
111
113
  const groupCount = (id) => listed.filter((t) => matchesQuery(t) && inState(t) && inGroup(t, id)).length;
112
- const filtering = needle.length > 0 || state !== "all" || group !== null;
114
+ const filtering = queryWords.length > 0 || state !== "all" || group !== null;
113
115
  const sections = groups.map((g) => ({ ...g, tours: visible.filter((t) => t.group === g.id) })).filter((g) => g.tours.length > 0);
114
116
  return /* @__PURE__ */ jsxs("div", { className: cn("space-y-4", className), children: [
115
117
  header,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tour/tour-catalogue.tsx"],"sourcesContent":["import { useId, type ReactNode } from \"react\";\nimport { Circle, CircleCheck, Clock, Play } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFormat } from \"../lib/format\";\nimport { foldForSearch } from \"../lib/text-match\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { Chip } from \"../components/chip\";\nimport { Caption } from \"../components/text\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { SearchField } from \"../components/search-field\";\nimport { Select } from \"../components/select\";\nimport { ToggleGroup } from \"../components/toggle-group\";\nimport { DEFAULT_TOUR_CATALOGUE_LABELS, type TourCatalogueLabels } from \"./tour-catalogue-labels\";\nimport { isTourDone, isTourNew, isTourReset, type TourCompletions, type TourGroup, type TourInfo } from \"./tour-info\";\nimport type { TourCatalogueFilter, TourStateFilter } from \"./use-tour-url-sync\";\n\nexport { DEFAULT_TOUR_CATALOGUE_LABELS };\nexport type { TourCatalogueLabels };\n\n/* ── The card ───────────────────────────────────────────────────────────────── */\n\nexport interface TourCatalogueCardProps {\n tour: TourInfo;\n /** The completion's ISO timestamp, `null` for one without a date; absent while untaken. */\n completedAt?: string | null;\n /** NEW — `isTourNew`. The catalogue works it out; a card on its own is told. */\n isNew?: boolean;\n onLaunch: (id: string) => void;\n /** \"Mark as not done\". Without it, the link is not drawn. */\n onReset?: (id: string) => void;\n /**\n * Formats the completion and update days. Default: the kit's medium date in the provider's locale.\n * It gets the completion's ISO instant and the update's bare `\"YYYY-MM-DD\"` (`updated.date`): read\n * the bare day on its calendar (the kit's `formatDate`, `parseIsoDate`), never `new Date(day)`,\n * which is UTC midnight and the day before west of UTC (round 0.45, N9).\n */\n formatDate?: (iso: string) => string;\n labels?: LabelOverride<TourCatalogueLabels>;\n className?: string;\n}\n\n/**\n * One tour's card — keksdose's (tours-page.tsx `TourCard`, dev#557), laid out top to\n * bottom so the cards sit side by side: the state icon beside a column holding the tour's\n * icon, title, NEW (or \"Updated\", §8) and the app's `badge`, the one-liner and, from `sm`,\n * the longer account. The foot is pinned to the bottom edge, so every Start in a row of\n * cards lands on one line: the points chip, the step count and the duration, the\n * completion (or reset) line, \"Mark as not done\" and Start / Replay. Points, steps and\n * duration are each left out when the tour has none — no gap, no \"0 points\" (KS).\n */\nexport function TourCatalogueCard({\n tour,\n completedAt,\n isNew = false,\n onLaunch,\n onReset,\n formatDate: formatDateProp,\n labels: labelsProp,\n className,\n}: TourCatalogueCardProps) {\n const words = useKitLabels(\"tourCatalogue\", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);\n const { formatDate } = useKitFormat();\n const day = formatDateProp ?? ((iso: string) => formatDate(iso, \"medium\"));\n const titleId = useId();\n const done = isTourDone(tour, completedAt);\n const reset = isTourReset(tour, completedAt);\n const hasFacts = tour.points != null || tour.steps != null || tour.duration != null;\n const note = done\n ? completedAt\n ? words.completedOn(day(completedAt))\n : words.completed\n : reset && tour.updated\n ? words.resetOn(day(tour.updated.date), tour.updated.reason)\n : null;\n\n return (\n <Card className={cn(\"flex h-full flex-col p-4\", className)}>\n {/* Title, one-liner and the long body share ONE left edge, in the column beside the\n state icon. */}\n <div className=\"flex flex-1 items-start gap-3\">\n <div className={cn(\"mt-0.5 shrink-0\", done ? \"text-success\" : \"text-muted\")}>\n {done ? <CircleCheck aria-hidden className=\"size-5\" /> : <Circle aria-hidden className=\"size-5\" />}\n <span className=\"sr-only\">{done ? words.completed : words.notCompleted}</span>\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n {tour.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 text-brand [&_svg]:size-4\">\n {tour.icon}\n </span>\n )}\n {/* The id on the words, not the heading: the Start button is described by them. */}\n <h3 className=\"font-medium\">\n <span id={titleId}>{tour.title}</span>\n </h3>\n {/* It retires itself the moment the tour is taken — nothing to dismiss. */}\n {isNew && !reset && (\n <Chip size=\"sm\" tone=\"brand\" caps className=\"px-1.5\">\n {words.badgeNew}\n </Chip>\n )}\n {reset && (\n <Chip size=\"sm\" tone=\"brand\" caps className=\"px-1.5\">\n {words.badgeUpdated}\n </Chip>\n )}\n {tour.badge}\n </div>\n {tour.description && <p className=\"mt-1 text-sm text-muted\">{tour.description}</p>}\n {/* `hidden sm:block`: one card per row is the phone, where these paragraphs\n would turn the page into a scroll; the search still reads them. */}\n {tour.about && <p className=\"mt-2 hidden text-sm leading-relaxed text-secondary sm:block\">{tour.about}</p>}\n </div>\n </div>\n <div className=\"mt-3 flex flex-wrap items-end justify-between gap-2\">\n <div className=\"min-w-0\">\n {hasFacts && (\n <div className=\"flex flex-wrap items-center gap-1.5\">\n {tour.points != null && (\n <Chip size=\"sm\" tone=\"brand\">\n {words.points(tour.points)}\n </Chip>\n )}\n {tour.steps != null && <Caption as=\"span\">{words.steps(tour.steps)}</Caption>}\n {tour.duration != null && (\n <Caption as=\"span\" className=\"inline-flex items-center gap-1\">\n <Clock aria-hidden className=\"size-3\" />\n {words.duration(tour.duration)}\n </Caption>\n )}\n </div>\n )}\n {note && <p className={cn(\"text-xs text-muted\", hasFacts && \"mt-1\")}>{note}</p>}\n </div>\n <div className=\"flex shrink-0 items-center gap-2\">\n {done && onReset && (\n <Button variant=\"link\" tone=\"muted\" size=\"sm\" aria-describedby={titleId} onClick={() => onReset(tour.id)}>\n {words.markUndone}\n </Button>\n )}\n <Button aria-describedby={titleId} onClick={() => onLaunch(tour.id)}>\n <Play aria-hidden className=\"size-4\" /> {done ? words.replay : words.start}\n </Button>\n </div>\n </div>\n </Card>\n );\n}\n\n/* ── The page body ─────────────────────────────────────────────────────────── */\n\nexport interface TourCatalogueProps extends TourCatalogueFilter {\n /** The tours this person may take, already filtered by role and data (§6.3). */\n tours: readonly TourInfo[];\n /** The sections, in the app's order. A tour whose group is not here is not listed. */\n groups: readonly TourGroup[];\n completed: TourCompletions;\n /** The account's `created_at`: a tour shipped after it, untaken, is NEW. Unknown → none is. */\n joined?: string | null;\n /** A filter changed — `{query}`, `{group}`, `{state}`, or all three for \"Clear filters\". */\n onChange: (patch: Partial<TourCatalogueFilter>) => void;\n onLaunch: (id: string) => void;\n /** \"Mark as not done\" — `useTourProgress().reset`. */\n onReset?: (id: string) => void;\n /** Above the filter bar: the app's page header with its points tile (§6.1). */\n header?: ReactNode;\n /** Each card's `formatDate`: see {@link TourCatalogueCardProps.formatDate} (a bare day for the update). */\n formatDate?: (iso: string) => string;\n /** The search's locale (case folding). Default: the provider's. */\n locale?: string;\n labels?: LabelOverride<TourCatalogueLabels>;\n className?: string;\n}\n\nconst STATE_FILTERS: readonly TourStateFilter[] = [\"all\", \"todo\", \"done\"];\n\n/**\n * The tours page's body (docs/tours-harmonization.md §2.1, §3) — keksdose's /tours:\n *\n * - **The filter bar**: a search, a group select and an all / to do / done toggle,\n * stacked below `lg` and side by side from it. Each count is what the OTHER filters\n * leave: the toggle counts the search and the group, each group the search and the\n * state. Under it, while a filter is on, \"Showing X of Y tours\".\n * - **The sections**: one per group, in the app's order — an h2 with its icon and help\n * line over a `sm:2 / xl:3` grid of `TourCatalogueCard`s. A group the filters empty is\n * hidden; when nothing is left, an empty state with \"Clear filters\".\n * - **Controlled**: `query`, `group`, `state` and `onChange`, so the app keeps them in its\n * URL (`useTourCatalogueUrlState`).\n *\n * A tour reset by an update (§8) is \"to do\" for the toggle and the counts.\n */\nexport function TourCatalogue({\n tours,\n groups,\n completed,\n joined,\n query,\n group: groupProp,\n state: stateProp,\n onChange,\n onLaunch,\n onReset,\n header,\n formatDate,\n locale: localeProp,\n labels: labelsProp,\n className,\n}: TourCatalogueProps) {\n const words = useKitLabels(\"tourCatalogue\", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);\n const uid = useId();\n const { locale: kitLocale } = useKitFormat();\n const locale = localeProp ?? kitLocale;\n // A group or state the list does not have is no filter at all (an old link).\n const group = groups.some((g) => g.id === groupProp) ? groupProp : null;\n const state = STATE_FILTERS.includes(stateProp) ? stateProp : \"all\";\n\n const listed = tours.filter((t) => groups.some((g) => g.id === t.group));\n const done = (tour: TourInfo) => isTourDone(tour, completed[tour.id]);\n const needle = foldForSearch(query.trim(), locale);\n const matchesQuery = (tour: TourInfo) =>\n !needle ||\n [tour.title, tour.description, tour.about].some((text) => !!text && foldForSearch(text, locale).includes(needle));\n const inState = (tour: TourInfo) => (state === \"all\" ? true : state === \"done\" ? done(tour) : !done(tour));\n const inGroup = (tour: TourInfo, id: string | null = group) => id === null || tour.group === id;\n\n const found = listed.filter((t) => matchesQuery(t) && inGroup(t));\n const foundDone = found.filter(done).length;\n const visible = found.filter(inState);\n const groupCount = (id: string | null) =>\n listed.filter((t) => matchesQuery(t) && inState(t) && inGroup(t, id)).length;\n const filtering = needle.length > 0 || state !== \"all\" || group !== null;\n const sections = groups\n .map((g) => ({ ...g, tours: visible.filter((t) => t.group === g.id) }))\n .filter((g) => g.tours.length > 0);\n\n return (\n <div className={cn(\"space-y-4\", className)}>\n {header}\n <div className=\"flex flex-col gap-2 lg:flex-row lg:items-center\">\n <SearchField\n className=\"lg:max-w-xs lg:flex-1\"\n value={query}\n onChange={(value) => onChange({ query: value })}\n aria-label={words.searchLabel}\n placeholder={words.searchPlaceholder}\n />\n {/* A select, not a chip per group: on a phone those wrap into rows above the list\n they filter, and an app may have ten groups (ka F8). Named, not labelled: a\n floating label makes it taller than the search box beside it (dev #589). */}\n <Select\n className=\"lg:w-60\"\n aria-label={words.groupLabel}\n value={group ?? \"\"}\n onChange={(event) => onChange({ group: event.target.value || null })}\n >\n <option value=\"\">{words.groupAll(groupCount(null))}</option>\n {groups.map((g) => (\n <option key={g.id} value={g.id}>\n {words.groupOption(g.title, groupCount(g.id))}\n </option>\n ))}\n </Select>\n <ToggleGroup\n value={state}\n onChange={(value: TourStateFilter) => onChange({ state: value })}\n aria-label={words.stateLabel}\n className=\"lg:w-auto\"\n options={[\n { value: \"all\", label: words.stateAll(found.length) },\n { value: \"todo\", label: words.stateTodo(found.length - foundDone) },\n { value: \"done\", label: words.stateDone(foundDone) },\n ]}\n />\n </div>\n {filtering && (\n <p role=\"status\" className=\"-mt-2 text-xs text-muted\">\n {words.showing(visible.length, listed.length)}\n </p>\n )}\n\n {visible.length === 0 ? (\n <div className=\"space-y-3\">\n <EmptyState title={words.noMatches} hint={words.noMatchesHint} />\n <div className=\"flex justify-center\">\n <Button variant=\"secondary\" onClick={() => onChange({ query: \"\", group: null, state: \"all\" })}>\n {words.clearFilters}\n </Button>\n </div>\n </div>\n ) : (\n // A GRID, never columns: a section holding one card keeps a cell's width.\n sections.map((section) => (\n <section key={section.id} aria-labelledby={`${uid}-${section.id}`} className=\"space-y-3\">\n <h2 id={`${uid}-${section.id}`} className=\"flex items-center gap-1.5 text-sm font-semibold text-muted\">\n {section.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 [&_svg]:size-4\">\n {section.icon}\n </span>\n )}\n {section.title}\n </h2>\n {section.help && <p className=\"-mt-2 text-xs text-muted\">{section.help}</p>}\n <div className=\"grid gap-3 sm:grid-cols-2 xl:grid-cols-3\">\n {section.tours.map((tour) => (\n <TourCatalogueCard\n key={tour.id}\n tour={tour}\n completedAt={completed[tour.id]}\n isNew={isTourNew({ since: tour.since, joined, completed: completed[tour.id] !== undefined })}\n onLaunch={onLaunch}\n onReset={onReset}\n formatDate={formatDate}\n labels={labelsProp}\n />\n ))}\n </div>\n </section>\n ))\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/tour/tour-catalogue.tsx"],"sourcesContent":["import { useId, type ReactNode } from \"react\";\nimport { Circle, CircleCheck, Clock, Play } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useDayFormatter } from \"../lib/day-format\";\nimport { matchesSearch, searchWords } from \"../lib/text-match\";\nimport { useKitLabels, useKitLocale, type LabelOverride } from \"../i18n/kit-labels\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { Chip } from \"../components/chip\";\nimport { Caption } from \"../components/text\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { SearchField } from \"../components/search-field\";\nimport { Select } from \"../components/select\";\nimport { ToggleGroup } from \"../components/toggle-group\";\nimport { DEFAULT_TOUR_CATALOGUE_LABELS, type TourCatalogueLabels } from \"./tour-catalogue-labels\";\nimport { isTourDone, isTourNew, isTourReset, type TourCompletions, type TourGroup, type TourInfo } from \"./tour-info\";\nimport type { TourCatalogueFilter, TourStateFilter } from \"./use-tour-url-sync\";\n\nexport { DEFAULT_TOUR_CATALOGUE_LABELS };\nexport type { TourCatalogueLabels };\n\n/* ── The card ───────────────────────────────────────────────────────────────── */\n\nexport interface TourCatalogueCardProps {\n tour: TourInfo;\n /** The completion's ISO timestamp, `null` for one without a date; absent while untaken. */\n completedAt?: string | null;\n /** NEW — `isTourNew`. The catalogue works it out; a card on its own is told. */\n isNew?: boolean;\n onLaunch: (id: string) => void;\n /** \"Mark as not done\". Without it, the link is not drawn. */\n onReset?: (id: string) => void;\n /**\n * Formats the completion and update days. Default (0.52): the `<UiKitProvider formatDate>`\n * when the app set one — its date preference, no weekday: the day sits in a sentence —\n * else the kit's medium date in the provider's locale and `timeZone`. It gets the\n * completion's ISO instant and the update's bare `\"YYYY-MM-DD\"` (`updated.date`): read the\n * bare day on its calendar (the kit's `formatDate`, `parseIsoDate`), never `new Date(day)`,\n * which is UTC midnight and the day before west of UTC (round 0.45, N9). An empty answer\n * falls back to the kit's.\n */\n formatDate?: (iso: string) => string;\n labels?: LabelOverride<TourCatalogueLabels>;\n className?: string;\n}\n\n/**\n * One tour's card — keksdose's (tours-page.tsx `TourCard`, dev#557), laid out top to\n * bottom so the cards sit side by side: the state icon beside a column holding the tour's\n * icon, title, NEW (or \"Updated\", §8) and the app's `badge`, the one-liner and, from `sm`,\n * the longer account. The foot is pinned to the bottom edge, so every Start in a row of\n * cards lands on one line: the points chip, the step count and the duration, the\n * completion (or reset) line, \"Mark as not done\" and Start / Replay. Points, steps and\n * duration are each left out when the tour has none — no gap, no \"0 points\" (KS).\n */\nexport function TourCatalogueCard({\n tour,\n completedAt,\n isNew = false,\n onLaunch,\n onReset,\n formatDate: formatDateProp,\n labels: labelsProp,\n className,\n}: TourCatalogueCardProps) {\n const words = useKitLabels(\"tourCatalogue\", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);\n const day = useDayFormatter(formatDateProp);\n const titleId = useId();\n const done = isTourDone(tour, completedAt);\n const reset = isTourReset(tour, completedAt);\n const hasFacts = tour.points != null || tour.steps != null || tour.duration != null;\n const note = done\n ? completedAt\n ? words.completedOn(day(completedAt))\n : words.completed\n : reset && tour.updated\n ? words.resetOn(day(tour.updated.date), tour.updated.reason)\n : null;\n\n return (\n <Card className={cn(\"flex h-full flex-col p-4\", className)}>\n {/* Title, one-liner and the long body share ONE left edge, in the column beside the\n state icon. */}\n <div className=\"flex flex-1 items-start gap-3\">\n <div className={cn(\"mt-0.5 shrink-0\", done ? \"text-success\" : \"text-muted\")}>\n {done ? <CircleCheck aria-hidden className=\"size-5\" /> : <Circle aria-hidden className=\"size-5\" />}\n <span className=\"sr-only\">{done ? words.completed : words.notCompleted}</span>\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n {tour.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 text-brand [&_svg]:size-4\">\n {tour.icon}\n </span>\n )}\n {/* The id on the words, not the heading: the Start button is described by them. */}\n <h3 className=\"font-medium\">\n <span id={titleId}>{tour.title}</span>\n </h3>\n {/* It retires itself the moment the tour is taken — nothing to dismiss. */}\n {isNew && !reset && (\n <Chip size=\"sm\" tone=\"brand\" caps className=\"px-1.5\">\n {words.badgeNew}\n </Chip>\n )}\n {reset && (\n <Chip size=\"sm\" tone=\"brand\" caps className=\"px-1.5\">\n {words.badgeUpdated}\n </Chip>\n )}\n {tour.badge}\n </div>\n {tour.description && <p className=\"mt-1 text-sm text-muted\">{tour.description}</p>}\n {/* `hidden sm:block`: one card per row is the phone, where these paragraphs\n would turn the page into a scroll; the search still reads them. */}\n {tour.about && <p className=\"mt-2 hidden text-sm leading-relaxed text-secondary sm:block\">{tour.about}</p>}\n </div>\n </div>\n <div className=\"mt-3 flex flex-wrap items-end justify-between gap-2\">\n <div className=\"min-w-0\">\n {hasFacts && (\n <div className=\"flex flex-wrap items-center gap-1.5\">\n {tour.points != null && (\n <Chip size=\"sm\" tone=\"brand\">\n {words.points(tour.points)}\n </Chip>\n )}\n {tour.steps != null && <Caption as=\"span\">{words.steps(tour.steps)}</Caption>}\n {tour.duration != null && (\n <Caption as=\"span\" className=\"inline-flex items-center gap-1\">\n <Clock aria-hidden className=\"size-3\" />\n {words.duration(tour.duration)}\n </Caption>\n )}\n </div>\n )}\n {note && <p className={cn(\"text-xs text-muted\", hasFacts && \"mt-1\")}>{note}</p>}\n </div>\n <div className=\"flex shrink-0 items-center gap-2\">\n {done && onReset && (\n <Button variant=\"link\" tone=\"muted\" size=\"sm\" aria-describedby={titleId} onClick={() => onReset(tour.id)}>\n {words.markUndone}\n </Button>\n )}\n <Button aria-describedby={titleId} onClick={() => onLaunch(tour.id)}>\n <Play aria-hidden className=\"size-4\" /> {done ? words.replay : words.start}\n </Button>\n </div>\n </div>\n </Card>\n );\n}\n\n/* ── The page body ─────────────────────────────────────────────────────────── */\n\nexport interface TourCatalogueProps extends TourCatalogueFilter {\n /** The tours this person may take, already filtered by role and data (§6.3). */\n tours: readonly TourInfo[];\n /** The sections, in the app's order. A tour whose group is not here is not listed. */\n groups: readonly TourGroup[];\n completed: TourCompletions;\n /** The account's `created_at`: a tour shipped after it, untaken, is NEW. Unknown → none is. */\n joined?: string | null;\n /** A filter changed — `{query}`, `{group}`, `{state}`, or all three for \"Clear filters\". */\n onChange: (patch: Partial<TourCatalogueFilter>) => void;\n onLaunch: (id: string) => void;\n /** \"Mark as not done\" — `useTourProgress().reset`. */\n onReset?: (id: string) => void;\n /** Above the filter bar: the app's page header with its points tile (§6.1). */\n header?: ReactNode;\n /** Each card's `formatDate`: see {@link TourCatalogueCardProps.formatDate} (a bare day for the update). */\n formatDate?: (iso: string) => string;\n /** The search's locale (case folding). Default: the provider's. */\n locale?: string;\n labels?: LabelOverride<TourCatalogueLabels>;\n className?: string;\n}\n\nconst STATE_FILTERS: readonly TourStateFilter[] = [\"all\", \"todo\", \"done\"];\n\n/**\n * The tours page's body (docs/tours-harmonization.md §2.1, §3) — keksdose's /tours:\n *\n * - **The filter bar**: a search, a group select and an all / to do / done toggle,\n * stacked below `lg` and side by side from it. Each count is what the OTHER filters\n * leave: the toggle counts the search and the group, each group the search and the\n * state. Under it, while a filter is on, \"Showing X of Y tours\".\n * - **The sections**: one per group, in the app's order — an h2 with its icon and help\n * line over a `sm:2 / xl:3` grid of `TourCatalogueCard`s. A group the filters empty is\n * hidden; when nothing is left, an empty state with \"Clear filters\".\n * - **Controlled**: `query`, `group`, `state` and `onChange`, so the app keeps them in its\n * URL (`useTourCatalogueUrlState`).\n *\n * A tour reset by an update (§8) is \"to do\" for the toggle and the counts.\n */\nexport function TourCatalogue({\n tours,\n groups,\n completed,\n joined,\n query,\n group: groupProp,\n state: stateProp,\n onChange,\n onLaunch,\n onReset,\n header,\n formatDate,\n locale: localeProp,\n labels: labelsProp,\n className,\n}: TourCatalogueProps) {\n const words = useKitLabels(\"tourCatalogue\", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);\n const uid = useId();\n const locale = useKitLocale(localeProp);\n // A group or state the list does not have is no filter at all (an old link).\n const group = groups.some((g) => g.id === groupProp) ? groupProp : null;\n const state = STATE_FILTERS.includes(stateProp) ? stateProp : \"all\";\n\n const listed = tours.filter((t) => groups.some((g) => g.id === t.group));\n const done = (tour: TourInfo) => isTourDone(tour, completed[tour.id]);\n // Every word somewhere in the title, the one-liner or the about — \"budget visits\" finds\n // \"Your budget\" whose about says \"Visits the budget page\" — as every kit list searches\n // since 0.51.4 (refactor audit 2026-10-30, DUP-4: the whole query had to be one\n // substring of one text).\n // `|| undefined`: an app's JSON says `null` where TourInfo says absent, and\n // `matchesSearch` leaves out only `undefined` — `\"about\": null` threw on the first letter\n // typed, as the old matcher's `!!text` never did (0.52 code review, tour-catalogue.tsx).\n const queryWords = searchWords(query, locale);\n const matchesQuery = (tour: TourInfo) =>\n matchesSearch(\n queryWords,\n [tour.title || undefined, tour.description || undefined, tour.about || undefined],\n locale,\n );\n const inState = (tour: TourInfo) => (state === \"all\" ? true : state === \"done\" ? done(tour) : !done(tour));\n const inGroup = (tour: TourInfo, id: string | null = group) => id === null || tour.group === id;\n\n const found = listed.filter((t) => matchesQuery(t) && inGroup(t));\n const foundDone = found.filter(done).length;\n const visible = found.filter(inState);\n const groupCount = (id: string | null) =>\n listed.filter((t) => matchesQuery(t) && inState(t) && inGroup(t, id)).length;\n const filtering = queryWords.length > 0 || state !== \"all\" || group !== null;\n const sections = groups\n .map((g) => ({ ...g, tours: visible.filter((t) => t.group === g.id) }))\n .filter((g) => g.tours.length > 0);\n\n return (\n <div className={cn(\"space-y-4\", className)}>\n {header}\n <div className=\"flex flex-col gap-2 lg:flex-row lg:items-center\">\n <SearchField\n className=\"lg:max-w-xs lg:flex-1\"\n value={query}\n onChange={(value) => onChange({ query: value })}\n aria-label={words.searchLabel}\n placeholder={words.searchPlaceholder}\n />\n {/* A select, not a chip per group: on a phone those wrap into rows above the list\n they filter, and an app may have ten groups (ka F8). Named, not labelled: a\n floating label makes it taller than the search box beside it (dev #589). */}\n <Select\n className=\"lg:w-60\"\n aria-label={words.groupLabel}\n value={group ?? \"\"}\n onChange={(event) => onChange({ group: event.target.value || null })}\n >\n <option value=\"\">{words.groupAll(groupCount(null))}</option>\n {groups.map((g) => (\n <option key={g.id} value={g.id}>\n {words.groupOption(g.title, groupCount(g.id))}\n </option>\n ))}\n </Select>\n <ToggleGroup\n value={state}\n onChange={(value: TourStateFilter) => onChange({ state: value })}\n aria-label={words.stateLabel}\n className=\"lg:w-auto\"\n options={[\n { value: \"all\", label: words.stateAll(found.length) },\n { value: \"todo\", label: words.stateTodo(found.length - foundDone) },\n { value: \"done\", label: words.stateDone(foundDone) },\n ]}\n />\n </div>\n {filtering && (\n <p role=\"status\" className=\"-mt-2 text-xs text-muted\">\n {words.showing(visible.length, listed.length)}\n </p>\n )}\n\n {visible.length === 0 ? (\n <div className=\"space-y-3\">\n <EmptyState title={words.noMatches} hint={words.noMatchesHint} />\n <div className=\"flex justify-center\">\n <Button variant=\"secondary\" onClick={() => onChange({ query: \"\", group: null, state: \"all\" })}>\n {words.clearFilters}\n </Button>\n </div>\n </div>\n ) : (\n // A GRID, never columns: a section holding one card keeps a cell's width.\n sections.map((section) => (\n <section key={section.id} aria-labelledby={`${uid}-${section.id}`} className=\"space-y-3\">\n <h2 id={`${uid}-${section.id}`} className=\"flex items-center gap-1.5 text-sm font-semibold text-muted\">\n {section.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 [&_svg]:size-4\">\n {section.icon}\n </span>\n )}\n {section.title}\n </h2>\n {section.help && <p className=\"-mt-2 text-xs text-muted\">{section.help}</p>}\n <div className=\"grid gap-3 sm:grid-cols-2 xl:grid-cols-3\">\n {section.tours.map((tour) => (\n <TourCatalogueCard\n key={tour.id}\n tour={tour}\n completedAt={completed[tour.id]}\n isNew={isTourNew({ since: tour.since, joined, completed: completed[tour.id] !== undefined })}\n onLaunch={onLaunch}\n onReset={onReset}\n formatDate={formatDate}\n labels={labelsProp}\n />\n ))}\n </div>\n </section>\n ))\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -11,8 +11,6 @@ export interface Band {
11
11
  top: number;
12
12
  bottom: number;
13
13
  }
14
- /** Below this the docked layout applies (Tailwind's `sm`). */
15
- export declare const DOCK_MAX_WIDTH = 640;
16
14
  /**
17
15
  * The usable band now: the visual viewport while a keyboard is up (`readKeyboardInset`'s
18
16
  * rules — pinch zoom is not a keyboard, 100 px minimum), else the layout viewport; its
@@ -1,6 +1,5 @@
1
1
  "use client";
2
2
  import { readKeyboardInset } from "../hooks/use-anchored-panel.js";
3
- const DOCK_MAX_WIDTH = 640;
4
3
  const GUTTER = 8;
5
4
  const FULL_SCREEN_SLACK = 64;
6
5
  const MIN_DOCK = 140;
@@ -74,7 +73,6 @@ function scrollIntoFreeHalf(el, card, previous) {
74
73
  return edge;
75
74
  }
76
75
  export {
77
- DOCK_MAX_WIDTH,
78
76
  dockEdge,
79
77
  dockStyle,
80
78
  readBand,
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tour/tour-dock.ts"],"sourcesContent":["import type { CSSProperties } from \"react\";\nimport { readKeyboardInset } from \"../hooks/use-anchored-panel\";\n\n/*\n * # The docked card (docs/tours-harmonization.md §11.7, contract round 054 §6)\n *\n * On a phone a field step's card is pinned full-width to the top or bottom edge of the\n * USABLE BAND — the visual viewport less the keyboard, raised above the highest\n * `[data-tour-obstruction]` (the number pad, a picker sheet) — on the edge away from the\n * spotlighted field, so it covers neither the field nor the keyboard nor the pad.\n */\n\nexport type DockEdge = \"top\" | \"bottom\";\n\nexport interface DockRect {\n top: number;\n left: number;\n width: number;\n height: number;\n}\n\n/** The band, in layout-viewport px (what `position: fixed` measures from). */\nexport interface Band {\n top: number;\n bottom: number;\n}\n\n/** Below this the docked layout applies (Tailwind's `sm`). */\nexport const DOCK_MAX_WIDTH = 640;\n/** The gutter between the card and the band's edge, and between the card and the field. */\nconst GUTTER = 8;\n/** An obstruction whose top is within this of the band's top covers the screen. */\nconst FULL_SCREEN_SLACK = 64;\n/** Under this much free space on its edge the card flips (the hysteresis floor). */\nconst MIN_DOCK = 140;\n\n/**\n * The usable band now: the visual viewport while a keyboard is up (`readKeyboardInset`'s\n * rules — pinch zoom is not a keyboard, 100 px minimum), else the layout viewport; its\n * bottom raised to the top of the highest visible obstruction that holds neither the field\n * nor the card. An obstruction that starts at the band's top (a full-screen picker, opened\n * over everything) is not a bottom layer and is ignored; a tall one (the number pad at\n * Large text is over half a phone) still counts.\n */\nexport function readBand(target: Element | null, card: Element | null): Band {\n const vh = window.innerHeight;\n let top = 0;\n let bottom = vh;\n const vv = window.visualViewport;\n if (vv && readKeyboardInset() > 0) {\n top = vv.offsetTop;\n bottom = vv.offsetTop + vv.height;\n }\n for (const el of Array.from(document.querySelectorAll(\"[data-tour-obstruction]\"))) {\n if ((target && el.contains(target)) || (card && el.contains(card))) continue;\n const r = el.getBoundingClientRect();\n if (r.width <= 0 || r.height <= 0 || r.top <= top + FULL_SCREEN_SLACK) continue;\n if (r.top < bottom) bottom = r.top;\n }\n return { top, bottom };\n}\n\n/** The card's free space on `edge`, between the band's edge and the spotlight. */\nfunction room(edge: DockEdge, spot: DockRect, band: Band): number {\n return edge === \"bottom\" ? band.bottom - (spot.top + spot.height) - GUTTER * 2 : spot.top - band.top - GUTTER * 2;\n}\n\n/**\n * The edge away from the field: the bottom when the spotlight's centre is in the band's\n * upper half, else the top. With a `previous` edge, it stays unless the card would have to\n * cover the spotlight there (no flip on every keyboard animation frame).\n */\nexport function dockEdge(spot: DockRect, band: Band, previous: DockEdge | null, cardHeight: number): DockEdge {\n const preferred: DockEdge = spot.top + spot.height / 2 < band.top + (band.bottom - band.top) / 2 ? \"bottom\" : \"top\";\n if (!previous || previous === preferred) return preferred;\n return room(previous, spot, band) >= Math.min(MIN_DOCK, cardHeight) ? previous : preferred;\n}\n\n/** The docked card's position: full-width with 8 px (safe-area aware) gutters, its height\n * capped to the free space so its body scrolls and its title and buttons show. */\nexport function dockStyle(edge: DockEdge, spot: DockRect | null, band: Band): CSSProperties {\n const vh = window.innerHeight;\n const cap = spot ? Math.max(MIN_DOCK / 2, room(edge, spot, band)) : band.bottom - band.top - GUTTER * 2;\n const base: CSSProperties = {\n left: `max(${GUTTER}px, env(safe-area-inset-left))`,\n right: `max(${GUTTER}px, env(safe-area-inset-right))`,\n width: \"auto\",\n maxWidth: \"none\",\n maxHeight: cap,\n };\n if (edge === \"top\") {\n return {\n ...base,\n top: band.top <= 0 ? `max(${GUTTER}px, env(safe-area-inset-top))` : band.top + GUTTER,\n bottom: \"auto\",\n };\n }\n const fromBottom = vh - band.bottom;\n return {\n ...base,\n top: \"auto\",\n bottom: fromBottom <= 0 ? `max(${GUTTER}px, env(safe-area-inset-bottom))` : fromBottom + GUTTER,\n };\n}\n\n/** The target's nearest ancestor that scrolls vertically now (a sheet's body), or null. */\nexport function scrollParent(el: Element): HTMLElement | null {\n for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {\n const { overflowY } = getComputedStyle(node);\n if ((overflowY === \"auto\" || overflowY === \"scroll\") && node.scrollHeight > node.clientHeight) return node;\n }\n return null;\n}\n\n/**\n * Scroll the field into the half of the band away from the card, through its nearest\n * scrollable ancestor (the window only when there is none — the page behind a sheet never\n * moves). A field already inside that half is left where it is. Returns the edge the card\n * takes.\n */\nexport function scrollIntoFreeHalf(el: Element, card: Element | null, previous: DockEdge | null): DockEdge {\n const r = el.getBoundingClientRect();\n const band = readBand(el, card);\n const edge = dockEdge(r, band, previous, card instanceof HTMLElement ? card.offsetHeight : MIN_DOCK);\n const mid = band.top + (band.bottom - band.top) / 2;\n const half = edge === \"bottom\" ? { top: band.top + GUTTER, bottom: mid } : { top: mid, bottom: band.bottom - GUTTER };\n if (r.top >= half.top && r.top + r.height <= half.bottom) return edge;\n // Centred in the half, or its top at the half's top when it is taller than the half.\n const delta =\n r.height > half.bottom - half.top ? r.top - half.top : r.top + r.height / 2 - (half.top + half.bottom) / 2;\n const scroller = scrollParent(el);\n if (scroller) scroller.scrollTop += delta;\n // A field in a kit layer with nothing to scroll: the page behind it stays put.\n else if (!el.closest(\"[data-kit-layer]\")) window.scrollBy?.(0, delta);\n return edge;\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/tour/tour-dock.ts"],"sourcesContent":["import type { CSSProperties } from \"react\";\nimport { readKeyboardInset } from \"../hooks/use-anchored-panel\";\n\n/*\n * # The docked card (docs/tours-harmonization.md §11.7, contract round 054 §6)\n *\n * On a phone a field step's card is pinned full-width to the top or bottom edge of the\n * USABLE BAND — the visual viewport less the keyboard, raised above the highest\n * `[data-tour-obstruction]` (the number pad, a picker sheet) — on the edge away from the\n * spotlighted field, so it covers neither the field nor the keyboard nor the pad.\n */\n\nexport type DockEdge = \"top\" | \"bottom\";\n\nexport interface DockRect {\n top: number;\n left: number;\n width: number;\n height: number;\n}\n\n/** The band, in layout-viewport px (what `position: fixed` measures from). */\nexport interface Band {\n top: number;\n bottom: number;\n}\n\n/** The gutter between the card and the band's edge, and between the card and the field. */\nconst GUTTER = 8;\n/** An obstruction whose top is within this of the band's top covers the screen. */\nconst FULL_SCREEN_SLACK = 64;\n/** Under this much free space on its edge the card flips (the hysteresis floor). */\nconst MIN_DOCK = 140;\n\n/**\n * The usable band now: the visual viewport while a keyboard is up (`readKeyboardInset`'s\n * rules — pinch zoom is not a keyboard, 100 px minimum), else the layout viewport; its\n * bottom raised to the top of the highest visible obstruction that holds neither the field\n * nor the card. An obstruction that starts at the band's top (a full-screen picker, opened\n * over everything) is not a bottom layer and is ignored; a tall one (the number pad at\n * Large text is over half a phone) still counts.\n */\nexport function readBand(target: Element | null, card: Element | null): Band {\n const vh = window.innerHeight;\n let top = 0;\n let bottom = vh;\n const vv = window.visualViewport;\n if (vv && readKeyboardInset() > 0) {\n top = vv.offsetTop;\n bottom = vv.offsetTop + vv.height;\n }\n for (const el of Array.from(document.querySelectorAll(\"[data-tour-obstruction]\"))) {\n if ((target && el.contains(target)) || (card && el.contains(card))) continue;\n const r = el.getBoundingClientRect();\n if (r.width <= 0 || r.height <= 0 || r.top <= top + FULL_SCREEN_SLACK) continue;\n if (r.top < bottom) bottom = r.top;\n }\n return { top, bottom };\n}\n\n/** The card's free space on `edge`, between the band's edge and the spotlight. */\nfunction room(edge: DockEdge, spot: DockRect, band: Band): number {\n return edge === \"bottom\" ? band.bottom - (spot.top + spot.height) - GUTTER * 2 : spot.top - band.top - GUTTER * 2;\n}\n\n/**\n * The edge away from the field: the bottom when the spotlight's centre is in the band's\n * upper half, else the top. With a `previous` edge, it stays unless the card would have to\n * cover the spotlight there (no flip on every keyboard animation frame).\n */\nexport function dockEdge(spot: DockRect, band: Band, previous: DockEdge | null, cardHeight: number): DockEdge {\n const preferred: DockEdge = spot.top + spot.height / 2 < band.top + (band.bottom - band.top) / 2 ? \"bottom\" : \"top\";\n if (!previous || previous === preferred) return preferred;\n return room(previous, spot, band) >= Math.min(MIN_DOCK, cardHeight) ? previous : preferred;\n}\n\n/** The docked card's position: full-width with 8 px (safe-area aware) gutters, its height\n * capped to the free space so its body scrolls and its title and buttons show. */\nexport function dockStyle(edge: DockEdge, spot: DockRect | null, band: Band): CSSProperties {\n const vh = window.innerHeight;\n const cap = spot ? Math.max(MIN_DOCK / 2, room(edge, spot, band)) : band.bottom - band.top - GUTTER * 2;\n const base: CSSProperties = {\n left: `max(${GUTTER}px, env(safe-area-inset-left))`,\n right: `max(${GUTTER}px, env(safe-area-inset-right))`,\n width: \"auto\",\n maxWidth: \"none\",\n maxHeight: cap,\n };\n if (edge === \"top\") {\n return {\n ...base,\n top: band.top <= 0 ? `max(${GUTTER}px, env(safe-area-inset-top))` : band.top + GUTTER,\n bottom: \"auto\",\n };\n }\n const fromBottom = vh - band.bottom;\n return {\n ...base,\n top: \"auto\",\n bottom: fromBottom <= 0 ? `max(${GUTTER}px, env(safe-area-inset-bottom))` : fromBottom + GUTTER,\n };\n}\n\n/** The target's nearest ancestor that scrolls vertically now (a sheet's body), or null. */\nexport function scrollParent(el: Element): HTMLElement | null {\n for (let node = el.parentElement; node && node !== document.body; node = node.parentElement) {\n const { overflowY } = getComputedStyle(node);\n if ((overflowY === \"auto\" || overflowY === \"scroll\") && node.scrollHeight > node.clientHeight) return node;\n }\n return null;\n}\n\n/**\n * Scroll the field into the half of the band away from the card, through its nearest\n * scrollable ancestor (the window only when there is none — the page behind a sheet never\n * moves). A field already inside that half is left where it is. Returns the edge the card\n * takes.\n */\nexport function scrollIntoFreeHalf(el: Element, card: Element | null, previous: DockEdge | null): DockEdge {\n const r = el.getBoundingClientRect();\n const band = readBand(el, card);\n const edge = dockEdge(r, band, previous, card instanceof HTMLElement ? card.offsetHeight : MIN_DOCK);\n const mid = band.top + (band.bottom - band.top) / 2;\n const half = edge === \"bottom\" ? { top: band.top + GUTTER, bottom: mid } : { top: mid, bottom: band.bottom - GUTTER };\n if (r.top >= half.top && r.top + r.height <= half.bottom) return edge;\n // Centred in the half, or its top at the half's top when it is taller than the half.\n const delta =\n r.height > half.bottom - half.top ? r.top - half.top : r.top + r.height / 2 - (half.top + half.bottom) / 2;\n const scroller = scrollParent(el);\n if (scroller) scroller.scrollTop += delta;\n // A field in a kit layer with nothing to scroll: the page behind it stays put.\n else if (!el.closest(\"[data-kit-layer]\")) window.scrollBy?.(0, delta);\n return edge;\n}\n"],"mappings":"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","names":[]}
@@ -1,3 +1,4 @@
1
+ import type { TourStep, TourStepContext } from "./tour.js";
1
2
  /** One publisher of a key. */
2
3
  interface GateEntry {
3
4
  satisfied: boolean;
@@ -31,6 +32,9 @@ export interface GateRegistry {
31
32
  /** The kit's dev switch (as number-input's): a development build, never under vitest. */
32
33
  export declare function devWarnings(): boolean;
33
34
  export declare function createGateRegistry(): GateRegistry;
35
+ export declare function stepContext(gates: GateRegistry, via: TourStepContext["via"]): TourStepContext;
36
+ /** A step's `when`, asked now; a throw is a skip, and the error goes to the console. */
37
+ export declare function isShown(step: TourStep, ctx: TourStepContext): boolean;
34
38
  /** What the provider shares with the gate hooks and `TourGuideButton` — internal. */
35
39
  export interface TourInternals {
36
40
  gates: GateRegistry;
@@ -68,6 +68,18 @@ function createGateRegistry() {
68
68
  }
69
69
  };
70
70
  }
71
+ function stepContext(gates, via) {
72
+ return { gate: (key) => gates.get(key), via };
73
+ }
74
+ function isShown(step, ctx) {
75
+ if (!step.when) return true;
76
+ try {
77
+ return step.when(ctx) === true;
78
+ } catch (error) {
79
+ console.error(error);
80
+ return false;
81
+ }
82
+ }
71
83
  const TourInternalContext = createContext(null);
72
84
  function useTourGate(key, satisfied) {
73
85
  const gates = useContext(TourInternalContext)?.gates ?? null;
@@ -108,6 +120,8 @@ export {
108
120
  TourInternalContext,
109
121
  createGateRegistry,
110
122
  devWarnings,
123
+ isShown,
124
+ stepContext,
111
125
  useTourGate,
112
126
  useTourGateRelease
113
127
  };
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tour/tour-gates.ts"],"sourcesContent":["import { createContext, useContext, useLayoutEffect, useMemo, useRef } from \"react\";\n\n/*\n * # Value gates (docs/tours-harmonization.md §11.1)\n *\n * A form publishes whether a field holds what a tour waits for (`useTourGate`), a step\n * names the key (`TourStep.gate`). The form knows nothing about steps, the step nothing\n * about the form's state: the registry between them is a `Map` held by the provider.\n *\n * - Each key holds a STACK of publishers in mount order; the most recently mounted one\n * is current. When it unmounts, the one below it is current again. StrictMode's\n * double mount is push, pop, push.\n * - Writes happen in a layout effect, only when `key` or `satisfied` changes, and never\n * re-render the provider: only the overlay subscribes (`useSyncExternalStore`), and it\n * is mounted only while a tour runs.\n * - The same object carries the release bus of `awaitGate` steps (§11.4): `release` and\n * `refuse` are EVENTS, not states, and are not buffered.\n */\n\n/** One publisher of a key. */\ninterface GateEntry {\n satisfied: boolean;\n}\n\n/** `release(key)` / `refuse(key, reason)` from the app, as the provider hears them. */\nexport type GateSignal = { kind: \"release\"; key: string } | { kind: \"refuse\"; key: string; reason?: string };\n\nexport interface GateRegistry {\n /** The current publisher's value; `undefined` while nobody publishes `key`. */\n get(key: string): boolean | undefined;\n /** Is anyone publishing `key`? */\n has(key: string): boolean;\n /** For `useSyncExternalStore`: bumped on every change. */\n subscribe(listener: () => void): () => void;\n version(): number;\n push(key: string, entry: GateEntry): void;\n pop(key: string, entry: GateEntry): void;\n changed(key: string): void;\n /** Whether a tour runs (for the two-publishers warning). */\n setActive(active: boolean): void;\n /** The release bus. */\n listen(listener: (signal: GateSignal) => void): () => void;\n emit(signal: GateSignal): void;\n}\n\n/** The kit's dev switch (as number-input's): a development build, never under vitest. */\nexport function devWarnings(): boolean {\n return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;\n}\n\nexport function createGateRegistry(): GateRegistry {\n let active = false;\n const stacks = new Map<string, GateEntry[]>();\n const listeners = new Set<() => void>();\n const signals = new Set<(signal: GateSignal) => void>();\n const warned = new Set<string>();\n let version = 0;\n const notify = (key: string) => {\n version++;\n const stack = stacks.get(key);\n // Two publishers of one key that disagree while a tour runs: the most recent wins,\n // which is right, but an app keying fields `amount` in two forms wants to hear it.\n if (stack && stack.length > 1 && active && devWarnings() && !warned.has(key)) {\n const values = new Set(stack.map((e) => e.satisfied));\n if (values.size > 1) {\n warned.add(key);\n console.warn(\n `[ui-kit] Two components publish the tour gate \"${key}\" with different values; the one mounted last ` +\n \"wins. Qualify gate keys by form (`txn.amount`, `account.name`).\",\n );\n }\n }\n for (const listener of listeners) listener();\n };\n return {\n get(key) {\n const stack = stacks.get(key);\n return stack && stack.length > 0 ? stack[stack.length - 1].satisfied : undefined;\n },\n has(key) {\n return (stacks.get(key)?.length ?? 0) > 0;\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n version: () => version,\n push(key, entry) {\n const stack = stacks.get(key) ?? [];\n stack.push(entry);\n stacks.set(key, stack);\n notify(key);\n },\n pop(key, entry) {\n const stack = stacks.get(key);\n if (!stack) return;\n const at = stack.indexOf(entry);\n if (at >= 0) stack.splice(at, 1);\n if (stack.length === 0) stacks.delete(key);\n notify(key);\n },\n changed: notify,\n setActive(value) {\n active = value;\n },\n listen(listener) {\n signals.add(listener);\n return () => {\n signals.delete(listener);\n };\n },\n emit(signal) {\n for (const listener of signals) listener(signal);\n },\n };\n}\n\n/** What the provider shares with the gate hooks and `TourGuideButton` — internal. */\nexport interface TourInternals {\n gates: GateRegistry;\n /** Did the provider get a `launch`? */\n canLaunch: boolean;\n}\n\nexport const TourInternalContext = createContext<TourInternals | null>(null);\n\n/**\n * Publish whether this field holds what a tour waits for (§11.1): `useTourGate(\"txn.amount\",\n * amount !== \"\")` beside the field. A step with `gate: \"txn.amount\"` keeps its Next locked\n * until it is `true`. A key no step names as `gate` is a FACT a step's `when` can ask\n * (`ctx.gate(\"txn.transfer\")`).\n *\n * - `key` `null`/`undefined` publishes nothing: one field component used by a create and\n * an edit form takes part only in create (`mode === \"create\" ? \"txn.memo\" : null`).\n * - Publish the value's state. Do not invent a \"touched\" gate: a field already filled\n * when the step arrives (a date prefilled with today) shows Next open at once, which is\n * intended.\n * - Qualify keys by form (`txn.amount`, not `amount`). Two publishers of one key: the one\n * mounted last wins.\n * - A no-op without a `TourProvider`, so a form's tests need none. It keeps publishing\n * while no tour runs, because \"Guide me\" can start inside a form already filled.\n */\nexport function useTourGate(key: string | null | undefined, satisfied: boolean): void {\n const gates = useContext(TourInternalContext)?.gates ?? null;\n const entryRef = useRef<GateEntry | null>(null);\n const satisfiedRef = useRef(satisfied);\n useLayoutEffect(() => {\n satisfiedRef.current = satisfied;\n const entry = entryRef.current;\n if (!gates || key == null || !entry || entry.satisfied === satisfied) return;\n entry.satisfied = satisfied;\n gates.changed(key);\n }, [gates, key, satisfied]);\n useLayoutEffect(() => {\n if (!gates || key == null) return;\n const entry: GateEntry = { satisfied: satisfiedRef.current };\n entryRef.current = entry;\n gates.push(key, entry);\n return () => {\n entryRef.current = null;\n gates.pop(key, entry);\n };\n }, [gates, key]);\n}\n\n/** What {@link useTourGateRelease} hands back. */\nexport interface TourGateRelease {\n /** The awaited thing happened (a save succeeded): the tour moves past the step that awaits `key`. */\n release(key: string): void;\n /** It was refused (a save failed): the step awaiting `key` stays and shows `reason`. */\n refuse(key: string, reason?: string): void;\n}\n\nconst NO_RELEASE: TourGateRelease = { release: () => {}, refuse: () => {} };\n\n/**\n * The signals of `awaitGate` steps (§11.4), usually from a mutation's `onSuccess` /\n * `onError`. Stable identities. No-ops without a provider, and while no tour runs or no\n * step of the run awaits the key: a release is an event and is never buffered.\n */\nexport function useTourGateRelease(): TourGateRelease {\n const gates = useContext(TourInternalContext)?.gates ?? null;\n return useMemo(\n () =>\n gates\n ? {\n release: (key: string) => gates.emit({ kind: \"release\", key }),\n refuse: (key: string, reason?: string) => gates.emit({ kind: \"refuse\", key, reason }),\n }\n : NO_RELEASE,\n [gates],\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/tour/tour-gates.ts"],"sourcesContent":["import { createContext, useContext, useLayoutEffect, useMemo, useRef } from \"react\";\nimport type { TourStep, TourStepContext } from \"./tour\";\n\n/*\n * # Value gates (docs/tours-harmonization.md §11.1)\n *\n * A form publishes whether a field holds what a tour waits for (`useTourGate`), a step\n * names the key (`TourStep.gate`). The form knows nothing about steps, the step nothing\n * about the form's state: the registry between them is a `Map` held by the provider.\n *\n * - Each key holds a STACK of publishers in mount order; the most recently mounted one\n * is current. When it unmounts, the one below it is current again. StrictMode's\n * double mount is push, pop, push.\n * - Writes happen in a layout effect, only when `key` or `satisfied` changes, and never\n * re-render the provider: only the overlay subscribes (`useSyncExternalStore`), and it\n * is mounted only while a tour runs.\n * - The same object carries the release bus of `awaitGate` steps (§11.4): `release` and\n * `refuse` are EVENTS, not states, and are not buffered.\n */\n\n/** One publisher of a key. */\ninterface GateEntry {\n satisfied: boolean;\n}\n\n/** `release(key)` / `refuse(key, reason)` from the app, as the provider hears them. */\nexport type GateSignal = { kind: \"release\"; key: string } | { kind: \"refuse\"; key: string; reason?: string };\n\nexport interface GateRegistry {\n /** The current publisher's value; `undefined` while nobody publishes `key`. */\n get(key: string): boolean | undefined;\n /** Is anyone publishing `key`? */\n has(key: string): boolean;\n /** For `useSyncExternalStore`: bumped on every change. */\n subscribe(listener: () => void): () => void;\n version(): number;\n push(key: string, entry: GateEntry): void;\n pop(key: string, entry: GateEntry): void;\n changed(key: string): void;\n /** Whether a tour runs (for the two-publishers warning). */\n setActive(active: boolean): void;\n /** The release bus. */\n listen(listener: (signal: GateSignal) => void): () => void;\n emit(signal: GateSignal): void;\n}\n\n/** The kit's dev switch (as number-input's): a development build, never under vitest. */\nexport function devWarnings(): boolean {\n return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;\n}\n\nexport function createGateRegistry(): GateRegistry {\n let active = false;\n const stacks = new Map<string, GateEntry[]>();\n const listeners = new Set<() => void>();\n const signals = new Set<(signal: GateSignal) => void>();\n const warned = new Set<string>();\n let version = 0;\n const notify = (key: string) => {\n version++;\n const stack = stacks.get(key);\n // Two publishers of one key that disagree while a tour runs: the most recent wins,\n // which is right, but an app keying fields `amount` in two forms wants to hear it.\n if (stack && stack.length > 1 && active && devWarnings() && !warned.has(key)) {\n const values = new Set(stack.map((e) => e.satisfied));\n if (values.size > 1) {\n warned.add(key);\n console.warn(\n `[ui-kit] Two components publish the tour gate \"${key}\" with different values; the one mounted last ` +\n \"wins. Qualify gate keys by form (`txn.amount`, `account.name`).\",\n );\n }\n }\n for (const listener of listeners) listener();\n };\n return {\n get(key) {\n const stack = stacks.get(key);\n return stack && stack.length > 0 ? stack[stack.length - 1].satisfied : undefined;\n },\n has(key) {\n return (stacks.get(key)?.length ?? 0) > 0;\n },\n subscribe(listener) {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n version: () => version,\n push(key, entry) {\n const stack = stacks.get(key) ?? [];\n stack.push(entry);\n stacks.set(key, stack);\n notify(key);\n },\n pop(key, entry) {\n const stack = stacks.get(key);\n if (!stack) return;\n const at = stack.indexOf(entry);\n if (at >= 0) stack.splice(at, 1);\n if (stack.length === 0) stacks.delete(key);\n notify(key);\n },\n changed: notify,\n setActive(value) {\n active = value;\n },\n listen(listener) {\n signals.add(listener);\n return () => {\n signals.delete(listener);\n };\n },\n emit(signal) {\n for (const listener of signals) listener(signal);\n },\n };\n}\n\n/*\n * The step context and `when` (§11.3), asked by the provider on each arrival and by the\n * overlay (a `beforeStep`, the \"Back to …\" link). Here rather than in tour.tsx, whose\n * names reach the barrel through `export *`, and rather than in the overlay, which loads\n * only once a tour starts (refactor audit 2026-10-30, MOD-1).\n */\n\nexport function stepContext(gates: GateRegistry, via: TourStepContext[\"via\"]): TourStepContext {\n return { gate: (key) => gates.get(key), via };\n}\n\n/** A step's `when`, asked now; a throw is a skip, and the error goes to the console. */\nexport function isShown(step: TourStep, ctx: TourStepContext): boolean {\n if (!step.when) return true;\n try {\n return step.when(ctx) === true;\n } catch (error) {\n console.error(error);\n return false;\n }\n}\n\n/** What the provider shares with the gate hooks and `TourGuideButton` — internal. */\nexport interface TourInternals {\n gates: GateRegistry;\n /** Did the provider get a `launch`? */\n canLaunch: boolean;\n}\n\nexport const TourInternalContext = createContext<TourInternals | null>(null);\n\n/**\n * Publish whether this field holds what a tour waits for (§11.1): `useTourGate(\"txn.amount\",\n * amount !== \"\")` beside the field. A step with `gate: \"txn.amount\"` keeps its Next locked\n * until it is `true`. A key no step names as `gate` is a FACT a step's `when` can ask\n * (`ctx.gate(\"txn.transfer\")`).\n *\n * - `key` `null`/`undefined` publishes nothing: one field component used by a create and\n * an edit form takes part only in create (`mode === \"create\" ? \"txn.memo\" : null`).\n * - Publish the value's state. Do not invent a \"touched\" gate: a field already filled\n * when the step arrives (a date prefilled with today) shows Next open at once, which is\n * intended.\n * - Qualify keys by form (`txn.amount`, not `amount`). Two publishers of one key: the one\n * mounted last wins.\n * - A no-op without a `TourProvider`, so a form's tests need none. It keeps publishing\n * while no tour runs, because \"Guide me\" can start inside a form already filled.\n */\nexport function useTourGate(key: string | null | undefined, satisfied: boolean): void {\n const gates = useContext(TourInternalContext)?.gates ?? null;\n const entryRef = useRef<GateEntry | null>(null);\n const satisfiedRef = useRef(satisfied);\n useLayoutEffect(() => {\n satisfiedRef.current = satisfied;\n const entry = entryRef.current;\n if (!gates || key == null || !entry || entry.satisfied === satisfied) return;\n entry.satisfied = satisfied;\n gates.changed(key);\n }, [gates, key, satisfied]);\n useLayoutEffect(() => {\n if (!gates || key == null) return;\n const entry: GateEntry = { satisfied: satisfiedRef.current };\n entryRef.current = entry;\n gates.push(key, entry);\n return () => {\n entryRef.current = null;\n gates.pop(key, entry);\n };\n }, [gates, key]);\n}\n\n/** What {@link useTourGateRelease} hands back. */\nexport interface TourGateRelease {\n /** The awaited thing happened (a save succeeded): the tour moves past the step that awaits `key`. */\n release(key: string): void;\n /** It was refused (a save failed): the step awaiting `key` stays and shows `reason`. */\n refuse(key: string, reason?: string): void;\n}\n\nconst NO_RELEASE: TourGateRelease = { release: () => {}, refuse: () => {} };\n\n/**\n * The signals of `awaitGate` steps (§11.4), usually from a mutation's `onSuccess` /\n * `onError`. Stable identities. No-ops without a provider, and while no tour runs or no\n * step of the run awaits the key: a release is an event and is never buffered.\n */\nexport function useTourGateRelease(): TourGateRelease {\n const gates = useContext(TourInternalContext)?.gates ?? null;\n return useMemo(\n () =>\n gates\n ? {\n release: (key: string) => gates.emit({ kind: \"release\", key }),\n refuse: (key: string, reason?: string) => gates.emit({ kind: \"refuse\", key, reason }),\n }\n : NO_RELEASE,\n [gates],\n );\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,27 @@
1
+ import type { TourLabels, TourStep, TourStepContext } from "./tour.js";
2
+ import { type GateRegistry } from "./tour-gates.js";
3
+ /** The app's `refuse` on the step that awaits its key (§11.4). */
4
+ export interface Refusal {
5
+ step: TourStep;
6
+ reason?: string;
7
+ /** Bumped per refusal, so the same words are announced again. */
8
+ seq: number;
9
+ }
10
+ export declare function TourOverlay({ steps, index, via, floor, position, count, refusal, onGoTo, labels, gates, dockOnPhones, onNext, onPrev, onSkip, }: {
11
+ steps: TourStep[];
12
+ index: number;
13
+ via: TourStepContext["via"];
14
+ floor: number;
15
+ /** The app refused this step's save (§11.4). */
16
+ refusal: Refusal | null;
17
+ onGoTo: (index: number) => void;
18
+ /** 1-based among the steps shown; Back is hidden on the first, Done on the last. */
19
+ position: number;
20
+ count: number;
21
+ labels: TourLabels;
22
+ gates: GateRegistry;
23
+ dockOnPhones: boolean;
24
+ onNext: () => void;
25
+ onPrev: () => void;
26
+ onSkip: () => void;
27
+ }): import("react").ReactPortal | null;