/* Self-hosted rather than pulled from Google Fonts: an @import of an
   external stylesheet is render-blocking, so on a network path where the
   external CDN is slow or unreachable (much more likely on a mobile
   connection than on the machine used for local testing), the whole page
   could sit unstyled/blank until it timed out. Only weight 700 is used. */
/* Verdana — the reference game's own UI font — self-hosted the same way,
   so it renders identically on machines that don't ship it (Linux, most
   phones). Registered under the name "Verdana" itself, so every
   `font-family: Verdana, ...` rule in this file picks these up with no
   other change; local() first, so a machine that already has Verdana
   installed (Mac/Windows) just uses its own copy and skips the download. */
@font-face {
  font-family: 'Verdana';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: local('Verdana'), url('/fonts/verdana.ttf') format('truetype');
}
@font-face {
  font-family: 'Verdana';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: local('Verdana Bold'), local('Verdana-Bold'), url('/fonts/verdana-bold.ttf') format('truetype');
}
@font-face {
  font-family: 'Verdana';
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: local('Verdana Italic'), local('Verdana-Italic'), url('/fonts/verdana-italic.ttf') format('truetype');
}
@font-face {
  font-family: 'Verdana';
  font-style: italic;
  font-weight: 700;
  font-display: swap;
  src: local('Verdana Bold Italic'), local('Verdana-BoldItalic'), url('/fonts/verdana-bold-italic.ttf') format('truetype');
}
@font-face {
  font-family: 'Cinzel';
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url('/fonts/cinzel-700.woff2') format('woff2');
}

:root {
  color-scheme: light;
  /* Parchment/wood theme (v0.22.0) — replaces the earlier dark flat
     theme, built around assets/theme/ (sorted from assets/DUMPFOLDER's
     classic-Tribal-Wars chrome: a tileable parchment paper texture, a
     wood-toned panel border, a resource-bar pill). --bg is the flat
     fallback color under that tiled texture (see body's background
     below), --panel a lighter cream for content surfaces on top of it. */
  --bg: #e6d3a3;
  /* The whole-page wood-trim frame's own flat fallback color (body's
     background-color, showing through wherever the frame art/
     parchment texture's alpha is transparent) — a separate variable
     from --bg per request, since --bg is also reused for several
     form-input backgrounds elsewhere that weren't part of this ask
     and shouldn't shift color along with the frame. */
  --frame-bg: #f4e4bc;
  --panel: #f6ecd2;
  --panel-border: #8a6633;
  /* Light gold per request — for row dividers specifically (Troops/
     the queues/Movements), not every other use of --panel-border
     (panel/table/modal outlines, heading underlines, ...) — an
     earlier pass changed the shared variable itself, which was too
     broad; reverted above, this is its own property instead. */
  --row-divider: #edd8ad;
  --text: #332412;
  --muted: #6e5a3c;
  --accent: #7a4c12;
  --accent-dim: #a97a2c;
  /* Filled surfaces (buttons, badges) stay dark/saturated the way the
     old dark theme's whole page was, so they can keep hosting light
     text — see --btn-text below — rather than every button needing its
     own bespoke text color now that the page itself is light. */
  --btn-text: #fdf3de;
  /* A darker gold than --accent-dim, computed to clear 4.5:1 (WCAG AA)
     against --btn-text — plain --accent-dim only manages ~3.46:1 with
     it. .build-btn/.nav-pill get away with --accent-dim anyway because
     their visible surface is actually the dark maroon --button-art
     image layered on top (see --button-art below), not this flat
     color — but a couple of spots (.modal-btn.modal-confirm's resting
     state, .unit-info-popup-header, .skip-link) render --accent-dim as
     a genuinely flat fill behind --btn-text and need this instead. */
  --accent-dim-contrast: #8a641f;
  --danger: #c0533f;
  /* Text-safe on the new light --panel (the old value, #e59a89, was
     picked for a dark page and reads far too pale on cream — see
     --btn-text for the still-dark, still-light-text-hosting use of
     --danger/--danger-dim as a background instead). */
  --danger-text: #9c3320;
  /* Dimmed the same way --accent-dim relates to --accent: a solid resting
     state for a destructive button, with the full --danger reserved for
     hover/focus — so every button in the app is a filled shape at rest,
     never an outline that only fills in on interaction. */
  --danger-dim: #6e3226;
  /* True neutral grey, not --muted (a warm tan) — the map's per-player
     palette (mapApp.js's PALETTE) already includes a grey-ish entry some
     player could get hashed onto, so this needs to be visually
     unambiguous as "not a player" rather than just "a muted tone". */
  --barbarian: #6b6b6b;
  /* Dark enough to read as text on --panel (production rates, "Win"
     reports, affordable costs all set color:var(--ok) directly) — see
     --btn-text for the one spot (.success-inline) that puts it behind
     light text as a background instead. */
  --ok: #3d6b28;
  /* .side-panel h2's own background (Production/Troops/Loyalty/etc's
     title bars) — a muted gold per a direct pixel-pick on a reference
     screenshot (an earlier pass misread these bars as dark maroon and
     used a maroon button color instead; corrected here). */
  --panel-header-bg: #c1a264;
  /* --ok/--danger-text (tuned for the much lighter --panel) both
     measure under 3:1 against --panel-header-bg's medium lightness, so
     .report-outcome's Victory/Defeat heading (which sits on that bar)
     needs its own darker variants, each computed by hand to clear
     4.5:1 against it specifically. Mutual uses plain --text instead of
     a third custom color — a gold-family accent wouldn't read as
     distinct on a gold background anyway. */
  --ok-on-header: #24401a;
  --danger-on-header: #661f12;

  /* Shared by .build-btn and .nav-pill — square corners now (was a
     2-slice button with button-cap-left-v2.webp's own baked-in rounded
     left cap + button-right-fill.webp's rounded right cap, both cut
     from the reference game's real per-button art). Square, per
     request, means dropping both cap images (their rounding is baked
     into their own alpha shape, not a CSS border-radius that could
     just be removed) and tiling button-fill-square.webp instead — a
     120px-wide crop out of button-right-fill.webp's own flat middle
     section (clear of its rounded cap and gold chevron decoration), so
     the same leather-grain texture still shows, just without the
     curve. Each button gets an explicit CSS border now too (it didn't
     need one before — the cap images drew their own edge), matching
     the same fix .resource-bar/.building-switcher needed. */
  --button-art: url('/assets/theme/button-fill-square.webp');
  --button-art-repeat: repeat-x;
  --button-art-position: left center;
  --button-art-size: auto 100%;
  /* Fallback fill color beneath the art, sampled from button-right-
     fill.webp's own midtone — only visible if a button ever renders
     wider than the art can tile to, so it needs to match the art's
     color exactly rather than the lighter --accent-dim (a visible gold
     sliver against a dark maroon button would look like a rendering
     bug, not a border). */
  --button-fill-color: #2a0505;

  --space-xs: 0.35rem;
  --space-sm: 0.6rem;
  --space-md: 1rem;
  --space-lg: 1.5rem;
  --space-xl: 2.25rem;

  /* Thickness of the wood-trim frame wrapped around the whole page (see
     body below) — 24px is the frame art's own native resolution
     (assets/theme/panel-corner-left.webp etc. are 24x24 source images),
     so this reads crisp rather than a scaled/soft version of it.
     Narrower on a phone (media query further down) so the frame doesn't
     eat a disproportionate chunk of an already-narrow viewport. */
  --site-frame: 24px;
}

/* One lever for "everything a consistent amount smaller/bigger" rather
   than hand-editing every font-size and padding declaration
   individually — every font-size in this stylesheet is already rem (or
   em, which chains off it), and --space-xs/sm/md/lg/xl (the padding/
   gap/margin system used almost everywhere) are rem too, so scaling the
   root scales both at once, consistently, in one place. Deliberately
   leaves fixed-px decorative sizing alone (button-art slice widths,
   border widths, border-radius, the unit-info popup's portrait image)
   — those aren't "font size or padding". History: 75% (25% smaller
   than the 1rem=16px default) -> 82.5% (+10%, 0.75 * 1.10) -> 90.75%
   (+10% again, 0.825 * 1.10) per a later request. This is the
   phone/default value; desktop gets its own separate override just
   below (currently 16px flat, not relative to this) since a phone
   screen doesn't have room to spare the way a desktop one does. */
html { font-size: 90.75%; }
/* Desktop-only bump — same breakpoint as --site-frame's phone override
   (720px) below, just the other side of it. Deliberately not layered on
   top of the 82.5% above (e.g. not 82.5% * 1.something) — landed on
   16px flat (the 1rem browser default) after an initial 120%/19.2px
   pass read as too large and a following 18px pass got dialed back
   further still. */
@media (min-width: 721px) {
  html { font-size: 16px; }
}

/* Without this, body's own background-color/image (the parchment fill
   behind the wood-trim frame) gets propagated to paint the *entire*
   canvas per the CSS spec's html/body background-propagation rule —
   whenever html has no background of its own, the browser uses body's
   instead, painting it across the full viewport regardless of body's
   own box size. That silently erased the visual distinction between
   "inside the frame" and "outside it" once body gained a max-width:
   both areas looked like the same continuous parchment, so the frame
   (which does exist, correctly sized) was invisible in practice.
   Giving html its own explicit background (the reference game's actual
   page backdrop — a knight and an archer-with-birds watermarked into
   aged parchment, assets/theme/page-background.webp, ex-DUMPFOLDER's
   bg-image.webp) stops that propagation and makes the framed page
   actually read as framed. */
/* Reserves the vertical scrollbar's own width always, whether or not
   the current page actually needs to scroll — otherwise a village whose
   page is short enough to fit (no scrollbar, full viewport width
   available for centering body) and one whose page is tall enough to
   need one (viewport width minus the scrollbar) get a *different*
   usable width to center body's own margin: 0 auto within, shifting
   the whole framed page sideways by about half the scrollbar's width
   when switching between them — the actual cause of a reported
   "jump" that turned out to have nothing to do with any element's own
   width (already made stable, see .village-heading-row/#resource-bar's
   own comments above) and everything to do with the scrollbar itself
   appearing and disappearing. scrollbar-gutter: stable is the modern,
   less visually intrusive fix (reserves the space without necessarily
   painting a visible track when content fits) — overflow-y: scroll is
   the older, universally-supported fallback for browsers that don't
   know the newer property yet (harmless if both apply: the property
   further down a browser understands simply wins). */
html {
  overflow-y: scroll;
  scrollbar-gutter: stable;
  background: url('/assets/theme/page-background.webp') center / cover fixed no-repeat, var(--frame-bg);
}

/* Square corners throughout, per request — every border-radius on a
   rectangular element (panels, buttons, popups, tables, inputs, ...)
   is 0 now. Genuine circles (border-radius: 50%/999px — the village
   nav arrows, notification/queue-count badges, map markers, the
   reports legend swatches/dots) are unaffected: those aren't "rounded
   corners" being softened, they're circular shapes by design, and
   squaring them would just look broken rather than "less rounded". */
* { box-sizing: border-box; }

/* Without this, any element that both sets its own `display` (like
   .notif-badge's `display: inline-flex`) and is toggled via the `hidden`
   attribute (el.hidden = true/false, used all over app.js/notify.js)
   stays visible regardless of that attribute — an author rule that sets
   `display` always wins over the browser's built-in `[hidden]{display:
   none}` default, attribute or not. This is what let the notification
   badge keep showing after being read: `hidden` was being set correctly,
   it just wasn't doing anything. !important guarantees this always wins,
   whatever else a more specific rule sets. */
[hidden] { display: none !important; }

/* The wood-trim frame (corners + edges) used to be redrawn around every
   individual sidebar panel (Production, Troops, Loyalty, the queues,
   Troop/Merchant Movements — see .side-panel below) — a frame nested
   inside a frame inside a frame, once per card. Moved here instead: one
   frame around the whole page, body's own padding leaving room for it.
   Same art, same corner-reuse trick .side-panel used (no dedicated top-
   corner art exists, so the bottom-corner pieces do double duty at the
   top too), just at --site-frame's size instead of 14px. The parchment
   texture is still the last (bottom) layer, showing through everywhere
   the frame art's alpha channel is transparent. min-height: 100vh so a
   short page (Settings, World) still gets a complete frame all the way
   to the bottom of the viewport, not one that stops where the content
   does. */
body {
  /* The 960px page cap (per request) applies to the whole framed page —
     this wood-trim border included, not just the content inside it — so
     it lives here rather than on each page's own *-main element. Below
     it, this fills the viewport as before; above it, centered, with the
     frame itself (not just the content column) stopping at 960px total.
     The two side trim frames (panel-left/right.webp) hug the content
     directly, no gap of their own — content starts right at the
     frame's own inner edge, same as the reference game. */
  max-width: 960px;
  margin: 0 auto;
  font-family: Verdana, Arial, sans-serif;
  color: var(--text);
  min-height: 100vh;
  padding: var(--site-frame);
  background-color: var(--frame-bg);
  background-image:
    url('/assets/theme/panel-corner-left.webp'),
    url('/assets/theme/panel-corner-right.webp'),
    url('/assets/theme/panel-corner-left.webp'),
    url('/assets/theme/panel-corner-right.webp'),
    url('/assets/theme/panel-left.webp'),
    url('/assets/theme/panel-right.webp'),
    url('/assets/theme/panel-top.webp'),
    url('/assets/theme/panel-bottom.webp'),
    url('/assets/theme/bg-shaded.webp'),
    url('/assets/theme/parchment.webp');
  /* bg-shaded.webp (ex-DUMPFOLDER, ~50x1500 native) sits between the
     panel-left/right.webp side trim above and parchment.webp below —
     "the main background in between panel-right.webp and panel-left.webp",
     per request. It's a tall, narrow vertical strip (a shaded gradient,
     not a flat tile), so it repeats only horizontally (repeat-x) to fill
     whatever width the frame's interior is, at its own native height —
     parchment.webp (tiled both directions, as before) is left as the
     fallback showing through below it on a page tall enough to scroll
     past that height, so no hard seam/cutoff appears partway down a long
     page like Reports.
     panel-left/right.webp themselves were edited (their trailing ~9px,
     past the dark inner stroke, was opaque light-cream in the source
     art — harmless while parchment.webp sat directly behind it at a
     near-identical tone, but a visible seam once bg-shaded's own
     different tone sat behind it instead) to fade that trailing strip's
     alpha to 0, so whatever's actually behind them (now bg-shaded) shows
     all the way up to the dark stroke instead of being covered by that
     baked-in sliver. Confirmed via a raw pixel dump (sharp) that this
     tail is consistent across the full tile height before editing it. */
  background-repeat: no-repeat, no-repeat, no-repeat, no-repeat, repeat-y, repeat-y, repeat-x, repeat-x, repeat-x, repeat;
  background-position: top left, top right, bottom left, bottom right, top left, top right, top left, bottom left, top left, top left;
  background-size:
    var(--site-frame) var(--site-frame), var(--site-frame) var(--site-frame),
    var(--site-frame) var(--site-frame), var(--site-frame) var(--site-frame),
    var(--site-frame) auto, var(--site-frame) auto,
    auto var(--site-frame), auto var(--site-frame),
    auto,
    auto;
}
@media (max-width: 720px) {
  :root { --site-frame: 10px; }
}

h1 {
  margin: 0;
  font-family: 'Cinzel', serif;
  font-weight: 700;
  letter-spacing: 0.02em;
}

h2 {
  margin: 0 0 var(--space-md);
  padding-bottom: var(--space-xs);
  font-size: 1.05rem;
  font-weight: 600;
  border-bottom: 1px solid var(--panel-border);
}

/* Loyalty renders as a single garrison-table row (village.ejs) — same
   icon+name/value row structure and font-size as Troops, rather than
   a big standalone number, per request. Only the color-coding is
   loyalty-specific: green/amber/red as loyalty drops, the same
   "getting risky" cue used elsewhere (e.g. the Train button turning
   red once unaffordable) — scoped to #loyalty-line (the value cell)
   since .garrison-table's own rules already handle the row/font-size/
   right-alignment/tabular-nums every other row gets for free. Bold
   too, matching Troops' own count styling (<strong> around t.count in
   garrisonTableHtml()). */
#loyalty-line { font-weight: bold; }
#loyalty-line.loyalty-high { color: var(--ok); }
#loyalty-line.loyalty-medium { color: var(--accent); }
#loyalty-line.loyalty-low { color: var(--danger-text); }

.icon { font-size: 1.15em; margin-right: 0.15em; }

/* Building/troop art (assets/buildings, assets/troops) sits inside an
   .icon span exactly where an emoji glyph used to, sized in em so it
   follows that span's own font-size — including the switcher's
   shrink-to-fit scale (switcher.js) — with no separate sizing rule needed. */
.icon-img { width: 1em; height: 1em; object-fit: contain; vertical-align: -0.15em; }

a { color: var(--accent); text-decoration: none; }

.home {
  max-width: 480px;
  margin: 4rem auto;
  padding: 0 1rem;
  text-align: center;
}
.subtitle { color: var(--muted); margin-bottom: 2rem; }
.player-list { list-style: none; padding: 0; display: grid; gap: 0.75rem; }
.player-card {
  display: flex;
  flex-direction: column;
  gap: 0.25rem;
  padding: 1rem;
  border: 1px solid var(--panel-border);
  border-radius: 0;
  background: var(--panel);
}
.player-card strong { color: var(--text); font-size: 1.1rem; }
.player-card span { color: var(--muted); font-size: 0.9rem; }

/* Invisible until it receives keyboard focus (the first Tab stop on
   every authenticated page) — a standard WCAG 2.4.1 "bypass blocks"
   escape hatch so keyboard/screen-reader users aren't forced through
   the whole nav (up to ~10 tab stops with the building switcher) on
   every single page before reaching the actual content. Off-screen via
   position, not display/visibility, so it stays in the tab order and a
   screen reader still announces it — display:none would remove it from
   both. */
.skip-link {
  position: absolute;
  top: -3rem;
  left: var(--space-sm);
  z-index: 200;
  background: var(--accent-dim-contrast);
  color: var(--btn-text);
  padding: 0.6rem 1rem;
  border-radius: 0;
  text-decoration: none;
  font-weight: 600;
  transition: top 120ms ease;
}
.skip-link:focus { top: var(--space-sm); }

/* Sticky as one block — the nav row plus the village/building row directly
   below it (see partials/header.ejs, included on every authenticated page,
   not just the village page) — so navigation stays reachable while the page
   content underneath scrolls, instead of a fixed pixel offset that would
   break the moment either row's height changes (e.g. wrapping on narrow
   screens). top: var(--site-frame), not 0 — sticky's offset is measured
   from the viewport, not from body's own padding box, so a bare 0 would
   let this slide up underneath (and visually cover) the page-frame's top
   trim on scroll instead of stopping right at its inner edge. */
/* Bleeds past body's own padding (top/left/right) instead of sitting
   inset within the page frame like everything else — the red velvet
   bar reaches the true top/left/right edges of the viewport, covering
   the frame's top trim there rather than leaving a wood-trim gap
   above it. Sticky top: 0 (not var(--site-frame)) to match — it's
   already at the true top now, nothing left to offset for. */
.page-header {
  position: sticky;
  top: 0;
  z-index: 30;
  margin: calc(-1 * var(--site-frame)) calc(-1 * var(--site-frame)) 0;
}

/* Red velvet/leather strip (assets/theme/navbar-left/main/right.webp —
   sorted from assets/DUMPFOLDER's topbar-left/main/right.webp, the
   reference game's own top-bar backdrop) across the whole header row,
   not just individual buttons — same 3-slice technique as
   --button-art/--site-frame (fixed-width end caps, no-repeat + a
   tiling middle fill), auto 100% on every layer so each keeps its own
   native aspect ratio at the header's actual height instead of being
   stretched. These three files were sorted in during an earlier,
   reverted attempt at fusing the nav pills into one continuous bar —
   unused since, now reused here instead of adding new copies. */
.app-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-sm);
  /* Containing block for .rank-badge's absolute centering below. */
  position: relative;
  /* Explicit height (was auto, padding-driven) — a flat 50px, per
     request, rather than however tall its tallest child (the 36px
     nav-pill) plus whatever padding happened to add up to. Horizontal
     padding stays; vertical padding dropped since height (with
     align-items: center above) does that centering job now. */
  height: 50px;
  padding: 0 var(--space-lg);
  flex-wrap: wrap;
  /* Breaks out to the true browser viewport width, per request — the
     nav bar "can extend all the way" even though body itself (the
     wood-trim frame + everything below this) is capped at 984px and
     centered. margin: calc(50% - 50vw) is the standard centered-
     container breakout: body is centered in the viewport, so this
     element's own normal (un-shifted) position is already centered the
     same way; shifting right by half of ITS OWN (currently ~984px-
     capped) width, then left by half the true viewport width, lands
     its edges exactly on the viewport's edges regardless of how wide
     body's own cap is. A no-op below the 984px breakpoint, where body
     already fills the viewport and there's no gap to break out of. */
  /* 100vw counts the vertical scrollbar too (html always reserves one,
     see html's overflow-y above), so a bare 100vw made this bar wider than
     the visible page by exactly the scrollbar's width — a few pixels of
     horizontal scroll on every page, at every width, on any system with
     classic (non-overlay) scrollbars. --scrollbar-w is the measured
     scrollbar width (nav.js sets it on load/resize; 0 until then and on
     overlay-scrollbar systems), so this spans exactly the usable width. */
  width: calc(100vw - var(--scrollbar-w, 0px));
  margin-left: calc(50% - 50vw + var(--scrollbar-w, 0px) / 2);
  margin-right: calc(50% - 50vw + var(--scrollbar-w, 0px) / 2);
  background-color: var(--accent-dim);
  background-image:
    url('/assets/theme/navbar-left.webp'),
    url('/assets/theme/navbar-right.webp'),
    url('/assets/theme/navbar-main.webp');
  background-repeat: no-repeat, no-repeat, repeat-x;
  background-position: left center, right center, left center;
  background-size: auto 100%, auto 100%, auto 100%;
}
/* Everything from Villages through Log out, as one unit — on desktop
   this is just a flex row standing in for .app-header's own two direct
   children (replaced by this wrapper + .hamburger-btn so both can be
   toggled as a single group on mobile below). */
.nav-menu {
  display: flex;
  align-items: center;
  justify-content: space-between;
  flex: 1;
  gap: var(--space-md);
  flex-wrap: wrap;
}
.nav-pills, .nav-actions {
  display: flex;
  align-items: center;
  /* Tighter than --space-sm, matching the reference screenshot's
     buttons sitting close together rather than spaced apart. */
  gap: 0.3rem;
  flex-wrap: wrap;
}

.hamburger-btn {
  display: none;
  position: relative;
  background: none;
  border: none;
  /* --btn-text (light), not --text (dark) — this sits directly on
     .app-header's own red velvet background now, not the page's
     parchment, so the dark default was nearly invisible. */
  color: var(--btn-text);
  font-size: 1.4rem;
  line-height: 1;
  cursor: pointer;
  padding: 0.4rem 0.6rem;
  margin-left: auto;
}
/* Offset clear of the button's own box (not top/right: 0) — pinned flush
   against the corner, the badge was sitting directly on top of the ☰
   glyph at this button's small size, hiding it completely instead of
   just decorating a corner of it. */
.hamburger-btn .notif-badge { position: absolute; top: -6px; right: -8px; }

/* Below 720px the nav pills wrapped across 2-3 rows instead of fitting
   one line — collapses the whole .nav-menu (nav pills, notification
   bell, Settings, Log out) behind a hamburger button instead, opened as
   a dropdown panel (see nav.js — same open/outside-click/Escape pattern
   notify.js already uses for #notif-dropdown). */
@media (max-width: 860px) {
  /* Roughly half .app-header's default height — the row only ever holds
     the hamburger button at this width, so it doesn't need the same
     padding sized for a full row of icon+text nav pills. */
  .app-header { padding: calc(var(--space-md) / 2) var(--space-lg); }
  .hamburger-btn { display: inline-flex; align-items: center; font-size: 1rem; padding: 0.1rem 0.4rem; }
  /* Closed by default at this width — nav.js adds/removes .open (rather
     than the [hidden] attribute other dropdowns use) specifically so
     this rule only ever applies inside this media query, never touching
     the element's visibility above 720px. */
  .nav-menu { display: none; }
  .nav-menu.open {
    display: flex;
    position: absolute;
    top: 100%;
    left: 0;
    right: 0;
    flex-direction: column;
    align-items: stretch;
    gap: var(--space-sm);
    padding: var(--space-md) var(--space-lg);
    background: var(--panel);
    border-bottom: 1px solid var(--panel-border);
    z-index: 40;
  }
  .nav-pills, .nav-actions { flex-direction: column; align-items: stretch; width: 100%; }
  .nav-pill { justify-content: flex-start; }
  .notif-bell-wrap { align-self: flex-start; }
}

/* The reference game's own rounded, gold-bracket-cornered nav buttons
   — a genuine 3-slice this time (button-cap-left-v2.webp +
   button-cap-right-v2.webp, no-repeat; button-fill-tile.webp,
   repeat-x), all three cropped from the same source
   (button-right-fill.webp, itself cut from DUMPFOLDER's
   topbutton-right.webp) so the gold ornament tone matches across all
   three pieces. Same layering convention as .app-header's own navbar-
   left/right/main below: end caps listed first (painted on top), the
   tiling fill last (painted underneath, showing everywhere including
   under the caps — harmless, since the opaque caps just cover it
   there). Unlike .build-btn (still the flat --button-art square, per
   the "square corners throughout" rule elsewhere) this is intentionally
   scoped to nav-pill alone, not the shared --button-art variables —
   the request was specifically "fix the nav buttons", not every
   button site-wide. No CSS border-radius needed: the rounded silhouette
   is baked into the cap art's own alpha shape, with background-color
   left transparent so there's no square fallback fill peeking out from
   its corners. */
.nav-pill {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.25rem;
  /* Explicit height (was ~25.6px, padding-driven) rather than relying on
     padding + line-height to land on a specific number, per request —
     also what the cap art's own auto 100% background-size scales
     against, so this is the one number that controls both the button's
     size and how large its rounded corners render. 44px (up from an
     initial 36px, which read as too small once .app-header settled at
     a flat 50px) leaves a small 3px margin top/bottom in that bar
     rather than filling it edge to edge. */
  height: 44px;
  /* A little bottom padding, none on top — per request, reversed from an
     earlier attempt that pushed content down and made it read worse.
     This nudges the icon/label up instead, the same amount that attempt
     moved it down. Horizontal padding widened from 0.9rem — per
     request, the icon/label read as cramped against the cap art. */
  padding: 0 1.3rem 3px;
  border: none;
  background-color: transparent;
  background-image:
    url('/assets/theme/button-cap-left-v2.webp'),
    url('/assets/theme/button-cap-right-v2.webp'),
    url('/assets/theme/button-fill-tile.webp');
  background-repeat: no-repeat, no-repeat, repeat-x;
  background-position: left center, right center, left center;
  background-size: auto 100%, auto 100%, auto 100%;
  color: var(--btn-text);
  font-size: 0.75rem;
  /* Heavier than the 600 this used before, per request. */
  font-weight: 800;
  cursor: pointer;
  white-space: nowrap;
}
.nav-pill:hover, .nav-pill:focus-visible { filter: brightness(1.25); }
/* Reports' own unread count (headerContext's unreadReportsCount, from
   reportService's getUnreadReportsCount — battle_reports newer than this
   player's reports_seen_at) — " (2)" appended right after the label, not
   a separate floating badge, so it just makes the pill read wider via
   the same content-driven width every .nav-pill already has (the
   fill-tile background above repeats to fit); nothing needed to grow the
   button itself. Clears on the next navigation once /reports' own route
   handler calls markReportsSeen. */
.nav-pill-count { color: #F6B628; }
/* Active still needs to read as distinct from every other pill now that
   they're all the same maroon — a gold ring rather than a swapped-out
   background, since the background is the button image everywhere. */
.nav-pill.active { box-shadow: 0 0 0 2px var(--accent); filter: brightness(1.15); }
/* World's nav slot — a small two-row "Ranglijst" (world ranking) badge
   instead of a plain icon+label pill, per a reference screenshot of the
   original game's own ranking widget. Background is the real game's own
   topdisplay-left/right.webp (sorted from assets/DUMPFOLDER into
   assets/theme, same 2-slice technique as .app-header's navbar-left/
   right: a wide left piece with the rounded left corner and enough
   flat gold-trimmed fill baked in to cover this element's own width,
   plus a narrow right end cap layered on top) instead of a flat color
   or CSS border — real rounded corners this time, baked into the art,
   not the "square corners throughout" treatment the flat-color button
   art elsewhere in the nav uses. Still a real link to /world. */
.rank-badge {
  display: flex;
  flex-direction: column;
  min-width: 8.5rem;
  text-decoration: none;
  cursor: pointer;
  background-color: transparent;
  background-image:
    url('/assets/theme/topdisplay-right.webp'),
    url('/assets/theme/topdisplay-left.webp');
  background-repeat: no-repeat, no-repeat;
  background-position: right center, left center;
  background-size: auto 100%, auto 100%;
  /* Centered on .app-header itself (see its own position: relative),
     not just wherever it lands in the nav-pills flow — per request, the
     badge sits in the middle of the whole bar regardless of how many
     pills are to its left or icons to its right. Reset back to static
     in the stacked mobile menu below, where it should just take its
     turn in the column like every other row. */
  position: absolute;
  left: 50%;
  transform: translateX(-50%);
  /* Full .app-header height (not vertically centered within it) and
     flush to its top, per request — topdisplay-left/right.webp are
     themselves 50px tall, .app-header's own exact height, so this
     spans it edge to edge instead of floating as a shorter box inside
     it; the art's own gold trim lines land exactly on the bar's own
     top/bottom edges instead of sitting short of them. */
  top: 0;
  height: 50px;
  justify-content: center;
}
/* Placed after the base rule above (not folded into the earlier 720px
   block further up the file) so it actually wins the cascade at this
   width — same specificity, and source order is the tiebreaker. Takes
   its turn in the stacked column like every other row here, instead of
   trying to absolutely center on .app-header (hidden behind the
   hamburger at this width anyway). */
@media (max-width: 860px) {
  .rank-badge {
    position: static;
    transform: none;
    min-width: 0;
    height: auto;
    /* The two-cap image background above assumes a roughly known,
       fairly narrow width (it's fixed art, not repeatable) — at this
       width the badge stretches full-width like every other stacked
       row, which the caps can't scale to cover (left/right art landing
       short of each other, transparent gap in between). Flat color
       fallback instead, same dark tone as the art. */
    background-image: none;
    background-color: #150b0c;
    border: 1px solid var(--panel-border);
  }
}
.rank-badge-title {
  background: transparent;
  color: var(--btn-text);
  font-size: 0.68rem;
  font-weight: 700;
  text-align: center;
  padding: 0.15rem 0.4rem;
}
.rank-badge-value {
  /* Transparent — the dark art background now shows through from
     .rank-badge itself, continuous behind both rows. */
  background: transparent;
  color: var(--btn-text);
  font-size: 0.72rem;
  font-weight: 600;
  text-align: center;
  padding: 0.2rem 0.4rem;
}
.rank-badge:hover .rank-badge-value, .rank-badge:focus-visible .rank-badge-value { text-decoration: underline; }
.rank-badge.active { box-shadow: 0 0 0 2px var(--accent); }
.logout-form { margin: 0; }

.notif-badge {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  min-width: 1.1rem;
  height: 1.1rem;
  padding: 0 0.3rem;
  margin-left: 0.3rem;
  border-radius: 999px;
  background: var(--danger);
  color: #fff;
  font-size: 0.7rem;
  font-weight: bold;
  line-height: 1;
  vertical-align: middle;
}

.notif-bell-wrap { position: relative; }
.notif-bell {
  position: relative;
  background: none;
  border: none;
  color: var(--text);
  font-size: 1.1rem;
  cursor: pointer;
  padding: 0.5rem;
  margin: -0.5rem;
  line-height: 1;
}
.notif-bell .notif-badge { position: absolute; top: -2px; right: -2px; margin-left: 0; }

/* Scrolls inside itself past 60vh — one of CLAUDE.md's listed exceptions to
   the no-internal-scrollbar rule (it hangs off the sticky header, so the
   page could never scroll to a taller list). */
.notif-dropdown {
  position: absolute;
  top: calc(100% + 0.5rem);
  right: 0;
  width: 300px;
  max-width: calc(100vw - 2rem);
  max-height: 60vh;
  overflow-y: auto;
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 0;
  box-shadow: 0 8px 24px rgba(0, 0, 0, 0.35);
  z-index: 20;
}
@media (max-width: 480px) {
  /* The bell isn't flush against the screen edge (it sits inside the nav
     row), so a dropdown positioned relative to it can clip off-screen on a
     narrow phone. Anchor to the viewport instead of the button there. */
  .notif-dropdown {
    position: fixed;
    top: auto;
    left: 1rem;
    right: 1rem;
    bottom: 1rem;
    width: auto;
    max-width: none;
  }
}
.notif-empty { color: var(--muted); font-size: 0.85rem; padding: 1rem; margin: 0; text-align: center; }
.notif-item {
  display: flex;
  flex-direction: column;
  gap: 0.2rem;
  padding: 0.65rem 0.85rem;
  border-bottom: 1px solid var(--panel-border);
  color: var(--text);
  font-size: 0.85rem;
}
.notif-item:last-child { border-bottom: none; }
.notif-item:hover { background: var(--accent-dim); }
.notif-item.notif-unread { border-left: 3px solid var(--accent); }
.notif-time { color: var(--muted); font-size: 0.75rem; }

.found-village-form input,
.admin-form select,
.admin-form input {
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--panel-border);
  border-radius: 0;
  padding: 0.4rem 0.6rem;
  font-size: 0.9rem;
}

.link-btn {
  background: none;
  border: none;
  color: var(--accent);
  cursor: pointer;
  font-size: 0.9rem;
  padding: 0;
  text-decoration: underline;
}

.found-village-form {
  display: flex;
  gap: 0.5rem;
  margin-top: 1.5rem;
  max-width: 420px;
  margin-left: auto;
  margin-right: auto;
}
.found-village-form input { flex: 1; }

.auth {
  max-width: 360px;
  margin: 4rem auto;
  padding: 0 1rem;
  text-align: center;
}
.auth-form { display: grid; gap: 1rem; text-align: left; margin-top: 1.5rem; }
.auth-form label { display: grid; gap: 0.35rem; font-size: 0.9rem; color: var(--muted); }
.auth-form input {
  background: var(--panel);
  color: var(--text);
  border: 1px solid var(--panel-border);
  border-radius: 0;
  padding: 0.6rem;
  font-size: 1rem;
}
.auth-hint { color: var(--muted); font-size: 0.85rem; margin-top: 1rem; }
.error-inline {
  background: var(--danger);
  color: #fff;
  padding: 0.5rem 0.75rem;
  border-radius: 0;
  font-size: 0.9rem;
}
.success-inline {
  background: var(--ok);
  color: var(--btn-text);
  padding: 0.5rem 0.75rem;
  border-radius: 0;
  font-size: 0.9rem;
  font-weight: 600;
}

.admin-main {
  max-width: 900px;
  margin: 0 auto;
  padding: var(--space-lg);
  display: grid;
  /* minmax(0, 1fr), not the implicit auto: same reasoning as .village-main
     below — an auto-sized grid track grows to fit whatever a child (an
     admin table with a long invite-code string, say) demands, even past
     the viewport, rather than clamping to the available width. Never
     surfaced before because /admin was its own desktop-only, admin-only
     page — now that this is Settings, reachable by every player on any
     device, it's the same phone-overflow bug the app already guards
     against elsewhere. */
  grid-template-columns: minmax(0, 1fr);
  gap: var(--space-xl);
}
@media (max-width: 720px) {
  .admin-main { padding: var(--space-md); gap: var(--space-lg); }
}
/* .admin-section used to carry its own wood-trim frame (the same art the
   page itself is now framed with — see body above) — nested framing, one
   card per section on top of the page's own border. No replacement
   box/line rule needed: .admin-main's own grid gap already separates
   sections, and each one's own h2 already draws a line under itself (the
   global h2 rule further up) — the class is kept on the markup only as a
   styling hook for anything added here later.
   .admin-table-wrap used to be overflow-x: auto — a table too wide for its
   column scrolled sideways inside its own frame (CLAUDE.md forbids that).
   What made one too wide was a long unbroken player-entered name (a
   30-character display name, a 40-character village name); .cell-text
   (below) caps how wide one of those can make its column instead.
   flow-root keeps the one other thing overflow did: a block formatting
   context, so the table's top margin stays inside the wrapper rather
   than collapsing through it (which shifted every table up ~11px). */
.admin-table-wrap { display: flow-root; }
.section-hint { color: var(--muted); font-size: 0.85rem; margin-top: -0.5rem; }
.admin-table { width: 100%; border-collapse: collapse; margin-top: 0.75rem; font-size: 0.9rem; }
/* A player-entered name in a table cell: a word longer than 12em wraps
   inside it instead of widening the whole table past its column (the
   inline-block's max-width caps what it contributes to the column's width;
   break-word lets the word wrap at that cap). Deliberately not
   overflow-wrap: anywhere, here or on the cells — that lowers min-content
   widths, and the auto table layout then re-squeezes columns that already
   fit (numbers split mid-number). Names shorter than 12em (all real ones)
   size exactly as plain text would. */
.cell-text { display: inline-block; max-width: 12em; overflow-wrap: break-word; }
.admin-table th, .admin-table td {
  text-align: left;
  padding: 0.5rem;
  border-bottom: 1px solid var(--panel-border);
}
.admin-table code { color: var(--accent); }
/* flex-wrap: wrap — a grant form now has a select alongside its amount
   input and button (choosing which resource/unit type to target, per
   request), which doesn't fit on one line at a phone width the way the
   input+button pair alone used to. */
.inline-form { display: flex; flex-wrap: wrap; gap: 0.4rem; }
.inline-form input { width: 120px; }
.inline-form select { width: 110px; }
/* The Villages admin table's Actions cell now stacks two of these (grant
   resources, grant troops) — a small gap between them so the second
   form doesn't read as glued directly under the first. */
.inline-form + .inline-form { margin-top: 0.4rem; }
/* min(420px, 100%), not a bare 420px: Settings (formerly a desktop-only,
   admin-only /admin page) is now reachable by every player, on any device
   — a fixed max-width wider than a narrow phone's viewport pushed the page
   itself past its own width instead of just capping the form's growth. */
.admin-form { display: grid; gap: 0.75rem; max-width: min(420px, 100%); margin-top: 0.75rem; }
.admin-form label { display: grid; gap: 0.3rem; font-size: 0.9rem; color: var(--muted); }
.speed-form { display: flex; flex-wrap: wrap; gap: 0.5rem; max-width: none; }
.speed-form .build-btn { width: auto; padding: 0.5rem 1rem; }
.speed-form .speed-active { background: var(--accent); border-color: var(--accent); font-weight: bold; }

.shortcuts-form { margin-top: 0.75rem; }
/* auto-fill/minmax, not a fixed column count: however many fit at the
   viewport's width, same "shrink before overflow" reasoning as everywhere
   else in this app rather than a hardcoded number of columns that could
   force horizontal scrolling on a narrow phone. */
.shortcuts-grid {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(160px, 1fr));
  gap: 0.6rem;
  margin-bottom: var(--space-md);
}
.shortcut-toggle {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  padding: 0.5rem 0.7rem;
  border: 1px solid var(--panel-border);
  border-radius: 0;
  background: var(--bg);
  font-size: 0.9rem;
  cursor: pointer;
}
.shortcut-toggle:has(input:checked) { border-color: var(--accent); }
.shortcut-toggle input { margin: 0; }

.village-main {
  /* No max-width of its own — fills 100% of body's now-capped content
     box (body's own max-width caps the whole framed page, not just
     this column). */
  /* Top/left/right match .village-right's own gap between Troops/
     Production/etc (calc(var(--space-lg) / 2)) by request — bottom
     stays the full, unrequested value. */
  padding: calc(var(--space-lg) / 2) calc(var(--space-lg) / 2) var(--space-lg);
  display: grid;
  /* minmax(0, 1fr), not the implicit auto: an auto column grows to fit
     whatever a child's content demands (even past the container, causing
     horizontal page overflow) rather than clamping to the available
     width — which is exactly what #building-switcher's 9 tabs would
     otherwise do on a narrow phone instead of actually shrinking to fit. */
  grid-template-columns: minmax(0, 1fr);
  /* Half of --space-xl by request — the space below .village-top-row
     (village-heading-row + the resource bar) before .village-layout
     (the scene/sidebar columns) starts. calc() off the variable, same
     reasoning as .village-layout/.village-right's own halved gaps
     above: stays tied to the root font-size lever. */
  gap: calc(var(--space-xl) / 2);
}
@media (max-width: 720px) {
  .village-main { padding: calc(var(--space-lg) / 2) calc(var(--space-lg) / 2) var(--space-md); gap: calc(var(--space-lg) / 2); }
}

/* Village name + resources in one row above the two-column layout, not
   confined to either column — the name has nothing to do with the
   scene/panel column it used to sit above, and the resources have nothing
   to do with the sidebar column they used to sit above; both are
   village-wide, so both sit outside the columns entirely, side by side.
   No margin-bottom here: .village-main is itself a grid with its own gap
   between .village-top-row and .village-layout, so an extra margin here
   was stacking on top of that gap instead of replacing it. */
.village-top-row {
  display: flex;
  align-items: center;
  flex-wrap: wrap;
  gap: var(--space-lg);
}

.village-layout {
  display: grid;
  /* minmax(0, 1fr) for the scene column: same reasoning as
     .village-main above — otherwise a wide child (e.g. the production
     list's rate figures) can grow a track past its share of the row,
     overflowing the page instead of wrapping/shrinking within it.
     The sidebar is a fixed 12.5rem now, not a flexible fr track, per
     request — it no longer shrinks as the viewport narrows; only the
     scene column (still minmax(0, 1fr), taking whatever width the
     fixed sidebar doesn't) does. 12.5rem is the sidebar's own
     previously-computed width at desktop (1fr of a 4fr:1fr split, ~200px
     at the 16px root), so this doesn't shift anything at that width —
     it only stops shrinking below it. */
  grid-template-columns: minmax(0, 1fr) 12.5rem;
  /* Half of --space-xl by request — calc() off the variable rather than
     a bare value, so this still scales with the root font-size lever
     instead of drifting out of sync with it. */
  gap: calc(var(--space-xl) / 2);
  align-items: start;
}
@media (max-width: 720px) {
  .village-layout { grid-template-columns: minmax(0, 1fr); }
}

.village-left {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  /* Was var(--space-xl), between #village-scene-wrap and #building-panel —
     but those two are mutually exclusive (one always hidden, see app.js's
     renderLeftColumn), so that gap never actually rendered. #movements-
     section below is the first child here that's *always* visible
     alongside whichever of the two is showing, so this is the first time
     this gap is actually seen — set to match .village-right's own
     between-panel gap (calc(var(--space-lg) / 2)) per request, so Troop
     Movements sits under the village view with the same spacing the
     sidebar panels use between each other. */
  gap: calc(var(--space-lg) / 2);
}

/* The page's own big title, with the same prev/next cycling the small
   header tab above already offers (identical headerPrevUrl/headerNextUrl —
   see server/middleware/headerContext.js) — a second, more prominent way to
   switch villages right where the page title already draws the eye.
   Gold bar treatment (matching .side-panel h2/.village-scene-title/the
   switcher below) per a reference screenshot — the name used to sit on
   plain parchment with no enclosing bar at all. The reference's
   trailing coordinates/continent and its own trailing icon are
   deliberately not included yet (asked to be ignored "for now").
   Padding matches .resource-bar's own (0.2rem 0.6rem) exactly, per
   request that this bar be just as tall as that one — with the h1
   inside now sized down to match .switcher-name (see below) rather
   than the browser's own large default h1 size, the two naturally
   compute to the same height off matching padding, no fixed height
   needed on either.
   flex: 1 1 auto (not the implicit 0 1 auto, i.e. shrink-to-fit-
   content) — per request, so this bar's own width tracks whatever
   space is actually available next to .resource-bar, not the village
   name's own text length. Before this, a short name (a narrow box) and
   a long one (a wide box) pushed .resource-bar to a different
   horizontal position each time, via .village-top-row's own
   justify-content: space-between (removed, no longer needed — this
   flex-grow does that positioning job instead, just anchored to a
   stable width rather than shrink-wrapped content). */
.village-heading-row {
  display: flex;
  align-items: center;
  gap: var(--space-sm);
  min-width: 0;
  flex: 1 1 auto;
  /* Same gold-toned texture as the shortcut bar (.building-switcher)
     below it, per request, instead of the flat --panel-header-bg this
     used before. */
  background-image: url('/assets/theme/statusbar-center.webp');
  background-repeat: repeat-x;
  background-position: left center;
  background-size: auto 100%;
  border: 1px solid var(--panel-border);
  padding: 0.2rem 0.6rem;
}
.village-heading-row h1 {
  min-width: 0;
  /* Both prev/next arrows sit together on the LEFT of the name here (the
     markup is prev, h1, next so the phone bar can flank the name — see the
     mobile block, which resets this to order 0). order: 1 pushes the name
     after both arrows, so the right arrow stays put at a fixed spot next
     to the left one instead of jumping around with each village name's
     length. */
  order: 1;
  /* Same font as the shortcut bar's own tab labels (.switcher-name),
     per request — was the browser's own large default h1 size in the
     Cinzel serif every other heading uses; this one now reads as part
     of the same UI chrome as the switcher instead of a distinct page
     title. */
  font-family: Verdana, Arial, sans-serif;
  font-size: calc(0.85rem * var(--switcher-scale, 1));
  font-weight: 700;
  letter-spacing: normal;
}
/* The page's own big title doubles as a "back to the village overview"
   link — the switcher's own home tab (partials/header.ejs) already does
   the same thing in miniature above it, but this is the more prominent,
   harder-to-miss version right where the eye already lands. No
   underline at rest, appearing on hover/focus, same convention
   .village-link uses elsewhere for a village name mentioned in prose —
   an always-underlined page title would read as broken/odd rather than
   as a link. */
.village-name-link {
  color: inherit;
  text-decoration: none;
}
.village-name-link:hover, .village-name-link:focus-visible { color: var(--accent); text-decoration: underline; }

.village-right {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  /* Half of --space-lg by request — see .village-layout's gap above for
     why calc() off the variable rather than a bare value. */
  gap: calc(var(--space-lg) / 2);
  align-content: start;
}

/* Current wood/clay/iron, the warehouse's shared capacity, and population
   (current/max) — sits directly above the Production panel rather than
   buried in the Troops section header where population alone used to live,
   since this is "everything about what the village currently has", not
   troop-specific. Plain, not boxed like the panels below it — no
   border/background/padding — matching the reference layout, where this
   row reads as part of the page itself rather than another card. */
/* A gold background (assets/theme/statusbar-center.webp, sorted from
   DUMPFOLDER's statusbar-left/center/right.webp — the resource-bar
   treatment the reference game itself uses) instead of the flat
   unboxed row this used to be. Square, not the rounded-cap pill this
   first shipped as (see .building-switcher's own comment for why:
   statusbar-left/right.webp's rounding is baked into their own alpha
   shape, so square means tiling just the plain center piece, not a
   border-radius to remove). An explicit border replaces the visible
   edge the cap images used to provide — without one, the bar had no
   defined boundary at all, just fading into the page at each end. */
.resource-bar {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  /* Was the gap itself — 0 now, replaced by each .resource-item's own
     padding below, so there's an actual element edge to hang the new
     divider lines' border on (a gap is pure whitespace, nothing to
     attach a border to). */
  gap: 0;
  padding: 0.2rem 0.6rem;
  border: 1px solid var(--panel-border);
  background-image: url('/assets/theme/statusbar-center.webp');
  background-repeat: repeat-x;
  background-position: left center;
  /* Height 100% (not a fixed px value) so the pill still covers the
     whole element if it wraps to two lines at a narrow width — the
     resource row has no fixed line count, unlike the fixed single-line
     bars this art was originally cut for. */
  background-size: auto 100%;
}
@media (max-width: 720px) {
  /* Content-sized (shrink-to-fit) by default, so at mobile — where
     .village-top-row wraps this onto its own line below the heading —
     it only ever took up as much width as its own items needed,
     reading noticeably narrower than the village scene/sidebar below
     it. Stretched to match that width, per request. */
  .resource-bar { width: 100%; }
}
.resource-item {
  display: inline-flex;
  align-items: center;
  gap: 0.2rem;
  padding: 0 0.6rem;
  font-size: 0.75rem;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.resource-item:first-child { padding-left: 0; }
.resource-item:last-child { padding-right: 0; }
/* Vertical divider between each resource/population figure, per
   request — border-left (not a border-right on the previous item,
   equivalent either way) on every item but the first, centered in the
   padding each item already carries above. */
.resource-item:not(:first-child) { border-left: 1px solid var(--panel-border); }
.resource-item .icon { font-size: 1em; margin-right: 0; }
/* Reserves *typical*-case digit width per number, not the absolute
   worst case — #resource-bar itself has no fixed width (it just sizes
   to its own content), so without this its overall width (and so
   .village-heading-row's own width, which grows to fill whatever space
   isn't taken here — see its own comment) drifted between villages:
   "1,000" is narrower than "14,749", so the boundary between the
   heading bar and this one moved sideways as a village's resources
   grew, even after the heading's own width stopped tracking the
   *name's* length. This is a min-width, a floor, not a cap — an
   actual number past the reserved budget still displays in full, just
   without the same no-shift guarantee, which only reliably holds
   within it. First pass reserved the true worst case a player could
   ever reach (7ch/13ch, covering the exact level-25 warehouse max of
   143,788 and level-30 farm cap of 12,150) — correct, but created an
   obvious, constant gap after every ordinary number for the entire
   game up to that point, which almost never gets that high in
   practice. 6ch/9ch instead covers what an actively-played village
   typically shows ("14,749", "197 / 270") close to exactly, trading
   away no-shift stability only in the rare, late-game case a resource
   or population number actually grows past this smaller budget.
   Left-aligned (not the reserved width's own right edge) so any slack
   trails after a short number instead of pushing it away from its own
   icon. */
#res-wood, #res-clay, #res-iron, #res-capacity, #res-population { display: inline-block; }
#res-wood, #res-clay, #res-iron, #res-capacity { min-width: 6ch; }
#res-population { min-width: 9ch; }
@media (max-width: 480px) {
  /* At phone width, population is the item most likely to wrap onto
     its own second line (it's last, and its own "current / max" format
     runs wider than a plain resource figure) — a divider still applied
     there via :not(:first-child) reads as orphaned, floating alone at
     the very start of that wrapped line with nothing to its own left
     on the same visual row. :has() targets the wrapper specifically
     rather than #res-population itself, since the border lives on
     .resource-item (see its own comment), not the number span inside
     it. */
  .resource-item:has(#res-population) { border-left: none; }
}

/* Every sidebar section (Production, Troops, Loyalty, the queues,
   Troop/Merchant Movements, plus the Battle Reports sidebar nav and a
   report's own detail card, which reuse this same class) used to carry
   its own full wood-trim frame — the same corner-medallion art the page
   itself is now framed with once (see body above), redrawn card after
   card instead. That was replaced with no box at all; now it gets the
   same plain outline + rounded corners as .village-scene-wrap (the
   village map's own frame) instead — a flat border, not the ornate
   wood-trim art, so it reads as one consistent "card" language with the
   map box rather than reintroducing the nested-frame-in-a-frame look
   the wood trim had. */
.side-panel {
  border: 1px solid var(--panel-border);
  border-radius: 0;
  /* Half of --space-md by request — matches a reference screenshot of
     the actual game's own units panel, which packs its header/rows
     much tighter than this app's original roomier spacing. */
  padding: calc(var(--space-md) / 2);
  /* #f4e4bc per request — scoped to just .side-panel rather than
     changing the shared --panel variable itself (14 other uses:
     modals, popups, tables, ...), learned from an earlier mix-up where
     a similarly-scoped-sounding request touched a shared variable and
     changed far more than intended. */
  background: #f4e4bc;
}
/* A solid gold title bar spanning the panel's full width, matching a
   reference screenshot of the actual game's own panel headers
   ("Eenheden", "Productie", ...) — not just a thin line under plain
   text like this used to be. #c1a264 per a direct pixel-pick on that
   screenshot (an earlier pass misread these as dark maroon and used
   --button-fill-color instead — corrected here). Negative margin
   (exactly .side-panel's own padding) breaks the bar out to the
   panel's true edges; its own top corners are rounded to match
   .side-panel's so it reads as part of the same card, not a bar
   floating inside it. Dark --text (not --btn-text) for the label —
   this gold is light/medium enough that dark text reads far better on
   it than the light cream every dark-maroon surface elsewhere uses. */
.side-panel h2 {
  margin: calc(-1 * var(--space-md) / 2) calc(-1 * var(--space-md) / 2) calc(var(--space-sm) / 2);
  /* Halved per request. */
  padding: 0.175rem 0.3rem;
  border-radius: 0;
  background: var(--panel-header-bg);
  font-size: 0.85rem;
  /* Starting-cap only now (the heading text itself is already "Troops",
     "Production", ... in the markup) — text-transform: uppercase and
     its paired letter-spacing (loosening tracking is a standard
     uppercase-readability pairing, not needed once this isn't
     all-caps) are both dropped, per request. Bold + italic, black —
     also per request, replacing the previous 600-weight var(--text). */
  font-weight: bold;
  font-style: italic;
  color: #000;
  /* Flex so .panel-collapse-toggle (below) lands at the bar's own
     right edge, per request — matching a reference screenshot's own
     panel headers, which each carry a small collapse button there. */
  display: flex;
  align-items: center;
  justify-content: space-between;
}

/* Collapse/expand toggle, one per .side-panel's own h2 — event
   delegation on .village-right (see app.js's setupPanelCollapse)
   toggles .collapsed on the ancestor .side-panel itself, not on any
   of the IDs a poll cycle's own re-render replaces the innerHTML of,
   so the collapsed state survives every subsequent poll without
   needing separate JS state tracking or persistence: the section
   element carrying the class is simply never recreated. */
.panel-collapse-toggle {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  width: 1.1rem;
  height: 1.1rem;
  padding: 0;
  /* No border — was drawing its own visible stroke inside the gold
     bar, on top of the (now-removed) .side-panel.collapsed border,
     which is what was actually meant to go. Same size/position/colors
     otherwise, untouched. */
  border: none;
  border-radius: 0;
  background: var(--button-fill-color);
  color: var(--btn-text);
  font-size: 0.75rem;
  font-style: normal;
  line-height: 1;
  cursor: pointer;
}
.panel-collapse-toggle:hover, .panel-collapse-toggle:focus-visible { filter: brightness(1.25); }
/* A collapsed panel is just its gold header bar inside the panel's own
   dark-brown outline — nothing else. Content children are hidden and
   the panel's own padding is removed, so the gold h2 fills edge to
   edge with no cream inset strip around it (that cream strip, the same
   color as the panel background showing through the padding, was the
   "golden bar with an inner stroke" reported). The 1px border stays,
   deliberately — it's the dark-brown outline that should remain around
   the collapsed bar. The h2's own negative margin (its breakout to the
   panel edges, see .side-panel h2 above) resets to 0 too, since with
   the padding gone there's nothing left to break out of. Every
   declaration here uses longhand rather than the padding/margin
   shorthands: the shorthand form of this exact rule was silently
   dropped by the browser's CSS parser while the two rules after it
   parsed fine, a quirk this sidesteps. */
.side-panel.collapsed {
  padding-top: 0;
  padding-right: 0;
  padding-bottom: 0;
  padding-left: 0;
}
.side-panel.collapsed h2 {
  margin-top: 0;
  margin-right: 0;
  margin-bottom: 0;
  margin-left: 0;
}
.side-panel.collapsed > *:not(h2) { display: none; }

/* One shared grid across all three rows (not per-row) — each
   .production-row is display: contents so its own icon/name/rate/unit
   join this grid directly as items, letting the rate column's width
   (and so its alignment) actually match across Wood/Clay/Iron instead
   of each row sizing its columns independently. Rate right-aligned
   within its own column so "840" and "1,169" line up on their last
   digit rather than their first, per request. */
.production-list {
  list-style: none;
  margin: 0;
  padding: 0;
  display: grid;
  grid-template-columns: auto auto auto auto;
  /* Without this, grid's default track-sizing stretches "auto" columns
     to fill the container's full width instead of hugging their own
     content — invisible at the old, narrower resource-bar-derived
     width, but once .side-panel got wider (this panel's own width
     tracks the village view now), it visibly spread "Wood"/"1,169"/
     "per hour" out across the whole row instead of reading as one
     compact phrase, which is what "way too large" turned out to mean. */
  justify-content: start;
  align-items: baseline;
  gap: 0.25rem 0.3rem;
  font-size: 0.9rem;
}
.production-row { display: contents; }
/* Not bold, per request (was <strong>) — extra margin-right so the
   rate column doesn't sit flush against it, "a little bit of space
   after that column". */
.production-name { font-weight: normal; margin-right: 0.4rem; }
/* Black per request (was var(--ok), green). */
.production-rate { color: #000; font-variant-numeric: tabular-nums; text-align: right; }
.production-unit { color: var(--muted); font-weight: normal; }

.village-scene-wrap {
  /* Capped at the background art's own native width (660px, see
     .village-scene below) so it never grows past its source resolution
     and looks soft/upscaled on wide viewports — the grid column itself
     is still minmax(0, 1fr) and will happily give it less. */
  max-width: 660px;
  border: 1px solid var(--panel-border);
  border-radius: 0;
  overflow: hidden;
  background: var(--panel);
}

/* Same gold title-bar treatment as .side-panel h2 (Production/Troops/
   etc's own headers — see that rule's own comment for the color/style
   reasoning), matching a reference screenshot of the actual game, which
   puts the village's own name in an identical bar directly above its
   scene. No negative-margin break-out needed here the way .side-panel
   h2 needs one: .village-scene-wrap has no padding of its own for this
   to break out of, so it's already flush with the wrap's true edges. */
.village-scene-title {
  margin: 0;
  padding: 0.175rem 0.3rem;
  background: var(--panel-header-bg);
  font-size: 0.85rem;
  font-weight: bold;
  font-style: italic;
  color: #000;
}

/* Painted walled-village illustration (assets/village-scene/background.webp
   — a terrain backdrop with a stone wall ring composited on top, see
   background-no-wall-source.webp + wall-ring-high-source.webp in this
   same directory) at its own native aspect ratio, so building plots
   (positioned in % below) land in consistent spots relative to the art
   instead of a generic gradient box. This is the top of three Wall
   tiers app.js's updateWallBackground() swaps between via an inline
   style, same terrain each time, just a different ring: level 0 only
   (not yet built), background-wall-unbuilt.webp (+ wall-ring-unbuilt-
   source.webp, the sparse, gapped ring); level 1-9, background-wall-
   tier1.webp (+ wall-ring-tier1-source.webp, a fuller but still
   all-wood ring); 10+, this default. */
.village-scene {
  position: relative;
  width: 100%;
  aspect-ratio: 660 / 465;
  background-image: url('/assets/village-scene/background.webp');
  background-size: cover;
  background-position: center;
}

.building-plot {
  position: absolute;
  transform: translate(-50%, -50%);
  width: 15%;
  display: flex;
  flex-direction: column;
  align-items: center;
  border: none;
  background: none;
  color: var(--text);
  cursor: pointer;
  padding: 0;
}
.plot-img {
  width: 100%;
  height: auto;
  display: block;
  filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.55));
  transition: filter 0.15s ease;
}
/* No transform: scale() here — the village-scene buildings' own hover
   zoom, removed alongside the world map's marker hover zoom per
   request. The glow/brightness highlight stays. */
.building-plot:hover .plot-img, .building-plot:focus-visible .plot-img {
  filter: drop-shadow(0 3px 4px rgba(0, 0, 0, 0.55)) brightness(1.18) drop-shadow(0 0 6px rgba(212, 165, 74, 0.8));
}
.building-plot:focus-visible { outline: none; }
/* Just the level number normally — a small square-ish badge with
   slightly rounded corners. With an in-progress build/training/research
   order, or (Warehouse only) a live projection of when it'll hit
   capacity, .plot-level-countdown stacks a second line underneath
   *inside this same badge* (flex-direction: column) rather than a
   second, separate element below it — matching a reference screenshot
   of the actual game, where the level and the countdown share one
   green badge. No fixed height for that reason: it needs to grow for
   the second line instead of clipping it. */
.plot-level {
  position: absolute;
  top: -6px;
  right: 6%;
  min-width: 1.3rem;
  min-height: 1.3rem;
  padding: 1px 0.3rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  border-radius: 4px;
  background: #5e7d2b;
  color: #fff;
  font-size: 0.78rem;
  font-weight: bold;
  line-height: 1.3;
  border: 1px solid rgba(0, 0, 0, 0.35);
  /* Every .building-plot is position: absolute with no z-index of its
     own, so they all share one stacking context — a building whose
     plot happens to come later in state.buildings (server order, not
     visual position) paints its own .plot-img over an earlier
     building's badge wherever the two visually overlap in the scene
     (e.g. the Wall's badge behind the gate). A z-index here doesn't
     need to out-rank other plots' own badges specially — just every
     plot's .plot-img (position: static, so implicitly z-index: 0) —
     so any positive value works; this only needs to clear that bar. */
  z-index: 1;
}
.plot-level-countdown {
  font-size: 0.66rem;
  font-weight: normal;
  font-variant-numeric: tabular-nums;
  white-space: nowrap;
}
.plot-level-countdown .countdown {
  /* .countdown is styled var(--accent) app-wide (queue rows, movements);
     override back to white here since it's sitting on a dark-green badge. */
  color: #fff;
}
@media (max-width: 480px) {
  .building-plot { width: 18%; }
}

/* Fixed height (not min-height, no internal scrollbar): whichever building
   is selected, the panel should stay the same size — the Headquarters
   (construction list) and Barracks (garrison + recruit) used to tower over
   every other building's panel and make the page jump on every selection
   change. Both of those now use a compact table layout (below) sized to
   comfortably fit within this height, so nothing here ever needs to
   scroll. */
/* No fixed height/internal scrollbar: the page itself scrolls below the
   sticky header instead, so a tall panel (the Headquarters' full
   construct list, say) just makes the page longer rather than needing
   its own scroll area. */
.building-panel {
  border: 1px solid var(--panel-border);
  border-radius: 0;
  background: var(--panel);
  padding: var(--space-lg);
}

/* Always visible, in the same spot above everything else, on *every* page
   (see partials/header.ejs) — not just the village page, so this is a
   persistent quick-nav row for the whole app, not something that only
   appears on a building's own page. Plain text links, not boxed buttons —
   no padding/border eating space. Sits below the nav-pills row as its own
   full-width strip — same background/padding/border treatment as that
   row — rather than being indented inside the (narrower) village-main
   column, which is what gives every name room to breathe.

   Text is never hidden, only shrunk (see switcher.js): it measures this
   row's needed vs. available width on load and on resize, and writes a
   --switcher-scale custom property that .switcher-name's font-size (and
   this row's own gap, and each tab's internal icon/name gap) scale by —
   continuously, down to a floor, rather than an abrupt "hide the text"
   breakpoint. overflow-x: hidden here is only a safety net for the brief
   window before that script runs (or the rare case it doesn't run at
   all): clip this row's own overflow rather than ever letting it push the
   whole page wider. */
/* Same square gold enclosure as .resource-bar now (see its own
   comment for why square: the rounding on statusbar-left/right.webp
   is baked into their own alpha shape, not a CSS border-radius, so
   this tiles just the plain statusbar-center.webp instead). The
   explicit border replaces the edge the cap images used to draw —
   without one this had no visible boundary at all. Tab font/icon size
   untouched — --switcher-scale and each .switcher-tab's own sizing are
   unrelated to this, purely the row's own background/edge. */
.building-switcher {
  display: flex;
  flex-wrap: nowrap;
  align-items: center;
  justify-content: center;
  gap: calc(0.85rem * var(--switcher-scale, 1));
  /* Vertical padding matches .village-heading-row's (0.2rem) per request,
     so the two bars read as the same height — was 0.6rem, taller than
     the heading row below it. */
  padding: 0.2rem 1.5rem;
  /* Enclosed within the main-content area below it, per request — was
     a full-bleed bar spanning the entire page width like the top nav.
     Getting this to track .village-main's actual content edges (the
     scene image's left edge, the sidebar's right edge) at every width
     took three passes:
       1. max-width: 960px, matching .village-main's own cap at the
          time — lined up with its *padding* edge, not its content
          (missed .village-main's own calc(var(--space-lg) / 2)
          left/right padding entirely).
       2. max-width: calc(960px - var(--space-lg)) — subtracted that
          padding, but as a fixed value, so it only matched right
          at/above the 960px cap; below it .village-main shrinks to
          100% of its own available width while this stayed pinned to
          a 960px-derived number, drifting wide again.
       3. This: min(960px, 100% - 2 * var(--site-frame)) - var(--space-lg)
          tracks .village-main's responsive width properly — "100%"
          here is .page-header's own content box, which bleeds past
          body's own padding via a negative margin (see .page-header
          above) to reach body's own edge; .village-main doesn't
          bleed, so its 100% is body's *content* box (already short by
          2 * --site-frame). Subtracting that same amount here first
          makes the two comparable before the same min()/padding math
          applies. (A --content-gap variable briefly added a second
          term to both numbers here — removed again per request, back
          to content butting directly against the frame's own edge.) */
  max-width: calc(min(960px, 100% - 2 * var(--site-frame)) - var(--space-lg));
  /* Top matches the gap below the switcher (.village-main's own top
     padding, calc(var(--space-lg) / 2)) — per request, the same amount
     of space above the switcher (between it and the nav bar) as below
     it (between it and the village name). Was 0 (touching the nav bar
     directly) since .header-banner, which used to fill that role
     visually, was removed. */
  margin: calc(var(--space-lg) / 2) auto 0;
  border: 1px solid var(--panel-border);
  background-image: url('/assets/theme/statusbar-center.webp');
  background-repeat: repeat-x;
  background-position: left center;
  background-size: auto 100%;
  min-width: 0;
  overflow-x: hidden;
}
/* switcher.js's last resort: even at --switcher-scale's floor, a very
   narrow viewport (or a long village name plus every building) still
   doesn't fit on one line. Wraps to a second (or third) line instead of
   clipping — and switcher.js resets scale to 1 when it applies this class,
   since wrapping already solved the space problem, so there's no reason
   to keep the text small too. */
.building-switcher.switcher-wrap {
  flex-wrap: wrap;
  overflow-x: visible;
  row-gap: 0.4rem;
}
/* Cycles through the player's own villages without leaving whatever page
   they're on — village-scoped pages land on the same kind of sub-page for
   the new village (see headerContext.js's villagePageSuffix), everywhere
   else just opens that village's overview. Hidden entirely (see
   headerContext.js) rather than disabled when there's only one village, so
   there's nothing to click that wouldn't do anything anyway. */
/* Small filled maroon button, not a circular muted outline — matching a
   reference screenshot's own prev/next arrows on the village name bar. */
.village-arrow {
  flex: none;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  /* Sized to fit within .village-heading-row's own content height
     (driven by .resource-bar's matching padding, per request that the
     two bars be the same height) rather than the reverse. */
  width: 0.875rem;
  height: 0.875rem;
  border-radius: 0;
  border: 1px solid var(--panel-border);
  background: var(--button-fill-color);
  color: var(--btn-text);
  font-size: 0.6rem;
  line-height: 1;
  cursor: pointer;
}
.village-arrow:hover, .village-arrow:focus-visible { filter: brightness(1.25); }
.switcher-tab {
  flex: none;
  min-width: 0;
  display: inline-flex;
  align-items: center;
  gap: calc(0.3rem * var(--switcher-scale, 1));
  /* Per request — a specific dark brown, not var(--muted). */
  color: #603000;
  text-decoration: none;
  font-size: 0.85rem;
  cursor: pointer;
  overflow: hidden;
}
.switcher-tab .icon { font-size: calc(1rem * var(--switcher-scale, 1)); margin: 0; line-height: 1; flex: none; }
/* Text always stays — never icon-only (see switcher.js) — so this scales
   down via a JS-computed --switcher-scale instead of ever disappearing.
   flex: none on .switcher-tab above means a tab is exactly as wide as its
   (scaled) content demands, nothing more, nothing less: no leftover
   flex-shrink compression on top of the deliberate JS-driven shrink. */
.switcher-name {
  min-width: 0;
  overflow: hidden;
  white-space: nowrap;
  font-size: calc(0.85rem * var(--switcher-scale, 1));
  /* Bold per request — every tab, not just .active (which already set its
     own font-weight: 700 below; now redundant there but harmless to
     leave, since active still reads distinctly via its own color).
     Safe to add without any layout shift: the ::before trick just below
     already reserved this exact bold width for every tab from the start
     — originally so switching *which* tab was active wouldn't nudge the
     others sideways, which holds just as well now that all of them
     render at that reserved width instead of just one at a time. */
  font-weight: 700;
}
.switcher-name::before {
  content: attr(data-text);
  display: block;
  height: 0;
  overflow: hidden;
  visibility: hidden;
  font-weight: 700;
}
/* Per request — a specific red, not var(--text). */
.switcher-tab:hover { color: #E01F0F; }
.switcher-tab.active { color: var(--accent); font-weight: 700; }
.building-panel-content h3 { margin: 0 0 var(--space-xs); }
.building-note { color: var(--muted); font-size: 0.8rem; margin: 0.4rem 0; }
.queue-hint { color: var(--muted); font-size: 0.75rem; margin: -0.3rem 0 var(--space-sm); }
.building-stat { font-size: 0.85rem; color: var(--text); margin: 0.4rem 0; }
.building-stat strong { color: var(--ok); }

.rename-form { display: flex; gap: 0.5rem; max-width: 320px; }
.rename-form input {
  flex: 1;
  min-width: 0;
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--panel-border);
  border-radius: 0;
  padding: 0.4rem 0.6rem;
}

/* Keeps an element available to screen readers/assistive tech without
   showing it visually — for a label whose purpose is already obvious
   from on-screen context (e.g. the rename field under its "Change village
   name" table header). */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  white-space: nowrap;
  border: 0;
}

.barracks-section { margin-top: var(--space-md); padding-top: var(--space-md); border-top: 1px solid var(--panel-border); }
.barracks-section h4 { margin: 0 0 var(--space-sm); font-size: 0.9rem; color: var(--muted); font-weight: 600; }

.building-level { color: var(--muted); font-size: 0.85rem; }

.table-build-btn { padding: 0.35rem 0.6rem; font-size: 0.8rem; margin-top: 0; }

/* The reference game's own dark maroon button
   (assets/theme/button-cap-left-v2.webp + button-right-fill.webp, cut
   directly from DUMPFOLDER's topbutton-left/right.webp — see the
   --button-art comment in :root for why this is a 2-slice, not the
   3-slice an earlier pass used before that source art was available).
   --button-art* custom properties (see :root) so this stays in sync
   with .nav-pill below instead of duplicating. */
.build-btn {
  margin-top: auto;
  padding: 0.5rem;
  /* Square corners means no more cap images drawing their own edge
     (see --button-art's comment) — this replaces it. */
  border: 1px solid var(--panel-border);
  border-radius: 0;
  background-color: var(--button-fill-color);
  background-image: var(--button-art);
  background-repeat: var(--button-art-repeat);
  background-position: var(--button-art-position);
  background-size: var(--button-art-size);
  color: var(--btn-text);
  cursor: pointer;
  font-size: 0.9rem;
}
.build-btn:hover:not(:disabled) { filter: brightness(1.25); }
.build-btn:disabled { opacity: 0.5; cursor: not-allowed; filter: grayscale(0.6); }
/* Recruiting more than current resources cover: stays red even though
   disabled, rather than fading to the same dim grey as "nothing to do
   here" buttons — the two mean different things. Plain solid red (no
   button art), so it still reads as a distinct, separate state rather
   than just a duller version of the normal button. */
.build-btn.insufficient { background: var(--danger); border-color: var(--danger); }
.build-btn.insufficient:disabled { opacity: 0.75; filter: none; }

/* A row per queue item, separated by a plain divider rather than each
   sitting in its own bordered/background box — that used to double up
   with the surrounding .side-panel's own wood-trim frame, reading as a
   visibly boxed card nested inside an already-boxed panel. Dragging
   still gets a genuine "picked up" card treatment (.queue-item.dragging
   below) — background, shadow, rounded corners — but only while
   actually mid-drag, not as every row's permanent resting state. */
.queue-list { list-style: none; padding: 0; margin: 0; display: grid; }
.queue-item {
  /* min-width: 0 — a grid item's default min-width: auto (both .queue-item
     and .movement-item below are grid items of a display: grid list) lets
     it grow past its track to fit its content instead of actually
     shrinking, which defeats .queue-item-name's text-overflow: ellipsis.
     Same underlying bug the switcher tabs needed this same fix for; only
     surfaced here once a barbarian village's longer-than-usual name (in a
     movement label) made a row wide enough to trigger it. */
  min-width: 0;
  border-bottom: 1px solid var(--row-divider);
  /* Tightened to match .garrison-table's own row padding (a reference
     screenshot's much denser panel spacing). */
  padding: 0.35rem 0.1rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  font-size: 0.9rem;
  /* Only the "pick up" cues (shadow/background) transition via CSS —
     the translateY driving the drag itself, and its settle-on-drop
     animation, are timed from app.js so they can track the pointer/
     FLIP siblings precisely. */
  transition: box-shadow 150ms ease, background-color 150ms ease;
}
.queue-item:last-child { border-bottom: none; }
.queue-empty { color: var(--muted); font-size: 0.9rem; }

.queue-item-info { display: flex; flex-direction: column; gap: 2px; flex: 1; min-width: 0; }
.queue-item-name { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.queue-item-eta { display: flex; gap: 0.5rem; font-size: 0.75rem; }
.countdown { color: var(--accent); font-variant-numeric: tabular-nums; }
.eta-clock { color: var(--muted); font-variant-numeric: tabular-nums; }

/* A village name mentioned in prose (a movement's "Attacking X", a battle
   report's "X attacked Y") that links to the map, centered on that
   village — no underline at rest so it doesn't read as a generic browser
   link in the middle of a sentence, appearing only on hover/focus as the
   affordance. */
.village-link { color: var(--accent); text-decoration: none; }
.village-link:hover, .village-link:focus-visible { text-decoration: underline; }

/* Same footprint as .drag-handle below (1.6rem square) so a queue row's
   controls line up. Solid at rest (--danger-dim) rather than an outline
   that only fills in on hover, matching every other button in the app —
   full --danger on hover/focus, since this action is destructive.

   The × is drawn from two rotated bars (::before/::after), not the
   &times; text glyph the button used to contain: removing padding fixed
   the box being asymmetric, but the glyph itself still wasn't optically
   centered within its own font metrics — visible even in a perfectly
   centered, equal-padding box, and not something any padding/line-height
   tweak can fix since it's inherent to the glyph. Two CSS-drawn bars
   center by actual geometry instead, so it's exact regardless of font. */
.cancel-btn {
  position: relative;
  flex: none;
  width: 1.6rem;
  height: 1.6rem;
  padding: 0;
  border: 1px solid var(--danger-dim);
  border-radius: 0;
  background: var(--danger-dim);
  cursor: pointer;
  transition: background-color 120ms ease, border-color 120ms ease;
}
.cancel-btn::before, .cancel-btn::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 55%;
  height: 2px;
  background: var(--btn-text);
  border-radius: 0;
}
.cancel-btn::before { transform: translate(-50%, -50%) rotate(45deg); }
.cancel-btn::after { transform: translate(-50%, -50%) rotate(-45deg); }
.cancel-btn:hover, .cancel-btn:focus-visible {
  background: var(--danger);
  border-color: var(--danger);
}
.cancel-btn:active { filter: brightness(0.85); }

.queue-reorder-spacer { flex: none; width: 1.6rem; }

.drag-handle {
  flex: none;
  width: 1.6rem;
  height: 1.6rem;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  border: none;
  border-radius: 0;
  background: transparent;
  color: var(--muted);
  cursor: grab;
  font-size: 1rem;
  letter-spacing: -2px;
  line-height: 1;
  /* Without this, starting a drag on a touchscreen also tries to scroll
     the page, fighting the reorder gesture the whole way. */
  touch-action: none;
  -webkit-user-select: none;
  user-select: none;
}
.drag-handle:hover, .drag-handle:focus-visible { color: var(--accent); background: var(--accent-dim); }

.queue-item.dragging {
  cursor: grabbing;
  border: none;
  border-radius: 0;
  background: var(--panel);
  box-shadow: 0 6px 16px rgba(0, 0, 0, 0.4);
  position: relative;
  z-index: 5;
}
.queue-item.dragging .drag-handle { cursor: grabbing; color: var(--accent); }

.error-banner {
  position: fixed;
  bottom: 1rem;
  left: 50%;
  transform: translateX(-50%);
  background: var(--danger);
  color: #fff;
  padding: 0.6rem 1rem;
  border-radius: 0;
  max-width: calc(100vw - 2rem);
  text-align: center;
  z-index: 30;
}
.error-banner.banner-ok { background: var(--ok); }

/* Replaces window.confirm() — styled like the rest of the app instead of
   the browser's own dialog chrome, which looked jarringly out of place
   next to it. */
.modal-overlay {
  position: fixed;
  inset: 0;
  background: rgba(0, 0, 0, 0.6);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: var(--space-md);
  z-index: 100;
  animation: modal-fade-in 120ms ease;
}
.modal-box {
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 0;
  padding: var(--space-lg);
  max-width: 360px;
  width: 100%;
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
  animation: modal-pop-in 150ms ease;
}
.modal-box h3 { margin: 0 0 var(--space-sm); }
.modal-box p { margin: 0 0 var(--space-sm); color: var(--text); font-size: 0.9rem; }
.modal-note { color: var(--danger-text); font-size: 0.85rem !important; }
.modal-actions { display: flex; justify-content: flex-end; gap: 0.6rem; margin-top: var(--space-md); }
/* Solid at rest, like every button in the app — the plain "dismiss"
   choice used to be an outline (transparent, border only) while its
   sibling .modal-confirm was already filled, which is exactly the
   inconsistency being fixed here. --panel-border doubles as a neutral
   solid fill: distinct from both the accent (confirm) and danger
   (destructive confirm) treatments without introducing a new color. */
.modal-btn {
  padding: 0.5rem 1rem;
  border: 1px solid var(--panel-border);
  border-radius: 0;
  background: var(--panel-border);
  color: var(--btn-text);
  cursor: pointer;
  font-size: 0.9rem;
}
/* :not(.modal-confirm) — .modal-confirm already gets its own explicit
   hover color swap below; without this exclusion the two rules would
   stack, brightening it twice. */
.modal-btn:not(.modal-confirm):hover, .modal-btn:not(.modal-confirm):focus-visible { filter: brightness(1.2); }
.modal-btn.modal-confirm { background: var(--accent-dim-contrast); border-color: var(--accent-dim-contrast); }
.modal-btn.modal-confirm:hover, .modal-btn.modal-confirm:focus-visible { background: var(--accent); border-color: var(--accent); }
.modal-btn.modal-confirm.danger { background: var(--danger-dim); border-color: var(--danger-dim); color: #fff; }
.modal-btn.modal-confirm.danger:hover, .modal-btn.modal-confirm.danger:focus-visible { background: var(--danger); border-color: var(--danger); }

@keyframes modal-fade-in { from { opacity: 0; } to { opacity: 1; } }
@keyframes modal-pop-in {
  from { opacity: 0; transform: scale(0.96); }
  to { opacity: 1; transform: scale(1); }
}

/* The click-to-open unit info popup (app.js's showUnitInfoPopup) — a
   single reused, absolutely-positioned element, unlike .modal-overlay
   above: no full-screen backdrop (position: fixed on the popup itself,
   nothing behind it), so it never blocks interacting with the rest of
   the page, and its header is a drag handle (setupUnitInfoPopupDrag)
   rather than a fixed dialog position. z-index sits below true modals
   (100) but above everything else, including the sticky header (30). */
/* Restyled (v0.41) to copy the reference game's own unit popup, measured
   off a screenshot of it: a 1px brown frame, a 30px header in the same
   gold gradient as its table headers with an 18px bold title and a brown
   "X", the main_bg texture, and a 440px column of "vis" tables (2px cell
   spacing inside a 1px #ded3b9 border) beside the unit's full-body art.
   Verdana/px sizing for the same reason as the production pages. */
.unit-info-popup {
  position: fixed;
  z-index: 90;
  width: min(470px, calc(100vw - 16px));
  background: #ecd8ac url('/assets/theme/main_bg.webp');
  border: 1px solid #804000;
  border-right-color: #603000;
  border-bottom-color: #402000;
  box-shadow: 2px 2px 4px rgba(0, 0, 0, 0.35);
  font: 12px Verdana, Arial, sans-serif;
  color: #000;
}
.unit-info-popup.has-art { width: min(702px, calc(100vw - 16px)); }
.unit-info-popup-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 8px;
  height: 30px;
  padding: 0 5px 0 6px;
  background: #c1a264 url('/assets/theme/tableheader_bg3.webp') repeat-x;
  color: #000;
  cursor: grab;
  user-select: none;
  touch-action: none;
}
.unit-info-popup-header:active { cursor: grabbing; }
.unit-info-popup-header h3 { margin: 0; font-size: 18px; font-weight: bold; line-height: 1; }
.unit-info-close {
  background: none;
  border: none;
  padding: 0;
  color: #603000;
  font: bold 18px Verdana, Arial, sans-serif;
  line-height: 1;
  cursor: pointer;
}
.unit-info-close:hover, .unit-info-close:focus-visible { color: #c00; }
.unit-info-body { display: flex; align-items: flex-start; gap: 20px; padding: 5px 9px 9px 10px; }
.unit-info-main { flex: 0 1 440px; min-width: 0; }
.unit-info-desc {
  margin: 12px 0 21px;
  padding-left: 29px;
  min-height: 22px;
  line-height: 16px;
  background: url('/assets/theme/info.webp') no-repeat 4px 4px;
}
.unit-info-table {
  width: 100%;
  margin-bottom: 16px;
  border: 1px solid #ded3b9;
  border-collapse: separate;
  border-spacing: 2px;
}
.unit-info-table:last-child { margin-bottom: 0; }
.unit-info-table th {
  padding: 3px;
  background: #c1a264 url('/assets/theme/tableheader_bg3.webp') repeat-x;
  font-weight: bold;
  text-align: left;
  vertical-align: top;
}
.unit-info-table td { padding: 2px 3px; background: #f4e4bc; line-height: 19px; }
.unit-info-table tr.center td { text-align: center; }
.unit-info-table .nobr { white-space: nowrap; }
.unit-info-cost-col { width: 180px; }
.unit-info-combat td:nth-child(2), .unit-info-combat td:nth-child(3) { text-align: center; }
.unit-info-combat .stat-icon { width: 21px; }
.unit-info-combat td:nth-child(3) { width: 41px; }
.unit-info-combat .stat-icon img { width: 16px; height: 16px; vertical-align: middle; }
.unit-info-art { flex: none; max-width: 200px; margin-top: 60px; }
/* Phone: the art column goes (there's no room beside the tables), and the
   cost column gives up its fixed width so the table reflows instead. */
@media (max-width: 600px) {
  .unit-info-art { display: none; }
  .unit-info-popup.has-art { width: min(470px, calc(100vw - 16px)); }
  .unit-info-cost-col { width: auto; }
}

/* The clickable unit name/icon (Recruit table, garrison lists) that
   opens the popup above — styled to read as plain table text, not a
   boxy button, until hovered/focused. */
.unit-info-trigger {
  background: none;
  border: none;
  padding: 0;
  margin: 0;
  color: inherit;
  font: inherit;
  cursor: pointer;
  text-align: left;
  display: inline-flex;
  align-items: center;
  gap: 0.3rem;
}
.unit-info-trigger:hover, .unit-info-trigger:focus-visible { color: var(--accent); text-decoration: underline; }

.map-main {
  /* No max-width of its own — see .village-main's own comment above;
     the page cap lives on body. */
  padding: var(--space-lg);
}
@media (max-width: 720px) {
  .map-main { padding: var(--space-md); }
}

/* The village-info and player-profile pages (see village-info.ejs /
   player-profile.ejs) — the map's radial "info" and "profile" buttons link
   here. Same page padding as every other *-main; the panels/tables inside
   reuse .side-panel / .admin-table / .world-stats / .reports-table, so these
   only add the page-specific bits. */
.info-main, .profile-main { padding: var(--space-lg); display: grid; gap: var(--space-lg); }
@media (max-width: 720px) {
  .info-main, .profile-main { padding: var(--space-md); }
}
.info-heading-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-md);
  flex-wrap: wrap;
}
/* Facts + commands sit side by side on a wide screen, stack on a phone —
   minmax(0,...) so neither column can push the row wider than the page (the
   no-internal-scrollbar rule: content reflows, it never scrolls). */
.info-layout {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
  gap: var(--space-lg);
}
@media (max-width: 560px) {
  .info-layout { grid-template-columns: minmax(0, 1fr); }
}
.info-table { width: 100%; border-collapse: collapse; }
.info-table td { padding: 0.35rem 0.2rem; border-bottom: 1px solid var(--row-divider); vertical-align: top; }
.info-table td:first-child { color: var(--muted); width: 40%; }
.info-table tr:last-child td { border-bottom: none; }
.info-command-list { list-style: none; margin: 0; padding: 0; display: grid; gap: 0.4rem; }
.info-command-list a,
.info-command-disabled {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.45rem 0.6rem;
  border: 1px solid var(--panel-border);
  background: var(--panel);
  color: var(--text);
  font: inherit;
  text-align: left;
  width: 100%;
  cursor: pointer;
}
.info-command-list a:hover,
.info-command-list a:focus-visible { border-color: var(--accent); background: var(--accent-dim); color: var(--btn-text); }
.info-command-disabled { opacity: 0.55; cursor: default; }
.soon-tag {
  margin-left: auto;
  font-size: 0.7rem;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  padding: 0.05rem 0.35rem;
  border: 1px solid var(--muted);
  color: var(--muted);
  border-radius: 999px;
}
.muted { color: var(--muted); }

/* position: relative anchors .map-popup (absolutely positioned) to the
   map area specifically, not the whole page — no more sidebar column to
   share a grid row with now that village info floats over the map
   instead. */
.map-panel { position: relative; display: grid; gap: var(--space-sm); min-width: 0; }

.map-viewport {
  /* Containing block for the overlays that live directly inside it — the
     5-field gridlines and the X/Y coordinate rulers — so their inset:0 /
     bottom:0 / left:0 anchor to the viewport, not the outer .map-panel. */
  position: relative;
  border: 1px solid var(--panel-border);
  border-radius: 0;
  background: var(--panel);
  /* The grass terrain base is tiled here, not on #map-grid — it has to be
     redrawn at the current pan/zoom via background-position/background-size
     (see mapApp.js's applyTransform), kept separate from the CSS transform
     on #map-grid: a raster tile scaled via `transform: scale()` softens and
     moirés at non-1 zoom, whereas recomputing background-size directly is
     just re-tiling the pattern and stays crisp at any zoom. This is the
     one continuous ground layer under the whole (infinitely pannable)
     world; per-cell terrain features (forest/mountain/lake) and the
     village sprites are real elements inside #map-grid on top of it. */
  background-image: url('/assets/map/gras1.png');
  background-repeat: repeat;
  image-rendering: auto;
  /* hidden, not auto: panning is now a manual pointer-drag/pinch (see
     mapApp.js's setupPanZoom) driving a CSS transform on #map-grid, not
     native scroll — a scrollbar here would be redundant at best, and
     fighting the transform at worst. */
  overflow: hidden;
  /* width: 100% is only the *starting* size — deliberately not paired
     with a matching max-width: 100% the way it might normally be. That
     would permanently cap the box at .map-panel's width, which defeats
     resizing wider than the page's own design column now that nothing
     sits beside the map to avoid — mapApp.js's setupResizeClamp enforces
     the real ceiling (the actual browser window) in JS instead. */
  width: 100%;
  height: min(70vh, 600px);
  min-width: 280px;
  min-height: 240px;
  max-height: 90vh;
  /* Native drag-to-resize handle, bottom-right corner, both directions.
     Backed by a ResizeObserver in mapApp.js (setupResizeClamp) that
     clamps the width back down to .map-panel's actual size on every
     resize step — belt-and-braces alongside max-width: 100% above, since
     plain CSS alone has proven unreliable for a manually-resized element
     before (see the git history for the sidebar-overlap version of this
     bug, back when there still was a sidebar to overlap). Hidden entirely
     on phone — see the media query below — where there's no mouse to
     grab a 16px corner with anyway, and it would only compete with the
     same corner's touch-drag panning. */
  resize: both;
  /* none, not pan-x pan-y: dragging/pinching is handled entirely in JS
     (setupPanZoom) now, and letting the browser's own touch-scroll/pinch-
     zoom run at the same time would fight it — this hands the browser's
     default touch handling off entirely so only our own logic responds. */
  touch-action: none;
  cursor: grab;
  -webkit-user-select: none;
  user-select: none;
}
.map-viewport.dragging { cursor: grabbing; }

@media (max-width: 720px) {
  /* No mouse on a phone to grab a 16px corner handle with — dragging
     there just pans the map instead (setupPanZoom already treats the
     corner specially so it never fights a real resize drag on desktop;
     removing the handle here removes the only reason that distinction
     needs to exist on a touch-only device). */
  .map-viewport { resize: none; }
}

.map-grid {
  position: relative;
  /* Pan/zoom is one CSS transform set on this element by mapApp.js
     (translate, then scale); origin pinned top-left so its math
     (screen px <-> world px) has a fixed, simple reference point. The
     gridlines are drawn on #map-viewport instead, not here — see its
     comment. */
  transform-origin: 0 0;
}

/* A deterministic terrain feature (forest/mountain/lake) painted on an
   otherwise-empty cell — a plain sprite tile that rides along with
   #map-grid's transform. pointer-events: none so it never intercepts a
   pan drag or steals a click meant for a village behind/beside it. */
.map-tile {
  position: absolute;
  background-size: 100% 100%;
  background-repeat: no-repeat;
  pointer-events: none;
  z-index: 1;
}

/* Thin gridlines every 5 fields, like the classic map. An overlay over the
   whole viewport (above the terrain and villages, below the popup), whose two
   linear-gradients mapApp.js re-tiles via background-size/position on every
   pan/zoom — so the lines stay a crisp 1px at any zoom rather than scaling
   with the grid transform. pointer-events off so it never eats a pan/click. */
.map-gridlines {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 6;
  background-image:
    linear-gradient(to right, rgba(34, 63, 26, 0.55) 0 1px, transparent 1px),
    linear-gradient(to bottom, rgba(34, 63, 26, 0.55) 0 1px, transparent 1px);
  background-repeat: repeat;
}

/* The single bold black cross marking the world center (500 | 500): one
   vertical line at world x=500 and one horizontal at world y=500, crossing at
   the middle of the map. Same crisp, no-scale trick as the gridlines — two
   no-repeat linear-gradients whose position mapApp.js sets to the center's
   screen coordinate on every pan/zoom, so the 2px lines never blur. Sits just
   above the green gridlines, below the coordinate rulers/popup. */
.map-center-cross {
  position: absolute;
  inset: 0;
  pointer-events: none;
  z-index: 6;
  background-image:
    linear-gradient(to right, rgba(0, 0, 0, 0.85) 0 2px, transparent 2px),
    linear-gradient(to bottom, rgba(0, 0, 0, 0.85) 0 2px, transparent 2px);
  background-repeat: no-repeat;
}

/* Coordinate rulers along the bottom (X) and left (Y) edges — a black
   translucent gutter with white numbers, sitting above the terrain/gridlines
   but below the popup. pointer-events: none so panning/clicking passes through;
   overflow: hidden so off-range labels stay clipped (never a scrollbar). Their
   labels are absolutely positioned by mapApp.js's applyTransform. */
.map-coord-ruler {
  position: absolute;
  pointer-events: none;
  z-index: 7;
  overflow: hidden;
  font-size: 10px;
  line-height: 1;
  font-weight: 600;
  font-variant-numeric: tabular-nums;
  color: rgba(255, 255, 255, 0.7); /* 70% opaque text */
  text-shadow: 0 1px 2px rgba(0, 0, 0, 0.9);
  background: rgba(0, 0, 0, 0.1); /* 10% opaque */
}
.map-coord-x { left: 0; right: 0; bottom: 0; height: 17px; }
.map-coord-y { left: 0; top: 0; bottom: 0; width: 26px; }
.map-coord-ruler .coord-label { position: absolute; white-space: nowrap; }
/* Vertically centered in the 17px strip (top:50% + translateY(-50%)) so the
   number never clips against the viewport's bottom border. */
.map-coord-x .coord-label { top: 50%; transform: translate(-50%, -50%); }
.map-coord-y .coord-label { left: 3px; transform: translateY(-50%); }

/* Villages are the game's own map sprites (v1..v6 by point tier, b1..b6 for
   barbarians — see mapApp.js's renderGrid) drawn as the button's background,
   with no ring or highlight around them — just the sprite sitting on the
   terrain, the way the real map looks. Owner is read from the legend/popup,
   not a colored border. */
.map-marker {
  position: absolute;
  border: none;
  border-radius: 3px;
  padding: 0;
  margin: 0;
  cursor: pointer;
  background-color: transparent;
  background-repeat: no-repeat;
  background-position: center;
  background-size: 100% 100%;
  z-index: 2;
  image-rendering: auto;
}
/* margin:0 !important-by-specificity — the marker needs the `home` class
   for the map tests to find it (.map-marker.home), but a generic global
   `.home { margin: 4rem auto }` exists elsewhere in this sheet; without this
   higher-specificity reset every home village sprite would be shoved 4rem
   off its terrain cell. */
.map-marker.home { z-index: 4; margin: 0; }
/* The one deliberate ring: a transient outline on the village you've tapped
   open, so it's clear which popup belongs to which village. Not a persistent
   per-owner highlight (removed, per request) — it appears only while selected. */
.map-marker.selected { outline: 3px solid var(--text); outline-offset: 2px; z-index: 5; }

/* The ornamental frame that brackets your current village (home.png),
   sized a touch larger than the marker and centered over it. pointer-events
   off so it never blocks clicking the village underneath. */
.map-home-frame {
  position: absolute;
  pointer-events: none;
  background-size: 100% 100%;
  background-repeat: no-repeat;
  z-index: 3;
}

.map-legend { list-style: none; padding: 0; margin: 0; display: flex; flex-wrap: wrap; gap: 0.75rem; font-size: 0.85rem; color: var(--muted); }
.map-legend li { display: flex; align-items: center; gap: 0.4rem; }
.legend-swatch { width: 12px; height: 12px; border-radius: 50%; display: inline-block; flex: none; }

/* Schematic minimap, pinned in the map's top-right corner (a child of the
   clipped #map-viewport, so it never scrolls or transforms with the grid).
   touch-action: none so a drag on it recenters the map rather than scrolling
   the page; line-height: 0 strips the inline gap under the <canvas>. */
.map-minimap {
  position: absolute;
  top: 8px;
  right: 8px;
  z-index: 8;
  border: 2px solid var(--panel-border);
  background: #6f9a3c;
  box-shadow: 0 2px 10px rgba(0, 0, 0, 0.45);
  line-height: 0;
  cursor: crosshair;
  touch-action: none;
}
.map-minimap[hidden] { display: none; }
.map-minimap-canvas { display: block; image-rendering: pixelated; }
/* The current-view rectangle: white outline with a dark 1px halo so it stays
   legible over both light (grass) and dark (forest) minimap cells. */
.map-minimap-view {
  position: absolute;
  box-sizing: border-box;
  border: 1px solid #fff;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.55);
  background: rgba(255, 255, 255, 0.14);
  pointer-events: none;
}

/* Radial action menu (see map.ejs / mapApp.js's openRadial): a 0-size anchor
   positioned in screen space over the clicked marker, with seven round buttons
   ringed around it via per-position transforms. A child of #map-viewport so it
   sits in the same clipped space as the markers; kept glued to its village as
   the map pans/zooms by applyTransform re-setting its left/top. z-index above
   the markers (2–5) but below the send popup (10) it hands off to. */
.map-radial { position: absolute; width: 0; height: 0; z-index: 9; }
.radial-btn {
  position: absolute;
  width: 40px;
  height: 40px;
  border-radius: 50%;
  border: 2px solid var(--panel-border);
  background: var(--panel);
  color: var(--text);
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0;
  font-size: 1.05rem;
  line-height: 1;
  cursor: pointer;
  box-shadow: 0 2px 8px rgba(0, 0, 0, 0.5);
}
.radial-btn .icon-img { width: 24px; height: 24px; }
.radial-btn:hover:not(:disabled),
.radial-btn:focus-visible:not(:disabled) { border-color: var(--accent); background: var(--accent-dim); color: var(--btn-text); }
/* Disabled ring buttons stay visible (so the ring reads as a complete,
   consistent menu) but plainly inert — dimmed, flat, no pointer. */
.radial-btn:disabled { opacity: 0.35; cursor: default; box-shadow: none; }
/* The center button is a touch larger, the way the village sits at the middle
   of the ring in the reference art. */
.radial-pos-center { width: 46px; height: 46px; font-size: 1.15rem; transform: translate(-50%, -50%); }
/* A regular hexagon (pointy top/bottom): top/bottom on the vertical axis, the
   four diagonals at the hex corners. translate(-50%,-50%) first centers each
   button on the anchor, then its own offset pushes it out to its slot. */
.radial-pos-top { transform: translate(-50%, -50%) translateY(-54px); }
.radial-pos-bottom { transform: translate(-50%, -50%) translateY(54px); }
.radial-pos-tl { transform: translate(-50%, -50%) translate(-48px, -28px); }
.radial-pos-tr { transform: translate(-50%, -50%) translate(48px, -28px); }
.radial-pos-bl { transform: translate(-50%, -50%) translate(-48px, 28px); }
.radial-pos-br { transform: translate(-50%, -50%) translate(48px, 28px); }

/* Floats over the map itself (a sibling of #map-viewport inside the same
   position: relative .map-panel) instead of living in a permanent sidebar
   — shown/hidden per village, not tied to the page layout, so nothing
   else on the page has to make room for it. Not a full-screen modal
   backdrop like confirmDialog: panning/zooming the map or clicking a
   different marker underneath should keep working while this is open,
   only the explicit close button (or Escape) dismisses it. */
.map-popup {
  position: absolute;
  top: var(--space-sm);
  right: var(--space-sm);
  width: min(320px, calc(100% - 2 * var(--space-sm)));
  background: var(--panel);
  border: 1px solid var(--panel-border);
  border-radius: 0;
  padding: var(--space-md);
  padding-right: 2.4rem; /* room for .map-popup-close without the village name wrapping under it */
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
  font-size: 0.9rem;
  z-index: 10;
}
@media (max-width: 720px) {
  /* Full-width across the top of the map, not a fixed-width top-right card
     (that would either run off a narrow screen or be too cramped). Anchored
     to the *top*, not the bottom: a tall popup (the attack form with many
     unit rows) pinned to the bottom grows upward, pushing its close button
     off the top of the screen with nothing to scroll to — the bug this
     fixes. Top-anchored, the close button always sits just under the map's
     top edge, and any overflow grows downward where the page itself can
     scroll to reach the Send button (no internal scrollbar — see the
     project's no-scrollbar rule). */
  .map-popup { top: var(--space-sm); right: var(--space-sm); bottom: auto; left: var(--space-sm); width: auto; }
}
/* A neutral close action, not .cancel-btn's usual danger-red — closing
   this popup isn't undoing or removing anything, just dismissing a view,
   so the destructive-action color would send the wrong signal here. Same
   solid-shape/CSS-drawn-X language as every other button, different
   color to match what it actually does. */
.map-popup-close {
  position: absolute;
  top: 0.6rem;
  right: 0.6rem;
  background: var(--panel-border);
  border-color: var(--panel-border);
}
.map-popup-close:hover, .map-popup-close:focus-visible {
  background: var(--muted);
  border-color: var(--muted);
}
.map-info-content h3 { margin: 0 0 0.5rem; }
.map-info-row { display: flex; justify-content: space-between; gap: 1rem; margin: 0.2rem 0; color: var(--muted); }
.map-info-row strong { color: var(--text); font-weight: 600; }

/* Cursor-following hover tooltip. position: fixed + JS keeps it at the
   pointer; pointer-events: none so it can float over a marker without ever
   stealing the click. Troops row wraps (flex-wrap) rather than widening the
   card or scrolling — same no-internal-scrollbar rule the rest of the app
   follows. */
.map-hover {
  position: fixed;
  left: 0;
  top: 0;
  z-index: 60;
  pointer-events: none;
  /* Wide enough that the full unit roster sits on one row like the classic
     popup, not wrapped — still capped to the viewport on a narrow screen. */
  width: min(404px, calc(100vw - 16px));
  background: var(--panel);
  border: 2px solid var(--panel-border);
  box-shadow: 0 12px 32px rgba(0, 0, 0, 0.5);
  font-size: 0.8rem;
  line-height: 1.25;
  color: var(--text);
}
/* Gold title bar, dark-brown text — the classic map popup's header. A top-
   down gradient (lighter above) gives it the same faint sheen as the real
   thing rather than a flat fill. Saturated, not the pale gold of an earlier
   pass. */
.map-hover-head {
  display: flex;
  justify-content: space-between;
  align-items: baseline;
  gap: 0.6rem;
  padding: 0.34rem 0.6rem;
  background: linear-gradient(180deg, #c6a559, #b0913f);
  border-bottom: 2px solid var(--panel-border);
  color: #372509;
  font-weight: 700;
  font-size: 0.88rem;
}
.map-hover-name { min-width: 0; overflow-wrap: anywhere; }
.map-hover-coords { white-space: nowrap; }
/* Two-column table: labels left, values left-aligned in their own column
   (not pushed to the far edge), with a divider line under every row and a
   vertical rule between the two columns — the engraved grid of the original.
   Lines are a semi-opaque brown, not the app's palest gold, so they read. */
.map-hover-body { display: grid; }
.map-hover-stat {
  display: grid;
  grid-template-columns: 6.5em 1fr;
  padding: 0.3rem 0;
  border-bottom: 1px solid rgba(122, 76, 18, 0.32);
}
.map-hover-stat > span { color: var(--muted); padding: 0 0.6rem; }
.map-hover-stat > strong {
  color: var(--text);
  font-weight: 700;
  padding: 0 0.6rem;
  border-left: 1px solid rgba(122, 76, 18, 0.18);
}
/* Troop travel-time strip along the bottom: one icon per unit with its time
   beneath, columns sharing the width evenly and split by vertical rules so
   each reads as its own table cell (and the times stop crowding each other).
   flex-wrap is a safety net for very narrow viewports — the no-internal-
   scrollbar rule — but at the card's normal width the roster is one line. */
.map-hover-troops {
  display: flex;
  flex-wrap: wrap;
  padding: 0;
  border-top: 2px solid var(--panel-border);
  background: rgba(122, 76, 18, 0.08);
}
.map-hover-troops[hidden] { display: none; }
.map-hover-troop {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: 3px;
  flex: 1 1 0;
  min-width: 2.9em;
  padding: 0.32rem 0.12rem;
  border-right: 1px solid rgba(122, 76, 18, 0.22);
}
.map-hover-troop:last-child { border-right: none; }
.map-hover-troop .icon-img { width: 20px; height: 20px; }
.map-hover-troop span { font-size: 0.64rem; color: var(--text); white-space: nowrap; }

/* overflow-wrap, not overflow-x: auto — no panel in this app ever gets its
   own internal scrollbar (the Headquarters panel used to scroll
   vertically; fixed by letting the page scroll instead — see below). Just
   two columns (type, count) fit the sidebar comfortably on their own, but
   this stays as a safety net regardless. */
.garrison-table { width: 100%; border-collapse: collapse; font-size: 0.9rem; }
.garrison-table td {
  /* Halved again per request (was 0.35rem 0.1rem, itself already
     matching .queue-item/.movement-item's own tightened row padding —
     one cell per row now, icon + amount + name all one line, not the
     icon+name/count two-column split this used to need padding to
     separate). */
  padding: 0.175rem 0.05rem;
  border-bottom: 1px solid var(--row-divider);
  overflow-wrap: break-word;
  font-variant-numeric: tabular-nums;
}

/* Every recruit-count / send-resources amount pairs a number input with
   a <input type="range"> underneath it (slider.js's pairAmountSlider) —
   stacked rather than side-by-side so it never fights the number input
   for horizontal space inside an already-narrow table cell or popup
   row; the column layout also means it just gets taller, never wider,
   at phone width (see CLAUDE.md's no-horizontal-scroll rule). */
.amount-control { display: flex; flex-direction: column; gap: 0.2rem; min-width: 0; }
.amount-slider {
  width: 100%;
  accent-color: var(--accent);
  cursor: pointer;
}

.train-count {
  width: 3.5rem;
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--panel-border);
  border-radius: 0;
  padding: 0.4rem;
  font-size: 0.9rem;
}

/* The Market's own "Send Resources" form — reuses .train-count's input
   styling by hand (same background/border/radius/padding) rather than
   sharing the class, since these inputs aren't tied to a specific unit
   the way .train-count's data-unit lookups are. */
.trade-form { display: grid; gap: 0.5rem; margin-top: 0.5rem; }
.trade-form-row { display: flex; flex-wrap: wrap; gap: 0.5rem; align-items: center; }
.trade-form-row label { display: inline-flex; align-items: center; gap: 0.3rem; font-size: 0.9rem; }
.trade-form-row .amount-control { min-width: 5rem; }
.trade-form-row input[type="number"] {
  width: 5rem;
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--panel-border);
  border-radius: 0;
  padding: 0.4rem;
  font-size: 0.9rem;
}
/* The Market's own search-as-you-type village picker (app.js's
   renderMarketExtra) — a dropdown list under a plain text input, closer
   to .notif-dropdown than a <select> since results are ranked/filtered
   client-side rather than being a fixed option list. */
.trade-search {
  width: 100%;
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--panel-border);
  border-radius: 0;
  padding: 0.5rem;
  font-size: 0.9rem;
  margin-bottom: 0.3rem;
}
.trade-search-results {
  list-style: none;
  margin: 0 0 0.5rem;
  padding: 0;
  border: 1px solid var(--panel-border);
  border-radius: 0;
  overflow: hidden;
}
.trade-search-results li {
  padding: 0.4rem 0.5rem;
  font-size: 0.9rem;
  cursor: pointer;
  border-bottom: 1px solid var(--panel-border);
}
.trade-search-results li:last-child { border-bottom: none; }
.trade-search-results li:hover { background: var(--accent-dim); }
.trade-search-owner { color: var(--muted); font-size: 0.8rem; }
.trade-search-empty { cursor: default; color: var(--muted); }
.trade-search-empty:hover { background: none; }

/* Same divider treatment as .queue-item above, for the same reason —
   a bordered/background box per row doubled up with the surrounding
   .side-panel's own wood-trim frame. This is #trade-list's (Merchant
   Movements) own layout now — it stayed behind in the narrow sidebar, so
   it keeps the stacked name-above-eta shape a 12.5rem column has room
   for. #movement-list (Troop Movements) moved out to its own panel below
   the village view (see .movements-panel/.village-left below) — see
   .movement-table below for its layout, which overrides this shared base
   via ID specificity rather than duplicating it. */
.movement-list { list-style: none; padding: 0; margin: 0; display: grid; }
.movement-item {
  min-width: 0; /* see .queue-item's comment — same fix, same reason */
  border-bottom: 1px solid var(--row-divider);
  /* Tightened to match .queue-item/.garrison-table's own row padding. */
  padding: 0.35rem 0.1rem;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 0.75rem;
  font-size: 0.9rem;
}
.movement-item:last-child { border-bottom: none; }

/* Troop Movements, moved out from the 12.5rem sidebar to its own panel
   directly below the village view (per request — a sibling of
   #village-scene-wrap/#building-panel inside .village-left, not
   .village-right, so it's capped at the scene's own 660px width rather
   than stretching across the sidebar too). With the extra width over the
   old sidebar column, each movement now lays out as one horizontal line
   (command | arrival | time left | recall) instead of stacking
   description above its eta, so a long list of movements stays compact
   and scannable instead of growing tall fast.
   One shared CSS grid across the whole table (header row + every
   movement row), the same "children join the parent grid" pattern
   .production-list/.production-row already use — #movement-list and
   each .movement-item are both display: contents so their own spans
   become grid items of .movement-table directly, which is what makes
   the arrival/countdown/recall columns actually line up across rows
   instead of each row sizing its own columns independently.
   minmax(0, 1fr) for the command column, fixed/auto for the rest: same
   reasoning as .village-main's own comment — an auto-sized first column
   would grow to fit a long "Attacking <village>: <composition>" string
   past the panel's width instead of clamping and letting the text
   ellipsis-truncate. */
.movements-panel {
  /* Matches #village-scene-wrap's own cap, so this panel is never wider
     than the village view sitting directly above it, per request. */
  max-width: 660px;
}
.movement-table {
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto auto auto;
  align-items: center;
  gap: 0.15rem 0.75rem;
}
.movement-table[hidden] { display: none; }
.movement-columns { display: contents; }
.movement-columns span {
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.02em;
  color: var(--muted);
  padding-bottom: 0.25rem;
  border-bottom: 1px solid var(--panel-border);
}
#movement-list { display: contents; }
#movement-list .movement-item { display: contents; }
.movement-desc {
  min-width: 0;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}
/* The attack-size pennant (assets/command/attack_{small,medium,large}.webp)
   app.js's attackSizeFlag() prepends to an attack row's own .movement-desc
   text — always the leading element in that nowrap/ellipsis span, so it's
   the text that truncates on a narrow panel, never the flag itself. */
.movement-flag { flex: none; vertical-align: -2px; margin-right: 0.3rem; }
.movement-arrival, .movement-countdown {
  white-space: nowrap;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.movement-arrival { color: var(--muted); font-size: 0.85rem; }
.movement-countdown { color: var(--accent); font-weight: 600; }
.movement-desc, .movement-arrival, .movement-countdown, .movement-cancel {
  padding: 0.35rem 0;
  border-bottom: 1px solid var(--row-divider);
}
#movement-list .movement-item:last-child .movement-desc,
#movement-list .movement-item:last-child .movement-arrival,
#movement-list .movement-item:last-child .movement-countdown,
#movement-list .movement-item:last-child .movement-cancel {
  border-bottom: none;
}
/* Always rendered (see app.js's renderMovements comment) even when a row
   has no recall button, so the grid stays four columns wide on every
   row — centered so an empty cell still reads as a deliberate gap, not
   a layout glitch. */
.movement-cancel { display: flex; align-items: center; justify-content: center; }

.reports-main {
  /* No max-width of its own — see .village-main's own comment above;
     the page cap lives on body. */
  padding: var(--space-lg);
}
.reports-main h1 { margin-bottom: var(--space-lg); }
@media (max-width: 720px) {
  .reports-main { padding: var(--space-md); }
}

.reports-layout { display: flex; align-items: flex-start; gap: var(--space-lg); }
.reports-sidebar { flex: none; width: 170px; display: flex; flex-direction: column; gap: 0.15rem; }
.reports-filter {
  display: flex;
  justify-content: space-between;
  gap: 0.5rem;
  padding: 0.4rem 0.5rem;
  border-radius: 0;
  color: var(--text);
  font-size: 0.9rem;
}
.reports-filter:hover { background: var(--accent-dim); }
.reports-filter.active { color: var(--accent); font-weight: 700; }
.reports-filter-count { color: var(--muted); font-variant-numeric: tabular-nums; }
.reports-list-wrap { flex: 1; min-width: 0; }
@media (max-width: 720px) {
  .reports-layout { flex-direction: column; }
  .reports-sidebar { width: 100%; flex-direction: row; flex-wrap: wrap; }
  .reports-filter { flex: 1 1 auto; justify-content: center; }
}

/* The list view (reports.ejs) — a compact table of subject rows, each
   linking to its own /reports/:id detail page (report-detail.ejs, which
   reuses everything from .report-subject down below unchanged). */
/* table-layout: fixed so the Received column keeps a predictable width
   instead of growing to fit its longest date string — without it, the
   Subject column's own text-overflow:ellipsis (below) never actually
   kicks in, since auto layout just widens the whole table past the
   viewport instead of constraining either column (see CLAUDE.md's "no
   internal scrollbars" rule — this fixes actual page-level horizontal
   overflow at phone width, same category of bug). */
.reports-table { width: 100%; table-layout: fixed; border-collapse: collapse; }
/* Wide enough for the longest nl-NL date+time string ("17-9-2026,
   11:35:39") with real margin, not just Chromium's own measured width —
   Firefox renders the identical string noticeably wider (measured ~140px
   against Chromium's ~121px for the same text at the same font-size),
   which at the previous 8.5rem/136px overflowed the column's right edge
   by a few px in Firefox specifically (reported via a zoomed screenshot:
   the divider line fell short of the last digit). */
.reports-table th:last-child, .reports-table td:last-child { width: 11.5rem; }
@media (max-width: 480px) {
  .reports-table th:last-child, .reports-table td:last-child { width: 4.5rem; }
}
.reports-table th {
  text-align: left;
  padding: 0.4rem 0.5rem;
  color: var(--muted);
  font-size: 0.8rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  border-bottom: 1px solid var(--panel-border);
}
.reports-table th:last-child, .reports-table td:last-child { text-align: right; }
.reports-table td { padding: 0; border-bottom: 1px solid var(--panel-border); }
.reports-table tr:last-child td { border-bottom: none; }
.reports-table td.report-row-date { padding: 0.5rem; color: var(--muted); font-size: 0.85rem; white-space: nowrap; vertical-align: middle; }
@media (max-width: 480px) {
  /* The column's own fixed width (above) is too narrow for a full
     date+time string at this size — wraps to 2 lines instead of
     overflowing its cell, same reflow-not-scroll approach as
     everywhere else in the app. */
  .reports-table td.report-row-date { white-space: normal; font-size: 0.7rem; line-height: 1.3; }
}
.report-row-link {
  display: flex;
  align-items: center;
  gap: 0.5rem;
  padding: 0.5rem;
  color: var(--text);
}
.report-row-link:hover { background: var(--accent-dim); }
.report-row-new .report-row-subject { font-weight: 700; }
.report-row-subject { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; font-size: 0.9rem; }
.report-row-badge {
  flex: none;
  padding: 0.1rem 0.4rem;
  border-radius: 999px;
  background: var(--danger);
  color: #fff;
  font-size: 0.65rem;
  font-weight: 700;
  text-transform: uppercase;
}
/* Same idea as the map's per-player dots — a quick-scan color for how a
   report went without reading its subject line, one row at a time. */
.report-dot { flex: none; width: 0.6rem; height: 0.6rem; border-radius: 50%; }
.report-dot-win { background: var(--ok); }
.report-dot-loss { background: var(--danger); }
.report-dot-mutual { background: var(--muted); }
.report-dot-conquest { background: var(--accent); }

.reports-pagination { display: flex; align-items: center; justify-content: center; gap: var(--space-md); margin-top: var(--space-lg); }
.reports-pagination-status { color: var(--muted); font-size: 0.85rem; }

/* The detail view (report-detail.ejs) — narrower than the list+sidebar
   layout since it's just one report, one column. */
.reports-main-narrow { max-width: 640px; }
.reports-back { display: inline-block; margin-bottom: var(--space-md); color: var(--accent); font-size: 0.9rem; }
.report-subject { display: flex; align-items: baseline; gap: 0.4rem; font-size: 0.95rem; margin-bottom: 0.2rem; }
.report-subject-text { color: var(--muted); }
.report-subject-text strong { color: var(--text); }
.report-time { margin: 0 0 var(--space-md); color: var(--muted); font-size: 0.8rem; }

.report-body { display: grid; gap: var(--space-md); }
.report-body h2.report-outcome {
  margin: 0;
  padding: 0;
  border-bottom: none;
  text-transform: none;
  letter-spacing: normal;
  font-family: 'Cinzel', serif;
  font-size: 1.15rem;
}
/* report-win/report-mutual only ever render on .report-outcome (the
   now-gold-barred h2 above) — see reportService.js's labelClass — so
   they go straight to the header-safe variants (mutual just uses
   --text; see its comment in :root). report-loss is also reused on
   the loot table's own light-background <td> cells further down, so
   it keeps --danger-text there; .report-outcome.report-loss (higher
   specificity) overrides just the h2 case. */
.report-win { color: var(--ok-on-header); }
.report-loss { color: var(--danger-text); }
.report-outcome.report-loss { color: var(--danger-on-header); }
.report-mutual { color: var(--text); }

.report-sides { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-lg); }
@media (max-width: 480px) {
  .report-sides { grid-template-columns: 1fr; }
}
.report-side-label {
  margin: 0;
  font-size: 0.75rem;
  font-weight: 600;
  text-transform: uppercase;
  letter-spacing: 0.04em;
  color: var(--muted);
}
.report-side-name { margin: 0.1rem 0 0.5rem; font-weight: 600; }
.report-side-empty { margin: 0; color: var(--muted); font-size: 0.9rem; }
.report-troop-table { width: 100%; border-collapse: collapse; font-size: 0.85rem; }
.report-troop-table th {
  text-align: left;
  padding: 0.2rem 0.4rem 0.3rem 0;
  color: var(--muted);
  font-weight: 600;
  border-bottom: 1px solid var(--panel-border);
}
.report-troop-table th:not(:first-child), .report-troop-table td:not(:first-child) { text-align: right; }
.report-troop-table td { padding: 0.25rem 0.4rem 0.25rem 0; border-bottom: 1px solid var(--panel-border); }
.report-troop-table tr:last-child td { border-bottom: none; }

.report-loot { display: flex; align-items: center; gap: var(--space-md); margin: 0; }
.report-loot .resource-item { font-size: 0.9rem; }
.report-loyalty { margin: 0; font-size: 0.9rem; }

/* Send troops and Send resources used to both render full-height,
   stacked one under the other, which made the popup tall enough to
   need scrolling the whole page just to reach the bottom of it —
   .map-popup-tabs switches between them instead, so only one is ever
   in the DOM's visible flow at a time. The top divider that used to
   separate the info block above from each form's own heading now
   lives on the tab bar itself (the shared boundary both forms sit
   under), not on .attack-form, which no longer has a heading of its
   own to separate from. */
.map-popup-tabs { display: flex; gap: 0.4rem; margin-top: 0.6rem; padding-top: 0.6rem; border-top: 1px solid var(--panel-border); }
.map-popup-tab {
  flex: 1;
  padding: 0.4rem 0.5rem;
  border: 1px solid var(--panel-border);
  border-radius: 0;
  background: var(--bg);
  color: var(--text);
  font-size: 0.85rem;
  cursor: pointer;
  text-align: center;
}
.map-popup-tab:hover, .map-popup-tab:focus-visible { background: var(--accent-dim); }
.map-popup-tab.active { background: var(--accent-dim); border-color: var(--accent); font-weight: 600; }

.attack-form { display: grid; gap: 0.3rem; margin-top: 0.6rem; }
/* flex-wrap: a long unit name plus the slider's minimum usable width
   don't always fit the popup's 320px card on one line — dropping the
   amount-control to its own line beats squeezing the slider unusably
   thin or forcing the popup to scroll horizontally. row-gap keeps that
   wrapped case tight. */
.attack-form-row { display: flex; align-items: center; justify-content: space-between; column-gap: 0.5rem; row-gap: 0.2rem; font-size: 0.85rem; flex-wrap: wrap; }
/* Let the unit label shrink instead of forcing the amount-control onto its
   own line — keeps most rows to a single line. */
.attack-form-row > span { flex: 1 1 6.5rem; min-width: 0; }
.attack-form-row .amount-control { flex: 1 1 8rem; min-width: 6rem; max-width: 11rem; }
.attack-form-row input[type="number"] {
  width: 3.2rem;
  flex: none;
  background: var(--bg);
  color: var(--text);
  border: 1px solid var(--panel-border);
  border-radius: 0;
  padding: 0.25rem;
  font-size: 0.85rem;
}
.attack-form-hint { color: var(--muted); font-size: 0.8rem; margin: 0.1rem 0 0; }

/* Compact the map popup's forms: the number field and its slider sit side
   by side (one line per unit) instead of the stacked column .amount-control
   uses elsewhere — the phone popup was tall enough that seven stacked units
   buried the Send button below the fold. Scoped to the popup's two forms so
   the Barracks train page keeps its stacked layout (see .amount-control's
   own comment for why it stacks there). flex-wrap on the row above still
   lets the slider drop to its own line on a genuinely cramped width. */
.attack-form-row .amount-control,
.trade-form-row .amount-control {
  flex-direction: row;
  align-items: center;
  gap: 0.4rem;
}
.attack-form-row .amount-slider,
.trade-form-row .amount-slider { flex: 1 1 3rem; min-width: 2.5rem; width: auto; }

.world-main {
  max-width: 720px;
  margin: 0 auto;
  padding: var(--space-lg);
  display: grid;
  gap: var(--space-xl);
}
@media (max-width: 720px) {
  .world-main { padding: var(--space-md); gap: var(--space-lg); }
}

.world-stats { display: flex; gap: var(--space-md); flex-wrap: wrap; }
.world-stat {
  flex: 1;
  min-width: 140px;
  border: 1px solid var(--panel-border);
  border-radius: 0;
  background: var(--panel);
  padding: var(--space-lg);
  display: flex;
  flex-direction: column;
  gap: var(--space-xs);
}
.world-stat strong { font-size: 1.8rem; font-family: 'Cinzel', serif; }
.world-stat span { color: var(--muted); font-size: 0.85rem; }

.leaderboard-table td:first-child { color: var(--muted); width: 3rem; }
.leaderboard-you { background: var(--accent-dim); }
.leaderboard-you td { color: var(--text); font-weight: 600; }

body[data-app-version]::after {
  content: "v" attr(data-app-version);
  position: fixed;
  right: 6px;
  bottom: 4px;
  font-size: 0.65rem;
  color: var(--muted);
  opacity: 0.45;
  pointer-events: none;
  z-index: 1;
}

/* ============================================================
   MOBILE APP SHELL (phone, max-width: 480px)
   ------------------------------------------------------------
   Per the mobile mockup: a native-app-style shell instead of
   the desktop framed-parchment page. A fixed dark-brown top bar
   (village name + prev/next arrows + a details button) with the
   K-shortened resource row beneath it, the village scene filling
   the space between, and a fixed bottom tab bar
   (Menu/Spel/Kaart/Opdrachten/Account). This whole file section
   is phone-only — tablet/desktop keep the existing layout, top
   nav, and wood-trim frame untouched.

   Chunk 1 (this block): the frame only. The village scene still
   sizes/scrolls as before (made to fill + drag in a later
   chunk); the resource tap-toggle, the details/sidebar page, the
   Menu page, and the tab-bar destinations are later chunks too.
   ============================================================ */

/* The dark warm brown of the mockup's bars, and the light cream
   the text/icons sit in on them. Scoped names (--m-*) so they
   don't touch the desktop theme variables. */
:root {
  --m-bar-bg: #37291a;
  --m-bar-bg-2: #2d2013;
  --m-bar-text: #f3e7cf;
  --m-bar-gold: #c9a24a;
}

/* In the DOM on every page (shared header partial) but only the
   phone shell shows it. */
.mobile-tabbar { display: none; }
.mobile-sidebar-btn { display: none; }

@media (max-width: 480px) {
  :root {
    /* Heights the fixed bars claim; the scrollable content is
       padded by exactly these so nothing hides behind a bar. The
       top bar is two stacked rows (name + resources). */
    --m-heading-h: 44px;
    --m-resource-h: 38px;
    --m-topbar-h: calc(var(--m-heading-h) + var(--m-resource-h));
    --m-tabbar-h: 58px;
  }

  /* Drop the framed-parchment treatment entirely — the phone UI
     is an edge-to-edge app, not a centered framed page. Light parchment
     fill (not the bars' brown): on the village page it's hidden behind the
     fixed brown bars + the scene, but on the other pages (Account/Reports/
     …) the content sits directly on it, so it must stay the readable light
     color rather than dark brown. */
  body {
    max-width: none;
    margin: 0;
    padding: 0;
    background: var(--frame-bg);
  }

  /* The desktop chrome (top nav + building switcher) is replaced
     by the top brown bar and the bottom tab bar. */
  .page-header { display: none; }

  /* ---- Fixed top brown bar: name row + resource row ---- */
  .village-top-row {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 40;
    flex-direction: column;
    flex-wrap: nowrap;
    gap: 0;
    padding: 0;
    background: var(--m-bar-bg);
    border-bottom: 1px solid #00000055;
  }

  /* Row 1: ‹ village name › + details button. The heading row's
     desktop gold-bar texture/border is dropped — it sits on the
     brown bar now. */
  .village-heading-row {
    height: var(--m-heading-h);
    flex: none;
    width: 100%;
    gap: var(--space-sm);
    padding: 0 0.5rem;
    background: none;
    background-image: none;
    border: none;
  }
  .village-heading-row h1 {
    /* Reset the desktop order: 1 — on the phone bar the name sits between
       the two arrows (source order prev, h1, next), not after both. */
    order: 0;
    flex: 1 1 auto;
    min-width: 0;
    text-align: center;
    font-size: 1.1rem;
  }
  .village-heading-row h1 a { color: var(--m-bar-text); }
  .village-name-link,
  #village-name-heading a { color: var(--m-bar-text); }

  .village-arrow {
    flex: none;
    color: var(--m-bar-gold);
    font-size: 1.6rem;
    line-height: 1;
    padding: 0 0.35rem;
  }

  /* Top-right details button (opens the sidebar page in a later
     chunk). */
  .mobile-sidebar-btn {
    display: inline-flex;
    align-items: center;
    justify-content: center;
    flex: none;
    width: 2rem;
    height: 2rem;
    padding: 0;
    border: none;
    background: none;
    color: var(--m-bar-gold);
    font-size: 1.3rem;
    cursor: pointer;
  }

  /* Row 2: resources, on the same brown bar (no gold statusbar
     texture, no border box). */
  .resource-bar {
    height: var(--m-resource-h);
    width: 100%;
    justify-content: space-around;
    gap: 0;
    padding: 0 0.4rem;
    background: none;
    background-image: none;
    border: none;
    color: var(--m-bar-text);
    font-weight: 700;
  }
  /* The default (un-tapped) K view. Sized on .resource-item itself, not
     .resource-bar — the desktop .resource-item rule sets its own 0.75rem
     which otherwise wins over an inherited bar size. The tapped amount/max
     view overrides this with its own size below. */
  .resource-item {
    padding: 0 0.2rem;
    border: none;
    cursor: pointer;
    gap: 0.3rem;
    font-size: 1rem;
  }
  .resource-item:not(:first-child) { border-left: none; }
  /* Warehouse capacity is dropped from the phone bar entirely (icon and
     number) — it resurfaces as the "/max" half of each storable
     resource's tapped-open amount/max view instead. */
  .resource-item[data-resource="capacity"] { display: none; }

  /* Tap-to-expand: one resource's full amount replaces the whole row.
     The other items are hidden and the tapped one centers and grows to
     fill the bar (a bit larger, so the full number reads clearly). Tap
     again reverts (see app.js's setupResourceToggle). */
  .resource-bar.expanded .resource-item { display: none; }
  .resource-bar.expanded .resource-item.active {
    display: flex;
    flex: 1 1 auto;
    justify-content: center;
    font-size: 1rem;
  }
  .resource-bar.expanded .resource-item.active .icon { font-size: 1.15em; }
  /* Amount stays bold (the bar's own 700); only the "/max" half is
     regular weight, per request. */
  .res-max { font-weight: 400; }

  /* ---- Content between the bars ---- */
  /* The scene fills exactly the gap between the two fixed bars: the
     content box (100dvh minus the two paddings) equals the scene-wrap
     height below, so nothing scrolls. */
  .village-main {
    padding: var(--m-topbar-h) 0 var(--m-tabbar-h);
    gap: 0;
  }
  .village-layout,
  .village-left { gap: 0; }

  /* The Spel overview is just the village scene on a phone — the sidebar
     panels (Production/Troops/queues) and the movements panel move to the
     fullscreen details page (opened by the top-right button, a later
     chunk), so they're hidden here rather than stacked below the scene. */
  .village-right,
  #movements-section { display: none; }

  /* The gold "village name" strip above the scene is redundant on a phone
     — the name already sits in the top brown bar. */
  .village-scene-title { display: none; }

  /* Pannable viewport: the scene is scaled to fill the height between the
     bars, which makes it wider than the screen, and it's dragged left/
     right by a CSS transform (see app.js's setupVillageScenePan) — the
     same deliberate "fixed camera window onto a larger world" pattern as
     the map's #map-viewport, not an auto/scroll scrollbar. No vertical
     drag and no zoom, per the mockup. */
  .village-scene-wrap {
    position: relative;
    max-width: none;
    height: calc(100dvh - var(--m-topbar-h) - var(--m-tabbar-h));
    border: none;
    border-radius: 0;
    background: #1b140c;
    overflow: hidden;
    touch-action: none;
  }
  .village-scene-wrap.dragging { cursor: grabbing; }
  /* Suppress the mobile tap-highlight flash and text/image selection while
     dragging across the plot buttons — otherwise each plot lights up blue
     as the drag passes over it. */
  .village-scene-wrap,
  .village-scene-wrap * {
    -webkit-tap-highlight-color: transparent;
    -webkit-user-select: none;
    user-select: none;
  }
  .village-scene-wrap img { -webkit-user-drag: none; }
  #village-scene {
    position: absolute;
    top: 0;
    left: 0;
    width: auto;
    height: 100%;
    aspect-ratio: 660 / 465;
    will-change: transform;
  }
  /* When a building is open its detail panel replaces the scene; let it
     use the same full gap and scroll internally is avoided by the app
     shell — leave its own sizing to a later chunk, just clear the desktop
     card framing that assumes the parchment page. */
  #building-panel { margin: 0; }

  /* ---- Fixed bottom tab bar ---- */
  .mobile-tabbar {
    display: flex;
    position: fixed;
    bottom: 0;
    left: 0;
    right: 0;
    z-index: 40;
    height: calc(var(--m-tabbar-h) + env(safe-area-inset-bottom, 0px));
    padding-bottom: env(safe-area-inset-bottom, 0px);
    background: var(--m-bar-bg-2);
    border-top: 1px solid #00000066;
  }
  .tabbar-item {
    flex: 1 1 0;
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 2px;
    position: relative;
    padding: 0;
    border: none;
    background: none;
    color: #b09a78;
    font-family: inherit;
    font-size: 0.8rem;
    text-decoration: none;
    cursor: pointer;
  }
  .tabbar-icon { font-size: 1.4rem; line-height: 1; }
  .tabbar-item.active { color: var(--m-bar-text); }
  .tabbar-item-disabled { opacity: 0.5; cursor: default; }
  .tabbar-badge {
    position: absolute;
    top: 4px;
    left: 50%;
    margin-left: 0.2rem;
    min-width: 1.1rem;
    height: 1.1rem;
    padding: 0 0.25rem;
    border-radius: 999px;
    background: var(--danger);
    color: #fff;
    font-size: 0.65rem;
    line-height: 1.1rem;
    text-align: center;
  }

  /* ---- Fullscreen "village details" sheet ---- */
  /* Covers the whole screen above the bars (z above them); its own brown
     header carries the title + close. The body scrolls (a listed exception in
     CLAUDE.md's no-internal-scrollbar rule — it's the page for
     this modal view, not an inner panel that should reflow) and holds the
     live sidebar panels moved in by app.js's setupMobileSidebar. */
  /* Stops just above the bottom tab bar (rather than inset: 0) so the tab
     bar stays visible/usable while a sheet is open — otherwise opening the
     Menu appeared to make the bottom bar vanish behind the fullscreen
     sheet. */
  .mobile-sheet:not([hidden]) {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    bottom: calc(var(--m-tabbar-h) + env(safe-area-inset-bottom, 0px));
    z-index: 60;
    display: flex;
    flex-direction: column;
    background: var(--frame-bg);
  }
  .mobile-sheet-header {
    flex: none;
    height: var(--m-heading-h);
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 0 0.6rem;
    background: var(--m-bar-bg);
    color: var(--m-bar-text);
  }
  .mobile-sheet-title { font-size: 1.1rem; font-weight: 700; }
  .mobile-sheet-close {
    flex: none;
    width: 2rem;
    height: 2rem;
    border: none;
    background: none;
    color: var(--m-bar-gold);
    font-size: 1.6rem;
    line-height: 1;
    cursor: pointer;
  }
  .mobile-sheet-body {
    flex: 1 1 auto;
    overflow-y: auto;
    -webkit-overflow-scrolling: touch;
    padding: calc(var(--space-lg) / 2);
    display: grid;
    gap: calc(var(--space-lg) / 2);
    align-content: start;
  }
  /* The panels are hidden in the normal phone overview; force them visible
     once they've been moved into the sheet. */
  .mobile-sheet-body .village-right {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: calc(var(--space-lg) / 2);
    align-content: start;
  }
  .mobile-sheet-body #movements-section { display: block; }

  /* Menu sheet rows (Reports / Ranglijst / Log out). */
  .mobile-menu-list { display: grid; gap: 0.5rem; }
  .mobile-menu-form { margin: 0; }
  .mobile-menu-item {
    display: flex;
    align-items: center;
    gap: 0.6rem;
    width: 100%;
    padding: 0.9rem 1rem;
    border: 1px solid var(--panel-border);
    border-radius: 0;
    background: var(--panel);
    color: var(--text);
    font-family: inherit;
    font-size: 1.05rem;
    text-align: left;
    text-decoration: none;
    cursor: pointer;
  }
  .mobile-menu-icon { font-size: 1.2rem; line-height: 1; }
  .mobile-menu-count { margin-left: auto; color: #F6B628; font-weight: 700; }
  .mobile-menu-sub { margin-left: auto; color: var(--muted); font-size: 0.9rem; }

  /* Pages other than the village overview (Account/Reports/Ranglijst/Map/
     the villages list) keep their own content flow, but must clear the
     fixed bottom tab bar so their last rows aren't tucked under it. The
     :not(.village-main) keeps this off the village page, which sets its
     own top+bottom padding. */
  #main-content:not(.village-main) {
    padding-bottom: calc(var(--m-tabbar-h) + env(safe-area-inset-bottom, 0px) + var(--space-md));
  }
}

/* ---------------------------------------------------------------------
   Production pages (every recruiting building + the Smithy) — a copy of
   the reference game's own building screen, measured off a screenshot of
   its Stable page: the framed content box (1px dark-brown border with a
   1px light inner highlight, its main_bg texture), big building art +
   "Name (level N)" header, a tab row, and its "vis" tables (gold header
   gradient, #f0e2be rows, 2px cell spacing). Verdana 12px throughout and
   px (not rem) sizing on purpose, so it matches the game exactly instead
   of tracking this app's root font-size lever.

   The sidebar is hidden on these pages (app.js toggles .production-
   layout): the recruit table's own "In the village/Total" column covers
   what its Troops panel showed, and the Training/Research Queue panel is
   moved into the building panel itself (#building-panel-queue). Only a
   direct child is hidden, so the phone's details sheet — which moves
   .village-right out of .village-layout — still shows it.
   --------------------------------------------------------------------- */
.village-layout.production-layout { grid-template-columns: minmax(0, 1fr); }
.village-layout.production-layout > .village-right { display: none; }
/* Matches the scene's 660px cap on the overview; on a full-width
   production page it spans the same width as the building panel. */
.village-layout.production-layout .movements-panel { max-width: none; }

.building-panel.tw-panel {
  border: 1px solid #7d510f;
  box-shadow: inset 0 0 0 1px #f9e1a8;
  background: #ecd8ac url('/assets/theme/main_bg.webp');
  padding: 11px;
  font-family: Verdana, Arial, sans-serif;
  font-size: 12px;
  color: #000;
}
.tw-panel a { color: #603000; font-weight: bold; }
/* The moved Training/Research Queue panel (see .production-layout above),
   restyled to read as one more vis table — the header bar in the same
   gold gradient, rows in the same #f0e2be — rather than a sidebar card.
   Hidden entirely while empty, the way the game shows no queue table
   at all when nothing is training. */
.tw-panel .building-panel-queue .side-panel {
  margin-top: 8px;
  padding: 0 2px 2px;
  border: none;
  background: none;
}
.tw-panel .building-panel-queue .side-panel:has(> .queue-empty:not([hidden])) { display: none; }
.tw-panel .building-panel-queue .side-panel h2 {
  margin: 0 0 2px;
  padding: 3px;
  background: #c1a264 url('/assets/theme/tableheader_bg3.webp') repeat-x;
  font-size: 12px;
  font-style: normal;
  border: none;
}
.tw-panel .building-panel-queue .panel-collapse-toggle { display: none; }
.tw-panel .building-panel-queue .queue-hint { margin: 2px 0; font-size: 11px; color: #555; }
.tw-panel .building-panel-queue .queue-item { margin-top: 2px; padding: 2px 3px; border: none; background: #f0e2be; font-size: 12px; }

.tw-building-header { border-spacing: 0; margin-bottom: 5px; }
.tw-building-header td { vertical-align: top; padding: 2px; }
/* A fixed 120x100 box, whatever the art's own proportions — the
   building art differs in shape per building/tier, and the tab row
   below shouldn't move up and down with it. */
.tw-big-building { display: block; width: 120px; height: 100px; object-fit: scale-down; object-position: center top; }
.tw-building-title { padding-left: 8px !important; line-height: 18px; }
.tw-panel .tw-building-title h2 {
  margin: 4px 0 14px;
  padding: 0;
  border: none;
  font-size: 19px;
  font-weight: bold;
  line-height: normal;
}

.tw-panel table.vis { border-collapse: separate; border-spacing: 2px; }
.tw-panel table.vis th {
  background: #c1a264 url('/assets/theme/tableheader_bg3.webp') repeat-x;
  padding: 3px;
  font-weight: bold;
  text-align: left;
  vertical-align: top;
}
.tw-panel table.vis td { background: #f4e4bc; padding: 2px 3px; vertical-align: middle; }
.tw-panel table.vis tr.row_a td { background: #f0e2be; }
.tw-panel table.vis td.selected { background: #fadc9b; }
.tw-panel table.vis td.selected a { color: #803000; }
.tw-panel table.vis td.nowrap { white-space: nowrap; }
.tw-tabs td { line-height: 16px; }

.recruit-table { width: 100%; font-variant-numeric: tabular-nums; }
.recruit-table .col-unit { width: 20%; }
.recruit-table .col-recruit { width: 120px; }
.recruit-req { line-height: 18px; }
.req-item { display: inline-block; min-width: 70px; margin-right: 4px; white-space: nowrap; }
.req-icon { width: 18px; height: 16px; object-fit: contain; vertical-align: -3px; margin-right: 2px; }

.tw-panel .unit-link { gap: 4px; font-weight: bold; color: #603000; vertical-align: middle; }
.tw-panel .unit-link:hover, .tw-panel .unit-link:focus-visible { color: #803000; text-decoration: none; }
.recruit-portrait { width: 30px; height: 30px; object-fit: contain; flex: none; }

.tw-panel .train-count {
  width: 58px;
  height: 20px;
  padding: 1px 3px;
  border: 1px solid #92919e;
  border-radius: 2px;
  background: #fff;
  color: #000;
  font: 12px Verdana, Arial, sans-serif;
  vertical-align: middle;
}
/* More than the "(max)" next to it allows — red, and the Recruit button
   disables itself (see app.js's updateRecruitForm). */
.tw-panel .train-count.over { color: #c00; border-color: #c00; }
.tw-panel .set-max { margin-left: 2px; }

/* A red .btn for something the village can't afford (the Smithy's
   Research button) — the .btn itself is in the game-buttons block below. */
.tw-panel .btn.insufficient { background: linear-gradient(to bottom, #c96b5b 0%, #a8402f 22%, #9c3320 30%, #9c3320 100%); }

/* Phone: no internal scrollbar allowed (CLAUDE.md), so the table reflows
   instead of widening — unit names drop to portrait-only (the portrait
   still opens the unit-info popup, which names it), requirements wrap,
   the "(max)" link drops under its field, and the header art shrinks. */
@media (max-width: 600px) {
  .building-panel.tw-panel { padding: 6px; }
  .tw-big-building { width: 72px; height: 60px; }
  .tw-panel .tw-building-title h2 { font-size: 16px; margin-bottom: 6px; }
  .recruit-table .col-unit { width: auto; }
  .recruit-table .col-recruit { width: auto; }
  .tw-panel .unit-link-name { display: none; }
  .req-item { min-width: 0; margin-right: 6px; }
  .tw-panel table.vis td.nowrap { white-space: normal; }
  .tw-panel .set-max { display: block; margin: 2px 0 0; }
  .tw-panel .train-count { width: 50px; }
}

/* ---------------------------------------------------------------------
   Shortcut bar, village-name bar and resource bar — copies of the
   reference game's quickbar / header_info boxes, measured off a
   screenshot of them (v0.43.0):
   - quickbar: full width, 28px tall, 1px #7d510f border with a 1px light
     inner edge, the game's iconbar-mc parchment texture; items centered,
     18px icon + 3px + bold 12px Verdana #603000 label, 14px apart.
   - name bar / resource bar: shrink-to-content boxes, 26px tall, built
     from the game's own statusbar-left/center/right slices (borders baked
     in); name bar left, resource bar right, nothing stretched between.
     Resource cells are 3px-padded icon/number pairs split by the game's
     statusbar-separator.
   - every box has the game's soft drop shadow underneath (shadow-left +
     shadow-right, 8px), which also makes up the gap to the next row.
   Desktop only (481px+): the phone app-shell restyles all three bars its
   own way (see the max-width: 480px block above), and the extra markup
   this adds (emblem, coordinates, the game's resource icons) is hidden
   there so the phone bar is unchanged.
   --------------------------------------------------------------------- */
.res-icon-tw, .village-emblem, .village-coords { display: none; }

@media (min-width: 481px) {
  .building-switcher,
  .village-heading-row,
  .resource-bar { position: relative; }
  .building-switcher::before, .village-heading-row::before, .resource-bar::before,
  .building-switcher::after, .village-heading-row::after, .resource-bar::after {
    content: '';
    position: absolute;
    top: 100%;
    height: 8px;
    pointer-events: none;
  }
  .building-switcher::before, .village-heading-row::before, .resource-bar::before {
    left: 0;
    width: 15px;
    background: url('/assets/theme/shadow-left.webp') no-repeat left top;
  }
  .building-switcher::after, .village-heading-row::after, .resource-bar::after {
    left: 15px;
    right: 0;
    background: url('/assets/theme/shadow-right.webp') no-repeat right top;
  }

  /* --- quickbar --- */
  .building-switcher {
    gap: calc(14px * var(--switcher-scale, 1));
    height: 28px;
    padding: 0 1.5rem;
    border: 1px solid #7d510f;
    box-shadow: inset 0 0 0 1px #ffe19c;
    background: #f0d4a0 url('/assets/theme/iconbar-mc.webp') repeat left center;
    /* clip, not hidden: hidden forces overflow-y to auto, which would
       both clip the drop shadow below and count as an internal scroller.
       clip keeps the same "never widen the page" safety net. */
    overflow-x: clip;
    overflow-y: visible;
  }
  .building-switcher.switcher-wrap { height: auto; padding-top: 4px; padding-bottom: 4px; }
  .switcher-tab { gap: calc(3px * var(--switcher-scale, 1)); color: #603000; font-size: 12px; overflow: visible; }
  .switcher-tab .icon { font-size: calc(18px * var(--switcher-scale, 1)); }
  .switcher-name {
    font-family: Verdana, Arial, sans-serif;
    font-size: calc(12px * var(--switcher-scale, 1));
    font-weight: bold;
  }
  .switcher-tab.active { color: #603000; }

  /* --- name bar + resource bar row --- */
  /* The game's spacing: 8px (exactly the quickbar's shadow) down to the
     name/resource row, 10px from that row to the content below it. */
  .village-main { padding-top: 8px; row-gap: 10px; }
  .village-top-row { align-items: flex-start; gap: 8px; }
  .village-heading-row,
  .resource-bar {
    height: 26px;
    padding: 0 6px 0 3px;
    border: none;
    background:
      url('/assets/theme/statusbar-left.webp') no-repeat left top,
      url('/assets/theme/statusbar-right.webp') no-repeat right top,
      url('/assets/theme/statusbar-center.webp') repeat-x left top;
    background-size: auto 26px;
    font: 12px Verdana, Arial, sans-serif;
    color: #000;
  }
  .village-heading-row { flex: 0 1 auto; gap: 0; }
  .resource-bar { margin-left: auto; padding: 0 3px; flex-wrap: nowrap; }

  .village-heading-row .village-arrow {
    order: 0;
    width: 16px;
    height: 22px;
    margin-right: 3px;
    border: none;
    background: url('/assets/theme/arrow-left.webp') no-repeat 0 0;
    font-size: 0;
    color: transparent;
  }
  .village-heading-row .village-arrow[aria-label="Next village"] { background-image: url('/assets/theme/arrow-right.webp'); }
  .village-heading-row .village-arrow:hover,
  .village-heading-row .village-arrow:focus-visible { filter: none; background-position: 0 -22px; }
  .village-emblem { display: block; order: 1; flex: none; width: 18px; height: 18px; margin-right: 3px; }
  .village-heading-row h1 {
    order: 1;
    font-family: Verdana, Arial, sans-serif;
    font-size: 12px;
    font-weight: bold;
    white-space: nowrap;
  }
  .village-heading-row .village-name-link { color: #603000; }
  .village-coords { display: inline; order: 2; margin-left: 7px; font-weight: bold; white-space: nowrap; }

  .resource-item {
    gap: 6px;
    height: 26px;
    padding: 0 3px;
    font-size: 12px;
  }
  .resource-item:first-child { padding-left: 0; }
  .resource-item:last-child { padding-right: 3px; }
  .resource-item:not(:first-child) {
    border-left: none;
    padding-left: 5px;
    background: url('/assets/theme/statusbar-separator1.webp') no-repeat left top;
  }
  .resource-item .icon .icon-img,
  .resource-item .res-icon-default { display: none; }
  .res-icon-tw { display: block; width: 18px; height: 16px; object-fit: contain; }
  .resource-item[data-resource="capacity"] .res-icon-tw,
  .resource-item[data-resource="population"] .res-icon-tw { height: 18px; }
  #res-wood, #res-clay, #res-iron, #res-capacity, #res-population { min-width: 0; }
  .pop-current.warn-90 { color: #632600; }
}

/* ---------------------------------------------------------------------
   Top menu — transcribed from the reference game's own stylesheet
   (docs/reference/tribalwars-game.css, its #menu_row / #topdisplay /
   .menu_column rules; v0.47.0). Each button is two layers, exactly as
   the game builds it:
     - the item (its td.menu-item): 43px tall, the wide topbutton-right
       art anchored right; on hover that art switches to its lower half
       ("right bottom").
     - the link inside (its td.menu-item > a): the 30px topbutton-left
       cap anchored left, padding 15px 15px 0 + height 28px (= 43px),
       shifted 2px left (left: -2px) — that overlap is what hides the
       dark edge of the right-hand art; without it a black sliver shows
       between buttons. Bold 10px Verdana, white, centered.
   2px between items (the game's default table cell spacing), the two
   stacked 20px manager icons, the Ranglijst display (topdisplay-left on
   the cell, topdisplay-right on its inner .bg, both at y -4px), and
   hover dropdowns (.menu_column: 20px in from the button, 4px up into
   it, bold 10px rows). Things this game doesn't have yet are listed but
   inactive — faded, not links. 861px+ only; the hamburger menu takes
   over below (see the block after this one).
   --------------------------------------------------------------------- */
@media (min-width: 861px) {
  .app-header { justify-content: center; }
  .nav-menu { flex: 0 1 auto; justify-content: center; gap: 0; flex-wrap: nowrap; }
  .nav-pills, .nav-actions { gap: 2px; flex-wrap: nowrap; align-items: center; }
  .nav-pills { margin-right: 2px; }

  .nav-item {
    position: relative;
    display: block;
    height: 43px;
    background: url('/assets/theme/topbutton-right.webp') no-repeat right top;
  }
  .nav-item:not(.inactive):hover,
  .nav-item.menu-open { background-position: right bottom; }
  .nav-item[data-menu="ranking"] { background: none; }

  /* content-box, like the game: its height 28px + padding-top 15px is
     meant to total 43px. This stylesheet is border-box everywhere else,
     which squashed the link to 28px — the left cap then stopped short and
     the dark inside of the right-hand art showed below it. */
  .nav-pill {
    box-sizing: content-box;
    display: block;
    position: relative;
    left: -2px;
    height: 28px;
    margin: 0;
    padding: 15px 15px 0;
    gap: 0;
    border: none;
    background: url('/assets/theme/topbutton-left.webp') no-repeat left top;
    color: #fff;
    font: bold 10px Verdana, Arial, sans-serif;
    line-height: normal;
    text-align: center;
    white-space: nowrap;
  }
  .nav-item:not(.inactive):hover > .nav-pill,
  .nav-item.menu-open > .nav-pill { filter: none; background-position: left bottom; }
  a.nav-pill:hover, a.nav-pill:focus-visible { filter: none; }
  .nav-pill.active { box-shadow: none; filter: none; }
  /* The game's .menu .icon: 18px, no margin (a plain space separates it
     from the label), nudged up 2px. */
  .nav-icon {
    display: inline-block;
    width: 18px;
    height: 18px;
    margin: 0;
    object-fit: contain;
    vertical-align: top;
    position: relative;
    top: -2px;
  }
  .nav-pill-count { margin-left: 3px; color: #f6b628; }
  .nav-item.inactive > .nav-pill { cursor: default; }
  .nav-item.inactive > .nav-pill > * { opacity: 0.45; }

  /* Its two a.manager_icon: 18px art + 1px padding, 1px apart, stacked. */
  .manager-icons { display: flex; flex-direction: column; gap: 0; margin: 0; }
  .manager-icon {
    display: block;
    width: 20px;
    height: 20px;
    margin-top: 1px;
    padding: 1px;
    border: none;
    background-repeat: no-repeat;
    background-position: 1px 1px;
  }
  .manager-icon.inactive { opacity: 0.55; }

  /* #topdisplay + its .bg, folded into the one link; its lpad (1px) and
     rpad (3px) spacer cells become margins. */
  .rank-badge {
    position: static;
    transform: none;
    display: block;
    min-width: 0;
    height: 43px;
    margin: 0 3px 0 1px;
    padding: 0 11px 0 10px;
    background:
      url('/assets/theme/topdisplay-right.webp') no-repeat right 0 top -4px,
      url('/assets/theme/topdisplay-left.webp') no-repeat left 0 top -4px;
    background-size: auto, auto;
    text-align: center;
  }
  .rank-badge.active { box-shadow: none; }
  .rank-badge-title, .rank-badge-value {
    display: block;
    padding: 0;
    color: #fff;
    font: bold 10px Verdana, Arial, sans-serif;
    text-align: center;
  }
  .rank-badge-title { padding-top: 5px; height: 16px; }
  .rank-badge-value { line-height: 18px; }
  .rank-badge:hover .rank-badge-title { color: #f0d49a; }
  .rank-badge:hover .rank-badge-value { text-decoration: none; }

  .notif-bell-wrap { margin-left: 2px; }
  .notif-bell {
    width: 20px;
    height: 20px;
    margin: 0;
    padding: 0;
    border: 1px solid #1a0a02;
    background: #2a0505;
    font-size: 11px;
  }
  .notif-bell .notif-badge { top: -6px; right: -8px; }

  /* --- hover dropdowns (the game's .menu_column) --- */
  .menu-column {
    display: none;
    position: absolute;
    top: 39px;
    left: 20px;
    z-index: 60;
    min-width: 92px;
    max-width: 250px;
    margin: 0;
    padding: 0;
    list-style: none;
  }
  .nav-item[data-menu="ranking"] > .menu-column { left: 5px; top: 43px; }
  /* Each row is one of its .menu-column-item cells: the slice art on the
     row (right part) and on the link (left part, with the little arrow
     ornament), the first row using the top slices instead. */
  .menu-column > li {
    padding: 0;
    background: url('/assets/theme/dropdown-itemright.webp') repeat-y right top;
  }
  .menu-column > li:first-child { background: url('/assets/theme/dropdown-topright.webp') no-repeat right top; }
  .menu-column-item {
    display: block;
    margin: 0;
    padding: 3px 12px 3px 20px;
    border: none;
    background: url('/assets/theme/dropdown-itemleft.webp') no-repeat left top;
    color: #fff;
    font: bold 10px Verdana, Arial, sans-serif;
    line-height: 12px;
    text-align: left;
    white-space: nowrap;
    cursor: pointer;
  }
  .menu-column > li:first-child > .menu-column-item,
  .menu-column > li:first-child > .logout-form > .menu-column-item {
    padding-top: 6px;
    background-image: url('/assets/theme/dropdown-topleft.webp');
  }
  .menu-column > li > .logout-form > .menu-column-item { background-image: url('/assets/theme/dropdown-itemleft.webp'); }
  /* Its bottom row: the rounded lower frame (corner + bottomright) with
     the little gold ornament centered on it. */
  .menu-column::after {
    content: '';
    display: block;
    height: 25px;
    background:
      url('/assets/theme/dropdown-decoration.webp') no-repeat center 12px,
      url('/assets/theme/dropdown-bottomleft.webp') no-repeat left top,
      url('/assets/theme/dropdown-bottomright.webp') no-repeat right top;
  }
  .nav-item.has-menu:hover > .menu-column,
  .nav-item.has-menu:focus-within > .menu-column,
  .nav-item.menu-open > .menu-column { display: block; }
  /* The last few items sit near the bar's right end — open those
     dropdowns leftwards so they don't run off the screen. */
  .nav-actions .nav-item:nth-last-child(-n+3) > .menu-column { left: auto; right: 0; }

  a.menu-column-item:hover, a.menu-column-item:focus-visible,
  .menu-logout:hover, .menu-logout:focus-visible { color: #f0d49a; }
  .menu-column-item.inactive { color: rgba(255, 255, 255, 0.4); cursor: default; }
  .menu-logout { width: 100%; }
}

/* Tablet (481-860px): the whole menu lives behind the hamburger as a
   stacked list. Dropdown entries show inline under their item there
   (there's no hover on touch), minus the inactive ones — nothing to tap
   on those anyway — so Log out (under Settings) stays one tap away. */
@media (max-width: 860px) {
  .nav-item { display: flex; flex-direction: column; align-items: stretch; }
  .nav-item.inactive > .nav-pill { opacity: 0.45; cursor: default; }
  .nav-icon { width: 16px; height: 16px; margin-right: 4px; }
  .manager-icons { display: none; }
  .menu-column { list-style: none; margin: 0.2rem 0 0.4rem; padding: 0 0 0 1.4rem; }
  .menu-column-item {
    display: block;
    padding: 0.2rem 0;
    border: none;
    background: none;
    color: var(--accent);
    font: inherit;
    font-size: 0.85rem;
    text-align: left;
    cursor: pointer;
  }
  .menu-column-item.inactive,
  .menu-column li:has(> .menu-column-item.inactive) { display: none; }
}

/* ---------------------------------------------------------------------
   Village-page sidebar widgets — copies of the reference game's own
   overview widgets (its #rightcolumn .vis.widget, v0.46.0): a ~165px
   column, 10px between widgets, each widget a 1px #804000 box on
   #f4e4bc with the game's gold gradient title bar (bold italic 12px),
   its own light "−"/"+" collapse button (minus.png; plus.png derived from
   it), 12px Verdana rows, and the Units widget's "» recruit" footer.
   Scoped to .village-main so Reports' own panels (same .side-panel
   class) keep their look. Desktop only (721px+); the stacked tablet/
   phone layouts keep theirs.
   --------------------------------------------------------------------- */
@media (min-width: 721px) {
  /* The game's own page geometry: its content column (maincell) is a
     fixed 850px, with 8px of margin inside it (834px of content). That
     holds the village view at its art's native 660px — plus its 1px
     border each side, so the image itself is never resized (resizing it
     blurs it) — then the same 10px gap used between the sidebar's own
     widgets, then the 162px sidebar: 662 + 10 + 162 = 834. */
  body { max-width: calc(850px + 2 * var(--site-frame)); }
  .village-main { padding-left: 8px; padding-right: 8px; }
  .building-switcher { max-width: calc(min(850px, 100% - 2 * var(--site-frame)) - 16px); }
  .village-layout { grid-template-columns: minmax(0, 1fr) 162px; gap: 10px; }
  .village-scene-wrap { max-width: 662px; }
  .movements-panel { max-width: none; }
  .village-right { gap: 10px; }

  .village-main .side-panel {
    padding: 0;
    border: 1px solid #804000;
    background: #f4e4bc;
    font: 12px Verdana, Arial, sans-serif;
  }
  .village-main .side-panel h2 {
    margin: 0;
    padding: 2px 3px 2px 4px;
    background: #c1a264 url('/assets/theme/tableheader_bg3.webp') repeat-x;
    font-size: 12px;
    font-weight: bold;
    font-style: italic;
    line-height: 16px;
  }
  .village-main .panel-collapse-toggle,
  .village-main .panel-collapse-toggle:hover,
  .village-main .panel-collapse-toggle:focus-visible {
    width: 16px;
    height: 16px;
    background: url('/assets/theme/minus.png') no-repeat center;
    font-size: 0;
    color: transparent;
    filter: none;
  }
  .village-main .panel-collapse-toggle[aria-expanded="false"] { background-image: url('/assets/theme/plus.png'); }

  /* Rows: the game's 2-3px cell padding, thin #ded3b9 lines between. */
  .village-main .side-panel > :not(h2) { margin-left: 0; margin-right: 0; }
  .village-main .production-list { padding: 3px; gap: 3px 4px; font-size: 12px; }
  .village-main .production-list .icon { font-size: 16px; }
  .village-main .production-list .icon-img { width: 18px; height: 16px; }
  .village-main .garrison-table { font-size: 12px; }
  .village-main .garrison-table td { padding: 2px 3px; border-bottom: 1px solid #ded3b9; }
  .village-main .garrison-table .icon { font-size: 16px; }
  .village-main .queue-hint { margin: 2px 3px; font-size: 11px; }
  .village-main .queue-empty { margin: 4px 3px; font-size: 12px; }
  .village-main .queue-list { padding: 0 3px; }
  .village-main .queue-item { font-size: 12px; padding: 3px 0; }
  .village-main .queue-item-eta { font-size: 11px; flex-wrap: wrap; column-gap: 4px; row-gap: 0; }
  /* Smaller controls in the narrow sidebar so the order name and its
     time keep their room (the game's own cancel icon is ~16px too). */
  .village-main .village-right .queue-item { gap: 4px; }
  .village-main .village-right .cancel-btn,
  .village-main .village-right .drag-handle { width: 16px; height: 16px; font-size: 9px; }
  .village-main .village-right .queue-reorder-spacer { width: 16px; }

  .widget-footer-link {
    display: block;
    padding: 2px 3px;
    color: #603000;
    font-weight: bold;
  }
  .widget-footer-link:hover, .widget-footer-link:focus-visible { text-decoration: underline; }
}
@media (max-width: 720px) {
  .widget-footer-link { display: block; padding: 0.3rem 0; font-weight: bold; }
}

/* Phone: the admin Players table (Settings) used to run ~60px past the
   screen edge — its per-row grant forms (a select + an amount + a button)
   don't fit a narrow cell. Fixed columns that wrap instead of widening,
   and the forms' controls stack, so the page never scrolls sideways. */
@media (max-width: 480px) {
  .admin-table { table-layout: fixed; }
  .admin-table th, .admin-table td { overflow-wrap: anywhere; }
  .admin-table .inline-form { flex-wrap: wrap; }
  .admin-table .inline-form select,
  .admin-table .inline-form input { min-width: 0; max-width: 100%; }
}
/* Up to 720px the same goes for the tables that carry per-row forms (the
   admin Villages/Players tables): their Actions column alone leaves too
   little room for a long player-entered name, which (with
   .admin-table-wrap no longer scrolling) would push the page sideways. */
@media (min-width: 481px) and (max-width: 720px) {
  .admin-table:has(.inline-form) { table-layout: fixed; }
  .admin-table:has(.inline-form) th,
  .admin-table:has(.inline-form) td { overflow-wrap: anywhere; }
  .admin-table .inline-form { flex-wrap: wrap; }
  .admin-table .inline-form select,
  .admin-table .inline-form input { min-width: 0; max-width: 100%; }
}

/* ---------------------------------------------------------------------
   Game buttons + the Headquarters page (v0.48.0), transcribed from the
   reference game's own stylesheet (docs/reference/tribalwars-game.css:
   its .btn / .btn-build / .btn-recruit, #buildings, .bmain_list_img,
   .unmet_req, .vis.tall, .inactive, .warn).
   --------------------------------------------------------------------- */
.tw-panel .btn {
  box-sizing: content-box;
  display: inline-block;
  height: auto;
  margin: 0 2px;
  padding: 3px;
  border: 1px solid #000;
  border-radius: 5px;
  background: #6c4824 linear-gradient(to bottom, #947a62 0%, #7b5c3d 22%, #6c4824 30%, #6c4824 100%);
  color: #fff;
  font: bold 12px Verdana, Arial, sans-serif;
  line-height: normal;
  text-align: center;
  white-space: nowrap;
  cursor: pointer;
}
.tw-panel .btn:hover:not(:disabled) { background: #b69471 linear-gradient(to bottom, #b69471 0%, #9f764d 22%, #8f6133 30%, #6c4d2d 100%); }
.tw-panel .btn:disabled,
.tw-panel .btn:disabled:hover { opacity: 1; background: #646464 linear-gradient(to bottom, #646464 0%, #4d4c4c 100%); cursor: not-allowed; }
/* The icon buttons put the game's buttons.webp sprite in their left
   padding (hammer at y 1px for Build, crossed swords at -125px for
   Recruit). */
.tw-panel .btn-build,
.tw-panel .btn-build:hover:not(:disabled),
.tw-panel .btn-recruit,
.tw-panel .btn-recruit:hover:not(:disabled),
.tw-panel .btn-recruit.insufficient,
.tw-panel .btn-recruit:disabled {
  padding: 3px 9px 3px 25px;
  background-repeat: no-repeat, no-repeat;
  background-size: auto, auto;
}
.tw-panel .btn-build { min-width: 90px; background-image: url('/assets/theme/buttons.webp'), linear-gradient(to bottom, #947a62 0%, #7b5c3d 22%, #6c4824 30%, #6c4824 100%); background-position: 3px 1px, 0 0; }
.tw-panel .btn-build:hover:not(:disabled) { background-image: url('/assets/theme/buttons.webp'), linear-gradient(to bottom, #b69471 0%, #9f764d 22%, #8f6133 30%, #6c4d2d 100%); background-position: 3px 1px, 0 0; }
.tw-panel .btn-recruit { background-image: url('/assets/theme/buttons.webp'), linear-gradient(to bottom, #947a62 0%, #7b5c3d 22%, #6c4824 30%, #6c4824 100%); background-position: 3px -125px, 0 0; }
.tw-panel .btn-recruit:hover:not(:disabled) { background-image: url('/assets/theme/buttons.webp'), linear-gradient(to bottom, #b69471 0%, #9f764d 22%, #8f6133 30%, #6c4d2d 100%); background-position: 3px -125px, 0 0; }
.tw-panel .btn-recruit:disabled { background-image: url('/assets/theme/buttons.webp'), linear-gradient(to bottom, #646464 0%, #4d4c4c 100%); background-position: 3px -125px, 0 0; }
.tw-panel .btn-recruit.insufficient { background-image: url('/assets/theme/buttons.webp'), linear-gradient(to bottom, #c96b5b 0%, #a8402f 22%, #9c3320 30%, #9c3320 100%); background-position: 3px -125px, 0 0; }

/* The game's text colours: .inactive (grey-gold) and .warn (red). */
.tw-panel .inactive, .tw-panel table.vis td.inactive { color: #c5a76b; }
.tw-panel .warn, .tw-panel table.vis td.warn { color: #dc0000; }
.tw-panel table.vis td.center, .tw-panel .center { text-align: center; }

.tw-panel .hq-table { width: 100%; margin-bottom: 14px; white-space: nowrap; }
.tw-panel .hq-col-name { width: 23%; }
.tw-panel .hq-col-build { width: 30%; }
.tw-panel .bmain_list_img { float: left; margin-right: 8px; }
.tw-panel .hq-level { font-size: 0.9em; }
.tw-panel #buildings .inactive { white-space: normal; }
.tw-panel table.vis.tall td { line-height: 30px; }
.tw-panel .unmet_req { display: flex; }
.tw-panel .unmet_req > span { flex: 1; }
.tw-panel .unmet_req img { vertical-align: middle; }
.tw-panel .tw-rename input[type="text"] { font: 12px Verdana, Arial, sans-serif; }

/* Phone: the 7-column building table has to fit ~340px without
   scrolling (CLAUDE.md) — names drop to picture-only (the picture still
   links to the building), cost icons go, cells wrap, and the build
   button tightens. */
@media (max-width: 600px) {
  .tw-panel .hq-table { white-space: normal; table-layout: fixed; }
  .tw-panel .hq-table th { font-size: 10px; overflow: hidden; text-overflow: ellipsis; }
  .tw-panel .hq-col-build { width: 86px; }
  .tw-panel .hq-table .bmain_list_img { float: none; margin: 0; }
  .tw-panel .hq-table a.hq-building-link + a.hq-building-link,
  .tw-panel .hq-table .hq-building-link + .hq-building-link + br { display: none; }
  .tw-panel .hq-table .req-icon { display: none; }
  /* Numbers never split mid-number; a build time may only break after its
     hour ("1:" / "05:10", via a <wbr> in app.js). */
  .tw-panel .hq-table td { overflow-wrap: normal; font-size: 11px; }
  .tw-panel .hq-table td:not(:first-child):not(.build_options):not([colspan]) { font-size: 10px; padding-left: 2px; padding-right: 2px; }
  .tw-panel .hq-col-name { width: 52px; }
  .tw-panel .btn-build { box-sizing: border-box; min-width: 0; max-width: 100%; padding: 3px 4px 3px 22px; font-size: 11px; white-space: normal; }
  .tw-panel .unmet_req { flex-direction: column; }
  .tw-panel .tw-rename input[type="text"] { width: 100%; }
}
/* The rename box is the game's plain .vis table with a default white text
   input — not the old flex row + beige input styling .rename-form had. */
.tw-panel .rename-form.tw-rename { display: block; max-width: none; }
.tw-panel .rename-form.tw-rename input[type="text"] {
  flex: none;
  width: auto;
  padding: 1px 2px;
  border: 1px solid #767676;
  border-radius: 0;
  background: #fff;
  color: #000;
}
@media (max-width: 600px) {
  .tw-panel .rename-form.tw-rename input[type="text"] { width: 100%; }
}
