/* ==========================================================================
   Victoria 3 RP — base stylesheet
   Drop-in for a Django/Flask project. Images referenced relative to this file
   as ../img/*.png (i.e. static/css/victoria.css + static/img/*.png).
   ========================================================================== */

/* --- 1. Tokens ----------------------------------------------------------- */

:root {
  /* Surfaces — dark chrome */
  --v-bg:            #14100d;
  --v-bg-2:          #1d1713;
  --v-panel-dark:    #241c17;
  --v-panel-dark-2:  #2e241d;
  --v-rail:          #1a1410;

  /* Surfaces — parchment */
  --v-paper:         #ece2c9;
  --v-paper-2:       #dcd0b2;
  --v-paper-3:       #cfc09c;
  --v-paper-sunken:  #d3c6a6;

  /* Ink */
  --v-ink:           #2b241c;
  --v-ink-2:         #4a4034;
  --v-ink-3:         #6d6151;
  --v-cream:         #ece2cc;
  --v-cream-2:       #b9ab90;

  /* Metals & accents */
  --v-gold:          #c2a15a;
  --v-gold-bright:   #e3ca8d;
  --v-gold-dim:      #8a7038;
  --v-maroon:        #4a1f24;
  --v-maroon-2:      #6b2b2f;
  --v-red:           #8c2b26;
  --v-red-bright:    #a8362f;
  --v-green:         #4f6b3a;
  --v-blue:          #3c5a72;
  --v-yellow:        #7a6a00;

  /* Type */
  --v-font-display: "Cinzel", "Trajan Pro", Georgia, serif;
  --v-font-body:    "EB Garamond", Georgia, "Times New Roman", serif;
  --v-font-mono:    ui-monospace, "SF Mono", Menlo, Consolas, monospace;

  --v-fs-xs:   0.75rem;
  --v-fs-sm:   0.875rem;
  --v-fs-base: 1rem;
  --v-fs-md:   1.125rem;
  --v-fs-lg:   1.375rem;
  --v-fs-xl:   1.75rem;
  --v-fs-2xl:  2.25rem;
  --v-fs-3xl:  clamp(2.25rem, 5vw, 3.75rem);

  /* Space */
  --v-1: 4px;  --v-2: 8px;  --v-3: 12px; --v-4: 16px;
  --v-5: 24px; --v-6: 32px; --v-7: 48px; --v-8: 64px;

  /* Structure */
  --v-radius:     3px;
  --v-radius-lg:  6px;
  --v-border:     1px solid var(--v-gold-dim);
  --v-border-ink: 1px solid rgba(43, 36, 28, 0.22);
  --v-shadow:     0 2px 10px rgba(0, 0, 0, 0.45);
  --v-shadow-lg:  0 12px 40px rgba(0, 0, 0, 0.6);
  --v-topbar-h:   64px;
  --v-container:  1360px;

  /* Textures */
  --v-tex-paper:  url("../img/paper_texture.63599a30c205.png");
  --v-tex-damask: url("../img/fancy_pattern.446d371ac595.png");
  --v-tex-cloth:  url("../img/cloth_texture.abefa4e5d565.png");
  --v-tex-velvet: url("../img/velvet_texture.a60da638c0c4.png");
  --v-tex-tile:   url("../img/bg_tiling_pattern.df9b8f7832f2.png");
  --v-ornament:   url("../img/divider_decoration.1208a8dc257a.png");
  --v-frame-gold: url("../img/popup_bg_frame.f560b6edd24f.png");
  --v-frame-thin: url("../img/simple_frame.3649276b1714.png");

  /* Heist (Iron Crown) — fixed player colours, naming contract §A. Side is read from the
     piece's icon (mask vs. pikeman), never from hue, so the two palettes are free to be
     maximally distinct within themselves: thieves saturated and cool, the garrison warm and
     neutral so four guards read as one bloc without becoming indistinguishable from each
     other. No thief hue is gold — --v-gold-bright stays reserved for "carrying the Iron
     Crown", so that fact can never be confused with "this is player 5". Twelve fixed values
     rather than a generated palette: they are picked for maximum pairwise distinction at
     these exact counts (8 thieves, 4 guards), which a formula would not guarantee. */
  --v-heist-p1: #4cc9f0; /* thief 1 — sky */
  --v-heist-p2: #80ed99; /* thief 2 — mint */
  --v-heist-p3: #c77dff; /* thief 3 — violet */
  --v-heist-p4: #ff9770; /* thief 4 — coral */
  --v-heist-p5: #64dfdf; /* thief 5 — teal */
  --v-heist-p6: #ff70a6; /* thief 6 — rose */
  --v-heist-p7: #b8b8ff; /* thief 7 — periwinkle */
  --v-heist-p8: #a3e635; /* thief 8 — lime */
  --v-heist-g1: #e5383b; /* guard 1 — scarlet */
  --v-heist-g2: #f4a261; /* guard 2 — sand */
  --v-heist-g3: #adb5bd; /* guard 3 — steel */
  --v-heist-g4: #b07d4f; /* guard 4 — leather */
}

/* --- 2. Reset ------------------------------------------------------------ */

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; }

/* In-page anchor targets (`#event-42`, any future `#fragment` link) land
   below the sticky `.v-topbar` instead of flush against it, which hid the
   target's own first line under 64px of header (item 14, feedback-
   2026-08-19: "you can't see the article's title, just the line below
   it" — templates/content/partials/recent_events.html's news links to
   `timeline/_event_collapsed.html`'s `#event-<id>`, but this is generic:
   every anchor on the site sits under the same sticky bar).
   `scroll-padding-top` on the document's scrolling box is honoured by
   both the browser's own `:target` fragment navigation and script-driven
   `scrollIntoView()`, so one rule fixes every anchor site at once rather
   than hand-adding `scroll-margin-top` per target class. Derived from
   `--v-topbar-h` (never a literal 64px) plus a little breathing room so
   the target's heading clears the bar rather than touching it, and stays
   correct if that token ever changes.

   No override is needed at the 820px mobile breakpoint below: the bar's
   nav collapses behind `.v-topbar__toggle` until tapped open (`.v-nav`
   stays `display: none` at rest), and since the 2026-08-23 owner round the
   user control is closed into that drawer with it — so the resting bar is
   the brand (the 40px seal, or the 50px wordmark) beside the toggle, one
   row. The seal case sits inside the `min-height: var(--v-topbar-h)` that
   sets this token; the taller wordmark plus the mobile bar's
   `padding-block` overshoots it by ~2px, so this padding lands 2px shy
   there — see `.v-brand__logo` below. It is worth being honest that this
   only became reliably true in that round: before it, the
   user chip wrapped to a second row at rest on a typical phone and the bar
   *did* outgrow the token here. Only the *open* mobile menu grows taller
   now, and nobody is mid-navigation to a fragment while that panel is
   open. (At ~320px with a long text site title the brand can still wrap —
   see `.v-brand__logo` below — which is the one remaining case where this
   padding under-reaches.) */
html {
  scroll-padding-top: calc(var(--v-topbar-h) + var(--v-4));
}

body {
  margin: 0;
  min-height: 100vh;
  font-family: var(--v-font-body);
  font-size: var(--v-fs-base);
  line-height: 1.55;
  color: var(--v-cream);
  background-color: var(--v-bg);
  text-wrap: pretty;
  -webkit-font-smoothing: antialiased;
}

/* Damask wallpaper, darkened and warmed */
body::before {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -2;
  background-image: var(--v-tex-damask);
  background-size: 480px;
  opacity: 0.10;
  filter: sepia(1) saturate(2.2) hue-rotate(-8deg);
  pointer-events: none;
}
body::after {
  content: "";
  position: fixed;
  inset: 0;
  z-index: -1;
  background: radial-gradient(120% 90% at 50% 0%, rgba(0,0,0,0) 30%, rgba(0,0,0,0.7) 100%);
  pointer-events: none;
}

img, svg { max-width: 100%; display: block; }

h1, h2, h3, h4, h5 {
  font-family: var(--v-font-display);
  font-weight: 600;
  letter-spacing: 0.01em;
  margin: 0 0 var(--v-3);
  line-height: 1.2;
}
h1 { font-size: var(--v-fs-2xl); }
h2 { font-size: var(--v-fs-xl); }
h3 { font-size: var(--v-fs-lg); }
h4 { font-size: var(--v-fs-md); }
p  { margin: 0 0 var(--v-3); }

a { color: var(--v-gold-bright); text-decoration: none; }
a:hover { color: #f3e2b4; text-decoration: underline; }

hr {
  border: 0;
  height: 2px;
  background: var(--v-gold-dim);
  opacity: 0.6;
  margin: var(--v-5) 0;
}

/* --- 3. Layout ----------------------------------------------------------- */

.v-container {
  width: 100%;
  max-width: var(--v-container);
  margin-inline: auto;
  padding-inline: var(--v-4);
}
.v-container--narrow { max-width: 860px; }

/* The heist room (contract §H, ask 6 — "wider map"). `templates/heist/room.html` and
   `host.html` both use it: they include the same `_live.html`, so they render the same
   board and both need the same extra width for it. No other page's width changes — every
   other `.v-container` is untouched. */
.v-container--wide { max-width: 1760px; }

.v-page { padding-block: var(--v-6) var(--v-8); }

.v-stack       { display: flex; flex-direction: column; gap: var(--v-4); }
.v-stack--tight{ gap: var(--v-2); }
.v-stack--loose{ gap: var(--v-6); }
.v-row {
  display: flex;
  align-items: center;
  gap: var(--v-3);
  flex-wrap: wrap;
}
.v-row--between { justify-content: space-between; }
.v-spacer { flex: 1 1 auto; }

.v-grid { display: grid; gap: var(--v-4); }
.v-grid--2 { grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); }
.v-grid--3 { grid-template-columns: repeat(auto-fit, minmax(230px, 1fr)); }
.v-grid--4 { grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }

/* Notable Figures roster (`#roster-groups`, `_country_characters.html`,
   WO-W1): a fixed two-up grid rather than `.v-grid--2`'s auto-fit —
   auto-fit's 280px minmax would spread across the sheet's full 1360px
   `.v-container` into 4+ cramped columns instead of the two wide ones
   this pane's badge-heavy member rows need. `align-items: start` stops a
   short IG card from stretching to a taller row partner's height. Falls
   back to one column below 820px alongside this file's other tablet/phone
   overrides (§16). */
.v-grid--roster { grid-template-columns: repeat(2, minmax(0, 1fr)); align-items: start; }

/* One member row of that roster (`world/_roster_member.html`): a hairline
   between consecutive members, so a row's muted ideology/culture/religion
   detail line reads as belonging to the name above it rather than floating
   between two characters (owner report, 2026-08-23). The same
   `--v-border-ink` hairline `.v-table tbody tr` and `.v-law` already use
   for on-paper row separation — not a new token.

   `--first-visible` is the pane's client-side filter talking back
   (`world/_country_characters.html`): the `+` combinator counts DOM
   siblings, including the ones the filter has set `display: none` on, so
   without it the topmost row left in a filtered card would draw a divider
   under the card's own approval line. The script re-marks the first
   still-visible row of each bucket on every keystroke; `:first-child`
   covers the unfiltered first render, before the script has run at all. */
.roster-member + .roster-member {
  border-top: var(--v-border-ink);
  padding-top: var(--v-2);
}
.roster-member.roster-member--first-visible {
  border-top: 0;
  padding-top: 0;
}

/* Sidebar + content shell, used by World and Dashboard */
.v-shell {
  display: grid;
  grid-template-columns: 300px minmax(0, 1fr);
  gap: var(--v-4);
  align-items: start;
}
.v-shell--wide-aside { grid-template-columns: 260px minmax(0, 1fr) 300px; }

/* --- 4. Top bar ---------------------------------------------------------- */

.v-topbar {
  position: sticky;
  top: 0;
  z-index: 50;
  min-height: var(--v-topbar-h);
  display: flex;
  align-items: center;
  gap: var(--v-4);
  padding-inline: var(--v-4);
  background:
    linear-gradient(180deg, rgba(255,255,255,0.06), rgba(0,0,0,0.3)),
    var(--v-tex-velvet) center / cover,
    var(--v-maroon);
  background-blend-mode: normal, multiply, normal;
  border-bottom: 2px solid var(--v-gold-dim);
  box-shadow: var(--v-shadow);
}
.v-topbar::after {
  content: "";
  position: absolute;
  left: 0; right: 0; bottom: -2px;
  height: 2px;
  background: var(--v-gold-dim);
  opacity: 0.7;
  pointer-events: none;
}

.v-brand {
  display: flex;
  align-items: center;
  gap: var(--v-3);
  font-family: var(--v-font-display);
  font-size: var(--v-fs-md);
  color: var(--v-gold-bright);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
}
.v-brand:hover { text-decoration: none; }
.v-brand__seal {
  width: 40px; height: 40px;
  display: grid; place-items: center;
  border: 2px solid var(--v-gold);
  border-radius: 50%;
  background: rgba(0,0,0,0.35);
  font-family: var(--v-font-display);
  font-size: var(--v-fs-sm);
  color: var(--v-gold-bright);
}

/* Owner-supplied wordmark logo (SiteConfig.brand_logo), replacing the seal +
   site-title text in .v-brand above when set. Constrained by height, not
   width: the topbar is a fixed `--v-topbar-h` (64px), and the wordmark is a
   wide (~5:1) transparent PNG, so bounding the height is what keeps it
   inside the bar. At the owner's 50px it still clears the 64px bar on
   desktop, where `.v-topbar` has no vertical padding; at the 820px
   breakpoint below the bar adds `padding-block: var(--v-2)`, so 50px + 16px
   comes to 66px and the resting mobile bar runs ~2px past the token —
   visible only as a hair of extra height and 2px of slack in the
   `scroll-padding-top` that keys off it.
   Width is deliberately uncapped (owner request): the wordmark is free to
   run as wide as its aspect ratio takes it, so a very wide upload can crowd
   row 1 on a narrow viewport. What it shares that row with is just
   `.v-topbar__toggle` — at the 820px breakpoint `.v-nav` is closed by
   default (`display:none` until `.is-open`) and the user control is closed
   into the drawer with it (owner request 2026-08-23) — and the toggle sits
   to its *left* there, so an over-wide wordmark wraps below the toggle
   rather than pushing anything off-screen. `display:block` avoids the
   few px of inline-baseline gap a bare `<img>` gets from line-height.
   Rendered as-is, no backing panel (owner decision: the wordmark is
   transparent and explicitly meant to sit directly on the maroon bar). */
.v-brand__logo {
  display: block;
  height: 50px;
  width: auto;
}

.v-nav { display: flex; gap: var(--v-1); flex-wrap: wrap; }
.v-nav__link {
  display: block;
  padding: var(--v-2) var(--v-4);
  font-family: var(--v-font-display);
  font-size: var(--v-fs-sm);
  letter-spacing: 0.06em;
  color: var(--v-cream-2);
  border-radius: var(--v-radius);
  border: 1px solid transparent;
  transition: color 0.15s, background 0.15s, border-color 0.15s;
}
.v-nav__link:hover {
  color: var(--v-gold-bright);
  background: rgba(0,0,0,0.25);
  text-decoration: none;
}
.v-nav__link.is-active {
  color: var(--v-gold-bright);
  background: rgba(0,0,0,0.4);
  border-color: var(--v-gold-dim);
}

.v-topbar__user {
  display: flex; align-items: center; gap: var(--v-2);
  padding: var(--v-1) var(--v-3);
  border: var(--v-border);
  border-radius: 999px;
  background: rgba(0,0,0,0.3);
  color: var(--v-cream);
  font-size: var(--v-fs-sm);
}
.v-avatar {
  width: 28px; height: 28px; flex: none;
  display: grid; place-items: center;
  border-radius: 50%;
  border: 1px solid var(--v-gold-dim);
  background: var(--v-panel-dark-2);
  font-family: var(--v-font-display);
  font-size: var(--v-fs-xs);
  color: var(--v-gold-bright);
}

/* Mobile nav toggle — checkbox-free, JS adds .is-open */
.v-topbar__toggle {
  display: none;
  padding: var(--v-2) var(--v-3);
  border: var(--v-border);
  border-radius: var(--v-radius);
  background: rgba(0,0,0,0.3);
  color: var(--v-gold-bright);
  font: inherit;
  cursor: pointer;
}

/* --- 5. Panels ----------------------------------------------------------- */

/* Dark panel: rails, sidebars, chrome */
.v-panel {
  position: relative;
  /* Own text colour explicitly (item 20, feedback-2026-08-18: the timeline
     toolbar's "Major events only" filter label — plain text with no class
     of its own — was inheriting `.v-paper`'s dark ink from the ancestor
     `.v-paper--framed` this panel sits inside on /timeline/, over its own
     dark background: unreadable. Every other dark-surface component here
     (`.v-plaque`, `.v-rail__item`, `.v-event__card`'s siblings) sets its
     own colour rather than trusting an ambient light-surface parent not to
     be there; `.v-panel` was the one omission. */
  color: var(--v-cream);
  background:
    linear-gradient(180deg, rgba(255,255,255,0.04), rgba(0,0,0,0.22)),
    var(--v-tex-cloth) center / 380px,
    var(--v-panel-dark);
  background-blend-mode: normal, overlay, normal;
  border: var(--v-border);
  border-radius: var(--v-radius);
  box-shadow: var(--v-shadow);
}
/* `.v-plaque` (§5) deliberately repeats the three background layers and the
   blend list above, so the dashboard's Weekly Turn box reads as the same
   dark surface as every panel (2026-08-23b goal 3). Retune the texture here
   and retune it there too, or the two silently diverge — which is the state
   that round existed to end. */
.v-panel__head {
  display: flex; align-items: center; gap: var(--v-3);
  padding: var(--v-3) var(--v-4);
  /* Burgundy/wine of the main header (item 19/37/62, feedback-2026-08-18:
     "Filters"/"1836 World Map"/"XX nations" heads were rendering the
     blurry `paper_bg_dark.png` texture — owner: "replace it with the
     burgundy/wine color of the main header, that one looks good"). Same
     token `.v-topbar` uses, flat rather than its velvet-texture treatment
     — a panel head is a small strip, not a hero band. */
  background: var(--v-maroon);
  border-bottom: 1px solid rgba(194, 161, 90, 0.35);
  font-family: var(--v-font-display);
  font-size: var(--v-fs-sm);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--v-gold-bright);
}
.v-panel__body { padding: var(--v-4); }

/* Parchment panel: the main content surface */
.v-paper {
  position: relative;
  color: var(--v-ink);
  background-color: var(--v-paper);
  background-image: var(--v-tex-paper);
  background-size: 520px;
  background-blend-mode: soft-light;
  border: 1px solid var(--v-gold-dim);
  border-radius: var(--v-radius);
  box-shadow: var(--v-shadow), inset 0 0 70px rgba(140, 112, 62, 0.22);
  padding: var(--v-5);
}
.v-paper--flush { padding: 0; }
.v-paper h1, .v-paper h2, .v-paper h3, .v-paper h4 { color: var(--v-ink); }
.v-paper a { color: #7a3a1e; }
.v-paper hr { opacity: 0.8; }

/* Gilded corner ornaments — opt in with .v-paper--framed */
.v-paper--framed { padding: var(--v-6); }
.v-paper--framed::before,
.v-paper--framed::after {
  content: "";
  position: absolute;
  width: 70px; height: 12px;
  background: var(--v-ornament) center / contain no-repeat;
  opacity: 0.85;
  pointer-events: none;
}
.v-paper--framed::before { top: 14px;    left: 18px; }
.v-paper--framed::after  { bottom: 14px; right: 18px; transform: rotate(180deg); }

/* Gilded 9-slice frame for hero-weight blocks */
.v-framed-gold {
  border: 40px solid transparent;
  border-image: var(--v-frame-gold) 74 fill / 40px / 0 stretch;
}

/* Thin cast-metal frame — for cards that need to read as an inset panel */
.v-framed-thin {
  border: 14px solid transparent;
  border-image: var(--v-frame-thin) 30 fill / 14px / 0 round;
}

/* Page-title wrap — formerly a gilded arch (`top_header_frame.png`) behind
   every title ("Shattered Congress", "Timeline", "Rules", "The Archive Has
   Faltered"): removed, it read as a blurry smear at real size (items 5/16/
   85, feedback-2026-08-18). The title keeps its size, centring and
   spacing; it now sits directly on the page with no background at all. */
.v-crest {
  width: min(560px, 100%);
  margin: 0 auto var(--v-3);
  text-align: center;
}
.v-crest > * { margin: 0; font-size: clamp(1.5rem, 4.6vw, 2.25rem); line-height: 1.1; }

/* Slate plaque — dark inset on parchment. Carries `.v-panel`'s exact
   background treatment (cloth over `--v-panel-dark`, same gradient, same
   blend list) rather than one of its own: it used to tile `paper_bg.png`
   at 320px, and at the width that box renders the tile's seams surfaced as
   two vertical lines down the Weekly Turn figures (owner report,
   2026-08-23b goal 3 —
   "remove/replace the texture behind the Weekly Turn box, because it has
   two weird lines on it"). Matching `.v-panel` rather than inventing a
   third dark surface is what retired `--v-tex-slate` and `paper_bg.png`
   from the project entirely — this rule was the only user of either. The
   frame stays the plaque's own: a gold hairline inset on parchment, not
   `.v-panel`'s heavier border + shadow, since a plaque carries no head
   strip to anchor it. */
.v-plaque {
  color: var(--v-cream);
  background:
    linear-gradient(180deg, rgba(255,255,255,0.04), rgba(0,0,0,0.22)),
    var(--v-tex-cloth) center / 380px,
    var(--v-panel-dark);
  background-blend-mode: normal, overlay, normal;
  border: 1px solid var(--v-gold-dim);
  border-radius: var(--v-radius);
  padding: var(--v-4);
}
.v-plaque .v-stat__key, .v-plaque .v-muted { color: var(--v-cream-2); }
.v-plaque .v-figure__key { color: var(--v-cream-2); }
.v-plaque .v-figure__val { color: var(--v-gold-bright); }
.v-plaque .v-stat { border-bottom-color: rgba(194,161,90,0.2); }

/* --- 6. Headings & dividers ---------------------------------------------- */

.v-title {
  font-family: var(--v-font-display);
  text-align: center;
  margin-bottom: var(--v-2);
}
.v-title--hero { font-size: var(--v-fs-3xl); line-height: 1.08; }

/* Owner-supplied wordmark logo (SiteConfig.hero_logo), replacing the hero
   `<h1>`'s text when set — still centred like the text it replaces (the
   `<h1>` stays `text-align:center`; `margin-inline:auto` centres this
   block-level image within it). `max-width:100%` scales it down on mobile
   alongside `.v-paper--framed`'s own padding collapse; the px cap keeps a
   huge upload from rendering enormous in the hero box's centred layout on
   desktop (`padding:56px 32px`, no width limit of its own beyond the page
   container). Rendered as-is, no backing panel (owner decision: the
   wordmark is transparent and meant to sit directly on the parchment). */
.v-hero__logo {
  display: block;
  max-width: min(100%, 560px);
  height: auto;
  margin-inline: auto;
}

.v-subtitle {
  text-align: center;
  font-size: var(--v-fs-md);
  color: var(--v-ink-2);
  font-style: italic;
}

/* Centred rule under a title — formerly the blurry `divider_fancy.png`
   bitmap (item 16/85, feedback-2026-08-18). A plain CSS-drawn line in the
   existing gold ink, same margins; `.v-rule` reads as a short flourish
   (narrow, centred) the way the bitmap's transparent edges did, `--wide`
   spans the full width the way `divider_gold.png` did at
   `background-size: 100% 100%`. */
.v-rule {
  height: 2px;
  width: min(220px, 60%);
  background: var(--v-gold-dim);
  opacity: 0.6;
  margin: var(--v-4) auto;
}
.v-rule--wide { width: 100%; }

/* Label with rules either side */
.v-legend {
  display: flex; align-items: center; gap: var(--v-3);
  font-family: var(--v-font-display);
  font-size: var(--v-fs-lg);
  color: inherit;
  margin: 0 0 var(--v-4);
}
.v-legend::before, .v-legend::after {
  content: ""; flex: 1 1 0;
  height: 2px;
  background: var(--v-gold-dim);
  opacity: 0.65;
}

/* Auto-generated "On this page" index above a Page body's headings
   (apps.content.templatetags.content_tags.page_body) — a short jump list,
   not a full nav, so it stays understated: no card chrome of its own,
   just a gold rule to separate it from the prose below. */
.v-toc {
  margin: 0 0 var(--v-4);
  padding-bottom: var(--v-3);
  border-bottom: 1px solid var(--v-gold-dim);
}
.v-toc__title {
  display: block;
  font-family: var(--v-font-display);
  font-size: var(--v-fs-sm);
  letter-spacing: 0.06em;
  color: var(--v-ink-2);
  margin-bottom: var(--v-2);
}
.v-toc__list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: flex;
  flex-direction: column;
  gap: var(--v-1);
}
.v-toc__item a {
  /* Link color inherited from `.v-paper a` — every place `.v-toc` renders
     is inside a `.v-paper`. */
  font-size: var(--v-fs-sm);
}
.v-toc__item--h3 { padding-left: var(--v-3); }
.v-toc__item--h4 { padding-left: calc(var(--v-3) * 2); }

.v-eyebrow {
  font-family: var(--v-font-display);
  font-size: var(--v-fs-xs);
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--v-ink-3);
}
.v-panel .v-eyebrow, .v-topbar .v-eyebrow { color: var(--v-cream-2); }

.v-quote {
  font-style: italic;
  text-align: center;
  font-size: var(--v-fs-md);
  color: var(--v-ink-2);
  max-width: 46ch;
  margin-inline: auto;
}

/* --- 7. Buttons & forms -------------------------------------------------- */

.v-btn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: var(--v-2);
  min-height: 44px;
  padding: var(--v-2) var(--v-5);
  font-family: var(--v-font-display);
  font-size: var(--v-fs-sm);
  letter-spacing: 0.05em;
  color: var(--v-cream);
  background:
    linear-gradient(180deg, rgba(255,255,255,0.14), rgba(0,0,0,0.28)),
    var(--v-maroon-2);
  border: 1px solid var(--v-gold-dim);
  border-radius: var(--v-radius);
  cursor: pointer;
  transition: filter 0.15s, border-color 0.15s;
}
.v-btn:hover { filter: brightness(1.18); border-color: var(--v-gold); text-decoration: none; }
.v-btn:active { filter: brightness(0.92); }
.v-btn:focus-visible { outline: 2px solid var(--v-gold-bright); outline-offset: 2px; }
.v-btn[disabled], .v-btn.is-disabled { opacity: 0.45; pointer-events: none; }

.v-btn--primary { background: linear-gradient(180deg, rgba(255,255,255,0.16), rgba(0,0,0,0.3)), var(--v-red); }
.v-btn--ghost   { background: rgba(0,0,0,0.25); color: var(--v-gold-bright); }
.v-btn--paper   { background: linear-gradient(180deg, #f0e7d0, var(--v-paper-3)); color: var(--v-ink); }
.v-btn--sm      { min-height: 34px; padding: var(--v-1) var(--v-3); font-size: var(--v-fs-xs); }
.v-btn--block   { width: 100%; }

/* Host-mode banner (dashboard shell, PAGES.md §6): same `.v-stale-banner`
   component (`site.css`) with the maroon-2 background swapped for red, so
   the "you are editing with Host/GA authority" notice reads distinctly
   from an ordinary stale-data banner rather than forking the component with
   an inline style (R17/N1, feedback-2026-08-18). */
.v-stale-banner--host { background: var(--v-red); }

/* A `<a class="v-btn">` keeps its own text color no matter what surrounding
   link-color rule it sits inside (`.v-paper a`, `.v-panel a`, …) — a button
   is a component, not a bare link, and must never inherit ambient link
   styling. Without this, `.v-paper a`'s `color: #7a3a1e` (equal specificity,
   declared earlier in this file) wins over plain `.v-btn`'s `color:
   var(--v-cream)` for exactly the buttons that sit on parchment — the
   framed hero's "Join the campaign"/"Read the rules" CTAs rendered as
   near-invisible dark-on-dark text (owner report, feedback-2026-08-18
   WO-F3.2, confirmed by screenshot: apps.content is not the CSS's owner but
   this file is the one place the fix belongs, per that work order). Same
   compound-selector trick, one rule per variant so each keeps its intended
   color. */
a.v-btn              { color: var(--v-cream); }
a.v-btn.v-btn--ghost { color: var(--v-gold-bright); }
a.v-btn.v-btn--paper { color: var(--v-ink); }

/* Ghost button on parchment (items 12/56, feedback-2026-08-18: "See the
   full timeline" on the home page, "See full history" on the country
   sheet — both `.v-btn--ghost` sitting on `.v-paper`). `.v-btn--ghost`'s
   `rgba(0,0,0,0.25)` wash and forced gold-bright text (above) are tuned
   for the dark chrome the mock built them for; over light parchment the
   wash barely darkens the paper at all, so pale gold on near-parchment
   measured 1.24:1 — nowhere near the 4.5:1 AA floor. Reuse the
   already-AA-passing `.v-btn--paper` recipe instead of inventing a third
   button treatment: the same gradient/ink pairing used on the country
   sheet's own "Edit" buttons. `.v-paper a.v-btn.v-btn--ghost` (three
   classes) is required, not just `.v-paper .v-btn--ghost`, to out-rank
   `a.v-btn.v-btn--ghost` two classes above. */
.v-paper .v-btn--ghost {
  background: linear-gradient(180deg, #f0e7d0, var(--v-paper-3));
  border-color: rgba(43, 36, 28, 0.35);
}
.v-paper a.v-btn.v-btn--ghost,
.v-paper button.v-btn.v-btn--ghost { color: var(--v-ink); }

/* …but a `.v-panel` nested inside a `.v-paper` is still dark chrome, so the
   parchment treatment above must not leak into it — the timeline's Clear
   filters control lives there. Ordered after the rule it overrides. */
.v-panel .v-btn--ghost,
.v-paper .v-panel a.v-btn.v-btn--ghost,
.v-paper .v-panel button.v-btn.v-btn--ghost {
  background: rgba(0,0,0,0.25);
  border-color: var(--v-gold-dim);
  color: var(--v-gold-bright);
}

.v-field { display: flex; flex-direction: column; gap: var(--v-1); }
.v-label {
  font-family: var(--v-font-display);
  font-size: var(--v-fs-xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--v-ink-3);
}
.v-panel .v-label { color: var(--v-cream-2); }

.v-input, .v-select, .v-textarea {
  width: 100%;
  min-height: 44px;
  padding: var(--v-2) var(--v-3);
  font-family: var(--v-font-body);
  font-size: var(--v-fs-base);
  color: var(--v-ink);
  background: rgba(255, 252, 240, 0.55);
  border: 1px solid rgba(43, 36, 28, 0.35);
  border-radius: var(--v-radius);
}
.v-textarea { min-height: 140px; resize: vertical; line-height: 1.6; }
.v-input:focus, .v-select:focus, .v-textarea:focus {
  outline: none;
  border-color: var(--v-gold-dim);
  box-shadow: 0 0 0 2px rgba(194, 161, 90, 0.35);
}
.v-panel .v-input, .v-panel .v-select, .v-panel .v-textarea {
  color: var(--v-cream);
  background: rgba(0, 0, 0, 0.35);
  border-color: var(--v-gold-dim);
}
.v-panel .v-input::placeholder { color: var(--v-ink-3); }

.v-select {
  appearance: none;
  padding-right: var(--v-6);
  background-image: linear-gradient(45deg, transparent 50%, currentColor 50%),
                    linear-gradient(135deg, currentColor 50%, transparent 50%);
  background-position: calc(100% - 18px) 50%, calc(100% - 12px) 50%;
  background-size: 6px 6px, 6px 6px;
  background-repeat: no-repeat;
}
/* `<option>` popups are UA-rendered chrome that most browsers do not tint
   from the closed `<select>`'s own `background` (translucent values in
   particular are routinely flattened to an opaque system default, usually
   white) even though they do inherit `color` — the mismatch item 21
   reported as "region names in the filter box, light yellow on white": the
   timeline region multi-select's `.v-panel .v-select` colour (cream) stayed,
   but its background didn't carry into the open list. Setting `color`
   *and* an opaque `background-color` directly on `option` is the one
   reliably cross-browser way to theme the popup itself, so both contexts
   (paper-toned default, panel-toned when nested in `.v-panel`) stay legible
   regardless of what the closed control shows. */
.v-select option {
  color: var(--v-ink);
  background-color: var(--v-paper);
}
.v-panel .v-select option {
  color: var(--v-cream);
  background-color: var(--v-panel-dark);
}

.v-search { position: relative; flex: 1 1 200px; }

/* --- 8. Tabs, rails, lists ------------------------------------------------ */

.v-tabs {
  display: flex;
  gap: var(--v-1);
  flex-wrap: wrap;
  border-bottom: 1px solid rgba(43, 36, 28, 0.3);
  margin-bottom: var(--v-4);
}
.v-tab {
  padding: var(--v-2) var(--v-4);
  font-family: var(--v-font-display);
  font-size: var(--v-fs-sm);
  /* Cream by default because most tab rows (reference subnav, leaderboard
     categories) sit bare on the dark page; `.v-paper .v-tab` below restores
     ink for the one row that sits on parchment, the country sheet's. The
     old single ink-3 default measured 2.78:1 on chrome (QA 2026-08-19,
     item 86). */
  color: var(--v-cream-2);
  background: transparent;
  border: 1px solid transparent;
  border-bottom: none;
  border-radius: var(--v-radius) var(--v-radius) 0 0;
  cursor: pointer;
  white-space: nowrap;
}
.v-paper .v-tab { color: var(--v-ink-3); }

/* Hover follows the same ground rule as the resting colour above — ink
   would be invisible on the dark page rows. (`.v-tab.is-active` needs no
   pairing: it paints its own near-white background under the ink.) */
.v-tab:hover { color: var(--v-cream); text-decoration: none; }
.v-paper .v-tab:hover { color: var(--v-ink); }
.v-tab.is-active {
  color: var(--v-ink);
  background: rgba(255, 250, 236, 0.6);
  border-color: rgba(43, 36, 28, 0.3);
  box-shadow: inset 0 2px 0 var(--v-gold);
}

/* Scrollable rail of selectable rows (country list, nation menu) */
.v-rail { max-height: 620px; overflow-y: auto; }
.v-rail__item {
  display: flex;
  align-items: center;
  gap: var(--v-3);
  width: 100%;
  padding: var(--v-2) var(--v-3);
  min-height: 44px;
  color: var(--v-cream);
  background: rgba(0,0,0,0.15);
  border: 1px solid transparent;
  border-left: 3px solid transparent;
  border-radius: var(--v-radius);
  font-family: var(--v-font-body);
  font-size: var(--v-fs-base);
  text-align: left;
  cursor: pointer;
}
.v-rail__item.is-disabled { opacity: 0.45; pointer-events: none; }
.v-rail__item:hover { background: rgba(194,161,90,0.12); text-decoration: none; }
.v-rail__item.is-active {
  background: rgba(194,161,90,0.2);
  border-color: var(--v-gold-dim);
  border-left-color: var(--v-gold);
  color: var(--v-gold-bright);
}
.v-rail__item .v-spacer + span { color: var(--v-cream-2); font-size: var(--v-fs-sm); }

/* Flag chip — the `<img class="v-flag">` that `{% country_flag %}`
   (apps/common/templatetags/flags.py) renders at every flag placement,
   including the archive rows/profiles added by owner decision 2026-08-22
   (PAGES.md §13.1/§13.2/§13.3). It began as an empty `<span class="v-flag">`
   placeholder box and the span-friendly declarations are kept so the
   class still degrades to a plain block if one is ever needed again;
   `object-fit` only affects a replaced element, so it is inert on a span.
   No template renders the span form today. */
.v-flag {
  display: inline-block;
  width: 30px; height: 20px; flex: none;
  border: 1px solid rgba(0,0,0,0.5);
  border-radius: 2px;
  background: var(--v-panel-dark-2);
  box-shadow: 0 1px 2px rgba(0,0,0,0.5);
  object-fit: cover;
  vertical-align: -4px;
}
/* The country sheet's header flag is a page identifier, not a row chip, so
   it takes the space the old placeholder box held (QA 2026-08-18,
   templates/world/country_sheet.html). Sizing only — border, shadow and
   `object-fit` stay the shared `.v-flag` treatment. */
.v-country-header > .v-flag { width: 66px; height: 44px; }

/* --- 9. Data display ------------------------------------------------------ */

.v-stat {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--v-3);
  padding: var(--v-2) 0;
  border-bottom: var(--v-border-ink);
}
.v-stat:last-child { border-bottom: none; }
.v-stat__key { font-size: var(--v-fs-sm); color: var(--v-ink-3); }
.v-panel .v-stat__key { color: var(--v-cream-2); }
.v-panel .v-stat { border-bottom-color: rgba(194,161,90,0.18); }
.v-stat__val { font-family: var(--v-font-display); font-size: var(--v-fs-base); }
/* Signed stat colouring (feedback-2026-08-18 WO-F5.2, owner: the country
   sheet's Cash figure is green when positive, red when negative; weekly
   income/expenses carry these same classes but fixed rather than sign-
   driven — see templates/world/_country_overview.html). */
.v-stat__val--positive { color: var(--v-green); }
.v-stat__val--negative { color: var(--v-red); }

/* Big figure block (Start Date / Current Year / …) */
.v-figure { text-align: center; padding: var(--v-3) var(--v-2); }
.v-figure__key {
  display: block;
  font-size: var(--v-fs-xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--v-ink-3);
}
.v-figure__val {
  display: block;
  font-family: var(--v-font-display);
  font-size: var(--v-fs-xl);
  line-height: 1.1;
}

.v-table { width: 100%; border-collapse: collapse; font-size: var(--v-fs-sm); }
.v-table th, .v-table td { padding: var(--v-2) var(--v-3); text-align: left; }
.v-table th {
  font-family: var(--v-font-display);
  font-size: var(--v-fs-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--v-ink-3);
  border-bottom: 1px solid rgba(43,36,28,0.35);
}
.v-table tbody tr { border-bottom: var(--v-border-ink); }
.v-table tbody tr:hover { background: rgba(194,161,90,0.12); }
/* `overflow-x: auto` is the deliberate choice for every wide `.v-table`
   (roster, leaderboards, council actions, modifiers, …) rather than a
   stacked-card layout — these are dense numeric grids where a card
   layout would just re-lay the same header/value pairs vertically per
   row, at several times the scroll length, for no readability gain.
   Left undecorated, though, the scrollbar is invisible until a pointer
   hovers it or a finger is already on it (found on the council "Actions
   this week" and rep-leaderboard tables at 375px: extra columns were
   simply gone, no visual hint more existed — confirmed structurally
   scrollable via scrollWidth > clientWidth, just undiscoverable). A thin
   *non-overlay* scrollbar is a permanent "there's more" cue that costs
   nothing when the table already fits (no overflow, no scrollbar drawn) —
   it covers pointer browsers (Firefox everywhere via `scrollbar-width`,
   Chrome/Edge on platforms that don't force overlay scrollbars). Touch
   platforms (iOS/Android, and this project's own headless-Chromium
   screenshot rig) render overlay scrollbars that CSS cannot force
   visible outside a live touch/scroll gesture, so the tables whose
   column count is fixed and reliably wider than a phone viewport
   (modifiers, council actions, rep leaderboards — never roster or the
   3-column leaderboards, which fit) additionally carry a static
   `.v-table__hint` caption, shown only below the width where these
   specific tables actually overflow (measured, see the caption's own
   media query below — not the tablet breakpoint: at 768px these six
   columns already fit their column, so showing "swipe" there would be
   pointing at nothing). */
.v-table__wrap {
  overflow-x: auto;
  scrollbar-width: thin;
  scrollbar-color: var(--v-gold-dim) transparent;
}
.v-table__wrap::-webkit-scrollbar { height: 6px; }
.v-table__wrap::-webkit-scrollbar-track { background: transparent; }
.v-table__wrap::-webkit-scrollbar-thumb {
  background: var(--v-gold-dim);
  border-radius: 3px;
}
.v-table__hint {
  display: none;
  margin: var(--v-1) 0 0;
  font-size: var(--v-fs-xs);
  color: var(--v-ink-3);
  text-align: right;
}
.v-panel .v-table__hint { color: var(--v-cream-2); }
/* 620px, not the shared 520/820 breakpoints: measured directly against
   the widest of these tables (modifiers, 6 columns) inside the country
   sheet's single-column layout — it still overflows at 600px content
   width and stops by ~700px, so this is the caption's own evidence-based
   cutoff rather than a page-layout one. */
@media (max-width: 620px) {
  .v-table__hint { display: block; }
}

.v-tag {
  display: inline-flex;
  align-items: center;
  gap: var(--v-1);
  padding: 2px var(--v-2);
  font-size: var(--v-fs-xs);
  letter-spacing: 0.04em;
  border: 1px solid currentColor;
  border-radius: 2px;
  background: rgba(0,0,0,0.06);
  white-space: nowrap;
}
.v-tag--gold   { color: var(--v-gold-dim); }
.v-tag--red    { color: var(--v-red); }
.v-tag--green  { color: var(--v-green); }
.v-tag--blue   { color: var(--v-blue); }
.v-tag--yellow { color: var(--v-yellow); }

/* Smaller variant for an event's *secondary* categories (feedback-2026-08-18
   item 22): the primary category drives the card's accent colour and the
   timeline dot, so the extra ones must read as subordinate to it rather
   than competing at the same weight. */
.v-tag--sm {
  padding: 1px calc(var(--v-2) - 2px);
  font-size: calc(var(--v-fs-xs) * 0.9);
  opacity: 0.85;
}

/* `.v-tag--muted` itself lives in site.css (a flat `--v-ink-3` for every
   surface it renders on); these two-class overrides win on specificity
   over its one-class rule regardless of load order. Item 39 (feedback-
   2026-08-18): the country sheet's "Unrecognized" chip must read as the
   same colour as the "Rank" figure beside it, which is unstyled — plain
   inherited `.v-paper` ink, not ink-3 — so match that ink exactly rather
   than keep the chip a visibly lighter/different brown. The directory's
   rail rows carry the same chip class for the plain country-tag chip
   ("FRA") too; ink-3 measured 2.78:1 against their dark background
   (fails AA), so this also folds in that fix rather than leaving one
   `.v-tag--muted` chip legible beside another that isn't. (The roster's
   own tag chip needs no rule here — that table sits on `.v-paper`, where
   ink-3 already passes.) */
.v-country-header .v-tag--muted { color: var(--v-ink); }
.v-rail__item .v-tag--muted { color: var(--v-cream); }

/* Same failure on a third dark surface, and the worst of them: the
   all-councils pane (PAGES.md §6.6) puts the country's tag chip — plus its
   "Unconfirmed"/"Closed" chips — inside `.v-panel__head`, whose background
   is a flat `--v-maroon` (#4a1f24). `--v-ink-3` (#6d6151) measures
   **2.30:1** there, below even the rail rows' 2.78:1 and nowhere near AA;
   the owner's report was simply that the tag was unreadable against the
   background. `--v-cream` takes it to 10.79:1, and `.v-tag`'s
   `border: 1px solid currentColor` means the chip's outline lifts with the
   text rather than staying a near-invisible box.

   Scoped to the surface (`.v-panel__head`), not to that one pane, matching
   how both overrides above are scoped: any muted chip dropped into a panel
   head later inherits the fix instead of re-reporting the same bug. Cream
   rather than the head's own `--v-gold-bright` (which would also pass, at
   8.66:1) so the chip still reads as subordinate to the gold country name
   beside it instead of competing with it at the same colour. */
.v-panel__head .v-tag--muted { color: var(--v-cream); }

.v-meter {
  height: 8px;
  background: rgba(43,36,28,0.25);
  border: 1px solid rgba(43,36,28,0.35);
  border-radius: 999px;
  overflow: hidden;
}
.v-meter__fill { height: 100%; background: linear-gradient(90deg, var(--v-gold-dim), var(--v-gold)); }
/* Goal progress colour thresholds (owner request, W3 item 2) — a goal's
   meter only, never the actions-budget meter, which stays plain gold at
   every level. The modifier class is computed server-side (`apps.council.
   pane_goals._goal_view`, `apps.world.services`'s council-pane goal
   context) from the same normalized `progress` value the percentage text
   already displays, never re-derived here or in a template expression. */
.v-meter__fill--low { background: linear-gradient(90deg, var(--v-maroon-2), var(--v-red-bright)); }
.v-meter__fill--high { background: linear-gradient(90deg, var(--v-green), #6f9450); }

/* --- 10. Timeline --------------------------------------------------------- */

.v-timeline { position: relative; padding-left: 92px; }
/* Item 31 (feedback-2026-08-18): "the timeline line should stop in the first
   event instead of continuously going up, because it's cutting into text
   above."

   The first attempt kept one container-length spine and merely nudged it to
   `top: 20px`, on the assumption that `.v-timeline` wraps only event cards.
   It does not: `services.build_stream_items` interleaves era headers, so
   whenever the stream opens on one the spine still began ~68px above the
   first dot and ran straight through the era band (QA 2026-08-19).

   A container-level pseudo-element cannot know where its first `.v-event`
   starts, so the spine is drawn per card instead: each event owns the
   segment beside it, extended by its own bottom margin to meet the next
   one. The first card starts its segment at the dot, so the line can never
   precede the first event; era headers simply have no segment, which also
   stops it cutting through them. */
.v-event::before {
  content: "";
  position: absolute;
  top: 0;
  bottom: calc(-1 * var(--v-4));
  /* Centered on the dot, not eyeballed: `.v-event__dot` below is
     `left: -22px` at `width: 14px` (center -15px from `.v-event`'s own
     edge — `.v-event__dot--major`'s `left: -25px`/`width: 20px` lands on
     the same -15px center, so one spine position serves both sizes). This
     2px-wide line centers on that same -15px, i.e. `left: -16px`. */
  left: -16px;
  width: 2px;
  background: var(--v-gold-dim);
}
.v-timeline > .v-event:first-of-type::before { top: 20px; }
.v-timeline > .v-event:last-of-type::before {
  bottom: 0;
  background: linear-gradient(180deg, var(--v-gold-dim), transparent);
}
.v-event { position: relative; display: flex; gap: var(--v-4); margin-bottom: var(--v-4); }
.v-event__year {
  position: absolute;
  left: -92px;
  width: 60px;
  text-align: right;
  font-family: var(--v-font-display);
  font-size: var(--v-fs-md);
  color: var(--v-ink-2);
  padding-top: var(--v-3);
}
.v-event__dot {
  position: absolute;
  left: -22px;
  top: 20px;
  width: 14px; height: 14px;
  border-radius: 50%;
  background: var(--v-red);
  border: 2px solid var(--v-gold);
  box-shadow: 0 0 0 3px rgba(230, 220, 194, 0.9);
}
.v-event__card {
  flex: 1 1 auto;
  display: flex;
  gap: var(--v-4);
  align-items: stretch;
  padding: var(--v-3) var(--v-4);
  background: rgba(255, 250, 236, 0.5);
  border: var(--v-border-ink);
  border-radius: var(--v-radius);
}
.v-event__body { flex: 1 1 auto; min-width: 0; }
.v-event__title { font-family: var(--v-font-display); font-size: var(--v-fs-md); margin: 0 0 var(--v-1); }
.v-event__text { font-size: var(--v-fs-sm); color: var(--v-ink-2); margin: 0; }
.v-event__thumb { width: 150px; flex: none; align-self: stretch; min-height: 90px; }
.v-event__publication { font-family: var(--v-font-display); font-style: italic; font-size: var(--v-fs-sm); margin: 0 0 var(--v-1); }
.v-event__byline { font-size: var(--v-fs-sm); color: var(--v-ink-3); margin: 0 0 var(--v-1); }

/* Home Recent Events card (session plan 2026-08-23-admin-permanent-event-
   delete, goal 6; restructured by session plan
   2026-08-24b-home-density-timeline-submission-sort.md goal 2 to the
   timeline's own stretched-link shape — the owner's "the individual event
   boxes are too tall ... they look better/more compact on the timeline"
   ask). The card root is `.v-home-event__card`, layered onto
   `.v-card v-card--compact` (not `.v-event__card` — giving it that class
   directly would double the border/background `.v-event__card` and
   `.v-card` each paint on their own) purely to give it `position:
   relative`, the containing block the title's stretched overlay needs
   below. `.v-home-event` is the minimal flex-row wrapper inside it: the
   same `gap`/`align-items` as `.v-event__card`, and (below, in this
   file's 820px breakpoint) the same column-stack, but none of
   `.v-event__card`'s own padding/background/border. It is a plain `<div>`
   now, not the `<a>` it used to be — goal 2 replaced the old
   wrap-everything-in-one-link shape with the timeline's stretched-title-
   link pattern (`.v-home-event__title a::after`, below), which is what
   frees the top row to hold the 🔥 reaction control inline instead of on
   a dedicated bottom row. It deliberately reuses `.v-event__thumb` on its
   `<img>` (width/object-fit/radius, defined once above and in site.css)
   rather than restating those numbers, so the timeline and home
   thumbnails cannot drift apart — keep this rule and its 820px
   counterpart in step with `.v-event__card`'s own if that ever changes.

   Compact sizing (owner ask 2026-08-24, session plan 2026-08-24-recent-
   events-rework.md goal 4: "lower the size of each event card a bit",
   paired with raising the panel's slice to 5 cards): the home panel's
   thumbnail shrinks from the timeline's 150px standard to 110px, scoped
   with the `.v-home-event ` descendant selector below so `.v-event__thumb`
   itself — and every `/timeline/` card that uses it bare — keeps the
   150px/90px numbers untouched. This is deliberately NOT a change to
   `.v-event__thumb`'s own rule above; it is a home-panel override sitting
   next to it. Its own height is deliberately uncapped (offered and
   declined, session plan 2026-08-24b goal 2) — a tall portrait image
   still sets its card's height via `align-self: stretch`. The title and
   card padding tighten alongside the thumbnail (`.v-home-event__title`
   margin and `.v-card--compact`, applied by the template) to keep the
   smaller thumbnail from looking lost next to too much whitespace. */
.v-home-event { display: flex; gap: var(--v-4); align-items: stretch; }
.v-home-event__body { flex: 1 1 auto; min-width: 0; }
.v-home-event .v-event__thumb { width: 110px; min-height: 64px; }
.v-home-event__title { margin: 0 0 2px; font-size: 1.02rem; }

/* The stretched title link (session plan
   2026-08-24b-home-density-timeline-submission-sort.md goal 2): mirrors
   `.v-event__card`/`.v-event__title a::after` in site.css (the timeline
   collapsed card's own pattern) exactly, scoped to the home classes.
   **This is a second copy of that pattern, not a shared one** — the two
   cards lift *different* element sets above the overlay (the timeline
   card lifts its `.v-btn` controls and the title; this one lifts the
   reaction control in either of its two forms and the byline link, and
   deliberately does NOT lift the title — see the rule's own comment
   below), and the geometry rules they do share are three declarations, so
   the pattern was duplicated rather than abstracted. The honest cost is
   that a future change to the overlay geometry has to be made twice;
   review 2026-08-24b flagged extracting a shared `v-` stretched-link
   component as the eventual fix. Whichever copy is edited, edit both.
   `.v-home-event__card` is the positioned root (see its own comment
   above); the title's `<a>` gets a full-bleed `::after { inset: 0 }`
   overlay so the whole card is one click target, while the reaction
   button/count (`.v-btn` when the viewer can react, `.v-tag--muted` when
   they only see the bare count — `templates/timeline/_reaction_button.
   html`) and the byline's own submitter link (`.v-home-event__body
   .v-event__byline a`, when the byline renders one — see the template's
   own "Author byline" comment for why this lift is needed) are raised
   above the overlay on `z-index: 1` so they stay individually clickable
   instead of being swallowed by the stretched hit area. */
.v-home-event__card { position: relative; }
.v-home-event__title a::after {
  content: "";
  position: absolute;
  inset: 0;
}
/* **The title link itself is deliberately NOT in the lift below** (QA
   2026-08-24b, measured in Chromium with `document.elementFromPoint` over
   the card body). `inset: 0` resolves against the pseudo-element's
   *containing block* — the nearest ancestor whose `position` is not
   `static`. Give the `<a>` `position: relative` and that ancestor becomes
   the anchor itself, so the "stretched" overlay silently collapses to the
   width of the title text and the rest of the card stops being clickable
   at all — a regression against the wrapping `<a class="v-home-event">`
   this shape replaced, and against PAGES.md §1's "the whole card is one
   click target". The anchor needs no lift of its own regardless: the
   overlay *is* its own pseudo-element, so a click on the overlay activates
   that same link. site.css's `.v-event__title a` carried the original
   form of this bug for `/timeline/`'s cards; it was fixed in the same
   round, once this copy exposed it — see that file.

   **`.v-tag--muted` is deliberately NOT lifted either.** That is the
   reaction control's *non-interactive* form — the bare "🔥 N" count an
   anonymous or non-member viewer sees instead of a button
   (`timeline/_reaction_button.html`). Lifting it above the overlay bought
   nothing (a `<span>` gains no behaviour from being on top) and cost a
   ~40x20px dead zone in the middle of a card that is supposed to be one
   click target: a reader clicking the count got nothing at all rather
   than the event (QA 2026-08-24b). Covered, it opens the event like the
   rest of the card — which is also exactly what the timeline's own card
   does with its copy of this chip. The trade is the `title="Reactions"`
   tooltip, which no longer fires through the overlay; the timeline
   already made that trade, and goal 2's whole point is that the two
   cards behave alike. */
.v-home-event__card .v-btn,
.v-home-event__body .v-event__byline a {
  position: relative;
  z-index: 1;
}
.v-home-event__title a::after {
  z-index: 0;
}

/* Summary clamp (goal 2): a home card's summary paragraph is capped to 3
   lines so one long summary can't stretch its card past its neighbours —
   home panel only, `-webkit-line-clamp` is the only cross-browser way to
   do this (prefixed but universally supported Chrome/Safari/Firefox/
   Edge; `line-clamp` is the unprefixed standard property for forward
   compatibility, added alongside it). A browser lacking both simply shows
   the full summary — a graceful degradation to pre-goal-2 behaviour, not
   a broken layout. */
.v-home-event__summary {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 3;
  line-clamp: 3;
  overflow: hidden;
}

/* Category-driven card accent (item 21, feedback-2026-08-19, colour half:
   "the flavor for 'primary category' says it should set the card color
   but all events... have the same color" — `TimelineEvent.category.color`
   was wired only to the `.v-tag` chip below, never to the card itself).
   State-class contract, mirroring `.v-tag--<color>`'s shape and reusing
   its exact palette tokens rather than inventing a second colour
   vocabulary: `.v-event__card--cat-gold|red|green|blue|yellow` — one class
   per `TimelineCategory.Color` value, so every colour a category can carry
   has a rule here. Applied by `timeline/_event_collapsed.html` and
   `_event_expanded.html` on the card element, off `event.category.color`,
   alongside the existing `--major` toggle below (keep those two in step:
   a state class no template applies is how phase-4.5's item 40 shipped
   inert, and how this comment itself was briefly false).

   Painted as an *inset* left-edge stripe (`box-shadow`), not a
   `border-left` on the `border` shorthand: `.v-event__card--major` below
   already claims the full `border`/`border-image` for its cast-metal
   frame, and `border-image` paints over `border-color` on every side it
   covers — a border-based stripe would simply vanish the moment both
   classes land on one card. `box-shadow` is a separate paint layer inside
   the padding box, so it composes with `--major`'s frame instead of
   fighting it: both render together on a major + categorized card.

   Contrast checked as a decorative UI accent (WCAG non-text, >=3:1) against
   the card's own actual rendered background — not the page's `--v-paper`
   underneath — since `.v-event__card` sits on its own semi-transparent
   wash (~#f6efdc effective; ~#e4d5b3 on `--major`'s gold-tinted variant,
   see below): red 7.35:1 / 5.81:1, blue 6.32:1 / 4.99:1, green 5.23:1 /
   4.14:1, yellow 4.71:1 / 3.72:1, gold 4.11:1 / 3.25:1 (plain card / major
   card) — every combination clears 3:1; gold reads lowest because it's the
   same dim tone `--major`'s own background tints toward, same as
   `.v-tag--gold` already is on this surface. `--v-yellow` (#7a6a00) is
   deliberately a separate token from `--v-gold`/`--v-gold-dim`, not a
   brighter/dimmer step of the same hue: Economy was recoloured off gold
   (owner report, 2026-08-24 — gold is also the fixed "major" dot colour
   below, so an Economy event's own dot read as a false "major" signal) to
   a hue with no blue component (gold's tan/bronze `#8a7038` vs. this pure
   olive-yellow `#7a6a00`), so the two stay visually distinct rather than
   just differently shaded. */
.v-event__card--cat-gold   { box-shadow: inset 5px 0 0 var(--v-gold-dim); }
.v-event__card--cat-red    { box-shadow: inset 5px 0 0 var(--v-red); }
.v-event__card--cat-green  { box-shadow: inset 5px 0 0 var(--v-green); }
.v-event__card--cat-blue   { box-shadow: inset 5px 0 0 var(--v-blue); }
.v-event__card--cat-yellow { box-shadow: inset 5px 0 0 var(--v-yellow); }

/* Category-driven timeline dot (session plan
   2026-08-23-timeline-dot-category-colour, goals 1-2). Owner ask, verbatim:
   "change the color of the timeline dot to match the color of the event
   card instead of it always being red." Same state-class contract as
   `.v-event__card--cat-*` immediately above, deliberately reusing its
   exact tokens (and `.v-tag--<color>`'s) rather than inventing a second
   colour vocabulary for the same categories — gold maps to `--v-gold-dim`,
   the dim tone, matching the card stripe and the tag text, not the
   brighter `--v-gold` the major dot below uses. Applied by the
   shared `timeline/_event_dot.html` partial, off `event.category.color`.

   Major still wins (goal 2): `_event_dot.html` applies `--major` and the
   category class as mutually exclusive alternatives (an
   `{% templatetag openblock %}elif{% templatetag closeblock %}`, not two
   independent `{% templatetag openblock %}if{% templatetag closeblock %}`s)
   so a major event never carries both classes at once — that exclusion is
   decided template-side and is the actual mechanism. These rules sit
   before `.v-event__dot--major` below purely as a source-order backstop:
   if both classes ever did land on one element despite the template,
   `--major`'s later declarations would still win the cascade. */
.v-event__dot--cat-gold   { background: var(--v-gold-dim); }
.v-event__dot--cat-red    { background: var(--v-red); }
.v-event__dot--cat-green  { background: var(--v-green); }
.v-event__dot--cat-blue   { background: var(--v-blue); }
.v-event__dot--cat-yellow { background: var(--v-yellow); }

/* Major events (WO-F6.7, owner decision 2026-08-18): a framed card (the
   same --v-frame-thin cast-metal border as .v-framed-thin, at a size that
   fits a timeline card) and a larger dot. Separate modifier classes rather
   than reusing .v-framed-thin directly — combining classes with
   conflicting `border` shorthands is a cascade-order footgun, and this way
   the frame width is tuned for .v-event__card's own padding.

   Item 24 (feedback-2026-08-18): the frame + "Major" tag alone didn't read
   at a glance while scanning — both are thin/small details easy to miss
   mid-scroll. Added here: a distinct gold-tinted card background (the same
   family `.v-choice.is-selected`/`.v-rail__item.is-active` already use for
   "this one is different", not a new colour) so the card itself pops
   before a reader even reaches the frame or tag, plus a heavier dot — gold
   fill instead of the ordinary red, a thicker ring — for the same
   at-a-glance signal on the spine itself. */
.v-event__card--major {
  background: rgba(194, 161, 90, 0.24);
  border: 8px solid transparent;
  border-image: var(--v-frame-thin) 30 fill / 8px / 0 round;
}
.v-event__dot--major {
  width: 20px;
  height: 20px;
  left: -25px;
  top: 16px;
  background: var(--v-gold);
  border-width: 3px;
  box-shadow: 0 0 0 4px rgba(230, 220, 194, 0.9);
}

/* Era thumbnail rail + cards (WO-F6.2): a horizontal strip on /timeline/
   (_era_rail.html) and the grid on /timeline/eras/ (_era_list.html) share
   one card component (_era_card.html). */
.v-era-rail__track {
  display: flex;
  gap: var(--v-3);
  overflow-x: auto;
  padding: 2px 2px var(--v-2);
  scroll-snap-type: x proximity;
}
.v-era-rail__arrow {
  flex: none;
  display: grid;
  place-items: center;
  width: 28px;
  color: var(--v-gold-dim);
  text-decoration: none;
  font-size: var(--v-fs-xl);
}
.v-era-rail__arrow:hover { color: var(--v-gold); text-decoration: none; }

.v-era-card {
  flex: none;
  scroll-snap-align: start;
  width: 160px;
  display: block;
  padding: var(--v-2);
  color: inherit;
  text-decoration: none;
  background: rgba(255, 250, 236, 0.5);
  border: var(--v-border-ink);
  border-radius: var(--v-radius);
}
a.v-era-card:hover { border-color: var(--v-gold); text-decoration: none; }
.v-era-card.is-active { border-color: var(--v-gold); box-shadow: 0 0 0 2px rgba(194,161,90,0.35) inset; }
.v-era-card__thumb,
.v-era-card__thumb-fallback {
  width: 100%;
  height: 90px;
  border-radius: 2px;
  margin-bottom: var(--v-2);
}
.v-era-card__thumb { object-fit: cover; display: block; }
/* Tasteful typographic fallback for an era with no thumbnail (WO-F6.2) —
   never a broken image: the era's initial, in the display face, on the
   same dark plaque treatment .v-portrait uses for characters. */
.v-era-card__thumb-fallback {
  display: grid;
  place-items: center;
  font-family: var(--v-font-display);
  font-size: var(--v-fs-xl);
  color: var(--v-gold-bright);
  background: linear-gradient(180deg, var(--v-panel-dark-2), var(--v-panel-dark));
}
.v-era-card__name { font-family: var(--v-font-display); font-size: var(--v-fs-sm); margin: 0; }
.v-era-card__meta { font-size: var(--v-fs-xs); color: var(--v-ink-3); margin: 2px 0 0; }

/* --- 11. Entity cards ----------------------------------------------------- */

.v-card {
  padding: var(--v-4);
  background: rgba(255, 250, 236, 0.5);
  border: var(--v-border-ink);
  border-radius: var(--v-radius);
}
.v-panel .v-card {
  background: rgba(0,0,0,0.25);
  border-color: rgba(194,161,90,0.25);
}
.v-card--sunken {
  background-color: rgba(43,36,28,0.07);
  background-image: var(--v-tex-tile);
  background-size: 41px 44px;
  background-blend-mode: soft-light;
}
/* Compact modifier (session plan 2026-08-24-recent-events-rework.md goal
   4, "lower the size of each event card a bit"): the home Recent Events
   panel's own request, one padding step down from the base `.v-card`
   (`--v-4` → `--v-3`) — this repo avoids id selectors for styling hooks
   (`grep -n "#v-" static/css/*.css` turns up none), so this is a class
   modifier applied directly on the card in
   templates/content/partials/recent_events.html rather than scoped off
   `#v-section-recent-events`. Not the same axis as `.v-card--sunken`
   above (that swaps the background texture; this only tightens spacing),
   so the two compose if a future card ever wants both. */
.v-card--compact { padding: var(--v-3); }

/* Character */
.v-char { display: flex; gap: var(--v-3); align-items: flex-start; }
.v-portrait {
  width: 64px; height: 76px; flex: none;
  display: grid; place-items: center;
  font-family: var(--v-font-display);
  font-size: var(--v-fs-lg);
  color: var(--v-gold-bright);
  background: linear-gradient(180deg, var(--v-panel-dark-2), var(--v-panel-dark));
  border: 2px solid var(--v-gold-dim);
  border-radius: 40% 40% 6px 6px / 26% 26% 6px 6px;
  box-shadow: inset 0 0 18px rgba(0,0,0,0.6);
}
.v-portrait--xs { width: 28px; height: 34px; font-size: var(--v-fs-xs); }
.v-portrait--sm { width: 44px; height: 52px; font-size: var(--v-fs-sm); }
.v-portrait--lg { width: 96px; height: 114px; font-size: var(--v-fs-xl); }
/* An uploaded character picture (`{% character_portrait %}`,
   apps/common/templatetags/pictures.py) wears the same plaque as the
   initials chip above — same frame, same silhouette — so a picture and an
   initials fallback are visually interchangeable wherever both can appear.
   `object-fit: cover` crops rather than distorts: uploads are arbitrary
   aspect ratios and the plaque is a fixed portrait ratio. The gradient
   below stays as the backdrop for a transparent PNG. */
.v-portrait--img { object-fit: cover; }
.v-char__name { font-family: var(--v-font-display); font-size: var(--v-fs-md); margin: 0; }
.v-char__role { font-size: var(--v-fs-sm); color: var(--v-ink-3); margin: 0 0 var(--v-2); }
.v-panel .v-char__role { color: var(--v-cream-2); }
.v-char__traits { display: flex; flex-wrap: wrap; gap: var(--v-1); }

/* Council slot */
.v-slot {
  display: flex;
  align-items: center;
  gap: var(--v-3);
  padding: var(--v-3);
  background: rgba(255, 250, 236, 0.45);
  border: 1px dashed rgba(43,36,28,0.35);
  border-radius: var(--v-radius);
  min-height: 84px;
}
.v-slot.is-filled  { border-style: solid; background: rgba(255, 250, 236, 0.62); }
.v-slot.is-locked  { border-color: var(--v-gold-dim); box-shadow: inset 0 0 0 1px rgba(194,161,90,0.35); }
.v-slot.is-empty   { color: var(--v-ink-3); font-style: italic; justify-content: center; }
.v-slot__label {
  font-family: var(--v-font-display);
  font-size: var(--v-fs-xs);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--v-ink-3);
}

/* Selectable goal / option card */
.v-choice {
  display: block;
  width: 100%;
  text-align: left;
  padding: var(--v-3) var(--v-4);
  background: rgba(255, 250, 236, 0.45);
  border: 1px solid rgba(43,36,28,0.28);
  border-radius: var(--v-radius);
  cursor: pointer;
  color: inherit;
  font: inherit;
}
.v-choice:hover { border-color: var(--v-gold-dim); text-decoration: none; }
.v-choice.is-selected {
  border-color: var(--v-gold-dim);
  background: rgba(194,161,90,0.18);
  box-shadow: inset 0 0 0 1px var(--v-gold-dim);
}
.v-choice.is-locked { opacity: 0.45; pointer-events: none; }

/* Law row */
.v-law { display: flex; align-items: center; gap: var(--v-3); padding: var(--v-2) 0; border-bottom: var(--v-border-ink); }
.v-law:last-child { border-bottom: none; }
.v-law__group { flex: 1 1 auto; font-size: var(--v-fs-sm); color: var(--v-ink-3); }
.v-law__value { font-family: var(--v-font-display); }

/* --- 12. Map -------------------------------------------------------------- */

.v-map { position: relative; overflow: hidden; border-radius: var(--v-radius); }
.v-map__img { width: 100%; height: auto; display: block; }
.v-map__hotspot {
  position: absolute;
  transform: translate(-50%, -50%);
  width: 18px; height: 18px;
  padding: 0;
  border-radius: 50%;
  border: 2px solid var(--v-gold-bright);
  background: rgba(140, 43, 38, 0.9);
  cursor: pointer;
  box-shadow: 0 0 0 3px rgba(0,0,0,0.35);
}
.v-map__hotspot:hover, .v-map__hotspot.is-active {
  background: var(--v-gold-bright);
  transform: translate(-50%, -50%) scale(1.25);
}
.v-map__hotspot span {
  position: absolute;
  left: 50%; top: -6px;
  transform: translate(-50%, -100%);
  padding: 2px var(--v-2);
  font-size: var(--v-fs-xs);
  white-space: nowrap;
  background: rgba(20,16,13,0.92);
  border: 1px solid var(--v-gold-dim);
  border-radius: 2px;
  color: var(--v-cream);
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.15s;
}
.v-map__hotspot:hover span { opacity: 1; }

/* --- 13. Admin editing ---------------------------------------------------- */

.v-admin-bar {
  display: flex;
  align-items: center;
  gap: var(--v-3);
  flex-wrap: wrap;
  padding: var(--v-2) var(--v-4);
  background: repeating-linear-gradient(135deg, rgba(194,161,90,0.14) 0 12px, rgba(194,161,90,0.06) 12px 24px);
  border: 1px solid var(--v-gold-dim);
  border-radius: var(--v-radius);
  font-size: var(--v-fs-sm);
  color: var(--v-cream);
}

/* Wrap any editable region; the pencil appears on hover */
.v-editable { position: relative; }
.v-editable:hover { outline: 1px dashed var(--v-gold-dim); outline-offset: 6px; }
.v-editable__edit {
  position: absolute;
  top: -10px; right: -10px;
  z-index: 5;
  opacity: 0;
  transition: opacity 0.15s;
}
.v-editable:hover .v-editable__edit,
.v-editable__edit:focus-visible { opacity: 1; }

/* --- 14. Utilities -------------------------------------------------------- */

.v-muted   { color: var(--v-ink-3); }
.v-panel .v-muted { color: var(--v-cream-2); }
.v-center  { text-align: center; }
.v-right   { text-align: right; }
.v-small   { font-size: var(--v-fs-sm); }
.v-tiny    { font-size: var(--v-fs-xs); }
.v-display { font-family: var(--v-font-display); }
.v-nowrap  { white-space: nowrap; }
.v-hide    { display: none !important; }
.v-sr-only {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}

/* Striped placeholder for art that isn't in yet */
.v-placeholder {
  display: grid;
  place-items: center;
  min-height: 80px;
  padding: var(--v-3);
  text-align: center;
  font-family: var(--v-font-mono);
  font-size: 11px;
  letter-spacing: 0.06em;
  color: rgba(43,36,28,0.6);
  background: repeating-linear-gradient(135deg,
      rgba(43,36,28,0.06) 0 8px, rgba(43,36,28,0.12) 8px 16px);
  border: 1px solid rgba(43,36,28,0.25);
  border-radius: 2px;
}
.v-panel .v-placeholder,
.v-placeholder--dark {
  color: rgba(236,226,204,0.6);
  background: repeating-linear-gradient(135deg,
      rgba(236,226,204,0.05) 0 8px, rgba(236,226,204,0.1) 8px 16px);
  border-color: rgba(194,161,90,0.3);
}

/* --- 15. Reference pages (ideology graph) --------------------------------- */

/* The Vic3-HRP visualiser's vocabulary, re-expressed in this design system:
   its palette is a dark SaaS theme, so only the *structure* is ported (level
   ramps, relationship cards, the ideology x ideology matrix). Everything here
   assumes a parchment surface; the `.v-panel` block below re-tints the ramps
   for the dark sidebar, where only the chip dots carry them. */

:root {
  /* Bond levels 1-4 and rivalry levels 1-2 (apps/reference/services.py owns
     their names). Ascending intensity = ascending level; bonds run to green,
     rivalries from amber to the house red. */
  --v-bond-1:  #6d8a72;
  --v-bond-2:  #577d58;
  --v-bond-3:  #416e3f;
  --v-bond-4:  #2f5f2c;
  --v-rival-1: #a8762a;
  --v-rival-2: #8c2b26;
}

.v-panel {
  --v-bond-1:  #8fae94;
  --v-bond-2:  #7fae80;
  --v-bond-3:  #6fae6c;
  --v-bond-4:  #63c05e;
  --v-rival-1: #d0a054;
  --v-rival-2: #c96a63;
}

/* Two-column shell: ideology rail beside the selected ideology's page. */
.v-ref {
  display: grid;
  grid-template-columns: 280px minmax(0, 1fr);
  gap: var(--v-5);
  align-items: start;
}

/* Group chips — a level-coloured dot plus the group name, with any aliases
   trailing. `--v-lvl` is set inline from the group's bond level. */
.v-chips { display: flex; flex-wrap: wrap; gap: var(--v-2); }
.v-chip {
  display: inline-flex;
  align-items: center;
  gap: var(--v-2);
  padding: var(--v-1) var(--v-3);
  min-height: 32px;
  font-size: var(--v-fs-sm);
  color: inherit;
  background: rgba(255, 250, 236, 0.5);
  border: var(--v-border-ink);
  border-radius: 999px;
}
a.v-chip:hover { background: rgba(194,161,90,0.18); text-decoration: none; }
.v-chip__dot {
  width: 9px; height: 9px; flex: none;
  border-radius: 50%;
  background: var(--v-lvl, var(--v-ink-3));
}
.v-chip__alt { color: var(--v-ink-3); font-size: var(--v-fs-xs); font-style: italic; }

/* Relationship cards (bonds / rivalries / frenemies). */
.v-relgrid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(268px, 1fr));
  gap: var(--v-3);
}
.v-relcard {
  display: block;
  padding: var(--v-3);
  color: inherit;
  background: rgba(255, 250, 236, 0.5);
  border: var(--v-border-ink);
  border-left: 3px solid var(--v-lvl, var(--v-ink-3));
  border-radius: var(--v-radius);
}
a.v-relcard:hover { background: rgba(194,161,90,0.14); text-decoration: none; }
.v-relcard__top { display: flex; align-items: center; gap: var(--v-2); }
.v-relcard__name { flex: 1; min-width: 0; font-family: var(--v-font-display); font-size: var(--v-fs-base); }
.v-relcard__label { margin: var(--v-1) 0 0; font-size: var(--v-fs-sm); color: var(--v-lvl, var(--v-ink-2)); }
/* `via` provenance: which group or rivalry line produced this edge. */
.v-relcard__via { margin: var(--v-2) 0 0; font-size: var(--v-fs-xs); color: var(--v-ink-3); line-height: 1.5; }
.v-relcard__via span { display: block; }
/* Frenemy: one stripe fading from the bond colour into the rivalry colour. */
.v-relcard--frenemy {
  border-left-color: transparent;
  background-image: linear-gradient(to bottom, var(--v-lvl-bond), var(--v-lvl-rival));
  background-size: 3px 100%;
  background-repeat: no-repeat;
  background-position: left center;
  background-origin: border-box;
}
.v-relcard__labels {
  display: flex; flex-wrap: wrap; align-items: baseline; gap: var(--v-2);
  margin-top: var(--v-1); font-size: var(--v-fs-sm);
}
.v-relcard__sep { color: var(--v-ink-3); font-style: italic; }

/* Level badge — pill tinted from the level colour it is given. */
.v-lvl {
  flex: none;
  padding: 2px var(--v-2);
  border: 1px solid var(--v-lvl, var(--v-ink-3));
  border-radius: 999px;
  color: var(--v-lvl, var(--v-ink-2));
  font-size: var(--v-fs-xs);
  white-space: nowrap;
}

/* Law stances (the static asset's grouped law cards). */
.v-lawgrid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(240px, 1fr));
  gap: var(--v-3);
}
.v-lawrow {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--v-3);
  padding: var(--v-1) 0;
  font-size: var(--v-fs-sm);
}
.v-stance {
  flex: none;
  display: inline-grid; place-items: center;
  min-width: 28px; height: 20px; padding: 0 var(--v-2);
  border: 1px solid currentColor;
  border-radius: 999px;
  font-size: var(--v-fs-xs);
  font-variant-numeric: tabular-nums;
}
.v-stance--strongly_approve    { color: var(--v-bond-4); }
.v-stance--approve             { color: var(--v-bond-2); }
.v-stance--neutral             { color: var(--v-ink-3); }
.v-stance--disapprove          { color: var(--v-rival-1); }
.v-stance--strongly_disapprove { color: var(--v-rival-2); }

/* Ideology x ideology (and IG x IG) matrix: both headers stay put while the
   grid scrolls in its own container, so the page never scrolls sideways. */
.v-matrix__wrap {
  overflow: auto;
  max-height: 76dvh;
  border: var(--v-border-ink);
  border-radius: var(--v-radius);
  background: rgba(255, 250, 236, 0.5);
}
.v-matrix { border-collapse: separate; border-spacing: 0; font-size: var(--v-fs-xs); }
.v-matrix th, .v-matrix td {
  border-right: 1px solid rgba(43,36,28,0.15);
  border-bottom: 1px solid rgba(43,36,28,0.15);
}
.v-matrix thead th {
  position: sticky; top: 0; z-index: 2;
  height: 132px; padding: var(--v-1);
  background: var(--v-paper-2);
  vertical-align: bottom;
}
.v-matrix thead th span {
  display: block;
  writing-mode: vertical-rl;
  transform: rotate(180deg);
  white-space: nowrap;
  color: var(--v-ink-2);
}
.v-matrix tbody th {
  position: sticky; left: 0; z-index: 1;
  min-width: 150px; padding: var(--v-1) var(--v-2);
  background: var(--v-paper-2);
  text-align: left;
  font-weight: normal;
}
.v-matrix thead th.v-matrix__corner { position: sticky; left: 0; z-index: 3; min-width: 150px; }
.v-matrix td {
  width: 26px; height: 26px;
  text-align: center;
  color: var(--v-paper);
  font-size: 10px;
}
.v-matrix__cell--self { background: rgba(43,36,28,0.25); }
/* Frenemy cell: bond in the top-left triangle, rivalry in the bottom-right. */
.v-matrix__cell--split {
  background-image: linear-gradient(135deg,
      var(--v-lvl-bond) 0 50%, var(--v-lvl-rival) 50% 100%);
}

/* Ideology icons (apps/reference/icons.py) AND interest-group icons
   (apps/common/icons.py, `{% ig_icon %}`) share this class — same generic
   icon-chip sizing, two independent resolvers/key spaces feeding it.
   Absent icons render nothing, so these never style an empty box. */
.v-ig-icon { width: 26px; height: 26px; flex: none; border-radius: 2px; vertical-align: -6px; }
.v-ig-icon--lg { width: 44px; height: 44px; }
.v-ig-icon--sm { width: 16px; height: 16px; vertical-align: -3px; }

/* Power-rank icons (apps/common/icons.py, `{% rank_icon %}`), sized to sit
   inline beside a `v-tag` rank chip. Absent icons render nothing. */
.v-rank-icon { width: 18px; height: 18px; flex: none; border-radius: 2px; vertical-align: -3px; }

/* Great Power emphasis (item 40, feedback-2026-08-18: "Great Power" must
   read as visibly distinct from and more prominent than the other three
   rank labels, which today all render as identical plain text/`.v-tag`
   colour). A state class in this codebase's existing `.is-*` vocabulary
   (`.is-active`, `.is-selected`, …), not a new `.v-tag--` colour variant,
   because the rank label isn't always a `.v-tag` — the country sheet
   header renders it as bare text (`country_sheet.html`'s `.v-display`),
   the roster table as a plain `<td>` (`_roster_table.html`) — so the
   lever has to work on any element. Applied at all three rank render
   sites — `_directory.html`, `_roster_table.html`, `country_sheet.html` —
   each adding `{% if rank_token == "great_power" %} is-great-power{% endif %}`
   next to its own rank markup, off `merged_rank`'s canonical token. */
.is-great-power {
  /* Deep gold: 5.9:1 on parchment, and distinct from the plain ink every
     other rank uses, which is what "stands out more" has to mean on this
     background. The dark-chrome contexts below keep the bright gold —
     one token cannot serve both grounds (QA 2026-08-19, item 40). */
  color: #6d4f00;
  font-weight: 700;
}
.v-panel .is-great-power,
.v-rail__item .is-great-power,
.v-topbar .is-great-power { color: var(--v-gold-bright); }

/* Major Power emphasis (item 38, feedback-2026-08-19: "change the color of
   'Major Power' to be something more highlighted, maybe a silvery
   highlight since golden is Great Power… do this on the country page as
   well"). Same `.is-*` state-class shape as `.is-great-power` right above
   — for the same reason (the rank label isn't always a `.v-tag`) — kept
   as its own sibling class rather than a second value the same class
   accepts, so a row can never carry both at once by a stray template
   condition. A cool steel-grey rather than a literal light "silver" tone:
   flat silver/light-grey measures under 3:1 on parchment at any usable
   weight (`#708090` slategray only clears 3.14:1) — this deep, desaturated
   blue-grey is parchment's equivalent of `.is-great-power`'s *deep* gold
   (`#6d4f00`, itself not the bright `--v-gold-bright` for the same
   contrast reason), reading as "steel/silver" while clearing AA, and the
   dark-chrome override below is its bright counterpart, mirroring
   `--v-gold-bright`'s role for great power. Deliberately cooler than both
   `.is-great-power`'s warm gold and the plain warm ink every other rank
   uses, so "major" reads as a different *kind* of emphasis, not just a
   paler version of "great" — clearly second to gold, clearly above the
   unranked plain-ink rows.

   Applied at all three of `.is-great-power`'s sites, off the same
   `merged_rank` canonical token: `_directory.html`'s `.v-tag`,
   `_roster_table.html`'s `<td>`, and `country_sheet.html`'s `.v-display`.
   Keep those three in step — a rank state class that no template applies
   is how phase-4.5's item 40 shipped inert. */
.is-major-power {
  /* 6.4:1 on parchment (`--v-paper`), 6.98:1 on `.v-event__card`'s own
     lighter wash — clears AA at both weights this class might render at. */
  color: #43505b;
  font-weight: 700;
}
.v-panel .is-major-power,
.v-rail__item .is-major-power,
.v-topbar .is-major-power {
  /* 11.4:1 on `--v-panel-dark`, 12.4:1 on `--v-rail` — the light, cool
     counterpart to `--v-gold-bright` above, same dark-chrome contexts. */
  color: #cfd6dc;
}

/* Icon + label pair outside an existing flex container (`.v-tag`, `.v-chip`,
   `.v-row`) — e.g. an IG icon beside its plain-text name in a table cell or
   a muted header line (WO-F7.2). The global `img, svg { display: block }`
   reset (top of this file) would otherwise stack the icon above the text
   instead of beside it. */
.v-icon-text { display: inline-flex; align-items: center; gap: var(--v-2); }

/* Classification blocks (groups + rivalries listings). */
.v-classblock { margin-bottom: var(--v-5); }
.v-classtitle {
  margin: 0 0 var(--v-3);
  padding-bottom: var(--v-1);
  border-bottom: 1px solid rgba(43,36,28,0.3);
  font-family: var(--v-font-display);
  font-size: var(--v-fs-xs);
  letter-spacing: 0.09em;
  text-transform: uppercase;
  color: var(--v-gold-dim);
}

/* Empty/explanatory note — distinct from `.v-placeholder`, which marks a
   stand-in for missing content rather than a genuine "nothing here". */
.v-callout {
  padding: var(--v-3) var(--v-4);
  border: 1px dashed rgba(43,36,28,0.35);
  border-radius: var(--v-radius);
  color: var(--v-ink-3);
  font-size: var(--v-fs-sm);
}
.v-panel .v-callout { border-color: rgba(194,161,90,0.3); color: var(--v-cream-2); }
/* Item 86 (feedback-2026-08-18): /reference/ideologies/'s "Pick an ideology
   from the list…" / "No such ideology." prompts (`ideologies.html`) are the
   one `.v-callout` on this page that sits bare on the dark page background
   rather than inside `.v-paper` or `.v-panel` — every other reference page
   wraps its copy in `.v-paper` first. Base `.v-callout`'s ink-3 measured
   3.14:1 there (fails AA). The selector must go through `#reference-detail`,
   the HTMX swap target that wraps both prompts — an earlier `.v-ref > div >
   .v-callout` was one level short of the real DOM and matched nothing at
   all (QA 2026-08-19). It still stops short of the callout inside
   `_ideology_content.html`'s own nested `.v-paper`, which renders correctly
   on parchment and must keep its ink. */
.v-ref #reference-detail > .v-callout { color: var(--v-cream-2); border-color: rgba(194,161,90,0.3); }

@media (max-width: 900px) {
  .v-ref { grid-template-columns: minmax(0, 1fr); }
}

/* --- 16. Responsive ------------------------------------------------------- */

@media (max-width: 1100px) {
  .v-shell, .v-shell--wide-aside { grid-template-columns: minmax(0, 1fr); }
  .v-rail { max-height: 320px; }
}

@media (max-width: 820px) {
  :root { --v-fs-2xl: 1.875rem; --v-fs-xl: 1.5rem; }

  .v-topbar { flex-wrap: wrap; padding-block: var(--v-2); }

  /* Owner request 2026-08-23 (roadmap/feedback-2026-08-23.md, goal 1): the
     hamburger is the first thing on the left, brand wordmark immediately
     after it. Done with `order`, not by moving the button in base.html —
     the DOM keeps the brand link first, so desktop layout and document
     order are unchanged. Precise about what that buys: visual order and
     focus order do diverge here, by exactly these two adjacent, separately
     labelled header controls. The alternative — moving the button ahead of
     the brand in the DOM — would put a bare icon control before the site
     identity for every desktop and screen-reader user, which is the worse
     trade. Tabbing forward from the toggle still walks into the drawer it
     just opened and on to the user control, because the button sits
     between `.v-brand` and `.v-nav` in the DOM. Above this breakpoint
     the toggle is `display:none`, so this rule has nothing to affect
     there either way; it lives here to keep the mobile bar readable in
     one place. */
  .v-topbar__toggle { display: inline-flex; order: -1; }

  /* Cheap insurance, not a fix — say so plainly: both spacers follow the
     brand in flex order and everything after them is `flex-basis: 100%`
     (its own row), so on mobile they sit at the end of row 1 with nothing
     left to push. Collapsing them reclaims only the 32px of the bar's own
     `gap` they occupy, which matters at one viewport that actually exists:
     320px with a wide wordmark, where those 32px decide whether the
     brand shares row 1 with the toggle or wraps below it. Scoped to the
     bar's own children because `.v-spacer` is a general layout utility
     used in ~26 other templates, none of them the topbar. */
  .v-topbar > .v-spacer { display: none; }

  .v-nav {
    order: 10;
    flex-basis: 100%;
    flex-direction: column;
    gap: 0;
    display: none;
  }
  .v-nav.is-open { display: flex; }
  .v-nav__link { padding-block: var(--v-3); border-radius: 0; border-bottom: 1px solid rgba(194,161,90,0.2); }

  /* Owner request 2026-08-23 (goal 2): the user control rides *inside* the
     drawer on mobile — the signed-out "Sign in" link (`.v-topbar__user`)
     and the signed-in chip with its Sign out / edit-mode / admin dropdown
     (`.v-user-menu`) alike — instead of wrapping onto a topbar row of its
     own. Both already follow `.v-nav` in base.html's document order, so
     the general-sibling combinator reaches them from the open nav and no
     second copy of the markup is needed: duplicating would mean two
     edit-mode POST forms and two Alpine `x-data` scopes on one page.
     `order: 11` puts it below the nav links; the whole thing is one more
     full-width drawer row.

     `site.js`'s "close the drawer when a nav link is picked" handler keys
     off `.v-nav__link`, which neither of these is — so tapping the chip to
     open its dropdown does not slam the drawer shut underneath it.

     Child combinator throughout, and it matters: `.v-topbar__user` is worn
     by *two* different elements — the signed-out `<a>` that is a direct
     child of the bar, and the signed-in chip's `<button>` nested inside
     `.v-user-menu`. Only the first is a drawer row; an unscoped rule would
     hide the button too, and the `~` rule below could never reach it to
     put it back (it is the nav's nephew, not its sibling). */
  .v-topbar > .v-topbar__user,
  .v-topbar > .v-user-menu {
    display: none;
    order: 11;
    flex-basis: 100%;
  }
  .v-nav.is-open ~ .v-topbar__user { display: flex; }
  .v-nav.is-open ~ .v-user-menu { display: block; }

  /* Make the two states look like the same drawer row. The signed-out
     link is itself the flex item, so `flex-basis: 100%` above already
     stretches its pill full width; the signed-in chip is a button *inside*
     the stretched `.v-user-menu`, so it needs its own width or it sits as
     a short pill against a full-width neighbour. Its dropdown is anchored
     `right: 0` to that wrapper — fine in the desktop bar where the chip
     hugs the right edge, but in a full-width drawer row it would open
     across the page from the chip it belongs to, so it flips to the left
     edge here. */
  .v-nav.is-open ~ .v-user-menu > .v-topbar__user { width: 100%; }
  .v-nav.is-open ~ .v-user-menu .v-user-menu__panel { left: 0; right: auto; }

  .v-paper, .v-paper--framed { padding: var(--v-4); }
  .v-framed-gold { border-width: 20px; border-image-width: 20px; }

  /* Item (owner screenshot, 2026-08-24): the dot floated "all over the
     place" on mobile because this breakpoint used to pull `.v-event__year`
     out of absolute positioning (`position: static`) and stack it above
     the card via `.v-event { flex-direction: column }`. `.v-event__dot`
     stayed absolutely positioned against `.v-event` with the same fixed
     `top: 20px` desktop uses — a value tuned for "the card is the only
     in-flow child, so its top edge is `.v-event`'s top edge". Once the
     year became a second in-flow item stacked above the card, the card's
     real top edge shifted down by the year's rendered height, but the
     dot's `top` didn't move with it — so the dot sat wherever the year
     line happened to be instead of next to the card's tag row. Worse,
     `show_year` is only `true` for the first event of a given year
     (`services.build_stream_items`); every other same-year card renders
     no `.v-event__year` at all, so even a hand-tuned fixed offset could
     never fit both cases at once.
     Fix: keep `.v-event__year` and `.v-event__dot` absolutely positioned
     in a left gutter exactly like desktop (so the card is once again the
     only in-flow child of `.v-event`, and `top: 20px` lines the dot up
     with the card's own top edge regardless of whether a year label
     renders beside it) — just a narrower gutter than desktop's 92px,
     sized to fit four digits + the dot rather than stacking them. */
  .v-timeline { padding-left: 56px; }
  /* Same centering as the base rule above, recomputed for this
     breakpoint's narrower gutter: `.v-event__dot`'s `left: -18px` at
     `width: 14px` (and `--major`'s `left: -21px`/`width: 20px`) both
     center on -11px, so this 2px line sits at `left: -12px`. */
  .v-event::before { left: -12px; }
  .v-event__year { left: -56px; width: 34px; text-align: left; }
  .v-event__dot { left: -18px; }
  .v-event__dot--major { left: -21px; }
  .v-event__card { flex-direction: column; }
  .v-event__thumb { width: 100%; min-height: 120px; }
  /* Mirrors `.v-event__card`'s stack above — `.v-home-event` is the home
     card's counterpart wrapper (see its definition earlier in this file).
     Unlike before the compact-sizing change, the `width:100%` rule just
     above is NOT enough on its own any more: the home panel now carries
     its own `.v-home-event .v-event__thumb { width: 110px; }` override
     (0,2,0 specificity), which beats this media query's bare
     `.v-event__thumb` (0,1,0) regardless of source order — specificity,
     not cascade position, decides ties across a breakpoint. So the
     full-width stacking has to be restated here at the same (0,2,0)
     specificity for the home scope, or the home thumb would get stuck at
     110px instead of stacking full-width below 820px. **Both** compact
     declarations need the restatement, not just `width` — the compact
     rule also sets `min-height: 64px`, which would otherwise keep beating
     the stacked standard's `min-height: 120px` for the same specificity
     reason (QA 2026-08-24). */
  .v-home-event .v-event__thumb { width: 100%; min-height: 120px; }
  .v-home-event { flex-direction: column; }

  /* Country-sheet tab strip (`.v-tab`) — 8 tabs (feedback-2026-08-18
     WO-F5) wrap onto several rows at phone width already (`.v-tabs`'s own
     `flex-wrap: wrap`); the wrapped rows sat close enough together that
     the ~34px hit area (padding `var(--v-2) var(--v-4)` at the base
     `--v-fs-sm` line height) read as cramped on a phone screenshot. Taller
     padding only below the breakpoint where tabs actually wrap into a
     multi-row block — desktop's single row is untouched. */
  .v-tab { padding-block: var(--v-3); }

  /* Country/roster directory rows (`.v-rail__item`, feedback-2026-08-18
     mobile pass): flag + name + tag chip + rank chip + an optional
     "Unrecognized" chip + a trailing status chip all sit on one flex row
     with no wrap. `.v-rail`'s own `overflow-y: auto` forces its
     `overflow-x` to resolve to `auto` too (the CSS overflow spec: a
     non-visible value on one axis promotes 'visible' on the other to
     'auto') — so at phone width the row didn't overflow the *page*, it
     silently overflowed *inside* `.v-rail` instead, clipping the last
     chip (often the assignment status) off tags-per-row with no scrollbar
     ever appearing on a touch swipe test. Letting the row wrap is the fix
     that keeps every chip visible instead of hiding it in an invisible
     scroll track. */
  .v-rail__item { flex-wrap: wrap; row-gap: var(--v-1); }

  /* Era thumbnail rail (`_era_rail.html`, WO-F6.2): the prev/next arrows
     and the scrollable card track share one `.v-row`, which wraps by
     default — below ~460px of content width the track's intrinsic
     content width (it never shrinks; `overflow-x: auto` needs a definite
     width to clip against) pushed the arrows onto their own lines above
     and below the track instead of beside it (found via the 375px
     screenshot: "<" over the track, ">" under it). `flex-wrap: nowrap`
     plus `min-width: 0` on the track (letting it actually shrink to the
     remaining row width so its own `overflow-x: auto` has something to
     clip against) keeps the arrows flanking the track at any width. The
     arrows also grow from 28px to 44px here — a comfortable thumb target
     — without touching their desktop size. */
  .v-era-rail__row { flex-wrap: nowrap; }
  .v-era-rail__track { min-width: 0; }
  .v-era-rail__arrow { width: 44px; }

  /* Notable Figures roster (`.v-grid--roster`, WO-W1): one column below
     the same breakpoint the rest of this block uses for tablet/phone —
     two columns down here would give each IG card less width than a
     phone's already-accepted single-column card gets, which is exactly
     the badge-wrapping layout this breakpoint exists to avoid. */
  .v-grid--roster { grid-template-columns: minmax(0, 1fr); }

}

@media (max-width: 520px) {
  .v-container { padding-inline: var(--v-3); }
  .v-figure__val { font-size: var(--v-fs-lg); }

  /* Home "Campaign Overview" (`.v-grid--4`, templates/content/partials/
     figures.html — the only user of this class) would otherwise fall to
     `auto-fit`'s single column below ~360px of content width, turning
     eight tiles into eight full-width rows (feedback-2026-08-18 mobile
     pass). Two columns keeps the pairing readable without the row-height
     hit of a single column, and without inventing a fifth `.v-grid--N`
     variant nobody else needs. */
  .v-grid--4 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
}

@media (prefers-reduced-motion: reduce) {
  * { transition: none !important; animation: none !important; }
}

/* --- 17. Print ------------------------------------------------------------ */

@media print {
  body::before, body::after { display: none; }
  .v-topbar, .v-admin-bar, .v-editable__edit { display: none; }
  body { background: #fff; color: #000; }
  .v-paper { box-shadow: none; }
}

/* --- 18. General utilities ------------------------------------------- */
/* Not heist-specific despite arriving with it; usable anywhere. */

/* Stack gap between the default (4) and --tight (2). */
.v-stack--snug { gap: var(--v-3); }

.v-mt-3 { margin-top: var(--v-3); }
.v-mb-4 { margin-bottom: var(--v-4); }

/* Keeps a single-button form on the same line as what precedes it. */
.v-inline { display: inline; }


/* --- 19. Heist (Iron Crown) components -------------------------------- */

/* Player name on a host-console roster card. */

.v-heist-player-name {
  font-weight: 600;
  color: var(--v-gold-bright);
}

/* Player metadata: small muted text */
.v-heist-player-detail {
  font-size: var(--v-fs-sm);
  color: var(--v-cream-2);
}

/* Flexbox row for player cards: flex row holding the seat's name, role and link + trailing metadata */
.v-heist-player-row {
  display: flex;
  align-items: center;
  gap: var(--v-3);
  margin-bottom: var(--v-3);
}

/* Player info section with flex:1 child (expanding left side) */
.v-heist-player-info {
  flex: 1;
}

/* Centered text on dark surface with muted color */
.v-heist-center-muted {
  text-align: center;
  color: var(--v-cream-2);
}

/* Host badge: gold text, small, right-aligned */
.v-heist-host-badge {
  color: var(--v-gold-bright);
  font-size: var(--v-fs-sm);
  margin-left: auto;
}

/* Narrow centered panel (centres and caps the width of a standalone notice) */
.v-heist-max-width {
  max-width: 640px;
  margin-left: auto;
  margin-right: auto;
}

/* The viewer's own row on the roster: gold, so you can find yourself at a glance. */
.v-heist-you,
.v-heist-you td:first-child {
  color: var(--v-gold-bright);
  font-weight: 600;
}

/* --- The board -------------------------------------------------------- */
/* templates/heist/_board.html. Sixty-one tiles drawn as one SVG plan. The board is one
   column of a wider grid holding the order panel and the event log, so it sizes to its
   column and never sets a width of its own.

   Two rules run through everything below. First, no colour literals — every value is a
   token from section 1, because the plan is the same aged paper as the rest of the site.
   Second, colour is never the only channel: each state that has a colour here also has a
   different shape or a glyph in the template, since roughly one player in twelve cannot
   use the colour and this map is the whole information surface of the game. */

/* The <figure> wrapping the sheet and its key. */
.v-heist-board {
  margin: 0;
}

/* `_piece_sprites.html`'s icon sheet: `<svg class="v-heist-sprites" width="0" height="0">`
   holding the `<symbol>` defs every piece, ghost, grate and footprint `<use>`s point at. Even
   at zero width/height an inline `<svg>` is still an inline-level box and can leave a stray
   gap at the top of the live region, so this takes it out of flow. Deliberately NOT
   `display: none` or `visibility: hidden` — a `<symbol>` inside a hidden subtree stops being
   a valid `<use>` reference in some engines, which would silently render every piece on the
   board as nothing at all rather than fail loudly. `position: absolute` removes it from flow
   without hiding it; `overflow: hidden` on a zero-size box is belt and suspenders. */
.v-heist-sprites {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

/* The sheet itself: fills its column, keeps its aspect, sits on aged paper.

   `max-height` is the owner ask of 2026-09-06 — "the goal is to have the map fit a 1920x1080
   browser without having to scroll or go full screen to see the whole map" — made structural
   rather than merely aimed at. The canvas itself is now five units wide to every three high
   (`services.BOARD_SCALE_X`/`_Y`), which is what makes the plan fit that screen at its natural
   size; this ceiling is the guarantee for every screen that is shorter, or every future row of
   chrome added above it. Subtracted, measured on a 1920x1080 screen rather than guessed: the
   top bar (64), this page's block padding (24 + 32), the in-play room-status strip and its gap
   (`.v-heist-status`, about 52), the board panel's own head (44) and body padding (32), and
   the key beneath the plan (about 28). When it bites, the SVG's own `preserveAspectRatio`
   letterboxes the plan inside a wider box — the whole map stays visible and simply draws
   smaller, which is the outcome to prefer over a scrollbar. Anything added above the board
   comes out of this allowance and has to be subtracted here too. */
.v-heist-board__sheet {
  display: block;
  width: 100%;
  height: auto;
  max-height: calc(100vh - 280px);
  border: var(--v-border);
  border-radius: var(--v-radius);
  box-shadow: var(--v-shadow);
}

/* The paper. On the `<svg>` itself rather than a background `<rect>` — a rect needs its own
   copy of the viewBox's numbers, and when the canvas started being derived
   (`views.BOARD_VIEWBOX`) that copy went stale and left a corner of the plan unpainted. */
.v-heist-board__sheet {
  background: var(--v-ink);
}

/* The corridors. Thin and dim: they are the grammar of the board, not its content, and a
   sixty-one tile plan with bold edges reads as a circuit diagram. */
.v-heist-edges line {
  stroke: var(--v-ink-3);
  stroke-width: 2;
}

/* --- Tiles ------------------------------------------------------------- */

.v-heist-tile__shape {
  fill: var(--v-ink-2);
  stroke: var(--v-gold-dim);
  stroke-width: 1.5;
}

/* A landmark is drawn larger in the template; here it also takes the brighter rule, so the
   twelve named places carry the eye around a board of otherwise identical connectors. */
.v-heist-tile--outer .v-heist-tile__shape,
.v-heist-tile--inner .v-heist-tile__shape {
  stroke: var(--v-gold);
}

/* The Barracks and its spur: Austrian ground, and closed to the crew. */
.v-heist-tile--barracks .v-heist-tile__shape,
.v-heist-tile--spur .v-heist-tile__shape {
  fill: var(--v-ink);
  stroke: var(--v-red);
  stroke-dasharray: 3 2;
}

/* Searched: the crew's shared notebook, and the single most-consulted marking on the board.
   A fill change rather than an outline, so it reads at a glance across sixty tiles, plus the
   muted X below (G5) for the one player in twelve who cannot use the fill change alone. */
.v-heist-tile--searched .v-heist-tile__shape {
  fill: var(--v-ink-3);
}

/* The searched X itself (G5). Muted rather than the sealed bars' bright red — it is the
   crew's own routine bookkeeping, not a warning — and small enough to sit inside a sealed
   tile's bars without the two marks reading as one blot when both apply. */
.v-heist-tile__searched-x {
  stroke: var(--v-cream-2);
  stroke-width: 1.5;
  opacity: 0.8;
}

/* A grate tile used to be drawn as a blue square here — "the blue box is not clear" (owner
   ask 5) — and is gone: a grate tile is now an ordinary/landmark circle like any other, and
   the grate itself is a separate glyph beside the tile (`.v-heist-grate`, below the Pieces
   section) rather than a state of the tile shape. */

/* Sealed forever. The bars below are the shape channel. */
.v-heist-tile--sealed .v-heist-tile__shape {
  fill: var(--v-ink);
}

/* Reachable this round (G1) — the owner's own word for it was "highlighted blue", so it is
   blue rather than the gold every other rule on this board reaches for, and it is placed
   after grate and sealed above so a reachable door still reads as reachable this round
   rather than losing its stroke back to gold. Toggled by `heist-board.js` alone: this
   template never applies the class itself any more (that used to be `tile.selectable`),
   which is what makes the highlight a genuine consequence of selecting a piece. */
.v-heist-tile--reachable .v-heist-tile__shape {
  fill: var(--v-blue);
  fill-opacity: 0.4;
  stroke: var(--v-blue);
  stroke-width: 3;
}

/* Three vertical bars (G5) — what this class's name always claimed to draw. It used to draw
   a diagonal cross, which is the searched mark's shape now; the two could not have gone on
   sharing one glyph once G5 asked for both on the same board. */
.v-heist-tile__bars {
  stroke: var(--v-red-bright);
  stroke-width: 2.5;
  fill: none;
}

/* 15px -> 20px (contract §C, ask 6 — "the text is tiny"). At the larger size a label
   crossing a corridor line, a glyph, or a neighbour it is now staggered above/below
   (`_board.html`) but still occasionally overlaps was unreadable exactly where it mattered,
   so it gets a halo in the board's own paper colour. `paint-order: stroke fill` is the part
   that makes this a halo rather than a disaster: SVG's default paint order is `fill stroke`,
   which draws the stroke centred ON the letterforms and thickens them instead of outlining
   them (the stroke fills back in the counters of any label with one, illegibly). Reversing
   the order paints the stroke FIRST, so the fill then paints on top of it and only the
   stroke's outer edge survives as a ring around each letter. */
.v-heist-tile__label {
  fill: var(--v-cream-2);
  font-size: 20px;
  text-anchor: middle;
  font-family: var(--v-font-body, inherit);
  paint-order: stroke fill;
  stroke: var(--v-ink);
  stroke-width: 3px;
  stroke-linejoin: round;
}

/* The crowns. Host console only — no player payload carries the key that draws them.
   20px -> 28px (contract §C). */
.v-heist-tile__crown {
  fill: var(--v-gold-bright);
  font-size: 28px;
  text-anchor: middle;
}

.v-heist-tile__crown--gilt {
  fill: var(--v-gold-dim);
}

/* The identified-gilt-copy diamond (G5) — ◈ in the template, not ✗: the searched mark owns
   the X now, and a crown glyph and a tile mark that both read as "X" on the same board was
   the confusion G5 exists to remove. 16px -> 22px (contract §C). */
.v-heist-tile__crown--inert {
  fill: var(--v-cream-2);
  font-size: 22px;
}

/* --- Hover & click affordance (contract §G, owner ask 1) --------------- */
/* "No feedback when a player mouses over a node — enlarge it, or show a pointer." Every
   tile's `<g class="v-heist-tile">` (templates/heist/_board.html) now carries an invisible
   `.v-heist-tile__hit` circle, `r="32"`, as its FIRST child — painted before, and therefore
   underneath, the visible `.v-heist-tile__shape` circle (`r="15"`, `r="22"` for a landmark)
   and everything else in the tile. That paint order matters for what follows.

   `fill: transparent` rather than `none`: SVG's default `pointer-events: visiblePainted`
   only treats an element as hit-testable where it is *painted*, and `none` is not a paint at
   all, so an untinted hit circle would silently stop being clickable. `transparent` still
   counts as a fill. `pointer-events: all` on top of that anyway, so this does not quietly
   keep working only because of which value happens to count as "painted" today. */
.v-heist-tile__hit {
  fill: transparent;
  pointer-events: all;
}

/* Because the hit circle sits UNDER the visible shape and everything drawn after it, the
   pointer is almost always over `.v-heist-tile__shape` (or a mark on top of it), not over
   this circle — which is most of the tile's area, not an edge case. Styling `:hover` on
   `.v-heist-tile__hit` alone would therefore only light the tile up in the thin r=15..32 (or
   r=22..32) ring around the visible shape and go dark exactly where a player is looking and
   aiming. The fix is to key every hover rule off the tile GROUP instead of either circle:
   `:hover` on an SVG `<g>` is true whenever ANY descendant is hovered, so
   `.v-heist-tile:hover` fires identically whether the pointer found the wide invisible
   circle or the small visible one on top of it, and `cursor` is an inherited property, so
   setting it on the group reaches every descendant that does not set its own. That is also
   why this hit circle does not need `pointer-events: none` on any of the marks drawn over
   it to "un-swallow" hover for the group-level rules below — paint order stops mattering
   the moment the selector is the ancestor rather than either circle. The one deliberate
   exception is `.v-heist-piece--drivable` further below, which keeps its own `cursor: grab`
   because picking a piece up really is a different action from clicking the tile it rides
   on, and a piece must go on winning that cursor whichever tile it happens to be standing
   on. */
.v-heist-tile:hover .v-heist-tile__shape {
  filter: brightness(1.35);
}

/* Every reachable tile gets a pointer cursor (ask 1's other half) and, on hover, grows —
   `transform: scale(1.18)` needs `transform-box: fill-box` to work on an SVG `<circle>`:
   without it, the transform's origin is the `<svg>`'s own coordinate system (roughly the
   sheet's top-left corner), not the circle's own geometry, so `transform-origin: center`
   would mean the CENTRE OF THE BOARD rather than the centre of the tile and the "grown"
   tile would fly off toward that corner instead of swelling in place. `fill-box` rebases the
   transform onto the shape's own bounding box first, which is what makes `center` mean what
   it reads as here. */
.v-heist-tile--reachable {
  cursor: pointer;
}

.v-heist-tile--reachable:hover .v-heist-tile__shape {
  transform: scale(1.18);
  transform-box: fill-box;
  transform-origin: center;
  filter: brightness(1.5);
  transition: transform 120ms ease-out, filter 120ms ease-out;
}

/* Reduced motion drops the grow (it is the one purely kinetic part of this) but keeps the
   brighten — a colour change is not the kind of motion this preference asks to lose, and the
   tile still needs a visible, instant hover state without it. */
@media (prefers-reduced-motion: reduce) {
  .v-heist-tile--reachable:hover .v-heist-tile__shape {
    transform: none;
    transition: none;
  }
}

/* --- Pieces ------------------------------------------------------------ */

/* `.v-heist-piece--thief` / `--guard` used to be a hollow diamond and a filled square — the
   shape channel that told the two sides apart before colour. The 2026-09-06 readability pass
   (contract §A/§B) replaced both shapes with `<use>`s of a mask icon and a pikeman icon, so
   side is read from *which icon* now, and these two classes are kept on the piece group only
   as identity hooks (`heist-board.js`/future rules can still ask "is this a thief") — they
   deliberately carry no `fill`/`stroke` of their own any more. A `fill` here would have had
   no effect regardless (the icon's own `<path fill="currentColor">` always wins over an
   ancestor's `fill`), but the old `stroke` values were not dead — `stroke` has no such
   presentation attribute on the icon paths, so it inherits — and a leftover cream or
   red-bright outline on every piece of a side would have fought the whole point of §A: a
   thief or guard's colour is supposed to come from exactly one declaration,
   `v-heist-piece--t{n}`/`--g{n}` below, and nothing else. */

/* The viewer's own piece (ask 4, contract §G) — a gold ring around the icon itself (`stroke`
   inherits into the `<use>`'s icon path the same way the old side-shape strokes did, since
   the path sets its own `fill` but never its own `stroke`), plus `.v-heist-you-pin` in the
   template: a small marker above the piece, so "which one is mine" doesn't come down to
   spotting a stroke width. Kept visually distinct from `.is-selected`'s blue below: gold
   means "this is mine", blue means "this is the one I have picked up right now" — a viewer
   can have the first without the second. */
.v-heist-piece--you {
  stroke: var(--v-gold-bright);
  stroke-width: 3;
}

/* Carrying the Iron Crown. The icon is already fully filled by its own `currentColor` path
   (see the note above `--you`), so this cannot also fill differently the way the old solid
   diamond mark did — but a gold stroke ring stands in for it well: gold is reserved
   exclusively for "this fact" (naming contract §A, "no thief hue is gold"), so the ring never
   competes with any player's own colour. */
.v-heist-piece--carrying {
  stroke: var(--v-gold-bright);
}

/* Stunned: out of the coming round. Faded, and it applies to both sides identically. */
.v-heist-piece--stunned {
  opacity: 0.45;
}

/* A piece this viewer may actually drive (G1) — `heist-board.js` selects and drags only
   these. `touch-action: none` stops the browser's own scroll/zoom gesture from racing the
   script's own `pointermove` handling on a touch device, which is what makes the drag
   reliable there at all (see `docs/heist/`'s note that this game is desktop-only by design;
   the property is still correct to set, it just has less to prove here than it would on a
   tablet). The cursor is the one hint that a piece is interactive at all before it is ever
   touched. */
.v-heist-piece--drivable {
  cursor: grab;
  touch-action: none;
  /* `all`, not the SVG default of `visiblePainted`. The icon is a `<use>` of a mask or
     pikeman glyph sitting inside a 30x30 box it does not fill edge to edge, so under the
     default only the glyph's own painted pixels are hit-testable and a press aimed at the
     gap around it — well inside what looks like "the piece" — falls straight through to the
     tile beneath. That silently cost the drag gesture entirely: press-in-the-gap never
     started one, and what looked like a working drag was the click path finishing the move
     on release. `all` makes the whole box respond, painted or not. */
  pointer-events: all;
}

/* While a drag is in flight (`heist-board.js` sets `.is-dragging` on `.v-heist-board`, the
   `<figure>`, same root `.has-selection` and `.is-submitting` use below), the piece being
   dragged shows the "holding it" cursor rather than the "could pick this up" one. */
.v-heist-board.is-dragging .v-heist-piece--drivable {
  cursor: grabbing;
}

/* Selected (G1) — the same blue as a reachable tile, so the link between "this piece" and
   "these lit tiles" reads as one idea rather than two unrelated colours, and thick enough to
   win out over `.v-heist-piece--you`'s gold outline on a thief's own piece (later in the
   cascade than every other piece rule above, on purpose). `.is-selected` is deliberately not
   `v-heist`-prefixed (see the session plan's naming contract): it is a state class, not a
   component, and `heist-board.js` is the only thing that ever adds or removes it. */
.v-heist-piece.is-selected {
  stroke: var(--v-blue);
  stroke-width: 4;
}

/* --- Player colour (naming contract §A) --------------------------------- */
/* One declaration each, `color:` only — every icon path this rides on top of is
   `fill="currentColor"` (`_piece_sprites.html`), so tinting the whole piece (and the ghost
   that shares this same class, further below) never needs more than this. Side is read from
   which icon it is, never from which of these twelve a piece carries — the two palettes are
   free to be maximally distinct within themselves because of that, and no thief hue is gold,
   since gold means "carrying the Iron Crown" (`.v-heist-piece--carrying` above) and must
   never be mistaken for "this is player 5". */
.v-heist-piece--t1 { color: var(--v-heist-p1); }
.v-heist-piece--t2 { color: var(--v-heist-p2); }
.v-heist-piece--t3 { color: var(--v-heist-p3); }
.v-heist-piece--t4 { color: var(--v-heist-p4); }
.v-heist-piece--t5 { color: var(--v-heist-p5); }
.v-heist-piece--t6 { color: var(--v-heist-p6); }
.v-heist-piece--t7 { color: var(--v-heist-p7); }
.v-heist-piece--t8 { color: var(--v-heist-p8); }
.v-heist-piece--g1 { color: var(--v-heist-g1); }
.v-heist-piece--g2 { color: var(--v-heist-g2); }
.v-heist-piece--g3 { color: var(--v-heist-g3); }
.v-heist-piece--g4 { color: var(--v-heist-g4); }

/* --- Own-piece marker and the on-board roll (ask 4, contract §G) -------- */

/* The small downward pin `_board.html` draws above a piece this viewer actually drives
   (`who.piece_id`, side-agnostic — a thief's own crew member and an Austrian's own guard
   both qualify). A plain filled path with no `fill` of its own, so it renders black without
   this; `pointer-events: none` keeps it, like every other tile decoration, from ever being
   what a hover or click lands on instead of the tile beneath it. */
.v-heist-you-pin {
  fill: var(--v-gold-bright);
  pointer-events: none;
}

/* --- Nameplates (owner ask, 2026-09-06) -------------------------------- */
/* "The characters should all have a small nameplate above them so I can know who is who. With
   your own characters in bold." One `<text>` per occupant, drawn above its piece icon by
   `_board.html` at coordinates `views._lay_out_occupants` computed — including the two
   alternating rows that keep two names on one tile from printing through each other.

   `fill: currentColor` is the point: the template puts that occupant's own
   `v-heist-piece--t{n}`/`--g{n}` class on this element as well as on the icon, so the plate is
   always exactly the colour of the piece it names and the two cannot be read as belonging to
   different players. The halo (`paint-order: stroke fill` over an ink-coloured stroke) is the
   same treatment `.v-heist-tile__label` above uses, and it is what lets a bright plate stay
   legible where it crosses a corridor line or another mark.

   `--you` is the bold the ask names. Bold is deliberately NOT the only channel saying "yours":
   `.v-heist-you-pin` above the icon and `.v-heist-piece--you`'s gold ring both still do, which
   is the same "colour/weight is never the only channel" rule the rest of this board follows.

   `pointer-events: none` throughout — a plate sits over the tile's own hit circle, and a name
   that swallowed the click that was meant to select or drop a piece would break the one
   interaction this board exists for. */
.v-heist-nameplate {
  fill: currentColor;
  font-size: 20px;
  font-family: var(--v-font-body, inherit);
  text-anchor: middle;
  paint-order: stroke fill;
  stroke: var(--v-ink);
  stroke-width: 3px;
  stroke-linejoin: round;
  pointer-events: none;
}

.v-heist-nameplate--you {
  font-weight: 700;
}

/* The roll, on the board (ask 4 — "I need to look to the side menu to see what I rolled"),
   carried by the nameplate since 2026-09-06 rather than by a pill of its own: a 54-unit pill
   beside the icon printed straight across whichever piece was fanned out next to it on a
   shared tile, and the plate is already the mark a player looks at to find their own piece.
   Gold, like every other "a fact about your own turn" mark here, and never any player's hue. */
.v-heist-nameplate__roll {
  fill: var(--v-gold-bright);
  font-weight: 400;
}

/* Per-tile step count (ask 4 — "hard to tell how much I can move"). `_board.html` renders an
   empty `<text class="v-heist-tile__steps">` on every tile unconditionally; `heist-board.js`
   fills the text in for a reachable tile once a piece is selected, reading the same
   `#heist-move-options` island that already builds the order form. Hidden with `opacity`
   rather than `display`, so nothing in the SVG's own layout shifts as a selection is made or
   cleared; shown only while `.v-heist-board` (the figure — same root `.is-dragging` and
   `.is-submitting` use) carries `.has-selection`, which `heist-board.js` sets.

   `_board.html` paints this text LAST within the tile, after the footprint, specifically so
   a tracked reachable tile does not turn the number and the shoe into one blot — this
   stylesheet must never fight that by giving the mark any stacking hint of its own (SVG has
   no `z-index`; DOM order alone decides this, and the template's order is authoritative).
   What CSS still owns is contrast: the same `paint-order: stroke fill` halo the landmark
   labels use above, so the digits hold up against a footprint or the tile's own fill sitting
   right behind them rather than merely not touching them. */
.v-heist-tile__steps {
  opacity: 0;
  fill: var(--v-cream);
  font-size: 13px;
  font-weight: 600;
  text-anchor: middle;
  font-family: var(--v-font-mono);
  pointer-events: none;
  paint-order: stroke fill;
  stroke: var(--v-ink);
  stroke-width: 3px;
  stroke-linejoin: round;
}
.v-heist-board.has-selection .v-heist-tile__steps {
  opacity: 1;
}

/* --- Pending orders / "ghosts" (ask 2, contract §E) --------------------- */
/* "When a thief chooses a sewer or a move, show the piece on the map before the host
   resolves — a dashed thief symbol. Same for guards." `_board.html` draws a side's own
   pending orders (never the other side's — `pending_moves` is filtered upstream, and this
   loop cannot leak across that line however it is written) as a `<g class="v-heist-ghost">`
   per order, in its own `<g class="v-heist-ghosts">` drawn LAST, after every real piece, so a
   ghost never sits underneath one and always reads as "on top of" the current picture. */
.v-heist-ghost {
  opacity: .55;
}

/* That "drawn last" is also why this whole layer must never be hit-testable: it paints over
   every tile's `.v-heist-tile__hit` circle wherever an order's destination lands, and
   `heist-board.js` finds a drop target with `document.elementFromPoint()`. Without this, a
   ghost sitting over a tile would shadow that tile for both hover and drag-drop the moment
   anyone had an order in near it — intermittent and maddening, since it only breaks once the
   board already has orders on it. A ghost is a picture of an intention, never a control;
   nothing in this layer — the group, the `<use>` inside it, or the line to it — should ever
   be a click or hover target, so this is set once on the layer rather than repeated per
   element. */
.v-heist-ghosts {
  pointer-events: none;
}

/* "A dashed thief symbol" — the owner's own words. The icon inside is a `<use>` of the same
   solid-fill glyph a real piece wears (`fill="currentColor"` is set on the symbol's own
   `<path>`, which is why `.v-heist-piece--thief`/`--guard` above cannot switch it to an
   outline either), and this markup exposes no separate ring or halo shape to dash instead —
   so the dash is a CSS `outline` around the icon's own box rather than around its
   silhouette. That is a deliberate trade: it is cheap, it is always a clean shape regardless
   of how intricate the mask or pikeman artwork gets, and it reads clearly at the small sizes
   this board uses, at the cost of being a dashed square rather than a dashed circle. */
.v-heist-ghost use {
  outline: 2px dashed currentColor;
  outline-offset: 3px;
}

/* The line from a pending order's origin to its destination. Same dash, same fade logic as
   the ghost it belongs to, and never solid — a solid line would read as a corridor on the
   plan rather than a proposal. */
.v-heist-ghost__line {
  fill: none;
  stroke: currentColor;
  stroke-width: 1.5;
  stroke-dasharray: 4 3;
  opacity: .45;
}

/* Your own pending order (contract §G) stands out from an ally's the same way
   `.v-heist-piece--you` stands out among real pieces: stronger, never a different hue — side
   and identity are still read from the icon and the colour class alone, not from this. */
.v-heist-ghost--you {
  opacity: .85;
}

/* The line needs its own hook for the same emphasis: `.v-heist-ghost__line` is a sibling of
   `.v-heist-ghost`, not a descendant, so `.v-heist-ghost--you` (above) can never reach it —
   `_board.html` wraps both in `<g class="v-heist-ghost-order">` one level up specifically to
   fix that, and `--you` on THIS wrapper is what gives the line a selector at all. With eight
   thieves' orders on the board at once, this is what lets a player pick their own trail out
   at a glance rather than only their own destination marker. Styled here, on the wrapper,
   and not as an `opacity` on `.v-heist-ghost-order--you` itself: `.v-heist-ghost--you` above
   already carries its own `opacity` on the inner group, and stacking a second opacity on the
   wrapper would multiply the two, making a viewer's own ghost fainter than an ally's —
   backwards from the intent. So this rule only ever touches the line. */
.v-heist-ghost-order--you .v-heist-ghost__line {
  opacity: .75;
}

/* Descend (contract §E) needs no rule of its own: `_board.html` already swaps the `<use>`'s
   `href` to the grate icon for a descending order, and the same colour class
   (`v-heist-piece--t{n}`/`--g{n}`) that tints every other ghost tints that icon too, since
   its path is `fill="currentColor"` as well. */

/* --- The sewer grate glyph (owner ask 5, contract §D) ------------------- */
/* "The sewer grate should be a grate drawn to the side of the tile, invisible to guards
   until they find it. The blue box is not clear." `_board.html` draws it as its own
   `<use href="#heist-icon-grate">` beside the tile, never as the tile (the old
   `.v-heist-tile--grate` blue-box rule above the Pieces section is gone). Rendered only when
   `tile.is_grate` is true in the VIEWER'S OWN projection — for an Austrian that key is only
   ever set once that side has already found the door — so this glyph adds no new leak of its
   own; it just draws what the template was already handed.

   `--v-blue` rather than a new token: it is the exact colour the old grate box was painted,
   so the glyph still reads as "the door" to anyone who remembers the previous board, and it
   is already the colour `.v-heist-alerts__item--seal` (§ The alert dialog, below) uses for
   "sewer grate sealed" — the fixture and its own alert agree without a second colour ever
   entering this file. Kept cool and metallic on purpose, never a warm UI colour (gold/red), so it reads as
   ironwork sitting beside the plan rather than a status chip drawn on top of it. */
.v-heist-grate {
  color: var(--v-blue);
  pointer-events: none; /* a fixture on the map, not a click target — the tile's own hit
    circle beneath it still owns hover/click for that spot. */
}

/* Sealed forever: the same red the on-tile bars already use for that fact (G5), so the
   glyph and the mark on the tile agree without a second code to learn. */
.v-heist-grate--sealed {
  color: var(--v-red-bright);
}

/* --- The key ----------------------------------------------------------- */

.v-heist-board__key {
  display: flex;
  flex-wrap: wrap;
  gap: var(--v-3);
  margin-top: var(--v-2);
  font-size: var(--v-fs-sm);
  color: var(--v-cream-2);
}

.v-heist-key {
  display: inline-flex;
  align-items: center;
  gap: var(--v-1);
}

.v-heist-key__mark {
  display: inline-block;
  width: 12px;
  height: 12px;
}

/* Thief, guard, grate, track and cold are now small `<use>`s of the exact icon symbols the
   board itself draws (or, for cold, the same plain ring `_board.html` draws on a tile), so
   the key can never drift from what is actually on the map — replacing the rotated-border
   diamond, the flat red square and the blue-and-gold square these five used to be drawn as
   in pure CSS. They need more room than those flat swatches did: at 12px the track and cold
   icons in particular read as near-invisible dots. Neutral colour throughout, not any
   per-player token — the key describes a SIDE or a KIND of mark, never a specific player, so
   it must not borrow one of the twelve identity colours (naming contract §A); `--v-cream-2`
   is this file's standing "muted ink on dark chrome" neutral, the same one
   `.v-heist-board__key` itself is already set in. `fill: none; stroke: currentColor` covers
   the cold swatch if it turns out to be a plain shape rather than a `<use>`, the same
   belt-and-suspenders reasoning as `.v-heist-footprint--cold` above — harmless for the four
   `<use>`-based icons, whose own `fill="currentColor"` always wins over it regardless. */
.v-heist-key__mark--thief,
.v-heist-key__mark--guard,
.v-heist-key__mark--grate,
.v-heist-key__mark--footprint,
.v-heist-key__mark--cold {
  width: 20px;
  height: 20px;
  color: var(--v-cream-2);
  fill: none;
  stroke: currentColor;
}

.v-heist-key__mark--thief,
.v-heist-key__mark--guard,
.v-heist-key__mark--grate {
  border: none;
  background: none;
  transform: none;
}

.v-heist-key__mark--searched {
  background: var(--v-ink-3);
  border-radius: 50%;
}

/* The three swatches G5/G6 add to the key. Plain HTML rather than a copy of the SVG marks —
   the key is prose about the map, not a second drawing of it. */
.v-heist-key__mark--sealed {
  border-radius: 50%;
  background:
    repeating-linear-gradient(
      to right,
      var(--v-red-bright) 0 2px,
      transparent 2px 5px
    );
}

.v-heist-key__mark--gilt {
  background: none;
}

.v-heist-key__mark--gilt::before {
  content: "\25C8";
  color: var(--v-cream-2);
}

/* `.v-heist-key__mark--footprint` no longer needs a rule of its own beyond the shared icon
   sizing/colour above — its old `::before: "\25AA"` bullet stood in for the mark before the
   key drew the real track icon; now that it does, a second glyph on top of it would just be
   visual noise. No `--cold` rule beyond that shared block either: it is a plain ring rather
   than a named icon symbol, and `fill: none; stroke: currentColor` above already draws it. */

/* --- The order panel --------------------------------------------------- */
/* templates/heist/_order_panel.html. Deliberately plain, and plainer since 2026-09-06: the
   select and the submit button are gone, and the panel is now a read-out — your roll, where
   you stand, what you have submitted — beside hidden forms the board fills in. The decision
   itself happens on the plan above it and out loud on the call, so anything elaborate here
   would compete with the map for the attention the map needs. */


/* --- The ending -------------------------------------------------------- */
/* templates/heist/_ending.html. The reveal, and the moment the night is built around, so it
   is given room rather than folded into the log. */

.v-heist-ending__how {
  font-style: italic;
  color: var(--v-cream-2);
}

.v-heist-ending__axis {
  margin: var(--v-3) 0 var(--v-2);
  color: var(--v-gold-bright);
  font-size: var(--v-fs-md);
}

.v-heist-ending__verdict {
  padding: var(--v-2) var(--v-3);
  border-left: 3px solid var(--v-gold-dim);
  background: rgba(0, 0, 0, 0.18);
}

/* The two results are coloured by which side will be pleased, but each also states the
   outcome in words — the colour is never carrying the meaning on its own. */
.v-heist-ending__verdict--crew {
  border-left-color: var(--v-green);
}

.v-heist-ending__verdict--austrian {
  border-left-color: var(--v-red);
}

.v-heist-ending__list-head {
  font-size: var(--v-fs-sm);
  color: var(--v-cream-2);
  text-transform: uppercase;
  letter-spacing: 0.06em;
}

.v-heist-ending__list {
  margin: var(--v-1) 0 0;
  padding-left: var(--v-4);
}


/* --- The in-play layout ------------------------------------------------ */
/* `_live.html` puts the cathedral, the order panel (or the host's round controls) and the
   event log in one `.v-grid v-grid--table` once the room leaves the lobby. `.v-grid` alone
   is a bare `display: grid`, so without this rule the three panels stacked in a single
   full-width column — which contradicts docs/PAGES.md §14.5, the template's own header and
   the gameplan's Art style ("show map, order panel and event log side by side at all
   times"). Same shape as `.v-grid--roster` (§4): fixed tracks rather than `.v-grid--2`'s
   auto-fit, whose 280px minmax would have split the 1360px container into four columns.

   The board is the wide track because it is the information surface of the game; the order
   panel and the log share the narrow one, stacked by the `.v-stack` the template wraps them
   in. `minmax(0, …)` on the board's track lets the SVG scale down with its column instead of
   forcing the track to its intrinsic width; the side track keeps a floor so a long room name
   on a destination button never squeezes it to nothing. `align-items: start` stops a short
   order panel from being stretched to the board's height.

   No small-viewport override on purpose — the gameplan's anti-goals rule out spending
   effort there ("Not mobile. Desktop only"), and this grid is used by the heist alone. */
.v-grid--table {
  grid-template-columns: minmax(0, 2fr) minmax(320px, 1fr);
  align-items: start;
}

/* A grid item's automatic minimum size is its content, which for the board panel is the
   floorplan and for the side panels is the longest unbreakable room name. Both are told
   they may shrink, so neither can widen its track past the shares above. */
.v-heist-board-panel,
.v-heist-orders,
.v-heist-log-panel {
  min-width: 0;
}

/* --- The in-play room-status strip -------------------------------------- */
/* `templates/heist/_room_status.html`, in-play branch. One line above the board carrying the
   room code, the phase, the round, the seat count and (host only) Reset — the same facts the
   lobby's full `.v-panel` shows stacked. It is a strip and not a panel because everything
   above the board is height the plan does not get, and the map has to fit a 1920x1080 browser
   without a scrollbar (owner ask, 2026-09-06); the panel spent about 240px saying this.

   Same aged-paper vocabulary as the rest of the game, one notch quieter than a panel head:
   this is chrome above the thing being looked at, not a thing to look at. `flex-wrap` so a
   narrow board column drops the host's button onto a second line rather than overflowing the
   grid track it sits in. */
.v-heist-status {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--v-1) var(--v-3);
  padding: var(--v-2) var(--v-3);
  background: var(--v-panel-dark-2);
  border: var(--v-border);
  border-radius: var(--v-radius);
  color: var(--v-cream-2);
  font-size: var(--v-fs-sm);
}

.v-heist-status__room {
  font-family: var(--v-font-display);
  font-size: var(--v-fs-md);
  letter-spacing: 0.06em;
  color: var(--v-gold-bright);
}

/* A hairline between items rather than a bullet character: a border is never read out as
   content by a screen reader and never mistaken for one of the values. `--v-border`'s own
   dim gold, not `--v-border-ink`, which is a dark ink hairline meant for the paper surfaces
   and would be invisible on this dark strip. */
.v-heist-status__item + .v-heist-status__item,
.v-heist-status__room + .v-heist-status__item {
  border-left: var(--v-border);
  padding-left: var(--v-3);
}

.v-heist-status strong { color: var(--v-cream); }

/* --- The in-play layout, v2 (contract §H, ask 6) ------------------------ */
/* "Rework the UI to fit: wider map, and do something about 'the night so far'." Once "the
   night so far" moved into `.v-heist-drawer` below, the grid above it is down to two tracks
   instead of three — the board and the order panel / host round controls — so `_live.html`
   uses this instead of `.v-grid--table` for that grid now. `.v-grid--table` itself is left
   exactly as it was above: other pages may come to use it, and nothing here may assume it
   changed shape.

   `340px` rather than `.v-grid--table`'s flexible `minmax(320px, 1fr)`: the order panel is
   now the ONLY occupant of the right rail (no log to share it with any more), so it does not
   need to flex to make room for anything. `minmax(0, 1fr)` on the board's track for the same
   reason `.v-grid--table` needed it above: the SVG has to be allowed to shrink with its
   column instead of forcing the track to its own intrinsic width. `align-items: start` for
   the same reason as `.v-grid--table` too — a short order panel must not stretch to the
   board's height. */
.v-heist-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) 340px;
  gap: var(--v-4);
  align-items: start;
}

/* Submitting an order, or the host resolving a round, jumped the whole page to the bottom
   of the document — reported 2026-09-06, root-caused the same day (plan
   2026-09-06-heist-ui-improvements.md, "G4 — Scroll"). It is browser **scroll anchoring**,
   not htmx, not site.js, not the log's DOM order: swapping `#heist-live` (`outerHTML`)
   while any descendant holds focus lets Chrome pick a scroll anchor inside the subtree the
   swap is about to destroy, and the anchoring adjustment that follows lands the viewport at
   `scrollHeight - innerHeight` — the document's bottom. Verified in headless Chromium: the
   jump survives blocking site.js entirely, un-reversing the log's column order, and
   stripping the focused element's `id` (so htmx's own `preventScroll: true` focus restore
   is not it either); it disappears the instant this one declaration is added and nothing
   else changes. This looks like a no-op — it sets an anchoring mode most elements never
   need — so do not delete it as dead code. Firefox implements both scroll anchoring and
   `overflow-anchor`; Safari implements neither, so it never had the bug. Scoped to this one
   region so anchoring keeps working everywhere else on the site. */
#heist-live {
  overflow-anchor: none;
}

/* --- The event log ---------------------------------------------------- */
/* templates/heist/_event_log.html. Sixteen rounds of lines would otherwise grow the page
   without bound and push the board off the top of the screen, so the feed keeps its own
   scrollbox. Capped against the viewport as well as in absolute terms — this default ceiling
   still applies anywhere `.v-heist-log` is not inside `.v-heist-drawer`, which overrides it
   below now that "the night so far" no longer shares a column with anything. */
.v-heist-log {
  max-height: min(24rem, 40vh);
  overflow-y: auto;
  overscroll-behavior: contain;
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  /* Newest line is first in the DOM (heist/_event_log.html), so plain column order already
     puts it at the top; nothing here needs to flip anything. */
  flex-direction: column;
  gap: 1px;
}

/* --- The log drawer (owner ask 6, contract §H) -------------------------- */
/* "The night so far" moves off the board's column into a right-edge slide-out so the board
   can take the width the log gives up — the headline layout ask, alongside the wider
   container above. Toggled by `.v-heist-drawer__toggle` in the board panel's own head
   (`_live.html`); JS persists the open state in `localStorage["heist-log-open"]` and applies
   `.is-open` here on load and on toggle — `data-default-open` (server-rendered: open for the
   host, closed for a player) is only the first paint's guess, before that choice is known.

   Deliberately backdrop-free: a modal-style scrim over the board would stop a player reading
   the log mid-move, which is exactly the "keep playing with it open" this ask needs. Fixed to
   the viewport, not the grid column, so it can slide fully clear of the board without leaving
   a gap in `.v-heist-layout`'s track — the drawer is not one of that grid's two tracks, it
   floats above both.

   QA fix, 2026-09-06 (heist-readability gate): at every realistic desktop width (this session's
   own 1440px and 1920px screenshots both showed it), a fixed 380px-wide right-edge panel lands
   exactly on top of `.v-heist-layout`'s own 340px order-panel / host-round-controls column —
   the container tops out at 1760px, so only a viewport wider than roughly 2500px leaves enough
   margin outside the container for the drawer to clear it. Open by default for the host, this
   hid the resolve-round button and the narration field entirely behind the very drawer the host
   is supposed to read while narrating. `--v-heist-drawer-w` plus the `body.v-heist-drawer-open`
   rule below reserve that width out of `.v-heist-layout` while the drawer is open, so the grid's
   own `minmax(0, 1fr)` board track gives up width instead of the order panel disappearing under
   an overlay. JS (`heist-board.js`, `applyDrawerState`) toggles the body class; it is on `body`
   rather than `#heist-live` because the class must survive `#heist-live`'s own outerHTML swap
   without waiting for `initDrawer` to run again first. Declared on `:root`, not scoped inside
   `.v-heist-drawer` itself, because a custom property is only visible to the element that
   declares it and that element's own descendants — `.v-heist-layout` is this element's
   sibling, not its descendant, and silently computed to nothing (padding-right: 0, the
   overlap this fix exists to remove) the first time this was scoped that way. */
:root {
  --v-heist-drawer-w: 380px;
}

.v-heist-drawer {
  position: fixed;
  top: var(--v-topbar-h);
  right: 0;
  bottom: 0;
  width: var(--v-heist-drawer-w);
  max-width: 90vw;
  display: flex;
  flex-direction: column;
  background: var(--v-panel-dark-2);
  border-left: var(--v-border);
  box-shadow: var(--v-shadow-lg);
  transform: translateX(100%);
  transition: transform 200ms ease-out;
  /* Above `.v-topbar`'s 50 (§4), so it slides out from under the bar's trailing edge rather
     than behind it; below the descend popup and drag ghost's 500, so an order actually in
     progress is never hidden by the drawer sliding open over it. */
  z-index: 60;
}

.v-heist-drawer.is-open {
  transform: translateX(0);
}

/* Reserves the drawer's own width out of the two-track grid while it is open, so the order
   panel / host round controls shrink and stay visible instead of sitting underneath the fixed
   overlay above. `max-width: 90vw` on the drawer itself only bites on very narrow viewports,
   where this padding would leave no room for either grid track — harmless there, since the
   drawer's own comment already treats sub-desktop widths as out of scope for this page. */
body.v-heist-drawer-open .v-heist-layout {
  padding-right: var(--v-heist-drawer-w);
}

@media (prefers-reduced-motion: reduce) {
  .v-heist-drawer {
    transition: none;
  }
}

/* The drawer's own title sits at the leading edge and its close button at the trailing one.
   The title used to be left off, on the reasoning that the toggle which opens the drawer
   already names it; that stopped being true once the drawer took the roster in beside the
   event log, because a reader who has it open needs to know the panel holds both. */
.v-heist-drawer__head {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--v-3);
  flex: none;
  padding: var(--v-2) var(--v-3);
  border-bottom: var(--v-border);
}

/* Names the drawer for someone already inside it, so it matches the toggle that opened it.
   Styled as a panel head rather than a heading: it labels a region, and a real <h*> here
   would inject a rank into the page outline that this slide-out does not deserve. */
.v-heist-drawer__title {
  min-width: 0;
  color: var(--v-gold-bright);
  font-family: var(--v-font-display, inherit);
  font-size: var(--v-fs-sm);
  letter-spacing: 0.04em;
  text-transform: uppercase;
}

/* Fills whatever height is left in the drawer and, in turn, stretches its one child
   (`.v-heist-log-panel`) to match. `min-height: 0` here and at every link in that chain below
   is load-bearing, not decoration: a flex item's automatic minimum size is its content's
   intrinsic height, and without overriding that a tall log would keep growing this box past
   the drawer instead of the log's own scrollbar ever engaging. */
.v-heist-drawer__body {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
  padding: var(--v-3);
}

/* Inside the drawer, "the night so far" stops being an ordinary content panel and becomes
   the drawer's one piece of content: its own head ("The night so far" title) pinned at the
   top, the log itself the thing that scrolls. Scoped to `.v-heist-drawer` so bare `.v-panel`/
   `.v-panel__body` keep their ordinary block behaviour everywhere else they are used. */
.v-heist-drawer .v-heist-log-panel {
  display: flex;
  flex-direction: column;
  flex: 1 1 auto;
  min-height: 0;
}
.v-heist-drawer .v-heist-log-panel .v-panel__body {
  flex: 1 1 auto;
  min-height: 0;
  display: flex;
  flex-direction: column;
}

/* The 24rem/40vh ceiling above existed only to keep the log from crowding the order panel it
   used to share a column with; that constraint is gone now that the log has its own drawer,
   so here it fills whatever height `.v-heist-drawer__body` gives it instead, and keeps doing
   its own internal scrolling exactly as it did before. */
.v-heist-drawer .v-heist-log {
  max-height: none;
  flex: 1 1 auto;
  min-height: 0;
}

/* The toggle lives in the board panel's head (`.v-panel__head`, already
   `display: flex; align-items: center; gap: var(--v-3)`), which has no wrapper around its
   title text — `margin-left: auto` is the only thing that pushes this to the head's trailing
   edge. A bare `<button>` with no reset applied anywhere else, so every one of these is this
   file's job, not a browser default left in place. */
.v-heist-drawer__toggle {
  appearance: none;
  margin: 0 0 0 auto;
  padding: var(--v-1) var(--v-3);
  font: inherit;
  font-size: var(--v-fs-sm);
  color: var(--v-cream);
  background: var(--v-panel-dark);
  border: 1px solid var(--v-gold-dim);
  border-radius: var(--v-radius);
  cursor: pointer;
}
.v-heist-drawer__toggle:hover,
.v-heist-drawer__toggle:focus-visible {
  border-color: var(--v-gold-bright);
  color: var(--v-gold-bright);
}

/* The event count on the toggle — "there is something to read" without opening the drawer to
   find out. */
.v-heist-drawer__badge {
  display: inline-block;
  min-width: 1.4em;
  padding: 0 0.35em;
  margin-left: var(--v-1);
  border-radius: 999px;
  background: var(--v-gold-dim);
  color: var(--v-ink);
  font-size: var(--v-fs-xs);
  font-weight: 700;
  text-align: center;
}

/* A visible close affordance inside the drawer itself, not only the toggle that opened it —
   the owner should never have to hunt back to wherever they clicked from. Also a bare
   `<button>`, reset the same way as the toggle above. */
.v-heist-drawer__close {
  appearance: none;
  margin: 0;
  padding: var(--v-1) var(--v-3);
  font: inherit;
  font-size: var(--v-fs-sm);
  color: var(--v-cream-2);
  background: none;
  border: 1px solid transparent;
  border-radius: var(--v-radius);
  cursor: pointer;
}
.v-heist-drawer__close:hover,
.v-heist-drawer__close:focus-visible {
  color: var(--v-gold-bright);
  border-color: var(--v-gold-dim);
}

/* One line. The 3px left rule is reserved space on every line, so the audience colours below
   change nothing about the layout. */
.v-heist-log__line {
  display: flex;
  align-items: baseline;
  gap: var(--v-2);
  padding: var(--v-1) var(--v-2);
  font-size: var(--v-fs-sm);
  color: var(--v-cream);
  border-left: 3px solid transparent;
  border-radius: 0 var(--v-radius) var(--v-radius) 0;
}

/* The round stamp, in the mono face and a fixed column so the texts line up under each other
   rather than stepping in and out between "r9" and "r16". */
.v-heist-log__round {
  flex: none;
  min-width: 2.4em;
  text-align: right;
  font-family: var(--v-font-mono);
  font-size: var(--v-fs-xs);
  color: var(--v-cream-2);
}

.v-heist-log__text { min-width: 0; }

/* Audience. This is the security model made visible: which feed a line came from decides
   whether the reader may act on it in front of the other side. Colour never carries it
   alone — the two side feeds take the same marks the board draws their pieces with, and the
   two restricted feeds are labelled in words.

   `--all` is the shared feed, and the host's narration lands in it (`services.narrate` logs
   AUDIENCE_ALL). There is no separate audience or flag for narration, so no selector can
   pick it out of the ordinary public events beside it; this rule gives the whole shared feed
   the gold rule and full-strength ink, and singling narration out needs a model change
   rather than a stylesheet one. */
.v-heist-log__line--all {
  border-left-color: var(--v-gold-dim);
}

/* The host's own prose, which shares its audience with every other public line and so cannot
   be told from one by `audience` alone — RoundEvent.is_narration exists for exactly this.
   Given full weight and the bright gold: the design doc calls the narrate button "worth more
   than everything else on the list", and a line the host wrote by hand should not look like
   "The night begins." A left quote marks it without relying on colour. */
.v-heist-log__line--narration {
  border-left-color: var(--v-gold-bright);
  background: rgba(194, 161, 90, 0.09);
  color: var(--v-cream);
  font-style: italic;
}

.v-heist-log__line--narration .v-heist-log__text::before {
  content: "\201C";
  font-family: var(--v-font-display);
  color: var(--v-gold-bright);
  font-style: normal;
}

/* The thieves' own feed. Open diamond, as on the board. */
.v-heist-log__line--thieves { border-left-color: var(--v-green); }
.v-heist-log__line--thieves .v-heist-log__text::before { content: "\25C7\00A0"; }

/* The Austrians' own feed. Filled square, as on the board. */
.v-heist-log__line--austrians { border-left-color: var(--v-blue); }
.v-heist-log__line--austrians .v-heist-log__text::before { content: "\25A0\00A0"; }

/* Host-only, and one-player-only. Both are labelled rather than merely tinted: on the host
   console every audience arrives in one feed, and "who else can read this" is not something
   to leave to a colour a reader may not see. */
.v-heist-log__line--host { border-left-color: var(--v-gold); }
.v-heist-log__line--player { border-left-color: var(--v-red-bright); }

.v-heist-log__line--host .v-heist-log__text::before,
.v-heist-log__line--player .v-heist-log__text::before {
  margin-right: var(--v-1);
  font-family: var(--v-font-display);
  font-size: var(--v-fs-xs);
  letter-spacing: 0.12em;
  text-transform: uppercase;
  color: var(--v-cream-2);
}

.v-heist-log__line--host .v-heist-log__text::before { content: "host only"; }
.v-heist-log__line--player .v-heist-log__text::before { content: "private"; }

/* --- The host's round controls ---------------------------------------- */
/* templates/heist/_host_round.html. There is no timer in this game — the host is the clock —
   so this roster is the only thing telling them whether the table is ready. */
.v-heist-submitted {
  display: flex;
  flex-direction: column;
  gap: 2px;
}

/* "Thieves" / "Austrians", emitted between the groups by the template's `ifchanged`. */
.v-heist-submitted__head {
  margin-top: var(--v-3);
  padding-bottom: var(--v-1);
  font-family: var(--v-font-display);
  font-size: var(--v-fs-xs);
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--v-gold);
  border-bottom: 1px solid rgba(194, 161, 90, 0.2);
}

.v-heist-submitted__head:first-child { margin-top: 0; }

/* One seat: who they are on the left, what they ordered on the right. */
.v-heist-submitted__row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--v-3);
  padding: var(--v-1) var(--v-2);
  border-left: 4px solid transparent;
  border-radius: 0 var(--v-radius) var(--v-radius) 0;
}

/* Submitted: a tick and a green rule, and otherwise deliberately unremarkable. The host is
   scanning for the rows that are NOT this one. */
.v-heist-submitted__row--in { border-left-color: var(--v-green); }

.v-heist-submitted__row--in > span:first-child::before {
  content: "\2713\00A0";
  font-weight: 700;
  color: var(--v-green);
}

/* Still waiting. The one thing on this console that has to survive a glance down twelve
   rows, so it gets every channel at once: a maroon band, a bright rule, bold text, and an
   empty circle against the tick above. */
.v-heist-submitted__row--out {
  background: var(--v-maroon);
  border-left-color: var(--v-red-bright);
  font-weight: 700;
  color: var(--v-cream);
}

.v-heist-submitted__row--out > span:first-child::before {
  content: "\25CB\00A0";
  color: var(--v-gold-bright);
}

.v-heist-submitted__row--out .v-heist-player-detail { color: var(--v-gold-bright); }

/* The narrate box. The design doc calls this line worth more than everything else on the
   list, so it is sized to invite a sentence rather than a word — `.v-input`'s 44px floor
   would have shown one line of a two-row textarea. */
.v-heist-narrate {
  min-height: 5.5em;
  line-height: 1.5;
  resize: vertical;
}

/* --- T4: board-driven movement, footprints and the compass rose (G1, G5, G6, G8) -------- */
/* templates/heist/_board.html, static/js/heist-board.js. New blocks only, appended here
   rather than folded into the sections above, so this addition and the alert work landing
   in the same file from a parallel session stay two contiguous diffs instead of one
   interleaved one. */

/* The compass rose. Static SVG, identical on every side's board, drawn in the plan's
   top-left margin — the viewBox in `_board.html` was widened to make room for it rather
   than shrinking the grid to fit it in the old one. */
.v-heist-compass__ring {
  fill: none;
  stroke: var(--v-gold-dim);
  stroke-width: 1.5;
}

.v-heist-compass__tick {
  stroke: var(--v-gold-dim);
  stroke-width: 1.5;
}

/* North gets the bright rule the outer ring's landmarks use elsewhere on this board, so it
   is the one point on the rose the eye finds first. */
.v-heist-compass__tick--north {
  stroke: var(--v-gold-bright);
  stroke-width: 2;
}

.v-heist-compass__label {
  fill: var(--v-gold-bright);
  font-size: 13px;
  font-family: var(--v-font-display);
  text-anchor: middle;
}

/* A footprint (G6), Austrian boards only. Restyled for contract §F: the hand-drawn
   ellipse+circle is gone in favour of `<use href="#heist-icon-track">`, whose own `<path>` is
   `fill="currentColor"` — so this group needs `color`, not `fill`/`stroke`, to tint it, the
   same as every other icon-based mark on this board.

   Two properties on this element are FORBIDDEN here regardless of what looks tempting:
   `_board.html` sets both `opacity` (the age fade, computed in
   `services._footprint_tracks` and floored at `TRACK_FADE_FLOOR`) and, when a departure
   direction is known, `transform="rotate(...)"` (the bearing) inline, per tile. A stylesheet
   value for either would not merge with the inline one — it would silently replace it with
   the same number on every tile, erasing the fade floor and pointing every rotated track the
   same way. Neither property appears anywhere below.

   A stop is a named thief who actually halted there; a pass is a trail nobody can name. That
   used to be fill-vs-outline; sharing one filled icon between them now, the channel is a
   glow instead — a stop gets a soft `drop-shadow`, a pass does not — which is still a
   shape/weight distinction and not colour alone, and touches neither of the two forbidden
   properties above. */
.v-heist-footprint {
  pointer-events: none;
}
.v-heist-footprint--stop {
  color: var(--v-cream);
  filter: drop-shadow(0 0 2px var(--v-cream));
}

.v-heist-footprint--pass {
  color: var(--v-cream-2);
}

/* A cold read (ask 7): a guard searched here and found nothing. Previously this left no mark
   on the board at all, so the log was the only record of ground already covered — exactly
   the re-reading the owner complained about. Rendered as a plain hollow ring rather than the
   track icon (nobody walked here, so a footprint glyph would overstate what was found), and
   deliberately the weakest of the three marks: no glow (that is `--stop`'s), no icon fill,
   just a thin ring in `--v-ink-3` — already this file's colour for background information
   that is not meant to compete for attention (the corridor lines and a searched tile's own
   fill both use it). That also keeps it well clear of `.v-heist-tile__searched-x`'s
   `--v-cream-2`, a different fact (the crew's own bookkeeping) on a different side's board —
   the two must never read as the same mark. `fill: none` + `stroke: currentColor` in case a
   future markup change draws this as a plain shape rather than an icon reference; either way
   `opacity` stays off this rule for the same reason it is off `--stop`/`--pass` above — the
   inline per-tile age fade must be the only thing setting it. */
.v-heist-footprint--cold {
  color: var(--v-ink-3);
  fill: none;
  stroke: currentColor;
  stroke-width: 1;
}

/* The drag ghost. Fixed to the pointer by `heist-board.js`, and `pointer-events: none` is
   load-bearing, not decoration: the script finds the drop target with
   `document.elementFromPoint`, and a ghost that could itself receive pointer events would be
   the element found under every single drag. */
.v-heist-drag-ghost {
  position: fixed;
  z-index: 500;
  width: 22px;
  height: 22px;
  margin: -11px 0 0 -11px;
  border-radius: 50%;
  background: var(--v-blue);
  opacity: 0.55;
  border: 2px solid var(--v-gold-bright);
  pointer-events: none;
}

/* The on-board descend choice (G1). A small fixed-position card `heist-board.js` positions
   itself, near the tile the choice is about — never inside the SVG, which cannot host an
   HTML button. Framed like the rest of the site's popovers rather than invented from
   scratch. */
.v-heist-descend {
  position: fixed;
  z-index: 500;
  display: flex;
  flex-direction: column;
  gap: var(--v-1);
  padding: var(--v-2);
  background: var(--v-panel-dark-2);
  border: var(--v-border);
  border-radius: var(--v-radius);
  box-shadow: var(--v-shadow);
}

.v-heist-descend__btn {
  padding: var(--v-1) var(--v-3);
  font-size: var(--v-fs-sm);
  font-family: var(--v-font-body, inherit);
  color: var(--v-cream);
  background: var(--v-panel-dark);
  border: 1px solid var(--v-gold-dim);
  border-radius: var(--v-radius);
  cursor: pointer;
  white-space: nowrap;
}

.v-heist-descend__btn:hover,
.v-heist-descend__btn:focus-visible {
  border-color: var(--v-gold-bright);
  color: var(--v-gold-bright);
}

/* --- The track tooltip (owner ask 7, contract §F) ----------------------- */
/* "Tracking information as a guard is hard: the shoes are tiny and the log has to be
   re-read. Hovering a track should say what was found and how old it is." Every tile carries
   a `data-tip` string (`_board.html`) alongside its native `<title>`; `heist-board.js`
   positions this panel itself, `position: fixed` near the pointer, next to the tile's own
   `<title>` fallback for a no-JS view — same family as the descend popup and the drag ghost
   above: a small HTML overlay beside the SVG, since the SVG cannot host a wrapping paragraph
   of prose the way `tile.track.summary` needs.

   Below the descend popup's 500 (a modal choice always wins if the two could ever be visible
   together, which should not happen but costs nothing to guarantee) and well above the
   board itself. `pointer-events: none` — a tooltip must never be what a hover lands on, or
   hovering it would stop the tile it describes from being hovered and it would vanish out
   from under the pointer that is reading it. No transition of any kind: ask 7 is about the
   text appearing the instant a hover starts, not fading in behind it. */
.v-heist-tip {
  position: fixed;
  z-index: 450;
  max-width: 260px;
  padding: var(--v-2) var(--v-3);
  background: var(--v-panel-dark-2);
  border: var(--v-border);
  border-left: 2px solid var(--v-gold);
  border-radius: var(--v-radius);
  box-shadow: var(--v-shadow);
  color: var(--v-cream);
  font-size: var(--v-fs-sm);
  line-height: 1.4;
  pointer-events: none;
}

/* --- The alert dialog --------------------------------------------------- */
/* static/js/heist-toasts.js. Was a stack of auto-fading toasts in the bottom-right corner
   until the owner asked (2026-09-06) for "a pop up in the middle of your screen which you can
   close/acknowledge": a capture or an escape is the one thing on this page a player must not
   miss, and an 8-second fade in the corner of a 1760px board was missable by design. One
   centred dialog now lists every alert since the last dismissal and stays until the player
   dismisses it — acknowledge button, Escape, or a backdrop click, and nothing else. No
   animation carries information here, so nothing depends on motion at all.

   `#heist-alerts` (_live.html) sits outside `#heist-live` on purpose — see that template's
   header comment — so the open dialog, its unacknowledged list and the focus inside it all
   survive every poll and mutation swap untouched. Fixed to the viewport (z-index above the
   descend popup's 500, §19: a modal the player has to acknowledge outranks everything else
   that can be on screen, and unlike the drawer it is not something to keep playing around).

   Closed, the host must not sit between a click and the board underneath it, so pointer
   events and visibility are both gated on `.is-open` — G4 exists precisely because this game
   is sensitive to anything stealing a click or the scroll position. */
.v-heist-alerts {
  position: fixed;
  inset: 0;
  z-index: 600;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--v-4);
  visibility: hidden;
  pointer-events: none;
}

.v-heist-alerts.is-open {
  visibility: visible;
  pointer-events: auto;
}

/* Dimmed, not blacked out: the board stays legible behind the dialog, so an alert about a
   tile can be read against the tile it is about. `opacity` on a token rather than a
   translucent colour literal — this stylesheet has no colour literals (§1). */
.v-heist-alerts__backdrop {
  position: absolute;
  inset: 0;
  background: var(--v-bg);
  opacity: 0.72;
}

.v-heist-alerts__dialog {
  position: relative;
  width: min(520px, 90vw);
  max-height: 80vh;
  display: flex;
  flex-direction: column;
  gap: var(--v-3);
  padding: var(--v-5);
  background: var(--v-panel-dark-2);
  border: var(--v-border);
  border-radius: var(--v-radius-lg);
  box-shadow: var(--v-shadow-lg);
  color: var(--v-cream);
}

/* Fade and rise, on EVERY open and not just the first. The dialog element is built once and
   reused for the life of the page (heist-toasts.js), so an `animation` on the element itself
   only ever ran at insertion — every reopen after the first appeared instantly, which is not
   what the keyframes below claim to do. Hanging the declaration off `.is-open` instead means
   the rule starts applying each time the class is added, and an animation restarts whenever
   it starts applying. */
.v-heist-alerts.is-open .v-heist-alerts__dialog {
  animation: v-heist-alerts-in 140ms ease-out;
}

.v-heist-alerts__dialog:focus-visible,
.v-heist-alerts__ack:focus-visible {
  outline: 2px solid var(--v-gold-bright);
  outline-offset: 2px;
}

.v-heist-alerts__title {
  margin: 0;
  font-family: var(--v-font-display);
  font-size: var(--v-fs-lg);
  color: var(--v-gold-bright);
}

/* Scrolls internally rather than growing the dialog: a round can produce several captures at
   once, and a night's ending arrives on top of whatever the last round left unacknowledged. */
.v-heist-alerts__list {
  margin: 0;
  padding: 0;
  list-style: none;
  overflow-y: auto;
  display: flex;
  flex-direction: column;
  gap: var(--v-2);
}

.v-heist-alerts__item {
  padding: var(--v-2) var(--v-3);
  background: var(--v-panel-dark);
  border: var(--v-border);
  border-left: 4px solid var(--v-gold-dim);
  border-radius: var(--v-radius);
  font-size: var(--v-fs-base);
  line-height: 1.4;
}

/* Colour is never the only channel a kind is told apart by — the label beside it is real text
   the script writes (KIND_LABELS, heist-toasts.js), not CSS `content`, so it is in the
   accessibility tree too. Same rule `.v-heist-log__line--host`/`--player` follow above (§19,
   "The event log"). */
.v-heist-alerts__kind {
  display: block;
  margin-bottom: var(--v-1);
  font-family: var(--v-font-display);
  font-size: var(--v-fs-xs);
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--v-gold);
}

.v-heist-alerts__item--capture { border-left-color: var(--v-red-bright); }
.v-heist-alerts__item--capture .v-heist-alerts__kind { color: var(--v-red-bright); }

.v-heist-alerts__item--crown { border-left-color: var(--v-gold-bright); }
.v-heist-alerts__item--crown .v-heist-alerts__kind { color: var(--v-gold-bright); }

.v-heist-alerts__item--seal { border-left-color: var(--v-blue); }
.v-heist-alerts__item--seal .v-heist-alerts__kind { color: var(--v-blue); }

.v-heist-alerts__item--escape { border-left-color: var(--v-green); }
.v-heist-alerts__item--escape .v-heist-alerts__kind { color: var(--v-green); }

.v-heist-alerts__item--ending { border-left-color: var(--v-gold); }
.v-heist-alerts__item--ending .v-heist-alerts__kind { color: var(--v-gold); }

.v-heist-alerts__actions {
  display: flex;
  justify-content: flex-end;
}

/* Purely decorative: the dialog is already in the DOM and already focused when this runs, and
   nothing removes it when it ends. */
@keyframes v-heist-alerts-in {
  from { opacity: 0; transform: translateY(8px); }
  to   { opacity: 1; transform: translateY(0); }
}

@media (prefers-reduced-motion: reduce) {
  .v-heist-alerts.is-open .v-heist-alerts__dialog { animation: none; }
}
