First Run Orchestrator
The FirstRunOrchestrator component coordinates the three "first-run" flows of an application — Disclaimer, Changelogs, and Onboarding — and renders them one at a time, in a well-defined priority order. It is the single owner of completion state for all three flows; child flows only emit a finished event and the orchestrator records progress in cookies.
Place the orchestrator once, high in your component tree (typically app.vue or a default layout), so it is mounted on every page and the flows are guaranteed to surface on the very first render.
Why an orchestrator?
Before this component existed, each flow (Disclaimer, Changelogs, Onboarding) owned its own mount condition, its own localStorage key, and its own version-check logic. Running them concurrently caused real problems:
- the Disclaimer modal had to be visible and clickable while the Onboarding overlay was active — but
driver.jssetspointer-events: noneon every descendant except the highlighted element, making the modal unclickable; - the Changelogs modal would compete with the Disclaimer for the same z-layer;
- each flow read a different storage key (
disclaimerAccepted,changelogs-last-read,tour-completed) and used a different read mechanism (localStoragevs. cookie), so SSR/hydration behavior was inconsistent.
The orchestrator solves all of this by:
- treating the three flows as a prioritized queue;
- rendering at most one flow at any time;
- owning every completion cookie write, so children stay stateless from a persistence perspective;
- resolving all flows through cookies (SSR-readable), so first-time users skip already-completed flows on the server render.
Priority order
Flows are evaluated in descending priority. The first pending flow wins, with one caveat: if any higher-priority flow is still loading (pending === undefined), the orchestrator renders nothing and waits. This prevents a resolving flow from yanking focus away from one that is already on screen.
| Priority (high → low) | Flow | Trigger |
|---|---|---|
| 1 | Disclaimer | Stored disclaimer-accepted cookie ≠ configured version |
| 2 | Changelogs | Server returns releases newer than changelogs-last-read cookie |
| 3 | Onboarding | onboardingBuilder prop provided AND tour-completed ≠ true |
A flow that has been disabled via runtime config is treated as pending === false and skipped.
Features
- Single source of truth: owns all completion cookies for the three flows
- Priority queue: disclaimer → changelogs → onboarding, never two at once
- Loading-aware: waits for async flows (changelogs fetch) before committing to a lower-priority flow
- Cookie-backed: SSR-readable state means no hydration flash for first-time users
- App-owned tour content: the consumer builds the onboarding tour; the orchestrator only decides when to mount it
- Runtime-configurable: disable any flow individually via
runtimeConfig.public.commonUi - Re-armable from anywhere: helper buttons (
ChangelogsButton,OnboardingRestartButton,DisclaimerButton) reset the relevant cookie and the orchestrator re-evaluates reactively
Props
| Prop | Type | Required | Description |
|---|---|---|---|
onboardingBuilder | OnboardingStepBuilder<Phases> | No | Tour builder created via useOnboardingBuilder(). If omitted, the Onboarding flow never becomes pending. The consumer owns construction. |
disclaimer | Partial<DisclaimerConfig> | No | Per-instance overrides for the runtimeConfig.public.commonUi.disclaimer defaults (e.g. appName, version, contentHtml, confirmationText). |
TIP
The orchestrator merges runtimeConfig.public.commonUi.disclaimer with the disclaimer prop, with the prop taking precedence. This lets you set a sensible default in nuxt.config.ts and override per-app in app.vue.
Events
Each child flow emits a finished event back to the orchestrator. You do not normally need to listen to this event yourself — the orchestrator handles it internally — but the contract is documented here for completeness.
| Event | Payload | Description |
|---|---|---|
finished | FirstRunFinishedPayload | Emitted by the active child flow when the user completes it. Currently { completed: boolean }. |
On receipt, the orchestrator writes the appropriate cookie:
| Flow id | Cookie written |
|---|---|
disclaimer | disclaimer-accepted ← configured version |
changelogs | changelogs-last-read ← newest release version |
onboarding | tour-completed ← true |
The cookie write flips the flow's pending flag reactively, which makes the activeFlow recomputation swap or unmount the child automatically.
Runtime configuration
The orchestrator reads from runtimeConfig.public.commonUi. Three individual disable flags plus a disclaimer block are supported:
// nuxt.config.ts
export default defineNuxtConfig({
runtimeConfig: {
public: {
commonUi: {
disableChangelog: false,
disableDisclaimer: false,
disableOnboarding: false,
disclaimer: {
appName: "",
version: "1.0.0",
// contentHtml, postfixHtml, confirmationText are also accepted here
},
},
},
},
});| Option | Type | Default | Description |
|---|---|---|---|
disableChangelog | boolean | string | false | When truthy, the Changelogs flow is treated as not-pending. |
disableDisclaimer | boolean | string | false | When truthy, the Disclaimer flow is treated as not-pending. |
disableOnboarding | boolean | string | false | When truthy, the Onboarding flow is treated as not-pending. |
disclaimer.appName | string | "" | Application name passed through to the Disclaimer flow. |
disclaimer.version | string | "1.0.0" | Version compared against the disclaimer-accepted cookie. Bump this to re-trigger the flow. |
TIP
The string "true" is accepted in addition to the boolean true, so these flags work cleanly when set via environment variables (e.g. NUXT_PUBLIC_COMMON_UI_DISABLE_ONBOARDING=true).
Cookies
The orchestrator reads and writes three cookies. They are all SSR-readable so first-time-vs-returning decisions happen on the server.
| Cookie | Type | Default | Purpose |
|---|---|---|---|
disclaimer-accepted | string | "" | Stores the version of the disclaimer the user has accepted. |
changelogs-last-read | string | "" | Stores the newest changelog version the user has seen. |
tour-completed | boolean | false | Whether the user has finished the onboarding tour at least once. |
Legacy localStorage keys
A Nuxt plugin (migrate-first-run-keys) is registered by the module to migrate the previous localStorage-based keys (disclaimerAccepted, etc.) to the new cookies on first load, so existing users are not re-prompted after upgrading.
Usage
Minimal setup
If you only want the Disclaimer and Changelogs flows (no tour), drop the orchestrator in app.vue with no props:
<!-- app.vue -->
<template>
<UApp>
<FirstRunOrchestrator />
<NuxtPage />
</UApp>
</template>With an onboarding tour
Construct the tour once in app.vue using useOnboardingBuilder() so it is available on every page, then pass it to the orchestrator. The orchestrator takes care of when to mount the tour — you do not call start() yourself.
<!-- app.vue -->
<script lang="ts" setup>
const builder = useOnboardingBuilder()
.addPhases<"Phase1" | "Phase2">([
{
name: "Phase1",
onEnter: async () => { /* ... */ },
onExit: async () => { /* ... */ },
},
{
name: "Phase2",
onEnter: async () => { /* ... */ },
onExit: async () => { /* ... */ },
},
])
.switchPhase("Phase1")
.addSteps([
{ popover: { title: "Step 1", description: "This is step 1" } },
{ popover: { title: "Step 2", description: "This is step 2" } },
])
.switchPhase("Phase2")
.addSteps([
{ popover: { title: "Step 3", description: "This is step 3" } },
]);
</script>
<template>
<UApp>
<FirstRunOrchestrator
:onboarding-builder="builder"
:disclaimer="{
appName: 'Test App',
confirmationText:
'I have read and understood the instructions and confirm that I will use Test App exclusively in compliance with the stated guidelines.',
}"
/>
<NuxtPage />
</UApp>
</template>Why construct the builder in app.vue?
The tour is app-owned — each app defines its own steps. Building it once in app.vue (rather than per-page) ensures the orchestrator can mount it on the very first render, immediately after the disclaimer and changelogs flows finish.
Overriding only part of the disclaimer config
Any key you omit from the disclaimer prop falls back to the runtimeConfig.public.commonUi.disclaimer default:
<FirstRunOrchestrator
:disclaimer="{ appName: 'My App' }"
/>Re-arming a flow from your UI
Because the orchestrator watches its cookies reactively, you can re-trigger any flow at runtime by resetting the corresponding cookie. The module ships with helper buttons that do exactly this:
| Helper | Resets | Effect |
|---|---|---|
DisclaimerButton | disclaimer-accepted | Re-shows the Disclaimer modal on next evaluation. |
ChangelogsButton | changelogs-last-read ← "0.0.0" | Surfaces the Changelogs flow with every existing release as "new". |
OnboardingRestartButton | (re-invokes the tour) | Restarts the tour without permanently marking it completed. |
DisclaimerButton and ChangelogsButton are wired into the DataBsFooter center slot by default, and OnboardingRestartButton is included in the NavigationBar right section, so end users always have a way back to any of the three flows.
INFO
The ChangelogsButton resets changelogs-last-read to the sentinel "0.0.0" so every existing release counts as newer than the stored value. The orchestrator's useChangelogsPending composable re-evaluates and surfaces the Changelogs flow without a page reload.
How it works
- Cookie reads: On mount (and reactively thereafter), the orchestrator reads the three completion cookies.
- Pending computation:
disclaimerPending= not disabled ANDdisclaimer-accepted≠ configuredversionchangelogsPending= async result ofuseChangelogsPending()(undefinedwhile loading,true/falseonce resolved)onboardingPending= not disabled ANDonboardingBuilderprovided ANDtour-completed≠true
- Active flow selection: flows are sorted by priority descending; the first
pending === trueflow is rendered. If any higher-priority flow ispending === undefined, nothing is rendered yet. - Child emits
finished: the orchestrator writes the matching cookie, which flips the flow'spendingflag, which recomputesactiveFlow, which swaps or unmounts the child — all reactively, with no manual coordination.
Best practices
- Mount once, high in the tree. Use
app.vueor a default layout. Mounting the orchestrator per-page will cause flows to re-evaluate on every navigation. - Don't call
onboarding.start()yourself. When the orchestrator owns the tour, it manages the lifecycle. UseOnboardingRestartButtonfor user-initiated replays. - Bump
disclaimer.versionto re-prompt. When your legal terms change, increment the version inruntimeConfig.public.commonUi.disclaimer.versionand every existing user will be re-shown the Disclaimer flow. - Keep tour construction in
app.vue. It needs to be available before the orchestrator decides whether the Onboarding flow is pending.
