37d5320664
A link to tera.lumbridgecorp.com or office.lumbridgecorp.com unfurled as a bare
blue URL. No picture, no sentence, and a title — "Lumbridge Simulate — San
Francisco" — that was wrong at one of the two doors and stale at the other.
There were no `og:` or `twitter:` tags in the document at all. For a project
whose entire pitch is that you should look at it, that is the most expensive
missing markup in the repo.
Both cards are screenshots of the running app, not drawings of it, and that is
the load-bearing decision rather than a shortcut. `scripts/brand-assets/` builds
them in two passes: shoot the city and the office out of `dist/`, then render
`og.html` over those shots at exactly 1200x630. The thing is worth looking at,
and a drawing of it goes stale in silence — which is not hypothetical. The card
currently live on lumbridgecorp.com is a viewport screenshot of a marketing page
that has since been rewritten, so that preview advertises a positioning the site
no longer uses, and it has been doing so for a month with nobody noticing. A
card regenerated from `dist/` by one command is a card that can be kept true by
running the command.
The clock is shifted to midday for the capture, because the sun is real —
`observe()` computes it from `new Date()` — and a card regenerated at two in the
morning is an honest photograph of a black rectangle. Shifted rather than
frozen: everything else runs off `requestAnimationFrame`, and a stopped clock
stalls the frame loop the screenshot is waiting on.
Every string on the cards is the project's own. The headlines are what
`README.md` already says each half is; "Clone it and it works" is CONTRACT.md
§0's acceptance test in the words `main.ts` uses for it; the licence in the
corner is the one in the repo root. A share card is the most-read and least
reviewed sentence a project has, which is exactly why it should not be where new
claims get invented.
The harder half was that the two doors are one bundle. The app sorts out which
door it is by reading its own hostname; a crawler cannot, because it reads the
HTML and nothing else — so one `index.html` means both doors unfurl as the same
place, and being a different place is the office door's whole reason to exist. A
second hand-written shell is what the Caddy config already argues against for the
static root ("sharing the root rather than copying it means a deploy cannot leave
the two doors on different builds"), and two 900-line files each carrying the
inline stylesheet would drift on the first CSS change with nothing to notice.
So the build emits both. Everything outside the `ogc:` markers is copied byte for
byte — verified: `office.html` and `index.html` are identical below `</head>` and
point at the same bundle hash — and only the head block differs. A change to the
interface reaches both doors by construction. It runs in `writeBundle` rather
than `transformIndexHtml` because it needs the finished document, after Vite has
rewritten the asset URLs, and it errors rather than no-oping if the markers go
missing: a card that is quietly the wrong one is the failure the plugin exists to
prevent.
`deploy/Caddyfile.snippet` documents the one line that turns it on — the office
door's `try_files` fallback pointing at `/office.html` off the same shared root.
A deployment that skips it is not broken; the office door keeps working and
unfurls with the city's card, which is what it did before.
Not deployed. The live Caddyfile still falls back to /index.html for the office
host, so this needs that one-line change on cloud-2 before office.
lumbridgecorp.com unfurls as the office.
Both shells boot clean in Chrome with zero console errors, 31 client tests and
the no-binary gate still pass — `public/` is exempt from it, which is where the
two PNGs live.
953 lines
40 KiB
HTML
953 lines
40 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="The San Francisco Bay Area rendered from above in Tera, under a midday sun."
|
||
/>
|
||
|
||
<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 Bay Area heightfield has been built.
|
||
*
|
||
* Two constraints shape every rule below.
|
||
*
|
||
* The first is that this chrome floats over a *photographic* background
|
||
* that changes from a bright noon sky to a nearly black one and back. A
|
||
* panel tuned only for the dark case — which is what this was — reads as
|
||
* a grey smudge at noon, because dark-on-dark needs no edge and
|
||
* dark-on-bright very much does. Every card therefore carries three
|
||
* separations at once: a fill dark enough to hold white text against a
|
||
* white sky, a hairline that is light on the inside so the card has a
|
||
* rim, and one soft drop shadow so it sits above rather than in.
|
||
*
|
||
* The second is that there is exactly one accent. `#f2b134` is the
|
||
* identity and it is spent only on things that are *active* — the current
|
||
* chapter, the current city, the office door, a focus ring. The moment it
|
||
* also means "heading" and "border" and "hover" it stops meaning
|
||
* anything, which is what a second pass at this looked like before it was
|
||
* pulled back.
|
||
*/
|
||
:root {
|
||
--amber: #f2b134;
|
||
--amber-lit: #ffc555;
|
||
--amber-ink: #ffd68a;
|
||
|
||
/* One glass recipe, two weights. The strong one is for things that must
|
||
be read over the brightest part of the sky: the boot card and the
|
||
shortcuts sheet. */
|
||
--glass: rgba(9, 13, 18, 0.62);
|
||
--glass-strong: rgba(9, 13, 18, 0.86);
|
||
--glass-inset: rgba(255, 255, 255, 0.05);
|
||
--hairline: rgba(255, 255, 255, 0.11);
|
||
--blur: blur(14px) saturate(1.2);
|
||
--shadow: 0 6px 22px rgba(3, 6, 10, 0.45);
|
||
|
||
/* Four steps of ink and no more. Anything that wanted a fifth was
|
||
saying something the type scale should have said instead. */
|
||
--ink: rgba(255, 255, 255, 0.78);
|
||
--ink-2: rgba(255, 255, 255, 0.56);
|
||
--ink-3: rgba(255, 255, 255, 0.4);
|
||
--ink-4: rgba(255, 255, 255, 0.26);
|
||
|
||
/* 4px rhythm. Every gap, pad and offset below is a multiple of it, so
|
||
the columns line up without anyone having to nudge a value. */
|
||
--s1: 4px;
|
||
--s2: 8px;
|
||
--s3: 12px;
|
||
--s4: 16px;
|
||
--s5: 24px;
|
||
|
||
--r: 8px;
|
||
--r-sm: 5px;
|
||
--t: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||
}
|
||
|
||
* { box-sizing: border-box; }
|
||
[hidden] { display: none !important; }
|
||
|
||
html, body {
|
||
margin: 0;
|
||
height: 100%;
|
||
/* Dark, not the old `#d9e6ee`. The canvas covers this within a frame,
|
||
but the frame before it used to be a full-screen flash of pale blue
|
||
that then dropped to a night scene. A dark page is wrong for half a
|
||
second at noon and right for half a second at midnight; the flash is
|
||
what people noticed. */
|
||
background: #0d1218;
|
||
overflow: hidden;
|
||
/* 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);
|
||
}
|
||
|
||
/* ---- 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 San Francisco, seen from above. Drag to orbit, scroll to zoom."></canvas>
|
||
|
||
<button id="panel-toggle" class="panel-toggle" aria-expanded="true" aria-controls="panel">
|
||
<span class="glyph" aria-hidden="true">☰</span><span id="panel-toggle-label">Bay Area</span>
|
||
</button>
|
||
|
||
<div id="panel">
|
||
<section class="card">
|
||
<h1 id="title">San Francisco</h1>
|
||
<p id="subtitle">Tera · Lumbridge Simulate</p>
|
||
<p id="clock" class="clock" aria-live="polite"></p>
|
||
</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><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>
|
||
|
||
<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>[</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>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>
|