/* ── ECHO shared stylesheet ────────────────────────────────────────────────
   Tokens + the creative nav bar + the menu overlay + a scaffold-page base
   (panel, hero, cdk button, building-next marker). New pages link this and
   stay tiny.

   SCOPE, corrected 2026-08-23: ALL EIGHTEEN pages link this file, index and
   members included. The old header said the index/members pages "keep their
   own inline CSS; this is for the section pages" — they do still carry inline
   blocks, but they carry them ON TOP of this one, not instead of it, and that
   misreading is exactly how the light palette and the amber accent ended up
   defined in index.html's <style> alone: two of the three theme axes were
   no-ops on the other seventeen pages for as long as the header implied this
   file did not serve them. The palette now lives here (below), so a page that
   links this file gets every axis the settings panel can set.

   One ordering trap worth knowing: members.html links this file AFTER its own
   <style> block, so here equal-specificity ties are won by this file on that
   page and lost by it on every other. Write deltas, not ties. */

/* Background canvas the shared rain.js draws into - same #pulsebg id/rule
   index.html always used, now shared so every page can include rain.js. */
#pulsebg{position:fixed;inset:0;z-index:0;pointer-events:none;display:block}

/* The two canvases cursor.js inserts (#echoreveal at z-index 1, #echocursor at
   95) do not belong on paper. blog.css already strips #pulsebg and the chrome
   for print but was written before these existed, so a print-to-PDF of a CTF
   write-up came out with a hexagon disc across page one - the exact outcome that
   print block exists to prevent. Stated here rather than there because it is
   true of all eighteen pages, and only five of them are blog posts.

   !important is LOAD-BEARING, not decoration: applyReveal() and applyCursor()
   set style.display='block' INLINE the moment an axis is switched on, and an
   inline declaration beats a non-important rule. Without it this rule works
   until the first visitor touches the setting, then silently stops. */
@media print{#echoreveal,#echocursor{display:none!important}}

:root{
  --bg:#06090b; --bg2:#080d10; --surf:#0c1214; --surf2:#111a1d; --surf3:#162226;
  --line:#1d2b2f; --line-soft:#141e21; --edge:#2c4045;
  --text:#d8e6e6; --muted:#7d9698; --dim:#48605f;
  --echo:#35e0c0; --echo-deep:#17836f; --ping:#6fe9ff; --warn:#e0b25a; --alert:#e0665a;
  --frame:#c6d6d6; --frame-soft:#7f9797; --scaffold:#e0a94a;
  /* The braille layer gets its OWN token rather than sharing --scaffold with the
     field labels: a label is the machine ASKING you something and the braille is
     the machine DRAWING. Different jobs, so tuning one must not move the other.
     Was declared only in index.html's <style>; moved here 2026-08-23 with the
     rest of the palette so the pages that read it are not reading a fallback. */
  --braille:#6fe9ff;
  /* ── --on-echo: the ink that sits ON an --echo fill ────────────────────────
     Anywhere the accent is the BACKGROUND (.enav-badge, ::selection, the member
     deck's filled chips) the label needs a colour that tracks the accent instead
     of a literal. #03110e was hardcoded in six places, and every one of them was
     dark-on-dark under amber+light, where --echo resolves to #6b4506: a dark
     brown plate carrying near-black text. One token, flipped with the theme. */
  --on-echo:#03110e;
  /* ── --flash: the peak-white crest of a flash or a charge ──────────────────
     The instant a braille cell lands, or a rail hit lands on a section title,
     the colour overshoots past --echo to near-white and falls back. #eafffb was
     hardcoded for that crest; on paper it is white-on-white, so the light branch
     takes it the other way and crests into the ink instead. */
  --flash:#eafffb;
  /* Shared glass-blur tiers - was 9 different ad hoc values (3px-10px, saturate .9-1.3)
     scattered across 7 files. --glass-1 = tiny chrome accents, --glass-2 = every real
     panel/modal, both reduced from their old max and unified so the whole site reads
     as one glass treatment instead of a different fog on every page. */
  --glass-1:blur(3px);
  --glass-2:blur(6px) saturate(1.1);

  /* ── --panel: THE content-panel surface, site-wide ────────────────────────
     ONE surface for every content panel on the site, and it carries NO
     backdrop-filter. Reference: index.html's .stage, rgba(10,15,17,.72).

     THE DIAGNOSIS, so this is not re-litigated a third time. Panels used to be
     a color-mix of --surf at ~60% PLUS backdrop-filter:var(--glass-2), and they
     read HEAVIER and flatter than the reference. The alpha was never the
     problem - the BLUR was. --glass-2 smears the sparse animated rain behind a
     panel into an even wash, so instead of a few sharp glyphs drifting past you
     get a uniform fog baked into every panel. Drop the blur and the panel goes
     back to being a dark sheet with real motion behind it, which is what the
     reference always was. The alpha then goes UP (60 -> 72) to keep body copy
     legible without the fog doing that job.

     WHAT IS NOT A PANEL: chips, buttons, inputs, the nav bar and its overlay,
     and modal SCRIMS. A scrim blurs page content behind a modal - a different
     job from a panel sitting on the rain - so scrims keep --glass-2. The
     floating .srail HUD keeps --glass-1 for the same reason: it overlays the
     page, it is not a sheet of it. --glass-1/--glass-2 therefore stay defined.

     Nested panels compose: a card inside a .stage lands over itself and reads
     near-opaque. That is the intended cost of one token, not a bug.

     2026-08-27: .72 -> .612, the transparency lab's 85% setting, operator-
     approved from dev/review/translab-{index,wargames,events}.html. The lab
     knob was a MULTIPLIER over the shipped alpha (85% of .72), not an absolute.
     The .sd-card modal below is deliberately pinned to the old literal - a
     detached modal over a backdrop-filtered scrim must not thin. */
  --panel:rgba(10,15,17,.612);
  --card:linear-gradient(155deg,var(--surf2),var(--surf));
  --head:Bahnschrift,"SF Compact Condensed","Segoe UI Semibold",system-ui,sans-serif;
  --mono:ui-monospace,"Cascadia Code","Cascadia Mono",Consolas,"DejaVu Sans Mono",monospace;
  --sans:system-ui,-apple-system,"Segoe UI",sans-serif;

  /* The full-bleed image strip above the nav bar. 150px because the artwork has
     to be legible: at the old 64px a 1584x396 banner crops to ~22:1 and reads as
     a sliver. This is the one number to change if the strip should be taller or
     shorter — --navclear is derived from it, so page content can never end up
     tucked under a band that grew. */
  --navband:150px;
  --navclear:calc(var(--navband) + 108px);
}
/* ── light theme — sonar on paper ───────────────────────────────────────────
   THE SEPARATE JOB, DONE (2026-08-23). The paragraph that used to sit here said
   the light palette "is declared only in index.html's own <style> ... until the
   palette itself is shared, which is a separate job". This block is that job.

   What the split actually cost, so it is not re-introduced: the boot-reader in
   every page's <head> sets data-theme / data-accent on <html> from localStorage,
   on seventeen of eighteen pages. But the rules those attributes select were
   page-local to index, so a visitor who chose light or amber on the homepage
   arrived everywhere else with the attribute set and NOTHING behind it. Two of
   the three settings axes were homepage-only features wearing a site-wide
   control. Pages that were otherwise perfectly tokenised (wargames, resources)
   showed no amber for exactly this reason - the fault was never their markup.

   Not an inversion. #35e0c0 is a GLOW colour: it reads on black and nearly
   vanishes on white, so light mode DEEPENS it rather than reusing the token.
   Same for the amber scaffold. The frame goes dark because on paper the job of
   a frame is to hold an edge, not to lift off the background.

   The page-local :root{} duplicates in index/members/agreement/survey are
   (0,1,0) and these are (0,2,0), so this block wins wherever they disagree and
   the dupes are drift risk rather than a conflict. Deleting them is page work. */
:root[data-theme=light]{
  --bg:#eef2f2; --bg2:#e7eded; --surf:#f7fafa; --surf2:#ffffff; --surf3:#e9efef;
  --line:#c2d0d0; --line-soft:#d9e1e1; --edge:#a6baba;
  --text:#0c1618; --muted:#4c6163; --dim:#7a8e8f;
  --echo:#064f43; --echo-deep:#053b32; --ping:#0a5866;
  --warn:#9a6c14; --alert:#a83c30;
  --frame:#334444; --frame-soft:#6b7e7e;
  --scaffold:#9c6410;
  --braille:#0a5866;
  /* the two device tokens invert with the surface they sit on: near-black ink on
     a light accent plate, and a crest that darkens into the ink instead of
     blowing out to white */
  --on-echo:#f7fafa;
  --flash:#0c1618;
  color-scheme:light;
  /* Light mode wants a few points MORE opacity than dark, not the same number:
     the paper surface and the paper background are close in value, so at the
     dark figure a panel stops reading as a panel. The alpha carries the same +6
     it always did over the dark value, measured against .72 - so on paper the
     panel is --surf (#f7fafa) at 78%. Still DERIVED from --surf rather than
     written as a literal; now that the palette above is shared, that derivation
     resolves to the same value on all eighteen pages instead of only on index.
     2026-08-27: 78% -> 66.3%, the lab's 85% multiplier applied to the light
     branch too (85% of .78 = .663), matching the dark change above. */
  --panel:color-mix(in srgb,var(--surf) 66.3%,transparent);
}

/* ── amber accent — a full terminal alternative, not a hue swap ──────────────
   Sonar is a signal colour: green-cyan reading as live. Amber is the older
   idiom, a phosphor terminal. Both need their own deep and ping values or the
   supporting tones stay green while the accent moves.

   Moved out of index.html's <style> with the light palette above, and for the
   same reason: [data-accent=amber] was being SET on every page and answered on
   one. Everything downstream that says var(--echo) follows amber from here for
   free - which is why the literals further down this file were converted to
   color-mix over the token in the same pass. A rule that still hardcodes
   #35e0c0 is a rule amber cannot reach. */
:root[data-accent=amber]{
  --echo:#e0a94a; --echo-deep:#8a6414; --ping:#f0d089;
  --braille:#c98f2e; --scaffold:#d8b877;
}
:root[data-accent=amber][data-theme=light]{
  --echo:#6b4506; --echo-deep:#513405; --ping:#8a5a08;
  --braille:#6b4506; --scaffold:#513405;
}
*{box-sizing:border-box}
body{margin:0;min-height:100vh;background:var(--bg);color:var(--text);font-family:var(--sans)}
a{color:var(--echo)}

/* ── top nav bar ────────────────────────────────────────────────────────────
   A fixed hairline bar: the ECHO wordmark left, the page links centred/right,
   each link carrying a braille tick that lights on hover. Collapses to a burger
   that raises the full menu overlay on narrow screens. */
/* The bar floats 20px clear of the very top rather than sitting flush. That gap
   is deliberate: a full-bleed image strip is coming behind it, and the glass
   reads properly only when there is something visible above and behind it -
   flush to the edge, the blur has nothing to work with. */
/* The band is OPAQUE and starts at the very top, with the 20px float kept as
   padding rather than as a transparent gap. Page content used to scroll up
   through that gap and on under the blurred bar, which put moving text behind
   the navigation. Operator rule: the space behind the nav is empty until
   something is deliberately put there. Swapping `background` here is all it
   takes to put an image in that band later - the geometry is already reserved. */
#echonav{position:fixed;top:0;left:0;right:0;z-index:40;padding-top:var(--navband);
  background:var(--bg)}
/* The band. Two layers in one background stack, top-most first:
   1. a gradient fading to the page colour, so the strip dissolves into the bar
      instead of meeting it at a hard seam;
   2. the artwork, cover-cropped and anchored RIGHT.
   Right-anchored on purpose: the banner carries its own ECHO monogram on the
   left, which would sit directly above the nav's own ECHO wordmark — the same
   mark twice, ~40px apart. Cropping past it keeps the hex field, which is the
   part that works as a strip, and leaves the deep navy under the wordmark.
   Swapping the strip is one url() here; a cycler can cross-fade children of this
   element without touching the bar's geometry. */
#echonav::before{content:"";position:absolute;top:0;left:0;right:0;height:var(--navband);
  background:
    /* A light wash across the whole strip on top of the foot gradient. Without
       it the top of the band is at full brightness and competes with the bar;
       with it the artwork still reads, the bar clearly wins, and text laid over
       the band would be safe. Going darker again would make the image do less
       work than a generated gradient, which is not worth 95KB. */
    linear-gradient(180deg,rgba(6,9,11,.34),rgba(6,9,11,.62) 60%,var(--bg)),
    url("assets/band-echo-nomark.jpg") 100% 50% / cover no-repeat,
    var(--bg);
  pointer-events:none}

/* ── the braille mark in the band ─────────────────────────────────────────
   ECHO, spelled in real braille, revealing on a loop: ⠑ ⠉ ⠓ ⠕.
   Small and at the FOOT on purpose. The band sits directly above a nav bar
   whose first word is already ECHO, so anything large or left-aligned spells
   the name twice - the same collision that made us crop the banner's own
   monogram out. Down here it reads as an instrument marking on the strip, and
   stays ambient, which is what a looping element on every page has to be. */
/* CENTRED in the band, not left. On the left it sat directly under the bar's own
   ECHO wordmark and was read as the same word twice - and it was partly behind
   it. Centred, it has the whole strip to itself and says the thing the bar does
   not: the full name. */
#echonav .navbrl{position:absolute;left:50%;top:calc(var(--navband) / 2);
  transform:translate(-50%,-50%);
  z-index:2;pointer-events:none;display:flex;flex-wrap:nowrap;gap:.16em;
  font-family:var(--mono);font-size:1.05rem;line-height:1;color:var(--echo);opacity:.5;
  text-shadow:0 0 16px color-mix(in srgb,var(--echo) 50%,transparent);max-width:92vw;
  transition:opacity .58s ease}
/* the whole line goes out between passes, and the braille is restored while it
   is invisible - so the swap itself is never seen, only its result */
#echonav .navbrl.out{opacity:0}
/* Letters are wider than braille cells, so the line would jump as it resolves.
   A fixed advance per cell keeps the whole strip the same width whether it is
   showing braille or words - the reveal reads as a change of state, not as a
   reflow. */
#echonav .navbrl i{font-style:normal;display:inline-block;width:.72em;text-align:center;
  transition:color .3s,text-shadow .3s}
/* the moment a cell lands on its letter it takes a brief charge */
#echonav .navbrl i.lit{color:var(--flash);text-shadow:0 0 12px color-mix(in srgb,var(--echo) 95%,transparent)}
/* word gaps read as gaps, not as cells */
#echonav .navbrl i.sp{width:.42em}
@media (max-width:1080px){#echonav .navbrl{font-size:.86rem;gap:.12em}}
@media (max-width:720px){#echonav .navbrl{font-size:.64rem}}
/* light theme fades to its own page colour, or the strip ends in a black bar */
:root[data-theme=light] #echonav::before{
  background:
    linear-gradient(180deg,rgba(238,242,242,0) 34%,rgba(238,242,242,.6) 72%,#eef2f2),
    url("assets/band-echo.jpg") 100% 50% / cover no-repeat,
    #eef2f2}
/* full width, not a 1400px column - nine links need the whole bar */
.enav-inner{display:flex;align-items:center;gap:clamp(10px,2vw,26px);
  padding:9px clamp(12px,2.2vw,28px);width:100%;margin:0 auto;
  background:radial-gradient(color-mix(in srgb,var(--echo) 5%,transparent) .6px,transparent .7px) 0 0/6px 6px}
.enav-mark{display:flex;flex-direction:column;text-decoration:none;flex:0 0 auto;line-height:1}
.enav-wm{font-family:var(--mono);font-weight:600;letter-spacing:.34em;text-indent:.34em;
  font-size:.9rem;color:var(--echo);text-shadow:0 0 14px color-mix(in srgb,var(--echo) 30%,transparent)}
.enav-tag{font-family:var(--mono);font-size:.44rem;letter-spacing:.18em;text-transform:uppercase;
  color:var(--dim);margin-top:3px}
/* ONE row, always. The links spread across the space they are given instead of
   wrapping into a second line - a nav that restacks as the window narrows reads
   as broken, so instead the tracking and padding tighten until the breakpoint
   hands over to the burger. */
.enav-links{display:flex;flex-wrap:nowrap;gap:0;margin-left:auto;flex:1 1 auto;
  justify-content:space-between;min-width:0}
/* The family rule. It is no longer only for <a> inside the bar: the two
   top-right controls (#signinbtn / #setbtn, injected by topctrl.js) carry this
   same class so they cannot drift from it. That is the only reason
   `background:transparent` is stated explicitly - an <a> has none to begin
   with, a <button> arrives with the UA's buttonface. It is declared on the BASE
   rule rather than on those two IDs so .enav-link.on (0,2,0) still wins the
   wash; an ID rule would have out-specified it and killed the toggled state. */
.enav-link{font-family:var(--mono);font-size:clamp(.46rem,.72vw,.56rem);
  letter-spacing:clamp(.04em,.16vw,.12em);text-transform:uppercase;white-space:nowrap;
  background:transparent;
  color:var(--dim);text-decoration:none;padding:7px clamp(5px,.8vw,11px);border:1px solid transparent;
  display:inline-flex;align-items:center;gap:clamp(3px,.5vw,6px);
  transition:color .16s,border-color .16s,background .16s}
.enav-link .enav-b{font-size:.62rem;opacity:.55;transition:opacity .16s}
/* The tick is a PSEUDO-ELEMENT, not text inside the span. This is the whole fix:
   @keyframes brlflash animates the `content` property, and `content` does
   nothing on a real element - only on a pseudo. Measured side by side: a <span>
   carrying animation:brlflash never changes a single glyph, an identical
   ::before cycles the keyframes. So the hover rule ~265 lines down had been a
   silent no-op since it was copied off .ebtn::before, which IS a pseudo and is
   the animation the operator has actually been seeing on buttons. nav.js and
   topctrl.js therefore emit .enav-b EMPTY and the glyph lives here. Emitting
   both would render the tick twice. */
.enav-link .enav-b::before{content:"⡀⠂"}
/* below 1180px the braille ticks go first - they are ornament, the labels are not */
@media (max-width:1180px){.enav-link .enav-b{display:none}}
.enav-link:hover{color:var(--text);border-color:var(--line)}
/* A gated page is dimmed in the bar, never hidden from it. A visitor should be
   able to see the whole shape of the site and understand what joining opens -
   a nav that silently shrinks for signed-out readers hides the offer. The mark
   is the braille tick, not a frame: no accent borders. */
.enav-link.locked{color:var(--dim)}
.enav-link.locked .enav-b{color:var(--echo-deep);opacity:.55}
/* the locked tick, moved here from the `lk ? '⣿⡀' : '⡀⠂'` branch nav.js used to
   carry - same reason as above, the glyph has to be pseudo content to animate */
.enav-link.locked .enav-b::before{content:"⣿⡀"}
.enav-link.locked:hover{color:var(--muted)}
.enav-card.locked .enav-card-t{color:var(--muted)}
.enav-card.locked .enav-card-s{color:var(--echo-deep);opacity:.9}
.enav-link:hover .enav-b{opacity:1;color:var(--echo)}
/* Softened from a solid --echo slab + near-black text to a transparent tint +
   teal label, matching this file's own rule for .primary a few lines down:
   "primary is a LABEL colour, not a frame colour" — the opaque fill broke
   that rule for the one nav state that used it. */
.enav-link.on{color:var(--echo);background:color-mix(in srgb,var(--echo) 9%,transparent);border-color:var(--line)}
.enav-link.on .enav-b{opacity:1;color:var(--echo)}
/* ── the label is its own span, so it can be braille-resolved ────────────────
   The other half of the idiom: the TICK churns (brlflash, above) and the LABEL
   scrambles through braille and lands back on the word, via nav.js's
   ECHO_BRL.resolveTextInto - the same one-shot resolve the rail counter and the
   members welcome line use. That function writes el.textContent outright, so it
   needs a node of its own; a bare text node sitting beside .enav-b was never
   usable, which is why only Sign In (the one control that already had a label
   span) ever had this behaviour.

   --n is the label's character count, emitted by nav.js/topctrl.js. The floor
   reproduces the rendered width almost exactly (9 chars measured at 56.93 vs
   56.94 actual: N ch of advance + N steps of letter-spacing), so a substituted
   braille glyph can never change a control's width mid-scramble. That matters
   more here than it did for Sign In alone: .enav-links is space-between, so ONE
   link changing width re-spaces all eight. Bare min-width first as the fallback
   for an engine that drops a clamp() nested in a calc() - same two-line pattern
   this file already used for .signin-label. */
.enav-t{display:inline-block;text-align:center}
.enav-link .enav-t{min-width:calc(var(--n,0) * 1ch);
  min-width:calc(var(--n,0) * 1ch + var(--n,0) * clamp(.04em,.16vw,.12em))}
/* Keyboard parity for the whole family, not just the two <button>s in the
   corner: the eight tabs are links a Tab key lands on and they had :hover and
   nothing else. Supersedes the #signinbtn/#setbtn-only pair further down. */
.enav-link:focus-visible{color:var(--text);border-color:var(--line)}
.enav-link:focus-visible .enav-b{opacity:1;color:var(--echo)}
/* .on MUST OUTRANK :focus-visible, and until now it did not. Both selectors are
   (0,2,0) and this focus rule is declared later, so it won - meaning a reader who
   opened Settings with Tab+Enter got a panel and a WHITE label, while every control
   inside that panel showed its own .on state in teal. The toggled state was still
   perceivable through the 9% wash and the tick, which is why it survived unnoticed,
   but "the control you just activated does not look activated" is exactly the kind
   of thing only keyboard users ever meet. Stated explicitly rather than by reorder,
   so a later edit cannot silently undo it again. */
.enav-link.on:focus-visible{color:var(--echo)}
.enav-burger{display:none;flex:0 0 auto;margin-left:auto;width:34px;height:30px;cursor:pointer;
  background:var(--surf);border:1px solid var(--edge);flex-direction:column;justify-content:center;gap:4px;padding:0 8px}
.enav-burger span{display:block;height:1.5px;background:var(--frame-soft);transition:background .16s}
.enav-burger:hover span{background:var(--echo)}
@media (max-width:1040px){
  .enav-links{display:none}
  .enav-burger{display:flex}
}

/* ── menu overlay ───────────────────────────────────────────────────────────
   Full-screen grid of page cards; the members Public Site button raises the
   same overlay. GW octagon cards, braille tick, sub-label. */
.enav-menu{position:fixed;inset:0;z-index:90;display:flex;align-items:center;justify-content:center;padding:28px;
  background:color-mix(in srgb,var(--bg) 60%,transparent);
  -webkit-backdrop-filter:var(--glass-2);backdrop-filter:var(--glass-2)}
.enav-menu[hidden]{display:none}
/* the menu is a PANEL, not loose cards on a backdrop - same octagon, glass and
   etched ticks as every other container on the site */
.enav-menu-inner{position:relative;width:min(96vw,1200px);max-height:88vh;overflow:auto;
  padding:22px clamp(18px,3vw,28px) 24px;
  background:linear-gradient(155deg,color-mix(in srgb,var(--surf2) 82%,transparent),
    color-mix(in srgb,var(--surf) 70%,transparent));
  -webkit-backdrop-filter:var(--glass-2);backdrop-filter:var(--glass-2);
  border:2px solid var(--line);
  box-shadow:0 26px 70px -30px #000,0 0 0 1px color-mix(in srgb,var(--echo) 5%,transparent);
  clip-path:polygon(0 18px,18px 0,calc(100% - 18px) 0,100% 18px,
    100% calc(100% - 18px),calc(100% - 18px) 100%,18px 100%,0 calc(100% - 18px));
  animation:enavIn .3s cubic-bezier(.4,0,.2,1) both}
.enav-menu-inner::before{content:"";position:absolute;top:14px;left:14px;width:11px;height:11px;
  border-top:1.5px solid var(--edge);border-left:1.5px solid var(--edge);opacity:.8;pointer-events:none}
.enav-menu-inner::after{content:"";position:absolute;bottom:14px;right:14px;width:11px;height:11px;
  border-bottom:1.5px solid var(--edge);border-right:1.5px solid var(--edge);opacity:.8;pointer-events:none}
/* member state: the menu grows a second tier when a session is live */
.enav-badge{font-family:var(--mono);font-size:.46rem;letter-spacing:.2em;text-transform:uppercase;
  color:var(--on-echo);background:var(--echo);padding:4px 8px;margin-left:10px;
  clip-path:polygon(0 4px,4px 0,100% 0,100% calc(100% - 4px),calc(100% - 4px) 100%,0 100%)}
.enav-sec{font-family:var(--mono);font-size:.5rem;letter-spacing:.24em;text-transform:uppercase;
  color:var(--echo);margin:20px 0 10px;display:flex;align-items:center;gap:10px}
.enav-sec::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--line),transparent)}
.enav-card.member{border-color:var(--echo-deep)}
.enav-card.member .enav-card-b{color:var(--echo)}
@keyframes enavIn{from{opacity:0;transform:translateY(8px)}to{opacity:1;transform:none}}
.enav-menu-head{display:flex;align-items:center;justify-content:space-between;margin-bottom:16px}
.enav-menu-kick{font-family:var(--mono);font-size:.6rem;letter-spacing:.28em;text-transform:uppercase;color:var(--echo)}
/* .enav-x is gone. The menu overlay is an expanding panel, and the standing rule
   is that every one of those closes with the SAME square - a bespoke rotated X
   made the control people press most the only one that looked different. */
.enav-grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(240px,1fr));gap:12px}
.enav-card{position:relative;display:flex;flex-direction:column;gap:4px;text-decoration:none;
  background:linear-gradient(155deg,var(--surf2),var(--surf));border:2px solid var(--line);padding:16px 18px;
  clip-path:polygon(0 12px,12px 0,calc(100% - 12px) 0,100% 12px,
    100% calc(100% - 12px),calc(100% - 12px) 100%,12px 100%,0 calc(100% - 12px));
  transition:border-color .18s,transform .22s cubic-bezier(.2,.7,.3,1)}
.enav-card::after{content:"";position:absolute;top:11px;left:11px;width:9px;height:9px;
  border-top:1.5px solid var(--edge);border-left:1.5px solid var(--edge);opacity:.9}
.enav-card:hover{border-color:var(--echo-deep);transform:translateY(-3px)}
.enav-card.on{border-color:var(--echo-deep)}
/* The tick on the right of each card is now the SAME control shape as the close
   button in the header - a bordered box with etched corner ticks holding the
   glyph - so the menu speaks one vocabulary instead of two. It is decoration,
   not a button: pointer-events stay off so the whole card remains the target. */
.enav-card-b{position:absolute;right:13px;bottom:11px;width:26px;height:26px;
  display:flex;align-items:center;justify-content:center;pointer-events:none;
  font-family:var(--mono);font-size:.66rem;color:var(--echo-deep);
  background:var(--surf);border:1.5px solid var(--edge);
  transition:color .16s,border-color .16s,box-shadow .3s}
.enav-card-b::before,.enav-card-b::after{content:"";position:absolute;width:6px;height:6px}
.enav-card-b::before{top:3px;left:3px;border-top:1px solid var(--edge);border-left:1px solid var(--edge)}
.enav-card-b::after{bottom:3px;right:3px;border-bottom:1px solid var(--edge);border-right:1px solid var(--edge)}
.enav-card:hover .enav-card-b{color:var(--echo);border-color:var(--echo-deep)}

/* Active = the page you are on. A SOFT pulse: the box breathes, it does not
   blink - the opacity floor stays high so it reads as alive rather than as an
   alert. One pulse in view at a time, since only one card is ever active. */
.enav-card.on .enav-card-b{color:var(--echo);border-color:var(--echo-deep);
  animation:navPulse 2.4s ease-in-out infinite}
@keyframes navPulse{
  0%,100%{box-shadow:0 0 0 0 color-mix(in srgb,var(--echo) 30%,transparent);opacity:1}
  50%    {box-shadow:0 0 12px 2px color-mix(in srgb,var(--echo) 22%,transparent);opacity:.82}}
@media (prefers-reduced-motion:reduce){.enav-card.on .enav-card-b{animation:none}}

/* Sign-in sits beside the close, so the menu offers the one action a signed-out
   reader most often wants without hunting for the member page. */
.enav-head-acts{display:flex;align-items:center;gap:10px}
/* .enav-signin's own rules are NOT here any more - they are size deltas now and
   they live below THE button, after the .cdk block, because a delta declared up
   here would tie .btn on specificity and lose on source order. nav.js emits
   `btn primary cdk cdk-flash enav-signin`. See the block at the end of the
   button section. */
.enav-card-t{font-family:var(--head);font-weight:800;text-transform:uppercase;font-size:1rem;color:var(--text)}
.enav-card-s{font-family:var(--mono);font-size:.56rem;letter-spacing:.14em;text-transform:uppercase;color:var(--dim)}

/* ── scaffold page base ─────────────────────────────────────────────────────
   A page that links this and sets <body data-nav="key"> gets the bar, a hero
   panel, and a building-next marker for free. */
/* top padding clears the image band + the bar + a breathing gap before the first
   panel, all from one variable - see --navband in :root */
/* ── the page column: index.html's bleed, applied to every section page ─────
   Operator, 2026-08-18: "I want all the pages to have the same layout as the
   ECHO Club page... Near full bleed to the right and a bleed with space for the
   mini navigation panel on the left hand side."

   This used to be max-width:1100px;margin:0 auto - a centred reading column
   that left a growing empty band on both sides as screens got wider, and had
   no relationship at all to the rail parked at the left edge. index.html does
   not do that: its #site takes 24px side padding, and above the rail's
   breakpoint it pushes its LEFT padding out to exactly the rail's footprint
   while the right side keeps the small gutter. Same numbers here, derived the
   same way rather than guessed:

     left  = 22px (.srail's fixed left offset)
           + 184px (.srail's resting max-width)
           + 24px (gap, so a panel edge never touches the rail)
           = 230px
     right = 24px  (near-full-bleed; the gutter is a margin, not a column)

   The rail widens to 290px on hover and will briefly overlap; that is the same
   accepted trade index.html documents - you are looking at the rail at that
   moment, and reserving 300px of permanent dead space costs far more.
   Line length is NOT protected by the column any more - it never should have
   been, since .epage .lede and #site p both cap prose at 70/74ch themselves. */
.epage{max-width:none;margin:0;padding:var(--navclear) clamp(16px,4vw,40px) 60px}
/* ── THE STACKING PRECONDITION for the pointer reveal ────────────────────────
   One line, twelve pages, and without it the reveal port draws a honeycomb OVER
   body copy on every one of them. cursor.js inserts #echoreveal as
   position:fixed;z-index:1. A page's content is an UNPOSITIONED in-flow block,
   and per CSS painting order non-positioned blocks and their inline text paint
   at steps 4 and 7 - before positioned z-index:1 at step 9. So the layer wins
   against text that has no position, however low its index is.

   This is index.html's own recipe, applied to the page column every other page
   uses: index escapes the bug only because of `#site{position:relative;z-index:2}`
   (index.html:435). Written once here so it cannot drift page by page. Full
   reasoning: dev/TASK_cursor-trail.md:227-241.

   Not fixable by going NEGATIVE: body{background:var(--bg)} is opaque, which is
   why #pulsebg sits at 0 and not at -1. And z-index:0 on the layer does not help
   either - a POSITIONED z-index:0 element still paints above unpositioned text.
   The content has to come up; the layer cannot go down.

   Harmless where the reveal is not mounted (ctf-rankings.html), and a no-op on
   index.html, which uses main#site. The three pages with no main.epage -
   agreement, survey (.panel) and members (.deck) - carry the same two
   declarations locally; members is the one that MUST have it, because .deck.etched
   has a drop-shadow filter and is therefore already a stacking context sitting
   below the layer. */
main.epage{position:relative;z-index:2}
/* 1081px, not a round number: it is exactly one pixel above .srail's
   max-width:1080px collapse further down this file. Above it there is a fixed
   left rail to clear; at or below it the rail is a horizontal strip under the
   nav and the content gets the whole width back. The two must never drift, so
   they are written as the same boundary. */
@media (min-width:1081px){
  .epage{padding-left:calc(22px + 184px + 24px);padding-right:24px}
}
/* Real 3-layer etched-corner panel, ported from AEGIS/Protocol 973's
   docs/aegis-site.css .card (the operator's own reference: "the panels all
   need that etched corner... that we pull from AEGIS and AUSPEX"). ECHO's
   .ehero previously only carried layer 3 below (the L-corner ticks) without
   the octagon bezel underneath — this was the missing piece. Layer 1: the
   element itself, solid --echo, octagon clip-path = the frame. Layer 2:
   ::before inset 1.5px with its OWN matching octagon clip-path, holding the
   real surface gradient — the 1.5px gap between the two clips IS the visible
   bezel. Layer 3: ::after, unclipped, the small L-shaped corner tick. */
/* THE LAYERS ARE INVERTED from how this panel was first built, and the reason
   matters. It used to be: element = solid --echo (the bezel), ::before inset
   1.5px = the opaque surface covering all but a 1.5px teal rim. That works only
   while the surface is OPAQUE. The moment the surface goes translucent - which
   is the whole point of this pass - the 40% you can see through is not the page
   behind the panel, it is the solid bright teal of the layer underneath, and
   the hero turns into a green box.

   So the element now carries the surface (it is the full-size layer, so the
   surface runs edge to edge), and ::before carries ONLY the teal rim, as a
   ring. The
   ring is one polygon with the even-odd fill rule: the outer octagon, back to
   its start, then the inner octagon, back to ITS start. The connector between
   the two is the same segment walked twice, so it has zero area and never
   paints; even-odd then treats the inner ring as a hole. Geometry is unchanged
   from the old two-clip version - 19.5px is 1.5px inset + the 18px chamfer, i.e.
   exactly where the old ::before's own octagon fell. */
.ehero{position:relative;border:0;
  background:var(--panel);
  padding:34px clamp(22px,5vw,56px);
  /* The `inset 0 -16px 30px rgba(0,0,0,.28)` that used to sit in this list is
     GONE. The surface here is var(--panel), the token every other panel takes -
     that bottom wash was the other reason the hero read heavier and flatter
     than the homepage panels (the blur, since removed, was the first):
     it painted an opaque-ish dark gradient across the lower third, on top of
     the very transparency it was meant to have. The reference keeps only the
     1px top highlight, so that is all that stays. */
  box-shadow:inset 0 1.5px 0 rgba(255,255,255,.04),
    inset 0 0 0 1px color-mix(in srgb,var(--echo) 6%,transparent);
  clip-path:polygon(0 18px,18px 0,calc(100% - 18px) 0,100% 18px,
    100% calc(100% - 18px),calc(100% - 18px) 100%,18px 100%,0 calc(100% - 18px));
  filter:drop-shadow(0 4px 14px rgba(0,0,0,.32))}
/* Light mode: the background is now the shared --panel token (66% on paper, set
   once in :root[data-theme=light]), so only the shadows need restating - and
   the bottom wash goes here too, for the same reason it went in dark mode. */
:root[data-theme=light] .ehero{
  box-shadow:inset 0 1.5px 0 rgba(255,255,255,.5),
    inset 0 0 0 1px color-mix(in srgb,var(--echo) 6%,transparent)}
.ehero::before{content:"";position:absolute;inset:0;z-index:0;pointer-events:none;
  /* Dotted-grid texture removed sitewide (operator, 2026-08-17): panels default
     to plain glass now, matching index.html's own panels. Add the dot layer
     back with .dotted (below) on any ONE panel the operator explicitly asks
     for it on - it is opt-in per panel, not a default anymore. */
  background:var(--echo);
  clip-path:polygon(evenodd,
    0 18px,18px 0,calc(100% - 18px) 0,100% 18px,
    100% calc(100% - 18px),calc(100% - 18px) 100%,18px 100%,0 calc(100% - 18px),
    0 18px,
    1.5px 19.5px,19.5px 1.5px,calc(100% - 19.5px) 1.5px,calc(100% - 1.5px) 19.5px,
    calc(100% - 1.5px) calc(100% - 19.5px),calc(100% - 19.5px) calc(100% - 1.5px),
    19.5px calc(100% - 1.5px),1.5px calc(100% - 19.5px),
    1.5px 19.5px)}
.ehero::after{content:"";position:absolute;top:15px;left:15px;width:11px;height:11px;
  border-top:1.5px solid var(--echo);border-left:1.5px solid var(--echo);opacity:.85;z-index:2}
.ehero>*{position:relative;z-index:1}
.ekick{font-family:var(--mono);font-size:.62rem;letter-spacing:.24em;text-transform:uppercase;color:var(--echo);
  margin:0 0 12px;display:flex;align-items:center;gap:12px}
.ekick::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--line),transparent)}
.epage h1{font-family:var(--head);font-weight:800;text-transform:uppercase;letter-spacing:.02em;
  font-size:clamp(1.9rem,4.4vw,3rem);margin:0 0 14px;color:var(--text)}
.epage .lede{font-size:1.02rem;color:var(--muted);line-height:1.65;max-width:70ch;margin:0 0 20px}
.epage .lede b{color:var(--text)}
/* .ebuilding removed 2026-08-18 (operator: "the site is now live") - the last
   badge usages were stripped from people/wargames/events the same day. */
.erow{display:flex;flex-wrap:wrap;gap:10px;margin-top:22px}
/* ── THE button ───────────────────────────────────────────────────────────────
   One definition for the whole site. It used to be five: .ebtn here, plus a
   separate .btn hand-written into index, members, survey and agreement. They had
   drifted into three different hover fills (--echo, --ping, and a tint) and two
   different border colours (--edge, --frame-soft), which is why buttons looked
   randomly different depending on which page you were standing on.
   The hover lift+scale live HERE now, not in a page-local .cdk layer - see the
   retirement note at the .cdk-flash block below for why. Pages still add
   .cdk-flash on top for the size-bumped tick; that's the only motion left that
   isn't already base. */
.ebtn,.btn{font-family:var(--mono);font-size:.62rem;letter-spacing:.16em;text-transform:uppercase;cursor:pointer;
  text-decoration:none;background:var(--surf);border:2px solid var(--edge);color:var(--frame);
  padding:9px 15px;display:inline-flex;align-items:center;gap:8px;transform-origin:left center;
  clip-path:polygon(0 6px,6px 0,100% 0,100% calc(100% - 6px),calc(100% - 6px) 100%,0 100%);
  transition:color .16s,background .16s,border-color .16s,transform .22s cubic-bezier(.2,.7,.3,1)}
.ebtn::before,.btn::before{content:"⡀⢀";opacity:.35;transition:opacity .16s}
/* Hover is a TINT, not a slab. It used to flood the button with solid --ping and
   flip the label to near-black, which worked until it met .ebtn.primary further
   down this file: `.ebtn:hover` and `.ebtn.primary` have identical specificity
   (two class-level selectors each), so source order handed `color` to .primary
   and its teal label stayed put on top of the cyan flood. #35e0c0 on #6fe9ff is
   a contrast ratio of 1.18:1 - WCAG wants 4.5:1 - so every primary button went
   blank the moment the pointer touched it. Sponsors & Support carries five of
   them, which is where it showed up.
   A low-alpha tint over the surface fixes both halves at once: the fill stops
   shouting, and BOTH labels - --text and --echo - land around 8:1 against it, so
   no rule below needs to fight this one for the colour. */
.ebtn:hover:not(:disabled),.btn:hover:not(:disabled){transform:translateY(-2px) scale(1.035);color:var(--text);
  background:color-mix(in srgb,var(--ping) 16%,var(--surf));border-color:var(--ping)}
/* press state, folded up from the retired .cdk:active - see the note at the
   bottom of the file where .cdk used to carry this */
.ebtn:active:not(:disabled),.btn:active:not(:disabled){transform:translateY(0) scale(1.02)}
/* the ascii art ANIMATES on every button, not just the ones inside members.html -
   the glyph filling on hover is the site's signature and it was missing from
   every .ebtn: the scaffold pages, the officer card, the blog. */
.ebtn:hover::before,.btn:hover:not(:disabled)::before{opacity:1;animation:brlflash .5s steps(1,end) 2}
@keyframes brlflash{0%{content:"⣄⡆"}25%{content:"⣿⣷"}50%{content:"⢹⣸"}75%{content:"⡜⣠"}100%{content:"⣀⣿"}}
@keyframes brlrise{0%{content:"⡀"}13%{content:"⣀"}26%{content:"⣄"}39%{content:"⣤"}
  52%{content:"⣦"}65%{content:"⣶"}78%{content:"⣷"}100%{content:"⣿"}}
/* the nav links and menu cards speak it too.
   ::before, NOT the .enav-b span - see the note up at the .enav-b rule. This
   selector read `.enav-link:hover .enav-b` and animated nothing at all, because
   the span is a real element and `content` has no effect on one. Every member
   of the family is covered by this one pair of selectors: the eight bar tabs,
   Sign In and Settings all carry .enav-link, which is what stops the corner
   pair drifting away from the bar again. Focus is here too, so the tick churns
   for a keyboard the same way it does for a pointer. */
.enav-link:hover .enav-b::before,
.enav-link:focus-visible .enav-b::before{animation:brlflash .5s steps(1,end) 2}
.enav-card:hover .enav-card-b{animation:brlrise .8s steps(1,end) infinite}
/* .btn's half of the pair above was MISSING from this list: the hover rule fires
   the tick for `.ebtn:hover::before,.btn:hover:not(:disabled)::before` and only
   the .ebtn half was ever suppressed. It went unnoticed because every page that
   had .btn markup also carried its own blanket local reduced-motion rule; a new
   page linking only this file inherited the hole. Stated at the same specificity
   as the rule it cancels ((0,3,0) - :not() contributes :disabled) and declared
   after it. .enav-signin is deliberately not listed: it is a .btn cdk cdk-flash
   now (see its rule below), so it is covered by this line and the .cdk block. */
@media (prefers-reduced-motion:reduce){
  .ebtn:hover::before,.btn:hover:not(:disabled)::before,
  .enav-link:hover .enav-b::before,
  .enav-link:focus-visible .enav-b::before,.enav-card:hover .enav-card-b{animation:none}}
/* primary is a LABEL colour, not a frame colour. Standing operator rule: no
   accent border unless explicitly asked - the emphasis belongs in the text, and
   a coloured frame made primary read as a different component rather than the
   same button carrying more weight. */
.ebtn.primary,.btn.primary{color:var(--echo)}
/* disabled is a base-button state, not a per-page one */
.btn:disabled,.ebtn:disabled{opacity:.4;cursor:default;transform:none}

/* ── the cyberdeck motion layer: .cdk-flash (retired: .cdk) ───────────────────
   THE ONE BUTTON VOICE. Lifted here from index.html's <style> 2026-08-23; the
   note four rules up already promised "only the base is shared, so the ascii
   sweep and the lift stay page-local" and that promise was the bug. .cdk and
   .cdk-flash existed as page-local copies in index.html and members.html plus a
   divergent fork in survey.html, so writing class="btn cdk cdk-flash" on any of
   the other fifteen pages produced a plain .btn and nothing else - the class was
   inert markup. Ported from the GW build; ECHO wears the sonar accent.

   RETIRED, 2026-08-23, same day: .cdk itself (the lift+scale transform pair and
   the ::after sweep bar). It duplicated the canonical `.ebtn,.btn` block instead
   of composing with it - two hover systems layered on the same element, base
   translateY plus an overlaid translateY+scale that only ever won because of a
   higher specificity than the block it should have been part of. The visible
   symptom: the bottom sweep bar rides up and scales with the lift on every hover,
   reading as a stray highlight rather than the button's own edge. The lift and
   scale now live in the canonical block above (`.ebtn:hover,.btn:hover`,
   `.ebtn:active,.btn:active`) so there is exactly one hover-transform rule per
   button, and the bar (`.cdk::after` / `.cdk:hover::after` / @keyframes
   brailsweep) is gone rather than folded - nothing in the canonical block
   replaces it. `.cdk` stays in every page's markup (harmless, matches no rule
   here any more) rather than touching fifteen pages' worth of class lists for a
   class that carried no other job.

   .cdk-flash is the braille tick at button size and is UNCHANGED - it never
   competed with anything, it only restates the base .btn::before one size up.
   .cdk-scan / .cdk-rise were already retired same-day as dead vocabulary; see
   the line above this one before the edit. brlflash itself is NOT redeclared
   here - this file already owns it, ~25 lines up. */
.cdk-flash::before{content:"⡀⢀";font-family:var(--mono);font-size:.72rem;line-height:1;
  color:currentColor;opacity:.35;flex:0 0 auto}
.cdk-flash:hover::before{opacity:1;animation:brlflash .5s steps(1,end) 2}
@media (prefers-reduced-motion:reduce){
  .cdk-flash:hover::before{animation:none}}

/* ── the menu overlay's Sign In: size deltas only ─────────────────────────────
   It sits beside the xetch close in the menu head, so the menu offers the one
   action a signed-out reader most often wants without hunting for the member
   page. It used to be a VERBATIM re-typing of the .btn base up there - same
   mono/uppercase/.16em, same 2px --edge frame, same 6px clip, same --ping 16%
   hover tint - plus a THIRD braille tick idiom (::before on the element itself,
   where the bar uses an .enav-b span and buttons use .btn::before). Three
   vocabularies for one glyph. nav.js now emits `btn primary cdk cdk-flash
   enav-signin`, so the frame, the fill, the tint, the lift and the tick all
   come from the one button voice and only the SIZE is stated here. (`cdk` in
   that class list is now inert - see the retirement note above .cdk-flash -
   left in place rather than touched, since it carries no rule of its own.)

   `primary` rather than a local `color:var(--echo)`: primary IS the teal-label
   idiom (label colour only, never a frame colour), which is exactly what this
   control was hand-rolling. .in then overrides it from below, as it always did.

   POSITION IS LOAD-BEARING. Every rule here is (0,1,0) or (0,2,0) and ties the
   base .btn rules, so it only wins by being declared after them. Moving this
   block back up beside .enav-head-acts silently reverts the size to .62rem and
   kills the 620px step. */
.enav-signin{font-size:.5rem;padding:8px 14px}
/* the tick rides the label's size instead of the button-scale .72rem, which
   reproduces the inherited size it had as a bespoke control at both breakpoints */
.enav-signin.cdk-flash::before{font-size:1em}
.enav-signin.in{color:var(--text)}
@media (max-width:620px){.enav-signin{font-size:.44rem;padding:7px 10px}}

/* ── section rail ──────────────────────────────────────────────────────────
   Built by rail.js from a page's own sections; see that file for the contract.
   Fixed left column on desktop, horizontal strip under the nav on narrow
   screens. It hides itself below 2 entries, so a page that has not grown
   sections yet simply has no rail. */
/* .srail already had the octagon clip-path AND a corner tick — 2 of the 3
   etched-panel layers. What's missing (the solid rim bezel) is deliberately
   NOT added here: this panel's translucency + backdrop-blur is load-bearing
   for it reading as a floating HUD overlay rather than an opaque page
   section, and the full AEGIS .card treatment would turn it into a solid
   block, losing that. Border tinted teal instead of neutral --line, so it
   reads as part of the same etched family without going opaque. */
.srail{position:fixed;left:22px;top:50%;transform:translateY(-50%);z-index:30;
  display:flex;flex-direction:column;gap:2px;padding:16px 14px 12px;max-width:184px;
  background:linear-gradient(155deg,color-mix(in srgb,var(--surf2) 88%,transparent),
    color-mix(in srgb,var(--surf) 82%,transparent));
  border:2px solid color-mix(in srgb,var(--echo) 22%,var(--line));
  -webkit-backdrop-filter:var(--glass-1);backdrop-filter:var(--glass-1);
  clip-path:polygon(0 11px,11px 0,calc(100% - 11px) 0,100% 11px,
    100% calc(100% - 11px),calc(100% - 11px) 100%,11px 100%,0 calc(100% - 11px));
  transition:max-width .22s cubic-bezier(.2,.7,.3,1),background .2s}
.srail::before{content:"";position:absolute;top:9px;left:9px;width:9px;height:9px;
  border-top:1.5px solid var(--echo);border-left:1.5px solid var(--echo);opacity:.85}
/* padding-right clears the collapse control, which is absolutely positioned in
   this corner. The LABEL gives way rather than the button: .srail is a
   max-width and the landing page's short labels leave it only 139px wide, so
   there is no width here to spend on a gutter. */
.srail-k{font-family:var(--mono);font-size:.46rem;letter-spacing:.26em;text-transform:uppercase;
  color:var(--dim);margin:0 0 8px;padding-left:2px;padding-right:28px}

/* The spine, and the progress that fills it. --fill is set by rail.js from the
   reading position, so the column reads as a completion bar without being one. */
.srail-list{position:relative;display:flex;flex-direction:column;gap:1px;padding-left:3px}
.srail-list::before{content:"";position:absolute;left:8px;top:4px;bottom:4px;width:1px;
  background:var(--line)}
.srail-list::after{content:"";position:absolute;left:8px;top:4px;width:1px;
  height:var(--fill,0%);background:var(--echo-deep);transition:height .18s linear}

/* One rail row = the section link, with its sub-links underneath it. Those two
   are SIBLINGS inside this wrapper and never nested, because a sub-link is an
   <a> and the row link is an <a>, and no HTML parser will nest those - see the
   note on rail.js's row template for what that was doing to the mini nav strip.
   The wrapper is what .srail-list lays out now; .srail-a keeps every rule it
   already had, and the row is the flex item in both the column and the <=1080px
   row direction. Deliberately NO min-width:0 - the wrapper has to take over
   .srail-a's old automatic min-content floor exactly, or the <=1080px strip
   (a row flex container) would let rows shrink past their own text. */
.srail-row{position:relative;z-index:1;display:flex;flex-direction:column}
.srail-a{position:relative;z-index:1;display:flex;align-items:center;gap:9px;
  font-family:var(--mono);font-size:.56rem;letter-spacing:.13em;text-transform:uppercase;
  color:var(--dim);text-decoration:none;padding:6px 4px;
  transition:color .16s}
/* ── hover: the border assembles, then breathes ───────────────────────────
   Two brackets - top-left and bottom-right - fly in from opposite corners and
   meet to close a frame around the title, then the frame pulses.

   steps(5,end) on the assembly is the point: a smooth slide would read as a box
   fading in, where a stepped one reads as pixels snapping into place, which is
   the site's own language. The pulse is delayed by exactly the assembly
   duration so it starts on a closed frame rather than mid-build.

   The lift is 1px. It only has to suggest the frame sits above the label; more
   than that and a 12px row visibly jumps under the pointer. */
.srail-a .srail-t{overflow:hidden;text-overflow:ellipsis;white-space:nowrap;
  position:relative;padding:3px 8px;margin:-3px -2px;
  transition:transform .2s cubic-bezier(.2,.7,.3,1),text-shadow .2s}
.srail-a .srail-t::before,
.srail-a .srail-t::after{content:"";position:absolute;left:0;top:0;
  width:100%;height:100%;box-sizing:border-box;opacity:0;pointer-events:none}
/* the *{box-sizing:border-box} reset at the top of this file doesn't reach
   generated pseudo-elements, so these defaulted to content-box: the border
   thickness on ::after (bottom+right) was drawn OUTSIDE the 100%x100% box,
   past .srail-t's own overflow:hidden edge, and got clipped away entirely —
   the right-hand hover bracket was never visible. Confirmed live in a real
   browser before and after this fix. */
.srail-a .srail-t::before{border-top:1.5px solid var(--echo);border-left:1.5px solid var(--echo)}
.srail-a .srail-t::after{border-bottom:1.5px solid var(--echo);border-right:1.5px solid var(--echo)}

.srail-a:hover .srail-t{transform:translateY(-1px);text-shadow:0 0 12px color-mix(in srgb,var(--echo) 50%,transparent)}
.srail-a:hover .srail-t::before{
  animation:pxTL .26s steps(5,end) forwards, pxPulse 1.6s .26s ease-in-out infinite}
.srail-a:hover .srail-t::after{
  animation:pxBR .26s steps(5,end) forwards, pxPulse 1.6s .26s ease-in-out infinite}

@keyframes pxTL{
  0%  {opacity:0;transform:translate(-9px,-7px);clip-path:inset(0 74% 74% 0)}
  100%{opacity:1;transform:none;           clip-path:inset(0 0 0 0)}}
@keyframes pxBR{
  0%  {opacity:0;transform:translate(9px,7px);clip-path:inset(74% 0 0 74%)}
  100%{opacity:1;transform:none;          clip-path:inset(0 0 0 0)}}
/* the assembled frame breathes rather than blinks - opacity floor stays high
   so the border never reads as flickering */
@keyframes pxPulse{
  0%,100%{opacity:1;   filter:drop-shadow(0 0 3px color-mix(in srgb,var(--echo) 55%,transparent))}
  50%    {opacity:.55; filter:drop-shadow(0 0 9px color-mix(in srgb,var(--echo) 90%,transparent))}}

@media (prefers-reduced-motion:reduce){
  .srail-a:hover .srail-t{transform:none}
  .srail-a:hover .srail-t::before,.srail-a:hover .srail-t::after{
    animation:none;opacity:1;clip-path:none;transform:none}}

/* ── the node on the line ─────────────────────────────────────────────────
   A geometric marker, not a glyph. The braille tick that used to sit here was
   one ornament too many beside a braille-ticked nav and braille-ticked buttons,
   and it crowded the connecting line it was supposed to sit on.

   Three states, read at a glance without any text:
     ahead    a small hollow node
     read     the same node, filled - the line behind you is solid
     current  a lit BAR, taller than a node and glowing, so "you are here" is a
              different shape rather than just a different colour. Shape survives
              a glance and a colour-blind reader; colour alone does not. */
.srail-a .b{position:relative;width:11px;height:14px;flex:0 0 auto;
  display:flex;align-items:center;justify-content:center}
.srail-a .b::before{content:"";width:5px;height:5px;background:var(--surf);
  border:1.5px solid var(--edge);
  transition:background .18s,border-color .18s,width .18s,height .18s}
.srail-a:hover .b::before{border-color:var(--echo-deep)}
.srail-a.read{color:var(--muted)}
.srail-a.read .b::before{background:var(--echo-deep);border-color:var(--echo-deep)}
/* Softened from a lit 3x14px rectangle + fixed glow to a breathing dot —
   reuses this same file's own navPulse idiom (.enav-card.on .enav-card-b),
   not a new animation invented for this one spot. */
.srail-a.on{color:var(--echo)}
.srail-a.on .b::before{width:6px;height:6px;background:var(--echo);border-color:var(--echo);
  animation:navPulse 2.4s ease-in-out infinite}
@media (prefers-reduced-motion:reduce){.srail-a.on .b::before{animation:none;box-shadow:0 0 6px color-mix(in srgb,var(--echo) 40%,transparent)}}
/* members-only sections read the same way they do in the nav: dimmed, ticked,
   never hidden - no accent border */
.srail-a.locked{color:var(--dim)}
.srail-a.locked .srail-m{font-size:.42rem;letter-spacing:.16em;color:var(--echo-deep);
  margin-left:auto;flex:0 0 auto}

/* The current-section bar reads on the strip too, where there is no spine to
   sit on - it becomes an underline instead of a node. */
@media (max-width:1080px){
  .srail-a .b{width:0;height:0;display:none}
  .srail-a.on{box-shadow:inset 0 -2px 0 var(--echo)}
}

/* Sub-entries exist collapsed and are revealed by widening the rail, so a long
   page is navigable two levels deep without a second control. */
.srail-subs{display:none;flex-direction:column;gap:2px;padding:4px 0 2px 20px}
.srail-subs a,.srail-subs span{font-size:.5rem;letter-spacing:.1em;text-transform:none;
  color:var(--dim);text-decoration:none;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.srail-subs a:hover{color:var(--echo)}
/* Widened hover used to go fully opaque here, breaking the translucent-HUD
   contract .srail's own resting state documents above - the widen is exactly
   when it's most likely to sit over a page panel, so opaque was the one state
   that most needed to stay see-through. Same color-mix technique as resting,
   just less transparent so the now-wider text stays legible. */
.srail:hover{max-width:290px;background:linear-gradient(155deg,
    color-mix(in srgb,var(--surf2) 78%,transparent),
    color-mix(in srgb,var(--surf) 70%,transparent))}
/* `.srail:hover .srail-a{flex-wrap:wrap}` used to sit here, to wrap the subs
   onto a second line INSIDE the row link. The subs are a sibling of that link
   now (see .srail-row), so there is nothing left to wrap and the rule is gone
   rather than left as furniture. .srail-row is already a column, so the reveal
   is the whole of it. */
.srail:hover .srail-subs{display:flex;width:100%}

.srail-foot{display:flex;align-items:center;justify-content:space-between;gap:10px;
  margin-top:10px;padding-top:9px;border-top:1px solid var(--line)}
.srail-n{font-family:var(--mono);font-size:.46rem;letter-spacing:.18em;color:var(--dim)}
.srail-n b{color:var(--echo);font-weight:600}
.srail-top{font-family:var(--mono);font-size:.46rem;letter-spacing:.18em;text-transform:uppercase;
  background:none;border:0;color:var(--dim);cursor:pointer;padding:2px 0;display:flex;align-items:center;gap:5px}
.srail-top:hover{color:var(--text)}
.srail-top:hover span{color:var(--echo)}

/* ── collapse: the rail folds into a token ──────────────────────────────────
   Operator: "making the mini navigation bar collapsible, it can shrink into a
   little cube or hex."

   THE TOKEN IS AN OCTAGON, because the octagon is this site's cube. Every panel
   here is a square with its corners cut — .ehero at 18px, .pcard 16, .srail 11,
   .pstat and #topctrl 7 — and cursor.js's octPath calls them by name: "the same
   eight points echo.css cuts into every panel". A hexagon would be the wrong
   word in the right language. The hex on this site is the identity MARK
   (assets/echo-logo-hex.svg, .emark), and mark.js says of it that "poking it
   would make it a button". Folding a navigation control into the logo would
   say "this became the brand"; folding it into the octagon says "this is the
   same panel, smaller", which is what actually happened.

   So the token is not a new object. It IS .srail at token scale: same tinted
   border, same translucency and backdrop-blur — load-bearing, per this file's
   own note above, and doubly so here, because the rail sits at x=22 against
   .epage's 40px padding and therefore overlays the content column; an opaque
   token would punch a hole in the text — and the same clip-path with the cut
   taken from 11px to 9px. cursor.js keeps its own cut proportional to the
   radius for exactly this reason: "so small trailing marks keep the cut legible
   instead of degenerating into a square".

   WHAT IS INSIDE IT is the counter the rail already keeps, 01 / 06. measure()
   goes on writing it — braille resolve and all — so the collapsed rail still
   answers the one question the rail exists to answer, where am I, instead of
   becoming a lid.

   NO SIZE ANIMATION, and that is a decision rather than an omission. The
   entries collapse by `display:none`, which is not an interpolable property, so
   an animated box could only ever be an empty frame shrinking after its
   contents had already gone. This site's vocabulary for a state change is a
   step and not a slide — the hover bracket assembly is steps(5,end) on purpose
   — and a change with nothing to interpolate needs no separate
   prefers-reduced-motion path, because there is nothing to reduce. (The
   max-width transition declared on .srail is already dead in practice: the
   reveal rule and then .synced each redeclare the `transition` shorthand
   further down this file, so max-width has not been animating anywhere,
   the hover-widen included.)

   DESKTOP ONLY. Below 1081px the rail is a different object: a 36px full-width
   strip with no footer, no sub-entries and no click wiring at all (see the
   media block below, and rail.js's wireClicks gate) — "a position indicator,
   not a menu". Collapsing that is a separate design, so the control is hidden
   there and none of the .shut rules can fire. */
.srail-tog{position:absolute;top:5px;right:5px;z-index:2;
  width:26px;height:26px;padding:0;flex:0 0 auto;cursor:pointer;
  display:flex;align-items:center;justify-content:center;
  -webkit-appearance:none;appearance:none;background:none;border:0}
/* 26px is the HIT area; the mark inside it is 14px. Reading the mark at button
   size would put a 2.5.8 target-size failure on the one control that has to be
   reachable in both states, and the padding costs nothing visually. */
/* The mark is GEOMETRIC, not a glyph — the same call .srail-a .b documents
   above, for the same reason: a braille tick here would be one more ornament
   beside a braille-ticked nav, a braille-ticked "top" and a braille-ticked
   wire. Two corner brackets on the same diagonal the xetch uses, and they
   travel INWARD on hover, because closing the frame is the thing this button
   does. Neutral --edge at rest; the accent is the hover response, never the
   border of a resting control. */
.srail-tg{position:relative;display:block;width:14px;height:14px}
.srail-tg::before,.srail-tg::after{content:"";position:absolute;width:6px;height:6px;
  box-sizing:border-box;
  transition:transform .2s cubic-bezier(.2,.7,.3,1),border-color .16s}
.srail-tg::before{top:0;left:0;
  border-top:1.5px solid var(--edge);border-left:1.5px solid var(--edge)}
.srail-tg::after{bottom:0;right:0;
  border-bottom:1.5px solid var(--edge);border-right:1.5px solid var(--edge)}
.srail-tog:hover .srail-tg::before,
.srail-tog:focus-visible .srail-tg::before{transform:translate(3px,3px);border-color:var(--echo)}
.srail-tog:hover .srail-tg::after,
.srail-tog:focus-visible .srail-tg::after{transform:translate(-3px,-3px);border-color:var(--echo)}
.srail-tog:focus-visible{outline:2px solid var(--echo-deep);outline-offset:0}
/* The blanket reduced-motion reset in this file is written on `*`, and `*` does
   NOT match generated content — so a transition declared on a ::before survives
   it. That gap is already patched by hand a few rules up for .srail-a's hover
   brackets; this is the same patch for the same reason, measured the same way
   (the computed transition-duration on .srail-tg::before read 0.2s under
   prefers-reduced-motion until this rule existed). !important because the shut
   state's own travel rules out-specify anything written without it — and
   because that is exactly how the blanket rule is written.
   Only the TRAVEL goes. The border-colour change is a different property and is
   left alone, so the control still answers a pointer without moving. */
@media (prefers-reduced-motion:reduce){
  .srail-tg::before,.srail-tg::after{transition:none!important;transform:none!important}
}

@media (min-width:1081px){
  .srail.shut{width:46px;max-width:46px;height:46px;padding:0;gap:0;
    clip-path:polygon(0 9px,9px 0,calc(100% - 9px) 0,100% 9px,
      100% calc(100% - 9px),calc(100% - 9px) 100%,9px 100%,0 calc(100% - 9px))}
  /* the hover-widen belongs to the expanded rail; a token that grew under the
     pointer would be a third state nobody asked for */
  .srail.shut:hover{max-width:46px;width:46px}
  /* DISPLAY, not visibility, and that is the whole point of the feature. The
     reveal rule further down this file uses visibility over opacity so a rail
     that has not arrived yet cannot be reached by Tab either; collapsing takes
     that one step further, because a rail you cannot see and cannot reach is
     the only honest collapsed state. Anything short of removal leaves a
     keyboard reader tabbing through ten invisible links. */
  .srail.shut .srail-k,
  .srail.shut .srail-list,
  .srail.shut .srail-top{display:none}
  /* the counter becomes the token's content, centred, still live */
  .srail.shut .srail-foot{flex:1 1 auto;width:100%;margin:0;padding:0;
    border-top:0;justify-content:center;gap:0}
  .srail.shut .srail-n{font-size:.44rem;letter-spacing:.05em}
  /* THE TOKEN IS THE CONTROL. The button fills it, so the whole octagon is the
     target rather than a mark sitting inside a panel — a collapsed rail nobody
     can work out how to reopen is worse than no collapse at all. */
  .srail.shut .srail-tog{inset:0;width:auto;height:auto}
  .srail.shut .srail-tog:hover{background:color-mix(in srgb,var(--echo) 7%,transparent)}
  /* clip-path clips descendant PAINTING, outlines included. An outline drawn
     outside a button that fills the token is outside the octagon, so the whole
     ring gets cut away and a keyboard user lands on the token with no
     indication whatsoever. Drawn inside instead. Verified in a browser. */
  .srail.shut .srail-tog:focus-visible{outline-offset:-4px}
  /* The two brackets become the token's own corner marks — the same diagonal
     pair #topctrl, .pstat and .dcard already cut, and the pair the xetch's
     brackets sit on — so the rail's single top-left tick stands down rather
     than crowding a 46px face with three marks. Inset 8px so that travelling
     outward on hover still lands inside the 9px cut instead of being clipped
     by it. Outward, because opening is the opposite gesture to closing. */
  .srail.shut::before{display:none}
  .srail.shut .srail-tg{position:absolute;inset:8px;width:auto;height:auto}
  .srail.shut .srail-tog:hover .srail-tg::before,
  .srail.shut .srail-tog:focus-visible .srail-tg::before{transform:translate(-2px,-2px)}
  .srail.shut .srail-tog:hover .srail-tg::after,
  .srail.shut .srail-tog:focus-visible .srail-tg::after{transform:translate(2px,2px)}
}

/* Under 1080px there is no room for a left column beside the content, so the
   rail becomes a scrollable strip pinned under the nav. Sub-entries and the
   footer go: on a phone the rail is a position indicator, not a menu. */
@media (max-width:1080px){
  .srail{left:0;right:0;top:calc(var(--navband) + 46px);transform:none;max-width:none;
    flex-direction:row;align-items:center;gap:2px;padding:7px 10px;overflow-x:auto;
    clip-path:none;border-left:0;border-right:0;border-top:0}
  .srail:hover{max-width:none}
  /* .srail-tog goes with the footer. The strip has no panel to collapse — the
     .shut rules are gated to >=1081px — so a control left rendering here would
     be a button that does nothing, and one that a keyboard reader would still
     land on. Hidden by display, so it leaves the tab order with everything
     else in this list. */
  .srail-k,.srail-foot,.srail-subs,.srail:hover .srail-subs,.srail-tog{display:none}
  .srail-list{flex-direction:row;padding-left:0}
  .srail-list::before,.srail-list::after{display:none}
  .srail-a{padding:5px 8px;white-space:nowrap}
  .srail-a .srail-t{max-width:120px}
}
@media (max-width:1080px){ .epage{padding-top:calc(var(--navclear) + 26px)} }

/* ── the rail is not there on arrival ─────────────────────────────────────
   It is an orientation aid for a page you are already reading; on first paint
   there is nothing to orient and it only competes with the hero. rail.js adds
   .up once the reader has actually started moving down the page.

   Opacity + visibility only, no transform: the rail is translateY(-50%) on
   desktop and transform:none as a strip on mobile, so animating transform here
   would have to fight both. visibility carries the tab-order - a hidden rail
   must not be reachable by keyboard either. */
.srail{opacity:0;visibility:hidden;
  transition:opacity .32s ease,visibility .32s}
.srail.up{opacity:1;visibility:visible}
@media (prefers-reduced-motion:reduce){.srail{transition:none}}

/* Anchors must land BELOW the fixed nav assembly, not at viewport y=0.
   Without this every jump - rail click, menu link, or a pasted #hash - puts the
   section's own title underneath the band and bar. It also broke the rail sync:
   with the title at y≈8 there is no rail position that can hold a 40px offset,
   so the clamp took over and the error grew by one row per section. One
   property fixes the jump and the sync together. */
section[id],[data-rail]{scroll-margin-top:var(--navclear)}

/* ── sync ──────────────────────────────────────────────────────────────────
   rail.js adds .synced (desktop only) and then drives `top` directly, so the
   active row can hold a fixed distance from its section's title. Centring is
   turned OFF here rather than in the base rule on purpose: if the script never
   runs, the rail stays vertically centred instead of collapsing to the top. */
/* Starts CENTRED and stays near centre. rail.js overrides `top` on a click and
   clamps the result to a band around the middle, so the panel travels rather
   than roams - and if the script never sets a top, this is where it sits. */
.srail.synced{top:50%;transform:translateY(-50%);
  transition:top 380ms cubic-bezier(.22,.75,.24,1),opacity .32s ease,visibility .32s}
/* once JS has placed it, top is absolute and the centring translate must go */
.srail.synced.placed{top:0;transform:none}
@media (prefers-reduced-motion:reduce){.srail.synced{transition:none}}

/* the connector: angular, quick, and gone again. Lives on <body>, above the
   page but below the rail, so it never draws over the panel it starts from. */
.srail-wire{position:fixed;left:0;top:0;width:100vw;height:100vh;z-index:29;
  pointer-events:none;overflow:visible}
/* thinner: 1.5 read as a cable, 1 reads as a trace */
.srail-wire path{fill:none;stroke:var(--echo);stroke-width:1;
  stroke-linejoin:round;filter:drop-shadow(0 0 5px color-mix(in srgb,var(--echo) 60%,transparent))}
.srail-wire .ghost{stroke:var(--echo-deep);opacity:.2;filter:none}
.srail-wire text{font-family:var(--mono);font-size:13px;fill:var(--echo);
  filter:drop-shadow(0 0 8px color-mix(in srgb,var(--echo) 85%,transparent))}
@media (max-width:1080px){.srail-wire{display:none}}
@media (prefers-reduced-motion:reduce){.srail-wire{display:none}}

/* ── the section answers the line ─────────────────────────────────────────
   A connection is two-ended. The line arriving is only half of it; the other
   half is the destination acknowledging that it landed. So as the glyph hits,
   the title takes a brief charge and the section's own frame ghosts in behind
   it, then both let go. Short, and it decays on its own - a highlight that
   stays is a highlight you stop seeing. */
.srail-hit{animation:srailHit .9s cubic-bezier(.2,.7,.3,1) both}
@keyframes srailHit{
  0%   {color:var(--echo);text-shadow:0 0 0 color-mix(in srgb,var(--echo) 0%,transparent)}
  18%  {color:var(--flash);text-shadow:0 0 18px color-mix(in srgb,var(--echo) 95%,transparent),0 0 4px rgba(255,255,255,.6)}
  100% {color:var(--echo);text-shadow:0 0 0 color-mix(in srgb,var(--echo) 0%,transparent)}}
/* the rule beside a section kick sweeps as the charge passes through it */
.srail-hit::after{animation:srailSweep .9s cubic-bezier(.2,.7,.3,1) both}
@keyframes srailSweep{
  0%   {background:var(--line)}
  22%  {background:linear-gradient(90deg,var(--echo),var(--echo-deep) 40%,var(--line))}
  100% {background:var(--line)}}

/* the panel itself registers the hit without moving - an outline that arrives
   and fades, so nothing reflows */
.srail-lit{animation:srailLit .9s ease-out both}
@keyframes srailLit{
  0%   {box-shadow:0 0 0 0 color-mix(in srgb,var(--echo) 0%,transparent)}
  20%  {box-shadow:0 0 0 1px color-mix(in srgb,var(--echo) 50%,transparent),
                   0 0 26px -6px color-mix(in srgb,var(--echo) 40%,transparent)}
  100% {box-shadow:0 0 0 0 color-mix(in srgb,var(--echo) 0%,transparent)}}
@media (prefers-reduced-motion:reduce){
  .srail-hit,.srail-hit::after,.srail-lit{animation:none}}

/* ── member gate ───────────────────────────────────────────────────────────
   Deliberately NOT a second use of .wip. Same visual verb, opposite message:
   .wip says "we haven't finished this", the gate says "this IS finished and
   it's for members". Resources carries both at once, which is the proof they
   are different components.

   Geometry differs too. .wip caps at 460px and masks out, because a development
   veil is meant to be a glimpse. The gate covers the whole gated region - the
   point is that a visitor sees there is substance here, sized honestly, without
   being able to read it.

   NOT ACCESS CONTROL. The markup still ships in the page; anyone can read it
   from view-source. This is a front door and a membership signal. Anything that
   must actually be private has to be fetched from the Apps Script API after the
   token check, the way the member deck's stats/me actions already are. */
.gate{display:grid}
/* Opacity is the visibility dial for BOTH veils on this site, and the two are
   kept in step deliberately - the gate and the in-development glass should read
   as the same material even though they say different things. Raised 10 points
   (operator, 2026-08-11) so there is more shape behind the glass: a visitor
   should be able to see that real content is there, which is the whole argument
   for joining. Blur is left alone - it is what makes it glass rather than a
   dimmer, and raising legibility is not the goal. */
.gate-veil{grid-area:1/1;filter:blur(9px) saturate(.7);opacity:.44;
  pointer-events:none;user-select:none;-webkit-user-select:none}
.gate-note{grid-area:1/1;display:flex;align-items:flex-start;justify-content:center;
  padding:60px 24px 24px;z-index:2}
.gate-card{position:relative;width:min(94%,560px);padding:30px 32px;text-align:center;
  background:linear-gradient(155deg,color-mix(in srgb,var(--surf2) 94%,transparent),
    color-mix(in srgb,var(--surf) 90%,transparent));
  border:2px solid var(--line);
  -webkit-backdrop-filter:var(--glass-1);backdrop-filter:var(--glass-1);
  clip-path:polygon(0 14px,14px 0,calc(100% - 14px) 0,100% 14px,
    100% calc(100% - 14px),calc(100% - 14px) 100%,14px 100%,0 calc(100% - 14px))}
.gate-card::before{content:"";position:absolute;top:12px;left:12px;width:11px;height:11px;
  border-top:1.5px solid var(--edge);border-left:1.5px solid var(--edge)}
.gate-card::after{content:"";position:absolute;bottom:12px;right:12px;width:11px;height:11px;
  border-bottom:1.5px solid var(--edge);border-right:1.5px solid var(--edge)}
/* No braille tick here. It sat to the left of the words and pushed them off the
   card's centre line, so the label read as left-of-centre on a card that is
   otherwise symmetrical. The glitching mark above it is the ornament now. */
.gate-k{font-family:var(--mono);font-size:.54rem;letter-spacing:.26em;text-transform:uppercase;
  color:var(--echo);margin:0 0 10px;text-align:center}

/* The mark, standard on every card of this kind. Centred, above the kick. */
.emark{position:relative;display:block;width:132px;margin:0 auto 16px;color:var(--echo);
  filter:drop-shadow(0 0 16px color-mix(in srgb,var(--echo) 42%,transparent))}
.emark svg{display:block;width:100%;height:auto}
.gate-card h2{font-family:var(--head);font-weight:800;text-transform:uppercase;letter-spacing:.02em;
  font-size:1.25rem;margin:0 0 10px;color:var(--text)}
.gate-card p{font-size:.88rem;line-height:1.65;color:var(--muted);margin:0 0 8px}
.gate-card p b{color:var(--text)}
.gate-card .erow{justify-content:center;margin-top:16px}
@media (max-width:640px){ .gate-note{padding-top:34px} .gate-card{padding:24px 20px} }

/* ── gated hero ────────────────────────────────────────────────────────────
   The hero KEEPS its kick, title and description on a gated page - that is what
   identifies the page, and the gate card below says nothing about it. Only the
   button row goes, because "ECHO Club home" and "Member access" are the same
   options the card underneath already offers.

   In their place, top-right, the same .xetch that closes every expanding panel
   here - leading back to the landing page. Anchored the way .sd-card anchors
   its own close, so the control sits where a close always sits. */
.ehero.gated{padding-right:74px}
.ehero.gated > .xetch{position:absolute;top:16px;right:16px}
@media (max-width:640px){
  .ehero.gated{padding-right:20px}
  .ehero.gated > .xetch{top:12px;right:12px}
}

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

/* ── portfolio / officer card ───────────────────────────────────────────────
   A member's card: mark, name + role, an animated stat row (rank badges), and
   verifiable credentials as cdk buttons — never raw hyperlinks. Reusable on
   People, Wargames & CTF, and anywhere a member is featured. */
/* Mark TOP-CENTRED, not beside the name (operator, 2026-08-10). Same anatomy as
   the detail card: the panel announces WHOSE it is before it says anything about
   them. Side-by-side, the mark competed with the name for the same eye-line and
   the card read as a row rather than an identity. */
.pcard{position:relative;display:grid;grid-template-columns:1fr;justify-items:center;gap:16px;
  background:var(--panel);border:2px solid var(--line);
  padding:26px clamp(20px,4vw,34px);max-width:760px;
  clip-path:polygon(0 16px,16px 0,calc(100% - 16px) 0,100% 16px,
    100% calc(100% - 16px),calc(100% - 16px) 100%,16px 100%,0 calc(100% - 16px))}
.pcard::before{content:"";position:absolute;top:13px;left:13px;width:10px;height:10px;
  border-top:1.5px solid var(--edge);border-left:1.5px solid var(--edge);opacity:.9}
.pcard::after{content:"";position:absolute;bottom:13px;right:13px;width:10px;height:10px;
  border-bottom:1.5px solid var(--edge);border-right:1.5px solid var(--edge);opacity:.9}
.pcard-mark{width:104px;height:auto;margin:2px auto 0;
  filter:drop-shadow(0 0 14px color-mix(in srgb,var(--echo) 40%,transparent))}
.pcard-role{font-family:var(--mono);font-size:.58rem;letter-spacing:.22em;text-transform:uppercase;color:var(--echo);margin:0 0 6px;text-align:center}
.pcard-name{font-family:var(--head);font-weight:800;text-transform:uppercase;font-size:1.5rem;color:var(--text);margin:0 0 8px;line-height:1.05;text-align:center}
.pcard-tag{font-family:var(--mono);font-size:.62rem;letter-spacing:.04em;color:var(--muted);margin:0 auto 18px;line-height:1.6;text-align:center;max-width:52ch}
.pstats{display:flex;flex-wrap:wrap;gap:10px;margin:0 0 16px;justify-content:center}
.pstat{position:relative;background:var(--bg2);border:2px solid var(--line);padding:9px 14px 8px;
  clip-path:polygon(0 7px,7px 0,100% 0,100% calc(100% - 7px),calc(100% - 7px) 100%,0 100%);overflow:hidden}
.pstat .pv{font-family:var(--head);font-weight:800;font-size:1.35rem;color:var(--echo);line-height:1;
  display:block;text-shadow:0 0 12px color-mix(in srgb,var(--echo) 40%,transparent)}
.pstat .pl{font-family:var(--mono);font-size:.5rem;letter-spacing:.14em;text-transform:uppercase;color:var(--dim);margin-top:4px;display:block}
.pstat::after{content:"";position:absolute;left:-101%;bottom:0;height:2px;width:100%;
  background:linear-gradient(90deg,transparent,var(--echo),transparent);animation:pscan 2.6s ease-in-out infinite}
@keyframes pscan{0%{left:-101%}55%,100%{left:101%}}
.pcreds{display:flex;flex-wrap:wrap;gap:8px;justify-content:center}
.pcard-body{width:100%}
/* the card is one centred column at every width now, so narrow only shrinks the mark */
@media (max-width:560px){.pcard-mark{width:78px}}
@media (prefers-reduced-motion:reduce){.pstat::after{animation:none;display:none}}

/* ── profile card: motion ───────────────────────────────────────────────────
   The mark tears like the boot mark (same TV-glitch idiom), the stat tiles
   carry a live scan, and a session/uptime tick marks the card as instrumented
   rather than printed. */
.pcard-mark{position:relative;display:block}
.pcard-mark svg{display:block;width:100%;height:auto}
.tvshard{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
  pointer-events:none;mix-blend-mode:screen}
.tvshard svg{display:block;width:100%;height:auto}
/* The sheen gradient. Tokenised rather than literal for the same reason as every
   other glow in this file - the roll is the accent passing over the mark, so it
   has to be the accent. Note this exact gradient is hand-copied in four other
   places (index, members, two blog posts); this is the copy that follows amber. */
.tvroll{position:absolute;left:-6%;right:-6%;height:8px;pointer-events:none;
  background:linear-gradient(180deg,transparent,
    color-mix(in srgb,var(--echo) 16%,transparent) 35%,
    color-mix(in srgb,var(--text) 28%,transparent) 50%,
    color-mix(in srgb,var(--echo) 16%,transparent) 65%,transparent);
  animation:tvRoll var(--rolld,420ms) linear both}
@keyframes tvRoll{from{top:-14%}to{top:104%}}
.tvslip{animation:tvSlip var(--tvd,300ms) steps(1,end) both}
@keyframes tvSlip{
  0%{transform:translateY(0)}
  18%{transform:translate(var(--tvx,3px),var(--tvy,-5px))}
  36%{transform:translate(calc(var(--tvx,3px) * -1),calc(var(--tvy,-5px) * -0.6))}
  54%{transform:translateY(var(--tvy,-5px))}
  72%{transform:translate(var(--tvx,3px),0)}
  100%{transform:translateY(0)}}

/* the live tick: a small instrument readout under the mark */
.pclock{display:flex;align-items:center;gap:7px;justify-content:center;margin:6px 0 2px;
  font-family:var(--mono);font-size:.46rem;letter-spacing:.16em;text-transform:uppercase;color:var(--dim)}
.pclock .pdot{width:6px;height:6px;background:var(--echo);border-radius:50%;
  box-shadow:0 0 8px var(--echo);animation:pblip 2s ease-in-out infinite}
@keyframes pblip{0%,100%{opacity:.3}50%{opacity:1}}
.pclock .pt{color:var(--echo);font-variant-numeric:tabular-nums}

/* stat tiles: hover lifts and the value brightens */
.pstat{transition:border-color .18s,transform .22s cubic-bezier(.2,.7,.3,1)}
.pstat:hover{border-color:var(--echo-deep);transform:translateY(-3px)}
.pstat:hover .pv{text-shadow:0 0 18px color-mix(in srgb,var(--echo) 80%,transparent)}
.pcreds .ebtn{transition:transform .22s cubic-bezier(.2,.7,.3,1),color .16s,background .16s,border-color .16s}
@media (prefers-reduced-motion:reduce){
  .tvshard,.tvroll{display:none}.tvslip{animation:none!important}
  .pclock .pdot{animation:none}
}

/* ── xetch: THE close control for anything that expands ───────────────────────
   Standing rule - every expanding panel gets one, never ask. It was written into
   members.html and blog.html separately; canonical copy lives here now so a new
   panel anywhere inherits it instead of growing a fourth variant. Pages that
   still carry their own copy override this harmlessly until they are cleaned. */
.xetch{position:relative;width:34px;height:34px;padding:0;flex:0 0 auto;cursor:pointer;
  background:var(--surf);border:2px solid var(--edge);transition:border-color .16s}
.xetch::before{content:"";position:absolute;top:4px;left:4px;width:8px;height:8px;
  border-top:1.5px solid var(--edge);border-left:1.5px solid var(--edge);
  transition:transform .22s cubic-bezier(.2,.7,.3,1)}
.xetch::after{content:"";position:absolute;bottom:4px;right:4px;width:8px;height:8px;
  border-bottom:1.5px solid var(--edge);border-right:1.5px solid var(--edge);
  transition:transform .22s cubic-bezier(.2,.7,.3,1)}
.xetch .xc{position:absolute;inset:0;display:flex;align-items:center;justify-content:center;
  font-family:var(--mono);font-size:.74rem;color:var(--echo);animation:qxbreathe 1.8s ease-in-out infinite}
.xetch .xc::before{content:"⣿"}
.xetch:hover{border-color:var(--edge)}
.xetch:hover::before{transform:translate(5px,5px)}
.xetch:hover::after{transform:translate(-5px,-5px)}
.xetch:hover .xc{animation:none}
.xetch:hover .xc::before{animation:qxdrain .6s steps(1,end) infinite}
@keyframes qxbreathe{0%,100%{opacity:.4}50%{opacity:1}}
@keyframes qxdrain{0%{content:"⣿"}20%{content:"⣷"}40%{content:"⣦"}60%{content:"⡄"}80%{content:"⡀"}100%{content:"⡀"}}

/* ── in-development veil ──────────────────────────────────────────────────────
   Frosted glass over content that exists but is not finished, with the reason
   said out loud on top of it. Shows there is something coming without pretending
   it is ready, and without the page going blank.

   The veil is not a security control and must never be used as one - the markup
   under it is fully present in the source. It is honesty about state, nothing
   more; anything genuinely private stays out of site/ entirely (see netlify.toml).

   inert would be the right attribute for the content beneath, but support is
   uneven, so the three things that matter are done by hand: pointer-events off,
   text unselectable, and aria-hidden so a screen reader is told the same story a
   sighted reader is, rather than reading out a draft nobody can see. */
/* Grid rather than absolute overlay, so the notice works in BOTH cases: over a
   tall veiled region it centres on the glass, and on a page with nothing built
   yet - where the veil has zero height - the row is sized by the notice itself
   and it simply sits in flow. Absolute positioning collapsed onto the hero and
   overlapped it on exactly those pages. */
.wip{display:grid}
/* A GLIMPSE, not the whole draft. Left uncapped the veiled region runs the full
   height of the unfinished page, which pushes the notice below the fold and makes
   the reader scroll through a screenful of blur to find out why. Capped and faded
   out at the bottom, so the notice sits in view and the blurred content reads as
   a hint of what is coming rather than a wall. */
/* Same 10-point raise as .gate-veil above - the two veils stay in step. */
.wip-veil{filter:blur(7px) saturate(.75);opacity:.6;pointer-events:none;user-select:none;
  -webkit-user-select:none;max-height:460px;overflow:hidden;
  -webkit-mask-image:linear-gradient(180deg,#000 45%,transparent 96%);
          mask-image:linear-gradient(180deg,#000 45%,transparent 96%)}
.wip-veil{grid-area:1/1}
.wip-note{grid-area:1/1;display:flex;align-items:center;justify-content:center;
  padding:24px 24px 8px;z-index:2}
.wip-card{position:relative;width:min(94%,520px);padding:26px 30px;text-align:center;
  background:linear-gradient(155deg,color-mix(in srgb,var(--surf2) 92%,transparent),
    color-mix(in srgb,var(--surf) 86%,transparent));
  -webkit-backdrop-filter:var(--glass-1);backdrop-filter:var(--glass-1);
  border:2px solid var(--line);
  box-shadow:0 26px 70px -30px #000;
  clip-path:polygon(0 14px,14px 0,calc(100% - 14px) 0,100% 14px,
    100% calc(100% - 14px),calc(100% - 14px) 100%,14px 100%,0 calc(100% - 14px))}
.wip-card::before{content:"";position:absolute;top:12px;left:12px;width:10px;height:10px;
  border-top:1.5px solid var(--edge);border-left:1.5px solid var(--edge);opacity:.9}
.wip-card::after{content:"";position:absolute;bottom:12px;right:12px;width:10px;height:10px;
  border-bottom:1.5px solid var(--edge);border-right:1.5px solid var(--edge);opacity:.9}
.wip-k{font-family:var(--mono);font-size:.54rem;letter-spacing:.26em;text-transform:uppercase;
  color:var(--scaffold);margin:0 0 10px;display:flex;align-items:center;justify-content:center;gap:9px}
.wip-k::before{content:"⣀⣤";color:var(--scaffold);opacity:.8}
.wip-card h2{font-family:var(--head);font-weight:800;text-transform:uppercase;letter-spacing:.02em;
  font-size:1.15rem;margin:0 0 10px;color:var(--text)}
.wip-card p{font-size:.86rem;line-height:1.65;color:var(--muted);margin:0 0 6px}
.wip-card p b{color:var(--text)}
@media (prefers-reduced-motion:reduce){.wip-veil{filter:blur(7px)}}

/* ── detail card ──────────────────────────────────────────────────────────────
   The expanding card used by sponsors.html and wargames.html. Built to the
   reference the operator picked (CTF Media/dd6.jpg): mark centred, kick and
   title centred under it, body left-aligned, stats, actions, xetch close.

   The "sd-" prefix started life as "sponsor detail" and now means SHARED detail
   - kept rather than renamed because renaming a working component across two
   pages buys nothing but risk.

   Detached: the markup is portaled to the end of <body> so no clip-path,
   backdrop-filter or transform ancestor can crop it. --acc carries the per-card
   accent; the frame never does.
   ── sponsor detail card ───────────────────────────────────────────────────
     (This paragraph carried its own comment-opener until 2026-08-21, nested
     inside this already-open one. CSS comments do not nest: the inner opener
     was inert text, the two blocks silently became one, and the file's
     delimiter counts still balanced - which is how it also concealed a
     genuinely broken block near .ecal-tbd.ctf that was missing an opener of its
     own. The delimiter is removed rather than the paragraph split, because the
     two halves describe one component.)

     Modelled on the reference card the operator picked (CTF Media/dd6.jpg): mark
     centred at the top, kick and title centred under it, body left-aligned,
     actions along the bottom, xetch close. Same anatomy as members.html's
   .cdetail, and a DETACHED modal for the same reason - it is portaled to the
     end of <body>, so no clip-path or backdrop-filter ancestor can crop it.

     Branding rides on one custom property, --acc. The mark and the kick take the
     sponsor's colour; the frame, the type and the buttons stay ECHO's, so five
     sponsors read as five guests in one house rather than five houses. Per the
     standing rule the border is never the accent. */
.sdetail{position:fixed;inset:0;z-index:80;display:flex;align-items:center;justify-content:center;
    padding:28px;background:color-mix(in srgb,var(--bg) 55%,transparent);
    -webkit-backdrop-filter:var(--glass-2);backdrop-filter:var(--glass-2)}
.sdetail[hidden]{display:none}
.sd-card{position:relative;width:min(92vw,600px);max-height:84vh;overflow:hidden;
    display:flex;flex-direction:column;
    /* PINNED, not var(--panel): the 2026-08-27 85% translucency pass thinned
       the panel token, but this is a detached modal card portaled over a
       position:fixed backdrop-filtered scrim - thinning it puts blurred page
       copy underneath modal body text. It keeps the pre-pass literals in both
       themes (the light branch is two rules down). */
    background:rgba(10,15,17,.72);border:2px solid var(--line);
    padding:26px 30px 62px;
    clip-path:polygon(0 14px,14px 0,calc(100% - 14px) 0,100% 14px,
      100% calc(100% - 14px),calc(100% - 14px) 100%,14px 100%,0 calc(100% - 14px));
    animation:sdIn .28s cubic-bezier(.4,0,.2,1) both}
  /* the pin's light half - without it the modal would take the light --panel
     and thin with the pass, or worse, keep the DARK literal on paper. */
  :root[data-theme=light] .sd-card{background:rgba(247,250,250,.78)}
  @keyframes sdIn{from{opacity:0;transform:translateY(9px)}to{opacity:1;transform:none}}
.sd-card::before{content:"";position:absolute;top:13px;left:13px;width:11px;height:11px;
    border-top:1.5px solid var(--edge);border-left:1.5px solid var(--edge);opacity:.9;pointer-events:none}
.sd-scroll{overflow:auto;min-height:0}

/* ── scrollbars ────────────────────────────────────────────────────────────
   Any panel that scrolls was showing the raw OS scrollbar - white track, grey
   thumb, arrow buttons - which reads as browser chrome sitting inside the
   panel rather than part of it. Restyled to the site's own idiom: a
   transparent track and a --line thumb that lifts to --edge on hover, no
   arrows.

   scrollbar-width/color are the standard properties (Firefox); the
   ::-webkit-* rules cover Chrome/Edge/Safari. Both are declared because
   neither is universal yet, and they agree on the colours. Applied by class,
   never to * - a global rule would also repaint the PAGE scrollbar, which
   belongs to the browser, not to us. */
.sd-scroll,.ecal-panel,.pvc-rail,.wwin,.enav-menu-inner,.display,.cd-card,.qd-card{
  scrollbar-width:thin;scrollbar-color:var(--line) transparent}
.sd-scroll::-webkit-scrollbar,
.ecal-panel::-webkit-scrollbar,
.pvc-rail::-webkit-scrollbar,
.wwin::-webkit-scrollbar,
.enav-menu-inner::-webkit-scrollbar,
.display::-webkit-scrollbar,
.cd-card::-webkit-scrollbar,
.qd-card::-webkit-scrollbar{width:8px;height:8px}
.sd-scroll::-webkit-scrollbar-track,
.ecal-panel::-webkit-scrollbar-track,
.pvc-rail::-webkit-scrollbar-track,
.wwin::-webkit-scrollbar-track,
.enav-menu-inner::-webkit-scrollbar-track,
.display::-webkit-scrollbar-track,
.cd-card::-webkit-scrollbar-track,
.qd-card::-webkit-scrollbar-track{background:transparent}
.sd-scroll::-webkit-scrollbar-thumb,
.ecal-panel::-webkit-scrollbar-thumb,
.pvc-rail::-webkit-scrollbar-thumb,
.wwin::-webkit-scrollbar-thumb,
.enav-menu-inner::-webkit-scrollbar-thumb,
.display::-webkit-scrollbar-thumb,
.cd-card::-webkit-scrollbar-thumb,
.qd-card::-webkit-scrollbar-thumb{background:var(--line);border:2px solid transparent;
  background-clip:padding-box}
.sd-scroll::-webkit-scrollbar-thumb:hover,
.ecal-panel::-webkit-scrollbar-thumb:hover,
.pvc-rail::-webkit-scrollbar-thumb:hover,
.wwin::-webkit-scrollbar-thumb:hover,
.enav-menu-inner::-webkit-scrollbar-thumb:hover,
.display::-webkit-scrollbar-thumb:hover,
.cd-card::-webkit-scrollbar-thumb:hover,
.qd-card::-webkit-scrollbar-thumb:hover{background:var(--edge);background-clip:padding-box}
/* the arrow buttons at each end are the most obviously-OS part of the default
   bar and have no site idiom to match - they go */
.sd-scroll::-webkit-scrollbar-button,
.ecal-panel::-webkit-scrollbar-button,
.pvc-rail::-webkit-scrollbar-button,
.wwin::-webkit-scrollbar-button,
.enav-menu-inner::-webkit-scrollbar-button,
.display::-webkit-scrollbar-button,
.cd-card::-webkit-scrollbar-button,
.qd-card::-webkit-scrollbar-button{width:0;height:0;display:none}
.sd-scroll::-webkit-scrollbar-corner,
.ecal-panel::-webkit-scrollbar-corner,
.display::-webkit-scrollbar-corner{background:transparent}

.sd-mark{display:block;width:92px;height:auto;margin:2px auto 16px;object-fit:contain}
  /* scoped to .sd-card, because .sd-card p below is (0,1,1) and would otherwise
     outrank a bare .sd-kick and repaint the accent grey - the same specificity
     trap that made primary buttons unreadable earlier today. */
.sd-card .sd-kick{font-family:var(--mono);font-size:.56rem;letter-spacing:.24em;text-transform:uppercase;
    color:var(--acc,var(--echo));text-align:center;margin:0 0 6px}
.sd-card h3{font-family:var(--head);font-weight:800;text-transform:uppercase;font-size:1.15rem;
    letter-spacing:.01em;text-align:center;margin:0 0 16px;color:var(--text)}
.sd-card p{font-size:.86rem;line-height:1.68;color:var(--muted);margin:0 0 14px}
.sd-card p b{color:var(--text)}
  /* stat tiles inside a sponsor card - the profile card's richness, which is the
     half of the model the operator asked for alongside the modal behaviour. The
     value takes the sponsor's accent; the label stays neutral. */
.sd-stats{display:flex;flex-wrap:wrap;gap:22px;margin:2px 0 18px;padding:14px 0;
    border-top:1px solid var(--line-soft);border-bottom:1px solid var(--line-soft)}
  /* the UA's [hidden]{display:none} is weaker than a class rule, so a sponsor
     with no stats still painted an empty row - visible as a stray rule under the
     title, because this element carries the divider borders. */
.sd-stats[hidden]{display:none}
.sd-stats .t{display:flex;flex-direction:column;gap:3px}
.sd-stats .v{font-family:var(--head);font-weight:800;font-size:1.35rem;line-height:1;
    color:var(--acc,var(--echo))}
.sd-stats .l{font-family:var(--mono);font-size:.52rem;letter-spacing:.16em;
    text-transform:uppercase;color:var(--dim)}
.sd-card h4{font-family:var(--mono);font-size:.54rem;letter-spacing:.22em;text-transform:uppercase;
    color:var(--muted);margin:20px 0 8px}
.sd-acts{display:flex;flex-wrap:wrap;gap:9px;margin-top:18px}
.sd-card .xetch{position:absolute;right:15px;bottom:15px}
  /* the member link is never hidden and never dead: signed out it says so and
     goes to the gate. A sponsors page is read by people who are not members yet,
     and that is exactly who should see that membership opens something. */
.sd-mem{opacity:.85}

/* Evidence shots inside a detail card. A placement claim is worth more with the
   scoreboard behind it, and this club's whole pitch is that its record is
   verifiable. Every one of these is CROPPED - the sources carry Discord invites,
   a wrong domain, a non-club email and other competitors' standings, none of
   which belong on the club's own site. The crop is the editorial decision. */
.sd-shot{display:block;width:100%;height:auto;margin:14px 0 4px;
  border:2px solid var(--line);background:var(--bg2);
  clip-path:polygon(0 10px,10px 0,calc(100% - 10px) 0,100% 10px,
    100% calc(100% - 10px),calc(100% - 10px) 100%,10px 100%,0 calc(100% - 10px))}
.sd-cap{font-family:var(--mono);font-size:.52rem;letter-spacing:.12em;text-transform:uppercase;
  color:var(--dim);margin:0 0 14px;text-align:center}

/* ── ECHO shared top-right controls: Sign In + Settings ─────────────────────
   Extracted from index.html's own #topctrl/#setpanel system so every page
   can include it via topctrl.js, not just the homepage. Top right, above
   everything, available at every stage including the boot - someone who
   wants motion off should not have to sit through it first. The panel's own
   open animation is named tcstep (not stepIn) because members.html defines
   its own local @keyframes stepIn that would otherwise collide by name. */
/* ── the corner cluster sits on a PLATE ─────────────────────────────────────
   MEASURED, because this had already been guessed at once. Resting labels are
   --dim on whatever pixel of assets/band-echo-nomark.jpg happens to land behind
   them, and #topctrl is fixed at top:14px - inside the 150px band - on every
   page, at every scroll position. Sampled off real 1400x900 captures: dark
   theme 1.27:1 median, 1.08:1 worst; light theme 1.22:1 median, 1.02:1 worst.
   WCAG AA for body text is 4.5:1. At 1.02 the label and the photograph behind
   it are the same colour. That is the entire discoverability problem - nobody
   was overlooking these two controls, they could not see them.

   WHY A PLATE AND NOT MORE INK. The family rule rests every .enav-link at
   --dim, and for the eight tabs that is legible because they sit on #echonav's
   OPAQUE var(--bg). The corner pair used to sit on that too. The band image
   landed behind them and took the surface away; the ink never changed. So the
   fix restores the missing precondition rather than fighting it: one surface
   under the cluster, and the same ink logic the bar has always used.

   THIS IS NOT THE OLD SLAB COMING BACK. What the block below records as
   deliberately removed was PER-BUTTON chrome - #setbtn a --bg2 slab of its own,
   #signinbtn a solid --echo fill with a glow and a lift - two controls speaking
   a private dialect at the bar they overlap. This is one CLUSTER-level surface
   carrying both, which is what makes them read as a pair distinct from the
   eight tabs, and which leaves every per-button state (hover, focus,
   .enav-link.on) exactly as the family declares it. Same argument one level up.

   Tokens are neutral throughout - var(--bg2) fill, a 1px var(--edge) hairline,
   the 7px corner chamfer #setpanel already uses so the buttons and the panel
   they drop read as one object. No accent frame. .enav-burger, the other corner
   control, already rests with a fill and a neutral frame for the same reason
   (see its audit note below): a control you must be able to find in a corner
   gets a surface, and that is an affordance, not decoration.

   The BACKGROUND lives on #topctrl, never on the buttons - #topctrl is an ID,
   so `background` on #topctrl .enav-link (1,1,0) would out-specify
   .enav-link.on's 9% echo wash (0,2,0) and kill the toggled Settings state.
   Same cascade trap the removed pulse was written around; the container dodges
   it outright.

   REMOVED WITH THIS: @keyframes ctrlbreathe, a 3.4s infinite opacity breath on
   an ::after wash, added 2026-08-19 against the same complaint. It failed twice
   over. It was an 11% --echo tint over a photograph that is already teal, so it
   moved the measured ratio by nothing - the numbers above were sampled WITH it
   running. And an animation that starts on load, runs longer than five seconds
   and has no pause control is a WCAG 2.2.2 failure; the band already spends its
   one flourish on the looping .navbrl braille line. Deleting it removes a live
   violation, and the plate does the job it was reaching for, statically, on
   every visit rather than as motion the eye has to catch. */
#topctrl{position:fixed;top:14px;right:14px;z-index:60;display:flex;align-items:center;gap:0;
  padding:2px;background:var(--bg2);border:1px solid var(--edge);
  clip-path:polygon(0 7px,7px 0,100% 0,100% calc(100% - 7px),calc(100% - 7px) 100%,0 100%)}
/* The pair reads as TWO controls, in the vocabulary .seg already uses for a
   segmented row: a 1px neutral rule between them. Drawn on the second button's
   own resting border - which the family rule already declares as 1px
   transparent - so this adds no box and no frame, only the one edge. It is
   --line in every state the family sets (hover and .on both resolve
   border-color to --line), so it cannot flicker as a state changes. */
#topctrl .enav-link+.enav-link{border-left-color:var(--line)}
/* Resting ink, and ONLY resting ink. --dim on the plate measures 2.90:1 dark /
   2.91:1 light, which is still under AA - the surface alone was not enough, the
   label had to come up one step too. --muted lands 6.22:1 dark and 5.55:1
   light, and is a token this family already uses (.enav-link.locked:hover).
   The :not() chain is what keeps this to ONE rule: it matches only in the
   resting state, so hover -> --text, focus-visible -> --text and .on -> --echo
   are left to the family rules that already declare them, with nothing
   restated here to drift out of step. */
#topctrl .enav-link:not(.on):not(:hover):not(:focus-visible){color:var(--muted)}
/* Same shape for the tick: present at rest (.55 reads as a smudge on a plate)
   without touching the hover/focus lift to full --echo. */
#topctrl .enav-link:not(:hover):not(:focus-visible):not(.on) .enav-b{opacity:.9}
/* :not(.on) matters - without it this (1,3,0) selector beat .enav-link.on .enav-b's
   (0,3,0) and held the tick at .9 while the control was ACTIVE, quietly undoing the
   one part of the toggled state that does not depend on colour. */
/* The ticks stay in the CORNER below 1180px, where the bar's own go away. That
   rule exists because eight labelled links plus eight glyphs stop fitting one
   row - a crowding problem this fixed two-item cluster does not have. Keeping
   them means the corner looks identical at 1400 and at 700, which matters more
   here: 1180 is just above the 1040 burger breakpoint, so without this the
   cluster would shed its marks at exactly the widths where it is one of only
   three controls left on screen. */
@media (max-width:1180px){#topctrl .enav-link .enav-b{display:inline}}
/* ── Sign In + Settings are NAV LINKS that happen to live in the corner ─────
   Both buttons now carry class="enav-link" (see topctrl.js's HTML string), so
   the family rule ~230 lines up owns their entire vocabulary and none of it is
   restated here: var(--mono) at clamp(.46rem,.72vw,.56rem), the same
   letter-spacing ramp, uppercase, padding 7px clamp(5px,.8vw,11px), a 1px
   TRANSPARENT resting border; hover to --text with a --line frame; the .enav-b
   glyph lighting to --echo with brlflash; and #setbtn.on landing on
   .enav-link.on - the 9% echo wash, --echo label, --line frame that nav.js
   already marks the current page with. Parity is structural now rather than
   copied, so the two cannot drift apart again.

   TWO DELTAS from the bar, both owned by the plate block above and neither
   restated here: the resting ink is --muted rather than --dim, and the tick
   does not go away at 1180px. Both exist for one reason - the corner has a
   photograph behind it and the bar does not. Measurements are in that block.

   What this replaced, for the record: #setbtn was a --bg2 slab with a 2px
   --line frame and a 4px chamfer, and #signinbtn was a solid --echo fill with
   --bg text, a 2px --echo frame, a two-stop teal glow, a --ping hover swap and
   a translateY(-2px) lift - written when the operator said "the setting button
   looks dark and sad". The answer to a sad button turned out to be the nav's
   own resting state rather than a louder one: these two controls sit over the
   bar on every page, and they were the only things on the site speaking a
   private dialect at it.

   Everything below is only what a <button> needs that an <a> does not, plus
   the label's anti-jitter floor. */
#signinbtn,#setbtn{-webkit-appearance:none;appearance:none;cursor:pointer;margin:0}
/* Keyboard parity used to be declared HERE, on these two IDs alone, on the
   reasoning that "the bar's links only ever need :hover". That was wrong twice
   over - the eight tabs are links a Tab key lands on too - so :focus-visible
   moved up into the family rule beside .enav-link:hover and now covers all ten
   controls identically. Nothing replaces it here on purpose: an ID rule would
   re-introduce exactly the corner-vs-bar split this block exists to close.
   (The braille half of it went with it: the tick's brlflash and the label's
   resolve are both family-level now.) */
/* ── the burger, audited against the same family ────────────────────────────
   .enav-burger (nav.js, inside .enav-inner) was checked against .enav-link
   when the two controls above were brought into line. Verdict: it is already
   a family member and KEEPS its one divergence deliberately.

   Already in the family: a 1px border, not the 2px the old #setbtn wore;
   neutral --surf / --edge tokens, so no accent frame and no standing-rule
   break; and a hover that lights its marks to --echo, which is exactly the
   .enav-b glyph move the labelled members make.

   The divergence, kept: it rests with a visible fill and frame where a
   .enav-link rests transparent. It is the only control in the bar with NO
   text label - three bare spans with nothing around them is not a button, it
   is a texture - and below 1040px, where .enav-links is display:none, it is
   the sole way into the site. The frame is that control's affordance, not
   decoration, so parity stops there. Worth naming that this is the width
   band where it sits directly under #signinbtn/#setbtn in the same corner
   (#topctrl is fixed at top:14px, the bar's row is below it), so the
   difference is visible rather than theoretical - and it is still the right
   call, because those two carry their own labels and this one cannot.

   The one real defect the audit did find is fixed here: the block above
   makes keyboard parity part of family membership, and the burger is a
   <button> a Tab key lands on that had :hover and nothing else. Focus now
   repeats hover, same as the two above. No reduced-motion guard is needed -
   this is a transition, not the brlflash animation. Placed in this block
   rather than beside .enav-burger's own rules to keep the whole
   family-parity pass in one reviewable place. */
.enav-burger:focus-visible span{background:var(--echo)}
/* The anti-jitter floor that used to be hard-coded here as `7ch + 7 steps of
   tracking` for "Sign In" alone is now the family rule `.enav-link .enav-t`,
   parameterised by --n. With --n:7 it computes the identical value, so this
   rule is deleted rather than reconciled - one width floor for all ten
   controls, and no ID-specificity copy to fall out of step with it. The
   .signin-label class stays on the span (index.html's own script comments
   name it); it just no longer carries geometry. */
#setpanel{position:fixed;top:48px;right:14px;z-index:60;width:210px;background:var(--bg2);
  border:2px solid var(--line);padding:14px 15px 15px;display:none;
  clip-path:polygon(0 7px,7px 0,100% 0,100% calc(100% - 7px),calc(100% - 7px) 100%,0 100%)}
#setpanel.on{display:block;animation:tcstep .22s cubic-bezier(.4,0,.2,1) both}
@keyframes tcstep{0%{opacity:0;transform:translateY(-5px)}100%{opacity:1;transform:none}}
#setpanel h4{font-family:var(--mono);font-size:.52rem;letter-spacing:.18em;text-transform:uppercase;
  color:var(--scaffold);margin:0 0 8px;font-weight:400}
#setpanel h4+.seg{margin-bottom:15px}
.seg{display:flex;gap:1px;background:var(--line-soft);border:2px solid var(--line)}
.seg button{flex:1;font-family:var(--mono);font-size:.53rem;letter-spacing:.1em;
  text-transform:uppercase;cursor:pointer;background:var(--bg2);color:var(--dim);border:0;
  padding:7px 4px;transition:color .14s,background .14s}
.seg button:hover{color:var(--text)}
.seg button.on{color:var(--echo);background:var(--surf)}
@media (prefers-reduced-motion:reduce){#setpanel.on{animation:none}}
/* ── the calendar ──────────────────────────────────────────────────────────
   The month grid, shared by the homepage mini calendar (index.html #calendar)
   and the full semester calendar (events.html #calendar). Data comes from
   calendar-data.js; each page has its own small IIFE that draws into these
   classes. Page-specific deltas (the mini's tighter cells, the record line)
   stay in that page's own <style>.

   Idiom notes, so this does not drift:
   - Event days are marked with a BRAILLE DENSITY RAMP, not a dot and not a
     coloured frame: one event is a light cell, four is a full cell. Same
     vocabulary as brlrise/brlflash further up this file.
   - "Today" is a NEUTRAL --edge outline plus a teal day number. Standing rule,
     no accent-coloured borders: the state lives in the number, not the box.
   - Past days dim rather than disappear, the same move .row.past makes on the
     landing page - the calendar is a record as well as a schedule.
   - CTF days carry a DOT STIPPLE plus a dotted run rail (.ecal-d.ctf, below).
     That texture is the non-colour channel: nothing on this grid may depend on
     hue alone to be readable.                                                 */

.ecal-months{display:grid;gap:14px;
  grid-template-columns:repeat(auto-fit,minmax(238px,1fr))}
.ecal-month{position:relative;background:var(--panel);border:2px solid var(--line);
  padding:14px 14px 12px;
  clip-path:polygon(0 12px,12px 0,calc(100% - 12px) 0,100% 12px,
    100% calc(100% - 12px),calc(100% - 12px) 100%,12px 100%,0 calc(100% - 12px))}
/* The month a visitor is standing in still reads first, and still without a
   coloured frame. It used to do that with a lighter SURFACE, which is no longer
   available now that every panel shares one surface token - so it borrows the
   device "today" already uses two rules down: a neutral --edge outline. Same
   idiom, same standing rule, the state is still not carried by frame colour. */
.ecal-month.now{border-color:var(--edge)}

.ecal-mh{display:flex;align-items:baseline;gap:9px;margin:0 0 10px}
.ecal-mn{font-family:var(--head);font-weight:800;text-transform:uppercase;
  letter-spacing:.02em;font-size:.95rem;color:var(--text)}
.ecal-my{font-family:var(--mono);font-size:.54rem;letter-spacing:.16em;color:var(--dim)}
.ecal-mc{margin-left:auto;font-family:var(--mono);font-size:.52rem;letter-spacing:.14em;
  text-transform:uppercase;color:var(--muted);white-space:nowrap}

.ecal-dow{display:grid;grid-template-columns:repeat(7,1fr);gap:2px;margin-bottom:3px}
.ecal-dow span{font-family:var(--mono);font-size:.5rem;letter-spacing:.08em;
  text-transform:uppercase;color:var(--dim);text-align:center;padding:2px 0}

.ecal-grid{display:grid;grid-template-columns:repeat(7,1fr);gap:2px}
/* every cell is the same box whether it is a button or not, so the grid never
   shifts when a day gains an event */
.ecal-d{position:relative;display:flex;flex-direction:column;align-items:center;
  justify-content:center;gap:1px;aspect-ratio:1/1;min-height:30px;padding:0;margin:0;
  background:var(--surf);border:1px solid transparent;
  font-family:var(--mono);font-size:.62rem;line-height:1;color:var(--muted);
  -webkit-appearance:none;appearance:none;text-align:center}
.ecal-d.out{background:transparent;border-color:transparent}
.ecal-d.past{opacity:.45}
/* today: neutral outline, teal number. No accent border. */
.ecal-d.today{border-color:var(--edge)}
.ecal-d.today .ecal-n{color:var(--echo);font-weight:700}
/* a day with events is a real <button> */
.ecal-d.has{background:var(--surf2);color:var(--text);cursor:pointer;
  transition:background .16s,transform .18s cubic-bezier(.2,.7,.3,1)}
.ecal-d.has:hover{background:color-mix(in srgb,var(--ping) 14%,var(--surf2));
  transform:translateY(-1px)}
.ecal-d.has:focus-visible{outline:2px solid var(--echo-deep);outline-offset:2px}
.ecal-d.has[aria-pressed="true"]{background:color-mix(in srgb,var(--echo) 18%,var(--surf2));
  color:var(--text)}
.ecal-n{display:block}
/* the density mark: ⡀ ⣀ ⣤ ⣶ ⣿ by event count */
.ecal-b{display:block;font-size:.5rem;line-height:1;color:var(--echo);opacity:.9}
.ecal-d.past .ecal-b{color:var(--muted)}
/* a day that is ONLY conditional events reads amber, the site's instruction voice */
.ecal-d.cond .ecal-b{color:var(--warn)}

/* ── CTF days ──────────────────────────────────────────────────────────────
   The club's competitions are its headline and they were reading as ordinary
   calendar days: Aug 21-23 (BrunnerCTF) looked exactly like Aug 25-27 (a
   campus resource fair). A CTF day now carries TWO devices, and neither of
   them is colour:

     ::before  a DOT STIPPLE over the cell - the identity. This is the
               non-colour channel: it is a luminance texture, so a CTF day is
               still obviously not a meeting day in greyscale, on a mono
               display, or to anyone who cannot separate teal from teal.
     ::after   a DOTTED RAIL along the bottom - the RUN. On a multi-day CTF the
               rail BRIDGES THE GRID GAP (left/right pull to -3px on a day that
               continues into its neighbour), so 21-22-23 reads as one thing
               with a start cap and an end cap rather than three unrelated
               marked days. A one-day CTF gets both caps and reads as a point.
               Which side joins is computed from the DATA (an event whose start
               is before this day / whose end is after it), never from grid
               position - so two overlapping CTFs join correctly and a run that
               wraps a week boundary resumes on the next row.

   Both are PSEUDO-ELEMENTS on purpose. .ecal-d.has swaps `background` on hover
   and on [aria-pressed="true"]; a texture set through the cell's own
   background would be clobbered by those states (or would clobber them).

   THE MOTION IS BOUNDED BY CONSTRUCTION, NOT BY A PAUSE BUTTON. WCAG 2.2.2
   exempts motion that stops inside five seconds, and this site has already
   shipped that mistake twice (people.html's ticker is a live violation;
   @keyframes ctrlbreathe was a 3.4s infinite breath, added 2026-08-19 and
   removed the same day). A calendar grid is no place for a pause control, so
   the rail instead does ONE staggered sweep along the run and stops: 520ms per
   cell, 100ms per day of stagger, the stagger index capped at 8 - worst case
   1.32s, and it never runs again. Note the static declaration IS the settled
   state; the keyframe only reveals it. That is what makes reduced-motion a
   one-line kill rather than a second code path.
   ONE flourish in this band. The stipple never animates: a CTF is identifiable
   from the first painted frame, mid-sweep or not.                            */
.ecal-d.ctf::before{content:"";position:absolute;inset:0;pointer-events:none;
  background-image:radial-gradient(var(--echo) .85px,transparent .95px);
  background-size:3px 3px;background-position:1px 1px;opacity:.28}
/* bottom:0, not bottom:3px - the rail sits on the cell's own edge so it reads as
   a track UNDER the day rather than a second mark competing with the braille
   density tick, which is what the middle of the cell is for. */
.ecal-d.ctf::after{content:"";position:absolute;left:2px;right:2px;bottom:0;
  height:3px;pointer-events:none;
  background-image:radial-gradient(circle,var(--echo) 42%,transparent 46%);
  background-size:3px 3px;
  animation:ctfrun .52s cubic-bezier(.25,.7,.3,1) both;
  animation-delay:calc(var(--ctfi,0) * .1s)}
/* the run bridges the 2px (small) / 3px (big) grid gap on the sides that continue */
.ecal-d.ctf.ctf-jl::after{left:-3px}
.ecal-d.ctf.ctf-jr::after{right:-3px}
/* A CTF the club has not qualified for yet keeps the amber voice, because a
   confident teal run would state a date the club does not hold.
   .ctf-cond, NOT .cond: those are different questions. .cond means the whole day
   is conditional; .ctf-cond means the COMPETITION on it is, which is what this
   texture is naming. Oct 26 is the case that separates them - the CyberHawks
   Finals (conditional) share the day with a campus event (not), so the day is
   not .cond but the run on it still must not read as settled. */
.ecal-d.ctf-cond::before{background-image:radial-gradient(var(--warn) .85px,transparent .95px)}
.ecal-d.ctf-cond::after{background-image:radial-gradient(circle,var(--warn) 42%,transparent 46%)}
@keyframes ctfrun{from{clip-path:inset(0 100% 0 0)}to{clip-path:inset(0 0 0 0)}}

/* An undated row - dashed, honest, never a day on the grid. There is more than
   one kind of them: the club's own monthly deep-dive, and SkillBit's monthly
   Flash CTF, whose cadence is published but whose dates are not.
   .ctf marks the competition one, and it wears the RUN RAIL rather than the
   day cells' stipple. A full-bleed stipple was tried first and there is no
   usable setting: the row is 22px of mostly TEXT, so ink that reads as texture
   behind a two-digit day number either vanishes at a 4px pitch or interferes
   with the label at a 3px one. Swapping the row's braille prefix for the rail
   says the same thing in the same vocabulary - it is literally the token the
   legend defines - costs the label nothing, and is one device, not two. */
.ecal-tbd{display:flex;align-items:center;gap:8px;margin-top:10px;padding:7px 9px;
  border:1px dashed var(--edge);font-family:var(--mono);font-size:.52rem;
  letter-spacing:.12em;text-transform:uppercase;color:var(--warn)}
.ecal-tbd + .ecal-tbd{margin-top:4px}
.ecal-tbd::before{content:"\28C0\2840";color:var(--echo);opacity:.55}
/* THE CTF TBD ROW TAKES THE RAIL AS ITS PREFIX, so a dated CTF and an undated
   one carry the same mark. This block was missing its opening delimiter -
   three lines of prose sat in the stylesheet as raw declarations, and the rule
   below them never applied: the SkillBit Flash CTF rows lost their rail on both
   calendars. Worth knowing how it hid. The file's comment-open and
   comment-close counts still balanced, because a second defect nearby (an
   opener nested inside an already-open comment) supplied the one this block
   lacked. Two errors cancelling in a count is exactly why a delimiter tally is
   not a parse - the engine's own rule list is, and that is what caught it.

   14px, not the legend's 24px: on the events.html grid these rows sit in a 196px
   card, and a wider swatch pushes "SkillBit Flash CTF — date TBD" onto a second
   line with "TBD" alone on it. Five dots is still unmistakably the rail. */
.ecal-tbd.ctf::before{content:"";width:14px;height:3px;flex:0 0 auto;opacity:1;
  background-image:radial-gradient(circle,var(--echo) 42%,transparent 46%);
  background-size:3px 3px}

/* the selected-day readout */
.ecal-panel{margin-top:12px;background:var(--panel);border:2px solid var(--line);
  padding:13px 15px;
  clip-path:polygon(0 10px,10px 0,calc(100% - 10px) 0,100% 10px,
    100% calc(100% - 10px),calc(100% - 10px) 100%,10px 100%,0 calc(100% - 10px))}
.ecal-panel[hidden]{display:none}
.ecal-pd{font-family:var(--mono);font-size:.54rem;letter-spacing:.16em;
  text-transform:uppercase;color:var(--dim);margin:0 0 9px}
.ecal-e{padding:10px 0;border-top:1px solid var(--line-soft)}
.ecal-e:first-of-type{border-top:0;padding-top:0}
.ecal-e:last-of-type{padding-bottom:0}
.ecal-et{font-family:var(--head);font-weight:700;text-transform:uppercase;
  letter-spacing:.02em;font-size:.95rem;color:var(--text)}
.ecal-em{display:flex;flex-wrap:wrap;gap:3px 16px;margin-top:5px;
  font-family:var(--mono);font-size:.56rem;letter-spacing:.1em;
  text-transform:uppercase;color:var(--muted)}
.ecal-em b{color:var(--echo);font-weight:500}
.ecal-ec{color:var(--warn)}
.ecal-en{margin-top:7px;font-size:.82rem;line-height:1.55;color:var(--muted);max-width:60ch}
.ecal-acts{display:flex;flex-wrap:wrap;gap:9px;margin-top:10px}

/* next-up / running-now callout */
.ecal-next{display:flex;flex-wrap:wrap;align-items:baseline;gap:5px 15px;
  margin-top:12px;padding:11px 13px;
  /* 85% surf - the one calendar surface the --panel token missed, named in the
     approved transparency lab (2026-08-27). Also carries index's #mcNext. */
  background:color-mix(in srgb,var(--surf) 85%,transparent);
  border:1px solid var(--line)}
.ecal-next[hidden]{display:none}
.ecal-nk{font-family:var(--mono);font-size:.5rem;letter-spacing:.2em;
  text-transform:uppercase;color:var(--dim)}
.ecal-next.live .ecal-nk{color:var(--echo)}
.ecal-nt{font-family:var(--head);font-weight:700;text-transform:uppercase;
  letter-spacing:.02em;font-size:.95rem;color:var(--text)}
.ecal-nd{font-family:var(--mono);font-size:.56rem;letter-spacing:.12em;
  text-transform:uppercase;color:var(--echo)}

/* legend, full view only */
.ecal-legend{display:flex;flex-wrap:wrap;gap:6px 20px;margin-top:14px;
  font-family:var(--mono);font-size:.52rem;letter-spacing:.12em;
  text-transform:uppercase;color:var(--dim)}
.ecal-legend b{color:var(--echo);font-weight:400}
.ecal-legend i{font-style:normal;color:var(--warn)}
/* a real swatch, not a word: the CTF line shows the dotted rail it is naming */
.ecal-legend em{display:inline-block;width:24px;height:3px;vertical-align:middle;
  font-style:normal;
  background-image:radial-gradient(circle,var(--echo) 46%,transparent 50%);
  background-size:4px 3px}

@media (prefers-reduced-motion:reduce){
  .ecal-d.has{transition:none}
  .ecal-d.has:hover{transform:none}
  /* the sweep is the only motion in this band, and it dies here. The rail's
     static declaration is already its settled state, so killing the animation
     leaves the full run drawn - no second code path, nothing to keep in sync. */
  .ecal-d.ctf::after{animation:none}
}
@media (max-width:640px){
  .ecal-months{grid-template-columns:1fr}
  .ecal-d{min-height:26px;font-size:.56rem}
  .ecal-b{font-size:.46rem}
}

/* ── vertical rotary wheels (.wheels / .wheelbox / .wtab) ──────────────────
   SHARED ENGINE. Lifted verbatim out of index.html's page-local <style> so a
   second page can carry a wheel without a second copy of the rules. The JS
   half is site/wheel.js. Nothing here is page-specific: layout of the wheel
   COLUMNS is left to each page (index.html uses the stock two-column .wheels;
   events.html overrides the columns under #all-events).

   As of this commit index.html still has its own identical local copy of this
   block, which simply overrides these rules with the same declarations - the
   page is untouched either way. The follow-up is a pure deletion; see the
   note at the head of wheel.js.

   Each entry sits on the rim of a drum that rotates on the X axis -
   rotateX(-theta) translateZ(R) per tab, on a drum pushed back by
   translateZ(-R) so the centred tab lands at z=0. Entries above and below
   recede and fade; past the cutoff angle they are hidden outright rather than
   piling up behind the face.

   Not a CLOSED drum: a closed drum's radius formula (cardW/2)/tan(PI/n)
   collapses to 0 at n=2, and the Upcoming wheel can be that short. This one is
   clamped and non-looping, so it reads the same at 2 entries and at 14.

   Everything below the "3D mode" divider is gated behind .spun, which only the
   JS adds. Without JS - and under prefers-reduced-motion, where the JS returns
   early - the tabs stay a plain vertical list in normal flow. Same markup, no
   motion, nothing to keep in step. */
.wheels{display:grid;grid-template-columns:1fr 1fr;gap:24px;margin-top:22px;align-items:start}
.wheelcol{min-width:0}
.wheelhead{font-family:var(--mono);font-size:.62rem;letter-spacing:.2em;text-transform:uppercase;
  color:var(--dim);margin:0 0 10px;display:flex;align-items:center;gap:12px}
.wheelhead::after{content:"";flex:1;height:1px;background:linear-gradient(90deg,var(--line),transparent)}
.wheelhead .wn{flex:0 0 auto;color:var(--muted);letter-spacing:.14em}

.wheelbox{position:relative;overflow:hidden}
.wdrum{display:flex;flex-direction:column;gap:1px;background:var(--line-soft)}
.wtab{position:relative;display:grid;grid-template-columns:1fr auto;gap:4px 12px;
  align-items:center;width:100%;height:44px;padding:0 13px;text-align:left;
  background:var(--panel);
  border:2px solid var(--line);font:inherit;color:inherit;
  clip-path:polygon(0 8px,8px 0,100% 0,100% calc(100% - 8px),calc(100% - 8px) 100%,0 100%);
  transition:background .18s,border-color .18s}
.wtab .wt{font-family:var(--head);font-weight:700;text-transform:uppercase;letter-spacing:.02em;
  font-size:.82rem;line-height:1.15;color:var(--text);min-width:0;
  display:-webkit-box;-webkit-line-clamp:2;-webkit-box-orient:vertical;overflow:hidden}
/* the trailing status chip. index.html has a page-local .st used by other
   sections too; this scoped copy is byte-identical in its declarations and
   wins on specificity, so the shared wheel never depends on a page-local rule
   and index.html looks the same before and after it drops its copy. */
.wtab .st{font-family:var(--mono);font-size:.56rem;letter-spacing:.14em;text-transform:uppercase;
  border:2px solid var(--line);padding:3px 8px;color:var(--dim);white-space:nowrap;
  clip-path:polygon(0 4px,4px 0,100% 0,100% calc(100% - 4px),calc(100% - 4px) 100%,0 100%)}
.wtab .st.done{color:var(--muted)}
/* conditional / not-yet-dated entries read amber in the TEXT only - the frame
   stays the neutral --line every other tab carries */
.wtab .st.soft{color:var(--warn)}
/* Tabs that are real handles onto something carry the cursor; inert record
   tabs (index.html's Archive) carry none - the frame never promises a click
   that does not exist. */
button.wtab{cursor:pointer}
/* hover lifts the shared panel surface rather than swapping in a second,
   opaque one - the tab stays a member of the one-surface family */
button.wtab:hover{border-color:var(--edge);background:color-mix(in srgb,var(--surf3) 22%,var(--panel))}
.wtab:focus-visible,.wwin:focus-visible{outline:2px solid var(--echo);outline-offset:2px}
/* Past events dim per DATA, never per wheel position - and the dimming is
   mostly colour + desaturation, not opacity, so the centred past tab still
   reads as text rather than as a disabled control. */
.wtab.past{--w-past:.72;filter:saturate(.55)}
.wtab.past .wt{color:var(--muted)}
/* the centred tab lifts off the shared panel surface rather than swapping in a
   second, opaque one - same move as the hover state above, one step further */
.wtab.front{border-color:var(--edge);background:color-mix(in srgb,var(--surf3) 38%,var(--panel))}
.wtab.front.past{--w-past:.94;filter:saturate(.8)}
.wtab.front .wt{color:var(--text)}

/* ── 3D mode - added by wheel.js only ──────────────────────────────────── */
.wheelbox.spun .wwin{height:300px;perspective:1000px;perspective-origin:50% 50%;
  cursor:grab;user-select:none;-webkit-user-select:none;
  /* the drag axis is VERTICAL here, so touch-action:pan-y would hand every
     touch straight to page scroll and make the wheel undraggable on a phone.
     none is the trade: at 300px tall there is plenty of page left to scroll
     around it. */
  touch-action:none}
.wheelbox.spun.dragging .wwin{cursor:grabbing}
.wheelbox.spun .wdrum{display:block;gap:0;background:none;position:relative;height:100%;
  transform-style:preserve-3d;transform:translateZ(-138px)}
.wheelbox.spun .wtab{position:absolute;top:50%;left:0;right:0;margin-top:-22px;
  backface-visibility:hidden;-webkit-backface-visibility:hidden;
  opacity:calc(var(--w-o,1) * var(--w-past,1))}
/* the selected slot, marked once and standing still - etched corner brackets,
   the same idiom as .readout .k and .stage's own corners. The centre tab is
   never given an accent border; the mark carries the state, not the frame. */
.wsel{display:none}
.wheelbox.spun .wsel{display:block;position:absolute;left:0;right:0;top:50%;
  height:48px;margin-top:-24px;pointer-events:none;z-index:20}
.wsel i{position:absolute;width:9px;height:9px;border:2px solid var(--echo-deep)}
.wsel i:nth-child(1){top:0;left:0;border-right:0;border-bottom:0}
.wsel i:nth-child(2){top:0;right:0;border-left:0;border-bottom:0}
.wsel i:nth-child(3){bottom:0;left:0;border-right:0;border-top:0}
.wsel i:nth-child(4){bottom:0;right:0;border-left:0;border-top:0}
/* the footer describes a wheel, so it only exists when there is one. It is an
   ADJACENT-SIBLING rule: nothing may be inserted between .wheelbox and .wfoot. */
.wfoot{display:none}
.wheelbox.spun + .wfoot{display:flex;flex-wrap:wrap;align-items:baseline;gap:6px 14px;
  margin:10px 0 0;font-family:var(--mono);font-size:.56rem;letter-spacing:.12em;
  text-transform:uppercase;color:var(--dim)}
.wfoot .wpos{color:var(--echo);letter-spacing:.16em}
@media (max-width:760px){
  .wheels{grid-template-columns:1fr;gap:28px}
}
