@gogitcms/design-system 0.15.0-next.3

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 (70) hide show
  1. package/README.md +77 -0
  2. package/css/components.css +312 -0
  3. package/css/tokens.css +212 -0
  4. package/package.json +65 -0
  5. package/src/ThemeProvider.tsx +86 -0
  6. package/src/__tests__/ApplyChangesModal.test.tsx +148 -0
  7. package/src/__tests__/BranchImport.test.tsx +46 -0
  8. package/src/__tests__/Button.test.tsx +45 -0
  9. package/src/__tests__/ChangeRequestSummary.test.tsx +57 -0
  10. package/src/__tests__/ContentBrowser.changes.test.tsx +611 -0
  11. package/src/__tests__/ContentBrowser.collab.test.tsx +322 -0
  12. package/src/__tests__/ContentBrowser.collabsync.test.tsx +264 -0
  13. package/src/__tests__/ContentBrowser.contentslot.test.tsx +53 -0
  14. package/src/__tests__/ContentBrowser.discriminator.test.tsx +142 -0
  15. package/src/__tests__/ContentBrowser.drafts.test.tsx +271 -0
  16. package/src/__tests__/ContentBrowser.fields.test.tsx +117 -0
  17. package/src/__tests__/ContentBrowser.media.test.tsx +140 -0
  18. package/src/__tests__/ContentBrowser.mixedcollab.test.tsx +63 -0
  19. package/src/__tests__/ContentBrowser.mixedvalues.test.tsx +38 -0
  20. package/src/__tests__/ContentBrowser.pagination.test.tsx +62 -0
  21. package/src/__tests__/ContentBrowser.previewtab.test.tsx +212 -0
  22. package/src/__tests__/ContentBrowser.reorder.test.tsx +45 -0
  23. package/src/__tests__/ContentBrowser.search.test.tsx +135 -0
  24. package/src/__tests__/ContentBrowser.selectvalue.test.tsx +185 -0
  25. package/src/__tests__/ContentBrowser.staged.test.tsx +132 -0
  26. package/src/__tests__/ContentBrowser.usermenu.test.tsx +56 -0
  27. package/src/__tests__/MediaBrowser.test.tsx +353 -0
  28. package/src/__tests__/MediaField.test.tsx +185 -0
  29. package/src/__tests__/Notifications.test.tsx +69 -0
  30. package/src/__tests__/Onboarding.test.tsx +287 -0
  31. package/src/__tests__/cssTokens.test.ts +201 -0
  32. package/src/__tests__/fieldComponents.test.ts +43 -0
  33. package/src/__tests__/reorder.test.ts +58 -0
  34. package/src/components/ApplyChangesModal.tsx +348 -0
  35. package/src/components/BranchImport.tsx +157 -0
  36. package/src/components/BranchMenu.tsx +192 -0
  37. package/src/components/Button.tsx +131 -0
  38. package/src/components/ChangeDetail.tsx +472 -0
  39. package/src/components/ChangeRequestSummary.tsx +173 -0
  40. package/src/components/CollabField.tsx +388 -0
  41. package/src/components/ContentBrowser.tsx +5073 -0
  42. package/src/components/Icon.tsx +28 -0
  43. package/src/components/Icon.web.tsx +31 -0
  44. package/src/components/Input.tsx +106 -0
  45. package/src/components/MediaBrowser.tsx +766 -0
  46. package/src/components/MediaField.tsx +670 -0
  47. package/src/components/MediaPreview.tsx +91 -0
  48. package/src/components/MediaPreview.web.tsx +169 -0
  49. package/src/components/NavRow.tsx +105 -0
  50. package/src/components/Notifications.tsx +301 -0
  51. package/src/components/Onboarding.tsx +751 -0
  52. package/src/components/ProjectMenu.tsx +124 -0
  53. package/src/components/Segment.tsx +87 -0
  54. package/src/components/Skeleton.tsx +216 -0
  55. package/src/components/Spinner.tsx +44 -0
  56. package/src/components/Text.tsx +85 -0
  57. package/src/components/documentDrafts.ts +213 -0
  58. package/src/components/layout.tsx +284 -0
  59. package/src/components/primitives.tsx +143 -0
  60. package/src/components/reorder.ts +40 -0
  61. package/src/fieldComponents.ts +63 -0
  62. package/src/icons.ts +102 -0
  63. package/src/index.ts +198 -0
  64. package/src/media.ts +229 -0
  65. package/src/theme.ts +116 -0
  66. package/src/web/Button.tsx +110 -0
  67. package/src/web/Icon.tsx +52 -0
  68. package/src/web/Input.tsx +39 -0
  69. package/src/web/index.ts +43 -0
  70. package/src/web/primitives.tsx +119 -0
@@ -0,0 +1,751 @@
1
+ import React, {
2
+ createContext,
3
+ useCallback,
4
+ useContext,
5
+ useEffect,
6
+ useMemo,
7
+ useRef,
8
+ useState,
9
+ } from "react";
10
+ import { Pressable, View, useWindowDimensions } from "react-native";
11
+ import { useTheme } from "../ThemeProvider";
12
+ import { Text } from "./Text";
13
+ import { Button, IconButton } from "./Button";
14
+
15
+ /**
16
+ * The guided-tour framework: a spotlight overlay that walks someone through a
17
+ * screen one anchored step at a time.
18
+ *
19
+ * Three pieces, deliberately separable:
20
+ *
21
+ * - `useOnboardingTarget(id)` marks a piece of UI as anchorable. It hands back
22
+ * a ref and nothing else, so anchoring a step to an existing element never
23
+ * changes that element's layout — the failure mode of every tour library
24
+ * that asks you to wrap things.
25
+ * - `OnboardingProvider` owns which flow is running and where in it we are,
26
+ * and renders the overlay.
27
+ * - `useOnboarding()` is the host's handle: start a tour, replay one, ask
28
+ * whether one has been seen.
29
+ *
30
+ * Persistence is a seam, not a dependency: the provider takes `progress` and
31
+ * calls `onProgress`, so the dashboard can back it with REST and the editor SPA
32
+ * with GraphQL without this file knowing either exists.
33
+ */
34
+
35
+ // ── Types ──────────────────────────────────────────────────────────────────
36
+
37
+ /** Where a step's card sits relative to its target. */
38
+ export type OnboardingPlacement = "auto" | "top" | "bottom" | "left" | "right" | "center";
39
+
40
+ export type OnboardingFlowStep = {
41
+ id: string;
42
+ title: string;
43
+ body: string;
44
+ /**
45
+ * The id passed to `useOnboardingTarget`. Absent — or present but not
46
+ * currently rendered — falls back to a centered card, which is what makes a
47
+ * step that describes the screen as a whole (or one whose target is hidden on
48
+ * mobile) degrade rather than break.
49
+ */
50
+ target?: string;
51
+ placement?: OnboardingPlacement;
52
+ /**
53
+ * Run when the step becomes current. For steps whose target only exists once
54
+ * something is open — a menu, a drawer — the host opens it here.
55
+ */
56
+ onEnter?: () => void;
57
+ };
58
+
59
+ export type OnboardingFlow = {
60
+ id: string;
61
+ /**
62
+ * Bumped when the tour's content changes materially. A user whose recorded
63
+ * version is lower is offered the tour again; this is why completion is not
64
+ * stored as a bare boolean.
65
+ */
66
+ version: number;
67
+ /** Shown as the overlay's kicker, e.g. "Dashboard tour". */
68
+ title: string;
69
+ steps: OnboardingFlowStep[];
70
+ };
71
+
72
+ export type OnboardingStatus = "pending" | "in_progress" | "completed" | "skipped";
73
+
74
+ /** One flow's recorded position. Mirrors the server's row. */
75
+ export type OnboardingProgress = {
76
+ status: OnboardingStatus;
77
+ stepIndex: number;
78
+ version: number;
79
+ };
80
+
81
+ export type OnboardingSession = {
82
+ flow: OnboardingFlow;
83
+ step: OnboardingFlowStep;
84
+ index: number;
85
+ total: number;
86
+ };
87
+
88
+ export type OnboardingApi = {
89
+ /** The running tour, or null. */
90
+ session: OnboardingSession | null;
91
+ /** Start (or resume) a flow. `atIndex` overrides the resume point. */
92
+ start: (flowId: string, atIndex?: number) => void;
93
+ /** Run a flow from the top regardless of what has been recorded. */
94
+ restart: (flowId: string) => void;
95
+ next: () => void;
96
+ back: () => void;
97
+ /** Dismiss: records `skipped`. */
98
+ skip: () => void;
99
+ /** Close without recording anything (route changes, unmount). */
100
+ stop: () => void;
101
+ /** What has been recorded for a flow — `pending` when nothing has. */
102
+ statusFor: (flowId: string) => OnboardingStatus;
103
+ /** True when the flow is worth offering, i.e. never seen or its version bumped. */
104
+ isUnseen: (flowId: string) => boolean;
105
+ flows: OnboardingFlow[];
106
+ };
107
+
108
+ type Rect = { x: number; y: number; width: number; height: number };
109
+
110
+ // ── Target registry ────────────────────────────────────────────────────────
111
+
112
+ type Registry = {
113
+ register: (id: string, node: unknown) => void;
114
+ unregister: (id: string) => void;
115
+ get: (id: string) => unknown;
116
+ /** Fires when a target mounts or unmounts, so a waiting step can re-measure. */
117
+ subscribe: (fn: () => void) => () => void;
118
+ };
119
+
120
+ const TargetsCtx = createContext<Registry | null>(null);
121
+ const OnboardingCtx = createContext<OnboardingApi | null>(null);
122
+
123
+ function createRegistry(): Registry {
124
+ const nodes = new Map<string, unknown>();
125
+ const subs = new Set<() => void>();
126
+ const notify = () => subs.forEach((fn) => fn());
127
+ return {
128
+ register(id, node) {
129
+ nodes.set(id, node);
130
+ notify();
131
+ },
132
+ unregister(id) {
133
+ nodes.delete(id);
134
+ notify();
135
+ },
136
+ get: (id) => nodes.get(id),
137
+ subscribe(fn) {
138
+ subs.add(fn);
139
+ return () => subs.delete(fn);
140
+ },
141
+ };
142
+ }
143
+
144
+ /**
145
+ * Marks a piece of UI as a tour anchor. Spread the returned ref onto any View:
146
+ *
147
+ * const stats = useOnboardingTarget("dash.stats");
148
+ * <View ref={stats.ref} style={…}>
149
+ *
150
+ * Registering is layout-neutral by construction — no wrapper element, no style.
151
+ */
152
+ export function useOnboardingTarget(id: string): { ref: (node: unknown) => void } {
153
+ const reg = useContext(TargetsCtx);
154
+ const ref = useCallback(
155
+ (node: unknown) => {
156
+ if (!reg) return;
157
+ if (node) reg.register(id, node);
158
+ else reg.unregister(id);
159
+ },
160
+ [id, reg],
161
+ );
162
+ return { ref };
163
+ }
164
+
165
+ /** Wrapper form of `useOnboardingTarget`, for anchoring something you don't own. */
166
+ export function OnboardingTarget({
167
+ id,
168
+ children,
169
+ style,
170
+ }: {
171
+ id: string;
172
+ children: React.ReactNode;
173
+ style?: any;
174
+ }) {
175
+ const { ref } = useOnboardingTarget(id);
176
+ return (
177
+ <View ref={ref as any} style={style} collapsable={false}>
178
+ {children}
179
+ </View>
180
+ );
181
+ }
182
+
183
+ // ── Measurement ────────────────────────────────────────────────────────────
184
+
185
+ // measureNode reads a node's viewport rect. On react-native-web the ref is the
186
+ // DOM element, so getBoundingClientRect is both available and cheaper than RN's
187
+ // callback API; measureInWindow is the native path. A zero-sized result counts
188
+ // as "not measurable" — that is what a display:none target looks like.
189
+ function measureNode(node: any): Promise<Rect | null> {
190
+ return new Promise((resolve) => {
191
+ if (!node) {
192
+ resolve(null);
193
+ return;
194
+ }
195
+ if (typeof node.getBoundingClientRect === "function") {
196
+ const r = node.getBoundingClientRect();
197
+ resolve(r.width || r.height ? { x: r.left, y: r.top, width: r.width, height: r.height } : null);
198
+ return;
199
+ }
200
+ if (typeof node.measureInWindow === "function") {
201
+ node.measureInWindow((x: number, y: number, width: number, height: number) => {
202
+ resolve(width || height ? { x, y, width, height } : null);
203
+ });
204
+ return;
205
+ }
206
+ resolve(null);
207
+ });
208
+ }
209
+
210
+ // scrollIntoViewIfNeeded brings an off-screen anchor into view before the
211
+ // spotlight is drawn around it. Web-only and best-effort: a tour that silently
212
+ // highlights something below the fold is worse than one that scrolls.
213
+ function scrollIntoViewIfNeeded(node: any, rect: Rect | null, viewportH: number) {
214
+ if (!node || typeof node.scrollIntoView !== "function" || !rect) return false;
215
+ if (rect.y >= 0 && rect.y + rect.height <= viewportH) return false;
216
+ try {
217
+ node.scrollIntoView({ block: "center", inline: "nearest", behavior: "smooth" });
218
+ return true;
219
+ } catch {
220
+ return false;
221
+ }
222
+ }
223
+
224
+ /**
225
+ * Tracks a target's rect while a step is showing.
226
+ *
227
+ * Measuring once is not enough: the anchor may not have mounted yet (data still
228
+ * loading), the page may need scrolling to reach it, and the window may resize
229
+ * under the overlay. So this re-measures on a short poll that stops as soon as
230
+ * the rect settles, and on every resize, scroll and registry change.
231
+ */
232
+ function useTargetRect(targetId: string | undefined, stepKey: string): Rect | null {
233
+ const reg = useContext(TargetsCtx);
234
+ const { height: viewportH } = useWindowDimensions();
235
+ const [rect, setRect] = useState<Rect | null>(null);
236
+ const last = useRef<Rect | null>(null);
237
+ const scrolled = useRef(false);
238
+
239
+ useEffect(() => {
240
+ if (!targetId || !reg) {
241
+ last.current = null;
242
+ setRect(null);
243
+ return;
244
+ }
245
+ let cancelled = false;
246
+ let timer: any;
247
+ let settled = 0;
248
+ scrolled.current = false;
249
+ last.current = null;
250
+
251
+ const tick = async () => {
252
+ if (cancelled) return;
253
+ const node = reg.get(targetId);
254
+ const next = await measureNode(node);
255
+ if (cancelled) return;
256
+ if (next && !scrolled.current) {
257
+ scrolled.current = true;
258
+ // A scroll moves the rect; the poll below picks up the new one.
259
+ scrollIntoViewIfNeeded(node, next, viewportH);
260
+ }
261
+ settled = next && sameRect(last.current, next) ? settled + 1 : 0;
262
+ if (!sameRect(last.current, next)) {
263
+ last.current = next;
264
+ setRect(next);
265
+ }
266
+ // Back off once the rect has held still for a few frames, so a settled
267
+ // tour is not re-measuring the DOM twelve times a second.
268
+ timer = setTimeout(tick, settled > 3 ? 500 : 80);
269
+ };
270
+ tick();
271
+
272
+ const unsub = reg.subscribe(() => {
273
+ clearTimeout(timer);
274
+ settled = 0;
275
+ tick();
276
+ });
277
+ return () => {
278
+ cancelled = true;
279
+ clearTimeout(timer);
280
+ unsub();
281
+ };
282
+ }, [targetId, stepKey, reg, viewportH]);
283
+
284
+ return rect;
285
+ }
286
+
287
+ function sameRect(a: Rect | null, b: Rect | null): boolean {
288
+ if (!a || !b) return a === b;
289
+ return (
290
+ Math.abs(a.x - b.x) < 0.5 &&
291
+ Math.abs(a.y - b.y) < 0.5 &&
292
+ Math.abs(a.width - b.width) < 0.5 &&
293
+ Math.abs(a.height - b.height) < 0.5
294
+ );
295
+ }
296
+
297
+ // ── Provider ───────────────────────────────────────────────────────────────
298
+
299
+ export type OnboardingProviderProps = {
300
+ flows: OnboardingFlow[];
301
+ /**
302
+ * The flow this screen is about. Changing it ends any running tour — a tour
303
+ * anchored to a screen you have navigated away from has nothing to point at.
304
+ */
305
+ activeFlow?: string;
306
+ /**
307
+ * Recorded progress, keyed by flow id. `undefined` means "not loaded yet" and
308
+ * suppresses auto-start, so a slow round-trip never replays a finished tour.
309
+ */
310
+ progress?: Record<string, OnboardingProgress>;
311
+ /** Persist a position. Called on every step, on skip and on completion. */
312
+ onProgress?: (flowId: string, next: OnboardingProgress) => void;
313
+ /**
314
+ * Hold auto-start until the screen's data has landed. Anchors that render
315
+ * after their step would otherwise spotlight nothing.
316
+ */
317
+ ready?: boolean;
318
+ /** False turns the whole thing off (embedded surfaces, tests). */
319
+ enabled?: boolean;
320
+ children: React.ReactNode;
321
+ style?: any;
322
+ };
323
+
324
+ export function OnboardingProvider({
325
+ flows,
326
+ activeFlow,
327
+ progress,
328
+ onProgress,
329
+ ready = true,
330
+ enabled = true,
331
+ children,
332
+ style,
333
+ }: OnboardingProviderProps) {
334
+ const registry = useMemo(createRegistry, []);
335
+ const [session, setSession] = useState<{ flowId: string; index: number } | null>(null);
336
+ // Optimistic overlay on `progress`: the host round-trips a write, and until it
337
+ // comes back the local record is what stops an auto-start loop.
338
+ const [local, setLocal] = useState<Record<string, OnboardingProgress>>({});
339
+
340
+ const merged = useMemo(() => ({ ...(progress ?? {}), ...local }), [progress, local]);
341
+ const flowById = useCallback((id: string) => flows.find((f) => f.id === id), [flows]);
342
+
343
+ const record = useCallback(
344
+ (flowId: string, next: OnboardingProgress) => {
345
+ setLocal((prev) => ({ ...prev, [flowId]: next }));
346
+ onProgress?.(flowId, next);
347
+ },
348
+ [onProgress],
349
+ );
350
+
351
+ const statusFor = useCallback(
352
+ (flowId: string): OnboardingStatus => merged[flowId]?.status ?? "pending",
353
+ [merged],
354
+ );
355
+
356
+ const isUnseen = useCallback(
357
+ (flowId: string) => {
358
+ const flow = flowById(flowId);
359
+ const p = merged[flowId];
360
+ if (!flow) return false;
361
+ if (!p || p.status === "pending") return true;
362
+ // A bumped version re-offers the tour to someone who finished the old one.
363
+ return p.version < flow.version;
364
+ },
365
+ [flowById, merged],
366
+ );
367
+
368
+ const start = useCallback(
369
+ (flowId: string, atIndex?: number) => {
370
+ const flow = flowById(flowId);
371
+ if (!flow || flow.steps.length === 0) return;
372
+ const stored = merged[flowId];
373
+ const resume =
374
+ atIndex ??
375
+ (stored && stored.status === "in_progress" && stored.version === flow.version
376
+ ? stored.stepIndex
377
+ : 0);
378
+ const index = Math.min(Math.max(resume, 0), flow.steps.length - 1);
379
+ setSession({ flowId, index });
380
+ record(flowId, { status: "in_progress", stepIndex: index, version: flow.version });
381
+ },
382
+ [flowById, merged, record],
383
+ );
384
+
385
+ const restart = useCallback((flowId: string) => start(flowId, 0), [start]);
386
+
387
+ const stop = useCallback(() => setSession(null), []);
388
+
389
+ const next = useCallback(() => {
390
+ setSession((cur) => {
391
+ if (!cur) return cur;
392
+ const flow = flowById(cur.flowId);
393
+ if (!flow) return null;
394
+ const last = flow.steps.length - 1;
395
+ if (cur.index >= last) {
396
+ record(cur.flowId, { status: "completed", stepIndex: last, version: flow.version });
397
+ return null;
398
+ }
399
+ const index = cur.index + 1;
400
+ record(cur.flowId, { status: "in_progress", stepIndex: index, version: flow.version });
401
+ return { ...cur, index };
402
+ });
403
+ }, [flowById, record]);
404
+
405
+ const back = useCallback(() => {
406
+ setSession((cur) => {
407
+ if (!cur || cur.index === 0) return cur;
408
+ const flow = flowById(cur.flowId);
409
+ if (!flow) return null;
410
+ const index = cur.index - 1;
411
+ record(cur.flowId, { status: "in_progress", stepIndex: index, version: flow.version });
412
+ return { ...cur, index };
413
+ });
414
+ }, [flowById, record]);
415
+
416
+ const skip = useCallback(() => {
417
+ setSession((cur) => {
418
+ if (!cur) return cur;
419
+ const flow = flowById(cur.flowId);
420
+ if (flow) {
421
+ record(cur.flowId, { status: "skipped", stepIndex: cur.index, version: flow.version });
422
+ }
423
+ return null;
424
+ });
425
+ }, [flowById, record]);
426
+
427
+ // Auto-start: the screen declares which flow it is about, and a flow nobody
428
+ // has seen (or whose version moved on) runs itself. An in-progress flow
429
+ // resumes where it stopped rather than restarting.
430
+ const activeRef = useRef<string | undefined>(undefined);
431
+ useEffect(() => {
432
+ if (activeRef.current !== activeFlow) {
433
+ activeRef.current = activeFlow;
434
+ // Leaving a screen ends its tour; nothing is recorded, so it resumes on
435
+ // the way back.
436
+ setSession(null);
437
+ }
438
+ }, [activeFlow]);
439
+
440
+ useEffect(() => {
441
+ if (!enabled || !ready || !activeFlow || progress === undefined || session) return;
442
+ const flow = flowById(activeFlow);
443
+ if (!flow) return;
444
+ const p = merged[activeFlow];
445
+ if (p && (p.status === "completed" || p.status === "skipped") && p.version >= flow.version) return;
446
+ start(activeFlow);
447
+ }, [enabled, ready, activeFlow, progress, session, merged, flowById, start]);
448
+
449
+ const currentFlow = session ? flowById(session.flowId) : undefined;
450
+ const api: OnboardingApi = useMemo(
451
+ () => ({
452
+ session:
453
+ currentFlow && session
454
+ ? {
455
+ flow: currentFlow,
456
+ step: currentFlow.steps[session.index],
457
+ index: session.index,
458
+ total: currentFlow.steps.length,
459
+ }
460
+ : null,
461
+ start,
462
+ restart,
463
+ next,
464
+ back,
465
+ skip,
466
+ stop,
467
+ statusFor,
468
+ isUnseen,
469
+ flows,
470
+ }),
471
+ [currentFlow, session, start, restart, next, back, skip, stop, statusFor, isUnseen, flows],
472
+ );
473
+
474
+ return (
475
+ <TargetsCtx.Provider value={registry}>
476
+ <OnboardingCtx.Provider value={api}>
477
+ <View style={[{ flex: 1 }, style]}>
478
+ {children}
479
+ {enabled && api.session ? <OnboardingOverlay /> : null}
480
+ </View>
481
+ </OnboardingCtx.Provider>
482
+ </TargetsCtx.Provider>
483
+ );
484
+ }
485
+
486
+ /** The host's handle on the running tour. Throws outside a provider. */
487
+ export function useOnboarding(): OnboardingApi {
488
+ const ctx = useContext(OnboardingCtx);
489
+ if (!ctx) throw new Error("useOnboarding outside OnboardingProvider");
490
+ return ctx;
491
+ }
492
+
493
+ /** Non-throwing form, for shared components that may render without a provider. */
494
+ export function useOnboardingOptional(): OnboardingApi | null {
495
+ return useContext(OnboardingCtx);
496
+ }
497
+
498
+ // ── Overlay ────────────────────────────────────────────────────────────────
499
+
500
+ const CARD_WIDTH = 340;
501
+ const CARD_GAP = 12;
502
+ const HOLE_PAD = 6;
503
+ const EDGE = 16;
504
+
505
+ // The scrim and the ring are fixed light-on-dark rather than themed: the
506
+ // spotlight always sits on a dark wash, so a themed ring would vanish into it
507
+ // in one mode. Same reasoning as the permanently dark top bar.
508
+ const SCRIM = "rgba(10,10,10,0.55)";
509
+ const RING = "rgba(255,255,255,0.92)";
510
+
511
+ export function OnboardingOverlay() {
512
+ const t = useTheme();
513
+ const { width: vw, height: vh } = useWindowDimensions();
514
+ const { session, next, back, skip } = useOnboarding();
515
+ const [cardSize, setCardSize] = useState<{ w: number; h: number } | null>(null);
516
+
517
+ const step = session?.step;
518
+ const stepKey = session ? `${session.flow.id}:${session.index}` : "";
519
+ const rect = useTargetRect(step?.target, stepKey);
520
+
521
+ // Steps that open something (a menu, a drawer) do it here, on entry.
522
+ const onEnter = step?.onEnter;
523
+ useEffect(() => {
524
+ onEnter?.();
525
+ }, [stepKey, onEnter]);
526
+
527
+ // Keyboard: the tour is a modal thing, so it takes the keys a modal takes.
528
+ useEffect(() => {
529
+ if (typeof document === "undefined") return;
530
+ const onKey = (e: any) => {
531
+ if (e.key === "Escape") {
532
+ e.preventDefault();
533
+ skip();
534
+ } else if (e.key === "ArrowRight" || e.key === "Enter") {
535
+ e.preventDefault();
536
+ next();
537
+ } else if (e.key === "ArrowLeft") {
538
+ e.preventDefault();
539
+ back();
540
+ }
541
+ };
542
+ document.addEventListener("keydown", onKey);
543
+ return () => document.removeEventListener("keydown", onKey);
544
+ }, [next, back, skip]);
545
+
546
+ if (!session || !step) return null;
547
+
548
+ const hole = rect
549
+ ? {
550
+ left: Math.max(rect.x - HOLE_PAD, 0),
551
+ top: Math.max(rect.y - HOLE_PAD, 0),
552
+ width: Math.min(rect.width + HOLE_PAD * 2, vw),
553
+ height: Math.min(rect.height + HOLE_PAD * 2, vh),
554
+ }
555
+ : null;
556
+
557
+ const cardW = Math.min(CARD_WIDTH, vw - EDGE * 2);
558
+ const card = placeCard(hole, cardSize, { vw, vh, cardW, placement: step.placement ?? "auto" });
559
+ const isLast = session.index === session.total - 1;
560
+
561
+ return (
562
+ <View
563
+ testID="onboarding-overlay"
564
+ style={{
565
+ position: "absolute",
566
+ top: 0,
567
+ left: 0,
568
+ right: 0,
569
+ bottom: 0,
570
+ zIndex: 100,
571
+ // The container spans the viewport, so leaving it hit-testable would
572
+ // swallow the click on the very control the step points at — the
573
+ // spotlight would be a picture of an interactive element rather than
574
+ // the element. It stays transparent to clicks and the pieces that do
575
+ // need them (the scrim panels, the card) opt back in individually.
576
+ // Written per-child rather than as `box-none` on the parent because
577
+ // react-native-web only honours "none"/"auto" from a style object.
578
+ pointerEvents: "none",
579
+ }}
580
+ >
581
+ {/* The scrim is four rects rather than one with a cut-out: it dims
582
+ everything but the anchor, and because nothing covers the anchor it
583
+ stays genuinely clickable — the user can do the thing being described
584
+ without leaving the tour. */}
585
+ {hole ? (
586
+ <>
587
+ <Backdrop style={{ left: 0, right: 0, top: 0, height: hole.top }} />
588
+ <Backdrop style={{ left: 0, right: 0, top: hole.top + hole.height, bottom: 0 }} />
589
+ <Backdrop style={{ left: 0, width: hole.left, top: hole.top, height: hole.height }} />
590
+ <Backdrop
591
+ style={{ left: hole.left + hole.width, right: 0, top: hole.top, height: hole.height }}
592
+ />
593
+ <View
594
+ testID="onboarding-spotlight"
595
+ style={{
596
+ position: "absolute",
597
+ // In style rather than as a prop: the ring is decoration, and the
598
+ // anchor beneath it has to stay clickable through it.
599
+ pointerEvents: "none",
600
+ left: hole.left,
601
+ top: hole.top,
602
+ width: hole.width,
603
+ height: hole.height,
604
+ borderWidth: 2,
605
+ borderColor: RING,
606
+ borderRadius: t.radius.md,
607
+ }}
608
+ />
609
+ </>
610
+ ) : (
611
+ <Backdrop style={{ left: 0, right: 0, top: 0, bottom: 0 }} />
612
+ )}
613
+
614
+ <View
615
+ testID="onboarding-card"
616
+ onLayout={(e) =>
617
+ setCardSize({ w: e.nativeEvent.layout.width, h: e.nativeEvent.layout.height })
618
+ }
619
+ style={{
620
+ position: "absolute",
621
+ left: card.left,
622
+ top: card.top,
623
+ width: cardW,
624
+ pointerEvents: "auto",
625
+ // Held invisible for the first frame only: placement needs the card's
626
+ // measured height, and a card that jumps is worse than one that fades.
627
+ opacity: cardSize ? 1 : 0,
628
+ gap: t.space(3),
629
+ padding: t.space(4),
630
+ borderRadius: t.radius.lg,
631
+ borderWidth: 1,
632
+ borderColor: t.color.borderDefault,
633
+ backgroundColor: t.color.surfaceRaised,
634
+ boxShadow: "0 12px 32px rgba(10,10,10,0.28)",
635
+ }}
636
+ >
637
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
638
+ <Text variant="label" color="tertiary" style={{ flex: 1 }}>
639
+ {`${session.flow.title} · ${session.index + 1}/${session.total}`}
640
+ </Text>
641
+ <IconButton name="x" size="sm" label="Close tour" onPress={skip} testID="onboarding-close" />
642
+ </View>
643
+
644
+ <View style={{ gap: t.space(1.5) }}>
645
+ <Text variant="h3" weight="semibold">
646
+ {step.title}
647
+ </Text>
648
+ <Text color="secondary">{step.body}</Text>
649
+ </View>
650
+
651
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(2) }}>
652
+ <Dots total={session.total} index={session.index} />
653
+ <View style={{ flex: 1 }} />
654
+ {session.index > 0 ? (
655
+ <Button title="Back" size="sm" onPress={back} testID="onboarding-back" />
656
+ ) : (
657
+ <Button title="Skip" variant="ghost" size="sm" onPress={skip} testID="onboarding-skip" />
658
+ )}
659
+ <Button
660
+ title={isLast ? "Done" : "Next"}
661
+ variant="primary"
662
+ size="sm"
663
+ onPress={next}
664
+ testID="onboarding-next"
665
+ />
666
+ </View>
667
+ </View>
668
+ </View>
669
+ );
670
+ }
671
+
672
+ // Backdrop swallows presses so a click meant for the tour never lands on the
673
+ // dimmed UI behind it. It deliberately does not dismiss: losing your place by
674
+ // clicking slightly wide of the card is not a thing a tour should do.
675
+ function Backdrop({ style }: { style: any }) {
676
+ return (
677
+ <Pressable
678
+ onPress={() => {}}
679
+ style={{ position: "absolute", backgroundColor: SCRIM, pointerEvents: "auto", ...style }}
680
+ />
681
+ );
682
+ }
683
+
684
+ function Dots({ total, index }: { total: number; index: number }) {
685
+ const t = useTheme();
686
+ return (
687
+ <View style={{ flexDirection: "row", alignItems: "center", gap: t.space(1) }}>
688
+ {Array.from({ length: total }).map((_, i) => (
689
+ <View
690
+ key={i}
691
+ style={{
692
+ width: i === index ? 14 : 5,
693
+ height: 5,
694
+ borderRadius: t.radius.pill,
695
+ backgroundColor: i === index ? t.color.textPrimary : t.color.borderDefault,
696
+ }}
697
+ />
698
+ ))}
699
+ </View>
700
+ );
701
+ }
702
+
703
+ type Hole = { left: number; top: number; width: number; height: number };
704
+
705
+ /**
706
+ * Places the card beside the spotlight, preferring below, then above, then to
707
+ * the side, and finally centered. Exported for the tests, which are the only
708
+ * practical way to check the fallbacks — three of the four need a viewport too
709
+ * small to reach by hand.
710
+ */
711
+ export function placeCard(
712
+ hole: Hole | null,
713
+ cardSize: { w: number; h: number } | null,
714
+ view: { vw: number; vh: number; cardW: number; placement: OnboardingPlacement },
715
+ ): { left: number; top: number } {
716
+ const { vw, vh, cardW, placement } = view;
717
+ const h = cardSize?.h ?? 180;
718
+
719
+ if (!hole || placement === "center") {
720
+ return { left: Math.max((vw - cardW) / 2, EDGE), top: Math.max((vh - h) / 2, EDGE) };
721
+ }
722
+
723
+ const clampX = (x: number) => Math.min(Math.max(x, EDGE), Math.max(vw - cardW - EDGE, EDGE));
724
+ const clampY = (y: number) => Math.min(Math.max(y, EDGE), Math.max(vh - h - EDGE, EDGE));
725
+
726
+ const below = hole.top + hole.height + CARD_GAP;
727
+ const above = hole.top - CARD_GAP - h;
728
+ const rightOf = hole.left + hole.width + CARD_GAP;
729
+ const leftOf = hole.left - CARD_GAP - cardW;
730
+
731
+ const fitsBelow = below + h <= vh - EDGE;
732
+ const fitsAbove = above >= EDGE;
733
+ const fitsRight = rightOf + cardW <= vw - EDGE;
734
+ const fitsLeft = leftOf >= EDGE;
735
+
736
+ // An explicit placement is honoured when it fits; when it doesn't, falling
737
+ // back beats rendering the card off-screen.
738
+ if (placement === "bottom" && fitsBelow) return { left: clampX(centerX(hole, cardW)), top: below };
739
+ if (placement === "top" && fitsAbove) return { left: clampX(centerX(hole, cardW)), top: above };
740
+ if (placement === "right" && fitsRight) return { left: rightOf, top: clampY(centerY(hole, h)) };
741
+ if (placement === "left" && fitsLeft) return { left: leftOf, top: clampY(centerY(hole, h)) };
742
+
743
+ if (fitsBelow) return { left: clampX(centerX(hole, cardW)), top: below };
744
+ if (fitsAbove) return { left: clampX(centerX(hole, cardW)), top: above };
745
+ if (fitsRight) return { left: rightOf, top: clampY(centerY(hole, h)) };
746
+ if (fitsLeft) return { left: leftOf, top: clampY(centerY(hole, h)) };
747
+ return { left: Math.max((vw - cardW) / 2, EDGE), top: Math.max((vh - h) / 2, EDGE) };
748
+ }
749
+
750
+ const centerX = (hole: Hole, cardW: number) => hole.left + hole.width / 2 - cardW / 2;
751
+ const centerY = (hole: Hole, h: number) => hole.top + hole.height / 2 - h / 2;