1
0

feat: tone-mapped render rig, studio devices, LA fidelity pass, UI overhaul

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>
This commit is contained in:
2026-08-21 19:44:24 -07:00
parent 8738367258
commit db074e9cf7
150 changed files with 36237 additions and 2586 deletions
+807
View File
@@ -0,0 +1,807 @@
/**
* Every visibility decision this interface makes, as one pure function.
*
* `renderLegend()` in `main.ts` made about forty of them — `walkButton.hidden =
* inside ? office?.walker === null : city.actorState() === null`, and thirty-nine
* more like it — interleaved with the DOM writes that acted on them, reading
* eight pieces of module-level mutable state, and with **zero** test assertions
* behind any of it. On a product whose entire pitch is the interface, the
* interface was the only subsystem in the repo with no coverage at all. It was
* not testable in principle either: every decision needed a live `Stage`, a built
* heightfield and a WebGL context to reach.
*
* This module is the decisions and nothing else. No DOM, no THREE, no import of
* `main.ts`, no clock, no `localStorage`, no network — hand it a plain object,
* get a plain object back. `mount.ts` is the only thing that writes to the page,
* and it writes exactly what this returned.
*
* Two properties fall out of that split and both are the point:
*
* - **The matrix is enumerable.** Three access tiers × six control modes ×
* inside/outside × coarse/fine pointer is 72 states, and
* `src/test/ui/chromeState.test.ts` walks all of them. That is a thing you can
* only do to a function.
* - **The integration workstream makes one call.** `apply(chromeState(inputs))`
* replaces forty scattered mutations, so a new piece of chrome is a field here
* and a write there, rather than a fortieth line in a function nobody wants to
* touch.
*
* ### On the input shape
*
* The twelve top-level keys are fixed by the build spec and are the contract with
* `integration`; they are not negotiable and are not reordered. Everything past
* them is optional, defaulted, and exists because the forty decisions genuinely
* depend on more than twelve facts — the label on the Fly button needs to know
* which board you are on, and the label on the touch action button needs to know
* whether the crow is currently in the air. Making those optional rather than
* required is deliberate: an integration stub that passes only the twelve
* documented keys type-checks and returns a sane, if plainer, chrome.
*/
import type { Tier } from "../access.ts";
import type { DeviceDeclaration } from "../devices/types.ts";
import type { ControlMode } from "../play/controlMode.ts";
import { formatAircraftDetail, formatPlayHud } from "./hud.ts";
import type { AircraftDetailInput, AircraftDetailView, PlayHudView, PlayTelemetry } from "./hud.ts";
import { railHints } from "./shortcuts.ts";
import { BREAKPOINT } from "./tokens.ts";
// ---- Inputs ---------------------------------------------------------------
/** What this visitor is, as the chrome needs to know it. */
export interface ChromeAccess {
tier: Tier;
/** `null` when this deployment has no door to knock on — a clean self-hosted clone. */
signInUrl: string | null;
/** The auth subject, when there is one. */
subject: string | null;
/** The name this person chose, which outranks the subject in the badge. */
displayName?: string | null;
/** True once a local profile exists, which is what the Character button edits. */
hasProfile?: boolean;
}
export interface ChromeViewport {
width: number;
height: number;
/**
* A finger, not a mouse.
*
* Keyed off the pointer rather than the width, because the defect this decides
* is about fingers: an 820px iPad has no keyboard either, and it lands on the
* desktop layout by design (see `deviceProfile` in `engine/stage.ts`).
*/
coarsePointer: boolean;
}
/** Which of the three feeds is answering, and what the page may therefore claim. */
export interface ChromeFeeds {
markers: boolean;
weather: boolean;
flights: boolean;
/** A sky somebody typed retires the live-weather claim for as long as it is up. */
weatherOverridden?: boolean;
/** The people in this office are invented, and the caption must say so. */
sampleOccupancy?: boolean;
/** Licence lines from whichever live sources are answering. Shown in the `?` card. */
credits?: readonly string[];
}
/** Which board and which building, for the labels that name them. */
export interface ChromeBoard {
cityId?: string;
cityLabel?: string;
officeId?: string;
officeLabel?: string;
officeStatus?: "active" | "building";
/** The aircraft route only exists on the California board. */
isCalifornia?: boolean;
}
/**
* One tab in the board strip — a world outside a building, a building inside one.
*
* The lists live in the inputs rather than being fetched by the applier, so that
* `apply(chromeState(inputs))` really is *one* call: a second `setBoards()`
* method on the handle would be a second place for the chrome to be half
* updated, which is the class of bug this whole module exists to remove.
*/
export interface ChromeBoardTab {
id: string;
label: string;
/** "active" or "building" for an office; `null` for a city, which has no status. */
status?: "active" | "building" | null;
}
/** One row of the chapter list: a city chapter and an office viewpoint are both this. */
export interface ChromeView {
id: string;
/** The authored number, or `null` to take the ordinal. */
number?: string | null;
shortLabel: string;
description?: string;
}
/**
* What the detail card is showing.
*
* Two shapes, because there are two things worth picking: a marker, which is one
* authored sentence, and an aircraft, which is five observed fields and is the
* moment the map stops looking like a diagram. Modelled as a union rather than a
* string so the aircraft case cannot be flattened back into one on its way here,
* which is exactly what used to happen.
*/
export type ChromeDetail =
| { kind: "text"; text: string }
| { kind: "aircraft"; aircraft: AircraftDetailInput };
/** The body currently under control, for the labels that describe it. */
export interface ChromeActor {
kind?: "crow" | "dog" | "humanoid" | null;
/** A crow on the wing takes a different action pad from one on the ground. */
flying?: boolean;
}
/** Facts about the building you are standing in. */
export interface ChromeOffice {
/** Screens authored in this pack that a full-depth viewer may drive. */
mediaSurfaceCount?: number;
/** How many of them are showing something right now. */
mediaSurfaceActiveCount?: number;
/** `resolveRobotOperations`' one-sentence disclosure, when the pack has robots. */
robotDisclosure?: string | null;
/** The pack could not give this office a walker — a dollhouse-only building. */
walkable?: boolean;
}
export interface ChromeInputs {
mode: ControlMode;
/** Every mode reachable right now, per `controlModeAvailable`. */
available: readonly ControlMode[];
access: ChromeAccess;
inside: boolean;
/** `null` outside a building. "public" is the shell; "full" is the people too. */
officeDepth: "public" | "full" | null;
viewport: ChromeViewport;
feeds: ChromeFeeds;
/** One sentence per demotion the server reported. Admin-visible. */
degraded: readonly string[];
/** Authored hardware in this room. Empty outside one. */
devices: readonly DeviceDeclaration[];
/** Nothing in `localStorage` says this browser has been here before. */
firstVisit: boolean;
panelOpen: boolean;
planOpen: boolean;
// ---- Optional refinements; see the header ----
board?: ChromeBoard;
actor?: ChromeActor;
office?: ChromeOffice;
/** A marker or aircraft is picked, so the detail card has something to show. */
detailOpen?: boolean;
/** A camera is live in this tab, which raises the top-right column over modals. */
cameraLive?: boolean;
/** Hosted presence is connected, which adds a row to the top-right column. */
presenceVisible?: boolean;
/** The board strip's entries: cities outside a building, offices inside one. */
boards?: readonly ChromeBoardTab[];
/** The chapter list: city chapters outside, authored viewpoints inside. */
views?: readonly ChromeView[];
activeViewId?: string | null;
/** Whatever the current mode's controller is reporting, for the play HUD. */
telemetry?: PlayTelemetry | null;
/** The picked marker or aircraft, or `null`. Supersedes `detailOpen`. */
detail?: ChromeDetail | null;
/** The clock line, already formatted by whoever owns the clock. */
clockLabel?: string;
}
// ---- Output ---------------------------------------------------------------
/**
* Three layouts, not two breakpoints' worth of tweaks.
*
* `phone` is a different design and not a narrow desktop: no hover, so nothing
* may depend on it; no keyboard, so a card explaining the keyboard is furniture;
* and one thumb, which reaches the bottom third of the screen and not the top
* corners.
*/
export type ChromeLayout = "phone" | "compact" | "desktop";
export interface ChromeModeButton {
mode: ControlMode;
label: string;
visible: boolean;
pressed: boolean;
}
export interface ChromeTouchAction {
/** The DOM id in `index.html`, so the applier needs no second table. */
id: string;
label: string;
visible: boolean;
/** The held control it feeds, for the buttons that hold one. */
control: string | null;
/** The one-shot request it fires, for the buttons that fire one. */
edge: string | null;
}
export interface ChromeRailHint {
id: string;
keys: readonly string[];
label: string;
}
export interface ChromeBoardButton extends ChromeBoardTab {
active: boolean;
}
export interface ChromeViewRow {
id: string;
/** Always a string by the time it reaches the applier: authored, or the ordinal. */
number: string;
label: string;
active: boolean;
}
/** The detail card, resolved. `null` when nothing is picked. */
export type ChromeDetailView =
| { kind: "text"; text: string }
| { kind: "aircraft"; view: AircraftDetailView };
export interface ChromeState {
// ---- Frame ----
layout: ChromeLayout;
/** A finger drives this session, so on-screen controls are the primary path. */
touchFirst: boolean;
/** Standing in a building rather than looking at a board. */
inside: boolean;
/** Every class the applier sets on `<body>`, computed once so nothing drifts. */
bodyClasses: readonly string[];
// ---- The left column ----
panelVisible: boolean;
panelOpen: boolean;
/**
* Carried through so the applier can compute the *next* value for a toggle
* without keeping its own copy of the state — which is how a toggle ends up
* disagreeing with the thing it toggles.
*/
planOpen: boolean;
panelToggleVisible: boolean;
panelToggleLabel: string;
scrimVisible: boolean;
panelTitle: string;
panelSubtitle: string;
/** Which list the board strip is pointed at: worlds outside, buildings inside. */
boardPicker: "city" | "office";
boards: readonly ChromeBoardButton[];
clockLabel: string;
enterVisible: boolean;
enterLabel: string;
walkVisible: boolean;
walkPressed: boolean;
walkLabel: string;
flyVisible: boolean;
flyPressed: boolean;
flyLabel: string;
screensVisible: boolean;
screensLabel: string;
devicePanelVisible: boolean;
devicePanelLabel: string;
/**
* The declarations themselves, carried through so the applier can build the
* panel without a second call. Pass-through, not a decision — but it belongs in
* the state for the same reason the board and view lists do: one call, one
* consistent frame of chrome.
*/
deviceDeclarations: readonly DeviceDeclaration[];
chaptersVisible: boolean;
views: readonly ChromeViewRow[];
blurbVisible: boolean;
blurbText: string;
// ---- Top right ----
tierVisible: boolean;
tierTone: Tier;
tierLabel: string;
/**
* What signing in **adds**, for a visitor who has not.
*
* `null` for anyone it would not help. The badge used to read "Public view"
* and stop there, which names the visitor rather than the offer and is the one
* place in the product where the anon-first promise reads as a demotion.
*/
tierAdds: string | null;
whoLabel: string | null;
characterVisible: boolean;
signInVisible: boolean;
signInLabel: string;
signInHref: string | null;
presenceVisible: boolean;
cameraAlert: boolean;
minimapVisible: boolean;
minimapReadoutVisible: boolean;
// ---- The dock and the HUD ----
modeDockVisible: boolean;
modeButtons: readonly ChromeModeButton[];
playHudVisible: boolean;
/** The three strings and the warning flag, or `null` in the orbit modes. */
playHud: PlayHudView | null;
// ---- The rail ----
railVisible: boolean;
detailVisible: boolean;
detail: ChromeDetailView | null;
helpVisible: boolean;
planToggleVisible: boolean;
planTogglePressed: boolean;
railHintsVisible: boolean;
railHints: readonly ChromeRailHint[];
// ---- Touch ----
touchControlsVisible: boolean;
stickVisible: boolean;
stickLabel: string;
touchActions: readonly ChromeTouchAction[];
// ---- Honesty, and the office ----
sourceVisible: boolean;
sourceLabel: string;
sourceLive: boolean;
officeInviteVisible: boolean;
officeInviteText: string;
officeInviteLinkLabel: string | null;
officeNoteVisible: boolean;
officeNoteText: string;
degradedVisible: boolean;
degradedLines: readonly string[];
/**
* The credit lines the live sources ask for, shown in the `?` card.
*
* MET Norway and Open-Meteo publish under CC BY 4.0 and the API forwards the
* line each of them asks for; adsb.lol asks to be named for the positions. All
* of it arrived, was parsed, and was then read by nobody — a licence
* obligation plumbed to within one line of being met. Empty when nothing live
* is on screen, because a credit for data nobody is looking at is noise and
* the zero-config build owes nobody anything.
*/
credits: readonly string[];
// ---- First run ----
onboardingVisible: boolean;
canvasLabel: string;
}
// ---- The dock's five buttons ---------------------------------------------
const DOCK_MODES: readonly { mode: ControlMode; label: string }[] = [
{ mode: "overview", label: "View" },
{ mode: "drive", label: "Drive" },
{ mode: "actor", label: "Explore" },
{ mode: "aircraft", label: "Fly" },
{ mode: "office-walk", label: "Walk" },
];
/**
* Modes in which a body is under control and the touch surface applies.
*
* `office-overview` is not one of them, and neither is `overview`: both are
* orbit-camera states where the joystick would move nothing.
*/
const PLAY_MODES: readonly ControlMode[] = ["drive", "actor", "aircraft", "office-walk"];
function isPlayMode(mode: ControlMode): boolean {
return PLAY_MODES.includes(mode);
}
/**
* The liveness phrase, restated here rather than imported.
*
* `describeLiveness` lives in `adapters/http.ts`, which cannot be imported under
* Node's type stripping today and which pulls a network client behind it. The
* wording is the same and the rule is the same: all three live is the only case
* that may say "live data", because "live data" printed over invented companies
* because a weather station answered is precisely the claim the flag exists to
* prevent.
*/
function describeFeeds(feeds: ChromeFeeds): string {
const weather = feeds.weather && feeds.weatherOverridden !== true;
const parts: string[] = [];
if (feeds.markers && weather && feeds.flights) return "live data";
if (feeds.markers) parts.push("live markers");
if (weather) parts.push("live weather");
if (feeds.flights) parts.push("live traffic");
return parts.join(" · ");
}
function layoutFor(viewport: ChromeViewport): ChromeLayout {
const width = Number.isFinite(viewport.width) ? viewport.width : BREAKPOINT.compact + 1;
if (width <= BREAKPOINT.phone) return "phone";
if (width <= BREAKPOINT.compact) return "compact";
return "desktop";
}
function actorNoun(actor: ChromeActor | undefined): string {
const kind = actor?.kind ?? null;
if (kind === "crow") return "your crow";
if (kind === "dog") return "your dog";
if (kind === "humanoid") return "your humanoid";
return "a body";
}
// ---- Seeds ----------------------------------------------------------------
/**
* Should the left column start open at this width?
*
* The width only *seeds* these two; the moment somebody presses the toggle the
* viewport stops having an opinion. That rule was already right and is kept —
* what was wrong was that the seed lived inline in `main.ts` next to the
* `resize` listener that re-ran one of the two and not the other, so a rotation
* from portrait to landscape re-seeded the plan and left the panel sheet closed
* over a desktop-width layout.
*/
export function seedPanelOpen(width: number): boolean {
return !(Number.isFinite(width) && width <= BREAKPOINT.compact);
}
/**
* Should the plan start visible at this width?
*
* **Yes, at every width, and that is the change.** It used to be
* `window.innerWidth > 600`, which meant a phone started with the plan off — and
* a phone has no `M`. Every visible control at 390px was enumerated once and
* none of them could turn it on, so the bottom-sheet plan layout `index.html`
* had carried for months was unreachable on the device it was drawn for. The
* `#plan-toggle` button closed the "no way to turn it on" half; this closes the
* "it was off to begin with" half, and the phone stylesheet gives the plan a
* glanceable 8.5rem home in the top-right column rather than a sheet that eats
* the map.
*/
export function seedPlanOpen(_width: number): boolean {
return true;
}
// ---- The function ---------------------------------------------------------
/**
* Resolve the whole interface for one instant. Pure, total, and cheap enough to
* call on every frame if the caller wants to.
*/
export function chromeState(inputs: ChromeInputs): ChromeState {
const layout = layoutFor(inputs.viewport);
const phone = layout === "phone";
const touchFirst = inputs.viewport.coarsePointer;
const inside = inputs.inside;
const mode = inputs.mode;
const available = inputs.available;
const board = inputs.board ?? {};
const office = inputs.office ?? {};
const actor = inputs.actor ?? {};
const tier = inputs.access.tier;
const can = (candidate: ControlMode): boolean => available.includes(candidate);
const walking = mode === "office-walk";
const exploring = mode === "actor";
const flying = mode === "aircraft";
const driving = mode === "drive";
const playing = isPlayMode(mode);
const crowFlying = exploring && actor.kind === "crow" && actor.flying === true;
const cityLabel = board.cityLabel ?? "";
const officeLabel = board.officeLabel ?? "the studio";
const officeStatus = board.officeStatus ?? "building";
// ---- The dock -----------------------------------------------------------
//
// Hidden entirely when `overview` is the only thing available. On two of the
// three city boards this rendered as a lit pill holding one already-pressed
// button that did nothing — a control whose only state is its current one is
// not a control, it is a label pretending to be one.
const modeButtons: ChromeModeButton[] = DOCK_MODES.map(({ mode: dockMode, label }) => {
// The "View" button stands for both orbit states: `overview` outside a
// building and `office-overview` inside one. They are one idea — the camera
// is yours and nothing else is — and two buttons for it would swap under the
// viewer as they walked through a door.
const visible =
dockMode === "overview" ? can("overview") || can("office-overview") : can(dockMode);
const pressed =
dockMode === "overview" ? mode === "overview" || mode === "office-overview" : mode === dockMode;
return { mode: dockMode, label, visible, pressed };
});
const modeDockVisible = modeButtons.filter((button) => button.visible).length > 1;
// ---- The touch action pad ----------------------------------------------
//
// Built from the mode rather than from a pile of independent `hidden`
// assignments, which is what this was: eight `if (touchX) touchX.hidden = …`
// lines whose conditions overlapped and could not all be true or all be false.
const touchActions: readonly ChromeTouchAction[] = [
{
id: "touch-primary",
label: driving ? "Handbrake" : flying ? "Throttle" : crowFlying ? "Climb" : "Sprint",
visible: playing && !walking,
control: crowFlying ? "ascend" : "primary",
edge: null,
},
{
id: "touch-secondary",
label: "Glide",
visible: crowFlying,
control: "secondary",
edge: null,
},
{ id: "touch-pitch-up", label: "Pitch +", visible: crowFlying || flying, control: "pitch-up", edge: null },
{ id: "touch-pitch-down", label: "Pitch ", visible: crowFlying || flying, control: "pitch-down", edge: null },
{ id: "touch-assist", label: "Assist", visible: driving || flying, control: null, edge: "assist" },
{ id: "touch-reset", label: "Reset", visible: driving || flying, control: null, edge: "reset" },
{ id: "touch-camera", label: "Camera", visible: driving, control: null, edge: "camera" },
{ id: "touch-map", label: "Map", visible: true, control: null, edge: null },
];
/**
* The joystick, and the defect it fixes.
*
* `pointerStick.ts` has carried a real analogue stick since the play modes
* landed, and it was shown only while a body was already under control — which
* on a phone in VIEW mode meant the screen offered no movement control at all,
* and no way to discover that one existed. It is now visible in every play
* mode *and* announced by the onboarding coach, so the first thing a thumb
* finds is the thing that moves.
*/
const touchControlsVisible = touchFirst && playing;
// ---- The rail -----------------------------------------------------------
//
// `?` is a keyboard reference. On a device with no keyboard it was still being
// drawn, which is the interface offering a person a list of keys they cannot
// press. On a coarse pointer the same button remains — the sheet now carries
// the touch and on-screen half of every row — but it is labelled for what it
// is there.
const hints = railHints(mode, touchFirst).map((entry) => ({
id: entry.id,
keys: entry.keys,
label: entry.meaning,
}));
// ---- The honesty line ---------------------------------------------------
const sampleOccupancy = inside && inputs.feeds.sampleOccupancy === true;
const liveness = describeFeeds(inputs.feeds);
const sourceLabel = sampleOccupancy
? "sample occupancy · these people are invented"
: liveness;
// ---- The tier badge -----------------------------------------------------
//
// The label names what you get; the second line names what you would gain.
// "Public view" said neither, and on a product designed anon-first it was the
// one string that framed the default visitor as a lesser one.
const tierLabel = tier === "god" ? "Godmode" : tier === "member" ? "Full view" : "Open demo";
const tierAdds =
tier === "anon" && inputs.access.signInUrl !== null
? "Sign in adds the people: who is in the room, your own character on the floor, and the studio desk."
: null;
// ---- The studio's one invitation ---------------------------------------
//
// It used to live on `#office-badge` inside `#panel`, and on a phone `#panel`
// is a bottom sheet that starts closed — so the only in-office call to sign in
// was behind a hamburger on the device least likely to open one. This is the
// same move `renderSource` already made for the occupancy disclosure, applied
// to the message that is an offer rather than a disclosure.
const publicOffice = inside && inputs.officeDepth === "public";
const officeInviteText = publicOffice
? inputs.access.signInUrl !== null
? "You are seeing the building, not the people."
: "This deployment has no sign-in, so the floor is always the building alone."
: "";
const mediaCount = office.mediaSurfaceCount ?? 0;
const mediaActive = office.mediaSurfaceActiveCount ?? 0;
const robotDisclosure = office.robotDisclosure ?? null;
const mediaNote =
mediaCount === 0
? ""
: mediaActive > 0
? `${mediaActive} of ${mediaCount} ${mediaCount === 1 ? "screen" : "screens"} active · stop control in Office screens.`
: `${mediaCount} ${mediaCount === 1 ? "screen" : "screens"} ready · media stays off until you opt in.`;
const officeNoteText = [mediaNote, robotDisclosure ?? ""].filter((part) => part !== "").join(" ");
// ---- Onboarding ---------------------------------------------------------
//
// Not while a modal-ish state is up, not inside a building, and not while a
// body is already under control — a coach that teaches "drag to orbit" over a
// scene somebody is already driving is a coach interrupting the thing it was
// written to cause.
const onboardingVisible = inputs.firstVisit && !inside && mode === "overview";
// ---- The two lists -------------------------------------------------------
//
// Resolved here rather than in the applier so the applier has no decisions
// left to make. The board strip is one control pointed at whichever list is
// currently the answer to "which of these am I in"; a second, separate office
// strip was the obvious alternative and is worse, because it would sit dead
// and greyed out for the whole time anybody is looking at the city.
const activeBoardId = inside ? board.officeId : board.cityId;
const boards: readonly ChromeBoardButton[] = (inputs.boards ?? []).map((tab) => ({
...tab,
active: tab.id === activeBoardId,
}));
const views: readonly ChromeViewRow[] = (inputs.views ?? []).map((view, i) => ({
id: view.id,
number: view.number ?? String(i + 1).padStart(2, "0"),
label: view.shortLabel,
active: view.id === inputs.activeViewId,
}));
const activeView = (inputs.views ?? []).find((view) => view.id === inputs.activeViewId);
const blurbText = activeView?.description ?? "";
const detailInput = inputs.detail ?? null;
const detail: ChromeDetailView | null =
detailInput === null
? null
: detailInput.kind === "text"
? { kind: "text", text: detailInput.text }
: { kind: "aircraft", view: formatAircraftDetail(detailInput.aircraft) };
const playHud = formatPlayHud(inputs.telemetry ?? null);
const canvasLabel = inside
? walking
? `${officeLabel}, following ${actorNoun(actor)}. Use W A S D to move, or the joystick.`
: `${officeLabel}, seen from above. Drag to orbit, scroll to zoom.`
: driving
? `${cityLabel}, following your car. Use W A S D to drive.`
: flying
? `${cityLabel}, following your electric aircraft. Use W A S D to fly, or P to resume assisted flight.`
: exploring
? `${cityLabel}, following ${actorNoun(actor)}. Use W A S D to move, Q and E for altitude, G to glide.`
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`;
const bodyClasses: string[] = [];
if (!inputs.panelOpen) bodyClasses.push("panel-closed");
if (!inputs.planOpen) bodyClasses.push("minimap-off");
if (sampleOccupancy) bodyClasses.push("sample-occupancy");
if (inputs.presenceVisible === true) bodyClasses.push("presence-on");
if (touchFirst) bodyClasses.push("touch-first");
if (inside) bodyClasses.push("inside");
if (playing) bodyClasses.push("playing");
if (inputs.cameraLive === true) bodyClasses.push("camera-live");
bodyClasses.push(`layout-${layout}`);
return {
layout,
touchFirst,
inside,
bodyClasses,
panelVisible: true,
panelOpen: inputs.panelOpen,
planOpen: inputs.planOpen,
// Above 900 the column is furniture and always there, so the toggle would be
// a button that hides the thing it is standing on top of.
panelToggleVisible: layout !== "desktop",
panelToggleLabel: inside ? officeLabel : cityLabel,
scrimVisible: phone && inputs.panelOpen,
panelTitle: inside ? officeLabel : cityLabel,
panelSubtitle: inside
? `Tera · ${officeStatus} environment`
: "Tera · Lumbridge Simulate",
boardPicker: inside ? "office" : "city",
boards,
clockLabel: inputs.clockLabel ?? "",
enterVisible: true,
enterLabel: inside
? "← Back to the city"
: officeStatus === "active"
? `Open ${officeLabel}`
: `Preview ${officeLabel} · building →`,
walkVisible: inside ? office.walkable !== false && can("office-walk") : can("actor"),
walkPressed: walking || exploring,
walkLabel: inside
? walking
? "Return to overview ↑"
: `Walk as ${actorNoun(actor)}`
: exploring
? "Return to flyover ↑"
: `Explore as ${actorNoun(actor)}`,
flyVisible: !inside && board.isCalifornia === true && can("aircraft"),
flyPressed: flying,
flyLabel: flying ? "Return to flyover ↑" : "Fly the California route →",
screensVisible: inside && inputs.officeDepth === "full" && mediaCount > 0,
screensLabel: "Office screens →",
// Anonymous visitors see the hardware too. The declarations are authored into
// the pack and are therefore public by construction; what an account buys is
// the live *readings*, and the panel says which it is showing.
devicePanelVisible: inside && inputs.devices.length > 0,
devicePanelLabel:
inputs.devices.length === 1 ? "Studio hardware →" : `Studio hardware · ${inputs.devices.length}`,
deviceDeclarations: inputs.devices,
chaptersVisible: views.length > 0,
views,
// On a phone the panel is a bottom sheet with a thumb-sized list in it; a
// paragraph of description under that list pushes the door off the screen.
blurbVisible: !phone && blurbText !== "",
blurbText,
tierVisible: true,
tierTone: tier,
tierLabel,
tierAdds,
whoLabel: inputs.access.displayName ?? inputs.access.subject,
characterVisible: inputs.access.hasProfile === true,
signInVisible: inputs.access.subject === null && inputs.access.signInUrl !== null,
signInLabel: "Sign in",
signInHref: inputs.access.signInUrl,
presenceVisible: inputs.presenceVisible === true,
cameraAlert: inputs.cameraLive === true,
minimapVisible: inputs.planOpen,
// Hover-only text on a device with no hover.
minimapReadoutVisible: !touchFirst,
modeDockVisible,
modeButtons,
playHudVisible: playing && playHud !== null,
playHud,
railVisible: true,
detailVisible: detail !== null || inputs.detailOpen === true,
detail,
helpVisible: true,
// For pointers, and only for pointers: where there is a keyboard the `M`
// hint is the affordance and a button saying the same thing is a second row
// of glass for nothing.
planToggleVisible: touchFirst || phone,
planTogglePressed: inputs.planOpen,
railHintsVisible: hints.length > 0,
railHints: hints,
touchControlsVisible,
stickVisible: touchControlsVisible,
stickLabel: walking
? "Walk"
: driving
? "Steer"
: flying
? "Fly"
: crowFlying
? "Bank"
: "Move",
touchActions,
sourceVisible: sourceLabel !== "",
sourceLabel,
sourceLive: !sampleOccupancy && liveness !== "",
officeInviteVisible: publicOffice,
officeInviteText,
officeInviteLinkLabel:
publicOffice && inputs.access.signInUrl !== null ? "Sign in for the live floor" : null,
officeNoteVisible: inside && officeNoteText !== "",
officeNoteText,
// The demotions are an operator's diagnostic, not a visitor's caption. They
// are shown in the `?` card to the tier that could act on them.
degradedVisible: tier === "god" && inputs.degraded.length > 0,
degradedLines: inputs.degraded,
// The weather override is somebody's invention; it is not MET Norway's sky
// and must not be attributed to them.
credits: [...new Set((inputs.feeds.credits ?? []).filter((line) => line.trim() !== ""))],
onboardingVisible,
canvasLabel,
};
}
+449
View File
@@ -0,0 +1,449 @@
/**
* The mic and the monitor speaker, drawn as instruments rather than as a table
* of key/value pairs.
*
* There was no device concept anywhere in this product a week ago — no type, no
* route, no state — so this panel is not a rewrite of anything and has no legacy
* to preserve. What it does have is a rule it must not break, and it is the same
* rule the media-surface panel and `RobotOperationsDefinition` already live
* under: **a reading that was invented has to say so, on the same surface as the
* reading.** A level meter bouncing next to a photograph of a desk is a claim
* about a real room. `DeviceDeclaration.disclosure` is the sentence that makes it
* an honest one, `validateDeviceDeclaration` refuses a `simulated` device whose
* disclosure does not contain the word, and this panel prints it under every
* instrument rather than once at the top — because a panel that scrolls is a
* panel whose header you have already scrolled past.
*
* ### One control per declared capability, and never a `switch` on kind
*
* The controls are built by walking `declaration.capabilities` and looking each
* one up in `CAPABILITY_READING`. Nothing here asks whether a device is a mic.
* That is the whole design of `src/devices/types.ts` and it is what makes a
* third kind — a smart light, a thermostat — data rather than a code change: the
* capability is declared, the reading is named by the table, and the control
* falls out. A `if (kind === "mic") renderGain()` ladder would have to be found
* and edited in four consumers the first time somebody added one.
*
* `level` is the odd one and is deliberately not a control: it is a *reading*.
* You can ask a microphone for its programme level; you cannot set it.
* `DeviceCommandOp` is `Exclude<DeviceCapability, "level">` for exactly this
* reason, so the type system agrees with the panel about which rows are knobs.
*
* ### Anonymous visitors get the panel
*
* Declarations are authored into the office pack, which is bundled into the
* static build, which means they are public by construction — the hardware in
* the room is a description of the room. What an account buys is the live
* readings from the authenticated route. So this mounts for everyone and shows
* the at-rest state from `initialDeviceState` until something better arrives,
* and the disclosure line is what tells you which of the two you are looking at.
*/
import {
CAPABILITY_READING,
DEVICE_RANGES,
initialDeviceState,
normalizeDeviceCommand,
} from "../devices/types.ts";
import type {
DeviceCapability,
DeviceCommand,
DeviceDeclaration,
DeviceState,
} from "../devices/types.ts";
import { TOUCH_TARGET_PX } from "./tokens.ts";
// ---- Options and handle ---------------------------------------------------
export interface DevicePanelOptions {
/**
* The authored hardware in this room, from the resolved `Plan`.
*
* Required, and this is the one addition to the signature the build spec
* fixed: the panel's structure is a function of the declarations and there is
* no honest way to render a control without one — the capability list, the
* label, the ranges and the disclosure all come from here. States alone would
* leave the panel guessing which readings a device is *supposed* to have,
* which is precisely the distinction `undefined` is reserved for.
*/
declarations: readonly DeviceDeclaration[];
/**
* Send one instruction. Already validated and clamped by
* `normalizeDeviceCommand` before it arrives; a refusal is a silent no-op here
* and never reaches this callback.
*/
onCommand(command: DeviceCommand): void;
/**
* False while nothing is connected — an anonymous visitor, or a build with no
* server. The controls stay visible and stay pressable, because a disabled
* mixing desk teaches nothing; what changes is the line under them.
*/
live?: boolean;
}
export interface DevicePanelHandle {
root: HTMLElement;
apply(states: readonly DeviceState[]): void;
dispose(): void;
}
// ---- Style ----------------------------------------------------------------
/**
* Exported so the stylesheet test can assert the two properties that are easy to
* lose and impossible to see in a Node test: that every interactive row clears
* the 44px touch target, and that nothing in here writes a raw `z-index`.
*/
export const DEVICE_PANEL_CSS = `
.tera-devices { display: flex; flex-direction: column; gap: var(--s2, 8px); }
.tera-device {
display: flex;
flex-direction: column;
gap: var(--s2, 8px);
padding: var(--s3, 12px);
border: 1px solid var(--hairline, rgba(255,255,255,.11));
border-radius: var(--r-sm, 5px);
background: rgba(255, 255, 255, 0.03);
}
.tera-device__head { display: flex; align-items: baseline; justify-content: space-between; gap: var(--s2, 8px); }
.tera-device__name { font-size: 11px; letter-spacing: .04em; color: var(--ink, rgba(255,255,255,.78)); }
.tera-device__kind { font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3, rgba(255,255,255,.4)); }
.tera-device__row {
display: grid;
grid-template-columns: 4.5rem minmax(0, 1fr) 3.25rem;
align-items: center;
gap: var(--s2, 8px);
min-height: ${TOUCH_TARGET_PX}px;
}
.tera-device__row[data-unavailable="true"] { display: none; }
.tera-device__label { font-size: 9px; letter-spacing: .1em; text-transform: uppercase; color: var(--ink-3, rgba(255,255,255,.4)); }
.tera-device__value { font-size: 10px; text-align: right; font-variant-numeric: tabular-nums; color: var(--ink-2, rgba(255,255,255,.56)); }
.tera-device__switch {
min-height: ${TOUCH_TARGET_PX}px;
min-width: ${TOUCH_TARGET_PX}px;
padding: 0 var(--s3, 12px);
font: inherit;
font-size: 10px;
letter-spacing: .08em;
text-transform: uppercase;
cursor: pointer;
color: var(--ink-2, rgba(255,255,255,.56));
background: rgba(255, 255, 255, 0.06);
border: 1px solid var(--hairline, rgba(255,255,255,.11));
border-radius: var(--r-pill, 999px);
}
.tera-device__switch[aria-pressed="true"] {
color: var(--amber-ink, #ffd68a);
background: rgba(242, 177, 52, 0.2);
border-color: rgba(242, 177, 52, 0.5);
}
.tera-device__slider { width: 100%; min-height: ${TOUCH_TARGET_PX}px; accent-color: var(--amber, #f2b134); }
.tera-device__meter {
position: relative;
height: 6px;
border-radius: 3px;
overflow: hidden;
background: rgba(255, 255, 255, 0.08);
}
.tera-device__meter i {
display: block;
height: 100%;
border-radius: 3px;
background: linear-gradient(90deg, #6fd58f 0%, #f2b134 78%, #ff8f6b 100%);
transition: width 120ms linear;
}
.tera-device__disclosure {
margin: 0;
font-size: 9px;
line-height: 1.6;
color: var(--ink-3, rgba(255,255,255,.4));
border-left: 2px solid rgba(242, 177, 52, 0.4);
padding-left: var(--s2, 8px);
}
@media (prefers-reduced-motion: reduce) { .tera-device__meter i { transition: none; } }
`;
const STYLE_ID = "tera-device-panel-style";
// ---- Rows ----------------------------------------------------------------
/** How each capability presents itself. `level` is a meter because it is a reading. */
type RowShape = "switch" | "slider" | "meter";
const ROW_SHAPE: Readonly<Record<DeviceCapability, RowShape>> = {
power: "switch",
mute: "switch",
playback: "switch",
gain: "slider",
volume: "slider",
level: "meter",
};
const ROW_LABEL: Readonly<Record<DeviceCapability, string>> = {
power: "Power",
mute: "Mute",
playback: "Play",
gain: "Gain",
volume: "Volume",
level: "Level",
};
/** The step a slider moves in, in the reading's own unit. */
const SLIDER_STEP: Readonly<Record<"gain" | "volume", number>> = { gain: 1, volume: 0.01 };
interface Row {
capability: DeviceCapability;
/**
* How this row presents itself, carried rather than re-derived.
*
* Deliberately not an `instanceof HTMLButtonElement` check at apply time:
* `HTMLButtonElement` is not a global in Node, so an `instanceof` against it
* throws a `ReferenceError` rather than returning false — which would make
* this module untestable outside a browser for no benefit at all.
*/
shape: RowShape;
root: HTMLElement;
control: HTMLElement;
/** The slider itself, for the two rows that have one. */
input: HTMLInputElement | null;
value: HTMLElement;
meterFill: HTMLElement | null;
}
interface Instrument {
declaration: DeviceDeclaration;
rows: readonly Row[];
}
function formatReading(capability: DeviceCapability, reading: unknown): string {
if (reading === undefined || reading === null) return "—";
if (typeof reading === "boolean") {
if (capability === "power") return reading ? "On" : "Off";
if (capability === "mute") return reading ? "Muted" : "Open";
return reading ? "Playing" : "Stopped";
}
if (typeof reading !== "number" || !Number.isFinite(reading)) return "—";
if (capability === "gain") return `${reading >= 0 ? "+" : ""}${reading.toFixed(0)} dB`;
if (capability === "level") return `${reading.toFixed(0)} dBFS`;
if (capability === "volume") return `${Math.round(reading * 100)}%`;
return String(reading);
}
/** A level in dBFS as a fraction of the meter's travel. `-60` is empty, `0` is full. */
export function meterFraction(levelDb: number | undefined): number {
if (levelDb === undefined || !Number.isFinite(levelDb)) return 0;
const { min, max } = DEVICE_RANGES.level;
return Math.min(1, Math.max(0, (levelDb - min) / (max - min)));
}
// ---- Mount ----------------------------------------------------------------
export function mountDevicePanel(
host: HTMLElement,
options: DevicePanelOptions,
): DevicePanelHandle {
const doc = host.ownerDocument;
const root = doc.createElement("section");
root.className = "tera-devices";
root.setAttribute("aria-label", "Studio hardware");
// One stylesheet per document, not per panel: this panel is torn down and
// rebuilt on every office switch, and a `<style>` per mount would accumulate
// one copy of the same rules per building the visitor walked through.
const head = doc.head ?? null;
let styleAdded = false;
if (head !== null && head.querySelector(`#${STYLE_ID}`) === null) {
const style = doc.createElement("style");
style.id = STYLE_ID;
style.textContent = DEVICE_PANEL_CSS;
head.append(style);
styleAdded = true;
}
const instruments: Instrument[] = [];
let disposed = false;
/** Last known state per device, so a control can compute its own toggle. */
const latest = new Map<string, DeviceState>();
function send(declaration: DeviceDeclaration, command: DeviceCommand): void {
if (disposed) return;
// Validated here as well as on the route, and that is not belt-and-braces
// duplication: this is the copy that stops the UI *sending* something the
// route would refuse, so a slider that overshoots its range by a float
// rounding error is clamped rather than round-tripped into a 400.
const normalized = normalizeDeviceCommand(declaration, command);
if (normalized === null) return;
options.onCommand(normalized);
}
for (const declaration of options.declarations) {
const card = doc.createElement("article");
card.className = "tera-device";
card.setAttribute("data-device", declaration.id);
card.setAttribute("data-kind", declaration.kind);
const head2 = doc.createElement("div");
head2.className = "tera-device__head";
const name = doc.createElement("span");
name.className = "tera-device__name";
name.textContent = declaration.label;
const kind = doc.createElement("span");
kind.className = "tera-device__kind";
kind.textContent = declaration.kind;
head2.append(name, kind);
card.append(head2);
const rows: Row[] = [];
for (const capability of declaration.capabilities) {
const shape = ROW_SHAPE[capability];
if (shape === undefined) continue;
const row = doc.createElement("div");
row.className = "tera-device__row";
row.setAttribute("data-capability", capability);
const label = doc.createElement("span");
label.className = "tera-device__label";
label.textContent = ROW_LABEL[capability];
const value = doc.createElement("span");
value.className = "tera-device__value";
value.textContent = "—";
let control: HTMLElement;
let input: HTMLInputElement | null = null;
let meterFill: HTMLElement | null = null;
if (shape === "switch") {
const button = doc.createElement("button");
button.type = "button";
button.className = "tera-device__switch";
button.setAttribute("aria-pressed", "false");
button.textContent = ROW_LABEL[capability];
button.addEventListener("click", () => {
const current = latest.get(declaration.id);
const reading = current?.[CAPABILITY_READING[capability]];
// The command is the *negation of what is on screen*, not a blind
// `true`: a toggle that always sends `true` is a button that works
// once. `undefined` — no reading yet — is treated as off, which is
// what `initialDeviceState` says a device at rest is.
send(declaration, {
deviceId: declaration.id,
op: capability === "power" ? "power" : capability === "mute" ? "mute" : "playback",
value: !(reading === true),
});
});
control = button;
} else if (shape === "slider") {
const range = capability === "gain" ? DEVICE_RANGES.gain : DEVICE_RANGES.volume;
const step = capability === "gain" ? SLIDER_STEP.gain : SLIDER_STEP.volume;
const slider = doc.createElement("input");
slider.type = "range";
slider.className = "tera-device__slider";
slider.min = String(range.min);
slider.max = String(range.max);
slider.step = String(step);
slider.value = String(range.initial);
slider.setAttribute("aria-label", `${declaration.label} ${ROW_LABEL[capability]}`);
slider.addEventListener("input", () => {
const parsed = Number(slider.value);
if (!Number.isFinite(parsed)) return;
send(declaration, {
deviceId: declaration.id,
op: capability === "gain" ? "gain" : "volume",
value: parsed,
});
});
control = slider;
input = slider;
} else {
const meter = doc.createElement("div");
meter.className = "tera-device__meter";
meter.setAttribute("role", "meter");
meter.setAttribute("aria-valuemin", String(DEVICE_RANGES.level.min));
meter.setAttribute("aria-valuemax", String(DEVICE_RANGES.level.max));
const fill = doc.createElement("i");
fill.style.width = "0%";
meter.append(fill);
meterFill = fill;
control = meter;
}
row.append(label, control, value);
card.append(row);
rows.push({ capability, shape, root: row, control, input, value, meterFill });
}
// Per instrument, not once per panel. A disclosure at the top of a scrolling
// list is a disclosure you have already scrolled past by the time you are
// looking at the meter that needed it.
const disclosure = doc.createElement("p");
disclosure.className = "tera-device__disclosure";
disclosure.textContent = declaration.disclosure;
card.append(disclosure);
root.append(card);
instruments.push({ declaration, rows });
}
host.append(root);
function apply(states: readonly DeviceState[]): void {
if (disposed) return;
const byId = new Map(states.map((state) => [state.id, state]));
latest.clear();
for (const state of states) latest.set(state.id, state);
for (const instrument of instruments) {
const state = byId.get(instrument.declaration.id) ?? null;
for (const row of instrument.rows) {
const reading = state === null ? undefined : state[CAPABILITY_READING[row.capability]];
// `undefined` is "this device has no such reading", which is a different
// statement from zero and gets a different treatment: the row goes away
// rather than sitting there reporting a confident 0 dB.
row.root.setAttribute("data-unavailable", String(reading === undefined));
row.value.textContent = formatReading(row.capability, reading);
if (row.shape === "switch") {
row.control.setAttribute("aria-pressed", String(reading === true));
} else if (row.shape === "slider") {
if (row.input !== null && typeof reading === "number" && Number.isFinite(reading)) {
row.input.value = String(reading);
}
} else if (row.meterFill !== null) {
const fraction = meterFraction(typeof reading === "number" ? reading : undefined);
row.meterFill.style.width = `${(fraction * 100).toFixed(1)}%`;
row.control.setAttribute(
"aria-valuenow",
typeof reading === "number" ? reading.toFixed(1) : String(DEVICE_RANGES.level.min),
);
}
}
}
}
/**
* Everything starts at rest rather than blank.
*
* `initialDeviceState` is a pure function of the declaration and is the same
* one the simulator and the route start from, so the pre-connection panel is
* not an invented placeholder — it is the honest answer to "what does this
* device report when nobody has observed it": powered off, gain at its resting
* value, meter at the floor, and `synthetic: true` whatever the declaration's
* provenance claims. A panel of dashes would teach nothing about the
* instrument; this teaches the whole of it before a single reading arrives.
*/
apply(options.declarations.map((declaration) => initialDeviceState(declaration, 0)));
return {
root,
apply,
dispose() {
if (disposed) return;
disposed = true;
root.remove();
latest.clear();
if (styleAdded) doc.querySelector(`#${STYLE_ID}`)?.remove();
},
};
}
+319
View File
@@ -0,0 +1,319 @@
/**
* The two readouts that sit *over* the scene rather than beside it: the play HUD
* and the aircraft detail card.
*
* Both were formatting code inlined into `main.ts` — `renderPlayHud` was a
* four-branch `if` chain writing three `textContent`s and toggling a class, and
* the aircraft card did not exist at all: clicking a plane produced the same
* one-line `showDetail(string)` a company marker produced, so the callsign,
* transponder hex, altitude and heading the ADS-B feed had already parsed were
* thrown away one line before they could be shown.
*
* Everything here is a pure formatter plus one small renderer. No THREE, no
* feed, no clock: hand it numbers, get strings. That is what lets the units be
* tested, which matters more than it sounds — a HUD is the one part of an
* interface that is *only* units, and `2.23694` in the wrong place is a bug that
* looks like a working product.
*
* ### The aircraft card is open to everyone
*
* Per the owner's first decision: the signed-out visitor is the audience this is
* designed for, not a degraded tier. Aircraft positions arrive from ADS-B, which
* is an unencrypted broadcast anybody with a forty-dollar receiver can hear —
* there is nothing here an account could grant access to, because nothing is
* withheld from anyone. Gating it would have traded the most legible "this is
* real" moment in the product for a rule with nothing behind it.
*/
import type { ControlMode } from "../play/controlMode.ts";
// ---- Units ---------------------------------------------------------------
const METRES_PER_SECOND_TO_MPH = 2.23694;
const METRES_PER_SECOND_TO_KNOTS = 1.94384;
const METRES_TO_FEET = 3.28084;
/** The sixteen-point compass, so a heading reads as a direction and not a number. */
const COMPASS: readonly string[] = [
"N", "NNE", "NE", "ENE", "E", "ESE", "SE", "SSE",
"S", "SSW", "SW", "WSW", "W", "WNW", "NW", "NNW",
];
/** `47` → `"NE"`. Degrees clockwise from true north; anything is accepted and wrapped. */
export function compassPoint(headingDeg: number): string {
if (!Number.isFinite(headingDeg)) return "—";
const wrapped = ((headingDeg % 360) + 360) % 360;
return COMPASS[Math.round(wrapped / 22.5) % 16] ?? "N";
}
/**
* A latitude or longitude as a person reads it: three decimal places and a
* hemisphere letter rather than a sign.
*
* Three places is about 110 m, which is the right precision for an aircraft
* position that was observed some seconds ago and is being drawn interpolated.
* Six places would be a claim about accuracy the feed never made.
*/
export function formatCoordinate(value: number, axis: "lat" | "lng"): string {
if (!Number.isFinite(value)) return "—";
const hemisphere = axis === "lat" ? (value >= 0 ? "N" : "S") : value >= 0 ? "E" : "W";
return `${Math.abs(value).toFixed(3)}° ${hemisphere}`;
}
// ---- The play HUD --------------------------------------------------------
export interface DrivePlayTelemetry {
kind: "drive";
speedMps: number;
roadName: string;
driveMode: string;
progress: number;
camera: string;
guardrailContact: boolean;
collisionRisk: number;
}
export interface ActorPlayTelemetry {
kind: "actor";
actor: "crow" | "dog" | "humanoid";
speedMps: number;
altitudeM: number;
distanceM: number;
pose: string;
flightEnergy: number;
displayName: string;
atAltitudeBound: boolean;
}
export interface AircraftPlayTelemetry {
kind: "aircraft";
speedMps: number;
altitudeM: number;
flightMode: string;
batteryWh: number;
stalled: boolean;
hardLanding: boolean;
envelopeContact: boolean;
}
export interface WalkPlayTelemetry {
kind: "office-walk";
officeLabel: string;
distanceM: number;
x: number;
z: number;
}
export type PlayTelemetry =
| DrivePlayTelemetry
| ActorPlayTelemetry
| AircraftPlayTelemetry
| WalkPlayTelemetry;
export interface PlayHudView {
/** The mode chip: two syllables, upper-cased by the stylesheet. */
mode: string;
/** The number that matters most in this mode, first. */
primary: string;
/** State and secondary numbers, right-aligned. */
status: string;
/**
* Something is wrong enough to colour.
*
* Deliberately narrow: a guardrail scrape, a stall, an altitude bound. A HUD
* that goes orange for anything interesting goes orange permanently.
*/
warning: boolean;
}
/**
* The HUD for one mode, as three strings and a flag.
*
* Returns `null` for the two orbit modes, which is what the applier keys the
* card's visibility off — one decision in one place, rather than a `hidden =`
* assignment above the branch that computes what to hide.
*/
export function formatPlayHud(telemetry: PlayTelemetry | null): PlayHudView | null {
if (telemetry === null) return null;
if (telemetry.kind === "drive") {
return {
mode: "Drive",
primary: `${Math.round(telemetry.speedMps * METRES_PER_SECOND_TO_MPH)} mph · ${telemetry.roadName}`,
status: `${telemetry.driveMode} · ${Math.round(telemetry.progress * 100)}% · ${telemetry.camera}`,
warning: telemetry.guardrailContact || telemetry.collisionRisk > 0.55,
};
}
if (telemetry.kind === "actor") {
const crow = telemetry.actor === "crow";
return {
mode: crow ? "Crow" : "Explore",
primary: crow
? `${telemetry.speedMps.toFixed(1)} m/s · ${Math.max(0, telemetry.altitudeM).toFixed(0)} m alt`
: `${telemetry.speedMps.toFixed(1)} m/s · ${telemetry.distanceM.toFixed(0)} m travelled`,
status: crow
? `${telemetry.pose} · ${Math.round(telemetry.flightEnergy * 100)}% energy`
: `${telemetry.pose} · ${telemetry.displayName}`,
warning: telemetry.atAltitudeBound,
};
}
if (telemetry.kind === "aircraft") {
return {
mode: "Flight",
primary: `${Math.round(telemetry.speedMps * METRES_PER_SECOND_TO_KNOTS)} kt · ${Math.round(telemetry.altitudeM).toLocaleString()} m`,
status: `${telemetry.flightMode} · ${Math.round(telemetry.batteryWh)} Wh${telemetry.stalled ? " · STALL" : ""}`,
warning: telemetry.stalled || telemetry.hardLanding || telemetry.envelopeContact,
};
}
return {
mode: "Studio",
primary: `${telemetry.officeLabel} · ${telemetry.distanceM.toFixed(0)} m walked`,
status: `${telemetry.x.toFixed(1)}, ${telemetry.z.toFixed(1)} m`,
warning: false,
};
}
/** Which telemetry kind a control mode expects, or `null` for the orbit modes. */
export function playHudKindFor(mode: ControlMode): PlayTelemetry["kind"] | null {
if (mode === "drive" || mode === "actor" || mode === "aircraft") return mode;
if (mode === "office-walk") return "office-walk";
return null;
}
// ---- The aircraft detail card --------------------------------------------
/**
* One aircraft, as much of it as the feed knew.
*
* Structurally the engine's `Aircraft` plus two facts the engine has and the
* card needs: whether the track was invented, and what the feed asks to be
* credited as. Restated rather than imported because `engine/types.ts` pulls the
* renderer's whole vocabulary behind it and this module must stay importable in
* a test with no THREE.
*/
export interface AircraftDetailInput {
/**
* The ADS-B transponder address, lower-case hex, for a real track — `"a1b2c3"`.
* A simulated one carries its own id and is labelled as simulated instead.
*/
id: string;
callsign?: string | null;
lat: number;
lng: number;
/** Barometric altitude, metres. */
altitude: number;
/** Degrees clockwise from true north. */
heading: number;
/** The track came out of the bundled simulator, not out of a receiver. */
synthetic?: boolean;
/** The feed's own credit line, shown on the card that displays its data. */
attribution?: string | null;
}
export interface AircraftDetailRow {
label: string;
value: string;
}
export interface AircraftDetailView {
/** The callsign if the aircraft broadcast one, else its hex. Never empty. */
title: string;
/** "Simulated track" or the transponder address, under the title. */
subtitle: string;
rows: readonly AircraftDetailRow[];
/** True when nothing here was observed. The card says so rather than implying it. */
synthetic: boolean;
attribution: string | null;
}
/**
* Format one aircraft for the card.
*
* Every value is stated in both the unit the feed reports and the unit aviation
* uses, because the two audiences for this card are "someone who wants to see a
* real plane" and "someone who knows what FL350 means", and neither is served by
* the other's unit alone.
*/
export function formatAircraftDetail(aircraft: AircraftDetailInput): AircraftDetailView {
const callsign = (aircraft.callsign ?? "").trim();
const hex = aircraft.id.trim();
const synthetic = aircraft.synthetic === true;
const feet = Number.isFinite(aircraft.altitude) ? aircraft.altitude * METRES_TO_FEET : Number.NaN;
const rows: AircraftDetailRow[] = [
{
label: "Altitude",
value: Number.isFinite(feet)
? `${Math.round(feet).toLocaleString()} ft · ${Math.round(aircraft.altitude).toLocaleString()} m`
: "—",
},
{
label: "Heading",
value: Number.isFinite(aircraft.heading)
? `${Math.round(((aircraft.heading % 360) + 360) % 360)}° ${compassPoint(aircraft.heading)}`
: "—",
},
{
label: "Position",
value: `${formatCoordinate(aircraft.lat, "lat")} · ${formatCoordinate(aircraft.lng, "lng")}`,
},
];
return {
title: callsign !== "" ? callsign : hex !== "" ? hex.toUpperCase() : "Unknown aircraft",
subtitle: synthetic
? "Simulated track · no receiver involved"
: hex !== ""
? `Mode S ${hex.toUpperCase()}`
: "No transponder address reported",
rows,
synthetic,
attribution: aircraft.attribution ?? null,
};
}
/**
* Draw the card into a host element.
*
* The host is `#detail-text` — the same slot a company marker's one-liner uses —
* because the rail already solves the hard parts of putting a card over a 3D
* scene on both layouts: it is safe-area inset, it is full width on a phone, and
* it already has a dismiss the thumb can find. A second card type would have had
* to solve all three again.
*/
export function renderAircraftDetail(
host: HTMLElement,
view: AircraftDetailView,
): void {
const doc = host.ownerDocument;
const root = doc.createElement("div");
root.className = "aircraft-card";
const title = doc.createElement("p");
title.className = "aircraft-card__title";
title.textContent = view.title;
const subtitle = doc.createElement("p");
subtitle.className = "aircraft-card__subtitle t-caps";
subtitle.textContent = view.subtitle;
const list = doc.createElement("dl");
list.className = "aircraft-card__rows";
for (const row of view.rows) {
const term = doc.createElement("dt");
term.textContent = row.label;
const value = doc.createElement("dd");
value.textContent = row.value;
list.append(term, value);
}
root.append(title, subtitle, list);
if (view.attribution !== null && view.attribution !== "") {
const credit = doc.createElement("p");
credit.className = "aircraft-card__credit";
credit.textContent = view.attribution;
root.append(credit);
}
host.replaceChildren(root);
}
+750
View File
@@ -0,0 +1,750 @@
/**
* The only thing in this product that writes to the page.
*
* `chromeState.ts` decides; this applies. The split is the whole point of both
* files: before them, `main.ts` held about forty `element.hidden = someCondition`
* lines with the condition and the write on the same line, so there was no way to
* assert a decision without a browser and no way to change a write without
* reading the decision. Here the decision arrives as a plain object and every
* DOM mutation in the product is in this file, in one pass, in reading order.
*
* `integration` makes one call — `chrome.apply(chromeState(inputs))` — where it
* used to make forty.
*
* ### What this owns, and what it deliberately does not
*
* It owns the chrome: the left column, the top-right stack, the rail, the dock,
* the play HUD, the touch surface, the shortcuts dialog, the onboarding coach and
* the device panel. It mounts the last three, because all three are *chrome that
* appears and disappears* and something has to own their lifetimes.
*
* It does not own the canvas, the scene, the boot card, the profile editor or the
* office-screen panel. The boot card in particular stays exactly where it is —
* inline in `index.html`, painting before a single module has been fetched, which
* is the only reason the page is not blank for the ~2.3 s it takes to build the
* Bay Area heightfield.
*
* ### Idempotence
*
* `apply` is called on every legend render and may be called on every frame. Text
* is compared before it is written, and the two lists are rebuilt only when their
* signature changes, because `replaceChildren` on the chapter list every frame
* would destroy focus and restart every transition on it. Attribute writes are
* left unconditional: they are cheap, and the browser already elides a write of
* the value that is there.
*/
import { PointerStick } from "../input/pointerStick.ts";
import type { PlayDigitalControl, PlayEdgeControl } from "../input/play.ts";
import type { ControlMode } from "../play/controlMode.ts";
import type { DeviceCommand, DeviceState } from "../devices/types.ts";
import type { ChromeLayout, ChromeState } from "./chromeState.ts";
import { BREAKPOINT } from "./tokens.ts";
import { mountDevicePanel } from "./devicePanel.ts";
import type { DevicePanelHandle } from "./devicePanel.ts";
import { renderAircraftDetail } from "./hud.ts";
import { browserStorage, mountOnboarding } from "./onboarding.ts";
import type { OnboardingHandle, OnboardingStorage } from "./onboarding.ts";
import { renderShortcutSheet } from "./shortcuts.ts";
import type { ShortcutSheetHandles } from "./shortcuts.ts";
// ---- Handlers -------------------------------------------------------------
/**
* Everything the chrome can ask the application to do.
*
* Every member is optional, and that is deliberate rather than lax: the
* `integration` workstream adopts this file by moving its call sites over a few
* at a time, and a required member would mean the first move needs all of them.
* An unwired control is inert, which is the correct behaviour for one — nothing
* here throws because a handler is missing.
*/
export interface ChromeHandlers {
onSelectBoard?(id: string): void;
onSelectView?(id: string, index: number): void;
onEnter?(): void;
onWalk?(): void;
onFly?(): void;
onScreens?(): void;
onDevices?(): void;
onDeviceCommand?(command: DeviceCommand): void;
onMode?(mode: ControlMode): void;
/** The `☰` button. The argument is the state being moved *to*. */
onTogglePanel?(open: boolean): void;
/** The rail's plan button and the touch pad's Map button. */
onTogglePlan?(open: boolean): void;
onShortcuts?(open: boolean): void;
onDismissDetail?(): void;
onCharacter?(): void;
/** The phone's scrim behind the panel sheet. */
onScrim?(): void;
/** A held touch control went down or up. */
onTouchHold?(control: PlayDigitalControl, pressed: boolean): void;
/** A one-shot touch control fired. */
onTouchEdge?(edge: PlayEdgeControl): void;
/** The analogue stick moved, or was released (`null`). */
onStick?(axes: { moveX: number; moveY: number } | null): void;
onOnboardingFinished?(reason: "finished" | "skipped"): void;
/**
* The viewport crossed one of the two published breakpoints.
*
* `resize` already re-seeded the plan and, for no stated reason, not the
* panel — so rotating a tablet from portrait to landscape left the panel
* closed over a layout where it is furniture and there is no toggle to reopen
* it with. A `matchMedia` change listener is the right shape for this anyway:
* it fires once per crossing rather than on every pixel of a drag, and it is
* the same 600/900 pair `tokens.ts` publishes and `deviceProfile()` keys off.
*/
onLayoutChange?(layout: ChromeLayout): void;
}
export interface ChromeHandle {
apply(state: ChromeState): void;
/** Live device readings, which arrive on their own timer rather than per frame. */
applyDeviceStates(states: readonly DeviceState[]): void;
openShortcuts(): void;
closeShortcuts(): void;
shortcutsOpen(): boolean;
dispose(): void;
}
export interface ChromeMountOptions extends ChromeHandlers {
/** First-run storage. Defaults to `localStorage`, guarded; pass a fake in a test. */
storage?: OnboardingStorage | null;
}
// ---- Small helpers --------------------------------------------------------
function setText(element: Element | null, text: string): void {
if (element === null) return;
if (element.textContent !== text) element.textContent = text;
}
function setHidden(element: HTMLElement | null, hidden: boolean): void {
if (element === null) return;
element.hidden = hidden;
}
function setPressed(element: Element | null, pressed: boolean): void {
element?.setAttribute("aria-pressed", String(pressed));
}
/** The held control a touch button feeds, by its DOM id. Mirrors `chromeState`. */
const TOUCH_HOLDS: Readonly<Record<string, PlayDigitalControl>> = {
"touch-primary": "primary",
"touch-secondary": "secondary",
"touch-pitch-up": "pitch-up",
"touch-pitch-down": "pitch-down",
};
const TOUCH_EDGES: Readonly<Record<string, PlayEdgeControl>> = {
"touch-assist": "assist",
"touch-reset": "reset",
"touch-camera": "camera",
};
// ---- Mount ----------------------------------------------------------------
/**
* Bind the chrome to a document (or to any subtree, which is what makes this
* testable against a fake DOM) and return the applier.
*/
export function mountChrome(
root: Document | HTMLElement,
handlers: ChromeMountOptions,
): ChromeHandle {
const doc: Document =
"createElement" in root && typeof (root as Document).createElement === "function"
? (root as Document)
: ((root as HTMLElement).ownerDocument as Document);
const scope: ParentNode = root;
const body: HTMLElement | null = doc.body ?? null;
const find = <T extends HTMLElement>(id: string): T | null =>
(scope.querySelector(`#${id}`) as T | null) ?? null;
// ---- The elements, resolved once ---------------------------------------
const panel = find("panel");
const panelToggle = find<HTMLButtonElement>("panel-toggle");
const panelToggleLabel = find("panel-toggle-label");
const scrim = find("scrim");
const title = find("title");
const subtitle = find("subtitle");
const clock = find("clock");
const boardNav = find("cities");
const boardTitle = find("boards-title");
const enterButton = find<HTMLButtonElement>("enter");
const walkButton = find<HTMLButtonElement>("walk");
const flyButton = find<HTMLButtonElement>("fly");
const screensButton = find<HTMLButtonElement>("screens");
const devicesButton = find<HTMLButtonElement>("devices");
const deviceSection = find("device-section");
const deviceHost = find("device-host");
const officeInvite = find("office-invite");
const officeNote = find("office-note");
const chapterNav = find("chapters");
const blurb = find("blurb");
const tier = find("tier");
const tierLabel = find("tier-label");
const tierWho = find("tier-who");
const tierSignIn = find<HTMLAnchorElement>("tier-signin");
const tierCharacter = find<HTMLButtonElement>("tier-character");
const tierAdds = find("tier-adds");
const corner = find("corner");
const minimapReadout = find("minimap-readout");
const modeDock = find("mode-dock");
const playHud = find("play-hud");
const playHudMode = find("play-hud-mode");
const playHudPrimary = find("play-hud-primary");
const playHudStatus = find("play-hud-status");
const rail = find("rail");
const detail = find("detail");
const detailText = find("detail-text");
const detailClose = find<HTMLButtonElement>("detail-close");
const hint = find("hint");
const planToggle = find<HTMLButtonElement>("plan-toggle");
const helpButton = find<HTMLButtonElement>("help");
const touchControls = find("touch-play-controls");
const stick = find("play-stick");
const stickKnob = find("play-stick-knob");
const stickLabel = find("play-stick-label");
const touchMap = find<HTMLButtonElement>("touch-map");
const source = find("source");
const shortcuts = find("shortcuts");
const shortcutsBody = find("shortcuts-body");
const shortcutsClose = find<HTMLButtonElement>("shortcuts-close");
const onboardingHost = find("onboarding-host") ?? body;
// ---- Mutable bits ------------------------------------------------------
let latest: ChromeState | null = null;
let boardSignature = "";
let viewSignature = "";
let deviceSignature = "";
/**
* Four more signatures, for the four rebuilds that are not lists of buttons.
*
* The header's idempotence claim covers "the two lists"; these were rebuilt
* unconditionally, which was survivable while `apply` ran once per legend
* render and is not now that `integration` drives it from the frame pump in
* every play mode. Each of the four ends in `replaceChildren`, so an
* unguarded rebuild at 60 Hz discards and re-allocates the whole subtree
* sixty times a second — and for the `?` sheet that is about thirty rows of
* `<dt>`/`<dd>` for content that changes when the tier does, i.e. never.
*/
let hintSignature = "";
let inviteSignature = "";
let detailSignature = "";
let sheetSignature = "";
let devicePanel: DevicePanelHandle | null = null;
let onboarding: OnboardingHandle | null = null;
let sheet: ShortcutSheetHandles | null = null;
let appliedBodyClasses: readonly string[] = [];
let disposed = false;
const cleanups: (() => void)[] = [];
function on(
target: EventTarget | null,
type: string,
listener: (event: Event) => void,
): void {
if (target === null) return;
target.addEventListener(type, listener);
cleanups.push(() => target.removeEventListener(type, listener));
}
// ---- Static wiring -----------------------------------------------------
on(panelToggle, "click", () => {
handlers.onTogglePanel?.(!(latest?.panelOpen ?? true));
});
on(scrim, "click", () => {
handlers.onScrim?.();
handlers.onTogglePanel?.(false);
});
on(enterButton, "click", () => handlers.onEnter?.());
on(walkButton, "click", () => handlers.onWalk?.());
on(flyButton, "click", () => handlers.onFly?.());
on(screensButton, "click", () => handlers.onScreens?.());
on(devicesButton, "click", () => handlers.onDevices?.());
on(detailClose, "click", () => handlers.onDismissDetail?.());
on(tierCharacter, "click", () => handlers.onCharacter?.());
on(planToggle, "click", () => handlers.onTogglePlan?.(!(latest?.planOpen ?? true)));
on(touchMap, "click", () => handlers.onTogglePlan?.(!(latest?.planOpen ?? true)));
on(helpButton, "click", () => (shortcutsOpen() ? closeShortcuts() : openShortcuts()));
on(shortcutsClose, "click", () => closeShortcuts());
on(shortcuts, "click", (event) => {
// The backdrop, not the sheet. A click on the card itself must not close it.
if (event.target === shortcuts) closeShortcuts();
});
on(modeDock, "click", (event) => {
const target = event.target;
if (!(target !== null && typeof target === "object" && "closest" in target)) return;
const button = (target as Element).closest("[data-control-mode]");
const mode = button?.getAttribute("data-control-mode");
if (mode !== null && mode !== undefined) handlers.onMode?.(mode as ControlMode);
});
on(boardNav, "click", (event) => {
const target = event.target;
if (!(target !== null && typeof target === "object" && "closest" in target)) return;
const id = (target as Element).closest("[data-board]")?.getAttribute("data-board");
if (id !== null && id !== undefined) handlers.onSelectBoard?.(id);
});
on(chapterNav, "click", (event) => {
const target = event.target;
if (!(target !== null && typeof target === "object" && "closest" in target)) return;
const button = (target as Element).closest("[data-view]");
const id = button?.getAttribute("data-view");
const index = Number(button?.getAttribute("data-view-index") ?? "-1");
if (id !== null && id !== undefined && index >= 0) handlers.onSelectView?.(id, index);
});
// ---- The touch action pad ----------------------------------------------
//
// Held buttons are bound on the pointer rather than on click, because a click
// is a press *and* a release and a sprint button that fires both at once
// sprints for zero milliseconds. Capture means a finger that slides off the
// button still releases it, which is the failure that leaves a control stuck
// on until the next press.
for (const [id, control] of Object.entries(TOUCH_HOLDS)) {
const button = find<HTMLButtonElement>(id);
if (button === null) continue;
const release = (event: Event) => {
setPressed(button, false);
handlers.onTouchHold?.(control, false);
event.preventDefault();
};
on(button, "pointerdown", (event) => {
const pointer = event as PointerEvent;
button.setPointerCapture?.(pointer.pointerId);
setPressed(button, true);
handlers.onTouchHold?.(control, true);
event.preventDefault();
});
on(button, "pointerup", release);
on(button, "pointercancel", release);
on(button, "lostpointercapture", release);
}
for (const [id, edge] of Object.entries(TOUCH_EDGES)) {
const button = find<HTMLButtonElement>(id);
on(button, "click", () => handlers.onTouchEdge?.(edge));
}
// ---- The analogue stick -------------------------------------------------
//
// `input/pointerStick.ts` has held a real analogue stick since the play modes
// landed and `main.ts` was the only thing that ever drove it. Moving the
// binding here is what lets the stick be shown by a state decision rather than
// by an imperative branch, which is the whole reason a phone in VIEW mode used
// to have no visible way to move.
// ---- Breakpoint crossings ----------------------------------------------
//
// Guarded, because `matchMedia` does not exist in a test document and this
// module has to stay mountable against one.
const media = (globalThis as { matchMedia?: (query: string) => MediaQueryList }).matchMedia;
if (typeof media === "function" && handlers.onLayoutChange !== undefined) {
const phone = media.call(globalThis, `(max-width: ${BREAKPOINT.phone}px)`);
const compact = media.call(globalThis, `(max-width: ${BREAKPOINT.compact}px)`);
const announce = (): void => {
handlers.onLayoutChange?.(phone.matches ? "phone" : compact.matches ? "compact" : "desktop");
};
for (const query of [phone, compact]) {
query.addEventListener?.("change", announce);
cleanups.push(() => query.removeEventListener?.("change", announce));
}
}
const pointerStick = new PointerStick();
function paintKnob(axes: { moveX: number; moveY: number } | null): void {
if (stickKnob === null) return;
const radius = 26;
const x = axes === null ? 0 : axes.moveX * radius;
const y = axes === null ? 0 : -axes.moveY * radius;
stickKnob.style.setProperty("--stick-x", `${x.toFixed(1)}px`);
stickKnob.style.setProperty("--stick-y", `${y.toFixed(1)}px`);
}
if (stick !== null) {
on(stick, "pointerdown", (event) => {
const pointer = event as PointerEvent;
const rect = stick.getBoundingClientRect();
const axes = pointerStick.begin(pointer.pointerId, pointer.clientX, pointer.clientY, {
left: rect.left,
top: rect.top,
width: rect.width,
height: rect.height,
});
if (axes === null) return;
stick.setPointerCapture?.(pointer.pointerId);
stick.classList.add("active");
paintKnob(axes);
handlers.onStick?.(axes);
event.preventDefault();
});
on(stick, "pointermove", (event) => {
const pointer = event as PointerEvent;
const axes = pointerStick.move(pointer.pointerId, pointer.clientX, pointer.clientY);
if (axes === null) return;
paintKnob(axes);
handlers.onStick?.(axes);
event.preventDefault();
});
const end = (event: Event) => {
const pointer = event as PointerEvent;
if (!pointerStick.end(pointer.pointerId)) return;
stick.classList.remove("active");
paintKnob(null);
handlers.onStick?.(null);
};
on(stick, "pointerup", end);
on(stick, "pointercancel", end);
on(stick, "lostpointercapture", end);
}
// ---- The shortcuts dialog ----------------------------------------------
function shortcutsOpen(): boolean {
return shortcuts !== null && shortcuts.hidden === false;
}
function openShortcuts(): void {
if (shortcuts === null || shortcutsOpen()) return;
shortcuts.hidden = false;
shortcutsClose?.focus?.();
handlers.onShortcuts?.(true);
}
function closeShortcuts(): void {
if (shortcuts === null || !shortcutsOpen()) return;
shortcuts.hidden = true;
helpButton?.focus?.();
handlers.onShortcuts?.(false);
}
// ---- apply --------------------------------------------------------------
function apply(state: ChromeState): void {
if (disposed) return;
latest = state;
// ---- <body> classes, as one diff ----
if (body !== null) {
for (const name of appliedBodyClasses) {
if (!state.bodyClasses.includes(name)) body.classList.remove(name);
}
for (const name of state.bodyClasses) body.classList.add(name);
appliedBodyClasses = state.bodyClasses;
}
// ---- Left column ----
setHidden(panel, !state.panelVisible);
setHidden(panelToggle, !state.panelToggleVisible);
panelToggle?.setAttribute("aria-expanded", String(state.panelOpen));
setText(panelToggleLabel, state.panelToggleLabel);
setHidden(scrim, !state.scrimVisible);
setText(title, state.panelTitle);
setText(subtitle, state.panelSubtitle);
setText(clock, state.clockLabel);
setText(boardTitle, state.boardPicker === "office" ? "Studios" : "Boards");
const nextBoardSignature = state.boards
.map((board) => `${board.id}:${board.label}:${board.status ?? ""}:${board.active}`)
.join("|");
if (boardNav !== null && nextBoardSignature !== boardSignature) {
boardSignature = nextBoardSignature;
boardNav.replaceChildren(
...state.boards.map((board) => {
const button = doc.createElement("button");
button.type = "button";
// `aria-pressed` rather than a class: these buttons choose exactly one
// active board, and the stylesheet keys off the attribute so the
// visual state and the announced state cannot drift apart.
button.className = board.status ? "board board--office" : "board";
button.setAttribute("data-board", board.id);
button.setAttribute("aria-pressed", String(board.active));
const name = doc.createElement("span");
name.className = "board__name";
name.textContent = board.label;
button.append(name);
if (board.status) {
const status = doc.createElement("span");
status.className = "board__status t-caps";
status.textContent = board.status;
button.append(status);
}
return button;
}),
);
}
setHidden(enterButton, !state.enterVisible);
setText(enterButton, state.enterLabel);
setHidden(walkButton, !state.walkVisible);
setPressed(walkButton, state.walkPressed);
setText(walkButton, state.walkLabel);
setHidden(flyButton, !state.flyVisible);
setPressed(flyButton, state.flyPressed);
setText(flyButton, state.flyLabel);
setHidden(screensButton, !state.screensVisible);
setText(screensButton, state.screensLabel);
setHidden(devicesButton, !state.devicePanelVisible);
setText(devicesButton, state.devicePanelLabel);
setHidden(officeInvite, !state.officeInviteVisible);
const nextInviteSignature = state.officeInviteVisible
? `${state.officeInviteText}|${state.officeInviteLinkLabel ?? ""}|${state.signInHref ?? ""}`
: "";
if (state.officeInviteVisible && officeInvite !== null && nextInviteSignature !== inviteSignature) {
inviteSignature = nextInviteSignature;
officeInvite.replaceChildren(doc.createTextNode(`${state.officeInviteText} `));
if (state.officeInviteLinkLabel !== null && state.signInHref !== null) {
const link = doc.createElement("a");
link.href = state.signInHref;
link.textContent = state.officeInviteLinkLabel;
officeInvite.append(link, doc.createTextNode("."));
}
} else if (!state.officeInviteVisible) {
inviteSignature = "";
}
setHidden(officeNote, !state.officeNoteVisible);
setText(officeNote, state.officeNoteText);
setHidden(chapterNav, !state.chaptersVisible);
const nextViewSignature = state.views
.map((view) => `${view.id}:${view.number}:${view.label}:${view.active}`)
.join("|");
if (chapterNav !== null && nextViewSignature !== viewSignature) {
viewSignature = nextViewSignature;
chapterNav.replaceChildren(
...state.views.map((view, index) => {
const button = doc.createElement("button");
button.type = "button";
button.className = "chapter";
button.setAttribute("data-view", view.id);
button.setAttribute("data-view-index", String(index));
button.setAttribute("aria-pressed", String(view.active));
const number = doc.createElement("span");
number.className = "num";
number.textContent = view.number;
const label = doc.createElement("span");
label.textContent = view.label;
button.append(number, label);
return button;
}),
);
}
setHidden(blurb, !state.blurbVisible);
setText(blurb, state.blurbText);
// ---- The device panel ----
//
// Rebuilt only when the *set of declarations* changes, which is once per
// office. Readings arrive through `applyDeviceStates` on the feed's own
// timer, because a mixing desk that rebuilt its sliders every frame would
// throw away the drag in progress on one of them.
setHidden(deviceSection, !state.devicePanelVisible);
const nextDeviceSignature = state.deviceDeclarations.map((d) => d.id).join("|");
if (nextDeviceSignature !== deviceSignature) {
deviceSignature = nextDeviceSignature;
devicePanel?.dispose();
devicePanel = null;
if (deviceHost !== null && state.deviceDeclarations.length > 0) {
devicePanel = mountDevicePanel(deviceHost, {
declarations: state.deviceDeclarations,
onCommand: (command) => handlers.onDeviceCommand?.(command),
});
}
}
// ---- Top right ----
setHidden(tier, !state.tierVisible);
tier?.setAttribute("data-tier", state.tierTone);
setText(tierLabel, state.tierLabel);
setText(tierWho, state.whoLabel ?? "");
setHidden(tierWho, state.whoLabel === null);
setHidden(tierCharacter, !state.characterVisible);
setHidden(tierSignIn, !state.signInVisible);
if (tierSignIn !== null && state.signInHref !== null) {
tierSignIn.href = state.signInHref;
setText(tierSignIn, state.signInLabel);
}
setHidden(tierAdds, state.tierAdds === null);
setText(tierAdds, state.tierAdds ?? "");
setHidden(corner, !state.minimapVisible);
setHidden(minimapReadout, !state.minimapReadoutVisible);
// ---- The dock ----
setHidden(modeDock, !state.modeDockVisible);
for (const button of modeDock?.querySelectorAll<HTMLButtonElement>("[data-control-mode]") ??
[]) {
const mode = button.getAttribute("data-control-mode");
const entry = state.modeButtons.find((candidate) => candidate.mode === mode);
button.hidden = entry === undefined || !entry.visible;
button.setAttribute("aria-pressed", String(entry?.pressed === true));
}
// ---- The play HUD ----
setHidden(playHud, !state.playHudVisible);
if (state.playHud !== null) {
setText(playHudMode, state.playHud.mode);
setText(playHudPrimary, state.playHud.primary);
setText(playHudStatus, state.playHud.status);
playHudStatus?.classList.toggle("warning", state.playHud.warning);
}
// ---- The rail ----
setHidden(rail, !state.railVisible);
setHidden(detail, !state.detailVisible);
if (state.detail !== null && detailText !== null) {
if (state.detail.kind === "text") {
// `setText` is already a compare-then-write, so the text card needs no
// signature of its own; the aircraft card is a subtree and does.
detailSignature = "";
setText(detailText, state.detail.text);
} else {
const view = state.detail.view;
const nextDetailSignature = [
view.title,
view.subtitle,
view.attribution ?? "",
...view.rows.map((row) => `${row.label}=${row.value}`),
].join("|");
if (nextDetailSignature !== detailSignature) {
detailSignature = nextDetailSignature;
renderAircraftDetail(detailText, view);
}
}
} else {
detailSignature = "";
}
setHidden(helpButton, !state.helpVisible);
setText(helpButton, state.touchFirst ? "Guide" : "?");
setHidden(planToggle, !state.planToggleVisible);
setPressed(planToggle, state.planTogglePressed);
setPressed(touchMap, state.planTogglePressed);
setHidden(hint, !state.railHintsVisible);
const nextHintSignature = state.railHintsVisible
? state.railHints.map((entry) => `${entry.id}:${entry.keys.join("+")}:${entry.label}`).join("|")
: "";
if (hint !== null && state.railHintsVisible && nextHintSignature !== hintSignature) {
hintSignature = nextHintSignature;
hint.replaceChildren(
...state.railHints.map((entry) => {
const chip = doc.createElement("span");
chip.className = "hint__chip";
for (const key of entry.keys) {
if (key === "…") {
chip.append(doc.createTextNode(""));
continue;
}
const cap = doc.createElement("kbd");
cap.textContent = key;
chip.append(cap);
}
const label = doc.createElement("span");
label.className = "hint__label";
// The rail is one line over a photograph. The full sentence lives in
// the `?` sheet; this is the clause before the first dash.
label.textContent = entry.label.split(" — ")[0] ?? entry.label;
chip.append(label);
return chip;
}),
);
}
// ---- Touch ----
setHidden(touchControls, !state.touchControlsVisible);
setHidden(stick, !state.stickVisible);
setText(stickLabel, state.stickLabel);
stick?.setAttribute("aria-label", `${state.stickLabel} joystick. Drag in any direction.`);
for (const action of state.touchActions) {
const button = find<HTMLButtonElement>(action.id);
if (button === null) continue;
button.hidden = !action.visible;
setText(button, action.label);
}
// ---- Honesty ----
setHidden(source, !state.sourceVisible);
setText(source, state.sourceLabel);
source?.classList.toggle("live", state.sourceLive);
// ---- The `?` sheet, rebuilt from KEYMAP ----
if (shortcutsBody !== null) {
const inputs = {
coarsePointer: state.touchFirst,
inside: state.inside,
god: state.tierTone === "god",
degraded: state.degradedVisible ? state.degradedLines : [],
credits: state.credits,
};
// Everything the sheet draws is a function of these five, and four of them
// change at most a handful of times in a session. Without the guard the
// whole sheet — about thirty rows — is rebuilt on every frame of every
// play mode for a card that is usually not even on screen.
const nextSheetSignature = [
inputs.coarsePointer,
inputs.inside,
inputs.god,
inputs.degraded.join("|"),
inputs.credits.join("|"),
].join("~");
if (sheet === null) {
sheetSignature = nextSheetSignature;
sheet = renderShortcutSheet(doc, inputs);
shortcutsBody.replaceChildren(sheet.root);
} else if (nextSheetSignature !== sheetSignature) {
sheetSignature = nextSheetSignature;
sheet.apply(inputs);
}
}
// ---- Onboarding ----
if (state.onboardingVisible && onboarding === null && onboardingHost !== null) {
onboarding = mountOnboarding(onboardingHost, {
coarsePointer: state.touchFirst,
storage: handlers.storage === undefined ? browserStorage() : handlers.storage,
onFinish: (reason) => handlers.onOnboardingFinished?.(reason),
});
} else if (!state.onboardingVisible && onboarding !== null) {
onboarding.dispose();
onboarding = null;
}
// ---- The canvas's own description ----
scope.querySelector("#scene")?.setAttribute("aria-label", state.canvasLabel);
}
return {
apply,
applyDeviceStates(states) {
devicePanel?.apply(states);
},
openShortcuts,
closeShortcuts,
shortcutsOpen,
dispose() {
if (disposed) return;
disposed = true;
for (const cleanup of cleanups) cleanup();
cleanups.length = 0;
devicePanel?.dispose();
devicePanel = null;
onboarding?.dispose();
onboarding = null;
sheet = null;
if (body !== null) {
for (const name of appliedBodyClasses) body.classList.remove(name);
}
appliedBodyClasses = [];
},
};
}
+464
View File
@@ -0,0 +1,464 @@
/**
* 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();
},
};
}
+666
View File
@@ -0,0 +1,666 @@
/**
* Every way there is to control this thing, in one table.
*
* Before this file there were three lists and they disagreed. The key handler in
* `main.ts` had a `switch` of ten letters; the `?` card in `index.html` had a
* hand-typed `<dl>`; and `tools/godmode.ts` spliced an eleventh row into that
* `<dl>` at runtime. The three had drifted in exactly the ways three copies
* always drift:
*
* - **`G` was bound twice.** The play handler maps it to `secondary`, which is
* the crow's glide. Godmode also took it, in the capture phase, with
* `preventDefault()` — so for the one visitor who has godmode, glide silently
* did nothing, and for everybody else the `?` card gained a row advertising a
* panel they cannot open. (The half of that fix which is not in this file: the
* godmode hotkey has moved to the backtick. See `tools/godmode.ts`.)
* - **`Space` was documented as "Handbrake while driving"** and is in fact the
* generic `primary` in every mode — sprint on foot, climb as a crow, throttle
* in the aircraft. A keyboard reference that is right in one mode out of four
* is worse than none, because it is believed.
* - **The card never mentioned anything that was not a key**: not the mode dock,
* not the panel sheet, not the Walk / Fly / Character / Office-screens buttons,
* not the joystick. On a phone — where the whole card is `display: none` — that
* meant the product had no reference at all.
*
* So: one `KEYMAP`, and everything else is derived from it. `controlForKey` reads
* it, the `?` sheet is rendered from it, the rail's contextual hint is selected
* from it, and `src/test/ui/shortcuts.test.ts` asserts there are no orphans in
* either direction and no key with two meanings. Adding a binding is one row.
*
* This module holds no DOM state and imports no renderer. `renderShortcutSheet`
* takes a `Document` and returns an element; everything else is pure.
*/
import type { PlayDigitalControl, PlayEdgeControl } from "../input/play.ts";
import type { ControlMode } from "../play/controlMode.ts";
// ---- The vocabulary -------------------------------------------------------
/**
* Where a row is drawn in the `?` sheet.
*
* Grouped by *what you are trying to do*, not by input device, which is why
* `touch` and `pointer` are not sections of their own for the movement rows: the
* joystick and `WASD` are the same intent and belong next to each other. The two
* device-shaped sections that remain are the gestures that genuinely have no
* keyboard equivalent.
*/
export type ShortcutSection = "move" | "world" | "view" | "screen" | "gesture" | "instrument";
export const SHORTCUT_SECTIONS: readonly ShortcutSection[] = [
"move",
"world",
"view",
"screen",
"gesture",
"instrument",
];
export const SECTION_TITLES: Readonly<Record<ShortcutSection, string>> = {
move: "Moving",
world: "The world",
view: "The view",
screen: "On screen",
gesture: "Touch and pointer",
instrument: "Instruments",
};
/**
* When a row is true.
*
* `always` rows are drawn in every state. The rest are drawn always in the sheet
* — a reference that hides what you are not currently doing cannot teach you
* that it exists — but they are what the *rail hint* selects on, and what a
* future contextual coach would select on.
*/
export type ShortcutScope =
| "always"
| "city"
| "office"
| "play"
| "drive"
| "fly"
| "crow"
| "god";
export interface Shortcut {
/** Stable, and the DOM id of the row: `shortcut-<id>`. */
id: string;
/**
* The keys as they are printed on a keycap, in press order. Empty for a row
* that is a gesture or an on-screen control rather than a key.
*/
keys: readonly string[];
/** For a row with no key: the gesture or control, named as the person sees it. */
gesture?: string;
/** What it does, in one clause, true in every mode the scope admits. */
meaning: string;
section: ShortcutSection;
scope: ShortcutScope;
/**
* The held play control this key feeds, when it feeds one. This is what makes
* the table the *source* of `controlForKey` rather than a description of it.
*/
control?: PlayDigitalControl;
/** The one-shot request this key fires, when it fires one. */
edge?: PlayEdgeControl;
/**
* The on-screen control that does the same thing without a keyboard.
*
* Present on every row a phone needs, and its presence is what puts the row in
* the phone's variant of the sheet. A reference card on a device with no
* keyboard should list what that device can actually press.
*/
onScreen?: string;
}
// ---- The table ------------------------------------------------------------
/**
* The single source of truth.
*
* Order is the reading order of the `?` sheet within each section, so the most
* used thing in a section comes first.
*/
export const KEYMAP: readonly Shortcut[] = [
// ---- Moving -------------------------------------------------------------
{
id: "move",
keys: ["W", "A", "S", "D"],
meaning: "Move: drive, walk, or fly whatever you are controlling",
section: "move",
scope: "play",
control: "forward",
onScreen: "the joystick, lower left",
},
{
id: "primary",
keys: ["Space"],
/**
* Named as the *role* and not as one mode's version of it. This row read
* "Handbrake while driving" for as long as the card existed, which is true
* in one of the four modes it fires in.
*/
meaning: "The primary action of whatever you are controlling — sprint, handbrake, throttle, climb",
section: "move",
scope: "play",
control: "primary",
onScreen: "the large action button, lower right",
},
{
id: "altitude",
keys: ["Q", "E"],
meaning: "Descend / climb, flying as a crow or an aircraft",
section: "move",
scope: "fly",
control: "ascend",
onScreen: "the joystick's action pad while flying",
},
{
id: "pitch",
keys: ["I", "K"],
meaning: "Pitch the nose up / down while flying",
section: "move",
scope: "fly",
control: "pitch-up",
onScreen: "Pitch + / Pitch on the action pad",
},
{
id: "glide",
/**
* The row that did not exist. `G` has been bound to the crow's glide since
* the actor landed and the card has never said so — because godmode had
* quietly taken the same letter, and the row godmode inserted said the
* opposite thing.
*/
keys: ["G"],
meaning: "Glide — stop beating and hold the air, as a crow",
section: "move",
scope: "crow",
control: "secondary",
onScreen: "Glide on the action pad",
},
{
id: "assist",
keys: ["P"],
meaning: "Hand the vehicle or aircraft back to its assisted route",
section: "move",
scope: "drive",
edge: "assist",
onScreen: "Assist on the action pad",
},
{
id: "reset",
keys: ["R"],
meaning: "Put the vehicle or aircraft back where it started",
section: "move",
scope: "drive",
edge: "reset",
onScreen: "Reset on the action pad",
},
{
id: "camera",
keys: ["C"],
meaning: "Switch between the chase camera and driver height",
section: "move",
scope: "drive",
edge: "camera",
onScreen: "Camera on the action pad",
},
// ---- The world ----------------------------------------------------------
{
id: "office",
keys: ["O"],
meaning: "Walk into the studio, or step back out to the city",
section: "world",
scope: "always",
onScreen: "the door button at the top of the left column",
},
{
id: "walk",
keys: ["V"],
meaning: "Take control of a body — walk inside a studio, explore outside one",
section: "world",
scope: "always",
onScreen: "Walk / Explore in the left column, or the mode dock",
},
{
id: "board",
keys: ["[", "]"],
meaning: "Previous / next board — California, the Bay Area, Southern California",
section: "world",
scope: "city",
onScreen: "the board tabs in the left column",
},
{
id: "chapters",
keys: ["1", "…", "9"],
meaning: "Fly to a chapter of the tour, or to a viewpoint inside a studio",
section: "world",
scope: "always",
onScreen: "the numbered list in the left column",
},
// ---- The view -----------------------------------------------------------
{
id: "plan",
keys: ["M"],
meaning: "Show or hide the plan view",
section: "view",
scope: "always",
onScreen: "the plan button on the rail, and Map on the action pad",
},
{
id: "help",
keys: ["?"],
meaning: "This card",
section: "view",
scope: "always",
onScreen: "the ? button on the rail",
},
{
id: "escape",
keys: ["Esc"],
meaning: "Close whatever is open, or leave the studio",
section: "view",
scope: "always",
onScreen: "the close button on any card",
},
{
id: "tab",
keys: ["Tab"],
meaning: "Every control on the page, in reading order",
section: "view",
scope: "always",
},
// ---- On screen ----------------------------------------------------------
//
// Rows with no key at all. They are here because the sheet is the one place a
// visitor looks to find out what the page can do, and until now it listed only
// the half of the product that needs a keyboard — which on a phone is none of
// it.
{
id: "mode-dock",
keys: [],
gesture: "The mode dock",
meaning: "The pill at the bottom: View, Drive, Explore, Fly, Walk. It is the fastest way between them",
section: "screen",
scope: "always",
onScreen: "bottom centre",
},
{
id: "panel-sheet",
keys: [],
gesture: "The ☰ button",
meaning: "Opens the left column — boards, chapters, and the door into a studio. On a phone it rises as a sheet",
section: "screen",
scope: "always",
onScreen: "top left",
},
{
id: "character",
keys: [],
gesture: "Character",
meaning: "Choose the body you appear as, and your name over it. Signed in only",
section: "screen",
scope: "always",
onScreen: "beside your name, top right",
},
{
id: "office-screens",
keys: [],
gesture: "Office screens",
meaning: "Put something on a screen in the room. Nothing is shared until you choose it",
section: "screen",
scope: "office",
onScreen: "the left column, inside a studio",
},
{
id: "devices",
keys: [],
gesture: "Studio hardware",
meaning: "The mic and the monitor speaker on the desk — power, gain, level, volume",
section: "screen",
scope: "office",
onScreen: "the left column, inside a studio",
},
{
id: "aircraft-detail",
keys: [],
gesture: "Click an aircraft",
meaning: "Its callsign, transponder hex, altitude, heading and position. Open to everyone, signed in or not",
section: "screen",
scope: "city",
onScreen: "anywhere on the board",
},
// ---- Touch and pointer --------------------------------------------------
{
id: "orbit",
keys: [],
gesture: "Drag",
meaning: "Orbit the camera around the board. One finger, or the left mouse button",
section: "gesture",
scope: "always",
},
{
id: "zoom",
keys: [],
gesture: "Pinch or scroll",
meaning: "Zoom in and out. Two fingers also drag the view across the ground",
section: "gesture",
scope: "always",
},
{
id: "pick",
keys: [],
gesture: "Tap or click a marker",
meaning: "Its detail card, at the bottom of the screen. Tap empty ground to dismiss it",
section: "gesture",
scope: "always",
},
{
id: "plan-drag",
keys: [],
gesture: "Drag on the plan",
meaning: "Move the view; scroll to zoom; arrow keys aim and Enter goes there",
section: "gesture",
scope: "always",
},
// ---- Instruments --------------------------------------------------------
{
id: "godmode",
/**
* The backtick, not `G`. See the header: `G` is the crow's glide and always
* was, and this panel was taking it in the capture phase.
*/
keys: ["`"],
meaning: "Godmode: the clock, the weather, the counters and the feed health",
section: "instrument",
scope: "god",
onScreen: "the godmode tab, bottom centre",
},
];
/** The key godmode's drawer opens on. Read by `tools/godmode.ts` so the two agree. */
export const GODMODE_HOTKEY = "`";
// ---- Derived lookups ------------------------------------------------------
/**
* The letters `WASD`/`QE`/`IK` expand into four and two rows' worth of bindings
* from one row each, because a keyboard reference that printed `W`, `A`, `S` and
* `D` on four lines would be four lines saying one thing. This is the expansion,
* kept next to the table it expands.
*/
const KEY_CONTROLS: Readonly<Record<string, PlayDigitalControl>> = {
w: "forward",
s: "backward",
a: "left",
d: "right",
q: "descend",
e: "ascend",
i: "pitch-up",
k: "pitch-down",
" ": "primary",
g: "secondary",
};
/** The one-shot requests, by key. Derived from the `edge` field of the table. */
const KEY_EDGES: Readonly<Record<string, PlayEdgeControl>> = Object.fromEntries(
KEYMAP.filter((entry) => entry.edge !== undefined).flatMap((entry) =>
entry.keys.map((key) => [key.toLowerCase(), entry.edge as PlayEdgeControl]),
),
);
/**
* The held play control a key feeds, or `null`.
*
* This replaces the `switch` that lived in `main.ts`. Space is compared before
* lower-casing because `" ".toLowerCase()` is `" "` but the distinction is worth
* being explicit about: every other binding is a letter and is case-insensitive,
* and this one is not a letter at all.
*/
export function controlForKey(key: string): PlayDigitalControl | null {
return KEY_CONTROLS[key === " " ? key : key.toLowerCase()] ?? null;
}
/** The one-shot request a key fires, or `null`. `P`, `R`, `C`. */
export function edgeForKey(key: string): PlayEdgeControl | null {
return KEY_EDGES[key.toLowerCase()] ?? null;
}
/** Every key string the product binds, lower-cased, for the uniqueness check. */
export function boundKeys(): readonly string[] {
const keys: string[] = [];
for (const entry of KEYMAP) {
for (const key of entry.keys) {
// The ellipsis in `1 … 9` is typography, not a key.
if (key === "…") continue;
keys.push(key.toLowerCase());
}
}
for (const key of Object.keys(KEY_CONTROLS)) {
if (!keys.includes(key)) keys.push(key);
}
return keys;
}
// ---- What the sheet shows -------------------------------------------------
export interface ShortcutSheetInputs {
/**
* No keyboard to speak of. The sheet keeps every row — a tablet with a paired
* keyboard reports a coarse pointer too — but leads with the on-screen half of
* each one and drops the keycaps to a trailing note.
*/
coarsePointer: boolean;
inside: boolean;
/** Godmode rows appear only where godmode does. */
god: boolean;
/**
* The one-sentence demotions the server reported: "no weather source
* configured", "ADS-B endpoint not on the allowlist".
*
* Admin only, and it turns "why is the weather always clear" from a question
* nobody can answer into one the page answers itself.
*/
degraded: readonly string[];
/** Licence credit lines from whichever live sources are answering. */
credits: readonly string[];
}
export interface ShortcutRow {
id: string;
keys: readonly string[];
gesture: string | null;
meaning: string;
section: ShortcutSection;
/** The on-screen equivalent, promoted to the front on a coarse pointer. */
onScreen: string | null;
}
/**
* The rows to draw, in order, for this visitor.
*
* Pure, so the test can assert the two directions of the no-orphan rule without
* a DOM: every `KEYMAP` entry that passes the scope filter appears exactly once,
* and every row carries an id that names a `KEYMAP` entry.
*/
export function shortcutRows(inputs: ShortcutSheetInputs): readonly ShortcutRow[] {
const rows: ShortcutRow[] = [];
for (const section of SHORTCUT_SECTIONS) {
for (const entry of KEYMAP) {
if (entry.section !== section) continue;
if (entry.scope === "god" && !inputs.god) continue;
rows.push({
id: entry.id,
keys: entry.keys,
gesture: entry.gesture ?? null,
meaning: entry.meaning,
section: entry.section,
onScreen: entry.onScreen ?? null,
});
}
}
return rows;
}
/**
* The one line the rail shows, instead of the wall of keycaps it used to.
*
* The old card printed five key hints at once, permanently, in the corner of a
* 3D scene: `19 chapters · choose 2 or 3 to follow · [ ] city · O office · M
* plan`. It was a paragraph of monospace over a photograph, it never changed,
* and four fifths of it was irrelevant to whatever you were doing. This returns
* at most two hints and they are about the mode you are actually in; everything
* else is one press of `?` away.
*/
export function railHints(mode: ControlMode, coarsePointer: boolean): readonly Shortcut[] {
if (coarsePointer) return [];
const wanted: readonly string[] =
mode === "drive"
? ["primary", "assist"]
: mode === "aircraft"
? ["primary", "pitch"]
: mode === "actor"
? ["primary", "glide"]
: mode === "office-walk"
? ["move", "walk"]
: mode === "office-overview"
? ["walk", "chapters"]
: ["chapters", "office"];
const byId = new Map(KEYMAP.map((entry) => [entry.id, entry]));
return wanted.flatMap((id) => {
const entry = byId.get(id);
return entry ? [entry] : [];
});
}
// ---- Rendering ------------------------------------------------------------
export interface ShortcutSheetHandles {
root: HTMLElement;
/** Re-render for a changed tier, pointer or feed state. */
apply(inputs: ShortcutSheetInputs): void;
}
/**
* Build the body of the `?` sheet.
*
* The caller owns the dialog, the backdrop and the focus trap — `mount.ts` does
* — because those are page concerns and this is a content concern. What this
* guarantees is the property the test checks: everything drawn came out of
* `KEYMAP`, and nothing in `KEYMAP` was left out.
*/
export function renderShortcutSheet(
doc: Document,
inputs: ShortcutSheetInputs,
): ShortcutSheetHandles {
const root = doc.createElement("div");
root.className = "sheet-body";
function apply(next: ShortcutSheetInputs): void {
root.replaceChildren();
const rows = shortcutRows(next);
let currentSection: ShortcutSection | null = null;
let list: HTMLElement | null = null;
for (const row of rows) {
if (row.section !== currentSection) {
currentSection = row.section;
const heading = doc.createElement("h3");
heading.className = "sheet-section t-caps";
heading.textContent = SECTION_TITLES[row.section];
root.append(heading);
list = doc.createElement("dl");
list.className = "keys";
root.append(list);
}
if (!list) continue;
const term = doc.createElement("dt");
term.id = `shortcut-${row.id}`;
// On a coarse pointer the on-screen name is the term and the keycaps are
// the footnote; on a keyboard it is the other way round. Same row, same
// id, same order — only the emphasis moves.
const leadWithGesture = row.keys.length === 0 || (next.coarsePointer && row.onScreen !== null);
if (leadWithGesture) {
term.className = "keys-gesture";
term.textContent = row.gesture ?? row.onScreen ?? "";
} else {
for (const key of row.keys) {
if (key === "…") {
term.append(doc.createTextNode(" … "));
continue;
}
const cap = doc.createElement("kbd");
cap.textContent = key;
term.append(cap);
}
}
const detail = doc.createElement("dd");
detail.textContent = row.meaning;
if (row.onScreen !== null && !leadWithGesture) {
const also = doc.createElement("span");
also.className = "keys-also";
also.textContent = ` · ${row.onScreen}`;
detail.append(also);
} else if (leadWithGesture && row.keys.length > 0) {
const also = doc.createElement("span");
also.className = "keys-also";
also.textContent = ` · ${row.keys.join(" ")}`;
detail.append(also);
}
list.append(term, detail);
}
// The disclosure, stated as the rule rather than as a claim about this page,
// because this file is static and the answer is not: one build serves a
// deployment with a weather station and one with nothing at all.
const note = doc.createElement("p");
note.className = "sheet-note";
note.textContent =
"The markers on this map are fabricated — no real company data ships in this build. " +
"Aircraft and weather are real observations where a deployment has wired them, and the " +
"corner names which. Studio hardware readings are simulated and say so on the panel.";
root.append(note);
if (next.credits.length > 0) {
const credits = doc.createElement("p");
credits.className = "sheet-note sheet-credits";
credits.id = "credits";
credits.textContent = next.credits.join(" · ");
root.append(credits);
}
/**
* The demotions, for the tier that can act on them.
*
* `degraded[]` has been built by the server, served in the health body and
* read by nobody since it was written. Every line in it is the answer to a
* question an administrator would otherwise have to read the source to
* answer — "why is the weather always clear", "why are there no aircraft".
*/
if (next.god && next.degraded.length > 0) {
const heading = doc.createElement("h3");
heading.className = "sheet-section t-caps";
heading.textContent = "This deployment is degraded";
const list2 = doc.createElement("ul");
list2.className = "sheet-degraded";
for (const line of next.degraded) {
const item = doc.createElement("li");
item.textContent = line;
list2.append(item);
}
root.append(heading, list2);
}
}
apply(inputs);
return { root, apply };
}
+285
View File
@@ -0,0 +1,285 @@
/**
* The design system, as data.
*
* Every visual constant this interface has ever agreed on already existed — it
* was written once in the `:root` block of `index.html`, and then written again,
* slightly differently, in each of the four modules that inject their own
* stylesheet at runtime (`tools/godmode.ts`, `profile/webcamPanel.ts`,
* `profile/editor.ts`, `media/officeScreenPanel.ts`). Those four cannot read
* `index.html`'s custom properties at author time, so each of them carries a
* fallback — `var(--s4, 16px)` — and the fallback is the copy that rots. A token
* whose value moves in one file and not in five is a token that no longer means
* anything.
*
* This module is the answer to that: the values live here, in TypeScript, where
* a test can read them; `index.html`'s `:root` block is *generated from* this
* file's text and asserted against it by `src/test/ui/tokens.test.ts`. The
* injected stylesheets keep their `var(--x, fallback)` form — they must, because
* a `<style>` element mounted into a page that never loaded `index.html` (the
* office-screen panel is reachable from an embed) still has to look right — but
* the fallbacks are now checked against the same source rather than remembered.
*
* ### Why the z-index scale is the part that earns its own file
*
* Before this there were seven raw z-index literals across five files and no
* document anywhere saying what order they were meant to be in. Two of them were
* collisions nobody had decided: the webcam "camera active" indicator at 12 was
* painting over the shortcuts modal at 10, and godmode's HUD at 6 was sitting on
* top of the play HUD at 4. Neither was wrong exactly — both are defensible — but
* neither had been *chosen*, and the difference between a stacking order that is
* chosen and one that is emergent shows up the first time somebody adds an
* eighth layer.
*
* So the layers are named, ordered and justified here, once. A module that needs
* to stack something looks up a name; nothing anywhere writes a bare number.
*
* This file imports nothing. It is read by the mount applier, by the onboarding
* and device-panel stylesheets, and by tests; it must never grow a DOM
* dependency, because half its value is being assertable in Node.
*/
// ---- Stacking -------------------------------------------------------------
/**
* The complete stacking order of this product, lowest first.
*
* The numbers are deliberately sparse and deliberately small. Sparse, so a layer
* can be slipped between two of these without renumbering the world; small,
* because a `z-index: 9999` is a confession that the author did not know what
* else was on screen, and everything on this screen is in this list.
*/
export const Z_LAYERS = {
/**
* Ambient chrome: the panel, the top-right column, the rail, the honesty line.
* Everything that is *about* the scene and is never modal.
*/
chrome: 3,
/**
* The mode dock and the panel toggle — controls that must stay reachable while
* a card is open, because they are how you get out of the state the card is
* describing.
*/
dock: 4,
/**
* The play surfaces: the play HUD, the touch joystick and its action pad, the
* scrim behind the phone's panel sheet. Above the dock because a thumb resting
* on the joystick must not press a dock button through it.
*/
play: 5,
/**
* Instruments: godmode's HUD and pose dock, and the onboarding coach.
*
* Above `play` on purpose, and this is one of the two orderings that used to be
* an accident. A debug readout that a play HUD can hide is a debug readout you
* will misread, and a first-run coach pointing at the joystick has to be drawn
* over the thing it is pointing at.
*/
instrument: 6,
/** Modal overlays: the shortcuts sheet, the profile editor, the screen panel. */
modal: 10,
/**
* Live-capture alerts, and nothing else — today only "camera active".
*
* Above `modal`, which is the second ordering that used to be an accident and
* is now a decision: an indicator saying a camera is on is a safety affordance,
* and a dialog that can cover it turns "your webcam is live" into a fact the
* interface knows and the person does not. It is scoped to the top-right column
* and only while the indicator is actually shown — see the `body:has()` rule in
* `index.html` — so nothing else rides up there with it.
*/
alert: 12,
/**
* The boot card. Above everything, because until a real frame is on the glass
* there is nothing underneath worth seeing, and because a modal that could open
* behind it would trap focus in an invisible dialog.
*/
boot: 20,
} as const;
export type ZLayer = keyof typeof Z_LAYERS;
export const Z_LAYER_NAMES: readonly ZLayer[] = [
"chrome",
"dock",
"play",
"instrument",
"modal",
"alert",
"boot",
];
/** The custom-property name for a layer: `"dock"` → `"--z-dock"`. */
export function zVarName(layer: ZLayer): string {
return `--z-${layer}`;
}
/**
* What a stylesheet writes: `zIndex("dock")` → `"var(--z-dock, 4)"`.
*
* The fallback is included because the three panels that inject their own
* `<style>` can be mounted into a document that never loaded `index.html`'s
* `:root` block, and a `z-index: var(--z-modal)` that resolves to nothing is not
* "auto", it is invalid-at-computed-value-time — which is worse, because the
* element quietly loses its stacking context instead of visibly failing.
*/
export function zIndex(layer: ZLayer): string {
return `var(${zVarName(layer)}, ${Z_LAYERS[layer]})`;
}
// ---- The rest of the system ----------------------------------------------
/**
* One accent, and the two shades of it that are still the same colour.
*
* `#f2b134` is spent only on things that are *active*: the current chapter, the
* current board, the office door, a focus ring. The moment it also means
* "heading" and "border" and "hover" it stops meaning anything — which is what a
* second pass at this looked like before it was pulled back.
*/
export const ACCENT = {
base: "#f2b134",
lit: "#ffc555",
ink: "#ffd68a",
} as const;
/**
* Four steps of ink and no more.
*
* Anything that wanted a fifth was saying something the type scale should have
* said instead. They are white at four alphas rather than four greys, because
* this chrome floats over a photographic background that runs from a noon sky to
* a night one, and a fixed grey is only ever right against one of them.
*/
export const INK = {
1: "rgba(255, 255, 255, 0.78)",
2: "rgba(255, 255, 255, 0.56)",
3: "rgba(255, 255, 255, 0.4)",
4: "rgba(255, 255, 255, 0.26)",
} as const;
/**
* The 4px rhythm. Every gap, pad and offset in the product is a multiple of it,
* which is what lets two columns written a month apart line up without anyone
* nudging a value.
*/
export const SPACE = {
1: "4px",
2: "8px",
3: "12px",
4: "16px",
5: "24px",
6: "32px",
} as const;
/**
* Five type sizes. 9px is reserved for uppercase micro-labels, where the caps and
* the tracking carry the legibility that the size does not.
*/
export const TYPE = {
title: { size: "11px", tracking: "0.2em", transform: "uppercase" },
body: { size: "11px", tracking: "0.01em", transform: "none" },
ctrl: { size: "12px", tracking: "0.01em", transform: "none" },
micro: { size: "10px", tracking: "0.06em", transform: "none" },
caps: { size: "9px", tracking: "0.1em", transform: "uppercase" },
} as const;
export type TypeStep = keyof typeof TYPE;
/**
* One glass recipe, two weights.
*
* Every card carries three separations at once, and all three are needed: a fill
* dark enough to hold white text against a white sky, a hairline that is light on
* the inside so the card has a rim, and one soft drop shadow so it sits *above*
* the scene rather than in it. A panel tuned only for the dark case — which is
* what this was — reads as a grey smudge at noon, because dark-on-dark needs no
* edge and dark-on-bright very much does.
*
* The strong weight is for anything that must be read over the brightest part of
* the sky with no scene context to help: the boot card, the shortcuts sheet, the
* phone's bottom sheets, the onboarding coach.
*/
export const GLASS = {
fill: "rgba(9, 13, 18, 0.62)",
fillStrong: "rgba(9, 13, 18, 0.86)",
inset: "rgba(255, 255, 255, 0.05)",
hairline: "rgba(255, 255, 255, 0.11)",
blur: "blur(14px) saturate(1.2)",
shadow: "0 6px 22px rgba(3, 6, 10, 0.45)",
} as const;
export const RADIUS = { base: "8px", small: "5px", pill: "999px" } as const;
/**
* The one transition. 150ms is under the ~200ms at which motion starts reading as
* lag and over the ~80ms at which it stops reading as motion at all.
*/
export const MOTION = { transition: "150ms cubic-bezier(0.4, 0, 0.2, 1)" } as const;
/**
* The smallest thing a finger is allowed to be asked to hit.
*
* 44px, which is 11 steps of the 4px rhythm and the smallest target anyone has
* managed to defend. The device panel's sliders and switches are checked against
* it by test, because a mixing desk rendered at desktop density is exactly the
* kind of control that quietly drops below it.
*/
export const TOUCH_TARGET_PX = 44;
/**
* The two breakpoints, and no more.
*
* At 900 the left column stops being furniture and becomes a sheet you open; at
* 600 the page stops being a scaled-down desktop and becomes a different layout.
* `deviceProfile()` in `engine/stage.ts` keys its pixel budget off the same 600,
* so the stylesheet and the renderer cannot drift apart about what a phone is.
*/
export const BREAKPOINT = { phone: 600, compact: 900 } as const;
// ---- The `:root` block ----------------------------------------------------
/**
* Every token as a `--name: value` pair, in the order they appear in `:root`.
*
* This is what makes the system checkable rather than merely documented: the
* stylesheet test walks this list and asserts each declaration appears verbatim
* in `index.html`. A token added here and forgotten there fails a test rather
* than producing an element styled with an invalid value.
*/
export const CSS_TOKENS: readonly (readonly [string, string])[] = [
["--amber", ACCENT.base],
["--amber-lit", ACCENT.lit],
["--amber-ink", ACCENT.ink],
["--glass", GLASS.fill],
["--glass-strong", GLASS.fillStrong],
["--glass-inset", GLASS.inset],
["--hairline", GLASS.hairline],
["--blur", GLASS.blur],
["--shadow", GLASS.shadow],
["--ink", INK[1]],
["--ink-2", INK[2]],
["--ink-3", INK[3]],
["--ink-4", INK[4]],
["--s1", SPACE[1]],
["--s2", SPACE[2]],
["--s3", SPACE[3]],
["--s4", SPACE[4]],
["--s5", SPACE[5]],
["--s6", SPACE[6]],
["--r", RADIUS.base],
["--r-sm", RADIUS.small],
["--r-pill", RADIUS.pill],
["--t", MOTION.transition],
["--tap", `${TOUCH_TARGET_PX}px`],
...Z_LAYER_NAMES.map((layer) => [zVarName(layer), String(Z_LAYERS[layer])] as const),
];
/** The declarations as CSS text, one per line, for a generated `:root` block. */
export function tokenCss(indent = " "): string {
return CSS_TOKENS.map(([name, value]) => `${indent}${name}: ${value};`).join("\n");
}