@eifi1/ui-kit 0.41.0 → 0.43.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 (190) hide show
  1. package/README.md +26 -7
  2. package/dist/admin/admin-area-labels.d.ts +59 -0
  3. package/dist/admin/admin-area-labels.js +35 -0
  4. package/dist/admin/admin-area-labels.js.map +1 -0
  5. package/dist/admin/admin-forbidden.d.ts +35 -0
  6. package/dist/admin/admin-forbidden.js +36 -0
  7. package/dist/admin/admin-forbidden.js.map +1 -0
  8. package/dist/admin/admin-overview.d.ts +110 -0
  9. package/dist/admin/admin-overview.js +92 -0
  10. package/dist/admin/admin-overview.js.map +1 -0
  11. package/dist/admin/admin-sections.d.ts +94 -0
  12. package/dist/admin/admin-sections.js +81 -0
  13. package/dist/admin/admin-sections.js.map +1 -0
  14. package/dist/admin/admin-tool-links.d.ts +48 -0
  15. package/dist/admin/admin-tool-links.js +31 -0
  16. package/dist/admin/admin-tool-links.js.map +1 -0
  17. package/dist/billing/billing-banner.d.ts +33 -2
  18. package/dist/billing/billing-banner.js +29 -3
  19. package/dist/billing/billing-banner.js.map +1 -1
  20. package/dist/billing/billing-labels.d.ts +36 -0
  21. package/dist/billing/billing-labels.js +10 -0
  22. package/dist/billing/billing-labels.js.map +1 -1
  23. package/dist/billing/billing-overview.d.ts +33 -0
  24. package/dist/billing/billing-overview.js +6 -0
  25. package/dist/billing/billing-overview.js.map +1 -1
  26. package/dist/billing/billing-standing.d.ts +8 -0
  27. package/dist/billing/billing-standing.js.map +1 -1
  28. package/dist/billing/cancel-request.d.ts +53 -0
  29. package/dist/billing/cancel-request.js +21 -0
  30. package/dist/billing/cancel-request.js.map +1 -0
  31. package/dist/billing/cancel-subscription.d.ts +46 -0
  32. package/dist/billing/cancel-subscription.js +57 -0
  33. package/dist/billing/cancel-subscription.js.map +1 -0
  34. package/dist/billing/plan-card.d.ts +45 -1
  35. package/dist/billing/plan-card.js +53 -2
  36. package/dist/billing/plan-card.js.map +1 -1
  37. package/dist/billing/plan-switch.d.ts +10 -1
  38. package/dist/billing/plan-switch.js +22 -13
  39. package/dist/billing/plan-switch.js.map +1 -1
  40. package/dist/billing/provider-page.d.ts +40 -0
  41. package/dist/billing/provider-page.js +33 -0
  42. package/dist/billing/provider-page.js.map +1 -0
  43. package/dist/billing/subscription-actions.d.ts +68 -4
  44. package/dist/billing/subscription-actions.js +65 -5
  45. package/dist/billing/subscription-actions.js.map +1 -1
  46. package/dist/components/account-chips.d.ts +50 -2
  47. package/dist/components/account-chips.js +33 -0
  48. package/dist/components/account-chips.js.map +1 -1
  49. package/dist/components/empty-state.d.ts +2 -1
  50. package/dist/components/empty-state.js.map +1 -1
  51. package/dist/eslint/index.js +1 -1
  52. package/dist/feedback/feedback-menu.d.ts +7 -4
  53. package/dist/feedback/feedback-menu.js +4 -2
  54. package/dist/feedback/feedback-menu.js.map +1 -1
  55. package/dist/hooks/use-search-param-state.js +4 -1
  56. package/dist/hooks/use-search-param-state.js.map +1 -1
  57. package/dist/i18n/defaults.js +7 -1
  58. package/dist/i18n/defaults.js.map +1 -1
  59. package/dist/i18n/german.js +113 -0
  60. package/dist/i18n/german.js.map +1 -1
  61. package/dist/i18n/kit-labels.d.ts +12 -0
  62. package/dist/i18n/kit-labels.js.map +1 -1
  63. package/dist/i18n/locales/en.js +142 -1
  64. package/dist/i18n/locales/en.js.map +3 -3
  65. package/dist/i18n/locales/es.js +110 -0
  66. package/dist/i18n/locales/es.js.map +1 -1
  67. package/dist/i18n/locales/fr.js +109 -0
  68. package/dist/i18n/locales/fr.js.map +1 -1
  69. package/dist/i18n/locales/hu.js +110 -0
  70. package/dist/i18n/locales/hu.js.map +1 -1
  71. package/dist/i18n/locales/it.js +110 -0
  72. package/dist/i18n/locales/it.js.map +1 -1
  73. package/dist/i18n/locales/zh.js +109 -0
  74. package/dist/i18n/locales/zh.js.map +1 -1
  75. package/dist/i18n/review.js +34 -1
  76. package/dist/i18n/review.js.map +1 -1
  77. package/dist/index.d.ts +24 -2
  78. package/dist/index.js +29 -1
  79. package/dist/index.js.map +1 -1
  80. package/dist/search/global-search.d.ts +3 -0
  81. package/dist/search/global-search.js +3 -1
  82. package/dist/search/global-search.js.map +1 -1
  83. package/dist/settings/settings-catalogue.d.ts +9 -1
  84. package/dist/settings/settings-catalogue.js +1 -1
  85. package/dist/settings/settings-catalogue.js.map +1 -1
  86. package/dist/settings/settings-layout.d.ts +7 -1
  87. package/dist/settings/settings-layout.js +35 -27
  88. package/dist/settings/settings-layout.js.map +1 -1
  89. package/dist/shell/account-menu-language.d.ts +46 -0
  90. package/dist/shell/account-menu-language.js +64 -0
  91. package/dist/shell/account-menu-language.js.map +1 -0
  92. package/dist/shell/admin-menu.d.ts +69 -0
  93. package/dist/shell/admin-menu.js +64 -0
  94. package/dist/shell/admin-menu.js.map +1 -0
  95. package/dist/shell/app-shell.d.ts +10 -0
  96. package/dist/shell/app-shell.js +22 -4
  97. package/dist/shell/app-shell.js.map +1 -1
  98. package/dist/shell/dev-switchers.d.ts +53 -0
  99. package/dist/shell/dev-switchers.js +107 -0
  100. package/dist/shell/dev-switchers.js.map +1 -0
  101. package/dist/shell/sync-status-indicator.d.ts +86 -0
  102. package/dist/shell/sync-status-indicator.js +170 -0
  103. package/dist/shell/sync-status-indicator.js.map +1 -0
  104. package/dist/shell/top-bar-actions.d.ts +49 -0
  105. package/dist/shell/top-bar-actions.js +31 -0
  106. package/dist/shell/top-bar-actions.js.map +1 -0
  107. package/dist/shell/top-bar-parts-labels.d.ts +90 -0
  108. package/dist/shell/top-bar-parts-labels.js +51 -0
  109. package/dist/shell/top-bar-parts-labels.js.map +1 -0
  110. package/dist/shell/topbar-action-menu.d.ts +13 -1
  111. package/dist/shell/topbar-action-menu.js +5 -2
  112. package/dist/shell/topbar-action-menu.js.map +1 -1
  113. package/dist/shell/topbar-controls.d.ts +16 -3
  114. package/dist/shell/topbar-controls.js +5 -1
  115. package/dist/shell/topbar-controls.js.map +1 -1
  116. package/dist/shell/tour-menu.d.ts +67 -0
  117. package/dist/shell/tour-menu.js +87 -0
  118. package/dist/shell/tour-menu.js.map +1 -0
  119. package/dist/shell.d.ts +16 -0
  120. package/dist/shell.js +21 -0
  121. package/dist/shell.js.map +1 -1
  122. package/dist/tour/tour-catalogue-labels.d.ts +44 -0
  123. package/dist/tour/tour-catalogue-labels.js +32 -0
  124. package/dist/tour/tour-catalogue-labels.js.map +1 -0
  125. package/dist/tour/tour-catalogue.d.ts +68 -0
  126. package/dist/tour/tour-catalogue.js +189 -0
  127. package/dist/tour/tour-catalogue.js.map +1 -0
  128. package/dist/tour/tour-info.d.ts +112 -0
  129. package/dist/tour/tour-info.js +36 -0
  130. package/dist/tour/tour-info.js.map +1 -0
  131. package/dist/tour/use-tour-progress.d.ts +61 -0
  132. package/dist/tour/use-tour-progress.js +116 -0
  133. package/dist/tour/use-tour-progress.js.map +1 -0
  134. package/dist/tour/use-tour-url-sync.d.ts +58 -0
  135. package/dist/tour/use-tour-url-sync.js +110 -0
  136. package/dist/tour/use-tour-url-sync.js.map +1 -0
  137. package/dist/tour.d.ts +8 -0
  138. package/dist/tour.js +18 -0
  139. package/dist/tour.js.map +1 -1
  140. package/package.json +1 -1
  141. package/src/admin/admin-area-labels.ts +91 -0
  142. package/src/admin/admin-forbidden.tsx +75 -0
  143. package/src/admin/admin-overview.tsx +217 -0
  144. package/src/admin/admin-sections.tsx +198 -0
  145. package/src/admin/admin-tool-links.tsx +84 -0
  146. package/src/billing/billing-banner.tsx +57 -6
  147. package/src/billing/billing-labels.ts +59 -0
  148. package/src/billing/billing-overview.ts +37 -0
  149. package/src/billing/billing-standing.ts +8 -0
  150. package/src/billing/cancel-request.ts +75 -0
  151. package/src/billing/cancel-subscription.tsx +102 -0
  152. package/src/billing/plan-card.tsx +97 -1
  153. package/src/billing/plan-switch.tsx +16 -0
  154. package/src/billing/provider-page.ts +75 -0
  155. package/src/billing/subscription-actions.tsx +155 -20
  156. package/src/components/account-chips.tsx +89 -2
  157. package/src/components/empty-state.tsx +2 -1
  158. package/src/feedback/feedback-menu.tsx +8 -3
  159. package/src/hooks/use-search-param-state.ts +6 -1
  160. package/src/i18n/defaults.ts +6 -0
  161. package/src/i18n/german.ts +127 -0
  162. package/src/i18n/kit-labels.tsx +12 -0
  163. package/src/i18n/locales/en.ts +32 -0
  164. package/src/i18n/locales/es.ts +121 -0
  165. package/src/i18n/locales/fr.ts +121 -0
  166. package/src/i18n/locales/hu.ts +119 -0
  167. package/src/i18n/locales/it.ts +122 -0
  168. package/src/i18n/locales/zh.ts +114 -0
  169. package/src/i18n/review.ts +33 -0
  170. package/src/index.ts +38 -2
  171. package/src/search/global-search.tsx +5 -0
  172. package/src/settings/settings-catalogue.ts +10 -2
  173. package/src/settings/settings-layout.tsx +58 -34
  174. package/src/shell/account-menu-language.tsx +116 -0
  175. package/src/shell/admin-menu.tsx +146 -0
  176. package/src/shell/app-shell.tsx +44 -3
  177. package/src/shell/dev-switchers.tsx +179 -0
  178. package/src/shell/sync-status-indicator.tsx +301 -0
  179. package/src/shell/top-bar-actions.tsx +79 -0
  180. package/src/shell/top-bar-parts-labels.ts +142 -0
  181. package/src/shell/topbar-action-menu.tsx +15 -2
  182. package/src/shell/topbar-controls.tsx +20 -3
  183. package/src/shell/tour-menu.tsx +179 -0
  184. package/src/shell.ts +27 -0
  185. package/src/tour/tour-catalogue-labels.ts +75 -0
  186. package/src/tour/tour-catalogue.tsx +316 -0
  187. package/src/tour/tour-info.ts +144 -0
  188. package/src/tour/use-tour-progress.ts +198 -0
  189. package/src/tour/use-tour-url-sync.ts +221 -0
  190. package/src/tour.ts +11 -0
@@ -0,0 +1,144 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ /**
4
+ * The tours an app offers, as data (docs/tours-harmonization.md §2.2, §6) — one list for
5
+ * the tours page (`TourCatalogue`) and the top bar's `TourMenu`. The step lists stay the
6
+ * app's own: the kit's engine runs them (`useTour().start`), and this describes them.
7
+ *
8
+ * The kit takes finished strings, never i18n keys (§6.3, kk K-1). Role- and data-gated
9
+ * tours are filtered by the app before they come in; every count counts what it is given
10
+ * (§6.3, kk K-2, ka F6).
11
+ */
12
+ export interface TourInfo {
13
+ /** Stable id: what `onLaunch` gets, the progress key, `?tour=`. */
14
+ id: string;
15
+ title: string;
16
+ /** The one-liner under the title (and what the menu could carry). */
17
+ description?: string;
18
+ /** The longer account: which screens it visits, what it explains. Shown from `sm`. */
19
+ about?: string;
20
+ /** The tour's icon, drawn at 16px in the brand colour. */
21
+ icon?: ReactNode;
22
+ /** The id of its `TourGroup`: the section it is listed under. */
23
+ group: string;
24
+ /** The page the tour starts on — `TourMenu`'s "On this page". */
25
+ route?: string;
26
+ /** The day it shipped (ISO `YYYY-MM-DD`): what makes it NEW (§6.4). Without it, never NEW. */
27
+ since?: string;
28
+ /** The last update or fix (§8): a completion from before `updated.date` no longer counts. */
29
+ updated?: TourUpdate;
30
+ /** How many steps it has. Left out, the card shows no step count. */
31
+ steps?: number;
32
+ /** The points it awards (§6.1). Left out, the card draws no chip (never "0 points"). */
33
+ points?: number;
34
+ /** About how long it takes, in minutes — worded by the kit ("About 3 min", §6.5). */
35
+ duration?: number;
36
+ /** A chip beside NEW in the app's words — kastlan's practice mode (§6.5, ka F1). */
37
+ badge?: ReactNode;
38
+ }
39
+
40
+ /** A tour's last update or fix (§8). One at a time: a later update replaces it. */
41
+ export interface TourUpdate {
42
+ /** The day it shipped (ISO `YYYY-MM-DD`). */
43
+ date: string;
44
+ /** Why, in a short sentence in the app's words: "The import screen moved; the tour follows it now."
45
+ * Only shown (the card's reset line); the reset itself reads `date` alone, so an app that
46
+ * keeps a reason KEY on its tour definitions passes those to `complete(tour)` as they are. */
47
+ reason?: string;
48
+ }
49
+
50
+ /** A section of the tours page, in the app's words and order (§2.3). An empty one is hidden. */
51
+ export interface TourGroup {
52
+ id: string;
53
+ title: string;
54
+ /** The help line under the heading. */
55
+ help?: string;
56
+ /** The heading's icon, drawn at 16px. */
57
+ icon?: ReactNode;
58
+ }
59
+
60
+ /**
61
+ * Which tours are completed and when: tour id → the ISO timestamp of the completion, or
62
+ * `null` for one recorded without a date (kastlan's old id list, §6.2). A tour that is
63
+ * not a key is untaken.
64
+ */
65
+ export type TourCompletions = Readonly<Record<string, string | null>>;
66
+
67
+ /** The calendar day of an ISO date or timestamp — `"2026-10-17T08:00:00Z"` → `"2026-10-17"`. */
68
+ const dayOf = (iso: string) => iso.slice(0, 10);
69
+
70
+ /** What a reset reads of a tour (§8): the update's day, nothing else. */
71
+ type TourUpdated = { updated?: { date: string } | null };
72
+
73
+ const BARE_DAY = /^\d{4}-\d{2}-\d{2}$/;
74
+
75
+ /** The start of an ISO day (`YYYY-MM-DD`) in THIS device's time zone, as an instant. */
76
+ function startOfLocalDay(day: string): number {
77
+ const [y, m, d] = dayOf(day).split("-").map(Number);
78
+ return new Date(y, m - 1, d).getTime();
79
+ }
80
+
81
+ /** A completion as an instant: a timestamp as written, a bare day from its local start. */
82
+ const instantOf = (iso: string) => (BARE_DAY.test(iso) ? startOfLocalDay(iso) : Date.parse(iso));
83
+
84
+ /**
85
+ * Is this tour NEW for the person looking at it (§2.4, §6.4)? It shipped AFTER the
86
+ * account was opened — compared by calendar day, so a tour shipped on the joining day was
87
+ * already there to be found — and it is not taken yet. No `since`, or no `joined` date,
88
+ * is never NEW: a pill that appears late is better than one that flickers, and a first
89
+ * tour shipped with a feature should not flash NEW at every older account (KS, ka F7).
90
+ *
91
+ * `completed` is whether the tour was EVER completed — a tour reset by an update (§8) is
92
+ * not NEW again; it carries the "Updated" pill instead, and `since` stays its first ship
93
+ * date.
94
+ */
95
+ export function isTourNew({
96
+ since,
97
+ joined,
98
+ completed,
99
+ }: {
100
+ since?: string | null;
101
+ /** The account's `created_at` (a date or a timestamp); unknown → never NEW. */
102
+ joined?: string | null;
103
+ completed?: boolean;
104
+ }): boolean {
105
+ return !!since && !!joined && dayOf(since) > dayOf(joined) && !completed;
106
+ }
107
+
108
+ /**
109
+ * Was this completion reset by an update (§8)? True when the tour carries `updated` and
110
+ * the completion happened BEFORE the update day began on this device — instants compared:
111
+ * the completion's timestamp against the start of `updated.date` in local time, so one
112
+ * taken at 00:30 in Zurich on the update day counts (its UTC day is the day before) — or
113
+ * has no date at all, which predates any update. A tour never completed (`completedAt`
114
+ * undefined) is never reset: for that person there is nothing to reset. The check itself
115
+ * deletes nothing; a new `complete(tour)` replaces the old date.
116
+ */
117
+ export function isTourReset(tour: TourUpdated, completedAt: string | null | undefined): boolean {
118
+ if (completedAt === undefined || !tour.updated) return false;
119
+ return completedAt === null || instantOf(completedAt) < startOfLocalDay(tour.updated.date);
120
+ }
121
+
122
+ /** Does this completion count — completed, and not reset by a later update (§8)? */
123
+ export function isTourDone(tour: TourUpdated, completedAt: string | null | undefined): boolean {
124
+ return completedAt !== undefined && !isTourReset(tour, completedAt);
125
+ }
126
+
127
+ /**
128
+ * How many of `tours` count as done (§8): the header's "x of y". A tour reset by an update
129
+ * does not count until it is taken again, as on its card.
130
+ */
131
+ export function doneCount(tours: readonly ({ id: string } & TourUpdated)[], completed: TourCompletions): number {
132
+ return tours.filter((tour) => isTourDone(tour, completed[tour.id])).length;
133
+ }
134
+
135
+ /**
136
+ * The points earned on `tours` (§6.1, §8): the sum of each done tour's `points`. A reset
137
+ * tour's points are not earned until it is taken again; a tour without points adds none.
138
+ */
139
+ export function earnedPoints(
140
+ tours: readonly ({ id: string; points?: number | null } & TourUpdated)[],
141
+ completed: TourCompletions,
142
+ ): number {
143
+ return tours.reduce((sum, tour) => sum + (isTourDone(tour, completed[tour.id]) ? (tour.points ?? 0) : 0), 0);
144
+ }
@@ -0,0 +1,198 @@
1
+ import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
2
+ import { readStored, removeStored, writeStored } from "../lib/safe-storage";
3
+ import { isTourDone, type TourCompletions } from "./tour-info";
4
+
5
+ /**
6
+ * Which tours this device has completed (docs/tours-harmonization.md §2.6, §6.2) —
7
+ * keksdose's `tour-progress-store` and kastlan's `completions.ts`, as one hook.
8
+ *
9
+ * - **Device storage, the app's key.** `storageKey` is always the app's: an app on shared
10
+ * devices scopes it per account (KS, bench PCs), and an app that lists its keys
11
+ * (KS `storage-keys.ts`) spells it there. Every access is guarded: a blocked or full
12
+ * storage keeps the marks for this page's life instead of throwing.
13
+ * - **One store, every reader.** A change notifies every hook on the same key in this tab
14
+ * (an in-tab emitter) and in other tabs (the `storage` event), so the tours page and
15
+ * `TourMenu` agree without a reload (ka F3).
16
+ * - **`migrate(raw)`** reads an older shape, so nobody loses their done marks at adoption:
17
+ * it gets the stored JSON (or the raw text, if it is no JSON) and returns the record for
18
+ * a shape it recognises, `undefined` for any other — the kit's own shape included,
19
+ * which it then reads itself. keksdose: zustand's `{state: {completed}, version: 1}`;
20
+ * kastlan: an array of ids, completed without a date (`null`). It runs only on a stored
21
+ * text that is not the kit's shape already, once per text and key, whichever reader
22
+ * reads it first; its identity is ignored. **Define `migrate` once, at module level**,
23
+ * and hand every reader of the key the same one: readers of one key share one snapshot.
24
+ * - **Resetting the device** (settings §11): register the key with `reset: () =>
25
+ * resetTourProgress(storageKey)`, which clears storage and the page's memory and tells
26
+ * every reader in this tab.
27
+ *
28
+ * The kit's own shape is the record itself, `{ [id]: ISO | null }`; an empty string reads
29
+ * as `null` (kastlan's record of 0.42).
30
+ */
31
+ export interface UseTourProgressOptions {
32
+ storageKey: string;
33
+ migrate?: (raw: unknown) => Record<string, string | null> | undefined;
34
+ }
35
+
36
+ export interface TourProgress {
37
+ /** Tour id → the completion's ISO timestamp, or `null` when recorded without a date. */
38
+ completed: TourCompletions;
39
+ /**
40
+ * Record a completion. Keeps the FIRST completion's date, so a replay changes nothing —
41
+ * unless the tour was reset by an update since (§8): pass the tour (`{id, updated}`) and
42
+ * a completion from before `updated.date` is replaced by today's.
43
+ *
44
+ * Returns `true` when this counts as a new completion — the moment to award the tour's
45
+ * points and show the app's toast (§6.1) — and `false` for a replay.
46
+ */
47
+ complete: (tour: string | { id: string; updated?: { date: string } | null }) => boolean;
48
+ /** "Mark as not done": forget the completion. */
49
+ reset: (id: string) => void;
50
+ }
51
+
52
+ /* ── The store: one per key, shared by every hook ───────────────────────────── */
53
+
54
+ const EMPTY: TourCompletions = Object.freeze({});
55
+ /** In-tab listeners per key. */
56
+ const listeners = new Map<string, Set<() => void>>();
57
+ /** What this page wrote where storage refused to keep it (blocked, full): per key. */
58
+ const memory = new Map<string, string>();
59
+ /**
60
+ * The last raw text read per key and what it parsed to, so a snapshot keeps its identity:
61
+ * the same text gives the same object, whichever reader asks and whatever `migrate` it
62
+ * holds. Several readers of one key with inline `migrate` arrows used to evict each other
63
+ * here and loop (kastlan B1, 0.43).
64
+ */
65
+ const cache = new Map<string, { raw: string | null; value: TourCompletions }>();
66
+
67
+ function emit(key: string) {
68
+ listeners.get(key)?.forEach((listener) => listener());
69
+ }
70
+
71
+ function rawOf(key: string): string | null {
72
+ return memory.get(key) ?? readStored(key);
73
+ }
74
+
75
+ /** Is this the kit's own shape already: a record whose every value is a string or null? */
76
+ function isKitShape(value: unknown): boolean {
77
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
78
+ return Object.values(value as Record<string, unknown>).every((at) => at === null || typeof at === "string");
79
+ }
80
+
81
+ /** The kit's own shape: a record of string or null; anything else is dropped. */
82
+ function fromRecord(value: unknown): TourCompletions | undefined {
83
+ if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
84
+ const out: Record<string, string | null> = {};
85
+ for (const [id, at] of Object.entries(value as Record<string, unknown>)) {
86
+ if (at === null || at === "") out[id] = null;
87
+ else if (typeof at === "string") out[id] = at;
88
+ }
89
+ return out;
90
+ }
91
+
92
+ function parse(raw: string | null, migrate: UseTourProgressOptions["migrate"]): TourCompletions {
93
+ if (raw === null) return EMPTY;
94
+ let value: unknown = raw;
95
+ try {
96
+ value = JSON.parse(raw);
97
+ } catch {
98
+ // Not JSON: `migrate` still sees the text.
99
+ }
100
+ if (migrate && !isKitShape(value)) {
101
+ try {
102
+ const migrated = migrate(value);
103
+ if (migrated !== undefined) return fromRecord(migrated) ?? EMPTY;
104
+ } catch {
105
+ // A migrate that throws reads as "not a shape it knows".
106
+ }
107
+ }
108
+ return fromRecord(value) ?? EMPTY;
109
+ }
110
+
111
+ function snapshot(key: string, migrate: UseTourProgressOptions["migrate"]): TourCompletions {
112
+ const raw = rawOf(key);
113
+ const hit = cache.get(key);
114
+ if (hit && hit.raw === raw) return hit.value;
115
+ const value = parse(raw, migrate);
116
+ cache.set(key, { raw, value });
117
+ return value;
118
+ }
119
+
120
+ function write(key: string, record: TourCompletions) {
121
+ const raw = JSON.stringify(record);
122
+ writeStored(key, raw);
123
+ // Storage refused it (blocked, full): keep it for this page's life instead.
124
+ if (readStored(key) === raw) memory.delete(key);
125
+ else memory.set(key, raw);
126
+ emit(key);
127
+ }
128
+
129
+ function subscribe(key: string, listener: () => void): () => void {
130
+ let set = listeners.get(key);
131
+ if (!set) listeners.set(key, (set = new Set()));
132
+ set.add(listener);
133
+ // Another tab wrote (or cleared all storage: `key` null).
134
+ const onStorage = (event: StorageEvent) => {
135
+ if (event.key !== null && event.key !== key) return;
136
+ memory.delete(key);
137
+ listener();
138
+ };
139
+ if (typeof window !== "undefined") window.addEventListener("storage", onStorage);
140
+ return () => {
141
+ set.delete(listener);
142
+ if (typeof window !== "undefined") window.removeEventListener("storage", onStorage);
143
+ };
144
+ }
145
+
146
+ /**
147
+ * Forget every tour completion under `storageKey` on this device: the stored key, what the
148
+ * page kept in memory where storage refused it, and every reader in this tab moves at
149
+ * once. For "Reset this device's settings" (settings §11):
150
+ *
151
+ * registerDeviceSetting({ key: TOUR_KEY, kind: "view", reset: () => resetTourProgress(TOUR_KEY) });
152
+ */
153
+ export function resetTourProgress(storageKey: string): void {
154
+ removeStored(storageKey);
155
+ memory.delete(storageKey);
156
+ cache.delete(storageKey);
157
+ emit(storageKey);
158
+ }
159
+
160
+ /** The device's tour completions under `storageKey`; see {@link UseTourProgressOptions}. */
161
+ export function useTourProgress({ storageKey, migrate }: UseTourProgressOptions): TourProgress {
162
+ // Held, not depended on: an inline arrow is a new function every render, and neither the
163
+ // snapshot nor the callbacks may change with it.
164
+ const migrateRef = useRef(migrate);
165
+ useEffect(() => {
166
+ migrateRef.current = migrate;
167
+ });
168
+ const completed = useSyncExternalStore(
169
+ useCallback((listener: () => void) => subscribe(storageKey, listener), [storageKey]),
170
+ () => snapshot(storageKey, migrateRef.current),
171
+ () => EMPTY,
172
+ );
173
+
174
+ const complete = useCallback(
175
+ (tour: string | { id: string; updated?: { date: string } | null }) => {
176
+ const subject = typeof tour === "string" ? { id: tour } : tour;
177
+ // Read now rather than from the render: two completions in one handler both count.
178
+ const current = snapshot(storageKey, migrateRef.current);
179
+ if (isTourDone(subject, current[subject.id])) return false;
180
+ write(storageKey, { ...current, [subject.id]: new Date().toISOString() });
181
+ return true;
182
+ },
183
+ [storageKey],
184
+ );
185
+
186
+ const reset = useCallback(
187
+ (id: string) => {
188
+ const current = snapshot(storageKey, migrateRef.current);
189
+ if (current[id] === undefined) return;
190
+ const next = { ...current };
191
+ delete next[id];
192
+ write(storageKey, next);
193
+ },
194
+ [storageKey],
195
+ );
196
+
197
+ return useMemo(() => ({ completed, complete, reset }), [completed, complete, reset]);
198
+ }
@@ -0,0 +1,221 @@
1
+ import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type Context } from "react";
2
+ import * as ReactRouter from "react-router";
3
+ import { useLocation, useNavigate, type Navigator } from "react-router";
4
+ import { useSearchParamsState } from "../hooks/use-search-param-state";
5
+ import { useTourOptional } from "./tour";
6
+
7
+ /* ── useTourUrlSync ─────────────────────────────────────────────────────────── */
8
+
9
+ export interface UseTourUrlSyncOptions {
10
+ /**
11
+ * Start the tour `id` at `startIndex` (0-based) — the app's launcher, which builds the
12
+ * steps, awaits its own `prepare()` and passes `onFinish` (the completion, the points
13
+ * toast) to `useTour().start`. An unknown id should do nothing. May return a promise:
14
+ * the parameters are kept until it settles.
15
+ */
16
+ launch: (id: string, options: { startIndex: number }) => void | Promise<unknown>;
17
+ /**
18
+ * Are the facts a tour's steps are built from loaded yet (keksdose's `ready`: the
19
+ * caches a branching tour reads)? A deep link waits for it, keeping its parameters, so
20
+ * an F5 mid-tour resumes the right variant at the right step. Default `true`.
21
+ */
22
+ ready?: boolean;
23
+ /**
24
+ * Older names of `tourStep`, read when it is absent and removed with it — kastlan's
25
+ * `ts` (§2.7, for one minor). Their value is a 0-based step index, as kastlan wrote it.
26
+ */
27
+ stepAliases?: readonly string[];
28
+ }
29
+
30
+ type Place = { pathname: string; search: string; hash: string };
31
+
32
+ /**
33
+ * Has the router already moved on from the location React is still rendering?
34
+ * keksdose's check (tour-url-sync.tsx, tours audit 2026-10-02): a mirror write built
35
+ * from the page being LEFT is a replace back onto it, which cancels a step's `go()`.
36
+ *
37
+ * - A data router (`createBrowserRouter`, kastlan): its navigator has no location (build
38
+ * review 0.43, kastlan A2), so the router's own state answers — a navigation still
39
+ * loading, or a location it has committed that React has not rendered yet.
40
+ * - A declarative router (`BrowserRouter`, keksdose): its navigator is its history and
41
+ * carries where it is.
42
+ * - Anything else answers "not in flight".
43
+ */
44
+ function navigationInFlight(history: Navigator | undefined, dataRouter: DataRouterProbe, rendered: Place) {
45
+ const state = dataRouter?.router?.state;
46
+ if (state && (state.navigation?.state ?? "idle") !== "idle") return true;
47
+ const live = state?.location ?? (history as (Navigator & { location?: Partial<Place> }) | undefined)?.location;
48
+ if (!live) return false;
49
+ return (
50
+ live.pathname !== rendered.pathname || (live.search ?? "") !== rendered.search || (live.hash ?? "") !== rendered.hash
51
+ );
52
+ }
53
+
54
+ type NavigationProbe = { navigator?: Navigator } | null;
55
+ const NO_NAVIGATION = createContext<NavigationProbe>(null);
56
+ /** The data router's state, as `useSearchParamState` reads it. */
57
+ type DataRouterProbe = {
58
+ router?: { state?: { navigation?: { state?: string }; location?: Partial<Place> } };
59
+ } | null;
60
+ const NO_DATA_ROUTER = createContext<DataRouterProbe>(null);
61
+ const DataRouterContext: Context<DataRouterProbe> = (() => {
62
+ try {
63
+ return (
64
+ (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??
65
+ NO_DATA_ROUTER
66
+ );
67
+ } catch {
68
+ return NO_DATA_ROUTER;
69
+ }
70
+ })();
71
+ /** Read off the namespace inside a `try`, as `useSearchParamState` reads the data router:
72
+ * a test's partial `vi.mock("react-router")` throws on an export it does not define. */
73
+ const NavigationContext: Context<NavigationProbe> = (() => {
74
+ try {
75
+ return (
76
+ (ReactRouter as unknown as { UNSAFE_NavigationContext?: Context<NavigationProbe> }).UNSAFE_NavigationContext ??
77
+ NO_NAVIGATION
78
+ );
79
+ } catch {
80
+ return NO_NAVIGATION;
81
+ }
82
+ })();
83
+
84
+ /** The step a link asks for, 0-based: `tourStep` is 1-based (keksdose), an alias 0-based. */
85
+ function stepOf(params: URLSearchParams, aliases: readonly string[]): number {
86
+ const step = Number(params.get("tourStep"));
87
+ if (params.has("tourStep")) return Number.isFinite(step) && step > 0 ? step - 1 : 0;
88
+ for (const alias of aliases) {
89
+ const value = Number(params.get(alias));
90
+ if (params.has(alias)) return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
91
+ }
92
+ return 0;
93
+ }
94
+
95
+ /**
96
+ * Tours and the URL (docs/tours-harmonization.md §2.7, §6.4) — keksdose's `TourUrlSync`,
97
+ * on the kit's engine (`useTourOptional`):
98
+ *
99
+ * - **`?tour=<id>` starts that tour whenever it APPEARS** — on load, and on in-app
100
+ * navigation too (kastlan's assistant links to `/help/tours?tour=<id>`, ka F4) —
101
+ * resuming at `&tourStep=<n>` (1-based). It waits for `ready`.
102
+ * - **While a tour runs, `?tour=&tourStep=` mirror where it is**, so a reload or a copied
103
+ * link pins the step (kk K-3). A step that navigates drops them; this puts them back.
104
+ * - **At the end it strips only its own parameters**; `?q=`, `?state=` and the hash stay.
105
+ *
106
+ * Mount it once inside the router and the `TourProvider`. Without a provider it still
107
+ * launches through `launch` and never mirrors.
108
+ */
109
+ export function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTourUrlSyncOptions): void {
110
+ const navigate = useNavigate();
111
+ const location = useLocation();
112
+ const { pathname, search, hash } = location;
113
+ const history = useContext(NavigationContext)?.navigator;
114
+ const dataRouter = useContext(DataRouterContext);
115
+ const engine = useTourOptional();
116
+ const active = engine?.active ?? false;
117
+ const activeId = engine?.activeId ?? null;
118
+ const index = engine?.index ?? 0;
119
+ const urlTour = new URLSearchParams(search).get("tour");
120
+ const aliases = stepAliases.join("\n");
121
+
122
+ const launchRef = useRef(launch);
123
+ const activeIdRef = useRef(activeId);
124
+ useLayoutEffect(() => {
125
+ launchRef.current = launch;
126
+ activeIdRef.current = active ? activeId : null;
127
+ });
128
+ /** The `tour` value last seen in the URL: a tour starts when it CHANGES to an id. */
129
+ const seenRef = useRef<string | null>(null);
130
+ /** A deep link waiting for `ready`. */
131
+ const pendingRef = useRef<{ id: string; startIndex: number } | null>(null);
132
+ /** A launch under way (an app's `prepare()`). */
133
+ const launchingRef = useRef(false);
134
+ /** Bumped when a launch settles, so the mirror looks again. */
135
+ const [settled, setSettled] = useState(0);
136
+
137
+ useEffect(() => {
138
+ if (urlTour !== seenRef.current) {
139
+ seenRef.current = urlTour;
140
+ // Its own mirror writing the running tour back is no new link.
141
+ pendingRef.current =
142
+ urlTour && urlTour !== activeIdRef.current
143
+ ? { id: urlTour, startIndex: stepOf(new URLSearchParams(search), aliases ? aliases.split("\n") : []) }
144
+ : null;
145
+ }
146
+ const pending = pendingRef.current;
147
+ if (!pending || !ready) return;
148
+ pendingRef.current = null;
149
+ launchingRef.current = true;
150
+ void Promise.resolve()
151
+ .then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }))
152
+ .catch(() => undefined)
153
+ .finally(() => {
154
+ launchingRef.current = false;
155
+ setSettled((n) => n + 1);
156
+ });
157
+ // Only a change of the parameter, or readiness, starts a tour.
158
+ // eslint-disable-next-line react-hooks/exhaustive-deps
159
+ }, [urlTour, ready]);
160
+
161
+ useEffect(() => {
162
+ if (navigationInFlight(history, dataRouter, { pathname, search, hash })) return;
163
+ // A link not yet used keeps its parameters: a reload in that window resumes.
164
+ if (pendingRef.current || launchingRef.current) return;
165
+ const tourId = active && activeId ? activeId : null;
166
+ const own = ["tour", "tourStep", ...(aliases ? aliases.split("\n") : [])];
167
+ const current = new URLSearchParams(search);
168
+ // Nothing to mirror and nothing to strip: never touch the URL (a no-op replace
169
+ // clobbers a page's own just-issued navigation, keksdose feedback #4).
170
+ if (!tourId && !own.some((key) => current.has(key))) return;
171
+ const next = new URLSearchParams(search);
172
+ // `set` keeps a parameter where it stands; only the aliases (and, at the end, the
173
+ // tour's own) go.
174
+ for (const key of tourId ? own.slice(2) : own) next.delete(key);
175
+ if (tourId) {
176
+ next.set("tour", tourId);
177
+ next.set("tourStep", String(index + 1));
178
+ }
179
+ if (next.toString() === current.toString()) return;
180
+ // Spelled out, not `setSearchParams` ("?…" drops the hash, keksdose dev#495).
181
+ void navigate({ pathname, search: next.toString(), hash }, { replace: true, state: location.state });
182
+ // eslint-disable-next-line react-hooks/exhaustive-deps
183
+ }, [active, activeId, index, pathname, search, hash, settled, aliases]);
184
+ }
185
+
186
+ /* ── useTourCatalogueUrlState ───────────────────────────────────────────────── */
187
+
188
+ /** The catalogue's state filter. */
189
+ export type TourStateFilter = "all" | "todo" | "done";
190
+
191
+ /** The catalogue's filters: the search, the group (`null`: every group), the state. */
192
+ export interface TourCatalogueFilter {
193
+ query: string;
194
+ group: string | null;
195
+ state: TourStateFilter;
196
+ }
197
+
198
+ const STATES: readonly TourStateFilter[] = ["all", "todo", "done"];
199
+
200
+ /**
201
+ * The catalogue's filters in the URL (§2.8) — keksdose's `?q`, `?topic`, `?state`, each
202
+ * written with `replace` (typing does not bury the Back button), the defaults as the
203
+ * clean URL. Spread it onto the catalogue: `<TourCatalogue {...useTourCatalogueUrlState()} … />`.
204
+ * `params` renames them (kastlan's `?group`). Requires a react-router Router.
205
+ */
206
+ export function useTourCatalogueUrlState(
207
+ params: { query?: string; group?: string; state?: string } = {},
208
+ ): TourCatalogueFilter & { onChange: (patch: Partial<TourCatalogueFilter>) => void } {
209
+ const [filter, setFilter] = useSearchParamsState<TourCatalogueFilter>({
210
+ query: { param: params.query ?? "q", default: "", history: "replace" },
211
+ group: { param: params.group ?? "topic", default: null, history: "replace", parse: (raw) => raw || undefined },
212
+ state: {
213
+ param: params.state ?? "state",
214
+ default: "all",
215
+ history: "replace",
216
+ parse: (raw) => STATES.find((x) => x === raw),
217
+ },
218
+ });
219
+ // `group: null` is a value to the setter (it clears `?topic`); `undefined` leaves it.
220
+ return { ...filter, onChange: setFilter };
221
+ }
package/src/tour.ts CHANGED
@@ -1,3 +1,14 @@
1
1
  // `@eifi1/ui-kit/tour` — guided tours.
2
2
  // A re-slicing of the main barrel, not a new API.
3
3
  export * from "./tour/tour";
4
+ // 0.43.0: the tours page (docs/tours-harmonization.md §3, §8) — keksdose's catalogue, its
5
+ // card, the NEW and reset rules, device progress and the URL. `useTourUrlSync` and
6
+ // `useTourCatalogueUrlState` need react-router (a peer already, for /shell).
7
+ export { TourCatalogue, TourCatalogueCard, DEFAULT_TOUR_CATALOGUE_LABELS } from "./tour/tour-catalogue";
8
+ export type { TourCatalogueProps, TourCatalogueCardProps, TourCatalogueLabels } from "./tour/tour-catalogue";
9
+ export { isTourNew, isTourReset, isTourDone, doneCount, earnedPoints } from "./tour/tour-info";
10
+ export type { TourInfo, TourGroup, TourUpdate, TourCompletions } from "./tour/tour-info";
11
+ export { useTourProgress, resetTourProgress } from "./tour/use-tour-progress";
12
+ export type { UseTourProgressOptions, TourProgress } from "./tour/use-tour-progress";
13
+ export { useTourUrlSync, useTourCatalogueUrlState } from "./tour/use-tour-url-sync";
14
+ export type { UseTourUrlSyncOptions, TourCatalogueFilter, TourStateFilter } from "./tour/use-tour-url-sync";