b25f217e3e
The world stops being a simulation of California and starts being California. **THE PROMOTION GATE WAS THE FIRST COMMIT, BEFORE ANY ORANGE PIXEL EXISTED.** On today's live store the SoCal board contains 22 incidents. Every one has NULL acreage and fifteen are nameless LA County dispatch numbers. Drawn naively that is 22 orange marks over Los Angeles on a day nothing is burning — in a frame that contains no other warm colour, so one glyph would be the most salient object on the board and twenty-two would spend its credibility permanently. `acres >= 10 AND contained < 80 AND type != 'RX' AND last_seen = max(last_seen)` returns 0 on SoCal, exactly 5 on California, 0 on the Bay — same body, same day, three correct answers. The empty board is a deliverable, not a fallback: it says "No active fire on this board — CAL FIRE and WFIGS, just now", states that 21 records were gated and why, lists the largest fires burning OUTSIDE the frame with distances, and counts the hot pixels it is deliberately not drawing. **The privacy leak is structurally impossible rather than carefully avoided.** cloud-1 serves a projection; the four home-relative columns never leave that box. `observations.threat` was the one that nearly got through — it is `(16/distance)^2 x log10(acres) x momentum x containment x wind-alignment`, so with acreage and containment public it inverts to a distance circle around a house and three fires give an intersection. A grep of the built bundle for distance_km, bearing_deg, threat, 7762 and the street name returns nothing. **Deliberately not used, and both would have produced a confident wrong answer:** the store's `air` table retains only the last parameter of each poll, so all 78 rows read "Good" while the live feed reports ozone 101 "Unhealthy for Sensitive Groups" — haze driven off it would clear the sky during a smoke event. And `weather` is written only inside the NWS alerts loop, so a quiet day stores no wind at all. Tera's own per-region NWS wind is already correct and already what the clouds drift on. Satellite detections are drawn as evidence and never as incidents. The permanent industrial heat source 4.7 km from the owner's house is flagged persistent and dropped, asserted by a test that first proves it is present in the fixture. MODIS integer confidence and VIIRS string confidence are branched on `sat`. **The LA office is a twin.** Its entire authored second storey — Model Loft, Model Bay, The Materials Room, 430 lines nobody had ever stood in — is reachable on foot: a walker crosses level-1 to level-2 in 73 fixed steps, floorY 0 to 5, verified against the real pack rather than a synthetic plan. Its two studio devices read real hardware through a field-allowlisted bridge: mute, volume and reachability only. Never level, because there is no passive level upstream and obtaining one would record a room with people in it. Never dB, because upstream is gainPct across four different native scales. The bridge refuses all writes. Fixed at its root: an anonymous visitor was getting permanently at-rest instruments backing off against a 401. The tier moves into `createDeviceSource`, so anon gets the living simulator three file headers already promised. **Item 8 is closed, not fixed, and the correction is the point.** The Bay Area "stutter" was GPU power management — the card sat at 500 MHz of 2725 through every run that reproduced it, 4096/2048/1024/256 shadow maps all render in 1.21-1.31 ms, and two consecutive runs over a byte-identical dist gave 33.4 then 16.7. The allowance is removed and the cell is back to 16.7. Geometry is the gate; frame time is advisory. Item 7 was re-scoped after measuring: 1,069,006 of the Bay Area's 2,265,056 triangles were the second submission of the same buildings into the shadow pass. Mobile now has its own triangle caps and bay-area mobile draws 1,266,096. Also: bridges and the freeway corridor light up at night as emission, not lights — 1,614 deck lamps and 18 tower heads on the Bay in two draw calls. The single change that made US-101 legible was moving its edge lines from the lit material to the unlit one: retroreflective paint, the argument the SFO night frame already makes. California went 21,991 lamps to 4,051, clustered at the 17 town districts, because a rural interurban corridor genuinely is unlit. Tests 1137 -> 1340, server 280. All ten budget cells pass on first attempt with no cap raised. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
1481 lines
62 KiB
HTML
1481 lines
62 KiB
HTML
<!doctype html>
|
||
<html lang="en">
|
||
<head>
|
||
<meta charset="UTF-8" />
|
||
<!--
|
||
`viewport-fit=cover` so the canvas reaches the edges of a notched screen;
|
||
every fixed card below then pays for it with an `env(safe-area-inset-*)`.
|
||
Deliberately **no** `user-scalable=no` and no `maximum-scale`: the map
|
||
itself is already protected from browser zoom by `touch-action: none` and
|
||
by `scenekit.ts` swallowing WebKit's `gesture*` events, and taking page
|
||
zoom away from everyone to protect one element is the accessibility
|
||
mistake that rule exists to avoid.
|
||
-->
|
||
<meta name="viewport" content="width=device-width, initial-scale=1, viewport-fit=cover" />
|
||
<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:," />
|
||
|
||
<!--
|
||
==== Social ==== ogc:start
|
||
Everything between the `ogc:` markers is rewritten at build time to produce
|
||
`office.html`, which is the same page with the office's title, description
|
||
and card on it. See the `twoDoors` plugin in `vite.config.ts` for why that
|
||
is a build step and not a second hand-maintained file.
|
||
|
||
Both doors had none of this at all, which for a project whose entire pitch
|
||
is visual is the most expensive line of missing markup in the repo: a link
|
||
to either one unfurled as a bare blue URL with no picture, no title beyond
|
||
the tab's, and no sentence.
|
||
-->
|
||
<title>Tera — cities from above, and an office you can walk into</title>
|
||
<meta
|
||
name="description"
|
||
content="The map view of Lumbridge Simulate: a real sun and moon over a board you can fly around, and an office you can step into. Apache-2.0, self-hostable, runs with no server at all."
|
||
/>
|
||
<link rel="canonical" href="https://tera.lumbridgecorp.com/" />
|
||
|
||
<meta property="og:type" content="website" />
|
||
<meta property="og:site_name" content="Lumbridge Simulate" />
|
||
<meta property="og:title" content="Tera — cities from above, and an office you can walk into" />
|
||
<meta
|
||
property="og:description"
|
||
content="A real sun and moon over a board you can fly around, and an office you can step into. Apache-2.0, self-hostable. Clone it and it works."
|
||
/>
|
||
<meta property="og:url" content="https://tera.lumbridgecorp.com/" />
|
||
<!--
|
||
Absolute, and it has to be. Several unfurlers — Slack and iMessage among
|
||
them — will not resolve a relative `og:image` against the page URL, and a
|
||
card that works on X and silently does not work in a DM is the worst of
|
||
the two outcomes because nobody sees it fail.
|
||
-->
|
||
<meta property="og:image" content="https://tera.lumbridgecorp.com/og-tera.png" />
|
||
<meta property="og:image:type" content="image/png" />
|
||
<meta property="og:image:width" content="1200" />
|
||
<meta property="og:image:height" content="630" />
|
||
<meta
|
||
property="og:image:alt"
|
||
content="California rendered from above in Tera, with Los Angeles and San Francisco joined by the US-101 and I-5 corridors."
|
||
/>
|
||
|
||
<meta name="twitter:card" content="summary_large_image" />
|
||
<meta name="twitter:title" content="Tera — cities from above, and an office you can walk into" />
|
||
<meta
|
||
name="twitter:description"
|
||
content="A real sun and moon over a board you can fly around, and an office you can step into. Apache-2.0, self-hostable. Clone it and it works."
|
||
/>
|
||
<meta name="twitter:image" content="https://tera.lumbridgecorp.com/og-tera.png" />
|
||
<!-- ogc:end -->
|
||
<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 California heightfield has been built.
|
||
*
|
||
* Three 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 board, 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.
|
||
*
|
||
* The third arrived with this pass and is the one that changed the layout
|
||
* rather than the paint. The left column was **seven unrelated dark
|
||
* cards** stacked on top of each other — a title card, a board strip, a
|
||
* primary button, two secondary buttons, a chapter list and a caption —
|
||
* with no hierarchy between them, three different button treatments, four
|
||
* different internal paddings and two different widths. Seven surfaces
|
||
* that are always all present are not seven cards; they are one panel with
|
||
* six sections in it, and that is what it is now: one glass surface, one
|
||
* padding, hairline rules between titled groups, and exactly two button
|
||
* treatments — filled amber for the door, ghosted for everything else.
|
||
*
|
||
* ---- Tokens ----------------------------------------------------------
|
||
* The block below is generated from `src/ui/tokens.ts` and asserted
|
||
* against it by `src/test/ui/tokens.test.ts`. Do not hand-edit a value
|
||
* here: change it there and the test will tell you if the two have
|
||
* drifted. Four other modules inject their own stylesheets at runtime
|
||
* (`tools/godmode.ts`, `profile/webcamPanel.ts`, `profile/editor.ts`,
|
||
* `media/officeScreenPanel.ts`) and all four read these same names through
|
||
* `var(--x, fallback)`, which is why the names are worth owning in one
|
||
* place at all.
|
||
*
|
||
* The `--z-*` scale in particular is the *whole* stacking order of the
|
||
* product, in one list, for the first time: chrome, dock, play,
|
||
* instrument, modal, alert, boot. Before it there were seven raw literals
|
||
* across five files and two collisions nobody had chosen. There are no
|
||
* bare `z-index` numbers anywhere in this file or in those four modules
|
||
* any more, and a test checks it.
|
||
*/
|
||
:root {
|
||
--amber: #f2b134;
|
||
--amber-lit: #ffc555;
|
||
--amber-ink: #ffd68a;
|
||
--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);
|
||
--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);
|
||
--s1: 4px;
|
||
--s2: 8px;
|
||
--s3: 12px;
|
||
--s4: 16px;
|
||
--s5: 24px;
|
||
--s6: 32px;
|
||
--r: 8px;
|
||
--r-sm: 5px;
|
||
--r-pill: 999px;
|
||
--t: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||
--tap: 44px;
|
||
--z-chrome: 3;
|
||
--z-dock: 4;
|
||
--z-play: 5;
|
||
--z-instrument: 6;
|
||
--z-modal: 10;
|
||
--z-alert: 12;
|
||
--z-boot: 20;
|
||
}
|
||
|
||
* { 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;
|
||
/* The canvas cannot scroll the page — `touch-action: none` sees to that
|
||
— but a drag that starts on the panel, the rail or the source line
|
||
still rubber-bands the whole document on iOS and can still trigger
|
||
pull-to-refresh on Android, which on a full-screen map looks like the
|
||
map itself has come loose. */
|
||
overscroll-behavior: none;
|
||
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;
|
||
/* A slow orbit on iOS is, to WebKit, a long press on a page: it raises
|
||
the selection callout and the magnifier over the city. There is no
|
||
text here to select. */
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
-webkit-touch-callout: 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;
|
||
}
|
||
|
||
/* ---- The left column --------------------------------------------------
|
||
One surface, six groups, one padding. See the third constraint at the
|
||
top of this stylesheet for what this replaced and why. */
|
||
#panel {
|
||
position: fixed;
|
||
top: 0;
|
||
left: 0;
|
||
z-index: var(--z-chrome);
|
||
padding: var(--s4);
|
||
width: min(20.5rem, calc(100vw - var(--s4) * 2));
|
||
max-height: 100dvh;
|
||
display: flex;
|
||
flex-direction: column;
|
||
pointer-events: none;
|
||
transition: transform var(--t), opacity var(--t);
|
||
}
|
||
.panel-card {
|
||
pointer-events: auto;
|
||
display: flex;
|
||
flex-direction: column;
|
||
min-height: 0;
|
||
max-height: calc(100dvh - var(--s4) * 2);
|
||
overflow-y: auto;
|
||
overscroll-behavior: contain;
|
||
scrollbar-width: thin;
|
||
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||
padding: 0;
|
||
}
|
||
|
||
/* Every group is the same box: the same inline padding, the same block
|
||
padding, a hairline above it. Four independently-tuned paddings is what
|
||
made the old column look assembled rather than designed. */
|
||
.panel-group {
|
||
padding: var(--s3);
|
||
border-top: 1px solid var(--hairline);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--s2);
|
||
}
|
||
.panel-group[hidden] { display: none; }
|
||
.panel-head {
|
||
padding: var(--s3);
|
||
display: flex;
|
||
flex-direction: column;
|
||
gap: var(--s1);
|
||
}
|
||
/* The title group has nothing above it, so nothing rules it off. */
|
||
.panel-head + .panel-group { border-top: 1px solid var(--hairline); }
|
||
.group-title {
|
||
margin: 0;
|
||
color: var(--ink-4);
|
||
}
|
||
|
||
h1 {
|
||
margin: 0;
|
||
font-size: 13px;
|
||
letter-spacing: 0.16em;
|
||
text-transform: uppercase;
|
||
color: var(--amber);
|
||
}
|
||
#subtitle { margin: 0; color: var(--ink-3); }
|
||
.clock {
|
||
margin: 0;
|
||
font-size: 10px;
|
||
letter-spacing: 0.06em;
|
||
color: var(--ink-2);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
#clock:empty { display: none; }
|
||
|
||
/* The `#hour` scrubber and its `now` button used to live here. They were
|
||
god-only, and the godmode panel now owns the same override with a date
|
||
as well as an hour — two writers for one value, of which this was the
|
||
one that silently threw the date away. `main.ts` says the rest. */
|
||
|
||
/* ---- The board strip --------------------------------------------------
|
||
One segmented control, pointed at whichever list is currently the answer
|
||
to "which of these am I in": worlds outside a building, buildings inside
|
||
one.
|
||
|
||
THE OVERLAP BUG, which was on every screenshot of every studio: an
|
||
office tab is a name plus an ACTIVE/BUILDING badge, and it was laid out
|
||
as `grid-template-columns: minmax(0, 1fr) auto` with nothing clipping
|
||
the first column. `minmax(0, 1fr)` lets a track shrink below its
|
||
content, and a `<span>` with no `overflow` happily paints outside its
|
||
track — so at the ~85px each tab gets in a three-up strip, the name was
|
||
drawn straight over the badge in all three tabs. Two columns was the
|
||
wrong shape for the space anyway. It is one column and two rows now:
|
||
name over badge, the name ellipsised, and there is no width at which the
|
||
two can reach each other. */
|
||
.boards { display: flex; gap: var(--s1); }
|
||
.board {
|
||
flex: 1;
|
||
min-width: 0;
|
||
display: grid;
|
||
gap: 1px;
|
||
text-align: left;
|
||
font: inherit;
|
||
font-size: 11px;
|
||
padding: var(--s2);
|
||
cursor: pointer;
|
||
border: 1px solid var(--hairline);
|
||
border-radius: var(--r-sm);
|
||
background: rgba(255, 255, 255, 0.04);
|
||
color: var(--ink-2);
|
||
transition: background var(--t), color var(--t), border-color var(--t);
|
||
}
|
||
.board__name {
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.board__status { color: var(--ink-4); }
|
||
@media (hover: hover) {
|
||
.board:hover { background: rgba(255, 255, 255, 0.1); color: var(--ink); }
|
||
}
|
||
.board[aria-pressed="true"] {
|
||
background: rgba(242, 177, 52, 0.2);
|
||
border-color: rgba(242, 177, 52, 0.4);
|
||
color: var(--amber-ink);
|
||
}
|
||
.board[aria-pressed="true"] .board__status { color: var(--amber-ink); }
|
||
|
||
/* ---- Actions ----------------------------------------------------------
|
||
TWO treatments, not three. Filled amber is the door and only the door —
|
||
one filled thing on screen, and it is the verb the whole product is
|
||
about. Everything else is the same ghost button at the same height, so
|
||
"walk", "fly", "screens" and "hardware" read as siblings instead of as
|
||
three unrelated inventions. */
|
||
.actions { display: flex; flex-direction: column; gap: var(--s1); }
|
||
.act {
|
||
font: inherit;
|
||
font-size: 11px;
|
||
min-height: 36px;
|
||
padding: var(--s2) var(--s3);
|
||
cursor: pointer;
|
||
text-align: left;
|
||
border: 1px solid rgba(242, 177, 52, 0.4);
|
||
border-radius: var(--r-sm);
|
||
color: var(--amber-ink);
|
||
background: rgba(242, 177, 52, 0.08);
|
||
transition: background var(--t), border-color var(--t);
|
||
}
|
||
@media (hover: hover) {
|
||
.act:hover { background: rgba(242, 177, 52, 0.18); border-color: var(--amber); }
|
||
}
|
||
.act[aria-pressed="true"] { background: rgba(242, 177, 52, 0.25); }
|
||
.act--primary {
|
||
font-size: 12px;
|
||
font-weight: 600;
|
||
min-height: 40px;
|
||
border-color: transparent;
|
||
color: #10161d;
|
||
background: var(--amber);
|
||
}
|
||
@media (hover: hover) { .act--primary:hover { background: var(--amber-lit); } }
|
||
|
||
/* ---- Notes ------------------------------------------------------------
|
||
An explanation, not an action: amber-edged rather than amber-filled, so
|
||
the one filled amber thing on screen stays the door. */
|
||
.note {
|
||
margin: 0;
|
||
border-left: 2px solid rgba(242, 177, 52, 0.5);
|
||
padding-left: var(--s2);
|
||
font-size: 10px;
|
||
line-height: 1.6;
|
||
color: var(--ink-2);
|
||
}
|
||
.note a { color: var(--amber-ink); }
|
||
|
||
/* ---- Chapters --------------------------------------------------------- */
|
||
.chapters { display: flex; flex-direction: column; gap: 1px; }
|
||
.chapter {
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: var(--s2);
|
||
padding: 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);
|
||
}
|
||
@media (hover: hover) {
|
||
.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-3); font-size: 10px; line-height: 1.6; }
|
||
|
||
/* The panel toggle only exists on a narrow screen, where it doubles as the
|
||
header — hence the board name in it. */
|
||
.panel-toggle {
|
||
position: fixed;
|
||
top: calc(var(--s3) + env(safe-area-inset-top));
|
||
left: var(--s3);
|
||
z-index: var(--z-dock);
|
||
align-items: center;
|
||
gap: var(--s2);
|
||
font: inherit;
|
||
font-size: 11px;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
min-height: 40px;
|
||
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:not([hidden]) { display: flex; }
|
||
.panel-toggle .glyph { font-size: 13px; line-height: 1; }
|
||
|
||
/* ---- Top right: one column, four things -------------------------------
|
||
The tier badge, the presence pill, the camera indicator and the plan
|
||
used to be four separately-positioned `fixed` elements whose vertical
|
||
relationship was maintained by four hand-summed magic numbers:
|
||
`+2.4rem`, `+2.5rem`, `+4.55rem`, and a `body.presence-on` rule that
|
||
moved a fifth. Every one of them had to be re-derived by hand whenever
|
||
any card's padding changed, and the webcam indicator's copy lived in a
|
||
different file entirely.
|
||
|
||
They are one flex column now. Nothing needs to know what is above it. */
|
||
.topright {
|
||
position: fixed;
|
||
top: calc(var(--s4) + env(safe-area-inset-top));
|
||
right: var(--s4);
|
||
z-index: var(--z-chrome);
|
||
width: min(15rem, 46vw);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: stretch;
|
||
gap: var(--s1);
|
||
pointer-events: none;
|
||
}
|
||
.topright > * { pointer-events: auto; }
|
||
/*
|
||
* The one exception to the stacking order, and it is a decision rather than
|
||
* an accident this time. A "camera active" indicator that a dialog can
|
||
* cover turns "your webcam is live" into a fact the interface knows and the
|
||
* person does not, so while — and only while — the indicator is actually
|
||
* shown, this column rises above the modal layer. The moment the camera
|
||
* stops, so does this.
|
||
*/
|
||
body:has(.tera-webcam-indicator:not([hidden])) .topright { z-index: var(--z-alert); }
|
||
|
||
.tier {
|
||
margin: 0;
|
||
padding: var(--s2) var(--s3);
|
||
display: flex;
|
||
align-items: baseline;
|
||
gap: var(--s2);
|
||
font-size: 9px;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-3);
|
||
}
|
||
.tier .who {
|
||
flex: 1;
|
||
color: var(--ink-2);
|
||
text-transform: none;
|
||
letter-spacing: 0.04em;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.tier[data-tier="god"] .who { color: var(--amber-ink); }
|
||
.tier a { color: var(--amber-ink); text-transform: none; letter-spacing: 0.04em; }
|
||
/*
|
||
* What signing in ADDS, rather than what you are missing.
|
||
*
|
||
* "PUBLIC VIEW · Sign in" was the entire signed-out story on this page, and
|
||
* it names the visitor rather than the offer — which on a product whose
|
||
* first owner decision is "the signed-out visitor is the audience we design
|
||
* for, not a degraded tier" is exactly the wrong sentence in exactly the
|
||
* most-read corner. This line is the offer. It is one clause, it is quiet,
|
||
* and it disappears the moment there is nothing to add.
|
||
*/
|
||
.tier-adds {
|
||
margin: 0;
|
||
padding: var(--s2) var(--s3);
|
||
font-size: 9px;
|
||
line-height: 1.65;
|
||
letter-spacing: 0.03em;
|
||
color: var(--ink-3);
|
||
}
|
||
.profile-trigger {
|
||
min-height: 26px;
|
||
padding: 3px var(--s2);
|
||
color: var(--amber-ink);
|
||
background: rgba(242, 177, 52, 0.1);
|
||
border: 1px solid rgba(242, 177, 52, 0.3);
|
||
border-radius: var(--r-sm);
|
||
font: inherit;
|
||
font-size: 9px;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
cursor: pointer;
|
||
}
|
||
.profile-trigger:hover { background: rgba(242, 177, 52, 0.2); }
|
||
.profile-overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: var(--z-modal);
|
||
display: grid;
|
||
place-items: center;
|
||
padding: var(--s4);
|
||
background: rgba(5, 8, 11, 0.68);
|
||
backdrop-filter: blur(6px);
|
||
-webkit-backdrop-filter: blur(6px);
|
||
}
|
||
|
||
#presence-host:empty { display: none; }
|
||
/* The wrapper is a mount point, not a box. `display: contents` lets the
|
||
camera indicator itself be the column's flex child, so the column's gap
|
||
does not reserve a row for an indicator that is not showing — which is
|
||
what an empty wrapper would do, and what an `:empty` rule cannot fix
|
||
once the (hidden) element has been mounted into it. */
|
||
#webcam-face-indicator { display: contents; }
|
||
.tera-presence {
|
||
display: flex;
|
||
align-items: center;
|
||
gap: var(--s2);
|
||
padding: 5px var(--s2);
|
||
color: var(--ink-3);
|
||
background: rgba(8, 12, 17, 0.76);
|
||
border: 1px solid var(--hairline);
|
||
border-radius: var(--r-sm);
|
||
box-shadow: var(--shadow);
|
||
backdrop-filter: var(--blur);
|
||
-webkit-backdrop-filter: var(--blur);
|
||
font-size: 9px;
|
||
letter-spacing: 0.04em;
|
||
}
|
||
.tera-presence[data-presence="live"] { color: var(--ink-2); }
|
||
.tera-presence__retry {
|
||
padding: 2px 6px;
|
||
color: var(--amber-ink);
|
||
background: transparent;
|
||
border: 1px solid rgba(242, 177, 52, 0.3);
|
||
border-radius: 3px;
|
||
font: inherit;
|
||
cursor: pointer;
|
||
}
|
||
|
||
#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;
|
||
}
|
||
|
||
/* ---- Bottom right: what you picked, and one hint ---------------------- */
|
||
.rail {
|
||
position: fixed;
|
||
right: var(--s4);
|
||
/* The safe-area inset is zero everywhere except the phone it exists for,
|
||
where `.rail { bottom: 16px }` puts the `?` button underneath the home
|
||
indicator — a control you can see and cannot press. */
|
||
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
|
||
z-index: var(--z-chrome);
|
||
display: flex;
|
||
flex-direction: column;
|
||
align-items: flex-end;
|
||
gap: var(--s2);
|
||
max-width: min(26rem, calc(100vw - var(--s4) * 2));
|
||
}
|
||
#detail {
|
||
max-width: 100%;
|
||
display: flex;
|
||
align-items: flex-start;
|
||
gap: var(--s3);
|
||
}
|
||
#detail-text { flex: 1; min-width: 0; }
|
||
/* A dismiss the thumb can find. On a mouse the detail card is cleared by
|
||
moving the pointer off the marker, which is an affordance touch simply
|
||
does not have: a tap sets it and nothing takes it away. Tapping empty
|
||
map clears it too — this is the version you can see. */
|
||
.detail-close {
|
||
flex: none;
|
||
font: inherit;
|
||
font-size: 14px;
|
||
line-height: 1;
|
||
padding: 0 var(--s1);
|
||
margin: -2px 0;
|
||
cursor: pointer;
|
||
border: 0;
|
||
border-radius: var(--r-sm);
|
||
background: none;
|
||
color: var(--ink-3);
|
||
transition: color var(--t);
|
||
}
|
||
@media (hover: hover) { .detail-close:hover { color: var(--ink); } }
|
||
|
||
/* ---- The aircraft card ------------------------------------------------
|
||
Clicking a plane used to produce the same one-line string a company
|
||
marker produced, so the callsign, transponder hex, altitude and heading
|
||
the ADS-B feed had already parsed were thrown away one line before they
|
||
could be shown. This is that data, and it is open to everyone: ADS-B is
|
||
an unencrypted broadcast, so there is nothing here an account could
|
||
grant access to. */
|
||
.aircraft-card { display: flex; flex-direction: column; gap: var(--s1); }
|
||
.aircraft-card__title {
|
||
margin: 0;
|
||
font-size: 14px;
|
||
letter-spacing: 0.1em;
|
||
color: var(--amber-ink);
|
||
font-variant-numeric: tabular-nums;
|
||
}
|
||
.aircraft-card__subtitle { margin: 0; color: var(--ink-4); }
|
||
.aircraft-card__rows {
|
||
margin: var(--s1) 0 0;
|
||
display: grid;
|
||
grid-template-columns: auto 1fr;
|
||
gap: 2px var(--s3);
|
||
font-size: 10px;
|
||
}
|
||
.aircraft-card__rows dt { color: var(--ink-4); text-transform: uppercase; letter-spacing: 0.08em; font-size: 9px; }
|
||
.aircraft-card__rows dd { margin: 0; color: var(--ink); font-variant-numeric: tabular-nums; }
|
||
.aircraft-card__credit { margin: var(--s1) 0 0; font-size: 9px; color: var(--ink-4); }
|
||
|
||
/* ---- The rail hint ----------------------------------------------------
|
||
This was a `.card` holding five permanent key hints — `1–9 chapters ·
|
||
choose 2 or 3 to follow · [ ] city · O office · M plan` — a paragraph of
|
||
monospace glass in the corner of a 3D scene, which never changed and
|
||
four fifths of which was irrelevant to whatever you were doing.
|
||
|
||
It is at most two chips now, chosen for the mode you are actually in by
|
||
`railHints()` in `src/ui/shortcuts.ts`, and the full reference is one
|
||
press of `?` away.
|
||
|
||
**The fill came back, and the text shadow went.** The card was dropped
|
||
for a text shadow over the bare scene, which was measured against the
|
||
wrong background: on the deployed build the rail sits in the corner of a
|
||
sky, and the sky at its brightest is `skyHorizon` — 0xe9d8bb on the
|
||
California board, a pale cream. Fifty-six per cent white on that is
|
||
about 1.3:1 and a shadow does not save it; what you saw was grey text
|
||
you had to hunt for. This is now the same glass, hairline and shadow the
|
||
honesty line in the opposite corner has always carried, which is the
|
||
part of the original argument that was right — the rail is a corner
|
||
caption and it should look like the other one — and the ink is at full
|
||
strength, which puts it past 4.5:1 against the whitest sky the
|
||
atmosphere can render. It is still two chips and it is still not a
|
||
paragraph. */
|
||
.hint {
|
||
display: flex;
|
||
flex-wrap: wrap;
|
||
justify-content: flex-end;
|
||
align-items: center;
|
||
gap: var(--s1) var(--s3);
|
||
font-size: 10px;
|
||
/* Was unset, so it inherited the body's and a wrapped chip drew its
|
||
second line straight through the chip beneath it. The rail sits over a
|
||
moving scene and cannot rely on a background to hide the collision. */
|
||
line-height: 1.5;
|
||
letter-spacing: 0.04em;
|
||
/* The rail is a corner, not a column. Without a ceiling it grew to meet
|
||
the mode pill in the middle of the screen. */
|
||
max-width: min(30rem, 42vw);
|
||
color: var(--ink);
|
||
padding: var(--s1) var(--s2);
|
||
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);
|
||
}
|
||
/* One chip is one line. Wrapping happens *between* chips, which the flex
|
||
container already does; a chip that wraps inside itself is the thing
|
||
that made this unreadable. */
|
||
.hint__chip {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
gap: 5px;
|
||
white-space: nowrap;
|
||
}
|
||
/* One step down from the keycap it labels, not three. `--ink-3` is 40%
|
||
white, which is a quiet grey on a panel and invisible in a corner of
|
||
sky; on the fill above, `--ink-2` is still clearly the subordinate half
|
||
of the chip and still legible on its own. */
|
||
.hint__label { color: var(--ink-2); }
|
||
kbd {
|
||
font: inherit;
|
||
font-size: 9px;
|
||
padding: 1px 4px;
|
||
border: 1px solid var(--hairline);
|
||
border-radius: 3px;
|
||
/* Opaque, because a keycap on the rail is now a dark chip on a dark
|
||
chip: at 0.7 the glass behind it showed through and the two washed
|
||
into one another over a bright sky. */
|
||
background: rgb(8, 12, 17);
|
||
color: var(--ink);
|
||
}
|
||
|
||
.rail-buttons { display: flex; align-items: center; gap: var(--s1); }
|
||
.help {
|
||
font: inherit;
|
||
font-size: 10px;
|
||
letter-spacing: 0.04em;
|
||
min-height: 30px;
|
||
padding: 2px var(--s2);
|
||
cursor: pointer;
|
||
border: 1px solid var(--hairline);
|
||
border-radius: var(--r-sm);
|
||
background: var(--glass);
|
||
backdrop-filter: var(--blur);
|
||
-webkit-backdrop-filter: var(--blur);
|
||
/* Full ink. This is the one control on the screen that has to be found
|
||
by somebody who does not yet know what any of this is, and it spends
|
||
most of its life over sky. */
|
||
color: var(--ink);
|
||
transition: background var(--t), color var(--t);
|
||
}
|
||
@media (hover: hover) {
|
||
.help:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
|
||
}
|
||
/* A toggle has to look like its state, or it is a button that appears to do
|
||
nothing on every second press. Same treatment the chapter and board
|
||
buttons take, for the same reason and from the same attribute. */
|
||
.help[aria-pressed="true"] { background: rgba(242, 177, 52, 0.2); color: var(--amber-ink); }
|
||
|
||
/* ---- Bottom left: where the numbers came from -------------------------
|
||
Shown only when the data is live — see `chromeState` 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: calc(var(--s4) + env(safe-area-inset-bottom));
|
||
margin: 0;
|
||
z-index: var(--z-chrome);
|
||
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; }
|
||
|
||
/* The scrim behind the panel sheet. Declared *before* the phone breakpoint
|
||
and not after it: two rules of equal specificity, and the last one in
|
||
the file wins whatever the media query says — which is how this spent
|
||
an afternoon being permanently invisible. */
|
||
#scrim {
|
||
display: none;
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: var(--z-play);
|
||
background: rgba(4, 7, 11, 0.45);
|
||
}
|
||
|
||
/* ---- Overlays --------------------------------------------------------- */
|
||
.overlay {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: var(--z-modal);
|
||
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(30rem, 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); }
|
||
.sheet-section {
|
||
margin: var(--s4) 0 var(--s2);
|
||
color: var(--ink-4);
|
||
}
|
||
.sheet-section:first-child { margin-top: 0; }
|
||
.keys { margin: 0; display: grid; grid-template-columns: minmax(5.5rem, auto) 1fr;
|
||
gap: var(--s2) var(--s3); align-items: baseline; font-size: 11px; }
|
||
.keys dt { text-align: right; display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 3px; }
|
||
.keys dt.keys-gesture { color: var(--ink-2); text-align: right; display: block; }
|
||
.keys dd { margin: 0; color: var(--ink-2); }
|
||
.keys-also { color: var(--ink-4); }
|
||
.sheet-note { margin: var(--s4) 0 0; font-size: 10px; line-height: 1.6; color: var(--ink-4); }
|
||
/* One notch up from the note above it, and a hairline off it. A credit
|
||
line is somebody's licence term rather than this project's small print,
|
||
and two identical grey paragraphs read as one. */
|
||
.sheet-credits {
|
||
margin-top: var(--s3);
|
||
padding-top: var(--s3);
|
||
border-top: 1px solid var(--hairline);
|
||
color: var(--ink-3);
|
||
}
|
||
.sheet-degraded {
|
||
margin: 0;
|
||
padding-left: var(--s4);
|
||
font-size: 10px;
|
||
line-height: 1.7;
|
||
color: #ffb98a;
|
||
}
|
||
.sheet-close {
|
||
margin-top: var(--s4);
|
||
font: inherit;
|
||
font-size: 11px;
|
||
min-height: var(--tap);
|
||
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);
|
||
}
|
||
@media (hover: hover) { .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: var(--z-boot);
|
||
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); }
|
||
|
||
/* ---- The play HUD and the mode dock ----------------------------------- */
|
||
.play-hud {
|
||
position: fixed;
|
||
top: calc(var(--s4) + env(safe-area-inset-top));
|
||
left: 50%;
|
||
z-index: var(--z-play);
|
||
min-width: min(27rem, calc(100vw - 12rem));
|
||
max-width: calc(100vw - var(--s4) * 2);
|
||
transform: translateX(-50%);
|
||
display: grid;
|
||
grid-template-columns: auto minmax(0, 1fr) auto;
|
||
align-items: baseline;
|
||
gap: var(--s2) var(--s3);
|
||
padding: var(--s2) var(--s3);
|
||
pointer-events: none;
|
||
color: var(--ink-2);
|
||
background: rgba(8, 12, 17, 0.78);
|
||
border: 1px solid var(--hairline);
|
||
border-radius: var(--r);
|
||
box-shadow: var(--shadow);
|
||
backdrop-filter: var(--blur);
|
||
-webkit-backdrop-filter: var(--blur);
|
||
font-size: 10px;
|
||
font-variant-numeric: tabular-nums;
|
||
letter-spacing: 0.05em;
|
||
}
|
||
.play-hud[hidden] { display: none; }
|
||
.play-hud-mode { color: var(--amber-ink); text-transform: uppercase; letter-spacing: 0.11em; }
|
||
.play-hud-primary { color: var(--ink); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
||
.play-hud-status { text-align: right; color: var(--ink-3); }
|
||
.play-hud-status.warning { color: #ff9d72; }
|
||
|
||
/*
|
||
* The mode dock, which is the single most important control on the page
|
||
* and used to read as an isolated pill with no relationship to anything.
|
||
* It is still a pill — that is right for a segmented control that must not
|
||
* cover the scene — but it is now the only thing at the bottom centre, it
|
||
* is sized for a thumb at every width, and `chromeState` hides it entirely
|
||
* when `overview` is the only mode available rather than rendering one
|
||
* already-pressed button that does nothing.
|
||
*/
|
||
.mode-dock {
|
||
position: fixed;
|
||
left: 50%;
|
||
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
|
||
z-index: var(--z-dock);
|
||
transform: translateX(-50%);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 3px;
|
||
padding: 4px;
|
||
border: 1px solid var(--hairline);
|
||
border-radius: var(--r-pill);
|
||
background: rgba(8, 12, 17, 0.82);
|
||
box-shadow: var(--shadow);
|
||
backdrop-filter: var(--blur);
|
||
-webkit-backdrop-filter: var(--blur);
|
||
}
|
||
.mode-dock[hidden] { display: none; }
|
||
.mode-dock button {
|
||
min-width: var(--tap);
|
||
min-height: 38px;
|
||
padding: 5px 12px;
|
||
border: 0;
|
||
border-radius: var(--r-pill);
|
||
color: var(--ink-3);
|
||
background: transparent;
|
||
font: inherit;
|
||
font-size: 9px;
|
||
letter-spacing: 0.06em;
|
||
text-transform: uppercase;
|
||
cursor: pointer;
|
||
}
|
||
.mode-dock button[aria-pressed="true"] {
|
||
color: var(--amber-ink);
|
||
background: rgba(242, 177, 52, 0.2);
|
||
}
|
||
.mode-dock button[hidden] { display: none; }
|
||
|
||
/* ---- The touch surface ------------------------------------------------
|
||
Analogue movement on the left, only the actions meaningful to the
|
||
current vehicle or actor on the right.
|
||
|
||
What used to live here as well: about a hundred lines of CSS for the
|
||
two button-bar controls this joystick replaced, whose elements were
|
||
removed from this document long ago. Every rule targeting them was
|
||
dead — and the rules keyed off *this* surface were worse than dead,
|
||
because `:has()` matches an element that is `display: none`. There was
|
||
no pointer guard on them, so **starting a drive on a desktop with a
|
||
mouse hid the key-hint strip and shoved the mode dock up 160px** to
|
||
make room for a control bar that is not drawn on that device, at
|
||
exactly the moment a new driver needed the hints. Both offset rules now
|
||
live inside the same `(pointer: coarse)` / `body.touch-capable` guard
|
||
the controls themselves do. */
|
||
.touch-play-controls { display: none; }
|
||
@media (pointer: coarse) {
|
||
.touch-play-controls:not([hidden]) { display: block; }
|
||
}
|
||
body.touch-capable .touch-play-controls:not([hidden]) { display: block; }
|
||
.touch-play-controls:not([hidden]) {
|
||
position: fixed;
|
||
inset: 0;
|
||
z-index: var(--z-play);
|
||
pointer-events: none;
|
||
}
|
||
.play-stick {
|
||
--stick-x: 0px;
|
||
--stick-y: 0px;
|
||
position: absolute;
|
||
left: max(var(--s3), env(safe-area-inset-left));
|
||
bottom: calc(var(--s3) + env(safe-area-inset-bottom));
|
||
width: clamp(7.5rem, 31vw, 9rem);
|
||
aspect-ratio: 1;
|
||
border-radius: 50%;
|
||
pointer-events: auto;
|
||
touch-action: none;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
background: radial-gradient(circle, rgba(255,255,255,.08) 0 38%, rgba(8,12,17,.78) 40% 100%);
|
||
border: 1px solid rgba(255,255,255,.18);
|
||
box-shadow: inset 0 0 24px rgba(0,0,0,.35), var(--shadow);
|
||
backdrop-filter: var(--blur);
|
||
-webkit-backdrop-filter: var(--blur);
|
||
}
|
||
.play-stick-ring {
|
||
position: absolute;
|
||
inset: 21%;
|
||
border: 1px solid rgba(242,177,52,.32);
|
||
border-radius: 50%;
|
||
}
|
||
/* The stick says what it moves. Unlabelled, it is a circle — and a circle
|
||
is what a first-time visitor ignores. */
|
||
.play-stick-label {
|
||
position: absolute;
|
||
left: 0;
|
||
right: 0;
|
||
bottom: -1.25rem;
|
||
text-align: center;
|
||
font-size: 9px;
|
||
letter-spacing: 0.1em;
|
||
text-transform: uppercase;
|
||
color: var(--ink-3);
|
||
text-shadow: 0 1px 3px rgba(3, 6, 10, 0.8);
|
||
pointer-events: none;
|
||
}
|
||
.play-stick-knob {
|
||
position: absolute;
|
||
left: 50%;
|
||
top: 50%;
|
||
width: 3.25rem;
|
||
height: 3.25rem;
|
||
transform: translate(calc(-50% + var(--stick-x)), calc(-50% + var(--stick-y)));
|
||
border-radius: 50%;
|
||
background: rgba(242,177,52,.28);
|
||
border: 1px solid rgba(242,177,52,.72);
|
||
box-shadow: 0 4px 14px rgba(0,0,0,.45);
|
||
transition: transform 80ms ease-out;
|
||
}
|
||
.play-stick.active .play-stick-knob { transition: none; }
|
||
.touch-actions {
|
||
position: absolute;
|
||
right: max(var(--s3), env(safe-area-inset-right));
|
||
bottom: calc(var(--s3) + env(safe-area-inset-bottom));
|
||
width: min(10rem, 42vw);
|
||
display: grid;
|
||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||
gap: 5px;
|
||
pointer-events: auto;
|
||
}
|
||
.touch-actions button {
|
||
min-height: var(--tap);
|
||
padding: 5px 7px;
|
||
border: 1px solid rgba(255,255,255,.18);
|
||
border-radius: var(--r-pill);
|
||
background: rgba(8,12,17,.78);
|
||
color: var(--ink-2);
|
||
box-shadow: var(--shadow);
|
||
backdrop-filter: var(--blur);
|
||
-webkit-backdrop-filter: var(--blur);
|
||
font: inherit;
|
||
font-size: 9px;
|
||
letter-spacing: .06em;
|
||
text-transform: uppercase;
|
||
touch-action: none;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
}
|
||
.touch-actions button[hidden] { display: none; }
|
||
.touch-actions button[aria-pressed="true"] {
|
||
border-color: rgba(242,177,52,.72);
|
||
background: rgba(242,177,52,.28);
|
||
color: var(--amber-ink);
|
||
}
|
||
|
||
/* The two offset rules, inside the guard they always should have been in.
|
||
See the block comment above `.touch-play-controls`. */
|
||
@media (pointer: coarse) {
|
||
body:has(.touch-play-controls:not([hidden])) .mode-dock {
|
||
bottom: calc(10rem + env(safe-area-inset-bottom));
|
||
}
|
||
body:has(.touch-play-controls:not([hidden])) .source { bottom: calc(10rem + env(safe-area-inset-bottom)); }
|
||
body:has(.touch-play-controls:not([hidden])) .rail { bottom: calc(13.5rem + env(safe-area-inset-bottom)); }
|
||
}
|
||
body.touch-capable:has(.touch-play-controls:not([hidden])) .mode-dock {
|
||
bottom: calc(10rem + env(safe-area-inset-bottom));
|
||
}
|
||
body.touch-capable:has(.touch-play-controls:not([hidden])) .source {
|
||
bottom: calc(10rem + env(safe-area-inset-bottom));
|
||
}
|
||
body.touch-capable:has(.touch-play-controls:not([hidden])) .rail {
|
||
bottom: calc(13.5rem + env(safe-area-inset-bottom));
|
||
}
|
||
|
||
/* ---- Responsive -------------------------------------------------------
|
||
Two breakpoints and no more, and they are the same two `tokens.ts`
|
||
publishes and `deviceProfile()` in `stage.ts` keys its pixel budget off,
|
||
so the stylesheet and the renderer cannot drift apart about what a phone
|
||
is. At 900 the left column stops being furniture and becomes a sheet you
|
||
open; at 600 the page stops being a scaled-down desktop entirely.
|
||
`chromeState` owns the *state* of both, so these rules only describe what
|
||
each state looks like. */
|
||
@media (max-width: 900px) {
|
||
#panel {
|
||
padding: calc(var(--s3) + 44px) var(--s3) var(--s3);
|
||
width: min(19rem, calc(100vw - var(--s3) * 2));
|
||
}
|
||
body.panel-closed #panel {
|
||
transform: translateX(calc(-100% - var(--s3)));
|
||
opacity: 0;
|
||
pointer-events: none;
|
||
}
|
||
.topright {
|
||
width: min(12rem, 46vw);
|
||
top: calc(var(--s3) + env(safe-area-inset-top));
|
||
right: var(--s3);
|
||
}
|
||
.minimap-frame { height: 10rem; }
|
||
.rail { right: var(--s3); bottom: calc(var(--s3) + env(safe-area-inset-bottom)); }
|
||
.source { left: var(--s3); bottom: calc(var(--s3) + env(safe-area-inset-bottom)); }
|
||
}
|
||
|
||
/* ---- The phone --------------------------------------------------------
|
||
Below 600 this stops being a scaled-down desktop and becomes a different
|
||
layout, because the constraints are different in kind and not in degree:
|
||
there is no hover, so nothing may depend on it; there is no keyboard, so
|
||
a card explaining the keyboard is furniture; and there is one thumb,
|
||
which reaches the bottom third of the screen and not the top corners.
|
||
|
||
Three things this layout got wrong and this pass fixes:
|
||
|
||
- **The plan disappeared entirely.** It was seeded off below 600px and
|
||
laid out as a full-width bottom sheet, so the only two states were
|
||
"gone" and "eats half the map". It is a glanceable 8rem square in the
|
||
top-right column now, always there in the city, and the plan button
|
||
still expands it.
|
||
- **A "? shortcuts" button on a device with no keyboard.** The button
|
||
stays — it is the only reference this layout has, since the key strip
|
||
is `display: none` here — but it is labelled "Guide" and the sheet
|
||
leads every row with its gesture rather than its keycap.
|
||
- **No visible way to move.** The joystick is shown in every play mode
|
||
now rather than only once a body was already moving, and the coach
|
||
names the mode dock on first run. */
|
||
@media (max-width: 600px) {
|
||
.play-hud {
|
||
top: calc(var(--s3) + env(safe-area-inset-top) + 3rem);
|
||
min-width: 0;
|
||
width: calc(100vw - var(--s3) * 2);
|
||
}
|
||
.mode-dock {
|
||
max-width: calc(100vw - var(--s3) * 2);
|
||
bottom: calc(var(--s3) + env(safe-area-inset-bottom));
|
||
}
|
||
.mode-dock button { padding-inline: 10px; min-height: var(--tap); }
|
||
|
||
/* Everything hit by a finger clears 44px, which is 11 steps of the 4px
|
||
rhythm and the smallest target anyone has managed to defend. `#help`
|
||
was 17px tall. */
|
||
#panel-toggle,
|
||
.act,
|
||
.board,
|
||
.chapter,
|
||
.help,
|
||
.detail-close,
|
||
.sheet-close {
|
||
min-height: var(--tap);
|
||
}
|
||
.chapter { align-items: center; }
|
||
.help,
|
||
.detail-close {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: var(--tap);
|
||
border-radius: var(--r-pill);
|
||
}
|
||
|
||
/* The rail spans the width so the detail card — now reachable, because
|
||
a marker picks on tap, and now much richer for an aircraft — is a
|
||
readable card rather than a column. */
|
||
.rail {
|
||
left: var(--s3);
|
||
right: var(--s3);
|
||
max-width: none;
|
||
align-items: stretch;
|
||
}
|
||
.rail-buttons { justify-content: flex-end; }
|
||
/* No keyboard, so no key hints. On a phone the hint *is* the Guide
|
||
button, and `railHints()` returns nothing for a coarse pointer so this
|
||
is belt and braces rather than the mechanism. */
|
||
.hint { display: none; }
|
||
|
||
/* The honesty line moves out of the thumb's way rather than out of the
|
||
layout. It is the one caption that is never allowed to be dropped for
|
||
space, so it goes to the quiet corner under the ☰ button and stays to
|
||
one ellipsised line. */
|
||
.source {
|
||
top: calc(var(--s3) + env(safe-area-inset-top) + 3.4rem);
|
||
bottom: auto;
|
||
max-width: min(58vw, calc(100vw - var(--s3) * 2));
|
||
white-space: nowrap;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
}
|
||
/* Except when it is carrying the office's disclosure, which is a
|
||
sentence and not a label. "sample occupancy · these people …" is a
|
||
truncation that removes the only word that mattered, and a disclosure
|
||
cut off mid-clause is worse than none at all — it looks like a caption
|
||
somebody bothered to write, so nobody goes looking for the rest. It
|
||
gets the width and the second line. */
|
||
body.sample-occupancy .source {
|
||
max-width: calc(100vw - var(--s3) * 2);
|
||
white-space: normal;
|
||
}
|
||
|
||
/* The plan: glanceable, not gone. */
|
||
.topright { width: min(8.5rem, 40vw); right: var(--s3); }
|
||
.minimap-frame { height: 8.5rem; }
|
||
.tier-adds { display: none; }
|
||
|
||
/* The panel, as a bottom sheet. A left drawer on a phone covers the
|
||
thing being looked at; a sheet rises from the edge the thumb starts
|
||
at, and the scrim behind it means tapping the map closes it. */
|
||
#panel {
|
||
inset: auto 0 0 0;
|
||
width: auto;
|
||
max-width: none;
|
||
max-height: 76dvh;
|
||
padding: var(--s3) var(--s3) calc(var(--s3) + env(safe-area-inset-bottom));
|
||
z-index: var(--z-instrument);
|
||
pointer-events: auto;
|
||
}
|
||
.panel-card { max-height: calc(76dvh - var(--s3) * 2); }
|
||
body.panel-closed #panel { transform: translateY(100%); opacity: 0; }
|
||
#scrim { display: block; }
|
||
body.panel-closed #scrim { display: none; }
|
||
}
|
||
|
||
/* The minimap's own visibility is a user decision (`M`, or the plan button
|
||
on the rail), decided by `chromeState` 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 California, seen from above. Drag to orbit, scroll to zoom."></canvas>
|
||
|
||
<button id="panel-toggle" class="panel-toggle" aria-expanded="true" aria-controls="panel" hidden>
|
||
<span class="glyph" aria-hidden="true">☰</span><span id="panel-toggle-label">California</span>
|
||
</button>
|
||
|
||
<!-- One surface, six groups. See the third constraint at the top of the
|
||
stylesheet for what this replaced. -->
|
||
<div id="panel">
|
||
<div class="card panel-card">
|
||
<header class="panel-head">
|
||
<h1 id="title">California</h1>
|
||
<p id="subtitle" class="t-caps">Tera · Lumbridge Simulate</p>
|
||
<p id="clock" class="clock" aria-live="polite"></p>
|
||
</header>
|
||
|
||
<section class="panel-group">
|
||
<p id="boards-title" class="group-title t-caps">Boards</p>
|
||
<nav id="cities" class="boards" aria-label="Board"></nav>
|
||
</section>
|
||
|
||
<!-- What is burning on the board above, and — on most days, and on the
|
||
Southland today — the sentence that says nothing is. Mounted by
|
||
`main.ts` rather than by `mount.ts`, like `#presence-host`: the
|
||
panel is rebuilt per board because its bounds are the board's, and
|
||
the section is hidden outright on a deployment with no fire
|
||
projection behind it. -->
|
||
<section class="panel-group" id="fire-section" hidden>
|
||
<p class="group-title t-caps">Fire</p>
|
||
<div id="fire-host"></div>
|
||
</section>
|
||
|
||
<section class="panel-group">
|
||
<p class="group-title t-caps">Go</p>
|
||
<div class="actions">
|
||
<button id="enter" type="button" class="act act--primary">Enter the studio →</button>
|
||
<button id="walk" type="button" class="act" aria-pressed="false" hidden>Walk →</button>
|
||
<button id="fly" type="button" class="act" aria-pressed="false" hidden>Fly the California route →</button>
|
||
<button id="screens" type="button" class="act" hidden>Office screens →</button>
|
||
<button id="devices" type="button" class="act" hidden>Studio hardware →</button>
|
||
</div>
|
||
<!-- The office's one invitation, and its media/robot note. Both used to
|
||
share a single `#office-badge`, which meant a disclosure and a call
|
||
to action took turns occupying one element. -->
|
||
<p id="office-invite" class="note" hidden></p>
|
||
<p id="office-note" class="note" hidden></p>
|
||
</section>
|
||
|
||
<section class="panel-group" id="device-section" hidden>
|
||
<p class="group-title t-caps">Studio hardware</p>
|
||
<div id="device-host"></div>
|
||
</section>
|
||
|
||
<section class="panel-group">
|
||
<p class="group-title t-caps">Chapters</p>
|
||
<nav id="chapters" class="chapters" aria-label="Chapters"></nav>
|
||
<p id="blurb" hidden></p>
|
||
</section>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- Four fixed elements became one column. Nothing in here needs to know
|
||
what is above it any more; see the block comment on `.topright`. -->
|
||
<div class="topright" id="topright">
|
||
<p class="card tier" id="tier" hidden>
|
||
<span id="tier-label"></span>
|
||
<span id="tier-who" class="who" hidden></span>
|
||
<a id="tier-signin" href="#" hidden>Sign in</a>
|
||
<button id="tier-character" type="button" class="profile-trigger" hidden>Character</button>
|
||
</p>
|
||
<p class="card tier-adds" id="tier-adds" hidden></p>
|
||
<div id="presence-host"></div>
|
||
<div id="webcam-face-indicator"></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>
|
||
</aside>
|
||
</div>
|
||
|
||
<div class="rail" id="rail">
|
||
<div class="card" id="detail" aria-live="polite" hidden>
|
||
<div id="detail-text"></div>
|
||
<button id="detail-close" class="detail-close" aria-label="Dismiss">×</button>
|
||
</div>
|
||
<div class="hint" id="hint"></div>
|
||
<div class="rail-buttons">
|
||
<!-- The plan view's tap target, for the devices where `M` is not a thing
|
||
that exists. Until this button did, the plan was seeded off below
|
||
600px and the key was the only way to turn it on, so the phone
|
||
layout designed for it was unreachable on the device it was drawn
|
||
for. `aria-pressed` and no second label, because the state it
|
||
reports is the map appearing next to it. -->
|
||
<button id="plan-toggle" type="button" class="help" aria-pressed="true">Plan</button>
|
||
<button id="help" type="button" class="help" aria-haspopup="dialog">?</button>
|
||
</div>
|
||
</div>
|
||
|
||
<section id="play-hud" class="play-hud" aria-live="polite" hidden>
|
||
<span id="play-hud-mode" class="play-hud-mode"></span>
|
||
<strong id="play-hud-primary" class="play-hud-primary"></strong>
|
||
<span id="play-hud-status" class="play-hud-status"></span>
|
||
</section>
|
||
|
||
<nav id="mode-dock" class="mode-dock" aria-label="Control mode">
|
||
<button type="button" data-control-mode="overview" aria-pressed="true">View</button>
|
||
<button type="button" data-control-mode="drive" aria-pressed="false" hidden>Drive</button>
|
||
<button type="button" data-control-mode="actor" aria-pressed="false" hidden>Explore</button>
|
||
<button type="button" data-control-mode="aircraft" aria-pressed="false" hidden>Fly</button>
|
||
<button type="button" data-control-mode="office-walk" aria-pressed="false" hidden>Walk</button>
|
||
</nav>
|
||
|
||
<div id="touch-play-controls" class="touch-play-controls" aria-label="Touch play controls" hidden>
|
||
<div id="play-stick" class="play-stick" role="application"
|
||
aria-label="Movement joystick. Drag in any direction.">
|
||
<span class="play-stick-ring" aria-hidden="true"></span>
|
||
<span id="play-stick-knob" class="play-stick-knob" aria-hidden="true"></span>
|
||
<span id="play-stick-label" class="play-stick-label" aria-hidden="true">Move</span>
|
||
</div>
|
||
<div class="touch-actions">
|
||
<button id="touch-primary" data-play-control="primary" aria-pressed="false">Action</button>
|
||
<button id="touch-secondary" data-play-control="secondary" aria-pressed="false" hidden>Glide</button>
|
||
<button id="touch-pitch-up" data-play-control="pitch-up" aria-pressed="false" hidden>Pitch +</button>
|
||
<button id="touch-pitch-down" data-play-control="pitch-down" aria-pressed="false" hidden>Pitch −</button>
|
||
<button id="touch-assist" hidden>Assist</button>
|
||
<button id="touch-reset" hidden>Reset</button>
|
||
<button id="touch-camera" hidden>Camera</button>
|
||
<button id="touch-map" aria-pressed="false">Map</button>
|
||
</div>
|
||
</div>
|
||
|
||
<p id="source" class="source"></p>
|
||
|
||
<div id="profile-overlay" class="profile-overlay" hidden></div>
|
||
<div id="screens-overlay" class="profile-overlay" hidden></div>
|
||
|
||
<!-- Where the first-run coach mounts. Empty in the document so that a
|
||
returning visitor's page carries no trace of it at all — see
|
||
`src/ui/onboarding.ts`. -->
|
||
<div id="onboarding-host"></div>
|
||
|
||
<!-- Behind the panel sheet on a phone, and nowhere else. A sheet with no
|
||
scrim leaves no way to dismiss it but the same button that opened it,
|
||
which is the wrong half of the screen for a thumb. -->
|
||
<div id="scrim"></div>
|
||
|
||
<!-- The `?` card. Its body is rendered from the one `KEYMAP` table in
|
||
`src/ui/shortcuts.ts`, which is also what the key handler reads, so the
|
||
two can no longer disagree — and they did, in three separate ways. -->
|
||
<div class="overlay" id="shortcuts" role="dialog" aria-modal="true"
|
||
aria-labelledby="shortcuts-title" hidden>
|
||
<div class="card sheet">
|
||
<h2 id="shortcuts-title">Every control</h2>
|
||
<div id="shortcuts-body"></div>
|
||
<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>
|