--- ## GuideHint **Stability:** beta — the API shape is settled and documented; behaviour-level fixes may still land as breaking changes in a minor. A contextual, non-blocking hint anchored to a `data-guide` element via `floating.ts` (flip/shift/arrow), rendered in the native popover top-layer. Waits at the right element rather than interrupting: shows on mount (or when `open` for `trigger="manual"`), persists "seen" so it appears once (`once`), and steps aside while a foreign modal or guided tour is open. Dismissable; announces itself via `aria-live`. Renders inert without a provider. **Import:** `import { GuideHint } from '@urbicon-ui/blocks';` ### Examples ```svelte You can now export on a schedule from here. ``` ### Api | Prop | Type | Required | Default | Description | | --- | --- | :---: | --- | --- | | for | `string` | yes | | `data-guide` id of the element to anchor to. Required — a hint with no anchor has nothing to point at. | | ...HTMLAttributes | `HTMLAttributes` | no | | HTML attributes (excluding: 'class' | 'children' | 'title') | | arrow | `boolean` | no | true | Render the pointer arrow. | | children | `Snippet` | no | | Hint body. | | class | `string` | no | | Additional classes on the hint root. In the same Tailwind bucket `class` wins over the `slotClasses` entry for the element it lands on. Presets and provider defaults sit below both. | | once | `boolean` | no | true | When dismissed, persist a "seen" flag (via the controller's StorageAdapter) so the hint does not reappear on later mounts. Mirrors the tour's "mark seen on end" rule — a hint shown but never dismissed may show again. Set `false` to always show. | | onDismiss | `() => void` | no | | Called when the hint is dismissed (close button or Escape). | | open | `boolean` | no | false | Manual visibility for `trigger="manual"` (the on-route / on-condition strategy). Ignored for `trigger="mount"`. Re-raising it to `true` after a dismiss re-surfaces the hint (subject to `once`). | | placement | `Placement` | no | 'bottom' | Preferred placement relative to the target. | | preset | `string` | no | | Apply a named preset registered via ``. | | seenId | `string` | no | the `for` id | Persistence key for the "seen once" state, decoupled from the anchor (this is *not* a DOM id — the popover gets none). Override only to track two hints on one element independently, or to keep a stable key across a renamed anchor. | | slotClasses | `Partial>` | no | | Per-slot class overrides. In the same Tailwind bucket `class` wins over the `slotClasses` entry for the element it lands on. Presets and provider defaults sit below both. | | title | `string` | no | | Optional bold heading above the body. | | trigger | `'mount' | 'manual'` | no | 'mount' | When the hint may appear: `'mount'` shows it as soon as it mounts; `'manual'` waits for `open` to become `true` (the consumer's route/condition logic). | | unstyled | `boolean` | no | false | Strip all default styles. | Inherited from: - Omit, 'class' | 'children' | 'title'> (omit-pattern) ### Types ```ts class GuideController { constructor(options: GuideControllerOptions = {}); registerTarget(id: string, element: HTMLElement, meta: GuideTopicMeta = {}): () => void; unregisterTarget(id: string, element: HTMLElement): void; target(id: string, meta?: GuideTopicMeta): Attachment; resolveTarget(id: string): HTMLElement | null; getTopicMeta(id: string): GuideTopicMeta | undefined; 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; get paused(): boolean; get overlayDepth(): number; startTour(tour: GuideTour): boolean; next(): void; prev(): void; skip(): void; finish(): void; stopTour(): void; reapplyStepHighlight(): void; get highlightedId(): string | null; highlight(id: string, options?: { scroll?: boolean }): void; clearHighlight(): void; get panelOpen(): boolean; get activeArticle(): string | null; openPanel(article?: string): void; closePanel(): void; setArticle(article: string | null): void; get panelId(): string | null; registerPanel(id: string): () => void; hasSeen(id: string): boolean; markSeen(id: string): void; resetSeen(id?: string): void; get seenIds(): string[]; } ``` ```ts type GuideDirection = 'to-guide' | 'to-ui' | 'both' ``` ```ts 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; } ``` ```ts 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; } ``` ```ts type Placement = Side | `${Side}-${Alignment}` ``` ```ts 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; } ``` ```ts 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; } ``` ```ts 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'; } ``` ```ts 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'; } ``` ```ts 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; } ``` ```ts type Side = 'top' | 'right' | 'bottom' | 'left' ``` ```ts type Alignment = 'start' | 'end' ``` ```ts type SlotNames = keyof ReturnType & string ``` ```ts type VariantProps = Omit< Exclude[0], undefined>, 'class' > ``` ```ts interface GuideOverlayStackLike { register(id: string, close: () => void): () => void; isTop(id: string): boolean; /** Number of modal overlays currently open — drives the non-modal hide (GUIDE.md §6). */ readonly depth: number; } ``` ```ts 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; } ``` ### Slots (slotClasses keys) `hint`, `title`, `body`, `dismiss`, `arrow`