A plan view in the corner, a night you can actually see, and three kinds of visitor
The right half of the screen was empty sky. It holds the board now, drawn flat,
with the footprint of the camera's own frustum on it — the one part of a minimap
that earns its place, because it answers "where am I looking from" without
leaving the shot. Click it, drag it, scroll it. It is a 2D canvas rather than a
second WebGL context, cached per city and redrawn only when something moved.
Night was black. Not dark — black: at 3 a.m. the coastline, the hills and the
bay were one shape, and the frame read as a failed render rather than as
darkness. The sky already had a floor for exactly this reason and nothing did
the equivalent for the ground, so the ground has one now. The moon still has to
be worth computing, so the gap between a moonlit night and a moonless one is
preserved rather than filled in.
Three tiers, resolved once in the new src/access.ts: anonymous, signed in,
admin. Anonymous gets the map and a public office — the shell, the furniture,
the named viewpoints, nobody home — built without the private objects rather
than with them hidden, because scene.traverse makes hiding a leak with a bow on
it. The time scrubber and the debug readouts are admin only, and admin is
granted by TERA_ADMIN_SUBJECTS on the server and inferred nowhere else. An
unreachable API means member, never god: the promise is "clone it and it works",
not "clone it and you are an administrator of a deployment you did not
configure".
Three things this run found and fixed rather than shipped:
- entryUrl came off the wire and went straight into an href with no scheme
check, and a CSP of script-src 'self' 'unsafe-inline' does not stop a
javascript: URL from navigating. One rejection point in access.ts now.
- A 5xx from /health was the same null as "no API at all" and therefore the
opposite conclusion. Eight seconds of tera-api restarting would have told
every anonymous visitor they were a member. A 5xx is an answer; it fails
closed.
- decodeURIComponent in cookieToken was the one path in auth/index.ts that
threw rather than returning ANONYMOUS, so one malformed cookie header from
an unauthenticated caller turned /api/v1/session into a 500.
Also: keyboard shortcuts, focus rings, a boot state instead of a blank 2.3
seconds, a collapsible panel under 900px, and no horizontal overflow at 375,
768, 1440 or 2560.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
+607
-53
@@ -3,75 +3,629 @@
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
<meta name="color-scheme" content="dark" />
|
||||
<!--
|
||||
An empty data URI, not an icon file. `src/` carries no binary assets by
|
||||
policy (ARCHITECTURE.md §3.1) and a favicon is not worth being the
|
||||
exception; without this line every load spends a request on `/favicon.ico`
|
||||
and logs the 404 to the console, which buries the errors that matter.
|
||||
-->
|
||||
<link rel="icon" href="data:," />
|
||||
<title>Lumbridge Simulate — San Francisco</title>
|
||||
<style>
|
||||
/*
|
||||
* The whole interface, inline, on purpose.
|
||||
*
|
||||
* There is no framework and no stylesheet because there is no build step
|
||||
* that would earn one: this file is served as written, so what you read
|
||||
* here is what the browser gets. It also means the first paint needs
|
||||
* nothing but this document — which is the entire reason the boot card at
|
||||
* the bottom of the body can be on screen before a single module has been
|
||||
* fetched, let alone before the Bay Area heightfield has been built.
|
||||
*
|
||||
* Two constraints shape every rule below.
|
||||
*
|
||||
* The first is that this chrome floats over a *photographic* background
|
||||
* that changes from a bright noon sky to a nearly black one and back. A
|
||||
* panel tuned only for the dark case — which is what this was — reads as
|
||||
* a grey smudge at noon, because dark-on-dark needs no edge and
|
||||
* dark-on-bright very much does. Every card therefore carries three
|
||||
* separations at once: a fill dark enough to hold white text against a
|
||||
* white sky, a hairline that is light on the inside so the card has a
|
||||
* rim, and one soft drop shadow so it sits above rather than in.
|
||||
*
|
||||
* The second is that there is exactly one accent. `#f2b134` is the
|
||||
* identity and it is spent only on things that are *active* — the current
|
||||
* chapter, the current city, the office door, a focus ring. The moment it
|
||||
* also means "heading" and "border" and "hover" it stops meaning
|
||||
* anything, which is what a second pass at this looked like before it was
|
||||
* pulled back.
|
||||
*/
|
||||
:root {
|
||||
--amber: #f2b134;
|
||||
--amber-lit: #ffc555;
|
||||
--amber-ink: #ffd68a;
|
||||
|
||||
/* One glass recipe, two weights. The strong one is for things that must
|
||||
be read over the brightest part of the sky: the boot card and the
|
||||
shortcuts sheet. */
|
||||
--glass: rgba(9, 13, 18, 0.62);
|
||||
--glass-strong: rgba(9, 13, 18, 0.86);
|
||||
--glass-inset: rgba(255, 255, 255, 0.05);
|
||||
--hairline: rgba(255, 255, 255, 0.11);
|
||||
--blur: blur(14px) saturate(1.2);
|
||||
--shadow: 0 6px 22px rgba(3, 6, 10, 0.45);
|
||||
|
||||
/* Four steps of ink and no more. Anything that wanted a fifth was
|
||||
saying something the type scale should have said instead. */
|
||||
--ink: rgba(255, 255, 255, 0.78);
|
||||
--ink-2: rgba(255, 255, 255, 0.56);
|
||||
--ink-3: rgba(255, 255, 255, 0.4);
|
||||
--ink-4: rgba(255, 255, 255, 0.26);
|
||||
|
||||
/* 4px rhythm. Every gap, pad and offset below is a multiple of it, so
|
||||
the columns line up without anyone having to nudge a value. */
|
||||
--s1: 4px;
|
||||
--s2: 8px;
|
||||
--s3: 12px;
|
||||
--s4: 16px;
|
||||
--s5: 24px;
|
||||
|
||||
--r: 8px;
|
||||
--r-sm: 5px;
|
||||
--t: 150ms cubic-bezier(0.4, 0, 0.2, 1);
|
||||
}
|
||||
|
||||
* { box-sizing: border-box; }
|
||||
html, body { margin: 0; height: 100%; background: #d9e6ee; overflow: hidden;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace; }
|
||||
#scene { display: block; width: 100vw; height: 100vh; }
|
||||
#panel { position: fixed; top: 0; left: 0; padding: 1rem; width: 19rem;
|
||||
display: flex; flex-direction: column; gap: 0.25rem; pointer-events: none; }
|
||||
[hidden] { display: none !important; }
|
||||
|
||||
html, body {
|
||||
margin: 0;
|
||||
height: 100%;
|
||||
/* Dark, not the old `#d9e6ee`. The canvas covers this within a frame,
|
||||
but the frame before it used to be a full-screen flash of pale blue
|
||||
that then dropped to a night scene. A dark page is wrong for half a
|
||||
second at noon and right for half a second at midnight; the flash is
|
||||
what people noticed. */
|
||||
background: #0d1218;
|
||||
overflow: hidden;
|
||||
font-family: ui-monospace, "SF Mono", Menlo, monospace;
|
||||
color: var(--ink);
|
||||
-webkit-font-smoothing: antialiased;
|
||||
}
|
||||
|
||||
#scene { display: block; width: 100vw; height: 100dvh; touch-action: none; }
|
||||
|
||||
/* ---- Type scale ------------------------------------------------------
|
||||
Five sizes. 9px is reserved for uppercase micro-labels, where the caps
|
||||
and the tracking carry the legibility that the size does not. */
|
||||
.t-title { font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase; }
|
||||
.t-body { font-size: 11px; letter-spacing: 0.01em; line-height: 1.55; }
|
||||
.t-ctrl { font-size: 12px; letter-spacing: 0.01em; }
|
||||
.t-micro { font-size: 10px; letter-spacing: 0.06em; }
|
||||
.t-caps { font-size: 9px; letter-spacing: 0.1em; text-transform: uppercase; }
|
||||
|
||||
/* ---- Glass ----------------------------------------------------------- */
|
||||
.card {
|
||||
background: var(--glass);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow: var(--shadow), inset 0 1px 0 var(--glass-inset);
|
||||
border-radius: var(--r);
|
||||
padding: var(--s3);
|
||||
color: var(--ink);
|
||||
font-size: 11px;
|
||||
line-height: 1.55;
|
||||
}
|
||||
|
||||
/* One focus treatment for everything that can take focus. The offset is
|
||||
what makes it legible on a control that is already amber. */
|
||||
:is(button, a, input, [tabindex]):focus-visible {
|
||||
outline: 2px solid var(--amber);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* ---- Left column ------------------------------------------------------ */
|
||||
#panel {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
padding: var(--s4);
|
||||
width: min(19.5rem, calc(100vw - var(--s4) * 2));
|
||||
max-height: 100dvh;
|
||||
overflow-y: auto;
|
||||
overscroll-behavior: contain;
|
||||
scrollbar-width: thin;
|
||||
scrollbar-color: rgba(255, 255, 255, 0.18) transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--s1);
|
||||
pointer-events: none;
|
||||
transition: transform var(--t), opacity var(--t);
|
||||
}
|
||||
#panel > * { pointer-events: auto; }
|
||||
.card { background: rgba(8,12,16,0.55); backdrop-filter: blur(6px);
|
||||
border-radius: 6px; padding: 0.75rem; color: rgba(255,255,255,0.72); font-size: 11px;
|
||||
line-height: 1.5; }
|
||||
h1 { margin: 0; font-size: 11px; letter-spacing: 0.2em; text-transform: uppercase;
|
||||
color: #f2b134; }
|
||||
h1 + p { margin: 0.15rem 0 0; color: rgba(255,255,255,0.55); }
|
||||
#chapters { display: flex; flex-direction: column; gap: 1px; background: rgba(8,12,16,0.45);
|
||||
backdrop-filter: blur(6px); border-radius: 6px; padding: 4px; }
|
||||
.chapter { display: flex; align-items: baseline; gap: 0.5rem; padding: 0.4rem 0.5rem;
|
||||
background: none; border: 0; border-radius: 4px; cursor: pointer; text-align: left;
|
||||
font: inherit; font-size: 12px; color: rgba(255,255,255,0.7); }
|
||||
.chapter:hover { background: rgba(255,255,255,0.09); }
|
||||
.chapter.active { background: rgba(242,177,52,0.2); color: #ffd68a; }
|
||||
.num { font-size: 10px; opacity: 0.6; font-variant-numeric: tabular-nums; }
|
||||
#detail { position: fixed; right: 1rem; bottom: 2.5rem; }
|
||||
#hint { position: fixed; right: 1rem; bottom: 1rem; font-size: 10px;
|
||||
color: rgba(20,30,40,0.5); }
|
||||
.clock { margin: 0.35rem 0 0; font-size: 10px; letter-spacing: 0.08em;
|
||||
color: rgba(255,255,255,0.42); }
|
||||
.enter { font: inherit; font-size: 12px; padding: 0.5rem 0.7rem; cursor: pointer;
|
||||
text-align: left; border: 0; border-radius: 6px; color: #10161d;
|
||||
background: #f2b134; font-weight: 600; }
|
||||
.enter:hover { background: #ffc555; }
|
||||
.scrub { display: flex; align-items: center; gap: 0.4rem; margin-top: 0.45rem; }
|
||||
.scrub input { flex: 1; accent-color: #f2b134; height: 14px; }
|
||||
.cities { display: flex; gap: 3px; }
|
||||
.city { flex: 1; font: inherit; font-size: 11px; padding: 0.35rem; cursor: pointer;
|
||||
border: 0; border-radius: 5px; background: rgba(8,12,16,0.55); color: rgba(255,255,255,0.6);
|
||||
backdrop-filter: blur(6px); }
|
||||
.city:hover { background: rgba(255,255,255,0.14); }
|
||||
.city.active { background: rgba(242,177,52,0.22); color: #ffd68a; }
|
||||
.source { position: fixed; left: 1rem; bottom: 1rem; font-size: 10px;
|
||||
color: rgba(255,255,255,0.4); background: rgba(8,12,16,0.5); padding: 0.25rem 0.5rem;
|
||||
border-radius: 4px; backdrop-filter: blur(6px); }
|
||||
.source.live { color: #7ee08a; }
|
||||
.scrub button { font: inherit; font-size: 9px; text-transform: uppercase;
|
||||
letter-spacing: 0.08em; padding: 0.15rem 0.35rem; cursor: pointer; border: 0;
|
||||
border-radius: 3px; background: rgba(255,255,255,0.13); color: rgba(255,255,255,0.7); }
|
||||
|
||||
h1 {
|
||||
margin: 0;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.2em;
|
||||
text-transform: uppercase;
|
||||
color: var(--amber);
|
||||
}
|
||||
#subtitle { margin: var(--s1) 0 0; font-size: 10px; letter-spacing: 0.06em; color: var(--ink-3); }
|
||||
.clock {
|
||||
margin: var(--s2) 0 0;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.06em;
|
||||
color: var(--ink-2);
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
.scrub { display: flex; align-items: center; gap: var(--s2); margin-top: var(--s2); }
|
||||
.scrub input { flex: 1; accent-color: var(--amber); height: 14px; min-width: 0; }
|
||||
.scrub button {
|
||||
font: inherit;
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: 3px 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-sm);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--ink-2);
|
||||
transition: background var(--t), color var(--t);
|
||||
}
|
||||
.scrub button:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
|
||||
|
||||
.cities { display: flex; gap: var(--s1); }
|
||||
.city {
|
||||
flex: 1;
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
padding: var(--s2) var(--s1);
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-sm);
|
||||
background: var(--glass);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
box-shadow: var(--shadow);
|
||||
color: var(--ink-2);
|
||||
transition: background var(--t), color var(--t);
|
||||
}
|
||||
.city:hover { background: rgba(40, 48, 58, 0.7); color: var(--ink); }
|
||||
.city[aria-pressed="true"] { background: rgba(242, 177, 52, 0.22); color: var(--amber-ink); }
|
||||
|
||||
.enter {
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
padding: var(--s2) var(--s3);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
border: 1px solid transparent;
|
||||
border-radius: var(--r-sm);
|
||||
color: #10161d;
|
||||
background: var(--amber);
|
||||
font-weight: 600;
|
||||
box-shadow: var(--shadow);
|
||||
transition: background var(--t);
|
||||
}
|
||||
.enter:hover { background: var(--amber-lit); }
|
||||
|
||||
/* The public-office note. Amber-edged rather than amber-filled: it is an
|
||||
explanation, not an action, and the one filled amber thing on screen
|
||||
should stay the door. */
|
||||
.badge {
|
||||
margin: 0;
|
||||
border-left: 2px solid rgba(242, 177, 52, 0.55);
|
||||
border-radius: var(--r-sm);
|
||||
padding: var(--s2) var(--s3);
|
||||
font-size: 10px;
|
||||
line-height: 1.6;
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.badge a { color: var(--amber-ink); }
|
||||
|
||||
#chapters {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1px;
|
||||
background: var(--glass);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow: var(--shadow), inset 0 1px 0 var(--glass-inset);
|
||||
border-radius: var(--r);
|
||||
padding: var(--s1);
|
||||
}
|
||||
.chapter {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--s2);
|
||||
padding: var(--s2) var(--s2);
|
||||
background: none;
|
||||
border: 0;
|
||||
border-radius: var(--r-sm);
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
font: inherit;
|
||||
font-size: 12px;
|
||||
color: var(--ink-2);
|
||||
transition: background var(--t), color var(--t);
|
||||
}
|
||||
.chapter:hover { background: rgba(255, 255, 255, 0.09); color: var(--ink); }
|
||||
.chapter[aria-pressed="true"] { background: rgba(242, 177, 52, 0.2); color: var(--amber-ink); }
|
||||
.num { font-size: 9px; letter-spacing: 0.1em; opacity: 0.55; font-variant-numeric: tabular-nums; }
|
||||
|
||||
#blurb { margin: 0; color: var(--ink-2); }
|
||||
|
||||
/* The panel toggle only exists on a narrow screen, where it doubles as the
|
||||
header — hence the city name in it. */
|
||||
.panel-toggle {
|
||||
display: none;
|
||||
position: fixed;
|
||||
top: var(--s3);
|
||||
left: var(--s3);
|
||||
z-index: 4;
|
||||
align-items: center;
|
||||
gap: var(--s2);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
padding: var(--s2) var(--s3);
|
||||
cursor: pointer;
|
||||
color: var(--amber);
|
||||
background: var(--glass);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-sm);
|
||||
box-shadow: var(--shadow);
|
||||
}
|
||||
.panel-toggle .glyph { font-size: 13px; line-height: 1; }
|
||||
|
||||
/* ---- Top right: the plan --------------------------------------------- */
|
||||
.corner {
|
||||
position: fixed;
|
||||
top: var(--s4);
|
||||
right: var(--s4);
|
||||
width: 15rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: var(--s1);
|
||||
}
|
||||
#minimap { padding: var(--s1); display: flex; flex-direction: column; gap: var(--s1); }
|
||||
/* A real height, always. The canvas is pinned to 100%/100% inline by
|
||||
`minimap.ts`, and an `auto` height here puts back the feedback loop that
|
||||
rule exists to break: the backing store feeds layout, layout feeds the
|
||||
backing store, and the map doubles in size every frame. */
|
||||
.minimap-frame {
|
||||
width: 100%;
|
||||
height: 14rem;
|
||||
border-radius: var(--r-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
.minimap-canvas { outline: none; cursor: crosshair; }
|
||||
.minimap-canvas:focus-visible { outline: 2px solid var(--amber); outline-offset: -2px; }
|
||||
.minimap-readout {
|
||||
margin: 0;
|
||||
padding: 0 var(--s1) 2px;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.05em;
|
||||
color: var(--ink-3);
|
||||
min-height: 1.3em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.tier {
|
||||
margin: 0;
|
||||
padding: var(--s2) var(--s3);
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
justify-content: space-between;
|
||||
gap: var(--s2);
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
.tier .who { color: var(--ink-2); text-transform: none; letter-spacing: 0.04em; }
|
||||
.tier.god .who { color: var(--amber-ink); }
|
||||
.tier a { color: var(--amber-ink); text-transform: none; letter-spacing: 0.04em; }
|
||||
|
||||
/* ---- Bottom right: what you picked, and how to drive ------------------ */
|
||||
.rail {
|
||||
position: fixed;
|
||||
right: var(--s4);
|
||||
bottom: var(--s4);
|
||||
z-index: 3;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: flex-end;
|
||||
gap: var(--s1);
|
||||
/* Wide enough that the key hints stay on one line at desktop widths —
|
||||
a shortcut list that wraps reads as five separate notes rather than
|
||||
one row — and narrow enough that a long detail card never becomes a
|
||||
second panel. */
|
||||
max-width: min(30rem, calc(100vw - var(--s4) * 2));
|
||||
}
|
||||
#detail { max-width: 100%; }
|
||||
.hint {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
align-items: center;
|
||||
gap: var(--s1) var(--s3);
|
||||
padding: var(--s2) var(--s3);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-3);
|
||||
}
|
||||
kbd {
|
||||
font: inherit;
|
||||
font-size: 9px;
|
||||
padding: 1px 4px;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: 3px;
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--ink-2);
|
||||
}
|
||||
.help {
|
||||
font: inherit;
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 2px 6px;
|
||||
cursor: pointer;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-sm);
|
||||
background: rgba(255, 255, 255, 0.06);
|
||||
color: var(--ink-2);
|
||||
transition: background var(--t), color var(--t);
|
||||
}
|
||||
.help:hover { background: rgba(255, 255, 255, 0.16); color: var(--ink); }
|
||||
|
||||
/* ---- Bottom left: where the numbers came from -------------------------
|
||||
This line is load-bearing. The markers on this map are invented, and a
|
||||
map that looks this much like a real one has to say so on the same
|
||||
screen as the map — not in a README nobody opens. */
|
||||
.source {
|
||||
position: fixed;
|
||||
left: var(--s4);
|
||||
bottom: var(--s4);
|
||||
margin: 0;
|
||||
z-index: 3;
|
||||
max-width: calc(100vw - var(--s4) * 2);
|
||||
font-size: 10px;
|
||||
letter-spacing: 0.04em;
|
||||
color: var(--ink-3);
|
||||
background: var(--glass);
|
||||
backdrop-filter: var(--blur);
|
||||
-webkit-backdrop-filter: var(--blur);
|
||||
border: 1px solid var(--hairline);
|
||||
box-shadow: var(--shadow);
|
||||
padding: var(--s1) var(--s2);
|
||||
border-radius: var(--r-sm);
|
||||
}
|
||||
.source.live { color: #8fe89a; }
|
||||
|
||||
/* ---- Overlays --------------------------------------------------------- */
|
||||
.overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 10;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--s4);
|
||||
background: rgba(4, 7, 11, 0.55);
|
||||
backdrop-filter: blur(3px);
|
||||
}
|
||||
.sheet {
|
||||
background: var(--glass-strong);
|
||||
width: min(26rem, 100%);
|
||||
max-height: calc(100dvh - var(--s5) * 2);
|
||||
overflow-y: auto;
|
||||
padding: var(--s4);
|
||||
}
|
||||
.sheet h2 { margin: 0 0 var(--s3); font-size: 11px; letter-spacing: 0.2em;
|
||||
text-transform: uppercase; color: var(--amber); }
|
||||
.keys { margin: 0; display: grid; grid-template-columns: auto 1fr; gap: var(--s2) var(--s3);
|
||||
align-items: baseline; font-size: 11px; }
|
||||
.keys dt { text-align: right; }
|
||||
.keys dd { margin: 0; color: var(--ink-2); }
|
||||
.sheet-close {
|
||||
margin-top: var(--s4);
|
||||
font: inherit;
|
||||
font-size: 11px;
|
||||
padding: var(--s2) var(--s3);
|
||||
cursor: pointer;
|
||||
width: 100%;
|
||||
border: 1px solid var(--hairline);
|
||||
border-radius: var(--r-sm);
|
||||
background: rgba(255, 255, 255, 0.08);
|
||||
color: var(--ink);
|
||||
}
|
||||
.sheet-close:hover { background: rgba(255, 255, 255, 0.16); }
|
||||
|
||||
/* ---- Boot -------------------------------------------------------------
|
||||
The page used to be blank for the ~2.3 s it takes to build the Bay Area
|
||||
heightfield, which reads as a broken site rather than a busy one. This
|
||||
card is in the document, so it is on screen at first paint — before the
|
||||
module graph has even been fetched — and `main.ts` only ever writes the
|
||||
step line and then fades the whole thing out.
|
||||
|
||||
It is deliberately not a spinner. A spinner says "wait"; naming the
|
||||
thing being built says "wait, for this", and the sentence about running
|
||||
with no server is the one fact about this project worth reading while
|
||||
you have nothing else to do. */
|
||||
.boot {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
z-index: 20;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--s4);
|
||||
background: radial-gradient(120% 90% at 50% 30%, #172029 0%, #0b0f14 70%);
|
||||
transition: opacity 260ms ease;
|
||||
}
|
||||
.boot.done { opacity: 0; pointer-events: none; }
|
||||
.boot-inner { width: min(22rem, 100%); text-align: left; }
|
||||
.boot-mark { margin: 0; font-size: 12px; letter-spacing: 0.42em; text-transform: uppercase;
|
||||
color: var(--amber); }
|
||||
.boot-sub { margin: var(--s2) 0 0; font-size: 10px; letter-spacing: 0.08em; color: var(--ink-3); }
|
||||
.boot-bar {
|
||||
margin: var(--s4) 0 var(--s2);
|
||||
height: 2px;
|
||||
border-radius: 2px;
|
||||
background: rgba(255, 255, 255, 0.09);
|
||||
overflow: hidden;
|
||||
}
|
||||
.boot-bar i {
|
||||
display: block;
|
||||
height: 100%;
|
||||
width: 34%;
|
||||
border-radius: 2px;
|
||||
background: linear-gradient(90deg, transparent, var(--amber), transparent);
|
||||
animation: sweep 1400ms cubic-bezier(0.45, 0, 0.55, 1) infinite;
|
||||
}
|
||||
@keyframes sweep {
|
||||
0% { transform: translateX(-100%); }
|
||||
100% { transform: translateX(340%); }
|
||||
}
|
||||
.boot-step { margin: 0; font-size: 10px; letter-spacing: 0.06em; color: var(--ink-2);
|
||||
min-height: 1.4em; }
|
||||
.boot-note { margin: var(--s4) 0 0; font-size: 10px; line-height: 1.6; color: var(--ink-4); }
|
||||
|
||||
/* ---- Responsive -------------------------------------------------------
|
||||
Two breakpoints and no more. At 900 the left column stops being furniture
|
||||
and becomes a sheet you open; at 600 the plan stops fitting beside the
|
||||
map at all and lives behind `M`. `main.ts` owns the *state* of both, so
|
||||
these rules only describe what each state looks like. */
|
||||
@media (max-width: 900px) {
|
||||
.panel-toggle { display: flex; }
|
||||
#panel {
|
||||
padding-top: 56px;
|
||||
width: min(18rem, calc(100vw - var(--s3) * 2));
|
||||
z-index: 3;
|
||||
}
|
||||
body.panel-closed #panel {
|
||||
transform: translateX(calc(-100% - var(--s3)));
|
||||
opacity: 0;
|
||||
pointer-events: none;
|
||||
}
|
||||
.corner { width: 11.5rem; top: var(--s3); right: var(--s3); }
|
||||
.minimap-frame { height: 10rem; }
|
||||
.rail { right: var(--s3); bottom: var(--s3); }
|
||||
.source { left: var(--s3); bottom: var(--s3); }
|
||||
}
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.rail { max-width: calc(100vw - var(--s3) * 2); }
|
||||
/* The provenance line and the key hints would collide in the last 40px
|
||||
of a phone, and the honesty line is the one that has to survive. */
|
||||
.hint span { display: none; }
|
||||
.source { max-width: 60vw; }
|
||||
}
|
||||
|
||||
/* The minimap's own visibility is a user decision (`M`), seeded from the
|
||||
viewport width by `main.ts` rather than by a media query, so that
|
||||
toggling it on at 375px actually shows it. */
|
||||
body.minimap-off .corner { display: none; }
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
*, *::before, *::after {
|
||||
transition-duration: 1ms !important;
|
||||
animation-duration: 1ms !important;
|
||||
animation-iteration-count: 1 !important;
|
||||
}
|
||||
.boot-bar i { width: 100%; transform: none; }
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<canvas id="scene"></canvas>
|
||||
<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">
|
||||
<div class="card">
|
||||
<section class="card">
|
||||
<h1 id="title">San Francisco</h1>
|
||||
<p id="subtitle">Tera · Lumbridge Simulate</p>
|
||||
<p id="clock" class="clock"></p>
|
||||
<div class="scrub">
|
||||
<input id="hour" type="range" min="0" max="23.9" step="0.1" value="12" />
|
||||
<p id="clock" class="clock" aria-live="polite"></p>
|
||||
<div id="scrub" class="scrub" hidden>
|
||||
<input id="hour" type="range" min="0" max="23.9" step="0.1" value="12"
|
||||
aria-label="Hour of day" />
|
||||
<button id="now" title="follow the wall clock">now</button>
|
||||
</div>
|
||||
</div>
|
||||
<nav id="cities" class="cities"></nav>
|
||||
</section>
|
||||
<nav id="cities" class="cities" aria-label="City"></nav>
|
||||
<button id="enter" class="enter">Enter the office →</button>
|
||||
<nav id="chapters"></nav>
|
||||
<p class="card badge" id="office-badge" hidden></p>
|
||||
<nav id="chapters" aria-label="Chapters"></nav>
|
||||
<p class="card" id="blurb"></p>
|
||||
</div>
|
||||
<div class="card" id="detail" hidden></div>
|
||||
|
||||
<aside id="corner" class="corner" aria-label="Plan view">
|
||||
<div class="card" id="minimap">
|
||||
<div class="minimap-frame"></div>
|
||||
<p id="minimap-readout" class="minimap-readout"></p>
|
||||
</div>
|
||||
<p class="card tier" id="tier" hidden></p>
|
||||
</aside>
|
||||
|
||||
<div class="rail" id="rail">
|
||||
<div class="card" id="detail" aria-live="polite" hidden></div>
|
||||
<div class="card hint" id="hint">
|
||||
<span><kbd>1</kbd>–<kbd>9</kbd> chapters</span>
|
||||
<span><kbd>[</kbd> <kbd>]</kbd> city</span>
|
||||
<span><kbd>O</kbd> office</span>
|
||||
<span><kbd>M</kbd> plan</span>
|
||||
<button id="help" class="help" aria-haspopup="dialog">? shortcuts</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p id="source" class="source"></p>
|
||||
<p id="hint">drag to orbit · scroll to zoom</p>
|
||||
|
||||
<div class="overlay" id="shortcuts" role="dialog" aria-modal="true"
|
||||
aria-labelledby="shortcuts-title" hidden>
|
||||
<div class="card sheet">
|
||||
<h2 id="shortcuts-title">Keyboard</h2>
|
||||
<dl class="keys">
|
||||
<dt><kbd>1</kbd>–<kbd>9</kbd></dt><dd>Fly to a chapter, or an office viewpoint</dd>
|
||||
<dt><kbd>[</kbd> <kbd>]</kbd></dt><dd>Previous / next city</dd>
|
||||
<dt><kbd>O</kbd></dt><dd>Enter or leave the office</dd>
|
||||
<dt><kbd>M</kbd></dt><dd>Show or hide the plan view</dd>
|
||||
<dt><kbd>?</kbd></dt><dd>This card</dd>
|
||||
<dt><kbd>Esc</kbd></dt><dd>Close an overlay, or leave the office</dd>
|
||||
<dt><kbd>Tab</kbd></dt><dd>Every control, in reading order</dd>
|
||||
</dl>
|
||||
<p class="boot-note">On the plan: click or drag to move the view, scroll to zoom,
|
||||
arrow keys to aim and <kbd>Enter</kbd> to go.</p>
|
||||
<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>
|
||||
|
||||
Reference in New Issue
Block a user