@eifi1/ui-kit 0.45.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 (228) hide show
  1. package/README.md +23 -12
  2. package/dist/admin/admin-area-labels.d.ts +9 -0
  3. package/dist/admin/admin-area-labels.js +5 -1
  4. package/dist/admin/admin-area-labels.js.map +1 -1
  5. package/dist/admin/admin-overview.d.ts +16 -1
  6. package/dist/admin/admin-overview.js +12 -1
  7. package/dist/admin/admin-overview.js.map +1 -1
  8. package/dist/admin/mail-test-card.d.ts +3 -2
  9. package/dist/admin/mail-test-card.js +3 -1
  10. package/dist/admin/mail-test-card.js.map +1 -1
  11. package/dist/components/appearance-labels.d.ts +10 -0
  12. package/dist/components/appearance-labels.js +6 -1
  13. package/dist/components/appearance-labels.js.map +1 -1
  14. package/dist/components/data-table-column-settings.js +2 -2
  15. package/dist/components/data-table-column-settings.js.map +1 -1
  16. package/dist/components/data-table-head.js +2 -2
  17. package/dist/components/data-table-head.js.map +1 -1
  18. package/dist/components/data-table-row-actions.d.ts +18 -2
  19. package/dist/components/data-table-row-actions.js +86 -2
  20. package/dist/components/data-table-row-actions.js.map +1 -1
  21. package/dist/components/data-table-shared.js +9 -2
  22. package/dist/components/data-table-shared.js.map +1 -1
  23. package/dist/components/data-table.d.ts +43 -2
  24. package/dist/components/data-table.js +9 -4
  25. package/dist/components/data-table.js.map +1 -1
  26. package/dist/components/date-format-setting.d.ts +29 -0
  27. package/dist/components/date-format-setting.js +36 -0
  28. package/dist/components/date-format-setting.js.map +1 -0
  29. package/dist/components/swipeable-row.d.ts +8 -0
  30. package/dist/components/swipeable-row.js.map +1 -1
  31. package/dist/components/tabs.d.ts +3 -2
  32. package/dist/components/tabs.js +1 -1
  33. package/dist/components/tabs.js.map +1 -1
  34. package/dist/components/translation-review-editor.js +1 -0
  35. package/dist/components/translation-review-editor.js.map +1 -1
  36. package/dist/components/translation-review-labels.d.ts +5 -0
  37. package/dist/components/translation-review-labels.js +2 -0
  38. package/dist/components/translation-review-labels.js.map +1 -1
  39. package/dist/components/translation-review.d.ts +18 -8
  40. package/dist/components/translation-review.js +64 -7
  41. package/dist/components/translation-review.js.map +1 -1
  42. package/dist/components/write-lock-labels.js +2 -1
  43. package/dist/components/write-lock-labels.js.map +1 -1
  44. package/dist/components/write-lock.d.ts +33 -3
  45. package/dist/components/write-lock.js +9 -0
  46. package/dist/components/write-lock.js.map +1 -1
  47. package/dist/data-table.d.ts +1 -1
  48. package/dist/data-table.js.map +1 -1
  49. package/dist/eslint/index.js +1 -1
  50. package/dist/feedback/feedback-row-detail.d.ts +4 -2
  51. package/dist/feedback/feedback-row-detail.js +17 -26
  52. package/dist/feedback/feedback-row-detail.js.map +1 -1
  53. package/dist/feedback/feedback-swipe.d.ts +18 -0
  54. package/dist/feedback/feedback-swipe.js +10 -1
  55. package/dist/feedback/feedback-swipe.js.map +1 -1
  56. package/dist/i18n/german.js +41 -5
  57. package/dist/i18n/german.js.map +1 -1
  58. package/dist/i18n/kit-labels.d.ts +12 -1
  59. package/dist/i18n/kit-labels.js +8 -2
  60. package/dist/i18n/kit-labels.js.map +1 -1
  61. package/dist/i18n/locales/en.js +40 -6
  62. package/dist/i18n/locales/en.js.map +2 -2
  63. package/dist/i18n/locales/es.js +41 -5
  64. package/dist/i18n/locales/es.js.map +1 -1
  65. package/dist/i18n/locales/fr.js +41 -5
  66. package/dist/i18n/locales/fr.js.map +1 -1
  67. package/dist/i18n/locales/hu.js +41 -5
  68. package/dist/i18n/locales/hu.js.map +1 -1
  69. package/dist/i18n/locales/it.js +41 -5
  70. package/dist/i18n/locales/it.js.map +1 -1
  71. package/dist/i18n/locales/zh.js +41 -5
  72. package/dist/i18n/locales/zh.js.map +1 -1
  73. package/dist/i18n/review.js +3 -1
  74. package/dist/i18n/review.js.map +1 -1
  75. package/dist/index.d.ts +15 -2
  76. package/dist/index.js +35 -0
  77. package/dist/index.js.map +1 -1
  78. package/dist/lib/date-format.d.ts +81 -0
  79. package/dist/lib/date-format.js +56 -0
  80. package/dist/lib/date-format.js.map +1 -0
  81. package/dist/lib/format.d.ts +3 -0
  82. package/dist/lib/format.js +5 -4
  83. package/dist/lib/format.js.map +1 -1
  84. package/dist/notifications/device-zone-report.d.ts +27 -0
  85. package/dist/notifications/device-zone-report.js +24 -0
  86. package/dist/notifications/device-zone-report.js.map +1 -0
  87. package/dist/notifications/feedback-notification-types-labels.d.ts +7 -1
  88. package/dist/notifications/feedback-notification-types-labels.js +4 -1
  89. package/dist/notifications/feedback-notification-types-labels.js.map +1 -1
  90. package/dist/notifications/feedback-notification-types.d.ts +11 -5
  91. package/dist/notifications/feedback-notification-types.js +2 -1
  92. package/dist/notifications/feedback-notification-types.js.map +1 -1
  93. package/dist/notifications/notification-bell.d.ts +33 -0
  94. package/dist/notifications/notification-bell.js +74 -0
  95. package/dist/notifications/notification-bell.js.map +1 -0
  96. package/dist/notifications/notification-inbox.d.ts +77 -0
  97. package/dist/notifications/notification-inbox.js +206 -0
  98. package/dist/notifications/notification-inbox.js.map +1 -0
  99. package/dist/notifications/notification-types-setting.d.ts +6 -4
  100. package/dist/notifications/notification-types-setting.js +103 -45
  101. package/dist/notifications/notification-types-setting.js.map +1 -1
  102. package/dist/notifications/notification-types.d.ts +19 -7
  103. package/dist/notifications/notifications-labels.d.ts +41 -7
  104. package/dist/notifications/notifications-labels.js +18 -1
  105. package/dist/notifications/notifications-labels.js.map +1 -1
  106. package/dist/notifications/push-devices-setting.d.ts +20 -3
  107. package/dist/notifications/push-devices-setting.js +93 -33
  108. package/dist/notifications/push-devices-setting.js.map +1 -1
  109. package/dist/notifications/push-worker.d.ts +108 -0
  110. package/dist/notifications/push-worker.js +62 -0
  111. package/dist/notifications/push-worker.js.map +1 -0
  112. package/dist/notifications/quiet-hours-setting.d.ts +29 -17
  113. package/dist/notifications/quiet-hours-setting.js +9 -41
  114. package/dist/notifications/quiet-hours-setting.js.map +1 -1
  115. package/dist/notifications/time-zone-select.d.ts +5 -2
  116. package/dist/notifications/time-zone-select.js.map +1 -1
  117. package/dist/notifications/use-push-device.d.ts +4 -17
  118. package/dist/notifications/use-push-device.js +7 -15
  119. package/dist/notifications/use-push-device.js.map +1 -1
  120. package/dist/offline/local-first.d.ts +33 -0
  121. package/dist/offline/local-first.js +39 -0
  122. package/dist/offline/local-first.js.map +1 -0
  123. package/dist/offline/query-persistence.d.ts +26 -1
  124. package/dist/offline/query-persistence.js +27 -23
  125. package/dist/offline/query-persistence.js.map +1 -1
  126. package/dist/offline/service-worker-updates.d.ts +46 -0
  127. package/dist/offline/service-worker-updates.js +39 -0
  128. package/dist/offline/service-worker-updates.js.map +1 -0
  129. package/dist/offline.d.ts +5 -2
  130. package/dist/offline.js +11 -2
  131. package/dist/offline.js.map +1 -1
  132. package/dist/push-worker.d.ts +2 -0
  133. package/dist/push-worker.js +14 -0
  134. package/dist/push-worker.js.map +1 -0
  135. package/dist/shell/account-menu-text-size.d.ts +33 -0
  136. package/dist/shell/account-menu-text-size.js +51 -0
  137. package/dist/shell/account-menu-text-size.js.map +1 -0
  138. package/dist/shell/top-bar-actions.d.ts +6 -2
  139. package/dist/shell/top-bar-actions.js +1 -0
  140. package/dist/shell/top-bar-actions.js.map +1 -1
  141. package/dist/shell.d.ts +2 -0
  142. package/dist/shell.js +2 -0
  143. package/dist/shell.js.map +1 -1
  144. package/dist/sync/db.d.ts +76 -0
  145. package/dist/sync/db.js +24 -0
  146. package/dist/sync/db.js.map +1 -0
  147. package/dist/sync/engine.d.ts +124 -0
  148. package/dist/sync/engine.js +474 -0
  149. package/dist/sync/engine.js.map +1 -0
  150. package/dist/sync/hooks.d.ts +30 -0
  151. package/dist/sync/hooks.js +67 -0
  152. package/dist/sync/hooks.js.map +1 -0
  153. package/dist/sync/testing.d.ts +28 -0
  154. package/dist/sync/testing.js +41 -0
  155. package/dist/sync/testing.js.map +1 -0
  156. package/dist/sync/wire.d.ts +50 -0
  157. package/dist/sync/wire.js +2 -0
  158. package/dist/sync/wire.js.map +1 -0
  159. package/dist/sync.d.ts +7 -0
  160. package/dist/sync.js +22 -0
  161. package/dist/sync.js.map +1 -0
  162. package/dist/tour/tour-labels.js +1 -0
  163. package/dist/tour/tour-labels.js.map +1 -1
  164. package/dist/tour/tour.d.ts +30 -0
  165. package/dist/tour/tour.js +50 -6
  166. package/dist/tour/tour.js.map +1 -1
  167. package/package.json +19 -1
  168. package/src/admin/admin-area-labels.ts +13 -0
  169. package/src/admin/admin-overview.tsx +37 -2
  170. package/src/admin/mail-test-card.tsx +7 -3
  171. package/src/components/appearance-labels.ts +16 -0
  172. package/src/components/data-table-column-settings.tsx +2 -2
  173. package/src/components/data-table-head.tsx +2 -2
  174. package/src/components/data-table-row-actions.tsx +118 -2
  175. package/src/components/data-table-shared.tsx +21 -2
  176. package/src/components/data-table.tsx +51 -6
  177. package/src/components/date-format-setting.tsx +64 -0
  178. package/src/components/swipeable-row.tsx +8 -0
  179. package/src/components/tabs.tsx +4 -3
  180. package/src/components/translation-review-editor.tsx +2 -0
  181. package/src/components/translation-review-labels.ts +7 -0
  182. package/src/components/translation-review.tsx +102 -14
  183. package/src/components/write-lock-labels.ts +1 -0
  184. package/src/components/write-lock.tsx +36 -3
  185. package/src/data-table.ts +1 -0
  186. package/src/feedback/feedback-row-detail.tsx +18 -23
  187. package/src/feedback/feedback-swipe.tsx +28 -1
  188. package/src/i18n/german.ts +44 -4
  189. package/src/i18n/kit-labels.tsx +18 -1
  190. package/src/i18n/locales/en.ts +1 -0
  191. package/src/i18n/locales/es.ts +44 -4
  192. package/src/i18n/locales/fr.ts +44 -4
  193. package/src/i18n/locales/hu.ts +44 -4
  194. package/src/i18n/locales/it.ts +44 -4
  195. package/src/i18n/locales/zh.ts +44 -2
  196. package/src/i18n/review.ts +3 -1
  197. package/src/index.ts +42 -0
  198. package/src/lib/date-format.ts +151 -0
  199. package/src/lib/format.ts +10 -4
  200. package/src/notifications/device-zone-report.ts +54 -0
  201. package/src/notifications/feedback-notification-types-labels.ts +10 -1
  202. package/src/notifications/feedback-notification-types.ts +12 -5
  203. package/src/notifications/notification-bell.tsx +116 -0
  204. package/src/notifications/notification-inbox.tsx +304 -0
  205. package/src/notifications/notification-types-setting.tsx +151 -55
  206. package/src/notifications/notification-types.ts +20 -8
  207. package/src/notifications/notifications-labels.ts +69 -11
  208. package/src/notifications/push-devices-setting.tsx +123 -34
  209. package/src/notifications/push-worker.ts +160 -0
  210. package/src/notifications/quiet-hours-setting.tsx +41 -58
  211. package/src/notifications/time-zone-select.tsx +5 -2
  212. package/src/notifications/use-push-device.ts +20 -33
  213. package/src/offline/local-first.ts +76 -0
  214. package/src/offline/query-persistence.ts +57 -24
  215. package/src/offline/service-worker-updates.ts +81 -0
  216. package/src/offline.ts +7 -1
  217. package/src/push-worker.ts +25 -0
  218. package/src/shell/account-menu-text-size.tsx +77 -0
  219. package/src/shell/top-bar-actions.tsx +6 -1
  220. package/src/shell.ts +3 -0
  221. package/src/sync/db.ts +103 -0
  222. package/src/sync/engine.ts +699 -0
  223. package/src/sync/hooks.ts +93 -0
  224. package/src/sync/testing.ts +73 -0
  225. package/src/sync/wire.ts +61 -0
  226. package/src/sync.ts +29 -0
  227. package/src/tour/tour-labels.ts +1 -0
  228. package/src/tour/tour.tsx +100 -7
@@ -0,0 +1,39 @@
1
+ "use client";
2
+ import { isTransientRefreshFailure } from "../lib/session-refresh.js";
3
+ function stampAfter(previous) {
4
+ const now = Date.now();
5
+ const prior = typeof previous === "string" ? Date.parse(previous) : Number.NaN;
6
+ return new Date(Number.isFinite(prior) && prior >= now ? prior + 1 : now).toISOString();
7
+ }
8
+ function isOfflineError(err) {
9
+ if (isTransientRefreshFailure(err)) return true;
10
+ if (typeof err !== "object" || err === null) return false;
11
+ const bag = err;
12
+ if (bag.isAxiosError === true || "response" in bag) return bag.response == null;
13
+ return err instanceof TypeError;
14
+ }
15
+ async function readLocalFirst(online, offline, onOnline) {
16
+ try {
17
+ const result = await online();
18
+ await onOnline?.(result);
19
+ return result;
20
+ } catch (err) {
21
+ if (!isOfflineError(err)) throw err;
22
+ return offline();
23
+ }
24
+ }
25
+ async function writeLocalFirst(rest, queue) {
26
+ try {
27
+ return await rest();
28
+ } catch (err) {
29
+ if (!isOfflineError(err)) throw err;
30
+ return queue();
31
+ }
32
+ }
33
+ export {
34
+ isOfflineError,
35
+ readLocalFirst,
36
+ stampAfter,
37
+ writeLocalFirst
38
+ };
39
+ //# sourceMappingURL=local-first.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/offline/local-first.ts"],"sourcesContent":["import { isTransientRefreshFailure } from \"../lib/session-refresh\";\n\n/*\n * The leaf utilities of local-first (docs/local-first-harmonization.md §2.3, 0.46): what a\n * page and a sync engine both need, with no store behind them. One module that both\n * `@eifi1/ui-kit/offline` and `@eifi1/ui-kit/sync` export, so `/sync` never loads\n * `/offline`'s TanStack and idb-keyval peers, and `/offline` never loads Dexie. It imports\n * no package at all (packaging-contract.test.ts holds that).\n */\n\n/**\n * An `updated_at` that wins Last-Write-Wins over `previous`: now, or one millisecond past\n * `previous` when that is not behind this clock (keksdose's sync-client.ts:639-643).\n *\n * `/sync` keeps the server's row when it is STRICTLY newer, comparing this device's clock\n * with the server's. A row the server just wrote carries the server's time, so an edit\n * stamped by a clock a second slow would lose and come straight back. The millisecond is\n * enough although the server keeps microseconds: `Date.parse` truncates them.\n */\nexport function stampAfter(previous: unknown): string {\n const now = Date.now();\n const prior = typeof previous === \"string\" ? Date.parse(previous) : Number.NaN;\n return new Date(Number.isFinite(prior) && prior >= now ? prior + 1 : now).toISOString();\n}\n\n/**\n * The connection, not the server, failed: the request had no answer (an axios-like error\n * without `response`, or `fetch`'s `TypeError`), or the session refresh marked a 401 as\n * passing because the server was out of reach ({@link isTransientRefreshFailure}). A real\n * HTTP answer — a 4xx or a 5xx — is never offline: the caller must see it.\n */\nexport function isOfflineError(err: unknown): boolean {\n if (isTransientRefreshFailure(err)) return true;\n if (typeof err !== \"object\" || err === null) return false;\n const bag = err as { isAxiosError?: unknown; response?: unknown };\n if (bag.isAxiosError === true || \"response\" in bag) return bag.response == null;\n return err instanceof TypeError;\n}\n\n/**\n * Read from the server, and from the device's copy only when the connection failed\n * ({@link isOfflineError}); a 4xx or 5xx still throws. `onOnline` gets the server's answer\n * before it is returned (to refresh the copy, say); a throw from it counts as a failure of\n * the online path. The plain form of keksdose's net.ts:58, without its sealed-household\n * and cold-start branches (those stay keksdose's).\n */\nexport async function readLocalFirst<T>(\n online: () => Promise<T>,\n offline: () => Promise<T>,\n onOnline?: (result: T) => void | Promise<void>,\n): Promise<T> {\n try {\n const result = await online();\n await onOnline?.(result);\n return result;\n } catch (err) {\n if (!isOfflineError(err)) throw err;\n return offline();\n }\n}\n\n/**\n * Write through the server first (`rest`), and queue the change on the device (`queue`)\n * only when the connection failed; any other failure throws, nothing queued. Online writes\n * stay server-stamped (D2). A create mints its id BEFORE calling this and sends it on both\n * paths, so a REST create that timed out after the server applied it is not created twice\n * (D16).\n */\nexport async function writeLocalFirst<T>(rest: () => Promise<T>, queue: () => Promise<T>): Promise<T> {\n try {\n return await rest();\n } catch (err) {\n if (!isOfflineError(err)) throw err;\n return queue();\n }\n}\n"],"mappings":";AAAA,SAAS,iCAAiC;AAmBnC,SAAS,WAAW,UAA2B;AACpD,QAAM,MAAM,KAAK,IAAI;AACrB,QAAM,QAAQ,OAAO,aAAa,WAAW,KAAK,MAAM,QAAQ,IAAI,OAAO;AAC3E,SAAO,IAAI,KAAK,OAAO,SAAS,KAAK,KAAK,SAAS,MAAM,QAAQ,IAAI,GAAG,EAAE,YAAY;AACxF;AAQO,SAAS,eAAe,KAAuB;AACpD,MAAI,0BAA0B,GAAG,EAAG,QAAO;AAC3C,MAAI,OAAO,QAAQ,YAAY,QAAQ,KAAM,QAAO;AACpD,QAAM,MAAM;AACZ,MAAI,IAAI,iBAAiB,QAAQ,cAAc,IAAK,QAAO,IAAI,YAAY;AAC3E,SAAO,eAAe;AACxB;AASA,eAAsB,eACpB,QACA,SACA,UACY;AACZ,MAAI;AACF,UAAM,SAAS,MAAM,OAAO;AAC5B,UAAM,WAAW,MAAM;AACvB,WAAO;AAAA,EACT,SAAS,KAAK;AACZ,QAAI,CAAC,eAAe,GAAG,EAAG,OAAM;AAChC,WAAO,QAAQ;AAAA,EACjB;AACF;AASA,eAAsB,gBAAmB,MAAwB,OAAqC;AACpG,MAAI;AACF,WAAO,MAAM,KAAK;AAAA,EACpB,SAAS,KAAK;AACZ,QAAI,CAAC,eAAe,GAAG,EAAG,OAAM;AAChC,WAAO,MAAM;AAAA,EACf;AACF;","names":[]}
@@ -59,6 +59,14 @@ export interface QueryPersistenceOptions {
59
59
  storage?: QueryPersistenceStorage;
60
60
  /** Default 1000 ms, as keksdose. */
61
61
  throttleTime?: number;
62
+ /**
63
+ * 0.46 (kastlan K1): whether paused mutations are written with the cache. Default `true`
64
+ * (keksdose: an offline write waits in TanStack's queue and is sent after a reload).
65
+ * `false` keeps every mutation off the disk, and drops any an older build wrote on
66
+ * restore — for an app that queues offline writes in its own outbox (kastlan's Dexie
67
+ * outbox), instead of overriding `dehydrateOptions` by hand.
68
+ */
69
+ persistMutations?: boolean;
62
70
  }
63
71
  /**
64
72
  * One persisted cache, for one key. **Mount it per key**: the provider subscribes once and
@@ -91,10 +99,27 @@ export interface QueryPersistence {
91
99
  * account's, another company's). Other apps' keys and fresh ones stay. Never throws. */
92
100
  removeStale: () => Promise<void>;
93
101
  }
102
+ export interface RemoveStaleQueryCopiesOptions {
103
+ /** Default: IndexedDB (idb-keyval), the store {@link createQueryPersistence} writes to. */
104
+ storage?: QueryPersistenceStorage;
105
+ /** One key to spare whatever its age — the signed-in account's own
106
+ * (`QueryPersistence.removeStale` passes its key). */
107
+ keep?: string;
108
+ }
109
+ /**
110
+ * 0.46 (kastlan K2): deletes this app's persisted query caches (`<app>-query-cache-…`)
111
+ * older than `maxAge`, with no persistence at hand — for the sign-in page, where no
112
+ * account is known. A session that ended with no user known (a refresh token refused at
113
+ * boot) cannot name its key for `clear`; this sweeps its copy once it is stale, without
114
+ * waiting for someone's next sign-in. Other apps' keys, other stores' keys and fresh
115
+ * caches stay; an unreadable one is stale. Never throws.
116
+ * {@link QueryPersistence.removeStale} is this with `keep` = its own key.
117
+ */
118
+ export declare function removeStaleQueryCopies(app: string, maxAge: number, { storage, keep }?: RemoveStaleQueryCopiesOptions): Promise<void>;
94
119
  /**
95
120
  * True when `data` still means the same thing after JSON.stringify and JSON.parse —
96
121
  * keksdose's `isCacheSafe` (query-client.ts:165). Class instances, Map/Set/Date/Blob, a
97
122
  * non-finite number, a function: all refused. An `undefined` property survives.
98
123
  */
99
124
  export declare function isCacheSafe(data: unknown, depth?: number): boolean;
100
- export declare function createQueryPersistence({ app, user, company, shouldPersistKey, denyRoots, scrubFields, onUnsafe, buster, maxAge, storage, throttleTime, }: QueryPersistenceOptions): QueryPersistence;
125
+ export declare function createQueryPersistence({ app, user, company, shouldPersistKey, denyRoots, scrubFields, onUnsafe, buster, maxAge, storage, throttleTime, persistMutations, }: QueryPersistenceOptions): QueryPersistence;
@@ -13,6 +13,26 @@ const idbStorage = {
13
13
  removeItem: (key) => del(key),
14
14
  keys: () => keys()
15
15
  };
16
+ async function removeStaleQueryCopies(app, maxAge, { storage = idbStorage, keep } = {}) {
17
+ const prefix = `${app}-query-cache-`;
18
+ try {
19
+ const now = Date.now();
20
+ for (const k of await storage.keys()) {
21
+ if (typeof k !== "string" || !k.startsWith(prefix) || k === keep) continue;
22
+ let timestamp = 0;
23
+ try {
24
+ const raw = await storage.getItem(k);
25
+ const parsed = raw ? JSON.parse(raw) : null;
26
+ if (parsed && typeof parsed === "object" && typeof parsed.timestamp === "number") {
27
+ timestamp = parsed.timestamp;
28
+ }
29
+ } catch {
30
+ }
31
+ if (now - timestamp > maxAge) await storage.removeItem(k);
32
+ }
33
+ } catch {
34
+ }
35
+ }
16
36
  function isCacheSafe(data, depth = 0) {
17
37
  if (depth > 12) return false;
18
38
  if (data === null) return true;
@@ -51,7 +71,8 @@ function createQueryPersistence({
51
71
  buster,
52
72
  maxAge,
53
73
  storage = idbStorage,
54
- throttleTime = 1e3
74
+ throttleTime = 1e3,
75
+ persistMutations = true
55
76
  }) {
56
77
  const prefix = `${app}-query-cache-`;
57
78
  const key = `${prefix}${user}${company === void 0 || company === null || company === "" ? "" : `-${company}`}`;
@@ -78,7 +99,7 @@ function createQueryPersistence({
78
99
  ([name, v]) => scrubFields.some((f) => typeof f === "string" ? f === name : f.test(name)) || carriesScrubbed(v, depth + 1)
79
100
  );
80
101
  };
81
- const shouldDehydrateMutation = (mutation) => mutation.state.isPaused && !carriesScrubbed(mutation.state.variables);
102
+ const shouldDehydrateMutation = (mutation) => persistMutations && mutation.state.isPaused && !carriesScrubbed(mutation.state.variables);
82
103
  const withScrubbedData = (entry) => {
83
104
  const query = entry;
84
105
  if (!query.state || !("data" in query.state)) return entry;
@@ -100,7 +121,7 @@ function createQueryPersistence({
100
121
  timestamp,
101
122
  buster: stored,
102
123
  clientState: {
103
- mutations: Array.isArray(clientState.mutations) ? clientState.mutations.filter(
124
+ mutations: persistMutations && Array.isArray(clientState.mutations) ? clientState.mutations.filter(
104
125
  (m) => !carriesScrubbed(m?.state?.variables)
105
126
  ) : [],
106
127
  queries: clientState.queries.map(restorable).filter((q) => q !== null)
@@ -144,25 +165,7 @@ function createQueryPersistence({
144
165
  } catch {
145
166
  }
146
167
  };
147
- const removeStale = async () => {
148
- try {
149
- const now = Date.now();
150
- for (const k of await storage.keys()) {
151
- if (typeof k !== "string" || !k.startsWith(prefix) || k === key) continue;
152
- let timestamp = 0;
153
- try {
154
- const raw = await storage.getItem(k);
155
- const parsed = raw ? JSON.parse(raw) : null;
156
- if (parsed && typeof parsed === "object" && typeof parsed.timestamp === "number") {
157
- timestamp = parsed.timestamp;
158
- }
159
- } catch {
160
- }
161
- if (now - timestamp > maxAge) await storage.removeItem(k);
162
- }
163
- } catch {
164
- }
165
- };
168
+ const removeStale = () => removeStaleQueryCopies(app, maxAge, { storage, keep: key });
166
169
  return {
167
170
  key,
168
171
  persistOptions: { persister, maxAge, buster, dehydrateOptions: { shouldDehydrateQuery, shouldDehydrateMutation } },
@@ -174,6 +177,7 @@ function createQueryPersistence({
174
177
  export {
175
178
  DEFAULT_SCRUB_FIELDS,
176
179
  createQueryPersistence,
177
- isCacheSafe
180
+ isCacheSafe,
181
+ removeStaleQueryCopies
178
182
  };
179
183
  //# sourceMappingURL=query-persistence.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/offline/query-persistence.ts"],"sourcesContent":["import { createAsyncStoragePersister } from \"@tanstack/query-async-storage-persister\";\nimport type { QueryClient } from \"@tanstack/react-query\";\nimport type { PersistedClient, PersistQueryClientOptions } from \"@tanstack/react-query-persist-client\";\nimport { del, get, keys, set } from \"idb-keyval\";\n\n/**\n * The persisted TanStack Query cache, keksdose's policy as a kit helper\n * (docs/local-first-harmonization.md §1.5 D4, D5, D7; §1.6). keksdose's\n * shared/state/query-client.ts is the canon: an IndexedDB persister, a deny-list, the\n * plain-JSON guard on write (`shouldDehydrateQuery`) and its twin on restore that keeps\n * paused mutations. On top: a key per account (and company), a scrub list that removes\n * secret fields from payloads that are otherwise wanted offline, and `removeStale` for\n * another account's leftovers.\n *\n * The only module of the kit that imports TanStack and idb-keyval: both are optional peers\n * of `@eifi1/ui-kit/offline` and never reached from the main barrel (packaging-contract).\n */\n\n/** A field name the scrub removes: an exact name, or a pattern (`/^signature_/`). */\nexport type ScrubField = string | RegExp;\n\n/**\n * Removed from every query's data, at any depth, before it is persisted (D4, D7): bank\n * account numbers and the handover protocol's signatures. A page restored offline shows\n * such a field as missing (\"Needs a connection\").\n */\nexport const DEFAULT_SCRUB_FIELDS: readonly ScrubField[] = Object.freeze([\n \"iban\",\n \"qr_iban\",\n \"default_qr_iban\",\n /^signature_/,\n]);\n\n/** The storage the persister writes to. The default is IndexedDB through idb-keyval. */\nexport interface QueryPersistenceStorage {\n getItem: (key: string) => Promise<string | null | undefined>;\n setItem: (key: string, value: string) => Promise<unknown>;\n removeItem: (key: string) => Promise<unknown>;\n /** Every key in the store — what `removeStale` walks. */\n keys: () => Promise<readonly unknown[]>;\n}\n\nexport interface QueryPersistenceOptions {\n /** The app's name, the key's first part: `kastlan` → `kastlan-query-cache-…`. */\n app: string;\n /** The signed-in account. One cache per account (D5). */\n user: string | number;\n /** The acting company, where the app has several (kastlan): the switch changes the key,\n * so no wipe and no cross-company leak — provided the app switches as\n * {@link QueryPersistence.leave} says (leave, then remount the provider per key). */\n company?: string | number | null;\n /** The app's own rule, beside `denyRoots` — keksdose denies one exact key and every key\n * whose second segment is `\"admin\"`. `false` keeps the query out. */\n shouldPersistKey?: (queryKey: readonly unknown[]) => boolean;\n /** Query roots (`queryKey[0]`) that never reach the disk: admin, billing, reports… */\n denyRoots?: readonly string[];\n /** Replaces {@link DEFAULT_SCRUB_FIELDS}; spread it to add to it. `[]` scrubs nothing.\n * A paused mutation whose variables carry one of these fields is never persisted (a\n * scrubbed mutation would send something else), and one an older build wrote is dropped\n * on restore. */\n scrubFields?: readonly ScrubField[];\n /** Told when a query's data is not plain JSON and is therefore not persisted. Default:\n * `console.error`, as keksdose's `cacheSafeOrWarn`. */\n onUnsafe?: (queryKey: readonly unknown[], data: unknown) => void;\n /** Changes with each deploy (the app version), so a new build discards the old cache. */\n buster: string;\n /** How long a persisted cache is good for, in ms (keksdose: 24 h). The QueryClient's\n * `gcTime` must be at least this, or restore drops every query. */\n maxAge: number;\n /** Default: IndexedDB (idb-keyval). Tests pass an in-memory one. */\n storage?: QueryPersistenceStorage;\n /** Default 1000 ms, as keksdose. */\n throttleTime?: number;\n}\n\n/**\n * One persisted cache, for one key. **Mount it per key**: the provider subscribes once and\n * ignores a changed `persistOptions`, so give it the key as its React key —\n *\n * ```tsx\n * <PersistQueryClientProvider key={persistence.key} client={queryClient} persistOptions={persistence.persistOptions}>\n * ```\n *\n * — and create the persistence once the account (and company) is known (`useMemo` on\n * them); before sign-in, mount the plain `QueryClientProvider`. On a company switch, call\n * {@link QueryPersistence.leave} on the old one BEFORE the new key mounts, instead of a bare\n * `queryClient.clear()`: otherwise the old subscription saves the emptied (or the next\n * company's) cache under the old key.\n */\nexport interface QueryPersistence {\n /** `<app>-query-cache-<user>[-<company>]`. */\n key: string;\n /** Everything `<PersistQueryClientProvider persistOptions>` needs. */\n persistOptions: Omit<PersistQueryClientOptions, \"queryClient\">;\n /** Sign-out: stops writing under this key, empties the in-memory cache and deletes this\n * key's persisted copy. Best-effort; never throws. */\n clear: (queryClient: QueryClient) => Promise<void>;\n /** The company switch (or any change of key without a sign-out): stops writing under this\n * key — a save already scheduled is dropped too — and empties the in-memory cache, so\n * the next key's provider starts clean. The persisted copy stays for the way back. A\n * provider that mounts this persistence again re-arms it with its restore. Never throws. */\n leave: (queryClient: QueryClient) => Promise<void>;\n /** At sign-in: deletes this app's OTHER persisted caches older than `maxAge` (another\n * account's, another company's). Other apps' keys and fresh ones stay. Never throws. */\n removeStale: () => Promise<void>;\n}\n\nconst idbStorage: QueryPersistenceStorage = {\n getItem: (key) => get<string>(key),\n setItem: (key, value) => set(key, value),\n removeItem: (key) => del(key),\n keys: () => keys(),\n};\n\n/**\n * True when `data` still means the same thing after JSON.stringify and JSON.parse —\n * keksdose's `isCacheSafe` (query-client.ts:165). Class instances, Map/Set/Date/Blob, a\n * non-finite number, a function: all refused. An `undefined` property survives.\n */\nexport function isCacheSafe(data: unknown, depth = 0): boolean {\n if (depth > 12) return false;\n if (data === null) return true;\n const t = typeof data;\n if (t === \"string\" || t === \"boolean\") return true;\n if (t === \"number\") return Number.isFinite(data as number);\n if (t !== \"object\") return false;\n if (Array.isArray(data)) return data.every((v) => isCacheSafe(v, depth + 1));\n const proto: unknown = Object.getPrototypeOf(data);\n if (proto !== Object.prototype && proto !== null) return false;\n return Object.values(data as Record<string, unknown>).every((v) => v === undefined || isCacheSafe(v, depth + 1));\n}\n\n/** A copy of `data` without the scrubbed fields, at any depth. The input is not touched. */\nfunction scrub(data: unknown, fields: readonly ScrubField[]): unknown {\n if (fields.length === 0 || data === null || typeof data !== \"object\") return data;\n if (Array.isArray(data)) return data.map((v) => scrub(v, fields));\n const out: Record<string, unknown> = {};\n for (const [name, value] of Object.entries(data as Record<string, unknown>)) {\n if (fields.some((f) => (typeof f === \"string\" ? f === name : f.test(name)))) continue;\n out[name] = scrub(value, fields);\n }\n return out;\n}\n\nconst defaultOnUnsafe = (queryKey: readonly unknown[]) => {\n console.error(\n `[query-cache] refusing to persist ${JSON.stringify(queryKey)}: the value is not plain JSON ` +\n `(a class instance, Map, Set, Date or Blob). Return plain data from the queryFn and build ` +\n `the rich object at the call site.`,\n );\n};\n\nexport function createQueryPersistence({\n app,\n user,\n company,\n shouldPersistKey,\n denyRoots = [],\n scrubFields = DEFAULT_SCRUB_FIELDS,\n onUnsafe = defaultOnUnsafe,\n buster,\n maxAge,\n storage = idbStorage,\n throttleTime = 1000,\n}: QueryPersistenceOptions): QueryPersistence {\n const prefix = `${app}-query-cache-`;\n const key = `${prefix}${user}${company === undefined || company === null || company === \"\" ? \"\" : `-${company}`}`;\n const denied = new Set(denyRoots);\n // Set by `leave` and `clear`: a write under this key after the person left it (a\n // throttled save of the emptied cache) is dropped. A restore re-arms it.\n let retired = false;\n\n const keyAllowed = (queryKey: readonly unknown[]) =>\n !denied.has(String(queryKey[0])) && (shouldPersistKey ? shouldPersistKey(queryKey) : true);\n\n /** The write side: a success, an allowed key, plain JSON (or the warning). */\n const shouldDehydrateQuery = (query: {\n queryKey: readonly unknown[];\n state: { status: string; data?: unknown };\n }): boolean => {\n if (query.state.status !== \"success\" || !keyAllowed(query.queryKey)) return false;\n if (isCacheSafe(query.state.data)) return true;\n onUnsafe(query.queryKey, query.state.data);\n return false;\n };\n\n /** One persisted query entry, scrubbed; `null` when it may not be restored. */\n const restorable = (entry: unknown): unknown => {\n if (!entry || typeof entry !== \"object\") return null;\n const { queryKey, state } = entry as { queryKey?: unknown; state?: { data?: unknown } };\n if (!Array.isArray(queryKey) || !keyAllowed(queryKey)) return null;\n if (!isCacheSafe(state?.data)) return null;\n return withScrubbedData(entry);\n };\n\n /** Whether `value` has a scrubbed field's name anywhere in it. */\n const carriesScrubbed = (value: unknown, depth = 0): boolean => {\n if (scrubFields.length === 0 || depth > 12 || value === null || typeof value !== \"object\") return false;\n if (Array.isArray(value)) return value.some((v) => carriesScrubbed(v, depth + 1));\n return Object.entries(value as Record<string, unknown>).some(\n ([name, v]) =>\n scrubFields.some((f) => (typeof f === \"string\" ? f === name : f.test(name))) || carriesScrubbed(v, depth + 1),\n );\n };\n\n /** The write side for mutations: TanStack's default (paused ones), minus any whose\n * variables carry a scrubbed field (S6). */\n const shouldDehydrateMutation = (mutation: { state: { isPaused: boolean; variables?: unknown } }): boolean =>\n mutation.state.isPaused && !carriesScrubbed(mutation.state.variables);\n\n const withScrubbedData = (entry: unknown): unknown => {\n const query = entry as { state?: { data?: unknown } };\n if (!query.state || !(\"data\" in query.state)) return entry;\n return { ...query, state: { ...query.state, data: scrub(query.state.data, scrubFields) } };\n };\n\n // The scrub on the way OUT: the in-memory cache keeps the field (the page that is open\n // shows it), the disk never sees it. Mutations are a paused write queue and are kept\n // whole — scrubbing one would change what it sends — so one carrying a scrubbed field is\n // not written at all (`shouldDehydrateMutation`).\n const serialize = (client: PersistedClient): string =>\n JSON.stringify({\n ...client,\n clientState: { ...client.clientState, queries: client.clientState.queries.map(withScrubbedData) },\n });\n\n // The twin on the way IN (keksdose's `sanitizePersistedCache`, :264-298): an older build\n // wrote what this one may deny. Never throws; an unreadable blob comes back with\n // `timestamp: 0`, which the restore treats as unusable and removes.\n const deserialize = (raw: string): PersistedClient => {\n const empty: PersistedClient = { timestamp: 0, buster: \"\", clientState: { mutations: [], queries: [] } };\n try {\n const parsed: unknown = JSON.parse(raw);\n if (!parsed || typeof parsed !== \"object\") return empty;\n const { timestamp, buster: stored, clientState } = parsed as Partial<PersistedClient>;\n if (typeof timestamp !== \"number\" || typeof stored !== \"string\") return empty;\n if (!clientState || typeof clientState !== \"object\" || !Array.isArray(clientState.queries)) return empty;\n return {\n timestamp,\n buster: stored,\n clientState: {\n mutations: Array.isArray(clientState.mutations)\n ? clientState.mutations.filter(\n (m) => !carriesScrubbed((m as { state?: { variables?: unknown } } | null)?.state?.variables),\n )\n : [],\n queries: clientState.queries.map(restorable).filter((q) => q !== null) as PersistedClient[\"clientState\"][\"queries\"],\n },\n };\n } catch {\n return empty;\n }\n };\n\n const persister = createAsyncStoragePersister({\n storage: {\n // Only the provider's restore reads: mounting this persistence again re-arms it.\n getItem: async (k) => {\n retired = false;\n return (await storage.getItem(k)) ?? null;\n },\n setItem: async (k, v) => {\n if (!retired) await storage.setItem(k, v);\n },\n removeItem: async (k) => {\n await storage.removeItem(k);\n },\n },\n key,\n throttleTime,\n serialize,\n deserialize,\n });\n\n const leave = async (queryClient: QueryClient) => {\n retired = true;\n try {\n queryClient.clear();\n } catch {\n /* best-effort */\n }\n };\n\n const clear = async (queryClient: QueryClient) => {\n retired = true;\n try {\n queryClient.clear();\n await persister.removeClient();\n } catch {\n /* best-effort: private mode / IndexedDB unavailable */\n }\n };\n\n const removeStale = async () => {\n try {\n const now = Date.now();\n for (const k of await storage.keys()) {\n if (typeof k !== \"string\" || !k.startsWith(prefix) || k === key) continue;\n let timestamp = 0;\n try {\n const raw = await storage.getItem(k);\n const parsed: unknown = raw ? JSON.parse(raw) : null;\n if (parsed && typeof parsed === \"object\" && typeof (parsed as PersistedClient).timestamp === \"number\") {\n timestamp = (parsed as PersistedClient).timestamp;\n }\n } catch {\n /* unreadable: stale */\n }\n if (now - timestamp > maxAge) await storage.removeItem(k);\n }\n } catch {\n /* best-effort */\n }\n };\n\n return {\n key,\n persistOptions: { persister, maxAge, buster, dehydrateOptions: { shouldDehydrateQuery, shouldDehydrateMutation } },\n clear,\n leave,\n removeStale,\n };\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/offline/query-persistence.ts"],"sourcesContent":["import { createAsyncStoragePersister } from \"@tanstack/query-async-storage-persister\";\nimport type { QueryClient } from \"@tanstack/react-query\";\nimport type { PersistedClient, PersistQueryClientOptions } from \"@tanstack/react-query-persist-client\";\nimport { del, get, keys, set } from \"idb-keyval\";\n\n/**\n * The persisted TanStack Query cache, keksdose's policy as a kit helper\n * (docs/local-first-harmonization.md §1.5 D4, D5, D7; §1.6). keksdose's\n * shared/state/query-client.ts is the canon: an IndexedDB persister, a deny-list, the\n * plain-JSON guard on write (`shouldDehydrateQuery`) and its twin on restore that keeps\n * paused mutations. On top: a key per account (and company), a scrub list that removes\n * secret fields from payloads that are otherwise wanted offline, and `removeStale` for\n * another account's leftovers.\n *\n * The only module of the kit that imports TanStack and idb-keyval: both are optional peers\n * of `@eifi1/ui-kit/offline` and never reached from the main barrel (packaging-contract).\n */\n\n/** A field name the scrub removes: an exact name, or a pattern (`/^signature_/`). */\nexport type ScrubField = string | RegExp;\n\n/**\n * Removed from every query's data, at any depth, before it is persisted (D4, D7): bank\n * account numbers and the handover protocol's signatures. A page restored offline shows\n * such a field as missing (\"Needs a connection\").\n */\nexport const DEFAULT_SCRUB_FIELDS: readonly ScrubField[] = Object.freeze([\n \"iban\",\n \"qr_iban\",\n \"default_qr_iban\",\n /^signature_/,\n]);\n\n/** The storage the persister writes to. The default is IndexedDB through idb-keyval. */\nexport interface QueryPersistenceStorage {\n getItem: (key: string) => Promise<string | null | undefined>;\n setItem: (key: string, value: string) => Promise<unknown>;\n removeItem: (key: string) => Promise<unknown>;\n /** Every key in the store — what `removeStale` walks. */\n keys: () => Promise<readonly unknown[]>;\n}\n\nexport interface QueryPersistenceOptions {\n /** The app's name, the key's first part: `kastlan` → `kastlan-query-cache-…`. */\n app: string;\n /** The signed-in account. One cache per account (D5). */\n user: string | number;\n /** The acting company, where the app has several (kastlan): the switch changes the key,\n * so no wipe and no cross-company leak — provided the app switches as\n * {@link QueryPersistence.leave} says (leave, then remount the provider per key). */\n company?: string | number | null;\n /** The app's own rule, beside `denyRoots` — keksdose denies one exact key and every key\n * whose second segment is `\"admin\"`. `false` keeps the query out. */\n shouldPersistKey?: (queryKey: readonly unknown[]) => boolean;\n /** Query roots (`queryKey[0]`) that never reach the disk: admin, billing, reports… */\n denyRoots?: readonly string[];\n /** Replaces {@link DEFAULT_SCRUB_FIELDS}; spread it to add to it. `[]` scrubs nothing.\n * A paused mutation whose variables carry one of these fields is never persisted (a\n * scrubbed mutation would send something else), and one an older build wrote is dropped\n * on restore. */\n scrubFields?: readonly ScrubField[];\n /** Told when a query's data is not plain JSON and is therefore not persisted. Default:\n * `console.error`, as keksdose's `cacheSafeOrWarn`. */\n onUnsafe?: (queryKey: readonly unknown[], data: unknown) => void;\n /** Changes with each deploy (the app version), so a new build discards the old cache. */\n buster: string;\n /** How long a persisted cache is good for, in ms (keksdose: 24 h). The QueryClient's\n * `gcTime` must be at least this, or restore drops every query. */\n maxAge: number;\n /** Default: IndexedDB (idb-keyval). Tests pass an in-memory one. */\n storage?: QueryPersistenceStorage;\n /** Default 1000 ms, as keksdose. */\n throttleTime?: number;\n /**\n * 0.46 (kastlan K1): whether paused mutations are written with the cache. Default `true`\n * (keksdose: an offline write waits in TanStack's queue and is sent after a reload).\n * `false` keeps every mutation off the disk, and drops any an older build wrote on\n * restore — for an app that queues offline writes in its own outbox (kastlan's Dexie\n * outbox), instead of overriding `dehydrateOptions` by hand.\n */\n persistMutations?: boolean;\n}\n\n/**\n * One persisted cache, for one key. **Mount it per key**: the provider subscribes once and\n * ignores a changed `persistOptions`, so give it the key as its React key —\n *\n * ```tsx\n * <PersistQueryClientProvider key={persistence.key} client={queryClient} persistOptions={persistence.persistOptions}>\n * ```\n *\n * — and create the persistence once the account (and company) is known (`useMemo` on\n * them); before sign-in, mount the plain `QueryClientProvider`. On a company switch, call\n * {@link QueryPersistence.leave} on the old one BEFORE the new key mounts, instead of a bare\n * `queryClient.clear()`: otherwise the old subscription saves the emptied (or the next\n * company's) cache under the old key.\n */\nexport interface QueryPersistence {\n /** `<app>-query-cache-<user>[-<company>]`. */\n key: string;\n /** Everything `<PersistQueryClientProvider persistOptions>` needs. */\n persistOptions: Omit<PersistQueryClientOptions, \"queryClient\">;\n /** Sign-out: stops writing under this key, empties the in-memory cache and deletes this\n * key's persisted copy. Best-effort; never throws. */\n clear: (queryClient: QueryClient) => Promise<void>;\n /** The company switch (or any change of key without a sign-out): stops writing under this\n * key — a save already scheduled is dropped too — and empties the in-memory cache, so\n * the next key's provider starts clean. The persisted copy stays for the way back. A\n * provider that mounts this persistence again re-arms it with its restore. Never throws. */\n leave: (queryClient: QueryClient) => Promise<void>;\n /** At sign-in: deletes this app's OTHER persisted caches older than `maxAge` (another\n * account's, another company's). Other apps' keys and fresh ones stay. Never throws. */\n removeStale: () => Promise<void>;\n}\n\nconst idbStorage: QueryPersistenceStorage = {\n getItem: (key) => get<string>(key),\n setItem: (key, value) => set(key, value),\n removeItem: (key) => del(key),\n keys: () => keys(),\n};\n\nexport interface RemoveStaleQueryCopiesOptions {\n /** Default: IndexedDB (idb-keyval), the store {@link createQueryPersistence} writes to. */\n storage?: QueryPersistenceStorage;\n /** One key to spare whatever its age — the signed-in account's own\n * (`QueryPersistence.removeStale` passes its key). */\n keep?: string;\n}\n\n/**\n * 0.46 (kastlan K2): deletes this app's persisted query caches (`<app>-query-cache-…`)\n * older than `maxAge`, with no persistence at hand — for the sign-in page, where no\n * account is known. A session that ended with no user known (a refresh token refused at\n * boot) cannot name its key for `clear`; this sweeps its copy once it is stale, without\n * waiting for someone's next sign-in. Other apps' keys, other stores' keys and fresh\n * caches stay; an unreadable one is stale. Never throws.\n * {@link QueryPersistence.removeStale} is this with `keep` = its own key.\n */\nexport async function removeStaleQueryCopies(\n app: string,\n maxAge: number,\n { storage = idbStorage, keep }: RemoveStaleQueryCopiesOptions = {},\n): Promise<void> {\n const prefix = `${app}-query-cache-`;\n try {\n const now = Date.now();\n for (const k of await storage.keys()) {\n if (typeof k !== \"string\" || !k.startsWith(prefix) || k === keep) continue;\n let timestamp = 0;\n try {\n const raw = await storage.getItem(k);\n const parsed: unknown = raw ? JSON.parse(raw) : null;\n if (parsed && typeof parsed === \"object\" && typeof (parsed as PersistedClient).timestamp === \"number\") {\n timestamp = (parsed as PersistedClient).timestamp;\n }\n } catch {\n /* unreadable: stale */\n }\n if (now - timestamp > maxAge) await storage.removeItem(k);\n }\n } catch {\n /* best-effort */\n }\n}\n\n/**\n * True when `data` still means the same thing after JSON.stringify and JSON.parse —\n * keksdose's `isCacheSafe` (query-client.ts:165). Class instances, Map/Set/Date/Blob, a\n * non-finite number, a function: all refused. An `undefined` property survives.\n */\nexport function isCacheSafe(data: unknown, depth = 0): boolean {\n if (depth > 12) return false;\n if (data === null) return true;\n const t = typeof data;\n if (t === \"string\" || t === \"boolean\") return true;\n if (t === \"number\") return Number.isFinite(data as number);\n if (t !== \"object\") return false;\n if (Array.isArray(data)) return data.every((v) => isCacheSafe(v, depth + 1));\n const proto: unknown = Object.getPrototypeOf(data);\n if (proto !== Object.prototype && proto !== null) return false;\n return Object.values(data as Record<string, unknown>).every((v) => v === undefined || isCacheSafe(v, depth + 1));\n}\n\n/** A copy of `data` without the scrubbed fields, at any depth. The input is not touched. */\nfunction scrub(data: unknown, fields: readonly ScrubField[]): unknown {\n if (fields.length === 0 || data === null || typeof data !== \"object\") return data;\n if (Array.isArray(data)) return data.map((v) => scrub(v, fields));\n const out: Record<string, unknown> = {};\n for (const [name, value] of Object.entries(data as Record<string, unknown>)) {\n if (fields.some((f) => (typeof f === \"string\" ? f === name : f.test(name)))) continue;\n out[name] = scrub(value, fields);\n }\n return out;\n}\n\nconst defaultOnUnsafe = (queryKey: readonly unknown[]) => {\n console.error(\n `[query-cache] refusing to persist ${JSON.stringify(queryKey)}: the value is not plain JSON ` +\n `(a class instance, Map, Set, Date or Blob). Return plain data from the queryFn and build ` +\n `the rich object at the call site.`,\n );\n};\n\nexport function createQueryPersistence({\n app,\n user,\n company,\n shouldPersistKey,\n denyRoots = [],\n scrubFields = DEFAULT_SCRUB_FIELDS,\n onUnsafe = defaultOnUnsafe,\n buster,\n maxAge,\n storage = idbStorage,\n throttleTime = 1000,\n persistMutations = true,\n}: QueryPersistenceOptions): QueryPersistence {\n const prefix = `${app}-query-cache-`;\n const key = `${prefix}${user}${company === undefined || company === null || company === \"\" ? \"\" : `-${company}`}`;\n const denied = new Set(denyRoots);\n // Set by `leave` and `clear`: a write under this key after the person left it (a\n // throttled save of the emptied cache) is dropped. A restore re-arms it.\n let retired = false;\n\n const keyAllowed = (queryKey: readonly unknown[]) =>\n !denied.has(String(queryKey[0])) && (shouldPersistKey ? shouldPersistKey(queryKey) : true);\n\n /** The write side: a success, an allowed key, plain JSON (or the warning). */\n const shouldDehydrateQuery = (query: {\n queryKey: readonly unknown[];\n state: { status: string; data?: unknown };\n }): boolean => {\n if (query.state.status !== \"success\" || !keyAllowed(query.queryKey)) return false;\n if (isCacheSafe(query.state.data)) return true;\n onUnsafe(query.queryKey, query.state.data);\n return false;\n };\n\n /** One persisted query entry, scrubbed; `null` when it may not be restored. */\n const restorable = (entry: unknown): unknown => {\n if (!entry || typeof entry !== \"object\") return null;\n const { queryKey, state } = entry as { queryKey?: unknown; state?: { data?: unknown } };\n if (!Array.isArray(queryKey) || !keyAllowed(queryKey)) return null;\n if (!isCacheSafe(state?.data)) return null;\n return withScrubbedData(entry);\n };\n\n /** Whether `value` has a scrubbed field's name anywhere in it. */\n const carriesScrubbed = (value: unknown, depth = 0): boolean => {\n if (scrubFields.length === 0 || depth > 12 || value === null || typeof value !== \"object\") return false;\n if (Array.isArray(value)) return value.some((v) => carriesScrubbed(v, depth + 1));\n return Object.entries(value as Record<string, unknown>).some(\n ([name, v]) =>\n scrubFields.some((f) => (typeof f === \"string\" ? f === name : f.test(name))) || carriesScrubbed(v, depth + 1),\n );\n };\n\n /** The write side for mutations: TanStack's default (paused ones), minus any whose\n * variables carry a scrubbed field (S6); none at all with `persistMutations: false`. */\n const shouldDehydrateMutation = (mutation: { state: { isPaused: boolean; variables?: unknown } }): boolean =>\n persistMutations && mutation.state.isPaused && !carriesScrubbed(mutation.state.variables);\n\n const withScrubbedData = (entry: unknown): unknown => {\n const query = entry as { state?: { data?: unknown } };\n if (!query.state || !(\"data\" in query.state)) return entry;\n return { ...query, state: { ...query.state, data: scrub(query.state.data, scrubFields) } };\n };\n\n // The scrub on the way OUT: the in-memory cache keeps the field (the page that is open\n // shows it), the disk never sees it. Mutations are a paused write queue and are kept\n // whole — scrubbing one would change what it sends — so one carrying a scrubbed field is\n // not written at all (`shouldDehydrateMutation`).\n const serialize = (client: PersistedClient): string =>\n JSON.stringify({\n ...client,\n clientState: { ...client.clientState, queries: client.clientState.queries.map(withScrubbedData) },\n });\n\n // The twin on the way IN (keksdose's `sanitizePersistedCache`, :264-298): an older build\n // wrote what this one may deny. Never throws; an unreadable blob comes back with\n // `timestamp: 0`, which the restore treats as unusable and removes.\n const deserialize = (raw: string): PersistedClient => {\n const empty: PersistedClient = { timestamp: 0, buster: \"\", clientState: { mutations: [], queries: [] } };\n try {\n const parsed: unknown = JSON.parse(raw);\n if (!parsed || typeof parsed !== \"object\") return empty;\n const { timestamp, buster: stored, clientState } = parsed as Partial<PersistedClient>;\n if (typeof timestamp !== \"number\" || typeof stored !== \"string\") return empty;\n if (!clientState || typeof clientState !== \"object\" || !Array.isArray(clientState.queries)) return empty;\n return {\n timestamp,\n buster: stored,\n clientState: {\n mutations: persistMutations && Array.isArray(clientState.mutations)\n ? clientState.mutations.filter(\n (m) => !carriesScrubbed((m as { state?: { variables?: unknown } } | null)?.state?.variables),\n )\n : [],\n queries: clientState.queries.map(restorable).filter((q) => q !== null) as PersistedClient[\"clientState\"][\"queries\"],\n },\n };\n } catch {\n return empty;\n }\n };\n\n const persister = createAsyncStoragePersister({\n storage: {\n // Only the provider's restore reads: mounting this persistence again re-arms it.\n getItem: async (k) => {\n retired = false;\n return (await storage.getItem(k)) ?? null;\n },\n setItem: async (k, v) => {\n if (!retired) await storage.setItem(k, v);\n },\n removeItem: async (k) => {\n await storage.removeItem(k);\n },\n },\n key,\n throttleTime,\n serialize,\n deserialize,\n });\n\n const leave = async (queryClient: QueryClient) => {\n retired = true;\n try {\n queryClient.clear();\n } catch {\n /* best-effort */\n }\n };\n\n const clear = async (queryClient: QueryClient) => {\n retired = true;\n try {\n queryClient.clear();\n await persister.removeClient();\n } catch {\n /* best-effort: private mode / IndexedDB unavailable */\n }\n };\n\n const removeStale = () => removeStaleQueryCopies(app, maxAge, { storage, keep: key });\n\n return {\n key,\n persistOptions: { persister, maxAge, buster, dehydrateOptions: { shouldDehydrateQuery, shouldDehydrateMutation } },\n clear,\n leave,\n removeStale,\n };\n}\n"],"mappings":"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","names":[]}
@@ -0,0 +1,46 @@
1
+ /**
2
+ * vite-plugin-pwa's `registerSW` from `virtual:pwa-register` (`registerType: "prompt"`) —
3
+ * the app passes it in, since a virtual module is the app's build's, not the kit's. It
4
+ * answers the `updateSW(reloadPage)` function.
5
+ */
6
+ export type RegisterServiceWorker = (options: {
7
+ onNeedRefresh?: () => void;
8
+ onOfflineReady?: () => void;
9
+ }) => (reloadPage?: boolean) => Promise<void>;
10
+ /** What {@link createServiceWorkerUpdates} answers: one store per app, a module constant. */
11
+ export interface ServiceWorkerUpdates {
12
+ /** Registers the service worker — once, however often it is called. Call it at start,
13
+ * before the router renders, so the sign-in pages get the offline shell too. */
14
+ start: () => void;
15
+ /** Whether a new version waits. */
16
+ needRefresh: () => boolean;
17
+ /** For `useSyncExternalStore`. */
18
+ subscribe: (listener: () => void) => () => void;
19
+ /** Activates the waiting version and reloads (`updateSW(true)`). Before `start`, nothing. */
20
+ reload: () => void;
21
+ /** The person closed the prompt: the version keeps waiting, the prompt stays down. */
22
+ dismiss: () => void;
23
+ }
24
+ /**
25
+ * 0.46 (kastlan K3, docs/local-first-harmonization.md §1.5 D8): the service worker
26
+ * registered ONCE AT START, and its "a new version waits" state as a small store the
27
+ * prompt reads wherever it lives. vite-plugin-pwa's `useRegisterSW` registers when the
28
+ * component that calls it mounts: an app whose {@link PwaUpdatePrompt} sits in its
29
+ * signed-in shell (it reads the tour state, the route) would register only after sign-in,
30
+ * and the sign-in pages would lose the offline shell on a first visit.
31
+ *
32
+ * ```ts
33
+ * // shared/pwa/sw-updates.ts
34
+ * import { registerSW } from "virtual:pwa-register";
35
+ * export const swUpdates = createServiceWorkerUpdates(registerSW);
36
+ * // main.tsx, before render: swUpdates.start();
37
+ * // the shell: <PwaUpdatePrompt {...useServiceWorkerUpdate(swUpdates)} hold={tourRunning} />
38
+ * ```
39
+ */
40
+ export declare function createServiceWorkerUpdates(register: RegisterServiceWorker): ServiceWorkerUpdates;
41
+ /** {@link PwaUpdatePrompt}'s props from the store: `needRefresh`, `onReload`, `onDismiss`. */
42
+ export declare function useServiceWorkerUpdate(updates: ServiceWorkerUpdates): {
43
+ needRefresh: boolean;
44
+ onReload: () => void;
45
+ onDismiss: () => void;
46
+ };
@@ -0,0 +1,39 @@
1
+ "use client";
2
+ import { useSyncExternalStore } from "react";
3
+ function createServiceWorkerUpdates(register) {
4
+ let waiting = false;
5
+ let update = null;
6
+ const listeners = /* @__PURE__ */ new Set();
7
+ const emit = () => listeners.forEach((listener) => listener());
8
+ const set = (value) => {
9
+ if (waiting === value) return;
10
+ waiting = value;
11
+ emit();
12
+ };
13
+ return {
14
+ start: () => {
15
+ if (update) return;
16
+ update = register({ onNeedRefresh: () => set(true) });
17
+ },
18
+ needRefresh: () => waiting,
19
+ subscribe: (listener) => {
20
+ listeners.add(listener);
21
+ return () => {
22
+ listeners.delete(listener);
23
+ };
24
+ },
25
+ reload: () => {
26
+ void update?.(true);
27
+ },
28
+ dismiss: () => set(false)
29
+ };
30
+ }
31
+ function useServiceWorkerUpdate(updates) {
32
+ const needRefresh = useSyncExternalStore(updates.subscribe, updates.needRefresh, () => false);
33
+ return { needRefresh, onReload: updates.reload, onDismiss: updates.dismiss };
34
+ }
35
+ export {
36
+ createServiceWorkerUpdates,
37
+ useServiceWorkerUpdate
38
+ };
39
+ //# sourceMappingURL=service-worker-updates.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/offline/service-worker-updates.ts"],"sourcesContent":["import { useSyncExternalStore } from \"react\";\n\n/**\n * vite-plugin-pwa's `registerSW` from `virtual:pwa-register` (`registerType: \"prompt\"`) —\n * the app passes it in, since a virtual module is the app's build's, not the kit's. It\n * answers the `updateSW(reloadPage)` function.\n */\nexport type RegisterServiceWorker = (options: {\n onNeedRefresh?: () => void;\n onOfflineReady?: () => void;\n}) => (reloadPage?: boolean) => Promise<void>;\n\n/** What {@link createServiceWorkerUpdates} answers: one store per app, a module constant. */\nexport interface ServiceWorkerUpdates {\n /** Registers the service worker — once, however often it is called. Call it at start,\n * before the router renders, so the sign-in pages get the offline shell too. */\n start: () => void;\n /** Whether a new version waits. */\n needRefresh: () => boolean;\n /** For `useSyncExternalStore`. */\n subscribe: (listener: () => void) => () => void;\n /** Activates the waiting version and reloads (`updateSW(true)`). Before `start`, nothing. */\n reload: () => void;\n /** The person closed the prompt: the version keeps waiting, the prompt stays down. */\n dismiss: () => void;\n}\n\n/**\n * 0.46 (kastlan K3, docs/local-first-harmonization.md §1.5 D8): the service worker\n * registered ONCE AT START, and its \"a new version waits\" state as a small store the\n * prompt reads wherever it lives. vite-plugin-pwa's `useRegisterSW` registers when the\n * component that calls it mounts: an app whose {@link PwaUpdatePrompt} sits in its\n * signed-in shell (it reads the tour state, the route) would register only after sign-in,\n * and the sign-in pages would lose the offline shell on a first visit.\n *\n * ```ts\n * // shared/pwa/sw-updates.ts\n * import { registerSW } from \"virtual:pwa-register\";\n * export const swUpdates = createServiceWorkerUpdates(registerSW);\n * // main.tsx, before render: swUpdates.start();\n * // the shell: <PwaUpdatePrompt {...useServiceWorkerUpdate(swUpdates)} hold={tourRunning} />\n * ```\n */\nexport function createServiceWorkerUpdates(register: RegisterServiceWorker): ServiceWorkerUpdates {\n let waiting = false;\n let update: ((reloadPage?: boolean) => Promise<void>) | null = null;\n const listeners = new Set<() => void>();\n const emit = () => listeners.forEach((listener) => listener());\n const set = (value: boolean) => {\n if (waiting === value) return;\n waiting = value;\n emit();\n };\n return {\n start: () => {\n if (update) return;\n update = register({ onNeedRefresh: () => set(true) });\n },\n needRefresh: () => waiting,\n subscribe: (listener) => {\n listeners.add(listener);\n return () => {\n listeners.delete(listener);\n };\n },\n reload: () => {\n void update?.(true);\n },\n dismiss: () => set(false),\n };\n}\n\n/** {@link PwaUpdatePrompt}'s props from the store: `needRefresh`, `onReload`, `onDismiss`. */\nexport function useServiceWorkerUpdate(updates: ServiceWorkerUpdates): {\n needRefresh: boolean;\n onReload: () => void;\n onDismiss: () => void;\n} {\n const needRefresh = useSyncExternalStore(updates.subscribe, updates.needRefresh, () => false);\n return { needRefresh, onReload: updates.reload, onDismiss: updates.dismiss };\n}\n"],"mappings":";AAAA,SAAS,4BAA4B;AA2C9B,SAAS,2BAA2B,UAAuD;AAChG,MAAI,UAAU;AACd,MAAI,SAA2D;AAC/D,QAAM,YAAY,oBAAI,IAAgB;AACtC,QAAM,OAAO,MAAM,UAAU,QAAQ,CAAC,aAAa,SAAS,CAAC;AAC7D,QAAM,MAAM,CAAC,UAAmB;AAC9B,QAAI,YAAY,MAAO;AACvB,cAAU;AACV,SAAK;AAAA,EACP;AACA,SAAO;AAAA,IACL,OAAO,MAAM;AACX,UAAI,OAAQ;AACZ,eAAS,SAAS,EAAE,eAAe,MAAM,IAAI,IAAI,EAAE,CAAC;AAAA,IACtD;AAAA,IACA,aAAa,MAAM;AAAA,IACnB,WAAW,CAAC,aAAa;AACvB,gBAAU,IAAI,QAAQ;AACtB,aAAO,MAAM;AACX,kBAAU,OAAO,QAAQ;AAAA,MAC3B;AAAA,IACF;AAAA,IACA,QAAQ,MAAM;AACZ,WAAK,SAAS,IAAI;AAAA,IACpB;AAAA,IACA,SAAS,MAAM,IAAI,KAAK;AAAA,EAC1B;AACF;AAGO,SAAS,uBAAuB,SAIrC;AACA,QAAM,cAAc,qBAAqB,QAAQ,WAAW,QAAQ,aAAa,MAAM,KAAK;AAC5F,SAAO,EAAE,aAAa,UAAU,QAAQ,QAAQ,WAAW,QAAQ,QAAQ;AAC7E;","names":[]}
package/dist/offline.d.ts CHANGED
@@ -1,8 +1,11 @@
1
- export { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe } from "./offline/query-persistence.js";
2
- export type { QueryPersistence, QueryPersistenceOptions, QueryPersistenceStorage, ScrubField, } from "./offline/query-persistence.js";
1
+ export { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe, removeStaleQueryCopies } from "./offline/query-persistence.js";
2
+ export type { QueryPersistence, QueryPersistenceOptions, QueryPersistenceStorage, RemoveStaleQueryCopiesOptions, ScrubField, } from "./offline/query-persistence.js";
3
3
  export { SignOutPendingDialog, DEFAULT_SIGN_OUT_PENDING_DIALOG_LABELS } from "./offline/sign-out-pending-dialog.js";
4
4
  export type { SignOutPendingDialogProps, SignOutPendingDialogLabels } from "./offline/sign-out-pending-dialog.js";
5
5
  export { PwaUpdatePrompt, DEFAULT_PWA_UPDATE_PROMPT_LABELS } from "./offline/pwa-update-prompt.js";
6
6
  export type { PwaUpdatePromptProps, PwaUpdatePromptLabels } from "./offline/pwa-update-prompt.js";
7
+ export { createServiceWorkerUpdates, useServiceWorkerUpdate } from "./offline/service-worker-updates.js";
8
+ export type { RegisterServiceWorker, ServiceWorkerUpdates } from "./offline/service-worker-updates.js";
7
9
  export { SharedComputerChoice, DEFAULT_SHARED_COMPUTER_LABELS, isSharedComputer, setSharedComputer, } from "./offline/shared-computer.js";
8
10
  export type { SharedComputerChoiceProps, SharedComputerLabels, SharedComputerStorage } from "./offline/shared-computer.js";
11
+ export { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from "./offline/local-first.js";
package/dist/offline.js CHANGED
@@ -1,13 +1,15 @@
1
1
  "use client";
2
- import { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe } from "./offline/query-persistence.js";
2
+ import { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe, removeStaleQueryCopies } from "./offline/query-persistence.js";
3
3
  import { SignOutPendingDialog, DEFAULT_SIGN_OUT_PENDING_DIALOG_LABELS } from "./offline/sign-out-pending-dialog.js";
4
4
  import { PwaUpdatePrompt, DEFAULT_PWA_UPDATE_PROMPT_LABELS } from "./offline/pwa-update-prompt.js";
5
+ import { createServiceWorkerUpdates, useServiceWorkerUpdate } from "./offline/service-worker-updates.js";
5
6
  import {
6
7
  SharedComputerChoice,
7
8
  DEFAULT_SHARED_COMPUTER_LABELS,
8
9
  isSharedComputer,
9
10
  setSharedComputer
10
11
  } from "./offline/shared-computer.js";
12
+ import { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from "./offline/local-first.js";
11
13
  export {
12
14
  DEFAULT_PWA_UPDATE_PROMPT_LABELS,
13
15
  DEFAULT_SCRUB_FIELDS,
@@ -17,8 +19,15 @@ export {
17
19
  SharedComputerChoice,
18
20
  SignOutPendingDialog,
19
21
  createQueryPersistence,
22
+ createServiceWorkerUpdates,
20
23
  isCacheSafe,
24
+ isOfflineError,
21
25
  isSharedComputer,
22
- setSharedComputer
26
+ readLocalFirst,
27
+ removeStaleQueryCopies,
28
+ setSharedComputer,
29
+ stampAfter,
30
+ useServiceWorkerUpdate,
31
+ writeLocalFirst
23
32
  };
24
33
  //# sourceMappingURL=offline.js.map
@@ -1 +1 @@
1
- {"version":3,"sources":["../src/offline.ts"],"sourcesContent":["// `@eifi1/ui-kit/offline` — local-first phase 1 (docs/local-first-harmonization.md §1.6,\n// 0.45): the persisted query cache with its scrub list, the sign-out question while changes\n// wait, the service-worker update prompt, and the shared-computer choice.\n//\n// The only entry point that requires `@tanstack/react-query`,\n// `@tanstack/react-query-persist-client`, `@tanstack/query-async-storage-persister` and\n// `idb-keyval` — optional peers for that reason. NOT re-exported from the main barrel, which\n// would make them everyone's install; the packaging contract test holds that line.\nexport { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe } from \"./offline/query-persistence\";\nexport type {\n QueryPersistence,\n QueryPersistenceOptions,\n QueryPersistenceStorage,\n ScrubField,\n} from \"./offline/query-persistence\";\nexport { SignOutPendingDialog, DEFAULT_SIGN_OUT_PENDING_DIALOG_LABELS } from \"./offline/sign-out-pending-dialog\";\nexport type { SignOutPendingDialogProps, SignOutPendingDialogLabels } from \"./offline/sign-out-pending-dialog\";\nexport { PwaUpdatePrompt, DEFAULT_PWA_UPDATE_PROMPT_LABELS } from \"./offline/pwa-update-prompt\";\nexport type { PwaUpdatePromptProps, PwaUpdatePromptLabels } from \"./offline/pwa-update-prompt\";\nexport {\n SharedComputerChoice,\n DEFAULT_SHARED_COMPUTER_LABELS,\n isSharedComputer,\n setSharedComputer,\n} from \"./offline/shared-computer\";\nexport type { SharedComputerChoiceProps, SharedComputerLabels, SharedComputerStorage } from \"./offline/shared-computer\";\n"],"mappings":";AAQA,SAAS,wBAAwB,sBAAsB,mBAAmB;AAO1E,SAAS,sBAAsB,8CAA8C;AAE7E,SAAS,iBAAiB,wCAAwC;AAElE;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;","names":[]}
1
+ {"version":3,"sources":["../src/offline.ts"],"sourcesContent":["// `@eifi1/ui-kit/offline` — local-first phase 1 (docs/local-first-harmonization.md §1.6,\n// 0.45): the persisted query cache with its scrub list, the sign-out question while changes\n// wait, the service-worker update prompt, and the shared-computer choice.\n//\n// The only entry point that requires `@tanstack/react-query`,\n// `@tanstack/react-query-persist-client`, `@tanstack/query-async-storage-persister` and\n// `idb-keyval` — optional peers for that reason. NOT re-exported from the main barrel, which\n// would make them everyone's install; the packaging contract test holds that line.\nexport { createQueryPersistence, DEFAULT_SCRUB_FIELDS, isCacheSafe, removeStaleQueryCopies } from \"./offline/query-persistence\";\nexport type {\n QueryPersistence,\n QueryPersistenceOptions,\n QueryPersistenceStorage,\n RemoveStaleQueryCopiesOptions,\n ScrubField,\n} from \"./offline/query-persistence\";\nexport { SignOutPendingDialog, DEFAULT_SIGN_OUT_PENDING_DIALOG_LABELS } from \"./offline/sign-out-pending-dialog\";\nexport type { SignOutPendingDialogProps, SignOutPendingDialogLabels } from \"./offline/sign-out-pending-dialog\";\nexport { PwaUpdatePrompt, DEFAULT_PWA_UPDATE_PROMPT_LABELS } from \"./offline/pwa-update-prompt\";\nexport type { PwaUpdatePromptProps, PwaUpdatePromptLabels } from \"./offline/pwa-update-prompt\";\n// 0.46 (kastlan K3): the service worker registered once at start, its update state as a store.\nexport { createServiceWorkerUpdates, useServiceWorkerUpdate } from \"./offline/service-worker-updates\";\nexport type { RegisterServiceWorker, ServiceWorkerUpdates } from \"./offline/service-worker-updates\";\nexport {\n SharedComputerChoice,\n DEFAULT_SHARED_COMPUTER_LABELS,\n isSharedComputer,\n setSharedComputer,\n} from \"./offline/shared-computer\";\nexport type { SharedComputerChoiceProps, SharedComputerLabels, SharedComputerStorage } from \"./offline/shared-computer\";\n// 0.46 (§2.3): the leaf utilities, shared with `@eifi1/ui-kit/sync` from one module that imports no peer.\nexport { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from \"./offline/local-first\";\n"],"mappings":";AAQA,SAAS,wBAAwB,sBAAsB,aAAa,8BAA8B;AAQlG,SAAS,sBAAsB,8CAA8C;AAE7E,SAAS,iBAAiB,wCAAwC;AAGlE,SAAS,4BAA4B,8BAA8B;AAEnE;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;AAGP,SAAS,gBAAgB,gBAAgB,YAAY,uBAAuB;","names":[]}
@@ -0,0 +1,2 @@
1
+ export { PUSH_SUBSCRIPTION_CHANGED, handlePushSubscriptionChange, pushSubscriptionInput, resubscribeAfterChange, } from "./notifications/push-worker.js";
2
+ export type { PushReplaceInput, PushResubscribeOutcome, PushSubscriptionChangeEventLike, PushSubscriptionChangeOptions, PushSubscriptionInput, PushWorkerScope, } from "./notifications/push-worker.js";
@@ -0,0 +1,14 @@
1
+ "use client";
2
+ import {
3
+ PUSH_SUBSCRIPTION_CHANGED,
4
+ handlePushSubscriptionChange,
5
+ pushSubscriptionInput,
6
+ resubscribeAfterChange
7
+ } from "./notifications/push-worker.js";
8
+ export {
9
+ PUSH_SUBSCRIPTION_CHANGED,
10
+ handlePushSubscriptionChange,
11
+ pushSubscriptionInput,
12
+ resubscribeAfterChange
13
+ };
14
+ //# sourceMappingURL=push-worker.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/push-worker.ts"],"sourcesContent":["// `@eifi1/ui-kit/push-worker` — the service worker's half of Web Push (0.46,\n// docs/notifications-harmonization.md §10.6 rule 5): re-registering a subscription the browser\n// rotated. A worker bundle imports from here, never from the main barrel, which reaches React\n// and the DOM components:\n//\n// // sw.ts\n// import { handlePushSubscriptionChange } from \"@eifi1/ui-kit/push-worker\";\n//\n// Worker-safe: this entry reaches no package and no module but src/notifications/push-worker.ts\n// (packaging-contract.test.ts, scripts/check-module-graph.mjs, scripts/verify-package.mjs).\n// The main barrel keeps re-exporting the same names, so an existing import keeps working.\nexport {\n PUSH_SUBSCRIPTION_CHANGED,\n handlePushSubscriptionChange,\n pushSubscriptionInput,\n resubscribeAfterChange,\n} from \"./notifications/push-worker\";\nexport type {\n PushReplaceInput,\n PushResubscribeOutcome,\n PushSubscriptionChangeEventLike,\n PushSubscriptionChangeOptions,\n PushSubscriptionInput,\n PushWorkerScope,\n} from \"./notifications/push-worker\";\n"],"mappings":";AAWA;AAAA,EACE;AAAA,EACA;AAAA,EACA;AAAA,EACA;AAAA,OACK;","names":[]}
@@ -0,0 +1,33 @@
1
+ import { type AppearanceLabels } from "../components/appearance-labels.js";
2
+ import type { LabelOverride } from "../i18n/kit-labels.js";
3
+ import { type TextSize } from "../theme/text-size.js";
4
+ export interface AccountMenuTextSizeProps {
5
+ /** The size in force. Default: the one on `<html>` ({@link useTextSize}), which is the
6
+ * size whoever applied it put there. */
7
+ value?: TextSize;
8
+ /**
9
+ * A step: the next size down or up. The app applies it and keeps it as its Settings ▸
10
+ * Appearance does — `useAccountAppearance`'s `pickTextSize` (this device, and the
11
+ * account when signed in), with `appearance.saveFailed` as the toast when the account
12
+ * refused. The kit does not apply it itself, so the two places can never disagree.
13
+ */
14
+ onPick: (size: TextSize) => void;
15
+ /** The row's name. Default: `appearance.textSize` ("Text size"). */
16
+ label?: string;
17
+ /** The `appearance` words (`textSize`, `textSizes`, `textSmaller`, `textLarger`). */
18
+ labels?: LabelOverride<AppearanceLabels>;
19
+ }
20
+ /**
21
+ * The text size one tap away, INSIDE the account menu (0.46, keksdose live #426, its
22
+ * `TextSizeRow`): the row's icon and name, the size in force, and A− / A+, which step
23
+ * through {@link TEXT_SIZES} (Normal → Large → Extra large). A− is disabled at the
24
+ * smallest, A+ at the largest. The browser's pinch zoom stays as it is — this is the
25
+ * quick path to the setting, not a replacement for zoom.
26
+ *
27
+ * It renders its own `<li>`: drop it into `TopBarActionMenu`'s `children` beside
28
+ * `AccountMenuLanguage`. The menu promotes the two buttons to `menuitem`s, so ↑/↓ reach
29
+ * them like every other row, and it stays open on a step: the person sees the page grow
30
+ * behind it and can step again. The size's name is a polite live region, so a step is
31
+ * announced.
32
+ */
33
+ export declare function AccountMenuTextSize({ value, onPick, label, labels: labelsProp }: AccountMenuTextSizeProps): import("react").JSX.Element;
@@ -0,0 +1,51 @@
1
+ "use client";
2
+ import { jsx, jsxs } from "react/jsx-runtime";
3
+ import { AArrowDown, AArrowUp, ALargeSmall } from "lucide-react";
4
+ import { IconButton } from "../components/icon-button.js";
5
+ import { useAppearanceLabels } from "../components/appearance-labels.js";
6
+ import { TEXT_SIZES, useTextSize } from "../theme/text-size.js";
7
+ function AccountMenuTextSize({ value, onPick, label, labels: labelsProp }) {
8
+ const labels = useAppearanceLabels(labelsProp);
9
+ const onHtml = useTextSize().size;
10
+ const size = value ?? onHtml;
11
+ const index = TEXT_SIZES.indexOf(size);
12
+ const step = (by) => {
13
+ const next = TEXT_SIZES[index + by];
14
+ if (next) onPick(next);
15
+ };
16
+ return /* @__PURE__ */ jsxs("li", { "data-account-menu-text-size": "", className: "flex items-center gap-2 px-3 py-1.5 text-sm", children: [
17
+ /* @__PURE__ */ jsx(ALargeSmall, { "aria-hidden": true, className: "size-4 shrink-0 text-muted" }),
18
+ /* @__PURE__ */ jsxs("span", { className: "min-w-0 flex-1 truncate", children: [
19
+ label ?? labels.textSize,
20
+ /* @__PURE__ */ jsx("span", { className: "ms-2 text-xs text-muted", "aria-live": "polite", children: labels.textSizes[size] })
21
+ ] }),
22
+ /* @__PURE__ */ jsx(
23
+ IconButton,
24
+ {
25
+ size: "sm",
26
+ label: labels.textSmaller,
27
+ labelVisible: false,
28
+ tooltip: false,
29
+ disabled: index <= 0,
30
+ onClick: () => step(-1),
31
+ children: /* @__PURE__ */ jsx(AArrowDown, {})
32
+ }
33
+ ),
34
+ /* @__PURE__ */ jsx(
35
+ IconButton,
36
+ {
37
+ size: "sm",
38
+ label: labels.textLarger,
39
+ labelVisible: false,
40
+ tooltip: false,
41
+ disabled: index >= TEXT_SIZES.length - 1,
42
+ onClick: () => step(1),
43
+ children: /* @__PURE__ */ jsx(AArrowUp, {})
44
+ }
45
+ )
46
+ ] });
47
+ }
48
+ export {
49
+ AccountMenuTextSize
50
+ };
51
+ //# sourceMappingURL=account-menu-text-size.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/shell/account-menu-text-size.tsx"],"sourcesContent":["import { AArrowDown, AArrowUp, ALargeSmall } from \"lucide-react\";\nimport { IconButton } from \"../components/icon-button\";\nimport { useAppearanceLabels, type AppearanceLabels } from \"../components/appearance-labels\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { TEXT_SIZES, useTextSize, type TextSize } from \"../theme/text-size\";\n\nexport interface AccountMenuTextSizeProps {\n /** The size in force. Default: the one on `<html>` ({@link useTextSize}), which is the\n * size whoever applied it put there. */\n value?: TextSize;\n /**\n * A step: the next size down or up. The app applies it and keeps it as its Settings ▸\n * Appearance does — `useAccountAppearance`'s `pickTextSize` (this device, and the\n * account when signed in), with `appearance.saveFailed` as the toast when the account\n * refused. The kit does not apply it itself, so the two places can never disagree.\n */\n onPick: (size: TextSize) => void;\n /** The row's name. Default: `appearance.textSize` (\"Text size\"). */\n label?: string;\n /** The `appearance` words (`textSize`, `textSizes`, `textSmaller`, `textLarger`). */\n labels?: LabelOverride<AppearanceLabels>;\n}\n\n/**\n * The text size one tap away, INSIDE the account menu (0.46, keksdose live #426, its\n * `TextSizeRow`): the row's icon and name, the size in force, and A− / A+, which step\n * through {@link TEXT_SIZES} (Normal → Large → Extra large). A− is disabled at the\n * smallest, A+ at the largest. The browser's pinch zoom stays as it is — this is the\n * quick path to the setting, not a replacement for zoom.\n *\n * It renders its own `<li>`: drop it into `TopBarActionMenu`'s `children` beside\n * `AccountMenuLanguage`. The menu promotes the two buttons to `menuitem`s, so ↑/↓ reach\n * them like every other row, and it stays open on a step: the person sees the page grow\n * behind it and can step again. The size's name is a polite live region, so a step is\n * announced.\n */\nexport function AccountMenuTextSize({ value, onPick, label, labels: labelsProp }: AccountMenuTextSizeProps) {\n const labels = useAppearanceLabels(labelsProp);\n const onHtml = useTextSize().size;\n const size = value ?? onHtml;\n const index = TEXT_SIZES.indexOf(size);\n const step = (by: -1 | 1) => {\n const next = TEXT_SIZES[index + by];\n if (next) onPick(next);\n };\n return (\n <li data-account-menu-text-size=\"\" className=\"flex items-center gap-2 px-3 py-1.5 text-sm\">\n <ALargeSmall aria-hidden className=\"size-4 shrink-0 text-muted\" />\n <span className=\"min-w-0 flex-1 truncate\">\n {label ?? labels.textSize}\n <span className=\"ms-2 text-xs text-muted\" aria-live=\"polite\">\n {labels.textSizes[size]}\n </span>\n </span>\n <IconButton\n size=\"sm\"\n label={labels.textSmaller}\n labelVisible={false}\n tooltip={false}\n disabled={index <= 0}\n onClick={() => step(-1)}\n >\n <AArrowDown />\n </IconButton>\n <IconButton\n size=\"sm\"\n label={labels.textLarger}\n labelVisible={false}\n tooltip={false}\n disabled={index >= TEXT_SIZES.length - 1}\n onClick={() => step(1)}\n >\n <AArrowUp />\n </IconButton>\n </li>\n );\n}\n"],"mappings":";AA+CM,cACA,YADA;AA/CN,SAAS,YAAY,UAAU,mBAAmB;AAClD,SAAS,kBAAkB;AAC3B,SAAS,2BAAkD;AAE3D,SAAS,YAAY,mBAAkC;AAgChD,SAAS,oBAAoB,EAAE,OAAO,QAAQ,OAAO,QAAQ,WAAW,GAA6B;AAC1G,QAAM,SAAS,oBAAoB,UAAU;AAC7C,QAAM,SAAS,YAAY,EAAE;AAC7B,QAAM,OAAO,SAAS;AACtB,QAAM,QAAQ,WAAW,QAAQ,IAAI;AACrC,QAAM,OAAO,CAAC,OAAe;AAC3B,UAAM,OAAO,WAAW,QAAQ,EAAE;AAClC,QAAI,KAAM,QAAO,IAAI;AAAA,EACvB;AACA,SACE,qBAAC,QAAG,+BAA4B,IAAG,WAAU,+CAC3C;AAAA,wBAAC,eAAY,eAAW,MAAC,WAAU,8BAA6B;AAAA,IAChE,qBAAC,UAAK,WAAU,2BACb;AAAA,eAAS,OAAO;AAAA,MACjB,oBAAC,UAAK,WAAU,2BAA0B,aAAU,UACjD,iBAAO,UAAU,IAAI,GACxB;AAAA,OACF;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,OAAO,OAAO;AAAA,QACd,cAAc;AAAA,QACd,SAAS;AAAA,QACT,UAAU,SAAS;AAAA,QACnB,SAAS,MAAM,KAAK,EAAE;AAAA,QAEtB,8BAAC,cAAW;AAAA;AAAA,IACd;AAAA,IACA;AAAA,MAAC;AAAA;AAAA,QACC,MAAK;AAAA,QACL,OAAO,OAAO;AAAA,QACd,cAAc;AAAA,QACd,SAAS;AAAA,QACT,UAAU,SAAS,WAAW,SAAS;AAAA,QACvC,SAAS,MAAM,KAAK,CAAC;AAAA,QAErB,8BAAC,YAAS;AAAA;AAAA,IACZ;AAAA,KACF;AAEJ;","names":[]}
@@ -22,6 +22,10 @@ export interface TopBarActionsProps {
22
22
  dev?: ReactNode;
23
23
  /** kastlan's platform button. */
24
24
  platform?: ReactNode;
25
+ /** 0.46 (docs/notifications-harmonization.md §10.1): `NotificationBell`, right before the
26
+ * account menu. Optional per app: an app shows the bell or an unread dot on the avatar,
27
+ * never both (keksdose keeps its dot and leaves this empty). */
28
+ notifications?: ReactNode;
25
29
  /** The account menu. When it is set the signed-out slots are not drawn. */
26
30
  account?: ReactNode;
27
31
  /** Signed out (§6.6): the bar's `LanguageMenu`, since there is no account menu to hold
@@ -32,11 +36,11 @@ export interface TopBarActionsProps {
32
36
  signIn?: ReactNode;
33
37
  }
34
38
  /** §2.1's order, then the signed-out pair in the account menu's place. */
35
- export declare const TOP_BAR_ACTION_ORDER: readonly ["companySwitcher", "search", "theme", "feedback", "tours", "assistant", "sync", "dev", "platform", "account", "language", "signIn"];
39
+ export declare const TOP_BAR_ACTION_ORDER: readonly ["companySwitcher", "search", "theme", "feedback", "tours", "assistant", "sync", "dev", "platform", "notifications", "account", "language", "signIn"];
36
40
  /**
37
41
  * The top bar's actions in the one order every app shares (§2.1): company switcher ·
38
42
  * Search · Theme · Feedback · Tours (· Assistant) · Sync · dev role · dev plan · platform ·
39
- * account menu — and, signed out, Language · Sign in where the account menu goes (§6.6).
43
+ * notifications (the bell, 0.46) · account menu — and, signed out, Language · Sign in where the account menu goes (§6.6).
40
44
  * The app fills the slots it has; an empty slot is skipped, so it leaves no gap.
41
45
  *
42
46
  * It draws a fragment, no box of its own: hand it to `TopBar`'s `actions`, whose row
@@ -11,6 +11,7 @@ const TOP_BAR_ACTION_ORDER = [
11
11
  "sync",
12
12
  "dev",
13
13
  "platform",
14
+ "notifications",
14
15
  "account",
15
16
  "language",
16
17
  "signIn"