@urbicon-ui/blocks 6.7.2 → 6.8.1

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.
@@ -3,14 +3,15 @@
3
3
  import type { GuideProviderProps } from './index';
4
4
  import { setGuideContext } from './guide.context';
5
5
 
6
- let { storage, controller, children }: GuideProviderProps = $props();
6
+ let { storage, navigate, controller, children }: GuideProviderProps = $props();
7
7
 
8
8
  // Use a consumer-supplied controller (for programmatic access from outside the provider)
9
9
  // or create one. Built once from the initial props — a stable controller/adapter is
10
10
  // expected, so later prop changes are intentionally ignored. SSR-safe: the default
11
- // adapter only touches localStorage inside load/save.
11
+ // adapter only touches localStorage inside load/save. `storage`/`navigate` are ignored
12
+ // when a `controller` is supplied (it already carries them).
12
13
  // svelte-ignore state_referenced_locally
13
- const instance = controller ?? new GuideController({ storage });
14
+ const instance = controller ?? new GuideController({ storage, navigate });
14
15
  setGuideContext(instance);
15
16
  </script>
16
17
 
@@ -30,10 +30,17 @@ import type { GuideArticleSlots, GuideBeaconSlots, GuideBeaconVariants, GuideHin
30
30
  export interface GuideProviderProps {
31
31
  /** Persistence adapter for "seen" state (tours/hints). @default localStorage-backed adapter */
32
32
  storage?: GuideStorageAdapter;
33
+ /**
34
+ * Navigation hook for declarative cross-route tours, forwarded to the internally created
35
+ * controller. A SvelteKit consumer wires `(route) => goto(route)`; a step's `route` then
36
+ * navigates before its spotlight. Ignored when a `controller` is supplied (set it there).
37
+ * @default undefined
38
+ */
39
+ navigate?: (route: string) => void | Promise<void>;
33
40
  /**
34
41
  * Supply a pre-created `GuideController` for programmatic access from outside the provider
35
42
  * (start tours, open the panel, query `hasSeen`). When omitted, the provider creates one
36
- * internally and shares it via context. When supplied, `storage` is ignored.
43
+ * internally and shares it via context. When supplied, `storage` and `navigate` are ignored.
37
44
  */
38
45
  controller?: GuideController;
39
46
  /** App subtree wired to the Guide context. */
@@ -343,177 +343,7 @@ declare const blocksTranslations: {
343
343
  };
344
344
  };
345
345
  };
346
- export declare const blocksI18n: import("@urbicon-ui/i18n").PackageI18n<{
347
- readonly accessibility: {
348
- readonly avatar: "Avatar";
349
- readonly clearInput: "Clear input";
350
- readonly clearSearch: "Clear search";
351
- readonly clearSelection: "Clear selection";
352
- readonly closeDialog: "Close dialog";
353
- readonly closeDrawer: "Close drawer";
354
- readonly dismiss: "Dismiss";
355
- readonly fileUpload: "File upload";
356
- readonly loading: "Loading";
357
- readonly maximum: "Maximum";
358
- readonly minimum: "Minimum";
359
- readonly pagination: "Page navigation";
360
- readonly progress: "Progress";
361
- readonly slider: "Slider";
362
- readonly toggle: "Toggle";
363
- readonly toggleOptions: "Toggle options";
364
- readonly removableBadge: "Removable badge";
365
- readonly removeBadge: "Remove badge";
366
- readonly removeFile: "Remove {{name}}";
367
- };
368
- readonly button: {
369
- readonly close: "Close";
370
- readonly apply: "Apply";
371
- readonly cancel: "Cancel";
372
- readonly confirm: "Confirm";
373
- };
374
- readonly menu: {
375
- readonly placeholder: "Select an option...";
376
- };
377
- readonly common: {
378
- readonly loading: "Loading...";
379
- };
380
- readonly languages: {
381
- readonly de: "Deutsch";
382
- readonly en: "English";
383
- readonly es: "Español";
384
- readonly fr: "Français";
385
- readonly it: "Italiano";
386
- readonly nl: "Nederlands";
387
- };
388
- readonly localeSwitcher: {
389
- readonly ariaLabel: "Language selection";
390
- readonly placeholder: "Select language...";
391
- };
392
- readonly pagination: {
393
- readonly next: "Next";
394
- readonly first: "First";
395
- readonly last: "Last";
396
- readonly page: "Page";
397
- readonly previous: "Previous";
398
- };
399
- readonly calendar: {
400
- readonly recurring: "Recurring event";
401
- readonly previousMonth: "Previous month";
402
- readonly nextMonth: "Next month";
403
- readonly previousWeek: "Previous week";
404
- readonly nextWeek: "Next week";
405
- readonly previousDay: "Previous day";
406
- readonly nextDay: "Next day";
407
- readonly previousYear: "Previous year";
408
- readonly nextYear: "Next year";
409
- readonly today: "Today";
410
- readonly calendarGrid: "Calendar";
411
- readonly weekdays: "Weekdays";
412
- readonly weekNumber: "Week number";
413
- readonly events: "Events";
414
- readonly noEvents: "No events";
415
- readonly showMore: "{{count}} more";
416
- readonly legend: "Legend";
417
- readonly yearView: "Year overview";
418
- readonly weekView: "Week view";
419
- readonly dayView: "Day view";
420
- readonly monthView: "Month view";
421
- readonly viewMonth: "Month";
422
- readonly viewYear: "Year";
423
- readonly viewWeek: "Week";
424
- readonly viewDay: "Day";
425
- readonly multiDayLabel: "Day {{current}} of {{total}}";
426
- readonly agendaView: "Agenda view";
427
- readonly viewAgenda: "Agenda";
428
- readonly viewSwitcher: "View mode";
429
- };
430
- readonly planner: {
431
- readonly previousWeek: "Previous week";
432
- readonly nextWeek: "Next week";
433
- readonly previousMonth: "Previous month";
434
- readonly nextMonth: "Next month";
435
- readonly previousRange: "Previous range";
436
- readonly nextRange: "Next range";
437
- readonly today: "Today";
438
- readonly grid: "Planner";
439
- readonly weekNumber: "Week number";
440
- readonly itemCount: "{{count}} items";
441
- };
442
- readonly commandPalette: {
443
- readonly noResults: "No results found.";
444
- readonly search: "Search...";
445
- readonly hints: {
446
- readonly navigate: "Navigate";
447
- readonly select: "Select";
448
- readonly close: "Close";
449
- };
450
- };
451
- readonly compositionBar: {
452
- readonly total: "Total";
453
- readonly summary: "Composition: {{total}} ({{count}} shares) — {{parts}}";
454
- readonly share: "Share";
455
- readonly value: "Value";
456
- readonly percent: "Percent";
457
- readonly remaining: "remaining";
458
- };
459
- readonly guide: {
460
- readonly next: "Next";
461
- readonly previous: "Back";
462
- readonly skip: "Skip tour";
463
- readonly done: "Done";
464
- readonly step: "Step {{current}} of {{total}}";
465
- readonly tour: "Guided tour";
466
- readonly close: "Close";
467
- readonly openHelp: "Help";
468
- readonly backToList: "All topics";
469
- readonly info: "More information";
470
- readonly infoAbout: "More information about {{label}}";
471
- readonly dismiss: "Dismiss hint";
472
- readonly startTour: "Start the guided tour";
473
- readonly actionRequired: "Complete the highlighted action to continue";
474
- readonly filterPlaceholder: "Filter topics…";
475
- readonly noResults: "No matching topics";
476
- };
477
- readonly datepicker: {
478
- readonly placeholder: "Select a date...";
479
- readonly rangePlaceholder: "Select a date range...";
480
- readonly clear: "Clear selection";
481
- readonly openCalendar: "Open calendar";
482
- readonly invalidDate: "Invalid date";
483
- readonly outOfRange: "Date is outside the allowed range";
484
- readonly invalidRange: "Invalid date range";
485
- };
486
- readonly fileUpload: {
487
- readonly exists: "File already added";
488
- readonly invalidType: "File type {{type}} is not allowed";
489
- readonly tooLarge: "File exceeds {{size}} limit";
490
- readonly tooMany: "Maximum {{count}} files allowed";
491
- readonly tooSmall: "File must be at least {{size}}";
492
- };
493
- readonly sankey: {
494
- readonly summary: "Sankey diagram: {{nodes}} nodes, {{links}} links — {{flows}}";
495
- readonly source: "Source";
496
- readonly target: "Target";
497
- readonly value: "Value";
498
- };
499
- readonly slider: {
500
- readonly rangeStatus: {
501
- readonly insideRecommended: "In recommended range";
502
- readonly insideValid: "In valid range";
503
- readonly insideValidOnly: "Outside recommended range, but valid";
504
- readonly outsideValid: "Outside valid range";
505
- };
506
- };
507
- readonly themeSwitcher: {
508
- readonly lightMode: "Light mode";
509
- readonly darkMode: "Dark mode";
510
- readonly systemTheme: "System theme";
511
- };
512
- readonly time: {
513
- readonly ago: "{{value}} {{unit}} ago";
514
- readonly now: "now";
515
- };
516
- }>;
346
+ export declare const blocksI18n: import("@urbicon-ui/i18n").PackageI18n<import("@urbicon-ui/i18n").Translations>;
517
347
  /**
518
348
  * Context-scoped translation hook for the blocks package. Call during component
519
349
  * initialisation, then use the returned `t` (conventionally aliased `bt`):
@@ -526,177 +356,7 @@ export declare const blocksI18n: import("@urbicon-ui/i18n").PackageI18n<{
526
356
  * Resolves against the nearest `<I18nProvider>`'s locale (or the base locale
527
357
  * when none is mounted) and re-renders reactively on locale change.
528
358
  */
529
- export declare const useBlocksI18n: () => import("@urbicon-ui/i18n").TypedTranslationFunction<{
530
- readonly accessibility: {
531
- readonly avatar: "Avatar";
532
- readonly clearInput: "Clear input";
533
- readonly clearSearch: "Clear search";
534
- readonly clearSelection: "Clear selection";
535
- readonly closeDialog: "Close dialog";
536
- readonly closeDrawer: "Close drawer";
537
- readonly dismiss: "Dismiss";
538
- readonly fileUpload: "File upload";
539
- readonly loading: "Loading";
540
- readonly maximum: "Maximum";
541
- readonly minimum: "Minimum";
542
- readonly pagination: "Page navigation";
543
- readonly progress: "Progress";
544
- readonly slider: "Slider";
545
- readonly toggle: "Toggle";
546
- readonly toggleOptions: "Toggle options";
547
- readonly removableBadge: "Removable badge";
548
- readonly removeBadge: "Remove badge";
549
- readonly removeFile: "Remove {{name}}";
550
- };
551
- readonly button: {
552
- readonly close: "Close";
553
- readonly apply: "Apply";
554
- readonly cancel: "Cancel";
555
- readonly confirm: "Confirm";
556
- };
557
- readonly menu: {
558
- readonly placeholder: "Select an option...";
559
- };
560
- readonly common: {
561
- readonly loading: "Loading...";
562
- };
563
- readonly languages: {
564
- readonly de: "Deutsch";
565
- readonly en: "English";
566
- readonly es: "Español";
567
- readonly fr: "Français";
568
- readonly it: "Italiano";
569
- readonly nl: "Nederlands";
570
- };
571
- readonly localeSwitcher: {
572
- readonly ariaLabel: "Language selection";
573
- readonly placeholder: "Select language...";
574
- };
575
- readonly pagination: {
576
- readonly next: "Next";
577
- readonly first: "First";
578
- readonly last: "Last";
579
- readonly page: "Page";
580
- readonly previous: "Previous";
581
- };
582
- readonly calendar: {
583
- readonly recurring: "Recurring event";
584
- readonly previousMonth: "Previous month";
585
- readonly nextMonth: "Next month";
586
- readonly previousWeek: "Previous week";
587
- readonly nextWeek: "Next week";
588
- readonly previousDay: "Previous day";
589
- readonly nextDay: "Next day";
590
- readonly previousYear: "Previous year";
591
- readonly nextYear: "Next year";
592
- readonly today: "Today";
593
- readonly calendarGrid: "Calendar";
594
- readonly weekdays: "Weekdays";
595
- readonly weekNumber: "Week number";
596
- readonly events: "Events";
597
- readonly noEvents: "No events";
598
- readonly showMore: "{{count}} more";
599
- readonly legend: "Legend";
600
- readonly yearView: "Year overview";
601
- readonly weekView: "Week view";
602
- readonly dayView: "Day view";
603
- readonly monthView: "Month view";
604
- readonly viewMonth: "Month";
605
- readonly viewYear: "Year";
606
- readonly viewWeek: "Week";
607
- readonly viewDay: "Day";
608
- readonly multiDayLabel: "Day {{current}} of {{total}}";
609
- readonly agendaView: "Agenda view";
610
- readonly viewAgenda: "Agenda";
611
- readonly viewSwitcher: "View mode";
612
- };
613
- readonly planner: {
614
- readonly previousWeek: "Previous week";
615
- readonly nextWeek: "Next week";
616
- readonly previousMonth: "Previous month";
617
- readonly nextMonth: "Next month";
618
- readonly previousRange: "Previous range";
619
- readonly nextRange: "Next range";
620
- readonly today: "Today";
621
- readonly grid: "Planner";
622
- readonly weekNumber: "Week number";
623
- readonly itemCount: "{{count}} items";
624
- };
625
- readonly commandPalette: {
626
- readonly noResults: "No results found.";
627
- readonly search: "Search...";
628
- readonly hints: {
629
- readonly navigate: "Navigate";
630
- readonly select: "Select";
631
- readonly close: "Close";
632
- };
633
- };
634
- readonly compositionBar: {
635
- readonly total: "Total";
636
- readonly summary: "Composition: {{total}} ({{count}} shares) — {{parts}}";
637
- readonly share: "Share";
638
- readonly value: "Value";
639
- readonly percent: "Percent";
640
- readonly remaining: "remaining";
641
- };
642
- readonly guide: {
643
- readonly next: "Next";
644
- readonly previous: "Back";
645
- readonly skip: "Skip tour";
646
- readonly done: "Done";
647
- readonly step: "Step {{current}} of {{total}}";
648
- readonly tour: "Guided tour";
649
- readonly close: "Close";
650
- readonly openHelp: "Help";
651
- readonly backToList: "All topics";
652
- readonly info: "More information";
653
- readonly infoAbout: "More information about {{label}}";
654
- readonly dismiss: "Dismiss hint";
655
- readonly startTour: "Start the guided tour";
656
- readonly actionRequired: "Complete the highlighted action to continue";
657
- readonly filterPlaceholder: "Filter topics…";
658
- readonly noResults: "No matching topics";
659
- };
660
- readonly datepicker: {
661
- readonly placeholder: "Select a date...";
662
- readonly rangePlaceholder: "Select a date range...";
663
- readonly clear: "Clear selection";
664
- readonly openCalendar: "Open calendar";
665
- readonly invalidDate: "Invalid date";
666
- readonly outOfRange: "Date is outside the allowed range";
667
- readonly invalidRange: "Invalid date range";
668
- };
669
- readonly fileUpload: {
670
- readonly exists: "File already added";
671
- readonly invalidType: "File type {{type}} is not allowed";
672
- readonly tooLarge: "File exceeds {{size}} limit";
673
- readonly tooMany: "Maximum {{count}} files allowed";
674
- readonly tooSmall: "File must be at least {{size}}";
675
- };
676
- readonly sankey: {
677
- readonly summary: "Sankey diagram: {{nodes}} nodes, {{links}} links — {{flows}}";
678
- readonly source: "Source";
679
- readonly target: "Target";
680
- readonly value: "Value";
681
- };
682
- readonly slider: {
683
- readonly rangeStatus: {
684
- readonly insideRecommended: "In recommended range";
685
- readonly insideValid: "In valid range";
686
- readonly insideValidOnly: "Outside recommended range, but valid";
687
- readonly outsideValid: "Outside valid range";
688
- };
689
- };
690
- readonly themeSwitcher: {
691
- readonly lightMode: "Light mode";
692
- readonly darkMode: "Dark mode";
693
- readonly systemTheme: "System theme";
694
- };
695
- readonly time: {
696
- readonly ago: "{{value}} {{unit}} ago";
697
- readonly now: "now";
698
- };
699
- }>;
359
+ export declare const useBlocksI18n: any;
700
360
  export declare const hasBlocksTranslation: (key: string) => boolean, getBlocksLocales: () => import("@urbicon-ui/i18n").Locale[];
701
361
  export type BlocksTranslationKey = keyof typeof enTranslations;
702
362
  export { blocksTranslations };
@@ -15,6 +15,18 @@ export interface GuideTopicMeta {
15
15
  export interface GuideStep {
16
16
  /** `data-guide` id of the element to anchor to. Omit for a centered, target-less step. */
17
17
  target?: string;
18
+ /**
19
+ * Optional route this step lives on — declarative cross-route touring. When set and different
20
+ * from the current location, the controller calls its injected `navigate` hook to go there
21
+ * **before** the spotlight, then re-anchors once the target appears on the new page (via the
22
+ * surface's existing `reapplyStepHighlight`). Such a tour-internal navigation keeps the tour
23
+ * running; a *foreign* navigation (the user leaving on their own) still stops it. Compared
24
+ * against `window.location.pathname` by default, so use a normalized path (no query/hash);
25
+ * inject a {@link GuideControllerOptions.navigationSource} for a custom router or base path.
26
+ * A `route` step with no `navigate` hook wired logs a DEV warning and stays put (no crash).
27
+ * @see GuideControllerOptions.navigate
28
+ */
29
+ route?: string;
18
30
  /** Step heading. */
19
31
  title?: string;
20
32
  /** Step body text. */
@@ -103,12 +115,48 @@ export interface GuideOverlayStackLike {
103
115
  /** Number of modal overlays currently open — drives the non-modal hide (§3.4). */
104
116
  readonly depth: number;
105
117
  }
118
+ /**
119
+ * Source of the current route and navigation notifications the controller needs for cross-route
120
+ * tours (injectable for tests and custom routers). The default {@link createBrowserNavigationSource}
121
+ * reads `window.location.pathname` and subscribes via the Navigation API, falling back to `popstate`.
122
+ */
123
+ export interface GuideNavigationSource {
124
+ /** The current path, compared against a step's `route` (e.g. `"/expenses"`). `''` on the server. */
125
+ current(): string;
126
+ /**
127
+ * Subscribe to navigations; `onNavigate` receives the new path. Returns an unsubscribe. The
128
+ * controller subscribes only while a tour with at least one `route` step is active, so a tour
129
+ * that never declares a route observes no navigation (preserving the manual-`goto` recipe).
130
+ * `onNavigate` may be invoked **synchronously, re-entrant** during the controller's own `navigate`
131
+ * hook (a SvelteKit `afterNavigate` source fires inside `goto`); the controller recognizes that as
132
+ * the tour's own navigation and keeps it running, so the source needs no `queueMicrotask` deferral.
133
+ */
134
+ subscribe(onNavigate: (path: string) => void): () => void;
135
+ }
106
136
  /** Options for {@link GuideController}; every dependency is injectable for testing. */
107
137
  export interface GuideControllerOptions {
108
138
  /** Persistence adapter. @default localStorage-backed adapter */
109
139
  storage?: GuideStorageAdapter;
110
140
  /** Overlay stack to integrate with. @default the shared `overlayStack` singleton */
111
141
  overlayStack?: GuideOverlayStackLike;
142
+ /**
143
+ * Navigation hook for declarative cross-route tours. When a step's `route` differs from the
144
+ * current location, the controller calls this to navigate there before spotlighting the step.
145
+ * Framework-agnostic by injection — a SvelteKit consumer wires `(route) => goto(route)`. May be
146
+ * sync or async; a thrown/rejected navigation is swallowed (DEV-warned). A `route` step with no
147
+ * hook wired logs a DEV warning and stays on the current route (no crash). @default undefined
148
+ */
149
+ navigate?: (route: string) => void | Promise<void>;
150
+ /**
151
+ * Source for the current path + navigation events, used to decide whether a step's `route`
152
+ * needs navigating and to tell a tour-internal navigation from a foreign one (which stops the
153
+ * tour). @default a browser source reading `window.location.pathname` and listening via the
154
+ * Navigation API. Where that API is unavailable it falls back to `popstate` (back/forward only,
155
+ * not `pushState`), so a foreign *forward* navigation may go unobserved — inject a router-backed
156
+ * source (SvelteKit: `afterNavigate` + the `page` store; see docs/GUIDE.md §9) for reliable
157
+ * detection, or to handle a configured base path / custom router.
158
+ */
159
+ navigationSource?: GuideNavigationSource;
112
160
  /** Force DEV-mode warnings on/off. @default `import.meta.env?.DEV ?? false` */
113
161
  dev?: boolean;
114
162
  }
@@ -117,6 +165,13 @@ export interface GuideControllerOptions {
117
165
  * resilient to private-mode / quota failures (mirrors `persistent-state`).
118
166
  */
119
167
  export declare function createLocalStorageAdapter(key?: string): GuideStorageAdapter;
168
+ /**
169
+ * The default {@link GuideNavigationSource}: reads `window.location.pathname` and observes SPA
170
+ * navigations through the Navigation API when available — catching link clicks, programmatic
171
+ * navigation (`goto`), and back/forward — falling back to `popstate` (back/forward only) where it
172
+ * is not. SSR-safe: `current()` returns `''` and `subscribe()` is a no-op without a `window`.
173
+ */
174
+ export declare function createBrowserNavigationSource(): GuideNavigationSource;
120
175
  /**
121
176
  * Headless engine for the Guide system — the UI-free state machine behind every
122
177
  * Guide surface (Panel, Marker, Mention, Hint, Tour).
@@ -42,6 +42,71 @@ export function createLocalStorageAdapter(key = SEEN_STORAGE_KEY) {
42
42
  }
43
43
  };
44
44
  }
45
+ /**
46
+ * The default {@link GuideNavigationSource}: reads `window.location.pathname` and observes SPA
47
+ * navigations through the Navigation API when available — catching link clicks, programmatic
48
+ * navigation (`goto`), and back/forward — falling back to `popstate` (back/forward only) where it
49
+ * is not. SSR-safe: `current()` returns `''` and `subscribe()` is a no-op without a `window`.
50
+ */
51
+ export function createBrowserNavigationSource() {
52
+ let warnedFallback = false;
53
+ return {
54
+ current() {
55
+ return BROWSER ? window.location.pathname : '';
56
+ },
57
+ subscribe(onNavigate) {
58
+ if (!BROWSER)
59
+ return () => { };
60
+ const nav = window.navigation;
61
+ if (nav && typeof nav.addEventListener === 'function') {
62
+ const handler = (event) => {
63
+ if (event.downloadRequest != null)
64
+ return; // a download is not a route change
65
+ const url = event.destination?.url;
66
+ if (typeof url !== 'string')
67
+ return;
68
+ let path;
69
+ try {
70
+ path = new URL(url).pathname;
71
+ }
72
+ catch {
73
+ return; // opaque/cross-origin destination — not a same-app route
74
+ }
75
+ onNavigate(path);
76
+ };
77
+ nav.addEventListener('navigate', handler);
78
+ return () => nav.removeEventListener('navigate', handler);
79
+ }
80
+ // `popstate` only catches back/forward, not `pushState` (a SvelteKit `goto` / intercepted
81
+ // link click), so a foreign *forward* navigation goes unobserved and the tour won't stop.
82
+ // Warn once so a degraded-detection browser is diagnosable; inject a router-backed
83
+ // navigationSource for reliable detection (docs/GUIDE.md §9).
84
+ if (import.meta.env?.DEV && !warnedFallback) {
85
+ warnedFallback = true;
86
+ console.warn('[Guide] the Navigation API is unavailable; cross-route foreign-navigation detection falls back to popstate and will miss pushState navigations (link clicks, goto). Inject a router-backed navigationSource for reliable detection — see docs/GUIDE.md §9.');
87
+ }
88
+ const onPop = () => onNavigate(window.location.pathname);
89
+ window.addEventListener('popstate', onPop);
90
+ return () => window.removeEventListener('popstate', onPop);
91
+ }
92
+ };
93
+ }
94
+ /** DEV-only: grace period for a navigated-to `route` step's target before warning it never showed. */
95
+ const ROUTE_TARGET_TIMEOUT_MS = 4000;
96
+ /**
97
+ * Heuristic: does `landed` look like a router-normalized form of `route` — a trailing slash
98
+ * (`/expenses/`) or a base/locale prefix (`/de/expenses`) — rather than an unrelated redirect target
99
+ * (`/login`)? Used ONLY to decide whether a *synchronous* own-navigation landing on a non-exact path
100
+ * deserves a DEV diagnostic, never to change tour behavior, so a wrong guess merely over- or
101
+ * under-warns in DEV. `route` is a normalized pathname (leading `/`), so the suffix test lands on a
102
+ * real segment boundary — `/myexpenses` does not end with `/expenses`.
103
+ */
104
+ function looksLikeNormalizedRoute(landed, route) {
105
+ const stripSlash = (s) => (s.length > 1 && s.endsWith('/') ? s.slice(0, -1) : s);
106
+ const l = stripSlash(landed);
107
+ const r = stripSlash(route);
108
+ return l === r || l.endsWith(r);
109
+ }
45
110
  let tourIdCounter = 0;
46
111
  /** Opaque id for overlay-stack registration. Never SSR-rendered, so no hydration risk. */
47
112
  function nextOverlayId() {
@@ -101,6 +166,32 @@ export class GuideController {
101
166
  // independently of #activeTour (today they always change in lockstep).
102
167
  #tourOverlayId = $state(null);
103
168
  #tourUnregister = null;
169
+ // ── Cross-route touring ─────────────────────────────────
170
+ #navigate;
171
+ #navSource;
172
+ /** The path a tour-internal navigation is heading to; distinguishes our own nav from a foreign one. */
173
+ #expectedRoute = null;
174
+ /**
175
+ * `true` only while our own injected `#navigate()` call is on the stack. A `navigationSource` that
176
+ * reports its location change **synchronously, re-entrant** during that call (SvelteKit's
177
+ * `afterNavigate` fires inside `goto`) IS that very navigation — never a foreign one. This is the
178
+ * exact signal; the `path === #expectedRoute` check in `#onLocationChange` is only an async-source
179
+ * proxy for the same thing and breaks the moment the router reports a normalized path
180
+ * (trailing slash, base/locale prefix) that doesn't equal the raw `step.route`.
181
+ *
182
+ * A boolean (not a depth counter) is enough: the sole re-entrant callee of a sync source is
183
+ * `#onLocationChange`, which never calls `#maybeNavigate`. A `navigate` hook that *synchronously*
184
+ * re-enters `next`/`prev`/`startTour` is out of contract — its inner `#maybeNavigate` `finally`
185
+ * would clear this early; that only ever fails *closed* (a later own report read as foreign → stop),
186
+ * never open, so it can't keep a genuinely foreign navigation alive.
187
+ */
188
+ #selfNavigating = false;
189
+ /** Last path the tour is known to be on — set at start, updated on each handled navigation. */
190
+ #knownPath = '';
191
+ /** Unsubscribe from the navigation source; non-null only while a route-using tour runs. */
192
+ #navUnsub = null;
193
+ /** DEV-only timer that warns when a navigated-to step's target never appears. */
194
+ #routeTargetTimer = null;
104
195
  // ── Highlight (shared by tour steps + Mention→UI, Direction B) ──
105
196
  #highlightedId = $state(null);
106
197
  // ── Panel state (UI in Phase 3) ─────────────────────────
@@ -112,6 +203,8 @@ export class GuideController {
112
203
  constructor(options = {}) {
113
204
  this.#storage = options.storage ?? createLocalStorageAdapter();
114
205
  this.#overlayStack = options.overlayStack ?? overlayStack;
206
+ this.#navigate = options.navigate;
207
+ this.#navSource = options.navigationSource ?? createBrowserNavigationSource();
115
208
  // `import.meta.env?.DEV` is `boolean | undefined` (undefined outside Vite);
116
209
  // `?? false` keeps `#dev` a strict boolean and means non-Vite consumers
117
210
  // simply get no dev warnings (rather than a crash).
@@ -240,8 +333,14 @@ export class GuideController {
240
333
  this.#stepIndex = 0;
241
334
  this.#tourOverlayId = nextOverlayId();
242
335
  this.#tourUnregister = this.#overlayStack.register(this.#tourOverlayId, () => this.skip());
243
- this.#applyStepHighlight();
244
- this.#emitStep('start');
336
+ // Observe navigation only for tours that declare a step route. This keeps the manual-`goto`
337
+ // recipe (consumer navigates in `onStep`, no `step.route`) untouched, where a navigation is
338
+ // the consumer's own and must NOT be read as a foreign one that stops the tour.
339
+ this.#knownPath = this.#navSource.current();
340
+ if (tour.steps.some((s) => typeof s.route === 'string')) {
341
+ this.#navUnsub = this.#navSource.subscribe((path) => this.#onLocationChange(path));
342
+ }
343
+ this.#activateStep('start');
245
344
  return true;
246
345
  }
247
346
  /** Advance to the next step, or finish the tour if on the last step. */
@@ -253,16 +352,14 @@ export class GuideController {
253
352
  return;
254
353
  }
255
354
  this.#stepIndex += 1;
256
- this.#applyStepHighlight();
257
- this.#emitStep('next');
355
+ this.#activateStep('next');
258
356
  }
259
357
  /** Go back one step. No-op on the first step. */
260
358
  prev() {
261
359
  if (!this.#activeTour || this.isFirstStep)
262
360
  return;
263
361
  this.#stepIndex -= 1;
264
- this.#applyStepHighlight();
265
- this.#emitStep('prev');
362
+ this.#activateStep('prev');
266
363
  }
267
364
  /** Dismiss the tour without completing it — marks it seen, fires `onSkip`. */
268
365
  skip() {
@@ -304,6 +401,163 @@ export class GuideController {
304
401
  if (this.#activeTour)
305
402
  this.#applyStepHighlight();
306
403
  }
404
+ // ─── Cross-route touring ──────────────────────────────────────────────────
405
+ /**
406
+ * Make the now-current step active: trigger its cross-route navigation (if any), apply the
407
+ * highlight, then fire `onStep`. Shared by `startTour`/`next`/`prev`, so every entry point
408
+ * navigates symmetrically — including `prev()` stepping back across a route boundary.
409
+ */
410
+ #activateStep(via) {
411
+ this.#clearRouteTargetWarning();
412
+ this.#maybeNavigate();
413
+ this.#applyStepHighlight();
414
+ this.#emitStep(via);
415
+ }
416
+ /**
417
+ * If the active step lives on a different route, navigate there via the injected hook and record
418
+ * it as the `expectedRoute`, so the resulting navigation is recognized as the tour's own rather
419
+ * than a foreign one. Already on the route → nothing to do. No hook wired → DEV warning, stay put.
420
+ */
421
+ #maybeNavigate() {
422
+ // Each step starts with a clean slate — no navigation is pending until we trigger one below.
423
+ // (A step that doesn't navigate must not inherit the previous step's `expectedRoute`.)
424
+ this.#expectedRoute = null;
425
+ const route = this.currentStep?.route;
426
+ if (route == null)
427
+ return;
428
+ if (route === this.#navSource.current())
429
+ return;
430
+ if (!this.#navigate) {
431
+ if (this.#dev) {
432
+ console.warn(`[Guide] tour "${this.#activeTour?.id}" step ${this.#stepIndex}: step has route "${route}" but no navigate hook was provided to the GuideController — staying on the current route.`);
433
+ }
434
+ return;
435
+ }
436
+ this.#expectedRoute = route;
437
+ this.#scheduleRouteTargetWarning();
438
+ // Mark the window during which a synchronous (re-entrant) location report from the
439
+ // navigationSource is unambiguously our own navigation. Reset in `finally` the instant the
440
+ // (sync part of the) hook returns — an *async* report (the default Navigation-API source, or a
441
+ // microtask-deferred one) then falls through to the normal `#expectedRoute` matching, so the
442
+ // async path is unchanged.
443
+ this.#selfNavigating = true;
444
+ try {
445
+ const result = this.#navigate(route);
446
+ if (result && typeof result.then === 'function') {
447
+ result.catch((err) => {
448
+ if (this.#dev)
449
+ console.warn('[Guide] navigate hook rejected:', err);
450
+ this.#abortPendingNavigation(route);
451
+ });
452
+ }
453
+ }
454
+ catch (err) {
455
+ if (this.#dev)
456
+ console.warn('[Guide] navigate hook threw:', err);
457
+ this.#abortPendingNavigation(route);
458
+ }
459
+ finally {
460
+ this.#selfNavigating = false;
461
+ }
462
+ }
463
+ /**
464
+ * Roll back the bookkeeping for a navigation that failed (hook threw/rejected) — but only if it
465
+ * is still the pending one, so a newer step's navigation (the user clicked Next while this hook
466
+ * was in flight) is never clobbered by a late rejection. Clears the now-meaningless "target never
467
+ * appeared" timer too, so it can't later fire a misleading warning for a navigation that never ran.
468
+ */
469
+ #abortPendingNavigation(route) {
470
+ if (this.#expectedRoute !== route)
471
+ return;
472
+ this.#expectedRoute = null;
473
+ this.#clearRouteTargetWarning();
474
+ }
475
+ /**
476
+ * React to a navigation observed while a route-using tour runs. A navigation that is the tour's own
477
+ * — reported synchronously during our `#navigate()` call (`#selfNavigating`), or matching the
478
+ * pending `#expectedRoute` — keeps it running (the ring lands via the surface's
479
+ * `reapplyStepHighlight`). Any other navigation is foreign — the user left, or a
480
+ * youngest-gesture-wins race — and tears the tour down, analytics-silent (`stopTour`).
481
+ */
482
+ #onLocationChange(path) {
483
+ if (!this.#activeTour)
484
+ return;
485
+ if (path === this.#knownPath)
486
+ return; // no pathname change (e.g. a hash/query update) — ignore
487
+ this.#knownPath = path;
488
+ const matchesExpected = this.#expectedRoute !== null && path === this.#expectedRoute;
489
+ // A location report is the tour's OWN navigation in two cases: it matches the pending
490
+ // `#expectedRoute` (the async-source proxy, for a report landing a tick after the hook returns),
491
+ // or it arrives synchronously, re-entrant, during our injected `#navigate()` call
492
+ // (`#selfNavigating`). The latter is a *causal* signal — our navigate produced this report,
493
+ // whatever path it carries — so it covers a router-normalized landing (trailing slash,
494
+ // base/locale prefix) that an exact match would miss. Either keeps the tour running.
495
+ if (this.#selfNavigating || matchesExpected) {
496
+ // A *synchronous* report on a path that isn't the expected route is still causally ours, but it
497
+ // landed somewhere unexpected: a normalized variant of `step.route` (fine — the target anchors
498
+ // once it mounts) or a genuine redirect off the route (an auth guard → `/login`). We keep
499
+ // running either way — stopping would re-break the normalized case this fix exists for — but a
500
+ // landing that doesn't even look like a normalized variant is most likely a redirect, so
501
+ // surface it in DEV. (A targetless step has no "target never appeared" timer, so an off-route
502
+ // bubble is otherwise invisible in both DEV and PROD.)
503
+ if (this.#dev &&
504
+ this.#selfNavigating &&
505
+ this.#expectedRoute !== null &&
506
+ !matchesExpected &&
507
+ !looksLikeNormalizedRoute(path, this.#expectedRoute)) {
508
+ console.warn(`[Guide] tour "${this.#activeTour?.id}" navigated toward "${this.#expectedRoute}" but synchronously landed on "${path}" — keeping the tour running as its own navigation. If this was a redirect off the step's route (not a normalized form of it), stop the tour explicitly from your router guard.`);
509
+ }
510
+ // For a *targeted* step, keep `#expectedRoute` set until the target settles
511
+ // (`#applyStepHighlight`) so a redirecting / multi-hop source (the Navigation API emitting one
512
+ // event per hop) firing a *second* event for the redirect target is still recognized as a
513
+ // mismatch (and DEV-warned) instead of silently stopping. A *targetless* route step has no
514
+ // target to wait for — it is settled on landing, so clear now; otherwise a later foreign
515
+ // navigation would misfire the "navigated toward …" warning. (`#maybeNavigate` also resets it
516
+ // on the next step. Clearing here can't move into `#applyStepHighlight`, which runs
517
+ // synchronously in `#activateStep` before the nav lands — it would mis-read our own nav.)
518
+ if (this.currentStep?.target == null)
519
+ this.#expectedRoute = null;
520
+ return;
521
+ }
522
+ // Any other navigation stops the tour (analytics-silent). When one was pending, a landed path
523
+ // that doesn't match is most often a normalized `step.route` (trailing slash, base/locale
524
+ // prefix) rather than a genuine foreign nav — so surface it instead of a silent teardown on a
525
+ // one-character mismatch. (The strict stop is kept either way: better than running on a wrong page.)
526
+ if (this.#dev && this.#expectedRoute !== null) {
527
+ console.warn(`[Guide] tour "${this.#activeTour?.id}" navigated toward "${this.#expectedRoute}" but landed on "${path}" — stopping as a foreign navigation. If "${path}" is just a normalized form of the step's route (trailing slash, base/locale prefix), set step.route to the router's actual path.`);
528
+ }
529
+ this.#expectedRoute = null;
530
+ this.stopTour();
531
+ }
532
+ /**
533
+ * DEV-only: after navigating for a `route` step, warn if its target never appears so the spotlight
534
+ * can't land (the step still renders, centered over the scrim — it never hangs). Browser-gated, so
535
+ * no timer is ever scheduled under SSR or in node tests.
536
+ */
537
+ #scheduleRouteTargetWarning() {
538
+ if (!this.#dev || !BROWSER)
539
+ return;
540
+ const step = this.currentStep;
541
+ if (!step?.target)
542
+ return;
543
+ this.#clearRouteTargetWarning();
544
+ const index = this.#stepIndex;
545
+ const targetId = step.target;
546
+ const route = step.route;
547
+ this.#routeTargetTimer = setTimeout(() => {
548
+ this.#routeTargetTimer = null;
549
+ if (this.#stepIndex === index && !this.resolveTarget(targetId)) {
550
+ console.warn(`[Guide] tour "${this.#activeTour?.id}" step ${index}: navigated to "${route}" but target "${targetId}" never appeared — showing the step without a spotlight ring.`);
551
+ }
552
+ }, ROUTE_TARGET_TIMEOUT_MS);
553
+ }
554
+ /** Cancel a pending route-target warning (target appeared, the step changed, or teardown). */
555
+ #clearRouteTargetWarning() {
556
+ if (this.#routeTargetTimer !== null) {
557
+ clearTimeout(this.#routeTargetTimer);
558
+ this.#routeTargetTimer = null;
559
+ }
560
+ }
307
561
  #endTour() {
308
562
  const tour = this.#activeTour;
309
563
  this.#teardownTour();
@@ -313,8 +567,12 @@ export class GuideController {
313
567
  if (tour && tour.once !== false)
314
568
  this.markSeen(tour.id);
315
569
  }
316
- /** Reset all tour state and release the overlay-stack registration. No persistence. */
570
+ /** Reset all tour state and release the overlay-stack + navigation registrations. No persistence. */
317
571
  #teardownTour() {
572
+ this.#clearRouteTargetWarning();
573
+ this.#navUnsub?.();
574
+ this.#navUnsub = null;
575
+ this.#expectedRoute = null;
318
576
  this.clearHighlight();
319
577
  this.#tourUnregister?.();
320
578
  this.#tourUnregister = null;
@@ -364,10 +622,16 @@ export class GuideController {
364
622
  // Unlike Direction-B hover (which never scrolls), a tour drives the viewport —
365
623
  // the scroll is reduced-motion-aware inside `highlight`.
366
624
  this.highlight(step.target, { scroll: true });
625
+ this.#clearRouteTargetWarning(); // target landed → no "never appeared" warning needed
626
+ this.#expectedRoute = null; // the navigated-to target resolved → navigation fully settled
367
627
  }
368
628
  else {
369
629
  this.clearHighlight();
370
- if (this.#dev) {
630
+ // A step bound to another route legitimately has no target on the current page — during the
631
+ // navigation gap, or when no navigate hook is wired — so don't warn "not found" there; the
632
+ // missing-hook and never-appeared paths own those diagnostics instead.
633
+ const offRoute = step.route != null && step.route !== this.#navSource.current();
634
+ if (this.#dev && !offRoute) {
371
635
  console.warn(`[Guide] tour "${this.#activeTour?.id}" step ${this.#stepIndex}: target "${step.target}" not found in DOM.`);
372
636
  }
373
637
  }
@@ -2,7 +2,7 @@ export * from './date.js';
2
2
  export * from './draggable.js';
3
3
  export * from './figma-token-export.js';
4
4
  export { arrow, autoUpdate, type ComputePositionReturn, computePosition, flip, type Middleware, offset, type Placement, type Strategy, shift, size } from './floating.js';
5
- export { createLocalStorageAdapter, GuideController, type GuideControllerOptions, type GuideDirection, type GuideEndEvent, type GuideOverlayStackLike, type GuideStep, type GuideStepEvent, type GuideStorageAdapter, type GuideTopicMeta, type GuideTour } from './guide.svelte.js';
5
+ export { createBrowserNavigationSource, createLocalStorageAdapter, GuideController, type GuideControllerOptions, type GuideDirection, type GuideEndEvent, type GuideNavigationSource, type GuideOverlayStackLike, type GuideStep, type GuideStepEvent, type GuideStorageAdapter, type GuideTopicMeta, type GuideTour } from './guide.svelte.js';
6
6
  export * from './id.js';
7
7
  export { observeTargetResolution } from './observe-target.js';
8
8
  export { createOptionalContext } from './optional-context.js';
@@ -2,7 +2,7 @@ export * from './date.js';
2
2
  export * from './draggable.js';
3
3
  export * from './figma-token-export.js';
4
4
  export { arrow, autoUpdate, computePosition, flip, offset, shift, size } from './floating.js';
5
- export { createLocalStorageAdapter, GuideController } from './guide.svelte.js';
5
+ export { createBrowserNavigationSource, createLocalStorageAdapter, GuideController } from './guide.svelte.js';
6
6
  export * from './id.js';
7
7
  export { observeTargetResolution } from './observe-target.js';
8
8
  export { createOptionalContext } from './optional-context.js';
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@urbicon-ui/blocks",
3
- "version": "6.7.2",
3
+ "version": "6.8.1",
4
4
  "description": "Svelte 5 UI component library with Tailwind CSS 4, OKLCH design tokens and zero runtime dependencies",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -91,8 +91,8 @@
91
91
  "@sveltejs/package": "^2.5.8",
92
92
  "@sveltejs/vite-plugin-svelte": "^7.0.0",
93
93
  "@tailwindcss/vite": "^4.3.1",
94
- "@urbicon-ui/i18n": "6.7.2",
95
- "@urbicon-ui/shared-types": "6.7.2",
94
+ "@urbicon-ui/i18n": "6.8.1",
95
+ "@urbicon-ui/shared-types": "6.8.1",
96
96
  "prettier": "^3.8.4",
97
97
  "prettier-plugin-svelte": "^4.1.1",
98
98
  "prettier-plugin-tailwindcss": "^0.8.0",