1018 lines
43 KiB
HTML
1018 lines
43 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.
|
||
*
|
||
* 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;
|
||
/* 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;
|
||
}
|
||
|
||
/* ---- 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;
|
||
}
|
||
|
||
/* 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. */
|
||
|
||
.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);
|
||
}
|
||
@media (hover: hover) {
|
||
.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);
|
||
}
|
||
@media (hover: hover) { .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);
|
||
}
|
||
@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-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: calc(var(--s3) + env(safe-area-inset-top));
|
||
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: who you are, and the plan ------------------------------
|
||
The tier badge is its own fixed card rather than the last child of
|
||
`.corner`, which it used to be. Two reasons, and the second is a bug:
|
||
a phone turns the plan into a bottom sheet and the badge must not go
|
||
down there with it, and `M` — which hides the plan — was also hiding
|
||
the one line that says whether you are signed in. */
|
||
.corner {
|
||
position: fixed;
|
||
/* Clear of the tier badge above it, which is one line of 9px caps in a
|
||
card: 8 + 14 + 8 of padding and leading, two hairlines, and the 4px
|
||
rhythm's worth of gap. */
|
||
top: calc(var(--s4) + env(safe-area-inset-top) + 2.4rem);
|
||
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 {
|
||
position: fixed;
|
||
top: calc(var(--s4) + env(safe-area-inset-top));
|
||
right: var(--s4);
|
||
z-index: 3;
|
||
margin: 0;
|
||
max-width: min(15rem, 50vw);
|
||
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;
|
||
min-width: 0;
|
||
overflow: hidden;
|
||
text-overflow: ellipsis;
|
||
white-space: nowrap;
|
||
}
|
||
.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);
|
||
/* 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: 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%;
|
||
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); } }
|
||
.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);
|
||
}
|
||
/* The plan toggle is for pointers, and only for pointers.
|
||
Where there is a keyboard the `M` hint two elements to the left is the
|
||
affordance and a button saying the same thing is a second row of glass
|
||
for nothing. Where there is not — a phone, a tablet, a touchscreen
|
||
kiosk — the hints are hidden or meaningless and this is the only way in.
|
||
Keyed off the pointer rather than off the width because the defect it
|
||
fixes is about fingers: an 820px iPad has no `M` either, and it lands on
|
||
the desktop layout by design (see `deviceProfile` in `stage.ts`). */
|
||
#plan-toggle { display: none; }
|
||
@media (pointer: coarse), (max-width: 600px) {
|
||
#plan-toggle { display: inline-flex; align-items: center; }
|
||
}
|
||
|
||
.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);
|
||
}
|
||
@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 city
|
||
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 `renderSource` 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. The `?` card is also where the live
|
||
sources' own credit lines land; see `#credits`. */
|
||
.source {
|
||
position: fixed;
|
||
left: var(--s4);
|
||
bottom: calc(var(--s4) + env(safe-area-inset-bottom));
|
||
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; }
|
||
|
||
/* 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: 5;
|
||
background: rgba(4, 7, 11, 0.45);
|
||
}
|
||
|
||
/* ---- 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); }
|
||
/* The Touch section, under the keyboard one. */
|
||
.sheet h2:not(:first-child) { margin-top: var(--s4); }
|
||
.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);
|
||
}
|
||
@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: 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); }
|
||
/* 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. */
|
||
.credits {
|
||
margin-top: var(--s3);
|
||
padding-top: var(--s3);
|
||
border-top: 1px solid var(--hairline);
|
||
color: var(--ink-3);
|
||
}
|
||
|
||
/* ---- Touch driving ---------------------------------------------------
|
||
Keyboard and standard gamepads need no chrome. Coarse pointers do, and
|
||
only while a route chapter owns the follow camera. Full words rather
|
||
than mystery glyphs: the controls disappear everywhere space is tight
|
||
except on the devices that cannot drive without them. */
|
||
.drive-controls { display: none; }
|
||
@media (pointer: coarse) {
|
||
.drive-controls:not([hidden]) {
|
||
position: fixed;
|
||
left: 50%;
|
||
bottom: calc(var(--s3) + env(safe-area-inset-bottom));
|
||
transform: translateX(-50%);
|
||
z-index: 5;
|
||
width: min(23rem, calc(100vw - var(--s4) * 2));
|
||
display: grid;
|
||
grid-template-columns: repeat(4, minmax(0, 1fr));
|
||
gap: var(--s1);
|
||
padding: var(--s1);
|
||
border: 1px solid var(--hairline);
|
||
border-radius: var(--r);
|
||
background: var(--glass-strong);
|
||
backdrop-filter: var(--blur);
|
||
-webkit-backdrop-filter: var(--blur);
|
||
box-shadow: var(--shadow);
|
||
}
|
||
.drive-control {
|
||
min-height: 48px;
|
||
border: 1px solid var(--hairline);
|
||
border-radius: var(--r-sm);
|
||
background: rgba(255, 255, 255, 0.08);
|
||
color: var(--ink);
|
||
font: inherit;
|
||
font-size: 10px;
|
||
letter-spacing: 0.05em;
|
||
text-transform: uppercase;
|
||
touch-action: none;
|
||
user-select: none;
|
||
-webkit-user-select: none;
|
||
}
|
||
.drive-control[aria-pressed="true"] {
|
||
border-color: rgba(242, 177, 52, 0.65);
|
||
background: rgba(242, 177, 52, 0.28);
|
||
color: var(--amber-ink);
|
||
}
|
||
/* The source line is useful but cannot sit underneath the controls. */
|
||
body:has(.drive-controls:not([hidden])) .source { bottom: 7.5rem; }
|
||
}
|
||
|
||
/* ---- 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 moves to the "plan view" button on the rail (and `M`,
|
||
where there is a keyboard). `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: calc(var(--s3) + env(safe-area-inset-top) + 2.4rem);
|
||
right: var(--s3);
|
||
}
|
||
.tier { 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.
|
||
|
||
The renderer draws the same conclusion from the same numbers —
|
||
`deviceProfile()` in `stage.ts` keys off this exact 600px edge — so the
|
||
stylesheet and the pixel budget cannot drift apart. */
|
||
@media (max-width: 600px) {
|
||
/* 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,
|
||
#enter,
|
||
.city,
|
||
.chapter,
|
||
.sheet-close {
|
||
min-height: 44px;
|
||
}
|
||
.chapter { align-items: center; }
|
||
.help,
|
||
.detail-close {
|
||
display: inline-flex;
|
||
align-items: center;
|
||
justify-content: center;
|
||
min-width: 44px;
|
||
min-height: 44px;
|
||
border-radius: 999px;
|
||
}
|
||
|
||
/* The rail spans the width so the detail card — now reachable, because
|
||
a marker picks on tap — is a readable line rather than a column. */
|
||
.rail {
|
||
left: var(--s3);
|
||
right: var(--s3);
|
||
max-width: none;
|
||
align-items: stretch;
|
||
}
|
||
/* No keyboard, so no key hints — and with them gone the card around
|
||
them is a full-width bar of glass holding one button. On a phone the
|
||
hint *is* the `?` button. */
|
||
/* The key hints go because there is no keyboard. The plan button is not
|
||
one of them and is not a `span`, so it stays — which is the whole
|
||
reason it exists. */
|
||
.hint span { display: none; }
|
||
.hint {
|
||
align-self: flex-end;
|
||
padding: 0;
|
||
border: 0;
|
||
background: none;
|
||
box-shadow: none;
|
||
backdrop-filter: none;
|
||
-webkit-backdrop-filter: 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.1rem);
|
||
bottom: auto;
|
||
max-width: min(60vw, 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, as a bottom sheet. At 11.5rem in a corner it ate a fifth of
|
||
a 375px map and its own affordances were about six pixels across —
|
||
a map you cannot read to move a map you can. Full width above the
|
||
rail, it is a plan view; and it is still off until asked for, which
|
||
on this layout means the "plan view" button on the rail. It was `M`
|
||
and only `M` for a while, which meant this block was written for a
|
||
device that could never reach it. */
|
||
.corner {
|
||
top: auto;
|
||
left: var(--s3);
|
||
right: var(--s3);
|
||
width: auto;
|
||
bottom: calc(var(--s3) + 3.5rem + env(safe-area-inset-bottom));
|
||
}
|
||
.minimap-frame { height: min(38dvh, 18rem); }
|
||
/* Hover-only text on a device with no hover. */
|
||
.minimap-readout { 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: 72dvh;
|
||
padding: var(--s4) var(--s3) calc(var(--s4) + env(safe-area-inset-bottom));
|
||
gap: var(--s2);
|
||
z-index: 6;
|
||
pointer-events: auto;
|
||
background: var(--glass-strong);
|
||
backdrop-filter: var(--blur);
|
||
-webkit-backdrop-filter: var(--blur);
|
||
border-top: 1px solid var(--hairline);
|
||
border-radius: var(--r) var(--r) 0 0;
|
||
box-shadow: 0 -8px 30px rgba(3, 6, 10, 0.5);
|
||
}
|
||
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 view"
|
||
button on the rail), 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 California, 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">California</span>
|
||
</button>
|
||
|
||
<div id="panel">
|
||
<section class="card">
|
||
<h1 id="title">California</h1>
|
||
<p id="subtitle">Tera · Lumbridge Simulate</p>
|
||
<p id="clock" class="clock" aria-live="polite"></p>
|
||
</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>
|
||
|
||
<p class="card tier" id="tier" hidden></p>
|
||
|
||
<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 class="rail" id="rail">
|
||
<div class="card" id="detail" aria-live="polite" hidden>
|
||
<span id="detail-text"></span>
|
||
<button id="detail-close" class="detail-close" aria-label="Dismiss">×</button>
|
||
</div>
|
||
<div class="card hint" id="hint">
|
||
<span><kbd>1</kbd>–<kbd>9</kbd> chapters</span>
|
||
<span>choose <kbd>2</kbd> or <kbd>3</kbd> to follow</span>
|
||
<span><kbd>[</kbd> <kbd>]</kbd> city</span>
|
||
<span><kbd>O</kbd> office</span>
|
||
<span><kbd>M</kbd> plan</span>
|
||
<!-- 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
|
||
bottom-sheet layout designed for it further up this file was
|
||
unreachable on the device it was drawn for. Hidden where the key
|
||
hint beside it is true; see `#plan-toggle` in the stylesheet.
|
||
`aria-pressed` and no second label, because the state it reports is
|
||
the map appearing next to it. -->
|
||
<button id="plan-toggle" class="help" aria-pressed="true">plan view</button>
|
||
<button id="help" class="help" aria-haspopup="dialog">? shortcuts</button>
|
||
</div>
|
||
</div>
|
||
|
||
<div id="drive-controls" class="drive-controls" aria-label="Vehicle controls" hidden>
|
||
<button class="drive-control" data-drive-key="a" aria-pressed="false">Left</button>
|
||
<button class="drive-control" data-drive-key="w" aria-pressed="false">Throttle</button>
|
||
<button class="drive-control" data-drive-key="s" aria-pressed="false">Brake</button>
|
||
<button class="drive-control" data-drive-key="d" aria-pressed="false">Right</button>
|
||
<button class="drive-control" data-drive-key=" " aria-pressed="false">Handbrake</button>
|
||
<button class="drive-control" data-drive-action="assist">Assist</button>
|
||
<button class="drive-control" data-drive-action="reset">Reset</button>
|
||
<button class="drive-control" data-drive-action="camera">Camera</button>
|
||
</div>
|
||
|
||
<p id="source" class="source"></p>
|
||
|
||
<!-- 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>
|
||
|
||
<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>W</kbd> <kbd>A</kbd> <kbd>S</kbd> <kbd>D</kbd></dt><dd>Drive after choosing the 101 or I-5 chapter</dd>
|
||
<dt><kbd>Space</kbd></dt><dd>Handbrake while driving</dd>
|
||
<dt><kbd>P</kbd> / <kbd>R</kbd></dt><dd>Resume assisted drive / reset the car</dd>
|
||
<dt><kbd>C</kbd></dt><dd>Switch chase / driver-height camera</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 id="key-overlays"><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>
|
||
<h2 id="touch-title">Touch</h2>
|
||
<dl class="keys">
|
||
<dt>One finger</dt><dd>Orbit</dd>
|
||
<dt>Two fingers</dt><dd>Pinch to zoom, drag to move over the ground</dd>
|
||
<dt>Tap a marker</dt><dd>Its card, at the bottom of the screen</dd>
|
||
<dt>Route controls</dt><dd>Choose 101 or I-5; the driving pad appears at the bottom</dd>
|
||
<dt>Tap the map</dt><dd>Dismiss the card; tap the ☰ sheet's scrim to close it</dd>
|
||
<dt>Plan view</dt><dd>The button on the rail shows or hides it, as a sheet above the rail</dd>
|
||
</dl>
|
||
<!-- Stated as the rule rather than as a claim about this page, because
|
||
this file is static and the answer is not: one build serves a
|
||
deployment with a weather station and one with nothing at all. -->
|
||
<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 names it in the corner —
|
||
<em>live weather</em>, <em>live traffic</em>, <em>live markers</em>, or
|
||
<em>live data</em> when all three are. Nothing in the corner means everything you
|
||
are looking at came out of this bundle.</p>
|
||
<!-- Credit for whatever live source is answering right now, written by
|
||
`renderCredits` in main.ts. Empty and hidden on a build with no
|
||
server, which is most of them — and not decoration when it is not:
|
||
MET Norway and Open-Meteo are CC BY 4.0 and the API forwards the
|
||
line each of them asks for. This card is where it is shown because
|
||
it is reachable from every state on both layouts, and because the
|
||
corner line is clamped to one ellipsised phrase on a phone. -->
|
||
<p class="boot-note credits" id="credits" hidden></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>
|