AbsoluteJS

Branching, Gates & Funnel Events

One tour rarely fits every viewer. Serializable conditions branch steps with showIf / skipIf and hold them with waitFor; useTourGate owns the auto-play decision across many tutorials (dismissal caps, once-per-session, priority, audience); and structured funnel events tell you exactly where viewers bail — down to the step, route, and whether they hit Skip or Escape.

#Condition Registry

Conditions mirror actions: a step (or a trigger rule) references a predicate by name in plain JSON, and the host registers it on useTourConditions() — a keyed singleton, default key 'absolute.tour'. Built-ins cover the DOM-shaped checks; anything product-shaped ("hasDeals", "isSubscribed") comes from the host.

Built-inArgsTrue when
element{ selector: '.pipeline-board' }True when document.querySelector finds the selector.
media{ query: '(max-width: 640px)' }True when the media query currently matches.
TS
import { useTourConditions } from '@absolutejs/tour';

// Conditions mirror actions: serializable refs resolved BY NAME against
// a keyed-singleton registry. Built-ins cover the DOM-shaped checks;
// anything product-shaped comes from the host.
const conditions = useTourConditions(); // default key 'absolute.tour'

const unregister = conditions.register(
	'hasDeals',
	() => dealCount.value > 0
);

conditions.registerAll({
	isSubscribed: () => Boolean(viewer.value?.subscription),
	onTrial: () => viewer.value?.plan === 'trial'
});

// Built-ins — no registration needed:
// { condition: 'element', args: { selector: '.pipeline-board' } }
// { condition: 'media', args: { query: '(max-width: 640px)' } }
Unknown conditions count as FALSE
An unresolved condition name warns and evaluates to false — so a showIf on a missing predicate hides the step rather than breaking, and a skipIf on one doesn't skip. Throwing predicates are contained the same way.

#showIf & skipIf

A step is skipped when any skipIf predicate holds, when not all showIf predicates hold, or when its mobile.skip flag applies below the mobile query. Skipped steps are hopped in the direction of travel — going forward hops forward, going back hops back (and falls forward again if everything behind is skipped).

TS
const step = {
	body: 'Every deal, one board.',
	route: '/portal/pipeline',
	// ALL showIf predicates must hold, or the step is skipped.
	showIf: [{ condition: 'hasDeals' }],
	// ANY skipIf predicate holding skips the step.
	skipIf: [
		{ args: { query: '(max-width: 640px)' }, condition: 'media' }
	],
	target: '.pipeline-board',
	title: 'Your pipeline',
	// Hold the step until the app is ready for it: a selector that must
	// exist (and have a visible box), or a registered condition that
	// must return true. Past timeoutMs (default 3500) the step shows
	// anyway rather than stalling the tour.
	waitFor: { selector: '.pipeline-board', timeoutMs: 5000 }
};

// waitFor can also poll a registered condition (120ms interval):
// waitFor: { condition: { condition: 'hasDeals' }, timeoutMs: 4000 }
Clean completion
If everything ahead is skipped, the tour completes cleanly instead of stranding the viewer — it emits tour_completed with reason "remaining-skipped" and closes.

#waitFor Readiness

Branching decides whether a step shows; waitFor decides when. It holds the step until the app is ready for it — a panel rendered after a data fetch, a host predicate flipped true — instead of spotlighting a half-loaded surface.

selectorPoll until the selector exists and has a visible box (a 0×0 element does not count).
conditionPoll a serialized condition ref (120ms interval) until it returns true.
timeoutMsdefault 3500Past the timeout the step shows anyway rather than stalling the tour.

#Funnel Events

Pass onEvent to useSpotlight and every lifecycle moment lands in your analytics. The engine emits; the host sinks the events into whatever analytics it runs.

tour_startedA genuine start — a cross-page resume does not re-fire it.
step_viewedA step is positioned and on screen (centered and missing-target steps included).
step_completedThe viewer (or an action) advanced past the step.
step_target_missingThe target selector was not found in time; the step degraded to a centered card.
action_failedAn action ref failed or named an unknown handler — reason carries the action name.
tour_completedThe last step finished. reason is "remaining-skipped" when the tail of the tour was branch-skipped.
tour_skippedThe viewer bailed — reason distinguishes the Skip button ("skip") from Escape ("escape").
TS
import { useSpotlight } from '@absolutejs/tour';

const t = useSpotlight({
	controller,
	onClose,
	// Every lifecycle moment lands in your analytics.
	onEvent: (event) => analytics.track(event.name, event),
	steps: () => activeTutorial.value?.steps ?? [],
	// Stamped on every event so tutorials don't blur together.
	tutorialSlug: () => activeTutorial.value?.slug
});

// A tour_skipped event — the one that matters — carries the exact
// screen the viewer was on when they'd had enough:
// {
//   at: '2026-07-12T17:03:11.302Z',
//   isReplay: false,
//   name: 'tour_skipped',
//   reason: 'escape',          // Skip button → 'skip'
//   route: '/portal/matches',  // window.location.pathname at fire time
//   stepCount: 7,
//   stepIndex: 4,
//   stepTitle: 'Swipe or list',
//   target: "[data-tour='match-view']",
//   tutorialSlug: 'portal-intro'
// }

Every TourEvent carries the full context:

atISO timestamp.
isReplayWhether the run was an explicit replay.
nameOne of the seven event names above.
reasonSkip source, completion detail, or the failed action name — per event.
routewindow.location.pathname when the event fired — for a skip, the screen the viewer was on when they had enough.
stepIndex / stepCount / stepTitleWhere in the tour the event happened.
targetThe step target selector, post mobile-override resolution.
tutorialSlugThe tutorial identity the host is playing, so events from different tutorials never blur together.

#Auto-play Gate

Home-grown tours always break here first: one "seen" flag can't express "stop nagging after two skips", "at most once per session", or "when three tutorials match this page, play the important one". useTourGate owns that bookkeeping — state persists in localStorage because dismissals must survive sessions — and evaluates the trigger rules; the host just asks pick() and records what happened.

TS
import { useTourController, useTourGate } from '@absolutejs/tour';

const controller = useTourController('myapp.tour');

// The auto-play decision, centralized. Dismissal counts persist in
// localStorage — they must survive sessions.
const gate = useTourGate({ roles: () => viewer.roles });

// The one tutorial to auto-play now: eligible candidates on this route,
// highest trigger.priority wins (stable on ties).
const tutorial = gate.pick(publishedTutorials, route.path);
if (tutorial) {
	gate.recordAutoPlay(tutorial.slug ?? '');
	controller.start();
}

// From your onEvent sink:
const onEvent = (event) => {
	const slug = activeTutorial.value?.slug ?? '';
	if (event.name === 'tour_skipped') gate.recordDismissal(slug);
	if (event.name === 'tour_completed') gate.recordCompletion(slug);
};

// Manual replays bypass the gate entirely — it governs auto-play only.
controller.start(true);
pick(tutorials, routePath)The one tutorial to auto-play now: eligible candidates, highest trigger.priority wins (stable on ties). Null when none qualify.
shouldAutoPlay(tutorial, routePath)The full eligibility check for a single tutorial. Manual replays bypass the gate entirely.
recordAutoPlay(slug)Stamp that an auto-play actually started (session marker + last-played timestamp).
recordDismissal(slug)The viewer skipped out — counts toward maxDismissals.
recordCompletion(slug)The viewer finished the tour.
stateFor(slug)The persisted state: { completedAt, dismissals, lastAutoPlayAt }.
reset(slug)Clear the persisted gate state for one tutorial.

#Trigger Rules

A tutorial's trigger block describes when it should auto-play; a tutorial without one never auto-plays. All rules are serializable, so they store and author like the rest of the protocol:

firstVisitOnlyAuto-play once per viewer — never again after a completion or a prior auto-play.
onRoutePrefixOnly auto-play when the current path starts with this prefix.
rolesRestrict auto-play to these role slugs — at least one must match the gate roles getter. Ignored when the getter is omitted.
maxDismissalsdefault 2Stop auto-playing after the viewer has skipped it this many times. Manual replay stays available.
oncePerSessionAuto-play at most once per browser session (sessionStorage marker).
priorityWhen several tutorials match a page, the highest priority wins.
showIfAudience predicates — all must hold for auto-play, resolved through the condition registry.
TS
import type { Tutorial } from '@absolutejs/tour';

const tutorial: Tutorial = {
	slug: 'portal-intro',
	steps: PORTAL_TOUR_STEPS,
	trigger: {
		// Auto-play once per viewer, on their first visit.
		firstVisitOnly: true,
		// Stop auto-playing after this many skips (default 2). Manual
		// replay stays available.
		maxDismissals: 2,
		// Only auto-play when the current path starts with this prefix.
		onRoutePrefix: '/portal',
		// Auto-play at most once per browser session.
		oncePerSession: true,
		// When several tutorials match a page, highest priority wins.
		priority: 10,
		// Restrict auto-play to these role slugs (at least one must
		// match the gate's roles getter).
		roles: ['member'],
		// Audience predicates — all must hold for auto-play.
		showIf: [{ condition: 'isSubscribed' }]
	}
};

#Wiring Events to the Gate

The gate and the funnel events close the loop: the gate decides, the engine reports, and the report feeds the next decision.

1
hostAsk pick() on navigation
Route change (or app mount): ask the gate which tutorial, if any, should auto-play here and now.
const tutorial = gate.pick(publishedTutorials, route.path);
2
hostRecord the auto-play, then start
Stamping the auto-play is what makes oncePerSession and firstVisitOnly hold on the next visit.
gate.recordAutoPlay(tutorial.slug ?? ''); controller.start();
3
engineThe tour plays and reports
useSpotlight emits the funnel events for the run — started, viewed, completed, skipped.
4
hostSink skips into the gate
Each skip counts toward maxDismissals — after the cap (default 2), the tutorial only plays via manual replay.
tour_skipped → gate.recordDismissal(slug)
5
hostSink completions into the gate
A completion permanently satisfies firstVisitOnly for that viewer.
tour_completed → gate.recordCompletion(slug)