import type { Attachment } from 'svelte/attachments'; import type { Placement } from './floating.js'; /** Which link directions a topic supports (D3/§4). `both` when omitted. */ export type GuideDirection = 'to-guide' | 'to-ui' | 'both'; /** Metadata attached to a registered target via `target(id, meta)`. */ export interface GuideTopicMeta { /** Human-readable label, e.g. "Save button". */ label?: string; /** Article id this topic links to (Direction A: Marker → Panel). */ article?: string; /** Enabled link directions. @default 'both' */ direction?: GuideDirection; } /** A single step of a guided tour. */ export interface GuideStep { /** `data-guide` id of the element to anchor to. Omit for a centered, target-less step. */ target?: string; /** * Optional route this step lives on — declarative cross-route touring. When set and different * from the current location, the controller calls its injected `navigate` hook to go there * **before** the spotlight, then re-anchors once the target appears on the new page (via the * renderer's existing `reapplyStepHighlight`). Such a tour-internal navigation keeps the tour * running; a *foreign* navigation (the user leaving on their own) still stops it. Compared * against `window.location.pathname` by default, so use a normalized path (no query/hash); * inject a {@link GuideControllerOptions.navigationSource} for a custom router or base path. * A `route` step with no `navigate` hook wired logs a DEV warning and stays put (no crash). * @see GuideControllerOptions.navigate */ route?: string; /** Step heading. */ title?: string; /** Step body text. */ body?: string; /** Preferred bubble placement relative to the target (Phase 6). */ placement?: Placement; /** Whether the highlighted element stays interactive during a spotlight step (Phase 6). */ interactive?: boolean; /** * How the step advances. `'user'` — the footer's Next button (and ArrowRight) work as * usual. `'action'` — learning-by-doing: Next is rendered disabled (`aria-disabled`, * with a screen-reader hint) and ArrowRight is inert; the app advances imperatively via * `controller.next()` once the user performed the real action (usually combined with * `interactive: true` so the spotlit target stays usable). Back and Skip stay available. * The gate lives purely in the `Guide` renderer — the engine's `next()` is never blocked. * @default 'user' */ advance?: 'user' | 'action'; } /** * Payload for {@link GuideTour.onStep} — fired when a step becomes the active one. */ export interface GuideStepEvent { /** The tour the step belongs to (handy for a shared handler keyed by `tour.id`). */ tour: GuideTour; /** Zero-based index of the now-active step. */ index: number; /** The now-active step. */ step: GuideStep; /** Total number of steps in the tour. */ total: number; /** How the step became active: initial `startTour`, or a `next`/`prev` navigation. */ via: 'start' | 'next' | 'prev'; } /** * Payload for {@link GuideTour.onComplete} and {@link GuideTour.onSkip} — a snapshot of * where the tour ended (e.g. the step a user skipped from, for drop-off analytics). */ export interface GuideEndEvent { /** The tour that ended. */ tour: GuideTour; /** Index of the step that was active when the tour ended. */ index: number; /** The step active at the end (`null` only for an empty tour, which never starts). */ step: GuideStep | null; /** Total number of steps in the tour. */ total: number; } /** A guided tour definition. */ export interface GuideTour { /** Unique id — used for "seen" persistence. */ id: string; /** Ordered steps. */ steps: GuideStep[]; /** Skip automatically once completed/dismissed. @default true */ once?: boolean; /** * Fired when a step becomes active — once on `startTour` (`via: 'start'`) and again on * every `next`/`prev`. This is where most onboarding analytics live (the step-by-step * funnel). A throw here is swallowed (DEV-warned) so analytics can't corrupt tour state. */ onStep?: (event: GuideStepEvent) => void; /** Fired when the tour is completed — via `finish()`, or `next()` on the last step. */ onComplete?: (event: GuideEndEvent) => void; /** * Fired when the tour is dismissed before completing — via `skip()`, Escape, or a foreign * overlay closing it. `event.index` is where the user dropped off. Deliberately **not** fired * by `stopTour()` (programmatic teardown such as a route change), which stays analytics-silent. */ onSkip?: (event: GuideEndEvent) => void; } /** * Persistence boundary for "seen" ids. The default is localStorage-backed, but a * consumer can inject a server-state adapter. Kept deliberately tiny. */ export interface GuideStorageAdapter { /** * Returns the persisted set of seen/completed ids. Return a `string[]` * synchronously (localStorage, the default) or a `Promise` for a * DB-/remote-backed store. While an async load is pending the seen set starts * empty and is merged (union) with the resolved ids once they arrive — so a * tour may briefly be startable before the store answers. */ load(): string[] | Promise; /** Persists the full set of seen/completed ids. */ save(ids: string[]): void; } /** Minimal slice of the overlay stack the controller depends on (injectable for tests). */ export interface GuideOverlayStackLike { register(id: string, close: () => void): () => void; isTop(id: string): boolean; /** Number of modal overlays currently open — drives the non-modal hide (§3.4). */ readonly depth: number; } /** * Source of the current route and navigation notifications the controller needs for cross-route * tours (injectable for tests and custom routers). The default {@link createBrowserNavigationSource} * reads `window.location.pathname` and subscribes via the Navigation API, falling back to `popstate`. */ export interface GuideNavigationSource { /** The current path, compared against a step's `route` (e.g. `"/expenses"`). `''` on the server. */ current(): string; /** * Subscribe to navigations; `onNavigate` receives the new path. Returns an unsubscribe. The * controller subscribes only while a tour with at least one `route` step is active, so a tour * that never declares a route observes no navigation (preserving the manual-`goto` recipe). * `onNavigate` may be invoked **synchronously, re-entrant** during the controller's own `navigate` * hook (a SvelteKit `afterNavigate` source fires inside `goto`); the controller recognizes that as * the tour's own navigation and keeps it running, so the source needs no `queueMicrotask` deferral. */ subscribe(onNavigate: (path: string) => void): () => void; } /** Options for {@link GuideController}; every dependency is injectable for testing. */ export interface GuideControllerOptions { /** Persistence adapter. @default localStorage-backed adapter */ storage?: GuideStorageAdapter; /** Overlay stack to integrate with. @default the shared `overlayStack` singleton */ overlayStack?: GuideOverlayStackLike; /** * Navigation hook for declarative cross-route tours. When a step's `route` differs from the * current location, the controller calls this to navigate there before spotlighting the step. * Framework-agnostic by injection — a SvelteKit consumer wires `(route) => goto(route)`. May be * sync or async; a thrown/rejected navigation is swallowed (DEV-warned). A `route` step with no * hook wired logs a DEV warning and stays on the current route (no crash). @default undefined */ navigate?: (route: string) => void | Promise; /** * Source for the current path + navigation events, used to decide whether a step's `route` * needs navigating and to tell a tour-internal navigation from a foreign one (which stops the * tour). @default a browser source reading `window.location.pathname` and listening via the * Navigation API. Where that API is unavailable it falls back to `popstate` (back/forward only, * not `pushState`), so a foreign *forward* navigation may go unobserved — inject a router-backed * source (SvelteKit: `afterNavigate` + the `page` store; see docs/GUIDE.md §9) for reliable * detection, or to handle a configured base path / custom router. */ navigationSource?: GuideNavigationSource; /** Force DEV-mode warnings on/off. @default `import.meta.env?.DEV ?? false` */ dev?: boolean; } /** * The default {@link GuideStorageAdapter}: localStorage-backed, SSR-safe, and * resilient to private-mode / quota failures (mirrors `persistent-state`). */ export declare function createLocalStorageAdapter(key?: string): GuideStorageAdapter; /** * The default {@link GuideNavigationSource}: reads `window.location.pathname` and observes SPA * navigations through the Navigation API when available — catching link clicks, programmatic * navigation (`goto`), and back/forward — falling back to `popstate` (back/forward only) where it * is not. SSR-safe: `current()` returns `''` and `subscribe()` is a no-op without a `window`. */ export declare function createBrowserNavigationSource(): GuideNavigationSource; /** * Headless engine for the Guide system — the UI-free state machine behind every * Guide component (Panel, Marker, Mention, Hint, Tour). * * One instance per `GuideProvider` (Phase 2) — *not* a singleton, so multiple scopes * can coexist and tests get a fresh instance. Modeled on `OverlayStack` (class with * `$state` + `untrack`). * * Responsibilities: * - **Target registry** — maps `data-guide` ids to live DOM elements, fed by the * `target()` attachment and resolved with a `[data-guide="…"]` DOM fallback. * - **Tour state machine** — `startTour` / `next` / `prev` / `skip` / `finish`. * - **Highlight** — `highlight` / `clearHighlight`, shared by tour steps and the * bidirectional Mention→UI link (Direction B). Toggles a `data-guide-highlight` * attribute; the additive ring itself is pure CSS (token-driven, D5). * - **Panel state** — `openPanel` / `closePanel` (the UI lands in Phase 3). * - **Persistence** — `hasSeen` / `markSeen` via an injectable {@link GuideStorageAdapter}. * - **Analytics hooks** — fires the active tour's `onStep` / `onComplete` / `onSkip` * callbacks (the actual business value of a tour) defensively, so a throwing * consumer callback can never corrupt tour state or leak the overlay entry. * - **overlay-stack integration** — a running tour registers itself so it pauses * when a foreign modal (Dialog/Drawer) stacks on top. * * @stability beta */ export declare class GuideController { #private; constructor(options?: GuideControllerOptions); /** * Register a DOM element under a `data-guide` id. Returns an unregister function — * use it as the cleanup of the `target()` attachment. */ registerTarget(id: string, element: HTMLElement, meta?: GuideTopicMeta): () => void; /** Remove a target. No-op if `element` is not the currently registered one. */ unregisterTarget(id: string, element: HTMLElement): void; /** * Svelte attachment that registers the host element as a guide target. * * @example * ```svelte * * ``` */ target(id: string, meta?: GuideTopicMeta): Attachment; /** * Resolve a `data-guide` id to its element: the programmatic registry first, then * a `[data-guide="…"]` DOM lookup as fallback. Returns `null` if unresolved. */ resolveTarget(id: string): HTMLElement | null; /** Metadata for a registered target, if any. */ getTopicMeta(id: string): GuideTopicMeta | undefined; /** * Effective link direction for a topic (§4.3). A surface-level `override` wins * over the topic's registered `direction`, which in turn defaults to `'both'`. * Surfaces derive their gating from this: a `GuideMarker` (UI→Guide) is live when * the result is not `'to-ui'`; a `GuideMention` (Guide→UI) when it is not `'to-guide'`. */ resolveDirection(id: string, override?: GuideDirection): GuideDirection; get activeTour(): GuideTour | null; get isTourActive(): boolean; get stepIndex(): number; get currentStep(): GuideStep | null; get stepCount(): number; get isFirstStep(): boolean; get isLastStep(): boolean; /** * `true` when a foreign overlay (Dialog/Drawer) sits above the running tour — the * UI should hide/dim while paused. Derives from `overlayStack.isTop`, so it reacts * automatically as foreign overlays open and close. */ get paused(): boolean; /** * Number of modal overlays currently stacked (Dialog/Drawer/Sidebar + an active * guided tour). Non-modal surfaces — notably `GuideHint` — hide while this is `> 0`, * the top-layer discipline from §3.4. Reactive via the injected overlay stack. */ get overlayDepth(): number; /** * Start a tour. Returns `false` (and does nothing) when the tour was already seen * (and `once !== false`) or has no steps. */ startTour(tour: GuideTour): boolean; /** Advance to the next step, or finish the tour if on the last step. */ next(): void; /** Go back one step. No-op on the first step. */ prev(): void; /** Dismiss the tour without completing it — marks it seen, fires `onSkip`. */ skip(): void; /** Complete the tour — marks it seen, fires `onComplete`. */ finish(): void; /** * Tear a running tour down **without** marking it seen — for programmatic teardown such as * the `Guide` renderer unmounting mid-tour, a route change, or logout. Unlike `skip`/`finish` * (user-driven, "never nag" → seen), a stopped tour can surface again. Releases the * overlay-stack entry and clears the highlight ring; no-op if no tour is active. */ stopTour(): void; /** * Re-apply the current step's highlight + scroll — for a surface that detected its * target resolving *after* the step began (a lazily rendered `data-guide` element) or * vanishing mid-step (removed / swapped by a route change). On step change the engine * highlights once; if the element wasn't in the DOM yet the ring couldn't land, so the * surface calls this once it observes the target appear (or disappear → ring clears). * No-op when no tour is active. */ reapplyStepHighlight(): void; get highlightedId(): string | null; /** * Highlight a target by id — toggles a `data-guide-highlight` attribute the CSS * ring hooks into (additive `outline`, never a scrim — D5). No-op + DEV warning * if the target is unresolved. * * @param options.scroll Scroll the target into view (Direction B click — D5). * Honors `prefers-reduced-motion` (instant jump instead of smooth scroll). */ highlight(id: string, options?: { scroll?: boolean; }): void; /** Clear the current highlight. */ clearHighlight(): void; get panelOpen(): boolean; get activeArticle(): string | null; /** Open the help panel, optionally jumping to a specific article (Direction A). */ openPanel(article?: string): void; /** Close the help panel. */ closePanel(): void; /** Set the active article without changing the panel's open state. */ setArticle(article: string | null): void; /** Live `GuidePanel` DOM id, or `null` when no panel is mounted. */ get panelId(): string | null; /** * Publish the mounted `GuidePanel`'s DOM id so `GuideMarker`s can target it via * `aria-controls`. Returns a cleanup that releases it. One panel per provider is * the norm; the most recent registration wins. */ registerPanel(id: string): () => void; /** Whether an id (tour, hint, …) has been seen. */ hasSeen(id: string): boolean; /** Mark an id as seen and persist. */ markSeen(id: string): void; /** Forget one id, or all of them when called without an argument. */ resetSeen(id?: string): void; /** Snapshot of all seen ids. */ get seenIds(): string[]; }