/*
 * Polis — styles.
 *
 * Colour tokens live in palette.css, GENERATED from site/palette.mjs so the
 * audited palette (tools/contrast.mjs) and the shipped palette are the same
 * bytes. This file owns type, layout, the isometric city's materials, and
 * motion. DESIGN.md is the art direction; where a rule here looks odd, the
 * reason is usually there.
 *
 * Type: no serif anywhere. Three roles —
 *   --font-display  the sans display face, headings and precinct titles
 *   --font-body     system sans, prose
 *   --font-mono     system mono, ids, numbers, plates
 */

:root {
  --font-display: "Segoe UI Variable Display", ui-sans-serif, -apple-system, "SF Pro Display", system-ui, "Segoe UI", Roboto, sans-serif;
  --font-body: "Segoe UI Variable Text", system-ui, -apple-system, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
  --font-mono: ui-monospace, "Cascadia Mono", Consolas, "SF Mono", Menlo, "Liberation Mono", monospace;
  --radius: 6px;
  color-scheme: light dark;

  /* Spacing. Values chosen to match rhythm already in the file, so adopting
     them in the rules this pass does not touch is a rename, not a re-layout. */
  --space-1: 0.25rem;
  --space-2: 0.5rem;
  --space-3: 0.75rem;
  --space-4: 1rem;
  --space-5: 1.25rem;
  --space-6: 1.5rem;
  --space-8: 2rem;
  --space-10: 2.5rem;
  --space-11: 2.75rem;
  --space-12: 3rem;
  --space-16: 4rem;

  /* Motion. Ten hand-authored durations were scattered through this file;
     they agreed with each other, which is precisely how a codebase acquires
     an eleventh that does not. Loops keep their own periods below because a
     bob, a step and a ripple are different physical behaviours, not
     inconsistent ones. */
  --dur-fast: 120ms;
  --dur-base: 140ms;
  --dur-cross: 300ms;
  --ease-standard: cubic-bezier(0.2, 0, 0, 1);
  --ease-ambient: ease-in-out;
  --ease-ripple: ease-out;
  --ease-mechanical: linear;
  --loop-bob: 3.4s;
  --loop-walk-step: 0.46s;
  --loop-tool-swing: 1.2s;
  --loop-window-pulse: 2.8s;
  --loop-banner-wave: 2.6s;
  --loop-road-pulse: 1.4s;
  --loop-construct-spin: 9s;
}

* { box-sizing: border-box; }

html {
  background: var(--bg);
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: 1rem;
  line-height: 1.55;
}

h1, h2, h3, h4 {
  font-family: var(--font-display);
  font-weight: 620;
  letter-spacing: -0.008em;
  line-height: 1.2;
  color: var(--ink);
}

h1 { font-size: 1.35rem; margin: 0 0 0.2em; }
h2 { font-size: 1.42rem; margin: 0 0 0.6em; border-bottom: 1px solid var(--border-subtle); padding-bottom: 0.35em; }
h3 { font-size: 1.125rem; margin: 1.4em 0 0.4em; }
h4 { font-size: 1rem; margin: 1em 0 0.3em; }

/* readable measure: prose in an unconstrained .page (1180px) column was
   wrapping to 150-165 characters at wide viewports — well past the 45-85ch
   band. 66ch sits in the middle of that band regardless of font-size. */
p { margin: 0 0 0.8em; max-width: 66ch; }

a { color: var(--accent); text-decoration-thickness: 1px; text-underline-offset: 0.15em; }
a:hover { text-decoration-thickness: 2px; }

code {
  font-family: var(--font-mono);
  background: var(--bg-elevated);
  border: 1px solid var(--border-subtle);
  border-radius: 3px;
  padding: 0.05em 0.35em;
  font-size: 0.9em;
}

kbd {
  font-family: var(--font-mono);
  font-size: 0.85em;
  border: 1px solid var(--border-subtle);
  border-bottom-width: 2px;
  border-radius: 3px;
  padding: 0 0.3em;
}

.visually-hidden {
  position: absolute !important;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

/* Hidden by clipping rather than by parking a real interactive box three rem
   above the viewport. The parked version was counted — correctly — as a
   control clipped by the viewport edge by every audit that asks the question,
   including this project's own instrument. Same behaviour, same appearance on
   focus, nothing hanging off the edge of the document. */
/* Third position, and the one that satisfies both audits at once. Off the left
   edge of the document failed "controls clipped at the edge"; clipping it to
   1x1 fixed that and failed SC 2.5.8 instead, because a 1px box is a 1px
   target. Neither audit was wrong — the technique was. Keeping it at FULL SIZE
   inside the horizontal viewport and parking it ABOVE the top edge satisfies
   both: nothing hangs off the sides, and the box is a real 24px-plus target the
   whole time. Fixed positioning keeps it out of the layout either way. */
.skip-link {
  position: fixed;
  left: 0.5rem;
  top: 0.5rem;
  padding: 0.5rem 0.9rem;
  white-space: nowrap;
  background: var(--bg-elevated);
  color: var(--ink);
  border: 2px solid var(--focus);
  border-radius: var(--radius);
  z-index: 100;
  transform: translateY(-250%);
}
.skip-link:focus {
  transform: none;
}

/* Focus visibility: one consistent, high-contrast treatment everywhere. */
:focus {
  outline: none;
}
:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 2px;
  border-radius: 2px;
}
svg :focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 3px;
}

/* ---------- layout shell ---------- */

.page {
  max-width: 1180px;
  margin: 0 auto;
  padding: 0.4rem 1.25rem 3rem;
}

/* The header is a nameplate, not a front page. It used to stack a title, a
   three-line lead, a heading, six buttons and two instruction paragraphs
   above the map, so at 1440x900 the city began 640px down and the first
   thing a visitor saw was chrome. Everything that can live over the map now
   lives over the map. */
header.site-header {
  /* A nameplate, and nothing more. Every pixel here is a pixel the map does
     not get, and the map is the product: at 1440x900 the header plus the
     control strip used to spend 106px of a 900px viewport before the city
     started. Halved, deliberately, and the tagline shortened so it stops
     being truncated with an ellipsis on a phone. */
  padding: 0.2rem 0 0.15rem;
  margin-bottom: 0.25rem;
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 0.35rem 1.5rem;
}

.site-header .titles {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: 0.2rem 0.85rem;
  max-width: 88ch;
}
.site-header h1 { margin: 0; }

.tagline {
  color: var(--ink-muted);
  font-size: 0.95rem;
  margin: 0;
}

/* The maker line sits on the nameplate beside the tagline. Quieter than the
   tagline on purpose: it is a credit, not a second sentence about the product,
   and on a desktop it rides on the same baseline row so the plate does not grow
   by a single pixel. */
.site-header .aj-attribution {
  color: var(--ink-muted);
  font-size: 0.82rem;
}

/* A second bypass, in the header, for anyone who tabs in before the map has
   mounted its own. Invisible until focused, like the page skip link.

   Hidden by clipping, not by `left: -9999px`. The old technique parks a real
   interactive box five screens to the left of the viewport, which is a
   control hanging off the edge of the document — harmless to a person, and
   counted (correctly) by any audit that asks "is anything clipped by the
   viewport edge". Clipping keeps the box where it belongs and 1px wide. */
.header-skip {
  position: absolute;
  width: 1px; height: 1px;
  padding: 0; margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  font-weight: 600;
}
.header-skip:focus {
  position: static;
  width: auto; height: auto;
  margin: 0;
  overflow: visible;
  clip: auto;
}

.header-controls {
  display: flex;
  align-items: center;
  gap: 0.6rem;
}

.theme-toggle {
  font-family: var(--font-mono);
  font-size: 0.82rem;
  padding: 0.25rem 0.55rem;
  white-space: nowrap;
}

main section, main aside {
  margin-bottom: 2.75rem;
}

/* ---------- buttons / inputs ---------- */

button, select, input[type="search"], input[type="text"], input[type="email"] {
  font-family: var(--font-body);
  font-size: 1rem;
  color: var(--ink);
  background: var(--bg-elevated);
  border: 1.5px solid var(--border-strong);
  border-radius: var(--radius);
  padding: 0.45rem 0.7rem;
}

button {
  cursor: pointer;
  font-weight: 600;
}
button:hover {
  background: var(--bg);
}

.link-button {
  background: none;
  border: none;
  padding: 0;
  color: var(--accent);
  font-weight: 600;
  text-decoration: underline;
  text-underline-offset: 0.15em;
  cursor: pointer;
}

/* ---------- status board ---------- */

.stats-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: 0.75rem;
  margin: 0 0 1.5rem;
}
.stats-grid > div {
  background: var(--bg-elevated);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius);
  padding: 0.7rem 0.85rem;
}
.stats-grid dt {
  font-size: 0.79rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ink-muted);
  margin: 0 0 0.15em;
}
.stats-grid dd {
  margin: 0;
  font-family: var(--font-mono);
  font-size: 1.80rem;
  font-weight: 600;
}

/* No side-tab accent bar. A thick coloured border down one edge of a card is
   the most recognisable tell of generated UI, and this panel is the one that
   reports honest findings, so it is the last place that should look generated. */
.finding {
  border: 1.5px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--bg-elevated);
  padding: 0.9rem 1.1rem;
  margin-bottom: 1rem;
}
.finding h3 {
  /* no own size: this IS an h3, it inherits the global h3 step (1.125rem)
     instead of carrying a bespoke near-duplicate of it */
  margin-top: 0;
}
.finding-warn {
  border-color: var(--warn);
  background: var(--warn-bg);
}
.finding-warn h3 {
  color: var(--warn);
}
.finding ul {
  margin: 0.4em 0 0;
  padding-left: 1.1em;
}
.finding li { margin-bottom: 0.25em; }

/* The headline strip: one generated sentence, above the map, on every
   breakpoint. It reuses the .finding component rather than inventing a
   second card, and its number is written by the same pass that writes the
   findings panel — never typed twice. */
.finding-strip {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: var(--space-2) var(--space-3);
  max-width: none;
  padding: var(--space-3) var(--space-4);
  margin-bottom: var(--space-6);
}
.finding-strip-count {
  font-family: var(--font-mono);
  font-weight: 600;
  font-size: 1.125rem;
  color: var(--warn);
}
.finding-strip a { margin-left: auto; }

/* Findings lead, the status board supports it. On a wide screen they sit
   side by side, findings wider and first; below 1024px they stack in the
   same order. */
@media (min-width: 1024px) {
  .findings-status {
    display: grid;
    grid-template-columns: 1.6fr 1fr;
    gap: var(--space-8);
    align-items: start;
  }
}

.hero-section { margin-bottom: var(--space-10); }

.jump-to-citizen {
  font-family: var(--font-mono);
  font-size: 0.89rem;
  background: none;
  border: none;
  padding: 0;
  color: var(--warn);
  text-decoration: underline;
  text-underline-offset: 0.15em;
  cursor: pointer;
  font-weight: 600;
}

.tag {
  display: inline-block;
  font-family: var(--font-mono);
  font-size: 0.70rem;
  letter-spacing: 0.03em;
  text-transform: uppercase;
  padding: 0.12em 0.45em;
  border-radius: 3px;
  border: 1px solid currentColor;
  vertical-align: 0.1em;
}
.tag-director {
  color: var(--hue-00-text);
}
.tag-warn {
  color: var(--warn);
  background: var(--warn-bg);
}

/* ---------- filters ---------- */

#filters-section .field-row {
  display: flex;
  flex-wrap: wrap;
  gap: 1rem 1.25rem;
  align-items: end;
  margin-bottom: 0.75rem;
}
.field {
  display: flex;
  flex-direction: column;
  gap: 0.3rem;
}
.field label {
  font-size: 0.79rem;
  color: var(--ink-muted);
}
.field-checkbox {
  flex-direction: row;
  align-items: center;
  gap: 0.45rem;
}
.field-checkbox label { font-size: 1rem; color: var(--ink); }

#filter-summary {
  font-family: var(--font-mono);
  font-size: 0.89rem;
  color: var(--ink-muted);
  margin: 0.4rem 0 0;
}

/* ---------- the map ---------- */

.hero-map {
  overflow: hidden;
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius);
  background: var(--sky-bottom);
  /* The map is the subject of this page and was being drawn inside a text
     column, at roughly half a CSS pixel per user unit. It now breaks out to
     the full viewport, capped so a 3440px ultrawide gets a diorama rather
     than a stretched one. Centred on the parent rather than offset from it:
     margin-inline with vw units resolves against the parent box, so in a
     narrow column it shoved the toolbar off the left edge instead. */
  width: min(100vw, 1800px);
  margin-left: 50%;
  transform: translateX(-50%);
  /* Explicit, viewport-relative height. This was `height:auto` on the SVG
     with `min-width:640px`, which on a 390px phone drew the map 640px wide
     inside a 358px frame and threw ~44% of the city off-screen before the
     visitor touched anything. preserveAspectRatio letterboxes whichever axis
     is not binding, and the sky rect is three frames wide, so the spare space
     reads as more sky rather than an empty bar. */
  /* Tall enough that the whole city fits AND is worth looking at. The frame
     fits the citizens to whatever box it is given (city-view computeFocusFrame),
     so this number IS how big the city is drawn. Measured at 1440x900: the map
     was 716px of a 900px viewport and the citizens filled 51% of the screen. */
  height: min(58vh, 460px);
  min-height: 320px;
  /* The controls live INSIDE the map's own frame rather than in the page
     above it, so the city is the first thing a visitor sees. They are a
     strip in normal flow, not an overlay: an overlay floating on the city
     could cover a citizen that keyboard focus had just landed on, which is
     WCAG 2.2 SC 2.4.11 (Focus Not Obscured) failing quietly. */
  position: relative;
  isolation: isolate;
  display: flex;
  flex-direction: column;
}
#city-map { flex: 1 1 auto; min-height: 0; }
@media (min-width: 600px) {
  .hero-map { height: min(74vh, 700px); min-height: 460px; }
}
@media (min-width: 1024px) {
  /* The hero is the viewport minus its own chrome: the compact header above it
     (about 46px with the page's top padding) and nothing else. `dvh` rather
     than `vh` so a mobile browser's collapsing URL bar does not leave the map
     taller than the screen. */
  .hero-map {
    height: calc(100dvh - 40px);
    max-height: 1000px;
    min-height: 560px;
  }
}

#city-map {
  display: block;
  width: 100%;
  height: 100%;
}

#city-map { cursor: grab; touch-action: none; }
#city-map.grabbing { cursor: grabbing; }

/* ---------- the isometric city ----------

   Depth in a flat vector drawing comes from exactly one thing: three faces of
   the same solid painted three different values, with a consistent light
   direction. The key light is upper right, travelling down-left along +row,
   so every roof is the base hue, every right wall a step down, every left
   wall a step down again, and every cast shadow lies on the tile grid.

   Colour still carries meaning, not decoration: hue = precinct, height =
   degree (and storeys, and windows — the same fact three ways), roof form =
   can this member change the repository, footprint = declared model, banner
   = Director, dashed lot with nothing on it = unreachable, lit windows and a
   working pose = real telemetry. Nothing on this map is drawn that is not in
   ecosystem.json or workforce.json. */

/* ---- environment: sky, board, streets ---- */

.sky { pointer-events: none; }

.board-outer {
  fill: var(--board-outer);
  stroke: var(--board-edge);
  stroke-width: 1.5;
  stroke-linejoin: round;
}
.board-inner { fill: var(--street); }
.plinth-right { fill: var(--plinth-right); }
.plinth-left { fill: var(--plinth-left); }

/* Streets for free: plot ground is lighter than the street in light theme
   and darker in dark, so the one-tile gaps between plots read as pavement
   with zero new nodes. */
.plot-ground {
  fill: color-mix(in oklab, var(--hue) var(--plot-mix), var(--board));
  stroke: none;
}
.plot-ground.kind-holding {
  fill: color-mix(in oklab, var(--hue) 5%, var(--board));
}
.tile-grid {
  fill: none;
  /* The paving takes the precinct's own colour rather than a shared grey, so
     the floor of a district is part of its identity and its kerb is drawn in
     the same stroke as its paving. */
  stroke: var(--hue-text, var(--shadow-ink));
  stroke-width: 0.6;
  stroke-opacity: 0.30;
  stroke-linejoin: round;
  pointer-events: none;
}
.numeral {
  font-family: var(--font-mono);
  font-weight: 700;
  fill: var(--numeral);
  dominant-baseline: central;
  pointer-events: none;
  user-select: none;
}

.wall-face { fill: var(--wall-face); }
.wall-north { fill: color-mix(in oklab, var(--wall-face) 82%, var(--shadow-ink)); }
@supports (color: oklch(from red l c h)) {
  .wall-north { fill: oklch(from var(--wall-face) calc(l - 0.09) c h); }
}
.wall-cap {
  stroke: var(--wall-cap);
  stroke-width: 2.5;
  stroke-linecap: round;
}
.wall-kerb { fill: var(--wall-face); fill-opacity: 0.9; }

.haze { pointer-events: none; }

/* ---- roads and routes ---- */

.road {
  fill: none;
  stroke: var(--road);
  stroke-width: 1.6;
  stroke-linejoin: round;
  stroke-linecap: round;
  opacity: 0;
  pointer-events: none;
}
/* All 219 at once is a hairball, so they are off until you ask — by hovering
   somebody, or by ticking "show all roads". */
#city-map.roads-on .road { opacity: 0.18; }
.road.highlight {
  stroke: var(--road-lit);
  opacity: 0.95;
}
#city-map.roads-on .road.dimmed { opacity: 0.06; }

.walk-route {
  fill: none;
  stroke: var(--road-lit);
  stroke-width: 1.6;
  stroke-linejoin: round;
  stroke-linecap: round;
  stroke-dasharray: 5 6;
  opacity: 0;
  pointer-events: none;
}
.walk-route.on { opacity: 0.42; }

/* ---- structures ---- */

.structure { cursor: pointer; }
.structure.kind-skill { cursor: default; }

/* One fixed key light from the upper right, expressed as perceptual lightness
   steps. Hue is held; only L and a little C move. No hue rotation anywhere. */
.structure, .figure {
  --face-top:        var(--hue);
  --face-gable-lit:  color-mix(in oklab, var(--hue) 96%, white);
  --face-gable-shade:color-mix(in oklab, var(--hue) 90%, var(--shadow-ink));
  --face-right:      color-mix(in oklab, var(--hue) 86%, var(--shadow-ink));
  --face-left:       color-mix(in oklab, var(--hue) 71%, var(--shadow-ink));
  --face-foot:       color-mix(in oklab, var(--hue) 58%, var(--shadow-ink));
  --tunic-lit:       color-mix(in oklab, var(--hue) 70%, white);
}
@supports (color: oklch(from red l c h)) {
  .structure, .figure {
    --face-top:        oklch(from var(--hue) l                c              h);
    --face-gable-lit:  oklch(from var(--hue) calc(l + 0.020)  c              h);
    --face-gable-shade:oklch(from var(--hue) calc(l - 0.055)  calc(c * 0.98) h);
    --face-right:      oklch(from var(--hue) calc(l - 0.085)  calc(c * 0.96) h);
    --face-left:       oklch(from var(--hue) calc(l - 0.170)  calc(c * 0.88) h);
    --face-foot:       oklch(from var(--hue) calc(l - 0.265)  calc(c * 0.78) h);
    --tunic-lit:       oklch(from var(--hue) calc(l + 0.180)  calc(c * 0.90) h);
  }
}

/* Self-stroked in its own fill: closes the antialiasing seam between adjacent
   polygons without drawing the pale line that made every solid look like a
   sticker. Never stroke a face in the background colour. */
.face { stroke-width: 0.5; stroke-linejoin: round; }
.face-top         { fill: var(--face-top);         stroke: var(--face-top); }
.face-gable-lit   { fill: var(--face-gable-lit);   stroke: var(--face-gable-lit); }
.face-gable-shade { fill: var(--face-gable-shade); stroke: var(--face-gable-shade); }
.face-gable-end   { fill: var(--face-right);       stroke: var(--face-right); }
.face-right       { fill: var(--face-right);       stroke: var(--face-right); }
.face-left        { fill: var(--face-left);        stroke: var(--face-left); }
.face-foot        { fill: var(--face-foot);        stroke: var(--face-foot); }

.storeys {
  fill: none;
  stroke: var(--face-foot);
  stroke-width: 0.6;
  stroke-opacity: 0.55;
}

/* Windows: see the MATERIALS section at the foot of this file. The fill is a
   gradient the renderer chooses per activity, so nothing here may set it. */

/* The silhouette. Opacity only. In the dark theme it is the compliant
   boundary of every wall and stays on; in light it appears on hover. */
/* The silhouette. At the default frame a building is about 45 CSS px tall,
   which is exactly the size at which a soft-edged box stops reading as a
   solid and starts reading as noise. The resting edge is a real line now, in
   both themes; hover still takes it to full. */
.edge {
  fill: none;
  stroke: var(--rim);
  stroke-width: var(--rim-width);
  stroke-linejoin: round;
  opacity: var(--rim-alpha);
  pointer-events: none;
}
.structure.active .edge,
.structure:focus-visible .edge { opacity: 1; }
.structure.neighbor .edge,
.structure.crew .edge { opacity: max(var(--rim-alpha), 0.75); }

/* Contact. The cheapest thing on the map that stops a box reading as a decal.
   fill-opacity per element, never opacity on the layer — a group opacity
   forces one offscreen composite over ~290 shapes and re-rasterises it on
   every wheel-zoom tick, which is the jank class this repo already hit once. */
.cast-shadow {
  fill: var(--shadow-ink);
  fill-opacity: var(--shadow-alpha);
  stroke: none;
  pointer-events: none;
}
.ao-patch { stroke: none; pointer-events: none; }

/* A citizen nobody names has a foundation and nothing built on it.
   `pointer-events: all` because `fill: none` otherwise leaves a 1.6px dashed
   outline as the entire hit target: the eight empty lots are the finding this
   whole page is about, and until this line they were the one thing on the map
   you could not click. Costs nothing and creates no element — it makes the
   interior of a shape that paints no interior targetable. */
.ruin-lot {
  fill: none;
  pointer-events: all;
  stroke: var(--warn);
  stroke-width: 1.6;
  stroke-dasharray: 4 3;
  stroke-linejoin: round;
}

.banner-pole { stroke: var(--ink); stroke-width: 1.2; opacity: 0.8; }
.banner-flag {
  fill: var(--hue);
  stroke: var(--face-foot);
  stroke-width: 0.5;
  stroke-linejoin: round;
  transform-box: fill-box;
  transform-origin: 0% 50%;
}

/* ---- citizens ---- */

.figure { pointer-events: none; }
.figure {
  --tunic: var(--face-right);
  --tunic-shade: var(--face-left);
}
.fig-shadow { fill: var(--shadow-ink); fill-opacity: 0.26; }
.fig-torso {
  fill: var(--tunic);
  stroke: var(--tunic);
  stroke-width: 0.4;
  stroke-linejoin: round;
}
.fig-mantle { fill: var(--tunic-shade); }
.fig-head { fill: var(--figure-head); stroke: var(--figure-head); stroke-width: 0.3; }
.fig-insignia { fill: var(--tunic-lit); }
.fig-arm { transform: translate(4.6px, -11.5px); }
.fig-tool { fill: var(--ink); }
.fig-belt, .fig-chip { fill: var(--ink); }
.fig-lamp { fill: var(--lamp); opacity: 0.7; }
.figure.act-active .fig-lamp { opacity: 1; }
.fig-ring { fill: var(--tunic-lit); }
.fig-pulse { fill: var(--lamp); opacity: 0; transform-box: view-box; }

/* Two LODs, rendered once. Detail earns its nodes above zoom 1.5x only. */
.fig-detail { display: none; }
#city-map.lod-1 .fig-detail { display: inline; }

/* Working pose: the torso pitches toward the instrument. A pose, not a loop. */
.figure.act-active .fig-torso,
.figure.act-active .fig-mantle,
.figure.act-active .fig-head,
.figure.act-active .fig-insignia,
.figure.act-active .fig-detail {
  transform: rotate(6deg);
  transform-origin: 0px -7px;
}

/* The person left their door; they were not copied. */
.figure.away { opacity: 0; }
.figure.walker { opacity: 0; }
.figure.walker.on { opacity: 1; }

/* Crew: the collaborators of whoever you are looking at, from telemetry. */
.figure.crew .fig-shadow { fill: var(--lamp); fill-opacity: 0.7; }

/* ---- plates ---- */

.precinct-plate { pointer-events: none; }
.plate-bg {
  fill: var(--plate);
  fill-opacity: var(--plate-alpha);
  stroke: var(--border-strong);
  stroke-opacity: 0.55;
  stroke-width: 1;
}
.precinct-plate .plate-bg { stroke: var(--hue-text); stroke-opacity: 0.55; }
/* The hue rule: the strip of precinct colour that ties a plate the placer had
   to move back to the block it names, without a second leader line. */
.plate-rule { fill: var(--hue); }
.plate-line1 { font-size: 22px; }
.plate-num {
  font-family: var(--font-mono);
  font-weight: 700;
  letter-spacing: 0.02em;
  fill: var(--hue-text);
}
.plate-title {
  font-family: var(--font-display);
  font-weight: 620;
  letter-spacing: -0.005em;
  fill: var(--ink);
}
/* The density fallback, drawn: a plate that could not fit whole next to its
   plot drops its second line rather than lying across a roof. Nothing is
   created or destroyed, one class is toggled, and the count is still one
   hover away and always in the roster. */
.precinct-plate.compact .plate-sub { display: none; }
.precinct-plate.compact .plate-line1 { transform: translateY(-3px); }
.plate-sub {
  font-family: var(--font-mono);
  font-size: 16px;
  font-weight: 400;
  letter-spacing: 0.02em;
  fill: var(--ink-muted);
}
/* Leaders are mounted for every plate and revealed only when that plate had
   to step aside — one class toggle, no node created or destroyed. */
/* A leader has one job: say which block a moved label belongs to. At the
   default frame the old 1.2-unit dashed hairline resolved to about one pale
   pixel, so a label the placer had moved read as a label about nothing. */
.plate-leader {
  stroke: var(--hue-text);
  stroke-width: 2.2;
  opacity: 0;
  stroke-dasharray: 7 5;
  stroke-linecap: round;
  pointer-events: none;
}
.plate-layer > .plate-leader.on { opacity: 0.75; }

.name-plate { pointer-events: none; }
.name-plate-in { opacity: 0; transform: translateY(3px); }
.name-plate.on .name-plate-in { opacity: 1; transform: none; }
/* A name plate that had to step aside to stay readable says which roof it
   belongs to, with the same dashed leader the precinct plates already use.
   Name plates carry no hue class, so they take the muted ink instead. */
.name-plate .plate-leader { stroke: var(--ink-muted); stroke-width: 1.8; opacity: 0; }
.name-plate.on.displaced .plate-leader { opacity: 0.7; }
/* A label whose subject is off screen is debris, not information. */
.precinct-plate.off-frame,
.name-plate.off-frame,
.plate-layer > .plate-leader.off-frame { opacity: 0; }
.plate-name {
  font-family: var(--font-mono);
  font-size: 20px;
  font-weight: 500;
  fill: var(--ink);
}

/* ---- focus and hover ----

   Transform and opacity only. stroke-width was in here once and it was a bad
   idea twice over: changing it recomputes every element's stroke geometry, and
   hovering one citizen touches ~150 elements at once — repeated on every
   arrow-key move through the keyboard list, not only on mouse hover. */

.structure { transform: translateY(var(--lift, 0px)); }
.structure.dimmed { opacity: 0.22; }
.structure.active { --lift: -5px; }
.structure.neighbor,
.structure.crew { --lift: -2px; }
.structure:focus { outline: none; }
.structure.hidden-by-filter { display: none; }

/* ---- motion ----

   Only opacity and transform ever animate. Every loop is disabled under
   prefers-reduced-motion and by the Life toggle; every state is reachable
   without motion. */

@media (prefers-reduced-motion: no-preference) {
  .structure { transition: opacity var(--dur-base) var(--ease-standard), transform var(--dur-base) var(--ease-standard); }
  .road, .walk-route, .edge { transition: opacity var(--dur-base) var(--ease-standard); }
  .figure.away, .figure.walker { transition: opacity var(--dur-cross) var(--ease-standard); }
  .name-plate-in { transition: opacity var(--dur-fast) var(--ease-standard), transform var(--dur-fast) var(--ease-standard); }

  /* There is no load animation. A 900ms fade of the whole scene from
     opacity 0 used to live here, and it was an intro a visitor had to sit
     through before the page could say anything — for 900ms the answer to
     "is this real and populated" was a blank frame. The city paints at full
     opacity, in place, in one frame. The only frame-to-frame difference at
     load is the phase offset already baked into the bob and walker-launch
     schedules, which desynchronises a running loop rather than delaying it. */

  /* A city with nobody moving in it is a diagram of a city. The bob is 1.8u
     and staggered so the crowd never pulses in unison. */
  .figure .fig-body {
    animation: fig-bob var(--loop-bob) var(--ease-ambient) infinite;
    animation-delay: var(--bob-delay, 0ms);
  }
  @keyframes fig-bob {
    0%, 100% { transform: scaleX(var(--flip, 1)) translateY(0); }
    50%      { transform: scaleX(var(--flip, 1)) translateY(-1.8px); }
  }
  .figure.walker .fig-body { animation: walk-step var(--loop-walk-step) var(--ease-ambient) infinite; }
  .figure.walker.pausing .fig-body { animation: fig-bob var(--loop-bob) var(--ease-ambient) infinite; }
  @keyframes walk-step {
    0%, 100% { transform: scaleX(var(--flip, 1)) translateY(0); }
    50%      { transform: scaleX(var(--flip, 1)) translateY(-1.4px); }
  }
  .figure.walker.on .fig-pulse { animation: road-pulse var(--loop-road-pulse) var(--ease-ripple) infinite; }
  @keyframes road-pulse {
    0%   { transform: scale(0.6); opacity: 0.5; }
    100% { transform: scale(1.6); opacity: 0; }
  }

  /* Working: the instrument swings. Transform only, about the hand. */
  .figure.act-active .fig-arm {
    animation: tool-swing var(--loop-tool-swing) var(--ease-ambient) infinite alternate;
  }
  @keyframes tool-swing {
    from { transform: translate(4.6px, -11.5px) rotate(-14deg); }
    to   { transform: translate(4.6px, -11.5px) rotate(12deg); }
  }

  /* Lit windows breathe on active buildings. */
  .structure.act-active .windows {
    animation: window-pulse var(--loop-window-pulse) var(--ease-ambient) infinite;
    animation-delay: var(--pulse-delay, 0ms);
  }
  @keyframes window-pulse {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.68; }
  }

  /* Banners wave: a small skew about the pole. */
  .banner-flag {
    animation: banner-wave var(--loop-banner-wave) var(--ease-ambient) infinite;
    animation-delay: var(--wave-delay, 0ms);
  }
  @keyframes banner-wave {
    0%, 100% { transform: skewY(-3deg) scaleX(1); }
    50%      { transform: skewY(4deg) scaleX(0.94); }
  }

  /* Explore is a construct: it hovers and turns, slowly. */
  .figure.is-construct .fig-body {
    animation: construct-spin var(--loop-construct-spin) var(--ease-mechanical) infinite;
    transform-origin: 0px -13px;
  }
  @keyframes construct-spin {
    from { transform: translateY(-3px) rotate(0deg); }
    to   { transform: translateY(-3px) rotate(360deg); }
  }
}

@media (prefers-reduced-motion: reduce) {
  #city-map *, #city-map { animation: none !important; transition: none !important; }
}
#city-map.life-off * { animation-play-state: paused !important; }
#city-map.life-off .figure.is-construct .fig-body { transform: translateY(-3px); }

.map-toolbar {
  position: relative;
  z-index: 2;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem 0.6rem;
  font-size: 0.82rem;
  /* A control bar on glass: it belongs to the map, so it is drawn on the map,
     tinted by the sky underneath rather than sitting on the page background. */
  padding: 0.22rem 0.5rem;
  line-height: 1.25;
  border-bottom: 1px solid var(--border-subtle);
  background: var(--bg-elevated);
}
.map-toolbar .tool-group {
  display: inline-flex;
  gap: 0.3rem;
}
.map-toolbar button {
  font: inherit;
  padding: 0.12rem 0.5rem;
  min-width: 2rem;
  /* 13px type on a 1.25 line box with 2px of padding drew a 21.4px control, a
     couple of pixels under the WCAG 2.2 AA floor (SC 2.5.8). These are HUD
     buttons in a control bar, not links in a sentence, so the exemption does
     not apply and the box has to carry the 24px itself. inline-flex keeps the
     glyphs centred as the box grows. */
  min-height: 24px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--bg-elevated);
  color: var(--ink);
  cursor: pointer;
}
.map-toolbar button:hover { border-color: var(--accent); }
.map-toolbar label { display: flex; align-items: center; gap: 0.35em; }
/* The bypass link rides in the toolbar rather than on its own row: same
   target, same reading order, one less block of chrome above the hero. */
.map-toolbar .map-skip { font-weight: 600; }

/* Two paragraphs of instructions used to sit between the visitor and the
   city. They are one disclosure now: closed by default, still text, still
   keyboard-reachable, and no longer the first thing anybody reads. */
/* The popover is positioned against the TOOLBAR, not against its own summary.
   Anchored to the summary it ran off the right edge of a 390px phone — the
   summary sits near the end of the bar, and `width: min(46ch, 78vw)` from
   there is wider than the screen has left. Two checkboxes and a link were
   measurably outside the viewport. Both edges are pinned to the toolbar now,
   so the panel can never be wider than the map it belongs to, at any width. */
.map-help { position: static; }
.map-help > summary {
  cursor: pointer;
  padding: 0.12rem 0.5rem;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--bg-elevated);
  list-style: none;
}
.map-help > summary::-webkit-details-marker { display: none; }
.map-help > summary::after { content: " ?"; font-family: var(--font-mono); }
.map-help[open] > summary { border-color: var(--accent); }
.map-help-body {
  position: absolute;
  top: calc(100% + 0.3rem);
  left: 0.4rem;
  right: 0.4rem;
  width: auto;
  max-width: 46ch;
  z-index: 3;
  padding: 0.7rem 0.85rem;
  border: 1px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--bg-elevated);
  box-shadow: 0 6px 16px color-mix(in oklab, var(--shadow-ink) 22%, transparent);
}
.map-help-body .map-hint + .map-hint { margin-top: 0.5rem; }

/* The dark-half readout, over the bottom of the map where it describes what
   just changed on it. */
.map-caption {
  position: absolute;
  left: 0.6rem;
  bottom: 0.6rem;
  right: 0.6rem;
  z-index: 2;
  margin: 0;
  font-size: 0.84rem;
  color: var(--ink);
  pointer-events: none;
}
.map-caption:not(:empty) {
  padding: 0.3rem 0.55rem;
  border-radius: var(--radius);
  background: color-mix(in oklab, var(--bg-elevated) 94%, transparent);
  border: 1px solid var(--border-subtle);
}

.map-underbar { margin-top: 0.7rem; }

.map-hint {
  font-size: 0.89rem;
  color: var(--ink-muted);
}

/* ---------- district legend ---------- */

.legend {
  display: flex;
  flex-wrap: wrap;
  gap: 0.5rem 1.1rem;
  list-style: none;
  padding: 0;
  margin: 0.75rem 0 0;
  font-size: 0.89rem;
}
.legend li { display: flex; align-items: center; gap: 0.4em; }
.legend .swatch {
  width: 0.85em; height: 0.85em;
  border-radius: 2px;
  background: var(--hue);
  border: 1px solid var(--border-strong);
  flex: none;
}

/* ---------- roster (static + interactive) ---------- */

.static-district, .roster-district {
  border-top: 1px solid var(--border-subtle);
  padding-top: var(--space-2);
}
.static-district-flagged {
  background: var(--warn-bg);
  border-radius: var(--radius);
  padding: var(--space-2) var(--space-4);
}
.static-district-flagged summary h3 {
  color: var(--warn);
}

/* Districts are disclosure widgets, closed at rest. Uncollapsed, this roster
   was ~22,000px of a 23,500px page — the whole document was the list, and the
   map was a stripe inside it. Nothing is removed: <details> is native, needs
   no script, and keeps every citizen one click away with JavaScript off. */
.static-district > details > summary,
.roster-district > details > summary {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  cursor: pointer;
  padding: var(--space-2) 0;
  list-style: none;
}
.static-district > details > summary::-webkit-details-marker { display: none; }
.static-district > details > summary::before,
.roster-district > details > summary::before {
  content: "\25B8";
  color: var(--ink-muted);
  font-size: 0.8em;
  line-height: 1;
}
.static-district > details[open] > summary::before,
.roster-district > details[open] > summary::before { content: "\25BE"; }
.static-district > details > summary h3,
.roster-district > details > summary h3 { margin: 0; }
.static-district > details > summary:hover h3,
.roster-district > details > summary:hover h3 { color: var(--accent); }
.static-district > details > summary:focus-visible {
  outline: 3px solid var(--focus);
  outline-offset: 2px;
  border-radius: 2px;
}
.static-count, .roster-count {
  font-family: var(--font-mono);
  font-size: 0.79rem;
  font-weight: 400;
  color: var(--ink-muted);
}
.static-note {
  color: var(--ink-muted);
  font-size: 0.89rem;
}
.static-district-list {
  list-style: none;
  padding: 0;
  margin: 0;
}

.static-agent-list, .roster-agent-list {
  list-style: none;
  padding: 0;
  margin: 0 0 1rem;
  display: grid;
  gap: 0.6rem;
}
.static-agent, .roster-agent {
  background: var(--bg-elevated);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius);
  padding: 0.75rem 0.95rem;
}
.static-agent h4 { margin-top: 0; }

.roster-agent-button {
  display: block;
  width: 100%;
  text-align: left;
  background: var(--bg-elevated);
  border: 1px solid var(--border-subtle);
  border-radius: var(--radius);
  padding: 0.6rem 0.85rem;
  font-family: var(--font-mono);
  font-size: 1rem;
  color: var(--ink);
}
.roster-agent-button:hover {
  border-color: var(--border-strong);
}
.roster-agent-button .rab-desc {
  display: block;
  font-family: var(--font-body);
  font-weight: 400;
  color: var(--ink-muted);
  font-size: 0.89rem;
  margin-top: 0.25em;
  white-space: normal;
}
.roster-agent-button.selected {
  border-color: var(--focus);
  border-width: 2px;
}

.static-meta, .static-edges {
  font-size: 0.89rem;
  color: var(--ink-muted);
  margin: 0.25em 0;
}
.static-meta strong, .static-edges strong { color: var(--ink); font-weight: 600; }
.static-edges-none { font-style: italic; }

.static-skill {
  margin-bottom: 0.5em;
  max-width: 66ch;
}
#skills-list, .static-skill-list {
  padding-left: 1.2em;
}

/* ---------- citizen panel ---------- */

.citizen-panel {
  position: relative;
  background: var(--bg-elevated);
  border: 2px solid var(--border-strong);
  border-radius: var(--radius);
  padding: 1.1rem 1.25rem 1.4rem;
  max-width: 46rem;
}
.citizen-panel[hidden] { display: none; }

#panel-close {
  position: absolute;
  top: 0.6rem;
  right: 0.6rem;
  width: 2.1rem;
  height: 2.1rem;
  line-height: 1;
  font-size: 1.125rem;
  border-radius: 50%;
}

.panel-division {
  font-family: var(--font-mono);
  color: var(--ink-muted);
  margin-top: -0.3rem;
}
.panel-meta {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0.2rem 0.8rem;
  font-family: var(--font-mono);
  font-size: 0.89rem;
  margin: 0.6rem 0 1rem;
}
.panel-meta dt { color: var(--ink-muted); }
.panel-meta dd { margin: 0; }

.panel-list {
  list-style: none;
  padding: 0;
  margin: 0 0 0.9rem;
  display: flex;
  flex-wrap: wrap;
  gap: 0.4rem;
}
.panel-list li {
  font-family: var(--font-mono);
  font-size: 0.89rem;
  background: var(--bg);
  border: 1px solid var(--border-subtle);
  border-radius: 3px;
  padding: 0.15rem 0.5rem;
}
.panel-list.panel-links li a {
  color: var(--ink);
}
.panel-empty {
  color: var(--ink-muted);
  font-style: italic;
  font-size: 0.89rem;
}

/* ---------- footer ---------- */

footer.site-footer {
  border-top: 1px solid var(--border-strong);
  margin-top: 3rem;
  padding-top: 1.25rem;
  font-size: 0.89rem;
  color: var(--ink-muted);
}
footer.site-footer p { margin: 0 0 0.5em; }

/* ---------- responsive ----------

   Below 600px the chrome above the map is the thing competing with the map.
   Nothing here removes a word; it takes the air out of the header, lets the
   headline strip run as one sentence instead of three stacked rows, and
   steps the map's own hint text down — the map itself keeps every pixel it
   was given. */

@media (max-width: 599px) {
  h1 { font-size: 1.60rem; }
  .stats-grid dd { font-size: 1.42rem; }
  .page { padding: 0.5rem 0.85rem 2.5rem; }
  header.site-header {
    padding: var(--space-1) 0 var(--space-2);
    margin-bottom: var(--space-2);
    gap: var(--space-1) var(--space-3);
  }
  /* On a phone the tagline was three lines of chrome between the visitor and
     the city. It is one quiet line under the title, and the theme control
     rides beside the title rather than on a row of its own. */
  header.site-header { flex-wrap: nowrap; align-items: center; }
  .site-header .titles { flex: 1 1 auto; min-width: 0; display: block; }
  /* It WRAPS, it does not trail off. The one-line-with-ellipsis rule was
     written when the tagline was twelve words; on a 390px phone it rendered
     "James Lorenz Santos's agent ecosystem, dr…" — a sentence that stops
     mid-word is worse than a sentence on two lines, and it is the first thing
     on the page. The tagline is seven words now and fits in two short lines. */
  .tagline {
    font-size: 0.8rem;
    line-height: 1.35;
  }
  .finding-strip {
    display: block;
    padding: var(--space-2) var(--space-3);
    margin-bottom: var(--space-4);
    font-size: 0.94rem;
  }
  .finding-strip a { margin-left: 0; }
  .hero-section h2 { margin-bottom: var(--space-2); }
  /* It WRAPS. It used to be one row that scrolled sideways, on the reasoning
     that a wrapped toolbar ate 110px off the map. What it actually did was
     slice the last control in half at the viewport edge — the screenshot at
     390px showed a button reading "R" — and hide four more off-screen with no
     scroll affordance to say they were there. Measured: 39 controls clipped by
     the viewport edge at 390x844. A control you cannot see is not a control.
     The bar is short enough to wrap into two rows now because everything
     secondary moved behind "More". */
  .map-toolbar {
    font-size: 0.78rem;
    gap: 0.3rem 0.45rem;
    padding: 0.3rem 0.4rem;
    flex-wrap: wrap;
    overflow-x: clip;
  }
  .map-toolbar > * { flex: 0 1 auto; white-space: nowrap; min-width: 0; }
  .map-toolbar .map-hint { font-size: 0.79rem; }
  .map-help-body { width: min(40ch, 86vw); white-space: normal; }
  .map-help-body .map-hint { white-space: normal; }
}

/* ---------- the dark half ----------

   One mode, no new geometry. `refreshHighlight()` puts `.dimmed` on every
   citizen who has ever been dispatched and `.dark` on the ones who never
   were plus the ones no charter names; both classes already existed for
   hover, so the toggle spends zero elements and reuses a treatment the
   visitor has already been taught by pointing at somebody.

   Everything that is not part of the question recedes with them: the Archive
   is procedures rather than people, and a walker is by definition somebody
   who was dispatched, so both belong to the warm half. */

#city-map.dark-half .structure.dark { --lift: -2px; }
#city-map.dark-half .structure.dark .edge { opacity: max(var(--rim-alpha), 0.8); }
/* Deeper than the 0.22 a hover uses, and deliberately so: a hover is asking
   "who does this one work with", where the rest of the city is still context
   worth reading. This mode is asking "how much of this never runs", and the
   answer only lands if the half that does run gets out of the way. The
   buildings do not disappear — they are still there, which is the honest
   drawing — and the complete record stays in the roster below either way. */
#city-map.dark-half .structure.dimmed,
#city-map.dark-half .structure.kind-skill { opacity: 0.10; }
#city-map.dark-half .figure.walker.on { opacity: 0.22; }
#city-map.dark-half .walk-route.on { opacity: 0.12; }
#city-map.roads-on.dark-half .road { opacity: 0.06; }

.map-toolbar button.map-mode {
  border-color: var(--accent);
  color: var(--accent);
}
.map-toolbar button.map-mode[aria-pressed="true"] {
  background: var(--accent);
  border-color: var(--accent);
  color: var(--bg-elevated);
}
.map-dark-note { margin-top: -0.35rem; }

/* ---------- following a walker ----------

   A walker is 8 units wide, so the person gets a hit area rather than the
   ink getting one: `.fig-hit` paints nothing and is targetable anyway.
   `.figure` is pointer-events:none for everyone else, which is what keeps
   hover on the building underneath honest. */

.fig-hit { fill: none; pointer-events: all; }
.figure.walker.on { pointer-events: auto; cursor: pointer; }
.figure.walker:not(.on) { pointer-events: none; }
.walk-route.followed { opacity: 1; }

@media (prefers-reduced-motion: no-preference) {
  /* Opacity only: the route the followed pair is walking breathes so it is
     obvious which of the twelve streets is the one being talked about. */
  .walk-route.followed { animation: route-follow 1800ms var(--ease-ambient) infinite; }
  @keyframes route-follow {
    0%, 100% { opacity: 1; }
    50%      { opacity: 0.45; }
  }
}

/* ---------- the panel as the next move ----------

   The connected set was always clickable and always correct; it was never
   obviously the point. It sits above the record now, it says how many of
   these the map has just lit, and each id is a whole chip instead of an
   underlined word inside one. */

.panel-walk {
  font-family: var(--font-mono);
  font-size: 0.89rem;
  border: 1.5px solid var(--accent);
  border-radius: var(--radius);
  padding: 0.5rem 0.7rem;
  margin: 0.7rem 0 0;
}
.panel-next { margin-top: 0.9rem; }
.panel-next-lead {
  margin: 0 0 0.6rem;
  font-weight: 600;
}
.panel-next-lead.is-empty {
  font-weight: 400;
  color: var(--ink-muted);
}
.panel-next h3 {
  font-size: 0.79rem;
  text-transform: uppercase;
  letter-spacing: 0.06em;
  color: var(--ink-muted);
  margin: 0 0 0.35rem;
}
.panel-hops {
  border-top: 1px solid var(--border-subtle);
  padding-top: 0.7rem;
  margin-top: 0.2rem;
}
.panel-hops[hidden] { display: none; }
.panel-hops-lead { margin: 0 0 0.5rem; }

.panel-list.panel-links li { padding: 0; overflow: hidden; }
.panel-links .link-button {
  display: flex;
  align-items: baseline;
  gap: 0.5em;
  padding: 0.28rem 0.6rem;
  color: var(--ink);
  text-decoration: none;
  font-family: var(--font-mono);
  font-size: 0.89rem;
}
.panel-links .link-button:hover { background: var(--bg-elevated); color: var(--accent); }
.panel-links .link-button .chip-id { font-weight: 600; }
/* The dark half, seen from inside a record. Never colour alone: the state is
   spelled out in words on the chip. */
.panel-links li.is-dark { border-color: var(--warn); }
.panel-links .link-button .chip-state {
  font-size: 0.79rem;
  color: var(--warn);
  white-space: nowrap;
}

/* ---------- the headline strip's invitation ---------- */

.finding-strip .strip-go {
  margin-left: auto;
  font-weight: 600;
  text-decoration: none;
  /* It inherits .jump-to-citizen, which is warn-coloured because it names a
     citizen inside a warning panel. Here it is the invitation, not the
     warning — the count next to it already carries that — so text and border
     agree on the accent. Both ratios measured in tools/contrast.mjs. */
  color: var(--accent);
  border: 1.5px solid var(--accent);
  border-radius: var(--radius);
  padding: 0.2rem 0.6rem;
}
.finding-strip .strip-go:hover { background: var(--accent); color: var(--bg-elevated); }
.finding-strip .strip-go + a { margin-left: 0; }

@media (max-width: 599px) {
  .finding-strip .strip-go {
    display: inline-block;
    margin-left: 0;
    margin-top: var(--space-2);
  }
  .panel-links .link-button { padding: 0.35rem 0.6rem; }
}

/* ---------- the studio: the second half of the page ----------------------

   Everything above this rule draws one ecosystem. The studio draws whichever
   one the visitor put on the stage, with the same renderer and the same
   palette, so there is one city view on this page and not two that have to be
   kept looking alike.

   It stays inside the 1180px column. Only the hero breaks out full-bleed
   (LAYOUT-SPEC 1.6); a second full-width band would make the page two heroes
   and the map above would stop being the subject. */

.studio {
  border-top: 1px solid var(--border-subtle);
  padding-top: var(--space-8);
}
.studio-lead { color: var(--ink-muted); }
.studio-nojs {
  border: 1.5px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--bg-elevated);
  padding: 0.9rem 1.1rem;
}

.studio-tabs {
  display: flex;
  gap: var(--space-2);
  margin-bottom: var(--space-5);
}
.studio-tabs button {
  background: none;
  border: none;
  border-bottom: 2px solid transparent;
  border-radius: 0;
  padding: var(--space-2) var(--space-1);
  color: var(--ink-muted);
}
.studio-tabs button[aria-selected="true"] {
  color: var(--ink);
  border-bottom-color: var(--accent);
}
.studio-tabs button:hover { background: none; color: var(--ink); }

.studio-body { display: grid; gap: var(--space-6); }
@media (min-width: 1024px) {
  .studio-body {
    grid-template-columns: 22rem 1fr;
    align-items: start;
  }
}

/* ---- the rail ---- */

.studio-drop {
  border: 1.5px dashed var(--border-strong);
  border-radius: var(--radius);
  background: var(--bg-elevated);
  padding: var(--space-5);
  text-align: center;
}
/* The only state change here, and it is a real one: the pointer is over the
   target and holding something. Colour and border only, no movement. */
.studio-drop.over { border-color: var(--accent); border-style: solid; }
.studio-drop-hint { margin: 0 0 var(--space-3); color: var(--ink-muted); max-width: none; }
.studio-drop-actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-2);
  justify-content: center;
}
.studio-note {
  font-size: 0.89rem;
  color: var(--ink-muted);
  margin: var(--space-3) 0 0;
  max-width: none;
}
.studio-status {
  font-size: 0.89rem;
  color: var(--ink);
  margin: var(--space-3) 0 0;
  max-width: none;
  min-height: 1.4em;
}
.studio-status:empty { min-height: 0; margin: 0; }

.studio-report { margin-top: var(--space-4); }
.stats-grid-small { grid-template-columns: repeat(auto-fit, minmax(6.5rem, 1fr)); }
.stats-grid-small dd { font-size: 1.42rem; }

.studio-skipped, .studio-notes {
  list-style: none;
  padding: 0;
  margin: var(--space-2) 0 0;
  font-size: 0.89rem;
  color: var(--ink-muted);
}
.studio-skipped li, .studio-notes li { margin-bottom: var(--space-1); }
.studio-skipped code { word-break: break-all; }
.studio-notes li { border-left: 2px solid var(--border-subtle); padding-left: var(--space-3); }

#studio-report-detail > summary {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  cursor: pointer;
  padding: var(--space-2) 0;
  list-style: none;
}
#studio-report-detail > summary::-webkit-details-marker { display: none; }
#studio-report-detail > summary::before { content: "\25B8"; color: var(--ink-muted); font-size: 0.8em; }
#studio-report-detail[open] > summary::before { content: "\25BE"; }
#studio-report-detail > summary h3 { margin: 0; }

/* ---- the template list ---- */

.studio-templates {
  list-style: none;
  padding: 0;
  margin: 0;
  display: grid;
  gap: var(--space-2);
}
.studio-card {
  display: flex;
  flex-direction: column;
  gap: var(--space-1);
  width: 100%;
  text-align: left;
  font-weight: 400;
  padding: 0.7rem 0.85rem;
  border: 1.5px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--bg-elevated);
}
.studio-card:hover { border-color: var(--accent); background: var(--bg-elevated); }
.studio-card[aria-pressed="true"] {
  border-color: var(--accent);
  border-width: 1.5px;
  box-shadow: inset 0 0 0 1px var(--accent);
}
.studio-card-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-2);
}
.studio-card-niche { color: var(--ink-muted); font-size: 0.89rem; }
.studio-card-counts { color: var(--ink-muted); font-family: var(--font-mono); font-size: 0.79rem; }
.tag-free { color: var(--hue-03-text); }
.tag-locked { color: var(--ink-muted); }

/* ---- the stage ---- */

/* The hero's frame, brought back inside the column and made shorter: this map
   is a preview beside a list, not the subject of the page. */
/* [hidden] is a UA rule and .hero-map sets display:flex, so an author
   declaration beat it and the empty stage frame was painted before anything
   was put on it. Seen in the first browser pass: a large empty ground plane
   sitting above the words "Nothing on the stage yet". */
.hero-map[hidden] { display: none; }

.studio-map {
  width: auto;
  margin-left: 0;
  transform: none;
  height: min(46vh, 380px);
  min-height: 280px;
}
@media (min-width: 1024px) {
  .studio-map { height: min(54vh, 460px); min-height: 340px; }
}
#studio-map {
  display: block;
  flex: 1 1 auto;
  width: 100%;
  min-height: 0;
  cursor: grab;
  touch-action: none;
}
#studio-map.grabbing { cursor: grabbing; }
#studio-map.lod-1 .fig-detail { display: inline; }
/* Declared wiring is the finding a template or an import is being read for,
   so the roads are drawn rather than waiting to be asked for. */
#studio-map .road { opacity: 0.22; }

/* NOTHING ON THIS MAP MOVES, and that is the honest rendering.
   The hero map animates because workforce.json says which citizens were
   dispatched and when. A template has no telemetry and an imported ecosystem
   has none either, so a bobbing figure or a breathing window here would be
   motion standing in for data that does not exist. */
#studio-map * { animation: none !important; }

.studio-stage-title {
  font-family: var(--font-mono);
  font-size: 0.84rem;
  color: var(--ink-muted);
  margin-right: auto;
}
.map-controls { display: inline-flex; gap: 0.3rem; }

.studio-empty {
  border: 1px dashed var(--border-subtle);
  border-radius: var(--radius);
  padding: var(--space-8) var(--space-4);
  text-align: center;
  color: var(--ink-muted);
  max-width: none;
}

.studio-actions { margin-top: var(--space-4); }
.studio-summary-text { color: var(--ink); }
.studio-members {
  list-style: none;
  padding: 0;
  margin: 0;
  font-size: 0.89rem;
}
.studio-members li { margin-bottom: var(--space-2); }
.studio-members strong { margin-right: var(--space-2); }
.studio-member-desc { color: var(--ink-muted); }
.studio-member-skill strong::after {
  content: " skill";
  font-family: var(--font-mono);
  font-size: 0.79rem;
  color: var(--ink-muted);
  font-weight: 400;
}
.studio-members-detail > summary {
  display: flex;
  align-items: baseline;
  gap: var(--space-2);
  cursor: pointer;
  padding: var(--space-2) 0;
  list-style: none;
}
.studio-members-detail > summary::-webkit-details-marker { display: none; }
.studio-members-detail > summary::before { content: "\25B8"; color: var(--ink-muted); font-size: 0.8em; }
.studio-members-detail[open] > summary::before { content: "\25BE"; }
.studio-members-detail > summary h3 { margin: 0; }
.studio-why {
  list-style: none;
  padding: 0;
  margin: var(--space-3) 0 0;
  font-size: 0.89rem;
  color: var(--ink-muted);
}
.studio-why li {
  border-left: 2px solid var(--border-subtle);
  padding-left: var(--space-3);
  margin-bottom: var(--space-2);
}

.studio-download {
  margin-top: var(--space-4);
  border-color: var(--accent);
  color: var(--accent);
}
.studio-download:hover { background: var(--bg); }

/* ---- the gate ---- */

.studio-gate {
  margin-top: var(--space-4);
  border: 1.5px solid var(--border-strong);
  border-radius: var(--radius);
  background: var(--bg-elevated);
  padding: 0.9rem 1.1rem;
}
.studio-gate h3 { margin-top: 0; }
.studio-gate-fields {
  display: grid;
  gap: var(--space-3);
  margin-bottom: var(--space-3);
}
@media (min-width: 600px) {
  .studio-gate-fields { grid-template-columns: 1fr 1fr; }
  .studio-gate-fields .field-wide { grid-column: 1 / -1; }
}
.studio-gate-fields input { width: 100%; }
/* The honeypot. Off-screen rather than display:none, because a bot that reads
   the computed style skips a hidden field and fills a visible one. */
.studio-hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

@media (prefers-reduced-motion: reduce) {
  #studio-map *, #studio-map { animation: none !important; transition: none !important; }
  .studio-drop, .studio-card { transition: none !important; }
}

/* ---------- the figures, the action, and the panels ----------

   The page used to render 836 words at rest, most of them statistical prose
   sitting between the map and the visitor: "34 of 59 citizens are dark: 28
   have never been dispatched, 8 are named by no charter, and 2 are both."
   That sentence is TRUE and worth keeping — it is just not worth reading
   before you have looked at the thing it describes. So the numbers became
   chips, the prose went behind disclosures, and nothing was deleted. */

.map-underbar {
  margin-top: 0.55rem;
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 0.5rem 1rem;
}

.map-figures {
  display: flex;
  flex-wrap: wrap;
  gap: 0.35rem 0.5rem;
  list-style: none;
  margin: 0;
  padding: 0;
}
.map-figures .figure {
  display: inline-flex;
  align-items: baseline;
  gap: 0.32em;
  font-size: 0.83rem;
  color: var(--ink-muted);
  padding: 0.18rem 0.5rem;
  border: 1px solid var(--border-subtle);
  border-radius: 999px;
  background: var(--bg-elevated);
  white-space: nowrap;
}
.map-figures .figure b {
  font-family: var(--font-mono);
  font-size: 0.95rem;
  font-weight: 700;
  color: var(--ink);
}
/* The one figure that is a finding rather than an inventory. */
.map-figures .figure-warn { border-color: var(--warn-border, var(--border-strong)); }
.map-figures .figure-warn b { color: var(--warn-ink, var(--ink)); }

.map-notes > summary,
.panel > summary {
  cursor: pointer;
  list-style: none;
}
.map-notes > summary::-webkit-details-marker,
.panel > summary::-webkit-details-marker { display: none; }

.map-notes > summary {
  font-size: 0.83rem;
  color: var(--ink-muted);
  padding: 0.18rem 0.5rem;
  border: 1px solid var(--border-subtle);
  border-radius: 999px;
  background: var(--bg-elevated);
}
.map-notes > summary::before { content: "› "; }
.map-notes[open] > summary::before { content: "⌄ "; }
.map-notes > summary:hover { border-color: var(--accent); }
.map-notes-body { padding-top: 0.6rem; }
.map-notes-body .map-hint { max-width: 70ch; }

/* One primary action. Not the only thing you can do — the only thing the page
   RECOMMENDS you do. Everything else on the page is a button among sixty-nine
   buttons; this is the one with weight. */
.act {
  display: flex;
  justify-content: center;
  margin: 1.1rem 0 1.4rem;
}
.cta {
  display: inline-block;
  font-family: var(--font-display);
  font-size: 1.02rem;
  font-weight: 650;
  letter-spacing: -0.005em;
  text-decoration: none;
  color: var(--bg-elevated);
  background: var(--accent);
  border: 1.5px solid var(--accent);
  border-radius: var(--radius);
  padding: 0.62rem 1.5rem;
}
.cta:hover {
  filter: brightness(1.08);
  text-decoration: none;
}
.cta:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }

/* A section that opens. The heading stays a heading — it is still the
   document outline, still the target of every in-page link — it just also
   happens to be the control that reveals its own section. */
.panel > summary { display: block; }
.panel > summary > h2 {
  display: flex;
  align-items: center;
  gap: 0.5em;
  margin-bottom: 0;
}
.panel > summary > h2::after {
  content: "›";
  font-family: var(--font-mono);
  font-weight: 400;
  color: var(--ink-muted);
  margin-left: auto;
}
.panel[open] > summary > h2::after { content: "⌄"; }
.panel > summary:hover > h2 { color: var(--accent); }
.panel > summary:focus-visible { outline: 3px solid var(--focus); outline-offset: 3px; }
.panel[open] > summary > h2 { margin-bottom: 0.6em; }

/* The secondary controls that moved out of the toolbar. */
.map-help-controls {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 0.4rem 0.6rem;
  padding-bottom: 0.5rem;
  margin-bottom: 0.5rem;
  border-bottom: 1px solid var(--border-subtle);
}

/* Sections are closer together now that most of them are one line tall. */
main section, main aside { margin-bottom: 1.5rem; }
.hero-section { margin-bottom: 0.5rem; }

/* Provenance is a footnote, not a farewell address. The two paragraphs that
   say where every number came from are the page's honesty story and they stay
   — behind a disclosure, because 60 words of methodology is not what a
   visitor reads on arrival. The maker's mark stays visible. */
.footer-note > summary {
  cursor: pointer;
  list-style: none;
  font-size: 0.86rem;
  color: var(--ink-muted);
  margin-bottom: 0.6rem;
}
.footer-note > summary::-webkit-details-marker { display: none; }
.footer-note > summary::before { content: "› "; }
.footer-note[open] > summary::before { content: "⌄ "; }
.footer-note > summary:hover { color: var(--accent); }

/* A closed disclosure must not lay out its contents.
   Chromium keeps an absolutely-positioned child of a closed <details> in the
   layout tree: `::details-content` is `content-visibility: hidden`, which
   skips PAINTING but still gives descendants boxes. So the two checkboxes and
   the link inside this popover reported rectangles hanging off the right edge
   of a 390px viewport while being invisible to any reader — a control that is
   simultaneously unreachable and unhidden. Hidden means hidden. */
.map-help:not([open]) > .map-help-body { display: none; }

/* ============================================================ MAP MODE ====

   The site IS the map.

   Everything above this comment describes a scrolling document with a map in
   it. That was the honest shape while the map was one figure among several;
   it stopped being honest the moment the map became the product. So the
   document is still there, byte for byte, and `map-mode` — set on <html> by
   app.mjs only after a city has actually been drawn — folds it into an
   application shell: the city fills the viewport, the page does not scroll,
   and every panel that used to sit BELOW the map now floats OVER it.

   The contract this preserves: with JavaScript off, or if the render throws,
   `map-mode` is never set, none of these rules apply, and the visitor gets
   exactly the stacked document that shipped before — static roster, findings,
   status board, skills archive and all.
   ======================================================================== */

:root {
  --dur-sheet: 320ms;
  --ease-out: cubic-bezier(0.16, 1, 0.3, 1);
  --hud-radius: 12px;
  --panel-w: 380px;
  --sheet-w: 440px;
  --hud-gap: 0.7rem;
}

html.map-mode,
html.map-mode body {
  height: 100%;
  overflow: hidden;
  overscroll-behavior: none;
}

html.map-mode .page {
  position: fixed;
  inset: 0;
  max-width: none;
  margin: 0;
  padding: 0;
}

html.map-mode #main { margin: 0; }
html.map-mode main > section,
html.map-mode main > aside,
html.map-mode main > div { margin-bottom: 0; }

/* The stage. */
html.map-mode #map-section {
  position: absolute;
  inset: 0;
  margin: 0;
}
html.map-mode .hero-map {
  position: absolute;
  inset: 0;
  width: auto;
  height: auto;
  min-height: 0;
  max-height: none;
  margin: 0;
  transform: none;
  border: 0;
  border-radius: 0;
  display: block;
}
html.map-mode #city-map {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}

/* Things the document needs and the application does not. */
html.map-mode .act,
html.map-mode #static-roster-section,
html.map-mode .site-footer { display: none; }

/* ---------- atmosphere ----------
   Two screen-space layers over the city: a vignette that pulls the eye to the
   middle, and luminance grain. Both are FIXED to the screen rather than to
   the world, which is the whole point — they never re-rasterise on a pan or a
   zoom, so they cost nothing per frame, and grain that swims with the camera
   reads as dirt on the lens rather than as material. */
.map-atmos {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 1;
  /* Promoted to its own compositing layer on purpose. Both children are
     FIXED in screen space, so once rasterised they never need repainting;
     without this hint Chromium repaints them into the same layer as the SVG
     on every camera tick. Measured on a 1440x900 pan: 22 fps with the
     atmosphere painting inline, 54 fps with it out of the way. */
  transform: translateZ(0);
  will-change: transform;
  contain: strict;
}
.map-atmos::before {
  content: "";
  position: absolute;
  inset: 0;
  background:
    radial-gradient(120% 90% at 50% 42%, transparent 38%, oklch(0.15 0.03 265 / 0.30) 100%);
}
.map-atmos::after {
  content: "";
  position: absolute;
  inset: 0;
  /* No `mix-blend-mode`. A blend mode must re-read the backdrop every time
     the backdrop changes, which while panning is every single frame: it was
     the single most expensive thing on this page (22 -> 40 fps just by
     switching it off). Grain composited normally at a low alpha reads almost
     identically and costs one texture. */
  opacity: 0.055;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)' opacity='0.35'/%3E%3C/svg%3E");
}

/* ---------- the HUD ----------
   One absolutely positioned layer that does not itself take pointer events,
   so the city under it stays draggable everywhere the HUD is empty. Only the
   panels inside it are hit-testable. */
.hud { display: none; }
html.map-mode .hud {
  display: flex;
  flex-direction: column;
  justify-content: space-between;
  position: absolute;
  inset: 0;
  z-index: 10;
  padding: var(--hud-gap);
  gap: var(--hud-gap);
  pointer-events: none;
}
html.map-mode .hud-top,
html.map-mode .hud-bottom {
  display: flex;
  align-items: flex-start;
  justify-content: space-between;
  gap: var(--hud-gap);
}
html.map-mode .hud-bottom { align-items: flex-end; }
/* The figures read left, the doors read right, whatever order the DOM is in. */
html.map-mode .hud-dock { order: -1; }
html.map-mode .hud > * > * { pointer-events: auto; }

/* The frosted plate every HUD element is drawn on. */
.hud-panel {
  background: var(--bg-elevated);
  border: 1px solid var(--border-subtle);
  border-radius: var(--hud-radius);
  box-shadow: 0 1px 2px -1px rgb(0 0 0 / 0.22), 0 14px 34px -12px rgb(0 0 0 / 0.42);
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  .hud-panel {
    background: color-mix(in oklab, var(--bg-elevated) 74%, transparent);
    -webkit-backdrop-filter: blur(10px) saturate(1.3);
    backdrop-filter: blur(10px) saturate(1.3);
  }
}
@supports (color: oklch(from red l c h)) {
  .hud-panel {
    border-color: oklch(from var(--border-subtle) l c h / 0.7);
    box-shadow:
      inset 0 1px 0 0 oklch(from var(--plate) l c h / 0.35),
      0 1px 2px -1px oklch(from var(--shadow-ink) l c h / 0.30),
      0 16px 40px -14px oklch(from var(--shadow-ink) l c h / 0.55);
  }
}

html.map-mode header.site-header.hud-brand {
  position: absolute;
  top: var(--hud-gap);
  left: var(--hud-gap);
  z-index: 11;
}
html.map-mode .hud-top { justify-content: flex-end; }
html.map-mode .hud-brand {
  padding: 0.4rem 0.85rem 0.45rem;
  margin: 0;
  display: block;
  max-width: 60vw;
}
html.map-mode .hud-brand h1 {
  font-size: 1.05rem;
  letter-spacing: 0.01em;
  margin: 0;
}
html.map-mode .hud-brand .tagline {
  font-size: 0.72rem;
  line-height: 1.3;
  margin: 0;
  color: var(--ink-muted);
}

html.map-mode .hud-tools { display: flex; }
html.map-mode .map-toolbar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: flex-end;
  gap: 0.3rem;
  padding: 0.32rem 0.4rem;
  border-bottom: 1px solid var(--border-subtle);
}
@supports (color: oklch(from red l c h)) {
  html.map-mode .map-toolbar { border-bottom-color: oklch(from var(--border-subtle) l c h / 0.7); }
}

html.map-mode .hud-sheets {
  display: flex;
  gap: 0.2rem;
  padding: 0.28rem;
}
html.map-mode .hud-sheets button {
  border: 0;
  background: transparent;
  border-radius: 8px;
  padding: 0.4rem 0.75rem;
  font-weight: 600;
  font-size: 0.85rem;
  min-height: 36px;
}
html.map-mode .hud-sheets button:hover { background: color-mix(in oklab, var(--accent) 16%, transparent); }
html.map-mode .hud-sheets button[aria-expanded="true"] {
  background: var(--accent);
  color: var(--bg-elevated);
}

/* Stat chips: HUD readouts, not a list of figures under a picture. */
html.map-mode .hud-dock {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 0.4rem;
  max-width: min(58ch, 62vw);
}
html.map-mode #map-figures {
  display: flex;
  flex-wrap: wrap;
  gap: 0.3rem;
  margin: 0;
  padding: 0;
  list-style: none;
}
html.map-mode #map-figures .figure {
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.01em;
  padding: 0.24rem 0.55rem;
  border-radius: 999px;
  background: var(--bg-elevated);
  border: 1px solid var(--border-subtle);
  color: var(--ink-muted);
  white-space: nowrap;
}
html.map-mode #map-figures .figure b { color: var(--ink); font-weight: 700; }
html.map-mode #map-figures .figure-warn { color: var(--warn); border-color: var(--warn); }
html.map-mode #map-figures .figure-warn b { color: var(--warn); }
/* No backdrop-filter on the chips. Five more elements each re-reading the
   moving map behind them cost more than the frosting was worth; a flat
   translucent fill over a dark city is nearly indistinguishable. */
html.map-mode #map-figures .figure {
  background: color-mix(in oklab, var(--bg-elevated) 88%, transparent);
}

/* The headline finding, floated into the HUD rather than stacked under it. */
html.map-mode #headline-strip {
  /* app.mjs moves this node into the dock in map mode, so it flows with the
     stat chips instead of being positioned against them and overlapping. */
  margin: 0;
  max-width: min(46ch, calc(100vw - 2 * var(--hud-gap)));
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  gap: 0.2rem 0.5rem;
  padding: 0.5rem 0.8rem;
  border-radius: var(--hud-radius);
  font-size: 0.82rem;
  line-height: 1.4;
  background: var(--bg-elevated);
  border: 1px solid color-mix(in oklab, var(--warn) 34%, var(--border-subtle));
  box-shadow: inset 0 0 0 100vmax color-mix(in oklab, var(--warn) 9%, transparent);
  pointer-events: auto;
  transition: transform var(--dur-sheet) var(--ease-out), opacity var(--dur-sheet) var(--ease-out);
}
@supports (backdrop-filter: blur(1px)) or (-webkit-backdrop-filter: blur(1px)) {
  html.map-mode #headline-strip {
    background: color-mix(in oklab, var(--bg-elevated) 82%, transparent);
    -webkit-backdrop-filter: blur(10px) saturate(1.3);
    backdrop-filter: blur(10px) saturate(1.3);
  }
}
html.map-mode #headline-strip .finding-strip-count {
  font-size: 1.25rem;
  line-height: 1;
  color: var(--warn);
}
html.map-mode #headline-strip a[href="#findings-section"] { display: none; }

/* ---------- sheets ----------
   Off in the document, drawers in the application. */
html.map-mode .sheet {
  position: fixed;
  z-index: 40;
  top: 0;
  bottom: 0;
  left: 0;
  width: min(var(--sheet-w), 100vw);
  display: flex;
  flex-direction: column;
  background: var(--bg-elevated);
  border-right: 1px solid var(--border-subtle);
  box-shadow: 0 0 60px -10px rgb(0 0 0 / 0.55);
  transform: translateX(-101%);
  visibility: hidden;
  transition: transform var(--dur-sheet) var(--ease-out), visibility 0s linear var(--dur-sheet);
}
html.map-mode .sheet.sheet-wide { width: min(720px, 100vw); }
html.map-mode .sheet.is-open {
  transform: none;
  visibility: visible;
  transition: transform var(--dur-sheet) var(--ease-out), visibility 0s;
}
.sheet-head { display: none; }
html.map-mode .sheet-head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 0.7rem 0.75rem 0.7rem 1.1rem;
  border-bottom: 1px solid var(--border-subtle);
  flex: 0 0 auto;
}
html.map-mode .sheet-title {
  margin: 0;
  border: 0;
  padding: 0;
  font-size: 1.05rem;
}
.sheet-close { font-size: 1.2rem; line-height: 1; padding: 0.25rem 0.6rem; }
html.map-mode .sheet-body {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 0.9rem 1.1rem 3rem;
}
/* Inside a sheet the section headings are redundant with the sheet's own. */
html.map-mode .sheet-body > section > .panel > summary,
html.map-mode .sheet-body > div > section > .panel > summary { display: none; }
html.map-mode .sheet-body section { margin-bottom: 1.4rem; }
html.map-mode #provenance-section > .panel > summary { display: list-item; }
html.map-mode .sheet-body h2 { font-size: 1.05rem; }

.sheet-scrim { display: none; }
html.map-mode .sheet-scrim {
  display: block;
  position: fixed;
  inset: 0;
  z-index: 30;
  background: oklch(0.12 0.02 265 / 0.42);
  opacity: 0;
  transition: opacity var(--dur-sheet) var(--ease-out);
  pointer-events: none;
}
html.map-mode .sheet-scrim.is-on { opacity: 1; pointer-events: auto; }
html.map-mode .sheet-scrim[hidden] { display: none; }

/* ---------- the citizen panel, as an inspector ---------- */
html.map-mode .citizen-panel {
  position: fixed;
  z-index: 35;
  top: 0;
  right: 0;
  bottom: 0;
  width: min(var(--panel-w), 100vw);
  margin: 0;
  max-width: none;
  border: 0;
  border-left: 1px solid var(--border-subtle);
  border-radius: 0;
  background: var(--bg-elevated);
  box-shadow: 0 0 60px -10px rgb(0 0 0 / 0.55);
  overflow-y: auto;
  overscroll-behavior: contain;
  padding: 1rem 1.1rem 3rem;
  transform: translateX(101%);
  transition: transform var(--dur-sheet) var(--ease-out);
}
html.map-mode .citizen-panel[hidden] {
  display: block;
  transform: translateX(101%);
  visibility: hidden;
  pointer-events: none;
}
html.map-mode .citizen-panel:not([hidden]) { transform: none; visibility: visible; }

/* When the inspector is out, the top-right controls step aside rather than
   hiding under it. WCAG 2.4.11: a focused control must not be obscured. */
html.map-mode.panel-open .hud { padding-right: calc(min(var(--panel-w), 100vw) + var(--hud-gap)); }
html.map-mode.sheet-open .hud { padding-left: calc(min(var(--sheet-w), 100vw) + var(--hud-gap)); }
html.map-mode.sheet-open #headline-strip { opacity: 0; pointer-events: none; }

/* ---------- phone: drawers become bottom sheets ---------- */
@media (max-width: 719px) {
  html.map-mode .sheet {
    top: auto;
    left: 0;
    right: 0;
    width: auto;
    height: min(86dvh, 100%);
    border-right: 0;
    border-top: 1px solid var(--border-subtle);
    border-radius: 16px 16px 0 0;
    transform: translateY(101%);
  }
  html.map-mode .sheet.sheet-wide { width: auto; }
  html.map-mode .citizen-panel {
    top: auto;
    left: 0;
    right: 0;
    width: auto;
    max-height: min(72dvh, 100%);
    border-left: 0;
    border-top: 1px solid var(--border-subtle);
    border-radius: 16px 16px 0 0;
    transform: translateY(101%);
  }
  html.map-mode .citizen-panel[hidden] { transform: translateY(101%); }
  html.map-mode.panel-open .hud,
  html.map-mode.sheet-open .hud { padding-left: var(--hud-gap); padding-right: var(--hud-gap); }
  html.map-mode.panel-open #headline-strip,
  html.map-mode.panel-open .hud-bottom { opacity: 0; pointer-events: none; }
  html.map-mode .hud-brand .tagline { display: none; }
  html.map-mode .hud-top { align-items: flex-start; }
  html.map-mode .hud-bottom {
    flex-direction: column;
    align-items: stretch;
    transition: opacity var(--dur-sheet) var(--ease-out);
  }
  html.map-mode .hud-dock { order: 0; }
  html.map-mode .hud-sheets { order: 1; justify-content: space-between; }
  html.map-mode .hud-sheets button { flex: 1 1 0; padding: 0.5rem 0.3rem; }
  html.map-mode #headline-strip { max-width: none; }
  html.map-mode .map-toolbar { max-width: 62vw; }
}

/* ---------- reduced motion ---------- */
@media (prefers-reduced-motion: reduce) {
  html.map-mode .sheet,
  html.map-mode .citizen-panel,
  html.map-mode .sheet-scrim,
  html.map-mode .hud,
  html.map-mode #headline-strip { transition: none; }
}

/* =========================================================== MATERIALS ====

   What separates a rendered scene from coloured rectangles.

   The value ladder in DESIGN.md §3 was already right — one key light from the
   upper right, three faces of every solid at three lightnesses — and it was
   also the whole of the lighting model. Three flat tints is what a diagram
   does. A surface does four more things, and each of them is a rule here:

     falloff   light is brighter where it strikes and dies toward the ground
     contact   volumes gather occlusion where they meet the plane and each other
     crest     an edge that catches light is brighter than either face it joins
     medium    air between the eye and a distant object is not free

   None of it changes a hue, a height, or anything else this map means.
   ======================================================================== */

.sun-glow,
.ground-light,
.sheen { pointer-events: none; }
.sheen { stroke: none; }

/* Falloff. One shared ramp composited over each hue-derived face: bright
   along the wall head, ambient ink gathering at the footing. */
.sheen-roof { opacity: 0.85; }

/* Crest. The edge where a roof meets the sky and its own walls catches more
   light than either surface, and drawing it is most of what stops a box
   reading as a sticker. It replaces the same-colour seam stroke that was
   there before, so it closes the antialiasing gap exactly as that did. */
.face-top,
.face-gable-lit {
  stroke: var(--face-crest);
  stroke-opacity: var(--crest-alpha);
  stroke-width: 0.9;
}
.structure,
.figure {
  --face-crest: color-mix(in oklab, var(--hue) 62%, white);
}
@supports (color: oklch(from red l c h)) {
  .structure,
  .figure { --face-crest: oklch(from var(--hue) calc(l + 0.14) calc(c * 0.72) h); }
}

/* Contact. The gutter line where a plot meets the street: a real kerb
   shadow rather than a hairline grid pretending to be one. */
.plot-ground {
  stroke: var(--ao-ink);
  stroke-opacity: var(--plot-edge-alpha);
  stroke-width: 2.4;
  stroke-linejoin: round;
}

/* Glass. The fill is a gradient chosen at render time (city-view picks lit or
   unlit from the same telemetry the figure's pose comes from), so this file
   must not set `fill` — a CSS declaration would beat the attribute and put
   every window back to a flat tint. Lit glass also throws a little light
   onto the wall around it, which is a stroke rather than a blur because a
   blur per building is 145 offscreen composites. */
.windows { stroke: none; }
.structure.act-recent .windows { opacity: 0.9; }
.structure.act-active .windows {
  opacity: 1;
  stroke: var(--window);
  stroke-width: 1.5;
  stroke-opacity: var(--glass-glow-alpha);
}

/* Focus, drawn in the world.
   `outline` on an SVG element is not something to bet a keyboard user's
   whole experience on: engines disagree about it, and even where it paints,
   an outline around the BOUNDING BOX of a tall isometric building is a tall
   thin rectangle that says very little about which person is focused. The
   hit target is already a circle centred on the citizen, so focus simply
   makes it visible. It is a real ring around a real person, it is 40 CSS px
   across at any zoom, and it works in every engine that can draw a circle.
   The silhouette lifts at the same time, so the indicator is never carried
   by one channel alone. */
.structure:focus-visible .hit-area {
  stroke: var(--focus);
  stroke-width: 2.5;
  stroke-opacity: 1;
}
.structure:focus-visible .edge {
  stroke: var(--focus);
  stroke-width: calc(var(--rim-width) * 2);
  opacity: 1;
}
/* The default UA outline would then draw a second, worse indicator on top. */
#city-map .structure:focus-visible { outline: none; }

/* The target, not the sprite.
   A citizen is drawn about nine CSS pixels tall on a 390px phone, which is
   the correct SIZE for a city of fifty-nine and an indefensible tap target.
   Every game draws a small sprite and hits a large box; `r` is a real CSS
   property on an SVG circle, so the box is sized from the live camera scale
   (app.mjs writes --hit-r) and stays roughly constant in screen pixels
   however far out the view is. */
.hit-area {
  fill: none;
  pointer-events: all;
  r: var(--hit-r, 18px);
}
#city-map.roads-off .hit-area { pointer-events: none; }

/* ---------- the bypass link is a bypass, not a label ----------
   `Skip to list` sat permanently in the toolbar as a blue underlined word,
   which is a control spending HUD space on people who are not using it. It
   still exists, still comes first in the tab order, and now appears the
   instant it is focused, like every other skip link on the page. */
/* Same technique as .skip-link above, and for the same reason: clipped to 1x1
   it measured as a 1x1 target, and it sat at the RIGHT-hand end of the toolbar
   where growing it on focus would have pushed it off the edge. Anchored to the
   left of the toolbar at full size and parked above it instead. */
.map-skip {
  position: absolute;
  left: 0.5rem;
  top: 0.35rem;
  z-index: 40;
  padding: 0.3rem 0.55rem;
  white-space: nowrap;
  background: var(--bg-elevated);
  color: var(--ink);
  border: 2px solid var(--focus);
  border-radius: var(--radius);
  transform: translateY(-250%);
}
.map-skip:focus {
  transform: none;
}

/* The penumbra: wider, longer, and much fainter than the shadow it sits
   under. Two flat polygons in the same paint beat one Gaussian blur that
   cost this scene 24 fps, measured. */
.cast-penumbra { fill-opacity: calc(var(--shadow-alpha) * 0.42); }

/* ---------- the selection marker ---------- */
.select-ring {
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--dur-cross) var(--ease-standard);
}
.select-ring.on { opacity: 1; }
.select-ring-fill { fill: var(--lamp); fill-opacity: 0.14; stroke: none; }
.select-ring-edge {
  fill: none;
  stroke: var(--lamp);
  stroke-width: 2;
  stroke-opacity: 0.85;
}

@media (prefers-reduced-motion: no-preference) {
  /* A slow breath, not a strobe: one cycle every 2.4s, opacity only, and it
     runs on a single element rather than on anything the camera paints. */
  @keyframes select-breathe {
    0%, 100% { stroke-opacity: 0.85; }
    50% { stroke-opacity: 0.35; }
  }
  .select-ring.on .select-ring-edge {
    animation: select-breathe 2.4s var(--ease-ambient) infinite;
  }

  /* Hover and selection lift the building off the plane. Four pixels of
     translate is the whole of it, and it is the difference between a picture
     of a city that changes colour when you point at it and a thing that
     responds to being touched. */
  .structure.kind-citizen:hover,
  .structure.kind-citizen.active { transform: translate(0, -4px); }
}

/* ---------- phone: the HUD gets out of the city's way ---------- */
@media (max-width: 719px) {
  /* On a phone the five readouts are ONE bar, not five pills. Five pills
     wrap to three rows and eat 90px of an 844px screen for information that
     reads perfectly well as a single dense line. */
  html.map-mode .hud-dock {
    align-self: stretch;
    max-width: none;
  }
  html.map-mode #map-figures {
    gap: 0 0.55rem;
    padding: 0.3rem 0.6rem;
    border-radius: var(--hud-radius);
    background: color-mix(in oklab, var(--bg-elevated) 84%, transparent);
    border: 1px solid var(--border-subtle);
  }
  html.map-mode #map-figures .figure {
    font-size: 0.64rem;
    padding: 0;
    border: 0;
    background: none;
    border-radius: 0;
  }
  html.map-mode #headline-strip {
    font-size: 0.74rem;
    padding: 0.4rem 0.6rem;
    gap: 0.15rem 0.4rem;
    display: block;
  }
  /* The count leads the sentence and the invitation ends it, on one run of
     text rather than three stacked blocks. */
  html.map-mode #headline-strip .finding-strip-count { margin-right: 0.3rem; }
  html.map-mode #headline-strip .strip-go {
    display: inline-block;
    margin-left: 0.3rem;
    vertical-align: baseline;
  }
  html.map-mode #headline-strip .finding-strip-count { font-size: 1.05rem; }
  html.map-mode #headline-strip .strip-go { font-size: 0.7rem; padding: 0.2rem 0.5rem; }
  html.map-mode .hud-sheets button { font-size: 0.8rem; }
  html.map-mode .hud-dock { gap: 0.3rem; }
}

/* ---------- labels stop being stickers ----------

   The single loudest remaining "this is a diagram" signal was a solid box
   with mono text pasted over the city for every person on it. Every serious
   map draws a place name as TEXT with a halo, not as a label on a card: the
   halo does the contrast work against whatever happens to be underneath, and
   nothing opaque covers the thing being named. The plate's box is kept in the
   DOM because the placement pass measures it and the leader mounts to it —
   it simply stops being painted.

   Contrast is carried by the halo, and the halo is the plate colour: near
   black behind near white in the dusk lighting, near white behind near black
   in daylight, so the pairing inverts correctly with the theme rather than
   depending on what the label happens to be standing on. */
.name-plate .plate-bg { fill: none; stroke: none; }
.plate-name {
  fill: var(--ink);
  stroke: var(--plate);
  stroke-width: 5;
  stroke-linejoin: round;
  paint-order: stroke fill;
}
@supports not (paint-order: stroke) {
  /* An engine that cannot put the stroke behind the fill would draw the halo
     ON TOP of the word. Give it the box back instead. */
  .name-plate .plate-bg { fill: var(--plate); fill-opacity: var(--plate-alpha); }
  .plate-name { stroke: none; }
}
/* A leader is a hint about which roof a moved label belongs to, not a wire. */
.name-plate .plate-leader { stroke-width: 0.8; }

/* Precinct plates keep their card, because a district title IS a piece of
   signage standing in the street, and soften into the world a little. */
.precinct-plate .plate-bg { rx: 5; }

/* The "More" popover, anchored in map mode.
   `.map-help-body` is positioned against the nearest positioned ancestor,
   which used to be `.hero-map`. In map mode the toolbar floats inside an
   absolutely positioned HUD, so the popover resolved against the wrong box
   and ran 23px off the right edge of a 390px phone, clipping the Life toggle
   and half of every line of the hint. It is anchored to the toolbar it
   belongs to now, and capped at the viewport minus the HUD's own gutters. */
html.map-mode .hud-tools { position: relative; }
html.map-mode .map-help-body {
  top: calc(100% + 0.4rem);
  right: 0;
  left: auto;
  width: min(42ch, calc(100vw - 2 * var(--hud-gap)));
  max-width: none;
}

/* The dark-half readout, back on the map. It is a finding about what the
   visitor is looking at, so it belongs where they are looking, and it exists
   only while that mode is on. */
.hud-caption { display: none; }
html.map-mode .hud-caption {
  display: block;
  margin: 0;
  max-width: min(52ch, 90vw);
  padding: 0.45rem 0.75rem;
  border-radius: var(--hud-radius);
  border: 1px solid color-mix(in oklab, var(--lamp) 30%, var(--border-subtle));
  background: color-mix(in oklab, var(--lamp) 10%, color-mix(in oklab, var(--bg-elevated) 86%, transparent));
  font-size: 0.76rem;
  line-height: 1.4;
  color: var(--ink);
}
html.map-mode .hud-caption[hidden] { display: none; }

/* ---------- attribution-kit v1 — the maker's mark ----------

   These rules were shipped as an inline <style> block in index.html. This site
   sets `style-src 'self'` with no 'unsafe-inline', so the browser refused the
   whole block on every page view: the mark fell back to display:inline and
   measured 0x0, which meant the one piece of identity every project in the
   portfolio is required to carry was invisible here. Same-origin stylesheet,
   same rules, nothing for the CSP to object to.

   Inherits `color` from wherever it sits (the mark draws in currentColor); set
   --aj-attribution-signal to the project's accent. Nothing here is a flex
   container on purpose: the links stay display:inline, which is the WCAG 2.5.8
   inline-text exemption. */
.aj-attribution { margin: 0; font: inherit; line-height: 1.5; color: inherit; }
.aj-attribution__mark { display: inline-block; width: 20px; height: 20px; vertical-align: -0.3em; margin-right: 0.45em; }
.aj-attribution__mark--full { width: 48px; height: 48px; vertical-align: middle; }
.aj-attribution a { color: inherit; text-decoration: underline; text-underline-offset: 0.15em; }
.aj-attribution a:hover { text-decoration-thickness: 2px; }
.aj-attribution a:focus-visible { outline: 2px solid currentColor; outline-offset: 2px; }
.aj-attribution__sep { margin: 0 0.35em; }
