1
0

A plan view in the corner, a night you can actually see, and three kinds of visitor

The right half of the screen was empty sky. It holds the board now, drawn flat,
with the footprint of the camera's own frustum on it — the one part of a minimap
that earns its place, because it answers "where am I looking from" without
leaving the shot. Click it, drag it, scroll it. It is a 2D canvas rather than a
second WebGL context, cached per city and redrawn only when something moved.

Night was black. Not dark — black: at 3 a.m. the coastline, the hills and the
bay were one shape, and the frame read as a failed render rather than as
darkness. The sky already had a floor for exactly this reason and nothing did
the equivalent for the ground, so the ground has one now. The moon still has to
be worth computing, so the gap between a moonlit night and a moonless one is
preserved rather than filled in.

Three tiers, resolved once in the new src/access.ts: anonymous, signed in,
admin. Anonymous gets the map and a public office — the shell, the furniture,
the named viewpoints, nobody home — built without the private objects rather
than with them hidden, because scene.traverse makes hiding a leak with a bow on
it. The time scrubber and the debug readouts are admin only, and admin is
granted by TERA_ADMIN_SUBJECTS on the server and inferred nowhere else. An
unreachable API means member, never god: the promise is "clone it and it works",
not "clone it and you are an administrator of a deployment you did not
configure".

Three things this run found and fixed rather than shipped:

  - entryUrl came off the wire and went straight into an href with no scheme
    check, and a CSP of script-src 'self' 'unsafe-inline' does not stop a
    javascript: URL from navigating. One rejection point in access.ts now.
  - A 5xx from /health was the same null as "no API at all" and therefore the
    opposite conclusion. Eight seconds of tera-api restarting would have told
    every anonymous visitor they were a member. A 5xx is an answer; it fails
    closed.
  - decodeURIComponent in cookieToken was the one path in auth/index.ts that
    threw rather than returning ANONYMOUS, so one malformed cookie header from
    an unauthenticated caller turned /api/v1/session into a 500.

Also: keyboard shortcuts, focus rings, a boot state instead of a blank 2.3
seconds, a collapsible panel under 900px, and no horizontal overflow at 375,
768, 1440 or 2560.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-08-05 22:53:30 -07:00
parent 47faec9f9d
commit 5bc7258753
24 changed files with 3982 additions and 211 deletions
+504 -46
View File
@@ -22,7 +22,11 @@ import SOCAL from "./cities/socal.ts";
import { createTeraClient } from "./adapters/http.ts";
import { SAMPLE_MARKERS, SAMPLE_PALETTE, SAMPLE_ROUTES } from "./adapters/sample.ts";
import { createOfficeScene, type OfficeScene } from "./interiors/officeScene.ts";
import { authFetch } from "./session.ts";
import LUMBRIDGE_HQ from "./offices/lumbridge-hq.ts";
import { capabilitiesFor, resolveAccess, type Access } from "./access.ts";
import { createMinimap, type Minimap } from "./engine/minimap.ts";
import { MaterialRegistry } from "./assets/materials.ts";
const CITIES: { id: string; label: string; city: City }[] = [
{ id: "sf", label: "Bay Area", city: SAN_FRANCISCO },
@@ -32,7 +36,10 @@ const CITIES: { id: string; label: string; city: City }[] = [
const canvas = document.querySelector<HTMLCanvasElement>("#scene");
if (!canvas) throw new Error("#scene canvas missing");
const tera = createTeraClient();
// `authFetch` so the private-office pack (`/api/v1/offices/:id`, which answers
// 404 rather than 403 to anyone who may not see it) is requested as the signed-in
// viewer. On a `password`-mode or open deployment it is an ordinary fetch.
const tera = createTeraClient({ fetch: authFetch });
let city: SceneHandle | null = null;
let cityId = "sf";
@@ -41,8 +48,47 @@ let inside = false;
let markers: Marker[] = SAMPLE_MARKERS;
let palette: MarkerPalette = SAMPLE_PALETTE;
let liveData = false;
let canEnterOffice = false;
/**
* What this visitor may do. Resolved once in `boot()`; every gate below reads
* `access.can.*` and nothing else.
*
* The pre-boot value is the **closed** one, deliberately. A handler that
* somehow fires before `resolveAccess()` has settled — a keystroke on a slow
* connection, a click on a control that is in the document from first paint —
* should offer a visitor less than they are entitled to and never more. The
* rule that produces this value, and the SSO bug that once produced it wrongly,
* are written out in `src/access.ts`.
*/
let access: Access = {
tier: "anon",
subject: null,
signInUrl: null,
can: capabilitiesFor("anon"),
};
let atmosphere: ReturnType<typeof createAtmosphere> | null = null;
let minimap: Minimap | null = null;
/**
* One texture set for every office this page ever builds.
*
* Signing in while standing in the public office is a `dispose()` and a second
* `createOfficeScene` at `"full"` — cheap only if both are handed the same
* registry, because drawing the textures is the expensive part and a registry
* draws them once. Owned here and disposed nowhere: it outlives every scene
* that borrows it, and the page teardown takes the process with it.
*/
const officeMaterials = new MaterialRegistry({ quality: "high" });
/**
* `office.lumbridgecorp.com` and `tera.lumbridgecorp.com` are one bundle behind
* two names, and which name you arrived at is the whole difference: one is a
* map with an office in it, the other is an office with a map behind it. The
* city is still built underneath either way — that is what makes "← Back to the
* city" work from the office front door — so this is a policy about where boot
* *stops*, not about what boot builds.
*/
const OPENS_IN_OFFICE =
location.hostname.split(".")[0] === "office" ||
new URLSearchParams(location.search).get("view") === "office";
// ---- Time -----------------------------------------------------------------
@@ -67,6 +113,10 @@ function updateSun() {
const env = observe(active.center.lat, active.center.lng, currentInstant());
city.setLighting(atmosphere.apply(env));
city.setSolarElevation(env.sun.elevation);
// The plan view follows the same day the map does. It computes its own
// palette from this one number rather than reading the rig, because a rig is
// a set of three.js lights and the minimap has none.
minimap?.setSolarElevation(env.sun.elevation);
const clock = document.querySelector<HTMLElement>("#clock");
if (!clock) return;
@@ -92,21 +142,38 @@ function mountCity(id: string) {
office?.dispose();
office = null;
inside = false;
minimap?.dispose();
minimap = null;
city?.dispose();
cityId = id;
city = createScene(canvas, {
city: entry.city,
markerPalette: palette,
flights: liveData ? tera.flights() : new SimulatedFlights(SAMPLE_ROUTES),
// `liveData` alone is not enough: it only records that a feed answered
// once, at boot, before the tier was known. An anonymous visitor asking for
// live traffic gets an empty sky rather than the simulation, which looks
// like a broken layer instead of an honest one.
flights:
access.can.liveData && liveData ? tera.flights() : new SimulatedFlights(SAMPLE_ROUTES),
onMarkerPick: (m) => showDetail(m ? `${m.label}${m.blurb ? `${m.blurb}` : ""}` : null),
});
// Fog distances are scene units, so they have to follow the board — 210/460
// was tuned for a 230-unit San Francisco and fogs out most of a 1000-unit
// Bay Area. They also have to clear the CAMERA, which sits about 0.6 spans
// out on the whole-board view: a fog starting nearer than that is behind the
// viewer's own shoulder, and at night, when the fog colour is nearly black
// rather than bright haze, it turns the entire map off.
// viewer's own shoulder, and every pixel in frame is then at full fog.
//
// That last failure used to be catastrophic and is now only bad, and the
// difference is worth recording because the comment used to claim the worse
// version. The night fog colour is derived from the sky, the night sky was
// nearly black, and so a fog plane behind the camera turned the entire map
// off. `atmosphere.ts` now floors the night ground rig and stops the
// obscuration convergence subtracting it again, and the night fog here lands
// around #16203a — aerial perspective that lifts distance rather than a
// blackout. The clearance is still required: a board flattened to one uniform
// value is unreadable at any brightness. It is no longer the difference
// between a map and a black rectangle.
const [wx, nz] = city.world.project(entry.city.bounds.maxLat, entry.city.bounds.minLng);
const [ex, sz] = city.world.project(entry.city.bounds.minLat, entry.city.bounds.maxLng);
const span = Math.max(Math.abs(ex - wx), Math.abs(sz - nz));
@@ -128,18 +195,100 @@ function mountCity(id: string) {
});
city.setMarkers(id === "sf" ? markers : []);
city.onChapterChange(() => renderLegend());
/**
* The plan view, built last, because it reads the finished `World` — the
* heightfield the terrain has already paid for — and the live camera and
* controls the scene has just made. It is torn down and rebuilt with the
* city for the same reason the city is: nothing in it survives a change of
* board, and it holds a `World` that would otherwise leak.
*/
minimap = createMinimap({
world: city.world,
city: entry.city,
camera: city.stageScene.camera,
controls: city.stageScene.controls,
markerPalette: palette,
onSeek(lat, lng) {
if (!city) return;
/**
* Slide the orbit target and carry the camera with it, keeping the offset
* between them. A seek is "look over there", not "go to chapter three":
* snapping to a chapter pose throws away the angle and the distance the
* user spent the last minute choosing, and doing it from a click on a map
* is the kind of surprise that stops people clicking on the map.
*
* No easing, deliberately. `flyTo` would need a pose, which is the thing
* being avoided, and an instant move is also the correct answer under
* `prefers-reduced-motion`.
*/
const { camera, controls } = city.stageScene;
const [x, z] = city.world.project(lat, lng);
const y = city.world.groundAt(lat, lng);
const dx = camera.position.x - controls.target.x;
const dy = camera.position.y - controls.target.y;
const dz = camera.position.z - controls.target.z;
controls.target.set(x, y, z);
camera.position.set(x + dx, y + dy, z + dz);
},
onHover(info) {
if (!minimapReadout) return;
minimapReadout.textContent = info
? `${info.lat.toFixed(4)}, ${info.lng.toFixed(4)}${info.district ? ` · ${info.district}` : ""}`
: "";
},
});
minimapFrame?.replaceChildren(minimap.canvas);
minimap.setMarkers(id === "sf" ? markers : []);
updateSun();
renderLegend();
}
/**
* The minimap's own frame pump.
*
* `Stage` owns the render loop and `SceneHandle` exposes no per-frame hook, so
* the alternative is adding an `onTick` to the scene handle for exactly one
* call site. This is the smaller change and it costs nothing measurable: an
* idle `tick()` is a timestamp comparison and a dirty flag, 0.0002 ms, and the
* loop keeps running unchanged across a city swap, across the office swap, and
* during the window where there is no minimap at all.
*/
requestAnimationFrame(function pumpMinimap() {
requestAnimationFrame(pumpMinimap);
minimap?.tick();
});
// ---- Office ---------------------------------------------------------------
/**
* Everyone gets in. The tier picks which building they get, not whether the
* door opens.
*
* The office used to be members-only, and the anonymous view of this site was a
* map with a greyed-out button on it — the single most interesting thing the
* project does, visible only as something you cannot have. At `"public"` depth
* the same shell, the same furniture and the same named viewpoints are built,
* and the only thing missing is the people. That is withheld because the API
* refuses occupancy to an anonymous caller, not because this function declined
* to draw it.
*/
function enterOffice() {
if (!city || !canEnterOffice) return;
if (!city) return;
if (!office) {
const depth = access.can.officeDepth;
office = createOfficeScene(LUMBRIDGE_HQ, {
dom: city.stage.renderer.domElement,
background: 0x11161c,
depth,
materials: officeMaterials,
// Two different questions, so two different callbacks. `onPresencePick`
// answers "who is at this desk"; `onPlacePick` answers only "this is a
// desk, and it is the fourteenth one" — which is all a stranger is told.
...(depth === "full"
? { onPresencePick: (p) => showDetail(p ? p.label : null) }
: { onPlacePick: (place) => showDetail(place ? place.label : null) }),
});
office.onViewChange(() => renderLegend());
}
@@ -166,6 +315,14 @@ const subtitle = document.querySelector<HTMLElement>("#subtitle");
const enterButton = document.querySelector<HTMLButtonElement>("#enter");
const cityNav = document.querySelector<HTMLElement>("#cities");
const source = document.querySelector<HTMLElement>("#source");
const minimapFrame = document.querySelector<HTMLElement>("#minimap .minimap-frame");
const minimapReadout = document.querySelector<HTMLElement>("#minimap-readout");
const tierBadge = document.querySelector<HTMLElement>("#tier");
const officeBadge = document.querySelector<HTMLElement>("#office-badge");
const panelToggle = document.querySelector<HTMLButtonElement>("#panel-toggle");
const panelToggleLabel = document.querySelector<HTMLElement>("#panel-toggle-label");
const shortcutsCard = document.querySelector<HTMLElement>("#shortcuts");
const helpButton = document.querySelector<HTMLButtonElement>("#help");
function showDetail(text: string | null) {
const card = document.querySelector<HTMLElement>("#detail");
@@ -179,12 +336,14 @@ function renderCityPicker() {
cityNav.replaceChildren();
for (const c of CITIES) {
const b = document.createElement("button");
b.className = c.id === cityId && !inside ? "city active" : "city";
// `aria-pressed` rather than a class, because that is what these are: two
// buttons of which exactly one is on. The stylesheet keys off the attribute
// so the visual state and the announced state cannot drift apart.
b.className = "city";
b.type = "button";
b.setAttribute("aria-pressed", String(c.id === cityId && !inside));
b.textContent = c.label;
b.addEventListener("click", () => {
if (inside) leaveOffice();
if (c.id !== cityId) mountCity(c.id);
});
b.addEventListener("click", () => switchCity(c.id));
cityNav.append(b);
}
}
@@ -199,13 +358,12 @@ function renderLegend() {
nav.replaceChildren();
views.forEach((view, i) => {
const button = document.createElement("button");
button.className = view.id === activeId ? "chapter active" : "chapter";
button.className = "chapter";
button.type = "button";
button.setAttribute("aria-pressed", String(view.id === activeId));
const number = view.number ?? String(i + 1).padStart(2, "0");
button.innerHTML = `<span class="num">${number}</span><span>${view.shortLabel}</span>`;
button.addEventListener("click", () => {
if (inside && office) office.flyTo(view.id);
else city?.flyTo(view.id);
});
button.addEventListener("click", () => flyToIndex(i));
nav.append(button);
});
@@ -220,64 +378,364 @@ function renderLegend() {
subtitle.textContent = inside ? "Spaces · a Lumbridge office" : "Tera · Lumbridge Simulate";
}
if (enterButton) {
if (inside) enterButton.textContent = "← Back to the city";
else if (canEnterOffice) enterButton.textContent = "Enter the office →";
else enterButton.textContent = "Sign in to enter the office →";
// One label for everyone. The door is open at both tiers; what differs is
// what is behind it, and that is the badge's job to say, not the button's.
enterButton.textContent = inside ? "← Back to the city" : "Enter the office →";
}
if (source) {
source.textContent = liveData ? "live data" : "sample data · fabricated, not real companies";
source.className = liveData ? "source live" : "source";
}
if (panelToggleLabel) panelToggleLabel.textContent = inside ? "Office" : cityLabel;
if (canvas) {
canvas.setAttribute(
"aria-label",
inside
? `${LUMBRIDGE_HQ.name}, seen from above. Drag to orbit, scroll to zoom.`
: `Map of ${cityLabel}, seen from above. Drag to orbit, scroll to zoom.`,
);
}
minimap?.setChapters(city.chapters, city.current());
renderOfficeBadge();
}
enterButton?.addEventListener("click", () => {
/**
* The one thing a public visitor is actually missing, said in the place where
* they would notice it missing.
*
* An empty office with no explanation reads as a bug — a floor that failed to
* load — and the fix for that is a sentence, not a disabled button. The
* sign-in link is offered *beside* the office rather than in front of it, so it
* is an upgrade and never a toll gate.
*/
function renderOfficeBadge() {
if (!officeBadge) return;
const publicOffice = inside && office !== null && office.depth === "public";
officeBadge.hidden = !publicOffice;
if (!publicOffice) return;
officeBadge.replaceChildren(
document.createTextNode("Public view — the building, not the people. "),
);
if (access.signInUrl !== null) {
const link = document.createElement("a");
link.href = access.signInUrl;
link.textContent = "Sign in for the live floor";
officeBadge.append(link, document.createTextNode("."));
} else {
officeBadge.append(document.createTextNode("Sign in to see who's in."));
}
}
/**
* Who the site thinks you are, in the corner, always. Three words and a name.
*
* It is here rather than buried in a menu because every other difference on
* this page — an empty office, sample markers, a missing scrubber — is a
* *silence*, and a silence you cannot attribute is indistinguishable from a
* fault. This is the line that tells you which of the two you are looking at.
*/
function renderTierBadge() {
if (!tierBadge) return;
tierBadge.className = `card tier ${access.tier}`;
const label = document.createElement("span");
/**
* The label names what you *get*, not who you are, and that is deliberate.
* "Signed in" was the first draft and it is a lie in the commonest case:
* a clean clone with no API at all resolves to `member`, and telling someone
* they are signed in to a server that does not exist is the sort of small
* dishonesty that makes the rest of the interface untrustworthy. "Full view"
* is true whether the tier came from a session or from there being nothing to
* have a session with; the subject, when there is one, says the rest.
*/
label.textContent =
access.tier === "god" ? "Godmode" : access.tier === "member" ? "Full view" : "Public view";
tierBadge.replaceChildren(label);
if (access.subject !== null) {
const who = document.createElement("span");
who.className = "who";
who.textContent = access.subject;
tierBadge.append(who);
} else if (access.signInUrl !== null) {
const link = document.createElement("a");
link.href = access.signInUrl;
link.textContent = "Sign in";
tierBadge.append(link);
}
tierBadge.hidden = false;
}
// ---- Navigation -------------------------------------------------------------
/** The views on offer right now — city chapters, or office viewpoints inside. */
function currentViews(): View[] {
if (inside && office) return office.views;
return city?.chapters ?? [];
}
function flyToIndex(index: number) {
const view = currentViews()[index];
if (!view) return;
if (inside && office) office.flyTo(view.id);
else city?.flyTo(view.id);
}
function switchCity(id: string) {
if (inside) leaveOffice();
else if (canEnterOffice) enterOffice();
else window.location.href = "/login.html";
if (id === cityId) return;
const label = CITIES.find((c) => c.id === id)?.label ?? id;
void building(`Building ${label}`, () => mountCity(id));
}
function stepCity(delta: number) {
const at = CITIES.findIndex((c) => c.id === cityId);
const next = CITIES[(at + delta + CITIES.length) % CITIES.length];
if (next) switchCity(next.id);
}
function toggleOffice() {
if (inside) {
leaveOffice();
return;
}
// Only the first entry builds anything; after that the office is parked in
// memory next to the paused city and the swap is a pointer.
if (office) enterOffice();
else void building("Building the office…", () => enterOffice());
}
enterButton?.addEventListener("click", () => toggleOffice());
// ---- Panels, plan and overlays ----------------------------------------------
/**
* Two pieces of chrome are a *user* decision rather than a media query, and the
* distinction matters: a media query that hides the plan below 600px also makes
* `M` do nothing there, which is the width where a plan view is most useful and
* least affordable. So the width only seeds the initial state, and the moment
* someone presses the key the viewport stops having an opinion.
*/
let panelOpen = window.innerWidth > 900;
let planOpen = window.innerWidth > 600;
let planChosen = false;
function applyPanel() {
document.body.classList.toggle("panel-closed", !panelOpen);
panelToggle?.setAttribute("aria-expanded", String(panelOpen));
}
function applyPlan() {
document.body.classList.toggle("minimap-off", !planOpen);
}
panelToggle?.addEventListener("click", () => {
panelOpen = !panelOpen;
applyPanel();
});
window.addEventListener("resize", () => {
if (!planChosen) {
planOpen = window.innerWidth > 600;
applyPlan();
}
});
function openShortcuts() {
if (!shortcutsCard || !shortcutsCard.hidden) return;
shortcutsCard.hidden = false;
document.querySelector<HTMLButtonElement>("#shortcuts-close")?.focus();
}
function closeShortcuts() {
if (!shortcutsCard || shortcutsCard.hidden) return;
shortcutsCard.hidden = true;
helpButton?.focus();
}
helpButton?.addEventListener("click", () => openShortcuts());
document.querySelector<HTMLElement>("#shortcuts-close")?.addEventListener("click", closeShortcuts);
shortcutsCard?.addEventListener("click", (event) => {
// The backdrop, not the sheet. Clicking the card itself must not close it.
if (event.target === shortcutsCard) closeShortcuts();
});
/**
* Keyboard access to everything the mouse can reach.
*
* Bound to `window` rather than to the canvas, because the canvas is only
* focusable by accident and a shortcut that stops working when you tab to the
* legend is worse than no shortcut. The guard is the usual one: a keystroke
* that lands in a text field or on the plan view's own arrow-key handler
* belongs to that control, not to this.
*/
window.addEventListener("keydown", (event) => {
if (event.metaKey || event.ctrlKey || event.altKey) return;
const target = event.target;
if (
target instanceof HTMLInputElement ||
target instanceof HTMLTextAreaElement ||
(target instanceof HTMLElement && target.isContentEditable)
) {
return;
}
if (event.key === "Escape") {
if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts();
else if (inside) leaveOffice();
else showDetail(null);
return;
}
if (event.key === "?") {
if (shortcutsCard && !shortcutsCard.hidden) closeShortcuts();
else openShortcuts();
event.preventDefault();
return;
}
if (event.key >= "1" && event.key <= "9") {
flyToIndex(Number(event.key) - 1);
return;
}
if (event.key === "[") {
stepCity(-1);
return;
}
if (event.key === "]") {
stepCity(1);
return;
}
const lower = event.key.toLowerCase();
if (lower === "m") {
planOpen = !planOpen;
planChosen = true;
applyPlan();
return;
}
if (lower === "o") toggleOffice();
});
// ---- Time -------------------------------------------------------------------
const scrubber = document.querySelector<HTMLInputElement>("#hour");
scrubber?.addEventListener("input", () => {
if (!access.can.timeControl) return;
hourOverride = Number(scrubber.value);
updateSun();
});
document.querySelector<HTMLElement>("#now")?.addEventListener("click", () => {
if (!access.can.timeControl) return;
hourOverride = null;
if (scrubber) scrubber.value = String(new Date().getHours());
updateSun();
});
/**
* The scrubber is an instrument, and instruments are god-only. The *clock* is
* not: a map that will not tell you what time it is showing is worse than one
* you cannot scrub, so `#clock` stays outside `#scrub` and stays visible to
* everyone.
*
* `hidden` rather than `disabled`, because a disabled slider is still a tab
* stop and still announces itself — an affordance offered and withdrawn in the
* same breath. Without the control there is no override, so the clock follows
* the wall clock, which is the honest default anyway.
*/
function applyTimeControl() {
const scrub = document.querySelector<HTMLElement>("#scrub");
if (scrub) scrub.hidden = !access.can.timeControl;
if (!access.can.timeControl) hourOverride = null;
if (scrubber) scrubber.value = String(new Date().getHours());
}
// ---- The boot card ----------------------------------------------------------
const bootCard = document.querySelector<HTMLElement>("#boot");
const bootStep = document.querySelector<HTMLElement>("#boot-step");
/**
* Wait until the browser has actually put pixels on the glass.
*
* Writing to `textContent` and then immediately building a heightfield paints
* nothing: the style change and the two seconds of synchronous work are in the
* same task, so the frame the user sees is the one *after* the work. Two
* `requestAnimationFrame`s straddle a paint, which is the whole trick — a
* single one still runs before it.
*/
function painted(): Promise<void> {
return new Promise((resolve) => {
requestAnimationFrame(() => requestAnimationFrame(() => resolve()));
});
}
/**
* Run something slow and synchronous with the boot card up and a sentence
* saying what it is.
*
* The Bay Area heightfield takes about 2.3 s and the office about half that.
* Neither can be made asynchronous without splitting the builders across
* frames, which is a large change to earn a progress bar. Naming the work is
* most of the value: a blank page for two seconds reads as broken, and
* "Building the Bay Area…" for two seconds reads as busy.
*/
async function building<T>(label: string, work: () => T): Promise<T> {
if (bootStep) bootStep.textContent = label;
if (bootCard) {
bootCard.hidden = false;
bootCard.classList.remove("done");
}
await painted();
const result = work();
// A second paint before the fade, so the first frame of the finished scene is
// behind the card rather than appearing with it.
await painted();
bootCard?.classList.add("done");
window.setTimeout(() => {
if (bootCard?.classList.contains("done")) bootCard.hidden = true;
}, 300);
return result;
}
// ---- Boot -----------------------------------------------------------------
/**
* Markers are awaited before the scene is built, because `markerPalette` is
* fixed at construction and the sample palette's keys are not the API's.
* Everything else about the API is optional: no server means sample data and a
* label saying so.
* Access first, then data, then the board.
*
* The order is load-bearing in both directions and it used to be wrong. Markers
* were fetched before the tier was known, which is a request an anonymous
* visitor should not be making; and both decisions have to be settled before
* the *first* `mountCity`, because `markerPalette` is fixed at scene
* construction — the sample palette's keys are not the API's — and the flight
* source is chosen in the same call.
*
* Everything about the API remains optional. No server means the bundled sample
* set, the simulated traffic, and a label at the bottom of the screen saying
* which of the two you are looking at.
*/
async function boot() {
try {
const feed = await tera.markers();
markers = feed.value;
palette = feed.palette;
liveData = feed.live;
} catch {
// A missing API is the self-host default, not an error.
}
try {
const res = await fetch("/api/v1/session", { credentials: "same-origin" });
if (res.ok) {
const s = (await res.json()) as { authenticated: boolean; passwordLogin: boolean };
canEnterOffice = s.authenticated || !s.passwordLogin;
} else {
canEnterOffice = true;
applyPanel();
applyPlan();
if (bootStep) bootStep.textContent = "Asking the deployment who you are…";
access = await resolveAccess();
applyTimeControl();
renderTierBadge();
if (access.can.liveData) {
try {
const feed = await tera.markers();
markers = feed.value;
palette = feed.palette;
liveData = feed.live;
} catch {
// A missing API is the self-host default, not an error.
}
} catch {
// No server means nothing to sign in to, so the office is open. That is the
// self-host posture: auth is something a deployment adds, not removes.
canEnterOffice = true;
}
mountCity("sf");
const first = CITIES[0];
await building(`Building ${first?.label ?? "the city"}`, () => mountCity(first?.id ?? "sf"));
// The `office.` front door. The city is already standing behind this, so the
// back button is a scene swap and not a rebuild.
if (OPENS_IN_OFFICE) await building("Building the office…", () => enterOffice());
window.setInterval(() => hourOverride === null && updateSun(), 60_000);
}