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>
185 lines
7.0 KiB
TypeScript
185 lines
7.0 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { describe, it } from "node:test";
|
|
|
|
import { FakeDocument, type FakeElement, fakeStorage } from "./fakeDom.ts";
|
|
import {
|
|
ONBOARDING_STEPS,
|
|
ONBOARDING_STORAGE_KEY,
|
|
hasSeenOnboarding,
|
|
markOnboardingSeen,
|
|
mountOnboarding,
|
|
} from "../../ui/onboarding.ts";
|
|
import type { OnboardingStorage } from "../../ui/onboarding.ts";
|
|
|
|
function setup(storage: OnboardingStorage | null, coarsePointer = false, force = false) {
|
|
const doc = new FakeDocument();
|
|
const host = doc.createElement("div");
|
|
doc.body.append(host);
|
|
const finished: string[] = [];
|
|
const coach = mountOnboarding(host as unknown as HTMLElement, {
|
|
coarsePointer,
|
|
storage,
|
|
force,
|
|
onFinish: (reason) => finished.push(reason),
|
|
});
|
|
return { doc, host, coach, finished, root: coach.root as unknown as FakeElement | null };
|
|
}
|
|
|
|
describe("the first-run coach", () => {
|
|
it("teaches the two verbs the product is, and the control surface that reaches them", () => {
|
|
// The product is "fly the board" and "walk into the studio", and before this
|
|
// file neither was ever stated on screen at any tier in any layout.
|
|
const verbs = ONBOARDING_STEPS.map((step) => step.verb.toLowerCase());
|
|
assert.equal(ONBOARDING_STEPS.length, 3);
|
|
assert.ok(verbs.some((verb) => verb.includes("fly")));
|
|
assert.ok(verbs.some((verb) => verb.includes("walk into")));
|
|
assert.ok(verbs.some((verb) => verb.includes("controls")));
|
|
});
|
|
|
|
it("gives every step a touch sentence that is a different act, not a translation", () => {
|
|
// The desktop's key strip is `display: none` below 600px, so on a phone this
|
|
// is the only place the controls are ever named.
|
|
for (const step of ONBOARDING_STEPS) {
|
|
assert.ok(step.touch.trim().length > 10, `${step.id} has no touch sentence`);
|
|
assert.notEqual(step.touch, step.pointer, `${step.id} just restates its pointer sentence`);
|
|
}
|
|
const dock = ONBOARDING_STEPS.find((step) => step.id === "dock");
|
|
// The joystick is the control a phone visitor otherwise had no way to find.
|
|
assert.match(dock?.touch ?? "", /joystick/);
|
|
});
|
|
|
|
it("appears when storage is empty", () => {
|
|
const storage = fakeStorage();
|
|
const { coach, root, host } = setup(storage);
|
|
assert.equal(coach.visible, true);
|
|
assert.ok(root);
|
|
assert.equal(host.children.length, 1);
|
|
assert.match(root.text(), /Fly the board/);
|
|
});
|
|
|
|
it("does not appear on the second mount", () => {
|
|
const storage = fakeStorage();
|
|
const first = setup(storage);
|
|
assert.equal(first.coach.visible, true);
|
|
|
|
const second = setup(storage);
|
|
assert.equal(second.coach.visible, false);
|
|
assert.equal(second.root, null);
|
|
assert.equal(second.host.children.length, 0, "a returning visitor's page carries no trace");
|
|
});
|
|
|
|
it("records the visit at mount, not at completion", () => {
|
|
/*
|
|
* Deliberate. Somebody who read the first card and went straight to dragging
|
|
* the map has been onboarded; a coach that reappears next visit because they
|
|
* did not press "Next" three times is arguing with its own success
|
|
* condition.
|
|
*/
|
|
const storage = fakeStorage();
|
|
setup(storage);
|
|
assert.equal(storage.map.has(ONBOARDING_STORAGE_KEY), true);
|
|
});
|
|
|
|
it("still renders when every localStorage accessor throws", () => {
|
|
// Safari private mode, and any embed with third-party storage blocked. An
|
|
// onboarding card is not worth a blank page.
|
|
const storage = fakeStorage("throwing");
|
|
assert.doesNotThrow(() => hasSeenOnboarding(storage));
|
|
assert.equal(hasSeenOnboarding(storage), false);
|
|
assert.doesNotThrow(() => markOnboardingSeen(storage));
|
|
|
|
const { coach, root } = setup(storage);
|
|
assert.equal(coach.visible, true);
|
|
assert.ok(root);
|
|
// And it keeps working: a throwing store means it will be offered again next
|
|
// visit, which is survivable in a way that never showing it is not.
|
|
const again = setup(storage);
|
|
assert.equal(again.coach.visible, true);
|
|
});
|
|
|
|
it("treats a missing store the same way", () => {
|
|
assert.equal(hasSeenOnboarding(null), false);
|
|
assert.doesNotThrow(() => markOnboardingSeen(null));
|
|
const { coach } = setup(null);
|
|
assert.equal(coach.visible, true);
|
|
});
|
|
|
|
it("steps forward, marks progress, and finishes on the last card", () => {
|
|
const { coach, root, finished } = setup(fakeStorage());
|
|
assert.ok(root);
|
|
assert.equal(coach.index(), 0);
|
|
|
|
coach.next();
|
|
assert.equal(coach.index(), 1);
|
|
assert.match(root.text(), /Take the controls/);
|
|
|
|
coach.next();
|
|
assert.equal(coach.index(), 2);
|
|
assert.match(root.text(), /Walk into the studio/);
|
|
assert.match(root.text(), /Start exploring/);
|
|
|
|
coach.next();
|
|
assert.equal(coach.index(), -1);
|
|
assert.equal(root.hidden, true);
|
|
assert.deepEqual(finished, ["finished"]);
|
|
|
|
// Idempotent: a double-press of the last button must not fire twice.
|
|
coach.next();
|
|
assert.deepEqual(finished, ["finished"]);
|
|
});
|
|
|
|
it("is skippable from any card, in one press", () => {
|
|
const { coach, root, finished } = setup(fakeStorage());
|
|
assert.ok(root);
|
|
coach.next();
|
|
const skip = root.querySelector(".tera-coach__skip");
|
|
assert.ok(skip);
|
|
skip.click();
|
|
assert.equal(root.hidden, true);
|
|
assert.equal(coach.index(), -1);
|
|
assert.deepEqual(finished, ["skipped"]);
|
|
});
|
|
|
|
it("rings the control each step is naming, and stops when it is done", () => {
|
|
// A coach that greys the scene to point at the scene has explained nothing.
|
|
// This is a card in a corner plus one attribute on <body>.
|
|
const { doc, coach } = setup(fakeStorage());
|
|
assert.equal(doc.body.getAttribute("data-coach"), null, "step one is about the whole scene");
|
|
coach.next();
|
|
assert.equal(doc.body.getAttribute("data-coach"), "mode-dock");
|
|
coach.next();
|
|
assert.equal(doc.body.getAttribute("data-coach"), "enter");
|
|
coach.skip();
|
|
assert.equal(doc.body.getAttribute("data-coach"), null);
|
|
});
|
|
|
|
it("leads with the touch sentence on a coarse pointer", () => {
|
|
const withMouse = setup(fakeStorage(), false);
|
|
assert.ok(withMouse.root);
|
|
assert.match(withMouse.root.text(), /scroll to zoom/);
|
|
|
|
const withThumb = setup(fakeStorage(), true);
|
|
assert.ok(withThumb.root);
|
|
assert.match(withThumb.root.text(), /pinch to zoom/);
|
|
});
|
|
|
|
it("can be shown again on request, without a second copy of the stylesheet", () => {
|
|
const storage = fakeStorage();
|
|
const first = setup(storage);
|
|
first.coach.dispose();
|
|
const again = setup(storage, false, true);
|
|
assert.equal(again.coach.visible, true);
|
|
assert.equal(again.doc.head.querySelectorAll("style").length, 1);
|
|
});
|
|
|
|
it("leaves nothing behind on dispose", () => {
|
|
const { doc, host, coach } = setup(fakeStorage());
|
|
coach.next();
|
|
assert.equal(doc.body.getAttribute("data-coach"), "mode-dock");
|
|
coach.dispose();
|
|
assert.equal(host.children.length, 0);
|
|
assert.equal(doc.body.getAttribute("data-coach"), null);
|
|
assert.equal(doc.head.querySelectorAll("style").length, 0);
|
|
});
|
|
});
|