@eifi1/ui-kit 0.44.0 → 0.46.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 (285) hide show
  1. package/README.md +29 -14
  2. package/dist/admin/admin-action-log-labels.js +9 -0
  3. package/dist/admin/admin-action-log-labels.js.map +1 -1
  4. package/dist/admin/admin-action-log.d.ts +18 -1
  5. package/dist/admin/admin-action-log.js +25 -16
  6. package/dist/admin/admin-action-log.js.map +1 -1
  7. package/dist/admin/admin-area-labels.d.ts +15 -0
  8. package/dist/admin/admin-area-labels.js +6 -1
  9. package/dist/admin/admin-area-labels.js.map +1 -1
  10. package/dist/admin/admin-overview.d.ts +16 -1
  11. package/dist/admin/admin-overview.js +12 -1
  12. package/dist/admin/admin-overview.js.map +1 -1
  13. package/dist/admin/mail-test-card-labels.d.ts +3 -0
  14. package/dist/admin/mail-test-card-labels.js +17 -0
  15. package/dist/admin/mail-test-card-labels.js.map +1 -0
  16. package/dist/admin/mail-test-card.d.ts +75 -0
  17. package/dist/admin/mail-test-card.js +82 -0
  18. package/dist/admin/mail-test-card.js.map +1 -0
  19. package/dist/components/appearance-labels.d.ts +10 -0
  20. package/dist/components/appearance-labels.js +6 -1
  21. package/dist/components/appearance-labels.js.map +1 -1
  22. package/dist/components/data-table-column-settings.js +2 -2
  23. package/dist/components/data-table-column-settings.js.map +1 -1
  24. package/dist/components/data-table-head.js +2 -2
  25. package/dist/components/data-table-head.js.map +1 -1
  26. package/dist/components/data-table-mobile.js +17 -2
  27. package/dist/components/data-table-mobile.js.map +1 -1
  28. package/dist/components/data-table-row-actions.d.ts +18 -2
  29. package/dist/components/data-table-row-actions.js +86 -2
  30. package/dist/components/data-table-row-actions.js.map +1 -1
  31. package/dist/components/data-table-shared.d.ts +2 -0
  32. package/dist/components/data-table-shared.js +17 -2
  33. package/dist/components/data-table-shared.js.map +1 -1
  34. package/dist/components/data-table.d.ts +43 -2
  35. package/dist/components/data-table.js +10 -10
  36. package/dist/components/data-table.js.map +1 -1
  37. package/dist/components/date-format-setting.d.ts +29 -0
  38. package/dist/components/date-format-setting.js +36 -0
  39. package/dist/components/date-format-setting.js.map +1 -0
  40. package/dist/components/swipeable-row.d.ts +8 -0
  41. package/dist/components/swipeable-row.js.map +1 -1
  42. package/dist/components/tabs.d.ts +3 -2
  43. package/dist/components/tabs.js +1 -1
  44. package/dist/components/tabs.js.map +1 -1
  45. package/dist/components/translation-review-editor.js +1 -0
  46. package/dist/components/translation-review-editor.js.map +1 -1
  47. package/dist/components/translation-review-labels.d.ts +5 -0
  48. package/dist/components/translation-review-labels.js +2 -0
  49. package/dist/components/translation-review-labels.js.map +1 -1
  50. package/dist/components/translation-review.d.ts +18 -8
  51. package/dist/components/translation-review.js +64 -7
  52. package/dist/components/translation-review.js.map +1 -1
  53. package/dist/components/write-lock-labels.js +2 -1
  54. package/dist/components/write-lock-labels.js.map +1 -1
  55. package/dist/components/write-lock.d.ts +33 -3
  56. package/dist/components/write-lock.js +9 -0
  57. package/dist/components/write-lock.js.map +1 -1
  58. package/dist/data-table.d.ts +1 -1
  59. package/dist/data-table.js.map +1 -1
  60. package/dist/eslint/index.js +1 -1
  61. package/dist/feedback/feedback-row-detail.d.ts +4 -2
  62. package/dist/feedback/feedback-row-detail.js +17 -26
  63. package/dist/feedback/feedback-row-detail.js.map +1 -1
  64. package/dist/feedback/feedback-swipe.d.ts +18 -0
  65. package/dist/feedback/feedback-swipe.js +10 -1
  66. package/dist/feedback/feedback-swipe.js.map +1 -1
  67. package/dist/i18n/defaults.js +9 -1
  68. package/dist/i18n/defaults.js.map +1 -1
  69. package/dist/i18n/german.js +81 -5
  70. package/dist/i18n/german.js.map +1 -1
  71. package/dist/i18n/kit-labels.d.ts +25 -1
  72. package/dist/i18n/kit-labels.js +8 -2
  73. package/dist/i18n/kit-labels.js.map +1 -1
  74. package/dist/i18n/locales/en.js +97 -7
  75. package/dist/i18n/locales/en.js.map +3 -3
  76. package/dist/i18n/locales/es.js +81 -5
  77. package/dist/i18n/locales/es.js.map +1 -1
  78. package/dist/i18n/locales/fr.js +81 -5
  79. package/dist/i18n/locales/fr.js.map +1 -1
  80. package/dist/i18n/locales/hu.js +81 -5
  81. package/dist/i18n/locales/hu.js.map +1 -1
  82. package/dist/i18n/locales/it.js +81 -5
  83. package/dist/i18n/locales/it.js.map +1 -1
  84. package/dist/i18n/locales/zh.js +81 -5
  85. package/dist/i18n/locales/zh.js.map +1 -1
  86. package/dist/i18n/review.js +16 -2
  87. package/dist/i18n/review.js.map +1 -1
  88. package/dist/index.d.ts +17 -2
  89. package/dist/index.js +40 -0
  90. package/dist/index.js.map +1 -1
  91. package/dist/lib/date-format.d.ts +81 -0
  92. package/dist/lib/date-format.js +56 -0
  93. package/dist/lib/date-format.js.map +1 -0
  94. package/dist/lib/format.d.ts +5 -1
  95. package/dist/lib/format.js +11 -5
  96. package/dist/lib/format.js.map +1 -1
  97. package/dist/notifications/device-zone-report.d.ts +27 -0
  98. package/dist/notifications/device-zone-report.js +24 -0
  99. package/dist/notifications/device-zone-report.js.map +1 -0
  100. package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
  101. package/dist/notifications/feedback-notification-types-labels.js +4 -1
  102. package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
  103. package/dist/notifications/feedback-notification-types.d.ts +11 -5
  104. package/dist/notifications/feedback-notification-types.js +2 -1
  105. package/dist/notifications/feedback-notification-types.js.map +1 -1
  106. package/dist/notifications/notification-bell.d.ts +33 -0
  107. package/dist/notifications/notification-bell.js +74 -0
  108. package/dist/notifications/notification-bell.js.map +1 -0
  109. package/dist/notifications/notification-inbox.d.ts +77 -0
  110. package/dist/notifications/notification-inbox.js +206 -0
  111. package/dist/notifications/notification-inbox.js.map +1 -0
  112. package/dist/notifications/notification-types-setting.d.ts +6 -4
  113. package/dist/notifications/notification-types-setting.js +103 -45
  114. package/dist/notifications/notification-types-setting.js.map +1 -1
  115. package/dist/notifications/notification-types.d.ts +19 -7
  116. package/dist/notifications/notifications-labels.d.ts +41 -7
  117. package/dist/notifications/notifications-labels.js +18 -1
  118. package/dist/notifications/notifications-labels.js.map +1 -1
  119. package/dist/notifications/push-devices-setting.d.ts +20 -3
  120. package/dist/notifications/push-devices-setting.js +93 -33
  121. package/dist/notifications/push-devices-setting.js.map +1 -1
  122. package/dist/notifications/push-worker.d.ts +108 -0
  123. package/dist/notifications/push-worker.js +62 -0
  124. package/dist/notifications/push-worker.js.map +1 -0
  125. package/dist/notifications/quiet-hours-setting.d.ts +29 -17
  126. package/dist/notifications/quiet-hours-setting.js +9 -41
  127. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  128. package/dist/notifications/time-zone-select.d.ts +5 -2
  129. package/dist/notifications/time-zone-select.js.map +1 -1
  130. package/dist/notifications/use-push-device.d.ts +4 -17
  131. package/dist/notifications/use-push-device.js +7 -15
  132. package/dist/notifications/use-push-device.js.map +1 -1
  133. package/dist/offline/local-first.d.ts +33 -0
  134. package/dist/offline/local-first.js +39 -0
  135. package/dist/offline/local-first.js.map +1 -0
  136. package/dist/offline/pwa-update-prompt-labels.d.ts +2 -0
  137. package/dist/offline/pwa-update-prompt-labels.js +9 -0
  138. package/dist/offline/pwa-update-prompt-labels.js.map +1 -0
  139. package/dist/offline/pwa-update-prompt.d.ts +32 -0
  140. package/dist/offline/pwa-update-prompt.js +36 -0
  141. package/dist/offline/pwa-update-prompt.js.map +1 -0
  142. package/dist/offline/query-persistence.d.ts +125 -0
  143. package/dist/offline/query-persistence.js +183 -0
  144. package/dist/offline/query-persistence.js.map +1 -0
  145. package/dist/offline/service-worker-updates.d.ts +46 -0
  146. package/dist/offline/service-worker-updates.js +39 -0
  147. package/dist/offline/service-worker-updates.js.map +1 -0
  148. package/dist/offline/shared-computer-labels.d.ts +2 -0
  149. package/dist/offline/shared-computer-labels.js +9 -0
  150. package/dist/offline/shared-computer-labels.js.map +1 -0
  151. package/dist/offline/shared-computer.d.ts +44 -0
  152. package/dist/offline/shared-computer.js +51 -0
  153. package/dist/offline/shared-computer.js.map +1 -0
  154. package/dist/offline/sign-out-pending-dialog-labels.d.ts +2 -0
  155. package/dist/offline/sign-out-pending-dialog-labels.js +14 -0
  156. package/dist/offline/sign-out-pending-dialog-labels.js.map +1 -0
  157. package/dist/offline/sign-out-pending-dialog.d.ts +58 -0
  158. package/dist/offline/sign-out-pending-dialog.js +66 -0
  159. package/dist/offline/sign-out-pending-dialog.js.map +1 -0
  160. package/dist/offline.d.ts +11 -0
  161. package/dist/offline.js +33 -0
  162. package/dist/offline.js.map +1 -0
  163. package/dist/push-worker.d.ts +2 -0
  164. package/dist/push-worker.js +14 -0
  165. package/dist/push-worker.js.map +1 -0
  166. package/dist/shell/account-menu-text-size.d.ts +33 -0
  167. package/dist/shell/account-menu-text-size.js +51 -0
  168. package/dist/shell/account-menu-text-size.js.map +1 -0
  169. package/dist/shell/admin-menu.d.ts +24 -0
  170. package/dist/shell/admin-menu.js +12 -3
  171. package/dist/shell/admin-menu.js.map +1 -1
  172. package/dist/shell/top-bar-actions.d.ts +6 -2
  173. package/dist/shell/top-bar-actions.js +1 -0
  174. package/dist/shell/top-bar-actions.js.map +1 -1
  175. package/dist/shell.d.ts +4 -0
  176. package/dist/shell.js +4 -0
  177. package/dist/shell.js.map +1 -1
  178. package/dist/sync/db.d.ts +76 -0
  179. package/dist/sync/db.js +24 -0
  180. package/dist/sync/db.js.map +1 -0
  181. package/dist/sync/engine.d.ts +124 -0
  182. package/dist/sync/engine.js +474 -0
  183. package/dist/sync/engine.js.map +1 -0
  184. package/dist/sync/hooks.d.ts +30 -0
  185. package/dist/sync/hooks.js +67 -0
  186. package/dist/sync/hooks.js.map +1 -0
  187. package/dist/sync/testing.d.ts +28 -0
  188. package/dist/sync/testing.js +41 -0
  189. package/dist/sync/testing.js.map +1 -0
  190. package/dist/sync/wire.d.ts +50 -0
  191. package/dist/sync/wire.js +2 -0
  192. package/dist/sync/wire.js.map +1 -0
  193. package/dist/sync.d.ts +7 -0
  194. package/dist/sync.js +22 -0
  195. package/dist/sync.js.map +1 -0
  196. package/dist/tour/tour-catalogue.d.ts +7 -1
  197. package/dist/tour/tour-catalogue.js.map +1 -1
  198. package/dist/tour/tour-labels.js +1 -0
  199. package/dist/tour/tour-labels.js.map +1 -1
  200. package/dist/tour/tour.d.ts +30 -0
  201. package/dist/tour/tour.js +54 -8
  202. package/dist/tour/tour.js.map +1 -1
  203. package/dist/tour/use-tour-progress.d.ts +99 -1
  204. package/dist/tour/use-tour-progress.js +228 -12
  205. package/dist/tour/use-tour-progress.js.map +1 -1
  206. package/dist/tour.d.ts +2 -0
  207. package/dist/tour.js +2 -0
  208. package/dist/tour.js.map +1 -1
  209. package/package.json +43 -1
  210. package/src/admin/admin-action-log-labels.ts +9 -0
  211. package/src/admin/admin-action-log.tsx +41 -5
  212. package/src/admin/admin-area-labels.ts +20 -0
  213. package/src/admin/admin-overview.tsx +37 -2
  214. package/src/admin/mail-test-card-labels.ts +19 -0
  215. package/src/admin/mail-test-card.tsx +190 -0
  216. package/src/components/appearance-labels.ts +16 -0
  217. package/src/components/data-table-column-settings.tsx +2 -2
  218. package/src/components/data-table-head.tsx +2 -2
  219. package/src/components/data-table-mobile.tsx +26 -3
  220. package/src/components/data-table-row-actions.tsx +118 -2
  221. package/src/components/data-table-shared.tsx +39 -3
  222. package/src/components/data-table.tsx +52 -21
  223. package/src/components/date-format-setting.tsx +64 -0
  224. package/src/components/swipeable-row.tsx +8 -0
  225. package/src/components/tabs.tsx +4 -3
  226. package/src/components/translation-review-editor.tsx +2 -0
  227. package/src/components/translation-review-labels.ts +7 -0
  228. package/src/components/translation-review.tsx +102 -14
  229. package/src/components/write-lock-labels.ts +1 -0
  230. package/src/components/write-lock.tsx +36 -3
  231. package/src/data-table.ts +1 -0
  232. package/src/feedback/feedback-row-detail.tsx +18 -23
  233. package/src/feedback/feedback-swipe.tsx +28 -1
  234. package/src/i18n/defaults.ts +8 -0
  235. package/src/i18n/german.ts +90 -4
  236. package/src/i18n/kit-labels.tsx +31 -1
  237. package/src/i18n/locales/en.ts +13 -0
  238. package/src/i18n/locales/es.ts +90 -4
  239. package/src/i18n/locales/fr.ts +90 -4
  240. package/src/i18n/locales/hu.ts +86 -4
  241. package/src/i18n/locales/it.ts +90 -4
  242. package/src/i18n/locales/zh.ts +84 -2
  243. package/src/i18n/review.ts +15 -1
  244. package/src/index.ts +45 -0
  245. package/src/lib/date-format.ts +151 -0
  246. package/src/lib/format.ts +23 -6
  247. package/src/notifications/device-zone-report.ts +54 -0
  248. package/src/notifications/feedback-notification-types-labels.ts +10 -1
  249. package/src/notifications/feedback-notification-types.ts +12 -5
  250. package/src/notifications/notification-bell.tsx +116 -0
  251. package/src/notifications/notification-inbox.tsx +304 -0
  252. package/src/notifications/notification-types-setting.tsx +151 -55
  253. package/src/notifications/notification-types.ts +20 -8
  254. package/src/notifications/notifications-labels.ts +69 -11
  255. package/src/notifications/push-devices-setting.tsx +123 -34
  256. package/src/notifications/push-worker.ts +160 -0
  257. package/src/notifications/quiet-hours-setting.tsx +41 -58
  258. package/src/notifications/time-zone-select.tsx +5 -2
  259. package/src/notifications/use-push-device.ts +20 -33
  260. package/src/offline/local-first.ts +76 -0
  261. package/src/offline/pwa-update-prompt-labels.ts +9 -0
  262. package/src/offline/pwa-update-prompt.tsx +72 -0
  263. package/src/offline/query-persistence.ts +356 -0
  264. package/src/offline/service-worker-updates.ts +81 -0
  265. package/src/offline/shared-computer-labels.ts +8 -0
  266. package/src/offline/shared-computer.tsx +87 -0
  267. package/src/offline/sign-out-pending-dialog-labels.ts +21 -0
  268. package/src/offline/sign-out-pending-dialog.tsx +135 -0
  269. package/src/offline.ts +32 -0
  270. package/src/push-worker.ts +25 -0
  271. package/src/shell/account-menu-text-size.tsx +77 -0
  272. package/src/shell/admin-menu.tsx +40 -3
  273. package/src/shell/top-bar-actions.tsx +6 -1
  274. package/src/shell.ts +7 -0
  275. package/src/sync/db.ts +103 -0
  276. package/src/sync/engine.ts +699 -0
  277. package/src/sync/hooks.ts +93 -0
  278. package/src/sync/testing.ts +73 -0
  279. package/src/sync/wire.ts +61 -0
  280. package/src/sync.ts +29 -0
  281. package/src/tour/tour-catalogue.tsx +7 -1
  282. package/src/tour/tour-labels.ts +1 -0
  283. package/src/tour/tour.tsx +105 -9
  284. package/src/tour/use-tour-progress.ts +378 -12
  285. package/src/tour.ts +3 -0
@@ -9,14 +9,16 @@ import { formatDate } from "../lib/format";
9
9
  import { Button } from "../components/button";
10
10
  import { Card } from "../components/card";
11
11
  import { Chip } from "../components/chip";
12
+ import { Disclosure } from "../components/disclosure";
12
13
  import { Skeleton } from "../components/skeleton";
14
+ import { Switch } from "../components/switch";
13
15
  import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
14
16
  import { CARD_DESCRIPTION_CLASS } from "../settings/settings-card-classes";
15
17
  import { SettingsCardTitle } from "../settings/settings-heading";
16
18
  import { settle, useMounted } from "../account/account-parts";
17
- import { describeFailure, FailureLine, useNotificationsLabels } from "./notification-parts";
19
+ import { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from "./notification-parts";
18
20
  import type { NotificationsLabels } from "./notifications-labels";
19
- import type { PushDeviceItem } from "./notification-types";
21
+ import type { NotificationTypeEntry, NotificationTypesValue, PushDeviceItem } from "./notification-types";
20
22
  import { notifyPushSubscriptionChanged } from "./use-push-device";
21
23
 
22
24
  /**
@@ -70,6 +72,17 @@ export interface PushDevicesSettingProps<Id extends string | number = string>
70
72
  locale?: string;
71
73
  /** The app's own words for a failed removal, or `undefined` for the kit's `failed`. */
72
74
  describeError?: (error: unknown) => ReactNode | undefined;
75
+ /**
76
+ * 0.46 (§10.2): "Types on this device" on each row — a switch per type whose account
77
+ * `push` is on, all on until the device mutes one. Save the device's WHOLE list:
78
+ * `PATCH /notifications/devices/{id} {"muted_types": [...]}`; refresh `devices` once it
79
+ * resolves. Left out, no row has the disclosure.
80
+ */
81
+ onMutedTypesChange?: (id: Id, mutedTypes: string[]) => Promise<unknown> | void;
82
+ /** The app's words for its types (the types card's `types`), for the disclosure. */
83
+ types?: readonly NotificationTypeEntry[];
84
+ /** The settings `GET`'s `types` (the types card's `value`): which types have push on. */
85
+ typeSwitches?: NotificationTypesValue;
73
86
  labels?: LabelOverride<NotificationsLabels>;
74
87
  }
75
88
 
@@ -99,7 +112,13 @@ async function unsubscribeThisBrowser(): Promise<void> {
99
112
  * `PUSH_SUBSCRIPTION_CHANGED` (keksdose `push-devices-card.tsx:83-97`), so the push card
100
113
  * stops saying "on".
101
114
  *
102
- * Under a write lock Remove is a `commit` control exempt from billing.
115
+ * **"Types on this device"** (0.46, §10.2, with `onMutedTypesChange`): a disclosure under
116
+ * each row with a switch per type the account gets by push; turning one off mutes it on
117
+ * that device only — a device can mute types, never add them. Each flip saves the
118
+ * device's whole `muted_types` list.
119
+ *
120
+ * Under a write lock Remove and the type switches are `commit` controls exempt from
121
+ * billing.
103
122
  */
104
123
  export function PushDevicesSetting<Id extends string | number = string>({
105
124
  devices,
@@ -109,11 +128,15 @@ export function PushDevicesSetting<Id extends string | number = string>({
109
128
  formatDate: formatDateProp,
110
129
  locale: localeProp,
111
130
  describeError,
131
+ onMutedTypesChange,
132
+ types,
133
+ typeSwitches,
112
134
  labels: labelsProp,
113
135
  className,
114
136
  ...rest
115
137
  }: PushDevicesSettingProps<Id>) {
116
138
  const labels = useNotificationsLabels(labelsProp);
139
+ const mutedOverlay = useWriteOverlay<string[]>(devices);
117
140
  const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
118
141
  const locale = useKitLocale(localeProp);
119
142
  const [asking, setAsking] = useState<Id | null>(null);
@@ -146,6 +169,65 @@ export function PushDevicesSetting<Id extends string | number = string>({
146
169
  });
147
170
  };
148
171
 
172
+ // The types a device can mute: those the account gets by push, in the app's order.
173
+ const pushTypes = (types ?? []).filter((entry) => typeSwitches?.[entry.id]?.push === true);
174
+ const mutedOf = (device: PushDeviceItem<Id>): string[] =>
175
+ mutedOverlay.get(String(device.id)) ??
176
+ // Ids the registry dropped (no longer in the settings answer) are ignored.
177
+ (device.muted_types ?? []).filter((id) => !typeSwitches || id in typeSwitches);
178
+ const setMuted = (device: PushDeviceItem<Id>, id: string, on: boolean) => {
179
+ const key = String(device.id);
180
+ if (!onMutedTypesChange || mutedOverlay.pending(key)) return;
181
+ const current = mutedOf(device);
182
+ const next = on ? current.filter((muted) => muted !== id) : [...current.filter((muted) => muted !== id), id];
183
+ setFailure(null);
184
+ mutedOverlay.set(key, next);
185
+ settle(() => onMutedTypesChange(device.id, next)).then(
186
+ () => {
187
+ if (mounted.current) mutedOverlay.settle(key, true);
188
+ },
189
+ (error: unknown) => {
190
+ if (!mounted.current) return;
191
+ mutedOverlay.settle(key, false);
192
+ setFailure(describeFailure(describeError?.(error), labels.failed));
193
+ },
194
+ );
195
+ };
196
+ const typesOf = (device: PushDeviceItem<Id>, name: string): ReactNode => {
197
+ if (!onMutedTypesChange) return null;
198
+ const muted = mutedOf(device);
199
+ return (
200
+ <Disclosure
201
+ variant="bare"
202
+ title={labels.deviceTypes}
203
+ // Every row has one: the name says whose (it starts with the visible words).
204
+ triggerProps={{ "aria-label": `${labels.deviceTypes}: ${name}` }}
205
+ className="text-sm"
206
+ data-device-types=""
207
+ >
208
+ <div className="space-y-2 pt-1 ps-6">
209
+ {pushTypes.length === 0 ? (
210
+ <p className={CARD_DESCRIPTION_CLASS}>{labels.deviceTypesEmpty}</p>
211
+ ) : (
212
+ <>
213
+ <p className={CARD_DESCRIPTION_CLASS}>{labels.deviceTypesDescription}</p>
214
+ {pushTypes.map((entry) => (
215
+ <Switch
216
+ key={entry.id}
217
+ size="sm"
218
+ label={entry.label}
219
+ checked={!muted.includes(entry.id)}
220
+ commit={COMMIT_EXCEPT_BILLING}
221
+ onCheckedChange={(on) => setMuted(device, entry.id, on)}
222
+ />
223
+ ))}
224
+ </>
225
+ )}
226
+ </div>
227
+ </Disclosure>
228
+ );
229
+ };
230
+
149
231
  let body: ReactNode;
150
232
  if (loading || devices === undefined) {
151
233
  body = <Skeleton lines={2} label={labels.devicesLoading} />;
@@ -170,6 +252,7 @@ export function PushDevicesSetting<Id extends string | number = string>({
170
252
  onConfirm={() => remove(device)}
171
253
  labels={labels}
172
254
  cancelLabel={common.cancel}
255
+ types={typesOf(device, describeUserAgent(device.user_agent) ?? labels.unknownDevice)}
173
256
  />
174
257
  ))}
175
258
  </ul>
@@ -202,6 +285,7 @@ function DeviceRow<Id extends string | number>({
202
285
  onConfirm,
203
286
  labels,
204
287
  cancelLabel,
288
+ types,
205
289
  }: {
206
290
  device: PushDeviceItem<Id>;
207
291
  name: string;
@@ -216,6 +300,8 @@ function DeviceRow<Id extends string | number>({
216
300
  onConfirm: () => void;
217
301
  labels: NotificationsLabels;
218
302
  cancelLabel: string;
303
+ /** "Types on this device", under the row (0.46). */
304
+ types: ReactNode;
219
305
  }) {
220
306
  const cancelRef = useRef<HTMLButtonElement>(null);
221
307
  const removeRef = useRef<HTMLButtonElement>(null);
@@ -268,39 +354,42 @@ function DeviceRow<Id extends string | number>({
268
354
  }
269
355
 
270
356
  return (
271
- <li {...row} className="flex items-center gap-3 py-2">
272
- <MonitorSmartphone aria-hidden className="size-4 shrink-0 text-muted" />
273
- <div className="min-w-0 flex-1">
274
- <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
275
- <span className="min-w-0 break-words text-sm font-medium">{name}</span>
276
- {current && (
277
- <Chip size="sm" tone="brand">
278
- {labels.thisDevice}
279
- </Chip>
280
- )}
357
+ <li {...row} className="space-y-1 py-2">
358
+ <div className="flex items-center gap-3">
359
+ <MonitorSmartphone aria-hidden className="size-4 shrink-0 text-muted" />
360
+ <div className="min-w-0 flex-1">
361
+ <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
362
+ <span className="min-w-0 break-words text-sm font-medium">{name}</span>
363
+ {current && (
364
+ <Chip size="sm" tone="brand">
365
+ {labels.thisDevice}
366
+ </Chip>
367
+ )}
368
+ </div>
369
+ <div className={CARD_DESCRIPTION_CLASS}>
370
+ {labels.added(added)}
371
+ {" · "}
372
+ {lastDelivery ? labels.lastDelivery(lastDelivery) : labels.noDelivery}
373
+ </div>
374
+ {device.failure_count > 0 && <div className="text-xs text-warning">{labels.failures(device.failure_count)}</div>}
281
375
  </div>
282
- <div className={CARD_DESCRIPTION_CLASS}>
283
- {labels.added(added)}
284
- {" · "}
285
- {lastDelivery ? labels.lastDelivery(lastDelivery) : labels.noDelivery}
286
- </div>
287
- {device.failure_count > 0 && <div className="text-xs text-warning">{labels.failures(device.failure_count)}</div>}
376
+ <Button
377
+ ref={removeRef}
378
+ type="button"
379
+ size="sm"
380
+ variant="ghost"
381
+ commit={COMMIT_EXCEPT_BILLING}
382
+ disabled={blocked}
383
+ aria-label={labels.removeItem(name)}
384
+ onClick={() => {
385
+ setReturning(false);
386
+ onAsk();
387
+ }}
388
+ >
389
+ {labels.remove}
390
+ </Button>
288
391
  </div>
289
- <Button
290
- ref={removeRef}
291
- type="button"
292
- size="sm"
293
- variant="ghost"
294
- commit={COMMIT_EXCEPT_BILLING}
295
- disabled={blocked}
296
- aria-label={labels.removeItem(name)}
297
- onClick={() => {
298
- setReturning(false);
299
- onAsk();
300
- }}
301
- >
302
- {labels.remove}
303
- </Button>
392
+ {types}
304
393
  </li>
305
394
  );
306
395
  }
@@ -0,0 +1,160 @@
1
+ /**
2
+ * The service worker's half of Web Push that the kit can own (0.46,
3
+ * docs/notifications-harmonization.md NT:1028): re-registering a subscription the
4
+ * browser ROTATED. A push service may replace a subscription (expiry, a key rotation, a
5
+ * browser update) and fires `pushsubscriptionchange` in the worker; until 0.46 the
6
+ * server kept sending to the old endpoint, got 410, deleted the device, and the person
7
+ * had to turn push on again without knowing why.
8
+ *
9
+ * {@link handlePushSubscriptionChange} listens for the event in the app's worker and
10
+ * calls the app's `replace` — `POST /notifications/devices/replace` (server-kit
11
+ * `PushReplaceIn`): the OLD endpoint and the old subscription's `auth` secret, which only
12
+ * this browser and the server hold, prove the device without a session (a worker has no
13
+ * `Authorization` header to send), and the server moves that row to the new endpoint in
14
+ * place — same account, same device. Open pages are told with a
15
+ * {@link PUSH_SUBSCRIPTION_CHANGED} message, and every mounted `usePushDevice` re-reads.
16
+ *
17
+ * Dependency-free (no React), so an app's worker bundle takes only this file — through
18
+ * the worker-safe subpath `@eifi1/ui-kit/push-worker` (0.46, §10.6 rule 5), not the main
19
+ * barrel, which reaches React and the components:
20
+ *
21
+ * // sw.ts
22
+ * import { handlePushSubscriptionChange } from "@eifi1/ui-kit/push-worker";
23
+ * handlePushSubscriptionChange(self, {
24
+ * pushKey: () => fetch("/api/notifications/push-key").then((r) => r.json()).then((b) => b.key),
25
+ * replace: (body) => fetch("/api/notifications/devices/replace", {
26
+ * method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body),
27
+ * }).then((r) => { if (!r.ok) throw new Error(String(r.status)); }),
28
+ * });
29
+ */
30
+
31
+ /** What `register` sends: `POST /notifications/devices` (§3.5). */
32
+ export interface PushSubscriptionInput {
33
+ endpoint: string;
34
+ keys: { p256dh: string; auth: string };
35
+ user_agent: string | null;
36
+ }
37
+
38
+ /** What `replace` sends: `POST /notifications/devices/replace` (server-kit
39
+ * `PushReplaceIn`). The endpoint never goes in a URL. */
40
+ export interface PushReplaceInput {
41
+ /** The rotated-away subscription's endpoint. */
42
+ old_endpoint: string;
43
+ /** Its `keys.auth` as registered — the proof that this browser held it. */
44
+ old_auth: string;
45
+ /** The browser's new subscription. */
46
+ subscription: PushSubscriptionInput;
47
+ }
48
+
49
+ /** The message a worker posts to its pages after a rotation, and the window event the
50
+ * hooks re-read on — the same name. */
51
+ export const PUSH_SUBSCRIPTION_CHANGED = "eifi1:push-subscription-changed";
52
+
53
+ /** A VAPID public key (base64url) as the bytes `PushManager.subscribe` wants. */
54
+ export function keyBytes(key: string): Uint8Array<ArrayBuffer> {
55
+ const base64 = key.replace(/-/g, "+").replace(/_/g, "/") + "=".repeat((4 - (key.length % 4)) % 4);
56
+ const raw = atob(base64);
57
+ const bytes = new Uint8Array(new ArrayBuffer(raw.length));
58
+ for (let i = 0; i < raw.length; i++) bytes[i] = raw.charCodeAt(i);
59
+ return bytes;
60
+ }
61
+
62
+ /** A subscription's register body, or `null` when the browser handed back an incomplete
63
+ * one. */
64
+ export function pushSubscriptionInput(subscription: PushSubscription): PushSubscriptionInput | null {
65
+ const json = subscription.toJSON();
66
+ const p256dh = json.keys?.p256dh;
67
+ const auth = json.keys?.auth;
68
+ if (!json.endpoint || !p256dh || !auth) return null;
69
+ return {
70
+ endpoint: json.endpoint,
71
+ keys: { p256dh, auth },
72
+ user_agent: (typeof navigator !== "undefined" && navigator.userAgent) || null,
73
+ };
74
+ }
75
+
76
+ /** The `pushsubscriptionchange` event as the worker receives it (not in TypeScript's
77
+ * DOM lib). Either subscription may be missing: Chrome often hands neither. */
78
+ export interface PushSubscriptionChangeEventLike {
79
+ readonly oldSubscription?: PushSubscription | null;
80
+ readonly newSubscription?: PushSubscription | null;
81
+ waitUntil?: (promise: Promise<unknown>) => void;
82
+ }
83
+
84
+ export interface PushSubscriptionChangeOptions {
85
+ /** `GET /notifications/push-key` → its `key` — asked only when the browser handed no
86
+ * new subscription and the old one does not carry its key. `""`: push is off. */
87
+ pushKey: () => Promise<string>;
88
+ /** `POST /notifications/devices/replace`. A rejection is the outcome `"failed"`. */
89
+ replace: (input: PushReplaceInput) => Promise<unknown>;
90
+ }
91
+
92
+ /**
93
+ * What a rotation came to: `"replaced"` — the server moved the device; `"no_previous"` —
94
+ * the browser named no old subscription (or one without its keys), so there is nothing
95
+ * to prove the device with and the page shows push as off; `"push_off"` — the server has
96
+ * no key; `"incomplete"` — the new subscription has no keys; `"failed"` — `replace` (or
97
+ * the browser's subscribe) threw.
98
+ */
99
+ export type PushResubscribeOutcome = "replaced" | "no_previous" | "push_off" | "incomplete" | "failed";
100
+
101
+ /**
102
+ * Re-register after `pushsubscriptionchange`: the browser's new subscription — or, when
103
+ * it handed none, the one it holds now, or a fresh one under the old subscription's key
104
+ * (else `pushKey()`) — sent to `replace` with the old endpoint and its `auth`.
105
+ */
106
+ export async function resubscribeAfterChange(
107
+ event: PushSubscriptionChangeEventLike,
108
+ pushManager: PushManager,
109
+ options: PushSubscriptionChangeOptions,
110
+ ): Promise<PushResubscribeOutcome> {
111
+ const old = event.oldSubscription;
112
+ const oldAuth = old?.toJSON().keys?.auth;
113
+ if (!old?.endpoint || !oldAuth) return "no_previous";
114
+ try {
115
+ let next = event.newSubscription ?? (await pushManager.getSubscription());
116
+ if (!next || next.endpoint === old.endpoint) {
117
+ const kept = old.options?.applicationServerKey;
118
+ const key = kept ? null : await options.pushKey();
119
+ if (key === "") return "push_off";
120
+ next = await pushManager.subscribe({
121
+ userVisibleOnly: true,
122
+ applicationServerKey: kept ?? keyBytes(key ?? ""),
123
+ });
124
+ }
125
+ const subscription = pushSubscriptionInput(next);
126
+ if (!subscription) return "incomplete";
127
+ await options.replace({ old_endpoint: old.endpoint, old_auth: oldAuth, subscription });
128
+ return "replaced";
129
+ } catch {
130
+ return "failed";
131
+ }
132
+ }
133
+
134
+ /** The parts of a `ServiceWorkerGlobalScope` this needs. */
135
+ export interface PushWorkerScope {
136
+ addEventListener: (type: "pushsubscriptionchange", listener: (event: PushSubscriptionChangeEventLike) => void) => void;
137
+ registration: { pushManager: PushManager };
138
+ clients?: { matchAll: (options?: { type?: "window"; includeUncontrolled?: boolean }) => Promise<readonly { postMessage: (message: unknown) => void }[]> };
139
+ }
140
+
141
+ /**
142
+ * Listen for `pushsubscriptionchange` in the app's service worker (`self`) and
143
+ * re-register through {@link resubscribeAfterChange}, holding the worker alive until it
144
+ * is done (`waitUntil`). Then every open page gets `{ type: PUSH_SUBSCRIPTION_CHANGED,
145
+ * outcome }`; `usePushDevice` re-reads on it. `onOutcome` is for the app's own logging.
146
+ */
147
+ export function handlePushSubscriptionChange(
148
+ scope: PushWorkerScope,
149
+ options: PushSubscriptionChangeOptions & { onOutcome?: (outcome: PushResubscribeOutcome) => void },
150
+ ): void {
151
+ scope.addEventListener("pushsubscriptionchange", (event) => {
152
+ const done = resubscribeAfterChange(event, scope.registration.pushManager, options).then(async (outcome) => {
153
+ options.onOutcome?.(outcome);
154
+ const pages = (await scope.clients?.matchAll({ type: "window", includeUncontrolled: true })) ?? [];
155
+ for (const page of pages) page.postMessage({ type: PUSH_SUBSCRIPTION_CHANGED, outcome });
156
+ return outcome;
157
+ });
158
+ event.waitUntil?.(done);
159
+ });
160
+ }
@@ -3,7 +3,6 @@ import type { ComponentPropsWithRef, ReactNode } from "react";
3
3
 
4
4
  import type { LabelOverride } from "../i18n/kit-labels";
5
5
  import { cn } from "../lib/cn";
6
- import { Button } from "../components/button";
7
6
  import { Card } from "../components/card";
8
7
  import { TimeInput } from "../components/time-input";
9
8
  import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
@@ -13,9 +12,9 @@ import { settle, useMounted } from "../account/account-parts";
13
12
  import { describeFailure, FailureLine, useNotificationsLabels, useWriteOverlay } from "./notification-parts";
14
13
  import type { NotificationsLabels } from "./notifications-labels";
15
14
  import type { NotificationSettingsPatch } from "./notification-types";
16
- import { deviceTimeZone, TimeZoneSelect, zoneName } from "./time-zone-select";
15
+ import { zoneName } from "./time-zone-select";
17
16
 
18
- /** The zone the server applies while the account has none (§2.6): every app's. */
17
+ /** The zone the server applies while no device ever reported one (§10.3): every app's. */
19
18
  const DEFAULT_FALLBACK_ZONE = "Europe/Zurich";
20
19
 
21
20
  export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div">, "children" | "onChange"> {
@@ -23,17 +22,29 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
23
22
  start: string | null;
24
23
  /** The `GET`'s `quiet_hours_end`. */
25
24
  end: string | null;
26
- /** The `GET`'s `timezone`, an IANA name, or `null` (the app's zone applies). */
27
- timezone: string | null;
25
+ /** The zone the 0.36 card showed, an IANA name, or `null`.
26
+ * @deprecated 0.46 — pass `zone` (§10.3: the zone is the account's, automatic only); removed in 0.47.
27
+ * Shown read-only while `zone` is left out; the card no longer offers a picker. */
28
+ timezone?: string | null;
28
29
  /**
29
- * Save one change — `PATCH /notifications/settings`: a time (`null` when cleared), with
30
- * the device's zone the first time one is entered while `timezone` is `null`; or
31
- * `{timezone}`. The field shows the new value while the call runs and goes back if it
30
+ * 0.46 (§10.3, amended 2026-10-20: automatic only): the account's zone — `/auth/me`'s
31
+ * `device_timezone`, the zone of the device last used; `null` while no device has
32
+ * reported one, and the card then names `fallbackZone`, the zone the server applies.
33
+ * Shown read-only once a time is set ("In your time zone (Europe / Zurich), which follows
34
+ * the device you last used."). No picker: the zone is never chosen.
35
+ */
36
+ zone?: string | null;
37
+ /** 0.46 (§10.4): the app queues a held push and sends it when the window ends (it runs
38
+ * the notifications tick); the description says so. Default `false`: dropped. */
39
+ held?: boolean;
40
+ /**
41
+ * Save one change — `PATCH /notifications/settings`: a time, or `null` when cleared.
42
+ * Never a zone. The field shows the new value while the call runs and goes back if it
32
43
  * rejects.
33
44
  */
34
45
  onChange: (patch: NotificationSettingsPatch) => Promise<unknown> | void;
35
- /** The zone shown, and the one the server applies, while `timezone` is `null`.
36
- * Default `"Europe/Zurich"`, every app's (decision 6). */
46
+ /** The zone named while neither `zone` nor `timezone` names one: the one the server
47
+ * applies. Default `"Europe/Zurich"`, every app's. */
37
48
  fallbackZone?: string;
38
49
  /** The app keeps an inbox, and the description says a held push still reaches it. */
39
50
  inbox?: boolean;
@@ -43,30 +54,32 @@ export interface QuietHoursSettingProps extends Omit<ComponentPropsWithRef<"div"
43
54
  labels?: LabelOverride<NotificationsLabels>;
44
55
  }
45
56
 
46
- type QuietField = "quiet_hours_start" | "quiet_hours_end" | "timezone";
57
+ type QuietField = "quiet_hours_start" | "quiet_hours_end";
47
58
 
48
59
  /**
49
60
  * Quiet hours (docs/notifications-harmonization.md §2.4, §2.6, §4.2): a window of local
50
61
  * times in the account's zone, which may cross midnight, when no push of a machine-sent
51
- * type is sent. A held push is dropped, not delayed; the inbox row and the mail go as
52
- * usual. keksdose's block, as a card of its own.
62
+ * type is sent. A held push is dropped — or, with `held` (0.46, §10.4), sent when the
63
+ * window ends; the inbox row and the mail go as usual. keksdose's block, as a card of its
64
+ * own.
65
+ *
66
+ * **0.46 (§10.3, amended 2026-10-20, Marcel): the zone is the account's, automatic
67
+ * only** — the zone of the device last used, else `Europe/Zurich`. The card names it
68
+ * read-only and never sends one; there is no picker and no "Use it".
53
69
  *
54
70
  * - **Two times**, each saved as it is entered; a cleared time sends `null`. While only
55
71
  * one is set the card says so: the window fails open until both are.
56
- * - **The zone** shows while a time is set. The first time entered while the account has
57
- * none sends this device's zone with it, so "22:00" means the person's 22:00
58
- * (keksdose `notifications-card.tsx:288-292`). With none stored, the card shows
59
- * `fallbackZone` — the zone the server then applies — never the device's (keksdose's
60
- * card showed the device's zone while its server applied UTC).
61
- * - **"This device is in …"** with "Use it" when the device's zone is not the one in
62
- * effect. Never written on its own: a zone is written by a person's pick (§2.6).
72
+ * - **The zone** shows while a time is set: `zone`, else the deprecated `timezone`, else
73
+ * `fallbackZone`.
63
74
  *
64
75
  * Under a write lock the fields are `commit` controls exempt from billing.
65
76
  */
66
77
  export function QuietHoursSetting({
67
78
  start,
68
79
  end,
69
- timezone,
80
+ timezone = null,
81
+ zone,
82
+ held = false,
70
83
  onChange,
71
84
  fallbackZone = DEFAULT_FALLBACK_ZONE,
72
85
  inbox = false,
@@ -77,7 +90,7 @@ export function QuietHoursSetting({
77
90
  ...rest
78
91
  }: QuietHoursSettingProps) {
79
92
  const labels = useNotificationsLabels(labelsProp);
80
- const overlay = useWriteOverlay<string | null>(`${start}|${end}|${timezone}`);
93
+ const overlay = useWriteOverlay<string | null>(`${start}|${end}`);
81
94
  const [failure, setFailure] = useState<ReactNode>(null);
82
95
  const mounted = useMounted();
83
96
  const id = useId();
@@ -86,9 +99,7 @@ export function QuietHoursSetting({
86
99
  overlay.has(field) ? (overlay.get(field) ?? null) : server;
87
100
  const startShown = shown("quiet_hours_start", start);
88
101
  const endShown = shown("quiet_hours_end", end);
89
- const zoneShown = shown("timezone", timezone);
90
- const effectiveZone = zoneShown ?? fallbackZone;
91
- const device = deviceTimeZone();
102
+ const accountZone = (zone === undefined ? timezone : zone) ?? fallbackZone;
92
103
 
93
104
  const save = (patch: NotificationSettingsPatch) => {
94
105
  const fields = Object.keys(patch) as QuietField[];
@@ -109,9 +120,7 @@ export function QuietHoursSetting({
109
120
  const saveTime = (field: "quiet_hours_start" | "quiet_hours_end", value: string) => {
110
121
  const next = value || null;
111
122
  if (next === shown(field, field === "quiet_hours_start" ? start : end)) return;
112
- const patch: NotificationSettingsPatch = { [field]: next };
113
- if (next && zoneShown === null) patch.timezone = device;
114
- save(patch);
123
+ save({ [field]: next });
115
124
  };
116
125
 
117
126
  const anySet = Boolean(startShown || endShown);
@@ -121,7 +130,7 @@ export function QuietHoursSetting({
121
130
  <Card {...rest} className={cn("p-4 space-y-3", className)}>
122
131
  <div>
123
132
  <SettingsCardTitle>{labels.quietTitle}</SettingsCardTitle>
124
- <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox)}</div>
133
+ <div className={CARD_DESCRIPTION_CLASS}>{labels.quietDescription(inbox, held)}</div>
125
134
  </div>
126
135
  <div className="flex flex-wrap items-end gap-3">
127
136
  <TimeInput
@@ -149,35 +158,9 @@ export function QuietHoursSetting({
149
158
  </p>
150
159
  )}
151
160
  {anySet && (
152
- <div className="space-y-2">
153
- <TimeZoneSelect
154
- id={`${id}-zone`}
155
- value={effectiveZone}
156
- disabled={disabled}
157
- commit={COMMIT_EXCEPT_BILLING}
158
- onChange={(zone) => save({ timezone: zone })}
159
- className="max-w-72"
160
- labels={labelsProp}
161
- />
162
- {device !== effectiveZone && (
163
- <div className="flex flex-wrap items-center gap-x-2 gap-y-1">
164
- <span id={`${id}-device`} className={CARD_DESCRIPTION_CLASS}>
165
- {labels.deviceZone(zoneName(device, labels.regions))}
166
- </span>
167
- <Button
168
- type="button"
169
- size="sm"
170
- variant="link"
171
- disabled={disabled}
172
- commit={COMMIT_EXCEPT_BILLING}
173
- aria-describedby={`${id}-device`}
174
- onClick={() => save({ timezone: device })}
175
- >
176
- {labels.useDeviceZone}
177
- </Button>
178
- </div>
179
- )}
180
- </div>
161
+ <p className={CARD_DESCRIPTION_CLASS} data-quiet-zone="">
162
+ {labels.quietZone(zoneName(accountZone, labels.regions))}
163
+ </p>
181
164
  )}
182
165
  <FailureLine>{failure}</FailureLine>
183
166
  </Card>
@@ -10,8 +10,8 @@ import type { NotificationRegionLabels, NotificationsLabels } from "./notificati
10
10
 
11
11
  /**
12
12
  * This device's IANA zone ("Europe/Zurich"), or `"UTC"` where the runtime names none —
13
- * the zone quiet hours are entered in the first time (§2.6), and the one the card offers
14
- * when it differs from the account's.
13
+ * the zone `useDeviceZoneReport` reports, which becomes the account's (§10.3, automatic
14
+ * only).
15
15
  */
16
16
  export function deviceTimeZone(): string {
17
17
  try {
@@ -40,6 +40,7 @@ function cityOf(rest: string): string {
40
40
  * rather than trailing the last group as if it were Pacific. `current` is kept even when
41
41
  * the runtime does not list it (an old alias such as "Europe/Kiev"): a picker without its
42
42
  * own value would show an empty field.
43
+ * @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47.
43
44
  */
44
45
  export function timeZoneOptions(
45
46
  current: string,
@@ -73,6 +74,7 @@ export function zoneName(zone: string, regions: NotificationRegionLabels): strin
73
74
  return `${regionName(regions, region) ?? region} / ${cityOf(zone.slice(slash + 1))}`;
74
75
  }
75
76
 
77
+ /** @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47. */
76
78
  export interface TimeZoneSelectProps {
77
79
  /** The IANA zone. Not clearable: a zone can be changed, never emptied. */
78
80
  value: string;
@@ -95,6 +97,7 @@ export interface TimeZoneSelectProps {
95
97
  * a search box instead of a 400-row wheel. Rows are the city under its region, the region
96
98
  * in the kit's words (`labels.regions`); a zone with no region first; a stored zone the
97
99
  * runtime does not list is kept. On the kit's {@link InlineEntityCombobox}.
100
+ * @deprecated 0.46 — the zone is automatic (§10.3, amended 2026-10-20), no app picks one; removed in 0.47.
98
101
  */
99
102
  export function TimeZoneSelect({
100
103
  value,