---
## 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`