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/login.html
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

251 lines
11 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
<meta name="color-scheme" content="dark" />
<meta name="robots" content="noindex" />
<title>Sign in — Lumbridge Simulate</title>
<style>
/*
* The same stack index.html uses. No webfont, no CDN, nothing to fetch:
* this page has to work on a box with no network but its own — which is
* also why the tokens are restated here rather than imported. They are the
* same values `src/ui/tokens.ts` publishes; this page is served on its own
* and cannot see the map's stylesheet.
*/
:root {
--amber: #f2b134;
--amber-lit: #ffc555;
--ink: rgba(255, 255, 255, 0.78);
--ink-2: rgba(255, 255, 255, 0.56);
--ink-3: rgba(255, 255, 255, 0.4);
--ink-4: rgba(255, 255, 255, 0.26);
--hairline: rgba(255, 255, 255, 0.11);
--r: 8px;
--r-sm: 5px;
--tap: 44px;
}
* { box-sizing: border-box; }
html, body { margin: 0; height: 100%; background: #0d1219;
font-family: ui-monospace, "SF Mono", Menlo, monospace; color: var(--ink); }
body { display: flex; align-items: center; justify-content: center;
padding: max(1rem, env(safe-area-inset-top)) 1rem calc(1rem + env(safe-area-inset-bottom)); }
main { width: 20rem; }
h1 { margin: 0; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase;
color: var(--amber); }
h1 + p { margin: 0.15rem 0 1.1rem; font-size: 11px; line-height: 1.5;
color: var(--ink-3); }
form { display: flex; flex-direction: column; gap: 0.5rem;
background: rgba(255,255,255,0.04); border: 1px solid var(--hairline);
border-radius: var(--r); padding: 0.9rem; }
label { font-size: 10px; letter-spacing: 0.08em; text-transform: uppercase;
color: var(--ink-3); }
/* Every field and the button clear the 44px touch target, the same one the
map's chrome is held to. This form was ~34px tall per control. */
input { font: inherit; font-size: 13px; min-height: var(--tap); padding: 0.5rem 0.6rem;
border-radius: var(--r-sm); border: 1px solid var(--hairline);
background: rgba(8,12,16,0.6); color: rgba(255,255,255,0.9); }
input:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }
button { font: inherit; font-size: 12px; font-weight: 600; margin-top: 0.35rem;
min-height: var(--tap); padding: 0.55rem 0.7rem; cursor: pointer; border: 0;
border-radius: var(--r-sm); color: #10161d; background: var(--amber); }
button:hover:enabled { background: var(--amber-lit); }
button:disabled { opacity: 0.55; cursor: default; }
button:focus-visible { outline: 2px solid var(--amber); outline-offset: 2px; }
#note { min-height: 1.4rem; margin: 0.6rem 0 0; font-size: 11px; line-height: 1.4;
color: var(--ink-2); }
#note.bad { color: #ffb4a2; }
/*
* The way back out, and it is not decoration.
*
* The whole product is designed for the signed-out visitor: the city, both
* studios, the aircraft, the weather and the Model X are all open. A sign-in
* page that presents itself as the entrance contradicts that on the one
* screen where somebody has already been made to feel they are outside. This
* says what an account adds and offers the door back to the demo.
*/
.escape { margin: 1.1rem 0 0; padding-top: 0.9rem; border-top: 1px solid var(--hairline);
font-size: 10px; line-height: 1.7; color: var(--ink-3); }
.escape a { display: inline-flex; align-items: center; min-height: var(--tap);
color: var(--amber-lit); }
footer { margin-top: 0.9rem; font-size: 10px; color: var(--ink-4); }
</style>
</head>
<body>
<main>
<h1>Lumbridge Simulate</h1>
<p>Tera · sign in to reach a private office.</p>
<form id="form" autocomplete="on">
<label id="userLabel" for="username">Username</label>
<input id="username" name="username" type="text" autocomplete="username" required
autocapitalize="none" autocorrect="off" spellcheck="false" />
<label for="password">Password</label>
<input id="password" name="password" type="password" autocomplete="current-password"
required />
<button id="submit" type="submit">Sign in</button>
</form>
<p id="note" role="status" aria-live="polite"></p>
<p class="escape">
You do not need an account to use Tera. The city, both studios, the live
aircraft and the weather are open to everyone — signing in adds the people:
live occupancy, your own character on the floor, and the studio desk.<br />
<a href="/">← Back to the open demo</a>
</p>
<footer id="footer">The session is a cookie this server signs. Nothing leaves the box.</footer>
</main>
<script type="module">
import {
authFetch,
clearToken,
identityConfigured,
signIn,
writeToken,
} from "./src/session.ts";
const form = document.getElementById("form");
const submit = document.getElementById("submit");
const note = document.getElementById("note");
/**
* Two deployments share this page.
*
* With an identity provider configured the credentials go to **it**, not
* here, and what comes back is a bearer token this origin stores — the
* `sso` arrangement, where this box holds no credentials and only ever
* revalidates. Without one, nothing below changes: the form posts to
* `/api/v1/session` and the server signs a cookie, which is the
* self-hoster's default and the better of the two.
*/
if (identityConfigured) {
document.getElementById("userLabel").textContent = "Email";
const username = document.getElementById("username");
username.type = "email";
username.autocomplete = "email";
username.placeholder = "you@example.com";
document.getElementById("footer").textContent =
"Signed in with your Lumbridge account. The office checks it with the control plane.";
}
function say(text, bad) {
note.textContent = text;
note.classList.toggle("bad", bad === true);
}
/**
* Where to go once the cookie is set. `?next=` is honoured only when it is
* a path on this origin — a redirect target taken from a query string is
* how a sign-in page becomes somebody else's phishing hop. A leading `//`
* is a protocol-relative URL to another host, which is exactly the case a
* `startsWith("/")` check on its own would wave through.
*/
function destination() {
const next = new URLSearchParams(location.search).get("next");
if (typeof next !== "string") return "/";
if (!next.startsWith("/") || next.startsWith("//")) return "/";
return next;
}
// If the cookie is already good, there is nothing to ask for. This also
// tells us whether this deployment can sign anyone in at all.
try {
const res = await authFetch("/api/v1/session");
const state = res.ok ? await res.json() : null;
if (state?.authenticated === true) {
location.replace(destination());
} else if (state !== null && state.passwordLogin !== true && !identityConfigured) {
// No local form AND no identity provider means there is genuinely
// nothing to offer. With a provider configured this branch must not
// fire: `passwordLogin` is false in `sso` mode precisely because the
// credentials belong somewhere else, which is the normal case here.
form.hidden = true;
say("This deployment does not sign people in here.");
} else if (state?.authenticated === false) {
// A stored token the server no longer accepts. Drop it, or the next
// request re-sends a token that is only going to be refused again.
clearToken();
}
} catch {
// An unreachable API is not a reason to hide the form; the submit below
// will produce a better message than a guess made before anyone typed.
}
form.addEventListener("submit", async (event) => {
event.preventDefault();
submit.disabled = true;
say("Checking…");
// Identity-provider path: the password never reaches this origin.
if (identityConfigured) {
try {
const token = await signIn(form.username.value, form.password.value);
if (token !== null) {
writeToken(token);
say("Signed in. Taking you back…");
location.replace(destination());
return;
}
form.password.value = "";
// One message, as below: which half was wrong is not this page's to
// disclose, and the provider does not tell us either.
say("Those credentials were not accepted.", true);
} catch {
say("Could not reach the sign-in service.", true);
} finally {
submit.disabled = false;
}
return;
}
try {
const res = await fetch("/api/v1/session", {
method: "POST",
credentials: "same-origin",
headers: { "content-type": "application/json" },
body: JSON.stringify({
username: form.username.value,
password: form.password.value,
}),
});
if (res.ok) {
// The token is in an HttpOnly cookie and this page never sees it,
// which is the point: a script that can read the session is a
// script that can walk off with it.
say("Signed in. Taking you back…");
location.replace(destination());
return;
}
form.password.value = "";
if (res.status === 429) {
const wait = Number(res.headers.get("retry-after"));
say(
Number.isFinite(wait) && wait > 0
? `Too many attempts. Try again in ${wait} seconds.`
: "Too many attempts. Try again shortly.",
true,
);
} else {
// Deliberately the server's single message: it does not distinguish
// a wrong password from a username that does not exist, and neither
// does this page.
say("Those credentials were not accepted.", true);
}
} catch {
say("Could not reach the server.", true);
} finally {
submit.disabled = false;
}
});
</script>
</body>
</html>