/* ============================================================
   PSC · PUBLIC SITE — brand foundation (Phase-2 skeleton)
   Monochrome / chrome techno-brutalist, distilled from the
   under-construction holding page (under-construction/index.html).

   Deliberately PLAIN to start — no Matrix-green accents (those
   are admin-only, assets/admin.css). Add flourishes slowly.

   All theming lives in :root. No external requests (system fonts
   only — the host CSP is 'self'-tight and external fonts have
   tripped it before). Retain that constraint.
   ============================================================ */

:root {
  /* palette — monochrome / chrome (mirrors the holding page) */
  --bg:        #0a0a0a;   /* near-black canvas            */
  --bg-2:      #121212;   /* panel / inset                */
  --bg-3:      #171717;   /* raised card                  */
  --plate:     #e9e6df;   /* bone plate (stamps, CTAs)    */
  --ink:       #f4f2ed;   /* primary text (bone-white)    */
  --ink-dim:   #8c8a85;   /* secondary / labels           */
  --ink-faint: #4a4845;   /* hairlines, registration      */
  --line:      #2a2a2a;   /* structural rules             */
  --rule-text: var(--ink-faint);   /* section dividers on text-only pages (About, Visit, legal), 2026-09-28 */
  --accent:    #d8d4cb;   /* chrome highlight             */

  /* type — FACE STACKS */
  --grotesk: "Helvetica Neue", Helvetica, Arial, "Arial Narrow", sans-serif;
  --mono: ui-monospace, "SFMono-Regular", "Cascadia Mono", Consolas, "Liberation Mono", monospace;

  /* type — ROLES. Added 2026-09-13.
     -------------------------------------------------------------------------------------
     Every font-family in this file now names a ROLE rather than a face, and each role
     defaults to the face it already used — so this block is a no-op on the rendered page
     and exists purely to create the seams.

     ▶ WHY. Uriah, 2026-09-12: *"it would be cool if there was a section to play with them
     also ... I mean this for all types of text, each header, paragraphs, numbers, navbar
     etc."* A playground that can only swap `--grotesk` for another face swaps SIX decisions
     at once and can therefore only ever be judged as a whole. These six roles are exactly
     the axes he named, and they are independent: a formal serif body under an alternative
     display, or an approachable body with the bureaucratic numerals, are both one override.

     ▶ THE ROLES ARE NOT ARBITRARY. Each one is a place where changing the face changes what
     the text is DOING, not merely how it looks:
       --font-display  the wordmark and page titles (h1) — the only text carrying the chrome
                       gradient, and the only text under a fold contract
       --font-heading  h2 — the section marker, and the heading a reader actually scans for
       --font-subhead  h3/h4 — subordinate headings, split from h2 on 2026-09-13 at Uriah's
                       request (*"I want to be able to choose between H1, H2, p and anything
                       else independantly of each other"*). They are genuinely a different
                       job: an h2 is a chapter, an h3 is a label inside one, and a face that
                       carries the first can be too loud for the second.
       --font-body     running prose — the one role where readability beats character
       --font-label    mono eyebrows, meta, pills, table headers — the "classified" voice
       --font-nav      navigation — read as targets, not as sentences
       --font-numeral  prices, counters, clause numbers — where tabular figures matter

     ▶ THE ZERO-EXTERNAL-REQUESTS RULE STILL HOLDS, and that is not a technicality. The
     header of this file says "no external requests (system fonts only — the host CSP is
     'self'-tight and external fonts have tripped it before)". Uriah waived that on
     2026-09-13 to allow a real typeface. It turned out not to need waiving: the faces are
     SELF-HOSTED under /fonts/, which `font-src 'self'` already permits (api/lib/http.php),
     so there is still no third-party request and no CSP relaxation. What changed is that
     the site may now make a same-origin request for a font; what did not change is that it
     never talks to anyone else. See docs/typography.md. */
  --font-display: var(--grotesk);
  --font-heading: var(--grotesk);
  --font-subhead: var(--font-heading);   /* follows h2 unless overridden on its own */
  --font-body:    var(--grotesk);
  --font-label:   var(--mono);
  --font-nav:     var(--mono);
  --font-numeral: var(--mono);

  /* rhythm */
  /* ▶ THE PAGE FRAME IS OFF, 2026-09-14, at the client's request — they feel the site reads as
     more premium without the box around it. TO RESTORE IT, put the clamp back here and the
     border back on .shell below; nothing else needs touching, and the two are commented as a
     pair so neither is found without the other.
     Was: clamp(10px, 2.5vw, 26px) */
  --frame: 0px;                        /* decorative page frame inset — disabled */
  --gutter: clamp(16px, 5vw, 40px);    /* content side padding        */
  --wrap: 1100px;                      /* max content width           */
  --prose: 68ch;                       /* readable text column        */
  color-scheme: dark;                  /* dark native controls (inputs/selects) */
}

/* ---- effect parameters (--fx-*) --------------------------------------------------------
   The knobs a human actually tunes, pulled out of the keyframes and gradients that consume
   them. Two reasons, and the second is the one that matters:

     1. A value buried inside a keyframe or a gradient is invisible — you cannot find it
        without reading the rule, and you cannot change it without editing the rule.
     2. @property TYPES them. The browser then validates them, interpolates them, and
        serialises them canonically — so a value read back out is exactly the value that
        would be pasted in. That is the round-trip guarantee a slider needs.

   ONLY the effects touched by the 2026-08-28 polish pass are parameterised. This is
   deliberately not a repo-wide retrofit: the convention spreads by being used, and a large
   mechanical diff with no visual payoff would make that pass unreviewable.

   The FORMAT of these names is owned elsewhere — the --fx-<effect>-<param> convention, the
   @property registration and the relation blocks are specified in Tweaker's own repo
   (docs/effect-format.md), because Tweaker is the consumer and therefore the arbiter. This
   file is a consumer of that format, not its definition. If the two ever disagree, THIS FILE
   WINS: PSC is a client deliverable and Tweaker is a tool that adapts to it.
   ------------------------------------------------------------------------------------- */

/* — chrome shimmer + CRT flicker (.wordmark, .page-title) — */
@property --fx-shimmer-rate  { syntax: "<time>";   inherits: true; initial-value: 7s;   }
@property --fx-flicker-rate  { syntax: "<time>";   inherits: true; initial-value: 11s;  }
@property --fx-flicker-drift { syntax: "<time>";   inherits: true; initial-value: 23s;  }
/* Depth is how far a dip drops. 1 = the dip depths tuned on the holding page; lower is
   subtler. Readability floor: the deepest dip must never approach invisible. */
@property --fx-flicker-depth { syntax: "<number>"; inherits: true; initial-value: 1;    }
/* Per-element phase offset, consumed as a NEGATIVE animation-delay so an element starts
   mid-cycle instead of waiting. Any two elements sharing an effect should differ here. */
@property --fx-flicker-seed  { syntax: "<number>"; inherits: false; initial-value: 0;   }

/* — structural rules — */
/* How far a hairline fades in from each end before reaching full strength. 0% = the flat
   full-length rule this site had before 2026-08-28. */
@property --fx-rule-fade     { syntax: "<percentage>"; inherits: true; initial-value: 12%; }

/* ▶ INHERITANCE IS PART OF THE CONTRACT, not an afterthought (learned 2026-08-28).
   Every knob below that a person would want to set ONCE for the whole page is inherits: true.
   The first draft had the screen and glow knobs at inherits: false, and the consequence was
   silent and convincing: setting --fx-screen-sheen on :root changed nothing, because the
   overlay that consumes it never inherited the value — while contrast-check.py dutifully
   reported identical numbers at every sheen setting, which looked like "the sheen is free"
   rather than "the sheen is not applied".

   THE RULE: a knob is inherits: false ONLY if it is genuinely per-element. --fx-flicker-seed
   is the one real case here — every element needs its own, and inheriting a parent's seed
   would put them back in step, which is the exact bug the seed exists to prevent. */

/* — bloom / glow (.emblem, .btn--primary) — */
/* How far light spills past a bright object's edge, as a multiple of that object's own size
   where possible, so a bloom looks the same on a phone and a 4K monitor. */
@property --fx-glow-radius   { syntax: "<length>"; inherits: true;  initial-value: 18px; }
/* Resting strength. The interactive states multiply this rather than replacing it, so there
   is ONE value to turn down if the whole site ever reads too soft. */
@property --fx-glow-strength { syntax: "<number>"; inherits: true;  initial-value: 0.18; }

/* — hero / title text depth (.wordmark, .page-title) — */
/* WHY drop-shadow AND NOT text-shadow. The wordmark paints its chrome gradient with
   background-clip: text and -webkit-text-fill-color: transparent. A text-shadow under
   transparent fill shows THROUGH the glyphs — you see the shadow inside the letterforms,
   which looks like a printing fault. filter: drop-shadow() works off the element's rendered
   alpha instead, so it follows the actual glyph edges and sits behind them properly.

   The stack below is three glow layers plus one dark extrude. Three is not arbitrary: one
   tight layer reads as a hard edge-light, one mid layer carries the actual bloom, and one wide
   dim layer is the ambient halo that makes it look emitted rather than drawn. Collapsing them
   into one blur gives you a smudge; that is the difference between "glowing" and "blurry".

   COST: this is a filter, and the file's standing rule is that filters must not ANIMATE — a
   blur recomputed every frame is the expensive case. This one is STATIC and painted once. The
   existing flicker animates `opacity`, which applies AFTER the filter, so the glow dims with
   the text for free and nothing re-blurs. That is why the glow flickers without costing
   anything, and why you must not move these values into a keyframe. */
@property --fx-text-glow        { syntax: "<number>"; inherits: true; initial-value: 0.5;  }
@property --fx-text-glow-radius { syntax: "<length>"; inherits: true; initial-value: 14px; }
/* Spread multiplies the gap between the three layers. Low = a tight rim-light; high = a wide
   atmospheric halo. This is the knob that changes the CHARACTER of the glow rather than its
   amount, and it is the one most worth playing with. */
@property --fx-text-spread      { syntax: "<number>"; inherits: true; initial-value: 1;    }
/* Extrude: a hard dark offset down-right, no blur. This is what gives the letters physical
   depth — the sense of a solid object lit from upper-left, rather than a flat fill. Set to 0
   for pure glow with no body. Deliberately shares the emblem's light direction. */
@property --fx-text-depth       { syntax: "<length>"; inherits: true; initial-value: 0px;  }

/* ▶ WHY THE GLOW READ AS NEON AND NOT AS A CRT (Uriah, 2026-08-28: "the glowing text looks
   good but it doesn't look like it would be on a CRT screen any more").

   He is right, and there are three separate reasons — none of them is "too much glow":

   1. FALLOFF. A neon tube is a light source seen directly: a wide, smooth, even halo.
      Phosphor is a surface excited by a beam: very bright at the stroke, falling off FAST.
      --fx-text-tightness compresses the three layers' radii toward the glyph, which is the
      difference between "lit sign" and "lit screen". High tightness + high glow is the
      phosphor look; low tightness + high glow is the neon look that prompted this note.
   2. THE BEAM IS HORIZONTAL. A CRT paints in horizontal sweeps, so its light smears
      SIDEWAYS, not radially. --fx-text-smear adds that anisotropy.
   3. CONVERGENCE. The three electron guns never align perfectly, so edges carry a faint
      warm/cool fringe. --fx-text-fringe adds it. On a monochrome site this is the one place
      hue is allowed, because it reads as a screen artefact rather than as a colour choice —
      keep it under ~0.3 or it becomes a glitch effect, which is a different brand.

   And the fourth reason is not in the text at all: the scanlines have to be strong enough to
   CUT the glow. At 0.06 alpha they pass over it without marking it, so the text reads as
   floating above the screen rather than being made of it. Raise --fx-crt-scan-dark alongside
   any of these. The two effects are one effect. */
/* RASTER CUT (scanlines carved into .wordmark and .page-title): RETIRED 2026-09-29 with the CRT overlay.
   Its cut was 0.16px, too fine to draw; Uriah and the client chose no lines. docs/retired-work.md. */
@property --fx-text-tightness   { syntax: "<number>"; inherits: true; initial-value: 1;    }
@property --fx-text-smear       { syntax: "<number>"; inherits: true; initial-value: 0;    }
@property --fx-text-fringe      { syntax: "<number>"; inherits: true; initial-value: 0;    }

/* — the viewport as a screen (.crt-global): RETIRED 2026-09-29. The client asked for matte black, like
   the club. The knobs, the overlay and their history are in docs/retired-work/crt-overlay.css. — */

/* ---- tuning seams: the emblem and the chrome shimmer (2026-09-15) ------------------------
   ▶ BEFORE DESIGNING AN EFFECT, READ docs/device-capability-matrix.md. Several things that
     render correctly here do something else on a real handset — most sharply, the one iPhone
     we have tested renders NO BLUR at all on a masked layer, so `filter: blur()` and SVG
     `feGaussianBlur` both paint nothing while the layer's colour still lands. Every row in that
     document carries its evidence, and every row comes from ONE device.

   Uriah, 2026-09-15: *"add as many parameters as possible for the emblem so that I can tune
   that to try get a cool artifacty, moving shine and glow happening on it"* and *"controls for
   shimmer as well but more than just speed, direction and style"*.

   ▶ EVERY DEFAULT BELOW REPRODUCES WHAT SHIPPED BEFORE THIS BLOCK EXISTED. The artefact
     effects default to zero or `none`; the shimmer defaults restate the literals they replaced.
     So this block is a no-op on the rendered page until the switchboard moves a value, and a
     tuned value is promoted by writing it into the SWITCHBOARD TUNING block at the end of this
     file — never by editing these initial values.

   ▶ ONE DELIBERATE, NEAR-INVISIBLE CHANGE: the emblem sweep's easing is now a smoothstep
     computed in CSS instead of the `ease-in-out` keyword, because the rest-in-the-cycle share
     (--fx-emb-shim-travel) cannot be a keyframe selector — selectors do not take var(). The
     sweep animates one registered progress number and derives its position from it. The two
     curves differ by a few percent mid-sweep.

   ▶ EFFECTS THAT WOULD COST A LAYER ARE OFF BY `content`/`animation-name`, NOT BY A ZERO.
     A pseudo-element with a filter at opacity 0 is still a composited layer; one with
     `content: none` does not exist. Same pattern as --fx-crt-roll-anim above. */

/* — chrome shimmer on .wordmark / .page-title (the H9 sweep layer) — */
@property --fx-shimmer-angle { syntax: "<angle>";      inherits: true; initial-value: 100deg; }
/* How wide the gradient is against the word: larger = a longer, gentler sweep. */
@property --fx-shimmer-size  { syntax: "<percentage>"; inherits: true; initial-value: 220%;   }
/* Where the sweep starts and stops. Swap them to reverse without touching direction. */
@property --fx-shimmer-from  { syntax: "<percentage>"; inherits: true; initial-value: 200%;   }
@property --fx-shimmer-to    { syntax: "<percentage>"; inherits: true; initial-value: -20%;   }
/* — the H9 hot spot's path — */
@property --fx-hx-from { syntax: "<percentage>"; inherits: true; initial-value: 14%; }
@property --fx-hx-to   { syntax: "<percentage>"; inherits: true; initial-value: 78%; }
@property --fx-hy-from { syntax: "<percentage>"; inherits: true; initial-value: 34%; }
@property --fx-hy-to   { syntax: "<percentage>"; inherits: true; initial-value: 44%; }

/* — the emblem's shine (.intro-shimmer::after on the 3D mark) — */
@property --fx-emb-shim-rate     { syntax: "<time>";       inherits: true;  initial-value: 6.5s;  }
@property --fx-emb-shim-angle    { syntax: "<angle>";      inherits: true;  initial-value: 115deg; }
/* half-width of the band, and the fully-lit core inside it (0 = a point, the shipped look) */
@property --fx-emb-shim-width    { syntax: "<percentage>"; inherits: true;  initial-value: 8%;    }
@property --fx-emb-shim-core     { syntax: "<percentage>"; inherits: true;  initial-value: 0%;    }
@property --fx-emb-shim-strength { syntax: "<number>";     inherits: true;  initial-value: 0.9;   }
@property --fx-emb-shim-from     { syntax: "<percentage>"; inherits: true;  initial-value: 200%;  }
@property --fx-emb-shim-to       { syntax: "<percentage>"; inherits: true;  initial-value: -120%; }
/* share of each cycle spent moving; the rest is a pause (0.55 = the shipped 55%) */
@property --fx-emb-shim-travel   { syntax: "<number>";     inherits: true;  initial-value: 0.55;  }
/* 0 = linear, 1 = smoothstep (shipped) */
@property --fx-emb-shim-ease     { syntax: "<number>";     inherits: true;  initial-value: 1;     }
@property --fx-emb-shim-p        { syntax: "<number>";     inherits: false; initial-value: 0;     }

/* — the emblem's glow (.emblem::before) — multipliers on the shared --fx-glow-* — */
@property --fx-emb-bloom      { syntax: "<number>"; inherits: true; initial-value: 1;  }
@property --fx-emb-bloom-size { syntax: "<number>"; inherits: true; initial-value: 1;  }
@property --fx-emb-pulse      { syntax: "<number>"; inherits: true; initial-value: 0;  }
@property --fx-emb-pulse-rate { syntax: "<time>";   inherits: true; initial-value: 4s; }

/* — the emblem's artefacts (the resting .intro-mark) — */
@property --fx-emb-flicker      { syntax: "<number>"; inherits: true; initial-value: 0;    }
@property --fx-emb-flicker-rate { syntax: "<time>";   inherits: true; initial-value: 9s;   }
@property --fx-emb-jitter       { syntax: "<length>"; inherits: true; initial-value: 0px;  }
@property --fx-emb-jitter-rate  { syntax: "<time>";   inherits: true; initial-value: 5s;   }
@property --fx-emb-feather      { syntax: "<percentage>"; inherits: true; initial-value: 78%; }
@property --fx-emb-scan         { syntax: "<number>"; inherits: true; initial-value: 0;    }
@property --fx-emb-scan-period  { syntax: "<length>"; inherits: true; initial-value: 4px;  }
@property --fx-emb-scan-angle   { syntax: "<angle>";  inherits: true; initial-value: 0deg; }
@property --fx-emb-rgb          { syntax: "<number>"; inherits: true; initial-value: 0.5;  }
@property --fx-emb-rgb-split    { syntax: "<length>"; inherits: true; initial-value: 3px;  }
@property --fx-emb-rgb-hue      { syntax: "<angle>";  inherits: true; initial-value: -35deg; }
@property --fx-emb-glitch       { syntax: "<length>"; inherits: true; initial-value: 0px;  }
@property --fx-emb-glitch-rate  { syntax: "<time>";   inherits: true; initial-value: 6s;   }

:root {
  /* The string-valued seams. Unregistered, because a timing function, a blend mode or a whole
     gradient is not a type @property can express. */
  --fx-shimmer-ease: linear;
  --fx-shimmer-direction: normal;
  --fx-shimmer-blend: overlay;
  --fx-shimmer-ramp: #6f6f6f 0%, #f7f7f7 18%, #bdbdbd 34%, #ffffff 50%,
                     #9a9a9a 66%, #ededed 82%, #6f6f6f 100%;
  --fx-drift-ease: ease-in-out;
  --fx-drift-direction: alternate;

  --fx-emb-shim-direction: normal;
  --fx-emb-shim-blend: screen;
  --fx-emb-shim-shape: linear-gradient(var(--_emb-angle),
      transparent calc(50% - var(--_emb-width)),
      #000 calc(50% - var(--fx-emb-shim-core)), #000 calc(50% + var(--fx-emb-shim-core)),
      transparent calc(50% + var(--_emb-width)));
  --fx-emb-bloom-color: var(--accent);
  /* `""` switches the chromatic ghost layer on; `none` means it does not exist at all */
  --fx-emb-rgb-on: none;
  --fx-emb-rgb-blend: screen;
}

/* ---- emblem: natural motion and contour glows (2026-09-15, second pass) --------------------
   Uriah: *"with the shine and glow, they feel very one dimensional, too regular, and the glow is
   just a simple circle ... more natural, different timings like the glitches, and glows that
   radiate from the contours of the emblem"*.

   ▶ THREE IDEAS, ALL OFF (OR IDENTICAL TO BEFORE) AT THEIR DEFAULTS:
     1. CONTOUR GLOWS. Extra layers carry the emblem's own frame (logo-intro.js copies it into
        --emb-img), thresholded and blurred, so light comes off the bright chrome shapes rather
        than out of a circle. Two of them (a tight rim and a wide halo) because one blur reads as
        a smudge and two read as light — the same reasoning as the heading's three-layer glow.
     2. EDGE GLOW. The frame differenced against a blurred copy of itself is an outline map:
        bright only where brightness CHANGES. Glowing that lights the contours and nothing else.
     3. IRREGULAR TIME. Every glow's opacity is a product of two waves with unrelated periods
        and irregular keyframe spacing, plus rare flares — so it never visibly loops. The shine
        is re-rolled by logo-intro.js at every pass (angle, width, speed, strength, the odd
        skipped pass) and surges and fades within a pass on two more waves.

   ▶ COST: a layer whose `display` is `none` does not exist. The blurs are STATIC filters on a
     still frame — painted once; only opacity moves. The re-roll is one style write per pass. */
@property --fx-emb-glow-a          { syntax: "<number>"; inherits: true; initial-value: 0.85; }
@property --fx-emb-glow-a-radius   { syntax: "<length>"; inherits: true; initial-value: 3px; }
@property --fx-emb-glow-a-spread   { syntax: "<number>"; inherits: true; initial-value: 1.01; }
@property --fx-emb-glow-a-x        { syntax: "<length>"; inherits: true; initial-value: 0px; }
@property --fx-emb-glow-a-y        { syntax: "<length>"; inherits: true; initial-value: 0px; }
@property --fx-emb-glow-a-breathe  { syntax: "<number>"; inherits: true; initial-value: 0.35; }
@property --fx-emb-glow-a-breathe-rate { syntax: "<time>"; inherits: true; initial-value: 5.3s; }
@property --fx-emb-glow-a-drift    { syntax: "<number>"; inherits: true; initial-value: 0.25; }
@property --fx-emb-glow-a-drift-rate { syntax: "<time>"; inherits: true; initial-value: 13.7s; }
@property --fx-emb-glow-a-flare    { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --fx-emb-glow-a-flare-rate { syntax: "<time>"; inherits: true; initial-value: 9.1s; }
@property --fx-emb-glow-b          { syntax: "<number>"; inherits: true; initial-value: 0.55; }
@property --fx-emb-glow-b-radius   { syntax: "<length>"; inherits: true; initial-value: 16px; }
@property --fx-emb-glow-b-spread   { syntax: "<number>"; inherits: true; initial-value: 1.06; }
@property --fx-emb-glow-b-x        { syntax: "<length>"; inherits: true; initial-value: 0px; }
@property --fx-emb-glow-b-y        { syntax: "<length>"; inherits: true; initial-value: 0px; }
@property --fx-emb-glow-b-breathe  { syntax: "<number>"; inherits: true; initial-value: 0.35; }
@property --fx-emb-glow-b-breathe-rate { syntax: "<time>"; inherits: true; initial-value: 8.9s; }
@property --fx-emb-glow-b-drift    { syntax: "<number>"; inherits: true; initial-value: 0.25; }
@property --fx-emb-glow-b-drift-rate { syntax: "<time>"; inherits: true; initial-value: 11.3s; }
@property --fx-emb-glow-b-flare    { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --fx-emb-glow-b-flare-rate { syntax: "<time>"; inherits: true; initial-value: 14.9s; }

@property --fx-emb-edge          { syntax: "<number>"; inherits: true; initial-value: 0.9; }
@property --fx-emb-edge-radius   { syntax: "<length>"; inherits: true; initial-value: 5px; }
@property --fx-emb-edge-spread   { syntax: "<number>"; inherits: true; initial-value: 1; }
@property --fx-emb-edge-x        { syntax: "<length>"; inherits: true; initial-value: 0px; }
@property --fx-emb-edge-y        { syntax: "<length>"; inherits: true; initial-value: 0px; }
@property --fx-emb-edge-breathe  { syntax: "<number>"; inherits: true; initial-value: 0.3; }
@property --fx-emb-edge-breathe-rate { syntax: "<time>"; inherits: true; initial-value: 6.7s; }
@property --fx-emb-edge-drift    { syntax: "<number>"; inherits: true; initial-value: 0.2; }
@property --fx-emb-edge-drift-rate { syntax: "<time>"; inherits: true; initial-value: 17.3s; }
@property --fx-emb-edge-flare    { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --fx-emb-edge-flare-rate { syntax: "<time>"; inherits: true; initial-value: 12.1s; }
/* ▶ THE EFFECTS WAIT FOR THE PAGE. Uriah, 2026-09-15: *"the bloom comes on instantly after the
   animation before things fade in, it should have a bit of a delay on it like the other elements
   so that it looks like it waits for everything else to be there before flickering and stuff"*.
   --emb-live runs 0 -> 1 when logo-intro.js adds .fx-live, which it does once the page reveal
   has finished plus --fx-emb-fx-delay. Every effect's strength is multiplied by it.

   ▶ THE CIRCLE GLOW IS NOT GATED BY DEFAULT, deliberately. logo-intro.css records why the resting
   bloom must be at full strength on the landing frame: fading it in was reported as "it stops,
   disappears and comes back". --fx-emb-bloom-hold (0 = today) lets that be tried anyway. */
@property --emb-live           { syntax: "<number>"; inherits: true; initial-value: 1; }
@property --emb-live-shim      { syntax: "<number>"; inherits: true; initial-value: 1; }
/* ▶ THE SHIMMER HAS ITS OWN GATE, 2026-09-19. Uriah: *"possibly make just the shimmer effects
   come in as soon as animation ends, but the blooms and glitches still wait the normal time."*

   `--emb-live` stays exactly as it was and still gates the blooms, flicker, jitter, colour split
   and glitch — nineteen call sites, none of them touched. Only the two SHINE rules read this one.
   Splitting it this way rather than renaming the original means the risky half of the change is
   zero lines: an effect moves between gates by editing the one `var()` that names it.

   Registered, not merely declared, because both gates are TRANSITIONED. An unregistered custom
   property has no type, so the browser cannot interpolate it and the fade would snap. */
@property --fx-emb-fx-fade     { syntax: "<time>";   inherits: true; initial-value: 1.2s; }
@property --fx-emb-bloom-hold  { syntax: "<number>"; inherits: true; initial-value: 0; }

/* glow colour, and a second soft spill of the same colour around it (haze) */
@property --fx-emb-glow-a-color    { syntax: "<color>";  inherits: true; initial-value: #ffffff; }
@property --fx-emb-glow-a-haze     { syntax: "<length>"; inherits: true; initial-value: 0px; }
@property --fx-emb-glow-a-haze-amt { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --fx-emb-glow-b-color    { syntax: "<color>";  inherits: true; initial-value: #e9e2ff; }
@property --fx-emb-glow-b-haze     { syntax: "<length>"; inherits: true; initial-value: 0px; }
@property --fx-emb-glow-b-haze-amt { syntax: "<number>"; inherits: true; initial-value: 0; }
@property --fx-emb-edge-color    { syntax: "<color>";  inherits: true; initial-value: #ffffff; }
@property --fx-emb-edge-haze     { syntax: "<length>"; inherits: true; initial-value: 0px; }
@property --fx-emb-edge-haze-amt { syntax: "<number>"; inherits: true; initial-value: 0; }

/* how far out the effect layers may paint before feathering to nothing */
@property --fx-emb-fx-feather    { syntax: "<percentage>"; inherits: true; initial-value: 86%; }

/* the second shine — its own timing, so two sweeps drift in and out of step */
@property --fx-emb-shine2          { syntax: "<number>";     inherits: true; initial-value: 0.6; }
@property --fx-emb-shine2-rate     { syntax: "<time>";       inherits: true; initial-value: 9.7s; }
@property --fx-emb-shine2-travel   { syntax: "<number>";     inherits: true; initial-value: 0.35; }
@property --fx-emb-shine2-angle    { syntax: "<angle>";      inherits: true; initial-value: 245deg; }
@property --fx-emb-shine2-width    { syntax: "<percentage>"; inherits: true; initial-value: 5%; }
@property --fx-emb-shine2-ease     { syntax: "<number>";     inherits: true; initial-value: 1; }
@property --fx-emb-shine2-from     { syntax: "<percentage>"; inherits: true; initial-value: 200%; }
@property --fx-emb-shine2-to       { syntax: "<percentage>"; inherits: true; initial-value: -120%; }

/* the main shine, within a pass: a surge in position and a flux in brightness */
@property --fx-emb-shim-wobble      { syntax: "<percentage>"; inherits: true; initial-value: 0%; }
@property --fx-emb-shim-wobble-rate { syntax: "<time>";       inherits: true; initial-value: 4.3s; }
@property --fx-emb-shim-flux        { syntax: "<number>";     inherits: true; initial-value: 0; }
@property --fx-emb-shim-flux-rate   { syntax: "<time>";       inherits: true; initial-value: 7.7s; }

/* the circular bloom, reshaped */
@property --fx-emb-bloom-x     { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --fx-emb-bloom-y     { syntax: "<percentage>"; inherits: true; initial-value: 50%; }
@property --fx-emb-bloom-inner { syntax: "<percentage>"; inherits: true; initial-value: 0%; }
@property --fx-emb-bloom-sx    { syntax: "<number>";     inherits: true; initial-value: 1; }
@property --fx-emb-bloom-sy    { syntax: "<number>";     inherits: true; initial-value: 1; }

/* the moving parts: animated per element, so never inherited */
@property --emb-wa { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --emb-wb { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --emb-fl { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --emb-p2 { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --fx-emb-shim-w { syntax: "<number>"; inherits: false; initial-value: 0; }
@property --fx-emb-shim-f { syntax: "<number>"; inherits: false; initial-value: 0; }

:root {
  --fx-emb-glow-a-on: none;
  --fx-emb-glow-a-blend: screen;
  --fx-emb-glow-b-on: none;
  --fx-emb-glow-b-blend: screen;
  --fx-emb-edge-on: none;
  --fx-emb-edge-blend: screen;
  --fx-emb-shine2-on: none;
  /* Which pre-built shape each glow is cut to. The sources are set on :root by logo-intro.js once the
     resting frame is known; the transparent gradients below mean "nothing" until then. */
  --emb-src-alpha: linear-gradient(transparent, transparent);
  --emb-src-hi: linear-gradient(transparent, transparent);
  --emb-src-edge: linear-gradient(transparent, transparent);
  --fx-emb-glow-a-src: var(--emb-src-hi);
  --fx-emb-glow-b-src: var(--emb-src-hi);
  --fx-emb-edge-src: var(--emb-src-edge);
  --fx-emb-shine2-blend: plus-lighter;
  --fx-emb-shine2-direction: normal;

  /* Read by logo-intro.js at each pass, not by CSS. 0 = every pass identical (the old look). */
  --fx-emb-shim-rand-angle: 0;      /* degrees either way */
  --fx-emb-shim-rand-width: 0;      /* 0..1, fraction either way */
  --fx-emb-shim-rand-time: 0;       /* 0..1, how much speed and pause vary */
  --fx-emb-shim-rand-strength: 0;   /* 0..1, how much dimmer a pass may be */
  --fx-emb-shim-skip: 0;            /* 0..1, chance a pass does not show */
  --fx-emb-glitch-rand: 0;          /* 0..1, how far the tears wander each cycle */
  --fx-emb-fx-delay: 0s;            /* from the landing; the slow curve keeps it under the shimmer (2026-09-27) */
  /* ▶ THE SHIMMER'S OWN TIMING, 2026-09-19. Both this and `--fx-emb-fx-delay` above are now measured
     from the MARK LANDING (the fx delay used to count from the page reveal finishing, several seconds
     later — changed 2026-09-27). Default 0: the shine arrives with the emblem, which is the thing that was asked for.
     Raise it to trail the landing slightly; `--fx-emb-shim-fade` falls back to the effects' own
     fade when unset, so there is one number to change and not two. */
  --fx-emb-shim-delay: 0s;
  /* ▶ THE FIRST SHIMMER AND GLOW, 2026-09-27. Uriah: *"the shimmer should be first ... almost at the
     same time the glow should start, but it should increase slowly instead of turning up to full
     straight away"*. Measured: the visible shimmer is the SECOND shine (.emb-shine2), and its band is
     over the mark only between ~10% and ~26% of its cycle — the layer used to start at 22% (the end
     of a pass) and fade up over 1.2s, so the first sweep anyone saw came ~8.5s later.
       · shine2-cue   where in its cycle the shine is started when the emblem stops. The band is over
                      the mark from ~0.10 to ~0.26 of the cycle, so 0.13 = already crossing.
       · shim-fade    how fast the shimmer group reaches full strength. Short, so pass one is whole.
       · fx-fade      how slowly the glow and glitches rise (ease-in-out: rising at once, but gently). */
  --fx-emb-shine2-cue: 0.13;        /* 0.08 let the glow beat it (Uriah, 2026-09-27): the band is ON the mark at 0.13 */
  --fx-emb-shim-fade: 0.3s;
  --fx-emb-fx-fade: 3.5s;           /* 5s once the fade actually worked, then 30% back toward instant (Uriah, 2026-09-27) */
  --fx-emb-fx-touch: block;         /* none = emblem effect layers off on touch screens */

  /* The per-pass values logo-intro.js writes. Neutral here, so with no script it is the old look. */
  --emb-r-angle: 0deg;  --emb-r-width: 1;  --emb-r-travel: 1;  --emb-r-str: 1;  --emb-r-skip: 1;
  --emb-r2-angle: 0deg; --emb-r2-width: 1; --emb-r2-travel: 1; --emb-r2-str: 1; --emb-r2-skip: 1;
  --emb-g1t: 28%; --emb-g1b: 54%; --emb-g-k1: 1;
  --emb-g2t: 58%; --emb-g2b: 22%; --emb-g-k2: -0.5;
  --emb-g3t: 40%; --emb-g3b: 44%; --emb-g-k3: -1;

  /* Resolved here, at :root, because the shape strings that use them are resolved here too. */
  --_emb-angle: calc(var(--fx-emb-shim-angle) + var(--emb-r-angle));
  --_emb-width: calc(var(--fx-emb-shim-width) * var(--emb-r-width));
  --_emb-s2-angle: calc(var(--fx-emb-shine2-angle) + var(--emb-r2-angle));
  --_emb-s2-width: calc(var(--fx-emb-shine2-width) * var(--emb-r2-width));
}

/* ---- reset / base ---- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--font-body);
  font-size: clamp(15px, 1.1vw + 12px, 17px);
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  min-height: 100svh;
  overflow-x: hidden;               /* belt-and-braces: no page-level h-scroll */
}
img, svg { max-width: 100%; height: auto; display: block; }
a { color: var(--ink); text-underline-offset: 3px; }
a:hover { color: var(--accent); }

/* interaction polish — global details */
::selection { background: var(--plate); color: #0a0a0a; }
:focus-visible { outline: 1px solid var(--accent); outline-offset: 3px; }
html { scroll-behavior: smooth; -webkit-tap-highlight-color: transparent; }
h1, h2, h3, h4 { text-wrap: balance; }
p { text-wrap: pretty; }
/* anything jumped to via #anchor clears the sticky header */
:target { scroll-margin-top: calc(4.5rem + var(--frame)); }

/* cross-document view transitions — MPA page-to-page fade/rise. Pure CSS,
   progressive (Chromium today; others just get instant navigation). */
@view-transition { navigation: auto; }
::view-transition-old(root) { animation: vt-out 0.16s ease both; }
::view-transition-new(root) { animation: vt-in 0.28s cubic-bezier(0.22, 1, 0.36, 1) both; }
@keyframes vt-out { to { opacity: 0; } }
@keyframes vt-in { from { opacity: 0; transform: translateY(8px); } }

h1, h2, h3, h4 { line-height: 1.1; letter-spacing: -0.01em; margin: 0 0 0.6em; }
h2 { font-family: var(--font-heading); }
h3, h4 { font-family: var(--font-subhead); }
h1 { font-family: var(--font-display);
     font-size: clamp(1.9rem, 6vw, 3.4rem); text-transform: uppercase; letter-spacing: -0.02em; }
h2 { font-size: clamp(1.4rem, 3.4vw, 2.1rem); }
h3 { font-size: clamp(1.1rem, 2vw, 1.35rem); }
p { margin: 0 0 1em; }

/* mono eyebrow/labels — the "classified" voice */
.eyebrow, .kicker {
  font-family: var(--font-label);
  font-size: 0.72rem;
  letter-spacing: 0.28em;
  text-transform: uppercase;
  color: var(--ink-dim);
  margin: 0 0 0.9em;
}

/* ---- framed shell: ALL content lives INSIDE the border, so nothing crosses
   or escapes the frame (mirrors the admin Pass-#2 containment model). The
   border scrolls with the page — a "framed document", not a fixed overlay. ---- */
.shell { position: relative; margin: var(--frame); border: 0;   /* frame off — see --frame */
         min-height: calc(100svh - 2 * var(--frame));
         display: flex; flex-direction: column; }
.shell > main { flex: 1 0 auto; min-width: 0; }
/* The shell's four corner marks go with the frame they registered. Scoped to `.shell > .reg`
   so the FEATURE band's own marks — a different thing, hover-revealed from the CTA — survive. */
.shell > .reg { display: none; }
.reg { position: absolute; width: 10px; height: 10px; border: 1px solid var(--ink-faint);
       pointer-events: none; z-index: 61; }
.reg.tl { top: -1px; left: -1px; border-right: 0; border-bottom: 0; }
.reg.tr { top: -1px; right: -1px; border-left: 0; border-bottom: 0; }
.reg.bl { bottom: -1px; left: -1px; border-right: 0; border-top: 0; }
.reg.br { bottom: -1px; right: -1px; border-left: 0; border-top: 0; }

/* ---- layout primitives ---- */
.wrap { width: 100%; max-width: var(--wrap); margin-inline: auto; padding-inline: var(--gutter); }
.section { padding-block: clamp(2.5rem, 8vw, 5.5rem); }
/* FADING TERMINALS (2026-08-28). The rule no longer butts into the frame at both ends; it
   resolves out of nothing and back into nothing. This is the single cheapest change on the
   site's "lines could be slicker" list — a drawn line that terminates reads as instrumentation,
   a full-width line reads as a border.

   Implemented as a mask on a ::before rather than on border-top itself, because masking an
   element's border would mask the element's CONTENT too. The pseudo-element carries only the
   line, so the mask has nothing else to damage.

   --fx-rule-fade is the one knob: 0% restores the flat full-length rule exactly. */
.section + .section { position: relative; }
.section + .section::before {
  content: ""; position: absolute; inset-inline: 0; top: 0; height: 1px;
  background: var(--line); pointer-events: none;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 var(--fx-rule-fade),
                      #000 calc(100% - var(--fx-rule-fade)), transparent);
          mask-image: linear-gradient(90deg, transparent, #000 var(--fx-rule-fade),
                      #000 calc(100% - var(--fx-rule-fade)), transparent);
}
/* HAIRLINE WEIGHT. A 1px CSS rule is two device pixels at DPR 2, which is why structural
   lines look heavier on a phone than they do on a desktop — the opposite of the intent.
   Structural rules ONLY: focus outlines and the button border are load-bearing for
   accessibility and interaction and stay at their full weight.

   NOT the .shell frame, deliberately: the four .reg registration marks are positioned at
   top/left/right/bottom -1px to sit exactly on that 1px border. Thinning it to 0.5px would
   leave them half a pixel adrift, which is precisely the kind of "slightly wrong and nobody
   can say why" detail this pass exists to remove. Same reasoning excludes .ticker's
   border-block and the header's border-bottom, which butt against other painted edges. */
@media (min-resolution: 2dppx) {
  .section + .section::before { height: 0.5px; }
}
.prose { max-width: var(--prose); }
.prose h2 { margin-top: 2rem; }
.prose a { color: var(--accent); overflow-wrap: anywhere; /* long emails/URLs on 320px screens */ }
.stack > * + * { margin-top: 1rem; }

/* ---- .prose-block — one heading and the copy that belongs to it ------------------------
   Added 2026-09-13. Every text page's prose was a FLAT run of h2 and p siblings, which is
   why the alternating and per-section-background layouts could not be built: CSS has no
   "until" combinator and :has() does not provide one, so "everything between this heading
   and the next" is genuinely unselectable. A wrapper is the only way to say it.

   It is also just correct — a heading and its copy ARE a section — and in a static build it
   costs nothing at runtime.

   ▶ THESE TWO RULES EXIST ONLY TO MAKE THE WRAPPER INERT. The markup change must be
   invisible on the shipped site; the layouts that use it are still candidates in dev/. Both
   restore a behaviour the wrapper broke by interposing an element:

     1. `.stack > * + *` only ever matched DIRECT children, so paragraphs inside a block lost
        their 1rem separation the moment they stopped being children of .stack.
     2. The section's own top margin collapses with its first child h2's 2rem, so the gap
        between blocks is unchanged — no rule needed, but it is the thing to check first if
        the rhythm ever looks off. */
.prose-block > * + * { margin-top: 1rem; }

/* ▶ THE DISCLOSURE'S FIVE NUMBERS, ON :root SO THE SWITCHBOARD CAN REACH THEM, 2026-09-19.
   Declared here rather than inside the rules that use them for the reason the intro's knobs record:
   the panel writes custom properties to the ROOT, and a value set locally would win over the knob
   and make the slider appear to do nothing.

   `--dis-speed` is read by collapse.js as well as by the icon's transition, so one slider moves the
   box, the stagger, the title handover and the spin together — which is the thing actually being
   judged. The others are independent.

   Every default below is the value shipped on 2026-09-19, so a switchboard at rest shows today's
   site. */
:root {
  --dis-speed: 1;        /* multiplies the measured expansion duration and the icon's spin */
  --dis-stagger: 0.13;   /* per-block delay, as a fraction of that open's duration */
  --dis-fade: 0.55;      /* per-block fade length, same fraction */
  --dis-mask: 2.6em;     /* depth of the soft revealed edge, when there is room for it */
  --dis-spin: 405deg;    /* where the + lands: 360 + 45 is one turn to an x */
  /* ▶ THE CLOSING DIRECTION GOT ITS OWN TWO, 2026-09-19. Uriah: *"when collapsing the text
     disappears too quickly … or even better just keep it there and fading out as the box collapses
     over it, masking as it goes."* That is what the defaults do: the text HOLDS at full strength
     and the closing edge wipes it. `--dis-out-to: 0` turns it back into a fade if he wants one. */
  --dis-fade-out: 0.9;   /* how long the closing text takes, as a fraction of that close */
  --dis-out-to: 1;       /* what it fades TO: 1 holds it and lets the mask do the work, 0 fades */
  /* ▶ THE ANSWER LINE'S OWN TIMING, 2026-09-19. It leaves before the box opens and comes back
     after the box has shut, so it is never in the layout at the same time as the copy. */
  --dis-hint-ms: 140ms;  /* how long the answer takes to fade, either direction */
  --dis-hint-wait: 60ms; /* the beat before the answer's INK returns; its space returns at once */
  /* ▶ HOW THE COPY ARRIVES, 2026-09-19. Uriah: *"if possible the paragraphs should fade in
     character by character, or line by line, or better if there were controls for picking between
     character, line and paragraph … just for future proofing, and so I can see the differences."*
     `paragraph` is the shipped behaviour; `line` and `character` are built by collapse.js at the
     moment of opening and torn down again afterwards, so the copy in the DOM is untouched at rest. */
  --dis-reveal: paragraph;
  /* ▶ AND THE SAME CHOICE FOR THE PAGE ITSELF, 2026-09-19. Uriah: *"make the text appear more
     line by line, than section by section … possibly all with sliders."* `section` is the
     shipped whole-band cross-fade and is the default, so nothing changes until this is moved or a
     section carries its own `data-reveal`. The other three use the disclosure's splitter, shared
     rather than copied (public/js/reveal-units.js), and like the disclosure they cut the copy at
     the moment of arrival and put it back afterwards. */
  --reveal-mode: section;
  /* ▶ AND THE SAME CHOICE FOR A TEXT BLOCK, SEPARATELY, 2026-09-26. `.prose-block` — the
     `<section>` with its own `<h2>` that about/visit/legal are built from, seven to ten per page —
     reads THIS knob instead of the one above. Two knobs because one cannot express the shipped
     intent: a band arrives whole, a text block arrives by its lines of copy, and with a single
     property sitting at `section` there is no way to distinguish the default from a deliberate
     choice of whole-band everywhere.

     It is `paragraph` because that is what those pages did on the desktop until 2026-09-26, via
     `settle-in` — which was `animation-timeline: view()` and therefore absent on iOS 16.7, so the
     phone got nothing at all on six pages. Paragraph mode reproduces the per-leaf arrival through
     the event-triggered path, which every engine has. See public/js/scroll-reveal.js `modeFor`. */
  /* ▶ `line`, NOT `paragraph`, 2026-09-26. Uriah: *"the reveal of the sections could be a bit more
     line by line if possible without impacting performance, maybe with a sweep instead of actual
     line by line, like the expandable sections in visit are."* What he is describing IS line mode:
     /visit/'s disclosures run `--dis-reveal: line` (promoted, in the tuning block at the foot of
     this file), and `sweep` does not exist in this engine at all — it is unmerged branch vocabulary.
     `reveal-units.js` measures where the line boxes actually fall rather than guessing, so this
     follows the real wrap at whatever width and font happen to be in play.
     Cost measured before shipping, per his condition — see docs/reveal-coverage.md. */
  --reveal-mode-block: line;
  --reveal-fade: 0.8s;  /* one unit's own fade; the band's cross-fade keeps using --intro-fade.
                            +20% from 1.05s, 2026-09-26: *"they could reveal 20% slower"*. */
  --reveal-spread: 0.72s; /* +20% from 0.6s, 2026-09-26, with --reveal-fade.
                            the whole run's stagger — the LAST unit always lands here, however
                            many there are, which is what keeps character mode from crawling */
  /* ▶ THE CARD BEHIND THE COPY, 2026-09-19. Uriah: *"the background colour panel that is behind
     the paragraph text, it stays slightly after the text fades out, it should fade at the same
     time as the text or not be there at all."* 1 is the full `--bg-2` plate, 0 removes it — and
     whatever the strength, it now fades on the SAME schedule as the copy rather than being a
     painted box that the closing panel slices through. */
  --dis-card: 1;
  /* ▶ HOW LONG AFTER A WHOLE-BAND ARRIVAL THE NEXT ONE STARTS, 2026-09-26.
     Uriah, on /about/: *"the first section (Ethos), appears way too quickly after the blurb does."*
     The blurb is a lone paragraph, so it arrives as a BAND (nothing to split), and the sequencer's
     step after a band was a hardcoded half of `--intro-fade` — 850ms, and evidently too short.
     Now a knob. Unset, scroll-reveal.js falls back to that same 0.5 x --intro-fade, so this is
     additive rather than a behaviour change waiting to be discovered. */
  /* After a WHOLE-BAND arrival (the page's lede, a section with nothing to split). Raised to 0.8s
     2026-09-26: at 0.5s the /about/ blurb-to-Ethos break measured 0.42s and Uriah asked for 0.5-1s. */
  --reveal-band-step: 0.9s;
  /* After a block that arrived BY ITS COPY. Lower than the band step on purpose: a split block has
     already spent `--reveal-spread` staggering its own lines, so the same nominal gap reads roughly
     twice as long here — measured 1.44 / 1.34 / 1.01s at 0.5s, which Uriah called *"waiting for the
     next section ... when it should lead you"*. */
  --reveal-block-step: 0.12s;
  /* ▶ THE PROSE PAGES' ONE CADENCE, 2026-09-26. On any page with a `.prose-block` the whole page is
     one continuous cascade assigned at load, and THIS is its only timing knob: every unit lands one
     step after the one before it, whether they are in the same block or not. Raise it to slow the
     whole page down, lower it to speed the whole page up — there is nothing else to balance, which is
     the point. See flushCascade() in scroll-reveal.js for why order stopped being tunable and became
     structural. `--reveal-spread` and `--reveal-block-step` are unused on those pages. */
  --reveal-cascade-step: 90ms;
  /* The beat between the page's opening line and the cascade proper. Applied once, after
     the first target, so the sections keep one uniform rhythm behind it. */
  --reveal-cascade-lead: 600ms;
}

/* ▶ THE DESKTOP CADENCE, 2026-09-26. Uriah: the PC's line-by-line should be *"slightly faster /
   smoother"*. Faster = a shorter step between lines; smoother = each line's fade overlapping more
   of the next ones, which a longer fade relative to the step gives. Phones keep the values above.
   46rem is the line collapse.js already draws between phone and desktop reading. */
@media (min-width: 46rem) {
  :root {
    --reveal-cascade-step: 70ms;
    --reveal-fade: 0.9s;
  }
}

/* ---- .disclosure — a prose-block that folds up on a phone, 2026-09-17 --------------------
   Paired with public/js/collapse.js; read that file's header for why the markup ships OPEN, why the
   panel is built at runtime, and why the height is measured rather than guessed.

   Uriah's rules and where each is answered:
     1. THE SUMMARY CARRIES THE ANSWER — `.disclosure__hint`, and the wording is copy, so it is his.
     2. THE AFFORDANCE MUST BE UNMISTAKABLE — the `+` below. *"as long as it's obvious that it's
        expandable so people don't assume the one sentence is it."*
     3. NO COLLAPSING ON /legal/ — nothing here applies without `.disclosure`, which those pages do
        not carry. Opt-in per section, not a blanket rule on .prose-block.
     4. 2026-09-17, on staging: *"keep the same Heading style"* — so THERE IS NO font-size rule on
        the heading any more. It renders at the page's own h2/h3 size, in the summary, exactly as it
        did as a bare heading. The first version shrank it to 1.15rem and that was the complaint.

   ▶ DESKTOP IS UNCHANGED. Everything that makes this a disclosure lives inside the narrow media
   query; above 46rem the sections are open, there is no icon, and the summary is not a control.

   ▶ THE HEADING IS STILL AN h2/h3 INSIDE THE SUMMARY, so the outline, the SEO surface and the
   screen-reader heading list are untouched. That is why this is `<details>` and not a div accordion. */
.disclosure > summary { list-style: none; cursor: pointer; }
.disclosure > summary::-webkit-details-marker { display: none; }
.disclosure > summary::marker { content: ""; }
.disclosure > summary > h2,
.disclosure > summary > h3 { margin: 0; }
.disclosure__hint { display: none; }

@media (max-width: 45.99rem) {
  /* ▶ DENSITY, 2026-09-17. Uriah: *"the spacing between sections makes it feel sparse, reduce the
     padding so there is not much dead space but the sections are as well defined as possible."*

     Measured before the change: each collapsed section was **98px tall with a 64px gap** between
     them — two thirds of a section's height in empty space, eight times over. That gap is the
     editorial layout's own `--fx-ed-space` margin, and it is right for the page it was written
     for: L7's whole argument is *"space as punctuation — no rules, no borders, the separation IS
     the signal."*

     **But a collapsed section already has a signal: the hairline.** Paying for separation twice is
     what made it read as sparse. On a phone the margin goes and the rule does the work, so the
     sections touch — a run of hairline rows reads as one structure, where 64px apart reads as
     eight things that happen to be near each other.

     ▶ AND IT HAS TO OUT-SPECIFY THE RULE IT NEUTRALISES, not merely contradict it.
     `.disclosure.prose-block` is (0,2,0) and so is `[data-page-kind="editorial"] .prose-block` —
     equal specificity, decided by document order, and the editorial block sits ~2,200 lines later
     in this file. The first attempt changed nothing and measured identically: **64px gaps, before
     and after.** Naming the page kind here makes it (0,3,0) and says out loud which layout is
     being overridden. */
  [data-page-kind="editorial"] .disclosure.prose-block { margin-top: 0; }
  /* ▶ `--ink-faint`, NOT `--line`, AND ONLY HERE. Uriah asked for the sections to be *"as well
     defined as possible"* while losing the dead space, and those two pull in opposite directions:
     take the space away and a #2a2a2a hairline is no longer enough to say where one section ends.
     `--ink-faint` (#4a4845) is the next step up the site's own ladder and is already what the
     registration marks use. On desktop nothing changes — there are no rules there at all. */
  .disclosure { border-top: 1px solid var(--ink-faint); }
  .disclosure:last-of-type { border-bottom: 1px solid var(--ink-faint); }

  .disclosure > summary {
    display: flex;
    /* ▶ `wrap` IS LOAD-BEARING, and leaving it out cost 107px of sideways scroll on a 390px phone,
       measured before it shipped. The hint takes a full-width flex line so it sits UNDER the
       heading; without wrapping, a 100% basis plus the gap and the icon cannot fit on one line and
       the summary simply overruns the viewport. A flex line that cannot wrap does not shrink a
       100% basis — it overflows. */
    flex-wrap: wrap;
    align-items: center;
    /* see the answer-line note below: a zero-height flex line still costs its row gap */
    row-gap: 0;
    /* ▶ PACK THE LINES, DO NOT SPREAD THEM. With `min-height` on a wrapping flex container, the
       spare height is shared BETWEEN the flex lines by default — so the icon drifted down off the
       heading's line and sat halfway to the hint, reading as a third floating element rather than
       as the heading's control. Packing to the start puts the icon back on the heading's own line
       and hands the spacing back to the padding, where it can be reasoned about. */
    align-content: flex-start;
    gap: 0.6rem;
    min-height: 44px;
    /* 0.9rem → 0.7rem: the heading's own line box still carries the row past the 44px touch
       floor, and the reclaimed space is what stops the column reading as a list of gaps. */
    padding-block: 0.7rem;
    -webkit-tap-highlight-color: transparent;
    -webkit-user-select: none;
    user-select: none;
  }
  .disclosure > summary > h2,
  .disclosure > summary > h3 { flex: 1 1 auto; }

  /* ▶ AND THE ROW GIVES BACK ITS LOWER PADDING WHEN IT OPENS. The hint's line leaves with it, so
     the summary's bottom padding is the last thing standing between the heading and the copy —
     and it is what kept the first paragraph sitting BELOW where the answer used to be. Closed, the
     padding is the row's breathing space and the touch target; open, the card's own padding takes
     over and this would just be dead air. */
  .disclosure.is-open > summary { padding-bottom: 0.1rem; }

  /* ▶ THE TITLE HANDS FOCUS TO THE PARAGRAPH, 2026-09-19.
     Uriah: *"if the Titles for each section could be the more bold white colour of the paragraph
     text when collapsed, and then as the box expands it fades to the grey colour so that the
     paragraph text then becomes the focal point so it then looks like the styles of the About
     page … the reverse … when collapsing too."*

     **This is a better idea than the thing it replaces, and worth saying why.** A collapsed row is
     a CONTROL — it is the only thing on screen, it has to be read first, and `--ink-dim` on a
     14.7px uppercase label at the top of a list of eight is the weakest it could be. An OPEN
     section is prose, and there the heading is a label on something more important than itself,
     which is exactly what the editorial layout says by putting it in `--ink-dim`. So the same
     element is two different things depending on state, and the colour follows the role rather
     than the element.

     ▶ THE REVERSE IS FREE. It is one transition between two declared values, so closing runs it
     backwards without a second rule — the title brightens as the paragraph leaves.

     ▶ TIMED TO THE BOX. `--panel-ms` is the measured duration of THIS open, set by collapse.js, so
     the handover finishes as the box does. A fixed duration would finish early on a long section
     and late on a short one.

     ▶ SPECIFICITY, STATED BECAUSE IT HAS BITTEN TWICE IN THIS FILE. The editorial rule that sets
     `--ink-dim` is (0,3,0) and sits ~2,200 lines later. `:not(.is-open)` contributes its argument's
     weight, so this is (0,3,2) and wins on specificity rather than on order — which is the only
     kind of win that survives someone moving a block. */
  [data-page-kind] .disclosure:not(.is-open) > summary > h2,
  [data-page-kind] .disclosure:not(.is-open) > summary > h3 {
    color: var(--ink);
  }
  [data-page-kind] .disclosure > summary > h2,
  [data-page-kind] .disclosure > summary > h3 {
    transition: color calc(var(--panel-ms, 400ms) * 0.8) ease-out;
  }

  /* ▶ THE PLUS THAT SPINS INTO AN ×, 2026-09-17.
     Uriah: *"instead of arrows … a + that spins a few times and lands on a rotation so it looks
     like an x, which then goes in reverse to close it, the same as the FAQ section for Simons
     Trucking 2026. But a bit more flashy."*

     Simons Trucking rotates a `+` GLYPH 45deg over 0.3s. This keeps the idea and changes three
     things:
       · it is DRAWN, not typed — two crossed gradients in one box, so it is a true plus at any
         size, inherits `color`, and cannot be re-shaped by a font swap (this site has nine of them
         in play on the F and N axes);
       · it spins **one full turn and lands at 45deg** (360 + 45 = 405deg) instead of turning
         45deg, which is
         the "more flashy" — and because the transition simply runs back to 0, the close unwinds
         the same two turns in reverse for free. No second rule, no keyframes, no JS.
       · the curve is the site's own settle curve, so it arrives fast and lands soft rather than
         spinning at a constant rate like a loader.

     ▶ TWO TURNS WAS TOO MUCH, and one is not simply half of it. Uriah, 2026-09-17: *"the
     spinning is a little intense, maybe one less full rotation."* So 405deg — **and the duration
     comes down with it, 0.62s → 0.46s**, because a single turn spread over the old duration reads
     as hesitant rather than calmer. The rotation rate is the thing that felt intense; keeping it
     and shortening the journey is what "less intense" actually means here.

     ▶ ONE ELEMENT, NOT TWO BARS. Two pseudo-elements would each need their own positioning and
     their own transform, and would drift apart by a subpixel at some sizes. A single box with two
     crossing linear-gradients rotates as one object. */
  .disclosure > summary::after {
    content: "";
    flex: 0 0 auto;
    width: 0.95rem;
    height: 0.95rem;
    margin-left: auto;
    background:
      linear-gradient(var(--ink-dim) 0 0) center / 100% 2px no-repeat,
      linear-gradient(var(--ink-dim) 0 0) center / 2px 100% no-repeat;
    transform: rotate(0deg);
    transition: transform calc(0.46s * var(--dis-speed)) cubic-bezier(0.22, 1, 0.36, 1),
                background-color 0.2s;
  }
  .disclosure.is-open > summary::after {
    transform: rotate(var(--dis-spin));
    background-image:
      linear-gradient(var(--accent) 0 0),
      linear-gradient(var(--accent) 0 0);
  }

  /* ▶ THE ANSWER LINE FADES, AND IT NEVER SHARES THE LAYOUT WITH THE COPY, 2026-09-19.
     Uriah: *"when collapsing, the description reappears, pushing the paragraph down and making it
     look a bit jittery … the description should wait for the paragraph to fade out, and then fade
     back in so that it isn't interfering with the placement of the paragraph at any point."*

     The fault was `display`. It cannot be transitioned, and it takes effect on the same frame the
     class flips — so on a close the answer line reappeared **while the panel was still shrinking**,
     added its line back to the summary, and shoved the copy down mid-animation.

     So the two jobs are separated, and `collapse.js` sequences them:
       · `.is-hint-fading` fades the OPACITY — that is the *"quick fade out for the description"*;
       · `.is-hint-gone` takes it out of the LAYOUT, and is only added once the fade has finished
         and only removed once the box has completely shut.
     The result is strictly ordered in both directions: answer fades → answer leaves → box opens;
     box closes → answer returns to the layout at zero → answer fades in. **At no point are both
     the answer and the copy claiming the same space.** */
  .disclosure > summary > .disclosure__hint {
    transition: opacity var(--dis-hint-ms) ease;
  }

  /* the answer, on the summary line, while the section is shut */
  .disclosure > summary > .disclosure__hint {
    display: block;
    flex: 1 1 100%;
    min-width: 0;
    /* ▶ ORDER, NOT SOURCE ORDER. The icon is ::after, so in DOM order it follows the hint — and
       once the hint takes a full flex line the icon wraps onto a THIRD line and floats on its own
       below the answer. Measured before it shipped: a 104px summary with the icon orphaned. */
    order: 1;
    margin-top: 0.05rem;
    font-size: 0.92rem;
    color: var(--ink-dim);
  }

  /* ▶ THE ANSWER'S SPACE IS ANIMATED, NOT SWITCHED, 2026-09-19 (second pass).
     Uriah, on the first version: *"the expansion has to wait for the description to fade before
     starting but it needs to start expanding as soon as the + is clicked"* and *"when collapsing
     the box closes over the whole paragraph making it one line too small and when the description
     returns it expands to that size again … instead the box should collapse to its original size
     with the description."*

     Both are the same fault: the answer's LINE was being added and removed discretely (first by
     `display`, then by a sequenced `display`), so its height arrived as a step. A step can only be
     put before the animation (which delays the open) or after it (which shoves the box back out) —
     there is no third place, which is why the first version had to choose one and got both
     complaints.

     Animating `max-height` instead removes the choice. The line SHRINKS as the box grows and GROWS
     as the box shrinks, so the total never steps: the box lands on its resting size with the answer
     already in place, rather than closing past it and being pushed back out. The opacity keeps its
     own timing, delayed on the way back by `--dis-hint-wait`, so the copy is still gone before the
     answer's ink returns — which is the ordering he asked for last time and still wants.

     ▶ `max-height` RATHER THAN `height` because the line has no fixed height: it is one line at
     390px and two at 320px, and `height: auto` cannot be animated. 3.5em clears both. The easing
     artefact that usually makes max-height unusable — the visible part finishing early — is
     invisible over 140ms on a 1.2em box.

     ▶ AND `row-gap: 0`, which is load-bearing: a flex line with zero height still costs its row
     gap, so a 0.6rem gap would leave 0.6rem of the answer's space behind after it had gone. */
  [data-page-kind] .disclosure > summary > .disclosure__hint {
    max-height: 3.5em;
    overflow: hidden;
    transition: opacity var(--dis-hint-ms) ease var(--dis-hint-wait),
                max-height var(--dis-hint-ms) ease,
                margin-top var(--dis-hint-ms) ease;
  }
  [data-page-kind] .disclosure.is-hint-fading > summary > .disclosure__hint {
    opacity: 0;
    max-height: 0;
    margin-top: 0;
    transition-delay: 0s;        /* going out, the ink leaves with the space, not after it */
  }

  /* ▶ THE HANDOVER FRAME. The panel has shrunk to exactly this line's height and is about to let
     it go; the line must take that space INSTANTLY or the box dips by one line while it grows.
     Only `max-height` loses its transition — the ink still fades in on its own delay, which is
     what keeps the answer from appearing while the copy is still there. */
  [data-page-kind] .disclosure.is-hint-snap > summary > .disclosure__hint {
    transition: opacity var(--dis-hint-ms) ease var(--dis-hint-wait);
  }

  /* ▶ THE EXPANDING CARD. Uriah: *"I want the expanding card with the fading in text as well."*
     The panel is the moving part — `height` is set in px by collapse.js and transitions, because
     `height: auto` is not animatable and `max-height` guessing is what clips long answers. The
     inner element is the fading part, so the text arrives just behind the box rather than being
     stretched by it, and the small rise gives the fade a direction. */
  /* ▶ A SHORTER TAIL ON THE HEIGHT, 2026-09-19. The site's settle curve
     (cubic-bezier(.22, 1, .36, 1)) spends its last third of TIME on about two percent of the
     DISTANCE — beautiful on a card lifting 4px, and the reason a 300px panel appears to stop while
     it is still going. Paired with the old constant mask, that stall is where both of Uriah's
     "it pauses near the end" reports came from. This curve still decelerates, but it lands. */
  .disclosure__panel {
    height: 0;
    overflow: hidden;
    /* ▶ THE PANEL IS STRUCTURE, NOT COPY, so `.prose-block > * + *`'s 1rem sibling margin does not
       belong to it — it was 16px of the gap between the answer and the first paragraph, sitting
       OUTSIDE the animated box where no amount of padding tuning could see it. The card's own
       padding is the only spacing here that is a decision. */
    margin-top: 0;
    transition: height 0.4s cubic-bezier(0.32, 0.8, 0.35, 1);
  }

  /* ▶ THE SOFT REVEALED EDGE — this is what makes it read line by line, 2026-09-19.
     Uriah: *"it would be good if it could fade in line by line as the box opens, still quickly but
     more fluid."*

     **The panel was already revealing line by line**: it clips, and the clip edge travels down the
     text as the height grows. What it lacked was softness at that edge, so each line appeared at
     full strength the instant it cleared the boundary — a shutter, not a fade. A gradient mask over
     the last ~2.6em turns the boundary into a ramp, so a line is half-there while it is being
     uncovered and settles as it clears. The effect is per-LINE without a single line ever being
     wrapped, split or measured.

     ▶ ONLY WHILE IT MOVES. `.is-animating` is added for the duration and removed by collapse.js on
     transitionend — a permanent mask would leave the bottom of every open section faded.

     ▶ AND IT IS FREE IN THE CLOSING DIRECTION. The same ramp sits at the same edge while the height
     shrinks, so the text dissolves from the bottom as the box swallows it. Uriah asked for the
     reverse on close; the mask needed no second rule to give it. */
  /* ▶ THE SOFT EDGE IS AS DEEP AS THE DISTANCE LEFT TO TRAVEL, 2026-09-19 — and that one change
     fixes both halves of what Uriah reported:

       *"as the expanding of the box is nearing its end, the bottom line or two stay dark for about
        a second before turning the brighter colour"* — the mask was a CONSTANT 2.6em that stayed on
       until `transitionend`. The height curve decelerates hard, so the box looks stopped while the
       last lines are still sitting under a fixed gradient; then the class comes off and they pop.

       *"when collapsing the box stops … about a line's height away from the actual collapse
        position"* — the same tail, seen from the other direction.

     `--dis-mask-live` is set by collapse.js every frame to `min(--dis-mask, distance remaining)`.
     As the box arrives the soft edge shrinks to nothing on its own, so there is no boundary left to
     pop, and the animation ENDS instead of stopping and then finishing. */
  .disclosure.is-animating > .disclosure__panel {
    -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - var(--dis-mask-live, var(--dis-mask))), transparent 100%);
            mask-image: linear-gradient(to bottom, #000 calc(100% - var(--dis-mask-live, var(--dis-mask))), transparent 100%);
  }

  /* ▶ THE FIRST PARAGRAPH STARTS WHERE THE ANSWER WAS, 2026-09-19. Uriah: *"the paragraphs start
     to appear underneath where it was which leaves some negative space which feels unnecessary
     since there is already a bit of padding in the background of the paragraph."*

     Exactly right, and it was two gaps stacked: the panel's own top padding AND the first block's
     margin, both sitting below a summary that had just lost the hint's line. The card's own padding
     is the only spacing that belongs there, so the top one goes to zero and the first block's
     margin with it — the copy now picks up the line the answer was on. */
  .disclosure__inner {
    padding: 0 0.9rem 1.1rem;
    position: relative;
  }

  /* ▶ THE CARD IS A LAYER NOW, NOT A BACKGROUND, AND THAT IS THE WHOLE FIX.
     `background: var(--bg-2)` on the inner cannot fade independently of the element that carries
     it, so during a close the copy went and the plate stayed — the panel just sliced down through
     a painted box, which reads as the text leaving a hole behind it rather than the section
     closing. Uriah: *"it stays slightly after the text fades out."*

     As its own absolutely-positioned layer it can take exactly the copy's transitions, so the two
     leave together. `--dis-card: 0` removes it entirely, which was his other acceptable answer.

     ▶ `z-index: -1` KEEPS IT BEHIND THE COPY WITHOUT A STACKING CONTEXT ON EVERY PARAGRAPH. The
     panel paints no background of its own, so there is nothing for it to slip behind. */
  .disclosure__inner::before {
    content: "";
    position: absolute;
    inset: 0;
    z-index: -1;
    background: var(--bg-2);
    opacity: 0;
    transition: opacity calc(var(--panel-ms, 400ms) * var(--dis-fade)) ease-out;
  }
  .disclosure.is-open > .disclosure__panel > .disclosure__inner::before {
    opacity: var(--dis-card);
  }
  /* closing: the card takes the copy's own exit — same duration, same target, so `--dis-out-to: 0`
     fades both and `--dis-out-to: 1` holds both for the mask to wipe */
  .disclosure.is-animating:not(.is-open) > .disclosure__panel > .disclosure__inner::before {
    opacity: calc(var(--dis-card) * var(--dis-out-to));
    transition: opacity calc(var(--panel-ms, 400ms) * var(--dis-fade-out)) ease-in 0s;
  }

  /* ▶ AND A SMALL STAGGER PER BLOCK ON TOP. `--line-i` is each child's index, set by collapse.js
     at the moment of opening; `--panel-ms` is that run's measured duration. The delays are a
     fraction of it, so a long section staggers over a long open and a one-paragraph section does
     not wait around — the rhythm belongs to the box, not to a hardcoded number.
     Capped at three steps: past that the last paragraph arrives after the box has stopped, which
     reads as lag rather than as sequence. */
  /* ▶ THE UNIT, WHATEVER IT IS. `.dis-unit` is a paragraph, a line or a character depending on
     `--dis-reveal`; collapse.js decides, builds the spans for the two finer modes at the moment of
     opening, and takes them out again afterwards so the copy at rest is exactly what the page
     shipped.

     ▶ THE DELAY IS A FRACTION OF ONE SPREAD, NOT A CONSTANT PER UNIT. `--u` is the unit's position
     from 0 to 1, so the LAST unit always lands at the same moment whether there are six paragraphs
     or nine hundred characters. A constant per-unit delay works for paragraphs and turns character
     mode into a minute-long crawl — the shape of mistake that makes a mode "not work" when it is
     only mis-scaled. */
  .disclosure__inner .dis-unit,
  .disclosure__inner > * {
    opacity: 0;
    transition: opacity calc(var(--panel-ms, 400ms) * var(--dis-fade)) ease-out
                calc(var(--u, 0) * var(--spread-ms, 0ms));
  }
  .disclosure__inner > * { transform: translateY(-3px);
    transition: opacity calc(var(--panel-ms, 400ms) * var(--dis-fade)) ease-out
                  calc(var(--u, 0) * var(--spread-ms, 0ms)),
                transform calc(var(--panel-ms, 400ms) * var(--dis-fade))
                  cubic-bezier(0.22, 1, 0.36, 1) calc(var(--u, 0) * var(--spread-ms, 0ms)); }
  /* a block that merely CONTAINS units must not fade as a block as well — that is two fades over
     the same pixels, and it reads as a slow smear rather than as units arriving */
  .disclosure__inner > *:has(.dis-unit) { opacity: 1; transform: none; transition: none; }
  .disclosure.is-open > .disclosure__panel > .disclosure__inner > *,
  .disclosure.is-open > .disclosure__panel > .disclosure__inner .dis-unit {
    opacity: 1;
    transform: none;
  }

  /* ▶ CLOSING HOLDS THE TEXT AND LETS THE BOX EAT IT. Without this rule the blocks inherit the
     OPENING transition backwards — a fast staggered fade that empties the card before the box has
     moved, which is what Uriah saw as *"the text disappears too quickly."*

     With `--dis-out-to: 1` (the default) the text does not fade at all while closing: it stays at
     full strength and the shrinking, soft-edged panel wipes it from the bottom up. `--dis-out-to:
     0` restores a fade, and `--dis-fade-out` sets how long it takes. Both are sliders.

     The delay is zero on the way out on purpose — a stagger closing is a queue of paragraphs
     leaving one at a time, and nobody watches a section close for that long. */
  .disclosure.is-animating:not(.is-open) > .disclosure__panel > .disclosure__inner > *,
  .disclosure.is-animating:not(.is-open) > .disclosure__panel > .disclosure__inner .dis-unit {
    opacity: var(--dis-out-to);
    transform: none;
    transition: opacity calc(var(--panel-ms, 400ms) * var(--dis-fade-out)) ease-in 0s;
  }
  /* the copy keeps its own rhythm inside the card */
  .disclosure__inner > :first-child { margin-top: 0; }
  .disclosure__inner > :last-child { margin-bottom: 0; }

  /* ▶ WITHOUT THE SCRIPT THERE IS NO PANEL, so this restores the plain open section — the
     fail-open state the whole design rests on. `.disclosure--js` is added by collapse.js only
     after it has built the panel, so this is the "script never ran" branch. */
  .disclosure:not(.disclosure--js) > :not(summary) { display: block; }

  .disclosure > summary:focus-visible { outline: 1px solid var(--accent); outline-offset: 3px; }

  /* Reduced motion: the state still changes, nothing travels. The icon still becomes an ×, because
     that is information rather than decoration — it just gets there without the two turns. */
  @media (prefers-reduced-motion: reduce) {
    .disclosure > summary::after { transition: none; }
    .disclosure.is-open > summary::after { transform: rotate(45deg); }
    .disclosure__panel { transition: none; }
    .disclosure.is-animating > .disclosure__panel {
      -webkit-mask-image: none; mask-image: none;
    }
    .disclosure__inner > * { transition: none; opacity: 1; transform: none; }
    /* the colour still CHANGES — it is information about state, not decoration — it just does not
       travel there */
    [data-page-kind] .disclosure > summary > h2,
    [data-page-kind] .disclosure > summary > h3 { transition: none; }
  }
}

/* skip link + a11y helpers */
.skip-link { position: absolute; left: -9999px; top: 0; z-index: 100;
             background: var(--plate); color: #0a0a0a; padding: 0.6em 1em; }
.skip-link:focus { left: var(--frame); top: var(--frame); }
.visually-hidden { position: absolute; width: 1px; height: 1px; overflow: hidden;
                   clip: rect(0 0 0 0); white-space: nowrap; }

/* ---- site header / nav ---- */
/* Sticky: pins to the viewport top once the frame's top edge scrolls away;
   the shell's side borders run full-height so the frame still reads. */
.site-header { position: sticky; top: 0; z-index: 50; border-bottom: 1px solid var(--line);
               background: var(--bg); }
.site-header__bar { display: flex; align-items: center; justify-content: space-between;
                    gap: clamp(0.35rem, 2vw, 1rem); padding-block: 0.9rem; }
/* brand text + mark scale down with the viewport so the bar NEVER overflows at
   narrow widths (320px was pushing the menu toggle off-screen with a fixed size).
   The 0.82rem floor was still ~1px too wide at 320: brand + gap + "Menu" came to
   267px against 266px of content box, so the toggle sat hard against the frame.
   Floor lowered and the bar's gap floor tightened to restore real clearance —
   verified by scripts/frame-check.py, which is what caught it. */
.brand { display: inline-flex; align-items: center; gap: 0.5em; font-weight: 800;
         text-transform: uppercase; letter-spacing: -0.01em;
         font-size: clamp(0.72rem, 4vw, 1.05rem);
         min-width: 0;
         text-decoration: none; line-height: 1; }
/* "Center" in the same white as "Public Service" (Uriah, 2026-09-30; it was --ink-dim grey). The 400 weight
   is what production reads until the type tuning is baked in; the staging engine sets 750. */
.brand span { color: var(--ink); font-weight: 400; }
.nav { display: flex; gap: clamp(1.2rem, 2.4vw, 2rem); align-items: center; }
.nav a { font-family: var(--font-nav); font-size: 0.78rem; letter-spacing: 0.14em;
         text-transform: uppercase; text-decoration: none; color: var(--ink-dim);
         position: relative; transition: color 0.18s; padding-block: 0.6em; /* hit area */
         white-space: nowrap; touch-action: manipulation; }
.nav a:hover, .nav a[aria-current="page"] { color: var(--ink); }
/* target-brackets: [ LINK ] slide in on hover; the current page keeps them.
   Absolutely positioned, so nothing shifts. */
.nav a::before, .nav a::after { font-family: var(--font-nav); color: var(--ink-faint);
  position: absolute; top: 50%; opacity: 0; pointer-events: none;
  transition: opacity 0.18s, transform 0.28s cubic-bezier(0.22, 1, 0.36, 1); }
/* the `/ ""` alt-text keeps the glyphs out of the links' accessible names */
.nav a::before { content: "[" / ""; left: -0.65em; transform: translate(0.35em, -50%); }
.nav a::after  { content: "]" / ""; right: -0.65em; transform: translate(-0.35em, -50%); }
.nav a[aria-current="page"]::before, .nav a[aria-current="page"]::after {
  opacity: 1; transform: translate(0, -50%); color: var(--ink-dim); }
@media (hover: hover) {
  .nav a:hover::before, .nav a:hover::after { opacity: 1; transform: translate(0, -50%); }
}
/* touch fallback — this bar is still visible on tablets (>720px), which are touch-only.
   Instant in, eased out (see the .btn:active note) — a nav tap navigates immediately, so a
   transitioned bracket reveal would never be seen at all. */
.nav a:active { color: var(--ink); transition-duration: 0s; }
.nav a:active::before, .nav a:active::after { opacity: 1; transform: translate(0, -50%);
                                              transition-duration: 0s; }

/* mobile disclosure nav (native <details>, no JS) — the open panel is a DROPDOWN
   (position:absolute, anchored under the toggle) so opening it never changes the
   header bar's own height/layout; it just overlays the page content below it. */
.nav-mobile { display: none; position: relative; }
.nav-mobile > summary { list-style: none; cursor: pointer; font-family: var(--font-nav);
                        font-size: 0.75rem; letter-spacing: 0.14em; text-transform: uppercase;
                        color: var(--ink); padding: 0.9em 0.4em; /* comfortable tap target */
                        white-space: nowrap;
                        /* this control is touch-first by definition — it only exists ≤720px */
                        touch-action: manipulation; -webkit-touch-callout: none; user-select: none;
                        transition: color 0.18s; }
/* press feedback on the toggle itself: the +/× already rotates on [open], but that only
   lands AFTER the panel opens — this confirms the tap at the moment it happens. */
.nav-mobile > summary:active { color: var(--accent); transition-duration: 0s; }
.nav-mobile > summary:active::after { transform: scale(1.25); transition-duration: 0s; }
.nav-mobile[open] > summary:active::after { transform: rotate(45deg) scale(1.25); }
.nav-mobile > summary::-webkit-details-marker { display: none; }
.nav-mobile > summary::after { content: "+" / ""; display: inline-block; margin-left: 0.5em;
                               color: var(--ink-dim); transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1); }
.nav-mobile[open] > summary { color: var(--accent); }
.nav-mobile[open] > summary::after { transform: rotate(45deg); }
.nav-mobile .nav { position: absolute; top: 100%; right: 0; z-index: 60;
                   flex-direction: column; align-items: stretch; gap: 0.2rem;
                   min-width: 200px; background: var(--bg); border: 1px solid var(--line);
                   padding: 0.8rem 1.2rem;
                   /* short-viewport guard (landscape phones): scroll inside, never clip */
                   max-height: calc(100svh - 6rem); overflow-y: auto; }
.nav-mobile[open] .nav { animation: menu-in 0.22s cubic-bezier(0.22, 1, 0.36, 1); }
@keyframes menu-in { from { opacity: 0; transform: translateY(-8px); } }
/* bracket hovers get cramped in the vertical panel — swap for a simple slide */
.nav-mobile .nav a::before, .nav-mobile .nav a::after { content: none; }
/* Negative inline margin + matching padding bleeds each row to the panel edges, so the
   :active highlight below is a full-width bar rather than a narrow inset strip (and the
   dividers become full-bleed too). Tap area grows to the panel width as a bonus. */
.nav-mobile .nav a { padding: 0.85em 1.2rem; /* ≥44px tap targets */
                     margin-inline: -1.2rem;
                     touch-action: manipulation; user-select: none;
                     transition: color 0.18s, background 0.12s,
                                 transform 0.22s cubic-bezier(0.22, 1, 0.36, 1); }
.nav-mobile .nav a + a { border-top: 1px solid var(--line); }
@media (hover: hover) { .nav-mobile .nav a:hover { transform: translateX(4px); } }
/* Touch: the 4px slide alone is too subtle to register on a phone mid-navigation, and it
   competes with the panel closing. A plate bar confirms WHICH row was hit. Instant in —
   this is the single most navigation-cut element on the site (see the .btn:active note). */
.nav-mobile .nav a:active { color: var(--ink); background: var(--bg-3);
                            transform: translateX(4px); transition-duration: 0s; }

@media (max-width: 720px) {
  .site-header__bar > .nav { display: none; }
  .nav-mobile { display: block; }
}

/* ---- buttons ----
   Sweep-fill inversion: a pseudo-layer wipes in from the left on hover and the
   button inverts (ghost → plate, plate → black). Wipes back out to the right.
   Only transform/opacity animate; color/border ride short transitions.

   TOUCH: every effect above is hover-gated, and html sets -webkit-tap-highlight-color:
   transparent — so before this pass, tapping a button on a phone suppressed the native
   flash and replaced it with nothing but a 1px nudge. Touch now gets its own :active
   inversion (below), matching the a.card:active fallback this file already established. */
.btn { display: inline-flex; align-items: center; justify-content: center; text-align: center;
       font-family: var(--font-label); font-size: 0.8rem;
       letter-spacing: 0.12em; text-transform: uppercase; text-decoration: none;
       padding: 0.85em 1.4em; border: 1px solid var(--line); color: var(--ink);
       background: transparent; cursor: pointer; position: relative; isolation: isolate;
       /* touch ergonomics: no 300ms double-tap-zoom delay, no iOS long-press link sheet,
          no text selection on a press-and-hold. All three read as "broken" on a phone. */
       touch-action: manipulation; -webkit-touch-callout: none; user-select: none;
       transition: border-color 0.2s, color 0.2s, transform 0.15s; }
.btn::before { content: ""; position: absolute; inset: 0; z-index: -1;
               background: var(--plate); transform: scaleX(0); transform-origin: right;
               transition: transform 0.3s cubic-bezier(0.22, 1, 0.36, 1); }
/* The hover fill and the focus ring follow a rounded button's corners (the staging type engine can
   tune border-radius on .btn; Session B, 2026-09-30). */
.btn::before, .btn--primary::after { border-radius: inherit; }
@media (hover: hover) {
  .btn:hover { border-color: var(--plate); color: #0a0a0a; }
  .btn:hover::before { transform: scaleX(1); transform-origin: left; }
  .btn--primary:hover { background: var(--plate); color: var(--plate); border-color: var(--plate); }
}
/* Touch: the same inversion, but STAMPED rather than wiped.
   INSTANT IN, EASED OUT — see the note above .btn:active below. */
@media (hover: none) {
  .btn:active { border-color: var(--plate); color: #0a0a0a; }
  .btn:active::before { transform: scaleX(1); transform-origin: left; transition-duration: 0s; }
  .btn--primary:active { color: var(--plate); }
}
/* Press physics: a 1px translate is invisible on a phone — a slight press-in is legible.
   ---------------------------------------------------------------------------------------
   INSTANT IN, EASED OUT. transition-duration:0s applies the pressed state on the SAME FRAME
   the finger lands, so it can never be caught half-finished. This matters because
   @view-transition (line ~68) is on: the browser snapshots the outgoing page at navigation
   start and cross-fades it in 0.16s, and :active is dropped at pointerup — so any time-based
   press animation is racing a clock set by how long the user happened to hold the button,
   typically 60–100ms. Making it instant removes the race instead of trying to win it.
   The easing lives on the EXIT (the base rule's durations apply when :active is released),
   which is only ever seen when a tap is cancelled — so nothing about navigation gets slower.
   Same principle applied to every other :active state in this file. */
.btn:active { transform: scale(0.97) translate(1px, 1px); transition-duration: 0s; }
/* ---- .event-body: the picture, and whatever fits beside it ----
   The side column asks for 22rem and takes whatever the picture did not use; when less than
   22rem is left it wraps underneath at full width. That is the whole mechanism, and it is flex
   wrap rather than a media query on purpose: THE PICTURE'S WIDTH IS A FUNCTION OF VIEWPORT
   HEIGHT (it is height-capped, so a short laptop gets a short and therefore narrow image), and
   no width breakpoint can express "whatever is left over" when the left-over depends on height.

   ▶ `min-width: min(22rem, 100%)` NOT `min-width: 22rem`. A bare 22rem is 352px, which is wider
   than the content box of a 375px phone once the gutters are taken — the column would refuse to
   shrink and push the page into a horizontal scroll. The min() lets it give up the request when
   the request is impossible, which is exactly the case the wrap exists to handle.

   ▶ align-items: flex-start so a short side column does not stretch to the picture's height and
   leave its own children floating in the middle of a tall box. */
.event-body { display: flex; flex-wrap: wrap; gap: 2rem; align-items: flex-start; }
.event-body > .event-hero { flex: 0 0 auto; }
/* ▶ CENTRED WHEN IT HAS A LINE TO ITSELF, 2026-09-27. Uriah: the picture *"isn't centered at load on the
   event pages"*, meaning on his phone, where the picture is capped by height and so narrower than
   the column, and sat against the left edge. Phone widths only: the desktop layout (picture on its
   own line, left-aligned with the text under it) was not part of the ask. */
@media (max-width: 45.99rem) {
  .event-body > .event-hero { margin-inline: auto; }
  /* ▶ AND BIGGER, 2026-09-27. Uriah: *"especially the event images, they can have a small side margin but
     less than what's there now"* (41px each side at 390px). A small margin, and a height cap loose enough
     that a wide picture fills the width; a square or tall one still stops at 55% of the screen. The
     second selector outranks the short-screen tier further down. */
  .event-body > .event-hero,
  html.vp-lock.vp-short .event-body > .event-hero {
    --hero-max-w: calc(100% - 1.5rem);
    --hero-max-h: calc(var(--vp-h, 100vh) * 0.55);
  }
}
.event-body__side { flex: 1 1 22rem; min-width: min(22rem, 100%); }
/* The side column's first child sets its own top margin; without this it inherits the
   picture's and starts low. */
.event-body__side > *:first-child { margin-top: 0; }

/* ---- .event-hero: the event image, bounded by the SCREEN rather than by the column ----
   Uriah, 2026-09-14: *"individual event pages should always have all information for the event
   and the event image contained within the immediate page view"* and *"display issues on laptop
   and when emulating PC sizes between my big screen and laptop, it's usually that the main
   picture is too big."*

   ▶ MEASURED BEFORE CHANGING ANYTHING, because "too big" has a cause. The image was sized only
   by the column: `max-width:100%` inside a 1100px wrap gives 1024px, and the 16:9 art is then
   575px TALL ON EVERY DESKTOP WIDTH. Against a laptop's usable height that is:

       Laptop 1280x800  ->  575px = 90% of the viewport
       ZBook FHD @150%  ->  575px = 110% — taller than the screen
       Desktop 1920     ->  575px = 62%

   The meta line did not start until y=1014 and the buy button until y=1322, so on every
   desktop size the date, the age restriction and the way to buy a ticket were all below the
   fold. It is worst at laptop sizes precisely because the viewport is SHORT while the column
   is WIDE — which is why it showed up between the big screen and the laptop rather than at one
   extreme.

   ▶ THE CAP IS ON HEIGHT AND IS VIEWPORT-RELATIVE, because the fault is a relationship between
   the art and the screen, not a number of pixels. `vh` is the quantity actually being blown.

   ▶ NO `width: 100%` AND NO `object-fit`, DELIBERATELY. Setting both a full width and a height
   cap makes the BOX 1024x420 while the art is 16:9, so `contain` letterboxes inside a bordered
   frame and `cover` crops someone's poster. Constraining both axes and letting the intrinsic
   ratio settle the rest means the ELEMENT shrinks to the art: no letterbox, no crop, and the
   border still hugs the image.

   Phones are unaffected: at 375px wide the art is already 211px tall, well under the cap. */
/* ▶ AND THEN THE FIRST FIX OVERCORRECTED, 2026-09-14. A two-column head with a 26vh cap
   passed every measurement and looked wrong: *"the picture is tiny and the space for all the
   info is massive but there's not much there at all."* The gate was satisfied and the page was
   worse — a fold contract says what must be VISIBLE, never what should be BIG. Single column
   again, with the vertical budget spent on the picture instead of on an empty column. */
/* ▶ THE CAPS ARE VARIABLES SINCE 2026-10-30 (--hero-max-w, --hero-max-h; every tier below sets them), so the
   rule after this one can size the poster BEFORE it loads. Until then the box was 0px tall until the picture
   arrived and the page jumped (layout shift 0.12-0.19). */
.event-hero {
  --hero-max-w: 100%;
  --hero-max-h: min(calc(var(--vp-h, 100vh) * 0.5), 500px);
  display: block;
  max-width: var(--hero-max-w);
  max-height: var(--hero-max-h);
  width: auto;
  height: auto;
  margin: 0.7rem 0 1rem;
  border: 1px solid var(--line);
}
/* event.php writes the picture's own width and ratio (style="--w:1180;--ar:1180/1474"). The width the browser
   would settle on after loading is the smallest of the picture's width, the width cap, and the height cap times
   the ratio; the ratio then gives the height. Same numbers, known up front. The 2px is the border (border-box):
   the caps hold the border box, the picture's ratio is its content's. `auto` hands the ratio back to the picture
   itself once it has loaded, so the settled size is exactly the old one. */
.event-hero[style*="--ar"] {
  width: min(var(--hero-max-w), calc(var(--w) * 1px + 2px), calc((var(--hero-max-h) - 2px) * var(--ar) + 2px));
  aspect-ratio: auto var(--ar);
}

/* The way back, now at the foot of the page rather than above the art. */
.event-back { margin-top: 2.4rem; }

/* ---- .btn--back: the way back from a single thing ----
   Uriah, 2026-09-14: *"the back to What's On should be a more obvious button saying What Else
   Is On, the back arrow is good though."* .backlink stays for prose-level returns; this is the
   version with weight, for the two event surfaces where the page is a dead end without it.

   ▶ THE ARROW IS IN THE MARKUP, NOT A PSEUDO-ELEMENT, and that is not laziness. `.btn::before`
   is already the hover fill (it scales in from the left) and `.btn--primary::after` is the focus
   ring, so a third decoration would be fighting for a slot on a class whose whole job is to be
   reused. A real span also sidesteps the `content: "x" / ""` alt-text trap that cost the legal
   page numbers their visibility, because there is no generated content to discard.

   aria-hidden on the span: the link's aria-label says where it goes ("Back to What's On").
   2026-09-30, Uriah's copy doc: the words went, the arrow stays on its own, so it has no margin.

   ▶ ONE ARROW, BOTH WAYS (Uriah, 2026-09-30): the back arrow here and the forward arrow on
   `.link-go` are the same drawn shape, mirrored, from Session B's option D: a shaft and a head
   symmetrical about the middle, in a stroke heavier than the font's arrow glyph was. */
.arrow { width: 1em; height: 1em; display: block; overflow: visible;
         fill: none; stroke: currentColor; stroke-width: 1.6; }
.link-go { display: inline-flex; align-items: center; gap: 0.4em; }
.link-go__arrow { display: inline-block; transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1); }
@media (hover: hover) {
  .link-go:hover .link-go__arrow { transform: translateX(0.25em); }
}
.link-go:focus-visible .link-go__arrow { transform: translateX(0.25em); }
@media (prefers-reduced-motion: reduce) {
  .link-go__arrow { transition: none; }
  .link-go:hover .link-go__arrow, .link-go:focus-visible .link-go__arrow { transform: none; }
}
.btn__arrow {
  display: inline-block;
  transition: transform 0.4s cubic-bezier(0.22, 1, 0.36, 1);
}
/* A perfect square (Uriah, 2026-09-30): the box is sized, not left to the padding, which made it a
   rectangle round the drawn arrow. 3em is the height of the site's other buttons. */
.btn--back { width: 3em; height: 3em; padding: 0; }
.btn--back .arrow { width: 1.25em; height: 1.25em; }
@media (hover: hover) {
  .btn--back:hover .btn__arrow { transform: translateX(-0.25em); }
}
.btn--back:focus-visible .btn__arrow { transform: translateX(-0.25em); }
@media (prefers-reduced-motion: reduce) {
  .btn__arrow { transition: none; }
  .btn--back:hover .btn__arrow, .btn--back:focus-visible .btn__arrow { transform: none; }
}
.btn--primary { background: var(--plate); color: #0a0a0a; border-color: var(--plate);
                position: relative; }
/* BLOOM on the primary CTA — the other genuinely bright object on the page. Same technique
   and same reasoning as .emblem::before: a radial gradient behind, painted size only, no
   layout size, never animated.

   ::after is free here because ::before is already the sweep-fill layer. Both sit at
   z-index -1 under `isolation: isolate` on .btn, so neither escapes the button's own
   stacking context.

   The gradient's alpha carries --fx-glow-strength, and the LAYER'S OPACITY carries the
   interaction: it rests below full and comes up to full on hover/focus/press. That way there
   is one strength knob and the interactive state is a ratio of it, not a second literal that
   can drift out of step. Only opacity transitions — the gradient is painted once. */
.btn--primary::after { content: ""; position: absolute;
                       inset: calc(-1 * var(--fx-glow-radius)); z-index: -1;
                       pointer-events: none; opacity: 0.55;
                       background: radial-gradient(closest-side,
                                   color-mix(in srgb, var(--plate) calc(var(--fx-glow-strength) * 100%), transparent),
                                   transparent 100%);
                       transition: opacity 0.28s cubic-bezier(0.22, 1, 0.36, 1); }
@media (hover: hover) {
  .btn--primary:hover::after { opacity: 1; }
}
.btn--primary:focus-visible::after { opacity: 1; }
/* Touch twin — every hover effect in this file has one, or it half-exists on a phone.
   Instant in, eased out, per the .btn:active note above. */
.btn--primary:active::after { opacity: 1; transition-duration: 0s; }
.btn--primary::before { background: #0a0a0a; }
/* ▶ ON TOUCH, THE RELEASE IS INSTANT TOO, 2026-09-27. Uriah, on his phone: tapping See what's on, Full calendar
   or What else is on, *"the fill masks the text or is the same colour, it works but it looks broken"*. The
   press was instant and the release eased, and the two halves of the release ran on different clocks: the
   text colour went back in 0.2s while the fill took 0.3s to wipe away, so for a moment the resting text sat
   on the pressed fill (dark on dark on the primary, light on light on the others). A tap navigates at once,
   so the page kept that frame on screen while the next one loaded. Stamped in, stamped out: every frame is
   either pressed or resting. Desktop keeps its eased hover wipe. Declared after every .btn rule it overrides. */
@media (hover: none) {
  .btn, .btn::before, .btn--primary::after { transition-duration: 0s; }
}
.btn[aria-disabled="true"] { opacity: 0.45; pointer-events: none; }
/* Buttons already compute to 44.24px tall (0.8rem × 1.6 line-height + 0.85em padding + border),
   which clears the 44px floor in WCAG 2.5.5 and Apple's HIG — measured, not assumed. This is a
   guard, not a fix: it holds the floor if the font-size or line-height above ever changes.
   No padding bump — the proportions are already right and don't need touching. */
@media (pointer: coarse) {
  .btn { min-height: 44px; }
}

/* ---- button row (matched CTA pair) ----
   Two buttons side by side were sized by their own label lengths, so "About the room" ran
   ~1 character wider than "See what's on" — obvious once they stacked and their edges lined
   up vertically. Side by side the filled plate also reads larger than the outlined ghost at
   identical metrics (irradiation), which compounds it. Equal grid tracks make both buttons
   genuinely identical in both axes, at every width and in every interaction state.
   auto-fit collapses to one full-width column on narrow screens; the gap keeps stacked tap
   targets apart (WCAG 2.5.8), which inline whitespace did not. */
.btn-row { display: grid; gap: 0.9rem; justify-content: center;
           grid-template-columns: repeat(auto-fit, minmax(min(100%, 15rem), 1fr));
           max-width: 32rem; margin-inline: auto; }

/* ---- hero / wordmark (chrome shimmer, reduced-motion aware) ----
   THE FOLD CONTRACT lives here. Everything in this hero used to be sized off
   viewport WIDTH only, so the block measured a constant 803px tall at 1280,
   1536, 1920 and 3440 alike — it had no relationship to viewport height at all.
   On a 1280x525 laptop (ZBook @150% with a bookmarks bar) that put the CTA
   278px below the fold: a first-time visitor saw an emblem and the word PUBLIC.

   Every scalable element below therefore takes min(<width term>, <height term>).
   `svh` is the SMALLEST viewport — the mobile-toolbar-shown case — which is the
   only unit that means "guaranteed visible"; `dvh` would let content qualify
   only while scrolled. .shell already sizes in svh, so this matches the file.

   --fold-room is the height left over for the three elements that can scale,
   after the fixed overhead of the first viewport. The 245px is MEASURED, not
   guessed: page frame 26 + ticker 36 + header 65 + eyebrow 29 + button row 44 +
   the lockup's own gaps. Each scalable element then takes a share of what's
   left, which is why a tall window still gets a full-size emblem and a short one
   shrinks continuously instead of snapping at a breakpoint.

   Asserted by scripts/fold-check.py; the contract is written up in
   docs/fold-contract.md. Run the harness after ANY change in this block. */
.hero { text-align: center;
        /* ▶ THE EMBLEM'S GLOW LAYERS MADE THE PAGE SCROLL SIDEWAYS ON NARROW PHONES, 2026-09-17.
           Measured on the home page: documentElement.scrollWidth was 342 at a 320px viewport and
           365 at 360px, clean from 375px up. Bisected by removal — hiding `.crt-global`,
           `.intro-bloom`, the header and the footer each recovered 0px; hiding `div.emblem`
           recovered all 22px and all 5px. The `.emb-fx` layers are inset -20% and the glows scale
           past 1.5, so on a 147px emblem they reach about 22px past the screen.

           `clip`, NOT `hidden`: `overflow: hidden` would make .hero a scroll container and break
           the sticky header and the scroll-timeline reveals above it. `overflow-x: clip` with an
           explicit `overflow-y: visible` is the one pairing that stays legal and leaves the
           vertical glow alone — verified computed, not assumed.

           IT CUTS NOTHING THAT WAS VISIBLE. .hero is full-bleed to at least 1024px, so the clip
           edge IS the screen edge and the 22px removed were already off it. At 1440 the hero box
           is 170..1270 while the emblem's widest layer reaches roughly 565..875, so there is ~390px
           of slack — but that slack is the thing to re-measure if the glow ever grows.

           NOTE FOR THE RECORD: stream A's 2026-09-17 log attributed this to `.crt-global` being
           328px wide in a 320px viewport. That is not what this build does — removing
           `.crt-global` changes scrollWidth by 0.

           ▶▶ AND IT IS NOW BEHIND A MEDIA QUERY, BECAUSE IT IS THE PRIME SUSPECT IN A REPAINT
           BUG ON URIAH'S PHONE, 2026-09-17 (later the same day).

           What he sees, and it is not an opacity fault — ?trace=1 recorded NO opacity change
           while it happened: the intro lands, everything fades in including the eyebrow and the
           lede, then *"after a second or two, both disappear for about 4 seconds, but if you
           scroll they return instantly."*

           **"Returns instantly on scroll" is the signature of a dropped repaint, not of a style.**
           And the timing names the other half: `promoteForFade()` in logo-intro.js sets
           `will-change: opacity` on each [data-intro-hold] element and REMOVES it on
           `transitionend` — i.e. one fade after the copy appears, which is "a second or two".
           Tearing down a compositing layer whose subtree sits inside a clipping ancestor is a
           known iOS repaint family: the region is never re-drawn until something dirties it, and
           a scroll dirties it.

           The clip is the new ingredient (it shipped this morning, 600feb4), which is why the
           symptom is new. It is kept where it is actually needed and removed where it is not:
           the sideways scroll was 22px at 320px and 5px at 360px, and CLEAN FROM 375px UP — so
           on a 375px iPhone 8 or a 390px iPhone this rule was buying nothing and risking this.

           ▶ IF THE SYMPTOM SURVIVES THIS, the other ingredient is the one to remove: `?wc=keep`
           on any URL stops logo-intro.js dropping will-change at all, which is the same
           experiment from the other end. Do not change both at once.

           The rule itself now lives in a media query immediately after this one. */
        /* so the wordmark's cqw resolves against the hero's CONTENT box —
           frame and gutter already subtracted — rather than the viewport */
        container-type: inline-size;
        --fold-room: max(0px, var(--vp-h, 100svh) - 245px);   /* --vp-h: public/js/viewport-lock.js */
        /* The emblem's size and drop-shadow offset live HERE, not on .emblem, because the
           lockup below has to derive its clearance from the lift — see .hero__lockup. */
        /* ▶ 96px, NOT 6rem — THE SAME iOS 16 BUG THE WORDMARK HIT, 2026-09-16. Caught on Uriah's
           iPhone 8 (iOS 16.7) by the ?probe=1 readout: the emblem measured 123.2px, dropped to
           EXACTLY 88px — this clamp's floor — and came back, which is the "it shrinks and grows
           again" that survived turning off every effect and even the animation itself.
           iOS 16 drops the unit from a `rem` inside a math function here, so `6rem` computes as
           `6px`: min(6px + 16vw, ...) is ~66px at 375px wide, under the floor, so the floor wins.
           It is intermittent because it only shows when the declaration is re-evaluated — the
           probe caught it on the frame `.fx-live` was added, and the next recalculation put it back.
           96px is 6rem at the default root size, so every other engine renders this identically.
           The accessibility cost is the same one the wordmark's note records: this no longer grows
           with a raised root font size. The emblem is decorative and its `alt` text carries the
           meaning, so the trade is smaller here than it was there.
           ▶ DO NOT PUT `rem` BACK INTO ANY min()/max()/clamp() IN THIS FILE while iOS 16 is a
           supported target — it is the second time this has cost a device-only bug hunt. */
        --emblem-size: clamp(88px, min(96px + 16vw, var(--fold-room) * 0.40), 260px);
        --emblem-lift: calc(var(--emblem-size) / 15);
        padding-block: clamp(1.2rem, min(7vw, calc(var(--vp-h, 100svh) * 0.035)), 4.5rem)
                       clamp(1rem,  min(6vw, calc(var(--vp-h, 100svh) * 0.03)),  4rem); }
/* ▶ ON A PHONE, THE SIZE IT USED TO GROW TO, 2026-09-27. With the height locked at load (viewport-lock.js)
   the emblem no longer grows when the toolbar hides, and Uriah: *"they would look better at the bigger
   size"*. 0.52 of the fold room at the toolbar-shown height is about what 0.40 gave once it hid (measured
   543 -> 633 on his iPhone). Narrow screens only; the landscape tier below still sets its own. */
@media (max-width: 45.99rem) {
  .hero { --emblem-size: clamp(88px, min(96px + 16vw, var(--fold-room) * 0.52), 260px); }
}
/* ▶ HARDEN L2, REDUCED RATHER THAN CLOSED, 2026-09-19 — and the measurements are the point.
   ---------------------------------------------------------------------------------------------
   The emblem's glow layers reach past the viewport and make the page scroll sideways. On
   2026-09-17 that was 22px at 320px, 5px at 360px and clean from 375px up, and the fix was
   `overflow-x: clip` on `.hero` — which stopped it and cost a day, because a clipping ancestor
   with a compositing-layer teardown inside it is an iOS repaint trap and the hero text vanished on
   Uriah's phone. Fencing the clip to <375px rescued his device.

   ▶ RE-MEASURED TODAY, AND IT HAD GOT WORSE WITHOUT ANYONE NOTICING: **54px at 320px and still
   24px at 430px** — the promoted emblem-glow values grew the layers, so the page had begun to
   scroll sideways at EVERY phone width, on a width the fence deliberately leaves unclipped. The
   next deploy would have shipped that.

   ▶ THE CONTAINMENT NOW SITS ON `.emb-fx` (logo-intro.css), which is a decorative sibling layer
   holding no text and no torn-down layers, so it cannot strand copy the way `.hero` did. That
   takes every width from 375px up back to **0px of overflow in both engines**.

   ▶ WHAT IS LEFT, AND WHY THIS FENCE STAYS: 22px at 320px and 6px at 360px — the original figures,
   from the glow children's layout boxes rather than from the `.emb-fx` box, which measures 2..318
   inside a 320px viewport and fits. Capping the spill percentage was tried and does not help for
   that reason. So below 375px the old trade stands: a clip, and the repaint pair with it.

   ▶ TO CLOSE IT PROPERLY: give the emblem its own wrapper element to clip (markup, stream A's), so
   the containment has somewhere to live that is not an ancestor of the hero copy at any width. */
@media (max-width: 374.98px) {
  .hero { overflow-x: clip; overflow-y: visible; }
}/* The lockup's padding and the CTA's top margin were inline styles on index.astro.
   They are ~45px of the fold budget, and the short-viewport block below has to be
   able to compress them — an inline style cannot be overridden by a stylesheet.

   TOP PADDING CLEARS THE EMBLEM'S SHADOW. The shadow is offset by --emblem-lift
   (= size/15) and inset 2px, so it reaches `lift - 2` px below the mark — a distance that
   GROWS WITH THE EMBLEM while a fixed 0.6rem gap does not. Measured on the previous
   version: 2.6px of clearance at 430px wide, 2.8px at 412, 3.0px at 393 — the exact phone
   widths — and an actual overlap of the eyebrow at 744 and 1920. Reported from a real
   phone, where subpixel rounding closed the last 3px.
   Same shape as the wordmark wrap: two quantities that don't scale together, sitting a few
   percent apart, with the platform deciding. So the gap is now a FUNCTION of the shadow —
   whichever is larger, the design's rhythm or enough room to clear it. The +4px is the
   cushion, not the requirement. Asserted by scripts/fold-check.py. */
.hero__lockup { padding-block: max(0.6rem, calc(var(--emblem-lift) + 4px)) 0.6rem; }
.hero__cta { margin-top: 1.6rem; }

/* ---- emblem (the mark, "stamped" in a bone plate) — from under-construction/index.html ---- */
/* Size lives in --emblem-size so there is ONE number to tune, and so the drop shadow can
   derive from it. The old clamp(90px, 18vw, 150px) was floored on every phone and capped on
   every desktop — the vw term only did real work between 500px and 833px, so the mark barely
   responded to screen size at all. The rem term sets the baseline (all screens together); the
   vw term sets how fast it grows (mobile → desktop spread); the cap is where it stops.
   --emblem-lift is size/15, which is exactly the old 10px at the old 150px — the lift is
   preserved at the original size and only scales from there.
   HEIGHT AXIS (2026-08-13): the width-only version was 260px on a 1280x525 laptop, where
   260px is half the usable viewport — 110px of that screen's 243px overflow was this one
   value. It now takes the smaller of the width term and 40% of --fold-room, so a tall
   window still gets the full 260px and a short one yields first. The emblem yields before
   the wordmark does because the title carries the message and the mark only decorates it.
   NOTE: --emblem-size / --emblem-lift are DECLARED ON .hero (above), not here, because
   .hero__lockup needs the lift to clear the drop shadow. This rule only consumes them. */
.emblem { position: relative; width: var(--emblem-size); aspect-ratio: 1 / 1;
          margin-inline: auto; background: var(--plate); display: grid; place-items: center;
          box-shadow: 0 0 0 1px #000,
                      var(--emblem-lift) var(--emblem-lift) 0 -2px rgba(0,0,0,0.55); }
/* BLOOM (2026-08-28). The single biggest "expensive screen" cue available, and until now
   there was none anywhere on the site: every bright object stopped dead at its own boundary.
   The emblem is the brightest thing on the page (a bone plate at #e9e6df), so it is where the
   absence read loudest.

   A radial gradient, NOT filter: blur() — a gradient IS a soft edge, costs no filter pass,
   and cannot be caught mid-recompute. It never animates; it simply exists.

   z-index: -1 puts it behind the plate's own background. Negative inset means it adds PAINTED
   size only and no layout size, which is what keeps the fold contract intact — the emblem's
   box is unchanged and fold-check.py measures boxes. */
.emblem::before { content: ""; position: absolute;
                  inset: calc(-1 * var(--fx-glow-radius) * var(--fx-emb-bloom-size));
                  z-index: -1; pointer-events: none;
                  scale: var(--fx-emb-bloom-sx) var(--fx-emb-bloom-sy);
                  background: radial-gradient(closest-side at var(--fx-emb-bloom-x) var(--fx-emb-bloom-y),
                              color-mix(in srgb, var(--fx-emb-bloom-color)
                                        calc(min(1, var(--fx-glow-strength) * var(--fx-emb-bloom)
                                                 * (1 - var(--fx-emb-bloom-hold) * (1 - var(--emb-live)))) * 100%), transparent)
                              var(--fx-emb-bloom-inner),
                              transparent 100%); }
.emblem::after { content: ""; position: absolute; inset: 0;
                 background: linear-gradient(115deg, transparent 40%, rgba(255,255,255,0.55) 50%, transparent 60%);
                 background-size: 280% 280%; mix-blend-mode: overlay;
                 animation: emblem-sweep 6.5s ease-in-out infinite; pointer-events: none; }
.emblem img { width: 100%; height: 100%; object-fit: contain; display: block; }
@keyframes emblem-sweep { 0% { background-position: 200% 0; } 55%, 100% { background-position: -120% 0; } }
@media (prefers-reduced-motion: reduce) { .emblem::after { animation: none; } }

/* ---- small navbar mark (to the left of the wordmark) ---- */
/* ▶ -0.04em, 2026-09-28. Flex centres the mark on the text's LINE BOX, which reserves room below the
   baseline, so the mark sat a little below the capitals' centre (measured with Inter: 1px on desktop,
   0.33px on a phone). Uriah: the mark *"should be aligned with the words in the center"*. */
.brand__mark { width: clamp(22px, 7vw, 28px); height: clamp(22px, 7vw, 28px);
               position: relative; top: -0.04em;   /* not margin: flex would centre the margin box and move the row */
               object-fit: contain; flex-shrink: 0;
               transition: transform 0.35s cubic-bezier(0.22, 1, 0.36, 1); }
/* ▶ A SMOOTH 30% ENLARGE, NOT A SPRING QUARTER-TURN, 2026-09-28. Uriah picked it from a side-by-side with a
   single full spin: *"stop rotation of navbar emblem ... a 30% smooth enlarge on hover"*. No overshoot. */
@media (hover: hover) { .brand:hover .brand__mark { transform: scale(1.3); } }

/* Shared chrome-shimmer + CRT-flicker text (the under-construction signature).
   Used by the home wordmark AND every page title, for one cohesive voice. */
.wordmark, .page-title {
  font-family: var(--font-display);
  font-weight: 900; text-transform: uppercase; letter-spacing: -0.02em; margin: 0;   /* 900 since 2026-09-28 */
  max-width: 100%;
  background: linear-gradient(100deg, #6f6f6f 0%, #f7f7f7 18%, #bdbdbd 34%, #ffffff 50%,
              #9a9a9a 66%, #ededed 82%, #6f6f6f 100%);
  background-size: 220% 100%;
  /* ▶ SOLID FIRST, GRADIENT ONLY IF THE BROWSER CAN ACTUALLY CLIP IT.
     The transparent fill used to be unconditional, sitting directly beside the clip. That is
     the single most dangerous pattern in this stylesheet: `color: transparent` is a plain
     declaration every engine understands, while `background-clip: text` is the thing that
     paints the letters back. If the clip is unsupported, ignored, or the gradient fails to
     paint for any reason, the browser faithfully applies the transparency and the title is
     INVISIBLE — not unstyled, gone. There is no console error and nothing looks broken except
     that the biggest words on the page are missing.

     Reported 2026-09-14 from an older Samsung, where "the fill of the title and effects" was
     wrong. Whatever the precise cause on that handset, a legacy engine must never be able to
     reach a state where the wordmark is transparent with nothing painting it.

     So: --ink is the declared colour, and the transparency is moved behind @supports. An engine
     that cannot clip gets a solid bone-white wordmark — no chrome, no shimmer, entirely
     legible. An engine that can gets exactly what it got before, because the @supports block
     comes later and wins at equal specificity. */
  color: var(--ink);
  /* DESYNCHRONISED, 2026-08-28. These were BOTH 7s, which meant they were phase-locked: the
     brightness dip landed at the identical point of the shimmer sweep on every single sweep,
     forever, and the dips themselves sat at fixed percentages inside it. Seven seconds is
     comfortably inside the window an eye learns a rhythm in, which is exactly why the effect
     read as "an animation is playing" rather than "a screen is warming up".

     Coprime periods fix it for free. 7 and 11 share no factor, so the combined pattern does
     not repeat for 77s. Nothing about the cost changes — the same two animations run, they
     simply stop agreeing with each other.

     Keep them coprime if you retune. 7/14 would be the same bug with different numbers.

     A THIRD, slower wander (--fx-flicker-drift, 23s) is registered and deliberately NOT
     used. It would take the true repeat to 7 x 11 x 23 = ~30 min, but a third animation on
     opacity does not stack the way it reads: when several animations target one property the
     LAST one wins outright, so it would replace the dips rather than modulate them. Doing it
     properly needs `animation-composition: add` and delta-valued keyframes — a real
     complication for a gain nobody can perceive once the pattern already runs 77s. The knob
     stays registered so the option is visible; adopt it only if 77s ever proves learnable. */
  /* Depth stack — see the --fx-text-* registrations for why drop-shadow and why three
     layers. Tinted from --accent so the whole thing re-tints from one palette value and
     never introduces hue of its own. */
  /* Layer radii are divided by tightness, so raising tightness pulls all three toward the
     glyph at once and keeps their ratio — one knob moves the whole falloff curve. The fringe
     and smear layers cost nothing when their knobs are 0 (a transparent shadow is not
     painted). */
  /* The depth stack has MOVED to .glow-host (below). See that rule for why. */
  animation: chrome  var(--fx-shimmer-rate) linear        infinite,
             flicker var(--fx-flicker-rate) steps(1, end) infinite;
  /* Negative delay = start mid-cycle, no initial wait. Set per element below so no two
     elements running this effect are ever in step. */
  animation-delay: calc(var(--fx-flicker-seed) * -1s),
                   calc(var(--fx-flicker-seed) * -1.7s);
}
/* The wordmark and an interior page title are never on screen together, but the navbar mark
   and a title can be — and two elements dipping in unison reads as a page-wide flash rather
   than as a screen. Distinct seeds, deliberately not round numbers. */
.wordmark   { --fx-flicker-seed: 0;   }
.page-title { --fx-flicker-seed: 3.4; }
/* TWO LINES, STRUCTURALLY — not by luck.
   The old `9vw` sized the title off viewport width while its container ALSO scaled with
   viewport width, so the ratio of "width of SERVICE CENTER" to "available width" was
   near-constant across the whole 338–996px band: permanently within a few percent of
   wrapping to a third line. Which side of the line it landed on was decided by the
   platform's font metrics — headless Chromium wrapped at 375px where a real iPhone 8 did
   not, at the same width. That is a ratio sitting on a threshold, not a breakpoint, and
   no choice of breakpoint could have fixed it.
   So the size is derived from the CONTAINER instead (cqw = 1% of .hero's content box):
   the longer line, "SERVICE CENTER", measures 9.48em in both Chromium and WebKit, so at
   9.4cqw it occupies 89% of the space it has — a measured 11% margin rather than a
   coin-flip. Both candidate splits are 14 characters, so the aesthetic choice of where to
   break does not change this.
   And because a silent wrap looks almost right while an overflow is loud, nowrap turns
   the failure into one a harness can see: scripts/fold-check.py asserts BOTH the two-line
   count and the absence of horizontal overflow. Same reasoning as scripts/.htaccess
   denying by default — make the wrong state detectable rather than plausible.
   The 1.4rem floor only engages below ~390px of viewport height (landscape phones), where
   the reduced contract applies; it stops --fold-room driving the title to nothing.
   NOTE: .page-title shares the block above but NOT this rule — interior pages have no
   fold contract and their titles are free to wrap. Don't tidy them back together. */

/* ▶ NO `rem` IN THIS EXPRESSION, AND IT IS NOT A STYLE CHOICE — IT IS AN iOS 16 BUG.
   ==========================================================================================
   This read `max(1.4rem, min(9.4cqw, 5.6rem, var(--fold-room, 100vh) * 0.155))` until
   2026-09-14. On iOS 16.7 that renders the wordmark at **5.6px**: Safari drops the unit from
   the `rem` terms inside a math function when a CONTAINER QUERY UNIT appears in the same
   expression, so `5.6rem` is treated as `5.6px`, wins the min(), and `1.4rem` becomes `1.4px`
   so the max() floor cannot catch it either. 5.6 is the coefficient, arriving as pixels.

   ▶ MEASURED ON THE DEVICE, because it reproduces nowhere else. Not Chromium, not Playwright's
   WebKit 26, not at ten viewports, not against staging, not with iPhone device profiles — five
   reproduction attempts, five failures, all reporting a correct 25–61px. public/hero-diag.html
   was written to read the numbers off a real iPhone instead of guessing, and did:

       font-size RENDERED   5.6px          <- what the browser actually did
       9.4cqw resolves to   28.9           <- what should have won
       min() winner         9.4cqw = 28.9  <- computed correctly in JS on the same page
       hit the 1.4rem floor YES            <- because the floor was 1.4px, not 22.4px
       wordmark box         308 x 10
       UA                   iPhone OS 16_7_16

   The rest of the site was unaffected because nothing else mixes `cqw` and `rem` in one math
   function — `clamp(1.9rem, 6vw, 3.4rem)` on the page titles is fine, which is exactly why only
   the hero wordmark was microscopic and everything around it looked normal.

   ▶ THE COST, STATED. 22.4px and 89.6px are 1.4rem and 5.6rem at the 16px default, so nothing
   changes for anyone at default text size — but the floor no longer scales if a visitor raises
   their browser's base font size. That is a real accessibility loss, accepted here only because
   this is the DECORATIVE display wordmark: no body text, no navigation and no control uses this
   expression, and all of those still scale with rem. `cqw` already ties this element to its
   container rather than to text preferences.

   ▶ DO NOT "TIDY" THE px BACK TO rem. It will read as an inconsistency. It is the fix. */
.wordmark { font-size: max(22.4px, min(9.4cqw, 89.6px, var(--fold-room, 100vh) * 0.155));
            line-height: 0.92; overflow-wrap: normal; }
.wordmark, .wordmark .l2 { white-space: nowrap; }
.wordmark .l2 { display: block; }
@keyframes chrome { from { background-position: 200% 0; } to { background-position: -20% 0; } }

/* ---- .glow-host — the depth stack, one element up (2026-09-07) -----------------------------
   THE PROBLEM THIS SOLVES, because it is a general one worth recognising elsewhere:

   The wordmark generates its glow with `filter: drop-shadow()` and carves its raster with
   `mask-image`. Both on the same element is a contradiction. **A mask's painting area is the
   border box, so it clips everything the filter paints outside that box** — measured: the glow
   died flat at the element's edge in all three engines, and `mask-clip: no-clip` does NOT lift it.

   The previous fix bought room with padding and gave it back with a negative margin. That worked
   vertically and could never work horizontally: the wordmark's ink sits flush to its own box edge,
   so the only budget is the gutter, and taking the full amount failed containment at every phone
   viewport.

   ▶ THE ACTUAL FIX IS TO STOP ASKING ONE ELEMENT TO DO BOTH. The glyphs keep the gradient, the
   mask and the animations. A wrapper carries the filter. A filter on a parent is applied to the
   child's ALREADY-RENDERED output and is not clipped by any border box, so:

     · the glow is unclipped in BOTH axes, at every setting, with no padding and no negative
       margin, so the fold and frame budgets are untouched;
     · the glow is generated from the ALREADY-MASKED glyphs, so the raster genuinely carves it —
       which is what the effect always intended and what the mask-on-the-same-element version was
       only approximating;
     · `.glow-host` has no text node of its own, so the containment harness skips it and still
       measures the real `<h1>` — the invariant is preserved rather than excluded.

   GENERAL RULE, worth carrying to any other element or project: **a mask clips, a filter does not.
   If one element needs both, put them on different elements — filter on the parent, mask on the
   child.** Reaching for padding to make room for a clipped effect is treating the symptom. */
.glow-host {
  filter:
    drop-shadow(calc(-1 * var(--fx-text-fringe) * 1.5px) 0 calc(var(--fx-text-fringe) * 1px)
      rgba(255, 90, 90, calc(var(--fx-text-fringe) * 0.55)))
    drop-shadow(calc(var(--fx-text-fringe) * 1.5px) 0 calc(var(--fx-text-fringe) * 1px)
      rgba(90, 210, 255, calc(var(--fx-text-fringe) * 0.55)))
    drop-shadow(calc(-1 * var(--fx-text-smear) * 6px) 0 calc(var(--fx-text-smear) * 5px)
      color-mix(in srgb, var(--accent) calc(var(--fx-text-smear) * 35%), transparent))
    drop-shadow(calc(var(--fx-text-smear) * 6px) 0 calc(var(--fx-text-smear) * 5px)
      color-mix(in srgb, var(--accent) calc(var(--fx-text-smear) * 35%), transparent))
    drop-shadow(0 0 calc(var(--fx-text-glow-radius) * 0.3 * var(--fx-text-spread) / var(--fx-text-tightness))
      color-mix(in srgb, var(--accent) calc(var(--fx-text-glow) * 90%), transparent))
    drop-shadow(0 0 calc(var(--fx-text-glow-radius) * 1.0 * var(--fx-text-spread) / var(--fx-text-tightness))
      color-mix(in srgb, var(--accent) calc(var(--fx-text-glow) * 45%), transparent))
    /* ▶ THE WIDEST LAYER IS CAPPED, AND THIS IS A PERFORMANCE FIX WITH NUMBERS BEHIND IT.
       Gaussian blur cost scales with the square of the radius, so this third halo is not one of
       three equal layers - it is essentially the whole bill. Measured on the built page with the
       wordmark animating (headless, so read the RATIO not the absolute): dropping this layer took
       the median frame from 150ms to 33ms at glow 2.0 / radius 40. Uriah reported the ticker
       stuttering at high glow; the lag is in the banner and the cause is here.

       ALSO MEASURED, AND BOTH DID NOTHING: `will-change: filter` and `contain: paint` on the host
       left it at 150ms exactly. They are the two things you would reach for first, and neither
       helps, because the filter is re-run on every repaint of the animated child regardless of how
       the layer is promoted. Do not re-try them.

       The cap is on the RADIUS, not the layer: below ~23px of requested radius nothing changes, so
       the shipped default is untouched and only the extreme end of the slider is bounded. */
    drop-shadow(0 0 min(60px, calc(var(--fx-text-glow-radius) * 2.6 * var(--fx-text-spread) / var(--fx-text-tightness)))
      color-mix(in srgb, var(--accent) calc(var(--fx-text-glow) * 22%), transparent))
    drop-shadow(var(--fx-text-depth) var(--fx-text-depth) 0 rgba(0,0,0,0.55));
}
/* No reduced-motion rule here on purpose: the depth stack is STATIC. A glow that never moves is
   not motion, and this file's precedent already settled that (the polish pass kept static bloom).
   An earlier draft wrote `filter: inherit` here, which would have inherited `none` from the parent
   and silently deleted the effect for exactly the readers it claimed to be looking after. */

/* The chrome fill, gated. See the note in .wordmark, .page-title above: everything here is
   additive, so losing the whole block costs the gradient and costs nothing else. */
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
  .wordmark, .page-title {
    -webkit-background-clip: text;
            background-clip: text;
    color: transparent;
    -webkit-text-fill-color: transparent;
  }
  /* The gradient is painted only inside the box, and at line-height 1 the tails of g, j, p, q and y
     hang below it, so they came out unpainted (Uriah, 2026-09-30, event titles). The padding gives
     the paint room; the equal negative margin keeps every title where it was (its margin is 0). */
  .page-title { padding-bottom: 0.2em; margin-bottom: -0.2em; }
}

/* subtle CRT/neon flicker — brief brightness dips, never fully off (readability).
   The dip depths are now expressed against --fx-flicker-depth rather than as literals, so
   there is one knob for "how hard does it flicker" instead of six numbers to keep in step.
   depth 1 reproduces the values tuned on the holding page EXACTLY (1 - 0.32 = 0.68,
   1 - 0.10 = 0.90); depth 0 is a dead-flat no-op. Raising it past ~1.5 starts to threaten
   readability, which is the whole reason the dips never reach zero. */
@keyframes flicker {
  0%, 17%, 20%, 41%, 44%, 62%, 65%, 100% { opacity: 1; }
  18.5%, 42.5%, 63.5% { opacity: calc(1 - 0.32 * var(--fx-flicker-depth)); }
  19%, 43%, 64%       { opacity: calc(1 - 0.10 * var(--fx-flicker-depth)); }
}

/* ---- page header / title band — cohesive titled top for content pages ---- */
.page-head { padding-block: clamp(1.6rem, 5vw, 2.8rem); }
/* keeps the break-word the shared block used to carry: an interior page title can be a
   long arbitrary string, and wrapping it is the right answer there. */
.page-title { font-size: clamp(1.9rem, 6vw, 3.4rem); line-height: 1; letter-spacing: -0.01em;
              overflow-wrap: break-word; }

.lede { color: var(--ink-dim); font-size: clamp(1rem, 2.2vw, 1.2rem); max-width: 46ch;
        margin-inline: auto; }
/* Height axis, scoped to the hero — .lede is shared with What's On, News and the
   newsletter block, none of which have a fold contract. On a short viewport the lede
   was three lines of 16px; this trades a little size for a line. */
.hero .lede { font-size: clamp(0.95rem, min(2.2vw, calc(var(--vp-h, 100svh) * 0.032)), 1.2rem); }

/* ---- the home tagline band: "Dance music every weekend." (owners, 2026-11-11) ----
   Between the hero and Next transmission. Arrives as the next step of the hero's own sequence: the band is
   [data-intro-hold], so logo-intro.js numbers it after the buttons and it fades at the wordmark's pace
   (--intro-hold-fade, --intro-stagger), and scroll-reveal.js's landing queue starts Next transmission one step
   after it. --reveal-gap (logo-intro.css) is the pause before Next transmission if that junction wants more.
   ▶ ALWAYS THE SAME SIZE RELATIVE TO THE WORDMARK (Uriah, 2026-11-11). The wordmark's size depends on --fold-room,
   which is set on .hero from the screen's height, so on a short screen it shrinks by height as well as width; a
   copy of its formula here would drift (the landscape contract overrides it again). So the inline script after
   the band measures the wordmark's rendered size into --wm-px and this line is that times --tl-ratio, at every
   size. Without the script, the fallback is the wordmark's own formula with --fold-room recomputed here.
   0.748 = the proportion Uriah approved on 2026-11-11 (67px against the wordmark's 89.6px at desktop).
   Matched to the wordmark AS RENDERED by the type engine (font-picker.js: weight 655, -0.022em, sentence case);
   the engine skips .tagline so these values hold.
   ▶ THE FILL IS THE WORDMARK'S (H9: the halogen hot spot drifting over 19s, the 7s shimmer over it, the flicker),
   with its own animation offsets so the two never move in step. The glow is the wordmark's .glow-host, on
   .tagline-host. The --tl-* values were approved on staging with a slider panel (2026-11-11), since removed. */
.tagline-band {
  container-type: inline-size; text-align: center;
  --tl-ratio: 0.748; --tl-ratio-2: 1.06; --tl-weight: 655; --tl-track: -0.022em; --tl-lh: 0.95;
  --tl-gap-top: 0.5rem; --tl-gap-bottom: 0.5rem;
  --tl-wm-fallback: max(22.4px, min(9.4cqw, 89.6px, max(0px, var(--vp-h, 100svh) - 245px) * 0.155));
  padding-block: var(--tl-gap-top) var(--tl-gap-bottom);
}
.tagline {
  /* "PSC Inter Var" is registered at runtime by the type engine for the wordmark; without it, the site face. */
  font-family: "PSC Inter Var", var(--font-display); font-weight: var(--tl-weight);
  letter-spacing: var(--tl-track); line-height: var(--tl-lh); margin: 0; white-space: nowrap;
  font-size: calc(var(--wm-px, var(--tl-wm-fallback)) * var(--tl-ratio));
  color: var(--ink);
}
.tagline .l2 { display: inline; }
.tagline[data-lines="2"] { font-size: calc(var(--wm-px, var(--tl-wm-fallback)) * var(--tl-ratio-2)); }
.tagline[data-lines="2"] .l2 { display: block; }
@container (max-width: 560px) {
  .tagline[data-lines="auto"] { font-size: calc(var(--wm-px, var(--tl-wm-fallback)) * var(--tl-ratio-2)); }
  .tagline[data-lines="auto"] .l2 { display: block; }
}
.tagline[data-variant="dim"] { color: var(--ink-dim); }
.tagline[data-variant="chrome"] {
  background: linear-gradient(100deg, #6f6f6f 0%, #f7f7f7 18%, #bdbdbd 34%, #ffffff 50%,
              #9a9a9a 66%, #ededed 82%, #6f6f6f 100%);
  background-size: 220% 100%;
  animation: chrome var(--fx-shimmer-rate) linear infinite, flicker var(--fx-flicker-rate) steps(1, end) infinite;
  animation-delay: -5.3s, -9.1s;
}
/* The wordmark's own fill, restated from the H9 block further down (.wordmark, .page-title), with offsets:
   the sweep 3.1s into its 7s, the hot spot 8.7s into its 19s drift, the flicker 5.3s into its 11s. */
.tagline[data-variant="wordmark"] {
  background-image:
    linear-gradient(var(--fx-shimmer-angle), var(--fx-shimmer-ramp)),
    radial-gradient(var(--fx-filament-focus) 150% at var(--hx) var(--hy),
      color-mix(in srgb, #fffdf6 calc(var(--fx-halogen-warmth) * 100%), #ffffff) 0%,
      color-mix(in srgb, #ffe9c6 calc(var(--fx-halogen-warmth) * 100%), #f2f2f2) 16%,
      color-mix(in srgb, #e8c08a calc(var(--fx-halogen-warmth) * 100%), #cfcfcf) 38%,
      color-mix(in srgb, #b08a5e calc(var(--fx-halogen-warmth) * 100%), #a8a8a8) 62%,
      #8e8e8e 100%);
  background-size: var(--fx-shimmer-size) 100%, 100% 100%;
  background-blend-mode: var(--fx-shimmer-blend), normal;
  animation:
    chrome var(--fx-shimmer-rate) var(--fx-shimmer-ease) infinite var(--fx-shimmer-direction),
    filament-drift var(--fx-drift-rate) var(--fx-drift-ease) infinite var(--fx-drift-direction),
    flicker var(--fx-flicker-rate) steps(1, end) infinite;
  animation-delay: -3.1s, -8.7s, -5.3s;
}
@supports ((background-clip: text) or (-webkit-background-clip: text)) {
  /* The same solid-first rule as the wordmark: transparent only where the clip can paint it back. */
  .tagline:is([data-variant="chrome"], [data-variant="wordmark"]) { -webkit-background-clip: text; background-clip: text;
    color: transparent; -webkit-text-fill-color: transparent; padding-bottom: 0.12em; margin-bottom: -0.12em; }
}
@media (prefers-reduced-motion: reduce) {
  .tagline[data-variant="chrome"] { animation: none; background-position: 0 0; }
  .tagline[data-variant="wordmark"] { animation: none; --hx: 62%; --hy: 40%; background-position: 50% 0, 0 0; }
}

/* ---- REDUCED FOLD CONTRACT — landscape phones ----
   Under 480px of usable height the full hero cannot fit at any legible size: measured
   658x264, the required stack ran 439px over. So the contract is reduced here to
   navbar + EMBLEM + primary CTA. 480 clears every landscape case in the matrix — the
   tallest is 932x430, iPhone 15 Pro Max with the toolbar retracted — and sits 45px below
   the shortest desktop case (1280x525), so no desktop can ever be silently exempted by it.
   Keep this number in step with REDUCED_MAX_HEIGHT in scripts/fold-check.py; the rule
   itself is documented in docs/fold-contract.md.

   WHY THE MARK AND NOT THE TITLE (Uriah, 2026-08-13): the navbar already reads
   "PUBLIC SERVICE CENTER" at this height, so a giant wordmark under it says the same thing
   twice and leaves nothing that is actually the brand. The emblem is the one element that
   is NOT duplicated in the header, so on the smallest screen it is the one worth keeping.

   NOTHING IS HIDDEN — the lockup and lede are REORDERED below the buttons. They stay in
   the document in source order, so the h1 is still the page's h1 for search engines and
   screen readers (display:none would have removed it from the accessibility tree entirely,
   leaving a landscape phone with no heading at all); they simply stop pushing the CTA off
   a 264px-tall screen. `order` is visual only and nothing here is focusable, so reading
   and focus order are untouched. */
@media (max-height: 480px) {
  .hero { display: flex; flex-direction: column;
          padding-block: 0.5rem 0.8rem; }
  /* the emblem is the whole message here, so it gets its own scale rather than a share of
     --fold-room, whose 245px overhead assumes an eyebrow and lockup that are now below */
  .hero { --emblem-size: clamp(48px, calc(var(--vp-h, 100svh) * 0.2), 110px); }
  .hero__cta { margin-top: 0.8rem; }
  .hero__lockup { order: 1; padding-block: 0.8rem 0; }
  .hero .lede { order: 2; margin-top: 0.6rem; }
}

/* ---- ticker / marquee (reusable) ---- */
.ticker { border-block: 1px solid var(--line); overflow: hidden; background: var(--bg); }
.ticker__track { display: inline-flex; white-space: nowrap; will-change: transform;
                 animation: ticker-scroll var(--tick, 26s) linear infinite; }
.ticker__track span { font-family: var(--font-label); font-size: 0.7rem; letter-spacing: 0.18em;
                      text-transform: uppercase; color: var(--ink-dim); padding: 0.7em 1.4rem; }
.ticker__track b { color: var(--ink); font-weight: 600; }
.ticker__track i { color: var(--ink-faint); font-style: normal; }
.ticker:hover .ticker__track { animation-play-state: paused; }
/* Translate by exactly ONE of the 6 copies rendered in Ticker.astro (100%/6 = 16.6667%) —
   keep this in sync with that component's COPIES constant if it ever changes. */
@keyframes ticker-scroll { from { transform: translateX(0); } to { transform: translateX(-16.6667%); } }

/* ---- cards / grid (What's On) ---- */
/* hairlines come from per-card outlines (not a painted .grid background), so a
   partially-filled last row shows plain page background instead of a filled block */
.grid { display: grid; gap: 1px; padding: 1px;
        /* min(280px, 100%) lets the column floor collapse on sub-320px screens */
        grid-template-columns: repeat(auto-fill, minmax(min(280px, 100%), 1fr)); }
.grid > .card { outline: 1px solid var(--line); }
.card { background: var(--bg-2); padding: 1.4rem; display: flex; flex-direction: column;
        gap: 0.7rem; text-decoration: none; color: inherit; position: relative;
        transition: background 0.2s, box-shadow 0.2s; }
a.card:focus-visible, a.card:active { background: var(--bg-3);
        box-shadow: inset 0 0 0 1px var(--ink-faint); }
/* instant in on press (see the .btn:active note) — a card tap navigates immediately */
a.card:active { transition-duration: 0s; }
@media (hover: hover) {
  a.card:hover { background: var(--bg-3); box-shadow: inset 0 0 0 1px var(--ink-faint); }
}
/* reticle corners — "target acquired" brackets fade in from opposite corners */
.card::before, .card::after { content: ""; position: absolute; width: 12px; height: 12px;
        border: 1px solid var(--accent); opacity: 0; pointer-events: none;
        transition: opacity 0.2s, transform 0.28s cubic-bezier(0.22, 1, 0.36, 1); }
.card::before { top: 7px; left: 7px; border-right: 0; border-bottom: 0; transform: translate(4px, 4px); }
.card::after { bottom: 7px; right: 7px; border-left: 0; border-top: 0; transform: translate(-4px, -4px); }
a.card:focus-visible::before, a.card:focus-visible::after,
a.card:active::before, a.card:active::after { opacity: 1; transform: translate(0, 0); }
a.card:active::before, a.card:active::after { transition-duration: 0s; }
@media (hover: hover) {
  a.card:hover::before, a.card:hover::after { opacity: 1; transform: translate(0, 0); }
}
.card__meta { font-family: var(--font-label); font-size: 0.72rem; letter-spacing: 0.16em;
              text-transform: uppercase; color: var(--ink-dim); }
.card__title { font-size: 1.25rem; margin: 0; text-transform: none; letter-spacing: -0.01em; }
.card__blurb { color: var(--ink-dim); font-size: 0.92rem; margin: 0; }
/* bottom action cue — arrow nudges right on hover */
.card__cta { font-family: var(--font-label); font-size: 0.72rem; letter-spacing: 0.16em;
             text-transform: uppercase; color: var(--ink-dim); margin-top: auto;
             transition: color 0.18s; }
@media (hover: hover) {
  a.card:hover .card__cta { color: var(--ink); }
}
a.card:active .card__cta { color: var(--ink); transition-duration: 0s; }
/* Line icons that say what a link does (2026-09-27) — a ticket on "Tickets" and "Buy tickets", the
   Instagram mark on Instagram — replacing the generic arrows that followed them. Stroked in currentColor. */
.ico { width: 1.15em; height: 1.15em; flex: none; display: inline-block; vertical-align: -0.2em; margin-right: 0.45em;
       fill: none; stroke: currentColor; stroke-width: 1.7; stroke-linecap: round; stroke-linejoin: round; }
.ico .ico-dot { fill: currentColor; stroke: none; }
.card__cta { display: inline-flex; align-items: center; }
.card__cta .ico { margin-right: 0.5em; }
/* The calendar's month steppers: chevrons drawn with borders, like the one on the mobile menu. */
.jump-cal__prev::before, .jump-cal__next::before { content: ""; display: inline-block; width: 0.45rem; height: 0.45rem;
  border-left: 1.5px solid currentColor; border-bottom: 1.5px solid currentColor; }
.jump-cal__prev::before { transform: translateX(2px) rotate(45deg); }
.jump-cal__next::before { transform: translateX(-2px) rotate(-135deg); }

/* ---- card background art (2026-08-13) ----
   An event's hero image, dimmed under a scrim, behind the card's text. Scoped entirely behind
   .card--has-bg so a card with no image renders byte-identically to how it did before this
   existed — the flat card is harness-gated (frame-check/fold-check) and must not move.
   The layer is an <img>, not a background-image: the URL is operator data from the database, and
   an element attribute escapes by construction where a CSS url() does not. See render-events.ts.
   .card already sets position: relative (above); isolation keeps these z-indexes from competing
   with the sticky header. */
/* --ink-dim is the binding constraint, not the title: at #8c8a85 it has only ~5.7:1 on the flat
   panel, so ANY lightening under it drops the blurb and the dateline below 4.5:1 almost at once.
   Lifting dim text — only on cards that actually carry art — is what buys enough headroom to
   show the photograph at all. Without it the scrim has to be so heavy that the image effectively
   disappears, which was the first attempt at this and defeated the point. */
.card--has-bg { overflow: hidden; isolation: isolate; --ink-dim: #c4c1ba; }
/* Blurred on purpose. The brief is COLOUR on the tile, not a picture to look at: blur keeps the
   hue and the light and throws away the detail that competes with small text — and it removes the
   high-frequency contrast that makes a busy photo unreadable under 12px mono. */
.card__bg { position: absolute; inset: 0; width: 100%; height: 100%; z-index: 0;
            opacity: 0.62; filter: saturate(1.25) blur(3px); pointer-events: none;
            transform: scale(1.1); /* hides the transparent fringe blur leaves at the edges */
            transition: opacity 0.2s; }
/* Vertical scrim, heaviest at the bottom where the blurb and CTA sit. The top stays lighter so
   the picture still reads as a picture. Tuned against the two deliberately BRIGHT seed images
   (a near-white drum kit, blown-out white beams) — the worst case for light text on light art —
   and verified by sampling the rendered pixels behind each text element, not by eye. */
/* A near-uniform FLOOR, weighted toward the bottom — not a steep gradient. A steep one has to be
   aimed at where the text happens to sit, and that moves: card height changes with blurb length,
   with the viewport, and with whether a lineup is present. Tuning stops against a moving target
   fixed the dateline and broke the blurb. The floor holds wherever the text lands. */
.card__scrim { position: absolute; inset: 0; z-index: 1; pointer-events: none;
               background: linear-gradient(to bottom, rgba(9, 9, 9, 0.64) 0%,
                                                      rgba(9, 9, 9, 0.64) 35%,
                                                      rgba(9, 9, 9, 0.78) 100%); }
.card--has-bg > :not(.card__bg):not(.card__scrim) { position: relative; z-index: 2; }
/* reticles above the scrim, or they'd be buried by it */
.card--has-bg::before, .card--has-bg::after { z-index: 3; }
@media (hover: hover) {
  a.card--has-bg:hover .card__bg { opacity: 0.42; }
}

/* ---- white block cards that turn to the poster (client ask, 2026-09-29; plan Step 4) ----
   Square white faces with the words; a card with a picture turns to show it on hover, on keyboard focus,
   and on a phone as render-events.ts wireTouchTurns() decides. The <a> never moves (live-arrive.ts owns
   its transform); `.card__cube` turns inside it. The dark card above stays only for the home page's
   white / dark comparison (`data-look-home-cards`); snapshot in docs/retired-work/dark-event-card.css.

   ▶ THE CUBE IS SIZED FROM THE CARD. `container-type` makes 50cqi half the card's width, which is the
   depth of a square block, so the side face sits exactly on the cube's right edge at every width.
   Compared live (Compare panel): `data-look-turn` block (default) or flip; `data-look-card-face`. */
.card--block { --card-face: var(--plate); --turn-ms: 0.7s;
               padding: 0; display: block; aspect-ratio: 1; background: var(--bg);
               container-type: inline-size; perspective: 1400px; }
a.card.card--block:is(:hover, :focus-visible, :active) { background: var(--bg); box-shadow: none; }
.card--block::before, .card--block::after { display: none; } /* the turn is the cue, not the reticles */
/* No hairline round a block card (Uriah, 2026-10-28): the white face draws the square itself, and as the cube
   turns its edges move inside the card, leaving the grid's outline (.grid > .card) showing round the gap. */
.grid > .card--block { outline: none; }
a.card--block:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; z-index: 2; }
.card--turns:is(:hover, :focus-visible, .is-turned) { z-index: 2; } /* the turning corner passes over its neighbours */
:root[data-look-card-face="white"] .card--block { --card-face: #fff; }
:root[data-look-card-face="ink"] .card--block { --card-face: var(--ink); }

.card__cube { position: absolute; inset: 0; transform-style: preserve-3d; transform: translateZ(-50cqi);
              transition: transform var(--turn-ms) cubic-bezier(0.3, 0.7, 0.2, 1); }
.card__face { position: absolute; inset: 0; overflow: hidden;
              -webkit-backface-visibility: hidden; backface-visibility: hidden; }
/* Dark ink on the white face. The page's tokens are for light-on-black, so the face re-points them:
   the pill, the strobe bolt and the ticket icon all read these. */
.card__face--front { --ink: #0a0a0a; --ink-dim: #45433f; --accent: #0a0a0a; --plate: #0a0a0a;
                     background: var(--card-face); color: var(--ink); transform: translateZ(50cqi);
                     padding: 1.4rem; display: flex; flex-direction: column; gap: 0.7rem;
                     transition: background-color 0.18s; }
a.card--block:active .card__face--front { background: color-mix(in srgb, var(--card-face) 88%, #000); transition-duration: 0s; }
.card__face--back { background: #000; transform: rotateY(90deg) translateZ(50cqi); }
.card__poster { display: block; width: 100%; height: 100%; }
/* A square holds a short title and a few lines of blurb; anything longer ends in an ellipsis. The event
   page carries the whole text. */
.card__face--front .card__title { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden;
                                  /* the clamp hides what falls outside the lines, and at the tuned line height
                                     that included the tail of a g (Uriah, 2026-09-30); the padding shows it,
                                     the negative margin keeps the spacing */
                                  padding-bottom: 0.18em; margin-bottom: -0.18em; }
.card__face--front .card__blurb { display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 3; overflow: hidden; }
.card--turns:is(:focus-visible, .is-turned) .card__cube { transform: translateZ(-50cqi) rotateY(-90deg); }
@media (hover: hover) {
  .card--turns:hover .card__cube { transform: translateZ(-50cqi) rotateY(-90deg); }
}
/* Flip: a flat card turning over, the poster on its back. */
:root[data-look-turn="flip"] .card__cube { transform: none; }
:root[data-look-turn="flip"] .card__face--front { transform: none; }
:root[data-look-turn="flip"] .card__face--back { transform: rotateY(180deg); }
:root[data-look-turn="flip"] .card--turns:is(:focus-visible, .is-turned) .card__cube { transform: rotateY(180deg); }
@media (hover: hover) {
  :root[data-look-turn="flip"] .card--turns:hover .card__cube { transform: rotateY(180deg); }
}
/* Reduced motion: no turn, the poster fades in over the face instead. */
@media (prefers-reduced-motion: reduce) {
  .card__cube, :root[data-look-turn] .card__cube, .card--turns .card__cube { transform: none !important; transition: none; }
  .card__face--front, .card__face--back, :root[data-look-turn] .card__face--back { transform: none; }
  .card__face--back { opacity: 0; transition: opacity 0.4s; }
  .card--turns:is(:focus-visible, .is-turned) .card__face--back { opacity: 1; }
}
@media (prefers-reduced-motion: reduce) and (hover: hover) {
  .card--turns:hover .card__face--back { opacity: 1; }
}

/* ---- weeknights (plan Step 6) ----
   render-events.ts marks Monday-to-Thursday cards `.card--weeknight`. With no attribute (production) they
   look like every other card; the staging Compare panel tries a tag and a darker face. */
:root[data-look-weeknight="tag"] .card--weeknight .card__meta::before {
  content: "Weeknight"; display: inline-block; margin-right: 0.6em; padding: 0.05em 0.45em;
  border: 1px solid var(--ink); font-size: 0.85em; }
:root[data-look-weeknight="tint"] .card--weeknight { --card-face: color-mix(in srgb, var(--plate) 82%, #6b6760); }

/* ---- teaser cards (migration 0024, plan Step 5) ----
   A night shown before it is announced: the real date, the title as a redaction, "To be announced". The
   shape is the server's (letters as 'x'), so the styles below only decide how the blanks look. Compared
   live on staging (`data-look-redact`, render-events.ts drawRedaction): bars (default), x, stars, scramble. */
.card--teaser { cursor: default; }
.card__redacted { display: inline; }
/* The bar is the face's ink (the white card re-points --ink to near-black), never currentColor: the
   text colour here is transparent, and currentColor would make the bar transparent too. */
/* A band, not the whole text box: at the title's tight line height full-height bars on two lines touch
   and read as one slab. The teaser title also gets a little more line spacing for the same reason. */
.card--teaser .card__title { line-height: 1.4; }
.card__redacted[data-redact="bars"] .redact { color: transparent;
  background: linear-gradient(transparent 16%, var(--ink) 16%, var(--ink) 84%, transparent 84%);
  -webkit-box-decoration-break: clone; box-decoration-break: clone; padding-inline: 0.04em; }
.card__redacted[data-redact="x"], .card__redacted[data-redact="stars"],
.card__redacted[data-redact="scramble"] { font-family: var(--mono); letter-spacing: 0.04em; }
.card__redacted[data-redact="scramble"] .redact { font-variant-numeric: tabular-nums; }

/* ---- featured next event — the "next transmission" manifest row ---- */
.feature { position: relative; border: 1px solid var(--line); background: var(--bg-2);
           padding: clamp(1.4rem, 4vw, 2.4rem); display: grid; gap: 1.4rem;
           grid-template-columns: 1fr; align-items: center; }
.feature > .reg { z-index: 1; /* stay under the sticky header, unlike the shell's frame marks */ }
@media (min-width: 800px) { .feature { grid-template-columns: 1fr auto; gap: 2.5rem; } }
.feature__title { font-size: clamp(1.5rem, 4.5vw, 2.6rem); margin: 0.25em 0 0.3em;
                  text-transform: none; letter-spacing: -0.01em; }
.feature__blurb { color: var(--ink-dim); margin: 0 0 0.8em; max-width: 56ch; }
.feature__side { display: flex; flex-direction: column; gap: 0.8rem; align-items: flex-start; }
@media (min-width: 800px) { .feature__side { align-items: flex-end; text-align: right; } }
.feature__price { font-family: var(--font-numeral); font-size: 0.78rem; letter-spacing: 0.14em;
                  text-transform: uppercase; color: var(--ink-dim); }
/* No background art since 2026-10-29: the owners asked for a plain box (the event's picture was
   shown blurred behind the text from 2026-09-13). */

/* ▶ THE MARKS ARE REVEALED FROM THE CTA, not from the card. Uriah, 2026-09-13: *"I want it on the
   card like the others, I just want it to trigger when the button is hovered."* The button is a
   descendant, so the card cannot be styled from it without :has().

   ▶ THREE GUARDS, AND EACH ONE EXISTS FOR A DIFFERENT FAILURE:
     · `@media (hover: hover)` — a touch device can never fire the trigger, so it must never be
       given the hidden default. It keeps the static marks.
     · `@supports selector(:has(*))` — same reasoning for a browser without :has(). The hidden
       state is only ever entered by something that can also leave it.
     · `:focus-visible` alongside `:hover` — the cards already respond to focus, and a reveal that
       only a mouse can trigger is a reveal keyboard users are told about but cannot see.

   The 4px settle and the curve are copied from the cards deliberately: the two treatments are
   different mechanisms (spans here, pseudo-elements there) and matching the MOTION is what makes
   them read as one vocabulary despite that. */
@media (hover: hover) {
  @supports selector(:has(*)) {
    .feature > .reg {
      opacity: 0;
      transition: opacity 0.2s, transform 0.28s cubic-bezier(0.22, 1, 0.36, 1);
    }
    .feature > .reg.tl { transform: translate(4px, 4px); }
    .feature > .reg.br { transform: translate(-4px, -4px); }
    .feature:has(.btn--primary:hover) > .reg,
    .feature:has(.btn--primary:focus-visible) > .reg { opacity: 1; transform: translate(0, 0); }
  }
}
@media (prefers-reduced-motion: reduce) {
  .feature > .reg { transition-duration: 0.01ms; transform: none; }
}

/* ---- loading state for fetched lists (What's On, News — 2026-09-26) ----
   The header mark turning clockwise in 12 fixed ticks, flat, like an old-school game sprite —
   Uriah's pick from smooth / 16 / 12 / 8 / 4 steps. It is the header's own 112px image, already
   loaded on every page, so the loading state costs no request. The caption stays: it is the
   role="status" text a screen reader announces. src/lib/live-arrive.ts replaces the whole block
   when the data lands, so there is nothing to tear down. */
.loading { display: flex; align-items: center; gap: 0.9rem; color: var(--ink-dim); }
.loading__mark {
  flex: none; width: 48px; height: 48px;
  background: url(/assets/psc-mark-inverted-112.png) center / contain no-repeat;
  animation: loading-tick 1.5s steps(12) infinite;
}
@keyframes loading-tick { to { transform: rotate(360deg); } }
@media (prefers-reduced-motion: reduce) { .loading__mark { animation: none; } }
/* While What's On's list loads, its space is held open so the footer starts below the screen (2026-10-30). The
   list arrives a second in and is taller than the loading line, so the footer used to jump down in view (layout
   shift 0.27 on a phone); a move that happens out of view doesn't count. Gone the moment live-arrive.ts replaces
   the loading block. */
[data-live-events]:has(> .loading) { min-height: calc(var(--vp-h, 100vh) * 0.85); }

/* ---- strobe symbol, What's On legend + footnote, month headings (2026-09-27) ----
   The strobe bolt takes the colour of the text it sits in (currentColor) and is sized to the line, so
   it reads as part of the card's date line rather than as a badge. Month headings are grid items that
   span the full row, so they arrive on the same cascade as the cards (src/lib/render-events.ts). */
.strobe-icon { display: inline-block; width: 1.05em; height: 1.05em; vertical-align: -0.16em;
               fill: currentColor; }
.strobe-legend { margin: 1.2rem 0 0; color: var(--ink-dim); font-size: 0.95rem; }
.strobe-legend .strobe-icon { color: var(--ink); }
.grid > .month-heading { grid-column: 1 / -1; margin: 1.4rem 0 -0.2rem; font-family: var(--font-label);
                         font-size: 1.2rem; letter-spacing: 0.2em; text-transform: uppercase;
                         color: var(--ink-dim); border-bottom: 1px solid var(--line); padding-bottom: 0.5rem; }
.grid > .month-heading:first-child { margin-top: 0; }
/* What's On jump bar (render-events.ts fillMonthJump). Anything jumped to clears the sticky header,
   the same margin as :target. Month buttons are one row of equal widths (`--cols` is set from the
   count, at most 6 a row): full names on a wide screen, "Oct" on a phone. */
.grid[data-group="month"] > * { scroll-margin-top: calc(4.5rem + var(--frame)); }
.month-jump { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 0.6rem 1.4rem; margin-top: 1.4rem; }
.month-jump[hidden] { display: none; }
.month-jump__months { display: grid; grid-template-columns: repeat(var(--cols, 3), 9rem); gap: 0.5rem; }
.month-jump__btn { padding: 0.6em 0.4em; min-height: 2.6rem; }
.month-jump__short { display: none; }
.month-jump__date { position: relative; }
.month-jump__toggle { gap: 0.6em; padding: 0.6em 1.1em; min-height: 2.6rem; }
.month-jump__toggle svg { width: 1.1em; height: 1.1em; fill: none; stroke: currentColor; stroke-width: 1.6; }
.month-jump__toggle[aria-expanded="true"] { border-color: var(--accent); }
.month-jump__note { flex-basis: 100%; margin: 0; color: var(--ink-dim); font-size: 0.9rem; }
.month-jump__note:empty { display: none; }
@media (max-width: 45.99rem) {
  .month-jump__months { width: 100%; grid-template-columns: repeat(var(--cols, 3), minmax(0, 1fr)); }
  .month-jump__long { display: none; }
  .month-jump__short { display: inline; }
  .month-jump__date, .month-jump__toggle, .jump-cal { width: 100%; max-width: none; }
}

/* The calendar: mono numerals on a hairline grid, the bone plate for nights with an event, the same
   inversion as .btn on hover. */
.jump-cal { position: absolute; z-index: 30; top: calc(100% + 0.5rem); left: 0; width: 19rem; max-width: calc(100vw - 2 * var(--gutter));
            padding: 0.9rem; background: var(--bg-2); border: 1px solid var(--line); box-shadow: 0 18px 40px rgba(0, 0, 0, 0.6); }
.jump-cal[hidden] { display: none; }
:where(.jump-cal button) { font: inherit; color: inherit; background: none; border: 1px solid transparent; cursor: pointer;
                   touch-action: manipulation; }
:where(.jump-cal button:disabled) { cursor: default; }
.jump-cal__head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 0.7rem; }
.jump-cal__title { font-family: var(--font-label); font-size: 0.78rem; letter-spacing: 0.16em; text-transform: uppercase; }
.jump-cal__prev, .jump-cal__next { width: 2.4rem; height: 2.4rem; font-size: 1.2rem; border-color: var(--line); }
.jump-cal__prev:disabled, .jump-cal__next:disabled { opacity: 0.3; }
.jump-cal__week, .jump-cal__days { display: grid; grid-template-columns: repeat(7, minmax(0, 1fr)); gap: 2px; }
.jump-cal__week span { text-align: center; font-family: var(--font-label); font-size: 0.62rem; letter-spacing: 0.1em;
                       color: var(--ink-dim); padding-bottom: 0.4rem; }
.jump-cal__day { aspect-ratio: 1; font-family: var(--font-numeral); font-size: 0.85rem; color: var(--ink-dim);
                 transition: background-color 0.15s, color 0.15s, border-color 0.15s; }
.jump-cal__day:disabled { color: var(--ink-faint); }
.jump-cal__day.has-event { color: var(--ink); background: color-mix(in srgb, var(--plate) 16%, transparent);
                           border-color: color-mix(in srgb, var(--accent) 45%, transparent); }
.jump-cal__day[aria-current="date"] { outline: 1px solid var(--accent); outline-offset: 1px; }
@media (hover: hover) {
  .jump-cal__day:not(:disabled):hover { border-color: var(--line); color: var(--ink); }
  .jump-cal__day.has-event:hover, .jump-cal__prev:not(:disabled):hover, .jump-cal__next:not(:disabled):hover {
    background: var(--plate); color: #0a0a0a; border-color: var(--plate); }
}
.jump-cal__key { display: flex; align-items: center; gap: 0.5rem; margin: 0.8rem 0 0; font-family: var(--font-label);
                 font-size: 0.62rem; letter-spacing: 0.12em; text-transform: uppercase; color: var(--ink-dim); }
.jump-cal__swatch { width: 0.9rem; height: 0.9rem; background: color-mix(in srgb, var(--plate) 16%, transparent);
                    border: 1px solid color-mix(in srgb, var(--accent) 45%, transparent); }
.card.is-jumped { animation: card-jumped 2.4s ease-out; }
@keyframes card-jumped { from, 40% { outline: 1px solid var(--accent); outline-offset: 4px; }
                         to { outline: 1px solid transparent; outline-offset: 4px; } }
@media (prefers-reduced-motion: reduce) { .card.is-jumped { animation: none; outline: 1px solid var(--accent); outline-offset: 4px; } }
.footnote { margin: 2rem 0 0; color: var(--ink-dim); font-size: 0.85rem; }
/* Error pages (src/components/ErrorPage.astro, 2026-09-27): the site's own page, lede and buttons left-aligned. */
.error-page .lede { margin-inline: 0; text-align: left; }
.section.error-page { padding-top: 0; }
.error-page__actions { justify-content: start; margin-inline: 0; margin-top: 1.8rem; }

/* ---- news / media feed (/news/, 2026-08-13) ----
   Built in the browser by src/lib/render-news.ts. Deliberately plain: a stack of items separated
   by hairlines, body text held to a readable measure. Reuses .card__meta for the dateline rather
   than introducing a parallel mono style. */
.news-feed { display: flex; flex-direction: column; }
.news-item { padding: 1.6rem 0; border-top: 1px solid var(--line); }
.news-item:first-child { border-top: 0; padding-top: 0; }
.news-item__title { font-size: clamp(1.25rem, 3vw, 1.6rem); margin: 0.3em 0 0.5em;
                    text-transform: none; letter-spacing: -0.01em; }
.news-item__body { color: var(--ink-dim); margin: 0 0 0.8em; max-width: 62ch; }
.news-item__link { display: inline-block; text-decoration: none; }
@media (hover: hover) {
  .news-item__link:hover { color: var(--ink); }
  .news-item__link:hover::after { transform: translateX(5px); }
}
.news-item__link:active { color: var(--ink); transition-duration: 0s; }

/* ---- back link (detail pages) ---- */
.backlink { font-family: var(--font-label); font-size: 0.75rem; letter-spacing: 0.14em;
            text-transform: uppercase; color: var(--ink-dim); text-decoration: none;
            display: inline-block; transition: color 0.18s; }
/* The plain declaration first for the same reason as the legal section numbers: a browser
   that does not parse the `/ ""` alt-text syntax drops the WHOLE declaration, taking the
   arrow with it. Failing to an announced arrow beats failing to no arrow. */
.backlink::before { content: "\2190";
                   content: "\2190" / ""; display: inline-block; margin-right: 0.5em;
            transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1); }
@media (hover: hover) {
  .backlink:hover { color: var(--ink); }
  .backlink:hover::before { transform: translateX(-4px); }
}
/* touch fallback — instant in (see the .btn:active note) */
.backlink:active { color: var(--ink); transition-duration: 0s; }
.backlink:active::before { transform: translateX(-4px); transition-duration: 0s; }

/* status pill (availability) — ONE TREATMENT FOR EVERY STATE, 2026-09-17.
   ---------------------------------------------------------------------------------------------
   Uriah, after seeing all six candidates over real hero art on staging: *"all pills should just be
   the same as what P3 displays for the 'Free Entry' pill, I think it is most visible against all
   backgrounds."*

   So there is no ladder any more, and no per-state modifier at all. Every pill is the P3 KEYLINE
   treatment: ink text, an `--accent` keyline, and an 8% `--plate` wash.

   ▶ WHAT THIS DECIDES, STATED PLAINLY, BECAUSE IT REVERSES SOMETHING FROM THIS MORNING. The
   emphasis ladder promoted eight hours earlier (P4) sorted the states by what a visitor could do
   and receded the shut ones. This replaces that judgement with a different and better-evidenced
   one: **legibility over hero art beats semantic shading**, measured by eye on a real phone
   against the real photographs, which is a test no candidate here could run. The WORD now carries
   the meaning — "Sold out" and "Free entry" say what they are — and the pill only has to be read.

   ▶ IT ALSO REMOVES THE ART-CARD PROBLEM RATHER THAN DOCUMENTING IT. `.card--has-bg` re-defines
   `--ink-dim` to #c4c1ba but leaves `--ink-faint` alone, so under the ladder the shut states sat
   at #4a4845 (~2:1) over the busiest ground on the page while every neighbour brightened. Nothing
   here depends on either token, so a pill renders identically on a plain card and over art.

   ▶ THE WASH DEGRADES SAFELY. A browser without `color-mix` drops that one declaration and gets
   the keyline and the ink text, which is still a complete, legible pill.

   ▶ `external` STILL HAS NO PILL AT ALL — hasPill() in src/lib/render-events.ts never creates the
   element, because we cannot see another seller's stock and legal-compliance §13 requires
   availability claims to be accurate. That is a claim decision, not a style one, and it stands.

   Record: docs/ui-decisions.md, Promotion 3 (the ladder) and Promotion 4 (this). */
.pill { font-family: var(--font-label); font-size: 0.64rem; letter-spacing: 0.16em;
        text-transform: uppercase; padding: 0.28em 0.6em;
        border: 1px solid var(--accent); color: var(--ink);
        background: color-mix(in srgb, var(--plate) 8%, transparent);
        align-self: flex-start; }

/* ---- .room-modes — the home page's two halves of the week ----
   Promoted from switchboard W4 MIRROR on 2026-09-17. Uriah: *"W1 is the best at the moment, but I
   feel it could be better"* → three refinements built → *"W4 is the best for now, but we will
   definitely come back to it at some point."*

   ▶ THE IDEA: the two halves lean INTO the rule. Left column left-aligned, right column
   right-aligned, so both texts are flush against the hairline and ragged on the outside — the rule
   stops being a border between two things and becomes a spine they hang from. That is a stronger
   statement of "one room, two modes" than a divider is, and it is Uriah's own mobile instinct
   (below) applied above the breakpoint.

   ▶ WHAT TO WATCH WHEN WE COME BACK TO IT: a right-aligned paragraph is harder to read, because
   every line starts somewhere new. It is fine at three or four lines and wrong at a screenful, so
   **if the Friday & Saturday copy grows, this is the first thing to re-judge.** W1 (plain split)
   does not have that exposure; `w1-split.css` is still in dev/variants/ and one keypress away.

   ▶ THE LABEL FOLLOWS ITS COLUMN. A left-aligned label over a right-aligned paragraph reads as a
   mistake rather than as a choice — the one detail that makes mirrored layouts look broken.

   ▶ STACKED IS THE DEFAULT, NOT THE FALLBACK. Every column rule is inside a min-width query over
   the unstyled markup, so a phone gets the shipped HTML with nothing applied to the grid. 46rem is
   a bare rem in a media query CONDITION, which is not the iOS 16 nested-math bug this file bans.

   ▶ AND THE STACKED STATE CARRIES THE CONTRAST ITSELF (Uriah, 2026-09-17): the weekend block is
   right-aligned and the note is left, so the two halves still lean apart once the columns are gone.
   The note is reached as `.room-modes + p` because it is an unclassed sibling in index.astro; if
   that markup moves, the rule stops applying and the note returns to the page default. */
.room-mode h3 { font-family: var(--font-label); font-size: 0.72rem; letter-spacing: 0.12em;
                text-transform: uppercase; color: var(--ink-dim); margin: 0 0 0.6rem; }

@media (min-width: 46rem) {
  .room-modes { display: grid; grid-template-columns: 1fr 1fr; gap: 2rem; }
  .room-modes > .room-mode + .room-mode {
    border-left: 1px solid var(--line); padding-left: 2rem; text-align: right;
  }
  .room-modes + p { text-align: center; }
}

@media (max-width: 45.99rem) {
  .room-modes > .room-mode:last-child { text-align: right; }
  .room-modes + p { text-align: left; }
}

/* ---- forms (newsletter / contact shells) ---- */
.field { display: flex; flex-direction: column; gap: 0.4rem; margin-bottom: 1rem; }
.field label { font-family: var(--font-label); font-size: 0.72rem; letter-spacing: 0.14em;
               text-transform: uppercase; color: var(--ink-dim); }
.field input, .field textarea {
  background: var(--bg-2); border: 1px solid var(--line); color: var(--ink);
  font: inherit; padding: 0.7em 0.8em; border-radius: 0;
}
.field input, .field textarea { transition: border-color 0.18s, box-shadow 0.18s; }
.field input:focus, .field textarea:focus { outline: 1px solid var(--accent); border-color: var(--accent);
  box-shadow: 0 0 0 3px rgba(233, 230, 223, 0.07); }
.field input:disabled, .field textarea:disabled, .form-inline input:disabled {
  opacity: 0.55; color: var(--ink-dim); cursor: not-allowed; }
.form-inline { display: flex; gap: 0.6rem; flex-wrap: wrap; max-width: 460px; }
.form-inline input { flex: 1 1 200px; }

/* ---- ticket table (event buy form) ---- */
.ticket-table { width: 100%; border-collapse: collapse; margin-block: 1rem; }
.ticket-table th { font-family: var(--font-numeral); font-size: 0.72rem; letter-spacing: 0.14em;
                   text-transform: uppercase; color: var(--ink-dim); text-align: left;
                   padding: 0 1.2em 0.6em 0; border-bottom: 1px solid var(--line); }
.ticket-table td { padding: 0.8em 1.2em 0.8em 0; border-bottom: 1px solid var(--line); }
.ticket-table th:last-child, .ticket-table td:last-child { padding-right: 0; }
.ticket-table input[type="number"] { background: var(--bg-2); border: 1px solid var(--line);
                   color: var(--ink); font: inherit; padding: 0.4em 0.5em; width: 4.5rem; }
.ticket-table input[type="number"]:focus { outline: 1px solid var(--accent); border-color: var(--accent); }
.ticket-table .sold-out { color: var(--ink-faint); font-family: var(--font-label); font-size: 0.85rem;
                          text-transform: uppercase; letter-spacing: 0.08em; }

/* ---- checkout: the in-house buy form (event.php) and the page after paying (return.php) ----
   Added 2026-10-13 (overnight pass): Uriah, *"the whole ticket buying workflow isn't up to date with
   current styling of the rest of the site"*. Only the in-house engine renders these classes, so under
   ticketing_mode=external nothing here applies to any page.

   ▶ NOTHING HERE STYLES CARD DATA. The card fields are inside Stripe's Payment Element iframe and are
   themed from checkout.js through Stripe's Appearance API, which is the only sanctioned way (SAQ-A).
   .checkout__pay is just the box the iframe is mounted into.

   ▶ THE BUSY STATE REUSES .loading__mark, the header mark ticking in 12 steps that What's On and News
   show while they load. One spinner for the whole site; it already stops under reduced motion.

   ▶ THE QUANTITY STEPPER'S BUTTONS ARE 44px SQUARE, the touch floor the .btn rules hold, and the number
   input inside stays a real input, so typing a quantity still works and is what checkout.js reads. */
.checkout { max-width: 34rem; margin-top: 1.6rem; display: flex; flex-direction: column; gap: 2.2rem; }
.checkout:not([style*="none"]), .checkout__step--pay:not([hidden]) { animation: checkout-in 0.45s cubic-bezier(0.22, 1, 0.36, 1) both; }
@keyframes checkout-in { from { opacity: 0; transform: translateY(8px); } }
.checkout__step { border: 0; margin: 0; padding: 0; min-width: 0; }
.checkout__step[hidden] { display: none; }
.checkout__step--pay { scroll-margin-top: calc(5rem + var(--frame)); }
.checkout__label { display: flex; align-items: center; gap: 0.75em; width: 100%; padding: 0; margin: 0 0 1rem;
                   font-family: var(--font-label); font-size: 0.72rem; letter-spacing: 0.2em;
                   text-transform: uppercase; color: var(--ink); }
.checkout__label::after { content: ""; flex: 1; height: 1px; background: var(--line); }
.checkout__num { display: inline-grid; place-items: center; width: 1.9rem; height: 1.9rem; flex: none;
                 border: 1px solid var(--ink-faint); color: var(--ink-dim); letter-spacing: 0;
                 font-family: var(--font-numeral); }

/* Ticket types: name over price on the left, the stepper on the right, ruled like the lineup. */
.checkout__types { list-style: none; margin: 0; padding: 0; }
.checkout__type { display: grid; grid-template-columns: 1fr auto; align-items: center;
                  grid-template-areas: "name step" "price step";
                  column-gap: 1rem; row-gap: 0.2rem; padding: 0.85rem 0; border-bottom: 1px solid var(--line); }
.checkout__type:first-child { padding-top: 0.2rem; }
.checkout__type-name { grid-area: name; font-weight: 600; }
.checkout__type-price { grid-area: price; font-family: var(--font-numeral); font-size: 0.85rem;
                        color: var(--ink-dim); font-variant-numeric: tabular-nums; }
.checkout__type .stepper, .checkout__type .sold-out { grid-area: step; }
.checkout__type.is-sold-out .checkout__type-name { color: var(--ink-dim); }
.checkout__type .sold-out { color: var(--ink-dim); font-family: var(--font-label); font-size: 0.72rem;
                            text-transform: uppercase; letter-spacing: 0.14em; padding: 0.5em 0.8em;
                            border: 1px dashed var(--ink-faint); }

.stepper { display: inline-flex; border: 1px solid var(--line); background: var(--bg-2);
           transition: border-color 0.18s; }
.stepper:focus-within { border-color: var(--accent); }
.stepper__btn { appearance: none; -webkit-appearance: none; width: 44px; height: 44px; padding: 0;
                border: 0; border-radius: 0; background: transparent; color: var(--ink);
                font: inherit; font-size: 1.15rem; line-height: 1; cursor: pointer;
                touch-action: manipulation; user-select: none; transition: background-color 0.18s, color 0.18s; }
@media (hover: hover) { .stepper__btn:hover { background: var(--plate); color: #0a0a0a; } }
.stepper__btn:active { background: var(--plate); color: #0a0a0a; transition-duration: 0s; }
.stepper .qty { width: 2.8rem; height: 44px; padding: 0; margin: 0; border: 0; border-radius: 0;
                border-inline: 1px solid var(--line); background: transparent; color: var(--ink);
                font-family: var(--font-numeral); font-size: 1rem; text-align: center;
                font-variant-numeric: tabular-nums; appearance: textfield; -moz-appearance: textfield; }
.stepper .qty::-webkit-inner-spin-button, .stepper .qty::-webkit-outer-spin-button { -webkit-appearance: none; margin: 0; }
.stepper .qty:focus { outline: none; background: var(--bg-3); }

.checkout__total { display: flex; justify-content: space-between; align-items: baseline; gap: 1rem;
                   margin: 1rem 0 0; }
.checkout__total-label { font-family: var(--font-label); font-size: 0.72rem; letter-spacing: 0.14em;
                         text-transform: uppercase; color: var(--ink-dim); }
.checkout__total #subtotal { font-family: var(--font-numeral); font-size: 1.3rem; color: var(--ink);
                             font-variant-numeric: tabular-nums; }

/* Details: the site's .field inputs, then the consent line and the fine print. */
.checkout .field input { font-size: 1rem; }   /* 16px: iOS zooms the page into any smaller input */
.checkout__hint { font-size: 0.85rem; color: var(--ink-dim); margin: -0.3rem 0 1.2rem; }
.checkout__step--pay .checkout__hint { margin: 0.9rem 0 0; }
.checkout__consent { display: flex; align-items: flex-start; gap: 0.75em; font-size: 0.95rem; cursor: pointer; }
.checkout__consent input { flex: none; width: 1.15rem; height: 1.15rem; margin: 0.15em 0 0;
                           accent-color: var(--plate); cursor: pointer; }
.checkout__fine { font-size: 0.8rem; color: var(--ink-dim); margin: 0.9rem 0 0; }
.checkout__consent a, .checkout__fine a { color: var(--ink); }
.checkout__pay { min-height: 3rem; }

.checkout__actions { display: flex; flex-direction: column; align-items: flex-start; gap: 1rem; }
.checkout .btn:disabled { opacity: 0.55; cursor: progress; }
@media (max-width: 30rem) { .checkout__actions .btn { width: 100%; } }
.checkout__status { display: flex; align-items: center; gap: 0.8rem; margin: 0; font-size: 0.92rem;
                    color: var(--ink-dim); }
.checkout__status:empty { display: none; }
.checkout__status[data-tone="busy"] { color: var(--ink); }
.checkout__status .loading__mark { width: 28px; height: 28px; }
.checkout__status[data-tone="error"] { color: #e5484d; padding-left: 0.8rem; border-left: 2px solid #e5484d; }
/* While a reservation or a payment is in flight, the steps above recede so the status line reads as
   the thing happening. Presentation only: checkout.js already holds the button disabled. */
.checkout__step { transition: opacity 0.25s; }
.checkout[aria-busy="true"] .checkout__step { opacity: 0.5; }

/* return.php: thank you, still processing, not completed. Same head and section as the error pages. */
.section.checkout-result { padding-top: 0; }
.checkout-result .lede { margin-inline: 0; text-align: left; }
.checkout-result > * { animation: checkout-in 0.5s cubic-bezier(0.22, 1, 0.36, 1) both; }
.checkout-result > :nth-child(2) { animation-delay: 0.08s; }
.checkout-result > :nth-child(3) { animation-delay: 0.16s; }
.checkout-result > :nth-child(4) { animation-delay: 0.24s; }
.checkout-result__wait { margin-bottom: 1.4rem; }
.checkout-result__wait .loading__mark { width: 36px; height: 36px; }
/* The order card shows its reticle corners at rest: on this page it is the thing confirmed. */
.checkout-result__card { max-width: 34rem; margin-top: 1.8rem; gap: 0; padding: 1rem 1.4rem; }
.checkout-result__card::before, .checkout-result__card::after { opacity: 1; transform: none; }
.checkout-result__row { display: grid; grid-template-columns: 5rem 1fr; gap: 1rem; margin: 0;
                        padding: 0.65rem 0; border-bottom: 1px solid var(--line); }
.checkout-result__row:last-child { border-bottom: 0; }
.checkout-result__key { font-family: var(--font-label); font-size: 0.72rem; letter-spacing: 0.14em;
                        text-transform: uppercase; color: var(--ink-dim); padding-top: 0.2em; }
.checkout-result__ref { font-family: var(--font-numeral); letter-spacing: 0.04em; overflow-wrap: anywhere; }
.checkout-result__next { max-width: 34rem; margin: 2rem 0 0; padding: 0; list-style: none; counter-reset: step; }
.checkout-result__next li { counter-increment: step; display: grid; grid-template-columns: 2.2rem 1fr;
                            gap: 0.5rem; margin-bottom: 0.9rem; color: var(--ink-dim); }
.checkout-result__next li::before { content: counter(step, decimal-leading-zero); padding-top: 0.2em;
                                    font-family: var(--font-numeral); font-size: 0.8rem; color: var(--ink); }
.checkout-result__next a { color: var(--ink); }
.checkout-result__actions { justify-content: start; margin-inline: 0; margin-top: 2.2rem; }
@media (prefers-reduced-motion: reduce) {
  .checkout:not([style*="none"]), .checkout__step--pay:not([hidden]), .checkout-result > * { animation: none; }
  .checkout__step, .stepper, .stepper__btn { transition: none; }
}

/* ---- footer ---- */
.site-footer { border-top: 1px solid var(--line); background: var(--bg); margin-top: auto; }
.site-footer__grid { display: grid; gap: 2rem; padding-block: 2.5rem;
                     grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); }
.site-footer h4 { font-family: var(--font-label); font-size: 0.72rem; letter-spacing: 0.18em;
                  text-transform: uppercase; color: var(--ink); margin: 0 0 0.9em; }
.site-footer ul { list-style: none; margin: 0; padding: 0; }
.site-footer li { margin-bottom: 0.5em; }
.site-footer a { font-size: 0.9rem; color: var(--ink-dim); text-decoration: none;
                 position: relative; display: inline-block; transition: color 0.18s; }
.site-footer a:hover { color: var(--ink); }
/* draw-in underline: enters from the left, exits to the right */
.site-footer a::after { content: ""; position: absolute; left: 0; right: 0; bottom: -2px;
                 height: 1px; background: var(--accent); transform: scaleX(0);
                 transform-origin: right; transition: transform 0.25s cubic-bezier(0.22, 1, 0.36, 1); }
@media (hover: hover) {
  .site-footer a:hover::after { transform: scaleX(1); transform-origin: left; }
}
/* touch fallback — instant in (see the .btn:active note) */
.site-footer a:active { color: var(--ink); transition-duration: 0s; }
.site-footer a:active::after { transform: scaleX(1); transform-origin: left;
                               transition-duration: 0s; }
.site-footer__legal { border-top: 1px solid var(--line); padding-block: 1.2rem;
                      font-family: var(--font-label); font-size: 0.68rem; letter-spacing: 0.1em;
                      text-transform: uppercase; color: var(--ink-faint);
                      display: flex; justify-content: space-between; gap: 1rem; flex-wrap: wrap; }

/* (footer-to-bottom + framing is handled by .shell above) */

/* ---- THE CRT OVERLAY (.crt-global) lived here, 2026-07-18 to 2026-09-29 ----------------------
   One fixed overlay that made the site a CRT screen: two-tone drifting scanlines, a vignette, a
   hairline bezel and a glass sheen. RETIRED 2026-09-29: the client asked for a matte black like the
   club itself, and Uriah compared every layer on and off first. Verbatim code, tuned values and
   history: docs/retired-work/crt-overlay.css; register: docs/retired-work.md. ---- */

/* ---- COMPARE SWITCHES: what is still undecided after the CRT (TEMPORARY, 2026-09-29) ----------
   Switches in the staging Compare panel (public/js/look-picker.js), which sets data-look-* on <html>.
   With NO attribute (production) the site shows the first option. When a choice is made, the winner
   becomes a plain rule and the switch goes. Register: docs/retired-work.md. */
/* Title flicker: off, chosen 2026-09-30 (the Compare panel's first option) and made the default 2026-10-28, so the
   preview and production match staging. :not() outranks the switchboard :root below; "on" stays on staging to compare. */
html:not([data-look-flicker="on"]) { --fx-flicker-depth: 0; }
html[data-look-black="050505"] { --bg: #050505; }
html[data-look-black="000"] { --bg: #000; }

/* A letter kept small when its link is set in capitals: the s of PSAs in the menu and footer (2026-10-28). */
.lc { text-transform: none; }

/* ---- compact-on-scroll header (progressive, Chromium scroll-timeline only) ---- */
@supports (animation-timeline: scroll()) {
  @media (prefers-reduced-motion: no-preference) {
    .site-header__bar { animation: header-compact linear both; animation-timeline: scroll();
                        animation-range: 0 140px; }
  }
}
@keyframes header-compact { to { padding-block: 0.45rem; } }

/* The scroll-range above is a GUESS at when the header matters — 140px of scroll is a proxy
   for "the user has left the top of the page". Scroll-state container queries let the header
   ask the true question instead: am I actually stuck to the top right now?

   Both are kept deliberately. The animation is a smooth compaction over distance; this adds
   the hairline that separates a stuck bar from the content sliding under it, which is the
   part a distance proxy cannot get right — at 140px on a short page the bar may not be stuck
   at all, and on a long one it was stuck long before.

   Progressive: no scroll-state support means no rule, and the existing behaviour is unchanged.

   NOTE ON THE SELECTOR: the border-bottom lives on .site-header, which is the element that
   becomes stuck and therefore the CONTAINER — and a container cannot be styled by its own
   query. So the cue is painted on .site-header__bar, its child. box-shadow rather than a
   border because a border would add a pixel of layout on stick and shift the whole page. */
@supports (container-type: scroll-state) {
  .site-header { container-type: scroll-state; }
  /* ▶ THE HEADER'S FULL WIDTH, NOT THE COLUMN'S, 2026-09-28. The cue was a box-shadow on the bar, which is
     `.wrap` wide, so on a big screen it read as a short mark floating under the nav. Uriah: *"it should just
     be the whole navbar length on bigger screens."* A ::after with no positioned ancestor nearer than the
     sticky header takes the HEADER's box, and sits on its bottom border. */
  @container scroll-state(stuck: top) {
    .site-header__bar::after { content: ""; position: absolute; inset-inline: 0; bottom: -1px; height: 1px;
                               background: var(--ink-faint); pointer-events: none; }
  }
}

/* ▶ LEGAL KEEPS THE QUICK CASCADE, 2026-09-26. Uriah: *"the quick cascading fade is good here"* —
   so the four /legal/ pages stay on `paragraph`, where a block's unit is its whole heading or
   paragraph, while /about/ and /visit/ take the finer `line` mode above. Targeted by page kind
   because that is the one thing that already distinguishes them, and it keeps the decision in one
   place instead of in four page files. */
[data-page-kind="legal"] { --reveal-mode-block: paragraph; }

/* ▶ /visit/ RUNS ITS BLOCKS FASTER THAN /about/, 2026-09-26. Uriah: /about/'s *"speed is good ...
   it should be replicated on visit"*, and separately that Visit *"needs to be faster"*. Those are the
   same request, and the reason they need different NUMBERS is structural rather than aesthetic:
   measured in line mode at 1512px, Visit averages 12.6 units per block against About's 9.4, across 9
   taller blocks instead of 8. `--reveal-spread` is the whole run's length, so more units means a
   denser stagger within the same span — and more page between blocks means longer spent scrolling to
   the next one. The page reads slower while every knob is identical.
   `editorial` is /visit/ alone (about carries no page kind, legal carries its own), so this cannot
   reach another page by accident. */
[data-page-kind="editorial"] { --reveal-spread: 0.5s; }

/* ---- scroll-driven reveals — RETIRED 2026-09-26. READ THIS BEFORE BRINGING ONE BACK ---------
   `rise-in`, `settle-in` and `rule-draw` lived here, all three gated on
   `@supports (animation-timeline: view())` + `prefers-reduced-motion: no-preference`. They were
   the arrival animation for every `.card`, every `.section` that was not already
   `[data-scroll-reveal]`, every prose leaf, and the hairline under every `.prose h2`.

   ▶▶ WHY THEY WENT: THE GATE IS A DEVICE POPULATION, AND NOBODY HAD PRICED IT.
   `animation-timeline: view()` reached WebKit only at Safari 26. The phone this site is measured
   on is an iPhone 8 / iOS 16.7 — `docs/device-capability-matrix.md` calls it *"the entire
   population"* of mobile testing here — and iOS forces every browser onto WebKit. Read off that
   device on 2026-09-26: `CSS.supports('animation-timeline','view()')` → **false**.

   So the gate was false, these rules never applied, and the content rendered instantly at full
   strength. Not a degraded fade — the ABSENCE of one, on six pages (about, visit and the four
   legal pages, whose entire desktop animation was `settle-in` on each block's heading and
   paragraphs), while index faded correctly through the JS path. Uriah, 2026-09-26: *"the fades
   don't seem as good on mobile."* They were not worse. They were missing, unevenly, and no amount
   of tuning could have found that because there was nothing to tune.

   ▶ WHAT REPLACED THEM. One mechanism, on every engine: `public/js/scroll-reveal.js` discovers
   every `.section` and `.prose-block` and reveals it on first intersection. A `.prose-block`
   arrives by its own lines of copy (`--reveal-mode-block: paragraph`); everything else arrives as
   a band (`--reveal-mode: section`). The contract is in logo-intro.css; the coverage is asserted
   by `scripts/reveal-coverage.py` rather than trusted.

   ▶ DO NOT REINTRODUCE `rise-in` OVER `[data-scroll-reveal]`. Scroll-progress and event-triggered
   are not the same product and putting both on one element is a documented fight: `rise-in`
   declares only a `from`, so its `to` is the element's underlying opacity, and a JS system
   setting that to 0 makes it animate 0.001 → 0 — invisible for the whole scroll — then snap.
   The full account is in logo-intro.css's scroll-reveal header, and it cost a fortnight once.

   ▶ WHAT WENT WITH THEM, DELIBERATELY:
     · the `sibling-index()` card stagger — Uriah, twice, with real cards on screen: *"i can't
       tell the difference."* Three cards translating 16px over 120ms underneath a 1.7s band fade.
     · `[data-scroll-reveal] .card { animation: none }` — it existed only to stop `rise-in`
       fighting the band fade. No `rise-in`, nothing to stop.
     · `rule-draw`, the hairline's draw-in. The hairline itself is KEPT, below.

   The header-compact block above is `animation-timeline: scroll()`, a different feature with a
   different population, and is untouched. */

/* A heading is a chapter marker, and the hairline under it is part of that — so it stays.
   ▶ THE LINE IS DESIGN; THE DRAWING OF IT WAS MOTION, and only the second one was negotiable.
   That is this file's own precedent, argued here when `rule-draw` was written: a flourish that
   renders for some readers and not others is worse than no flourish, which is why the
   pseudo-element was declared for everyone and only its animation sat behind the gate. Retiring
   the animation and keeping the line is that rule applied, not an exception to it — reduced-motion
   readers and Firefox already saw exactly what everyone now sees.
   `transform-origin` went with the animation; nothing transforms this any more. */
.prose h2 { position: relative; }
.prose h2::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: -0.35rem; height: 1px;
  background: var(--line, currentColor); opacity: 0.55;
}

/* ---- motion / accessibility ---- */
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  .wordmark, .page-title { animation: none; background-position: 0 0; }
  .ticker__track { animation: none; transform: translateX(0); }
  ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
  .nav-mobile[open] .nav { animation: none; }
  /* Consistent with the rest of this block: transitions off, final states still apply —
     they just land instantly instead of animating. Press feedback therefore survives as
     an instant state change, which is what reduced-motion asks for. .btn and
     .backlink::before are new here; .btn now carries a transform on :active, and
     .backlink::before was already animating and had been missed. */
  .brand__mark, .nav a::before, .nav a::after, .btn, .btn::before,
  .card::before, .card::after, .backlink::before,
  .site-footer a::after, .nav-mobile > summary::after,
  .nav-mobile .nav a { transition: none; }
  /* 2026-08-28 additions. The CTA bloom transitions its opacity, so it belongs in the list
     above's company: the final state still applies, it just lands instantly.

     THE BLOOM ITSELF IS NOT REMOVED, and that is deliberate. A glow that never moves is not
     motion — it is a static paint, like a shadow or a border. Stripping it under `reduce`
     would make the page worse for that user while satisfying nothing the preference asks
     for. Reduced motion means less movement, not less design. The same reasoning keeps the
     screen corners, the edge falloff and the (default-off) sheen: none of them animate. */
  .btn--primary::after { transition: none; }
}


/* ============================================================================================
   PROMOTED 2026-09-14 — the picks, out of dev/ and onto the site.
   ============================================================================================
   Everything below was chosen by Uriah in the switchboard and lifted from `dev/variants/`. The
   full decision record, including the exported preset JSON, is in `docs/ui-decisions.md`.

   ▶ APPENDED AS ONE BLOCK RATHER THAN THREADED INTO THE FILE ABOVE, deliberately.
     Variants are injected AFTER brand.css in the lab, so they win on document order at equal
     specificity and need no `!important`. Appending reproduces that exact cascade position,
     which is what makes "what shipped" the same as "what was seen". It also keeps the first
     visual change the client will see reviewable and revertable as a unit. Folding these rules
     into their topical homes above is a later job, once the picks stop moving.

   ▶ WHAT WAS **NOT** PROMOTED, and it was deliberate on Uriah's part (2026-09-14):
       · NO BACKGROUND. The presets sit on B0. The tuned grain/reflector/turbulence values they
         carry are inert leftovers from cycling — with B0 selected those variants never load and
         nothing registers or consumes those properties.
       · NO MEASURE. The presets sit on M0, so the known left/right asymmetry (41px vs 288px at
         1024px) is STILL PRESENT and is not a regression. M1 fixes it and is ready in dev/.
     *"Yes deliberate, i will change those on the final UI pass. I just wanted to make sure the
     system is working well for the moment."* Both are open items for that pass.

   ▶ THE LABEL AND NAV ROLES ARE NOT MONOSPACE, and that is also as chosen. Those roles are
     styled for mono — uppercase, 0.14–0.28em tracking, down to 0.64rem — so Space Grotesk on
     labels and IBM Plex Serif on the navbar are the most unusual part of this set. Flagged for
     the final pass rather than second-guessed here.
   ============================================================================================ */

/* ---- TYPE: the seven roles, as chosen per-role rather than as a set ------------------------
   F0 BASELINE was selected on the font axis and every role was then overridden individually,
   which is exactly what the per-role choosers exist for. Four families ship out of the nine in
   the playground; the rest are deleted from public/fonts/ along with their licences.

   Only the weights actually used are here. Archivo carries 800 because `.wordmark`,
   `.page-title` and `.brand` are `font-weight: 800` — without that face the browser falls back
   to 700 for the largest type on the site. IBM Plex Sans has no 800 and does not need one; 700
   is its bold and the cascade resolves to it. */
@font-face {
  font-family: "Space Grotesk";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/space-grotesk-400.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Space Grotesk";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/space-grotesk-400-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "Space Grotesk";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/space-grotesk-700.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Space Grotesk";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/space-grotesk-700-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/ibm-plex-sans-400.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/ibm-plex-sans-400-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/fonts/ibm-plex-sans-600.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/fonts/ibm-plex-sans-600-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/ibm-plex-sans-700.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "IBM Plex Sans";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/ibm-plex-sans-700-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "IBM Plex Serif";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/ibm-plex-serif-400.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "IBM Plex Serif";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/ibm-plex-serif-400-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "IBM Plex Serif";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/fonts/ibm-plex-serif-600.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "IBM Plex Serif";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/fonts/ibm-plex-serif-600-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "IBM Plex Serif";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/ibm-plex-serif-700.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "IBM Plex Serif";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/ibm-plex-serif-700-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "Archivo";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/archivo-400.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Archivo";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/archivo-400-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "Archivo";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/archivo-700.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Archivo";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/archivo-700-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "Archivo";
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url("/fonts/archivo-800.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Archivo";
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url("/fonts/archivo-800-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}

/* ---- INTER — the Neue Haas Grotesk stand-in, 2026-09-14 -------------------------------------
   Four weights because this face now carries EVERY role: 400 body, 600 subheads and labels,
   700 headings, 800 the wordmark and page titles. 800 matters — `.brand` and `.page-title` are
   `font-weight: 800`, and without a real 800 the browser synthesises one by smearing 700, which
   on type carrying the chrome gradient looks like a rendering fault rather than a weight.
   The 800 pair was fetched from Fontsource (same source as the rest) on 2026-09-14.
   ------------------------------------------------------------------------------------------ */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/inter-400.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/inter-400-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/fonts/inter-600.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 600;
  font-display: swap;
  src: url("/fonts/inter-600-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/inter-700.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/inter-700-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url("/fonts/inter-800.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url("/fonts/inter-800-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
/* Inter 900 (Black), 2026-09-28: the wordmark and page titles. Uriah chose it over 800 in a side-by-side
   (*"go Inter 900"*): the same width, a little more weight. Fontsource build of the same OFL family. */
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 900;
  font-display: swap;
  src: url("/fonts/inter-900.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Inter";
  font-style: normal;
  font-weight: 900;
  font-display: swap;
  src: url("/fonts/inter-900-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}

/* Public Sans, 2026-09-29: the owners' pick of the free faces, on every role until Neue Haas Grotesk is
   licensed. Only the weights the site renders (measured: 400, 700, 800, 900; nothing uses 600). */
@font-face {
  font-family: "Public Sans";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/public-sans-400.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Public Sans";
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url("/fonts/public-sans-400-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "Public Sans";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/public-sans-700.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Public Sans";
  font-style: normal;
  font-weight: 700;
  font-display: swap;
  src: url("/fonts/public-sans-700-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "Public Sans";
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url("/fonts/public-sans-800.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Public Sans";
  font-style: normal;
  font-weight: 800;
  font-display: swap;
  src: url("/fonts/public-sans-800-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}
@font-face {
  font-family: "Public Sans";
  font-style: normal;
  font-weight: 900;
  font-display: swap;
  src: url("/fonts/public-sans-900.woff2") format("woff2");
  unicode-range: U+0000-00FF,U+0131,U+0152-0153,U+02BB-02BC,U+02C6,U+02DA,U+02DC,U+0304,U+0308,U+0329,U+2000-206F,U+2074,U+20AC,U+2122,U+2191,U+2193,U+2212,U+2215,U+FEFF,U+FFFD;   /* latin */
}
@font-face {
  font-family: "Public Sans";
  font-style: normal;
  font-weight: 900;
  font-display: swap;
  src: url("/fonts/public-sans-900-ext.woff2") format("woff2");
  unicode-range: U+0100-02BA,U+02BD-02C5,U+02C7-02CC,U+02CE-02D7,U+02DD-02FF,U+0304,U+0308,U+0329,U+1D00-1DBF,U+1E00-1E9F,U+1EF2-1EFF,U+2020,U+20A0-20AB,U+20AD-20C0,U+2113,U+2C60-2C7F,U+A720-A7FF;   /* latin-ext */
}

/* ═══ THE ACTIVE TYPE SET ═══════════════════════════════════════════════════════════════════
   ▶ TO CHANGE THE WHOLE SITE'S TYPE, EDIT ONE LINE: --face-brand, below. Every role points at
     it, so there is exactly one place to change and exactly one place to revert. The previous
     set is kept immediately underneath, commented and complete — restoring it is a copy-paste,
     not a reconstruction.

   ▶ WHY IT IS ONE FACE AND NOT SEVEN. The client asked for Neue Haas Grotesk, and when asked
     whether that meant everything — headings, body, labels, nav, numerals — said yes
     (Uriah, 2026-09-14). That is a real design position, not laziness: a single neo-grotesque
     across every role is the Swiss house style the face comes from, and it is what a venue
     brand built on one mark tends to want. It does cost two things, and both are deliberate:
       · the LABEL role loses its monospace "classified" voice (eyebrows, meta, pills, table
         headers). That voice was carrying some of the site's character — if it is missed,
         --font-label is the one line to put back.
       · the NAV loses IBM Plex Serif, which was the 2026-09-14 pick's deliberate odd note.

   ▶ THIS IS A STAND-IN, NOT THE REAL FACE. Neue Haas Grotesk is a licensed Monotype/Linotype
     typeface and cannot be self-hosted here without a paid web licence. Public Sans is the
     owners' pick of the free stand-ins (2026-09-29, from the staging font picker; Inter before
     that), self-hosted under the SIL OFL, so it ships with no licence exposure, no external
     request and nothing to unpick at IP handover. Inter's @font-face blocks stay above: the
     picker offers it, and going back is this one line.
     ▶ THE FULL REASONING, THE PRICES, THE PROCESS AND THE LEGAL POSITION:
       docs/typography-licensing-neue-haas-grotesk.md
     When a licence is bought, drop the woff2 files into /fonts/, add an @font-face block
     beside the others, and change --face-brand. Nothing else in this file moves.

   ▶ FALLBACK ORDER IS NOT ARBITRARY. --grotesk is "Helvetica Neue", Helvetica, Arial — which
     is the correct fallback for this face specifically: Neue Haas Grotesk IS Helvetica, as
     drawn before the phototypesetting era changed it. A device that has Helvetica renders
     something closer to the client's ask than Inter does, and should be allowed to.
   ═════════════════════════════════════════════════════════════════════════════════════════ */
:root {
  /* The one line. Swap this for the licensed face when it is bought. */
  --face-brand: "Public Sans", var(--grotesk);

  --font-display:  var(--face-brand);
  --font-heading:  var(--face-brand);
  --font-subhead:  var(--face-brand);
  --font-body:     var(--face-brand);
  --font-label:    var(--face-brand);
  --font-nav:      var(--face-brand);
  --font-numeral:  var(--face-brand);
}

/* ▶ THE PREVIOUS SET — the 2026-09-14 promotion (docs/ui-decisions.md). Kept verbatim so the
   revert is a paste. Delete the block above and uncomment this one to go back exactly.
:root {
  --font-display:  "Archivo", var(--grotesk);
  --font-heading:  "IBM Plex Sans", var(--grotesk);
  --font-subhead:  "IBM Plex Sans", var(--grotesk);
  --font-body:     "IBM Plex Sans", var(--grotesk);
  --font-label:    "Space Grotesk", var(--grotesk);
  --font-nav:      "IBM Plex Serif", Georgia, serif;
  --font-numeral:  "IBM Plex Sans", var(--grotesk);
}
*/

/* ---- HEADING: H9 FILAMENT + SWEEP ---------------------------------------------------------
   Lifted verbatim from dev/variants/h9-filament-sweep.css (its own header explains the
   treatment). Scored 74.6 against a shipped baseline of 29.8 and a no-glow ceiling of 83.1.

   ▶ IT CARRIES H2'S GRADIENT COMPRESSION, which changes how the existing shimmer looks —
     flatter, less dramatic. That was flagged on 2026-09-12 as the one change in the set that
     alters existing work rather than adding to it. It ships because H9 was chosen as a whole.
   ------------------------------------------------------------------------------------------- */
/* ═══ H9 · FILAMENT + SWEEP ═══ Cumulative: this file IS H6 in full, with the original
   linear chrome shimmer layered back over the hot spot at the bottom. Read to the end. */

/* H6 · FILAMENT — the letters have a hot end and a cool end.
   ============================================================================================
   Replaces the chrome SWEEP with a chrome HOT SPOT. Instead of a linear gradient travelling
   across the type, a small radial source sits at one point and falls away from it — so the
   letters nearest the lamp are white-hot and the letters furthest are dim.

   ▶ WHY THIS IS THE MOST "LAMP" OF ALL THE FILL TREATMENTS. A linear gradient says "this
     surface is metal". A radial one with a tight, bright centre says "there is a point source
     over there, and I can tell you where it is". That is the single strongest cue available
     that a physical fixture exists off-frame, and it costs one gradient.

   ▶ IT ALSO FIXES THE ORIGINAL BUG BY CONSTRUCTION. The shipped linear gradient's problem is
     that it drags a #6f6f6f stop across every letter once every 7 seconds, so any given letter
     is periodically darker than its own halo. A radial hot spot has no travelling dark stop:
     the falloff is stable and positional, so a letter's brightness is a function of WHERE it
     is, not of when you looked. The dark end is dark on purpose and stays put.

   ▶ THE MOTION IS ONE ANIMATED GRADIENT STOP ON ONE ELEMENT, and that is the documented
     budget. Animating a gradient position is PAINT work, not compositor work — fine here,
     never for body copy, and never for more than a couple of elements. @property is what makes
     --hx interpolate at all: an unregistered custom property is a string and jumps between
     keyframes rather than sliding. Registered, it is a typed <percentage> the engine can
     tween. Baseline since July 2024.

   THE COST, STATED: the chrome shimmer as a rhythm is gone. This is a different idea, not a
   refinement of the existing one — which is precisely why it is a variant to cycle rather than
   a change to make. */

@property --hx { syntax: "<percentage>"; inherits: true; initial-value: 22%; }
@property --hy { syntax: "<percentage>"; inherits: true; initial-value: 38%; }

.page-head .glow-host { font-size: clamp(1.9rem, 6vw, 3.4rem); line-height: 0; }
.hero .glow-host {
  font-size: max(22.4px, min(9.4cqw, 89.6px, var(--fold-room, 100vh) * 0.155));
  line-height: 0;
}

:root {
  --fx-text-glow-radius: 0.16em;
  --fx-text-contact: 0.9;
  --fx-halogen-warmth: 0.35;
  --fx-drift-rate: 19s;         /* how long the hot spot takes to cross */
  --fx-filament-focus: 42%;     /* how tight the source is - low = a harder point */
}

.wordmark, .page-title {
  /* The ramp is the halogen signature: white-hot core, warm mid, deep amber shoulder, then a
     dim but NEVER black tail — the darkest stop is #8e8e8e rather than the shipped #6f6f6f,
     because even the cool end of a lit object has to stay above its own halo.
     At warmth 0 every warm stop collapses to a neutral grey and this is a monochrome radial
     chrome, which is the neutral twin of the same variant rather than a different one. */
  background-image: radial-gradient(var(--fx-filament-focus) 150% at var(--hx) var(--hy),
    color-mix(in srgb, #fffdf6 calc(var(--fx-halogen-warmth) * 100%), #ffffff) 0%,
    color-mix(in srgb, #ffe9c6 calc(var(--fx-halogen-warmth) * 100%), #f2f2f2) 16%,
    color-mix(in srgb, #e8c08a calc(var(--fx-halogen-warmth) * 100%), #cfcfcf) 38%,
    color-mix(in srgb, #b08a5e calc(var(--fx-halogen-warmth) * 100%), #a8a8a8) 62%,
    #8e8e8e 100%);
  background-size: 100% 100%;
  /* The `chrome` keyframe animates background-position, which does nothing to a 100%-sized
     radial gradient. Replaced with a slow drift of the source itself. */
  animation:
    filament-drift var(--fx-drift-rate) ease-in-out infinite alternate,
    flicker var(--fx-flicker-rate) steps(1, end) infinite;
}

/* 19s against the 11s flicker: coprime, so the combined pattern does not repeat for 209
   seconds. Same reasoning brand.css records for choosing 7 and 11 over 7 and 7 — a rhythm the
   eye can learn is what makes an effect read as "an animation is playing" rather than as a
   room. Do not round this to 20s. */
@keyframes filament-drift {
  from { --hx: var(--fx-hx-from); --hy: var(--fx-hy-from); }
  to   { --hx: var(--fx-hx-to);   --hy: var(--fx-hy-to); }
}

@media (prefers-reduced-motion: reduce) {
  .wordmark, .page-title { animation: none; --hx: 30%; --hy: 38%; }
}

.glow-host {
  --halogen-core: color-mix(in srgb, #fff4e2 calc(var(--fx-halogen-warmth) * 100%), #ffffff);
  --halogen-spill: color-mix(in srgb, #ff9c3c calc(var(--fx-halogen-warmth) * 100%), var(--accent));
  filter:
    drop-shadow(0 0 0.012em rgba(0, 0, 0, var(--fx-text-contact)))
    drop-shadow(0 0 0.030em rgba(0, 0, 0, calc(var(--fx-text-contact) * 0.75)))
    drop-shadow(0 0 calc(var(--fx-text-glow-radius) * 0.07)
      color-mix(in srgb, var(--halogen-core) calc(var(--fx-text-glow) * 100%), transparent))
    drop-shadow(0 0 calc(var(--fx-text-glow-radius) * 1.3)
      color-mix(in srgb, var(--halogen-spill) calc(var(--fx-text-glow) * 18%), transparent))
    drop-shadow(0 0 min(60px, calc(var(--fx-text-glow-radius) * 3.2))
      color-mix(in srgb, var(--halogen-spill) calc(var(--fx-text-glow) * 10%), transparent))
    drop-shadow(var(--fx-text-depth) var(--fx-text-depth) 0 rgba(0, 0, 0, 0.55));
}

/* ▶ ITS SCORE IS NOT DIRECTLY COMPARABLE WITH THE OTHERS, FOR TWO REASONS. Measured 45.7 on
   .wordmark against H5's 74.3, and both reasons are structural rather than a verdict:

   1. The effect's whole IDEA is that one end of the word is dim. definition-check.py scores
      the worst case, so a deliberate cool end is indistinguishable to it from an accidental
      one. Lc 66.2 still clears the APCA 45 floor for display type comfortably; it is a
      legibility trade made on purpose, not a failure.

   2. definition-check.py pins the animation phase by setting a negative animation-delay across
      a 7-SECOND period, because that is the shipped `chrome` sweep. This variant replaces that
      animation with a 19s drift, so the harness samples four points of a cycle it has the
      wrong length for and never sees the true worst phase. The number is indicative, not
      authoritative. Logged in TESTING-BACKLOG.md.

   Judge this one by eye. It is the variant the harness is least equipped to rank. */


/* ════════════════════════════════════════════════════════════════════════════════════════
   H9 · FILAMENT + SWEEP — the hot spot AND the shimmer, together.
   ════════════════════════════════════════════════════════════════════════════════════════
   Uriah, 2026-09-12: *"I like the original shimmer / gradient shift on the normal one that is
   similar to the one on the logo, so if there was a way to test that alongside others that
   would be good too."*

   ▶ WORTH KNOWING FIRST: the original shimmer is ALREADY in H1-H5, H7 and H8. Those variants
     only override `background-image`; brand.css's `background-size: 220%` and its `chrome`
     keyframe carry straight through, so all of them still sweep. **H6 is the only one that
     removed it**, because a 100%-sized radial gradient has nothing for a background-position
     animation to move. So "the shimmer alongside the others" is really "the shimmer back on
     H6", and that is this file.

   ▶ HOW TWO GRADIENTS COEXIST ON ONE GLYPH. `background-image` takes a LIST, and each layer
     gets its own `background-size` and `background-position`. So the linear sweep is layer one
     at 220% (wide enough to travel) and the radial hot spot is layer two at 100% (static box,
     moved by --hx/--hy instead). `background-blend-mode: overlay` combines them: the hot spot
     supplies the underlying brightness structure and the sweep rides over it as a moving
     specular, which is exactly the relationship a polished metal sign has with a lamp.

     Overlay rather than screen: screen only ever lightens, so the sweep would wash the cool end
     of the filament back up to bright and undo the thing H6 exists for. Overlay preserves the
     dark end and brightens the light end — it keeps the hot/cool axis and adds movement to it.

   ▶ THE TWO ANIMATIONS ARE COPRIME. The sweep is brand.css's 7s; the drift defaults to 19s.
     7 and 19 share no factor, so the combined pattern does not repeat for 133 seconds. Keeping
     them coprime is the same rule brand.css records for 7 and 11, and for the same reason: a
     rhythm the eye can learn is what makes an effect read as an animation rather than a room.
     If you slide the drift rate, avoid multiples of 7. */
.wordmark, .page-title {
  background-image:
    linear-gradient(var(--fx-shimmer-angle), var(--fx-shimmer-ramp)),
    radial-gradient(var(--fx-filament-focus) 150% at var(--hx) var(--hy),
      color-mix(in srgb, #fffdf6 calc(var(--fx-halogen-warmth) * 100%), #ffffff) 0%,
      color-mix(in srgb, #ffe9c6 calc(var(--fx-halogen-warmth) * 100%), #f2f2f2) 16%,
      color-mix(in srgb, #e8c08a calc(var(--fx-halogen-warmth) * 100%), #cfcfcf) 38%,
      color-mix(in srgb, #b08a5e calc(var(--fx-halogen-warmth) * 100%), #a8a8a8) 62%,
      #8e8e8e 100%);
  background-size: var(--fx-shimmer-size) 100%, 100% 100%;
  background-blend-mode: var(--fx-shimmer-blend), normal;
  /* Three animations now: the sweep (position), the drift (the hot spot), the flicker. The
     sweep animates only the FIRST layer's position; the second is pinned at 0 0 so the hot
     spot stays where --hx/--hy put it. */
  animation:
    chrome var(--fx-shimmer-rate) var(--fx-shimmer-ease) infinite var(--fx-shimmer-direction),
    filament-drift var(--fx-drift-rate) var(--fx-drift-ease) infinite var(--fx-drift-direction),
    flicker var(--fx-flicker-rate) steps(1, end) infinite;
}

/* brand.css's `chrome` keyframe animates `background-position` as a single value, which would
   move BOTH layers. Restated here for two layers so only the sweep travels. */
@keyframes chrome {
  from { background-position: var(--fx-shimmer-from) 0, 0 0; }
  to   { background-position: var(--fx-shimmer-to) 0, 0 0; }
}

@media (prefers-reduced-motion: reduce) {
  .wordmark, .page-title { animation: none; --hx: 30%; --hy: 38%;
                           background-position: 50% 0, 0 0; }
}


/* ---- LAYOUT: L7 on [data-page-kind="editorial"] — About, Contact, and every other prose-led page ----
   Scoped rather than global: L7 and the other two promoted layouts all restyle
   `.prose-block > h2`, so unscoped the last one in this file would win on every page. The
   attribute is set once in src/layouts/Base.astro. Variant header comments are kept — they
   carry the reasoning, and a stylesheet without it becomes folklore.
   ------------------------------------------------------------------------------------------- */
/* L7 · EDITORIAL — About and Visit stop speaking the legal pages' language.
   ============================================================================================
   Uriah, 2026-09-12: *"I'm not settled on the ones for about and visit yet although I think the
   baseline is still the best, but I think we can do better. ... since there's nothing but text
   it just feels like more legal pages."*

   ▶ THE DIAGNOSIS THIS CANDIDATE ACTS ON. Every layout built before today was a DOCUMENT
     layout — rules, numbers, plates, rails. Applied to About and Visit they make the problem
     worse, not better, because the complaint was never "these pages lack structure". It was
     that a venue's about page and its terms of sale currently use ONE typographic vocabulary,
     so the reader cannot tell from a glance which kind of page they are on. L4 through L6 all
     make the two families more alike. This one makes them less alike, which is the only move
     that can actually answer the note.

     So: the legal pages keep L4's numbered sections and stay documents; About and Visit get an
     editorial register — a lede that announces itself (where one exists), far fewer and larger
     structural signals, and space used as punctuation rather than filled.

   ▶ WHAT MAKES A PAGE READ AS EDITORIAL RATHER THAN LEGAL, concretely, because "feels like a
     magazine" is not implementable:
       1. A LEDE AT A DIFFERENT SIZE. One paragraph set materially larger than the body tells
          the reader the page has an opening rather than a first item. The wrappers leave any
          paragraph before the first heading outside every .prose-block, so it is selectable as
          `.prose.stack > p:first-child` without a single markup change.

          ▶ BUT ONLY /about/ HAS ONE. Checked in the browser rather than assumed, after an
          earlier draft of this comment claimed both pages did: /visit/ opens straight into
          `<h2>Where we are</h2>`, so on that page this rule matches nothing and the layout
          delivers three of its four devices instead of four. That is a CONTENT gap, not a CSS
          one — Visit has no opening line because nobody has written one — and it is worth
          putting to Uriah directly, because a page that starts with a bare section heading
          reads as a form no matter what is done to it typographically. It is also exactly the
          kind of thing the switchboard's `[` filler mode hides, which is why this was checked
          against C0 and not against filler.
       2. HEADINGS THAT RECEDE. A legal heading is a label you scan for. An editorial heading is
          a breath between passages, so it goes DOWN in weight and up in space — the opposite of
          L4, which makes it the cleanest possible A/B.
       3. SPACE AS PUNCTUATION. Section separation roughly doubles, and there are no rules,
          numbers or borders at all. Nothing is added to the page; something is taken away.
       4. AN END MARK. A small registration square after the final section, in the site's own
          hairline vocabulary — the one editorial device this identity can carry without
          borrowing from a different design language entirely.

   ▶ DELIBERATELY NO DROP CAP. It is the first device anyone reaches for and it is wrong here
     twice over: it needs a serif or a high-contrast display face to look like anything (and
     the shipped stack is Helvetica), and `::first-letter` float behaviour is genuinely
     inconsistent once a heading follows within a couple of lines — which on the four-paragraph
     About page is every case. Cycle F3 on with this layout if you want to see the version of
     this idea that WOULD support a drop cap; then it is worth revisiting.

   ▶ JUDGE THIS AT REALISTIC CONTENT VOLUME, NOT TODAY'S. About is 60% client placeholder: two
     of its five paragraphs are 🔸 CLIENT slots. An editorial layout is a layout for copy that
     exists — it will look emptiest on exactly the page that is emptiest. Press `[` in the panel
     to fill the pages out to a realistic length first. Uriah's own second diagnosis —
     *"we don't have much in the way of the proper copy yet so it feels sparse"* — is probably
     the larger half of the problem, and this candidate cannot fix that half. */

@property --fx-ed-lede  { syntax: "<number>"; inherits: true; initial-value: 1.20; }

@property --fx-ed-space { syntax: "<number>"; inherits: true; initial-value: 4.0; }

/* 1 · THE LEDE. Only ever the paragraph that opens the page — never a paragraph inside a
   section, which would read as random emphasis. On /about/ that is the opening paragraph; on
   /visit/ it matches NOTHING, because that page has no opening line (see the header note). On
   the legal pages it also matches nothing, which is correct — they open with a section. */
[data-page-kind="editorial"] .prose.stack > p:first-child {
  font-size: calc(1rem * var(--fx-ed-lede));
  line-height: 1.45;
  letter-spacing: -0.005em;
  /* ▶ QUIETER SINCE 2026-09-27: 1.2 and --ink-dim, from 1.3 and --ink. Uriah: the blurb on About and
     Visit *"needs to be less prominent, a bit smaller and slightly duller, closer to the blurb on
     What's On"*, which is the shared `.lede` (1rem to 1.2rem, --ink-dim). */
  color: var(--ink-dim);
  max-width: 46ch;          /* a shorter measure at a larger size — same optical line length */
  margin-bottom: calc(1rem * var(--fx-ed-space) * 0.5);
  text-wrap: pretty;
}

/* 2 · HEADINGS RECEDE. Smaller, lighter, lower-contrast, wide-tracked — a breath, not a label.
   This is the single declaration block that most separates L7 from L4, and it is worth
   cycling the two directly against each other on /visit/ to feel how much of "reads like a
   legal page" was carried by heading weight alone. */
/* ▶ AND THE SAME HEADING INSIDE A <summary>, 2026-09-17. Uriah: *"I meant the Headings be the
   same as the sections for the about page."* /about/ and /visit/ are BOTH `editorial`, so they
   were always meant to match — what broke it is that this selector is a CHILD combinator, and
   wrapping the heading in a `<summary>` for the disclosure moved it one level down where the rule
   could no longer reach it. The h2 then fell back to the base `clamp(1.4rem, 3.4vw, 2.1rem)`,
   which is why /visit/'s headings came out large and unlike About's.
   **A child combinator is a promise about structure, and a disclosure changes the structure.**
   The h3 is listed too: `Parking` is an h3 and must read as a section heading like the rest. */
[data-page-kind="editorial"] .prose-block > h2,
[data-page-kind="editorial"] .prose-block > summary > h2,
[data-page-kind="editorial"] .disclosure > summary > h3 {
  font-size: clamp(0.92rem, 1.15vw, 1.05rem);
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: 0.2em;
  color: var(--ink-dim);        /* 5.74:1 — never --ink-faint, which fails every threshold */
  margin: 0 0 1.1rem;
}

/* brand.css's animated under-hairline is a document device. Removing it is most of the point:
   an editorial heading is separated by space, not by a line. */
[data-page-kind="editorial"] .prose-block > h2::after,
[data-page-kind="editorial"] .prose-block > summary > h2::after,
[data-page-kind="editorial"] .disclosure > summary > h3::after { content: none; }

/* ▶ BUT NOT THE 1.1rem BELOW IT, WHEN THE HEADING IS A SUMMARY. That margin is the editorial
   layout's space-as-punctuation, and inside a `<summary>` it is not separation — it is a taller
   flex line, which pushed the `+` a clear 10px below the heading it belongs to and padded every
   collapsed row by the same amount. Same specificity as the rule above and immediately after it,
   because the generic `.disclosure > summary > h2 { margin: 0 }` further up the file is (0,2,0)
   and loses to the editorial selector on both counts. */
[data-page-kind="editorial"] .prose-block > summary > h2,
[data-page-kind="editorial"] .disclosure > summary > h3 { margin: 0; }

/* 3 · SPACE AS PUNCTUATION. No rules, no borders, no numbers — the separation is the signal. */
[data-page-kind="editorial"] .prose-block { margin-top: calc(1rem * var(--fx-ed-space)); }

[data-page-kind="editorial"] .prose-block:first-child { margin-top: 0; }

/* ▶ HAIRLINES BETWEEN SECTIONS, 2026-09-27. Uriah: *"hairline dividers on About and on the PC version
   of Visit too, to split the sections up a bit."* This reverses point 3's "no rules" for these two
   pages, on his call. It is the site's own divider (the fading `.section + .section` rule above),
   centred in the gap so the spacing is unchanged. Not on a phone's /visit/: there the sections are
   collapsed questions with their own top borders already. */
[data-page-kind="editorial"] .prose-block + .prose-block { position: relative; }
[data-page-kind="editorial"] .prose-block + .prose-block::before {
  content: ""; position: absolute; inset-inline: 0; height: 1px;
  top: calc(-0.5rem * var(--fx-ed-space));
  /* --rule-text, not --line (2026-09-28). Uriah: *"the hairline dividers could be a bit more prominent ...
     when it's all text it doesn't break it up as much as it should."* #2a2a2a at half a device pixel
     all but vanished on a page with nothing else to separate its parts. */
  background: var(--rule-text); pointer-events: none;
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 var(--fx-rule-fade),
                      #000 calc(100% - var(--fx-rule-fade)), transparent);
          mask-image: linear-gradient(90deg, transparent, #000 var(--fx-rule-fade),
                      #000 calc(100% - var(--fx-rule-fade)), transparent);
}
@media (max-width: 45.99rem) {
  [data-page-kind="editorial"] .disclosure + .disclosure::before { content: none; }
}

[data-page-kind="editorial"] .prose-block > p { line-height: 1.62; }

/* 4 · THE END MARK WAS REMOVED ON 2026-09-13, at Uriah's request. It was a 0.5rem registration
   square closing the last section the way a printed article closes. Recorded here rather than
   left commented out: a device that appears on one or two pages of a seven-page site reads as a
   stray mark rather than as punctuation, and the vocabulary it borrowed from — the shell's four
   corner registrations — is already doing that job at the frame. */

@media (max-width: 62rem) {/* The lede's size advantage has to shrink or it wraps to six lines on a phone and stops
     reading as an opening. The rest of the layout is width-independent by construction. */
[data-page-kind="editorial"] .prose.stack > p:first-child { font-size: clamp(1rem, 2.2vw, 1.2rem); }}

/* ---- LAYOUT: L4 on [data-page-kind="legal"] — the four /legal/ pages ----
   Scoped rather than global: L4 and the other two promoted layouts all restyle
   `.prose-block > h2`, so unscoped the last one in this file would win on every page. The
   attribute is set once in src/layouts/Base.astro. Variant header comments are kept — they
   carry the reasoning, and a stylesheet without it becomes folklore.
   ------------------------------------------------------------------------------------------- */
/* L4 · SECTION RULES — the half of L3 that was worth keeping. Uriah's pick for the legal pages.
   ============================================================================================
   A full-measure hairline above every heading, and nothing else. No columns, no rail, no
   horizontal split: every paragraph runs the full text measure, in order, the way it always
   did. The only change is that the page now has visible PARTS.

   Why it exists: L3 tried to do this and multi-column at the same time, and the multi-column
   half fired inconsistently — sections with several paragraphs split into two columns while
   short ones did not, so the page split and unsplit as you scrolled. The rules were the good
   idea; the columns were the arbitrary one.

   This is also the most conservative candidate in the set. It changes rhythm and nothing else,
   it is identical on a phone and a desktop, and there is no reading-order question to answer
   because nothing moves. If the rail layouts feel like too much, this is the floor.

   ▶ REWRITTEN 2026-09-13 ONTO `.prose-block`, and the rewrite is not cosmetic.
     This file used to hang everything off `.prose.stack > h2` and fake the section boundary
     with `:first-child`. Now that a section is a real element, the rule, the counter and the
     spacing all belong to the SECTION, which is what they were always describing. Two things
     fall out for free that the old version could not express at all:
       · the counter increments per section rather than per heading, so a section carrying an
         h3 or no heading at all still numbers correctly;
       · the first-section special case is `:first-child` on the block, not a guess about which
         heading happens to come first — on /about/ and /legal/terms/ the first child of .prose
         is a LEDE PARAGRAPH, so the old `h2:first-child` rule never matched and the opening
         section kept a rule above it that no other layout drew.
     THIS IS THE PICK, so it is the one that has to be right rather than merely demonstrative. */
[data-page-kind="legal"] .prose-block {
  margin-top: 2.4rem;
  padding-top: 1.15rem;
  border-top: 1px solid var(--rule-text);   /* was --line; see --rule-text (2026-09-28) */
}

/* A lede paragraph before the first heading still wants separating from the first section, so
   the rule is suppressed only when the block genuinely opens the page. */
[data-page-kind="legal"] .prose-block:first-child { margin-top: 0; padding-top: 0; border-top: 0; }

[data-page-kind="legal"] .prose-block > h2 {
  margin-top: 0;
  font-size: clamp(1.05rem, 1.6vw, 1.3rem);
  text-transform: uppercase;
  letter-spacing: 0.04em;
}

/* At DPR 2 a 1px rule is two device pixels, which makes it heavier on a phone than on a
   desktop - the opposite of the intent. Same bucket brand.css already keeps for structural
   rules. */
@media (min-resolution: 2dppx) {[data-page-kind="legal"] .prose-block { border-top-width: 0.5px; }}

/* brand.css draws its own animated hairline UNDER each h2. With a rule above as well that is
   two lines per heading, which reads as a boxed label rather than as a chapter break. */
[data-page-kind="legal"] .prose-block > h2::after { content: none; }

/* ── THE SECTION NUMBER ──────────────────────────────────────────────────────────────────────
   Uriah, 2026-09-13: *"on mobile, the numbers don't show next to the titles, in that case they
   can be part of the titles if necessary."*

   ▶ AND THE MARGIN VERSION WAS ALSO OVERFLOWING THE FRAME, which nothing had caught. Measured on
     /legal/house-rules/: the number is positioned off the prose column at `right: calc(100% +
     1.4rem)`, and the prose column's left edge is only ~41px from the shell at 900-1024px. The
     number is ~26px wide plus a 22px offset, so between about 992px and 1150px it rendered
     PARTLY OUTSIDE the shell's border — 18px from the viewport edge against a frame sitting at
     26px.

     `frame-check.py` passed all 50 viewports anyway, and that is the part worth keeping:
     **a pseudo-element has no box to measure.** `getBoundingClientRect()` does not see `::before`,
     so a containment harness built on element geometry is structurally blind to exactly the kind
     of decoration that gets pushed into a margin. The harness is not wrong; its coverage has a
     shape, and generated content is outside it. Anything positioned into a margin needs checking
     by eye or by screenshot.

   ▶ SO THE NUMBER NOW EARNS ITS MARGIN POSITION RATHER THAN ASSUMING IT. The old gate was
     `min-width: 62rem` — a guess about the viewport, standing in for a fact about the layout. The
     real question is whether there is room *between the text column and the frame*, which is a
     different quantity: it depends on --wrap, --prose, --gutter and --frame, not on the viewport
     alone. That is exactly what a container/style query cannot express either, so it is spelled
     out as the arithmetic it is.

     Below that, the number becomes part of the heading — an inline prefix, which is what Uriah
     asked for and is better anyway: it keeps the numbering visible on the pages most likely to be
     read on a phone (the legal ones), and it never collides with anything.

   ▶ BOTH FORMS ARE DECORATIVE, so both use the `/ ""` alt-text syntax. Modern screen readers DO
     announce generated content, and "zero one" before every heading is noise — the heading text
     is the structure, the numeral is a visual aid to scanning. */
[data-page-kind="legal"] .prose.stack { counter-reset: psc-section; }

[data-page-kind="legal"] .prose-block { counter-increment: psc-section; }

/* — NARROW: the number is part of the title. — */
[data-page-kind="legal"] .prose-block > h2::before {
  /* ▶ TWO DECLARATIONS, AND THE FIRST IS THE POINT. `content: <value> / <alt-text>` is ONE
     declaration: a browser that does not understand the `/ ""` alt-text syntax discards the
     WHOLE thing as invalid, so the NUMBER disappears — not just the alt text. The plain
     declaration first means such a browser still gets a visible numeral and only loses the
     screen-reader suppression, which is the right way round to fail. Browsers that do
     understand it take the second and override the first. Added 2026-09-14 after the numbering
     was reported missing on mobile; that turned out to be a stale stylesheet rather than this,
     but the exposure was real and costs one line to close. */
  content: counter(psc-section, decimal-leading-zero);
  content: counter(psc-section, decimal-leading-zero) / "";
  font-family: var(--font-numeral);
  font-size: 0.72em;
  letter-spacing: 0.14em;
  /* --ink-dim (5.74:1), never --ink-faint (2.17:1, fails everything). */
  color: var(--ink-dim);
  margin-right: 0.7em;
}

/* — WIDE: the number moves into the margin, but only once the margin is genuinely big enough. —
   The condition is the real one: half the slack between the wrap and the text measure has to
   exceed the gutter the number needs plus its own width. 1100px of wrap against a 68ch measure
   only clears that comfortably past ~1200px, which is why the old 62rem (992px) gate failed. */
@media (min-width: 75rem) {[data-page-kind="legal"] .prose.stack { position: relative; }

[data-page-kind="legal"] .prose-block { position: relative; }

[data-page-kind="legal"] .prose-block > h2::before { content: none; }

[data-page-kind="legal"] .prose-block::before {
    /* Same two-declaration fallback as the narrow form above — see the note there. */
    content: counter(psc-section, decimal-leading-zero);
    content: counter(psc-section, decimal-leading-zero) / "";
    position: absolute;
    right: calc(100% + 1.4rem);
    top: 1.35rem;
    font-family: var(--font-numeral);
    font-size: 0.68rem;
    letter-spacing: 0.18em;
    color: var(--ink-dim);
  }

[data-page-kind="legal"] .prose-block:first-child::before { top: 0.2rem; }}

/* ---- LAYOUT: L8 on [data-page-kind="spec"] — /visit/ ----
   Scoped rather than global: L8 and the other two promoted layouts all restyle
   `.prose-block > h2`, so unscoped the last one in this file would win on every page. The
   attribute is set once in src/layouts/Base.astro. Variant header comments are kept — they
   carry the reasoning, and a stylesheet without it becomes folklore.
   ------------------------------------------------------------------------------------------- */
/* L8 · SPEC PLATE — Visit's facts as a plate, because Visit's content genuinely IS data.
   ============================================================================================
   Specified in the previous plan as "L5 SPEC PLATE" and never built. It is the highest-value
   structural change available on /visit/ and the only candidate in the set that is about WHAT
   the page is rather than how it is spaced.

   ▶ THE ARGUMENT. Six of Visit's seven sections answer a single closed question — where, what
     age, what ID, when does entry close, is there a lift, what are the hours. A person opening
     that page at 10pm on a Friday wants one of those facts and is currently reading prose to
     find it. Every other layout in this set rearranges the prose. This one says the prose is
     the wrong FORM for half the page, and puts the closed-question answers in a plate at the
     top where they can be read in one glance, leaving the prose to do what prose is for.

   ▶ IT IS ALSO THE ONLY CANDIDATE THAT NEEDS CONTENT THAT DOES NOT EXIST YET, AND THAT IS A
     DECISION FOR URIAH RATHER THAN A TASK. A `<dl>` of facts is new markup carrying RESTATED
     claims — "R20", "no entry after 1am", "wheelchair lift" — and this repo's standing rule is
     that no copy changes without him saying so, with docs/content-source-of-truth.md updated in
     the same commit. Restating a claim in two places on one page is also exactly how a site
     ends up with an age policy that says R20 in the plate and R18 in the prose, which is the
     class of error that cost four page edits on 2026-09-11.

     So the plate is rendered by the SWITCHBOARD, from the page's own sentences, as a MOCK. No
     markup is added to visit.astro, nothing ships, and what you are judging is the idea rather
     than the wording. Press `[` to cycle the content mode to one that includes it. If the idea
     wins, the real version needs: a single source for each fact (the plate generated from the
     same data the prose cites, not typed twice), and Uriah's sign-off on the wording.

   ▶ WHY A DEFINITION LIST AND NOT A TABLE. These are key/value pairs, not a grid of records: a
     table implies columns that can be compared down, a `<dl>` says "term, then its definition",
     which is what this is. It also collapses to a single column on a phone without any of a
     table's reflow problems, and screen readers announce the pairing.

   ▶ THE PLATE IS NOT A SUMMARY OF THE PAGE. It carries only facts that are complete and
     unambiguous. Access is deliberately NOT in it — visit.astro's own comment says that
     section is incomplete and says so on purpose, and a spec plate is precisely the wrong place
     for a partial answer, because its whole visual claim is "this is the definitive list". */
[data-page-kind="spec"] .spec-plate {
  display: grid;
  grid-template-columns: max-content minmax(0, 1fr);
  column-gap: clamp(1rem, 3vw, 2.4rem);
  row-gap: 0;
  margin: 0 0 2.6rem;
  padding: 1.2rem 0 0;
  border-top: 1px solid var(--line);
  border-bottom: 1px solid var(--line);
}

@media (min-resolution: 2dppx) {[data-page-kind="spec"] .spec-plate { border-width: 0.5px; }}

[data-page-kind="spec"] .spec-plate dt {
  grid-column: 1;
  font-family: var(--font-label);
  font-size: 0.68rem;
  letter-spacing: 0.2em;
  text-transform: uppercase;
  color: var(--ink-dim);       /* 5.74:1 — the label is read, so never --ink-faint */
  padding: 0.55rem 0;
  white-space: nowrap;
}

[data-page-kind="spec"] .spec-plate dd {
  grid-column: 2;
  margin: 0;
  padding: 0.55rem 0;
  color: var(--ink);
  font-size: 0.95rem;
  line-height: 1.4;
}

/* A hairline between rows, drawn on the ROW rather than on either cell — a border on dt alone
   stops at the label's width, which is the same "gap in the wall" failure L6 documents. Using
   a pseudo-element spanning both columns keeps the rule continuous regardless of cell content. */
[data-page-kind="spec"] .spec-plate dt:not(:first-of-type),
[data-page-kind="spec"] .spec-plate dd:not(:first-of-type) {
  border-top: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
}

/* On a phone the two-column grid gives the value about 18 characters, so it stacks. The label
   keeps its own line and the value sits under it, still paired, still scannable. */
@media (max-width: 34rem) {[data-page-kind="spec"] .spec-plate { grid-template-columns: minmax(0, 1fr); }

[data-page-kind="spec"] .spec-plate dt { grid-column: 1; padding-bottom: 0.15rem; border-top: 0; }

[data-page-kind="spec"] .spec-plate dd { grid-column: 1; padding-top: 0; padding-bottom: 0.85rem; border-top: 0; }

[data-page-kind="spec"] .spec-plate dt:not(:first-of-type) {
    border-top: 1px solid color-mix(in srgb, var(--line) 60%, transparent);
    padding-top: 0.85rem;
  }}

/* The prose below the plate keeps the editorial register, because the plate has taken over the
   page's structural job and the copy no longer needs to carry it. This is L7's heading
   treatment, deliberately reused rather than reinvented: if the plate wins, it wins ON TOP of
   an editorial page, and cycling should show that combination rather than a third register. */
[data-page-kind="spec"] .prose-block > h2 {
  font-size: clamp(0.92rem, 1.15vw, 1.05rem);
  font-weight: 400;
  text-transform: uppercase;
  letter-spacing: 0.2em;
  color: var(--ink-dim);
  margin: 0 0 1.1rem;
}

[data-page-kind="spec"] .prose-block > h2::after { content: none; }

[data-page-kind="spec"] .prose-block { margin-top: 3rem; }

[data-page-kind="spec"] .prose-block:first-child { margin-top: 0; }

/* The mock plate the switchboard injects is labelled, and the label must be impossible to
   mistake for part of the design when a screenshot of this ends up in a client email. */
[data-page-kind="spec"] .spec-plate__mock {
  font-family: var(--font-label);
  font-size: 0.6rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: #e5484d;
  margin: 0 0 0.6rem;
}

/* ---- SHORT VIEWPORTS ON THE EVENT PAGE: RECLAIM THE WHITESPACE ----------------------------
   ▶▶ THIS BLOCK LIVES AT THE END OF THE FILE ON PURPOSE. The first attempt sat beside the
   event rules further up and DID NOTHING, because `.page-head { padding-block: clamp(...) }` is
   declared again later in this file and A MEDIA QUERY ADDS NO SPECIFICITY — so the later plain
   rule won on document order. Same trap as the promoted variants, same symptom: a rule that
   reads correctly and has no effect. If these move, they can only move DOWN.

   ▶ WHAT IT BUYS, measured on event.php: 414px sat above the image and only 137px of it was
   content — 90px of .page-head padding and an 88px .section gap, together more than the site
   header and the page title combined. Nothing was ever in it. The back button was another
   61px and has moved to the foot of the page, where Uriah wanted it anyway.

   ▶ SCOPED WITH :has(), not global. `.section` is on every page and a short viewport is not on
   its own a reason to tighten the legal pages.

   ▶ THE TIERS ARE A FLOOR AS WELL AS A CEILING. An earlier pass drove the art to 26vh to force
   the fold on a 525px viewport and produced a postage stamp. On the shortest screens the
   picture is the thing worth seeing, so the tier stops at 40vh and the ticket block sits at the
   fold rather than the art being destroyed to keep it above one. */
/* ▶ THE EVENT PAGE'S OWN SPACING, AT EVERY HEIGHT — not only on short viewports.
   The height-gated block below was written for laptops, so on a 1920x1080 screen (920 of
   viewport, above the 56rem gate) none of it applied and the page kept the full clamp: 145px
   of nothing between the title and the date. Uriah: *"on my bigger screen there's lots of
   empty space."* A gap that size does not read as breathing room, it reads as a missing
   element.

   Scoped with `body:has(.event-hero)` so it is the EVENT PAGE that is tightened, not every
   page with a .page-head. The legal and editorial pages want their air; a page whose job is
   to get you from a title to a ticket does not. */
body:has(.event-hero) .page-head { padding-block: 1.3rem; }
body:has(.event-hero) .section:has(.event-hero) { padding-block: 1.4rem; }

@media (max-height: 56rem) {
  .page-head { padding-block: 1.1rem; }
  .section:has(.event-hero) { padding-block: 1.2rem; }
  .event-hero { --hero-max-h: min(calc(var(--vp-h, 100vh) * 0.43), 400px); }
}
@media (max-height: 36rem) {
  html:not(.vp-lock) .event-hero { --hero-max-h: 28vh; }
}
/* ▶ THE SAME TIER, DECIDED ONCE AT LOAD (viewport-lock.js). In Brave on iOS the viewport height changes
   as the toolbar hides, and on an iPhone 8 it crosses this 36rem line: the picture jumped from this
   tier to the one above as you scrolled. The media query above now applies only when the lock has
   not run. */
html.vp-lock.vp-short .event-hero { --hero-max-h: calc(var(--vp-h) * 0.28); }
/* ▶ THE SMALLEST VIEWPORT IN THE MATRIX GETS ITS OWN TIER, and the width condition is the
   point. At 320px wide the picture is constrained by WIDTH, not by the height caps above — it
   is only ~158px tall there and the cap never binds — so the previous tiers could not help and
   lowering them further would have shrunk the art on real phones to fix a 320x546 window that
   no current handset has. Pairing the two conditions reaches exactly the case that fails and
   nothing else: Uriah's iPhone is untouched. */
@media (max-width: 22rem) and (max-height: 36rem) {
  html:not(.vp-lock) .event-hero { --hero-max-h: 22vh; }
}
@media (max-width: 22rem) {
  html.vp-lock.vp-short .event-hero { --hero-max-h: calc(var(--vp-h) * 0.22); }
}

/* >>> SWITCHBOARD TUNING — written by dev/switchboard.py, last 2026-09-19 14:00 ----------------
   Values promoted from the switchboard's Save tab. It is LAST in the file on purpose:
   a later :root wins, so these beat every default above without editing it. Remove a
   line here (or use 'remove' in the Save tab) to return to the shipped default. */
:root {
  --fx-text-glow: 0.88;
  --fx-text-glow-radius: 0.195em;
  --fx-text-spread: 0.2;
  --fx-text-tightness: 1.1;
  --fx-text-contact: 0.72;
  --fx-text-depth: 3px;
  --fx-text-smear: 0.6;
  --fx-text-fringe: 0.38;
  --fx-shimmer-rate: 8s;
  --fx-halogen-warmth: 0.49;
  --fx-drift-rate: 5s;
  --fx-filament-focus: 90%;
  --fx-flicker-rate: 9s;
  --fx-flicker-depth: 1.95;
  --fx-emb-shim-rate: 4.7s;
  --fx-emb-shim-ease: 0.56;
  --fx-emb-shim-direction: alternate;
  --fx-emb-shim-angle: 162deg;
  --fx-emb-shim-shape: linear-gradient(var(--fx-emb-shim-angle), transparent calc(50% - var(--fx-emb-shim-width) * 4), rgba(0,0,0,.35) calc(50% - var(--fx-emb-shim-width)), #000 50%, transparent calc(50% + 1%));
  --fx-emb-shim-core: 19%;
  --fx-emb-shim-from: -185%;
  --fx-emb-shim-blend: color-dodge;
  --fx-emb-bloom: 0;
  --fx-emb-bloom-size: 6;
  --fx-emb-pulse: 1;
  --fx-emb-pulse-rate: 15.5s;
  --fx-emb-flicker: 1.22;
  --fx-emb-flicker-rate: 4.3s;
  --fx-emb-jitter: 12.25px;
  --fx-emb-scan: 0; /* 0.17 until 2026-09-29: lines off on the emblem (Uriah). The rule goes with the resting fx in Step 3. */
  --fx-emb-scan-period: 3.5px;
  --fx-emb-feather: 86%;
  --fx-emb-rgb-on: "";
  --fx-emb-rgb-blend: plus-lighter;
  --fx-emb-rgb-hue: -111deg;
  --fx-emb-rgb: 0.39;
  --fx-emb-glitch: 40px;
  --fx-emb-glitch-rate: 14.3s;
  --fx-emb-shim-rand-angle: 22;
  --fx-emb-shim-rand-width: 0.21;
  --fx-emb-shim-rand-time: 0.28;
  --fx-emb-bloom-hold: 0.79;
  --fx-emb-glow-a-on: block;
  --fx-emb-glow-a-radius: 15px;
  --fx-emb-glow-a-blend: difference;
  --fx-emb-glow-a-breathe: 0.86;
  --fx-emb-glow-a-breathe-rate: 23.7s;
  --fx-emb-glow-a-drift: 0.24;
  --fx-emb-glow-a-flare: 0.78;
  --fx-emb-glow-a-drift-rate: 8.7s;
  --fx-emb-glow-b-on: block;
  --fx-emb-glow-b: 1.53;
  --fx-emb-glow-b-radius: 34.5px;
  --fx-emb-edge-on: block;
  --fx-emb-edge: 0.3;
  --fx-emb-glow-a: 0.37;
  --fx-emb-glow-a-spread: 1.065;
  --fx-emb-glow-a-flare-rate: 34.1s;
  --fx-emb-glow-a-x: 4.5px;
  --fx-emb-glow-a-y: -16.5px;
  --fx-emb-glow-b-spread: 1.505;
  --fx-emb-glow-b-breathe: 0.6;
  --fx-emb-glow-b-breathe-rate: 5.4s;
  --fx-emb-glow-b-x: 6.5px;
  --fx-emb-glow-b-drift: 1;
  --fx-emb-glow-b-drift-rate: 29.1s;
  --fx-emb-edge-radius: 18.5px;
  --fx-emb-edge-spread: 1.38;
  --fx-emb-edge-x: 32px;
  --fx-emb-edge-breathe: 0.54;
  --fx-emb-edge-breathe-rate: 27.3s;
  --fx-emb-edge-drift: 0.64;
  --fx-emb-edge-drift-rate: 7s;
  --fx-emb-edge-flare-rate: 10.4s;
  --fx-emb-fx-feather: 74%;
  --fx-emb-edge-flare: 0;
  --fx-emb-glitch-rand: 1;
  --fx-emb-shine2-on: block;
  --fx-emb-shine2-blend: screen;
  --fx-emb-shim-travel: 0.36;
  --fx-emb-shim-rand-strength: 0.57;
  --fx-emb-shim-skip: 0.31;
  --fx-emb-shim-wobble: 40%;
  --fx-emb-shim-wobble-rate: 1s;
  --fx-emb-shim-flux: 0.05;
  --dis-spin: 315deg;
  --dis-mask: 6em;
  --dis-fade: 1.2;
  --dis-stagger: 0.35;
  --dis-speed: 2.5;
  --fx-hx-from: 76%;
  --fx-hy-from: 79%;
  --fx-hy-to: 84%;
  --dis-out-to: 0;
  --dis-hint-wait: 500ms;
  --dis-hint-ms: 320ms;
  --dis-reveal: line;
}
/* <<< SWITCHBOARD TUNING */
