/** * The first thirty seconds, which until now did not exist. * * An anonymous visitor landing on this product got a boot card, a city, and a * button labelled "? shortcuts". That was the whole of it. The product is two * verbs — **fly the board** and **walk into the studio** — and neither of them * was ever stated on screen, in any layout, at any tier. The mode dock existed * and was never explained; the door existed and looked like one more dark card * in a column of seven; the analogue joystick in `input/pointerStick.ts` existed * and was invisible until you were already using the thing it moves. * * Per the owner's first decision the signed-out visitor is the audience this is * designed for and not a degraded tier, which raises the bar here specifically: * this is the surface that decides whether a stranger who has never heard of * Lumbridge finds the two verbs, and it has to do it without a tour, without a * modal that blocks the thing it is describing, and without ever appearing twice. * * ### The four rules it is built to * * 1. **It never covers the subject.** A coach that greys the scene to point at * the scene has explained nothing. This is a card in a corner with a ring * around the control it is naming; the city keeps turning behind it and the * camera keeps working the whole time. * 2. **It is skippable at every step**, from one press, and skipping is * remembered. * 3. **It does not nag.** The visit is recorded at mount and not at completion — * somebody who read the first card and went straight to dragging the map has * been onboarded, and re-offering it on their next visit would be the * interface disagreeing with them about that. * 4. **The phone is not a smaller copy.** The desktop's key strip is * `display: none` below 600px, so on a phone this is the *only* place the * controls are ever named. The steps therefore carry both a pointer sentence * and a touch sentence, and the touch one names the joystick — the control * that a phone visitor otherwise had no way to discover at all. * * `localStorage` is touched only through `readSeen`/`writeSeen`, both of which * swallow everything. That is not defensive habit: the accessor itself throws in * Safari's private mode and in any embed with third-party storage blocked, and * an onboarding card is not worth a blank page. */ import { zIndex } from "./tokens.ts"; // ---- Storage -------------------------------------------------------------- /** * The subset of `Storage` this needs, so a test can hand in an object that * throws and so nothing here has to reach for a global. */ export interface OnboardingStorage { getItem(key: string): string | null; setItem(key: string, value: string): void; } /** * Versioned, because the day the two verbs change is the day everybody should be * shown the new ones once. Bumping the suffix re-runs the coach for the whole * audience and costs nothing to the people who never saw the old one. */ export const ONBOARDING_STORAGE_KEY = "tera.onboarding.v1"; /** * Has this browser been offered the coach before? * * Returns `false` on any failure, which is the safe direction: showing a * skippable three-card coach to somebody who has seen it is a small annoyance, * and never showing it to somebody who has not is the entire defect this file * exists to fix. */ export function hasSeenOnboarding(storage: OnboardingStorage | null): boolean { if (storage === null) return false; try { return storage.getItem(ONBOARDING_STORAGE_KEY) !== null; } catch { return false; } } /** Record the offer. Silent on failure — a coach is not worth a thrown boot. */ export function markOnboardingSeen(storage: OnboardingStorage | null): void { if (storage === null) return; try { storage.setItem(ONBOARDING_STORAGE_KEY, String(Date.now())); } catch { // Private browsing, a blocked third-party context, a quota. All the same // answer: the visitor sees the coach again next time, which is survivable. } } /** * `window.localStorage`, or `null` if reaching for it throws. * * The *property access* is what throws — not the call — which is why this exists * rather than a `try` around each `getItem`. A caller in a test hands in its own * object and never touches this. */ export function browserStorage(): OnboardingStorage | null { try { const candidate = globalThis.localStorage; return candidate ?? null; } catch { return null; } } // ---- The steps ------------------------------------------------------------ export interface OnboardingStep { id: string; /** Two or three words. This is the verb, and it is the largest type on the card. */ verb: string; /** One sentence. What it is and why you would want it. */ line: string; /** How to do it with a mouse and a keyboard. */ pointer: string; /** How to do it with a thumb. Never a translation of the above — a different act. */ touch: string; /** * The `data-coach` value the applier writes on `` while this step is up, * so the stylesheet can ring the control being named. `null` for the step whose * subject is the whole scene. */ target: string | null; } /** * Three cards, and three is the number on purpose. * * Two verbs plus the control surface that reaches both of them. A fourth card * would be about a feature rather than about the product, and the honest test of * an onboarding sequence is whether somebody who skipped it at card two can * still find everything — which they can, because the mode dock and the door are * both permanently on screen. */ export const ONBOARDING_STEPS: readonly OnboardingStep[] = [ { id: "fly", verb: "Fly the board", line: "California from above, under the real sun, at the real time of day. The aircraft are live.", pointer: "Drag to orbit · scroll to zoom · click an aircraft for its callsign", touch: "Drag with one finger · pinch to zoom · tap an aircraft for its callsign", target: null, }, { id: "dock", verb: "Take the controls", line: "Drive the 101, explore as a crow, or fly the route yourself. The dock at the bottom switches between them.", pointer: "Pick a mode, then W A S D", touch: "Pick a mode, then the joystick in the lower left", target: "mode-dock", }, { id: "studio", verb: "Walk into the studio", line: "Two real rooms — San Francisco and Los Angeles — modelled to the desk. Walk around them.", pointer: "The door at the top of the left column, or press O", touch: "The door at the top of the ☰ sheet", target: "enter", }, ]; // ---- Style ---------------------------------------------------------------- /** * Exported for the stylesheet test, which asserts this file writes no raw * `z-index` and that the coach sits on the instrument layer — above the play * surfaces, because step two points at a dock the play HUD would otherwise * cover, and below the modal layer, because a dialog must still be able to open * over it. */ export const ONBOARDING_CSS = ` .tera-coach { position: fixed; z-index: ${zIndex("instrument")}; left: max(var(--s4, 16px), env(safe-area-inset-left)); bottom: calc(var(--s4, 16px) + env(safe-area-inset-bottom)); width: min(23rem, calc(100vw - var(--s4, 16px) * 2)); display: flex; flex-direction: column; gap: var(--s3, 12px); padding: var(--s4, 16px); color: var(--ink, rgba(255,255,255,.78)); background: var(--glass-strong, rgba(9,13,18,.86)); border: 1px solid var(--hairline, rgba(255,255,255,.11)); border-radius: var(--r, 8px); box-shadow: var(--shadow, 0 6px 22px rgba(3,6,10,.45)), inset 0 1px 0 var(--glass-inset, rgba(255,255,255,.05)); backdrop-filter: var(--blur, blur(14px) saturate(1.2)); -webkit-backdrop-filter: var(--blur, blur(14px) saturate(1.2)); font-family: ui-monospace, "SF Mono", Menlo, monospace; animation: tera-coach-in 320ms cubic-bezier(.2,.7,.3,1) both; } .tera-coach[hidden] { display: none; } @keyframes tera-coach-in { from { opacity: 0; transform: translateY(10px); } to { opacity: 1; transform: none; } } .tera-coach__ticks { display: flex; gap: var(--s1, 4px); } .tera-coach__tick { flex: 1; height: 2px; border-radius: 2px; background: rgba(255, 255, 255, 0.12); transition: background 200ms ease; } .tera-coach__tick[data-on="true"] { background: var(--amber, #f2b134); } .tera-coach__verb { margin: 0; font-size: 15px; letter-spacing: .04em; color: var(--amber-ink, #ffd68a); } .tera-coach__line { margin: 0; font-size: 11px; line-height: 1.6; color: var(--ink, rgba(255,255,255,.78)); } .tera-coach__how { margin: 0; padding-left: var(--s2, 8px); border-left: 2px solid rgba(242, 177, 52, 0.45); font-size: 10px; line-height: 1.6; color: var(--ink-2, rgba(255,255,255,.56)); } .tera-coach__actions { display: flex; align-items: center; gap: var(--s2, 8px); } .tera-coach__next { flex: 1; min-height: var(--tap, 44px); padding: 0 var(--s3, 12px); font: inherit; font-size: 12px; font-weight: 600; cursor: pointer; color: #10161d; background: var(--amber, #f2b134); border: 1px solid transparent; border-radius: var(--r-sm, 5px); } .tera-coach__next:hover { background: var(--amber-lit, #ffc555); } .tera-coach__skip { min-height: var(--tap, 44px); padding: 0 var(--s3, 12px); font: inherit; font-size: 10px; letter-spacing: .06em; cursor: pointer; color: var(--ink-3, rgba(255,255,255,.4)); background: none; border: 0; } .tera-coach__skip:hover { color: var(--ink, rgba(255,255,255,.78)); } .tera-coach :is(button):focus-visible { outline: 2px solid var(--amber, #f2b134); outline-offset: 2px; } /* The ring around whatever the current step is naming. One rule, driven by a single attribute on , so a new step is a new value and not a new rule. */ body[data-coach="mode-dock"] .mode-dock, body[data-coach="enter"] #enter { outline: 2px solid var(--amber, #f2b134); outline-offset: 3px; border-radius: var(--r-sm, 5px); animation: tera-coach-pulse 1800ms ease-in-out infinite; } @keyframes tera-coach-pulse { 0%, 100% { outline-color: rgba(242, 177, 52, 0.95); } 50% { outline-color: rgba(242, 177, 52, 0.35); } } /* On a phone the coach is a bottom sheet above the dock rather than a card in a corner, because there is no corner a thumb can reach and no space beside it. */ @media (max-width: 600px) { .tera-coach { left: var(--s3, 12px); right: var(--s3, 12px); width: auto; bottom: calc(var(--s3, 12px) + 3.75rem + env(safe-area-inset-bottom)); } .tera-coach__verb { font-size: 14px; } } @media (prefers-reduced-motion: reduce) { .tera-coach { animation: none; } body[data-coach] .mode-dock, body[data-coach] #enter { animation: none; } } `; const STYLE_ID = "tera-onboarding-style"; // ---- Mount ---------------------------------------------------------------- export interface OnboardingOptions { /** A thumb drives this session, so each step leads with its touch sentence. */ coarsePointer: boolean; /** * Where first-run state lives. Pass `browserStorage()` in the app and a fake * — including one that throws — in a test. */ storage: OnboardingStorage | null; /** * Show it even though storage says this browser has been here. * * For the "show me that again" affordance, and for the smoke test, which has to * be able to assert both halves of the not-on-the-second-visit rule. */ force?: boolean; /** Fired once, when the last card is finished or any card is skipped. */ onFinish?(reason: "finished" | "skipped"): void; /** Fired on every step change, with the step's `target`, for anything that wants it. */ onStep?(step: OnboardingStep, index: number): void; } export interface OnboardingHandle { /** `null` when the coach decided not to appear. Nothing was added to the page. */ root: HTMLElement | null; /** Did it appear? */ visible: boolean; /** Which card is up, or `-1` once it is done. */ index(): number; next(): void; skip(): void; dispose(): void; } /** * Put the coach on the page, or decide not to. * * The decision is made here rather than by the caller so that the "was this * browser here before" rule lives in one place with the write that sets it. The * caller passes `firstVisit` through `chromeState` for layout purposes; this is * the module that actually knows. */ export function mountOnboarding( host: HTMLElement, options: OnboardingOptions, ): OnboardingHandle { const seen = hasSeenOnboarding(options.storage); if (seen && options.force !== true) { return { root: null, visible: false, index: () => -1, next: () => {}, skip: () => {}, dispose: () => {}, }; } // Recorded at mount, not at completion. See rule 3 in the header: somebody who // read the first card and went straight to dragging the map has been // onboarded, and a coach that reappears because they did not press "Next" // three times is a coach arguing with its own success condition. markOnboardingSeen(options.storage); const doc = host.ownerDocument; const body = doc.body ?? null; let styleAdded = false; const head = doc.head ?? null; if (head !== null && head.querySelector(`#${STYLE_ID}`) === null) { const style = doc.createElement("style"); style.id = STYLE_ID; style.textContent = ONBOARDING_CSS; head.append(style); styleAdded = true; } const root = doc.createElement("section"); root.className = "tera-coach"; root.setAttribute("role", "region"); root.setAttribute("aria-label", "Getting started"); const ticks = doc.createElement("div"); ticks.className = "tera-coach__ticks"; ticks.setAttribute("aria-hidden", "true"); const tickElements = ONBOARDING_STEPS.map(() => { const tick = doc.createElement("span"); tick.className = "tera-coach__tick"; ticks.append(tick); return tick; }); const verb = doc.createElement("h2"); verb.className = "tera-coach__verb"; const line = doc.createElement("p"); line.className = "tera-coach__line"; const how = doc.createElement("p"); how.className = "tera-coach__how"; const actions = doc.createElement("div"); actions.className = "tera-coach__actions"; const nextButton = doc.createElement("button"); nextButton.type = "button"; nextButton.className = "tera-coach__next"; const skipButton = doc.createElement("button"); skipButton.type = "button"; skipButton.className = "tera-coach__skip"; skipButton.textContent = "Skip"; actions.append(nextButton, skipButton); root.append(ticks, verb, line, how, actions); host.append(root); let index = 0; let disposed = false; let finished = false; function paint(): void { const step = ONBOARDING_STEPS[index]; if (step === undefined) return; verb.textContent = step.verb; line.textContent = step.line; how.textContent = options.coarsePointer ? step.touch : step.pointer; nextButton.textContent = index === ONBOARDING_STEPS.length - 1 ? "Start exploring" : "Next"; for (const [i, tick] of tickElements.entries()) { tick.setAttribute("data-on", String(i <= index)); } if (body !== null) { if (step.target === null) body.removeAttribute("data-coach"); else body.setAttribute("data-coach", step.target); } options.onStep?.(step, index); } function finish(reason: "finished" | "skipped"): void { if (finished) return; finished = true; index = -1; root.hidden = true; body?.removeAttribute("data-coach"); options.onFinish?.(reason); } function next(): void { if (disposed || finished) return; if (index >= ONBOARDING_STEPS.length - 1) { finish("finished"); return; } index += 1; paint(); } function skip(): void { if (disposed) return; finish("skipped"); } nextButton.addEventListener("click", next); skipButton.addEventListener("click", skip); paint(); return { root, visible: true, index: () => index, next, skip, dispose() { if (disposed) return; disposed = true; body?.removeAttribute("data-coach"); root.remove(); if (styleAdded) doc.querySelector(`#${STYLE_ID}`)?.remove(); }, }; }