/* ── typefaces ─────────────────────────────────────────────────────────────
   Self-hosted, because they have to be: the CSP is default-src 'self', so both
   the Google Fonts stylesheet and gstatic's font files are blocked outright. A
   <link> to either renders the whole site in fallback with nothing in the
   console to explain it.

   Latin subsets only - 41KB for the pair. JetBrains Mono ships as ONE variable
   file covering every weight, which is why there is no separate bold; Chakra
   Petch is only ever used at 600 (the switch caps), so its 700 is not shipped.
   Both faces are SIL OFL 1.1, which permits self-hosting. */
@font-face{
  font-family:"JetBrains Mono"; font-style:normal; font-weight:100 800;
  font-display:swap; src:url("/static/fonts/jetbrains-mono-latin.woff2") format("woff2");
}
@font-face{
  font-family:"Chakra Petch"; font-style:normal; font-weight:600;
  font-display:swap; src:url("/static/fonts/chakra-petch-600-latin.woff2") format("woff2");
}

:root{
  --bg:#1b1b1b; --panel:#252526; --panel2:#2d2d2d; --line:#3c3c3c;
  --label:#d4d4d4; --muted:#808080; --black:#141414; --err:#f14c4c;
  --frame:#3b6ea5;

  --font-mono:"JetBrains Mono",ui-monospace,Consolas,"Courier New",monospace;
  /* Squared-off display face. Used only where the design uses it: the switch
     caps. Body copy and every form stays monospace. */
  --font-display:"Chakra Petch",var(--font-mono);

  /* Console tones, from the Fighter Jet Navigation Menu export. The brand pair
     below is unchanged - the export restated it slightly differently (#C7541F /
     #4B9CE2) but the reference image shipped with it is a screenshot of THIS
     header, so the existing values are the ones being matched. */
  --void:#08090a;      /* the page behind everything */
  --hull:#0a0b0c;      /* the header plate */
  --armed:#d3381c;     /* a switch that has been thrown */
  --edge:rgba(91,168,232,.16);   /* every hairline in the console chrome */
  --dim:#39424a;       /* telemetry small-caps */
  --dimmer:#5a646d;
  --ease:cubic-bezier(.2,1.2,.32,1);

  /* Brand, and the whole site's accent palette - the two are the same thing.
     Checked against the surfaces they actually sit on, not against black:

       --accent   on --panel #252526  5.9:1   on --bg #1b1b1b  6.6:1
       --hot      on --panel          4.9:1   on --bg          5.5:1
       --hot-deep BEHIND #fff button text     5.1:1

     --hot-deep is the title's burnt orange and is a BACKGROUND only: it is too
     dark to read as text on a dark panel. --hot is the same hue lifted until it
     passes as text. Buttons darken on hover rather than lighten, because a
     lighter orange cannot carry white text at AA. */
  --brand-a:#c1440e; --brand-b:#5ba8e8;
  --accent:#5ba8e8;
  --hot:#e86f2e; --hot-lit:#f58a52;
  --hot-deep:#c1440e; --hot-press:#a63a0c;
}
*{box-sizing:border-box;}
body{
  margin:0; padding:0; background:var(--void);
  min-height:100vh; display:flex; flex-direction:column;
  font-family:var(--font-mono); color:var(--label);
  font-size:14px; line-height:1.4; -webkit-font-smoothing:antialiased;
}
/* The frame fills whatever the header leaves, measured rather than guessed:
   min-height:100vh here made every page scroll by exactly the header's height,
   and the header is not a fixed height - it grows with the switches. */
.wrap{max-width:1180px; width:100%; margin:0 auto; flex:1 0 auto;
      display:flex; flex-direction:column;
      border:2px solid var(--frame); background:var(--bg);}
.wrap.narrow{max-width:460px; margin:40px auto; flex:0 0 auto;}
/* The home page is one picture edge to edge, so the frame that makes a module
   page read as an instrument panel just boxes it in. */
.wrap.plain{border:0; background:none;}

/* ── header ───────────────────────────────────────────────────────────────
   Ported from templates/Fighter Jet Navigation Menu (Claude Lab export, moved to
   docs/design/). That export is React with every rule in a style attribute and
   its two typefaces pulled from Google Fonts - the CSP forbids both, so the
   geometry is reproduced here as classes and nothing in nav.js ever touches
   .style.

   The plate is full-bleed but its CONTENTS are clamped to the same 1180px as
   .wrap, so the brand sits on the module frame's left edge and the controls on
   its right, whatever the viewport is. Sticky, because a module page scrolls and
   the switches are the only way out of it. */
.topbar{
  position:sticky; top:0; z-index:50;
  background:linear-gradient(180deg,rgba(10,11,12,.97) 0%,rgba(10,11,12,.93) 100%);
  backdrop-filter:blur(14px);
  border-bottom:1px solid var(--edge);
}
/* Backplate grid, faded out downward so it reads as depth behind the header
   rather than as a pattern laid over the page. */
.topbar::before{
  content:""; position:absolute; inset:0; pointer-events:none;
  background-image:linear-gradient(var(--edge) 1px,transparent 1px),
                   linear-gradient(90deg,var(--edge) 1px,transparent 1px);
  background-size:56px 56px;
  mask-image:radial-gradient(120% 150% at 50% 0%,#000 0%,transparent 78%);
  -webkit-mask-image:radial-gradient(120% 150% at 50% 0%,#000 0%,transparent 78%);
}
/* Two rows: the identity plate, then the switch bank. No bottom padding - the
   bank runs to the plate's edge so its panels drop from there. */
.topbar-inner{
  position:relative; display:flex; flex-direction:column;
  max-width:1180px; margin:0 auto; padding:14px 18px 0;
}
.topbar-head{
  display:flex; align-items:center; justify-content:space-between;
  gap:24px; min-height:36px;
}
/* The hazard rail. Two pixels of crawling stripe is most of what makes the
   header read as equipment instead of as a web page. */
.topbar-rail{
  height:2px; opacity:.5;
  background:repeating-linear-gradient(115deg,
             rgba(193,68,14,.85) 0 14px, rgba(10,11,12,0) 14px 28px);
  animation:barCrawl 2.4s linear infinite;
}
@keyframes barCrawl{from{background-position:0 0;} to{background-position:28px 0;}}

.topbar .brand{
  display:flex; align-items:baseline; gap:.4em; text-decoration:none;
  font-weight:700; font-size:21px; letter-spacing:-.5px; white-space:nowrap;
  animation:flicker 7s linear infinite;
}
@keyframes flicker{0%,96%,100%{opacity:1;} 97%{opacity:.55;} 98.5%{opacity:.85;}}
.brand-a{color:var(--brand-a);}
.brand-b{color:var(--brand-b);}
.topbar .brand:hover .brand-a{color:#e0632a;}
.topbar .brand:hover .brand-b{color:#8fc6f5;}

.topbar .right{display:flex; align-items:center; gap:12px; white-space:nowrap;}
/* The export used its dimmest grey for a decorative "SYS-04//NODE" tag. This
   slot carries the signed-in username, which is real information, so it keeps the
   small-caps treatment at a colour that can actually be read (2.2:1 -> 5.4:1). */
.whoami{font-size:9px; letter-spacing:.22em; color:var(--muted); text-transform:uppercase;}
/* Admin links and Sign out are one control in two element types, so they get one
   look: a chamfered chip, blue at rest and orange when live. Sign out lives here
   rather than on the home page - the home page is a picture, and this has to be
   reachable from inside a module. */
/* All four control rules below are prefixed with .topbar deliberately. The
   generic button{} / button:hover{} further down this file carry the same
   specificity as a bare `button.signout` and sit LATER, so without the extra
   class they win and every header control fills in solid burnt orange on hover.
   The switch rules carry the same prefix for the same reason. */
.topbar .right a, .topbar button.signout{
  font-family:var(--font-mono); font-size:9.5px; letter-spacing:.22em;
  text-transform:uppercase; text-decoration:none; color:var(--accent);
  background:rgba(255,255,255,.02); border:0; box-shadow:inset 0 0 0 1px var(--edge);
  padding:8px 13px; cursor:pointer;
  clip-path:polygon(0 6px,6px 0,100% 0,100% calc(100% - 6px),calc(100% - 6px) 100%,0 100%);
  transition:color .14s linear, background .14s linear, box-shadow .14s linear;
}
.topbar .right a:hover, .topbar button.signout:hover{
  color:var(--hot-lit); background:rgba(193,68,14,.1);
  box-shadow:inset 0 0 0 1px rgba(193,68,14,.55);
}
.topbar #signout{display:flex;}

/* ── module switches: guarded toggles ─────────────────────────────────────
   One switch per module. Three positions, and the design already had all three:

     off  cover DOWN, blue      the module is closed
     mid  cover UP, red         its options panel is out
     on   cover UP, lever THROWN + armed glow    you are inside that module

   Being inside wins - a switch cannot be thrown past ON - so opening the current
   module's panel leaves the lever up. This replaces the three switch PNGs; the
   whole assembly is CSS now, which is why the artwork is gone from static/img. */
.switchbar{display:flex; align-items:stretch; gap:2px; min-height:88px;}
.switch-slot{position:relative; display:flex; align-items:stretch;}

.topbar .switch, .topbar .switch:hover{
  display:flex; align-items:center; gap:15px;
  position:relative; margin:0; padding:0 18px;
  background:none; border:0; cursor:pointer; text-decoration:none;
  font-family:var(--font-mono); -webkit-tap-highlight-color:transparent;
}
/* The bank is left-aligned rather than centred so the first switch shares the
   brand's gutter and the panels never hang off the frame. */
.switch-slot:first-child .switch{padding-left:0;}
.switch-slot:first-child .switch-panel{left:0;}
.switch:focus-visible{outline:1px solid var(--accent); outline-offset:-3px;}

/* The housing. 44x52, pushed down 20px so the cover has room to swing up. */
.sw{position:relative; width:44px; height:52px; margin-top:20px; perspective:560px; flex:none;}
.sw-glow{
  position:absolute; inset:-8px; pointer-events:none; opacity:0; filter:blur(10px);
  background:radial-gradient(58% 52% at 50% 50%, var(--hot-deep) 0%, rgba(0,0,0,0) 72%);
  transition:opacity .25s ease, background .25s ease;
}
.sw-body{
  position:absolute; inset:0; overflow:hidden;
  background:linear-gradient(160deg,#191c1f 0%,#0d0f11 55%,#141719 100%);
  clip-path:polygon(0 8px,8px 0,100% 0,100% calc(100% - 8px),calc(100% - 8px) 100%,0 100%);
}
.sw-inner{
  position:absolute; inset:1px; background:#0a0c0d;
  clip-path:polygon(0 7px,7px 0,100% 0,100% calc(100% - 7px),calc(100% - 7px) 100%,0 100%);
}
.sw-backlight{
  position:absolute; inset:1px; opacity:0; transition:opacity .18s linear;
  clip-path:polygon(0 7px,7px 0,100% 0,100% calc(100% - 7px),calc(100% - 7px) 100%,0 100%);
  background:radial-gradient(130% 95% at 50% 112%, var(--hot-deep) 0%,
             rgba(193,68,14,.42) 52%, rgba(193,68,14,0) 100%);
}
.sw-well{
  position:absolute; inset:6px;
  background:linear-gradient(180deg,#050607 0%,#101315 100%);
  box-shadow:inset 0 2px 6px rgba(0,0,0,.95), inset 0 -1px 0 rgba(255,255,255,.06);
}
.sw-slot{
  position:absolute; left:50%; top:50%; width:15px; height:36px;
  margin:-18px 0 0 -7.5px; border-radius:8px;
  background:linear-gradient(180deg,#030405 0%,#0c0f11 100%);
  box-shadow:inset 0 1px 4px rgba(0,0,0,.95), inset 0 -1px 0 rgba(255,255,255,.05);
}
.sw-pivot{position:absolute; left:50%; top:50%; width:0; height:0;}
.sw-lever{
  position:absolute; left:-4px; bottom:0; width:8px; height:15px;
  border-radius:3px 3px 1px 1px; transform-origin:50% 100%; transform:rotate(0deg);
  background:linear-gradient(90deg,#5a6167 0%,#e8ecef 30%,#a6aeb4 62%,#454c51 100%);
  transition:transform .5s var(--ease), filter .3s ease;
}
.sw-ball{
  position:absolute; left:-4px; top:-9px; width:16px; height:16px; border-radius:50%;
  background:radial-gradient(circle at 34% 28%,#f4f7f9 0%,#b4bcc2 38%,#767e85 66%,#3b4247 100%);
  box-shadow:0 3px 7px rgba(0,0,0,.85), inset 0 -2px 3px rgba(0,0,0,.35);
}
.sw-collar{
  position:absolute; left:50%; top:50%; width:20px; height:7px;
  margin:-3.5px 0 0 -10px; border-radius:4px;
  background:linear-gradient(180deg,#3a4046 0%,#171a1d 60%,#0a0c0d 100%);
  box-shadow:0 2px 4px rgba(0,0,0,.7);
}
.sw-hazard{
  position:absolute; left:0; right:0; bottom:0; height:3px;
  background:repeating-linear-gradient(115deg,rgba(193,68,14,.55) 0 5px,rgba(0,0,0,0) 5px 10px);
}

/* The lid. Hinged at the top, so it swings up and towards the viewer. */
.sw-cover{
  position:absolute; inset:4px; transform-style:preserve-3d; transform-origin:50% 0%;
  transform:rotateX(0deg); transition:transform .42s var(--ease);
}
.sw-front{position:absolute; inset:0; transform:translateZ(2px);}
.sw-back{
  position:absolute; inset:0; transform:rotateY(180deg) translateZ(2px);
  clip-path:polygon(0 7px,7px 0,100% 0,100% calc(100% - 7px),calc(100% - 7px) 100%,0 100%);
  background:linear-gradient(180deg,#132a3d,#080f16);
  box-shadow:inset 0 0 12px rgba(0,0,0,.9);
  transition:background .28s ease;
}
.sw-face{
  position:absolute; inset:0;
  clip-path:polygon(0 7px,7px 0,100% 0,100% calc(100% - 7px),calc(100% - 7px) 100%,0 100%);
  background:linear-gradient(158deg,#7cc0f2 0%,#3e8fd6 46%,#173e5e 100%);
  box-shadow:inset 0 0 0 1px rgba(150,205,255,.35), inset 0 1px 0 rgba(255,255,255,.4),
             0 4px 14px rgba(0,0,0,.7);
  transition:background .28s ease, box-shadow .28s ease;
}
.sw-hi{position:absolute; left:0; right:0; top:12px; height:1px; background:rgba(255,255,255,.28);}
.sw-lo{position:absolute; left:0; right:0; top:17px; height:1px; background:rgba(0,0,0,.45);}
.sw-stripe{
  position:absolute; left:0; right:0; bottom:7px; height:6px; opacity:.45;
  background:repeating-linear-gradient(115deg,rgba(0,0,0,.55) 0 4px,rgba(255,255,255,.12) 4px 8px);
  transition:opacity .28s ease;
}
.sw-dot{position:absolute; left:6px; top:5px; width:3px; height:3px; background:rgba(255,255,255,.35);}

.sw-text{display:flex; flex-direction:column; align-items:flex-start; gap:5px;}
.switch .cap{
  font-family:var(--font-display); font-weight:600; font-size:13.5px;
  letter-spacing:.22em; line-height:1; white-space:nowrap; color:var(--accent);
  transition:color .16s linear, text-shadow .28s ease;
}
.switch .code{
  font-size:8.5px; letter-spacing:.28em; line-height:1; color:var(--dim);
  transition:color .16s linear;
}

/* hover - the housing lights from below and the cap warms up, but nothing moves */
.switch:hover .sw-backlight{opacity:1;}
.switch:hover .sw-glow{opacity:.5;}
.switch:hover .cap{color:var(--hot); text-shadow:0 0 14px rgba(232,111,46,.35);}
.switch:hover .code{color:rgba(232,111,46,.7);}

/* mid + on - the lid is up, so the face turns red and the stripe sharpens */
.switch[data-pos="mid"] .sw-cover, .switch[data-pos="on"] .sw-cover{transform:rotateX(-118deg);}
.switch[data-pos="mid"] .sw-face, .switch[data-pos="on"] .sw-face{
  background:linear-gradient(158deg,#ff6a46 0%,#d3381c 46%,#6e1809 100%);
  box-shadow:inset 0 0 0 1px rgba(255,150,120,.5), inset 0 1px 0 rgba(255,255,255,.4),
             0 4px 14px rgba(0,0,0,.7);
}
.switch[data-pos="mid"] .sw-back, .switch[data-pos="on"] .sw-back{
  background:linear-gradient(180deg,#4a1409,#1b0805);
}
.switch[data-pos="mid"] .sw-stripe, .switch[data-pos="on"] .sw-stripe{opacity:.9;}
.switch[data-pos="mid"] .cap{color:var(--hot);}
.switch[data-pos="mid"] .code, .switch[data-pos="on"] .code{color:rgba(211,56,28,.85);}

/* on - the lever is thrown and the housing holds a slow armed pulse */
.switch[data-pos="on"] .sw-lever{
  transform:rotate(180deg); filter:drop-shadow(0 0 7px var(--armed));
}
.switch[data-pos="on"] .sw-glow{
  opacity:.6; animation:armPulse 2.8s ease-in-out infinite;
  background:radial-gradient(58% 52% at 50% 50%, var(--armed) 0%, rgba(0,0,0,0) 72%);
}
.switch[data-pos="on"] .cap{color:var(--hot-lit); text-shadow:0 0 16px rgba(255,126,51,.6);}
@keyframes armPulse{0%,100%{opacity:.42;} 50%{opacity:.78;}}

/* ── the panel a switch pops out ──────────────────────────────────────────
   Drops from the bottom of the header plate rather than from the button, which
   is why the slot stretches to the full row height. */
.switch-panel{
  position:absolute; top:calc(100% + 12px); left:-4px; width:292px; z-index:30;
  background:linear-gradient(180deg,rgba(13,15,17,.99),rgba(9,11,12,.99));
  box-shadow:inset 0 0 0 1px var(--edge), 0 0 0 1px rgba(0,0,0,.6),
             0 28px 70px rgba(0,0,0,.85), 0 0 40px rgba(193,68,14,.12);
  clip-path:polygon(0 12px,12px 0,100% 0,100% calc(100% - 12px),calc(100% - 12px) 100%,0 100%);
  overflow:hidden; opacity:1; visibility:visible; transform:translateY(0);
  transition:opacity .16s linear, transform .3s var(--ease), visibility .16s;
}
/* nav.js toggles the `hidden` attribute and display:none cannot transition, so
   the panel keeps its box and is hidden with visibility instead. That still
   takes it out of the accessibility tree and out of the tab order, which is the
   part `hidden` was doing for us. */
.switch-panel[hidden]{
  display:block; opacity:0; visibility:hidden; pointer-events:none;
  transform:translateY(-10px);
}
.switch-panel::before{   /* scanlines, under the rows */
  content:""; position:absolute; inset:0; z-index:0; pointer-events:none; opacity:.35;
  background:repeating-linear-gradient(180deg,rgba(0,0,0,.5) 0 1px,transparent 1px 3px);
}
.switch-panel::after{    /* the accent bar across the top edge */
  content:""; position:absolute; top:0; left:0; right:0; height:2px; z-index:2;
  background:linear-gradient(90deg,var(--hot-deep) 0%,rgba(193,68,14,.15) 100%);
}
.switch-panel > *{position:relative; z-index:1;}
.switch-panel .phead, .switch-panel .pfoot{
  display:flex; align-items:center; justify-content:space-between;
  font-size:9.5px; letter-spacing:.3em; color:var(--dimmer);
}
.switch-panel .phead{padding:15px 18px 13px; border-bottom:1px solid rgba(255,255,255,.06);}
.switch-panel .pfoot{
  padding:10px 18px; border-top:1px solid rgba(255,255,255,.06);
  font-size:9px; letter-spacing:.26em; color:#3d464d;
}
.switch-panel .pfoot::after{content:"\25C6";}
/* Everything drawn here came back from an access-clamped /nav.json, so this is a
   statement of fact rather than decoration. */
.switch-panel .lock{letter-spacing:.22em; color:var(--hot);}
.switch-panel a{
  display:flex; align-items:center; gap:14px; margin:0; padding:12px 18px;
  font-size:13px; letter-spacing:.06em; color:#aeb7bf; white-space:nowrap;
  text-decoration:none; border-left:2px solid var(--edge); opacity:1;
  transform:translateX(0);
  transition:opacity .18s linear, transform .26s var(--ease), color .14s,
             background .14s, border-color .14s, padding-left .14s, letter-spacing .14s;
}
.switch-panel a .num{font-size:9.5px; letter-spacing:.12em; color:#4a545c; flex:none; width:18px;}
.switch-panel a:hover{
  color:var(--hot-lit); border-left-color:var(--hot); padding-left:26px; letter-spacing:.1em;
  background:linear-gradient(90deg,rgba(193,68,14,.16),rgba(193,68,14,0));
}
/* The rows deal themselves out one after another. nth-of-type rather than a
   custom property per row, because nav.js must not touch .style at all. */
.switch-panel a:nth-of-type(1){transition-delay:50ms;}
.switch-panel a:nth-of-type(2){transition-delay:80ms;}
.switch-panel a:nth-of-type(3){transition-delay:110ms;}
.switch-panel a:nth-of-type(4){transition-delay:140ms;}
.switch-panel a:nth-of-type(5){transition-delay:170ms;}
.switch-panel a:nth-of-type(6){transition-delay:200ms;}
.switch-panel a:nth-of-type(7){transition-delay:230ms;}
.switch-panel a:nth-of-type(8){transition-delay:260ms;}
.switch-panel[hidden] a{opacity:0; transform:translateX(-8px); transition-delay:0s;}

@media (max-width:980px){
  .topbar-inner{padding:12px 14px 0;}
  .switchbar{overflow-x:auto;}
  .topbar .switch, .switch-slot:first-child .switch{padding:0 12px;}
}
.section{margin:16px; border:1px solid var(--line); background:var(--panel); padding:14px 16px 18px;}
.section > h2{margin:0 0 14px; font-size:14px; color:var(--accent); font-weight:bold; letter-spacing:.5px;}
.grid{display:flex; flex-wrap:wrap; gap:14px 22px;}
.field{display:flex; flex-direction:column; min-width:170px; flex:0 0 auto;}
.field.wide{min-width:320px;}
label{color:var(--label); margin-bottom:4px; white-space:nowrap;}
label .req{color:var(--err);}
input,select{
  background:var(--panel2); border:1px solid var(--line); color:var(--accent);
  padding:6px 8px; font-family:inherit; font-size:14px; outline:none;
}
input:focus,select:focus{border-color:var(--accent);}
input.miss{border-color:var(--err);}
.hint{color:var(--muted); font-size:12px; margin-top:3px; max-width:300px; white-space:normal;}
button{
  background:var(--hot-deep); color:#fff; border:none; padding:9px 18px; font-family:inherit;
  font-weight:bold; font-size:13px; cursor:pointer; letter-spacing:.5px;
}
button:hover{background:var(--hot-press);}
button.ghost{background:#3a3a3a; color:var(--label);}
button.ghost:hover{background:#484848;}
button.danger{background:#5a1f1f; color:#ffb3b3;}
button.danger:hover{background:#7a2626;}
.msg{margin-top:12px; color:var(--err); white-space:pre-wrap;}
.msg.ok{color:var(--accent);}
.note{color:var(--muted); font-size:12px;}
.cards{display:flex; flex-wrap:wrap; gap:14px;}
.card{border:1px solid var(--line); background:var(--panel2); padding:14px 16px; min-width:240px; text-decoration:none; display:block;}
.card:hover{border-color:var(--accent);}
.card .name{color:var(--accent); font-weight:bold;}
.card .slug{color:var(--muted); font-size:12px;}
.empty{color:#555;}
table{border-collapse:collapse; width:100%;}
th,td{text-align:left; padding:6px 8px; border-bottom:1px solid var(--line); vertical-align:top;}
th{color:var(--accent); font-weight:bold;}
td.muted{color:var(--muted);}
pre.box{background:var(--black); border:1px solid var(--line); color:#cfe8df; padding:12px; overflow:auto; white-space:pre-wrap; word-break:break-all;}
.qr{background:#fff; padding:10px; display:inline-block;}
.actions{display:flex; gap:14px; align-items:center; flex-wrap:wrap;}

/* ── home hero ────────────────────────────────────────────────────────────
   The landing picture. Layer stack, back to front:

     1  .hero-bg      the city plate (parallax, opposite the pointer)
     2  .hero-bloom   a blue charge glow behind him, up while he materialises
     2  .hero-cyan / .hero-red   the pre-tinted torn frames, channel-separated
     3  .hero-torn    the torn intact frame - his stand-in during a glitch
     4  .hero-main    the intact character
     5  .hero-slice   four displaced bands cut out of the art, rolled per frame
     6  .hero-roll    a CRT vertical-hold bar sweeping down
     6  .hero-noise   coarse static, only during a hard burst
     6  ::after       scanlines
     7  .hero-tele    the SIGNAL LOST readout

   The slices re-use art the page is already fetching, so nothing here adds a
   download. Three glitch states, set by hero.js: nothing, `.glitching` on the
   layers (light), and `.glitching` + `.hard` on the stage (slices, static,
   jolt, roll bar, telemetry). --gain scales every channel offset, and hero.js
   ramps it through the final burst so the break-up escalates instead of running
   flat.

   The stack is sized to the page frame rather than 100vh - the header sits
   above it, so 100vh forced a scrollbar on every load - and the whole animation
   is dropped under prefers-reduced-motion, because a hard strobe at 130ms steps
   is exactly the kind of thing that setting exists for. */
.hero{
  position:relative; overflow:hidden; background:#0b0d12;
  flex:1 1 auto; min-height:340px;

  --gain:1;              /* channel-offset multiplier, 1 -> ~2.7 in a burst */
  --px:0; --py:0;        /* pointer parallax, -1..1 */
  --jx:0px; --jy:0px;    /* whole-frame jolt, rolled per glitch frame */

  /* Per-slice band geometry: top inset, bottom inset, x displacement, opacity.
     All four are rolled together by hero.js; 0 opacity parks a slice. */
  --s1t:100%; --s1b:0%; --s1x:0px; --s1o:0;
  --s2t:100%; --s2b:0%; --s2x:0px; --s2o:0;
  --s3t:100%; --s3b:0%; --s3x:0px; --s3o:0;
  --s4t:100%; --s4b:0%; --s4x:0px; --s4o:0;

  transform:translate3d(var(--jx),var(--jy),0);
}
.hero img{position:absolute; inset:0; width:100%; height:100%;}

/* Vignette. Pulls the eye off the plate's corners and onto him. */
.hero::before{
  content:""; position:absolute; inset:0; z-index:5; pointer-events:none;
  background:radial-gradient(78% 78% at 50% 45%, rgba(0,0,0,0) 40%, rgba(0,0,0,.72) 100%);
}
/* CRT scanlines, from the export's `scanlines` prop. Scoped to the hero rather
   than fixed over the viewport as the export had it: striping a page of dense
   entry forms costs legibility all day for a texture that only pays off over a
   picture. They drift slowly, so the plate is never quite still, and sharpen
   while the signal is coming apart. */
.hero::after{
  content:""; position:absolute; inset:0; z-index:6; pointer-events:none; opacity:.5;
  background:repeating-linear-gradient(180deg,rgba(0,0,0,.35) 0 1px,transparent 1px 3px);
  animation:scanDrift 9s linear infinite; transition:opacity .12s linear;
}
@keyframes scanDrift{from{background-position:0 0;} to{background-position:0 60px;}}
.hero.hard::after{opacity:.8;}

.hero-bg{object-fit:cover; z-index:1; opacity:.85; --par:-9px;}
.hero-layer{object-fit:contain; object-position:center bottom;}
/* Parallax. A transform here is overwritten by the glitch animations while
   those are running, which is exactly what should happen. */
.hero-bg, .hero-layer{
  transform:translate3d(calc(var(--px) * var(--par,4px)), calc(var(--py) * var(--par,4px) * .5), 0);
}
.hero-main{opacity:0; z-index:4; --par:3px; transition:opacity 1.4s ease-in-out;}
.hero-main.visible{opacity:1;}
/* He fades IN but does not fade out - he flicks out, and each beat has to land
   instantly. hero.js sets this for the duration of that sequence and clears it
   again, so the fade-in is untouched.

   The beats are partial, not on/off, so opacity comes from --flick rather than
   from .visible. This rule MUST stay below .hero-main.visible: the two carry
   equal specificity, so source order is the only thing that decides which wins,
   and .visible is still set for most of the sequence. Asserted in
   tests/test_hero_animation.py. */
.hero-main.flickering{transition:none; opacity:var(--flick, 0);}
/* The three torn frames. Their alpha carries the break-up, and the two channel
   copies are pre-tinted in the artwork, so nothing here filters or recolours
   them - a CSS approximation of the same tearing was tried and was not close.
   .hero-torn is the intact character's stand-in: the glitch switches between the
   two, so he comes apart in the gaps rather than just flickering. */
.hero-torn{opacity:0; z-index:3; pointer-events:none; --par:5px;}
.hero-red{--par:11px;}
.hero-cyan{--par:-12px;}
.hero-red, .hero-cyan{
  opacity:0; z-index:2; pointer-events:none;
  /* Vertical jitter for the channel offsets, re-rolled by hero.js on every
     burst - each one lands between 0 and half its own horizontal shift, rounded
     up. The 0px fallbacks keep the animation valid if the script never runs. */
  --ry2:0px; --ry3:0px; --ry4:0px; --ry6:0px;
  --cy1:0px; --cy3:0px; --cy4:0px; --cy5:0px;
}

/* A blue charge glow behind him: full while he materialises, then a low idle
   presence so the plate has a light source. */
.hero-bloom{
  position:absolute; inset:0; z-index:2; pointer-events:none; opacity:0;
  background:radial-gradient(40% 44% at 50% 56%,
             rgba(91,168,232,.42) 0%, rgba(91,168,232,.10) 46%, rgba(91,168,232,0) 72%);
  transition:opacity .9s ease;
}
.hero.live .hero-bloom{opacity:.22;}
.hero.charging .hero-bloom{opacity:1; transition:opacity .25s ease;}

/* ── displaced slices ─────────────────────────────────────────────────────
   Four bands cut out of the artwork and shoved sideways. This is the part that
   reads as datamosh rather than as a flicker: the geometry is re-rolled by
   hero.js every frame of a hard burst, so no two frames tear alike. Two carry
   the intact art and two carry the tinted torn frames, which is where the hard
   colour blocks come from. */
.hero-slice{
  position:absolute; inset:0; z-index:5; pointer-events:none;
  opacity:0; will-change:transform,clip-path,opacity;
}
.hero-slice img{position:absolute; inset:0; width:100%; height:100%;
                object-fit:contain; object-position:center bottom;}
.hero-slice.s1{opacity:var(--s1o); clip-path:inset(var(--s1t) 0 var(--s1b) 0);
               transform:translate3d(var(--s1x),0,0);}
.hero-slice.s2{opacity:var(--s2o); clip-path:inset(var(--s2t) 0 var(--s2b) 0);
               transform:translate3d(var(--s2x),0,0);}
.hero-slice.s3{opacity:var(--s3o); clip-path:inset(var(--s3t) 0 var(--s3b) 0);
               transform:translate3d(var(--s3x),0,0); mix-blend-mode:screen;}
.hero-slice.s4{opacity:var(--s4o); clip-path:inset(var(--s4t) 0 var(--s4b) 0);
               transform:translate3d(var(--s4x),0,0); mix-blend-mode:screen;}

/* Vertical-hold bar. One bright band rolling down the plate whenever the
   signal is hard-broken. */
.hero-roll{
  position:absolute; left:0; right:0; top:0; height:22%; z-index:6;
  pointer-events:none; opacity:0; mix-blend-mode:screen;
  background:linear-gradient(180deg,
             rgba(91,168,232,0) 0%, rgba(91,168,232,.10) 30%,
             rgba(255,255,255,.16) 50%, rgba(232,111,46,.12) 70%, rgba(0,0,0,0) 100%);
}
.hero.hard .hero-roll{opacity:.9; animation:rollDown 900ms linear infinite;}
@keyframes rollDown{from{transform:translateY(-120%);} to{transform:translateY(520%);}}

/* Coarse static. Two repeating gradients beating against each other - no image
   asset, and nothing for the CSP to object to. */
.hero-noise{
  position:absolute; inset:-40%; z-index:6; pointer-events:none; opacity:0;
  background:
    repeating-linear-gradient(97deg, rgba(255,255,255,.16) 0 1px, rgba(0,0,0,0) 1px 3px),
    repeating-linear-gradient(3deg, rgba(0,0,0,.35) 0 1px, rgba(0,0,0,0) 1px 2px);
  mix-blend-mode:overlay;
}
.hero.hard .hero-noise{opacity:.55; animation:noiseJump 120ms steps(1) infinite;}
@keyframes noiseJump{
  0%{transform:translate3d(0,0,0);}      25%{transform:translate3d(-2%,1.5%,0);}
  50%{transform:translate3d(1.5%,-1%,0);} 75%{transform:translate3d(-1%,-2%,0);}
  100%{transform:translate3d(2%,1%,0);}
}

/* Telemetry. Only up while the carrier is gone, so it states a fact about the
   picture rather than decorating it. */
.hero-tele{
  position:absolute; left:18px; bottom:16px; z-index:7; pointer-events:none;
  font-family:var(--font-mono); font-size:10px; letter-spacing:.28em;
  text-transform:uppercase; color:var(--hot);
  text-shadow:0 0 12px rgba(232,111,46,.5); opacity:0;
}
.hero.hard .hero-tele{opacity:1; animation:teleBlink 240ms steps(1) infinite;}
@keyframes teleBlink{0%,60%{opacity:1;} 61%,100%{opacity:.15;}}

/* ── the strobe ───────────────────────────────────────────────────────────
   steps(1) throughout: these jump, they never interpolate. Horizontal shifts
   are multiplied by --gain, which hero.js ramps from 1 up through the final
   burst - the same keyframes carry both a twitch and a total loss of signal. */
.hero-main.glitching{animation:mainGlitch 200ms steps(1) infinite;}
@keyframes mainGlitch{
  0%{opacity:.98; transform:translate(0,0);}
  15%{opacity:.04; transform:translate(-2px,0);}
  28%{opacity:.96; transform:translate(1px,0);}
  43%{opacity:.08; transform:translate(3px,1px);}
  57%{opacity:.99; transform:translate(-1px,0);}
  69%{opacity:.03; transform:translate(2px,-1px);}
  84%{opacity:.94; transform:translate(0,0);}
  100%{opacity:.06; transform:translate(-2px,0);}
}
/* Hard burst: the same rhythm, faster, thrown further, and sheared. */
.hero.hard .hero-main.glitching{animation:mainGlitchHard 130ms steps(1) infinite;}
@keyframes mainGlitchHard{
  0%{opacity:.98; transform:translate(0,0) skewX(0deg);}
  14%{opacity:.05; transform:translate(calc(-9px * var(--gain)),1px) skewX(-6deg);}
  27%{opacity:.92; transform:translate(calc(4px * var(--gain)),0) skewX(2deg);}
  41%{opacity:.10; transform:translate(calc(13px * var(--gain)),-2px) skewX(7deg);}
  55%{opacity:.99; transform:translate(calc(-3px * var(--gain)),0) skewX(0deg);}
  68%{opacity:.03; transform:translate(calc(8px * var(--gain)),2px) skewX(-4deg);}
  82%{opacity:.90; transform:translate(0,0) skewX(1deg);}
  100%{opacity:.06; transform:translate(calc(-6px * var(--gain)),0) skewX(3deg);}
}
/* While the wink is up the main layer holds for longer between drop-outs than
   the channel ghosts do - same rhythm and the same jumps, just fewer gaps, so he
   reads as present rather than as a flicker for that last second. On-time goes
   from 58% of each cycle to 76%. It overrides the hard cut too: the wink is the
   one beat that must stay legible. */
.hero-main.winking.glitching,
.hero.hard .hero-main.winking.glitching{animation:mainGlitchWink 200ms steps(1) infinite;}
@keyframes mainGlitchWink{
  0%{opacity:.98; transform:translate(0,0);}
  22%{opacity:.04; transform:translate(-2px,0);}
  30%{opacity:.96; transform:translate(1px,0);}
  52%{opacity:.08; transform:translate(3px,1px);}
  60%{opacity:.99; transform:translate(-1px,0);}
  82%{opacity:.03; transform:translate(2px,-1px);}
  90%{opacity:.94; transform:translate(0,0);}
  100%{opacity:.06; transform:translate(-2px,0);}
}
.hero-torn.glitching{animation:tornGlitch 200ms steps(1) infinite;}
.hero.hard .hero-torn.glitching{animation:tornGlitch 130ms steps(1) infinite;}
.hero-torn.winking.glitching,
.hero.hard .hero-torn.winking.glitching{animation:tornGlitchWink 200ms steps(1) infinite;}
@keyframes tornGlitch{
  0%{opacity:.05;}  15%{opacity:.95;} 28%{opacity:.06;} 43%{opacity:.9;}
  57%{opacity:.04;} 69%{opacity:.96;} 84%{opacity:.07;} 100%{opacity:.92;}
}
@keyframes tornGlitchWink{
  0%{opacity:.05;}  22%{opacity:.95;} 30%{opacity:.06;} 52%{opacity:.9;}
  60%{opacity:.04;} 82%{opacity:.96;} 90%{opacity:.07;} 100%{opacity:.92;}
}
/* The channel ghosts. Horizontal shifts MUST stay in step with CHANNEL_SHIFT in
   hero.js, which derives each burst's vertical jitter from them. */
.hero-red.glitching{animation:redGlitch 260ms steps(1) infinite;}
.hero.hard .hero-red.glitching{animation:redGlitch 150ms steps(1) infinite;}
@keyframes redGlitch{
  0%{opacity:0; transform:translate(0,0);}
  20%{opacity:.45; transform:translate(calc(15px * var(--gain)), var(--ry2));}
  40%{opacity:.1; transform:translate(calc(6px * var(--gain)), var(--ry3));}
  60%{opacity:.62; transform:translate(calc(21px * var(--gain)), var(--ry4));}
  80%{opacity:0; transform:translate(0,0);}
  100%{opacity:.4; transform:translate(calc(12px * var(--gain)), var(--ry6));}
}
.hero-cyan.glitching{animation:cyanGlitch 180ms steps(1) infinite;}
.hero.hard .hero-cyan.glitching{animation:cyanGlitch 110ms steps(1) infinite;}
@keyframes cyanGlitch{
  0%{opacity:.4; transform:translate(calc(-12px * var(--gain)), var(--cy1));}
  25%{opacity:0; transform:translate(0,0);}
  45%{opacity:.55; transform:translate(calc(-21px * var(--gain)), var(--cy3));}
  65%{opacity:.12; transform:translate(calc(-6px * var(--gain)), var(--cy4));}
  85%{opacity:.5; transform:translate(calc(-15px * var(--gain)), var(--cy5));}
  100%{opacity:0; transform:translate(0,0);}
}
@media (prefers-reduced-motion:reduce){
  .hero-main{opacity:1; transition:none;}
  .hero{transform:none;}
  .hero::after{animation:none;}
  .hero-main.glitching, .hero-red.glitching, .hero-cyan.glitching,
  .hero-torn.glitching{animation:none;}
  .hero-red, .hero-cyan, .hero-torn, .hero-slice, .hero-roll, .hero-noise,
  .hero-tele{display:none;}
  /* The header's three looping animations go too. The switch transitions stay -
     those only run when the operator throws something, which is feedback rather
     than ambient motion. */
  .topbar-rail, .topbar .brand, .switch[data-pos="on"] .sw-glow{animation:none;}
}
