1
0
This repository has been archived on 2026-08-25. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
tera/src/ui/tokens.ts
T
karti db074e9cf7 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>
2026-08-21 19:44:24 -07:00

286 lines
11 KiB
TypeScript

/**
* 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");
}