Onboarding coachmarks
ui-design-system/ui/onboarding-coachmarkslive
| Kind | frontend |
|---|---|
| Surface | SPA |
| Maturity | prod |
| Reusable | yes |
| Design system | alchemy-ui |
| Stack | react-19, react-dom-portal, tailwind-3.4, alchemy-ui |
| Depends on | _shared/design-system-and-tokens, _shared/react-app-skeleton, ui-design-system/ui/appshell-navigation |
| Renders | no backend module: a design-system row |
| Entry | alchemy_labs/frontend/src/onboarding/OnboardingProvider.tsx |
| Reference implementation |
|
| Measured on | alchemy_labs d1a-token-source fd8558f 2026-09-11 |
| Version | 1 |
Purpose
centred client only A first-visit tour per module: a spotlight ring around the thing being explained and a card with one benefit-first sentence, in du-Form Swiss German, that a user can step through, skip, or switch off for good. The invariant that makes it trustworthy is that a tour can never block the app: a missing anchor centres the card instead of throwing, a dismissed tour stays dismissed across reloads, and nothing about it touches the network.
It makes no backend module visible. It renders on top of the app shell and reads the shell's data-onboard anchors.
When to use
Use it when a module gets a page a new user has to understand in under a minute: add a Tour to TOURS keyed by the module's DomainId, and data-onboard anchors to the elements the steps point at. Trigger phrases: "show new users around", "Zeig mir die App", "a tooltip tour", "explain the rail".
Do not use it for confirmation dialogs, for validation errors, or for anything the user must acknowledge (it can be switched off globally). Do not use it on public pages: the provider is mounted by AppShell only.
Stack assumptions
S1 tokens and S2's shell; the provider is mounted inside AppShell (AppShell.tsx lines 39 to 41) so the coachmark portal renders above every private page. React 19 with createPortal from react-dom; the card reuses the Checkbox atom from alchemy-ui. No fetch. Copy language: Schweizer Hochdeutsch, du-Form, one sentence per step (tours.ts header comment); the <b> emphasis in a body is run through sanitizeHtml (Coachmark.tsx line 5) before dangerouslySetInnerHTML.
Data model
Types (tours.ts lines 14 to 29): Placement = 'top' | 'bottom' | 'left' | 'right' | 'center'; TourStep = { target?: string; title: string; body: string; placement?: Placement }; Tour = { id: string; label: string; steps: TourStep[] }. ANCHOR (lines 32 to 40) fixes the anchor keys: rail, subnav, workspace, rail-prima, help, forms-prima, forms-list. TOURS: Partial<Record<DomainId, Tour>> holds 8 tours at fd8558f: home (5 steps), forms, insights, store, bookings, prima, contacts, activity.
Provider state (OnboardingProvider.tsx): tour: Tour | null, index, a timer ref, and a module-level autoStartedThisSession: Set<string> (line 45) so navigating between a module's sub-routes does not re-trigger its tour before the user finishes it. AUTOSTART_DELAY is 450 ms (line 47). Card constants (Coachmark.tsx lines 7 to 9): CARD_W 340, GAP 16, and a 12 px viewport clamp.
Browser storage (storage.ts), all localStorage, all read through try/catch so a private window simply does not persist:
| Key | Meaning | Lifetime |
|---|---|---|
alchemy.onboarding.off | '1' when the user chose "Onboarding nicht mehr zeigen" | until resetOnboarding() (Settings) |
alchemy.onboarding.v | the ONBOARDING_VERSION the done list belongs to (1 at fd8558f) | rewritten on a version bump |
alchemy.onboarding.done | JSON array of finished or skipped tour ids | cleared on a version mismatch or resetOnboarding() |
Bumping ONBOARDING_VERSION re-surfaces every tour once (lines 8 to 10, 48 to 55).
API surface
None. storage.ts is written so a PATCH /api/me/onboarding can be dropped in behind isOnboardingOff, setOnboardingOff, isTourDone, markTourDone and resetOnboarding without touching the provider or the card (header comment, lines 1 to 10). Until then the seen-state is per browser.
Application logic
last client only AppShellmountsOnboardingProvider; the provider resolves the current module withactiveDomain(loc.pathname).id(line 51).- On every module change (
useEffect, lines 79 to 96): stop any open tour; if the module has a tour, the global switch is off, the tour is not done, and it has not auto-started this session, start it after 450 ms. Coachmarkmeasures the step's anchor withdocument.querySelector('[data-onboard="<target>"]')(lines 25 to 29); with a rect it draws the spotlight ring and positions the card byplacement, clamped to the viewport by the 12 px constant; without one (or withplacement: 'center') it dims the page withbg-ink/55and centres the card (lines 61 to 66).- "Weiter" advances; on the last step it reads "Fertig" and
finish()marks the tour done (markTourDone) and closes it. "Zurück" steps back (hidden on the first step). "Tour überspringen" isfinish()too: a skipped tour is a done tour. - The checkbox "Onboarding nicht mehr zeigen" calls
handleNever: setsalchemy.onboarding.off, marks the current tour done, closes. - The rail's help button ("Zeig mir die App",
AppShell.tsxlines 209 to 226) is rendered only whenhasTouris true and callsreplay(), which isstart(domainId, { force: true }): the off switch and the done list are ignored on an explicit replay. - Keyboard (
Coachmark.tsxlines 120 to 129): Escape skips (onSkip, which isfinish), ArrowRight and Enter advance, ArrowLeft steps back except on the first step. A click on the backdrop does nothing (no handler, measured); the card isrole="dialog" aria-modal.
Invariants
- A missing anchor never throws: the step centres. Page-level anchors (
forms-prima,forms-list) exist only on their page; shell anchors exist on every page. - Targets are
data-onboardkeys, never CSS selectors, so a class rename cannot break a tour. - One auto-start per module per session; a replay is always explicit.
- The off switch wins over auto-start and never over
replay. - A version bump re-shows every tour once and never loses the off switch.
- No network, no PII: the storage holds tour ids and a version, nothing about the user.
- The card is built on shell tokens (
ink,line,paper,primary-*,shadow-pop) and reuses theCheckboxatom; it is the one component the design system had no atom for (Coachmark.tsxlines 31 to 36).
Frontend
The component, rebuilt from the demo in the kit with the design system the recipe names, one frame per state. What is in the frames is what the recipe describes and what Primacode builds from; it is not a picture of the running application.
anchored client only ui-design-system/ui/onboarding-coachmarks/demo.tsx
/**
* Live demo for ui-design-system/ui/onboarding-coachmarks.
*
* Labs' Coachmark.tsx (fd8558f, 258 lines) rendered over the shell and the
* dashboard demos. The component portals to document.body and positions from
* getBoundingClientRect (lines 25 to 29, 144); the demo passes a `container`
* (the frame's stage) where the Labs component uses document.body, and every
* rectangle is taken relative to that container. That one parameter is the
* only change and is written back to Labs as a prop with the old default.
*
* Every state is client-only (rendering.hydrate: only): the ring and the card
* are placed by measurement, which has no server-side answer.
*
* The step bodies are the `home` tour from tours.ts (lines 43 to 78) with
* the product name shortened to "Labs" and the source's em dashes rewritten
* as commas and colons (house rule); the `<b>` emphasis is rendered as nodes
* here instead of through sanitizeHtml, so the demo carries no sanitizer
* dependency.
*/
import React, { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import { defineStates } from '@prima-modules/demo';
import { Checkbox } from '@prima-modules/alchemy-ui';
import { Shell } from '../appshell-navigation/demo';
import { DOMAINS } from '../appshell-navigation/nav';
import { HomePage, states as dashboardStates } from '../merchant-home-dashboard/demo';
/* ---- tours.ts (fd8558f, lines 14 to 29 and the home tour at 43 to 78) --- */
export type Placement = 'top' | 'bottom' | 'left' | 'right' | 'center';
export interface TourStep {
target?: string;
title: string;
body: string;
placement?: Placement;
}
export interface Tour {
id: string;
label: string;
steps: TourStep[];
}
export const homeTour: Tour = {
id: 'home',
label: 'Kurze Tour',
steps: [
{
title: 'Willkommen an Bord',
body: 'Das ist dein Cockpit. Alles dreht sich um eine Reise: <b>Gewinnen, Verkaufen, Verstehen</b>. Dauert 30 Sekunden, oder überspring und erkunde selbst.',
placement: 'center',
},
{
target: 'rail',
title: 'Eine Leiste, alle Module',
body: 'Klick dich durch die Icon-Leiste. Daneben öffnet sich immer die passende Unterspalte mit den Details des Moduls.',
placement: 'right',
},
{
target: 'subnav',
title: 'Hier steckt das Modul',
body: 'Die Unterspalte zeigt Ansichten und Aktionen des aktiven Moduls, etwa „Alle Formulare" oder „Neues Formular".',
placement: 'right',
},
{
target: 'rail-prima',
title: 'Prima macht es für dich',
body: 'Prima ist immer dabei. Frag den KI-Operator oder nutz die „Prima machts"-Buttons: sie füllen Arbeit vor, statt dich vor ein leeres Blatt zu setzen.',
placement: 'right',
},
{
target: 'help',
title: 'Tour jederzeit erneut öffnen',
body: 'Jedes Modul hat seinen eigenen Rundgang. Klick hier auf „Zeig mir die App", um ihn zu wiederholen.',
placement: 'right',
},
],
};
/* ---- props ----------------------------------------------------------- */
export interface Props {
tour: Tour;
/** Zero-based step index. */
index: number;
}
export const states = defineStates<Props>({
centred: { label: 'Centred step: 1 of 5 over the dimmed dashboard', props: { tour: homeTour, index: 0 }, height: 640, viewport: 1440, hydrate: 'only' },
anchored: { label: 'Anchored step: ring and arrow around the rail', props: { tour: homeTour, index: 1 }, height: 640, viewport: 1440, hydrate: 'only' },
last: { label: 'Last step: "Fertig" next to the help button', props: { tour: homeTour, index: 4 }, height: 640, viewport: 1440, hydrate: 'only' },
});
/* ---- Coachmark.tsx with the container parameter -------------------------- */
const CARD_W = 340;
const GAP = 16;
const EDGE = 12;
interface Box {
top: number;
left: number;
arrow: 'top' | 'bottom' | 'left' | 'right' | 'none';
centered: boolean;
}
interface RingRect {
top: number;
left: number;
width: number;
height: number;
}
/** The `<b>` emphasis of a step body as nodes. */
function Emphasis({ text }: { text: string }) {
const parts = text.split(/(<b>.*?<\/b>)/g);
return (
<>
{parts.map((p, i) => (p.startsWith('<b>') ? <b key={i}>{p.slice(3, -4)}</b> : <React.Fragment key={i}>{p}</React.Fragment>))}
</>
);
}
export function Coachmark({
tour,
index,
onNext,
onBack,
onSkip,
onNever,
container,
}: {
tour: Tour;
index: number;
onNext: () => void;
onBack: () => void;
onSkip: () => void;
onNever: () => void;
/** Where the overlay mounts and what the anchors are measured against. Labs: document.body. */
container: HTMLElement;
}) {
const step = tour.steps[index];
const isFirst = index === 0;
const isLast = index === tour.steps.length - 1;
const cardRef = useRef<HTMLDivElement>(null);
const [ring, setRing] = useState<RingRect | null>(null);
const [box, setBox] = useState<Box>({ top: 0, left: 0, arrow: 'none', centered: true });
const place = useCallback(() => {
const origin = container.getBoundingClientRect();
const el = step.placement === 'center' || !step.target ? null : container.querySelector(`[data-onboard="${step.target}"]`);
const raw = el ? el.getBoundingClientRect() : null;
if (!raw || raw.width === 0) {
setRing(null);
setBox({ top: 0, left: 0, arrow: 'none', centered: true });
return;
}
const rect = { top: raw.top - origin.top, left: raw.left - origin.left, right: raw.right - origin.left, bottom: raw.bottom - origin.top, width: raw.width, height: raw.height };
const pad = 6;
setRing({ top: rect.top - pad, left: rect.left - pad, width: rect.width + pad * 2, height: rect.height + pad * 2 });
const cardH = cardRef.current?.offsetHeight ?? 200;
const placement: Placement = step.placement ?? 'bottom';
let top: number;
let left: number;
let arrow: Box['arrow'];
switch (placement) {
case 'right':
left = rect.right + GAP;
top = rect.top - 6;
arrow = 'left';
break;
case 'left':
left = rect.left - CARD_W - GAP;
top = rect.top - 6;
arrow = 'right';
break;
case 'top':
left = rect.left;
top = rect.top - cardH - GAP;
arrow = 'bottom';
break;
default:
left = rect.left;
top = rect.bottom + GAP;
arrow = 'top';
break;
}
left = Math.max(EDGE, Math.min(left, origin.width - CARD_W - EDGE));
top = Math.max(EDGE, Math.min(top, origin.height - cardH - EDGE));
setBox({ top, left, arrow, centered: false });
}, [container, step.placement, step.target]);
useLayoutEffect(place, [place, index]);
useEffect(() => {
const onChange = () => place();
window.addEventListener('resize', onChange);
window.addEventListener('scroll', onChange, true);
return () => {
window.removeEventListener('resize', onChange);
window.removeEventListener('scroll', onChange, true);
};
}, [place]);
useEffect(() => {
const onKey = (e: KeyboardEvent) => {
if (e.key === 'Escape') onSkip();
else if (e.key === 'ArrowRight' || e.key === 'Enter') onNext();
else if (e.key === 'ArrowLeft' && !isFirst) onBack();
};
container.addEventListener('keydown', onKey);
return () => container.removeEventListener('keydown', onKey);
}, [container, onNext, onBack, onSkip, isFirst]);
useEffect(() => {
cardRef.current?.focus({ preventScroll: true });
}, [index]);
const arrowPos: Record<Box['arrow'], string> = {
left: '-left-2 top-7 border-l border-b',
right: '-right-2 top-7 border-r border-t',
top: '-top-2 left-7 border-t border-l',
bottom: '-bottom-2 left-7 border-b border-r',
none: 'hidden',
};
return createPortal(
<div className="absolute inset-0 z-50 font-sans" role="dialog" aria-modal="true" aria-label={`${tour.label}: ${step.title}`}>
{box.centered ? (
<div className="absolute inset-0 bg-ink/55 animate-fade-in" />
) : ring ? (
<div
className="absolute rounded-xl pointer-events-none transition-all duration-200 motion-reduce:transition-none"
style={{ top: ring.top, left: ring.left, width: ring.width, height: ring.height, boxShadow: '0 0 0 4px rgba(144,123,230,0.9), 0 0 0 9999px rgba(14,12,21,0.55)' }}
/>
) : null}
<div
ref={cardRef}
tabIndex={-1}
data-gallery-focus="true"
className="absolute outline-none bg-paper border border-line rounded-2xl shadow-pop p-5 transition-all duration-200 motion-reduce:transition-none"
style={box.centered ? { width: CARD_W, top: '50%', left: '50%', transform: 'translate(-50%,-50%)' } : { width: CARD_W, top: box.top, left: box.left }}
>
{!box.centered && <span className={`absolute h-3.5 w-3.5 rotate-45 bg-paper border-line ${arrowPos[box.arrow]}`} aria-hidden />}
{box.centered && (
<div className="mb-3.5 h-11 w-11 rounded-xl bg-gradient-to-br from-primary-400 to-primary-600 grid place-items-center">
<svg width="22" height="22" viewBox="0 0 24 24" fill="#fff">
<path d="M12 3l1.8 4.9L18.5 9l-4.7 1.8L12 15.5l-1.8-4.7L5.5 9l4.7-1.1L12 3z" />
</svg>
</div>
)}
<div className="flex items-center gap-2 mb-2">
<span className="text-[11px] font-bold uppercase tracking-wider text-primary-500">{tour.label}</span>
<span className="text-[10.5px] font-semibold text-primary-700 bg-primary-50 rounded-full px-2 py-0.5">
{index + 1} / {tour.steps.length}
</span>
</div>
<h3 className="text-[17px] font-extrabold text-ink leading-snug mb-1.5">{step.title}</h3>
<p className="text-[13.5px] leading-relaxed text-ink/80">
<Emphasis text={step.body} />
</p>
<div className="flex items-center gap-2 mt-4">
<div className="flex items-center gap-1.5 mr-auto">
{tour.steps.map((_, k) => (
<span key={k} className={`block h-1.5 rounded-full transition-all ${k === index ? 'w-4 bg-primary-500' : 'w-1.5 bg-line'}`} />
))}
</div>
{!isFirst && (
<button type="button" onClick={onBack} className="text-[13px] font-semibold text-ink-60 hover:text-ink px-2 py-1.5 rounded-lg transition-colors">
Zurück
</button>
)}
<button
type="button"
onClick={onNext}
className="text-[13px] font-semibold text-white bg-primary-500 hover:bg-primary-700 px-4 py-2 rounded-pill transition-colors focus:outline-none focus:ring-2 focus:ring-primary-500 focus:ring-offset-2"
>
{isLast ? 'Fertig' : 'Weiter'}
</button>
</div>
<div className="flex items-center justify-between mt-3 pt-3 border-t border-line">
<Checkbox
size="sm"
label="Onboarding nicht mehr zeigen"
className="[&_label]:gap-2 [&_span]:text-[12px] [&_span]:text-ink-60"
onChange={(checked) => {
if (checked) onNever();
}}
/>
<button type="button" onClick={onSkip} className="text-[12px] text-ink-40 hover:text-ink underline underline-offset-2">
Tour überspringen
</button>
</div>
</div>
</div>,
container,
);
}
/* ---- the demo: the tour over the dashboard in the shell ------------------ */
export default function Demo({ tour, index: initial, galleryMode }: Props & { galleryMode?: boolean }) {
const [index, setIndex] = useState(initial);
const [open, setOpen] = useState(true);
const [container, setContainer] = useState<HTMLElement | null>(null);
const page = dashboardStates['fresh-workspace'].props;
useEffect(() => {
setIndex(initial);
setOpen(true);
}, [initial, tour]);
const finish = () => setOpen(false);
return (
<div ref={setContainer} className="relative h-full min-h-[560px] overflow-hidden">
<Shell domains={DOMAINS} user={page.user} workspaces={[page.workspace]} currentWorkspaceId={page.workspace.id} activePath="/dashboard" hasTour hideChrome={galleryMode}>
<HomePage {...page} />
</Shell>
{!open && (
<button
type="button"
onClick={() => {
setIndex(initial);
setOpen(true);
}}
className="absolute bottom-4 right-4 z-40 rounded-pill bg-primary-500 text-white text-[13px] font-semibold px-4 py-2 shadow-pop"
>
Tour erneut öffnen
</button>
)}
{open && container && (
<Coachmark
tour={tour}
index={index}
container={container}
onNext={() => (index === tour.steps.length - 1 ? finish() : setIndex(index + 1))}
onBack={() => setIndex(Math.max(0, index - 1))}
onSkip={finish}
onNever={finish}
/>
)}
</div>
);
}
Component tree, from the recipe
AppShell
OnboardingProvider (context: replay, start, hasTour)
{shell and page} anchors: [data-onboard=rail|subnav|workspace|rail-prima|help|forms-prima|forms-list]
Coachmark (createPortal -> document.body, div.fixed.inset-0.z-50 role=dialog aria-modal)
backdrop div.bg-ink/55 (centred step) | spotlight ring around the anchor rect (anchored step)
card w-[340px] bg-white rounded-2xl shadow-pop
icon tile (centred step only) bg-gradient primary-400 -> primary-600
row tour.label (uppercase, tracking-wider, text-primary-500) + "n / N" pill
h3 step.title
p step.body (sanitized <b> allowed)
row progress dots (one per step) . "Zurück" (not on the first step) . "Weiter" | "Fertig"
row Checkbox "Onboarding nicht mehr zeigen" . link "Tour überspringen" The rendering is the live rebuild onboarding-coachmarks/demo.tsx: Coachmark.tsx (258 lines at fd8558f) rendered over a sample page inside the frame, built from @prima-modules/alchemy-ui (the Checkbox atom) and @prima-modules/tokens, rendered by the docs host as one frame per state (RENDERING.md, section 3). The Labs component portals to document.body (line 144) and positions from getBoundingClientRect (lines 25 to 29); the demo passes a container (the frame's stage) where Labs uses document.body, measures the anchor relative to it and clamps to it. That one parameter is the only change, and it is written back to Labs as a prop with the old default. The sample page behind the card is the shell rebuild from appshell-navigation/demo.tsx, which carries the data-onboard anchors the steps point at; the tour copy is the home tour from tours.ts (lines 43 to 78) with the product name shortened to "Labs" and the em dashes of the source rewritten as commas and colons (house rule); the <b> emphasis is rendered as nodes instead of through sanitizeHtml, so the demo carries no sanitizer.
States: centred (step 1 of 5: the icon tile, "Kurze Tour" and "1 / 5", "Willkommen bei Labs", five dots, "Weiter", the checkbox and the skip link over a dimmed page), anchored (step 2: the ring and the arrow around the rail, "Zurück" present), last (step 5: "Fertig"). Every state is hydrate: only, because the card measures its anchor in a layout effect; on the server it renders nothing. At 390 px the card is clamped to the stage by the 12 px edge constant. Step 4 targets rail-prima; the sample navigation hides Prima from the rail as Labs does at fd8558f, so that step falls back to the centred card, which is the component's own behaviour for a missing anchor.
Tokens: ink/55 backdrop, white card, rounded-2xl, shadow-pop, primary-400 to primary-600 gradient tile, primary-500 label and primary button (rounded-pill), primary-50 and primary-700 for the step pill, ink, ink/80, ink-60, ink-40 text, line for the footer hairline, animate-fade-in on the backdrop, motion-reduce:transition-none on the ring.
States: centred step (rendered), anchored step with a ring and an arrow on one of four sides, first step (no "Zurück"), last step ("Fertig"), replay (same card, forced), closed. Loading and error states do not exist.
Security & tenancy
Nothing sensitive: three localStorage keys with tour ids. The only HTML injection point is the <b> emphasis in a step body, and the body text is authored in tours.ts (not user content) and still passed through sanitizeHtml. Surface gating does not apply beyond the shell's own (the provider is inside PrivateRoute pages by construction). Nothing is hidden by CSS: a dismissed tour is not rendered.
Tests
- No colocated test at fd8558f (measured:
ls src/onboarding/__tests__finds nothing). What a new app adds: a vitest file forstorage.ts(off switch, done list, version bump clears done and keeps off) and a jsdom render ofOnboardingProviderwith a fakeTOURSasserting (a) the card appears after the delay when nothing is stored, (b) it does not appear whenalchemy.onboarding.offis1, (c)replay()opens it anyway, (d) a step whose target is absent renders with the centred class. - Playwright: none names the tour at fd8558f; the shell specs close it implicitly by setting the off key or by clicking through. A gate spec that walks a page inside the shell must either set
alchemy.onboarding.off=1inlocalStoragebefore navigation or dismiss the dialog first. - The kit's render smoke (
node tooling/verify/render-smoke.mjs) mounts the three demo states in jsdom withcreateRoot(they are client only) and asserts[role="dialog"], the recipe'sverify.wait, is in the document;tooling/verify/compare.mjs --state centredagainst/dashboardin a fresh context is the visual check, and its report is never committed.
Deploy & ops
Ships in the Labs bundle; no env, no endpoint. Editing copy is a change to tours.ts; bumping ONBOARDING_VERSION is the way to re-show tours after a module changes shape. Rollback is the bundle's.
Gotchas
- 2026-09-11 (rendering trap): the tour auto-opens 450 ms after the first authenticated visit to a module in a fresh browser, so a verifier capture of any page inside the shell carries the dialog unless
verify.storagesetsalchemy.onboarding.off=1(the otherui-design-systemrecipes do). The reverse is true here: this recipe'sverifyasks for a fresh context and waits for[role="dialog"]. - 2026-09-11 (rendering trap):
createPortal(..., document.body)andwindow.innerWidthin the Labs component cannot run on the server, so the demo ishydrate: onlyon every state and takes the stage as its container; a rebuild that keepsdocument.bodyescapes the frame and dims the docs page. - 2026-09-11 (measured,
Coachmark.tsxline 192): the tour label isuppercase tracking-wider, against S1 rule 14 (founder rule of 2026-08-08). Pre-rule code; a new app renders the label in sentence case. - 2026-09-11 (measured): the seen-state is per browser. A user who switches devices sees every tour again; the planned
PATCH /api/me/onboardingbehindstorage.tsis the fix and is not built. - 2026-09-11 (measured,
OnboardingProvider.tsxlines 79 to 96): the provider remounts per page (the shell is a per-page wrapper, seeappshell-navigation), which is whyautoStartedThisSessionis module-level and not React state; moving the shell to a layout route would allow plain state. - 2026-09-11 (measured): the
primatour exists while the Prima domain is hidden from the rail; itsrail-primaanchor is absent, so its steps centre. Harmless, and a reminder that a tour and its anchors live in different files. - 2026-09-11 (measured,
tours.ts): the copy uses typographic quotes and the em dash inside step bodies; that is product copy, not recipe copy, and is left as authored.
Reference implementation
Branch: alchemy_labs d1a-token-source at fd8558f (2026-09-11), forked from feat/service-desk b1089b6; the onboarding overlay merged to trunk earlier (2026-09, before b1089b6). Paths relative to frontend/:
src/onboarding/OnboardingProvider.tsx(137 lines):OnboardingApi(22 to 30),useOnboarding(34 to 38),autoStartedThisSession(45),AUTOSTART_DELAY(47),start(60 to 73),replay(75), the auto-start effect (79 to 96),finish,handleNext,handleBack,handleNever(98 to 116), the provider and card mount (122 to 135).src/onboarding/Coachmark.tsx(258 lines): constants (7 to 9),anchorRect(25 to 29), positioning (61 to 105), the portal (144 to 256: backdrop or ring 147 to 160, card 161 onward, label row 191 to 196, controls 207 to 233, footer 237 to 252).src/onboarding/tours.ts(256 lines): types (14 to 29),ANCHOR(32 to 40),TOURS(42 to 250),getTour.src/onboarding/storage.ts(82 lines): keys (11 to 14), version (16), the five functions.src/onboarding/index.ts: the barrel.src/components/AppShell.tsx: provider mount (39 to 41), the anchors (data-onboardat 157, 216, 234, 330, 429, 433), the help button (209 to 226).src/pages/Forms.tsx: the two page-level anchors.docs/onboarding-overlay/in the Labs repo: the plan and the copy overview the tours follow.
Deviations of the reference from this recipe, dated 2026-09-11: the uppercase tour label; no tests; seen-state not synced to the account; the orphan prima tour.
Acceptance checks
From alchemy_labs/frontend at fd8558f, or a new app built to this recipe:
grep -c "^ [a-z]*: {$" src/onboarding/tours.tsprints8;grep -c "data-onboard" src/components/AppShell.tsxprints6.grep -n "ONBOARDING_VERSION = " src/onboarding/storage.tsprints one line with1.- Browser walk-through, signed in,
localStoragecleared ofalchemy.onboarding.*: (a) open/dashboard; within a second the dimmed page and the "Willkommen bei Labs" card appear with "1 / 5"; (b) click "Weiter"; the card moves next to the rail with a ring around it and "Zurück" appears; (c) click "Tour überspringen"; the card closes andlocalStorage.getItem('alchemy.onboarding.done')is["home"]; (d) reload; no card; (e) click the question-mark button at the bottom of the rail; the card opens at "1 / 5" again; (f) tick "Onboarding nicht mehr zeigen";alchemy.onboarding.offis"1"; open/forms; no card; (g)localStorage.setItem('alchemy.onboarding.v', '0')and reload/forms; still no card (the off switch wins); remove the off key and reload; the Forms tour opens (the version bump cleared the done list). - Demo gate:
npm run checkprints no line for this recipe;node tooling/verify/render-smoke.mjs ui-design-system/ui/onboarding-coachmarksmounts the three states in jsdom, finds[role="dialog"]in each and prints0 failures. - Verifier:
node tooling/verify/compare.mjs ui-design-system/ui/onboarding-coachmarks --state centred --docs http://127.0.0.1:4321 --app http://127.0.0.1:3020 --session-file tooling/verify/.labs-local/session.json(fresh context,--wait '[role="dialog"]'fromverify.wait) writes a report withpage_errors: []; the verifier readsmismatch_ratiofor the card only, since the page behind it is the dashboard on the app and the shell sample in the frame.
Content synced from the prima-modules repository at 0df2ec8 on 2026-09-14. The recipe file is the source; this page is a rendering of it.