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/tools/godmode.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

2247 lines
82 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
/**
* The instrument panel behind `access.can.debug`.
*
* Godmode used to be one hour scrubber, which is a strange thing for the top
* tier to unlock: the astronomy in this repo is four hundred lines of Meeus in
* `solar.ts` and another four hundred in `atmosphere.ts`, and a control that can
* only move within *today* makes almost none of it visible. You cannot watch the
* arc flatten between June and December, you cannot watch a day get four hours
* shorter, and the moon — computed to a hundredth of a degree — is a number in
* the corner that never changes while you are looking at it. Same for the
* weather: `observe()` takes a `WeatherObservation`, `main.ts` has always passed
* `null`, and so the cloud, precipitation, visibility and marine-layer paths are
* correct code that nothing in production has ever run. This panel is the wiring
* loom for all of it.
*
* Four instruments, and each earns its space by showing something that is
* otherwise invisible rather than something merely convenient:
*
* 1. **Time with a date in it.** Day of the year and hour, the solstices and
* equinoxes computed for the current year rather than written down, golden
* and blue hour solved for *this* place on *this* date, and the next four
* lunar quarters found by searching the same Meeus series the renderer
* lights the night with.
* 2. **Weather that can lie**, loudly. Presets and raw sliders for the four
* fields `atmosphere.ts` reads, including the difference between "zero" and
* "not reported" that the observation type is careful about and a slider
* would otherwise erase.
* 3. **A frame budget**, as a median and a p95 rather than an instantaneous
* number that flickers too fast to read, plus the one measurement nothing
* else in the app makes: whether a board leaves geometries and textures
* behind when you leave it.
* 4. **Overlays** for the numbers you want while your hands are on the
* camera — a paste-ready `Chapter.focus`, both bodies' az/el, the marine
* strength, and the fog the atmosphere actually installed.
*
* Two more sections belong to whatever board is up rather than to the panel, and
* are absent when there is nothing to point them at: **sky** (`GodmodeSky`) and
* **office** (`GodmodeOffice`). Neither is a fifth instrument in the sense above
* — they are handles onto one scene's dials, they arrive with it and they mean
* nothing without it, which is why both are optional interfaces rather than
* fields on `GodmodeOptions`.
*
* ## It is not shipped to anyone else
*
* `access.ts` is explicit that `debug` is a *drawing* decision and not a
* boundary — there is no server to enforce a client-side sun against. That
* argument justifies hiding a slider; it does not justify shipping a whole
* instrument panel to every anonymous visitor and hiding it with CSS. So this
* module is constructed only when `can.debug` is true, and it lives under
* `src/tools/` behind `src/tools/index.ts` precisely so that `main.ts` can reach
* it through a dynamic `import()` and Vite can keep it out of the bundle
* everyone else downloads. A visitor who is not god does not get a hidden panel;
* they do not get the code.
*
* ## Shape
*
* The same imperative handle `minimap.ts` uses, for the same reasons: there is
* no framework here, the caller owns the layout, and a module that mounts, is
* driven through setters and disposes itself completely is the only kind that
* survives a city swap. The one difference is that a minimap is a canvas the
* caller styles and this is a panel that styles itself — `index.html` carries
* the whole interface's CSS inline and is not ours to edit, so the rules live in
* one injected `<style>` at the bottom of this file. Every custom property it
* reads has a fallback, so the panel is legible with or without the app's
* tokens; when the tokens are there it matches the rest of the chrome exactly.
*/
import { GODMODE_HOTKEY } from "../ui/shortcuts.ts";
import {
moonPosition,
observe,
type Environment,
type SkyCondition,
type WeatherObservation,
} from "../engine/atmosphere.ts";
import { MAX_EXTRA_TRAFFIC } from "../engine/flights.ts";
import { daylightPhase, solarPosition, sunTimes } from "../engine/solar.ts";
import type { Stage } from "../engine/stage.ts";
/**
* Where we are, as much of it as the instruments can use.
*
* The suggested interface took `initial: { instant, lat, lng }` and stopped
* there, which works right up until somebody presses `]`: switching city moves
* the observer, and every one of the sun, moon, golden-hour and marine readouts
* is wrong from that moment until it is told. So the three facts are one object
* with a setter, and the two optional hooks that need a `World` ride along with
* it — they belong to a board and die with it.
*/
export interface GodmodePlace {
/** For the readouts, and the key the leak ledger groups boards under. */
label: string;
lat: number;
lng: number;
/**
* The stage this board is being drawn on, for `renderer.info` and the live
* camera. Never written to.
*
* It lives here rather than in `GodmodeOptions` because in this app a stage
* does not outlive a board: `createScene` builds its own and
* `SceneHandle.dispose()` disposes it, so switching city replaces the
* renderer wholesale. A panel that cached the stage it was constructed with
* would spend the rest of the session reading a dead renderer's counters and
* a paused scene's camera, and every number on it would be quietly, plausibly
* wrong — which is the worst failure an instrument has.
*/
stage: Stage;
/**
* `World.unproject`, if the caller has one. With it, the pose overlay prints a
* `Chapter.focus` you can paste into a city pack; without it, scene units,
* which are still useful and still true.
*/
unproject?(x: number, z: number): [number, number];
/** `World.metresPerUnit`, so the fog distances can also be stated in km. */
metresPerUnit?: number;
/**
* How strongly the marine layer is running, 0..1, or `null` where the city has
* no layer configured.
*
* A hook rather than an import because `marineStrength()` in `atmosphere.ts`
* is private to that file today. It is the one number in the fog model you
* cannot infer from the outside — season, clock and wind gate multiply into
* it and only the product reaches the rig — so the overlay says so plainly
* when nobody wired it, rather than printing a plausible guess.
*/
marineStrength?(env: Environment): number | null;
}
/**
* The two dials that point at the sky rather than at the light.
*
* Optional as a unit, and the whole interface is absent rather than each method
* being nullable: without a scene there is nothing for either control to do, and
* a panel that showed a dead traffic slider next to a live sun scrubber would be
* inviting somebody to drag it and conclude the renderer was broken. `main.ts`
* passes this only once a board is up.
*
* `read()` is polled on the panel's own refresh rather than pushed, because both
* numbers it returns change without anybody touching a control — traffic goes
* live when a fetch lands, and the satellite count changes every time the
* propagation sweep completes a pass.
*/
export interface GodmodeSky {
/**
* Fabricate this many aircraft on top of whatever is being drawn, or `0` for
* none. Composes with a live feed rather than replacing it; see
* `withTrafficDial` in `engine/flights.ts` for why that direction, and for the
* argument about fabricating traffic at all.
*/
onExtraTraffic(count: number): void;
/** Draw the satellite layer, or do not. */
onSatellitesVisible(visible: boolean): void;
read(): {
extraTraffic: number;
/** True when the aircraft underneath the fabricated ones were observed. */
trafficIsLive: boolean;
/**
* Objects above the horizon at the last completed propagation pass, and
* element sets this build could read. `null` on the overwhelming majority of
* deployments, which serve no catalogue at all — a different state from a
* catalogue with nothing in it, and one the panel says out loud.
*/
satellites: { visible: number; total: number } | null;
};
}
/**
* Where the office's house lights take their level from.
*
* `"sun"` is the building's own behaviour and the position everything boots in;
* the other two are a hand on the switch, and they stay where they were put
* until somebody moves them — across a walk out to the city and back, and across
* the rebuild a city switch performs.
*/
export type GodmodeHouseLights = "sun" | "on" | "off";
/**
* The office, when the stage is showing one.
*
* The same shape and the same argument as `GodmodeSky` — optional as a unit,
* methods that reach the room through the caller's live handle rather than
* through one captured at construction — with the one difference that the sky
* does not have: a city is simply there, and an office is a room you walk into
* and out of. So `read()` may answer `null` at any moment and the section takes
* itself off screen when it does. A ceiling switch pointed at a building nobody
* is looking at is worse than no switch, and greying it out would only put the
* dead panel back in a paler colour.
*
* The panel owns the three switch positions and re-asserts all three the moment
* `read().id` changes. It has to: in this app an office handle does not survive
* a city switch — it is disposed and the next one is built at its own defaults,
* lids off and robots drawn — so a panel whose chips still claimed "ceilings"
* over a freshly built building would be lying about the only thing it does.
* Re-asserting is three idempotent calls on an event that happens when somebody
* changes metro, which is not a budget anybody has to think about.
*/
export interface GodmodeOffice {
/**
* Where the house lights take their level from.
*
* This is the control the section is worth building for. The fittings ramp on
* against the sun's height — fully on at or below 0°, fully off at or above
* +6°, as `LIGHTS_ON_BELOW_DEG` and `LIGHTS_OFF_ABOVE_DEG` in
* `interiors/luminaires.ts` have it — so until now the only way to see the lit
* building was to scrub the clock into the evening, which also moves the sun,
* the sky behind the glazing, the fog and every shadow. You end up comparing
* two pictures that differ in five ways and learning nothing about any of
* them. Forcing the level moves the fittings and the interior term and
* nothing else: `withHouseLights` in `interiors/daylight.ts` lifts the ambient
* and hemisphere terms and returns the rest of the rig untouched, so the sun
* outside is still the clock's and the two pictures differ in the one thing
* that was asked about.
*/
onHouseLights(mode: GodmodeHouseLights): void;
/**
* Draw the robots, or do not — they keep walking either way, and that is the
* useful half rather than a caveat.
*
* A fitting responds to where a robot *is* and not to whether it is drawn:
* the luminaires hold the live array the robot layer mutates in place, and
* hiding a mesh does not move a position. So this is how you watch the ceiling
* follow somebody across an empty floor with nothing else in the frame, which
* is the one view that shows the occupancy response on its own.
*/
onRobotsVisible(visible: boolean): void;
/**
* Put the lids back on. `OfficeScene.setCeilingsVisible`, which has existed
* since the shell did and which nothing in the app has ever called — the
* ceilings are off by default because looking down into the floor plate is the
* entire view, and that is exactly why being able to close it is worth a chip:
* a fitting you cannot see the underside of is a fitting you cannot check.
*/
onCeilingsVisible(visible: boolean): void;
/**
* The office on the stage, or `null` when the stage is showing something else.
*
* Polled on the panel's own refresh rather than pushed, like
* `GodmodeSky.read`, and here for a stronger reason than either of the sky's:
* `houseLevel` is a ramp against the sun and climbs on its own all through
* dusk while nobody touches a control, which is precisely the moment somebody
* has this section open.
*/
read(): {
/**
* Which building. Printed, and watched for the rebuild described on this
* interface — so it must name the *building*, and must change when the room
* on the stage is a different one.
*/
id: string;
/**
* `"public"` is the stranger's building: same shell, same plan, same
* furniture, and no presence layer at all. Worth a word on screen, because
* an empty office is otherwise indistinguishable from an occupancy feed that
* never landed.
*/
depth: "full" | "public";
/** `OfficeScene.houseLevel()`, 0..1, after the last solar elevation it was given. */
houseLevel: number;
/** How many robots this pack asked for. Zero is a normal answer, not a fault. */
robots: number;
} | null;
}
export interface GodmodeOptions {
/**
* Where to mount. The root positions *itself* — bottom centre, over the map,
* clear of every other card while collapsed — so this may simply be
* `document.body`; the container is a mounting point and a disposal boundary,
* not a layout.
*/
container: HTMLElement;
/** The current instant and place, so the panel opens showing the truth. */
initial: { instant: Date; place: GodmodePlace };
/** A new instant to render at, or `null` to follow the wall clock again. */
onTimeChange(instant: Date | null): void;
/** A fabricated observation, or `null` for whatever the deployment reports. */
onWeatherOverride(w: WeatherObservation | null): void;
/** Traffic and satellites, when there is a board to point them at. */
sky?: GodmodeSky;
/**
* House lights, robots and ceilings, when this deployment has an office at
* all. Absent leaves the section unbuilt; present but `read()`ing `null`
* leaves it off screen until somebody walks into the building.
*/
office?: GodmodeOffice;
/** Start with the drawer open. Default `false`: the tab, and nothing else. */
open?: boolean;
/**
* The key that opens the drawer, lower case. Defaults to `GODMODE_HOTKEY` —
* the backtick.
*
* **It was `g`, and `g` was already taken.** `controlForKey` has mapped `g` to
* `secondary` — the crow's glide — since the play modes landed, and this
* handler is registered in the *capture* phase and calls `preventDefault()`.
* So for the one visitor in a deployment who has godmode, glide silently did
* nothing, and the `?` card grew a `G` row advertising this panel while the
* table of shortcuts said `G` was glide. Two bindings, one key, and the loser
* was the one that belonged to everybody.
*
* The backtick is the conventional key for a developer console and collides
* with nothing. Every other binding lives in `KEYMAP` in `src/ui/shortcuts.ts`
* — including this one — and a test asserts no key in that table has two
* meanings, which is what stops this happening again.
*/
hotkey?: string;
}
export interface Godmode {
/** The panel's own root. Removed entirely by `destroy()`. */
root: HTMLElement;
/**
* Adopt the caller's instant without firing `onTimeChange` — the caller is the
* authority on what is being rendered, and this is how the panel stays honest
* about it. Call it on the wall-clock tick; while an override is up the caller
* has no reason to.
*/
setInstant(instant: Date): void;
/** The board changed under us. Re-aims every place-dependent readout. */
setPlace(place: GodmodePlace): void;
setOpen(open: boolean): void;
isOpen(): boolean;
destroy(): void;
}
// ---- Constants --------------------------------------------------------------
/** Readout cadence. Fast enough to feel live, slow enough that nobody reads a blur. */
const REFRESH_MS = 250;
/** Frame intervals kept for the median. Two seconds at 60 Hz. */
const FRAME_SAMPLES = 120;
/**
* How long after a scene swap the memory counters are believed.
*
* Textures upload lazily — the first frame that draws a material is the one that
* allocates it — so a count taken the instant the new board appears is short by
* however many materials have not been seen yet, and every board would look like
* it had leaked on the way *out*.
*/
const SETTLE_MS = 1500;
/** Solar elevation photographers call golden, and the one they call blue. */
const GOLDEN_DEG = 6;
const BLUE_DEG = -4;
const MS_PER_MINUTE = 60_000;
const MS_PER_DAY = 86_400_000;
/**
* The presets, as observations rather than as moods.
*
* `marine` is the one with real numbers behind it: `PACIFIC_MARINE_LAYER` gates
* on a wind from 275° and `windGate` wants 8-35 km/h — calm leaves the layer
* offshore and a gale mixes it into stratus — so 275° at 20 km/h is the middle
* of the gate rather than a guess. It is also only a third of the model. Season
* and time of day are the other two factors and they come from the *date*, so
* choosing this preset in February renders a clear afternoon and looks broken;
* that is why picking it offers you a July dawn.
*/
interface WeatherDraft {
cloudCover: number;
precipitation: number;
visibilityKm: number | null;
windKph: number | null;
windDirDeg: number | null;
condition: SkyCondition;
}
const WEATHER_PRESETS: { id: string; label: string; draft: WeatherDraft }[] = [
{
id: "clear",
label: "clear",
draft: {
cloudCover: 0.05,
precipitation: 0,
visibilityKm: 40,
windKph: 9,
windDirDeg: 290,
condition: "clear",
},
},
{
id: "partly",
label: "partly",
draft: {
cloudCover: 0.45,
precipitation: 0,
visibilityKm: 24,
windKph: 13,
windDirDeg: 280,
condition: "partly-cloudy",
},
},
{
id: "overcast",
label: "overcast",
draft: {
cloudCover: 1,
precipitation: 0,
visibilityKm: 11,
windKph: 15,
windDirDeg: 200,
condition: "overcast",
},
},
{
id: "rain",
label: "rain",
draft: {
cloudCover: 0.95,
precipitation: 0.55,
visibilityKm: 6,
windKph: 24,
windDirDeg: 190,
condition: "rain",
},
},
{
id: "storm",
label: "storm",
draft: {
cloudCover: 1,
precipitation: 0.9,
visibilityKm: 2.5,
windKph: 48,
windDirDeg: 200,
condition: "thunderstorm",
},
},
{
id: "marine",
label: "marine layer",
draft: {
cloudCover: 0.95,
precipitation: 0,
visibilityKm: 5,
windKph: 20,
windDirDeg: 275,
condition: "fog",
},
},
];
const CONDITIONS: SkyCondition[] = [
"clear",
"partly-cloudy",
"cloudy",
"overcast",
"fog",
"rain",
"snow",
"thunderstorm",
];
/** The peak of the Pacific layer's season, as `PACIFIC_MARINE_LAYER.peakDay` has it. */
const MARINE_PEAK_MONTH = 6;
const MARINE_PEAK_DATE = 15;
const MARINE_PEAK_HOUR = 5.5;
// ---- The panel --------------------------------------------------------------
/** Only for the `aria-controls` pairs; two panels on one page would collide. */
let instances = 0;
export function createGodmode(options: GodmodeOptions): Godmode {
const hotkey = (options.hotkey ?? GODMODE_HOTKEY).toLowerCase();
const id = `gm-${++instances}`;
let place = options.initial.place;
/** `null` follows the wall clock. The same convention `main.ts` uses. */
let override: Date | null = null;
let shown = options.initial.instant;
let weatherOn = false;
let activePreset: string | null = null;
const draft: WeatherDraft = clearDraft();
let open = options.open ?? false;
let showPose = false;
let showBodies = false;
let showAir = false;
const sheet = acquireStylesheet();
// ---- Root -----------------------------------------------------------------
const root = el("div", "gm-root");
root.setAttribute("role", "region");
root.setAttribute("aria-label", "Godmode instruments");
/**
* The overlay block, top centre.
*
* Fixed, and therefore deliberately *not* laid out by the root's flex column —
* you want these numbers next to the thing they describe, not stacked under
* the controls at the far end of the screen. It is a child of the root anyway
* so that one `root.remove()` still takes everything, which is only safe
* because the root carries no `transform`: a transformed ancestor becomes the
* containing block for fixed descendants, and this would silently reposition
* itself against the drawer instead of the viewport. Centring the root is done
* with `left: 0; right: 0` and flex for exactly that reason.
*/
const hud = el("pre", "gm-hud");
hud.hidden = true;
hud.setAttribute("aria-live", "off");
const body = el("div", "gm-body");
body.id = `${id}-body`;
body.hidden = !open;
/**
* The standing declaration that something on screen is fabricated.
*
* It is outside the drawer on purpose: an override survives the panel being
* closed, and an instrument that can lie about the weather has to say so while
* you are looking at the *map*, not while you are looking at the instrument.
* The dashed rim is the tell, and it is borrowed rather than invented —
* `markers.ts` and `minimap.ts` both draw a pin whose position is a guess as a
* hollow one rather than a differently coloured one, on the principle that a
* different *silhouette* survives a glance and a different shade does not.
* Amber because this is an active state and amber is what active means here;
* the dashes are what "not real" means.
*/
const banner = el("div", "gm-banner");
banner.setAttribute("role", "status");
banner.hidden = true;
const bannerText = el("span", "gm-banner-text");
const bannerClear = button("gm-mini", "clear", () => {
setOverride(null);
setWeather(false, null);
});
banner.append(bannerText, bannerClear);
const tab = el("button", "gm-tab");
tab.type = "button";
tab.setAttribute("aria-expanded", String(open));
tab.setAttribute("aria-controls", body.id);
tab.append(
el("span", "gm-tab-label", "godmode"),
el("kbd", "gm-kbd", hotkey.toUpperCase()),
);
tab.addEventListener("click", () => setOpen(!open));
root.append(hud, body, banner, tab);
options.container.append(root);
// ---- Time -----------------------------------------------------------------
const timeSection = section("time");
const stamp = el("div", "gm-stamp");
const sunLine = el("div", "gm-line");
const dayLine = el("div", "gm-line");
const moonCanvas = document.createElement("canvas");
moonCanvas.className = "gm-moon";
moonCanvas.width = 40;
moonCanvas.height = 40;
moonCanvas.setAttribute("role", "img");
const moonCtx = moonCanvas.getContext("2d");
const moonLine = el("div", "gm-line");
const moonRow = el("div", "gm-moonrow");
moonRow.append(moonCanvas, moonLine);
const dateSlider = slider("Day of year", 1, 365, 1, (value) => {
const next = new Date(currentInstant());
// `setMonth(0, n)` with n past the end of January rolls forward through the
// calendar correctly, leap years included, which is the whole reason the day
// of the year is stored as one number and never as a month and a date.
next.setMonth(0, value);
setOverride(next);
});
const hourSlider = slider("Hour of day", 0, 24, 1 / 60, (value) => {
const next = new Date(currentInstant());
next.setHours(Math.floor(value), Math.round((value % 1) * 60), 0, 0);
setOverride(next);
});
const lightChips = el("div", "gm-chips");
const seasonChips = el("div", "gm-chips");
const moonChips = el("div", "gm-chips");
const lightButtons: { el: HTMLButtonElement; at(): Date | null }[] = [];
for (const spec of [
{ label: "blue ↑", target: BLUE_DEG, rising: true },
{ label: "golden ↑", target: GOLDEN_DEG, rising: true },
{ label: "noon", target: null, rising: true },
{ label: "golden ↓", target: GOLDEN_DEG, rising: false },
{ label: "blue ↓", target: BLUE_DEG, rising: false },
]) {
const at = () =>
spec.target === null
? sunTimes(place.lat, place.lng, currentInstant()).solarNoon
: elevationCrossing(place.lat, place.lng, currentInstant(), spec.target, spec.rising);
const b = button("gm-chip", spec.label, () => {
const when = at();
if (when) setOverride(when);
});
lightButtons.push({ el: b, at });
lightChips.append(b);
}
for (const marker of ["Mar eq", "Jun sol", "Sep eq", "Dec sol"] as const) {
seasonChips.append(
button("gm-chip", marker, () => {
const events = seasonMarkers(currentInstant().getFullYear());
const when = events[marker];
if (!when) return;
/**
* The date moves and the clock does not, which is the opposite of the
* lunar buttons and is the point of these four. Holding the hour fixed
* across the solstices is what makes the change visible: the same 09:00
* that is a low raking winter sun is a high one in June, and the arc,
* the shadow length and the day length all move together. Jumping to the
* exact instant of the solstice instead would land you at some arbitrary
* hour and prove nothing.
*/
const next = new Date(currentInstant());
next.setFullYear(when.getFullYear(), when.getMonth(), when.getDate());
setOverride(next);
}),
);
}
for (const spec of [
{ label: "new", phase: 0 },
{ label: "1Q", phase: 0.25 },
{ label: "full", phase: 0.5 },
{ label: "3Q", phase: 0.75 },
]) {
moonChips.append(
button("gm-chip", spec.label, () => {
// Here the whole instant moves, hour included: the quarter *is* an
// instant, and rendering it an hour early renders a different moon.
const when = nextPhase(place.lat, place.lng, currentInstant(), spec.phase);
if (when) setOverride(when);
}),
);
}
const followButton = button("gm-chip gm-chip-wide", "follow the wall clock", () => {
setOverride(null);
});
timeSection.body.append(
stamp,
sunLine,
moonRow,
dayLine,
labelled("date", dateSlider.el, dateSlider.value),
labelled("hour", hourSlider.el, hourSlider.value),
lightChips,
seasonChips,
labelled("moon", moonChips),
followButton,
);
// ---- Weather --------------------------------------------------------------
const weatherSection = section("weather");
const weatherState = el("div", "gm-line");
const weatherChips = el("div", "gm-chips");
const presetButtons: { id: string; el: HTMLButtonElement }[] = [];
const realChip = button("gm-chip", "real", () => setWeather(false, null));
weatherChips.append(realChip);
for (const preset of WEATHER_PRESETS) {
const b = button("gm-chip", preset.label, () => {
Object.assign(draft, preset.draft);
setWeather(true, preset.id);
});
presetButtons.push({ id: preset.id, el: b });
weatherChips.append(b);
}
/**
* The marine layer's other two thirds, offered only when it is the thing you
* just asked for. Season and clock are facts about the date and no weather
* observation can carry them, so the honest options are to say nothing and let
* the preset quietly do a tenth of what its name promises, or to say what else
* it needs and offer the jump. `offSeason` is 0.1 — a February marine chip is
* doing a tenth of the work — so this is not a nicety.
*/
const marineHint = el("div", "gm-hint");
const marineJump = button("gm-mini", "15 Jul, 05:30", () => {
const next = new Date(currentInstant());
next.setMonth(MARINE_PEAK_MONTH, MARINE_PEAK_DATE);
next.setHours(Math.floor(MARINE_PEAK_HOUR), (MARINE_PEAK_HOUR % 1) * 60, 0, 0);
setOverride(next);
});
marineHint.append(
document.createTextNode("the layer also wants a summer dawn — "),
marineJump,
);
marineHint.hidden = true;
const cloudSlider = slider("Cloud cover", 0, 1, 0.01, (v) => {
draft.cloudCover = v;
setWeather(true, null);
});
const precipSlider = slider("Precipitation", 0, 1, 0.01, (v) => {
draft.precipitation = v;
setWeather(true, null);
});
/**
* Visibility on a log-ish scale, because the interesting half of the range is
* the bottom two kilometres — `observedObscuration` is a ramp from 10 km down
* to 500 m and everything above 12 km is the same clear day — and a linear
* 0-40 slider spends nine tenths of its travel there.
*/
const visSlider = slider("Visibility", 0, 1, 0.005, (v) => {
draft.visibilityKm = Math.round(Math.exp(Math.log(0.2) + v * Math.log(60 / 0.2)) * 10) / 10;
setWeather(true, null);
});
const windSlider = slider("Wind speed", 0, 90, 1, (v) => {
draft.windKph = v;
setWeather(true, null);
});
const dirSlider = slider("Wind direction", 0, 359, 1, (v) => {
draft.windDirDeg = v;
setWeather(true, null);
});
/**
* `null` is not zero, and the observation type says so in a comment: a station
* that does not measure visibility is not a station reporting perfect
* visibility. A slider cannot express that difference, so each optional field
* gets a toggle beside it and the slider goes dead when the answer is "nobody
* asked". Without these three buttons this panel could not produce the
* commonest real observation there is.
*/
const visReported = reportedToggle(() => {
draft.visibilityKm = draft.visibilityKm === null ? 10 : null;
setWeather(true, null);
});
const windReported = reportedToggle(() => {
draft.windKph = draft.windKph === null ? 15 : null;
setWeather(true, null);
});
const dirReported = reportedToggle(() => {
draft.windDirDeg = draft.windDirDeg === null ? 275 : null;
setWeather(true, null);
});
const conditionSelect = document.createElement("select");
conditionSelect.className = "gm-select";
conditionSelect.setAttribute("aria-label", "Sky condition");
for (const c of CONDITIONS) {
const opt = document.createElement("option");
opt.value = c;
opt.textContent = c;
conditionSelect.append(opt);
}
conditionSelect.addEventListener("change", () => {
draft.condition = conditionSelect.value as SkyCondition;
setWeather(true, null);
});
weatherSection.body.append(
weatherState,
weatherChips,
marineHint,
labelled("cloud", cloudSlider.el, cloudSlider.value),
labelled("precip", precipSlider.el, precipSlider.value),
labelled("vis", visSlider.el, visSlider.value, visReported.el),
labelled("wind", windSlider.el, windSlider.value, windReported.el),
labelled("from", dirSlider.el, dirSlider.value, dirReported.el),
labelled("cond", conditionSelect),
);
// ---- Sky ------------------------------------------------------------------
/**
* Built unconditionally and appended only when `options.sky` is present.
*
* The alternative — branching around the whole block — means every readout
* below has to be nullable and `refreshSky` becomes a pyramid of guards. One
* check at append time is cheaper to read and the unappended nodes are
* garbage a moment later.
*/
const skySection = section("sky");
const trafficLine = el("div", "gm-line");
const trafficSlider = slider("Fabricated aircraft", 0, MAX_EXTRA_TRAFFIC, 1, (value) => {
options.sky?.onExtraTraffic(value);
refresh();
});
const trafficNote = el("div", "gm-hint");
trafficNote.textContent =
"invented traffic, added to whatever is being drawn — trails run out past ~190 aircraft";
const satelliteLine = el("div", "gm-line");
const satelliteChips = el("div", "gm-chips");
let satellitesOn = true;
const satelliteChip = button("gm-chip", "satellites", () => {
satellitesOn = !satellitesOn;
options.sky?.onSatellitesVisible(satellitesOn);
refresh();
});
const satelliteNote = el("div", "gm-hint");
satelliteNote.textContent =
"drawn on a dome at true azimuth and elevation — 550 km will not fit on the board";
satelliteChips.append(satelliteChip);
skySection.body.append(
trafficLine,
labelled("extra", trafficSlider.el, trafficSlider.value),
trafficNote,
satelliteLine,
satelliteChips,
satelliteNote,
);
// ---- Office ---------------------------------------------------------------
/**
* Built unconditionally and appended only with `options.office`, exactly as
* the sky block above is, and then hidden whenever `read()` says the stage is
* not showing a room. The `hidden` attribute rather than a detach: the section
* is a grid item, `[hidden]` is `display: none` here in a rule that outranks
* the grid, and taking a node out and putting it back would also have to
* remember where in the column order it belonged.
*/
const officeSection = section("office");
const officeLine = el("div", "gm-stamp");
const houseLine = el("div", "gm-line");
const houseChips = el("div", "gm-chips");
let houseLights: GodmodeHouseLights = "sun";
const houseButtons: { mode: GodmodeHouseLights; el: HTMLButtonElement }[] = [];
for (const spec of [
{ mode: "sun", label: "sun" },
{ mode: "on", label: "force on" },
{ mode: "off", label: "force off" },
] as const) {
const b = button("gm-chip", spec.label, () => {
houseLights = spec.mode;
options.office?.onHouseLights(spec.mode);
refresh();
});
houseButtons.push({ mode: spec.mode, el: b });
houseChips.append(b);
}
const houseNote = el("div", "gm-hint");
houseNote.textContent =
"the fittings and the interior term only — the sun outside the glazing is still the clock's";
const robotLine = el("div", "gm-line");
const showChips = el("div", "gm-chips");
let robotsOn = true;
const robotChip = button("gm-chip", "robots", () => {
robotsOn = !robotsOn;
options.office?.onRobotsVisible(robotsOn);
refresh();
});
let ceilingsOn = false;
const ceilingChip = button("gm-chip", "ceilings", () => {
ceilingsOn = !ceilingsOn;
options.office?.onCeilingsVisible(ceilingsOn);
refresh();
});
showChips.append(robotChip, ceilingChip);
const officeNote = el("div", "gm-hint");
officeNote.textContent =
"hidden robots keep walking and the fittings above them still come up; " +
"the lids are off by default, which is why putting them back is a control";
officeSection.body.append(
officeLine,
houseLine,
labelled("lights", houseChips),
houseNote,
robotLine,
labelled("show", showChips),
officeNote,
);
// ---- Performance ----------------------------------------------------------
const perfSection = section("performance");
const frameLine = el("div", "gm-stamp");
const frameNote = el("div", "gm-hint");
frameNote.textContent = "interval between frames; vsync floors it at the refresh rate";
const drawLine = el("div", "gm-line");
const memLine = el("div", "gm-line");
const ledgerNote = el("div", "gm-hint");
ledgerNote.textContent =
"geometries and textures held by the renderer, per board, against its first visit";
const ledgerTable = el("div", "gm-ledger");
const ledgerReset = button("gm-mini", "reset ledger", () => {
ledger.clear();
ledgerOrder.length = 0;
renderLedger();
});
perfSection.body.append(frameLine, frameNote, drawLine, memLine, ledgerNote, ledgerTable);
perfSection.body.append(ledgerReset);
// ---- Overlays -------------------------------------------------------------
const overlaySection = section("overlays");
const overlayChips = el("div", "gm-chips");
const poseChip = button("gm-chip", "pose", () => {
showPose = !showPose;
refresh();
});
const bodiesChip = button("gm-chip", "sun & moon", () => {
showBodies = !showBodies;
refresh();
});
const airChip = button("gm-chip", "air", () => {
showAir = !showAir;
refresh();
});
const copyButton = button("gm-chip", "copy pose", () => copyPose());
const copyNote = el("div", "gm-hint");
overlayChips.append(poseChip, bodiesChip, airChip, copyButton);
overlaySection.body.append(overlayChips, copyNote);
body.append(
timeSection.el,
weatherSection.el,
...(options.sky ? [skySection.el] : []),
...(options.office ? [officeSection.el] : []),
perfSection.el,
overlaySection.el,
);
// ---- Sampling -------------------------------------------------------------
/**
* One rAF, running whether the drawer is open or not, because the leak ledger
* has to see a board it was not watching — you switch cities, then open the
* panel to ask what that cost. It is a subtraction and a modulo per frame, and
* the readouts it feeds are only recomputed four times a second.
*
* What it measures is the *interval* between frames, not the time spent
* rendering one. There is no hook inside the stage's loop and adding one for a
* debug panel would be the tail wagging the dog; the interval is the number
* that matters anyway, since it is the one the eye sees. It does mean a scene
* that renders in 4 ms reads as 16.7 at vsync, which is what the note under it
* says.
*/
const intervals = new Float64Array(FRAME_SAMPLES);
const scratch = new Float64Array(FRAME_SAMPLES);
let intervalCount = 0;
let intervalAt = 0;
let lastFrame = 0;
let lastRefresh = 0;
let raf = 0;
interface Sample {
geometries: number;
textures: number;
programs: number;
calls: number;
triangles: number;
}
interface LedgerEntry {
visits: number;
first: Sample;
last: Sample;
}
const ledger = new Map<string, LedgerEntry>();
const ledgerOrder: string[] = [];
let knownScene = place.stage.current();
let settleAt = performance.now() + SETTLE_MS;
function sample(): Sample {
const info = place.stage.renderer.info;
return {
geometries: info.memory.geometries,
textures: info.memory.textures,
programs: info.programs?.length ?? 0,
calls: info.render.calls,
triangles: info.render.triangles,
};
}
function settle() {
const now = sample();
const key = place.label;
const entry = ledger.get(key);
if (!entry) {
ledger.set(key, { visits: 1, first: now, last: now });
ledgerOrder.push(key);
} else {
entry.visits += 1;
entry.last = now;
}
renderLedger();
}
function frame(now: number) {
raf = requestAnimationFrame(frame);
if (lastFrame !== 0) {
intervals[intervalAt] = now - lastFrame;
intervalAt = (intervalAt + 1) % FRAME_SAMPLES;
if (intervalCount < FRAME_SAMPLES) intervalCount += 1;
}
lastFrame = now;
const scene = place.stage.current();
if (scene !== knownScene) {
knownScene = scene;
settleAt = now + SETTLE_MS;
}
if (settleAt !== 0 && now >= settleAt) {
settleAt = 0;
settle();
}
if (now - lastRefresh < REFRESH_MS) return;
lastRefresh = now;
// The office rides the fast path with the counters rather than waiting for
// the next `refresh()`. Everything else on this panel changes because
// somebody moved something or because `main.ts` announced a new instant —
// and `main.ts` announces one once a minute. The house level is neither: it
// ramps against the sun's height, so at dusk it is a number that visibly
// moves, and it is the number this whole section exists to explain.
if (open) {
refreshLive();
refreshOffice();
}
if (!hud.hidden) refreshHud();
}
raf = requestAnimationFrame(frame);
function percentile(p: number): number {
if (intervalCount === 0) return 0;
const window = scratch.subarray(0, intervalCount);
window.set(intervals.subarray(0, intervalCount));
window.sort();
const index = Math.min(intervalCount - 1, Math.floor(p * intervalCount));
return window[index] ?? 0;
}
// ---- State transitions ----------------------------------------------------
function currentInstant(): Date {
return override ?? new Date();
}
function setOverride(instant: Date | null) {
override = instant;
shown = instant ?? new Date();
options.onTimeChange(instant);
refresh();
}
function setWeather(on: boolean, preset: string | null) {
weatherOn = on;
activePreset = on ? preset : null;
options.onWeatherOverride(on ? { ...draft } : null);
refresh();
}
function setOpen(next: boolean) {
if (next === open) return;
open = next;
body.hidden = !open;
tab.setAttribute("aria-expanded", String(open));
if (open) refresh();
}
// ---- Rendering ------------------------------------------------------------
/** The observation the map is being rendered from, as this panel understands it. */
function environment(): Environment {
return observe(place.lat, place.lng, shown, weatherOn ? { ...draft } : null);
}
function refresh() {
shown = currentInstant();
refreshBanner();
if (open) {
refreshTime();
refreshWeather();
refreshSky();
refreshOffice();
refreshLive();
}
refreshHud();
}
function refreshSky() {
const sky = options.sky;
if (!sky) return;
const state = sky.read();
// The slider is not written back from `state` — it is an input somebody may
// be dragging, and four writes a second to its value while they do that
// fights the pointer. `state.extraTraffic` is read for the line above it,
// which is the readout, and the two agree because the app is the only other
// writer and there is nothing else to disagree with.
setText(
trafficLine,
`traffic ${state.trafficIsLive ? "observed" : "simulated"}` +
`${state.extraTraffic > 0 ? ` +${state.extraTraffic} fabricated` : ""}`,
);
setText(trafficSlider.value, String(state.extraTraffic));
// Three states, not two, and the middle one is the one worth distinguishing:
// a deployment with no catalogue is not the same as a catalogue on a night
// when nothing happens to be up, and an operator debugging an empty sky needs
// to know which they have.
if (state.satellites === null) {
setText(satelliteLine, "satellites no catalogue — set TERA_SATELLITES_SOURCE=celestrak");
} else {
setText(
satelliteLine,
`satellites ${state.satellites.visible} above the horizon ` +
`of ${state.satellites.total} tracked`,
);
}
satelliteChip.disabled = state.satellites === null;
satelliteChip.setAttribute("aria-pressed", String(satellitesOn && state.satellites !== null));
}
/**
* The building this office handle last described, or `null` for "no room on
* the stage". See `GodmodeOffice` for what a change in it means and why the
* switches are pushed back down when it happens.
*/
let knownOffice: string | null = null;
function refreshOffice() {
const office = options.office;
if (!office) return;
const state = office.read();
officeSection.el.hidden = state === null;
if (!state) {
// Walking out to the city forgets the building rather than the switches,
// so walking back in re-asserts them onto whatever handle is there now —
// which may not be the one that was there when they were set.
knownOffice = null;
return;
}
if (state.id !== knownOffice) {
// Written before the calls, not after. None of the three re-enters this
// panel today, and a debug control that could loop the render loop by
// being wired slightly differently tomorrow is not worth the two lines it
// saves.
knownOffice = state.id;
office.onHouseLights(houseLights);
office.onRobotsVisible(robotsOn);
office.onCeilingsVisible(ceilingsOn);
}
setText(
officeLine,
`${state.id} · ${state.depth === "full" ? "full" : "public — no presence layer"}`,
);
setText(
houseLine,
`house ${Math.round(state.houseLevel * 100)}% · ` +
(houseLights === "sun" ? "following the sun" : `forced ${houseLights}`),
);
for (const entry of houseButtons) {
entry.el.setAttribute("aria-pressed", String(entry.mode === houseLights));
}
// Three states again, and the middle one is again the one worth saying out
// loud: a pack that asked for no robots is not a pack whose robots are
// hidden, and the chip that would toggle nothing says so by going dead
// rather than by appearing to work.
if (state.robots === 0) {
setText(robotLine, "robots none — this pack asked for none");
} else {
setText(
robotLine,
`robots ${state.robots} walking${robotsOn ? "" : ", hidden"}`,
);
}
robotChip.disabled = state.robots === 0;
robotChip.setAttribute("aria-pressed", String(robotsOn && state.robots > 0));
ceilingChip.setAttribute("aria-pressed", String(ceilingsOn));
}
function refreshBanner() {
const parts: string[] = [];
if (override) parts.push(`time ${fmtStamp(override)}`);
if (weatherOn) {
const vis = draft.visibilityKm === null ? "vis n/r" : `vis ${draft.visibilityKm} km`;
parts.push(
`weather ${draft.condition}, cloud ${Math.round(draft.cloudCover * 100)}%, ${vis}`,
);
}
banner.hidden = parts.length === 0;
setText(bannerText, `overridden — ${parts.join(" · ")} — not real`);
}
function refreshTime() {
const env = environment();
const times = sunTimes(place.lat, place.lng, shown);
setText(stamp, `${fmtStamp(shown)}${override ? "" : " · wall clock"}`);
setText(
sunLine,
`sun az ${fmtDeg(env.sun.azimuth)} el ${fmtSigned(env.sun.elevation)} ` +
`${daylightPhase(env.sun.elevation)}`,
);
setText(
moonLine,
`moon az ${fmtDeg(env.moon.azimuth)} el ${fmtSigned(env.moon.elevation)} ` +
`${Math.round(env.moon.illuminated * 100)}% ${phaseName(env.moon.phase)}`,
);
moonCanvas.setAttribute(
"aria-label",
`Moon, ${Math.round(env.moon.illuminated * 100)} per cent lit, ${phaseName(env.moon.phase)}`,
);
drawMoon(moonCtx, env.moon.illuminated, env.moon.phase);
const { sunrise, sunset, solarNoon } = times;
const noonElevation = solarPosition(place.lat, place.lng, solarNoon).elevation;
if (sunrise && sunset) {
const length = sunset.getTime() - sunrise.getTime();
setText(
dayLine,
`day ${fmtDuration(length)} · rise ${fmtClock(sunrise)} set ${fmtClock(sunset)} ` +
`· noon ${fmtSigned(noonElevation)}`,
);
} else {
// A legitimate answer at high latitude, and `sunTimes` returns it rather
// than throwing; saying "no sunrise" is more useful than an empty line.
setText(
dayLine,
`${noonElevation > 0 ? "no sunset" : "no sunrise"} · noon ${fmtSigned(noonElevation)}`,
);
}
if (document.activeElement !== dateSlider.el) {
dateSlider.el.max = String(daysInYear(shown.getFullYear()));
dateSlider.el.value = String(dayOfYear(shown));
}
if (document.activeElement !== hourSlider.el) {
hourSlider.el.value = String(shown.getHours() + shown.getMinutes() / 60);
}
setText(dateSlider.value, fmtDate(shown));
setText(hourSlider.value, fmtClock(shown));
followButton.setAttribute("aria-pressed", String(override === null));
for (const entry of lightButtons) {
const when = entry.at();
entry.el.disabled = when === null;
entry.el.title = when ? fmtClock(when) : "the sun does not reach it here today";
}
}
function refreshWeather() {
setText(
weatherState,
weatherOn
? "fabricated — this is not an observation of anywhere"
: "following the deployment; no source configured ships no weather at all",
);
realChip.setAttribute("aria-pressed", String(!weatherOn));
for (const preset of presetButtons) {
preset.el.setAttribute("aria-pressed", String(weatherOn && activePreset === preset.id));
}
marineHint.hidden = activePreset !== "marine";
if (document.activeElement !== cloudSlider.el) cloudSlider.el.value = String(draft.cloudCover);
if (document.activeElement !== precipSlider.el) {
precipSlider.el.value = String(draft.precipitation);
}
setText(cloudSlider.value, `${Math.round(draft.cloudCover * 100)}%`);
setText(precipSlider.value, `${Math.round(draft.precipitation * 100)}%`);
const visKnown = draft.visibilityKm !== null;
visSlider.el.disabled = !visKnown;
if (visKnown && document.activeElement !== visSlider.el) {
const km = draft.visibilityKm ?? 10;
visSlider.el.value = String(
(Math.log(Math.max(0.2, km)) - Math.log(0.2)) / Math.log(60 / 0.2),
);
}
setText(visSlider.value, visKnown ? `${draft.visibilityKm} km` : "n/r");
visReported.set(visKnown);
const windKnown = draft.windKph !== null;
windSlider.el.disabled = !windKnown;
if (windKnown && document.activeElement !== windSlider.el) {
windSlider.el.value = String(draft.windKph ?? 0);
}
setText(windSlider.value, windKnown ? `${draft.windKph} km/h` : "n/r");
windReported.set(windKnown);
const dirKnown = draft.windDirDeg !== null;
dirSlider.el.disabled = !dirKnown;
if (dirKnown && document.activeElement !== dirSlider.el) {
dirSlider.el.value = String(draft.windDirDeg ?? 0);
}
setText(dirSlider.value, dirKnown ? `${draft.windDirDeg}°` : "n/r");
dirReported.set(dirKnown);
conditionSelect.value = draft.condition;
}
/** The numbers that move on their own: frame budget and renderer counters. */
function refreshLive() {
const median = percentile(0.5);
const p95 = percentile(0.95);
setText(
frameLine,
`${median.toFixed(1)} ms median · ${p95.toFixed(1)} p95 · ` +
`${median > 0 ? Math.round(1000 / median) : 0} fps`,
);
const now = sample();
setText(drawLine, `draw ${fmtInt(now.calls)} calls · ${fmtTris(now.triangles)} tris`);
setText(
memLine,
`geom ${fmtInt(now.geometries)} · tex ${fmtInt(now.textures)} ` +
`· prog ${fmtInt(now.programs)}`,
);
}
function renderLedger() {
ledgerTable.replaceChildren();
for (const key of ledgerOrder) {
const entry = ledger.get(key);
if (!entry) continue;
const row = el("div", "gm-ledger-row");
const dGeom = entry.last.geometries - entry.first.geometries;
const dTex = entry.last.textures - entry.first.textures;
row.append(
el("span", "gm-ledger-name", key),
el("span", "gm-ledger-n", `×${entry.visits}`),
el("span", "gm-ledger-n", `${fmtInt(entry.last.geometries)} geom`),
delta(dGeom, entry.visits),
el("span", "gm-ledger-n", `${fmtInt(entry.last.textures)} tex`),
delta(dTex, entry.visits),
);
ledgerTable.append(row);
}
if (ledgerOrder.length === 0) {
ledgerTable.append(el("div", "gm-hint", "no board has settled yet"));
}
}
/**
* A delta against the first visit, and the only cell here that is allowed to
* shout.
*
* These are not heap numbers and there is no collector to wait for:
* `WebGLRenderer` decrements them on an explicit `dispose()` and at no other
* time. What a rise means depends on which way you got here, and both readings
* are worth the colour:
*
* - **Within one board**, watching the number climb while you sit still is a
* leak outright — something is being built every frame or every poll and
* nothing is releasing it.
* - **Across visits to the same board**, it is duplicated *work* rather than
* a missed dispose, because in this app a board brings its own renderer:
* `createScene` calls `createStage` and `SceneHandle.dispose()` disposes
* it, so visit two starts from zero and only reaches a higher number if it
* built more than visit one did. A `MaterialRegistry` that stopped being
* shared would show up here and nowhere else.
*
* The cell stays blank on a first visit rather than claiming a confident zero.
*/
function delta(value: number, visits: number): HTMLElement {
if (visits < 2) return el("span", "gm-ledger-d", "—");
const cls = value > 0 ? "gm-ledger-d gm-leak" : "gm-ledger-d";
return el("span", cls, value === 0 ? "±0" : `${value > 0 ? "+" : ""}${fmtInt(value)}`);
}
// ---- Overlays -------------------------------------------------------------
function poseBlock(): { text: string; paste: string } {
const active = place.stage.current();
if (!active) return { text: "no scene on the stage", paste: "" };
const { camera, controls } = active;
const dx = camera.position.x - controls.target.x;
const dy = camera.position.y - controls.target.y;
const dz = camera.position.z - controls.target.z;
const distance = Math.hypot(dx, dz);
// `scene.ts` builds a chapter pose as target + (sin r · d, h, cos r · d)
// above the ground at the focus, so this is that arithmetic run backwards.
// `height` is therefore height above the ground under the target, which is
// only the same as the camera's altitude when the target is on the ground —
// and it always is, because both `chapterPose` and the minimap's seek put it
// there.
const rotation = Math.atan2(dx, dz);
const scene = `cam ${trim(camera.position.x, 1)}, ${trim(camera.position.y, 1)}, ` +
`${trim(camera.position.z, 1)} · target ${trim(controls.target.x, 1)}, ` +
`${trim(controls.target.y, 1)}, ${trim(controls.target.z, 1)}`;
if (!place.unproject) {
return {
text:
`${scene}\ndistance ${trim(distance, 1)} · height ${trim(dy, 1)} · ` +
`rotation ${trim(rotation, 2)}\n(no unproject wired — scene units, not degrees)`,
paste: "",
};
}
const [lat, lng] = place.unproject(controls.target.x, controls.target.z);
const paste =
`focus: { lat: ${lat.toFixed(4)}, lng: ${lng.toFixed(4)}, ` +
`distance: ${trim(distance, 1)}, height: ${trim(dy, 1)}, ` +
`rotation: ${trim(rotation, 2)} },`;
return { text: `${paste}\n${scene}`, paste };
}
function refreshHud() {
const on = showPose || showBodies || showAir;
poseChip.setAttribute("aria-pressed", String(showPose));
bodiesChip.setAttribute("aria-pressed", String(showBodies));
airChip.setAttribute("aria-pressed", String(showAir));
copyButton.disabled = !place.unproject;
hud.hidden = !on;
if (!on) return;
const blocks: string[] = [];
if (showPose) blocks.push(poseBlock().text);
if (showBodies) {
const env = environment();
blocks.push(
`sun az ${fmtDeg(env.sun.azimuth)} el ${fmtSigned(env.sun.elevation)} ` +
`dec ${fmtSigned(env.sun.declination)} eot ${fmtMinutes(env.sun.equationOfTime)}\n` +
`moon az ${fmtDeg(env.moon.azimuth)} el ${fmtSigned(env.moon.elevation)} ` +
`${Math.round(env.moon.illuminated * 100)}% ${phaseName(env.moon.phase)} ` +
`${fmtInt(Math.round(env.moon.distanceKm))} km`,
);
}
if (showAir) blocks.push(airBlock());
setText(hud, blocks.join("\n\n"));
}
function airBlock(): string {
const strength = place.marineStrength ? place.marineStrength(environment()) : undefined;
const marine =
strength === undefined
? "marine — (atmosphere.ts keeps marineStrength private; no hook wired)"
: strength === null
? "marine — (no layer configured for this board)"
: `marine ${strength.toFixed(3)}`;
const scene = place.stage.current();
const fog = scene?.scene.fog ?? null;
let fogLine = "fog none";
if (fog && "near" in fog) {
const km =
place.metresPerUnit === undefined
? ""
: ` (${trim((fog.near * place.metresPerUnit) / 1000, 1)}` +
`${trim((fog.far * place.metresPerUnit) / 1000, 1)} km)`;
fogLine =
`fog ${trim(fog.near, 1)} ${trim(fog.far, 1)} u${km} ` +
`#${fog.color.getHexString()}`;
} else if (fog) {
fogLine = `fog exponential, density ${fog.density}`;
}
return `${marine}\n${fogLine}`;
}
/**
* Clipboard where there is one, a selection where there is not.
*
* `navigator.clipboard` is undefined on any non-secure origin, which includes
* every `http://` dev server anyone runs this from, so the fallback is not
* theoretical. Selecting the text is a real answer: the block is already on
* screen, and one Ctrl-C finishes the job.
*/
function copyPose() {
const { paste } = poseBlock();
if (!paste) return;
if (!navigator.clipboard) {
selectHud();
setText(copyNote, "no clipboard on this origin — selected, press copy");
return;
}
void navigator.clipboard.writeText(paste).then(
() => setText(copyNote, "copied"),
() => {
selectHud();
setText(copyNote, "clipboard refused — selected, press copy");
},
);
}
function selectHud() {
showPose = true;
refreshHud();
const range = document.createRange();
range.selectNodeContents(hud);
const selection = window.getSelection();
selection?.removeAllRanges();
selection?.addRange(range);
}
// ---- Keyboard -------------------------------------------------------------
/**
* Capture, and only capture.
*
* `main.ts` binds its own `keydown` on `window` at module scope, which is long
* before this panel exists, so a bubble-phase listener here would always run
* second — and `Esc` would have closed the office out from under you before
* the drawer heard about it. Capture runs first regardless of registration
* order. It is used for exactly two keys and gives `Esc` back the moment focus
* leaves this panel, which is the whole difference between borrowing a binding
* and stealing one.
*/
function onKeyDown(event: KeyboardEvent) {
if (event.defaultPrevented || event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target;
if (event.key === "Escape") {
if (!open || target instanceof HTMLSelectElement) return;
if (!(target instanceof Node) || !root.contains(target)) return;
event.preventDefault();
event.stopPropagation();
setOpen(false);
tab.focus();
return;
}
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
target instanceof HTMLSelectElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
return;
}
if (event.key.toLowerCase() !== hotkey || event.repeat) return;
event.preventDefault();
setOpen(!open);
}
window.addEventListener("keydown", onKeyDown, true);
// ---- Lifecycle ------------------------------------------------------------
// No callback is fired here. Construction announces nothing: the caller's
// state is already "no override" or this panel would not be being built, and
// an `onWeatherOverride(null)` during `createGodmode` would run inside the
// caller's own constructor expression.
refresh();
renderLedger();
return {
root,
setInstant(instant) {
// The caller is telling us what it rendered. If an override is up it has
// presumably rendered that; either way the panel shows what the map shows
// and nothing is fired back at the caller.
shown = instant;
if (override !== null) override = instant;
refresh();
},
setPlace(next) {
place = next;
refresh();
},
setOpen,
isOpen: () => open,
destroy() {
cancelAnimationFrame(raf);
window.removeEventListener("keydown", onKeyDown, true);
// Every other listener in this module is on a node inside `root`, and goes
// when the node does. The stylesheet is shared, so it is refcounted rather
// than removed outright — two panels have never existed at once, and a
// teardown that deletes the CSS out from under a second one would be a
// very confusing bug to meet later.
root.remove();
sheet.release();
ledger.clear();
ledgerOrder.length = 0;
},
};
}
// ---- Astronomy the panel needs and the engine does not ----------------------
/**
* The instant, this year, of each equinox and solstice.
*
* Computed rather than tabulated, for the same reason `solar.ts` computes the
* sun rather than fetching it: a table is four numbers a year that are wrong the
* moment the clock passes new year, and this is thirty lines that are right
* forever. The equinoxes are where the sun's declination crosses zero and the
* solstices are where it turns round, both of which `solarTerms` already knows —
* so the search is over `solarPosition` at the prime meridian, whose declination
* is a property of the instant and not of the observer.
*
* A day-resolution scan to bracket, then bisection to the minute. Roughly eight
* hundred evaluations of a handful of trig terms, once per year per session, all
* of it behind a memo.
*/
const seasonCache = new Map<number, Record<string, Date>>();
function seasonMarkers(year: number): Record<string, Date> {
const cached = seasonCache.get(year);
if (cached) return cached;
const dec = (ms: number) => solarPosition(0, 0, new Date(ms)).declination;
const start = Date.UTC(year, 0, 1);
const days = daysInYear(year);
const samples = new Float64Array(days + 1);
for (let i = 0; i <= days; i++) samples[i] = dec(start + i * MS_PER_DAY);
const events: Record<string, Date> = {};
let maxAt = 0;
let minAt = 0;
for (let i = 1; i <= days; i++) {
const previous = samples[i - 1] ?? 0;
const current = samples[i] ?? 0;
if (current > (samples[maxAt] ?? 0)) maxAt = i;
if (current < (samples[minAt] ?? 0)) minAt = i;
if (previous < 0 && current >= 0) {
events["Mar eq"] = new Date(
bisect(start + (i - 1) * MS_PER_DAY, start + i * MS_PER_DAY, dec),
);
}
if (previous > 0 && current <= 0) {
events["Sep eq"] = new Date(
bisect(start + (i - 1) * MS_PER_DAY, start + i * MS_PER_DAY, (ms) => -dec(ms)),
);
}
}
events["Jun sol"] = new Date(extremum(start + (maxAt - 1) * MS_PER_DAY, dec, 1));
events["Dec sol"] = new Date(extremum(start + (minAt - 1) * MS_PER_DAY, dec, -1));
seasonCache.set(year, events);
return events;
}
/** Where a monotonically rising `f` crosses zero, to about a second. */
function bisect(lo: number, hi: number, f: (ms: number) => number): number {
let a = lo;
let b = hi;
for (let i = 0; i < 26; i++) {
const mid = (a + b) / 2;
if (f(mid) < 0) a = mid;
else b = mid;
}
return (a + b) / 2;
}
/** A turning point, by ten-minute scan over two days. Declination moves slowly. */
function extremum(from: number, f: (ms: number) => number, sign: 1 | -1): number {
let best = from;
let bestValue = sign * f(from);
for (let ms = from; ms <= from + 2 * MS_PER_DAY; ms += 10 * MS_PER_MINUTE) {
const value = sign * f(ms);
if (value > bestValue) {
bestValue = value;
best = ms;
}
}
return best;
}
/**
* When the sun passes `target` degrees on the way up or the way down, today,
* here — or `null` if it never gets there.
*
* Bisected inside the half-day either side of solar noon, where the elevation is
* monotonic at every latitude this renderer has a city at. (Inside the polar
* circles it is still monotonic; what fails there is that the crossing may not
* exist, which is the `null`.) `sunTimes` supplies solar noon, so the window is
* the sun's own day rather than the calendar's and an evening call after
* midnight UTC does not silently answer for tomorrow.
*/
function elevationCrossing(
lat: number,
lng: number,
when: Date,
target: number,
rising: boolean,
): Date | null {
const noon = sunTimes(lat, lng, when).solarNoon.getTime();
const elevation = (ms: number) => solarPosition(lat, lng, new Date(ms)).elevation - target;
const edge = rising ? noon - MS_PER_DAY / 2 : noon + MS_PER_DAY / 2;
if (elevation(noon) < 0) return null;
if (elevation(edge) > 0) return null;
// `bisect` wants the negative end first, which is the small hours on the way
// up and solar noon on the way down — the two calls are not symmetric and
// swapping them silently returns the wrong side of the day.
const found = rising
? bisect(edge, noon, elevation)
: bisect(noon, edge, (ms) => -elevation(ms));
return new Date(found);
}
/**
* The next instant the moon reaches a given phase, within thirty-two days.
*
* `moonPosition().phase` is the elongation from the sun over 360°, so it climbs
* from 0 at new to 1 at the next new and wraps. Six-hour steps bracket the
* crossing — the phase moves about 0.0085 an hour, so a step cannot skip a
* target — then the usual bisection. Thirty-two days rather than thirty: a
* synodic month is 29.53 and the search has to start from wherever you are.
*/
function nextPhase(lat: number, lng: number, from: Date, target: number): Date | null {
const signed = (ms: number) => {
const p = moonPosition(lat, lng, new Date(ms)).phase - target;
return ((p % 1) + 1.5) % 1 - 0.5;
};
const step = 6 * 3_600_000;
let a = from.getTime() + 60_000;
let previous = signed(a);
for (let i = 1; i <= 128; i++) {
const b = from.getTime() + i * step;
const current = signed(b);
if (previous < 0 && current >= 0) {
return new Date(bisect(a, b, signed));
}
a = b;
previous = current;
}
return null;
}
function phaseName(phase: number): string {
const p = ((phase % 1) + 1) % 1;
if (p < 0.03 || p >= 0.97) return "new";
if (p < 0.22) return "waxing crescent";
if (p < 0.28) return "first quarter";
if (p < 0.47) return "waxing gibbous";
if (p < 0.53) return "full";
if (p < 0.72) return "waning gibbous";
if (p < 0.78) return "last quarter";
return "waning crescent";
}
/**
* The moon, at 20 CSS pixels, drawn rather than fetched — `src/` carries no
* binary assets, and eight phases as images would be eight of them.
*
* The construction is the classical one: a dark disc, the lit half of it, and
* then one ellipse whose semi-minor axis is `r·|1 2k|` filled light for a
* gibbous moon and dark for a crescent. That single ellipse is the terminator in
* projection, and it is exact, not an approximation of a circle by an oval.
*
* Lit on the right while waxing, which is a northern-hemisphere fact rather than
* a universal one — south of the equator it is the other limb. Both cities on
* this map are at 34° and 38° north, and a moon that is correct for the viewer's
* hemisphere rather than the scene's would be the wrong kind of clever.
*/
function drawMoon(ctx: CanvasRenderingContext2D | null, illuminated: number, phase: number) {
if (!ctx) return;
const size = ctx.canvas.width;
const r = size / 2 - 2;
const c = size / 2;
const k = Math.min(1, Math.max(0, illuminated));
const waxing = ((phase % 1) + 1) % 1 < 0.5;
const lit = "#e8eef6";
const dark = "#161d29";
ctx.clearRect(0, 0, size, size);
ctx.beginPath();
ctx.arc(c, c, r, 0, Math.PI * 2);
ctx.fillStyle = dark;
ctx.fill();
ctx.beginPath();
ctx.arc(c, c, r, -Math.PI / 2, Math.PI / 2, !waxing);
ctx.closePath();
ctx.fillStyle = lit;
ctx.fill();
ctx.beginPath();
ctx.ellipse(c, c, r * Math.abs(1 - 2 * k), r, 0, 0, Math.PI * 2);
ctx.fillStyle = k > 0.5 ? lit : dark;
ctx.fill();
ctx.beginPath();
ctx.arc(c, c, r, 0, Math.PI * 2);
ctx.strokeStyle = "rgba(255, 255, 255, 0.22)";
ctx.lineWidth = 1;
ctx.stroke();
}
// ---- Formatting -------------------------------------------------------------
function dayOfYear(d: Date): number {
const startOfYear = new Date(d.getFullYear(), 0, 1).getTime();
const startOfDay = new Date(d.getFullYear(), d.getMonth(), d.getDate()).getTime();
// Rounded rather than floored: a spring-forward between the two puts the
// difference an hour short of a whole number of days.
return Math.round((startOfDay - startOfYear) / MS_PER_DAY) + 1;
}
function daysInYear(year: number): number {
return dayOfYear(new Date(year, 11, 31));
}
function fmtStamp(d: Date): string {
return `${fmtDate(d)} ${fmtClock(d)}`;
}
function fmtDate(d: Date): string {
return d.toLocaleDateString([], { weekday: "short", day: "numeric", month: "short" });
}
function fmtClock(d: Date): string {
return d.toLocaleTimeString([], { hour: "2-digit", minute: "2-digit", hour12: false });
}
function fmtDuration(ms: number): string {
const minutes = Math.round(ms / MS_PER_MINUTE);
return `${Math.floor(minutes / 60)}h ${String(minutes % 60).padStart(2, "0")}m`;
}
function fmtDeg(value: number): string {
return `${value.toFixed(1)}°`;
}
function fmtSigned(value: number): string {
return `${value >= 0 ? "+" : ""}${value.toFixed(1)}°`;
}
/** The equation of time, which is the one signed quantity here that is not an angle. */
function fmtMinutes(value: number): string {
return `${value >= 0 ? "+" : ""}${value.toFixed(1)} min`;
}
function fmtInt(value: number): string {
return value.toLocaleString("en-US");
}
function fmtTris(value: number): string {
return value >= 1e6 ? `${(value / 1e6).toFixed(2)} M` : fmtInt(value);
}
/** Fixed decimals with the trailing zeros dropped, so a pasted pose reads hand-written. */
function trim(value: number, places: number): string {
return String(Number(value.toFixed(places)));
}
/**
* What the sliders read before anyone has touched them, and it is not zeroes.
* A wind of `null` rather than 0 because that is what an unwired deployment
* reports, and starting the panel on the state it is overriding makes the first
* drag a change of one thing rather than of five.
*/
function clearDraft(): WeatherDraft {
return {
cloudCover: 0.05,
precipitation: 0,
visibilityKm: 40,
windKph: null,
windDirDeg: null,
condition: "clear",
};
}
// ---- DOM ---------------------------------------------------------------------
function el<K extends keyof HTMLElementTagNameMap>(
tag: K,
className?: string,
text?: string,
): HTMLElementTagNameMap[K] {
const node = document.createElement(tag);
if (className) node.className = className;
if (text !== undefined) node.textContent = text;
return node;
}
function button(className: string, label: string, onClick: () => void): HTMLButtonElement {
const b = el("button", className, label);
b.type = "button";
b.addEventListener("click", onClick);
return b;
}
/**
* Only write when the string has actually changed.
*
* Four readouts a second across thirty nodes is not a lot of work, but every one
* of those writes is a layout invalidation on an element sitting over a WebGL
* canvas, and the panel exists to measure frame time rather than to spend it.
*/
function setText(node: HTMLElement, text: string) {
if (node.textContent !== text) node.textContent = text;
}
interface Slider {
el: HTMLInputElement;
value: HTMLElement;
}
function slider(
label: string,
min: number,
max: number,
step: number,
onInput: (value: number) => void,
): Slider {
const input = el("input", "gm-slider");
input.type = "range";
input.min = String(min);
input.max = String(max);
input.step = String(step);
input.setAttribute("aria-label", label);
input.addEventListener("input", () => onInput(Number(input.value)));
return { el: input, value: el("span", "gm-value") };
}
/** The "somebody actually measured this" switch beside an optional field. */
function reportedToggle(onToggle: () => void): { el: HTMLButtonElement; set(on: boolean): void } {
const b = button("gm-mini gm-nr", "n/r", onToggle);
b.title = "reported, or not reported at all — which is not the same as zero";
return {
el: b,
set(on) {
b.setAttribute("aria-pressed", String(!on));
b.textContent = on ? "rep" : "n/r";
},
};
}
function labelled(name: string, ...controls: HTMLElement[]): HTMLElement {
const row = el("div", "gm-row");
row.append(el("span", "gm-label", name), ...controls);
return row;
}
function section(title: string): { el: HTMLElement; body: HTMLElement } {
const wrap = el("section", "gm-section");
wrap.append(el("h2", "gm-h", title));
const inner = el("div", "gm-section-body");
wrap.append(inner);
return { el: wrap, body: inner };
}
// ---- Stylesheet --------------------------------------------------------------
let sheetNode: HTMLStyleElement | null = null;
let sheetUsers = 0;
function acquireStylesheet(): { release(): void } {
if (!sheetNode) {
sheetNode = document.createElement("style");
sheetNode.dataset.godmode = "";
sheetNode.textContent = STYLE;
document.head.append(sheetNode);
}
sheetUsers += 1;
let released = false;
return {
release() {
if (released) return;
released = true;
sheetUsers -= 1;
if (sheetUsers > 0) return;
sheetNode?.remove();
sheetNode = null;
},
};
}
/**
* One stylesheet, injected, every token with a fallback.
*
* The fallbacks are not defensive padding. This module is meant to be liftable
* into any page that has a `Stage` — that is what `src/tools/` is for — and a
* panel that renders as unstyled black-on-white text because the host has no
* `--glass` would be useless in exactly the situation where you most need to
* read it. With `index.html` present every value resolves to the app's own and
* the panel is indistinguishable from the rest of the chrome.
*
* The collapsed panel is one tab, bottom centre, sitting above the key-hint rail
* and clear of every other card at every width. Open, it is a wide sheet over
* the middle of the map and it will overlap the corners on a small screen; that
* is the correct trade for an instrument you opened deliberately and close with
* one key, and it is why the sheet uses the strong glass rather than the light
* one.
*/
const STYLE = `
.gm-root {
position: fixed;
left: 0;
right: 0;
bottom: calc(var(--s4, 16px) + 2.4rem + env(safe-area-inset-bottom));
/* The instrument layer, named rather than numbered. See src/ui/tokens.ts:
above the play surfaces on purpose — a debug readout that a play HUD can
hide is a debug readout you will misread — and below the modal layer, so a
dialog can still open over it. */
z-index: var(--z-instrument, 6);
display: flex;
flex-direction: column;
align-items: center;
gap: var(--s1, 4px);
pointer-events: none;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: var(--ink, rgba(255, 255, 255, 0.78));
}
.gm-root > * { pointer-events: auto; }
/*
* The hidden attribute has to keep working, and on its own it does not.
*
* The UA stylesheet's [hidden] { display: none } is an attribute selector at the
* lowest cascade origin, so any class rule here that sets a display — the drawer
* is a grid, the banner is a flex row — silently outranks it, and both were
* still on screen with the attribute set. index.html happens to carry an
* !important version of the same rule, which is exactly the kind of accident
* this module must not inherit: it would work in the app and be broken anywhere
* else, including in a harness, which is where it was found.
*/
.gm-root [hidden] { display: none !important; }
.gm-root :is(button, input, select):focus-visible {
outline: 2px solid var(--amber, #f2b134);
outline-offset: 2px;
}
.gm-tab {
display: flex;
align-items: center;
gap: var(--s2, 8px);
font: inherit;
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 5px 10px;
cursor: pointer;
color: var(--amber-ink, #ffd68a);
background: var(--glass, rgba(9, 13, 18, 0.62));
backdrop-filter: var(--blur, blur(14px) saturate(1.2));
-webkit-backdrop-filter: var(--blur, blur(14px) saturate(1.2));
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
box-shadow: var(--shadow, 0 6px 22px rgba(3, 6, 10, 0.45));
}
.gm-tab[aria-expanded="true"] { background: rgba(242, 177, 52, 0.22); }
.gm-kbd {
font: inherit;
font-size: 9px;
padding: 0 4px;
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: 3px;
color: var(--ink-3, rgba(255, 255, 255, 0.4));
}
.gm-banner {
display: flex;
align-items: center;
gap: var(--s2, 8px);
max-width: calc(100vw - var(--s4, 16px) * 2);
padding: 4px var(--s2, 8px);
font-size: 10px;
letter-spacing: 0.06em;
color: var(--amber-ink, #ffd68a);
background: var(--glass-strong, rgba(9, 13, 18, 0.86));
backdrop-filter: var(--blur, blur(14px) saturate(1.2));
-webkit-backdrop-filter: var(--blur, blur(14px) saturate(1.2));
border: 1px dashed rgba(242, 177, 52, 0.75);
border-radius: var(--r-sm, 5px);
}
.gm-banner-text { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.gm-body {
width: min(46rem, calc(100vw - var(--s4, 16px) * 2));
max-height: min(56dvh, 30rem);
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
display: grid;
grid-template-columns: repeat(auto-fit, minmax(13.5rem, 1fr));
gap: var(--s3, 12px);
padding: var(--s3, 12px);
background: var(--glass-strong, rgba(9, 13, 18, 0.86));
backdrop-filter: var(--blur, blur(14px) saturate(1.2));
-webkit-backdrop-filter: var(--blur, blur(14px) saturate(1.2));
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r, 8px);
box-shadow: var(--shadow, 0 6px 22px rgba(3, 6, 10, 0.45));
}
.gm-h {
margin: 0 0 var(--s2, 8px);
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--amber, #f2b134);
}
.gm-section-body { display: flex; flex-direction: column; gap: var(--s1, 4px); }
.gm-stamp {
font-size: 11px;
color: var(--ink, rgba(255, 255, 255, 0.78));
font-variant-numeric: tabular-nums;
}
.gm-line {
font-size: 10px;
color: var(--ink-2, rgba(255, 255, 255, 0.56));
font-variant-numeric: tabular-nums;
}
.gm-hint {
font-size: 9px;
line-height: 1.5;
color: var(--ink-3, rgba(255, 255, 255, 0.4));
}
.gm-moonrow { display: flex; align-items: center; gap: var(--s2, 8px); }
.gm-moon { width: 20px; height: 20px; flex: none; }
.gm-row { display: flex; align-items: center; gap: var(--s2, 8px); min-height: 18px; }
.gm-label {
flex: none;
width: 3.2rem;
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-3, rgba(255, 255, 255, 0.4));
}
.gm-value {
flex: none;
min-width: 4rem;
text-align: right;
font-size: 10px;
color: var(--ink-2, rgba(255, 255, 255, 0.56));
font-variant-numeric: tabular-nums;
}
.gm-slider { flex: 1; min-width: 0; accent-color: var(--amber, #f2b134); height: 14px; }
.gm-slider:disabled { opacity: 0.35; }
.gm-select {
flex: 1;
min-width: 0;
font: inherit;
font-size: 10px;
padding: 2px 4px;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: rgba(255, 255, 255, 0.08);
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: 3px;
}
.gm-chips { display: flex; flex-wrap: wrap; gap: var(--s1, 4px); }
.gm-chip, .gm-mini {
font: inherit;
font-size: 9px;
letter-spacing: 0.06em;
padding: 3px 6px;
cursor: pointer;
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r-sm, 5px);
background: rgba(255, 255, 255, 0.08);
color: var(--ink-2, rgba(255, 255, 255, 0.56));
transition: background var(--t, 150ms ease), color var(--t, 150ms ease);
}
.gm-chip-wide { width: 100%; }
.gm-chip:hover:not(:disabled), .gm-mini:hover { background: rgba(255, 255, 255, 0.16); }
.gm-chip:disabled { opacity: 0.3; cursor: default; }
.gm-chip[aria-pressed="true"] {
background: rgba(242, 177, 52, 0.22);
color: var(--amber-ink, #ffd68a);
}
.gm-nr[aria-pressed="true"] { border-style: dashed; color: var(--ink-3, rgba(255,255,255,0.4)); }
.gm-ledger { display: flex; flex-direction: column; gap: 2px; }
.gm-ledger-row {
display: flex;
align-items: baseline;
gap: var(--s1, 4px);
font-size: 9px;
font-variant-numeric: tabular-nums;
color: var(--ink-2, rgba(255, 255, 255, 0.56));
}
/* One board to a line, ellipsised. A wrapped name turns a four-row table into a
nine-row one and the deltas stop lining up under each other, which is the only
reason the table is a table. */
.gm-ledger-name {
flex: 1;
min-width: 0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.gm-ledger-n { flex: none; color: var(--ink-3, rgba(255, 255, 255, 0.4)); }
.gm-ledger-d { flex: none; min-width: 2.6rem; text-align: right; }
.gm-leak { color: var(--amber-lit, #ffc555); }
/*
* The pose editor's dock, styled here rather than in index.html.
*
* main.ts builds #pose-dock and used to set its whole geometry through
* Object.assign(dock.style, ...) — position, offsets, width, max-height, flex —
* with a comment explaining why: every visitor downloads index.html, and a
* #pose-dock rule sitting in it that can never match is a trace of an
* instrument that was not shipped to them.
*
* That reasoning is right and the conclusion was one file off. This stylesheet
* is injected by createGodmode, which is reached only through the dynamic
* import behind access.can.debug, so these bytes reach exactly the same
* audience the inline styles did — and unlike them, they can carry a media
* query and a safe-area inset. The dock had neither, which put it under the home
* indicator on the one device class that has one.
*/
#pose-dock {
position: fixed;
right: var(--s4, 16px);
bottom: calc(var(--s4, 16px) + 5rem + env(safe-area-inset-bottom));
z-index: var(--z-play, 5);
width: min(20rem, calc(100vw - var(--s4, 16px) * 2));
max-height: min(52dvh, calc(100dvh - 26rem));
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--s1, 4px);
}
#pose-dock > button { align-self: flex-end; font-family: inherit; }
#pose-dock > div { flex: 1; min-height: 0; display: flex; }
@media (max-width: 600px) {
#pose-dock {
right: var(--s3, 12px);
left: var(--s3, 12px);
width: auto;
bottom: calc(var(--s3, 12px) + 8.5rem + env(safe-area-inset-bottom));
}
}
.gm-hud {
position: fixed;
top: calc(var(--s4, 16px) + env(safe-area-inset-top));
left: 50%;
transform: translateX(-50%);
margin: 0;
z-index: var(--z-instrument, 6);
max-width: min(34rem, calc(100vw - var(--s4, 16px) * 2));
overflow-x: auto;
padding: var(--s2, 8px) var(--s3, 12px);
font-family: inherit;
font-size: 10px;
line-height: 1.6;
white-space: pre;
color: var(--ink, rgba(255, 255, 255, 0.78));
background: var(--glass-strong, rgba(9, 13, 18, 0.86));
backdrop-filter: var(--blur, blur(14px) saturate(1.2));
-webkit-backdrop-filter: var(--blur, blur(14px) saturate(1.2));
border: 1px solid var(--hairline, rgba(255, 255, 255, 0.11));
border-radius: var(--r, 8px);
box-shadow: var(--shadow, 0 6px 22px rgba(3, 6, 10, 0.45));
}
@media (max-width: 600px) {
.gm-body { max-height: 62dvh; }
.gm-hud { max-width: calc(100vw - var(--s3, 12px) * 2); }
}
@media (prefers-reduced-motion: reduce) {
.gm-chip, .gm-mini { transition: none; }
}
`;