db074e9cf7
The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
465 lines
16 KiB
TypeScript
465 lines
16 KiB
TypeScript
/**
|
|
* 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 `<body>` 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 <body>, 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();
|
|
},
|
|
};
|
|
}
|