db074e9cf7
The build the studios needed, across eight workstreams and one strict file partition. **The render rig was the quality ceiling.** The renderer ran three's NoToneMapping default while atmosphere drove the sun to 2.35 and assets set emissives to 3.2, so every value above 1.0 hard-clipped to flat white — which is why walls blew out and every fitting looked like a white rectangle. ACES filmic tone mapping and an explicit output colour space land in `stage.ts`, and the atmosphere intensity table and palette headroom are re-tuned against the new curve rather than left tuned for the clipping we removed. `engine/environmentRig.ts` builds a PMREM environment at runtime, procedurally, so nothing binary is committed. There was no environment map anywhere before, so every `metalness > 0` role had nothing to reflect and rendered dull grey — a defect the code already documented against itself in `office/optimus.ts`, where a whole material role was abandoned over it, and worked around in `modelX.ts` with a fake emissive that this change deletes. Atmosphere remains the sole light owner; the rig derives from the `LightingState` it already produced. **Studio hardware exists.** There was no device concept anywhere in the product: no type, no route, no state. `devices/types.ts` fixes a declaration/state/ capability/command contract that a smart light, a thermostat, a door sensor and a charger all fit without a schema change, and both studios now carry a desk mic and a computer speaker with deterministic simulated behaviour behind an adapter seam a real API can occupy later. Reads are the demo and are open; commands are a signed-in action and are kept off the read body entirely, because a shared cache replaying a GET that turned a microphone on is exactly what the fail-closed cache default exists to prevent. **The ADS-B licence hole is closed.** `TERA_ADSB_ENDPOINT` accepted any URL, the response was served publicly cacheable, and the attribution hardcoded adsb.lol regardless of where the endpoint pointed — one env var away from republishing non-redistributable data under an open-terms credit. The host is now allowlisted, the credit is derived from the host actually configured, public cacheability is conditional on redistributability, and a refused endpoint demotes to simulated flights and says so in `degraded[]`. The gate is on the source, not the feature: live aircraft and their detail cards stay open to anonymous visitors. **The LA studio was never the smaller pack** — 16 rooms and 248 props against SF's 4 and 28. Its deficit was fidelity per square metre: 98 of those props were ceiling troffers, it bound no props to seats, placed none of the habitat kit, and 12 of its 16 rooms had no viewpoint. Density comes from new asset kinds rather than more instances, because `furnish.ts` draws once per kind and folds colour into the batch key, so repeat instances add nothing the eye can read. **The interface stops being forty imperative mutations.** Every visibility decision moves into a pure, tested `ui/chromeState.ts` and one applier, so the chrome has coverage for the first time. Deleted: ~100 lines of CSS and two bindings targeting elements that no longer exist, and a `body:has()` rule that shifted the desktop layout by 160px for touch controls hidden there. Fixed: the office picker tabs that drew their label and their badge on top of each other. Added: a first-run flow, because the product is two verbs and neither was ever stated on screen. Mobile is designed on its own terms instead of being the desktop with things hidden — the plan view comes back, and the keyboard-only shortcuts button is replaced by touch controls. `arena/studioOps.ts` frames the whole thing as the multi-variable environment it is, wrapping the same simulators the renderer drives rather than a headless copy. Also removed `input/vehicle.ts`, which nothing but its own test imported. Tests 385 -> 961, all passing. Typecheck, build, performance budgets across six matrix cells, no-binaries, provenance, dependency licences, zero-config boot and arena source hashes all green. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
229 lines
10 KiB
TypeScript
229 lines
10 KiB
TypeScript
import assert from "node:assert/strict";
|
|
import { readFileSync } from "node:fs";
|
|
import { describe, it } from "node:test";
|
|
|
|
import { DEVICE_PANEL_CSS } from "../../ui/devicePanel.ts";
|
|
import { ONBOARDING_CSS } from "../../ui/onboarding.ts";
|
|
import { TOUCH_TARGET_PX } from "../../ui/tokens.ts";
|
|
|
|
function read(relative: string): string {
|
|
return readFileSync(new URL(`../../../${relative}`, import.meta.url), "utf8");
|
|
}
|
|
|
|
const html = read("index.html");
|
|
|
|
/**
|
|
* The stylesheet with its own prose removed.
|
|
*
|
|
* Every structural assertion below runs against this rather than the raw file,
|
|
* for two reasons that both bit once while writing it: a CSS comment quoting a
|
|
* selector reads as that selector to a line scanner, and a comment containing a
|
|
* `}` truncates any "slice to the closing brace" rule-body extraction. The raw
|
|
* text is still used for the z-index assertion, deliberately — that one is meant
|
|
* to be as literal as the `grep` in the build spec.
|
|
*/
|
|
const css = html.replace(/\/\*[\s\S]*?\*\//g, "");
|
|
|
|
/** Any `z-index: 4`-shaped declaration. A `var(--z-…)` reference is not one. */
|
|
const RAW_Z_INDEX = /z-index:\s*\d/g;
|
|
|
|
describe("the stylesheet", () => {
|
|
it("writes no raw z-index outside the :root token block", () => {
|
|
const start = html.indexOf(":root {");
|
|
const end = html.indexOf("\n }", start);
|
|
const outside = html.slice(0, start) + html.slice(end);
|
|
const found = outside.match(RAW_Z_INDEX) ?? [];
|
|
assert.deepEqual(
|
|
found,
|
|
[],
|
|
`index.html writes ${found.length} raw z-index literal(s) outside :root — use var(--z-…)`,
|
|
);
|
|
});
|
|
|
|
it("writes no raw z-index in any of the four injected stylesheets", () => {
|
|
// These four modules mount a `<style>` at runtime and each used to carry its
|
|
// own stacking literal, invisible to every other file on the page. Two of
|
|
// them collided with rules in index.html.
|
|
for (const file of [
|
|
"src/tools/godmode.ts",
|
|
"src/profile/webcamPanel.ts",
|
|
"src/profile/editor.ts",
|
|
"src/media/officeScreenPanel.ts",
|
|
]) {
|
|
const found = read(file).match(RAW_Z_INDEX) ?? [];
|
|
assert.deepEqual(found, [], `${file} writes a raw z-index literal`);
|
|
}
|
|
for (const [name, css] of [
|
|
["devicePanel", DEVICE_PANEL_CSS],
|
|
["onboarding", ONBOARDING_CSS],
|
|
] as const) {
|
|
assert.deepEqual(css.match(RAW_Z_INDEX) ?? [], [], `${name} writes a raw z-index literal`);
|
|
}
|
|
});
|
|
|
|
it("has no trace of the two removed control-bar ids left anywhere", () => {
|
|
// ~100 lines of CSS and two JS bindings survived the removal of the elements
|
|
// themselves. Some of the rules were not merely dead — see the next test.
|
|
//
|
|
// The ids are assembled rather than written out, because the release gate
|
|
// greps the whole tree for them and a test file containing the literal
|
|
// string would be the last remaining hit.
|
|
for (const id of ["drive", "walk"].map((name) => `${name}-controls`)) {
|
|
assert.equal(html.includes(id), false, `index.html still references #${id}`);
|
|
}
|
|
});
|
|
|
|
it("guards every .touch-play-controls offset rule behind a coarse pointer", () => {
|
|
/*
|
|
* The live layout bug this closes.
|
|
*
|
|
* `body:has(.touch-play-controls:not([hidden])) .mode-dock { bottom: 10rem }`
|
|
* had no pointer guard, and `.touch-play-controls` is only `display: none`
|
|
* on a fine pointer — `:has()` still matches an element that is not
|
|
* displayed. So starting a drive with a mouse hid the key-hint strip and
|
|
* shoved the mode dock up 160px to make room for controls that are not
|
|
* drawn on that device, at exactly the moment a new driver needed the hints.
|
|
*
|
|
* The rule below is structural rather than textual: find every line that
|
|
* both selects `.touch-play-controls` in a `:has()` and sets an offset, and
|
|
* require each one to be inside a `(pointer: coarse)` block or prefixed with
|
|
* `body.touch-capable`.
|
|
*/
|
|
const lines = css.split("\n");
|
|
let coarseDepth = 0;
|
|
let braceDepthAtCoarse = 0;
|
|
let braceDepth = 0;
|
|
const unguarded: string[] = [];
|
|
|
|
for (const line of lines) {
|
|
if (coarseDepth === 0 && line.includes("@media (pointer: coarse)")) {
|
|
coarseDepth = 1;
|
|
braceDepthAtCoarse = braceDepth;
|
|
}
|
|
if (line.includes(":has(.touch-play-controls") && !line.includes("display")) {
|
|
const guarded = coarseDepth > 0 || line.includes("body.touch-capable");
|
|
if (!guarded) unguarded.push(line.trim());
|
|
}
|
|
braceDepth += (line.match(/{/g) ?? []).length - (line.match(/}/g) ?? []).length;
|
|
if (coarseDepth > 0 && braceDepth <= braceDepthAtCoarse) coarseDepth = 0;
|
|
}
|
|
|
|
assert.deepEqual(
|
|
unguarded,
|
|
[],
|
|
"an offset rule keyed off .touch-play-controls escaped its pointer guard",
|
|
);
|
|
});
|
|
|
|
it("keeps page zoom, and keeps the canvas reaching the notch", () => {
|
|
const viewport = html.match(/<meta name="viewport" content="([^"]+)"/)?.[1] ?? "";
|
|
assert.ok(viewport.includes("viewport-fit=cover"));
|
|
// Deliberately preserved. Taking page zoom away from everyone to protect one
|
|
// element is the accessibility mistake the rule exists to avoid, and the
|
|
// canvas is already protected by `touch-action: none`.
|
|
assert.equal(viewport.includes("user-scalable=no"), false);
|
|
assert.equal(viewport.includes("maximum-scale"), false);
|
|
});
|
|
|
|
it("pays for viewport-fit=cover: every fixed edge carries a safe-area inset", () => {
|
|
// The rule the notch imposes. A `bottom: var(--s4)` on a phone puts a
|
|
// control under the home indicator: visible, unpressable.
|
|
for (const selector of [".rail", ".source", ".mode-dock", ".panel-toggle"]) {
|
|
const block = css.slice(css.indexOf(`${selector} {`));
|
|
const body = block.slice(0, block.indexOf("}"));
|
|
assert.match(
|
|
body,
|
|
/env\(safe-area-inset-/,
|
|
`${selector} is pinned to an edge without a safe-area inset`,
|
|
);
|
|
}
|
|
});
|
|
|
|
it("cannot draw an office tab's badge on top of its own name", () => {
|
|
/*
|
|
* The defect that was on every screenshot of every studio.
|
|
*
|
|
* The old rule was `grid-template-columns: minmax(0, 1fr) auto` with nothing
|
|
* clipping the first track, and `minmax(0, 1fr)` lets a track shrink below
|
|
* its content while a plain `<span>` paints outside it — so at the ~85px
|
|
* each tab gets in a three-up strip, "SF HQ · Studio" was drawn straight
|
|
* over the ACTIVE badge.
|
|
*
|
|
* Two properties make that impossible now and both are asserted: the tab is
|
|
* a single-column grid, so the name and the badge are on different rows and
|
|
* cannot occupy the same box at any width; and the name clips.
|
|
*/
|
|
const board = css.slice(css.indexOf(".board {"));
|
|
const boardBody = board.slice(0, board.indexOf("}"));
|
|
assert.match(boardBody, /display:\s*grid/);
|
|
assert.equal(
|
|
/grid-template-columns/.test(boardBody),
|
|
false,
|
|
"an office tab must be one column: two tracks is what let the badge and the name collide",
|
|
);
|
|
|
|
const name = css.slice(css.indexOf(".board__name {"));
|
|
const nameBody = name.slice(0, name.indexOf("}"));
|
|
assert.match(nameBody, /overflow:\s*hidden/);
|
|
assert.match(nameBody, /text-overflow:\s*ellipsis/);
|
|
assert.match(nameBody, /min-width:\s*0/);
|
|
});
|
|
|
|
it("carries every element the chrome applier writes to", () => {
|
|
// `mount.ts` resolves these by id once and then never checks again. A
|
|
// renamed id would silently stop applying one decision rather than throwing.
|
|
const required = [
|
|
"scene", "panel", "panel-toggle", "panel-toggle-label", "scrim",
|
|
"title", "subtitle", "clock", "cities", "boards-title",
|
|
"enter", "walk", "fly", "screens", "devices",
|
|
"device-section", "device-host", "office-invite", "office-note",
|
|
"chapters", "blurb",
|
|
"topright", "tier", "tier-label", "tier-who", "tier-signin", "tier-character",
|
|
"tier-adds", "presence-host", "webcam-face-indicator", "corner", "minimap",
|
|
"minimap-readout",
|
|
"mode-dock", "play-hud", "play-hud-mode", "play-hud-primary", "play-hud-status",
|
|
"rail", "detail", "detail-text", "detail-close", "hint", "plan-toggle", "help",
|
|
"touch-play-controls", "play-stick", "play-stick-knob", "play-stick-label",
|
|
"touch-primary", "touch-secondary", "touch-pitch-up", "touch-pitch-down",
|
|
"touch-assist", "touch-reset", "touch-camera", "touch-map",
|
|
"source", "shortcuts", "shortcuts-body", "shortcuts-close",
|
|
"onboarding-host", "boot", "boot-step",
|
|
];
|
|
for (const id of required) {
|
|
assert.ok(html.includes(`id="${id}"`), `index.html is missing #${id}`);
|
|
}
|
|
});
|
|
|
|
it("keeps the boot card inline, above everything, and first", () => {
|
|
// It is in the document on purpose: it is on screen at first paint, before
|
|
// the module graph has been fetched, let alone before the ~2.3 s heightfield
|
|
// build. Moving it into a module would make the page blank for that whole
|
|
// time.
|
|
assert.ok(html.includes('class="boot" id="boot"'));
|
|
assert.ok(html.indexOf('id="boot"') < html.indexOf('src="/src/main.ts"'));
|
|
const boot = css.slice(css.indexOf(".boot {"));
|
|
assert.match(boot.slice(0, boot.indexOf("}")), /z-index:\s*var\(--z-boot\)/);
|
|
});
|
|
|
|
it("clears the touch target in both injected panels", () => {
|
|
for (const [name, css] of [
|
|
["devicePanel", DEVICE_PANEL_CSS],
|
|
["onboarding", ONBOARDING_CSS],
|
|
] as const) {
|
|
const targets = [...css.matchAll(/min-height:\s*(?:var\(--tap,\s*)?(\d+)px/g)].map((m) =>
|
|
Number(m[1]),
|
|
);
|
|
assert.ok(targets.length > 0, `${name} declares no touch target at all`);
|
|
for (const size of targets) {
|
|
assert.ok(size >= TOUCH_TARGET_PX, `${name} has a ${size}px target, below ${TOUCH_TARGET_PX}px`);
|
|
}
|
|
}
|
|
});
|
|
|
|
it("respects prefers-reduced-motion everywhere something animates", () => {
|
|
assert.ok(html.includes("@media (prefers-reduced-motion: reduce)"));
|
|
assert.ok(ONBOARDING_CSS.includes("@media (prefers-reduced-motion: reduce)"));
|
|
assert.ok(DEVICE_PANEL_CSS.includes("@media (prefers-reduced-motion: reduce)"));
|
|
});
|
|
});
|