@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
@@ -0,0 +1,67 @@
1
+ "use client";
2
+ import { useCallback, useEffect, useState, useSyncExternalStore } from "react";
3
+ import { liveQuery } from "dexie";
4
+ function useSyncWorker(engine) {
5
+ useEffect(() => {
6
+ if (!engine) return;
7
+ engine.start();
8
+ return () => engine.stop();
9
+ }, [engine]);
10
+ }
11
+ const IDLE = {
12
+ phase: "idle",
13
+ online: true,
14
+ counts: { pending: 0, waiting: 0, blocked: 0, rejected: 0 },
15
+ lastSyncedAt: null,
16
+ nextRetryAt: null
17
+ };
18
+ const noEngine = () => () => void 0;
19
+ const idle = () => IDLE;
20
+ function useSyncStatus(engine) {
21
+ const state = useSyncExternalStore(
22
+ engine ? engine.subscribe : noEngine,
23
+ engine ? engine.getState : idle,
24
+ engine ? engine.getState : idle
25
+ );
26
+ const browserOnline = useSyncExternalStore(subscribeOnline, readOnline, () => true);
27
+ const onSync = useCallback(async () => {
28
+ await engine?.syncNow();
29
+ }, [engine]);
30
+ const status = {
31
+ phase: state.phase,
32
+ online: engine ? state.online : browserOnline,
33
+ pending: state.counts.pending,
34
+ waitingForPlan: state.counts.waiting,
35
+ blocked: state.counts.blocked + state.counts.rejected,
36
+ lastSyncedAt: state.lastSyncedAt
37
+ };
38
+ return { status, onSync };
39
+ }
40
+ function subscribeOnline(listener) {
41
+ window.addEventListener("online", listener);
42
+ window.addEventListener("offline", listener);
43
+ return () => {
44
+ window.removeEventListener("online", listener);
45
+ window.removeEventListener("offline", listener);
46
+ };
47
+ }
48
+ const readOnline = () => navigator.onLine !== false;
49
+ function useSyncQuery(db, query, deps) {
50
+ const [state, setState] = useState({ data: void 0, isLoading: true });
51
+ useEffect(() => {
52
+ if (!db) return;
53
+ const subscription = liveQuery(() => query(db)).subscribe({
54
+ next: (data) => setState({ data, isLoading: false }),
55
+ error: () => setState({ data: void 0, isLoading: false })
56
+ });
57
+ return () => subscription.unsubscribe();
58
+ }, [db, ...deps]);
59
+ return db ? state : NO_COPY;
60
+ }
61
+ const NO_COPY = { data: void 0, isLoading: true };
62
+ export {
63
+ useSyncQuery,
64
+ useSyncStatus,
65
+ useSyncWorker
66
+ };
67
+ //# sourceMappingURL=hooks.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/sync/hooks.ts"],"sourcesContent":["import { useCallback, useEffect, useState, useSyncExternalStore } from \"react\";\nimport { liveQuery } from \"dexie\";\nimport type { SyncStatus } from \"../shell/sync-status-indicator\";\nimport type { SyncDb } from \"./db\";\nimport type { SyncEngine, SyncEngineState } from \"./engine\";\n\n/**\n * Keep `engine` running while it is mounted: started on mount, stopped on unmount or when\n * the engine changes (a company switch builds a new engine on its own database, D15).\n * `null` runs nothing — signed out, or a computer marked shared. Mount once, in the\n * signed-in layout (kastlan's `useHandoverSyncLoop`).\n */\nexport function useSyncWorker(engine: SyncEngine | null): void {\n useEffect(() => {\n if (!engine) return;\n engine.start();\n return () => engine.stop();\n }, [engine]);\n}\n\nconst IDLE: SyncEngineState = {\n phase: \"idle\",\n online: true,\n counts: { pending: 0, waiting: 0, blocked: 0, rejected: 0 },\n lastSyncedAt: null,\n nextRetryAt: null,\n};\nconst noEngine = () => () => undefined;\nconst idle = () => IDLE;\n\n/**\n * The engine's state as the kit's `SyncStatusIndicator` reads it (§2.3): `pending` = queued\n * and in flight (and the steps' pending), `waitingForPlan` = `waiting`, `blocked` = `blocked`\n * + `rejected`, `lastSyncedAt` from `meta`. `onSync` is \"Sync now\". Without an engine, an\n * idle status that follows the browser's connection.\n */\nexport function useSyncStatus(engine: SyncEngine | null): { status: SyncStatus; onSync: () => Promise<void> } {\n const state = useSyncExternalStore(\n engine ? engine.subscribe : noEngine,\n engine ? engine.getState : idle,\n engine ? engine.getState : idle,\n );\n const browserOnline = useSyncExternalStore(subscribeOnline, readOnline, () => true);\n const onSync = useCallback(async () => {\n await engine?.syncNow();\n }, [engine]);\n const status: SyncStatus = {\n phase: state.phase,\n online: engine ? state.online : browserOnline,\n pending: state.counts.pending,\n waitingForPlan: state.counts.waiting,\n blocked: state.counts.blocked + state.counts.rejected,\n lastSyncedAt: state.lastSyncedAt,\n };\n return { status, onSync };\n}\n\nfunction subscribeOnline(listener: () => void) {\n window.addEventListener(\"online\", listener);\n window.addEventListener(\"offline\", listener);\n return () => {\n window.removeEventListener(\"online\", listener);\n window.removeEventListener(\"offline\", listener);\n };\n}\nconst readOnline = () => navigator.onLine !== false;\n\n/**\n * A read of the copy that follows every change to it (Dexie's `liveQuery`; kastlan's\n * `useLive`). `deps` names what `query` reads; `query` itself is rebuilt every render.\n * `query` gets the app's own database type (`HandoverDb`). Without a database (not open\n * yet) it answers `isLoading: true`, so a page shows no \"empty\" before the copy opens.\n */\nexport function useSyncQuery<T, D extends SyncDb = SyncDb>(\n db: D | null,\n query: (db: D) => Promise<T>,\n deps: readonly unknown[],\n): { data: T | undefined; isLoading: boolean } {\n const [state, setState] = useState<{ data: T | undefined; isLoading: boolean }>({ data: undefined, isLoading: true });\n useEffect(() => {\n if (!db) return;\n const subscription = liveQuery(() => query(db)).subscribe({\n next: (data) => setState({ data, isLoading: false }),\n error: () => setState({ data: undefined, isLoading: false }),\n });\n return () => subscription.unsubscribe();\n // `query` is rebuilt every render; `deps` names what it reads.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [db, ...deps]);\n return db ? state : NO_COPY;\n}\n\nconst NO_COPY = { data: undefined, isLoading: true } as const;\n"],"mappings":";AAAA,SAAS,aAAa,WAAW,UAAU,4BAA4B;AACvE,SAAS,iBAAiB;AAWnB,SAAS,cAAc,QAAiC;AAC7D,YAAU,MAAM;AACd,QAAI,CAAC,OAAQ;AACb,WAAO,MAAM;AACb,WAAO,MAAM,OAAO,KAAK;AAAA,EAC3B,GAAG,CAAC,MAAM,CAAC;AACb;AAEA,MAAM,OAAwB;AAAA,EAC5B,OAAO;AAAA,EACP,QAAQ;AAAA,EACR,QAAQ,EAAE,SAAS,GAAG,SAAS,GAAG,SAAS,GAAG,UAAU,EAAE;AAAA,EAC1D,cAAc;AAAA,EACd,aAAa;AACf;AACA,MAAM,WAAW,MAAM,MAAM;AAC7B,MAAM,OAAO,MAAM;AAQZ,SAAS,cAAc,QAAgF;AAC5G,QAAM,QAAQ;AAAA,IACZ,SAAS,OAAO,YAAY;AAAA,IAC5B,SAAS,OAAO,WAAW;AAAA,IAC3B,SAAS,OAAO,WAAW;AAAA,EAC7B;AACA,QAAM,gBAAgB,qBAAqB,iBAAiB,YAAY,MAAM,IAAI;AAClF,QAAM,SAAS,YAAY,YAAY;AACrC,UAAM,QAAQ,QAAQ;AAAA,EACxB,GAAG,CAAC,MAAM,CAAC;AACX,QAAM,SAAqB;AAAA,IACzB,OAAO,MAAM;AAAA,IACb,QAAQ,SAAS,MAAM,SAAS;AAAA,IAChC,SAAS,MAAM,OAAO;AAAA,IACtB,gBAAgB,MAAM,OAAO;AAAA,IAC7B,SAAS,MAAM,OAAO,UAAU,MAAM,OAAO;AAAA,IAC7C,cAAc,MAAM;AAAA,EACtB;AACA,SAAO,EAAE,QAAQ,OAAO;AAC1B;AAEA,SAAS,gBAAgB,UAAsB;AAC7C,SAAO,iBAAiB,UAAU,QAAQ;AAC1C,SAAO,iBAAiB,WAAW,QAAQ;AAC3C,SAAO,MAAM;AACX,WAAO,oBAAoB,UAAU,QAAQ;AAC7C,WAAO,oBAAoB,WAAW,QAAQ;AAAA,EAChD;AACF;AACA,MAAM,aAAa,MAAM,UAAU,WAAW;AAQvC,SAAS,aACd,IACA,OACA,MAC6C;AAC7C,QAAM,CAAC,OAAO,QAAQ,IAAI,SAAsD,EAAE,MAAM,QAAW,WAAW,KAAK,CAAC;AACpH,YAAU,MAAM;AACd,QAAI,CAAC,GAAI;AACT,UAAM,eAAe,UAAU,MAAM,MAAM,EAAE,CAAC,EAAE,UAAU;AAAA,MACxD,MAAM,CAAC,SAAS,SAAS,EAAE,MAAM,WAAW,MAAM,CAAC;AAAA,MACnD,OAAO,MAAM,SAAS,EAAE,MAAM,QAAW,WAAW,MAAM,CAAC;AAAA,IAC7D,CAAC;AACD,WAAO,MAAM,aAAa,YAAY;AAAA,EAGxC,GAAG,CAAC,IAAI,GAAG,IAAI,CAAC;AAChB,SAAO,KAAK,QAAQ;AACtB;AAEA,MAAM,UAAU,EAAE,MAAM,QAAW,WAAW,KAAK;","names":[]}
@@ -0,0 +1,28 @@
1
+ import type { SyncRequestBody, SyncResponseBody } from "./wire.js";
2
+ /** One scripted answer: a body, a function of the request, or a failure. */
3
+ export type FakeSyncAnswer = SyncResponseBody | ((body: SyncRequestBody) => SyncResponseBody | Promise<SyncResponseBody>) | {
4
+ fail: "offline";
5
+ } | {
6
+ fail: number;
7
+ data?: unknown;
8
+ };
9
+ export interface FakeSyncTransport {
10
+ /** The engine's `send`. */
11
+ send(body: SyncRequestBody, signal?: AbortSignal): Promise<SyncResponseBody>;
12
+ /** Every request received, in order (the body as sent). */
13
+ readonly requests: SyncRequestBody[];
14
+ /** Queue answers for the next requests, in order; past them the default answers. */
15
+ answer(...answers: FakeSyncAnswer[]): void;
16
+ /** The next request meets no connection (an axios-like error with no response). */
17
+ failOffline(): void;
18
+ /** The next request answers `status` (a 5xx, a 402 `{code}`, a 403). */
19
+ failStatus(status: number, data?: unknown): void;
20
+ /** The default answer's server knowledge, raised by one for every request. */
21
+ knowledge: number;
22
+ }
23
+ /**
24
+ * The default answer echoes every pushed change as applied, with a server id (`pk`, 100 on),
25
+ * and an empty pull besides; `knowledge` goes up by one each request. Script anything else
26
+ * with `answer(...)`, `failOffline()` and `failStatus(...)`.
27
+ */
28
+ export declare function createFakeSyncTransport(): FakeSyncTransport;
@@ -0,0 +1,41 @@
1
+ "use client";
2
+ function wireError(fail, data) {
3
+ const err = new Error(fail === "offline" ? "Network Error" : `Request failed with status code ${fail}`);
4
+ return Object.assign(err, {
5
+ isAxiosError: true,
6
+ response: fail === "offline" ? void 0 : { status: fail, data: data ?? {} }
7
+ });
8
+ }
9
+ function createFakeSyncTransport() {
10
+ const script = [];
11
+ let nextPk = 100;
12
+ const transport = {
13
+ requests: [],
14
+ knowledge: 0,
15
+ answer: (...answers) => void script.push(...answers),
16
+ failOffline: () => void script.push({ fail: "offline" }),
17
+ failStatus: (status, data) => void script.push({ fail: status, data }),
18
+ async send(body) {
19
+ transport.requests.push(structuredClone(body));
20
+ const next = script.shift();
21
+ if (next && typeof next === "object" && "fail" in next) {
22
+ throw wireError(next.fail, "data" in next ? next.data : void 0);
23
+ }
24
+ if (typeof next === "function") return next(body);
25
+ if (next) return next;
26
+ transport.knowledge += 1;
27
+ return {
28
+ server_knowledge: transport.knowledge,
29
+ changes: body.changes.map((change) => ({ ...change, pk: change.op === "delete" ? null : nextPk++ })),
30
+ rejections: [],
31
+ refused: null,
32
+ page_cursor: null
33
+ };
34
+ }
35
+ };
36
+ return transport;
37
+ }
38
+ export {
39
+ createFakeSyncTransport
40
+ };
41
+ //# sourceMappingURL=testing.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/sync/testing.ts"],"sourcesContent":["import type { SyncRequestBody, SyncResponseBody } from \"./wire\";\n\n/*\n * `@eifi1/ui-kit/sync/testing` — a scripted `/sync` server for an app's tests of the engine\n * (docs/local-first-harmonization.md §2.3). Pass `transport.send` as the engine's `send`.\n * Imports nothing (no Dexie): it is the engine's `send`, not a store.\n */\n\n/** One scripted answer: a body, a function of the request, or a failure. */\nexport type FakeSyncAnswer =\n | SyncResponseBody\n | ((body: SyncRequestBody) => SyncResponseBody | Promise<SyncResponseBody>)\n | { fail: \"offline\" }\n | { fail: number; data?: unknown };\n\nexport interface FakeSyncTransport {\n /** The engine's `send`. */\n send(body: SyncRequestBody, signal?: AbortSignal): Promise<SyncResponseBody>;\n /** Every request received, in order (the body as sent). */\n readonly requests: SyncRequestBody[];\n /** Queue answers for the next requests, in order; past them the default answers. */\n answer(...answers: FakeSyncAnswer[]): void;\n /** The next request meets no connection (an axios-like error with no response). */\n failOffline(): void;\n /** The next request answers `status` (a 5xx, a 402 `{code}`, a 403). */\n failStatus(status: number, data?: unknown): void;\n /** The default answer's server knowledge, raised by one for every request. */\n knowledge: number;\n}\n\n/** The error an axios client throws: `isAxiosError`, and `response` only with an answer. */\nfunction wireError(fail: \"offline\" | number, data?: unknown): Error {\n const err = new Error(fail === \"offline\" ? \"Network Error\" : `Request failed with status code ${fail}`);\n return Object.assign(err, {\n isAxiosError: true,\n response: fail === \"offline\" ? undefined : { status: fail, data: data ?? {} },\n });\n}\n\n/**\n * The default answer echoes every pushed change as applied, with a server id (`pk`, 100 on),\n * and an empty pull besides; `knowledge` goes up by one each request. Script anything else\n * with `answer(...)`, `failOffline()` and `failStatus(...)`.\n */\nexport function createFakeSyncTransport(): FakeSyncTransport {\n const script: FakeSyncAnswer[] = [];\n let nextPk = 100;\n const transport: FakeSyncTransport = {\n requests: [],\n knowledge: 0,\n answer: (...answers) => void script.push(...answers),\n failOffline: () => void script.push({ fail: \"offline\" }),\n failStatus: (status, data) => void script.push({ fail: status, data }),\n async send(body) {\n transport.requests.push(structuredClone(body));\n const next = script.shift();\n if (next && typeof next === \"object\" && \"fail\" in next) {\n throw wireError(next.fail, \"data\" in next ? next.data : undefined);\n }\n if (typeof next === \"function\") return next(body);\n if (next) return next;\n transport.knowledge += 1;\n return {\n server_knowledge: transport.knowledge,\n changes: body.changes.map((change) => ({ ...change, pk: change.op === \"delete\" ? null : nextPk++ })),\n rejections: [],\n refused: null,\n page_cursor: null,\n };\n },\n };\n return transport;\n}\n"],"mappings":";AA+BA,SAAS,UAAU,MAA0B,MAAuB;AAClE,QAAM,MAAM,IAAI,MAAM,SAAS,YAAY,kBAAkB,mCAAmC,IAAI,EAAE;AACtG,SAAO,OAAO,OAAO,KAAK;AAAA,IACxB,cAAc;AAAA,IACd,UAAU,SAAS,YAAY,SAAY,EAAE,QAAQ,MAAM,MAAM,QAAQ,CAAC,EAAE;AAAA,EAC9E,CAAC;AACH;AAOO,SAAS,0BAA6C;AAC3D,QAAM,SAA2B,CAAC;AAClC,MAAI,SAAS;AACb,QAAM,YAA+B;AAAA,IACnC,UAAU,CAAC;AAAA,IACX,WAAW;AAAA,IACX,QAAQ,IAAI,YAAY,KAAK,OAAO,KAAK,GAAG,OAAO;AAAA,IACnD,aAAa,MAAM,KAAK,OAAO,KAAK,EAAE,MAAM,UAAU,CAAC;AAAA,IACvD,YAAY,CAAC,QAAQ,SAAS,KAAK,OAAO,KAAK,EAAE,MAAM,QAAQ,KAAK,CAAC;AAAA,IACrE,MAAM,KAAK,MAAM;AACf,gBAAU,SAAS,KAAK,gBAAgB,IAAI,CAAC;AAC7C,YAAM,OAAO,OAAO,MAAM;AAC1B,UAAI,QAAQ,OAAO,SAAS,YAAY,UAAU,MAAM;AACtD,cAAM,UAAU,KAAK,MAAM,UAAU,OAAO,KAAK,OAAO,MAAS;AAAA,MACnE;AACA,UAAI,OAAO,SAAS,WAAY,QAAO,KAAK,IAAI;AAChD,UAAI,KAAM,QAAO;AACjB,gBAAU,aAAa;AACvB,aAAO;AAAA,QACL,kBAAkB,UAAU;AAAA,QAC5B,SAAS,KAAK,QAAQ,IAAI,CAAC,YAAY,EAAE,GAAG,QAAQ,IAAI,OAAO,OAAO,WAAW,OAAO,SAAS,EAAE;AAAA,QACnG,YAAY,CAAC;AAAA,QACb,SAAS;AAAA,QACT,aAAa;AAAA,MACf;AAAA,IACF;AAAA,EACF;AACA,SAAO;AACT;","names":[]}
@@ -0,0 +1,50 @@
1
+ /** One row's delta on the wire. */
2
+ export interface SyncWireChange {
3
+ entity: string;
4
+ id: string;
5
+ op: "upsert" | "delete";
6
+ data?: Record<string, unknown> | null;
7
+ /** The device's edit time on a push (D2). */
8
+ updated_at?: string | null;
9
+ /** The server's id of the row, on a pull (kastlan). */
10
+ pk?: number | null;
11
+ knowledge?: number | null;
12
+ }
13
+ /** Where a paged pull stopped (server-kit `SyncPageCursor`); handed back as it came. */
14
+ export interface SyncPageCursor {
15
+ entity: string;
16
+ knowledge?: number | null;
17
+ id?: string | null;
18
+ window_end: number;
19
+ }
20
+ export interface SyncRequestBody {
21
+ scope: string;
22
+ last_knowledge: number;
23
+ changes: SyncWireChange[];
24
+ /** This client keeps refused changes and reads `refused` and `rejections` (D1). */
25
+ refusals: true;
26
+ /** The device's clock as the body is built, fresh for every request (D2 amendment). */
27
+ client_now: string;
28
+ paged?: boolean;
29
+ page_cursor?: SyncPageCursor | null;
30
+ }
31
+ /** The WHOLE push refused (a lapsed plan): the pull is served all the same. */
32
+ export interface SyncRefusalBody {
33
+ code: string;
34
+ detail: string;
35
+ change_ids: string[];
36
+ }
37
+ /** One pushed change the server did not apply, with its reason. */
38
+ export interface SyncRejectionBody {
39
+ change_id: string;
40
+ code: string;
41
+ detail: string;
42
+ }
43
+ export interface SyncResponseBody {
44
+ server_knowledge: number;
45
+ changes: SyncWireChange[];
46
+ refused?: SyncRefusalBody | null;
47
+ rejections?: SyncRejectionBody[];
48
+ /** Set while the pull did not fit one page; `null` on the last page (D12). */
49
+ page_cursor?: SyncPageCursor | null;
50
+ }
@@ -0,0 +1,2 @@
1
+ "use client";
2
+ //# sourceMappingURL=wire.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":[],"sourcesContent":[],"mappings":"","names":[]}
package/dist/sync.d.ts ADDED
@@ -0,0 +1,7 @@
1
+ export { createSyncDb, SYNC_DB_STORES } from "./sync/db.js";
2
+ export type { CreateSyncDbOptions, OutboxChange, OutboxStatus, SyncDb, SyncDbUpgrade, SyncedRow, SyncMetaEntry } from "./sync/db.js";
3
+ export { createSyncEngine, DEFAULT_HOLD_CODES, DEFAULT_SYNC_BACKOFF_MS, releaseSyncEngine, syncEngineFor } from "./sync/engine.js";
4
+ export type { SyncCounts, SyncEngine, SyncEngineOptions, SyncEngineState, SyncRoundResult, SyncStep, SyncStepContext, SyncStepResult, } from "./sync/engine.js";
5
+ export { useSyncQuery, useSyncStatus, useSyncWorker } from "./sync/hooks.js";
6
+ export type { SyncPageCursor, SyncRefusalBody, SyncRejectionBody, SyncRequestBody, SyncResponseBody, SyncWireChange, } from "./sync/wire.js";
7
+ export { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from "./offline/local-first.js";
package/dist/sync.js ADDED
@@ -0,0 +1,22 @@
1
+ "use client";
2
+ import { createSyncDb, SYNC_DB_STORES } from "./sync/db.js";
3
+ import { createSyncEngine, DEFAULT_HOLD_CODES, DEFAULT_SYNC_BACKOFF_MS, releaseSyncEngine, syncEngineFor } from "./sync/engine.js";
4
+ import { useSyncQuery, useSyncStatus, useSyncWorker } from "./sync/hooks.js";
5
+ import { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from "./offline/local-first.js";
6
+ export {
7
+ DEFAULT_HOLD_CODES,
8
+ DEFAULT_SYNC_BACKOFF_MS,
9
+ SYNC_DB_STORES,
10
+ createSyncDb,
11
+ createSyncEngine,
12
+ isOfflineError,
13
+ readLocalFirst,
14
+ releaseSyncEngine,
15
+ stampAfter,
16
+ syncEngineFor,
17
+ useSyncQuery,
18
+ useSyncStatus,
19
+ useSyncWorker,
20
+ writeLocalFirst
21
+ };
22
+ //# sourceMappingURL=sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../src/sync.ts"],"sourcesContent":["// `@eifi1/ui-kit/sync` — local-first phase 3 (docs/local-first-harmonization.md §2.3, 0.46):\n// the sync engine on a Dexie copy, its worker and status hooks, and the live read of the copy.\n//\n// The only entry point that imports `dexie` — an optional peer for that reason. It never loads\n// `/offline`'s TanStack and idb-keyval peers: the leaf utilities both entries export live in\n// one module of their own (src/offline/local-first.ts). NOT re-exported from the main barrel.\nexport { createSyncDb, SYNC_DB_STORES } from \"./sync/db\";\nexport type { CreateSyncDbOptions, OutboxChange, OutboxStatus, SyncDb, SyncDbUpgrade, SyncedRow, SyncMetaEntry } from \"./sync/db\";\nexport { createSyncEngine, DEFAULT_HOLD_CODES, DEFAULT_SYNC_BACKOFF_MS, releaseSyncEngine, syncEngineFor } from \"./sync/engine\";\nexport type {\n SyncCounts,\n SyncEngine,\n SyncEngineOptions,\n SyncEngineState,\n SyncRoundResult,\n SyncStep,\n SyncStepContext,\n SyncStepResult,\n} from \"./sync/engine\";\nexport { useSyncQuery, useSyncStatus, useSyncWorker } from \"./sync/hooks\";\nexport type {\n SyncPageCursor,\n SyncRefusalBody,\n SyncRejectionBody,\n SyncRequestBody,\n SyncResponseBody,\n SyncWireChange,\n} from \"./sync/wire\";\nexport { isOfflineError, readLocalFirst, stampAfter, writeLocalFirst } from \"./offline/local-first\";\n"],"mappings":";AAMA,SAAS,cAAc,sBAAsB;AAE7C,SAAS,kBAAkB,oBAAoB,yBAAyB,mBAAmB,qBAAqB;AAWhH,SAAS,cAAc,eAAe,qBAAqB;AAS3D,SAAS,gBAAgB,gBAAgB,YAAY,uBAAuB;","names":[]}
@@ -14,7 +14,12 @@ export interface TourCatalogueCardProps {
14
14
  onLaunch: (id: string) => void;
15
15
  /** "Mark as not done". Without it, the link is not drawn. */
16
16
  onReset?: (id: string) => void;
17
- /** Formats the completion and update days. Default: the kit's medium date in the provider's locale. */
17
+ /**
18
+ * Formats the completion and update days. Default: the kit's medium date in the provider's locale.
19
+ * It gets the completion's ISO instant and the update's bare `"YYYY-MM-DD"` (`updated.date`): read
20
+ * the bare day on its calendar (the kit's `formatDate`, `parseIsoDate`), never `new Date(day)`,
21
+ * which is UTC midnight and the day before west of UTC (round 0.45, N9).
22
+ */
18
23
  formatDate?: (iso: string) => string;
19
24
  labels?: LabelOverride<TourCatalogueLabels>;
20
25
  className?: string;
@@ -44,6 +49,7 @@ export interface TourCatalogueProps extends TourCatalogueFilter {
44
49
  onReset?: (id: string) => void;
45
50
  /** Above the filter bar: the app's page header with its points tile (§6.1). */
46
51
  header?: ReactNode;
52
+ /** Each card's `formatDate`: see {@link TourCatalogueCardProps.formatDate} (a bare day for the update). */
47
53
  formatDate?: (iso: string) => string;
48
54
  /** The search's locale (case folding). Default: the provider's. */
49
55
  locale?: string;
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tour/tour-catalogue.tsx"],"sourcesContent":["import { useId, type ReactNode } from \"react\";\nimport { Circle, CircleCheck, Clock, Play } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFormat } from \"../lib/format\";\nimport { foldForSearch } from \"../lib/text-match\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { Chip } from \"../components/chip\";\nimport { Caption } from \"../components/text\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { SearchField } from \"../components/search-field\";\nimport { Select } from \"../components/select\";\nimport { ToggleGroup } from \"../components/toggle-group\";\nimport { DEFAULT_TOUR_CATALOGUE_LABELS, type TourCatalogueLabels } from \"./tour-catalogue-labels\";\nimport { isTourDone, isTourNew, isTourReset, type TourCompletions, type TourGroup, type TourInfo } from \"./tour-info\";\nimport type { TourCatalogueFilter, TourStateFilter } from \"./use-tour-url-sync\";\n\nexport { DEFAULT_TOUR_CATALOGUE_LABELS };\nexport type { TourCatalogueLabels };\n\n/* ── The card ───────────────────────────────────────────────────────────────── */\n\nexport interface TourCatalogueCardProps {\n tour: TourInfo;\n /** The completion's ISO timestamp, `null` for one without a date; absent while untaken. */\n completedAt?: string | null;\n /** NEW — `isTourNew`. The catalogue works it out; a card on its own is told. */\n isNew?: boolean;\n onLaunch: (id: string) => void;\n /** \"Mark as not done\". Without it, the link is not drawn. */\n onReset?: (id: string) => void;\n /** Formats the completion and update days. Default: the kit's medium date in the provider's locale. */\n formatDate?: (iso: string) => string;\n labels?: LabelOverride<TourCatalogueLabels>;\n className?: string;\n}\n\n/**\n * One tour's card — keksdose's (tours-page.tsx `TourCard`, dev#557), laid out top to\n * bottom so the cards sit side by side: the state icon beside a column holding the tour's\n * icon, title, NEW (or \"Updated\", §8) and the app's `badge`, the one-liner and, from `sm`,\n * the longer account. The foot is pinned to the bottom edge, so every Start in a row of\n * cards lands on one line: the points chip, the step count and the duration, the\n * completion (or reset) line, \"Mark as not done\" and Start / Replay. Points, steps and\n * duration are each left out when the tour has none — no gap, no \"0 points\" (KS).\n */\nexport function TourCatalogueCard({\n tour,\n completedAt,\n isNew = false,\n onLaunch,\n onReset,\n formatDate: formatDateProp,\n labels: labelsProp,\n className,\n}: TourCatalogueCardProps) {\n const words = useKitLabels(\"tourCatalogue\", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);\n const { formatDate } = useKitFormat();\n const day = formatDateProp ?? ((iso: string) => formatDate(iso, \"medium\"));\n const titleId = useId();\n const done = isTourDone(tour, completedAt);\n const reset = isTourReset(tour, completedAt);\n const hasFacts = tour.points != null || tour.steps != null || tour.duration != null;\n const note = done\n ? completedAt\n ? words.completedOn(day(completedAt))\n : words.completed\n : reset && tour.updated\n ? words.resetOn(day(tour.updated.date), tour.updated.reason)\n : null;\n\n return (\n <Card className={cn(\"flex h-full flex-col p-4\", className)}>\n {/* Title, one-liner and the long body share ONE left edge, in the column beside the\n state icon. */}\n <div className=\"flex flex-1 items-start gap-3\">\n <div className={cn(\"mt-0.5 shrink-0\", done ? \"text-success\" : \"text-muted\")}>\n {done ? <CircleCheck aria-hidden className=\"size-5\" /> : <Circle aria-hidden className=\"size-5\" />}\n <span className=\"sr-only\">{done ? words.completed : words.notCompleted}</span>\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n {tour.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 text-brand [&_svg]:size-4\">\n {tour.icon}\n </span>\n )}\n {/* The id on the words, not the heading: the Start button is described by them. */}\n <h3 className=\"font-medium\">\n <span id={titleId}>{tour.title}</span>\n </h3>\n {/* It retires itself the moment the tour is taken — nothing to dismiss. */}\n {isNew && !reset && (\n <Chip size=\"sm\" tone=\"brand\" caps className=\"px-1.5\">\n {words.badgeNew}\n </Chip>\n )}\n {reset && (\n <Chip size=\"sm\" tone=\"brand\" caps className=\"px-1.5\">\n {words.badgeUpdated}\n </Chip>\n )}\n {tour.badge}\n </div>\n {tour.description && <p className=\"mt-1 text-sm text-muted\">{tour.description}</p>}\n {/* `hidden sm:block`: one card per row is the phone, where these paragraphs\n would turn the page into a scroll; the search still reads them. */}\n {tour.about && <p className=\"mt-2 hidden text-sm leading-relaxed text-secondary sm:block\">{tour.about}</p>}\n </div>\n </div>\n <div className=\"mt-3 flex flex-wrap items-end justify-between gap-2\">\n <div className=\"min-w-0\">\n {hasFacts && (\n <div className=\"flex flex-wrap items-center gap-1.5\">\n {tour.points != null && (\n <Chip size=\"sm\" tone=\"brand\">\n {words.points(tour.points)}\n </Chip>\n )}\n {tour.steps != null && <Caption as=\"span\">{words.steps(tour.steps)}</Caption>}\n {tour.duration != null && (\n <Caption as=\"span\" className=\"inline-flex items-center gap-1\">\n <Clock aria-hidden className=\"size-3\" />\n {words.duration(tour.duration)}\n </Caption>\n )}\n </div>\n )}\n {note && <p className={cn(\"text-xs text-muted\", hasFacts && \"mt-1\")}>{note}</p>}\n </div>\n <div className=\"flex shrink-0 items-center gap-2\">\n {done && onReset && (\n <Button variant=\"link\" tone=\"muted\" size=\"sm\" aria-describedby={titleId} onClick={() => onReset(tour.id)}>\n {words.markUndone}\n </Button>\n )}\n <Button aria-describedby={titleId} onClick={() => onLaunch(tour.id)}>\n <Play aria-hidden className=\"size-4\" /> {done ? words.replay : words.start}\n </Button>\n </div>\n </div>\n </Card>\n );\n}\n\n/* ── The page body ─────────────────────────────────────────────────────────── */\n\nexport interface TourCatalogueProps extends TourCatalogueFilter {\n /** The tours this person may take, already filtered by role and data (§6.3). */\n tours: readonly TourInfo[];\n /** The sections, in the app's order. A tour whose group is not here is not listed. */\n groups: readonly TourGroup[];\n completed: TourCompletions;\n /** The account's `created_at`: a tour shipped after it, untaken, is NEW. Unknown → none is. */\n joined?: string | null;\n /** A filter changed — `{query}`, `{group}`, `{state}`, or all three for \"Clear filters\". */\n onChange: (patch: Partial<TourCatalogueFilter>) => void;\n onLaunch: (id: string) => void;\n /** \"Mark as not done\" — `useTourProgress().reset`. */\n onReset?: (id: string) => void;\n /** Above the filter bar: the app's page header with its points tile (§6.1). */\n header?: ReactNode;\n formatDate?: (iso: string) => string;\n /** The search's locale (case folding). Default: the provider's. */\n locale?: string;\n labels?: LabelOverride<TourCatalogueLabels>;\n className?: string;\n}\n\nconst STATE_FILTERS: readonly TourStateFilter[] = [\"all\", \"todo\", \"done\"];\n\n/**\n * The tours page's body (docs/tours-harmonization.md §2.1, §3) — keksdose's /tours:\n *\n * - **The filter bar**: a search, a group select and an all / to do / done toggle,\n * stacked below `lg` and side by side from it. Each count is what the OTHER filters\n * leave: the toggle counts the search and the group, each group the search and the\n * state. Under it, while a filter is on, \"Showing X of Y tours\".\n * - **The sections**: one per group, in the app's order — an h2 with its icon and help\n * line over a `sm:2 / xl:3` grid of `TourCatalogueCard`s. A group the filters empty is\n * hidden; when nothing is left, an empty state with \"Clear filters\".\n * - **Controlled**: `query`, `group`, `state` and `onChange`, so the app keeps them in its\n * URL (`useTourCatalogueUrlState`).\n *\n * A tour reset by an update (§8) is \"to do\" for the toggle and the counts.\n */\nexport function TourCatalogue({\n tours,\n groups,\n completed,\n joined,\n query,\n group: groupProp,\n state: stateProp,\n onChange,\n onLaunch,\n onReset,\n header,\n formatDate,\n locale: localeProp,\n labels: labelsProp,\n className,\n}: TourCatalogueProps) {\n const words = useKitLabels(\"tourCatalogue\", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);\n const uid = useId();\n const { locale: kitLocale } = useKitFormat();\n const locale = localeProp ?? kitLocale;\n // A group or state the list does not have is no filter at all (an old link).\n const group = groups.some((g) => g.id === groupProp) ? groupProp : null;\n const state = STATE_FILTERS.includes(stateProp) ? stateProp : \"all\";\n\n const listed = tours.filter((t) => groups.some((g) => g.id === t.group));\n const done = (tour: TourInfo) => isTourDone(tour, completed[tour.id]);\n const needle = foldForSearch(query.trim(), locale);\n const matchesQuery = (tour: TourInfo) =>\n !needle || [tour.title, tour.description, tour.about].some((text) => !!text && foldForSearch(text, locale).includes(needle));\n const inState = (tour: TourInfo) => (state === \"all\" ? true : state === \"done\" ? done(tour) : !done(tour));\n const inGroup = (tour: TourInfo, id: string | null = group) => id === null || tour.group === id;\n\n const found = listed.filter((t) => matchesQuery(t) && inGroup(t));\n const foundDone = found.filter(done).length;\n const visible = found.filter(inState);\n const groupCount = (id: string | null) => listed.filter((t) => matchesQuery(t) && inState(t) && inGroup(t, id)).length;\n const filtering = needle.length > 0 || state !== \"all\" || group !== null;\n const sections = groups\n .map((g) => ({ ...g, tours: visible.filter((t) => t.group === g.id) }))\n .filter((g) => g.tours.length > 0);\n\n return (\n <div className={cn(\"space-y-4\", className)}>\n {header}\n <div className=\"flex flex-col gap-2 lg:flex-row lg:items-center\">\n <SearchField\n className=\"lg:max-w-xs lg:flex-1\"\n value={query}\n onChange={(value) => onChange({ query: value })}\n aria-label={words.searchLabel}\n placeholder={words.searchPlaceholder}\n />\n {/* A select, not a chip per group: on a phone those wrap into rows above the list\n they filter, and an app may have ten groups (ka F8). Named, not labelled: a\n floating label makes it taller than the search box beside it (dev #589). */}\n <Select\n className=\"lg:w-60\"\n aria-label={words.groupLabel}\n value={group ?? \"\"}\n onChange={(event) => onChange({ group: event.target.value || null })}\n >\n <option value=\"\">{words.groupAll(groupCount(null))}</option>\n {groups.map((g) => (\n <option key={g.id} value={g.id}>\n {words.groupOption(g.title, groupCount(g.id))}\n </option>\n ))}\n </Select>\n <ToggleGroup\n value={state}\n onChange={(value: TourStateFilter) => onChange({ state: value })}\n aria-label={words.stateLabel}\n className=\"lg:w-auto\"\n options={[\n { value: \"all\", label: words.stateAll(found.length) },\n { value: \"todo\", label: words.stateTodo(found.length - foundDone) },\n { value: \"done\", label: words.stateDone(foundDone) },\n ]}\n />\n </div>\n {filtering && (\n <p role=\"status\" className=\"-mt-2 text-xs text-muted\">\n {words.showing(visible.length, listed.length)}\n </p>\n )}\n\n {visible.length === 0 ? (\n <div className=\"space-y-3\">\n <EmptyState title={words.noMatches} hint={words.noMatchesHint} />\n <div className=\"flex justify-center\">\n <Button variant=\"secondary\" onClick={() => onChange({ query: \"\", group: null, state: \"all\" })}>\n {words.clearFilters}\n </Button>\n </div>\n </div>\n ) : (\n // A GRID, never columns: a section holding one card keeps a cell's width.\n sections.map((section) => (\n <section key={section.id} aria-labelledby={`${uid}-${section.id}`} className=\"space-y-3\">\n <h2 id={`${uid}-${section.id}`} className=\"flex items-center gap-1.5 text-sm font-semibold text-muted\">\n {section.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 [&_svg]:size-4\">\n {section.icon}\n </span>\n )}\n {section.title}\n </h2>\n {section.help && <p className=\"-mt-2 text-xs text-muted\">{section.help}</p>}\n <div className=\"grid gap-3 sm:grid-cols-2 xl:grid-cols-3\">\n {section.tours.map((tour) => (\n <TourCatalogueCard\n key={tour.id}\n tour={tour}\n completedAt={completed[tour.id]}\n isNew={isTourNew({ since: tour.since, joined, completed: completed[tour.id] !== undefined })}\n onLaunch={onLaunch}\n onReset={onReset}\n formatDate={formatDate}\n labels={labelsProp}\n />\n ))}\n </div>\n </section>\n ))\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
1
+ {"version":3,"sources":["../../src/tour/tour-catalogue.tsx"],"sourcesContent":["import { useId, type ReactNode } from \"react\";\nimport { Circle, CircleCheck, Clock, Play } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitFormat } from \"../lib/format\";\nimport { foldForSearch } from \"../lib/text-match\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { Button } from \"../components/button\";\nimport { Card } from \"../components/card\";\nimport { Chip } from \"../components/chip\";\nimport { Caption } from \"../components/text\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { SearchField } from \"../components/search-field\";\nimport { Select } from \"../components/select\";\nimport { ToggleGroup } from \"../components/toggle-group\";\nimport { DEFAULT_TOUR_CATALOGUE_LABELS, type TourCatalogueLabels } from \"./tour-catalogue-labels\";\nimport { isTourDone, isTourNew, isTourReset, type TourCompletions, type TourGroup, type TourInfo } from \"./tour-info\";\nimport type { TourCatalogueFilter, TourStateFilter } from \"./use-tour-url-sync\";\n\nexport { DEFAULT_TOUR_CATALOGUE_LABELS };\nexport type { TourCatalogueLabels };\n\n/* ── The card ───────────────────────────────────────────────────────────────── */\n\nexport interface TourCatalogueCardProps {\n tour: TourInfo;\n /** The completion's ISO timestamp, `null` for one without a date; absent while untaken. */\n completedAt?: string | null;\n /** NEW — `isTourNew`. The catalogue works it out; a card on its own is told. */\n isNew?: boolean;\n onLaunch: (id: string) => void;\n /** \"Mark as not done\". Without it, the link is not drawn. */\n onReset?: (id: string) => void;\n /**\n * Formats the completion and update days. Default: the kit's medium date in the provider's locale.\n * It gets the completion's ISO instant and the update's bare `\"YYYY-MM-DD\"` (`updated.date`): read\n * the bare day on its calendar (the kit's `formatDate`, `parseIsoDate`), never `new Date(day)`,\n * which is UTC midnight and the day before west of UTC (round 0.45, N9).\n */\n formatDate?: (iso: string) => string;\n labels?: LabelOverride<TourCatalogueLabels>;\n className?: string;\n}\n\n/**\n * One tour's card — keksdose's (tours-page.tsx `TourCard`, dev#557), laid out top to\n * bottom so the cards sit side by side: the state icon beside a column holding the tour's\n * icon, title, NEW (or \"Updated\", §8) and the app's `badge`, the one-liner and, from `sm`,\n * the longer account. The foot is pinned to the bottom edge, so every Start in a row of\n * cards lands on one line: the points chip, the step count and the duration, the\n * completion (or reset) line, \"Mark as not done\" and Start / Replay. Points, steps and\n * duration are each left out when the tour has none — no gap, no \"0 points\" (KS).\n */\nexport function TourCatalogueCard({\n tour,\n completedAt,\n isNew = false,\n onLaunch,\n onReset,\n formatDate: formatDateProp,\n labels: labelsProp,\n className,\n}: TourCatalogueCardProps) {\n const words = useKitLabels(\"tourCatalogue\", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);\n const { formatDate } = useKitFormat();\n const day = formatDateProp ?? ((iso: string) => formatDate(iso, \"medium\"));\n const titleId = useId();\n const done = isTourDone(tour, completedAt);\n const reset = isTourReset(tour, completedAt);\n const hasFacts = tour.points != null || tour.steps != null || tour.duration != null;\n const note = done\n ? completedAt\n ? words.completedOn(day(completedAt))\n : words.completed\n : reset && tour.updated\n ? words.resetOn(day(tour.updated.date), tour.updated.reason)\n : null;\n\n return (\n <Card className={cn(\"flex h-full flex-col p-4\", className)}>\n {/* Title, one-liner and the long body share ONE left edge, in the column beside the\n state icon. */}\n <div className=\"flex flex-1 items-start gap-3\">\n <div className={cn(\"mt-0.5 shrink-0\", done ? \"text-success\" : \"text-muted\")}>\n {done ? <CircleCheck aria-hidden className=\"size-5\" /> : <Circle aria-hidden className=\"size-5\" />}\n <span className=\"sr-only\">{done ? words.completed : words.notCompleted}</span>\n </div>\n <div className=\"min-w-0 flex-1\">\n <div className=\"flex flex-wrap items-center gap-2\">\n {tour.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 text-brand [&_svg]:size-4\">\n {tour.icon}\n </span>\n )}\n {/* The id on the words, not the heading: the Start button is described by them. */}\n <h3 className=\"font-medium\">\n <span id={titleId}>{tour.title}</span>\n </h3>\n {/* It retires itself the moment the tour is taken — nothing to dismiss. */}\n {isNew && !reset && (\n <Chip size=\"sm\" tone=\"brand\" caps className=\"px-1.5\">\n {words.badgeNew}\n </Chip>\n )}\n {reset && (\n <Chip size=\"sm\" tone=\"brand\" caps className=\"px-1.5\">\n {words.badgeUpdated}\n </Chip>\n )}\n {tour.badge}\n </div>\n {tour.description && <p className=\"mt-1 text-sm text-muted\">{tour.description}</p>}\n {/* `hidden sm:block`: one card per row is the phone, where these paragraphs\n would turn the page into a scroll; the search still reads them. */}\n {tour.about && <p className=\"mt-2 hidden text-sm leading-relaxed text-secondary sm:block\">{tour.about}</p>}\n </div>\n </div>\n <div className=\"mt-3 flex flex-wrap items-end justify-between gap-2\">\n <div className=\"min-w-0\">\n {hasFacts && (\n <div className=\"flex flex-wrap items-center gap-1.5\">\n {tour.points != null && (\n <Chip size=\"sm\" tone=\"brand\">\n {words.points(tour.points)}\n </Chip>\n )}\n {tour.steps != null && <Caption as=\"span\">{words.steps(tour.steps)}</Caption>}\n {tour.duration != null && (\n <Caption as=\"span\" className=\"inline-flex items-center gap-1\">\n <Clock aria-hidden className=\"size-3\" />\n {words.duration(tour.duration)}\n </Caption>\n )}\n </div>\n )}\n {note && <p className={cn(\"text-xs text-muted\", hasFacts && \"mt-1\")}>{note}</p>}\n </div>\n <div className=\"flex shrink-0 items-center gap-2\">\n {done && onReset && (\n <Button variant=\"link\" tone=\"muted\" size=\"sm\" aria-describedby={titleId} onClick={() => onReset(tour.id)}>\n {words.markUndone}\n </Button>\n )}\n <Button aria-describedby={titleId} onClick={() => onLaunch(tour.id)}>\n <Play aria-hidden className=\"size-4\" /> {done ? words.replay : words.start}\n </Button>\n </div>\n </div>\n </Card>\n );\n}\n\n/* ── The page body ─────────────────────────────────────────────────────────── */\n\nexport interface TourCatalogueProps extends TourCatalogueFilter {\n /** The tours this person may take, already filtered by role and data (§6.3). */\n tours: readonly TourInfo[];\n /** The sections, in the app's order. A tour whose group is not here is not listed. */\n groups: readonly TourGroup[];\n completed: TourCompletions;\n /** The account's `created_at`: a tour shipped after it, untaken, is NEW. Unknown → none is. */\n joined?: string | null;\n /** A filter changed — `{query}`, `{group}`, `{state}`, or all three for \"Clear filters\". */\n onChange: (patch: Partial<TourCatalogueFilter>) => void;\n onLaunch: (id: string) => void;\n /** \"Mark as not done\" — `useTourProgress().reset`. */\n onReset?: (id: string) => void;\n /** Above the filter bar: the app's page header with its points tile (§6.1). */\n header?: ReactNode;\n /** Each card's `formatDate`: see {@link TourCatalogueCardProps.formatDate} (a bare day for the update). */\n formatDate?: (iso: string) => string;\n /** The search's locale (case folding). Default: the provider's. */\n locale?: string;\n labels?: LabelOverride<TourCatalogueLabels>;\n className?: string;\n}\n\nconst STATE_FILTERS: readonly TourStateFilter[] = [\"all\", \"todo\", \"done\"];\n\n/**\n * The tours page's body (docs/tours-harmonization.md §2.1, §3) — keksdose's /tours:\n *\n * - **The filter bar**: a search, a group select and an all / to do / done toggle,\n * stacked below `lg` and side by side from it. Each count is what the OTHER filters\n * leave: the toggle counts the search and the group, each group the search and the\n * state. Under it, while a filter is on, \"Showing X of Y tours\".\n * - **The sections**: one per group, in the app's order — an h2 with its icon and help\n * line over a `sm:2 / xl:3` grid of `TourCatalogueCard`s. A group the filters empty is\n * hidden; when nothing is left, an empty state with \"Clear filters\".\n * - **Controlled**: `query`, `group`, `state` and `onChange`, so the app keeps them in its\n * URL (`useTourCatalogueUrlState`).\n *\n * A tour reset by an update (§8) is \"to do\" for the toggle and the counts.\n */\nexport function TourCatalogue({\n tours,\n groups,\n completed,\n joined,\n query,\n group: groupProp,\n state: stateProp,\n onChange,\n onLaunch,\n onReset,\n header,\n formatDate,\n locale: localeProp,\n labels: labelsProp,\n className,\n}: TourCatalogueProps) {\n const words = useKitLabels(\"tourCatalogue\", DEFAULT_TOUR_CATALOGUE_LABELS, labelsProp);\n const uid = useId();\n const { locale: kitLocale } = useKitFormat();\n const locale = localeProp ?? kitLocale;\n // A group or state the list does not have is no filter at all (an old link).\n const group = groups.some((g) => g.id === groupProp) ? groupProp : null;\n const state = STATE_FILTERS.includes(stateProp) ? stateProp : \"all\";\n\n const listed = tours.filter((t) => groups.some((g) => g.id === t.group));\n const done = (tour: TourInfo) => isTourDone(tour, completed[tour.id]);\n const needle = foldForSearch(query.trim(), locale);\n const matchesQuery = (tour: TourInfo) =>\n !needle || [tour.title, tour.description, tour.about].some((text) => !!text && foldForSearch(text, locale).includes(needle));\n const inState = (tour: TourInfo) => (state === \"all\" ? true : state === \"done\" ? done(tour) : !done(tour));\n const inGroup = (tour: TourInfo, id: string | null = group) => id === null || tour.group === id;\n\n const found = listed.filter((t) => matchesQuery(t) && inGroup(t));\n const foundDone = found.filter(done).length;\n const visible = found.filter(inState);\n const groupCount = (id: string | null) => listed.filter((t) => matchesQuery(t) && inState(t) && inGroup(t, id)).length;\n const filtering = needle.length > 0 || state !== \"all\" || group !== null;\n const sections = groups\n .map((g) => ({ ...g, tours: visible.filter((t) => t.group === g.id) }))\n .filter((g) => g.tours.length > 0);\n\n return (\n <div className={cn(\"space-y-4\", className)}>\n {header}\n <div className=\"flex flex-col gap-2 lg:flex-row lg:items-center\">\n <SearchField\n className=\"lg:max-w-xs lg:flex-1\"\n value={query}\n onChange={(value) => onChange({ query: value })}\n aria-label={words.searchLabel}\n placeholder={words.searchPlaceholder}\n />\n {/* A select, not a chip per group: on a phone those wrap into rows above the list\n they filter, and an app may have ten groups (ka F8). Named, not labelled: a\n floating label makes it taller than the search box beside it (dev #589). */}\n <Select\n className=\"lg:w-60\"\n aria-label={words.groupLabel}\n value={group ?? \"\"}\n onChange={(event) => onChange({ group: event.target.value || null })}\n >\n <option value=\"\">{words.groupAll(groupCount(null))}</option>\n {groups.map((g) => (\n <option key={g.id} value={g.id}>\n {words.groupOption(g.title, groupCount(g.id))}\n </option>\n ))}\n </Select>\n <ToggleGroup\n value={state}\n onChange={(value: TourStateFilter) => onChange({ state: value })}\n aria-label={words.stateLabel}\n className=\"lg:w-auto\"\n options={[\n { value: \"all\", label: words.stateAll(found.length) },\n { value: \"todo\", label: words.stateTodo(found.length - foundDone) },\n { value: \"done\", label: words.stateDone(foundDone) },\n ]}\n />\n </div>\n {filtering && (\n <p role=\"status\" className=\"-mt-2 text-xs text-muted\">\n {words.showing(visible.length, listed.length)}\n </p>\n )}\n\n {visible.length === 0 ? (\n <div className=\"space-y-3\">\n <EmptyState title={words.noMatches} hint={words.noMatchesHint} />\n <div className=\"flex justify-center\">\n <Button variant=\"secondary\" onClick={() => onChange({ query: \"\", group: null, state: \"all\" })}>\n {words.clearFilters}\n </Button>\n </div>\n </div>\n ) : (\n // A GRID, never columns: a section holding one card keeps a cell's width.\n sections.map((section) => (\n <section key={section.id} aria-labelledby={`${uid}-${section.id}`} className=\"space-y-3\">\n <h2 id={`${uid}-${section.id}`} className=\"flex items-center gap-1.5 text-sm font-semibold text-muted\">\n {section.icon != null && (\n <span aria-hidden className=\"inline-flex shrink-0 [&_svg]:size-4\">\n {section.icon}\n </span>\n )}\n {section.title}\n </h2>\n {section.help && <p className=\"-mt-2 text-xs text-muted\">{section.help}</p>}\n <div className=\"grid gap-3 sm:grid-cols-2 xl:grid-cols-3\">\n {section.tours.map((tour) => (\n <TourCatalogueCard\n key={tour.id}\n tour={tour}\n completedAt={completed[tour.id]}\n isNew={isTourNew({ since: tour.since, joined, completed: completed[tour.id] !== undefined })}\n onLaunch={onLaunch}\n onReset={onReset}\n formatDate={formatDate}\n labels={labelsProp}\n />\n ))}\n </div>\n </section>\n ))\n )}\n </div>\n );\n}\n"],"mappings":"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","names":[]}
@@ -5,6 +5,7 @@ const DEFAULT_TOUR_LABELS = {
5
5
  skip: "Skip",
6
6
  done: "Done",
7
7
  awaitClickHint: "Click the highlighted element to continue",
8
+ awaitInputHint: "Enter a value in the highlighted field to continue",
8
9
  step: (c, t) => `${c} / ${t}`
9
10
  };
10
11
  export {
@@ -1 +1 @@
1
- {"version":3,"sources":["../../src/tour/tour-labels.ts"],"sourcesContent":["// The English words of ./tour, in a module with no component code: the English catalogue\n// (src/i18n/defaults.ts) imports them without the component, so `en` loads as one chunk\n// like the other languages (0.34, KS U8). ./tour re-exports them.\n\nimport type { TourLabels } from \"./tour\";\n\nexport const DEFAULT_TOUR_LABELS: TourLabels = {\n next: \"Next\",\n back: \"Back\",\n skip: \"Skip\",\n done: \"Done\",\n awaitClickHint: \"Click the highlighted element to continue\",\n step: (c, t) => `${c} / ${t}`,\n};\n"],"mappings":";AAMO,MAAM,sBAAkC;AAAA,EAC7C,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,gBAAgB;AAAA,EAChB,MAAM,CAAC,GAAG,MAAM,GAAG,CAAC,MAAM,CAAC;AAC7B;","names":[]}
1
+ {"version":3,"sources":["../../src/tour/tour-labels.ts"],"sourcesContent":["// The English words of ./tour, in a module with no component code: the English catalogue\n// (src/i18n/defaults.ts) imports them without the component, so `en` loads as one chunk\n// like the other languages (0.34, KS U8). ./tour re-exports them.\n\nimport type { TourLabels } from \"./tour\";\n\nexport const DEFAULT_TOUR_LABELS: TourLabels = {\n next: \"Next\",\n back: \"Back\",\n skip: \"Skip\",\n done: \"Done\",\n awaitClickHint: \"Click the highlighted element to continue\",\n awaitInputHint: \"Enter a value in the highlighted field to continue\",\n step: (c, t) => `${c} / ${t}`,\n};\n"],"mappings":";AAMO,MAAM,sBAAkC;AAAA,EAC7C,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,MAAM;AAAA,EACN,gBAAgB;AAAA,EAChB,gBAAgB;AAAA,EAChB,MAAM,CAAC,GAAG,MAAM,GAAG,CAAC,MAAM,CAAC;AAC7B;","names":[]}
@@ -30,6 +30,34 @@ export interface TourStep {
30
30
  * first, then the tour steps forward on a microtask. The spotlight hole stays
31
31
  * click-through to the target; the rest of the page does not. */
32
32
  awaitClick?: boolean;
33
+ /**
34
+ * The spotlighted control is for USING, not only for looking at (0.46, keksdose live
35
+ * #436): the layers it opens sit above the scrim and work. A plain step's overlay is
36
+ * `z-[60]`, over every kit layer, so a field whose number pad (`NumberPadSheet`), a
37
+ * select's menu or a popover opens at `z-50` opened UNDER the shields, where nothing
38
+ * could be tapped. Here the overlay drops to `z-[45]`: still over the top bar and the
39
+ * page (`z-40` and below, still dimmed and shielded), under every `z-50` layer the
40
+ * target opens — so an app's own layer wants `z-50` or more to clear it. Keys pressed
41
+ * in the target or in such a layer are theirs (Escape closes the pad, arrows walk a
42
+ * list); from the card the tour's keys work as on any step. The overlay is not
43
+ * `aria-modal` then, and carries `data-tour-interactive`.
44
+ *
45
+ * The card is in the same layer as the scrim, so an open pad or sheet may cover it
46
+ * until it closes. Requires `target`. A `HoverMenu` (`z-40`, the top bar's menus) stays
47
+ * under the scrim: point an `awaitClick` step at its trigger instead.
48
+ */
49
+ interactive?: boolean;
50
+ /**
51
+ * Advance once a value was ENTERED in the spotlighted control (0.46, keksdose live
52
+ * #436) — `awaitClick` for a field. Implies `interactive`. Counted when focus leaves
53
+ * the target (the number pad's Done, a Tab, a tap elsewhere), or Enter is pressed in
54
+ * it, and one of its fields (`input`, `textarea`, `select`: the target itself or
55
+ * inside it) then holds another value than when the step began; a native `change`
56
+ * (a select, a checkbox) counts at once. The card swaps Next for `awaitInputHint`,
57
+ * as for `awaitClick`. A picker whose value is not a field's (the kit's `Select`
58
+ * button) has nothing to compare: call `useTour().next()` from its `onChange`.
59
+ */
60
+ awaitInput?: boolean;
33
61
  /** Runs before the step is shown — e.g. navigate to a route, open a panel.
34
62
  * May be async; the step waits for it, then locates the target. */
35
63
  beforeStep?: () => void | Promise<void>;
@@ -41,6 +69,8 @@ export interface TourLabels {
41
69
  done: string;
42
70
  /** Shown in place of the Next button on an `awaitClick` step. */
43
71
  awaitClickHint: string;
72
+ /** Shown in place of the Next button on an `awaitInput` step (0.46). */
73
+ awaitInputHint: string;
44
74
  /** Step counter, e.g. (2, 7) => "2 / 7". */
45
75
  step: (current: number, total: number) => string;
46
76
  }
package/dist/tour/tour.js CHANGED
@@ -17,7 +17,7 @@ import { useFocusTrap } from "../hooks/use-focus-trap.js";
17
17
  import { sameRect } from "../hooks/use-anchored-rect.js";
18
18
  import { dirOf, horizontalStep } from "../lib/direction.js";
19
19
  import { DEFAULT_TOUR_LABELS } from "./tour-labels.js";
20
- import { completeStored, rememberMigrate } from "./use-tour-progress.js";
20
+ import { completeStored, rememberAccount, rememberMigrate } from "./use-tour-progress.js";
21
21
  const TourContext = createContext(null);
22
22
  function useTour() {
23
23
  const ctx = useContext(TourContext);
@@ -49,9 +49,11 @@ function TourProvider({
49
49
  });
50
50
  const storageKey = progress?.storageKey;
51
51
  const migrate = progress?.migrate;
52
+ const account = progress?.account;
52
53
  useLayoutEffect(() => {
53
54
  if (storageKey !== void 0) rememberMigrate(storageKey, migrate);
54
- }, [storageKey, migrate]);
55
+ if (storageKey !== void 0) rememberAccount(storageKey, account);
56
+ }, [storageKey, migrate, account]);
55
57
  const warnedNoTourRef = useRef(false);
56
58
  const stepsRef = useRef(null);
57
59
  const indexRef = useRef(0);
@@ -223,14 +225,51 @@ function TourOverlay({
223
225
  el.addEventListener("click", onClick, { once: true });
224
226
  return () => el.removeEventListener("click", onClick);
225
227
  }, [ready, spotted, index, step, onNext]);
228
+ const interactive = spotted && !!step.target && !!(step.interactive || step.awaitInput);
229
+ useEffect(() => {
230
+ if (!ready || !spotted || !step.awaitInput || !step.target) return;
231
+ const el = queryVisibleTarget(step.target);
232
+ if (!el) return;
233
+ const read = () => fieldValues(el);
234
+ const before = read();
235
+ let timer = 0;
236
+ let done = false;
237
+ const check = () => {
238
+ window.clearTimeout(timer);
239
+ timer = window.setTimeout(() => {
240
+ if (done || read() === before) return;
241
+ done = true;
242
+ onNext();
243
+ }, 0);
244
+ };
245
+ const onFocusOut = (e) => {
246
+ if (e.relatedTarget instanceof Node && el.contains(e.relatedTarget)) return;
247
+ check();
248
+ };
249
+ const onKeyDown = (e) => {
250
+ if (e.key === "Enter") check();
251
+ };
252
+ el.addEventListener("focusout", onFocusOut);
253
+ el.addEventListener("keydown", onKeyDown);
254
+ el.addEventListener("change", check);
255
+ return () => {
256
+ window.clearTimeout(timer);
257
+ el.removeEventListener("focusout", onFocusOut);
258
+ el.removeEventListener("keydown", onKeyDown);
259
+ el.removeEventListener("change", check);
260
+ };
261
+ }, [ready, spotted, index, step, onNext]);
226
262
  useEffect(() => {
227
263
  const onKey = (e) => {
264
+ if (interactive && e.target instanceof Node && e.target !== document.body && !rootRef.current?.contains(e.target)) {
265
+ return;
266
+ }
228
267
  if (e.key === "Escape") {
229
268
  onSkip();
230
269
  return;
231
270
  }
232
271
  if (e.key === "Enter") {
233
- if (step.awaitClick || ownsKey(e.target, "Enter")) return;
272
+ if (step.awaitClick || step.awaitInput || ownsKey(e.target, "Enter")) return;
234
273
  e.preventDefault();
235
274
  onNext();
236
275
  return;
@@ -246,7 +285,7 @@ function TourOverlay({
246
285
  };
247
286
  document.addEventListener("keydown", onKey);
248
287
  return () => document.removeEventListener("keydown", onKey);
249
- }, [isFirst, step, onNext, onPrev, onSkip]);
288
+ }, [isFirst, step, interactive, onNext, onPrev, onSkip]);
250
289
  if (!ready) return null;
251
290
  const pad = step.padding ?? 8;
252
291
  const spot = rect ? { top: rect.top - pad, left: rect.left - pad, width: rect.width + pad * 2, height: rect.height + pad * 2 } : null;
@@ -255,9 +294,10 @@ function TourOverlay({
255
294
  "div",
256
295
  {
257
296
  ref: rootRef,
258
- className: "pointer-events-none fixed inset-0 z-[60]",
297
+ className: cn("pointer-events-none fixed inset-0", interactive ? "z-[45]" : "z-[60]"),
259
298
  role: "dialog",
260
- "aria-modal": "true",
299
+ "aria-modal": interactive ? void 0 : "true",
300
+ "data-tour-interactive": interactive ? "" : void 0,
261
301
  "aria-label": step.title,
262
302
  dir,
263
303
  children: [
@@ -316,6 +356,12 @@ function TourOverlay({
316
356
  document.body
317
357
  );
318
358
  }
359
+ function fieldValues(el) {
360
+ const fields = el.matches("input,textarea,select") ? [el] : Array.from(el.querySelectorAll("input,textarea,select"));
361
+ return fields.map(
362
+ (f) => f instanceof HTMLInputElement && (f.type === "checkbox" || f.type === "radio") ? String(f.checked) : f.value
363
+ ).join("\0");
364
+ }
319
365
  function ownsKey(target, key) {
320
366
  if (!(target instanceof HTMLElement)) return false;
321
367
  if (target.isContentEditable) return true;
@@ -433,10 +479,10 @@ function TourCard({
433
479
  /* @__PURE__ */ jsxs("div", { className: "flex items-center gap-2", children: [
434
480
  /* @__PURE__ */ jsx("span", { className: "whitespace-nowrap text-xs tabular-nums text-placeholder", children: labels.step(index + 1, total) }),
435
481
  !isFirst && /* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: onBack, className: "px-2.5 py-1 text-xs", children: labels.back }),
436
- !(step.awaitClick && step.target && rect) && /* @__PURE__ */ jsx(Button, { variant: "brand", onClick: onNext, className: cn("px-2.5 py-1 text-xs"), children: isLast ? labels.done : labels.next })
482
+ !((step.awaitClick || step.awaitInput) && step.target && rect) && /* @__PURE__ */ jsx(Button, { variant: "brand", onClick: onNext, className: cn("px-2.5 py-1 text-xs"), children: isLast ? labels.done : labels.next })
437
483
  ] })
438
484
  ] }),
439
- step.awaitClick && step.target && rect && /* @__PURE__ */ jsx("div", { className: "text-center text-xs italic text-placeholder", children: labels.awaitClickHint })
485
+ (step.awaitClick || step.awaitInput) && step.target && rect && /* @__PURE__ */ jsx("div", { className: "text-center text-xs italic text-placeholder", children: step.awaitInput && !step.awaitClick ? labels.awaitInputHint : labels.awaitClickHint })
440
486
  ] })
441
487
  ]
442
488
  }