/* =========================================================================
   site.css — the shared shell: nav, hero, buttons, cards, status, footer,
   the site-wide live bar.

   THIS SHEET HOLDS NO COLOUR OF ITS OWN. Every colour, gradient, shadow,
   radius and font stack is a role token from theme.css, which webcore/theme.py
   generates from the owner's palette. A palette change is one edit in
   owner.json and zero edits here. A test refuses this file if a raw #hex or
   rgba() ever reappears in it.

   The donor sheet this was ported from carried a :root block of metaphor names
   (--moon, --silver, --stone, --ink) plus about ninety literals scattered
   through the rules. Both are gone: the names now say what a colour DOES, so
   the next owner inherits a system rather than somebody else's night.
   ========================================================================= */

/* =========================================================================
   ★ `hidden` WINS, EVERYWHERE, ONCE.

   The browser's own `[hidden] { display: none }` is a UA rule worth one class
   of specificity, so ANY author rule that sets a display on the same element
   ties it and beats it for coming later in the cascade. The result is an
   element the code believes is hidden, sitting fully on screen, with nothing
   in the console to say why. This sheet already carried one hand-patched case
   (`.nav-lang[hidden]`, below) and admin.css carries NINE - one per selector,
   each added after the bug appeared on a page. That is a whole class of defect
   being fixed retail.

   Sequence's live page hit it again on 2026-08-09: the signed-out gate and the
   signed-in body both drew at once, because `.gate { display: grid }` outranked
   the UA rule.

   One declaration closes it for every element on every page in the fleet. It is
   `!important` on purpose: nothing should ever want an element carrying
   `hidden` to be visible, and a rule that can be beaten is the bug itself. The
   per-selector cancels elsewhere are now redundant and harmless - they are left
   where they are, because each one documents the page it was written for.
   ========================================================================= */
[hidden] { display: none !important; }

/* =========================================================================
   THE MATERIAL LAYER — composed here, from role tokens, with color-mix().

   WHY THIS BLOCK EXISTS. theme.css decides an owner's COLOURS. It cannot
   decide the owner's MATERIAL: how far the art is allowed to show, how a
   panel catches light, how many real steps there are between the brightest
   ink and the quietest. Those are shared craft, so they live here — and
   because every value below is mixed FROM the owner's own tokens, the same
   rules produce a different site per owner. A near-black ember island and a
   pale jungle one get the same depth model and never the same face.

   THE FAILURE THIS REPLACES: a backdrop that laid a FLAT wash across the
   owner's artwork at ~80% and called it legibility. A flat wash does not
   shape a picture, it deletes one: true blacks lift to mid-grey, highlights
   fall to mid-grey, and the whole page converges on the one tone in the
   middle. Everything then looks washed out, because it IS — the page threw
   away its own dynamic range to avoid thinking about where the text sits.
   The scrims below are SHAPED instead: heavy where words are, open where
   they are not.

   No literals here either: color-mix() reads the owner's tokens, so an alpha
   is a mix against `transparent` rather than a hand-typed rgba().
   ========================================================================= */
:root {
  /* ---- veils: the owner's own floor colour, at four densities ----
     Mixed from --bg-deep so a scrim always deepens the page's OWN black
     instead of greying it towards a neutral nobody chose. */
  --veil-deep:   color-mix(in srgb, var(--bg-deep) 94%, transparent);
  --veil-strong: color-mix(in srgb, var(--bg-deep) 82%, transparent);
  --veil-mid:    color-mix(in srgb, var(--bg-deep) 55%, transparent);
  --veil-soft:   color-mix(in srgb, var(--bg-deep) 26%, transparent);

  /* ---- the ink ladder ----
     theme.css derives --fg-dim and --fg-mute four points apart, which on a
     dark ground is not a ladder, it is one grey used twice. These are the
     THREE steps the eye can actually count, mixed in sRGB so the maths is
     the same linear interpolation theme.py documents:
        ink-1  the owner's --fg, undiluted        (headings, numbers, names)
        ink-2  16% toward the ground              (body copy)
        ink-3  34% toward the ground              (labels, meta, captions)
     Measured on this owner in the wave report; the floor is AA on --surface,
     which is the tightest ground any of the three is asked to sit on. */
  --ink-1: var(--fg);
  --ink-2: color-mix(in srgb, var(--fg) 84%, var(--bg));
  --ink-3: color-mix(in srgb, var(--fg) 66%, var(--bg));

  /* ---- carved surfaces ----
     A panel is not a rectangle 3% lighter than the page. It is a face with
     a LIT TOP EDGE, a shaded bottom, a hairline, and a shadow that says how
     far off the page it stands. --edge-top is the owner's own lit edge, so
     an ember island gets an ember rim and a silver one gets silver. */
  --edge-top:    color-mix(in srgb, var(--edge-lit) 34%, transparent);
  --edge-hair:   color-mix(in srgb, var(--edge) 42%, transparent);
  --edge-sink:   color-mix(in srgb, var(--bg-deep) 70%, transparent);
  --lift-1: 0 1px 0 var(--edge-top) inset,
            0 -1px 0 var(--edge-sink) inset,
            0 1px 2px var(--edge-sink),
            0 18px 34px -20px var(--veil-deep);
  --lift-2: 0 1px 0 var(--edge-top) inset,
            0 -1px 0 var(--edge-sink) inset,
            0 2px 4px var(--edge-sink),
            0 34px 60px -28px var(--veil-deep);

  /* ---- tooled grain ----
     A 3px horizontal tooling on the big faces. Subtle enough to read as
     material rather than as a pattern, and cheap enough to be free: it is
     two stops of a repeating gradient, not an image. */
  --grain: repeating-linear-gradient(180deg,
             color-mix(in srgb, var(--edge-lit) 4%, transparent) 0 1px,
             transparent 1px 3px);

  /* ---- the accent, used as a mark rather than a fill ----
     One rule weight, everywhere something is live, current or clickable. */
  --rule-accent: linear-gradient(90deg, var(--accent), var(--accent-hi));

  /* The accent as a HAIRLINE rather than as ink: the border of a quiet accent
     chip, which has to read as a bordered thing without becoming a button.
     Mixed rather than written as rgba(var(--accent-rgb), .45) because this
     sheet may not decide a colour - theme.css is the only file allowed a
     literal, and a bare rgba() here is what the no-raw-colour gate refuses. */
  --edge-accent: color-mix(in srgb, var(--accent) 45%, transparent);
}

/* ---------- reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; scroll-behavior: smooth; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after { animation-duration: .001ms !important; animation-iteration-count: 1 !important; transition-duration: .001ms !important; }
}
body {
  margin: 0;
  min-height: 100%;
  background: var(--bg-deep);
  color: var(--fg);
  font: 400 var(--fs-3)/1.65 var(--font-ui);
  letter-spacing: .006em;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  overflow-x: hidden;
}
img, svg, canvas { max-width: 100%; display: block; }
a { color: var(--accent-hi); text-decoration: none; }
a:hover { color: var(--fg); }
button, input, select, textarea { font: inherit; color: inherit; }

/* FORM CONTROLS — the browser's own look is a WHITE BOX, and on a dark site
   that reads as broken rather than as unstyled. Found in a screenshot: a
   filter field and a sort menu shipped as two white slabs across a dark page,
   and a stray white square sat under a card grid. Every byte-level test
   passed, because a control with no rules is still a control.

   Styled HERE, once, in the shared sheet: a page that grows an input later
   inherits the look instead of discovering this again. Colours are role
   tokens, so an owner's own palette drives them. `color-scheme` is what stops
   the browser painting its OWN dark-mode chrome inside the select and the
   date picker, which no amount of background rules can reach. */
input, select, textarea {
  background: var(--surface-2);
  color: var(--fg);
  border: 1px solid var(--border);
  border-radius: var(--r-s);
  padding: 10px 12px;
  width: 100%;
  color-scheme: dark;
}
input[type="checkbox"], input[type="radio"] {
  width: auto; padding: 0; accent-color: var(--accent);
}
select { padding-right: 34px; cursor: pointer; }
textarea { min-height: 96px; resize: vertical; }
input::placeholder, textarea::placeholder { color: var(--ink-3); }
input:hover, select:hover, textarea:hover { border-color: var(--border-2); }
input:focus, select:focus, textarea:focus {
  border-color: var(--accent); background: var(--surface-3);
}
input:disabled, select:disabled, textarea:disabled {
  color: var(--fg-mute); cursor: not-allowed; opacity: .6;
}
/* DISPLAY TYPE CARRIES WEIGHT. The sheet shipped every heading at 500 — the
   middle of Fraunces' 300..600 range — so the biggest type on the page was
   also the least committed thing on it: large, and weightless. 600 is the top
   of the vendored range and the face is drawn for it. Tracking tightens as
   size grows (see .hero h1 and .sec-head h2): optical spacing is a function
   of size, and one letter-spacing for a 16px h4 and a 72px h1 is wrong for
   at least one of them. */
h1, h2, h3, h4 { margin: 0; font-family: var(--font-display); font-weight: 600; color: var(--fg); letter-spacing: -.012em; line-height: 1.14; }
p { margin: 0 0 1em; }
p:last-child { margin-bottom: 0; }

/* focus — visible against any canvas */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 2px;
  border-radius: 3px;
}

/* skip link (a11y; also the very first focusable node) */
.skip {
  position: absolute; left: -9999px; top: 0; z-index: var(--z-skip);
  background: var(--surface-2); color: var(--fg);
  padding: 10px 16px; border: 1px solid var(--border-2); border-radius: 0 0 var(--r-s) 0;
}
.skip:focus { left: 0; }

/* ---------- the page backdrop ----------
   Fixed (not scrolling) so long pages stay cheap to paint.

   ★ THE SCRIM IS SHAPED, NOT FLAT. The layers below are ordered top-down and
     each one has a job:

       a. THE READING COLUMN. Every heading, lede and paragraph on every page
          in this site is left-aligned inside a centred wrap — so the left of
          the viewport is where words are, and the right is where they never
          are. The veil is therefore heavy at the left edge and gone by the
          right, which buys legibility from the half of the picture nobody
          reads over instead of from the whole picture.
       b. THE BAR AND THE FLOOR. Opaque under the sticky top bar so one bar
          reads the same on all ten pages, and closing again at the bottom so
          the live bar and the last strip of any short page sit on the floor
          colour rather than on somebody's artwork.
       c. A VIGNETTE, off-centre toward the art, so the frame darkens at the
          corners the way a lit object does.
       d. THE OWNER'S ARTWORK, at full strength. Not dimmed: SHAPED by a-c.
       e. --backdrop-layers underneath, so an owner with no background art
          still gets the theme's own depth rather than a slab.

   Everything below the first screen is grounded by `main` (next rule), so the
   art is never asked to carry body copy — which is what a flat wash was
   really apologising for. */
/* ★★★★★ `background`, NOT `background-image`. THIS ONE WORD IS WHY THE
   OWNER'S ARTWORK WAS NEVER ON THE PAGE.
   `background-image` is a longhand: its value is a list of IMAGES and nothing
   else. This layer list carries `18% center / cover no-repeat` — a position,
   a size and a repeat — which are legal ONLY in the `background` shorthand.
   One illegal token invalidates the WHOLE declaration, and CSS drops an
   invalid declaration in silence, so the computed value was
   `background-image: none`. Measured in a real browser on the live site:
   `getComputedStyle(document.querySelector('.night')).backgroundImage` -> "none".
   Not dimmed to a few percent. Never drawn at all.
   It was invalid twice over on this owner, because a pinned
   `--backdrop-layers` carries the same position/size/repeat syntax inside its
   own value — so even removing the art layer would not have rescued it.
   The colour is restated AFTER the shorthand deliberately: `background` resets
   every sub-property it omits, including `background-color`, so the longhand
   has to follow it rather than precede it. */
.night {
  position: fixed; inset: 0; z-index: -2; pointer-events: none;
  background:
    linear-gradient(90deg,
      var(--veil-deep) 0%, var(--veil-strong) 18%, var(--veil-mid) 40%,
      var(--veil-soft) 58%, transparent 78%),
    linear-gradient(180deg,
      var(--veil-deep) 0%, var(--veil-strong) 7%, var(--veil-soft) 20%,
      transparent 34%, transparent 66%,
      var(--veil-mid) 84%, var(--veil-deep) 100%),
    radial-gradient(126% 104% at 64% 32%,
      transparent 0%, transparent 40%, var(--veil-mid) 100%),
    var(--art-background) 18% center / cover no-repeat,
    var(--backdrop-layers);
  background-color: var(--bg-deep);
}
.night::after {
  content: ""; position: absolute; inset: -10% -30%;
  background: var(--backdrop-drift);
  animation: drift 128s linear infinite;
}

/* ★ PHONE. The reading column argument does not survive a 390px viewport:
     there, text spans the whole width, so there is no open half to give the
     picture. The horizontal ramp flattens to one strong, even veil and the
     art stays as texture and weather rather than as a subject. Legibility
     first — an unreadable lede is not a bolder design. */
@media (max-width: 820px) {
  /* Same correction as the base rule, and it had the same bug: `background`,
     because the layer list carries a position, a size and a repeat. This copy
     is the one that mattered most — a phone was the one place the artwork was
     supposed to survive as texture, and it was `none` here too. */
  .night {
    background:
      linear-gradient(90deg, var(--veil-deep) 0%, var(--veil-strong) 100%),
      linear-gradient(180deg,
        var(--veil-deep) 0%, var(--veil-strong) 10%, var(--veil-mid) 34%,
        var(--veil-mid) 62%, var(--veil-deep) 100%),
      var(--art-background) center top / cover no-repeat,
      var(--backdrop-layers);
    background-color: var(--bg-deep);
  }
}

/* ★ THE GROUND. The artwork owns the FIRST SCREEN and nothing below it.
   `main` carries a ramp from clear to the owner's floor colour, so a page
   opens on the picture and then settles onto true black for the reading.
   This is what lets the scrim above stay open: body copy further down the
   page is never over artwork at all, so it does not have to be defended.

   The stops are clamped rather than fixed: 26vh keeps the hero clear on a
   tall desktop, the 430px floor keeps it clear on a short laptop, and the
   760px ceiling stops a very tall window pushing the ramp past the fold.
   No colour of its own — the ramp closes on --bg-deep, the same floor the
   backdrop and the footer close on, so the three meet without a seam.

   ★★★★★ THE `*` `/` THAT USED TO SIT ON THE LINE ABOVE DELETED THIS ENTIRE
   RULE, IN SILENCE, ON EVERY OWNER'S SITE.
   The comment closed there, the six lines of prose below it became garbage
   at the top level of the sheet, and CSS error recovery ate everything up to
   and including the `main` rule. No warning, no console error, no failing
   test — the page simply had no ground ramp, which is a large part of why
   this owner said his background was missing.
   A comment terminator inside comment PROSE is the same character sequence
   as a real one. The gate that catches it is
   tests/frontend/test_stylesheet_integrity.py, which strips comments and
   then refuses any leftover line that is not plausible CSS — a delimiter
   count alone passes this happily, because the two closers do balance.

   ★ AND IT CLOSES ON --veil-deep, NOT ON AN OPAQUE FLOOR. At 94% the artwork
   survives underneath as a 6% ghost, so a long page keeps the owner's own
   texture the whole way down instead of becoming a dead slab below the fold.
   Measured: body ink still lands at 11.0:1 over the brightest pixel in the
   picture, so the texture costs nothing that matters. An opaque stop here
   would have been the flat wash again, just further down the page. */
main {
  background-image: linear-gradient(180deg,
    transparent 0,
    transparent clamp(150px, 26vh, 300px),
    var(--veil-deep) clamp(430px, 72vh, 760px));
}
@keyframes drift {
  0%   { transform: translate3d(-3%, 0, 0)  scale(1.04); }
  50%  { transform: translate3d( 3%, -1.2%, 0) scale(1.09); }
  100% { transform: translate3d(-3%, 0, 0)  scale(1.04); }
}
@media (prefers-reduced-motion: reduce) { .night::after { animation: none; } }

/* ---------- layout ---------- */
.wrap { width: 100%; max-width: var(--maxw); margin-inline: auto; padding-inline: var(--gut); }
main { display: block; }
.section { padding: 64px 0; }
.section + .section { padding-top: 0; }

/* Section heading. The donor drew a pure-CSS triple claw-mark here — three
   stacked linear-gradients that were literally the donor server's own logo
   motif. Every fork inherited another island's brand mark in its section
   headings. It is now the owner's OWN mark, masked so it takes the theme's
   edge colour instead of arriving as a foreign coloured image. */
.sec-head { margin-bottom: 26px; }
.eyebrow {
  font: 700 11.5px/1 var(--font-ui);
  letter-spacing: .19em; text-transform: uppercase;
  color: var(--ink-2);
  display: flex; align-items: center; gap: 11px;
  margin-bottom: 13px;
}

/* ★ THE MARK CANNOT BE A MASK, AND THIS IS NOT A STYLE PREFERENCE.
   `mask` keys off a source's ALPHA. An owner whose mark is a raster with no
   alpha channel — a 256x256 RGB PNG wrapped in an SVG, which is exactly what
   this owner ships — masks to a fully opaque box, so every section heading on
   every page printed a plain grey SQUARE where the brand mark should be. It
   rendered as a broken-image placeholder and nothing failed, because a mask
   with no transparency is still a valid mask.

   So the bullet is now a RULE in the owner's accent: two pixels of ember that
   cannot depend on somebody's export settings, and that spends the accent on
   the thing the accent is for — marking what is live and what is current.
   The mark itself is still used, at a size where it is legible, as the
   footer's watermark (see .foot::before). */
.eyebrow::before {
  content: ""; width: 26px; height: 2px; flex: none; border-radius: var(--r-xs);
  background: var(--rule-accent);
}

/* Section heads get real scale and real tracking. The lede below one is
   secondary INK, not a second heading colour. */
.sec-head h2 { font-size: clamp(25px, 3.4vw, var(--fs-5)); letter-spacing: -.022em; }
.sec-head p { margin-top: 10px; color: var(--ink-2); max-width: 62ch; }
.page-h1 { font-size: clamp(29px, 4.2vw, 40px); letter-spacing: -.02em; }
.page-h2 { font-size: clamp(21px, 3vw, 27px); letter-spacing: -.016em; }

/* ---------- top nav — FIRST in the DOM, on every page ----------
   ★ ONE BAR, TEN PAGES. The band is the only chrome a player sees on every
     surface, so it cannot be a translucent sheet that changes character with
     whatever artwork happens to be behind it. --scrim-nav carries the owner's
     tint; the gradient over it holds the density steady; the hairline and the
     drop shadow separate it from the page so it reads as a bar sitting ABOVE
     the site rather than a strip of the same wash. */
.nav {
  position: sticky; top: 0; z-index: var(--z-nav);
  background-color: var(--scrim-nav);
  background-image: linear-gradient(180deg, var(--veil-strong), var(--veil-mid));
  border-bottom: 1px solid var(--border);
  box-shadow: 0 1px 0 var(--edge-sink), 0 14px 28px -22px var(--veil-deep);
  backdrop-filter: blur(11px) saturate(1.15);
  -webkit-backdrop-filter: blur(11px) saturate(1.15);
}
.nav-in { display: flex; align-items: center; gap: 14px; min-height: 62px; }
.brand { display: flex; align-items: center; gap: 11px; margin-right: auto; min-width: 0; }
/* ★ THE MARK IS THE SITE'S LOGO, SO IT IS SIZED LIKE ONE.
   34px with a radius treated it as an avatar. An owner's mark is usually a
   real crest or lockup with internal detail, and at 34px that detail turns to
   mud — this owner's has a whole animal, an arch and two words inside it.
   46px is the largest the nav row carries without growing (the nav's own
   min-height is 62px), and the radius is gone because a logo is not a rounded
   tile: any corner rounding here crops the artwork's own frame.
   `object-fit: contain` so a mark that is not square is letterboxed rather
   than squashed — the previous rule forced every mark into a square box. */
.brand img {
  width: 46px; height: 46px; flex: none;
  object-fit: contain;
}
.brand-txt { display: flex; flex-direction: column; line-height: 1.1; min-width: 0; }
.brand-txt b {
  font: 500 16.5px/1.1 var(--font-display); color: var(--fg);
  letter-spacing: .012em; white-space: nowrap;
}
.brand-txt span {
  font: 600 9.5px/1 var(--font-ui); letter-spacing: .24em;
  text-transform: uppercase; color: var(--ink-3); margin-top: 3px;
}
/* ★★ A NAVIGATION LABEL IS NEVER HALF A WORD.
   Owner, 2026-08-11, on a signed-in admin bar at 1653px: "too compact here.
   cant even see staff." The last link read "St".

   ★ AND IT WAS NOT TEXT BEING CLIPPED - IT WAS BEING PAINTED OVER, which is
   why every byte-level check passed and why a per-link measurement finds
   nothing. `.nav-links` was `flex: 0 1 auto`, so the ROW shrank; its links are
   `white-space: nowrap` and kept their natural size, so they carried on
   painting past the row's right edge; and `.nav-lang` comes LATER in the
   document with an opaque background, so it drew straight over the tail of the
   last label. Measured on the live page in all three engines: the row was
   755px wide holding 793px of links. The `<a>` itself was never clipped - its
   own scrollWidth matched its clientWidth the whole time - so the only reading
   that finds this is the CONTAINER's, or a screenshot.

   A label a visitor cannot read is worse than any amount of lost decoration,
   so the link row no longer shrinks and the BRAND yields instead: it already
   carries `min-width: 0` and an ellipsis for exactly that, and the site's name
   is still the site's name at half width. `.nav-lang`, `.wc-aud` and
   `.nav-auth` are `flex: none` already; `.nav-cta` is NOT, so it can still
   give a little before the brand is exhausted - which is the intended order,
   the community button being the one control the bar can afford to narrow. */
.nav-links { display: flex; align-items: center; gap: 2px; flex: 0 0 auto; }
.nav-links a {
  position: relative;
  display: block; padding: 9px 12px; border-radius: var(--r-s);
  font: 550 14px/1 var(--font-ui); color: var(--ink-2);
  white-space: nowrap; transition: background .16s, color .16s;
}
.nav-links a:hover { color: var(--fg); background: var(--surface-hover); }

/* ★ THE CURRENT PAGE IS MARKED IN EMBER, AND IN MORE THAN COLOUR.
   It used to be a faint accent wash behind the label — the same weight as a
   hover, and invisible to anyone who cannot separate the two browns. Now the
   live tab is the brightest ink in the bar, sits on the wash, AND carries a
   2px rule under it. The rule is the signal; the colour agrees with it. */
.nav-links a[aria-current="page"] { color: var(--fg); background: var(--accent-wash-2); }
.nav-links a[aria-current="page"]::before {
  content: ""; position: absolute; left: 12px; right: 12px; bottom: 3px;
  height: 2px; border-radius: var(--r-xs);
  background: var(--rule-accent);
}

/* =========================================================================
   HAIRLINE GROUPS — one row, cut into families.
   -------------------------------------------------------------------------
   A dozen links in one flat row is one list nobody can scan. These rules draw
   the families the manifest declares (web.nav_links `sep` / `style`) WITHOUT
   a second row, a dropdown or a mega-menu: a 1px rule between groups, and one
   quiet accent chip for the link the owner wants found first.

   ★ EVERY RULE HERE IS GATED. `.sep` cannot exist unless the assembler emitted
     one, and `.grouped` is only on the nav element once it did — so an owner
     who never opted in gets a stylesheet whose extra bytes never match
     anything. That is what makes this framework capability instead of a
     redesign shipped to eleven people who did not ask for it.
   ========================================================================= */
.nav-links .sep {
  width: 1px; height: 18px; flex: none;
  margin: 0 6px;
  background: var(--border-2);
}
/* ★ THE BRACE FOR WHAT THE ASSEMBLER CANNOT SEE.
   The assembler refuses to draw a hairline in front of the first link on the
   row, but it decides that at BUILD time, and the row can still change at RUN
   time: adminnav.js DETACHES every staff entry, so a nav of [Admin, | Shop]
   loses its first link in a visitor's browser and the rule would open the row.
   `:first-child` / `:last-child` count ELEMENTS, and a detached entry leaves a
   comment node behind - so the rule that is now first or last simply stops
   drawing, for exactly as long as that is true. */
.nav-links .sep:first-child,
.nav-links .sep:last-child { display: none; }

/* The signposted link. A CHIP, not a button: it carries the accent as a
   hairline and a wash, never as a fill, because the nav already has one filled
   control in it (.nav-cta) and two would be two calls to action. */
.nav-links a.nav-sup {
  display: inline-flex; align-items: center;
  padding: 8px 13px; border-radius: var(--r-s);
  border: 1px solid var(--edge-accent);
  background: var(--accent-wash-2);
  color: var(--fg); font: 600 13.5px/1 var(--font-ui);
}
.nav-links a.nav-sup:hover { border-color: var(--accent); background: var(--accent-wash-2); }

/* ---- DENSITY. Twelve links only fit once the row tightens. ----
   Hung off `.grouped` so a row of eight never pays a tightening it does not
   need. The current-page rule tracks the smaller padding: an underline inset
   12px inside an 8px-padded link would sit outside its own label. */
.nav-links.grouped a { padding: 9px 8px; font-size: 13px; }
.nav-links.grouped a[aria-current="page"]::before { left: 8px; right: 8px; }
.nav-links.grouped a.nav-sup { padding: 7px 12px; font-size: 13px; }

.nav-cta {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 9px 15px; border-radius: var(--r-s);
  background: var(--grad-cta);
  border: 1px solid var(--border-2); color: var(--fg);
  font: 600 13.5px/1 var(--font-ui); white-space: nowrap;
  box-shadow: var(--shadow); transition: border-color .16s, transform .16s;
}
.nav-cta:hover { border-color: var(--edge); transform: translateY(-1px); color: var(--fg); }

/* ---------- language lane: the picker in the top right ----------
   A native <select>, styled to sit beside the sign-in control rather than to
   stand out from it: this is a preference, not a call to action, and a second
   filled control in the bar would compete with the one that already is one.
   The element is built by js/i18n.js, so every rule here has to survive the
   browser's own select chrome - hence the explicit appearance reset and the
   drawn chevron, which is the only way the arrow follows the theme.

   ★ A HIDDEN HOST MUST STAY HIDDEN. i18n.js sets `hidden` on a site that
   offers one language, and `display: flex` would beat the browser's default
   [hidden] rule and put an empty box in the bar of every single-language site
   in the fleet. The attribute selector below is what stops that. */
.nav-lang { display: flex; align-items: center; flex: none; }
.nav-lang[hidden] { display: none; }
.lang-select {
  -webkit-appearance: none; appearance: none;
  padding: 9px 30px 9px 12px; border-radius: var(--r-s);
  border: 1px solid var(--border); background-color: var(--surface);
  color: var(--fg); font: 600 13.5px/1 var(--font-ui);
  cursor: pointer; transition: border-color .16s, color .16s;
  background-image: url("data:image/svg+xml;charset=utf-8,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath fill='none' stroke='%23888' stroke-width='1.6' stroke-linecap='round' stroke-linejoin='round' d='M1 1.5 6 6.5l5-5'/%3E%3C/svg%3E");
  background-repeat: no-repeat;
  background-position: right 11px center;
  background-size: 10px 7px;
}
.lang-select:hover { border-color: var(--edge); }
.lang-select:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
/* The dropdown itself is drawn by the OS, which does not inherit the page's
   palette - naming both sides here keeps a dark site's menu from opening as a
   white sheet. */
.lang-select option { background: var(--surface); color: var(--fg); }

/* ---- DENSITY, because a twelfth link and a language control do not both fit.
   MEASURED, not guessed: on a nav at the twelve-entry cap the link row starts
   losing its last entries at 1500px and is 125px short at 1150px - the
   Supporters chip sat UNDER the picker on a 1440px laptop. MEASURED AGAIN
   after the first fix, because the first breakpoint was itself a guess: at
   1520px no rule applied and the row was 43px short.

   ★ Scoped with :has() to the bar that actually carries a language control, so
   every single-language site in the fleet keeps the row it has always had -
   the control is hidden there, and this whole block is inert. A browser
   without :has() support simply keeps today's spacing, which is the same
   place we started. */
/* ★ THE TAGLINE GOES EARLY ON A LANGUAGE BAR. Raised from 1620 to 1860 on
   2026-08-12: the owner's bar overflowed with the tagline still on screen, so
   the squeeze that was supposed to prevent clipping had simply never fired at
   his width. The tagline is the cheapest thing in the bar and the first that
   should go. */
@media (max-width: 1860px) {
  .nav-in:has(.nav-lang:not([hidden])) .brand-txt span { display: none; }
}

@media (max-width: 1620px) {
  /* ★ THE TAGLINE YIELDS FIRST, AND ON A LANGUAGE BAR IT YIELDS HERE (owner,
     2026-08-10, on a signed-in admin bar at ~1456: "design too compact here").
     The flat-row rule below still drops it at 1240; this is the same ruling
     brought forward for the bar that carries the most controls in the fleet -
     brand, tagline, twelve links, a language picker, a sound toggle, the
     points pill and the community button.

     ★ IT IS THE RIGHT THING TO SPEND. The tagline is the widest single item in
     the brand block (~190px against ~110px for the name), so dropping it buys
     back about EIGHTY pixels - more than the twelfth link and the signed-in
     identity pill cost together - and it costs a visitor nothing the name does
     not already say. Shaving another pixel off the link padding instead buys a
     fraction of that and makes every label harder to read. */
  .nav-in:has(.nav-lang:not([hidden])) .brand-txt span { display: none; }
  .nav-in:has(.nav-lang:not([hidden])) .nav-links.grouped a {
    padding: 9px 5px; font-size: 12.5px;
  }
  .nav-in:has(.nav-lang:not([hidden])) .nav-links.grouped a[aria-current="page"]::before {
    left: 5px; right: 5px;
  }
  .nav-in:has(.nav-lang:not([hidden])) .nav-links.grouped a.nav-sup {
    padding: 7px 9px; font-size: 12.5px;
  }
  .nav-in:has(.nav-lang:not([hidden])) .lang-select {
    padding: 8px 25px 8px 9px; font-size: 12.5px;
    background-position: right 9px center;
  }
  .nav-in:has(.nav-lang:not([hidden])) .nav-cta,
  .nav-in:has(.nav-lang:not([hidden])) .nav-auth-btn,
  .nav-in:has(.nav-lang:not([hidden])) .nav-linkd { padding: 9px 11px; }
}
/* Below this the two BUTTONS keep their icons and drop their words. The
   language control never does: it is the one control a reader who cannot read
   the bar has to be able to find, and two letters of a language name is not a
   language name. */
@media (max-width: 1460px) {
  .nav-in:has(.nav-lang:not([hidden])) .nav-cta span.nav-cta-txt,
  .nav-in:has(.nav-lang:not([hidden])) .nav-auth-btn span.nav-auth-txt,
  .nav-in:has(.nav-lang:not([hidden])) .nav-linkd span.nav-linkd-txt {
    display: none;
  }
  .nav-in:has(.nav-lang:not([hidden])) .nav-cta,
  .nav-in:has(.nav-lang:not([hidden])) .nav-auth-btn,
  .nav-in:has(.nav-lang:not([hidden])) .nav-linkd { padding: 9px 10px; }
  .nav-in:has(.nav-lang:not([hidden])) .nav-links.grouped a { padding: 9px 4px; }
}
/* The last step before the row becomes a phone menu at 1080.
   ★ RAISED 1180 -> 1320 on 2026-08-10. Measured on the twelve-link signed-in
   bar, the row wanted 1362px of a 1351px bar at 1366 and 1292 of 1265 at 1280:
   it was OVER, by real pixels, in a band where no step applied. Dropping the
   tagline above pays for 1366; this step is what pays for the rest of the band
   down to the burger. */
@media (max-width: 1320px) {
  .nav-in:has(.nav-lang:not([hidden])) .nav-links.grouped a {
    padding: 9px 3px; font-size: 12px;
  }
  .nav-in:has(.nav-lang:not([hidden])) .nav-links.grouped a.nav-sup {
    padding: 6px 7px; font-size: 12px;
  }
  .nav-in:has(.nav-lang:not([hidden])) .nav-links .sep { margin: 0 2px; }
}

/* ---------- session lane: the topbar identity slot ----------
   Signed OUT this is a button, signed IN it is the player. It is never an
   empty box: the HTML ships the signed-out gate and JS only ever replaces it
   with another rendered state. */
.nav-auth { display: flex; align-items: center; gap: 8px; flex: none; }
.nav-auth-btn {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 9px 13px; border-radius: var(--r-s);
  border: 1px solid var(--border); background: var(--surface);
  color: var(--fg); font: 600 13.5px/1 var(--font-ui); white-space: nowrap;
  transition: border-color .16s, color .16s;
}
.nav-auth-btn:hover { border-color: var(--edge); color: var(--fg); }
.nav-auth-btn[aria-disabled="true"] { opacity: .62; cursor: not-allowed; }
.nav-auth-btn[aria-disabled="true"]:hover { border-color: var(--border); color: var(--fg-dim); }

/* THE DISCORD LINK PILL (2026-08-10). Rendered by site.js beside the chip,
   ONLY for a signed-in player the bot does not know yet, on a site whose link
   lane is open. A CHIP, not a button: accent as a hairline and a wash, never a
   fill — the nav already has one filled call to action (.nav-cta), and the
   .nav-sup signpost rule applies to this one for the same reason. */
.nav-linkd {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 9px 13px; border-radius: var(--r-s);
  border: 1px solid var(--edge-accent);
  background: var(--accent-wash-2);
  color: var(--fg); font: 600 13.5px/1 var(--font-ui); white-space: nowrap;
  transition: border-color .16s, color .16s;
}
.nav-linkd:hover { border-color: var(--accent); color: var(--fg); }

/* signed-in chip + dropdown: sign out lives in the menu the chip opens, never
   on the bar itself — the bar carries no destructive control. */
.nav-chip-wrap { position: relative; }
.nav-chip {
  display: inline-flex; align-items: center; gap: 9px;
  padding: 5px 11px 5px 5px; border-radius: var(--r-pill);
  border: 1px solid var(--border); background: var(--surface);
  max-width: 220px; cursor: pointer;
  transition: border-color .16s;
}
.nav-chip:hover { border-color: var(--edge); }
.nav-chip[aria-expanded="true"] { border-color: var(--edge-lit); }
.nav-avatar {
  width: 26px; height: 26px; border-radius: var(--r-round); flex: none;
  background: var(--surface-3); object-fit: cover;
  border: 1px solid var(--border-2);
}
.nav-who-name {
  font: 600 13px/1 var(--font-ui); color: var(--fg);
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  max-width: 140px;
}
/* ---- the balance, in the bar ----
   The chip used to carry the player's own name, which is the one fact they
   already know. It carries their POINT BALANCE instead; the name moved to the
   dropdown header, which was already showing it. MONOSPACE on purpose: a
   number that changes must not reflow the chip every time a digit does.
   ★ THE SUFFIX IS NOT COPY THIS SHEET INVENTS. site.js writes the owner's own
     catalogs.currency_label, uppercased, and renders NOTHING here when the
     balance could not be read - see renderAuthSlot. */
.nav-pts {
  display: inline-flex; align-items: baseline; gap: 4px;
  font: 600 13px/1 var(--font-mono); color: var(--fg);
  white-space: nowrap;
}
.nav-pts-suffix {
  font: 600 10px/1 var(--font-ui); color: var(--fg-mute);
  letter-spacing: .08em; text-transform: uppercase;
}
/* The same number, spelled out in the dropdown. NOT a `.nav-menu-item`: it is
   a reading, not a control, so it must not inherit the hover an item has. */
.nav-menu-pts {
  display: flex; align-items: baseline; justify-content: space-between; gap: 12px;
  padding: 9px 10px; border-radius: var(--r-s);
  font: 600 13px/1 var(--font-ui); color: var(--ink-2);
}
.nav-menu-pts .nav-pts { font-size: 13.5px; }

.nav-caret {
  width: 0; height: 0; flex: none;
  border-left: 4.5px solid transparent; border-right: 4.5px solid transparent;
  border-top: 5.5px solid var(--fg-mute);
  transition: transform .16s;
}
.nav-chip[aria-expanded="true"] .nav-caret { transform: rotate(180deg); }
.nav-menu {
  position: absolute; top: calc(100% + 9px); right: 0; z-index: var(--z-nav);
  min-width: 224px; padding: 8px;
  background: var(--surface-2); border: 1px solid var(--border-2);
  border-radius: var(--r); box-shadow: var(--shadow);
}
.nav-menu-id { display: flex; align-items: center; gap: 11px; padding: 8px 9px 10px; }
.nav-menu-avatar {
  width: 40px; height: 40px; border-radius: var(--r-round); flex: none;
  background: var(--surface-3); object-fit: cover; border: 1px solid var(--border-2);
}
.nav-menu-id b {
  display: block; font: 600 14px/1.25 var(--font-ui); color: var(--fg);
  max-width: 150px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
}
.nav-menu-sub { font: 500 11.5px/1.3 var(--font-ui); color: var(--fg-mute); }
.nav-menu-sep { height: 1px; background: var(--edge-hair); margin: 2px 6px 6px; }
.nav-menu-item {
  display: block; padding: 9px 10px; border-radius: var(--r-s);
  font: 600 13px/1 var(--font-ui); color: var(--ink-2);
}
.nav-menu-item:hover { color: var(--fg); background: var(--surface-hover); }
.nav-menu-out { color: var(--fg-mute); }
.nav-menu-out:hover { color: var(--bad); background: var(--bad-ink); }

/* SELF-SERVICE UNLINK (2026-08-10) — the only <button> in this menu, because
   it is the only ACTION here; everything else is a destination. It has to be
   told to look like its neighbours: a button inherits none of the page's type
   and brings a chrome background and border of its own. */
.nav-menu-unlink {
  width: 100%; text-align: left; cursor: pointer;
  background: transparent; border: 0; -webkit-appearance: none; appearance: none;
  color: var(--fg-mute); font: 600 13px/1 var(--font-ui);
}
.nav-menu-unlink:hover { color: var(--bad); background: var(--bad-ink); }
/* ARMED reads as the thing it is about to do, so the second press is never a
   surprise — and it stays legible without moving anything, because a row that
   resizes under a finger is a row that gets mis-pressed. */
.nav-menu-unlink[data-armed="1"] { color: var(--bad); background: var(--bad-ink); }
.nav-menu-unlink[disabled] { cursor: default; opacity: .6; }
/* The consequence, and afterwards the outcome. A READING, not a control: no
   hover, and it wraps rather than truncating — the whole point of the line is
   that it can be read before the second press. */
.nav-menu-note {
  margin: 0; padding: 2px 10px 6px;
  font: 500 11.5px/1.45 var(--font-ui); color: var(--fg-mute);
  max-width: 260px;
}

/* burger — hidden on desktop */
.burger {
  display: none; width: 40px; height: 38px; flex: none;
  background: transparent; border: 1px solid var(--border); border-radius: var(--r-s);
  cursor: pointer; padding: 0; position: relative;
}
.burger span, .burger::before, .burger::after {
  content: ""; position: absolute; left: 10px; right: 10px; height: 1.5px;
  background: var(--fg-dim); border-radius: 2px; transition: transform .2s, opacity .2s;
}
.burger::before { top: 12px; } .burger span { top: 18px; } .burger::after { top: 24px; }
.burger[aria-expanded="true"]::before { transform: translateY(6px) rotate(45deg); }
.burger[aria-expanded="true"] span { opacity: 0; }
.burger[aria-expanded="true"]::after { transform: translateY(-6px) rotate(-45deg); }

/* ---------- voice nav entry ----------
   The voice dock paints this entry's live dot via data-state. These rules ship
   in the SHEET, not injected from JS as the donor did with three hardcoded
   signal colours — a dock that paints itself off-theme is a dock that ignores
   the owner's palette. The geometry rule deliberately sets no background: the
   per-state rules have lower attribute specificity, so a colour set there
   would lose to it and every state would render the same. */
.nav-links a[data-voice-link] { position: relative; }
.nav-links a[data-voice-link][data-state]:not([data-state="idle"])::after {
  content: ""; position: absolute; top: 6px; right: 0;
  width: 6px; height: 6px; border-radius: var(--r-round);
}
.nav-links a[data-voice-link][data-state="connected"]::after  { background: var(--ok);   box-shadow: 0 0 8px var(--ok-glow); }
.nav-links a[data-voice-link][data-state="connecting"]::after { background: var(--warn); box-shadow: 0 0 8px var(--warn-glow); }
.nav-links a[data-voice-link][data-state="error"]::after      { background: var(--bad);  box-shadow: 0 0 8px var(--bad-glow); }

/* ---------- hero ---------- */
.hero { position: relative; padding: 58px 0 22px; }
.hero-grid {
  display: grid; grid-template-columns: 1.06fr .94fr;
  gap: 46px; align-items: center;
}

/* ★ THE HEADLINE IS SOLID INK, AND THAT IS THE FIX.
   It used to be painted with --grad-hero-text, a gradient whose last stop is
   a dull brown: the biggest, most-read words on the site FADED OUT from top
   to bottom, so a two-line headline had a bright first line and a muddy
   second one. That is not a highlight, it is the page washing out its own
   masthead — and it is the single most literal source of the complaint this
   wave answers.

   Solid --fg instead, at the full bone-white the owner's palette already
   holds, with --shadow-text to separate it from the artwork behind. Tracking
   goes to -.03em because at 72px the default fit is loose; line-height to
   .98 so two lines lock into one block rather than drifting apart. */
.hero h1 {
  font-size: clamp(36px, 6.4vw, var(--fs-6));
  line-height: .98; letter-spacing: -.03em;
  color: var(--fg);
  text-shadow: var(--shadow-text);
  text-wrap: balance;
}

/* The kicker takes the accent and a rule. It is the first ember on the page
   and it points at the headline. */
.hero .kicker {
  display: flex; align-items: center; gap: 12px;
  font: 700 11.5px/1 var(--font-ui); letter-spacing: .28em; text-transform: uppercase;
  color: var(--accent-hi); margin-bottom: 18px;
}
.hero .kicker::before {
  content: ""; width: 34px; height: 2px; flex: none; border-radius: var(--r-xs);
  background: var(--rule-accent);
}
.hero .lede {
  margin-top: 18px; font-size: 17.5px; color: var(--ink-2);
  max-width: 52ch; line-height: 1.62;
  text-shadow: var(--shadow-text);
}
.hero-cta { display: flex; flex-wrap: wrap; gap: 11px; margin-top: 29px; }

/* ★ THE ART IS A LIT PLATE, NOT A ROUNDED THUMBNAIL.
   It carried a hand-typed `border-radius: 15px` — a soft corner on a site
   whose own geometry token is 2px, so the owner's best asset arrived looking
   like a stock card on a chiselled page. It now takes the owner's radius, a
   lit top edge, a real shadow, and a feathered outer edge so the plate
   dissolves into the ground instead of ending on a hard rectangle. The
   feather only reaches the outermost eighth, which on this owner's banner is
   empty field. */
/* `overflow: clip` because the halo below is drawn at `inset: -14%`, so it
   sticks out past this box on every side. On a 390 px phone that made the
   DOCUMENT 35 px wider than the viewport and the whole page scrolled sideways -
   on every webcore owner, not just one. `clip` and not `hidden`: hidden would
   also make this a scroll container and can trap a sticky/fixed descendant,
   which is the fix that causes the next bug. The halo is decoration and loses
   nothing by being clipped to its own box. */
.hero-art { position: relative; justify-self: center; width: 100%; max-width: 430px; overflow: clip; }
.hero-art img {
  width: 100%; height: auto; border-radius: var(--r-lg);
  -webkit-mask-image: radial-gradient(126% 126% at 50% 48%, black 78%, transparent 100%);
  mask-image: radial-gradient(126% 126% at 50% 48%, black 78%, transparent 100%);
}
.hero-art::before {
  content: ""; position: absolute; inset: -14%; z-index: -1; border-radius: var(--r-round);
  background: var(--grad-hero-halo);
  filter: blur(16px);
}

/* ---------- buttons ---------- */
.btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 8px;
  padding: 12px 21px; border-radius: var(--r-s);
  font: 600 14.5px/1 var(--font-ui); letter-spacing: .012em;
  border: 1px solid var(--border-2); background: var(--surface-2); color: var(--fg);
  cursor: pointer; box-shadow: var(--shadow);
  transition: transform .16s, border-color .16s, background .16s;
  text-align: center;
}
.btn:hover { transform: translateY(-1px); border-color: var(--edge); color: var(--fg); }
.btn:active { transform: translateY(0); }

/* ★ THE PRIMARY FILL IS BUILT AROUND ITS OWN INK, NOT AROUND A LOOK.
   theme.py guarantees exactly one contrast pair on this control: --on-primary
   against --accent-hi (its CONTRAST_FLOORS entry, floor 4.5). The sheet was
   not using that pair — it painted --grad-btn-primary, which ramps 12% DARKER
   than the colour the ink was measured against, and on this owner the bottom
   of that ramp puts a 14.5px label at 4.37:1. Under AA, on the one control
   the whole page is pointing at.

   So the ramp is anchored to --accent-hi and travels only 22% toward
   --accent, which keeps every row of the button inside the pair theme.py
   actually floors. Measured on this owner: 7.68:1 at the top edge, 7.10:1 at
   the bottom. It also reads brighter, which is what an accent is for. */
.btn-primary {
  background: linear-gradient(180deg,
    var(--accent-hi),
    color-mix(in srgb, var(--accent-hi) 78%, var(--accent)));
  color: var(--on-primary); border-color: var(--accent-hi); font-weight: 700;
  box-shadow: var(--glow), var(--lift-1);
}
.btn-primary:hover {
  color: var(--on-primary);
  background: linear-gradient(180deg, var(--accent-hi), var(--accent-hi));
}
.btn-ghost { background: transparent; }
.btn[disabled], .btn[aria-disabled="true"] { opacity: .5; cursor: not-allowed; transform: none; }

/* ---------- cards ----------
   ★ A PANEL IS A CARVED FACE, NOT A LIGHTER RECTANGLE.
     --surface is derived 6% toward --fg, so a card was, literally, the page
     plus three percent. On a flat mid-grey backdrop that is invisible; the
     page read as one undifferentiated sheet with words at different sizes.

     Four things make a face instead, and none of them is more brightness:
       - the tooled --grain, so the surface has material rather than being a
         solved colour;
       - a LIT TOP EDGE and a shaded bottom edge (--lift-1's two insets), the
         two strokes that tell an eye which way the light comes from;
       - a hairline that is actually visible against the new true-black
         ground;
       - a shadow with real distance, so the card stands off the page.
     Every one of them is mixed from the owner's own tokens. */
.card {
  background-color: var(--surface);
  background-image: var(--grain), var(--grad-card);
  border: 1px solid var(--border); border-radius: var(--r);
  padding: 21px; box-shadow: var(--lift-1);
  position: relative;
}
/* the 1px lit top edge */
.card::before {
  content: ""; position: absolute; left: 13px; right: 13px; top: -1px; height: 1px;
  background: linear-gradient(90deg, transparent, var(--chisel), transparent);
}
.card h3 { font-size: var(--fs-4); margin-bottom: 9px; letter-spacing: -.014em; }
.card p { color: var(--ink-2); font-size: 14.6px; }

.cta-card { padding: 36px 24px; }
.cta-card h2 { font-size: clamp(21px, 3vw, 27px); letter-spacing: -.018em; }
.cta-card p { color: var(--ink-2); max-width: 52ch; margin-inline: auto; }

.grid { display: grid; gap: 15px; }
.grid-2 { grid-template-columns: repeat(2, minmax(0, 1fr)); }
.grid-3 { grid-template-columns: repeat(3, minmax(0, 1fr)); }
.grid-4 { grid-template-columns: repeat(4, minmax(0, 1fr)); }

/* ---------- live status card ----------
   The one panel on the landing page that is genuinely live, so it is the one
   that stands highest off the ground: --lift-2 rather than --lift-1. */
.status {
  border: 1px solid var(--border-2); border-radius: var(--r);
  background-color: var(--surface);
  background-image: var(--grain), var(--grad-status);
  padding: 21px 23px; box-shadow: var(--lift-2);
  position: relative;
}
.status::before {
  content: ""; position: absolute; left: 15px; right: 15px; top: -1px; height: 1px;
  background: linear-gradient(90deg, transparent, var(--chisel), transparent);
}
.status-top {
  display: flex; align-items: center; gap: 10px; flex-wrap: wrap;
  padding-bottom: 15px; margin-bottom: 15px; border-bottom: 1px solid var(--edge-hair);
}
.dot { width: 9px; height: 9px; border-radius: var(--r-round); background: var(--edge); flex: none; }
.dot.on  { background: var(--ok);   box-shadow: 0 0 0 4px var(--ok-halo); animation: pulse 2.6s ease-in-out infinite; }
.dot.off { background: var(--bad);  box-shadow: 0 0 0 4px var(--bad-halo); }
.dot.wait{ background: var(--warn); box-shadow: 0 0 0 4px var(--warn-halo); }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: .45; } }
.status-label { font: 600 13.5px/1 var(--font-ui); color: var(--fg); letter-spacing: .02em; }
.status-src {
  margin-left: auto; font: 500 10.5px/1 var(--font-mono);
  letter-spacing: .07em; text-transform: uppercase;
  color: var(--ink-3); border: 1px solid var(--border);
  padding: 5px 8px; border-radius: var(--r-xs); white-space: nowrap;
}
.status-figs { display: grid; grid-template-columns: repeat(3, minmax(0,1fr)); gap: 13px; }
.status-figs:has(.fig:nth-child(2):last-child) { grid-template-columns: repeat(2, minmax(0,1fr)); }
.fig { min-width: 0; }
.fig b {
  display: block; font: 500 26px/1.05 var(--font-mono);
  color: var(--fg); letter-spacing: -.02em;
  font-variant-numeric: tabular-nums;
  overflow-wrap: anywhere;
}
.fig span {
  display: block; margin-top: 6px;
  font: 700 10.5px/1.3 var(--font-ui); letter-spacing: .13em;
  text-transform: uppercase; color: var(--ink-3);
}
.status-note { margin-top: 14px; font-size: 13px; color: var(--ink-2); }

/* ---------- join / connect ---------- */
.copyrow {
  display: flex; align-items: stretch; gap: 8px;
  background: var(--surface-3); border: 1px solid var(--border);
  border-radius: var(--r-s); padding: 5px 5px 5px 13px;
}
.copyrow code {
  font: 500 14.5px/1 var(--font-mono); color: var(--fg);
  align-self: center; overflow-x: auto; white-space: nowrap;
  padding: 6px 0; flex: 1; min-width: 0;
  scrollbar-width: thin;
}
.copyrow button {
  flex: none; padding: 8px 13px; border-radius: var(--r-xs);
  border: 1px solid var(--border-2); background: var(--surface-2);
  color: var(--ink-2); font: 600 12.5px/1 var(--font-ui); cursor: pointer;
  transition: background .16s, color .16s;
}
.copyrow button:hover { background: var(--surface-3); color: var(--fg); }
.copyrow button.ok { color: var(--ok); border-color: var(--ok-dim); }

.steps { counter-reset: s; list-style: none; margin: 0; padding: 0; }
.steps li {
  counter-increment: s; position: relative;
  padding: 0 0 18px 40px; color: var(--ink-2); font-size: 14.8px;
}
.steps li:last-child { padding-bottom: 0; }
.steps li::before {
  content: counter(s); position: absolute; left: 0; top: -1px;
  width: 26px; height: 26px; border-radius: var(--r-round);
  display: grid; place-items: center;
  background: var(--surface-2); border: 1px solid var(--border-2);
  box-shadow: 0 1px 0 var(--edge-top) inset;
  font: 700 12.5px/1 var(--font-mono); color: var(--accent-hi);
}
.steps li b { color: var(--fg); font-weight: 600; }
.steps li:not(:last-child)::after {
  content: ""; position: absolute; left: 13px; top: 27px; bottom: 8px;
  width: 1px; background: linear-gradient(180deg, var(--border-2), transparent);
}
.join-hint { font-size: 13.4px; }

/* ---------- restart schedule tiles ---------- */
.restarts { display: grid; grid-template-columns: repeat(4, minmax(0,1fr)); gap: 10px; }
.restart {
  text-align: center; padding: 15px 8px;
  background-color: var(--surface); background-image: var(--grain);
  border: 1px solid var(--border); border-radius: var(--r-s);
  box-shadow: var(--lift-1);
}
.restart b {
  display: block; font: 500 21px/1 var(--font-mono); color: var(--fg);
  font-variant-numeric: tabular-nums;
}
.restart span {
  display: block; margin-top: 6px; font: 700 10px/1 var(--font-ui);
  letter-spacing: .12em; text-transform: uppercase; color: var(--ink-3);
}

/* ---------- feature grid ---------- */
.feat {
  display: flex; gap: 13px; align-items: flex-start;
  background-color: var(--surface);
  background-image: var(--grain), var(--grad-card);
  border: 1px solid var(--border); border-radius: var(--r);
  padding: 17px; box-shadow: var(--lift-1);
  transition: border-color .18s, transform .18s, box-shadow .18s;
}
/* Hover lights the EDGE in the accent and lifts the tile — the affordance is
   the same ember that marks the current page, so "this responds to you" is
   one colour across the whole site rather than a different idea per surface. */
.feat:hover {
  border-color: var(--accent); transform: translateY(-2px); box-shadow: var(--lift-2);
}
/* The icon well took a hand-typed 9px radius on a site whose radius is a
   token — a soft-cornered well punched into a sharp-cornered tile. */
.feat-ico {
  flex: none; width: 36px; height: 36px; border-radius: var(--r-s);
  display: grid; place-items: center;
  background: var(--grad-icon-well);
  border: 1px solid var(--border-2); color: var(--accent-hi);
  box-shadow: 0 1px 0 var(--edge-top) inset;
}
.feat-ico svg { width: 18px; height: 18px; }
.feat h3 { font-family: var(--font-ui); font-size: 14.8px; font-weight: 650; letter-spacing: 0; }
.feat p { font-size: var(--fs-2); margin-top: 4px; color: var(--ink-2); line-height: 1.55; }
.feat-cmd { display: block; margin-top: 6px; font-size: 11.5px; color: var(--ink-3); }
.feat-cmd code {
  font-family: var(--font-mono); font-size: 11px; color: var(--accent-hi);
  background: var(--surface-3); padding: 2px 5px; border-radius: var(--r-xs);
}

/* ---------- notice / gate panels ---------- */
.notice {
  display: flex; gap: 12px; align-items: flex-start;
  border: 1px solid var(--border-2); border-radius: var(--r);
  background-color: var(--bg-2);
  background-image: var(--grain), var(--grad-notice);
  padding: 15px 17px; color: var(--ink-2); font-size: 14.4px;
  box-shadow: var(--lift-1);
}
.notice b { color: var(--fg); font-weight: 600; }
.notice-ico { flex: none; color: var(--warn); margin-top: 2px; }
.notice-ico svg { width: 17px; height: 17px; }

/* ★ AN EMPTY STATE IS STILL A SURFACE. A gate is what a signed-out player
   sees most, so it gets the same carved face as everything else — dashed
   edge to say "nothing here yet", real material so it is not a hole. */
.gate {
  display: grid; place-items: center; text-align: center;
  padding: 46px 24px; gap: 6px;
  border: 1px dashed var(--border-2); border-radius: var(--r);
  background-color: var(--scrim-panel); background-image: var(--grain);
  box-shadow: var(--lift-1);
}
.gate h3 { font-size: 19px; letter-spacing: -.014em; }
.gate p { color: var(--ink-2); max-width: 42ch; font-size: 14.5px; }
.gate .btn { margin-top: 10px; }
.gate-ico { color: var(--accent); margin-bottom: 4px; opacity: .85; }
.gate-ico svg { width: 30px; height: 30px; }
.gate-note { font-size: var(--fs-2); max-width: 46ch; margin: 10px auto 0; }
.gate-act { margin-top: 16px; }

/* =========================================================================
   GROWTH PAUSE — the two-state control on the player's own page.

   ★ WHICH STATE IS ACTIVE IS SAID FOUR TIMES, ON PURPOSE. Colour alone fails
     roughly one reader in twelve, and a "highlighted" segment is invisible to
     a screen reader that only hears two buttons with two labels. So the live
     state carries: `aria-pressed="true"` (the machine-readable half), a filled
     PRESSED surface with an inset edge (shape, not hue), a TICK that exists in
     the markup only on the pressed segment, and a written status line under
     the control. Any one of the four alone is a control that lies to somebody.

   ★ NOTHING HERE ANIMATES ITS MEANING IN. Every state is painted by the class
     the renderer already wrote, so the first frame is the FINISHED picture. A
     tick that faded in, or a fill that slid across, would leave a
     reduced-motion reader looking at an unpressed control — and the reduced
     motion rule at the top of this sheet collapses transitions to nothing, so
     motion-carried meaning arrives as no meaning at all.

   NO COLOUR OF ITS OWN, like the rest of this sheet: every value is a role
   token from theme.css.
   ========================================================================= */
.gpause { display: flex; flex-direction: column; gap: 12px; }

.gpause-label {
  font: 700 10px/1 var(--font-ui); letter-spacing: .2em;
  text-transform: uppercase; color: var(--fg-mute);
}

/* The control and its state chip sit on ONE line so the answer to "which is
   active" is read in the same glance as the buttons. It wraps rather than
   shrinking, because a chip squeezed to two characters is a chip that has
   stopped being a word. */
.gpause-row {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
}

/* The two segments live in one bordered well so they read as ONE control with
   two positions rather than as two unrelated buttons. */
.gpause-seg {
  display: inline-flex; gap: 4px; padding: 4px; align-self: flex-start;
  max-width: 100%; flex-wrap: wrap;
  background: var(--surface); border: 1px solid var(--border);
  border-radius: var(--r-s);
}

.gpause-btn {
  display: inline-flex; align-items: center; justify-content: center; gap: 7px;
  padding: 10px 18px; min-width: 8.5em;
  border: 1px solid transparent; border-radius: var(--r-xs);
  background: transparent; color: var(--fg-dim);
  font: 600 13.5px/1 var(--font-ui); letter-spacing: .012em;
  cursor: pointer;
  transition: background .15s, color .15s, border-color .15s;
}
.gpause-btn:hover:not([aria-pressed="true"]):not([disabled]) { color: var(--fg); }

/* THE PRESSED SEGMENT. Filled surface + a lit inset edge + a tick: three
   non-colour signals, so the state survives a greyscale print. */
.gpause-btn[aria-pressed="true"] {
  background: var(--surface-3); color: var(--fg);
  border-color: var(--edge);
  box-shadow: inset 0 1px 0 var(--chisel), inset 0 0 0 1px var(--accent-wash-2);
  cursor: default;
}
.gpause-tick { flex: none; width: 14px; height: 14px; }

/* A segment the server will refuse stays VISIBLE and reads as unavailable,
   because a control that vanishes takes its explanation with it — the honest
   card below says why, with the server's own numbers. */
.gpause-btn[disabled] { opacity: .45; cursor: not-allowed; }

/* The focus ring is the site's own, restated so the pressed segment (which
   carries its own inset shadow) cannot swallow it. */
.gpause-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }

/* THE STATE CHIP. Three states — PAUSED / GROWING / NO DATA — and each one is
   a WORD, so the chip survives a greyscale print, a colour-blind reader and a
   screen reader unchanged. The tinted border is the fourth signal, never the
   first: strip every colour from this rule and the chip still says which
   state is live. `NO DATA` is deliberately as loud as the other two - "the
   island has not said" is a real answer, and hiding it would leave the page
   looking confident about something nobody published. */
.gpause-chip {
  display: inline-flex; align-items: center; gap: 7px;
  padding: 6px 12px; border-radius: var(--r-pill);
  border: 1px solid var(--border-2); background: var(--surface-2);
  font: 700 11px/1 var(--font-ui); letter-spacing: .11em;
  text-transform: uppercase; color: var(--fg-dim); white-space: nowrap;
}
.gpause-chip[data-state="paused"] { color: var(--warn); border-color: var(--warn-dim); }
.gpause-chip[data-state="growing"] { color: var(--ok); border-color: var(--ok-dim); }
.gpause-chip .dot { flex: none; }

/* The state, IN WORDS AND IN A SENTENCE. This is the half a screen reader and
   a greyscale reader both get for free, and it is why the control never
   depends on the fill alone. */
.gpause-state {
  display: inline-flex; align-items: center; gap: 8px;
  font: 600 13px/1.4 var(--font-ui); color: var(--fg);
}

/* An accepted write the island has not confirmed yet. Quiet on purpose - it
   is a receipt, not an alarm - but present, so a press is never a button that
   visibly did nothing. */
.gpause-sent { font-size: var(--fs-2); color: var(--fg-dim); }

/* The honest refusal card: why the pause is unavailable, in the server's own
   numbers. Rendered, never a disabled control with no words beside it. */
.gpause-card {
  display: flex; gap: 12px; align-items: flex-start;
  border: 1px solid var(--warn-dim); border-radius: var(--r);
  background: var(--warn-halo);
  padding: 13px 15px; color: var(--fg-dim); font-size: 14px;
}
.gpause-card-ico { flex: none; color: var(--warn); margin-top: 2px; }
.gpause-card-ico svg { width: 17px; height: 17px; }
.gpause-card b { display: block; color: var(--fg); font-weight: 600; }

.gpause-note { font-size: var(--fs-2); color: var(--fg-mute); }

@media (max-width: 460px) {
  .gpause-seg { display: flex; width: 100%; }
  .gpause-btn { flex: 1 1 auto; min-width: 0; }
}

/* =========================================================================
   LIVE ACTIONS — the rail of things a player may do to their own dinosaur,
   on the page that shows it: park, redeem, slay, teleport.

   ★ EVERY TILE SAYS WHAT IT DOES, IN A SENTENCE. A rail of four bare verbs
     is a quiz: "park" and "redeem" are this game's words, not English ones,
     and "slay" beside them reads like another storage option right up until
     it kills the animal. So each tile carries a verb AND a line of plain
     language, and the sheet reserves the room for it rather than letting the
     copy decide the height.

   ★ THE DESTRUCTIVE ONE DOES NOT LOOK LIKE THE OTHERS. Slay carries the
     `bad` role - a tinted edge and a tinted icon - and it is the only tile
     that does, so the one irreversible verb is never one mis-aimed click
     away from a routine one. It also never fires on a single press: the
     renderer swaps the rail for a written confirm, which is the half that
     actually protects the animal. Colour is the reminder, not the guard.

   ★ AN UNAVAILABLE TILE STAYS ON THE PAGE. A verb the server cannot serve
     right now (park with no dinosaur under you, redeem with an empty vault)
     renders disabled WITH ITS REASON underneath, because a control that
     vanishes takes its explanation with it and a player is left wondering
     whether the site is broken.

   No colour of its own: every value is a role token from theme.css.
   ========================================================================= */
.lva { display: flex; flex-direction: column; gap: 12px; }

/* ★ IN AN EMPTY STATE THE RAIL STILL GETS THE WHOLE WIDTH. The card centres its
   empty-state action slot (`.lvc-empty-act` is a centred flex row, sized for a
   button or two), and an auto-fit grid dropped into it collapses to a single
   narrow column - three tiles in a thin strip down the middle of a wide card,
   which is what the first live render showed. This is the state where the rail
   matters MOST: "no dinosaur in game" is exactly when a player wants Redeem. */
.lvc-empty-act > .lva { flex: 1 1 100%; text-align: left; }

/* INSIDE THE CARD IT IS A SEPARATE THING AND HAS TO LOOK LIKE ONE. The rail is
   appended after the card's own footer row (badges + "updated N minutes ago"),
   and with no rule between them the ACTIONS label lands against the mutation
   pill - which is what the first browser render showed. The border only
   applies to the rail as a direct child of the card: in an EMPTY state the
   rail is centred inside `.lvc-empty-act`, where a full-width rule above it
   would be a line under nothing. */
.lvc-card > .lva {
  margin-top: 18px;
  padding-top: 16px;
  border-top: 1px solid var(--border);
}

.lva-label {
  margin: 0;
  font: 700 10px/1 var(--font-ui); letter-spacing: .2em;
  text-transform: uppercase; color: var(--fg-mute);
}

/* AUTO-FIT, NOT FOUR COLUMNS. The rail is rendered inside a card that may be
   half a page wide (with a specimen beside it) or the whole of it, and an
   owner may arm two of the four verbs. Tiles size themselves and rewrap. */
.lva-rail {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(148px, 1fr));
  gap: 10px;
}

.lva-btn {
  display: flex; flex-direction: column; align-items: flex-start; gap: 4px;
  min-width: 0; padding: 13px 14px; text-align: left;
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: var(--r-s); color: var(--fg);
  font: 600 14px/1.2 var(--font-ui);
  cursor: pointer;
  transition: background .15s, border-color .15s, transform .15s;
}
.lva-btn:hover:not([disabled]) {
  background: var(--surface-3); border-color: var(--edge);
}
.lva-btn:focus-visible { outline: 2px solid var(--accent); outline-offset: 2px; }
.lva-btn[disabled] { opacity: .5; cursor: not-allowed; }

.lva-btn i { display: flex; color: var(--accent); }
.lva-btn i svg { width: 17px; height: 17px; }
.lva-btn b { font-weight: 650; letter-spacing: .01em; }
/* The plain-language line. It wraps rather than truncating: a hint cut off
   mid-word is worse than no hint, and the tile is the only thing that says
   what this game's verb means. */
.lva-btn small {
  font: 500 11.5px/1.35 var(--font-ui); color: var(--fg-mute);
  overflow-wrap: anywhere;
}

/* The one irreversible verb. */
.lva-btn.is-bad i { color: var(--bad); }
.lva-btn.is-bad:hover:not([disabled]) { border-color: var(--bad-dim); }

/* An open tile keeps its lit edge while its panel is on screen, so the panel
   below is never an orphan the reader has to match up by guesswork. */
.lva-btn[aria-expanded="true"] {
  background: var(--surface-3); border-color: var(--accent);
}

/* ---- the panel a tile opens: a picklist, a code, a confirm ---- */
.lva-panel {
  border: 1px solid var(--border-2); border-radius: var(--r-s);
  background: var(--surface); padding: 14px 15px;
  display: flex; flex-direction: column; gap: 12px;
}
.lva-panel[hidden] { display: none; }

.lva-panel-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
}
.lva-panel-head b { font: 650 14px/1.3 var(--font-ui); color: var(--fg); }
.lva-panel-head span { font-size: var(--fs-2); color: var(--fg-mute); }

/* A confirm is WORDS FIRST. The sentence names the animal and says the thing
   cannot be undone; the two buttons come after it, never above. */
.lva-confirm { font: 500 14px/1.5 var(--font-ui); color: var(--fg); margin: 0; }
.lva-acts { display: flex; gap: 9px; flex-wrap: wrap; }

/* The stored-dinosaur picklist. One row per slot, the row IS the button. */
.lva-list { display: flex; flex-direction: column; gap: 8px; margin: 0; padding: 0; list-style: none; }
.lva-row {
  display: flex; align-items: center; justify-content: space-between;
  gap: 12px; flex-wrap: wrap;
  padding: 10px 12px; border: 1px solid var(--border);
  border-radius: var(--r-xs); background: var(--surface-2);
}
.lva-row-l { display: flex; flex-direction: column; gap: 2px; min-width: 0; }
.lva-row-l b { font: 650 13.5px/1.25 var(--font-ui); color: var(--fg); overflow-wrap: anywhere; }
.lva-row-l span { font: 500 11.5px/1.3 var(--font-ui); color: var(--fg-mute); }

/* ---- the teleport code ----
   THE CODE IS THE POINT OF THE PANEL, so it is set in the mono face at a size
   somebody can read off a screen and type into a chat window, with the letters
   spaced apart. A six-character code in body type is a code people mistype. */
.lva-code {
  display: flex; align-items: center; gap: 12px; flex-wrap: wrap;
}
.lva-code b {
  font: 700 26px/1 var(--font-mono); letter-spacing: .22em;
  color: var(--fg); padding: 10px 14px;
  border: 1px dashed var(--border-2); border-radius: var(--r-xs);
  background: var(--surface-2);
}
.lva-code-none { font: 500 13.5px/1.4 var(--font-ui); color: var(--fg-mute); }
/* The countdown. Quiet, but present: a code with no expiry on screen is a
   code somebody hands out ten minutes after it died. */
.lva-code-ttl { font-size: var(--fs-2); color: var(--fg-dim); }

.lva-field { display: flex; gap: 9px; flex-wrap: wrap; align-items: center; }
.lva-input {
  flex: 1 1 9em; min-width: 0; padding: 10px 12px;
  background: var(--surface-2); border: 1px solid var(--border);
  border-radius: var(--r-xs); color: var(--fg);
  font: 700 16px/1 var(--font-mono); letter-spacing: .18em;
  text-transform: uppercase;
}
.lva-input:focus-visible { outline: 2px solid var(--accent); outline-offset: 1px; }

/* The outcome line. EVERY press ends here - accepted, refused or never sent -
   because a button that answers nothing is indistinguishable from a broken
   one. `is-bad` is the refusal tint; the words carry the meaning either way. */
.lva-say {
  display: flex; gap: 9px; align-items: flex-start;
  margin: 0; font: 500 13.5px/1.5 var(--font-ui); color: var(--fg-dim);
}
.lva-say svg { flex: none; width: 16px; height: 16px; margin-top: 2px; }
.lva-say.is-bad { color: var(--fg); }
.lva-say.is-bad svg { color: var(--bad); }
.lva-say.is-ok svg { color: var(--ok); }

@media (max-width: 460px) {
  .lva-rail { grid-template-columns: minmax(0, 1fr); }
  .lva-acts .btn { flex: 1 1 auto; }
}

/* ---------- tabs ---------- */
.tabs {
  display: flex; gap: 3px; padding: 4px;
  background-color: var(--surface); background-image: var(--grain);
  border: 1px solid var(--border);
  border-radius: var(--r-s); overflow-x: auto; scrollbar-width: thin;
  box-shadow: 0 1px 0 var(--edge-top) inset;
}
.tabs button {
  position: relative;
  flex: none; padding: 9px 16px 11px; border-radius: var(--r-xs); border: 0;
  background: transparent; color: var(--ink-2);
  font: 600 13.5px/1 var(--font-ui); cursor: pointer; white-space: nowrap;
  transition: background .15s, color .15s;
}
.tabs button:hover { color: var(--fg); }
/* Same two signals as the nav: the brightest ink in the group, plus the rule.
   The selected tab is never told by fill alone. */
.tabs button[aria-selected="true"] { background: var(--surface-3); color: var(--fg); }
.tabs button[aria-selected="true"]::after {
  content: ""; position: absolute; left: 12px; right: 12px; bottom: 4px;
  height: 2px; border-radius: var(--r-xs);
  background: var(--rule-accent);
}

/* ---------- footer ----------
   ★ THE FOOTER IS OPAQUE ON PURPOSE. --grad-foot is a semi-transparent ramp,
     which was fine over a flat wash and is not fine over artwork: the last
     band of the page would show whatever the backdrop is doing behind the
     legal line. It closes on --bg-deep, the same floor `main` and the
     backdrop close on, so the three meet without a seam.

   ★ AND IT IS WHERE THE OWNER'S MARK ACTUALLY GETS TO BE THE MARK. The mark
     is a raster with no alpha (see .eyebrow::before), so it cannot be masked
     into a shape — but it CAN be shown as what it is. `screen` drops its own
     near-black field into the footer's near-black ground and keeps only the
     lit detail, so the emblem surfaces out of the page instead of sitting in
     a box. Decorative, aria-hidden by construction (a pseudo-element), and
     it costs one already-downloaded asset. */
.foot {
  position: relative; isolation: isolate; overflow: hidden;
  margin-top: 76px; border-top: 1px solid var(--border);
  background-color: var(--bg-deep);
  background-image: var(--grad-foot);
  box-shadow: 0 1px 0 var(--edge-top) inset;
  padding: 34px 0 30px;
}
.foot::before {
  content: ""; position: absolute; z-index: -1; pointer-events: none;
  right: -60px; top: 50%; width: 300px; height: 300px; translate: 0 -50%;
  background: var(--art-mark) center / contain no-repeat;
  mix-blend-mode: screen; opacity: .1;
}
.foot-in { display: flex; flex-wrap: wrap; gap: 22px; align-items: center; }
.foot-brand { display: flex; align-items: center; gap: 11px; margin-right: auto; }
.foot-brand img { width: 38px; height: 38px; border-radius: var(--r-s); }
.foot-brand b { display: block; font: 600 15.5px/1.2 var(--font-display); color: var(--fg); }
.foot-brand span { display: block; font-size: 13px; color: var(--ink-3); margin-top: 3px; }
.foot-links { display: flex; flex-wrap: wrap; gap: 7px 20px; }
.foot-links a { font-size: 14px; color: var(--ink-2); }
.foot-links a:hover { color: var(--accent-hi); }
.foot-legal {
  width: 100%; padding-top: 20px; margin-top: 4px; border-top: 1px solid var(--border);
  font-size: 12.8px; color: var(--ink-3);
  display: flex; flex-wrap: wrap; gap: 6px 18px; align-items: center;
}
.foot-legal a { color: var(--ink-3); text-decoration: underline; }
.foot-legal a:hover { color: var(--fg); }

/* ---------- utility ---------- */
.mono { font-family: var(--font-mono); }
.dim  { color: var(--ink-3); }
.mt-s { margin-top: 12px; } .mt-m { margin-top: 22px; } .mt-l { margin-top: 34px; }
.center { text-align: center; }
.sr-only {
  position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px;
  overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0;
}
/* The SITE pill. The shop sheet used to redeclare `.pill` for a different
   shape on the same page; its variant is `.shop-pill` now. */
.pill {
  display: inline-flex; align-items: center; gap: 6px;
  padding: 5px 10px; border-radius: var(--r-pill);
  border: 1px solid var(--border-2); background: var(--surface-2);
  font: 600 11px/1 var(--font-ui); letter-spacing: .1em; text-transform: uppercase;
  color: var(--ink-2);
}
.pill.live { color: var(--ok); border-color: var(--ok-dim); }
.pill.soon { color: var(--warn); }

/* =========================================================================
   RESPONSIVE — phone-first checkpoints.
   ========================================================================= */
/* THE TAGLINE YIELDS BEFORE THE LINKS DO — but only on a grouped row.
   A twelve-link nav and a two-line brand lockup want the same 300px, and the
   burger does not arrive until 820px, so between 1240px and 820px something
   has to give. The tagline gives: it is decoration the brand name already
   implies, and losing it costs a visitor nothing, where a crushed link row
   costs them the site. `:has()` because the brand is a PRECEDING sibling of
   the nav element that carries the marker, and no other combinator reads
   backwards. Owners with a flat row keep their tagline at every width. */
@media (max-width: 1240px) {
  .nav-in:has(.nav-links.grouped) .brand-txt span { display: none; }
}

@media (max-width: 1000px) {
  .hero-grid { grid-template-columns: 1fr; gap: 34px; }
  .hero-art { max-width: 360px; order: -1; }
  .grid-4 { grid-template-columns: repeat(2, minmax(0,1fr)); }
  .grid-3 { grid-template-columns: repeat(2, minmax(0,1fr)); }
}

/* ★ THE BAND WHERE TWELVE LINKS DO NOT FIT AND THE BURGER HAS NOT ARRIVED.
   Between the burger (820) and the point where a grouped, two-language row
   still fits (~1150) there is simply not enough width: twelve links want
   ~635px and about 360px is left at 900. Something has to give, and the two
   ways it can give badly are both already known here - the links painting
   over the language picker (what this wave fixed above) and the whole page
   scrolling sideways (what refusing to shrink would cause instead).

   So the row is CLIPPED AND SCROLLABLE, which is the same ruling the live bar
   already carries a few hundred lines down: "overflow is clipped, not left to
   paint". Nothing is drawn over anything, the page keeps its width, and the
   links that do not fit are still reachable. Scoped to the shape that has the
   problem - a grouped row beside a language picker - so an owner with a flat
   eight-link bar is untouched. */
@media (min-width: 821px) and (max-width: 1150px) {
  .nav-in:has(.nav-links.grouped):has(.nav-lang:not([hidden])) .nav-links {
    /* ★ ALL THREE, OR NONE OF IT WORKS. `flex` puts back the shrink the rule
       above deliberately took away - without it the row keeps its full width
       and pushes the page sideways instead - `min-width` lets that shrink go
       below the links' own size, and `overflow-x` is what makes the result a
       scroll rather than an overpaint. */
    flex: 0 1 auto; min-width: 0; overflow-x: auto;
    scrollbar-width: none; -ms-overflow-style: none;
  }
  .nav-in:has(.nav-links.grouped):has(.nav-lang:not([hidden]))
    .nav-links::-webkit-scrollbar { display: none; }
}

@media (max-width: 820px) {
  .burger { display: block; }
  .nav-links {
    position: absolute; left: 0; right: 0; top: 100%;
    flex-direction: column; align-items: stretch; gap: 0;
    background: var(--scrim-nav-mobile);
    border-bottom: 1px solid var(--border);
    padding: 8px var(--gut) 14px;
    display: none;
  }
  .nav-links[data-open="true"] { display: flex; }
  .nav-links a { padding: 12px 10px; border-radius: var(--r-s); font-size: 15px; }
  /* ---- a group boundary in the COLUMN is a gap, not a line ----
     The burger menu is already a stack of one-per-row links, so a 1px rule
     between two of them reads as a floating dash rather than as a division.
     The separator element is taken out of the layout entirely and the family
     it opens is pushed down instead: same information, in the idiom a column
     actually has. The density class is undone here too — a phone menu has one
     link per row and has never had a width problem to solve. */
  .nav-links .sep { display: none; }
  .nav-links .sep + a { margin-top: 12px; }
  .nav-links.grouped a { padding: 12px 10px; font-size: 15px; }
  .nav-links.grouped a.nav-sup { padding: 12px 10px; font-size: 15px; }
  .nav-in { position: relative; }
  .nav-cta { padding: 9px 13px; font-size: 13px; }
  /* On a phone the identity chip keeps the face and drops the words — the
     avatar is still a rendered state, so the slot never goes blank. The
     BALANCE is not words: it is the one thing the chip is now for, so it
     survives, and the chip keeps a right inset to sit around it. */
  .nav-chip { max-width: none; padding: 4px; }
  .nav-chip:has(.nav-pts) { padding: 4px 11px 4px 4px; }
  .nav-who-name, .nav-caret { display: none; }
  .nav-auth-btn { padding: 9px 12px; font-size: 13px; }
  /* The link pill keeps its icon and drops its words with the rest of the
     bar's buttons — it must never be the thing that overflows the row into
     a second line on the one screen it matters most on. */
  .nav-linkd span.nav-linkd-txt { display: none; }
  .nav-linkd { padding: 9px 11px; }
  /* The language control tightens with the rest of the bar. It keeps its
     words at every width: two letters of a language name is not a language
     name, and this is the one control a reader who cannot read the bar needs
     to be able to find. */
  .lang-select { padding: 9px 27px 9px 10px; font-size: 13px; }
}

@media (max-width: 640px) {
  :root { --gut: 16px; }
  .section { padding: 44px 0; }
  .hero { padding: 34px 0 28px; }
  .hero .lede { font-size: 16.4px; }
  .grid-2, .grid-3, .grid-4 { grid-template-columns: 1fr; }
  .restarts { grid-template-columns: repeat(2, minmax(0,1fr)); }
  .status-figs { grid-template-columns: 1fr; gap: 15px; }
  .fig b { font-size: 23px; }
  .hero-cta .btn { flex: 1 1 100%; }
  .foot-in { flex-direction: column; align-items: flex-start; gap: 16px; }
  .brand-txt b { font-size: 15px; }
  .status-src { margin-left: 0; width: 100%; text-align: center; }
}

@media (max-width: 400px) {
  .brand img { width: 30px; height: 30px; }
  .brand-txt span { letter-spacing: .18em; }
  .nav-cta span.nav-cta-txt { display: none; }
  .nav-cta { padding: 9px 11px; }
  .nav-auth-btn span.nav-auth-txt { display: none; }
  .nav-auth-btn { padding: 9px 11px; }
}

/* =========================================================================
   THE LIVE BAR — site-wide bottom instrument ribbon.
   Population + activity on the left, restart countdown + weather in the
   middle, source chip on the right.

   NEUTRAL PUBLIC CONTRACT: the element is #wc-livebar and the payload event
   is `wc:status`. The donor named both after its own server, so every fork
   inherited a foreign prefix in its public DOM surface.
   ========================================================================= */
#wc-livebar {
  position: fixed; left: 0; right: 0; bottom: 0; z-index: var(--z-livebar);
  display: flex; align-items: center; justify-content: space-between;
  gap: 18px; padding: 9px calc(var(--gut) + 4px);
  background-color: var(--scrim-nav);
  background-image: linear-gradient(180deg, var(--veil-mid), var(--veil-strong));
  -webkit-backdrop-filter: blur(9px); backdrop-filter: blur(9px);
  border-top: 1px solid var(--border);
  box-shadow: 0 -1px 0 var(--edge-top) inset, 0 -14px 28px -22px var(--veil-deep);
  font: 500 11.5px/1 var(--font-mono);
  letter-spacing: .14em; text-transform: uppercase;
  color: var(--ink-2);
}
/* THE BAR'S HEIGHT IS A TOKEN, not a number retyped per sheet.
   Anything anchored to the bottom of the VIEWPORT has to clear this bar, and
   the map page already carried a hand-typed 74px for its zoom control while its
   attribution credit was simply forgotten - so on the map the bar printed
   straight over the credit line. One value, read by everyone that needs it. */
body.has-livebar { --livebar-h: 44px; padding-bottom: var(--livebar-h); }
body.has-livebar .foot { padding-bottom: 30px; }

/* THE FLOW SEAT (`build.livebar_mount`): the same instrument, sitting in the
   document right after the node the owner named - usually the header - instead
   of docked to the viewport. Nothing needs clearing below it, so the body
   carries `has-livebar-flow` and NO bottom padding; the ribbon swaps its top
   border for a bottom one because the neighbouring edge swapped sides too. */
#wc-livebar.lb-flow {
  position: static;
  border-top: 0;
  border-bottom: 1px solid var(--border);
  box-shadow: 0 1px 0 var(--edge-top) inset;
}

/* OVERFLOW IS CLIPPED, NOT LEFT TO PAINT.
   `min-width: 0` lets a flex child shrink below its content, and every label in
   here is `white-space: nowrap` - so at phone width the three groups shrank and
   their text carried on painting straight over each other into an unreadable
   smear. A rendered 390px screenshot found it; every byte-level test passed,
   because nothing about the markup or the bytes was wrong.

   `overflow: hidden` on the groups is the structural fix: a clipped box CANNOT
   paint outside itself, whatever an owner's copy says or how long a translation
   runs. The ellipsis on the text spans turns that clip into something a reader
   understands rather than a word cut mid-letter. Do not remove either one to
   "let it breathe" - breathing is what produced the smear. */
.lb-left, .lb-mid, .lb-right {
  display: flex; align-items: center; gap: 10px;
  min-width: 0; overflow: hidden;
}
/* Shrink in a chosen order rather than an arbitrary one: the population group
   gives way first, the countdown second, and the state chip is fixed - it is
   already the shortest thing in the bar and it is the one that matters most
   when something is wrong. */
.lb-left  { flex: 1 1 auto; }
.lb-mid   { flex: 0 1 auto; }
.lb-right { flex: none; }
.lb-k { color: var(--ink-3); white-space: nowrap;
        overflow: hidden; text-overflow: ellipsis; }
.lb-v { color: var(--fg); font-weight: 600; white-space: nowrap;
        overflow: hidden; text-overflow: ellipsis; }
.lb-v.soon { color: var(--warn); }
.lb-sep { width: 1px; height: 14px; background: var(--border-2); flex: none; }
.lb-sep[hidden] { display: none; }
.lb-wx { display: inline-flex; align-items: center; gap: 10px; }
.lb-wx[hidden] { display: none; }

/* THE PULSE CARRIES THE STATE, and it must agree with the chip beside it.
   It used to be `--ok` unconditionally, so a screenshot showed a calm green
   dot sitting next to a red NO SIGNAL chip - the bar contradicting itself. It
   now takes the same three classes the chip does, from the same branch in
   site.js, which is also what makes the chip safe to drop at phone width: the
   state is still on screen. */
.lb-pulse { position: relative; width: 9px; height: 9px; flex: none; }
.lb-pulse i {
  position: absolute; inset: 0; border-radius: var(--r-round);
  background: var(--ok); box-shadow: 0 0 9px var(--ok-glow);
  animation: lb-breathe 2.8s ease-in-out infinite;
}
.lb-pulse.part i { background: var(--warn); box-shadow: 0 0 9px var(--warn-glow); }
.lb-pulse.off i  { background: var(--bad);  box-shadow: 0 0 9px var(--bad-glow);
                   animation: none; }
@keyframes lb-breathe {
  0%, 100% { opacity: 1; } 50% { opacity: .45; }
}

.lb-bars { display: inline-flex; align-items: flex-end; gap: 2.5px; height: 14px; }
.lb-bars i {
  width: 2.5px; height: 3px; border-radius: 1px;
  background: var(--accent); opacity: .75;
  transition: height .9s ease;
}

.lb-src {
  padding: 5px 10px; border-radius: 4px; border: 1px solid var(--border-2);
  font: 600 10.5px/1 var(--font-mono); letter-spacing: .17em; white-space: nowrap;
}
.lb-src.on   { color: var(--ok);   border-color: var(--ok-dim); }
.lb-src.part { color: var(--warn); border-color: var(--warn-dim); }
.lb-src.off  { color: var(--bad);  border-color: var(--bad-dim); }

@media (max-width: 860px) {
  .lb-bars { display: none; }
  #wc-livebar { gap: 10px; }
}
@media (max-width: 640px) {
  /* PHONE. Three groups plus a chip do not fit in 390px, and the honest fix is
     to decide what goes rather than to let them overlap.

     The weather segment leaves first (it is the only one that is already
     conditional), and its separator goes with it - a divider with nothing after
     it is a dangling stroke.

     The state CHIP goes too, and only because the pulse dot now carries the
     same three states in colour: the information stays on screen at 9px
     instead of 85px. What survives is what a player opened the page for - who
     is on the island, and when it restarts. */
  .lb-wx, .lb-sep { display: none; }
  .lb-right { display: none; }
  .lb-k { letter-spacing: .09em; }
  #wc-livebar { padding: 8px var(--gut); font-size: 10.5px; }
  body.has-livebar { --livebar-h: 38px; }
}
@media (prefers-reduced-motion: reduce) {
  .lb-pulse i { animation: none; }
  .lb-bars i { transition: none; }
}

/* =========================================================================
   FORCED COLOURS / WINDOWS HIGH CONTRAST

   ★ THE MATERIAL LAYER HAS TO GET OUT OF THE WAY, AND IT DOES NOT DO THAT BY
     ITSELF. Forced-colors overrides `color`, `background-color` and
     `border-color` with the user's own pair — but it does NOT remove
     BACKGROUND IMAGES. Everything this wave added for depth is a background
     image: the artwork, the shaped veils, the ramp on `main`, the tooled
     grain. Left alone, a high-contrast reader would get system-coloured text
     printed straight over a photograph, which is the exact failure the mode
     exists to prevent — and it would have shipped looking fine to everyone
     who does not use it.

   So the decoration is dropped and the STRUCTURE is restated in system
   colours: every panel keeps a real CanvasText edge (shadows do not survive
   forced colours, so a card with only a shadow would lose its outline), and
   the three accent rules that mark what is current become Highlight, which
   is the user's own "this one" colour rather than an ember they did not
   choose. Nothing meaningful is carried by a removed layer.
   ========================================================================= */
@media (forced-colors: active) {
  /* decoration out */
  .night { display: none; }
  .foot::before { display: none; }
  main { background-image: none; }
  .nav, #wc-livebar { background-image: none; box-shadow: none; }
  .hero h1, .hero .lede { text-shadow: none; }
  .hero-art img { -webkit-mask-image: none; mask-image: none; }
  .hero-art::before { display: none; }

  /* structure restated as a real edge, because a shadow is not one here */
  .card, .status, .feat, .notice, .restart, .tabs, .nav-menu, .gpause-seg {
    background-image: none;
    box-shadow: none;
    border: 1px solid CanvasText;
  }
  .gate { background-image: none; box-shadow: none; border: 1px dashed CanvasText; }
  .card::before, .status::before { display: none; }
  .feat-ico, .steps li::before { box-shadow: none; border: 1px solid CanvasText; }

  /* the "this one is current" marks keep their job in the user's own colour */
  .eyebrow::before,
  .hero .kicker::before,
  .nav-links a[aria-current="page"]::before,
  .tabs button[aria-selected="true"]::after {
    background: Highlight;
    forced-color-adjust: none;
  }
}

/* =========================================================================
   THE PROMPTPAY QR MODAL — built by js/qrpay.js, used by the top-up page and
   by the supporter page on the tmweasy rail.

   It lives in the SHARED sheet because the modal itself is shared: the same
   dialog must not look like two different dialogs on one site, and the two
   fragments that open it carry a standing law against colour literals in
   their markup. Every value here is a TOKEN, so the owner's palette drives
   all of it.
   ========================================================================= */
.tu-modal { border: 1px solid var(--border); border-radius: 12px;
  background: var(--panel); color: var(--fg); max-width: 380px; padding: 20px; }
.tu-modal::backdrop { background: var(--scrim-modal); }
.tu-centre { text-align: center; }
/* A fixed, generous box: a PromptPay code is scanned off a screen by a phone
   camera, and a QR that reflows with the viewport is a QR that is sometimes
   too small to read. `image-rendering: pixelated` keeps the modules crisp when
   the provider's PNG is smaller than the box. The white plate is REQUIRED, not
   decorative - a QR on a dark panel does not scan. */
.tu-qr { display: block; width: 260px; max-width: 100%; height: auto;
  margin: 0 auto; background: var(--qr-plate); padding: 10px;
  border-radius: 8px;
  image-rendering: pixelated; }
.tu-price { font-size: 26px; font-weight: 600; color: var(--fg);
  line-height: 1.2; }
/* Tabular figures so the seconds do not jitter the layout as they tick - a
   number that shifts sideways every second reads as a page that is
   struggling. */
.tu-count { font-variant-numeric: tabular-nums; font-weight: 600; }
.tu-count[data-low="1"] { color: var(--warn); }

/* =========================================================================
   THE SUPPORTER LADDER (PromptPay rail).

   THE RULE THIS BLOCK IS BUILT AROUND: the ladder is the PRODUCT, so it
   renders in every state - closed, signed out, unlinked, open. Only the
   action area at the foot of each card changes. A page that hides what it
   sells because nobody can buy it this minute has answered the wrong
   question.

   Every colour is a TOKEN. Every size steps off the existing scale. There is
   no icon here and no eyebrow: a rank has a name, a price and a list, and
   anything else is decoration competing with the number the eye came for.
   ========================================================================= */

/* Folds to one column on its own - no media query, and no minimum wide enough
   to push a phone into a horizontal scroll. */
.sup-grid {
  display: grid; gap: 16px;
  grid-template-columns: repeat(auto-fit, minmax(232px, 1fr));
  align-items: stretch;
}
.sup-tier { display: flex; flex-direction: column; gap: 10px; }

.sup-tier-head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 10px; flex-wrap: wrap;
}
/* The display face, because the rank NAME is the one piece of voice on the
   card. Everything else is a fact. */
.sup-tier-name {
  font-family: var(--font-display);
  font-size: var(--fs-4); letter-spacing: -.014em; margin: 0;
}
/* The viewer's own rank. A quiet outline, not a filled chip: it is a note
   about the reader, not a call to action competing with the buttons. */
.sup-badge {
  font-family: var(--font-ui); font-size: 11.5px; letter-spacing: .06em;
  text-transform: uppercase; color: var(--accent);
  border: 1px solid var(--accent); border-radius: 999px;
  padding: 2px 9px; white-space: nowrap;
}

/* THE PRICE. Mono so the digits align down the row and a 4-figure baht number
   does not shove the card wider than its neighbours. */
.sup-price { display: flex; align-items: baseline; gap: 8px; flex-wrap: wrap;
  margin: 0; }
.sup-thb {
  font-family: var(--font-mono); font-size: var(--fs-3);
  color: var(--fg); font-weight: 600; letter-spacing: -.02em;
  font-variant-numeric: tabular-nums;
}
/* A HINT, and dressed as one. It is not what the bank takes, and at equal
   weight it would read as a second price. */
.sup-usd { font-size: 12.5px; color: var(--fg-mute); }
.sup-days { font-size: 13px; color: var(--fg-dim); margin: 0; }

/* The perks. The API sends only what is NEW at this rung, so every line here
   is an upgrade - no ticks, no crosses, no icon soup. The rule is the marker. */
.sup-perks { list-style: none; margin: 4px 0 0; padding: 12px 0 0;
  border-top: 1px solid var(--border); display: grid; gap: 7px; }
.sup-perks li { font-size: 14px; color: var(--fg-dim); line-height: 1.45; }
.sup-inherits { font-size: 12.5px; color: var(--fg-mute); margin: 2px 0 0;
  font-style: italic; }

/* The action area is pinned to the foot so buttons line up across a row of
   cards whose perk lists are different lengths. */
.sup-action { margin-top: auto; padding-top: 14px; }
.sup-action .btn { width: 100%; text-align: center; }
.sup-note { font-size: 13px; color: var(--fg-mute); margin: 0; }
/* CLOSED. A marker, never a disabled button - a greyed button reads as "click
   harder", and this is not something the reader can fix. */
.sup-closed-mark {
  font-size: 12px; letter-spacing: .07em; text-transform: uppercase;
  color: var(--fg-mute); margin: 0;
  border: 1px dashed var(--border-2); border-radius: var(--r);
  padding: 9px; text-align: center;
}

/* THE TOP RUNG. A warmer hairline and the faintest accent wash - enough to
   say "this one" from across the page, and nothing that turns the text into
   an effect. No gradient type, no glow. */
.sup-tier[data-top="1"] {
  border-color: var(--accent);
  background-image: var(--grain), var(--accent-wash-2), var(--grad-card);
}
/* The card the reader already owns. Marked on the EDGE rather than by tinting
   the whole surface, so it does not compete with the top rung beside it. */
.sup-tier[data-mine="1"] { border-color: var(--accent-hi); }

@media (prefers-reduced-motion: no-preference) {
  .sup-tier { transition: border-color .18s ease, transform .18s ease; }
  .sup-tier:hover { transform: translateY(-2px); }
}
