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/index.html
T
karti a6f6a91813 A disclosure nobody reads is a watermark
"sample data · fabricated, not real companies" sat in the corner of every load,
because no deployment has a markers source wired by default and the sample set
is therefore the overwhelmingly common case. A caption that is always on screen
stops being read after the first second, which is the opposite of what a
disclosure is for.

So the corner keeps only the rare half — "live data", which is genuinely worth
interrupting someone to say — and the fact that the markers are invented moves
to the two places someone actually looks: the boot card everyone passes through,
and the `?` card, which is one keypress away from any state the app can be in.
It did not go into a README nobody opens.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-05 23:08:50 -07:00

637 lines
24 KiB
HTML
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.
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="color-scheme" content="dark" />
<!--
An empty data URI, not an icon file. `src/` carries no binary assets by
policy (ARCHITECTURE.md §3.1) and a favicon is not worth being the
exception; without this line every load spends a request on `/favicon.ico`
and logs the 404 to the console, which buries the errors that matter.
-->
<link rel="icon" href="data:," />
<title>Lumbridge Simulate — San Francisco</title>
<style>
/*
* The whole interface, inline, on purpose.
*
* There is no framework and no stylesheet because there is no build step
* that would earn one: this file is served as written, so what you read
* here is what the browser gets. It also means the first paint needs
* nothing but this document — which is the entire reason the boot card at
* the bottom of the body can be on screen before a single module has been
* fetched, let alone before the Bay Area heightfield has been built.
*
* Two constraints shape every rule below.
*
* The first is that this chrome floats over a *photographic* background
* that changes from a bright noon sky to a nearly black one and back. A
* panel tuned only for the dark case — which is what this was — reads as
* a grey smudge at noon, because dark-on-dark needs no edge and
* dark-on-bright very much does. Every card therefore carries three
* separations at once: a fill dark enough to hold white text against a
* white sky, a hairline that is light on the inside so the card has a
* rim, and one soft drop shadow so it sits above rather than in.
*
* The second is that there is exactly one accent. `#f2b134` is the
* identity and it is spent only on things that are *active* — the current
* chapter, the current city, the office door, a focus ring. The moment it
* also means "heading" and "border" and "hover" it stops meaning
* anything, which is what a second pass at this looked like before it was
* pulled back.
*/
:root {
--amber: #f2b134;
--amber-lit: #ffc555;
--amber-ink: #ffd68a;
/* One glass recipe, two weights. The strong one is for things that must
be read over the brightest part of the sky: the boot card and the
shortcuts sheet. */
--glass: rgba(9, 13, 18, 0.62);
--glass-strong: rgba(9, 13, 18, 0.86);
--glass-inset: rgba(255, 255, 255, 0.05);
--hairline: rgba(255, 255, 255, 0.11);
--blur: blur(14px) saturate(1.2);
--shadow: 0 6px 22px rgba(3, 6, 10, 0.45);
/* Four steps of ink and no more. Anything that wanted a fifth was
saying something the type scale should have said instead. */
--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);
/* 4px rhythm. Every gap, pad and offset below is a multiple of it, so
the columns line up without anyone having to nudge a value. */
--s1: 4px;
--s2: 8px;
--s3: 12px;
--s4: 16px;
--s5: 24px;
--r: 8px;
--r-sm: 5px;
--t: 150ms cubic-bezier(0.4, 0, 0.2, 1);
}
* { box-sizing: border-box; }
[hidden] { display: none !important; }
html, body {
margin: 0;
height: 100%;
/* Dark, not the old `#d9e6ee`. The canvas covers this within a frame,
but the frame before it used to be a full-screen flash of pale blue
that then dropped to a night scene. A dark page is wrong for half a
second at noon and right for half a second at midnight; the flash is
what people noticed. */
background: #0d1218;
overflow: hidden;
font-family: ui-monospace, "SF Mono", Menlo, monospace;
color: var(--ink);
-webkit-font-smoothing: antialiased;
}
#scene { display: block; width: 100vw; height: 100dvh; touch-action: none; }
/* ---- Type scale ------------------------------------------------------
Five sizes. 9px is reserved for uppercase micro-labels, where the caps
and the tracking carry the legibility that the size does not. */
.t-title { font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; }
.t-body { font-size: 11px; letter-spacing: 0.01em; line-height: 1.55; }
.t-ctrl { font-size: 12px; letter-spacing: 0.01em; }
.t-micro { font-size: 10px; letter-spacing: 0.06em; }
.t-caps { font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; }
/* ---- Glass ----------------------------------------------------------- */
.card {
background: var(--glass);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
border: 1px solid var(--hairline);
box-shadow: var(--shadow), inset 0 1px 0 var(--glass-inset);
border-radius: var(--r);
padding: var(--s3);
color: var(--ink);
font-size: 11px;
line-height: 1.55;
}
/* One focus treatment for everything that can take focus. The offset is
what makes it legible on a control that is already amber. */
:is(button, a, input, [tabindex]):focus-visible {
outline: 2px solid var(--amber);
outline-offset: 2px;
}
/* ---- Left column ------------------------------------------------------ */
#panel {
position: fixed;
top: 0;
left: 0;
padding: var(--s4);
width: min(19.5rem, calc(100vw - var(--s4) * 2));
max-height: 100dvh;
overflow-y: auto;
overscroll-behavior: contain;
scrollbar-width: thin;
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
display: flex;
flex-direction: column;
gap: var(--s1);
pointer-events: none;
transition: transform var(--t), opacity var(--t);
}
#panel > * { pointer-events: auto; }
h1 {
margin: 0;
font-size: 11px;
letter-spacing: 0.2em;
text-transform: uppercase;
color: var(--amber);
}
#subtitle { margin: var(--s1) 0 0; font-size: 10px; letter-spacing: 0.06em; color: var(--ink-3); }
.clock {
margin: var(--s2) 0 0;
font-size: 10px;
letter-spacing: 0.06em;
color: var(--ink-2);
font-variant-numeric: tabular-nums;
}
.scrub { display: flex; align-items: center; gap: var(--s2); margin-top: var(--s2); }
.scrub input { flex: 1; accent-color: var(--amber); height: 14px; min-width: 0; }
.scrub button {
font: inherit;
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: 3px 6px;
cursor: pointer;
border: 1px solid var(--hairline);
border-radius: var(--r-sm);
background: rgba(255, 255, 255, 0.08);
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
.scrub button:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
.cities { display: flex; gap: var(--s1); }
.city {
flex: 1;
font: inherit;
font-size: 11px;
padding: var(--s2) var(--s1);
cursor: pointer;
border: 1px solid var(--hairline);
border-radius: var(--r-sm);
background: var(--glass);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
box-shadow: var(--shadow);
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
.city:hover { background: rgba(40, 48, 58, 0.7); color: var(--ink); }
.city[aria-pressed="true"] { background: rgba(242, 177, 52, 0.22); color: var(--amber-ink); }
.enter {
font: inherit;
font-size: 12px;
padding: var(--s2) var(--s3);
cursor: pointer;
text-align: left;
border: 1px solid transparent;
border-radius: var(--r-sm);
color: #10161d;
background: var(--amber);
font-weight: 600;
box-shadow: var(--shadow);
transition: background var(--t);
}
.enter:hover { background: var(--amber-lit); }
/* The public-office note. Amber-edged rather than amber-filled: it is an
explanation, not an action, and the one filled amber thing on screen
should stay the door. */
.badge {
margin: 0;
border-left: 2px solid rgba(242, 177, 52, 0.55);
border-radius: var(--r-sm);
padding: var(--s2) var(--s3);
font-size: 10px;
line-height: 1.6;
color: var(--ink-2);
}
.badge a { color: var(--amber-ink); }
#chapters {
display: flex;
flex-direction: column;
gap: 1px;
background: var(--glass);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
border: 1px solid var(--hairline);
box-shadow: var(--shadow), inset 0 1px 0 var(--glass-inset);
border-radius: var(--r);
padding: var(--s1);
}
.chapter {
display: flex;
align-items: baseline;
gap: var(--s2);
padding: var(--s2) var(--s2);
background: none;
border: 0;
border-radius: var(--r-sm);
cursor: pointer;
text-align: left;
font: inherit;
font-size: 12px;
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
.chapter:hover { background: rgba(255, 255, 255, 0.09); color: var(--ink); }
.chapter[aria-pressed="true"] { background: rgba(242, 177, 52, 0.2); color: var(--amber-ink); }
.num { font-size: 9px; letter-spacing: 0.1em; opacity: 0.55; font-variant-numeric: tabular-nums; }
#blurb { margin: 0; color: var(--ink-2); }
/* The panel toggle only exists on a narrow screen, where it doubles as the
header — hence the city name in it. */
.panel-toggle {
display: none;
position: fixed;
top: var(--s3);
left: var(--s3);
z-index: 4;
align-items: center;
gap: var(--s2);
font: inherit;
font-size: 11px;
letter-spacing: 0.1em;
text-transform: uppercase;
padding: var(--s2) var(--s3);
cursor: pointer;
color: var(--amber);
background: var(--glass);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
border: 1px solid var(--hairline);
border-radius: var(--r-sm);
box-shadow: var(--shadow);
}
.panel-toggle .glyph { font-size: 13px; line-height: 1; }
/* ---- Top right: the plan --------------------------------------------- */
.corner {
position: fixed;
top: var(--s4);
right: var(--s4);
width: 15rem;
display: flex;
flex-direction: column;
align-items: stretch;
gap: var(--s1);
}
#minimap { padding: var(--s1); display: flex; flex-direction: column; gap: var(--s1); }
/* A real height, always. The canvas is pinned to 100%/100% inline by
`minimap.ts`, and an `auto` height here puts back the feedback loop that
rule exists to break: the backing store feeds layout, layout feeds the
backing store, and the map doubles in size every frame. */
.minimap-frame {
width: 100%;
height: 14rem;
border-radius: var(--r-sm);
overflow: hidden;
}
.minimap-canvas { outline: none; cursor: crosshair; }
.minimap-canvas:focus-visible { outline: 2px solid var(--amber); outline-offset: -2px; }
.minimap-readout {
margin: 0;
padding: 0 var(--s1) 2px;
font-size: 10px;
letter-spacing: 0.05em;
color: var(--ink-3);
min-height: 1.3em;
font-variant-numeric: tabular-nums;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.tier {
margin: 0;
padding: var(--s2) var(--s3);
display: flex;
align-items: baseline;
justify-content: space-between;
gap: var(--s2);
font-size: 9px;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--ink-3);
}
.tier .who { color: var(--ink-2); text-transform: none; letter-spacing: 0.04em; }
.tier.god .who { color: var(--amber-ink); }
.tier a { color: var(--amber-ink); text-transform: none; letter-spacing: 0.04em; }
/* ---- Bottom right: what you picked, and how to drive ------------------ */
.rail {
position: fixed;
right: var(--s4);
bottom: var(--s4);
z-index: 3;
display: flex;
flex-direction: column;
align-items: flex-end;
gap: var(--s1);
/* Wide enough that the key hints stay on one line at desktop widths —
a shortcut list that wraps reads as five separate notes rather than
one row — and narrow enough that a long detail card never becomes a
second panel. */
max-width: min(30rem, calc(100vw - var(--s4) * 2));
}
#detail { max-width: 100%; }
.hint {
display: flex;
flex-wrap: wrap;
justify-content: flex-end;
align-items: center;
gap: var(--s1) var(--s3);
padding: var(--s2) var(--s3);
font-size: 10px;
letter-spacing: 0.04em;
color: var(--ink-3);
}
kbd {
font: inherit;
font-size: 9px;
padding: 1px 4px;
border: 1px solid var(--hairline);
border-radius: 3px;
background: rgba(255, 255, 255, 0.08);
color: var(--ink-2);
}
.help {
font: inherit;
font-size: 10px;
letter-spacing: 0.04em;
padding: 2px 6px;
cursor: pointer;
border: 1px solid var(--hairline);
border-radius: var(--r-sm);
background: rgba(255, 255, 255, 0.06);
color: var(--ink-2);
transition: background var(--t), color var(--t);
}
.help:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
/* ---- Bottom left: where the numbers came from -------------------------
Shown only when the data is live — see `renderLegend` in main.ts for why
the sample-data half of this was retired. The disclosure it used to
carry did not go into a README nobody opens: it is on the boot card
everyone passes through and in the `?` card, which is one keypress away
from any state the app can be in. */
.source {
position: fixed;
left: var(--s4);
bottom: var(--s4);
margin: 0;
z-index: 3;
max-width: calc(100vw - var(--s4) * 2);
font-size: 10px;
letter-spacing: 0.04em;
color: var(--ink-3);
background: var(--glass);
backdrop-filter: var(--blur);
-webkit-backdrop-filter: var(--blur);
border: 1px solid var(--hairline);
box-shadow: var(--shadow);
padding: var(--s1) var(--s2);
border-radius: var(--r-sm);
}
.source.live { color: #8fe89a; }
/* ---- Overlays --------------------------------------------------------- */
.overlay {
position: fixed;
inset: 0;
z-index: 10;
display: flex;
align-items: center;
justify-content: center;
padding: var(--s4);
background: rgba(4, 7, 11, 0.55);
backdrop-filter: blur(3px);
}
.sheet {
background: var(--glass-strong);
width: min(26rem, 100%);
max-height: calc(100dvh - var(--s5) * 2);
overflow-y: auto;
padding: var(--s4);
}
.sheet h2 { margin: 0 0 var(--s3); font-size: 11px; letter-spacing: 0.2em;
text-transform: uppercase; color: var(--amber); }
.keys { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: var(--s2) var(--s3);
align-items: baseline; font-size: 11px; }
.keys dt { text-align: right; }
.keys dd { margin: 0; color: var(--ink-2); }
.sheet-close {
margin-top: var(--s4);
font: inherit;
font-size: 11px;
padding: var(--s2) var(--s3);
cursor: pointer;
width: 100%;
border: 1px solid var(--hairline);
border-radius: var(--r-sm);
background: rgba(255, 255, 255, 0.08);
color: var(--ink);
}
.sheet-close:hover { background: rgba(255, 255, 255, 0.16); }
/* ---- Boot -------------------------------------------------------------
The page used to be blank for the ~2.3 s it takes to build the Bay Area
heightfield, which reads as a broken site rather than a busy one. This
card is in the document, so it is on screen at first paint — before the
module graph has even been fetched — and `main.ts` only ever writes the
step line and then fades the whole thing out.
It is deliberately not a spinner. A spinner says "wait"; naming the
thing being built says "wait, for this", and the sentence about running
with no server is the one fact about this project worth reading while
you have nothing else to do. */
.boot {
position: fixed;
inset: 0;
z-index: 20;
display: flex;
align-items: center;
justify-content: center;
padding: var(--s4);
background: radial-gradient(120% 90% at 50% 30%, #172029 0%, #0b0f14 70%);
transition: opacity 260ms ease;
}
.boot.done { opacity: 0; pointer-events: none; }
.boot-inner { width: min(22rem, 100%); text-align: left; }
.boot-mark { margin: 0; font-size: 12px; letter-spacing: 0.42em; text-transform: uppercase;
color: var(--amber); }
.boot-sub { margin: var(--s2) 0 0; font-size: 10px; letter-spacing: 0.08em; color: var(--ink-3); }
.boot-bar {
margin: var(--s4) 0 var(--s2);
height: 2px;
border-radius: 2px;
background: rgba(255, 255, 255, 0.09);
overflow: hidden;
}
.boot-bar i {
display: block;
height: 100%;
width: 34%;
border-radius: 2px;
background: linear-gradient(90deg, transparent, var(--amber), transparent);
animation: sweep 1400ms cubic-bezier(0.45, 0, 0.55, 1) infinite;
}
@keyframes sweep {
0% { transform: translateX(-100%); }
100% { transform: translateX(340%); }
}
.boot-step { margin: 0; font-size: 10px; letter-spacing: 0.06em; color: var(--ink-2);
min-height: 1.4em; }
.boot-note { margin: var(--s4) 0 0; font-size: 10px; line-height: 1.6; color: var(--ink-4); }
/* ---- Responsive -------------------------------------------------------
Two breakpoints and no more. At 900 the left column stops being furniture
and becomes a sheet you open; at 600 the plan stops fitting beside the
map at all and lives behind `M`. `main.ts` owns the *state* of both, so
these rules only describe what each state looks like. */
@media (max-width: 900px) {
.panel-toggle { display: flex; }
#panel {
padding-top: 56px;
width: min(18rem, calc(100vw - var(--s3) * 2));
z-index: 3;
}
body.panel-closed #panel {
transform: translateX(calc(-100% - var(--s3)));
opacity: 0;
pointer-events: none;
}
.corner { width: 11.5rem; top: var(--s3); right: var(--s3); }
.minimap-frame { height: 10rem; }
.rail { right: var(--s3); bottom: var(--s3); }
.source { left: var(--s3); bottom: var(--s3); }
}
@media (max-width: 600px) {
.rail { max-width: calc(100vw - var(--s3) * 2); }
/* The provenance line and the key hints would collide in the last 40px
of a phone, and the honesty line is the one that has to survive. */
.hint span { display: none; }
.source { max-width: 60vw; }
}
/* The minimap's own visibility is a user decision (`M`), seeded from the
viewport width by `main.ts` rather than by a media query, so that
toggling it on at 375px actually shows it. */
body.minimap-off .corner { display: none; }
@media (prefers-reduced-motion: reduce) {
*, *::before, *::after {
transition-duration: 1ms !important;
animation-duration: 1ms !important;
animation-iteration-count: 1 !important;
}
.boot-bar i { width: 100%; transform: none; }
}
</style>
</head>
<body>
<canvas id="scene" aria-label="Map of San Francisco, seen from above. Drag to orbit, scroll to zoom."></canvas>
<button id="panel-toggle" class="panel-toggle" aria-expanded="true" aria-controls="panel">
<span class="glyph" aria-hidden="true"></span><span id="panel-toggle-label">Bay Area</span>
</button>
<div id="panel">
<section class="card">
<h1 id="title">San Francisco</h1>
<p id="subtitle">Tera · Lumbridge Simulate</p>
<p id="clock" class="clock" aria-live="polite"></p>
<div id="scrub" class="scrub" hidden>
<input id="hour" type="range" min="0" max="23.9" step="0.1" value="12"
aria-label="Hour of day" />
<button id="now" title="follow the wall clock">now</button>
</div>
</section>
<nav id="cities" class="cities" aria-label="City"></nav>
<button id="enter" class="enter">Enter the office →</button>
<p class="card badge" id="office-badge" hidden></p>
<nav id="chapters" aria-label="Chapters"></nav>
<p class="card" id="blurb"></p>
</div>
<aside id="corner" class="corner" aria-label="Plan view">
<div class="card" id="minimap">
<div class="minimap-frame"></div>
<p id="minimap-readout" class="minimap-readout"></p>
</div>
<p class="card tier" id="tier" hidden></p>
</aside>
<div class="rail" id="rail">
<div class="card" id="detail" aria-live="polite" hidden></div>
<div class="card hint" id="hint">
<span><kbd>1</kbd><kbd>9</kbd> chapters</span>
<span><kbd>[</kbd> <kbd>]</kbd> city</span>
<span><kbd>O</kbd> office</span>
<span><kbd>M</kbd> plan</span>
<button id="help" class="help" aria-haspopup="dialog">? shortcuts</button>
</div>
</div>
<p id="source" class="source"></p>
<div class="overlay" id="shortcuts" role="dialog" aria-modal="true"
aria-labelledby="shortcuts-title" hidden>
<div class="card sheet">
<h2 id="shortcuts-title">Keyboard</h2>
<dl class="keys">
<dt><kbd>1</kbd><kbd>9</kbd></dt><dd>Fly to a chapter, or an office viewpoint</dd>
<dt><kbd>[</kbd> <kbd>]</kbd></dt><dd>Previous / next city</dd>
<dt><kbd>O</kbd></dt><dd>Enter or leave the office</dd>
<dt><kbd>M</kbd></dt><dd>Show or hide the plan view</dd>
<dt><kbd>?</kbd></dt><dd>This card</dd>
<dt><kbd>Esc</kbd></dt><dd>Close an overlay, or leave the office</dd>
<dt><kbd>Tab</kbd></dt><dd>Every control, in reading order</dd>
</dl>
<p class="boot-note">On the plan: click or drag to move the view, scroll to zoom,
arrow keys to aim and <kbd>Enter</kbd> to go.</p>
<p class="boot-note">The markers on this map are fabricated. No real company data
ships in this build; a deployment wired to a live source says <em>live data</em>
in the corner, and this one does not.</p>
<button class="sheet-close" id="shortcuts-close">Close</button>
</div>
</div>
<div class="boot" id="boot">
<div class="boot-inner">
<p class="boot-mark">Tera</p>
<p class="boot-sub">Lumbridge Simulate · cities from above</p>
<div class="boot-bar"><i></i></div>
<p class="boot-step" id="boot-step">Starting up</p>
<p class="boot-note">Runs with no server, no key and no account. The markers are
fabricated — no real company data ships in this build.</p>
</div>
</div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>