@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
@@ -10,6 +10,7 @@ import { Skeleton } from "../components/skeleton";
10
10
  import { Switch } from "../components/switch";
11
11
  import { SectionLabel } from "../components/text";
12
12
  import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
13
+ import { usePhoneLayout } from "../hooks/use-breakpoint";
13
14
  import { CARD_DESCRIPTION_CLASS } from "../settings/settings-card-classes";
14
15
  import { SettingsCardTitle } from "../settings/settings-heading";
15
16
  import { settle, useMounted } from "../account/account-parts";
@@ -18,6 +19,7 @@ import type { WriteOverlay } from "./notification-parts";
18
19
  import type { NotificationsLabels } from "./notifications-labels";
19
20
  import type {
20
21
  DigestCadence,
22
+ NotificationChannel,
21
23
  NotificationSettingsPatch,
22
24
  NotificationSwitches,
23
25
  NotificationTypeEntry,
@@ -73,15 +75,33 @@ interface Row<Id extends string> {
73
75
  }
74
76
 
75
77
  /** A switch's key in the write overlay. */
76
- const switchKey = (id: string, channel: "app" | "email") => `${id}.${channel}`;
78
+ const switchKey = (id: string, channel: NotificationChannel) => `${id}.${channel}`;
77
79
  const CADENCE_KEY = "digest_cadence";
78
80
 
81
+ /** The grid of a several-channel row on a wide screen: the type's words, then one narrow
82
+ * column per channel drawn. Literal classes, so Tailwind's scan finds them. */
83
+ const COLUMN_GRID: Record<number, string> = {
84
+ 1: "grid-cols-[minmax(0,1fr)_4.5rem]",
85
+ 2: "grid-cols-[minmax(0,1fr)_repeat(2,4.5rem)]",
86
+ 3: "grid-cols-[minmax(0,1fr)_repeat(3,4.5rem)]",
87
+ };
88
+
89
+ /** The channels in the card's column order (§10.2). */
90
+ const CHANNELS: readonly NotificationChannel[] = ["app", "push", "email"];
91
+
92
+ /** The channels a type offers: every one the server did not answer `null` (or leave out,
93
+ * as `push` before server-kit 0.16). */
94
+ const channelsOf = (switches: NotificationSwitches): NotificationChannel[] =>
95
+ CHANNELS.filter((channel) => switches[channel] !== null && switches[channel] !== undefined);
96
+
79
97
  /**
80
98
  * What the person hears about, and how (docs/notifications-harmonization.md §2.1–§2.3,
81
- * §4.2): one row per type the SERVER answered, the app's words on it, a switch for "in
82
- * the app" and "By e-mail" under it where the type offers mail. Where the app has no
83
- * "in the app" channel (Kurvenschmiede), the e-mail switch is the row's own, with the
84
- * type's label.
99
+ * §4.2, §10.2): one row per type the SERVER answered, the app's words on it, and a switch
100
+ * per channel the type offers — "In the app", "Push", "E-mail". On a wide screen they are
101
+ * narrow columns under those headings; on a phone they stack under the type's label. A
102
+ * channel the server answers `null` is not drawn (kastlan and KS offer no push; KS no
103
+ * "in the app"), and a row with one channel keeps a single switch named by the type
104
+ * (Kurvenschmiede's e-mail-only rows).
85
105
  *
86
106
  * **The server's list is the only list.** The card keeps no defaults and no list of
87
107
  * types: it draws what `value` holds, in the order of `groups`, else of `types`. A type
@@ -140,23 +160,24 @@ export function NotificationTypesSetting<Id extends string = string>({
140
160
  }
141
161
  }
142
162
 
163
+ const phone = usePhoneLayout();
143
164
  const rowOf = (id: Id): Row<Id> | null => {
144
165
  const switches = value?.[id];
145
166
  const entry = entries.get(id);
146
- if (!switches || !entry || (switches.app === null && switches.email === null)) return null;
167
+ if (!switches || !entry || channelsOf(switches).length === 0) return null;
147
168
  return { entry, switches };
148
169
  };
149
170
  const rowsOf = (ids: readonly Id[]) => ids.map(rowOf).filter((row): row is Row<Id> => row !== null);
150
171
 
151
- const shown = (id: string, channel: "app" | "email", server: boolean): boolean => {
172
+ const shown = (id: string, channel: NotificationChannel, server: boolean): boolean => {
152
173
  const key = switchKey(id, channel);
153
174
  return overlay.get(key) ?? server;
154
175
  };
155
- /** The row's own switch: `app`, or `email` where the app has no `app` channel. */
156
- const rowOn = ({ entry, switches }: Row<Id>) =>
157
- switches.app !== null
158
- ? shown(entry.id, "app", switches.app)
159
- : shown(entry.id, "email", switches.email === true);
176
+ const channelOn = ({ entry, switches }: Row<Id>, channel: NotificationChannel) =>
177
+ shown(entry.id, channel, switches[channel] === true);
178
+ /** The type reaches the person at all: some channel is on (the cadence and the app's
179
+ * extra show then). */
180
+ const rowOn = (row: Row<Id>) => channelsOf(row.switches).some((channel) => channelOn(row, channel));
160
181
 
161
182
  const save = <V,>(key: string, patch: NotificationSettingsPatch<Id>, which: WriteOverlay<V>, next: V) => {
162
183
  if (which.pending(key)) return;
@@ -174,7 +195,7 @@ export function NotificationTypesSetting<Id extends string = string>({
174
195
  );
175
196
  };
176
197
 
177
- const flip = (id: Id, channel: "app" | "email", on: boolean) =>
198
+ const flip = (id: Id, channel: NotificationChannel, on: boolean) =>
178
199
  save(
179
200
  switchKey(id, channel),
180
201
  { types: { [id]: { [channel]: on } } as NotificationSettingsPatch<Id>["types"] },
@@ -190,11 +211,33 @@ export function NotificationTypesSetting<Id extends string = string>({
190
211
  </span>
191
212
  ) : null;
192
213
 
214
+ const channelLabel: Record<NotificationChannel, string> = {
215
+ app: labels.channelApp,
216
+ push: labels.channelPush,
217
+ email: labels.channelEmail,
218
+ };
219
+ /** The columns on a wide screen: every channel some several-channel row offers. */
220
+ const drawnRows = value && !loading ? rowsOf(groups ? groups.flatMap((group) => group.types) : types.map((entry) => entry.id)) : [];
221
+ const multi = drawnRows.filter((row) => channelsOf(row.switches).length > 1);
222
+ const columns = CHANNELS.filter((channel) => multi.some((row) => channelsOf(row.switches).includes(channel)));
223
+ const columnHeadings =
224
+ !phone && columns.length > 0 ? (
225
+ // The switches carry the channel's name themselves; the headings are for the eye.
226
+ <div aria-hidden className={cn("grid gap-x-2", COLUMN_GRID[columns.length])} data-channel-headings="">
227
+ <span />
228
+ {columns.map((channel) => (
229
+ <span key={channel} className="text-center text-caption font-medium text-muted">
230
+ {channelLabel[channel]}
231
+ </span>
232
+ ))}
233
+ </div>
234
+ ) : null;
235
+
193
236
  const renderRow = (row: Row<Id>) => {
194
237
  const { entry, switches } = row;
195
238
  const on = rowOn(row);
196
- const emailOnly = switches.app === null;
197
- const both = !emailOnly && switches.email !== null;
239
+ const offered = channelsOf(switches);
240
+ const single = offered.length === 1 ? offered[0] : null;
198
241
  const cadenceSelect =
199
242
  on && digest?.type === entry.id && cadence ? (
200
243
  <Select
@@ -213,52 +256,99 @@ export function NotificationTypesSetting<Id extends string = string>({
213
256
  </Select>
214
257
  ) : null;
215
258
  const extra = on ? renderExtra?.(entry.id) : null;
216
- const description =
217
- emailOnly && unverifiedLine ? (
218
- <>
219
- {entry.description !== undefined && <span className="block">{entry.description}</span>}
220
- {unverifiedLine}
221
- </>
222
- ) : (
223
- entry.description
259
+ const below =
260
+ cadenceSelect || hasContent(extra) ? (
261
+ <div className="ps-4 space-y-2">
262
+ {cadenceSelect}
263
+ {extra}
264
+ </div>
265
+ ) : null;
266
+
267
+ // One channel: today's single switch, named by the type (KS's e-mail-only rows).
268
+ if (single) {
269
+ const description =
270
+ single === "email" && unverifiedLine ? (
271
+ <>
272
+ {entry.description !== undefined && <span className="block">{entry.description}</span>}
273
+ {unverifiedLine}
274
+ </>
275
+ ) : (
276
+ entry.description
277
+ );
278
+ return (
279
+ <div key={entry.id} data-notification-type={entry.id} data-channels={single} className="space-y-2">
280
+ <Switch
281
+ label={entry.label}
282
+ description={description}
283
+ checked={channelOn(row, single)}
284
+ disabled={disabled}
285
+ commit={COMMIT_EXCEPT_BILLING}
286
+ onCheckedChange={(next) => flip(entry.id, single, next)}
287
+ />
288
+ {below}
289
+ </div>
224
290
  );
291
+ }
292
+
293
+ // Several channels: a group named by its type, a switch per channel — columns on a
294
+ // wide screen (the headings above the list name them), stacked on a phone.
295
+ const labelId = `${groupId}-type-${entry.id}`;
296
+ const unverifiedId = `${groupId}-unverified-${entry.id}`;
297
+ const channelSwitch = (channel: NotificationChannel) => (
298
+ <Switch
299
+ key={channel}
300
+ size="sm"
301
+ label={phone ? channelLabel[channel] : undefined}
302
+ aria-label={phone ? undefined : channelLabel[channel]}
303
+ description={phone && channel === "email" ? (unverifiedLine ?? undefined) : undefined}
304
+ aria-describedby={!phone && channel === "email" && unverifiedLine ? unverifiedId : undefined}
305
+ checked={channelOn(row, channel)}
306
+ disabled={disabled}
307
+ commit={COMMIT_EXCEPT_BILLING}
308
+ onCheckedChange={(next) => flip(entry.id, channel, next)}
309
+ data-channel={channel}
310
+ />
311
+ );
312
+ const text = (
313
+ <div className="min-w-0">
314
+ <div id={labelId} className="text-sm font-medium text-primary">
315
+ {entry.label}
316
+ </div>
317
+ {(entry.description !== undefined || (!phone && offered.includes("email") && unverifiedLine)) && (
318
+ <div className={CARD_DESCRIPTION_CLASS}>
319
+ {entry.description !== undefined && <span className="block">{entry.description}</span>}
320
+ {!phone && offered.includes("email") && <span id={unverifiedId}>{unverifiedLine}</span>}
321
+ </div>
322
+ )}
323
+ </div>
324
+ );
225
325
  return (
226
326
  <div
227
327
  key={entry.id}
228
328
  data-notification-type={entry.id}
229
- // A row with two switches is a group named by its type, so "By e-mail" is heard
230
- // with the type it belongs to.
231
- role={both ? "group" : undefined}
232
- aria-label={both ? entry.label : undefined}
329
+ data-channels={offered.join(" ")}
330
+ role="group"
331
+ aria-labelledby={labelId}
233
332
  className="space-y-2"
234
333
  >
235
- <Switch
236
- label={entry.label}
237
- description={description}
238
- checked={on}
239
- disabled={disabled}
240
- commit={COMMIT_EXCEPT_BILLING}
241
- onCheckedChange={(next) => flip(entry.id, emailOnly ? "email" : "app", next)}
242
- />
243
- {both && (
244
- <div className="ps-4">
245
- <Switch
246
- size="sm"
247
- label={labels.byEmail}
248
- description={unverifiedLine ?? undefined}
249
- checked={shown(entry.id, "email", switches.email === true)}
250
- disabled={disabled}
251
- commit={COMMIT_EXCEPT_BILLING}
252
- onCheckedChange={(next) => flip(entry.id, "email", next)}
253
- />
254
- </div>
255
- )}
256
- {(cadenceSelect || hasContent(extra)) && (
257
- <div className="ps-4 space-y-2">
258
- {cadenceSelect}
259
- {extra}
334
+ {phone ? (
335
+ <>
336
+ {text}
337
+ <div className="ps-4 space-y-2" data-stacked="">
338
+ {offered.map(channelSwitch)}
339
+ </div>
340
+ </>
341
+ ) : (
342
+ <div className={cn("grid items-start gap-x-2", COLUMN_GRID[columns.length])}>
343
+ {text}
344
+ {columns.map((channel) => (
345
+ <div key={channel} className="flex justify-center pt-0.5">
346
+ {offered.includes(channel) ? channelSwitch(channel) : null}
347
+ </div>
348
+ ))}
260
349
  </div>
261
350
  )}
351
+ {below}
262
352
  </div>
263
353
  );
264
354
  };
@@ -274,7 +364,9 @@ export function NotificationTypesSetting<Id extends string = string>({
274
364
  drawn.length === 0 ? (
275
365
  <p className={CARD_DESCRIPTION_CLASS}>{labels.typesEmpty}</p>
276
366
  ) : (
277
- drawn.map(({ group, rows }) => {
367
+ <>
368
+ {columnHeadings}
369
+ {drawn.map(({ group, rows }) => {
278
370
  const headingId = `${groupId}-${group.id}`;
279
371
  return (
280
372
  <div key={group.id} role="group" aria-labelledby={headingId} className="space-y-3">
@@ -284,7 +376,8 @@ export function NotificationTypesSetting<Id extends string = string>({
284
376
  {rows.map(renderRow)}
285
377
  </div>
286
378
  );
287
- })
379
+ })}
380
+ </>
288
381
  );
289
382
  } else {
290
383
  const rows = rowsOf(types.map((entry) => entry.id));
@@ -292,7 +385,10 @@ export function NotificationTypesSetting<Id extends string = string>({
292
385
  rows.length === 0 ? (
293
386
  <p className={CARD_DESCRIPTION_CLASS}>{labels.typesEmpty}</p>
294
387
  ) : (
295
- <div className="space-y-3">{rows.map(renderRow)}</div>
388
+ <div className="space-y-3">
389
+ {columnHeadings}
390
+ {rows.map(renderRow)}
391
+ </div>
296
392
  );
297
393
  }
298
394
 
@@ -13,16 +13,23 @@ import type { ReactNode } from "react";
13
13
  export type DigestCadence = "weekly" | "monthly";
14
14
 
15
15
  /**
16
- * One type's two switches in the `GET` (§3.2). `null` where the type does not offer
17
- * that channel: `email` for a type with no mail, `app` in an app with no "in the app"
18
- * channel (Kurvenschmiede: `{app: null, email: true}`). An unset switch already reads as
19
- * its default — the server fills it in.
16
+ * One type's switches in the `GET` (§3.2; §10.2: one per channel). `null` where the type
17
+ * does not offer that channel: `email` for a type with no mail, `app` in an app with no
18
+ * "in the app" channel (Kurvenschmiede: `{app: null, push: null, email: true}`), `push` in
19
+ * an app with no push (server-kit `NotificationTypes(..., push_channel=False)`: kastlan,
20
+ * KS). An unset switch already reads as its default — the server fills it in.
20
21
  */
21
22
  export interface NotificationSwitches {
22
23
  app: boolean | null;
24
+ /** 0.46 (§10.2): push, independent of `app`. Left out by a server before
25
+ * server-kit 0.16, which reads as `null` (not offered). */
26
+ push?: boolean | null;
23
27
  email: boolean | null;
24
28
  }
25
29
 
30
+ /** A channel a type may offer (§10.2), in the order the types card draws them. */
31
+ export type NotificationChannel = "app" | "push" | "email";
32
+
26
33
  /** The `GET`'s `types`: every type the account can receive, in the registry's order. */
27
34
  export type NotificationTypesValue = Readonly<Record<string, NotificationSwitches>>;
28
35
 
@@ -51,16 +58,18 @@ export interface NotificationTypeGroup<Id extends string = string> {
51
58
 
52
59
  /**
53
60
  * The parts of `PATCH /notifications/settings` (§3.2) the cards send. Omitted keeps; a
54
- * type's `null` puts both its switches back to their defaults; `quiet_hours_*: null`
61
+ * type's `null` puts its switches back to their defaults; `quiet_hours_*: null`
55
62
  * clears a time. One switch per call from the types card (`{types: {digest: {app:
56
- * true}}}`), or `{digest_cadence}`; quiet hours send a time, with the device's zone the
57
- * first time; the zone picker sends `{timezone}`.
63
+ * true}}}`), or `{digest_cadence}`; quiet hours send a time. No zone: it is the
64
+ * account's, automatic only (§10.3, amended 2026-10-20).
58
65
  */
59
66
  export interface NotificationSettingsPatch<Id extends string = string> {
60
- types?: Partial<Record<Id, { app?: boolean; email?: boolean } | null>>;
67
+ types?: Partial<Record<Id, { app?: boolean; push?: boolean; email?: boolean } | null>>;
61
68
  digest_cadence?: DigestCadence;
62
69
  quiet_hours_start?: string | null;
63
70
  quiet_hours_end?: string | null;
71
+ /** @deprecated 0.46 — the zone is the account's, automatic only (§10.3), and server-kit 0.16 refuses it here;
72
+ * removed in 0.47. No kit card sends it. */
64
73
  timezone?: string;
65
74
  }
66
75
 
@@ -77,4 +86,7 @@ export interface PushDeviceItem<Id extends string | number = string> {
77
86
  last_success_at: string | null;
78
87
  /** Failed deliveries in a row; the server removes the device at 10. */
79
88
  failure_count: number;
89
+ /** 0.46 (§10.2): the type ids this device mutes — a device can mute types, never add
90
+ * them. Left out by a server before server-kit 0.16 (none muted). */
91
+ muted_types?: string[];
80
92
  }
@@ -30,8 +30,13 @@ export interface NotificationsLabels {
30
30
  typesTitle: string;
31
31
  /** The line under it. */
32
32
  typesDescription: string;
33
- /** The e-mail switch under a type that offers mail. */
33
+ /** @deprecated 0.46 — the card's channels are columns now (`channelEmail`); removed in 0.47. Unused,
34
+ * kept so an app's override still type-checks. */
34
35
  byEmail: string;
36
+ /** 0.46 (§10.2): the channel columns' headings, and each switch's name. */
37
+ channelApp: string;
38
+ channelPush: string;
39
+ channelEmail: string;
35
40
  /** Under every e-mail switch while the account's address is unverified (decision 16):
36
41
  * no notification mail goes to an unverified address. */
37
42
  emailUnverified: string;
@@ -47,8 +52,12 @@ export interface NotificationsLabels {
47
52
  /* ── Quiet hours ────────────────────────────────────────────────────── */
48
53
  quietTitle: string;
49
54
  /** The line under the title; `inbox`: the app keeps an inbox, which a held push still
50
- * reaches. */
51
- quietDescription: (inbox: boolean) => string;
55
+ * reaches; `held` (0.46, §10.4): the app queues a held push and sends it when the
56
+ * window ends, instead of dropping it. */
57
+ quietDescription: (inbox: boolean, held?: boolean) => string;
58
+ /** 0.46 (§10.3, automatic only): the card names the account's zone, read-only, given
59
+ * the zone as `zoneName` writes it ("Europe / Zurich"); it follows the device last used. */
60
+ quietZone: (zone: string) => string;
52
61
  /** The start time's label. */
53
62
  quietFrom: string;
54
63
  /** The end time's label. */
@@ -57,12 +66,13 @@ export interface NotificationsLabels {
57
66
  quietIncomplete: string;
58
67
  /** The zone picker's label. */
59
68
  timeZone: string;
60
- /** Given the device's zone, as the picker names it, when it differs from the one the
61
- * server applies. */
69
+ /** @deprecated 0.46 — the zone is automatic (§10.3), the card offers no device zone; removed in 0.47.
70
+ * Unused, kept so an app's override still type-checks. */
62
71
  deviceZone: (zone: string) => string;
63
- /** The button that takes the device's zone. */
72
+ /** @deprecated 0.46 — the zone is automatic (§10.3), nothing to take; removed in 0.47. Unused, kept
73
+ * so an app's override still type-checks. */
64
74
  useDeviceZone: string;
65
- /** The zone picker's region headings. */
75
+ /** The zone's region headings, in the picker and in a zone's name ("Europe / Zurich"). */
66
76
  regions: NotificationRegionLabels;
67
77
 
68
78
  /* ── This device ────────────────────────────────────────────────────── */
@@ -123,6 +133,32 @@ export interface NotificationsLabels {
123
133
  removeItem: (device: string) => string;
124
134
  /** The question the row asks before it removes. */
125
135
  removePrompt: string;
136
+ /** 0.46 (§10.2): the disclosure on a device's row listing the types with push on. */
137
+ deviceTypes: string;
138
+ /** The line inside it. */
139
+ deviceTypesDescription: string;
140
+ /** No type has push on for the account. */
141
+ deviceTypesEmpty: string;
142
+
143
+ /* ── The inbox and the bell (0.46, §10.1) ───────────────────────────── */
144
+ /** The inbox's heading (keksdose's "Inbox"). */
145
+ inboxTitle: string;
146
+ inboxLoading: string;
147
+ /** Nothing in the inbox. */
148
+ inboxEmpty: string;
149
+ markAllRead: string;
150
+ /** The header's clear button, and the confirm's answer. */
151
+ clearAll: string;
152
+ /** The question asked before the inbox is cleared. */
153
+ clearPrompt: string;
154
+ /** The row that loads the next page. */
155
+ showMore: string;
156
+ /** The bell's name. */
157
+ bellLabel: string;
158
+ /** Read after the bell's name, given the unread count (≥ 1). */
159
+ bellUnread: (count: number) => string;
160
+ /** The bell panel's link to the app's inbox. */
161
+ allNotifications: string;
126
162
 
127
163
  /* ── The unsubscribe page ───────────────────────────────────────────── */
128
164
  unsubscribeTitle: string;
@@ -150,6 +186,9 @@ export const DEFAULT_NOTIFICATIONS_LABELS: NotificationsLabels = {
150
186
  typesTitle: "Notify me about",
151
187
  typesDescription: "Choose what you hear about, and how.",
152
188
  byEmail: "By e-mail",
189
+ channelApp: "In the app",
190
+ channelPush: "Push",
191
+ channelEmail: "E-mail",
153
192
  emailUnverified: "Confirm your address to receive these.",
154
193
  digestCadence: "Frequency",
155
194
  digestWeekly: "Weekly",
@@ -158,10 +197,15 @@ export const DEFAULT_NOTIFICATIONS_LABELS: NotificationsLabels = {
158
197
  typesEmpty: "There are no notifications for this account.",
159
198
 
160
199
  quietTitle: "Quiet hours",
161
- quietDescription: (inbox) =>
162
- inbox
163
- ? "No push notifications during this window. They still reach your inbox."
164
- : "No push notifications during this window.",
200
+ quietDescription: (inbox, held) =>
201
+ held
202
+ ? inbox
203
+ ? "Push notifications wait during this window and arrive when it ends. They reach your inbox at once."
204
+ : "Push notifications wait during this window and arrive when it ends."
205
+ : inbox
206
+ ? "No push notifications during this window. They still reach your inbox."
207
+ : "No push notifications during this window.",
208
+ quietZone: (zone) => `In your time zone (${zone}), which follows the device you last used.`,
165
209
  quietFrom: "From",
166
210
  quietUntil: "Until",
167
211
  quietIncomplete: "Set both a start and an end time. Quiet hours take effect once both are set.",
@@ -221,6 +265,20 @@ export const DEFAULT_NOTIFICATIONS_LABELS: NotificationsLabels = {
221
265
  remove: "Remove",
222
266
  removeItem: (device) => `Remove ${device}`,
223
267
  removePrompt: "Stop sending notifications to this device?",
268
+ deviceTypes: "Types on this device",
269
+ deviceTypesDescription: "Turn a type off to mute it on this device only.",
270
+ deviceTypesEmpty: "No type has push notifications turned on.",
271
+
272
+ inboxTitle: "Inbox",
273
+ inboxLoading: "Loading notifications…",
274
+ inboxEmpty: "You’re all caught up.",
275
+ markAllRead: "Mark all as read",
276
+ clearAll: "Clear all",
277
+ clearPrompt: "Delete all notifications from your inbox?",
278
+ showMore: "Show more",
279
+ bellLabel: "Notifications",
280
+ bellUnread: (count) => `${count} unread`,
281
+ allNotifications: "All notifications",
224
282
 
225
283
  unsubscribeTitle: "E-mail notifications",
226
284
  unsubscribeAbout: (type) => `You get e-mails about: ${type}.`,