/* ============================================================================
   apexisle — VISUAL SYSTEM (prototype, branch `visualSystem`)

   This file is a LAYER OVER app.css, not a replacement. It loads after it on
   every page, so it wins ties by source order and nothing in app.css had to be
   edited. Deleting this file and its two <link>/<script> lines is a complete
   revert — that is the whole reason it is a separate file.

   EVERY COLOUR IN HERE IS A CSS VARIABLE, and system.js writes those variables
   onto <html> from the player's EQUIPPED THEME. That indirection is the point:
   the values below are the defaults a visitor gets before any theme is applied,
   and a theme is nine values that override them. Nothing about the palette is
   hardcoded.

   Sections:
     1  tokens        colour, type, spacing, shape — all var-driven
     2  accent        one accent, repointed through --brand
     3  type floor    the ~35 sub-12px labels in app.css, lifted
     4  motif         corner geometry, four placeholder shapes
     5  atmosphere    #world — translucent only, and cheap by construction
     6  home page     the restored one-screen layout
     7  design tool   chrome for the colour panel — see the note on section 7
     8  surface       panels that read as material — and the mutation grid
   ============================================================================ */

/* ══════════════════════════════════════════════════════════════════════════
   1. TOKENS.

   COLOUR is the only live layer: system.js overrides the colour tokens on
   <html> from the equipped theme. Everything below the colour block is BAKED —
   type, spacing, motif, atmosphere, hero and background were settled, and these
   are the values they settled on. Change them here.
   ══════════════════════════════════════════════════════════════════════════ */
:root{
  /* ── colour ──
     --accent is the ONE accent. --accent-hi/-lo/-rgb are DERIVED from it by
     system.js. --on-accent is NOT derived — it is the "Text on accent" setting,
     black or white, and it is the single foreground every accent fill uses.
     The values here are the pastel yellow the branch defaults to. */
  --accent:#f0d98c;
  --accent-hi:#f9e9b4;
  --accent-lo:#b9a05a;
  --accent-rgb:240,217,140;
  --on-accent:#141206;
  /* The accent's SECOND stop and the gradient built from the pair.
     --accent stays stop A and stays a plain colour, because it is read as one
     in dozens of places a gradient is not legal — text, borders,
     rgba(var(--accent-rgb),...), accent-color. --accent-grad is the fill
     treatment, used only where a surface is big enough to show a sweep. */
  --accent-b:#e9a94f;
  --accent-grad:linear-gradient(160deg, #f0d98c, #e9a94f);
  /* the rail's colour when it is not following the accent */
  --rail-c:#f0d98c;
  --rail-rgb:240,217,140;
  /* One sweep for the WHOLE rail. The per-item slice offsets (--rg-h, --rg-y)
     are measured and written by system.js, not declared here. */
  --rail-grad:linear-gradient(180deg, #f0d98c, #8fb9c9);

  /* ── spacing ── 1,2,3,5,8,12,18 x a 10px base. It was computed by system.js
     from one number; the control is gone, so the ladder is written out. */
  --sp-1:10px;  --sp-2:20px;  --sp-3:30px;  --sp-4:50px;
  --sp-5:80px;  --sp-6:120px; --sp-7:180px;
  --gutter:clamp(8px,5vw,8px);
  --section:clamp(16px,9vw,16px);

  /* ── type ──
     app.css runs 7px, 7.5px, 8px, 8.5px, 9px and 9.5px micro-labels in ~35
     places; --fs-micro is the floor those come up to. Body copy moves
     14/15px -> 17.5px at line-height 1.62. Rajdhani is a condensed face and
     reads roughly one step smaller than its nominal size, so these are chosen
     against that rather than in the abstract. */
  /* The faces themselves. These did NOT used to live here — system.js wrote
     them from its font library, which is gone with the Type controls. Without
     these four lines every page silently falls back to app.css's
     Anton/Cinzel/Rajdhani, with the sizes below still tuned for these.
     Each page's Google Fonts <link> loads exactly this pair. */
  --font-d:"Lora",Georgia,serif;
  --font-e:"Fraunces",Georgia,serif;
  --font-b:"Fraunces",Georgia,serif;
  --mono:"Fraunces",Georgia,serif;

  --fs-micro:20px;
  --fs-label:20px;
  --fs-small:25px;
  --fs-body:20px;
  --fs-lede:20px;
  --fs-h4:20px;
  --fs-h3:clamp(50px,1.8vw,63px);
  --fs-h2:clamp(57px,3.6vw,100px);
  --fs-h1:clamp(48px,5.6vw,100px);
  --lh-body:1.14;
  --lh-head:1.06;
  --track-micro:.18em;

  /* ── shape ── app.css reads both directly; the motif rules that scaled from
     --mo-base are gone, but the tokens are still referenced. */
  --mo-base:10px;
  --mo:10px;

  /* ── SURFACE ── see section 8.
     app.css turned every 1px outline off site-wide and said surfaces would be
     told apart "by their fill and their spacing instead". The fill it left them
     with is rgba(7,7,10,.4) — 40% black over a marbled photo background — which
     at panel size is not a surface, it is a slightly darker patch of page. These
     four tokens are what a surface is made of instead, and section 8 applies
     them. Nothing reads them until then, so declaring them changes nothing.
       --surf        the lit fill: brighter at the top edge, falling away down
                     the panel, so the panel has a direction and a light source
       --surf-edge   an INSET hairline, not a border. It draws inside the box,
                     only along the top, and it is the highlight a real edge
                     catches — it does not re-create the 240 outlines that were
                     deliberately removed, because it is not an outline.
       --surf-cast   what the panel drops on the page. This is the whole reason
                     a panel reads as being ON something rather than cut out of
                     it.
       --r-lg        a card corner. --radius (6px) is the site's control corner
                     and stays exactly that; a 400px panel wearing a button's
                     radius is most of why these read as boxes from 2003. */
  --surf:linear-gradient(180deg, rgba(245,245,243,.055), rgba(245,245,243,.014) 42%, rgba(245,245,243,.008));
  --surf-edge:inset 0 1px 0 rgba(245,245,243,.085);
  --surf-cast:0 12px 32px rgba(0,0,0,.34);
  --r-lg:14px;

  /* ── the hero mark ── a CAP on how tall img/logoHero.png renders, and where
     its panel sits. The logo is fitted to its panel (max-height:100%), so this
     only binds when pulled below what the panel gives; 900 is high enough to be
     inert on a normal screen, which is what keeps the reverted home page
     looking exactly as it did. See section 6. */
  /* 0 = no accent halo behind the hero mark. index.html falls back to 0 on its
     own, but a token the markup names should exist somewhere findable. */
  --hero-glow:0;
  --hero-h:619px;
  --hero-x:0px;
  --hero-y:0px;

  /* ── atmosphere ── every layer's weight. Not per-theme and not per-player:
     these are the shipped values. See section 5. */
  --atmos-o:1;      /* whole-layer opacity, 0..1 */
  --grain-o:0.047;   /* film grain */
  --vig-a:0.7;      /* vignette strength */
  --fog-d:block;    /* the only MOVING layer — display:none turns motion off */
  --fog-o:0.22;        /* mist density */
  --fog-dur:24.9s;    /* How long the mist takes to travel one tile. Nothing
                       rewrites it at runtime: it was briefly driven by an
                       Animation.playbackRate, because changing a running
                       animation's DURATION recomputes elapsed/duration and
                       teleports the layer to a new phase, which made the
                       slider look like it moved the mist rather than sped it
                       up. A stylesheet cannot express a playback rate, so the
                       EXPORT bakes the chosen speed back into this value. */

  /* ── the background photo ── app.css hardcodes the image, .15 opacity and an
     8px blur into body::before. All three are tunable here. */
  --bg-img:url(img/background1.jpg);
  --bg-o:0.15;
  --bg-blur:9px;
}

/* ══════════════════════════════════════════════════════════════════════════
   2. THE ACCENT — repointed through --brand so app.css's ~290 existing call
   sites follow without being touched.
   --diet-* is deliberately NOT repointed: those still mean "carnivore",
   "herbivore", "omnivore". They were the same colour as --brand by an accident
   of history, not by meaning, and splitting them is half the point.
   There is no way back to the production cyan from here any more: the toggle
   that did it is gone, so this is unconditional rather than sitting behind
   data-accent="on". It still has to out-rank app.css's own :root, which it does
   on order — app.css is linked first.
   ══════════════════════════════════════════════════════════════════════════ */
:root{
  --brand:var(--accent);
  --brand-hi:var(--accent-hi);
  --brand-lo:var(--accent-lo);
  --brand-rgb:var(--accent-rgb);
  --brand-wash:rgba(var(--accent-rgb),.10);
  --brand-line:rgba(var(--accent-rgb),.38);
  --on-brand:var(--on-accent);
}

/* The rail stops decorating with the diet palette. nav.js sets --sp inline per
   item, cycling carni/herbi/omni down the list, so thirteen nav rows wear three
   saturated hues that mean nothing there. A custom property declaration can
   carry !important, which is how a stylesheet beats an inline one.
   The diet colours keep every place they actually encode diet (nursery hatch
   picker, species tags, roster art) — those read --diet-* directly, not --sp. */
html[data-rail="accent"] .rail .it{--sp:var(--accent)!important;--spr:var(--accent-rgb)!important}
html[data-rail="custom"] .rail .it{--sp:var(--rail-c)!important;--spr:var(--rail-rgb)!important}
/* Those are the only two modes now — the diet rail is no longer offered. nav.js
   still cycles carni/herbi/omni into --sp inline as it always has; both rules
   above override it, so that cycle is now unreachable for the RAIL. It is left
   alone deliberately: --diet-* still means diet everywhere it actually encodes
   diet (nursery hatch picker, species tags, roster art), and those read the
   variables directly rather than through --sp. */

/* ── 2b. THE ACCENT AS A GRADIENT ──
   Applied as background-IMAGE over the existing background-COLOUR, so every
   rule below is purely additive: drop data-accentgrad and the solid fill
   underneath is still there, untouched. Nothing here removes a fallback.

   Scoped to surfaces big enough for a sweep to read. A 15px badge or a 2px
   edge bar showing two colours is noise, not a gradient, so those keep stop A.
   :hover uses the background SHORTHAND in app.css, which resets
   background-image to none — hence the explicit repeat on the hover rule. */
html[data-accentgrad="on"] .btn.pri{background-image:var(--accent-grad)}
html[data-accentgrad="on"] .btn.pri:hover{background-image:var(--accent-grad);filter:brightness(1.08)}
html[data-accentgrad="on"] .tiles .tile{background-image:var(--accent-grad)}
html[data-accentgrad="on"] .soc{background-image:var(--accent-grad)}
html[data-accentgrad="on"] #dtuner .dt-act.pri{background-image:var(--accent-grad)}

/* ── 2b-i. TEXT ON A FILLED TILE IS THE "TEXT ON ACCENT" COLOUR ──
   The rule above fills a stat tile with the accent. Its contents did not follow:
   app.css sets .tile .t to --mute-dim, .tile .v to --white and .tile .v.am to
   --brand — a grey label, white ink and, in the headline figure's case, THE
   ACCENT ITSELF sitting on the accent. That last one is not low contrast, it is
   invisible, and it is the figure the tile exists to show.

   Every other accent FILL on the site already reads --on-accent: .btn.pri and
   the badges through --on-brand, .soc directly, the rail through section 2c.
   The tiles were the one filled surface still painting their own foreground, so
   this is the same decision, applied where it was missed — not a new one.

   Scoped to data-accentgrad="on", because that attribute is the only thing that
   makes a tile a filled surface. With it off the tile is --panel again and the
   app.css colours are right; inverting them there would be the same bug with the
   contrast the other way up.

   .t keeps its hierarchy by ALPHA rather than by a second colour: on a fill with
   one foreground, a label is quieter by being lighter, not by being grey. And
   .v.am / .v.lv lose their meaning colours here on purpose — "headline" and
   "live" are said by which tile it is, and neither accent-on-accent nor green on
   a pastel fill says anything at all. */
html[data-accentgrad="on"] .tiles .tile,
html[data-accentgrad="on"] .tiles .tile .v,
html[data-accentgrad="on"] .tiles .tile .v.am,
html[data-accentgrad="on"] .tiles .tile .v.lv{color:var(--on-accent)}
html[data-accentgrad="on"] .tiles .tile .t{color:var(--on-accent);opacity:.72}

/* ── 2b-ii. THE RAIL TABS TAKE THE SAME CORNER AS EVERYTHING ELSE ──
   --radius is app.css's one corner softening, and every other interactive
   surface already wears it: buttons, panels, badges, the wallet, the presence
   pill. The nav tabs were the only square things on the page, which read as a
   different material rather than as emphasis.

   overflow:hidden rather than putting the radius on each layer: the tab has a
   gradient slice in ::before and the selection bar in ::after, both absolutely
   positioned to the edges, and both would poke out of a rounded corner. Nothing
   inside a tab is meant to escape it — the notification badge sits well within
   the box — so clipping the whole thing is the honest way to say that.

   A horizontal inset was tried and dropped: it detaches the strip from the
   rail's own edge and the sweep stops reading as one continuous run down the
   column, which is the thing section 2c exists to produce. */
.rail .it{border-radius:var(--radius);overflow:hidden}

/* ── 2c. ONE GRADIENT DOWN THE WHOLE RAIL ──
   The items are windows onto a single sweep, not thirteen separate gradients.
   --rg-h (the height of the whole item block) and --rg-y (this item's offset
   into it) are measured per item by system.js; the background is sized to the
   block and pushed back by the offset, so the slices line up edge to edge.
   Without those two the fallbacks make every item show the same full sweep,
   which is wrong but visible — a missed measurement should look off, not blank.

   The fill moves to ::before so STATE can be opacity on one layer instead of a
   different colour-mix per state. .it .l/.i are already position:relative
   z-index:1 so the labels stay above it, and ::after (the selection bar) paints
   after ::before in tree order, so the marker survives. */
html[data-railgrad="on"] .rail .it{background:var(--panel-solid)}
html[data-railgrad="on"] .rail .it::before{
  content:"";position:absolute;inset:0;z-index:0;pointer-events:none;
  background-image:var(--rail-grad);
  background-repeat:no-repeat;
  background-size:100% var(--rg-h,100%);
  background-position:0 calc(var(--rg-y,0px) * -1);
  opacity:var(--rg-o,.30);transition:opacity .16s}
html[data-railgrad="on"] .rail .it:hover::before{opacity:var(--rg-o-h,.55)}
html[data-railgrad="on"] .rail .it.on::before{opacity:1}
/* TEXT ON THE ACCENT, one colour everywhere the accent is a fill.
   The buttons, tiles and badges get it through --on-accent -> --on-brand, which
   app.css already reads in every one of those places; system.js writes that
   variable from the same setting. Only the RAIL needs rules of its own, because
   its fill is a slice of a gradient rather than a --brand surface.
   Every rail state takes it — rest, hover and selected — so the rail reads as
   one decision instead of thirteen. NOT scoped to data-railgrad: a solid rail
   gets the same treatment, or the label would jump when a theme turns its
   gradient off. */
html[data-onaccent="black"] .rail .it,
html[data-onaccent="black"] .rail .it:hover,
html[data-onaccent="black"] .rail .it.on{color:var(--ink)}
html[data-onaccent="white"] .rail .it,
html[data-onaccent="white"] .rail .it:hover,
html[data-onaccent="white"] .rail .it.on{color:var(--white)}

/* ══════════════════════════════════════════════════════════════════════════
   3. (was THE TYPE FLOOR — removed.)

   ~35 sub-12px labels in app.css used to be lifted onto the scale, but only
   while data-type was "scale". Nothing sets that attribute any more and the
   settled config had it at "current", so every one of those rules was dead:
   they could never match again. Deleted rather than left to look active.

   Those labels are back at app.css's own sizes. If the lift should ship, it
   comes back as unconditional rules here — not behind an attribute nothing
   sets.
   ══════════════════════════════════════════════════════════════════════════ */

/* ══════════════════════════════════════════════════════════════════════════
   4. (was THE CORNER MOTIF — removed.)

   Four placeholder cut-corner treatments, all behind data-motif. The settled
   config had it "off", and the control that could change it is gone, so all 16
   rules were unreachable. --mo/--mo-base stay declared above because app.css
   reads them directly.
   ══════════════════════════════════════════════════════════════════════════ */

/* ══════════════════════════════════════════════════════════════════════════
   5. THE ATMOSPHERE — #world, injected once per page by system.js.
   ══════════════════════════════════════════════════════════════════════════ */
/* ── THE BACKGROUND PHOTO ─────────────────────────────────────────────────
   app.css:175 draws this on body::before with the image, the .15 opacity and
   the 8px blur all hardcoded. Overridden here rather than edited there, so
   deleting this layer restores the old background along with everything else.

   Source: ref/assets/backgroundPlaceholder.png — 1200x2598 portrait, 6.1 MB.
   Baked to landscape by rotating 90 degrees CLOCKWISE (it is abstract
   marbling, so there is no true "up"; flip the sign in the build step to put
   the warm side on the right instead), then capped at 1920 wide and encoded
   as progressive JPEG q82: 486 KB, down from 6.1 MB. Rotation is baked into
   the file rather than done with a CSS transform because rotating a
   full-bleed fixed layer means swapping its width and height in viewport
   units and rotating about its centre — correct, but fragile, and a static
   site wants a correctly-oriented asset.
   At 15% opacity behind an 8px blur, resolution past 1920 buys nothing a
   viewer can see. If the blur is ever dialled to 0 on a large display, that
   cap is the thing to raise.

   THE INSET IS NOT INDEPENDENT of the blur. A blur samples past the element's
   edge, and at inset:0 that edge is the viewport edge, so the filter reads
   transparency there and draws a pale band around the whole page. app.css
   documents the rule (bleed ~2x the radius) but hardcodes both numbers, so
   making the blur tunable would have broken it. It is computed from the blur
   now, which keeps the invariant true at every slider position. */
body::before{
  z-index:-2;                            /* below #world, which is -1 */
  inset:calc(var(--bg-blur) * -2.5);
  background:var(--bg-img) center/cover no-repeat;
  opacity:var(--bg-o);
  filter:blur(var(--bg-blur));
}

#world{position:fixed;inset:0;z-index:-1;pointer-events:none;overflow:hidden;
  contain:strict;opacity:var(--atmos-o)}
html[data-atmos="off"] #world{display:none}

/* ── RULE ONE: NOTHING IN HERE MAY BE OPAQUE ──────────────────────────────
   The first version of this layer opened with a full-viewport
   `linear-gradient(178deg,#0b0b0d,#08080a,#060608)`. That is an opaque fill,
   and body::before (the site's background photo) sits underneath it — so the
   "atmosphere" painted the background out completely instead of sitting on
   top of it. "Reduced" did not help because it only stopped the ANIMATIONS;
   the opaque sky was still there at every level.
   There is no base fill now. Every layer is a translucent tint over the photo,
   and the photo is the thing you are supposed to be looking at. */
#world .w-tint{position:absolute;inset:0;
  background:
    radial-gradient(78% 52% at 78% 2%, rgba(var(--accent-rgb),.09), transparent 60%),
    radial-gradient(88% 68% at 18% 102%, rgba(16,24,34,.20), transparent 62%)}

/* ── RULE TWO: NO filter:blur() AND NO mix-blend-mode ─────────────────────
   Both were in the first version and both are why an "atmospheric" page
   stutters on a low-end machine: a blurred layer must be re-rasterised every
   time it moves or its opacity changes, and a blend mode forces the whole
   stack into a composited group. Neither buys anything a radial gradient with
   soft colour stops does not already give.
   The only animated property is transform, which stays on the compositor and
   never triggers layout or paint. Setting --fog-d to none removes these three
   layers outright, which is what a reduced-motion preference does — pausing an
   animation still leaves the promoted layers behind. */
/* ── WHY THE FIRST VERSION LOOKED STATIC, AND LIKE A BLOB ─────────────────
   Speed: it drifted +/-5% of an element 180% of the viewport wide, over 120s.
   At 1920px that is 346px of total travel at 2.88 px/s — real motion, and
   three to five times slower than the ~8-15 px/s where drift becomes
   perceptible. It was moving; nobody could see it.
   Shape: each layer was ONE radial gradient, which is by definition one soft
   ellipse. A single ellipse cannot read as cloud however it is tinted.

   Both fixed without reaching for blur or a blend mode, so the performance
   rules above still hold:
   - Shape comes from FRACTAL NOISE, not from gradients. Stacked ellipses were
     the first attempt and any number of them still reads as stacked ellipses;
     cloud needs a lumpy self-similar silhouette, which is what feTurbulence
     gives. The tiles are cloud-a/b/c.svg — SVGs used as background IMAGES, so
     the browser rasterises each once on decode and caches the bitmap. That is
     not a live CSS filter and costs nothing per frame; the film grain has
     always worked the same way. No gradients are left in these layers at all.
   - Travel is much larger and the layers move at different speeds and in
     OPPOSITE directions, which is what sells depth — a rigid slab sliding
     one way reads as a texture, cross-drifting layers read as weather.
   - The motion is a SEAMLESS LOOP now, not an alternating drift: see the
     geometry note below. Scale and vertical drift were dropped to get it —
     a loop needs its last frame to be identical to its first, and neither of
     those returns to where it started. Three layers at different speeds carry
     the life that the billowing was adding, and this costs one transform per
     layer with no easing curve to evaluate.
   Still transform-only, still composited, still no per-frame paint.
   --fog-dur and --fog-o are the shipped speed and density; see section 1. */
#world .w-fog{display:var(--fog-d);opacity:var(--fog-o)}

/* ── HOW THE LOOP CLOSES ──────────────────────────────────────────────────
   ONE seamless image, translated by EXACTLY one tile width — so the last frame is pixel-identical to the first
   and the animation is linear + non-alternating. No seam, and no turnaround to
   catch the eye.

   THE TILE IS A FIXED PIXEL WIDTH, NOT A PERCENTAGE, and that is the fix for
   the loop tearing on some machines but not others.

   It used to be: element 200% of the viewport, background-size 25% of the
   element, translate -25%. Those agree with each other arithmetically — but
   25% of 200vw is viewport/2, so the tile was only ever a whole number of
   DEVICE pixels when the viewport width happened to be even at the user's
   scaling. A 16:9 laptop at 125% OS scaling lands on widths like 1707 CSS px,
   giving a 853.5px tile: the browser then rounds the repeating background's
   tile pitch one way and the composited transform another, and the two stop
   agreeing. That is the seam. It was never about aspect ratio — a 16:10 panel
   simply happened to land on even widths.

   Now --tile is an explicit px value, and the SAME value drives all three of:
     background-size   var(--tile)                 the repeat pitch
     translate        calc(var(--tile) * -1)       exactly one tile
     element width    calc(100vw + var(--tile))    always covers after moving
   One number, so they cannot disagree, and none of them depends on the
   viewport at all.

   system.js goes one better and SNAPS --tile to the device pixel grid for the
   current devicePixelRatio (see snapTiles), so tile * dpr is an exact integer
   and the rounding above cannot happen in the first place. The px values below
   are the no-JS fallback.

   The previous version stacked TWO masked tiles per layer at different sizes,
   trying to hide the repeat by making the rhythms clash. That made it worse:
   masked tiles fade to nothing at their own edges, so every repeat was a
   visibly separate blob and there were twice as many of them. Seamless noise
   removes the problem instead of camouflaging it. */
/* background-size is set PER LAYER, not here: layer 1 must keep the tile's
   natural aspect and the two veils must not. See each layer below. */
#world .w-fog i{position:absolute;left:-1px;
  width:calc(100vw + var(--tile) + 2px);
  background-repeat:repeat-x;
  will-change:transform;
  animation-iteration-count:infinite;animation-timing-function:linear}

/* Each element carries its own --tile, so one rule moves it by its own exact
   tile width. Only fogLoop is used now that a single layer remains; fogLoopRev
   is kept because running a second layer the other way is what stops two from
   reading as one texture sliding, and that is the shape any future depth pass
   will want. */
@keyframes fogLoop{
  from{transform:translate3d(0,0,0)}
  to  {transform:translate3d(calc(var(--tile) * -1),0,0)}}
@keyframes fogLoopRev{
  from{transform:translate3d(calc(var(--tile) * -1),0,0)}
  to  {transform:translate3d(0,0,0)}}

/* The low ground bank — the only mist layer. Its z-index is left at 3 from when
   there were three: it stacks against nothing now, and renumbering it to 1
   would only make a future second layer look like it belongs underneath. */
/* NOTHING ABOUT THIS LAYER'S APPEARANCE DEPENDS ON THE VIEWPORT.
   It used to be bottom:-6vh / height:44vh with background-size "--tile 100%",
   which forced the tile to the element height — so the SHAPE of the clouds
   stretched with the window. Measured across common heights it ran from 62% to
   124% of the tile's natural aspect: a 2x swing in how the clouds look,
   depending only on how tall the browser was.

   `auto` takes the height from the SVG's own aspect instead (1600x700), so at
   --tile 1280px the tile is exactly 1280x560 on every device. --tile-a is 1280
   rather than 900 precisely so that natural height lands at 560px and covers
   the band without being stretched to reach it.

   Anchored to the element's BOTTOM, which sits 60px below the viewport. That
   puts the dense end of the tile off-screen and means a short window clips the
   FADED top rather than the solid bottom. height is min()-capped so the element
   can never be shorter than it needs on a small screen, and the cap is the only
   thing here the viewport touches — it changes how much of the tile you see,
   never how the tile looks. */
#world .w-fog i:nth-child(1){bottom:-60px;height:min(560px,78vh);opacity:.55;z-index:3;
  --tile:var(--tile-a,1280px);
  background-image:url(img/cloud-a.svg);
  background-size:var(--tile) auto;background-position:left bottom;
  animation-name:fogLoop;animation-duration:var(--fog-dur)}

/* THERE IS ONLY THE LOW LAYER. A mid band and a high haze used to sit above it,
   each a full-viewport repeating background running its own transform
   animation. Both are gone — the elements are not created at all, so this is a
   removal rather than a display:none that keeps paying for them.
   fogLoopRev and --tile-b/--tile-c went with them; the keyframes stay defined
   because a second layer running the other way is the obvious way to bring
   depth back, and it should not need re-deriving. */

/* Static — one paint, then nothing. */
#world .w-grain{position:absolute;inset:0;opacity:var(--grain-o);
  background-image:url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23n)'/%3E%3C/svg%3E")}
#world .w-vig{position:absolute;inset:0;
  background:radial-gradient(128% 104% at 50% 42%, transparent 58%, rgba(2,2,4,var(--vig-a)) 100%)}

/* An OS-level preference outranks the site's default; system.js also seeds the
   defaults from it, so this is the belt to that braces. */
@media (prefers-reduced-motion:reduce){ #world .w-fog{display:none} }

/* ══════════════════════════════════════════════════════════════════════════
   6. THE HOME PAGE

   The scrolling landing page that used to live here is GONE, reverted on the
   owner's call — it did not look good, and the ~60 .lp-* rules that built it
   went with it rather than sitting here dead. It is recoverable from git if it
   is ever wanted back; nothing else in this file depended on it.

   index.html is the original one-screen app-shell layout again: the logo panel
   and the two live-population tiles on the left, the greeting and the welcome
   message on the right, no page scroll. What survived the revert is only what
   was not part of that layout — the new artwork.

   The hero sliders were built for the landing page's art panel, so they are
   repointed at this layout's .hero. --hero-h is a CAP here rather than a fixed
   height: the logo is sized to fit its panel (max-height:100%) and always was,
   so the cap only binds when it is pulled below what the panel gives — which
   is why its default moved 476 -> 900, a value high enough that this page looks
   exactly as it did before anyone touches the slider.
   ══════════════════════════════════════════════════════════════════════════ */
/* --hero-x / --hero-y move the logo. --hero-h is consumed in index.html's own
   <style>, NOT here: that block loads after this file, so a rule for the same
   selector there wins on source order and this one was silently dead — which
   is exactly why the Height slider did nothing. One selector, one rule. */
.hero{transform:translate(var(--hero-x),var(--hero-y))}

/* ══════════════════════════════════════════════════════════════════════════
   7. THE COLOUR PANEL — a DESIGN-TIME TOOL, and the only part of this file that
   is not part of the site. It exists to make authoring a theme quick: pick two
   pairs, read the contrast, copy the entry into themes.json. It is not a player
   feature and does not ship — dropping the #dtuner block here and buildTuner()
   in system.js removes it completely, and nothing else changes, because the
   colours a visitor sees come from their equipped theme either way.
   Everything above is a proposal; this is how it gets judged and settled
   without a redeploy per variant. It is the first thing to delete once a final
   config is exported and pasted into section 1.
   EXCEPTION: the Visual effects sliders have to SHIP in some form — a player on
   a weak machine needs that control permanently. See system.js.
   ══════════════════════════════════════════════════════════════════════════ */
#dtuner{position:fixed;right:16px;bottom:calc(var(--fab-gutter) + 12px);z-index:9000;
  font-family:var(--font-b)}
#dtuner .dt-btn{width:46px;height:46px;display:grid;place-items:center;font-size:20px;
  background:var(--accent);color:var(--on-accent);border:0;cursor:pointer;
  box-shadow:0 10px 30px rgba(0,0,0,.6)}
#dtuner .dt-panel{position:absolute;right:0;bottom:56px;width:340px;
  max-height:min(82vh,780px);overflow-y:auto;padding:16px;
  background:#0d0d11;border:1px solid rgba(245,245,243,.16);
  box-shadow:0 24px 70px rgba(0,0,0,.8);display:none}
#dtuner.open .dt-panel{display:block}
#dtuner .dt-panel::-webkit-scrollbar{width:8px}
#dtuner .dt-panel::-webkit-scrollbar-thumb{background:rgba(245,245,243,.18)}

#dtuner h4{font-size:var(--fs-micro);letter-spacing:var(--track-micro);text-transform:uppercase;
  color:var(--accent);margin:0 0 10px;font-weight:700}
#dtuner .dt-grp{margin:0 0 18px;padding:0 0 16px;border-bottom:1px solid rgba(245,245,243,.10)}
#dtuner .dt-grp:last-of-type{border-bottom:0}
#dtuner .dt-row{display:flex;align-items:center;gap:8px;margin-bottom:8px}
#dtuner .dt-row > label{flex:1 1 auto;font-size:var(--fs-micro);color:var(--mute);
  letter-spacing:.04em;min-width:0}
#dtuner .dt-row.stack{display:block}
#dtuner .dt-row.stack > label{display:block;margin-bottom:5px}

#dtuner input[type=color]{flex:0 0 auto;width:44px;height:28px;padding:0;cursor:pointer;
  background:none;border:1px solid rgba(245,245,243,.22)}
#dtuner input[type=text],
#dtuner input[type=number],
#dtuner select{flex:0 0 auto;width:78px;padding:6px 8px;font-family:var(--mono);
  font-size:var(--fs-micro);background:rgba(245,245,243,.06);
  border:1px solid rgba(245,245,243,.18);color:var(--white)}
#dtuner select{width:100%;font-family:var(--font-b);cursor:pointer}
#dtuner select option{background:#14141a;color:var(--white)}
#dtuner input[type=range]{flex:1 1 auto;min-width:0;accent-color:var(--accent);cursor:pointer}
/* the typeable half of every slider row — narrower than a standalone number
   field, since it shares the row with the slider it mirrors */
#dtuner input[type=number].dt-num{flex:0 0 60px;width:60px;padding:5px 6px;text-align:right}

#dtuner .dt-seg{display:flex;flex-wrap:wrap;gap:4px}
#dtuner .dt-seg button{flex:1 1 auto;padding:7px 9px;font-size:var(--fs-micro);font-weight:700;
  letter-spacing:.4px;background:rgba(245,245,243,.05);border:1px solid transparent;
  color:var(--mute);cursor:pointer;transition:.15s;white-space:nowrap}
#dtuner .dt-seg button:hover{color:var(--white);background:rgba(245,245,243,.11)}
#dtuner .dt-seg button.on{background:var(--accent);color:var(--on-accent);border-color:var(--accent)}

#dtuner .dt-act{width:100%;padding:9px;margin-top:6px;font-size:var(--fs-micro);font-weight:700;
  letter-spacing:.4px;background:rgba(245,245,243,.05);border:1px solid rgba(245,245,243,.18);
  color:var(--mute);cursor:pointer;transition:.15s}
#dtuner .dt-act:hover{color:var(--white);border-color:rgba(245,245,243,.4)}
#dtuner .dt-act.pri{background:var(--accent);border-color:var(--accent);color:var(--on-accent)}
#dtuner textarea{width:100%;height:82px;margin-top:6px;padding:8px;resize:vertical;
  font-family:var(--mono);font-size:11.5px;line-height:1.45;
  background:rgba(245,245,243,.06);border:1px solid rgba(245,245,243,.18);color:var(--white)}
#dtuner .dt-note{font-size:var(--fs-micro);line-height:1.5;color:var(--mute-dim);margin-top:10px}
/* contrast readout — the one number that decides whether a picked accent is
   usable at all, shown rather than assumed */
#dtuner .dt-cr{display:flex;align-items:center;gap:7px;margin-top:6px;
  font-size:var(--fs-micro);color:var(--mute-dim)}
#dtuner .dt-cr b{font-family:var(--mono);color:var(--white)}
#dtuner .dt-cr .pip{width:10px;height:10px;flex:0 0 auto;border-radius:50%}
#dtuner .dt-cr .pip.ok{background:var(--green)}
#dtuner .dt-cr .pip.no{background:var(--danger)}
#dtuner .dt-swatch{height:26px;margin-top:6px;display:grid;
  grid-template-columns:repeat(3,1fr);align-items:center;text-align:center;
  font-size:11px;font-weight:800;font-family:var(--mono)}
#dtuner .dt-swatch span{height:100%;display:grid;place-items:center}
/* the gradient strip under each pair of stops — the value the page uses, drawn */
#dtuner .dt-grad{height:16px;margin:6px 0 2px;border-radius:3px;
  border:1px solid rgba(255,255,255,.14)}

/* ══════════════════════════════════════════════════════════════════════════
   8. SURFACE — panels that read as material.

   THE PROBLEM, stated exactly. Section 1 of app.css turned --rule and --edge
   transparent, removing ~240 outlines in one edit, on the reasoning that fill
   and spacing would separate surfaces instead. The fill it left them with is
   --panel, rgba(7,7,10,.4). Over the marbled background photo that is not a
   surface — it is a slightly darker patch of the same page, with a hard 6px
   corner and nothing to say where it begins. Six of those stacked in a column
   is what "an early internet website" means when someone says it: content in
   boxes, boxes on a page, no light anywhere.

   WHAT REPLACES IT is not the outlines coming back. It is the three things a
   real surface has and a transparent rectangle does not:
     a lit fill      --surf, brighter along the top edge and falling away, so
                     the panel has a direction
     a caught edge   --surf-edge, an INSET highlight along the top only. Inset,
                     so it is not an outline and does not re-litigate that
                     decision — it is the light the top edge catches.
     a cast shadow   --surf-cast. The one that does the actual work: it is what
                     makes a panel sit ON the page instead of being a hole in
                     it.
   ...plus --r-lg, a card corner, because --radius is a BUTTON's corner and a
   400px panel wearing it is half the problem on its own.

   SCOPE. The Live Dino tab's panels, and the two components that tab shares
   with other pages so they cannot fork: the mutation grid (below, also on
   dino.html's detail card) and the overlays panel (livemap.css, also the Map
   page). Everything else on the site is untouched and still wears --panel.
   Widening it is a separate pass with its own review.
   ══════════════════════════════════════════════════════════════════════════ */

/* ── 8a. HOW A PANEL APPLIES IT ──
   There is no .surf utility class, deliberately. Half the panels here are built
   by JS that this file does not own (APEX.primeCard emits .pcard, livemap.js
   emits the overlays panel), so a class would have to be threaded through two
   other files to reach them. The TOKENS are the single source — the four values
   exist once, above — and each component applies them in its own stylesheet:

     background-color:var(--panel);          <- untouched, still the fallback
     background-image:var(--surf);           <- additive, like section 2b
     border-radius:var(--r-lg);
     box-shadow:var(--surf-edge), var(--surf-cast);

   background-IMAGE over the existing background-COLOUR is the same additive
   trick section 2b uses: drop these and the solid fill underneath is still
   there. Nothing here removes a fallback. */

/* ── 8b. THE MUTATION GRID ──
   Sixteen slots, and until now fourteen of them were an empty box with a DASHED
   border — the single most dated thing on the tab, and a dashed rule is how a
   1990s table said "nothing here". An empty slot is not a broken slot; it is a
   slot. So it is a quiet filled tile now, and the FILLED ones are what carry
   ink: a tinted chip in the group's own colour, which was already encoded in
   .parent/.eld and is now visible at a glance rather than only in the value's
   text colour.

   Overrides, not edits: app.css:794-807 still says what it said. The slot
   colours below reuse its own three (--green / --brand-hi / --blue) rather than
   introducing a fourth palette.

   The 8px/7.5px uppercase slot labels stay the size they are. They are the
   site's idiom and this is not the pass that relitigates the type scale — but
   the tracking comes off a little, because at 7.5px 1.1em of letter-spacing is
   what makes a label read as shouted rather than small. */
.mgroup .slot{
  border:0;
  border-radius:10px;
  background:rgba(245,245,243,.035);
  padding:7px 9px;
  transition:background .16s;
}
.mgroup .slot .sn{letter-spacing:.9px;opacity:.85}
/* empty: present, but with nothing to say. No dashes. */
.mgroup .slot.empty{background:rgba(245,245,243,.018)}
.mgroup .slot.empty .sv{font-style:normal;opacity:.6}
/* A filled slot's colour is 8c's business now — the per-group green/brand/blue
   tint that used to live here was replaced by the panel-wide sweep, and BOTH of
   mutGrid's callers wear .mutsweep, so keeping it would have been three rules
   that can never win. What is left above is the part 8c builds on: no dashes, a
   tile shape, and labels that do not shout. */
.mgroup .gh{letter-spacing:1.3px}

/* ── 8c. THE MUTATION PANEL AS ONE SWEEP ──
   Opt-in, by putting .mutsweep on the panel that HOLDS the grid: the vault's
   live-dino card and dino.html's Mutations panel both wear it, so the two say
   the same thing about the same data. APEX.mutSweep (data.js, beside mutGrid)
   does the measuring; --g-h is the panel's height and --g-y each element's
   offset into it.

   The gradient is the RAIL's — var(--rail-grad), which system.js writes from the
   equipped theme, so this re-colours with the rail and keeps no copy of anything.
   The technique is the rail's too (section 2c): the PANEL holds one continuous
   sweep and every slot is a WINDOW onto it, rather than sixteen tiles each
   painting their own gradient, which would be sixteen gradients and not one.

   THE WEIGHTS ARE THE RAIL'S. system.js paints the rail's slices at opacity .65,
   and a panel carrying the same sweep at .12 does not read as carrying it at all
   — the first attempt at this was invisible for exactly that reason. The panel
   backdrop sits just under the rail's weight, a filled slot just over it, and an
   empty slot well under, so the three layers rank: empty recedes into the panel,
   filled lifts out of it.

   This REPLACES 8b's per-group green/brand/blue tint wherever .mutsweep is worn.
   Which group a mutation belongs to is still said by the title above its row;
   what colour says here is where in the panel it sits. */
.mutsweep{position:relative;overflow:hidden}
.mutsweep::before{content:"";position:absolute;inset:0;z-index:0;pointer-events:none;
  background-image:var(--rail-grad);background-repeat:no-repeat;background-size:100% 100%;opacity:.22}
.mutsweep > *{position:relative;z-index:1}
/* The fill moves to ::before so STATE is opacity on one layer instead of a
   different colour per group — the same move, and the same reason, as the rail's. */
.mutsweep .slot,.mutsweep .slot.filled,.mutsweep .slot.empty{background:rgba(245,245,243,.03);box-shadow:none}
.mutsweep .slot{position:relative;overflow:hidden}
.mutsweep .slot::before{content:"";position:absolute;inset:0;z-index:0;pointer-events:none;
  background-image:var(--rail-grad);background-repeat:no-repeat;
  background-size:100% var(--g-h,100%);
  background-position:0 calc(var(--g-y,0px) * -1);
  opacity:var(--g-o,.1);transition:opacity .16s}
.mutsweep .slot.filled{--g-o:.7}
.mutsweep .slot.empty{--g-o:.12}
.mutsweep .slot .sn,.mutsweep .slot .sv{position:relative;z-index:1}
/* THE VALUE IS A WINDOW TOO — but only once the measurement exists, which is
   what .grad gates (see APEX.mutSweep). The white layer on top is what keeps it
   legible: the tile under it is this same sweep at .7, so text painted from the
   raw gradient would be very nearly its own background. Lifting it 30% toward
   white keeps the hue and the slice — still a window — while clearly sitting on
   top of the tile. */
.mutsweep.grad .slot.filled .sv{
  background-image:linear-gradient(rgba(255,255,255,.3),rgba(255,255,255,.3)), var(--rail-grad);
  background-repeat:no-repeat,no-repeat;
  background-size:100% 100%, 100% var(--g-h,100%);
  background-position:0 0, 0 calc(var(--g-y,0px) * -1);
  -webkit-background-clip:text;background-clip:text;color:transparent;font-weight:700}
