/* Enhanced CRT Effects with Realistic Physics Simulation */

/* Base styles and CSS variables */
:root {
  --vh: 1vh;
  --font-ui: 'JetBrains Mono', 'Hack', 'Hack Nerd Font Mono', monospace;
  --bg: #0a0a0a;
  --fg: #e8e3d8;
  --cyan: #00ffc8;
  --vhs-warm: #ffdc80;
  --fade-dur: 2s;
  --period: 7s;
  --ui-bottom-pad: 72px;

  /* ==========================================================================
   * TYPE SCALE AND SPACING SCALE
   *
   * AUDITED BEFORE WRITING THIS, because "the typography needs work" is not actionable and
   * restyling by eye is how a page ends up with eleven sizes that are all nearly the same.
   * Measured at 390 / 1440 / 2560px, the page had:
   *
   *   - NO type scale at all. The whole stylesheet contained four font-size declarations --
   *     the title letters, the nav, a loading indicator and one media query. `h2` at 24px and
   *     body at 16px were BROWSER DEFAULTS, which is why neither moved between a phone and a
   *     2560px display. A 16px body in a 70ch column reads as unfinished on a large screen
   *     because nothing ever told it otherwise.
   *   - Ratios of 1.006, 1.325, 1.429, 1.050, 1.111, 1.125 between adjacent sizes. A 1.006
   *     step is two sizes that are visually identical; that is the signature of ad-hoc values
   *     rather than a scale.
   *   - Block gaps of 16, 20 and 40/54/64px. 16 and 20 are close enough to read as the same
   *     gap, so there were effectively two levels of separation doing three jobs.
   *
   * ONE FLUID SOURCE, DERIVED STEPS. --step-0 is the only value with a clamp in it; every
   * other step is a multiplication by the ratio. That makes the ratio exact by construction
   * and means a change to the base moves the whole scale together, which is the property a
   * scale has and a pile of independent clamps does not.
   *
   * 1.25 (a major third) rather than something more dramatic: the body face is monospace and
   * already visually heavy, and large ratio jumps on a mono face read as shouting.
   * ========================================================================== */
  --type-ratio: 1.25;
  /* 16px at 390, ~17.5px at 1440, capped at 18px. The cap matters: without it a 2560px
   * display gets 19-20px body text, which is not "generous", it is a large-print edition. */
  --step-0: clamp(1rem, 0.93rem + 0.18vw, 1.125rem);
  --step-1: calc(var(--step-0) * var(--type-ratio));
  --step-2: calc(var(--step-1) * var(--type-ratio));
  --step-3: calc(var(--step-2) * var(--type-ratio));

  /*
   * SPACING. Steps of 8 / 16 / 24 / 40 / 64 -- roughly Fibonacci rather than doubling, because
   * pure doubling leaves a hole between 16 and 32 that every layout then fills with an ad-hoc
   * 24. Having the 24 in the scale is what stops it being invented later.
   */
  --space-1: 0.5rem;
  --space-2: 1rem;
  --space-3: 1.5rem;
  --space-4: 2.5rem;
  --space-5: 4rem;
  --bg-current: none;

  /*
   * The picture, as a LUMINANCE MASK for the emissive layers.
   *
   * Phosphor emits in proportion to beam current, and beam current is the video signal. So
   * every layer on this page that models emitted light -- the three gun layers, the phosphor
   * beam -- has to be zero wherever the picture is black, because an unlit phosphor emits
   * nothing. They were all screen-blended over the picture with no such gating, which over a
   * black field is pure addition: measured at 8.08 luma for the guns and 7.05 for the beam,
   * out of a 36.76 total that should have been 0.
   *
   * WHY THIS IS NOT JUST --bg-current. It carries the same URL, which normally would be a
   * second copy of the truth and therefore a defect. The two cannot be merged because their
   * SAFE DEFAULTS are opposites: as a paint source an unpublished picture must resolve to
   * `none` (paint nothing), and as a luminance mask it must resolve to white (mask nothing).
   * Collapsing them either paints a white floor under the page or blanks every emissive layer
   * the moment bg-loader is slow. Different types, different failure directions, two names.
   *
   * White is deliberately the default rather than black: if bg-loader never publishes, this
   * degrades to exactly the ungated behaviour that shipped before, rather than silently
   * deleting three layers and leaving nothing to notice.
   */
  --crt-emission-mask: linear-gradient(#fff, #fff);

  /*
   * HALATION, the shared constants -- read by the picture layer AND by the text.
   *
   * Radius is DERIVED: a 14-inch tube has about 285mm of visible width and the halo runs to
   * roughly 3mm, so it is 1.05% of picture width. Against 100vw it tracks the viewport the way
   * the mask magnification and the moire period already do.
   *
   * Gain is a STATED exaggeration, the same pattern as VISIBILITY_GAIN (convergence, 4) and
   * PERSISTENCE_GAIN (phosphor, 20). True halation is a couple of percent of peak, which on a
   * web page is indistinguishable from nothing. The MECHANISM and the RADIUS are physical;
   * only the amplitude is lifted, and it is named rather than buried in an opacity.
   */
  /* ONE owner for the halo size. The shader needs a number and CSS needs a length, so the
   * percentage is the source and the length is derived from it rather than the two being
   * written separately and drifting. */
  --crt-halation-pct: 1.05;
  --crt-halation-radius: calc(100vw * var(--crt-halation-pct) / 100);

  /*
   * BEAM PROFILE, read by the WebGL tube.
   *
   * SPOT GAIN: the electron spot is Gaussian across the scan line and gets WIDER as drive
   * rises, because a harder-driven beam is a physically bigger spot. That is why a bright line
   * on a tube is fatter than a dim one, why highlights bleed into their neighbours, and why
   * bright text loses its scan structure before dark text does. Nothing in the CSS stack could
   * express it: a blur radius that depends on local luminance is not a thing CSS has.
   *
   * EDGE DEFOCUS: the beam travels further to the corners and lands at an angle, so the spot
   * arrives as a smeared ellipse rather than a dot. Uniform corner-to-corner sharpness is one
   * of the loudest tells that a picture is not coming off a tube.
   */
  /*
   * FACEPLATE REFLECTION. A tube front is a sheet of glass, and glass is a mirror. Even in a
   * dim room a switched-off CRT shows the room back to you, so its black is a reflective grey
   * and never nothing. The compositing work earlier today drove black to absolute zero, which
   * is cleaner than any tube has ever been and is part of why the result still reads as a flat
   * panel rather than as glass.
   *
   * Small on purpose: this is the one term that legitimately lifts the black floor, and the
   * whole point of the operator work was that nothing else may.
   */
  --crt-faceplate-reflection: 0.006;

  --crt-spot-gain: 0.22;

  /*
   * THE TAPE TRANSPORT. Read by the GPU tube; see crt-webgl-tube.js for what each one is.
   *
   * FLAGGING is the top-of-frame lean from tape tension, recovering over the next few lines --
   * the artifact that makes a picture read as a TAPE before any other tell does, and the page had
   * none of it. TIME-BASE ERROR is the residual per-line wobble the servo does not catch. CHROMA
   * LAG is colour arriving LATE, which is a horizontal offset and is NOT the same defect as
   * --crt-chroma-bleed, a symmetric blur: an edge that trails its colour and an edge with soft
   * colour look nothing alike, and a tape does both.
   *
   * Restrained on purpose. Turned up these read as a broken tape rather than as a tape, and the
   * target is a picture that looks recorded.
   */
  --crt-flagging: 0.004;
  --crt-flagging-recovery: 4;
  --crt-time-base-error: 0.0006;
  --crt-chroma-lag: 1.4;

  /*
   * HORIZONTAL LUMA RESOLUTION IN TV LINES, which is how a format is actually specified.
   * VHS about 240, broadcast NTSC about 330, S-VHS about 400, Betamax about 250. 0 = no limit.
   *
   * TVL counts alternating light and dark vertical lines resolvable across a horizontal distance
   * equal to the picture HEIGHT, so the shader derives the finest carryable period from it and
   * the aspect ratio. This is the right way round: --crt-luma-bleed is a radius in PIXELS, which
   * is a consequence rather than a property -- it needs re-tuning at every viewport and says
   * nothing about which format is being modelled. A TVL figure survives a resize and can be
   * looked up.
   *
   * Default 0 because the tube itself imposes no such limit; it is the recording format that
   * does. The vhs-playback preset sets it.
   */
  --crt-luma-tvl: 0;

  /*
   * THE VISIBLE APERTURE, as a MULTIPLIER of the corner radius the mask family implies --
   * grille 2% of the short edge, slot 6%, delta 12%, because the family names a tube
   * generation and its glass mould. The radius itself lives in the shader beside the mask it
   * is derived from; a number here would be a second copy of it.
   *
   * 1 is the era-typical corner and 0 is square. Declared here so the shader has the value
   * before the panel initialises, and so the panel's own default has something to agree with.
   */
  --crt-aperture: 1;

  /*
   * CROSS-COLOUR: luma detail at the subcarrier, decoded as colour that is not in the picture.
   * Dot crawl is the subcarrier leaking into luma; this is the reverse, and it is what puts a
   * shimmering rainbow over fine stripes on a composite feed. Needs --crt-subcarrier-cycles,
   * which crt-color-system publishes from NTSC_CONSTANTS.
   */
  --crt-cross-colour: 0.35;

  /*
   * HEAD SWITCHING: the last few lines of every field, torn sideways and carrying hash, where a
   * helical-scan deck swaps between its two heads. Permanent on tape rather than an event -- it
   * is a property of the format. On a tube it sits in the overscan, which is why broadcast
   * monitors never showed it and every un-cropped digitised tape does.
   */
  --crt-head-switch: 0.01;
  --crt-head-switch-lines: 6;

  /*
   * DROPOUT: a short run of a line lost to worn oxide or a head losing contact. What is drawn is
   * the PREVIOUS LINE, verbatim, because that is what a dropout compensator substitutes from its
   * delay line -- which is why a tape dropout reads as a streak of repeated content rather than
   * as speckle. Modelling it as white noise, which most VHS filters do, is modelling the defect
   * without the machinery built to hide it.
   *
   * EDGE NOISE: inter-track crosstalk, which shows at high-contrast vertical edges because that
   * is where the neighbouring track disagrees most. Keyed on the picture gradient, so flat areas
   * stay clean -- that is what separates it from grain.
   */
  --crt-dropout-rate: 0.004;
  --crt-dropout-length: 0.06;
  --crt-edge-noise: 0.05;

  /*
   * MISTRACKING: a localised horizontal band, displaced, noisy and desaturated, that TRAVELS
   * vertically -- the head sweep missing the centre of the recorded stripe. Default 0 because a
   * well-tracked tape does not show it; this is the control you would reach for to make a tape
   * look badly adjusted, and it is off until asked for.
   */
  --crt-tracking-error: 0;
  --crt-tracking-band: 14;
  --crt-tracking-speed: 0.07;

  /*
   * RF GHOSTING from multipath: the signal arriving twice, once direct and once off a building
   * or a hillside. Specified in MICROSECONDS of delay because that is what a path-length
   * difference converts into -- 11.2us is a reflector 1.6km away -- and the shader derives the
   * screen offset from the active line length. A post-ghost trails right and is the common case;
   * a pre-ghost leads left and only happens when the receiver has locked to the reflection.
   *
   * HUM BAR: mains leaking into the supply. It crawls at the BEAT between 60Hz mains and the
   * 59.94Hz field rate -- 0.06Hz, one pass every 16.7 seconds -- computed from V_FREQ.
   *
   * All off by default. These are reception faults, not properties of the display.
   *
   * THE LEVELS ARE THE ENABLES; THE DELAYS ARE NEVER ZERO. A multipath reflection with zero
   * excess delay IS the direct signal, so a ghost at 0us is not a faint ghost, it is the
   * picture added to itself -- a 60% brightness lift with no displaced copy anywhere. Both
   * delays used to sit at 0 alongside their levels, which made the pair mutually dead: raising
   * the level alone brightened the picture, and raising the delay alone was gated off by
   * `uGhostLevel > 0.001`. The delays now carry a physical value and the control minimums
   * forbid zero, so turning a level up produces an actual ghost.
   *
   * 11.2us is a reflector 1.6km away, which is the figure the shader's own derivation uses.
   * 1.6us is about a 240m path difference and sits under the 2us the same note gives as the
   * practical ceiling for a pre-ghost.
   *
   * THESE MUST MATCH THE PANEL SCHEMA. They are a second copy of the same defaults -- needed,
   * because the stylesheet has to stand alone before any JS runs and on the ?tube=css path --
   * and they were kept in step by hand until 2026-09-26, when changing one silently left the
   * other behind and the ghost stayed dead while the slider read 11.2. Enforced now by
   * tests/dependency-state-contract.test.js.
   */
  --crt-ghost-delay-us: 11.2;
  --crt-ghost-level: 0;
  --crt-pre-ghost-delay-us: 1.6;
  --crt-pre-ghost-level: 0;
  --crt-hum-bar: 0;

  /*
   * THE DIGITAL FAULTS. Everything above is analogue -- a beam, a tape, an aerial. These are what
   * breaks when the picture has been through a CODEC, and they look nothing like the analogue
   * set, which is the point of having both.
   *
   * DATAMOSH   a missing keyframe, so the decoder keeps applying old motion to new content
   * BLOCK      DCT quantisation collapsing to the block average, on a grid fixed to the frame
   * PIXEL SORT a glitch-art technique rather than a fault: runs reordered by brightness
   * HUE BANDS  the hex-edited-JPEG tell -- bands whose colour rotates but whose brightness does not
   *
   * All zero. This page is a tube; these are for when the signal reaching it came through a
   * codec instead of a cable, and that is a choice rather than a default.
   */
  --crt-datamosh: 0;
  --crt-block-corruption: 0;
  --crt-block-size: 8;
  --crt-pixel-sort: 0;
  --crt-pixel-sort-threshold: 0.55;
  --crt-hue-bands: 0;

  /*
   * PLASMA. A different display technology, not a variant of the tube.
   *
   * Its texture is physical glass BARRIER RIBS, and the lattice is ANISOTROPIC: the rib between
   * one pixel and the next is much wider than the ribs between the R, G and B subpixels inside
   * it, with black matrix on both. Reported by owners as horizontal ribs about three times the
   * vertical ones, visible at two metres. A black egg-crate with heavy rows and fine columns --
   * which none of the three shadow-mask geometries can produce.
   *
   * FALSE CONTOUR is the signature plasma artifact: a cell is only ever on or off, so grey comes
   * from pulse-width modulation across weighted subfields, and at a carry boundary the emission
   * pattern changes completely for a one-step change in level. An eye tracking motion integrates
   * the wrong thing and sees a band that is not in the signal. It vanishes when motion stops.
   *
   * DITHER is the other half: error diffusion hiding the banding, trading a contour for noise.
   *
   * All off. This page is a tube.
   */
  --plasma-cells: 0;
  --plasma-rib-v: 1;
  --plasma-rib-h: 3;
  --plasma-pitch: 9;
  --plasma-false-contour: 0;
  --plasma-dither: 0;
  --crt-edge-defocus: 0.07;
  --crt-halation-gain: 0.16;

  /*
   * TEXT HALATION.
   *
   * #bg-container::before blooms the PICTURE, because it is the picture blurred and screened.
   * It cannot reach the text, which is a separate DOM layer and not part of that image -- so
   * the brightest thing on the page, the title, was the one thing with no halo at all.
   *
   * On a tube there is no such distinction: text is drawn by the same beam, its light scatters
   * in the same faceplate, and the halo radius is a property of the GLASS rather than of the
   * feature being drawn. So the text halo uses the same radius, which is why it is a variable
   * and not a second number.
   *
   * THREE STOPS, NOT ONE. A halo is a falloff. A single wide shadow is a flat disc of glow and
   * reads as a drop shadow; sampling the falloff at 0.15 / 0.5 / 1.0 of the radius, with alpha
   * decreasing as the area grows, is a cheap approximation of the Gaussian. The alphas are all
   * expressed against --crt-halation-gain so the picture and the text cannot drift apart.
   *
   * A CUSTOM PROPERTY holding a shadow list, so it composes: every consumer writes its own
   * legibility stops first and appends this. That keeps one owner for the halo and lets each
   * element keep the contrast treatment it needs.
   */
  --crt-text-halation:
    0 0 calc(var(--crt-halation-radius) * 0.15 + var(--crt-luma-bleed, 0px))
      rgba(255, 255, 255, calc(var(--crt-halation-gain) * 1.6)),
    0 0 calc(var(--crt-halation-radius) * 0.5 + var(--crt-luma-bleed, 0px))
      rgba(255, 255, 255, calc(var(--crt-halation-gain) * 0.75)),
    0 0 calc(var(--crt-halation-radius) + var(--crt-luma-bleed, 0px))
      rgba(255, 255, 255, calc(var(--crt-halation-gain) * 0.3));
  /* How long the retrace bar takes to cross the screen. Was 25s, which is not a sweep --
   * at that speed the bar is on screen for most of the interval between background changes
   * and reads as a stain rather than as a scan. A rolling bar crosses in a couple of
   * seconds. See .scanline-sweep. */
  --sweep-dur: 2.6s;

  /*
   * The scan-line structure, expressed as a LINE COUNT and a derived period.
   *
   * It used to be two literals inside .scanlines-interlaced's gradient. That was fine while
   * nothing else needed to know it, and stopped being fine the moment moire had to be
   * derived: moire is the BEAT between this period and the shadow mask's row period, so a
   * second copy of either number is a beat that silently describes the wrong tube.
   *
   * The COUNT is the physical quantity, not the period. A fixed 4px meant a taller window
   * showed more scan lines, and a tube has the number of lines it has whatever size its face
   * is. crt-interlacing-engine.js publishes --crt-scanline-lines from
   * CRTPhysics.getVisibleLines() -- NTSC's 486, halved by a stated visibility divisor
   * because 486 over a 900px viewport is a sub-device-pixel line that aliases to grey.
   * 243 lands at 3.70px on a 900px viewport, which is where the hand-picked 4px already was.
   *
   * The fallback count, 225, is what the old 4px literal worked out to at 900px -- so with
   * no JS the picture is unchanged rather than merely plausible.
   */
  --crt-scanline-lines: 225;
  --crt-scanline-period: calc(100vh / var(--crt-scanline-lines, 225));
}

* {
  -webkit-tap-highlight-color: transparent;
  box-sizing: border-box;
}

/* Dark loading screen instead of white */
body[data-loading] {
  background: #0a0a0a !important; /* Dark loading background */
  color: #00ffc8 !important; /* Cyan loading text */
}

body[data-loading]::before {
  content: 'LOADING SYSTEM...';
  position: fixed;
  top: 50%;
  left: 50%;
  transform: translate(-50%, -50%);
  font-family: var(--font-ui);
  color: #00ffc8;
  font-size: clamp(1rem, 4vw, 1.5rem);
  letter-spacing: 0.2em;
  text-shadow: 0 0 10px rgba(0, 255, 200, 0.5);
  animation: loadingPulse calc(2s * var(--animation-scale, 1)) ease-in-out infinite alternate;
  z-index: 9999;
}

@keyframes loadingPulse {
  from {
    opacity: 0.5;
    text-shadow: 0 0 10px rgba(0, 255, 200, 0.3);
  }
  to {
    opacity: 1;
    text-shadow: 0 0 20px rgba(0, 255, 200, 0.8);
  }
}

html,
body {
  height: 100%;
  margin: 0;
  padding: 0;
}

/*
 * THE PAGE DOES NOT SCROLL, AND THAT IS THE DESIGN. Do not "fix" it.
 *
 * Travis, 2026-10-01: "The site isn't supposed to scroll... No content should be below the fold."
 *
 * `overflow: hidden` on <body> propagates to the viewport whenever the root element's overflow is
 * `visible`, which it is. That is what makes this page one fixed screen: a full-viewport hero with
 * the tube filling it, and the MINI WINDOWS as the route to every piece of content. The
 * declaration is load-bearing, and `width: 100vw` depends on the horizontal half of it too.
 *
 * I BROKE THIS ON 2026-10-01 AND SHIPPED IT TO PRODUCTION BEFORE ASKING. The measurement was
 * right and the conclusion was not mine to draw: a wheel gesture moves scrollY by 0 against
 * 2771px of `main` content below the fold, and from that I concluded "every piece of content on
 * this site is unreachable", called it the largest defect in the repo's history, split
 * overflow-x/overflow-y and relaxed the height in all three copies.
 *
 * Every number in that measurement was correct. The inference was a DESIGN DECISION wearing a bug
 * report, which is the exact failure the behavioural rules name: rigor is not authorization, and
 * being right about a mechanism is not standing to change what it does.
 *
 * The tell I walked past: a nine-month-old declaration, duplicated across three synchronised
 * copies, sitting next to `width: 100vw`, is far more likely to be deliberate than forgotten.
 * `main` carrying content nothing scrolls to is a QUESTION for Travis, not a conclusion.
 *
 * Guarded by tests/page-scroll-contract.test.js, which asserts the page does NOT scroll in all
 * three copies and was verified red against my own change.
 */
body {
  background: var(--bg);
  color: var(--fg);
  font-family: var(--font-ui);
  overflow: hidden;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
  width: 100vw;
  height: 100vh;
  height: 100dvh;
  position: relative;
}

/* ===========================================================================
 * CONTENT TYPOGRAPHY
 *
 * There was NO `main` rule in this stylesheet at all, at any width, so the prose below the
 * hero inherited nothing and rendered as an unstyled document dropped under a finished
 * page. Measured in Firefox before this existed:
 *
 *   width    measure
 *   1280px   133 characters per line
 *   1440px   150
 *   1920px   200
 *   2560px   267
 *
 * The comfortable band is 45-75. At 2560 the eye has to track a quarter-metre of text and
 * find the start of the next line unaided, which is the single biggest reason the content
 * area read as unfinished next to the hero.
 *
 * `ch` is the right unit and it is exact here rather than approximate: the body face is
 * JetBrains Mono, so every glyph is one `ch` wide and `70ch` is seventy characters, not an
 * estimate of seventy.
 *
 * box-sizing is content-box ON PURPOSE, against the border-box default set at the top of
 * this file. With border-box the padding would come out of the measure, so the text column
 * would narrow as the padding grew and the one number this rule exists to control would
 * depend on the viewport. content-box keeps the measure at exactly 70ch and lets the
 * padding sit outside it.
 * =========================================================================== */
main {
  max-width: 70ch;
  margin-inline: auto;
  /* Same value as the scrim's inline feather, written once. The ramp has to end exactly where
   * the text box begins; two independent clamps would let one move without the other and put
   * the fade back under the first and last words of every line. */
  padding-inline: var(--scrim-fade-x);
  box-sizing: content-box;

  /*
   * THE SCRIM, and it is an accessibility fix rather than a styling choice.
   *
   * Measured against all 31 background photographs, sampling the real composited backdrop --
   * photograph plus vignette plus mask plus scan lines -- inside the box the body copy
   * actually occupies, and computing the WCAG contrast ratio of the text colour against every
   * pixel of it:
   *
   *   treatment      worst median   backgrounds failing AA   mean area below 4.5:1
   *   none                3.00:1                 4 of 31                     19.8%
   *   flat 35%            4.03:1                 2 of 31                      9.8%
   *   flat 55%            6.59:1                 0 of 31                      1.1%
   *   feathered 50%       4.79:1                 0 of 31                      2.2%
   *
   * So four of the backgrounds put the median below the 4.5:1 AA floor outright, and a fifth
   * of the text area was under it on average. The text-shadow below genuinely helps and WCAG
   * cannot account for it, so those numbers are the pessimistic bound -- but a bound that says
   * "four images are unreadable" is not one to argue with.
   *
   * FLAT 55% rather than the feathered gradient. Both reach 0 of 31, and the feather looks
   * softer, but its worst case leaves 46.5% of the area below AA against the flat panel's
   * 10.8% -- the fade puts its thinnest part exactly where the first and last words of each
   * line sit. Better contrast at the edges is worth more than a softer edge.
   *
   * padding-block is part of the fix, not decoration: without it the first and last lines sit
   * on the scrim's boundary, which is the one place the protection stops.
   */
  /*
   * ALL FOUR EDGES ARE FEATHERED, and none of the feather touches a glyph.
   *
   * This used to feather the top only, and the note here said the left and right boundaries
   * "read as a panel" on desktop -- accepting a hard-edged translucent rectangle sitting over
   * a photograph. Travis reported it as "a black square showing that obviously wasn't supposed
   * to be, translucent", which is precisely what it is. Accepting it was the wrong call.
   *
   * WHY THE MEASURED TRADE-OFF ABOVE DOES NOT APPLY. That table rejected the feathered variant
   * because its worst case left 46.5% of the text area under AA: a fade drawn across the whole
   * box puts its thinnest part exactly where the first and last words of each line sit. The
   * ramps below are drawn only within the PADDING -- 1-2rem inline, and the block padding at
   * top and bottom -- and the text box begins where the padding ends. So the scrim is still at
   * its full measured 55% underneath every glyph, and the flat-55% row of that table is still
   * the one in force. Contrast is unchanged; only the boundary is gone.
   *
   * ON A PSEUDO-ELEMENT rather than on `main` itself, because the feather is done with
   * mask-image and masking `main` would fade the TEXT out at the edges along with the scrim --
   * turning a contrast fix into a contrast bug at exactly the pixels it was added to protect.
   *
   * The inline ramp is var(--scrim-fade-x), tied to the same clamp as padding-inline rather
   * than written twice, so the ramp cannot drift outside the padding and start eating text if
   * one of them is later retuned.
   */
  --scrim: rgba(0, 0, 0, 0.55);
  --scrim-fade: 2.5rem;
  --scrim-fade-x: clamp(1rem, 4vw, 2rem);
  --scrim-fade-b: clamp(1.25rem, 3vh, 2rem);
  padding-block-start: calc(var(--scrim-fade) + 0.5rem);
  padding-block-end: var(--scrim-fade-b);
  position: relative;
}

main::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: -1;
  pointer-events: none;
  background: var(--scrim);
  border-radius: 4px;
  mask-image:
    linear-gradient(
      to bottom,
      transparent 0,
      #000 var(--scrim-fade),
      #000 calc(100% - var(--scrim-fade-b)),
      transparent 100%
    ),
    linear-gradient(
      to right,
      transparent 0,
      #000 var(--scrim-fade-x),
      #000 calc(100% - var(--scrim-fade-x)),
      transparent 100%
    );
  mask-composite: intersect;
}

/*
 * Anchors must not land a heading under the fixed chrome.
 *
 * .transport and #tube-panel-handle are position: fixed at the top of the viewport. Tapping
 * About, Portfolio or Contact scrolls that section to y=0, which is exactly where those two
 * controls are -- measured on an iPhone 13, the word "About" rendered directly behind the
 * effects key this transport replaced. The nav worked and the destination was unreadable.
 *
 * scroll-margin-top rather than padding, so it costs nothing in layout and only applies to the
 * thing it is about: where a scroll comes to rest.
 */
main section {
  scroll-margin-top: 5rem;
}

/*
 * THE TRANSPORT. Travis, 2026-09-30: "Changed the Effects on/off button to a transport panel,
 * play = active, pause = active but still image, and stop button is whatever the site loads
 * with but still image."
 *
 * ONE KEY BECAME THREE BECAUSE "OFF" WAS NEVER WHAT IT DID. Both WebGL contexts are created
 * with preserveDrawingBuffer: true, so when the tube's rAF loop stops the canvas keeps its last
 * frame -- measured, two captures a second apart are byte-identical while the playing control
 * differs. So the only two states the control ever had were a moving picture and a still one,
 * and calling the still one "Effects: Off" described a third state that does not exist.
 *
 * It keeps everything the key had, which was the earlier instruction and still stands --
 * Travis, 2026-09-27: "The effects button does need to remain visible and stay in its current
 * location, but it's not fitting with the design of the site."
 *
 * It did not fit because it was the only control on the page made of nothing. The panel is a
 * plasma OSD -- a near-black substrate with an egg-crate lattice on a 9px cell, hairline
 * ribs, flat steps and no radius -- and this was a 1px rgba rectangle with rounded-off web
 * defaults sitting next to it. Two controls, two materials.
 *
 * It is now cut from the same chassis: the panel's substrate colour, the panel's lattice on
 * the panel's own frozen pitch, and a bevel instead of a border -- light on the top and left,
 * dark on the bottom and right, with a seated shadow under it. Four flat 1px steps and no
 * gradient, because a gradient here would be faking a curved surface, which is the one thing
 * the chassis rules already forbid.
 *
 * IT STAYS TOP-LEFT AND IT STAYS VISIBLE, which was the rest of his instruction. Nothing
 * here moves it or hides it, and the 44px minimum target is unchanged.
 *
 * THE VALUES ARE var() WITH LITERAL FALLBACKS because the --osd-* set is published by the
 * panel's injected stylesheet, which does not exist until the panel initialises. A key that
 * is unstyled for the first second is worse than one that never reads the variables at all,
 * and the fallbacks are the panel's own frozen defaults rather than numbers invented here.
 *
 * DUPLICATED IN index.html AND src/_includes/critical-css.njk, deliberately and identically.
 * Those copies are emitted AFTER this stylesheet, so they win; a divergence is invisible in
 * the source and obvious on screen. tests/root-variable-contract.test.js compares them.
 */
.transport {
  position: fixed;
  /*
   * IN OFF THE CORNER, 2026-09-29. Travis: chrome jammed 16px into a corner reads as browser
   * UI rather than as part of a set -- and the tube gained a rounded aperture the same day, so
   * a control at 16px now sits in the dark outside the glass instead of on the lit picture.
   *
   * 4.5vh is DERIVED from the aperture rather than chosen. The largest family radius is 12% of
   * the short edge, and the inset at which a corner-anchored box first clears a radius R is
   * R(1 - 1/sqrt(2)) = 0.293R, which is 3.52vh; 4.5 gives visible clearance rather than
   * tangency. In vh because the radius is, so the two track each other at any window height.
   *
   * The floor keeps it sane on a short window, where 4.5vh would put it against the edge again.
   * Past an Aperture setting of about 1.2 the radius outgrows this and the key starts to sit in
   * the dark corner -- that is a control for looking, and it is visible when it happens.
   */
  right: max(18px, 4.5vh);
  top: max(18px, 4.5vh);
  z-index: 47;
  display: inline-flex;
  align-items: stretch;
  border: none;
  border-radius: 0;
  /*
   * A 2px GUTTER so the frame below survives. The frame is an inset box-shadow on this element,
   * which paints on its own background -- and a key is a CHILD, so a lit key painted straight
   * over it and the frame vanished along that edge. Visible in a capture as a box with three
   * sides. Padding keeps the keys off it without a second element to draw it with.
   */
  padding: 2px;
  /*
   * THE MENU'S OWN BOX AT A SMALLER SIZE, 2026-09-29. It was cell-textured and bevelled,
   * which was right while the panel was too; the OSD redesign made the panel one flat plane
   * inside a hard 2px frame, and this is the only other piece of chrome on the page, so it
   * has to be the same object rather than a second design.
   */
  background-color: var(--osd-off, #0c0805);
  background-image: none;
  box-shadow: inset 0 0 0 2px var(--osd-lit, #cdc0a8);
}

/*
 * 44px MINIMUM ON BOTH AXES, which is the WCAG 2.5.5 target and Apple's HIG floor. The key this
 * replaces got there through a mobile-only min-height rule; three keys side by side have to
 * carry it at every width, because the thing that was wide enough was a word and these are
 * 12px glyphs.
 */
.transport-key {
  position: relative;
  min-width: 44px;
  min-height: 44px;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  margin: 0;
  padding: 0;
  border: none;
  border-radius: 0;
  background: transparent;
  color: var(--osd-lit, #cdc0a8);
  cursor: pointer;
  transition:
    color 120ms steps(3, end),
    background-color 120ms steps(3, end);
}

/* A hairline between keys rather than three boxes. One object with three keys in it. */
.transport-key + .transport-key {
  box-shadow: inset 1px 0 0 var(--osd-rule, #6d6151);
}

/*
 * INVERTED FOR THE CURRENT STATE, which is the panel's own vocabulary -- .stage-head.open is a
 * solid --osd-readout bar with the ground knocked out of it. Using it here rather than a second
 * idea of "selected" is the whole reason the chrome and the menu read as one instrument.
 *
 * It keys off aria-pressed, so the lit key and the screen reader cannot disagree. A light wired
 * to a second copy of the state is how a control tells one story to the eye and another to
 * assistive tech, and that is what the pilot lamp this replaces was carefully avoiding.
 */
.transport-key[aria-pressed='true'] {
  background-color: var(--osd-readout, #d89b45);
  color: var(--osd-off, #0c0805);
}

.transport-key:hover {
  color: var(--osd-readout, #d89b45);
}
.transport-key[aria-pressed='true']:hover {
  color: var(--osd-off, #0c0805);
}
.transport-key:focus-visible {
  outline: 2px solid var(--osd-lit, #cdc0a8);
  outline-offset: -4px;
}

/*
 * THE GLYPHS ARE DRAWN, NOT TYPED. The panel draws its select chevron as two lit cells rather
 * than a character, on the stated grounds that a glyph would be the only text on the surface
 * that is not a legend. The same applies here, and more so: the Unicode transport characters
 * render in whatever fallback face has them, at whatever size that face decides, which is
 * exactly the native-widget problem the panel's <select> had.
 *
 * currentColor, so all three follow the inversion above with no second colour rule.
 */
.transport-key::before {
  content: '';
  width: 12px;
  height: 12px;
  background: currentColor;
}
.transport-key[data-transport='play']::before {
  clip-path: polygon(8% 0%, 100% 50%, 8% 100%);
}
.transport-key[data-transport='pause']::before {
  background: linear-gradient(
    90deg,
    currentColor 0 34%,
    transparent 34% 66%,
    currentColor 66% 100%
  );
}
/* stop is the bare square, which is what the ::before already is. */

/*
 * Vertical rhythm. All three sections measured `margin: 0px` and `padding: 0px`, so About,
 * Portfolio and Contact ran together with nothing but a heading to separate them.
 */
main section + section {
  margin-top: var(--space-5);
}

main p,
main ul,
main ol {
  margin-block: 0 var(--space-2);
}

/* The first heading in a section must not add its top margin to the section gap; two
 * separations stacking is what produced the 54 and 64px outliers in the audit. */
main section > :first-child {
  margin-block-start: 0;
}

/*
 * Legibility over a full-bleed photograph.
 *
 * Body copy was `line-height: normal` -- about 1.2 for 16px, which is tight for any prose
 * and punishing at the measure above -- with no shadow at all, in near-white over whatever
 * the background rotation happened to be showing. The shadow is the same move
 * `#nav-list a` already makes under a comment reading "Stronger shadow for better contrast
 * on mobile"; prose needs it more than the nav does, being smaller and longer.
 *
 * A dark scrim panel behind the column would do more for contrast than this does. That is a
 * design decision about how much of the background to cover, not a defect, so it is left
 * for Travis rather than taken here.
 */
main p,
main li {
  font-size: var(--step-0);
  line-height: 1.65;
  /*
   * Body copy gets the full halo, and that needed measuring rather than assuming, because the
   * scrim behind this text exists for one reason -- WCAG contrast over 31 rotating photographs
   * -- and a white glow works directly against it. This is the one place on the page where
   * fidelity does not automatically beat legibility.
   *
   * Measured by sampling the composited paragraph region and splitting glyph from background
   * with an Otsu threshold, so the split comes from the data: the ratio against the text's own
   * local background goes 14.07 -> 13.75 across the full gain range. A 2.3% cost, with AA's
   * 4.5:1 an order of magnitude away.
   *
   * Do not compare 14.07 with the 6.59:1 recorded for the scrim. That figure deliberately HID
   * the text and sampled the clean backdrop across every background, as a pessimistic bound;
   * this one samples the real local background on one. Different questions.
   */
  text-shadow:
    0 0 2px rgba(0, 0, 0, 0.95),
    0 0 10px rgba(0, 0, 0, 0.8),
    var(--crt-text-halation);
}

main h2 {
  font-size: var(--step-2);
  letter-spacing: -0.01em;
}

main h3 {
  font-size: var(--step-1);
}

main h2,
main h3 {
  line-height: 1.2;
  /*
   * Rhythm from the scale rather than from the browser. UA margins on a heading are expressed
   * in em, so they scaled with the heading and produced a different gap for every level --
   * which is how 16 and 20 ended up both meaning "a gap".
   *
   * Asymmetric on purpose: a heading belongs to what follows it, so the space above is large
   * and the space below is small. Equal margins are what make a page read as a list of
   * paragraphs rather than as sections.
   */
  margin-block: var(--space-4) var(--space-2);
  /* Legibility first, then the halo. Order matters only for readability of the rule; shadows
   * composite additively either way. */
  text-shadow:
    0 0 2px rgba(0, 0, 0, 0.95),
    0 0 12px rgba(0, 0, 0, 0.85),
    var(--crt-text-halation);
}

/*
 * Content links were the USER-AGENT DEFAULT -- browser blue, visited purple -- on a page
 * whose entire palette is three colours. Nothing in this stylesheet had ever styled an
 * anchor outside `#nav-list`, so the email address and the two profile links rendered in
 * the one colour scheme the design does not contain.
 *
 * --cyan is the existing accent (the CRT panel toggle already uses it), so this introduces
 * no new colour. The underline is kept rather than removed: these are the only links in the
 * prose and colour alone should not be what identifies them.
 */
/*
 * THE MINI WINDOWS HAD NO SURFACE. Added 2026-10-01.
 *
 * `.mini` was styled nowhere: not in this file, not in fonts.css, and the only rule naming it
 * anywhere was a positioning override in the critical CSS. So every mini window has always been
 * transparent, unpadded text laid directly over a full-bleed rotating photograph, with the close
 * button sitting on top of the first line.
 *
 * That is exactly the defect the project cards had and for the same reason -- the component
 * looked correct in markup and in computed style, and was unreadable on the page, because the
 * thing it needed was a background nobody had given it. The card note calls the background
 * load-bearing rather than decoration, and it is true twice.
 *
 * Opaque enough to guarantee contrast on any background in the rotation, not fully opaque so the
 * window still reads as glass over the tube, and deliberately NOT a backdrop-filter: attaching
 * one to a surface this size is the most expensive thing available on this page, and a flat fill
 * buys the same legibility for nothing.
 *
 * padding-top clears the close button, which is absolutely positioned in the top corner and was
 * previously overlapping whatever the fragment put first.
 */
#mini-overlay .mini {
  background: rgba(10, 10, 10, 0.94);
  border: 1px solid rgba(232, 227, 216, 0.22);
  border-radius: 4px;
  padding: 2.6rem 1.5rem 1.5rem;
  box-shadow: 0 0 0 1px rgba(0, 0, 0, 0.6);
  overflow: hidden;
  flex-direction: column;
}

/*
 * THE max-height IS A SCROLL REGION NOW. positionMini() has always set
 * `max-height: viewportHeight - 2 * padding` inline and NOTHING consumed it: every container in
 * the chain computed `overflow: visible`, and #mini-overlay is position:fixed so document scroll
 * could not reveal an overflow either. Measured with a 1600px block at 390x844 -- rendered 1639px
 * tall inside an 805px cap, 798px of it off-screen and unreachable after setting scrollTop on
 * .mini, the content div, the overlay, HTML and BODY in turn. A 300px control fitted with
 * headroom, which is why nobody had ever hit it.
 *
 * It mattered the moment the portfolio mini became a project browser: a panel of prose is the one
 * thing that can exceed a phone viewport, and the failure is silent -- the text is simply not
 * there, with no scrollbar to suggest otherwise.
 *
 * `display` IS SET INLINE BY openMini(), not here, and that is deliberate rather than untidy: an
 * inline style beats a stylesheet at any specificity, which is how crt-interlacing-engine.js
 * silently cancelled the performance tiers on its own layer. So the JS owns show/hide and the
 * stylesheet owns the direction, and neither can overwrite the other.
 *
 * `min-height: 0` on the child is the load-bearing half. A flex item defaults to
 * `min-height: auto`, which refuses to shrink below its content -- so the cap would apply to a box
 * that had already grown past it and the scroll region would never engage. Omitting it reproduces
 * the original bug exactly while looking like the fix.
 */
#mini-overlay .mini > div {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  scrollbar-width: thin;
  scrollbar-color: rgba(232, 227, 216, 0.3) transparent;
}

/*
 * The close button was styled NOWHERE either -- `grep` finds no `.close` rule in this file or the
 * critical CSS. So it rendered as a default system button, grey browser chrome with a `times`
 * glyph, in normal flow at the top of a CRT. The padding above was added to clear a button that
 * was never positioned.
 *
 * Out of flow, so the scroll region above cannot carry it off the top, and 44px square to clear
 * WCAG 2.5.5 -- the mobile pass raised every other control on the page to that floor and could
 * not reach this one, because it had no rule to raise.
 */
#mini-overlay .mini > .close {
  position: absolute;
  top: 0.35rem;
  right: 0.35rem;
  z-index: 1;
  min-width: 44px;
  min-height: 44px;
  padding: 0;
  border: 1px solid rgba(232, 227, 216, 0.22);
  border-radius: 3px;
  background: rgba(10, 10, 10, 0.9);
  color: var(--fg);
  font-family: var(--font-ui);
  font-size: 1.1rem;
  line-height: 1;
  cursor: pointer;
}

#mini-overlay .mini > .close:hover,
#mini-overlay .mini > .close:focus-visible {
  border-color: var(--cyan);
  color: var(--cyan);
}

/*
 * VISUALLY HIDDEN, AVAILABLE TO ASSISTIVE TECH. Added 2026-10-01 because the mini windows had no
 * accessible name: mini-windows.js sets aria-labelledby="mini-title-<id>" and no fragment had
 * ever supplied that element, so every dialog was announced as "dialog" and nothing else.
 * Measured on the live site, labelTargetExists false for all three.
 *
 * The clip-rect form rather than display:none or visibility:hidden, both of which remove the
 * element from the accessibility tree and so would defeat the entire purpose. The portfolio mini
 * already carried this rule privately as .sr-status; this is the same thing where the rest of
 * the site can reach it.
 */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

main a {
  color: var(--cyan);
  text-decoration-color: rgba(0, 255, 200, 0.45);
  text-underline-offset: 0.2em;
}

main a:hover,
main a:focus-visible {
  text-decoration-color: currentColor;
  text-shadow: 0 0 6px rgba(0, 255, 200, 0.5);
}

/*
 * THE 44px TOUCH FLOOR FOR CONTENT LINKS, 2026-10-01.
 *
 * Measured on an iPhone 13, an iPhone SE and a Pixel 7 against WCAG 2.5.5: the category links on
 * /projects/ and the Category line on every project page were 22px tall, so 9 of 36 tappable
 * things on the index failed. Same shape as the nav links fixed in the first mobile pass -- wide
 * enough already, short because they are text sized by line-height.
 *
 * TWO MECHANISMS, because the two cases are not the same. A link that is the only child of a
 * list item can become a flex box and claim real height. A link INSIDE a sentence cannot: making
 * it inline-flex would pull it out of the line box and wreck the paragraph it sits in. Vertical
 * padding on an inline element does not change line height but does enlarge the hit area, which
 * is the thing the criterion is about.
 *
 * Scoped to coarse pointers so a mouse keeps the tighter list rhythm this was set on.
 */
@media (pointer: coarse) {
  main li > a:only-child,
  main li > a:first-child:last-of-type {
    display: inline-flex;
    align-items: center;
    min-height: 44px;
  }
  main p > a {
    padding-block: 0.7em;
  }
}

/*
 * NTSC SETUP. The 7.5 IRE pedestal, which is why NTSC black is not black.
 *
 * Picture black on the North American variant sits 7.5 IRE ABOVE blanking, so the darkest
 * thing a correctly adjusted set can show is a lifted, slightly grey black. It is most of
 * why NTSC looks washed in the shadows next to PAL, which uses zero setup.
 *
 * WHY THIS IS A FLAT SCREEN-BLENDED LAYER AND NOT A FILTER. The transform is
 * out = setup + (white - setup) * in, and screen-blending a flat level L gives
 * out = 1 - (1 - in)(1 - L) = L + (1 - L) * in -- identical when WHITE_LEVEL is 1.0, which
 * it is. So the exact transform costs one static composited layer: no filter, nothing
 * re-rastered per frame, and no baking a level remap into every background image on load.
 * The cheap version and the correct version are the same version again.
 *
 * `--crt-setup-level` is a PERCENTAGE, not a byte. 0.075 x 255 is 19.125, and rounding that
 * to rgb(19,19,19) would put the pedestal 0.7% low for no reason.
 *
 * ::before, NOT ::after, and with an explicit z-index. ::after below is a legibility tint
 * that MULTIPLIES -- run the setup under it and the tint pulls the lifted floor straight
 * back down, which is the whole effect cancelled by a design overlay. Both pseudo-elements
 * are positioned, so z-index decides, and the pedestal has to be last.
 *
 * Inside #bg-container on purpose: setup is a property of the SIGNAL, so it applies to the
 * picture and not to the mask, scan lines or UI chrome, all of which sit at z-index 38+ in
 * their own layers above this one.
 *
 * ----------------------------------------------------------------------------------------
 * RETIRED 2026-09-24, AND THE COMMENT ABOVE IS WHY. Every line of it is correct, including
 * the algebra: screen-blending a flat level L really does give out = L + (1 - L) * in, which
 * really is the NTSC setup transform. What it implements is exactly HALF of a two-step chain.
 *
 * Setup is added by the ENCODER and removed by the RECEIVER. A monitor's black-level control
 * exists to put 7.5 IRE at beam cutoff -- that is the adjustment's entire purpose -- so on a
 * correctly set up tube the pedestal is precisely the level that renders as black. The
 * receiver half is out = (in - setup) / (1 - setup), which cancels the encoder half exactly.
 * Net contribution of NTSC setup to a properly adjusted picture: nothing at all.
 *
 * Applying the encode without the decode does not simulate NTSC. It simulates a monitor with
 * the brightness knob wound too far up, which is the one adjustment fault that destroys black.
 *
 * MEASURED, because the argument alone would not justify deleting a documented physics layer.
 * Driving the picture to a flat black field and reading the composited pixels back, this
 * single layer contributed 12.04 of the 36.76 luma sitting on black -- the largest contributor
 * of any layer on the page, ahead of the three gun layers (8.08) and the phosphor beam (7.05).
 * The stack turned black into RGB(27,34,47) and held a contrast ratio of 39:1. A tube in a dim
 * room is in the thousands. Flat, hazy, grey: the thing Travis kept calling fake.
 *
 * --crt-setup-level is NO LONGER PUBLISHED TO CSS either, and that is the honest end state
 * rather than an oversight. The quantity is real and stays on the physics API as
 * getSetupLevel(); what is gone is the CSS variable, because a display does not show setup, so
 * there is nothing left for a stylesheet to do with it. css-var-contract caught the
 * intermediate state where the producer outlived its consumer, which is what that test is for.
 * ----------------------------------------------------------------------------------------
 */

/*
 * HALATION -- and its absence was the loudest remaining tell once the black floor was fixed.
 *
 * A macro of the rendered title showed scan lines and convergence fringing both working, over
 * bright white letters with perfectly crisp edges. Real tubes do not do that. Two mechanisms
 * spread light out of a lit area, and this page had neither:
 *
 *   BEAM SPOT GROWTH   the electron spot is Gaussian and gets WIDER as drive increases, so a
 *                      bright line is physically fatter than a dim one.
 *   FACEPLATE HALATION the phosphor sits on the back of 10-13mm of glass. Light leaving it
 *                      scatters, and the fraction striking the front surface beyond the
 *                      critical angle (about 40 degrees at n=1.54) is reflected back to the
 *                      phosphor and out again as a halo several millimetres across.
 *
 * Between them they are why CRT highlights GLOW, and why the format flatters text. A
 * simulation without them is a photograph with scan lines drawn over it -- which is what this
 * was, and no amount of further artefact work would have covered for it.
 *
 * THE RADIUS IS DERIVED, not chosen. A 14-inch 4:3 tube has about 285mm of visible width and
 * the halation halo runs to roughly 3mm, so the radius is 3/285 = 1.05% of the picture width.
 * Against 100vw it therefore tracks the viewport the way the mask magnification and the moire
 * period already do, rather than being a pixel count that is correct at one window size.
 *
 * THE STRENGTH IS A STATED GAIN, following the pattern already established for per-gun
 * convergence (VISIBILITY_GAIN 4) and phosphor persistence (PERSISTENCE_GAIN 20). True
 * halation is a couple of percent of peak, which on a web page is indistinguishable from
 * nothing. The MECHANISM and the RADIUS are physical; only the amplitude is exaggerated, and
 * it is named here rather than buried inside an opacity.
 *
 * IT SELF-GATES, which is why it needs no --crt-emission-mask. The layer IS the picture,
 * blurred. Over a large dark area the blurred picture is dark, so screen adds nothing; near a
 * bright area the blur carries light outward into the dark, which is exactly what a halo is.
 * There is no separate "is this area lit" test because the source answers it.
 *
 * INSIDE #bg-container, so it lands UNDER the mask and the scan lines rather than over them.
 * That ordering is the difference between halation and haze: light that has left the phosphor
 * is then scanned and masked along with everything else, whereas a bloom layer floating above
 * the whole stack is the "white haze sitting on top of the image" that reads as a filter
 * instead of a tube.
 */
#bg-container::before {
  content: '';
  position: absolute;
  inset: 0;
  z-index: 1;
  pointer-events: none;

  background-image: var(--bg-current, none);
  background-position: center;
  background-size: cover;
  background-repeat: no-repeat;

  /* Both constants now live on :root, because the TEXT halation reads them too and two
   * copies of a physical constant is how this project's favourite defect starts. */
  filter: blur(var(--crt-halation-radius));
  opacity: var(--crt-halation-gain);
  mix-blend-mode: screen;
}

/*
 * WHEN THE SHADER IS LIVE, the CSS stack stops drawing the picture.
 *
 * html.crt-webgl is set by crt-webgl-tube.js only AFTER it has compiled, linked and drawn one
 * frame. Everything below is a layer the shader now computes itself, in linear light and in one
 * pass; leaving them up would composite each effect twice.
 *
 * The .bg-image layers stay in the DOM and keep loading and rotating -- they are what publishes
 * --bg-current, which is the shader's texture source. They are just not painted.
 *
 * THE LIST GREW AFTER LOOKING AT IT. The first version stood down only the picture, the mask
 * and the scan lines, and the result was violently over-saturated -- because the remaining
 * SCREEN-BLENDED layers were still adding light on top of a picture the shader had already
 * composited, so every emissive effect landed twice. Standing down the picture without standing
 * down everything that emits onto it is worse than not switching at all.
 *
 * NOT hidden, deliberately: the vignette and the legibility tint, which are page furniture
 * rather than tube physics; the VHS transport and dot crawl, which are signal-path artifacts
 * the shader does not model; and the transient bars, which are events rather than steady state.
 */
/*
 * THE CSS EFFECT STACK IS A FALLBACK, SO IT WAITS TO BE ASKED FOR.
 *
 * It used to be the DEFAULT, stood down by html.crt-webgl once the tube had drawn a frame. That
 * ordering is safe -- a driver that fails, a refused context or a shader that will not compile
 * all leave the old stack in place -- and it means every single load paints the full CSS effect
 * stack for as long as the tube takes to arrive. Measured at this viewport: the class lands
 * between 430ms and 1.1s depending on cache, and the frame before it is the heavy one.
 *
 * Travis, on exactly that frame: "The default look is too heavy. The loading default needs to be
 * more subtle... the bars showing so strongly like that. They went away when I clicked the
 * effects on button."
 *
 * So the gate is inverted for the EFFECT layers: hidden unless something has explicitly asked
 * for the fallback. The picture SOURCES keep the old gate below, because hiding those during
 * boot would mean no photograph at all for half a second rather than a plain one.
 *
 * WHAT ASKS. crt-webgl-tube.js adds crt-css-fallback on every failure exit, and init.js adds it
 * on a timeout if neither class has appeared -- which covers the tube script never running at
 * all. Two writers because one of them cannot cover the case where it is the thing that is
 * missing.
 */
html:not(.crt-css-fallback) .crt-phosphor-dots,
html:not(.crt-css-fallback) .crt-phosphor-dots::before,
html:not(.crt-css-fallback) .crt-phosphor-dots::after,
html:not(.crt-css-fallback) .scanlines-interlaced,
html:not(.crt-css-fallback) .scanlines-rgb,
html:not(.crt-css-fallback) .crt-gun,
html:not(.crt-css-fallback) .crt-phosphor-beam,
html:not(.crt-css-fallback) #crtPhosphorBeam,
html:not(.crt-css-fallback) .crt-flicker-layer,
html:not(.crt-css-fallback) #crtRetrace,
html:not(.crt-css-fallback) #vhsHeadSwitch,
html:not(.crt-css-fallback) .vhs-sweep,
html:not(.crt-css-fallback) .vhs-signal-layer,
html:not(.crt-css-fallback) #vhsDropout,
html:not(.crt-css-fallback) #vhsTracking,
html:not(.crt-css-fallback) .scanline-sweep {
  display: none !important;
}

html.crt-webgl #bg-container .bg-image,
html.crt-webgl #bg-container::before {
  display: none !important;
}

/*
 * THE TITLE IS DRAWN BY THE SAME BEAM AS THE PICTURE.
 *
 * Measured 2026-09-29 as a step response at 1440x900 dpr 2, against production: the picture
 * rises 10-90% in 15.0 device px, the same pipeline with its bandwidth terms at zero in 7.8,
 * and a plain div under the glass in 0.8. So page content was nineteen times sharper than the
 * picture beside it and sharper than the picture pipeline's own floor -- a horizontal edge no
 * tube can produce, on the largest element on the page.
 *
 * It is the same tell the two-pass split exists to remove -- crisp text over a scanned picture
 * -- surviving in the one axis that split does not cover: the glass carries occluders, and the
 * band limit lives in the picture pass where the shader cannot see the DOM.
 *
 * THE TITLE ONLY, on Travis's call. At 60px+ the softening is invisible to reading; body copy
 * at 12-16px would pay for it, and legibility is what the preceding four passes were about.
 *
 * html.crt-webgl, because the CSS fallback path has no luma band limit on its picture either --
 * softening the title there would make the text softer than the picture rather than matching
 * it, which is this fix pointed backwards.
 *
 * The sigma is set by CRTFilters.setBeamWidth from the picture's own bleed; see the derivation
 * there. Nothing is hard-coded in this file on purpose.
 */
html.crt-webgl #glitch-title {
  filter: url(#crt-beam-h);
}

/* Overlay tint to improve legibility without altering body background */
#bg-container::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background:
    radial-gradient(ellipse at center, rgba(0, 0, 0, 0) 40%, rgba(0, 0, 0, 0.35) 100%),
    linear-gradient(to bottom, rgba(0, 0, 0, 0.35), rgba(0, 0, 0, 0.6));
  mix-blend-mode: multiply;
  opacity: 0.8;
  transition: opacity calc(0.4s * var(--transition-scale, 1)) ease;
}

[data-motion='paused'] #bg-container::after {
  opacity: 0.9;
}

/* Slightly stronger default effect layers */
/* .crt-scanlines and its pseudo-elements retired 2026-09-20 -- see the note by
 * .crt-vignette's opacity below. */
.crt-vignette,
.scanline-sweep,
.crt-retrace-sweep,
.vhs-sweep {
  opacity: 0.35;
}
[data-motion='paused'] .crt-vignette,
[data-motion='paused'] .scanline-sweep,
[data-motion='paused'] .crt-retrace-sweep,
[data-motion='paused'] .vhs-sweep {
  opacity: 0.1;
}

/* Minor typography tweaks for readability */
header h1 {
  letter-spacing: 0.04em;
  line-height: 1.1;
}

nav a {
  /* 0.02em, matching the inline block that outranks this file. Was 0.04em and never
   * applied -- measured 0.511875px in Firefox, which is 0.02em at the rendered size. */
  letter-spacing: 0.02em;
}

.bg-container {
  position: fixed;
  inset: 0;
  z-index: -2;
  overflow: hidden;
}

.bg-image {
  position: absolute;
  inset: 0;
  background-position: center;
  background-size: cover;
  background-repeat: no-repeat;
  opacity: 0;
  will-change: opacity, transform;
  transition: opacity calc(var(--fade-dur) * var(--transition-scale, 1)) ease-in-out;
}

.bg-image.active {
  opacity: 1;
}

header {
  /*
   * 90, NOT 100 -- this is the hero-to-content transition.
   *
   * At exactly 100 the hero filled the viewport to the pixel at every desktop size (measured:
   * 900 of 900 at 1440 wide, 1440 of 1440 at 2560), so nothing signalled that anything existed
   * below it. A full-bleed photograph ending exactly at the fold reads as the whole page.
   *
   * 90 lets the top of the next section cross the fold, which is a measurable property rather
   * than a feel: main's top must be less than the viewport height.
   *
   * --vh rather than vh because mobile browser chrome makes 100vh taller than the visible
   * area and init.js keeps --vh honest. The mobile media query overrides this anyway, where
   * the header already sat at about 65% and needed no help.
   */
  min-height: calc(var(--vh) * 100);
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  align-items: center;
  text-align: center;
  /* 20, matching the inline block that outranks this file. Was 10 and never applied;
   * the relative order (header under nav) is unchanged either way. */
  z-index: 20;
  pointer-events: none;
  padding-bottom: calc(env(safe-area-inset-bottom, 0px) + var(--ui-bottom-pad));
  text-shadow:
    0 0 1px rgba(0, 0, 0, 0.9),
    0 0 8px rgba(0, 0, 0, 0.6);
  gap: clamp(0.5rem, 2vh, 1rem);
}

/* Fallback for mobile browsers that don't handle viewport units well */
@supports not (height: 100vh) {
  header {
    min-height: 100vh;
  }
}

#glitch-title {
  pointer-events: none;
}

#glitch-title .tg-letter {
  /*
   * SYNCED 2026-09-20 to the value that actually renders.
   *
   * This said `clamp(2.6rem, 6.8vw, 5.6rem)` and had never applied. The inline <style> in
   * index.html and critical-css.njk is emitted AFTER the crt.css <link>, so for equal
   * specificity it is the last word -- and it sets clamp(1.99rem, 5.2vw, 4.28rem). Measured
   * in Firefox at 1280px wide: 66.5px, which is 5.2vw, not the 87px this rule asked for.
   *
   * The larger value arrived with the v3.66 CRT port; the smaller one predates it. So this
   * was a tuning that has never been on screen, not the design. Recording reality here
   * rather than changing the site's main heading by 24% on the strength of a dead
   * declaration -- whether the title SHOULD be bigger is an aesthetic call for Travis, and
   * it is now a one-line change in two files instead of a mystery.
   *
   * tests/root-variable-contract.test.js asserts crt.css and the inline blocks agree, so
   * these cannot drift apart again silently.
   */
  font-size: clamp(1.99rem, 5.2vw, 4.28rem);
  font-weight: 800;
  letter-spacing: 0.02em;
  line-height: 1.05;
  display: inline-block;
  position: relative;
}

nav {
  margin-top: 0;
  margin-bottom: clamp(var(--space-2), 5vh, var(--space-4));
  position: relative;
  /* 22, matching the inline block that outranks this file. Was 21 and never applied. */
  z-index: 22;
}

#nav-list {
  list-style: none;
  padding: 0;
  margin: 0;
  display: flex;
  gap: clamp(var(--space-3), 4.8vw, var(--space-5));
  flex-wrap: wrap;
  justify-content: center;
  pointer-events: auto;
}

#nav-list a {
  color: #e8e3d8;
  text-decoration: none;
  /*
   * On the type scale, like everything else. This was clamp(1.05rem, 2.6vw, 1.6rem) -- an
   * independent fluid range reaching 25.6px at 2560px, larger than the section headings and
   * answerable to nothing. --step-1 is one ratio step above body, which is what a primary nav
   * should be beside a display title that is already several steps up.
   */
  font-size: var(--step-1);
  letter-spacing: 0.02em;
  padding: 0.15em 0.32em;
  position: relative;
  /* Colour only. This element's text-shadow carries the colour system's per-gun
   * convergence offsets and luma bleed radius; easing them made four controls
   * look dead. The transition-property is restated later at html-prefixed
   * specificity so it also beats the inline critical CSS. */
  transition: color calc(0.2s * var(--transition-scale, 1)) ease;

  /*
   * NO text-shadow here, deliberately. `html #nav-list a` further down sets one and outranks
   * this selector (1,0,2 against 1,0,1), so the three stops that used to sit here could never
   * render -- a rule silently disagreeing with the one it loses to, which is a shape this
   * project keeps finding. The halo has one owner now, and it is the shared halation model.
   */
  filter: brightness(1.05);
}

footer {
  position: fixed;
  left: 0;
  right: 0;
  bottom: env(safe-area-inset-bottom, 0px);
  padding: 0.5em 0.7em;
  z-index: 22;
  pointer-events: none;
  display: block;
  text-align: left;
}

/*
 * RETIRED 2026-09-20: the `.crt-barrel-distortion` rule, and with it the
 * #crt-barrel-distortion SVG filter that existed only to feed it.
 *
 * IT MATCHED NO ELEMENT, and never has. No page source emits that class. Measured in
 * Firefox: `document.querySelectorAll('.crt-barrel-distortion').length` is 0, and no element
 * on the page carries `url(#crt-barrel-distortion)` in its computed filter.
 *
 * The filter was added so the rule "stops being a dangling reference" -- which treated the
 * symptom. The reference dangled because the ELEMENT does not exist, and building a filter
 * for it made that fact invisible rather than answering it. Two aliased primitives were then
 * rebuilt on every page load, with a fresh PNG data URL written into one of them on every
 * geometry rebuild, all for a filter nothing carried.
 *
 * AND IT WAS A LOADED GUN. The rule carried
 * `backdrop-filter: contrast() brightness() hue-rotate()` on a full-viewport fixed layer at
 * z-index 37. That is the single most expensive thing this page can do -- see the rendering
 * rules in CLAUDE.md, where a backdrop-filter on a moving surface and an animated filter
 * have each already cost a CPU core. The day someone added the missing div to "make barrel
 * distortion work", they would have got that plus a full SVG displacement pass, in a commit
 * reading as a one-line fix. Same reasoning Travis applied to the `.scanlines` rule below.
 *
 * THE REAL BARREL DISTORTION IS UNAFFECTED and always was: it is #crt-picture's
 * feDisplacementMap, fed from crt-geometry-engine.js's calculateDistortion(), live on
 * .bg-image.
 */

/*
 * RETIRED 2026-09-19, on Travis's instruction: the `.scanlines` rule and both of its keyframe
 * tracks, `scanlineFlicker` and `scanlineIntensityVar`.
 *
 * It was a full-viewport `mix-blend-mode: multiply` layer with a repeating-gradient scanline
 * pattern, a two-layer curvature mask, and two `infinite` animations -- and it matched no element
 * in this repo's entire history. Every page source emits `class="crt-scanlines"`, a different
 * rule (see above); `.scanlines-interlaced` and `.scanlines-rgb` are created by JS. Nothing ever
 * emitted a bare `scanlines` class.
 *
 * It was not deleted as dead weight. It was deleted because it was a LOADED GUN.
 * `scanlineIntensityVar` animated `filter: brightness()/contrast()` infinitely on a full-viewport
 * blended layer -- structurally identical to the +-1deg `hue-rotate` on `.scanlines-rgb` that
 * measured a median 99% CPU, forever. The day anyone "fixed" the class name to match the element
 * that does exist, this page would have silently lost a core, in a commit that read as a one-word
 * typo fix. The picture it would have bought at that price is a +-5% brightness wobble under an
 * existing opacity flicker: close to invisible.
 *
 * Retire-or-connect was Travis's call and he chose retire. tests/animation-cost-contract.test.js
 * now asserts it stays gone, in place of the old assertion that it stayed unreachable.
 */

/*
 * Realistic interlaced scanlines -- LAYER GEOMETRY ONLY.
 *
 * RETIRED 2026-09-20: this rule's `background-image`, `mix-blend-mode: multiply` and
 * `animation: interlaceShift ...` declarations, and the `@keyframes interlaceShift` block
 * they referenced.
 *
 * All three were overridden by the second `.scanlines-interlaced` rule ~1100 lines down in
 * the Interlacing section, which redeclares every one of them. Confirmed in Firefox by
 * reading the computed style rather than by reasoning about the cascade: the gradient that
 * renders is the other rule's, mix-blend-mode computes to `normal`, and
 * `el.getAnimations()` returns exactly ONE animation, `interlaceFieldAlternate`.
 *
 * So editing the gradient or the animation here changed nothing on screen -- this repo's
 * signature failure wearing a stylesheet. It also made CLAUDE.md's "checked and FINE, do
 * not re-litigate" entry describe an animation that never runs, which is the worst place for
 * a wrong fact to sit.
 *
 * The live versions are better on every count: the gradient is derived from
 * --crt-scanline-period, `normal` blending is deliberate and load-bearing (a blended
 * full-viewport layer recomposites the whole backdrop on every paint and turned this page
 * black once), and interlaceFieldAlternate is engine-driven at 59.934 field alternations per
 * second against NTSC's 59.940 -- where interlaceShift's fixed 0.033s would have been 60.6.
 *
 * Restore with: git show 8dd559f:assets/css/crt.css
 */
.scanlines-interlaced {
  position: fixed;
  inset: 0;
  z-index: 39;
  pointer-events: none;
}

/* Enhanced RGB separation with phosphor-accurate colors */
.scanlines-rgb {
  position: fixed;
  inset: 0;
  z-index: 41;
  pointer-events: none;
  mix-blend-mode: screen;
  opacity: 0.18;

  /*
   * The P22 phosphor gradients live on the .crt-gun children below, one per electron gun,
   * so that each can be positioned independently -- which is what convergence error is.
   * See the .crt-gun block for the full reasoning.
   */

  animation:
    rgbConvergenceError calc(7s * var(--animation-scale, 1)) ease-in-out infinite alternate,
    phosphorBrightness calc(12s * var(--animation-scale, 1)) ease-in-out infinite;

  /*
   * TWO masks, intersected.
   *
   * The second is the original radial falloff, unchanged: phosphor coverage thins toward the
   * edge of the faceplate.
   *
   * The FIRST is the picture itself, read as luminance. These three gun layers model light
   * EMITTED by phosphor, and phosphor emits in proportion to beam current, which is the video
   * signal -- so wherever the picture is black the beam is cut off and these must contribute
   * nothing. Screen-blended and ungated they did the opposite, adding a fixed 8.08 luma of
   * coloured glow to every black area on the page. That is the single most recognisable tell
   * of a fake CRT: real tubes go black where the signal does, and a simulation that glows in
   * the dark reads as an overlay sitting on top of a photograph, which is exactly what it was.
   *
   * mask-size/position track .bg-image's `cover`/`center` so the mask registers with the
   * picture it is derived from. Gating emission on the picture is not an approximation of the
   * physics, it IS the physics -- so this replaces no feature and loses no effect; the
   * fringing still appears wherever there is light to fringe.
   */
  mask-image:
    var(--crt-emission-mask),
    radial-gradient(
      circle at center,
      rgba(0, 0, 0, 1) 55%,
      rgba(0, 0, 0, 0.8) 75%,
      rgba(0, 0, 0, 0.4) 90%,
      rgba(0, 0, 0, 0.2) 100%
    );
  mask-mode: luminance, alpha;
  mask-size: cover, auto;
  mask-position: center, center;
  mask-repeat: no-repeat, no-repeat;
  mask-composite: intersect;
}

/*
 * One layer per electron gun.
 *
 * WHAT THIS REPLACES, AND WHY
 * The three phosphor sets were three stacked backgrounds on one element, and their
 * "convergence error" was `filter: hue-rotate()` swinging +-1 degree on the whole thing.
 * That is not what misconvergence is. Three guns misconverge by landing in different
 * PLACES; it is a purely spatial fault. A hue rotation is purely colourimetric and has no
 * spatial component at all, so the two have nothing in common beyond both being able to
 * change what a pixel looks like. It also cost a full CPU core, because animating `filter`
 * re-rasterises the viewport every frame.
 *
 * An intermediate version of this cross-faded two statically hue-rotated copies. That was
 * cheap and it was still a fake -- a faithful reproduction of the wrong model.
 *
 * These three layers are the real thing. Each holds ONE phosphor set, and each is translated
 * by that gun's own convergence error, so the colour fringing on every edge emerges the way
 * it does on a tube: from the three phosphor sets being out of register with each other. The
 * offsets come from the per-gun model in crt-physics-enhanced.js -- factory misalignment plus
 * thermal drift, green best-converged because it is the centre gun of an in-line trio, red
 * and blue erring in opposition -- which has been computing them since it was written and
 * had nothing reading it.
 *
 * The cost is a transform on three cached textures, which the compositor does for free. The
 * cheap version and the correct version turned out to be the same version.
 */
.crt-gun {
  position: absolute;
  inset: 0;
  pointer-events: none;

  /*
   * Deliberately NO `will-change: transform`.
   *
   * will-change asks the compositor to keep a promoted layer standing by because this thing
   * is about to move. These do not move in that sense: convergence settles as the tube warms
   * over roughly half an hour, and crt-physics-enhanced.js skips the write entirely unless
   * something shifted by more than 0.02px, so in practice each layer is repositioned a
   * handful of times and then never again.
   *
   * Hinting it anyway pinned three full-viewport layers in the compositor permanently, which
   * measured at ~49% CPU -- more than the effect itself costs. will-change earns its place on
   * something animating every frame, like .scanline-sweep during a run; on something that
   * changes twice an hour it is pure overhead.
   */
}

/* Authentic P22 phosphor colors with realistic distribution */
.crt-gun-r {
  background-image: repeating-linear-gradient(
    to bottom,
    rgba(255, 69, 0, 0.15) 0 1.5px,
    /* Red phosphor */ transparent 2px
  );
}

.crt-gun-g {
  background-image: repeating-linear-gradient(
    to bottom,
    rgba(0, 255, 100, 0.14) 0.5px 2px,
    /* Green phosphor */ transparent 2.5px
  );
}

.crt-gun-b {
  background-image: repeating-linear-gradient(
    to bottom,
    rgba(70, 130, 255, 0.13) 1px 2.5px,
    /* Blue phosphor */ transparent 3px
  );
}

/* Enhanced CRT vignette with corner darkening */
.crt-vignette {
  position: fixed;
  inset: 0;
  z-index: 38;
  pointer-events: none;
  background:
    /* Corner darkening (CRT shadow mask effect) */
    radial-gradient(ellipse 130% 120% at 15% 15%, rgba(0, 0, 0, 0.3), transparent 45%),
    radial-gradient(ellipse 130% 120% at 85% 15%, rgba(0, 0, 0, 0.3), transparent 45%),
    radial-gradient(ellipse 130% 120% at 15% 85%, rgba(0, 0, 0, 0.3), transparent 45%),
    radial-gradient(ellipse 130% 120% at 85% 85%, rgba(0, 0, 0, 0.3), transparent 45%),
    /* Main vignette with realistic falloff */
      radial-gradient(
        ellipse 95% 90% at 50% 50%,
        rgba(0, 0, 0, 0) 20%,
        rgba(0, 0, 0, 0.15) 60%,
        rgba(0, 0, 0, 0.35) 85%,
        rgba(0, 0, 0, 0.6) 100%
      ),
    /* Edge brightening (CRT phosphor edge glow) */
      radial-gradient(
        ellipse 88% 82% at 50% 50%,
        rgba(255, 255, 255, 0) 70%,
        rgba(255, 255, 255, 0.02) 85%,
        rgba(255, 255, 255, 0.04) 95%
      );
  mix-blend-mode: multiply;
  opacity: 0.85;
}

/*
 * The retrace sweep -- the bar that rolls down the picture on a background change.
 *
 * REBUILT 2026-09-13. Travis's assessment of the previous version was that it "needs a lot of
 * work", and it was wrong in two separate ways that happened to share a cause.
 *
 * It did not LOOK like a retrace. A retrace bar is a thin bright line with a soft wake
 * trailing behind it, and it rolls past in a couple of seconds. The old one was a 32vh slab
 * of blurred high-contrast wash with no line in it at all, crawling the height of the screen
 * over --sweep-dur: 25s. At that speed it is not a sweep, it is a slowly moving stain.
 *
 * It was also expensive, and one of the three causes is genuinely irreducible while the other
 * two were implementation choices:
 *
 *   1. `backdrop-filter` with a blur. This CANNOT be cached on a moving element, because the
 *      backdrop beneath it is different every frame by definition. It is RETAINED -- it is
 *      what makes the picture under the bar go hot and hue-shift, which no overlay can fake
 *      -- and it is affordable now only because the bar crosses in 2.6s instead of 25s. That
 *      trade is the reason the duration changed; see --sweep-dur.
 *   2. The keyframes animated the `filter` property. Same defect measured at a full CPU core
 *      on .scanlines-rgb: animating `filter` defeats the layer cache. The filter there was
 *      only ever carrying a `brightness()` envelope, and on a screen-blended overlay scaling
 *      brightness and scaling alpha do the same thing -- so the envelope now rides on
 *      `opacity`, which the compositor animates for free. Same brightening, same curve.
 *   3. `skewY` changed on every keyframe. Retained. This one turned out to be a bad guess on
 *      my part: skew is part of the transform matrix and composites like any other transform.
 *      It was never the problem and the wobble is back, unchanged.
 *
 * So the appearance is intact: the backdrop treatment, both mask layers, the seven-keyframe
 * skew and translateX wobble, the brightness envelope and the original easing are all still
 * here. What changed is that the brightness envelope stopped being a filter, and the bar
 * stopped taking twenty-five seconds to cross.
 *
 * The added gradient is new -- the old bar had no background of its own at all, which is why
 * it had no visible retrace LINE in it, only a wash. That is the part that makes it read as
 * a scan rather than a stain.
 */
.scanline-sweep {
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  /*
   * 14vh, down from 32vh -- a third of the viewport at every size, which is why this read as
   * "a white flashing bar" rather than as a retrace sweep.
   *
   * The size is anchored to something real now: the vertical blanking interval is 7.43% of a
   * field (39 of 525 lines), which is what #crtRetrace already draws. A rolling bar is that
   * blanking interval plus the phosphor wake trailing it, so roughly double -- not a third of
   * the screen.
   */
  height: 14vh;
  z-index: 45;
  pointer-events: none;

  /*
   * The backdrop treatment lives on `.scanline-sweep.run`, not here.
   *
   * It is the same declaration, unchanged; it is only scoped to when the bar is actually
   * sweeping. A backdrop-filter makes the compositor read and re-filter the region behind
   * the element, and it does that whether or not anything is moving -- so while the bar sat
   * parked off-screen between sweeps, which is most of the time, it was paying for a
   * treatment nobody could see. Nothing is lost: outside `.run` the element is translated
   * clear of the viewport.
   */

  /*
   * The beam profile, read top to bottom. The bar travels DOWNWARD, so the lower part of
   * this gradient is the leading edge and everything above it is the phosphor wake decaying
   * behind. That asymmetry is what makes it read as a direction of travel rather than as a
   * band that happens to be moving.
   *
   * The line sits at ~70% rather than near the bottom because the linear mask below closes
   * from 75% down; putting it lower would have the mask erase the brightest part of it.
   */
  background-image: linear-gradient(
    to bottom,
    rgba(255, 255, 255, 0) 0%,
    rgba(186, 212, 255, 0.05) 22%,
    rgba(210, 230, 255, 0.12) 44%,
    rgba(234, 244, 255, 0.24) 60%,
    rgba(255, 255, 255, 0.44) 67%,
    /* the retrace line itself: thin, and the only genuinely bright part */ rgba(255, 255, 255, 0.9)
      70%,
    rgba(255, 255, 255, 0.62) 71%,
    rgba(168, 204, 255, 0.36) 73.5%,
    rgba(116, 162, 240, 0.09) 76%,
    rgba(255, 255, 255, 0) 82%
  );

  /* Enhanced noise filter for retrace distortion. Static, so it rasterises once and is
   * cached -- it was animating it, not having it, that cost. */
  filter: url(#crt-retrace-noise);

  /* Realistic masking for electron beam shape */
  -webkit-mask-image:
    /* Horizontal beam intensity profile */
    linear-gradient(
      to bottom,
      transparent 0%,
      rgba(0, 0, 0, 0.04) 6%,
      rgba(0, 0, 0, 0.15) 12%,
      rgba(0, 0, 0, 0.36) 18%,
      rgba(0, 0, 0, 0.65) 24%,
      rgba(0, 0, 0, 0.88) 30%,
      rgba(0, 0, 0, 1) 38%,
      rgba(0, 0, 0, 1) 62%,
      rgba(0, 0, 0, 0.88) 70%,
      rgba(0, 0, 0, 0.65) 76%,
      rgba(0, 0, 0, 0.36) 82%,
      rgba(0, 0, 0, 0.15) 88%,
      rgba(0, 0, 0, 0.04) 94%,
      transparent 100%
    ),
    /* Radial falloff for beam focus */
      radial-gradient(
        ellipse 85% 90% at center,
        rgba(0, 0, 0, 1) 50%,
        rgba(0, 0, 0, 0.8) 70%,
        rgba(0, 0, 0, 0.4) 85%,
        rgba(0, 0, 0, 0.2) 95%,
        transparent 100%
      );
  /*
   * THE PICTURE, FIRST IN THE LIST, as a luminance gate.
   *
   * Same reason as #crtRetrace: a sweep is emitted light and cannot be brighter than the
   * phosphor it is exciting. Ungated it painted at full strength over a near-black frame,
   * which is a white bar appearing out of nothing rather than a beam crossing a picture. The
   * two gradients below shape the bar; this decides whether there is anything to shape.
   *
   * Only on the standard property. -webkit-mask-composite takes different keywords and cannot
   * express intersect the same way, so prepending there would ADD the picture to the mask
   * rather than gate by it -- which would make the bar cover more, not less.
   */
  mask-image:
    var(--crt-emission-mask),
    linear-gradient(
      to bottom,
      transparent 0%,
      rgba(0, 0, 0, 0.04) 6%,
      rgba(0, 0, 0, 0.15) 12%,
      rgba(0, 0, 0, 0.36) 18%,
      rgba(0, 0, 0, 0.65) 24%,
      rgba(0, 0, 0, 0.88) 30%,
      rgba(0, 0, 0, 1) 38%,
      rgba(0, 0, 0, 1) 62%,
      rgba(0, 0, 0, 0.88) 70%,
      rgba(0, 0, 0, 0.65) 76%,
      rgba(0, 0, 0, 0.36) 82%,
      rgba(0, 0, 0, 0.15) 88%,
      rgba(0, 0, 0, 0.04) 94%,
      transparent 100%
    ),
    radial-gradient(
      ellipse 85% 90% at center,
      rgba(0, 0, 0, 1) 50%,
      rgba(0, 0, 0, 0.8) 70%,
      rgba(0, 0, 0, 0.4) 85%,
      rgba(0, 0, 0, 0.2) 95%,
      transparent 100%
    );
  -webkit-mask-composite: source-in;
  mask-mode: luminance, alpha, alpha;
  mask-size: cover, auto, auto;
  mask-position: center, center, center;
  mask-repeat: no-repeat, no-repeat, no-repeat;
  mask-composite: intersect;

  mix-blend-mode: screen;
  opacity: 0.5;

  /* Parked off the top of the screen until .run starts it. */
  transform: translate3d(0, -36vh, 0) skewY(0.8deg) translateX(-2px);
  will-change: transform, opacity;
}

/*
 * The original seven-keyframe wobble, with the brightness envelope moved onto opacity.
 *
 * The skew and translateX values are unchanged from the version that shipped. The vertical
 * positions are the same trajectory rescaled: the original ran -120vh to 220vh across a
 * 32vh band parked at top:-30vh, this runs -36vh to 108vh from top:0, and the original's
 * offsets were near-linear in time so a proportional remap preserves the motion.
 *
 * The opacity numbers are the old `brightness()` values -- 1.2, 1.4, 1.6, 1.5, 1.3, 1.1, 1.0
 * -- normalised against their own peak and scaled by the element's 0.5 base. On a
 * screen-blended overlay, scaling the layer's alpha and scaling its RGB both scale what it
 * contributes, so this is the same brightening curve arriving by a route the compositor can
 * animate without re-rasterising.
 */
@keyframes realisticSweepDown {
  0% {
    transform: translate3d(0, -36vh, 0) skewY(0.8deg) translateX(-2px);
    opacity: 0.375;
  }
  15% {
    transform: translate3d(0, -14.4vh, 0) skewY(-0.3deg) translateX(1px);
    opacity: 0.4375;
  }
  35% {
    transform: translate3d(0, 14.4vh, 0) skewY(1.2deg) translateX(-1px);
    opacity: 0.5;
  }
  50% {
    transform: translate3d(0, 36vh, 0) skewY(-0.6deg) translateX(2px);
    opacity: 0.469;
  }
  65% {
    transform: translate3d(0, 57.6vh, 0) skewY(1deg) translateX(-1px);
    opacity: 0.406;
  }
  85% {
    transform: translate3d(0, 86.4vh, 0) skewY(-0.8deg) translateX(3px);
    opacity: 0.344;
  }
  100% {
    transform: translate3d(0, 108vh, 0) skewY(0.5deg) translateX(-2px);
    opacity: 0.313;
  }
}

.scanline-sweep.run {
  /*
   * Realistic CRT retrace effect. Scoped here rather than to .scanline-sweep so the
   * compositor only reads the backdrop while the bar is actually crossing the screen.
   *
   * TONED DOWN HARD on 2026-09-23, from contrast(2.8) brightness(1.4) saturate(1.8)
   * hue-rotate(8deg) blur(1.5px), because a backdrop-filter draws a RECTANGLE.
   *
   * The element's own paint is masked into a soft beam profile, but the backdrop-filter is
   * clipped to the border box and the mask does not feather it the same way -- so the picture
   * behind the bar was being contrast-boosted inside a hard-edged 306px band with visible
   * horizontal seams top and bottom, travelling down the screen. Driven live and caught
   * mid-travel, that rectangle was the most artificial thing on the page: a soft glowing beam
   * with a crisp grey box around it.
   *
   * At these values the boundary is below the threshold where it reads as an edge at all, and
   * the picture beneath the bar still lifts, which is the thing an overlay genuinely cannot
   * reproduce. The blur is gone entirely -- it was the largest single contributor to the
   * visible seam, because a blurred backdrop against an unblurred one is a sharp change in
   * detail rather than in brightness, and the eye finds that boundary instantly.
   *
   * Cheaper too, though that is not why. CLAUDE.md measures the old filter at ~127% of a core
   * while the bar runs; a contrast/brightness pair with no blur is a fraction of that.
   */
  backdrop-filter: contrast(1.3) brightness(1.16) saturate(1.12);
  -webkit-backdrop-filter: contrast(1.3) brightness(1.16) saturate(1.12);

  /*
   * NOT scaled by the performance tier. Same argument as the interlace field rate.
   *
   * How fast a retrace bar crosses the screen is set by the vertical hold error on the tube,
   * not by how fast the viewer's GPU is. Slowing it does not make a cheaper version of the
   * effect, it makes a different and worse one -- CLAUDE.md already records that this bar was
   * rescued from 25s once, because "at that speed a retrace bar is a stain, not a scan".
   *
   * And the duty cycle is load-bearing rather than incidental: the backdrop-filter here is
   * affordable only because the bar is on screen about 17% of the time, which is why a test
   * pins --sweep-dur under 6s. Multiplying by a tier factor at runtime walked straight past
   * that test, because the test reads the literal.
   */
  animation: realisticSweepDown var(--sweep-dur) cubic-bezier(0.15, 0.61, 0.25, 0.98) forwards;
}

/* Phosphor persistence effect for text elements */
.tg-letter {
  position: relative;
  display: inline-block;
  will-change: transform, filter, text-shadow;
}

.tg-letter::after {
  content: attr(data-char);
  position: absolute;
  top: 0;
  left: 0;
  opacity: 0;
  pointer-events: none;
  transition: opacity calc(0.3s * var(--transition-scale, 1)) cubic-bezier(0.23, 1, 0.32, 1);
  text-shadow: inherit;
  filter: blur(1px) brightness(0.8);
}

/*
 * The idle phosphor glow, coloured by the P22 decay model.
 *
 * This was `rgba(0, 255, 100, ...)` -- a flat green held for 400ms -- while
 * title-glitch-enhanced.js computed the real per-gun decay on these same elements. The
 * whole point of that model is that a fading CRT highlight is NOT green: red has a tenth
 * the persistence of blue, so it drops out first, then green, and the residue is blue, so a
 * white highlight fades through cyan and dies blue. A fixed green overlay was sitting on
 * top of the effect built to demonstrate exactly that.
 *
 * The stops are set as custom properties by crt-effects.js from CRTPhysics.phosphorMixAt()
 * -- the same function and the same intensities-to-colour mapping title-glitch uses, so
 * there is one answer to "what colour is a phosphor trail this old" rather than one per
 * consumer. The fallbacks below are that sequence at the current gain, used only if the
 * physics script has not loaded; they are a load-order fallback, not a second source.
 *
 * `forwards` matters: without it the shadow snaps back to the 0% stop on the final frame,
 * which is a white flash as the glow disappears -- the opposite of a decay.
 */
.tg-letter.phosphor-glow::after {
  opacity: 0.6;
  animation: phosphorGlowDecay var(--phosphor-glow-dur, 400ms) linear forwards;
}

@keyframes phosphorGlowDecay {
  /* Front-loaded stops rather than even ones: at gain 20 red's time constant is 20ms
     against blue's 200ms, so the whole white-to-cyan transit happens in the first tenth
     and evenly spaced stops would step straight over it. */
  0% {
    text-shadow:
      0 0 8px rgba(var(--phosphor-c0, 255, 255, 255), 0.4),
      0 0 16px rgba(var(--phosphor-c0, 255, 255, 255), 0.2);
  }
  10% {
    text-shadow:
      0 0 8px rgba(var(--phosphor-c1, 42, 115, 255), 0.4),
      0 0 16px rgba(var(--phosphor-c1, 42, 115, 255), 0.2);
  }
  30% {
    text-shadow:
      0 0 8px rgba(var(--phosphor-c2, 1, 23, 255), 0.4),
      0 0 16px rgba(var(--phosphor-c2, 1, 23, 255), 0.2);
  }
  100% {
    text-shadow:
      0 0 8px rgba(var(--phosphor-c3, 0, 0, 255), 0.4),
      0 0 16px rgba(var(--phosphor-c3, 0, 0, 255), 0.2);
  }
}

/*
 * `scanlineFlicker` and `scanlineIntensityVar` were removed here 2026-09-19 along with their only
 * owner, the orphaned `.scanlines` rule. See the note at that rule's former site for why.
 */

/*
 * RETIRED 2026-09-20 with the overridden declarations that referenced it: @keyframes
 * interlaceShift, a 1px translateY at a fixed 0.033s steps(2).
 *
 * Its only reference was the `animation` shorthand in the first .scanlines-interlaced rule,
 * which the Interlacing section overrode with animation-name: interlaceFieldAlternate. So it
 * never ran. The live one is engine-driven off --crt-interlace-period and lands at 59.934
 * field alternations per second against NTSC's 59.940; this one's fixed 0.033s would have
 * been 60.6. See the note on .scanlines-interlaced above.
 */

/*
 * Convergence drift on the phosphor stripe layer.
 *
 * THIS USED TO ANIMATE `filter: hue-rotate()` ALONGSIDE THE TRANSFORM, AND THAT ONE TRACK
 * COST A FULL CPU CORE, PERMANENTLY.
 *
 * Measured 2026-09-13 in Firefox, five randomised rounds against two null controls whose
 * medians came out at -3% and -2%: removing this single keyframe track and changing nothing
 * else recovered a median of 99% CPU, positive in all five runs. Removing the `opacity`
 * track next door recovered 5%, which is noise. That asymmetry is the whole lesson --
 * `transform` and `opacity` are compositor properties and animate for free, while `filter`
 * is not, so animating it defeats the layer cache and re-rasterises the entire viewport
 * every frame for as long as the page is open.
 *
 * THE NAME IS A MISNOMER AND THE REMAINING TRACK IS NOT CONVERGENCE. This animates the
 * PARENT, which moves all three phosphor sets together, and three guns that move together
 * are not misconverged -- that is the whole raster shifting. Which is itself a real thing:
 * the picture wanders slightly as the yoke warms and the EHT supply sags. So the motion is
 * kept and is honest, it is simply raster drift wearing the wrong name.
 *
 * Actual convergence error now lives where it belongs, on the three .crt-gun children, one
 * offset per gun, driven by the per-gun thermal model in crt-physics-enhanced.js. See the
 * .crt-gun block.
 */
@keyframes rgbConvergenceError {
  0% {
    transform: translateX(0px);
  }
  25% {
    transform: translateX(0.5px);
  }
  50% {
    transform: translateX(-0.3px);
  }
  75% {
    transform: translateX(0.8px);
  }
  100% {
    transform: translateX(0px);
  }
}

/*
 * @keyframes phosphorGunDrift was here.
 *
 * It cross-faded two statically hue-rotated copies of the phosphor gradients, reproducing
 * the old `filter: hue-rotate()` swing at compositor cost. It was cheap and it was wrong:
 * it faithfully reimplemented a model that had nothing to do with convergence. Travis called
 * it what it was.
 *
 * There is no CSS animation in its place because there should not be one. Convergence drift
 * is not a 7-second loop, it is a slow one-way settling as the tube reaches operating
 * temperature, and crt-physics-enhanced.js already models it that way. The three .crt-gun
 * layers are positioned from that model directly.
 *
 * This note is here so the next reader looking for the colour wobble finds where it went
 * rather than concluding it was dropped.
 */

@keyframes phosphorBrightness {
  0% {
    opacity: 0.18;
  }
  30% {
    opacity: 0.22;
  }
  60% {
    opacity: 0.16;
  }
  100% {
    opacity: 0.18;
  }
}

/* Horizontal hold error simulation */
@keyframes horizontalHoldError {
  0% {
    transform: translateX(0px) skewX(0deg);
  }
  10% {
    transform: translateX(-3px) skewX(-0.2deg);
  }
  20% {
    transform: translateX(5px) skewX(0.3deg);
  }
  30% {
    transform: translateX(-2px) skewX(-0.1deg);
  }
  40% {
    transform: translateX(1px) skewX(0.1deg);
  }
  50% {
    transform: translateX(-1px) skewX(-0.05deg);
  }
  100% {
    transform: translateX(0px) skewX(0deg);
  }
}

.horizontal-hold-error {
  animation: horizontalHoldError calc(0.8s * var(--animation-scale, 1))
    cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;
}

/* Motion reduction overrides */
/* Mobile responsive adjustments */
@media (max-width: 768px) {
  :root {
    --ui-bottom-pad: 0px; /* Remove default bottom padding on mobile */
    --mobile-bottom-safe: 70px; /* Space for browser UI bars */
  }

  /* Center content vertically with safe bottom margin */
  header {
    min-height: 70vh !important; /* Shorter viewport usage */
    justify-content: center !important; /* Center instead of flex-end */
    padding-bottom: var(--mobile-bottom-safe) !important; /* Safe space for browser UI */
    gap: clamp(0.8rem, 2vh, 1.2rem);
  }

  /* Ensure navigation is positioned properly and visible */
  nav {
    position: relative !important; /* Ensure proper positioning */
    margin-bottom: clamp(8px, 2vh, 16px) !important; /* Controlled spacing */
    z-index: 999 !important; /* Force above other elements */
  }

  #nav-list {
    gap: clamp(1rem, 6vw, 2rem);
  }

  #nav-list a {
    padding: 0.4em 0.6em; /* Larger touch targets */
    font-size: clamp(1.1rem, 4vw, 1.4rem);
    /* Stronger shadow for better contrast on mobile */
    text-shadow:
      0 0 2px rgba(0, 0, 0, 1),
      0 0 8px rgba(0, 0, 0, 0.8),
      0 0 16px rgba(0, 0, 0, 0.4);
  }

  /*
   * 44px minimum on anything tappable. The comment above says "larger touch targets" and the
   * padding it applies was not enough: measured across iPhone 13, iPhone SE, Pixel and iPad
   * Mini profiles, EIGHT controls came back under 44px on every one of them -- the three nav
   * links at 28px tall, the motion toggle at 33, the CRT panel toggle at 32, and the three
   * contact links at 22.
   *
   * 44px is the floor in Apple's HIG and what WCAG 2.5.5 asks for. Height is the whole
   * problem here: every one of these is already wide enough, and they are short because they
   * are text sized by line-height with a little padding. min-height plus centring fixes the
   * axis that is actually failing without changing the type size or the layout rhythm.
   */
  #nav-list a,
  #contact a,
  #tube-panel-handle {
    min-height: 44px;
    display: inline-flex;
    align-items: center;
    justify-content: center;
  }

  /*
   * The padding, leading and legibility shadow that used to sit here have MOVED TO THE BASE
   * `main` rule near the top of this file, and that is a strengthening rather than a
   * deletion: they were defects at every width, not mobile ones. Mobile got them first only
   * because mobile was audited first.
   *
   * What stays mobile-only is the 44px touch floor above, which genuinely is a phone
   * concern. The base rule's `clamp(1rem, 4vw, 2rem)` resolves to 1rem at phone widths, so
   * the padding this replaced is unchanged where it was already applying.
   */
}

@media (max-width: 480px) {
  :root {
    --ui-bottom-pad: 0px; /* No bottom padding on phones */
    --mobile-bottom-safe: 80px; /* More space on small screens */
  }

  /* Even more conservative positioning for small screens */
  header {
    min-height: 65vh !important; /* Even shorter for phones */
    justify-content: center !important; /* Maintain centered positioning */
    padding-bottom: var(--mobile-bottom-safe) !important; /* Larger safe margin */
    gap: clamp(0.6rem, 1.5vh, 1rem);
  }

  nav {
    margin-bottom: clamp(4px, 1vh, 8px) !important; /* Minimal spacing */
  }

  #nav-list {
    gap: clamp(0.8rem, 5vw, 1.5rem);
  }
}

/* Landscape phones need different handling */
@media (max-height: 500px) and (orientation: landscape) {
  :root {
    --mobile-bottom-safe: 40px; /* Less space needed in landscape */
  }

  header {
    min-height: 85vh !important; /* Use more viewport in landscape */
    justify-content: center !important;
    padding-bottom: var(--mobile-bottom-safe) !important;
  }
}

@media (prefers-reduced-motion: reduce) {
  .scanlines-rgb,
  .scanlines-interlaced,
  .scanline-sweep,
  .crt-vignette {
    display: none !important;
  }

  .tg-letter::after {
    display: none !important;
  }
}

/* ===========================================================================
 * A CONSTRAINED DEVICE GETS FEWER LAYERS, NOT SLOWER ONES.
 *
 * Measured 2026-09-23, and the ordering is the whole argument:
 *
 *   - pausing EVERY animation on the page:        +1.7%, inside the null band
 *   - scaling every duration by 3x (the tiers):   inside the null band, all four
 *   - hiding the visible layer stack:             60-65% of total page cost
 *
 * So cost tracks the NUMBER of full-viewport composited layers, not their motion. The tier
 * system only stretched durations, which is why it measured as doing nothing at all -- and
 * on a phone it was worse than nothing, because --animation-scale: 3 bought no CPU while
 * making the motion sluggish.
 *
 * WHY A MEDIA QUERY AND NOT JUST .perf-reduce. `perf-reduce` is added by JavaScript after
 * performance-monitor.js initialises, so a phone composites all twenty layers through page
 * load -- the moment it can least afford it and the moment someone is watching. A media
 * query applies at first paint, needs no JS and cannot fail. The class is kept as the second
 * axis, for the case a width query cannot see: a wide but weak laptop.
 *
 * WHAT GOES, strongest evidence first:
 *   .crt-gun x3     per-gun convergence. At the shipped defaults these were measured
 *                   rendering a BIT-IDENTICAL frame -- three full-viewport layers producing
 *                   no pixels. Not a judgement call.
 *   #crtPhosphorBeam  a full-viewport screen-blended canvas with its own rAF loop. Its
 *                   backing store is a quarter of its CSS size, so on a 2-3x DPR phone it is
 *                   upscaled eightfold and reads as mush regardless.
 *   the VHS set     signal drift, chroma noise, dot crawl, tracking bar. These are TAPE
 *                   artifacts, not tube artifacts, and dot crawl's period is 6.8px -- texture
 *                   a 390px-wide screen cannot resolve.
 *   .crt-flicker-layer  refresh flicker, which at phone size reads as brightness noise.
 *
 * WHAT STAYS, and why: the shadow mask, the scanlines and the vignette are what make this
 * read as a tube at all. Until 2026-09-23 the opposite was shipping --
 * performance-monitor.js's disableHeavyEffects() hid `.crt-phosphor-dots`, so every phone
 * lost the shadow mask, the single most identifying CRT feature and a STATIC layer, while
 * keeping the three gun layers that render nothing.
 *
 * This is deliberately a different set from the `prefers-reduced-motion` block above, and
 * the two must not be merged. That one answers "what moves distractingly?" and this one
 * answers "what costs most per unit of look?" Those criteria select nearly opposite layers.
 * =========================================================================== */
@media (max-width: 768px) {
  .crt-gun,
  #crtPhosphorBeam,
  .vhs-signal-layer,
  .vhs-chroma-noise,
  .composite-artifacts,
  .vhs-sweep,
  .crt-flicker-layer {
    display: none !important;
  }

  /*
   * THE TWO TRANSIENT BARS, and this is the "two white bars that flash" Travis reported.
   *
   * I read that as a scan-line complaint and rebuilt the scan-line gradient. Scan lines are a
   * 4px repeating pattern; they cannot read as "two bars". Two bright bars that cross and
   * vanish is a literal description of .scanline-sweep and #crtRetrace, and neither had ever
   * been looked at on a phone.
   *
   * .scanline-sweep is `height: 32vh` -- a third of the screen at EVERY viewport. Measured on
   * an iPhone 13 that is 218px of bright bar with a backdrop-filter behind it, against a
   * viewport a quarter the area of the desktop one it was tuned on. Photographed at phone size
   * it puts a hard-edged bright band across the frame; the same shot with both bars suppressed
   * is clean. #crtRetrace adds a second at 7.4%.
   *
   * Dropped here rather than resized because a bar is an OCCASIONAL FAULT -- a sync roll --
   * and a fault that occupies a third of a phone screen is not a subtle detail, it is the
   * page's dominant visual event. Desktop keeps both; 32vh of 900px reads as a sweep, 32vh of
   * 664px reads as a flash. The height is still worth revisiting on desktop, which is a look
   * decision rather than a defect.
   */
  /*
   * RESTORED. I dropped both bars here because the sweep was 32vh -- a third of a phone screen
   * -- and removing them was the wrong fix for that: the size was the defect, not the effect.
   * The sweep is 14vh now, which is about 93px on a 664px viewport, so it behaves like a bar
   * crossing a screen instead of like a screen going white.
   */
}

html.perf-reduce .crt-gun,
html.perf-reduce #crtPhosphorBeam,
html.perf-reduce .vhs-signal-layer,
html.perf-reduce .vhs-chroma-noise,
html.perf-reduce .composite-artifacts,
html.perf-reduce .vhs-sweep,
html.perf-reduce .crt-flicker-layer {
  display: none !important;
}

/*
 * Halation is dropped on perf-reduce ONLY, not by the width query the layers above also use.
 *
 * It is a full-viewport blur, which is exactly the shape this file calls expensive, so adding
 * it straight after a session spent cutting mobile cost needs a reason rather than an
 * exemption. The reason is that its cost scales with the viewport it is drawn over AND with a
 * radius expressed in vw: 1.05% of 1280px is a 13px blur over 1.02M pixels, while 1.05% of
 * 390px is a 4px blur over 0.33M. Measured on desktop, where it is most expensive, it costs
 * about 10% of frame rate (51/52 fps down to 46/48, interleaved off/on/off/on).
 *
 * It is also the single effect that buys the most realism per unit cost -- it is what makes
 * highlights read as emitted light rather than as a photograph with scan lines over it -- so
 * cutting it from every phone by width would give back the best thing here to save the least.
 * A device that has actually been measured as struggling still loses it.
 *
 * display:none rather than --crt-halation-enabled:0, and the distinction is the one this file
 * already makes about opacity:0 layers: an element at zero opacity is still laid out and still
 * composited, so zeroing the gain would leave a full-viewport blur being computed for a device
 * that was demoted precisely because it cannot afford one.
 *
 * And there is no --crt-halation-enabled companion variable. The first version of this rule had
 * one, read with a fallback of 1 and set by nothing, and css-var-contract failed on it exactly
 * as it should: the fallback is the only value that could ever apply, so the rule was a
 * constant wearing a parameter. --crt-halation-gain is the control surface and already exists.
 */
html.perf-reduce #bg-container::before {
  display: none !important;
}

[data-motion='paused'] .scanlines-rgb,
[data-motion='paused'] .scanlines-interlaced,
[data-motion='paused'] .scanline-sweep {
  animation-play-state: paused !important;
  display: none;
}

[data-motion='paused'] .scanline-sweep.run {
  animation: none !important;
}

[data-motion='paused'] .tg-letter::after {
  opacity: 0 !important;
  /* Stop the decay keyframes too, not just hide them. An animation on an opacity-0 element
     is still a live animation the compositor has to service -- same reasoning as
     `.scanline-sweep.run` above, which takes `animation: none` rather than relying on being
     invisible. */
  animation: none !important;
}

/* Missing CRT Effect Elements - IDs that JavaScript expects */

/* CRT Retrace Effect */
#crtRetrace {
  position: fixed;
  inset: 0;
  z-index: 45;
  pointer-events: none;
  opacity: 0;
  transform: translateY(-100vh);
  transition: opacity calc(0.1s * var(--transition-scale, 1)) ease-out;

  /* Enhanced retrace beam effect */
  background: linear-gradient(
    to bottom,
    transparent 0%,
    rgba(0, 255, 200, 0.1) 20%,
    rgba(255, 255, 255, 0.3) 50%,
    rgba(0, 255, 200, 0.1) 80%,
    transparent 100%
  );

  /*
   * THE BAR IS THE BLANKING INTERVAL, so its height is derived, not chosen.
   *
   * This was a flat `8px`. What crosses the screen when a set rolls is the gap between two
   * fields -- the 39 lines of every 525 that carry no picture while the beam flies back --
   * so the band is a fraction of the PICTURE HEIGHT, not a pixel count. 8px is 0.9% at a
   * 900px viewport, thin enough to read as a scan line rather than as a gap, and wrong at
   * every other window size for exactly the reason a 4px scan-line period was.
   *
   * THE FRACTION IS 39/486, NOT 39/525, corrected 2026-09-30. 39/525 = 7.43% is the share of
   * the field PERIOD the beam is off; the screen shows only the 486 VISIBLE lines and never
   * the other 39, so a 39-line band laid across it spans 39/486 = 8.02% of the height. The
   * old number was right about time and wrong about space.
   *
   * --crt-blanking-fraction is published by crt-interlacing-engine.js from
   * CRTPhysics.getBlankingHeightFraction(), which is the only consumer
   * NTSC_CONSTANTS.LINES_TOTAL has ever had. The fallback is the same arithmetic for no-JS.
   */
  height: calc(100vh * var(--crt-blanking-fraction, 0.08025));

  /*
   * GATED ON THE PICTURE, like every other emissive layer here.
   *
   * Retrace is the beam flying back with residual current still on it. It EMITS, and emission
   * is beam current, so over a dark frame there is almost nothing to see. Ungated, this layer
   * painted a near-white band at fixed brightness whatever was underneath -- measured on a cold
   * load as a 49px band sitting +52 luma over a frame whose median was 21, and +116 over a flat
   * black field. That is not a retrace bar, it is a white flash arriving out of nothing, and it
   * is the thing Travis kept pointing at.
   *
   * The gun layers and the phosphor beam were gated this way earlier the same day; these two
   * were missed. Same defect, found twice, fixed once.
   */
  mask-image: var(--crt-emission-mask);
  mask-mode: luminance;
  mask-size: cover;
  mask-position: center;
  mask-repeat: no-repeat;
}

#crtRetrace.active {
  opacity: 1;
  /* Also a beam rate rather than decorative motion -- see .scanline-sweep.run. At the low
   * tier's scale of 3 this ran for 2.4s, which is a bright bar loitering rather than a
   * retrace. Decorative drift still scales; the beam does not. */
  animation: crtRetraceBeam 0.8s cubic-bezier(0.25, 0.46, 0.45, 0.94) forwards;

  /*
   * Scoped to .active, which is where the equivalent already sits on .scanline-sweep.run.
   * A backdrop-filter is the most expensive thing on this page -- the backdrop under a
   * moving element differs every frame, so nothing about it can be cached -- and leaving it
   * on the bare selector attaches it to a permanently present fixed layer for the sake of
   * a bar that crosses for 0.8s at a time. Same fix, same reason, one rule later.
   */
  /*
   * TONED DOWN on 2026-09-23, from brightness(2) saturate(2) contrast(1.5).
   *
   * Same fault as .scanline-sweep.run one rule up, and worse here because the numbers were
   * bigger: a backdrop-filter is clipped to the border box, so DOUBLING the brightness of the
   * backdrop draws a hard-edged rectangle of twice-as-bright picture with a crisp seam top and
   * bottom. Caught on the /projects/ page as a step straight across the frame -- the single
   * most artificial thing in that view.
   *
   * A retrace bar is a bright LINE with a phosphor wake, which this element already paints for
   * itself. What the backdrop-filter adds is the picture behind it lifting slightly, and that
   * is worth having at a strength where its boundary is not a visible edge. Doubling was never
   * that.
   */
  backdrop-filter: brightness(1.22) saturate(1.15) contrast(1.08);
  -webkit-backdrop-filter: brightness(1.22) saturate(1.15) contrast(1.08);
}

@keyframes crtRetraceBeam {
  0% {
    transform: translateY(-100vh);
    opacity: 0;
  }
  5% {
    opacity: 0.8;
  }
  95% {
    opacity: 0.6;
  }
  100% {
    transform: translateY(110vh);
    opacity: 0;
  }
}

/* VHS Tracking Error */
#vhsTracking {
  position: fixed;
  inset: 0;
  z-index: 42;
  pointer-events: none;
  opacity: 0;

  background: repeating-linear-gradient(
    90deg,
    transparent 0px,
    rgba(255, 255, 255, 0.1) 1px,
    transparent 2px,
    rgba(0, 0, 0, 0.2) 3px,
    transparent 4px
  );
}

#vhsTracking.active {
  opacity: 1;
  animation: vhsTrackingGlitch calc(0.6s * var(--animation-scale, 1)) steps(4) forwards;

  /*
   * The backdrop-filter moved to .active on 2026-09-20, joining .scanline-sweep.run and
   * #crtRetrace.active. It sat on the bare selector: a full-viewport fixed layer at
   * `opacity: 0`, with a backdrop-filter attached at all times for an effect that runs for
   * a fraction of a second at a time.
   *
   * OPACITY 0 IS NOT DISPLAY NONE, and that is the whole reason this is worth moving.
   * A `display: none` element generates no box, so #mini-overlay's backdrop-filter costs
   * nothing whatever it says. An `opacity: 0` element is laid out, composited and can still
   * force a backdrop root -- it is invisible, not absent.
   *
   * NO MEASURED SAVING IS CLAIMED. This is a consistency fix against a pattern this repo
   * already established twice; the cost of an unpainted backdrop-filter has not been put on
   * the harness. What IS measured is that a backdrop-filter on a MOVING element costs ~127%
   * of a core, which is why none of these should be attached longer than they are used.
   */
  backdrop-filter: contrast(1.2) brightness(0.9) hue-rotate(-5deg);
  -webkit-backdrop-filter: contrast(1.2) brightness(0.9) hue-rotate(-5deg);
}

@keyframes vhsTrackingGlitch {
  0% {
    transform: translateX(0);
    filter: hue-rotate(0deg);
  }
  25% {
    transform: translateX(-3px);
    filter: hue-rotate(10deg);
  }
  50% {
    transform: translateX(5px);
    filter: hue-rotate(-8deg);
  }
  75% {
    transform: translateX(-2px);
    filter: hue-rotate(5deg);
  }
  100% {
    transform: translateX(0);
    filter: hue-rotate(0deg);
    opacity: 0;
  }
}

/* VHS Dropout Effect */
#vhsDropout {
  position: fixed;
  inset: 0;
  z-index: 43;
  pointer-events: none;
  opacity: 0;

  background:
    radial-gradient(ellipse 30% 8% at 20% 30%, rgba(0, 0, 0, 0.8) 0%, transparent 70%),
    radial-gradient(ellipse 25% 6% at 70% 60%, rgba(0, 0, 0, 0.9) 0%, transparent 60%),
    radial-gradient(ellipse 40% 10% at 45% 80%, rgba(0, 0, 0, 0.7) 0%, transparent 80%);
}

#vhsDropout.active {
  opacity: 1;
  animation: vhsDropoutFlicker calc(0.4s * var(--animation-scale, 1)) steps(3) forwards;

  /*
   * The backdrop-filter moved to .active on 2026-09-20, joining .scanline-sweep.run and
   * #crtRetrace.active. It sat on the bare selector: a full-viewport fixed layer at
   * `opacity: 0`, with a backdrop-filter attached at all times for an effect that runs for
   * a fraction of a second at a time.
   *
   * OPACITY 0 IS NOT DISPLAY NONE, and that is the whole reason this is worth moving.
   * A `display: none` element generates no box, so #mini-overlay's backdrop-filter costs
   * nothing whatever it says. An `opacity: 0` element is laid out, composited and can still
   * force a backdrop root -- it is invisible, not absent.
   *
   * NO MEASURED SAVING IS CLAIMED. This is a consistency fix against a pattern this repo
   * already established twice; the cost of an unpainted backdrop-filter has not been put on
   * the harness. What IS measured is that a backdrop-filter on a MOVING element costs ~127%
   * of a core, which is why none of these should be attached longer than they are used.
   */
  backdrop-filter: blur(2px) contrast(0.5);
  -webkit-backdrop-filter: blur(2px) contrast(0.5);
}

@keyframes vhsDropoutFlicker {
  0% {
    opacity: 0;
  }
  20% {
    opacity: 0.8;
  }
  40% {
    opacity: 0.3;
  }
  60% {
    opacity: 0.9;
  }
  80% {
    opacity: 0.2;
  }
  100% {
    opacity: 0;
  }
}

/* VHS Head Switch Effect */
#vhsHeadSwitch {
  position: fixed;
  inset: 0;
  z-index: 44;
  pointer-events: none;
  opacity: 0;

  background: linear-gradient(
    to right,
    transparent 0%,
    rgba(255, 100, 255, 0.1) 25%,
    rgba(100, 255, 255, 0.1) 50%,
    rgba(255, 255, 100, 0.1) 75%,
    transparent 100%
  );
}

#vhsHeadSwitch.active {
  opacity: 1;
  animation: vhsHeadSwitchGlitch calc(0.3s * var(--animation-scale, 1)) ease-out forwards;

  /*
   * The backdrop-filter moved to .active on 2026-09-20, joining .scanline-sweep.run and
   * #crtRetrace.active. It sat on the bare selector: a full-viewport fixed layer at
   * `opacity: 0`, with a backdrop-filter attached at all times for an effect that runs for
   * a fraction of a second at a time.
   *
   * OPACITY 0 IS NOT DISPLAY NONE, and that is the whole reason this is worth moving.
   * A `display: none` element generates no box, so #mini-overlay's backdrop-filter costs
   * nothing whatever it says. An `opacity: 0` element is laid out, composited and can still
   * force a backdrop root -- it is invisible, not absent.
   *
   * NO MEASURED SAVING IS CLAIMED. This is a consistency fix against a pattern this repo
   * already established twice; the cost of an unpainted backdrop-filter has not been put on
   * the harness. What IS measured is that a backdrop-filter on a MOVING element costs ~127%
   * of a core, which is why none of these should be attached longer than they are used.
   */
  backdrop-filter: hue-rotate(180deg) saturate(2);
  -webkit-backdrop-filter: hue-rotate(180deg) saturate(2);
}

@keyframes vhsHeadSwitchGlitch {
  0% {
    opacity: 0;
    transform: translateX(0) scaleX(1);
    filter: hue-rotate(0deg);
  }
  20% {
    opacity: 0.7;
    transform: translateX(-5px) scaleX(1.02);
    filter: hue-rotate(90deg);
  }
  40% {
    opacity: 0.9;
    transform: translateX(3px) scaleX(0.98);
    filter: hue-rotate(-45deg);
  }
  60% {
    opacity: 0.5;
    transform: translateX(-2px) scaleX(1.01);
    filter: hue-rotate(180deg);
  }
  100% {
    opacity: 0;
    transform: translateX(0) scaleX(1);
    filter: hue-rotate(0deg);
  }
}

/* ===========================================================================
 * Layers that existed in the markup but had NO CSS at all (added 2026-09-12).
 *
 * index.html and both Eleventy templates emit twelve CRT/VHS effect layers. Only
 * four of them had any styling: .crt-scanlines, .crt-vignette, .crt-retrace-sweep
 * and .vhs-sweep, plus four more driven by id selectors (#vhsTracking.active and
 * friends). The rest were empty divs that have never displayed anything:
 *
 *   .vhs-signal-layer  .crt-flicker-layer  .vhs-chroma-noise  .composite-artifacts
 *
 * (.crt-phosphor-dots was the fifth; it now carries the generated shadow mask.)
 *
 * CORRECTION 2026-09-20: "had any styling" counted RULES, not PAINT, and that let one
 * through. .crt-scanlines had only `opacity` and `transition-property` -- no background, no
 * pseudo-element content, no children, no animation -- so it was in exactly the state of the
 * four listed above while looking already handled. Confirmed in Firefox rather than read off
 * the source: a 1280x900 fixed layer at z-index 10 with `background-image: none`, painting
 * nothing, and it never had. Retired; the scanlines it was named for come from
 * .scanlines-interlaced at z-index 39.
 *
 * The question that finds this is "does the element paint", not "does a rule match it".
 *
 * PERFORMANCE CONSTRAINT, learned the hard way. A full-viewport layer with
 * mix-blend-mode forces the browser to recomposite the whole backdrop every paint,
 * and briefly made this site unusable. So everything below animates ONLY transform
 * and opacity, which the compositor handles without repainting, and uses no filter,
 * no blend mode and no backdrop-filter. Each texture rasterises once and is then
 * just moved.
 * =========================================================================== */

/* VHS luminance noise. Fine diagonal grain drifting slowly downward, as tape
 * noise does, rather than the uniform static of a dead channel. */
.vhs-signal-layer {
  background-image: repeating-linear-gradient(
    100deg,
    rgba(255, 255, 255, 0.05) 0px,
    rgba(255, 255, 255, 0.05) 1px,
    transparent 1px,
    transparent 3px
  );
  opacity: 0.05;
  will-change: transform;
  /* One source. The performance-adaptation block overrides animation-duration as a
   * longhand, so a literal here would be decorative and a literal there would be the
   * truth -- which is how the dot-crawl derivation got overridden. See that block. */
  --vhs-signal-dur: 2.4s;
  animation: vhsSignalDrift calc(var(--vhs-signal-dur) * var(--animation-scale, 1)) linear infinite;
}

@keyframes vhsSignalDrift {
  from {
    transform: translate3d(0, 0, 0);
  }
  to {
    transform: translate3d(0, 3px, 0);
  }
}

/* CRT refresh flicker. A real tube's brightness is never perfectly steady; the
 * beam refresh and power supply ripple produce a slight breathing. Deliberately
 * slow and shallow -- fast, deep flicker is both inauthentic and a seizure risk. */
.crt-flicker-layer {
  background: rgba(232, 227, 216, 0.028);
  opacity: 0.5;
  will-change: opacity;
  animation: crtRefreshFlicker calc(4.7s * var(--animation-scale, 1)) ease-in-out infinite;
}

@keyframes crtRefreshFlicker {
  0%,
  100% {
    opacity: 0.34;
  }
  43% {
    opacity: 0.62;
  }
  71% {
    opacity: 0.41;
  }
}

/* Chroma noise. Colour information on VHS is recorded at much lower bandwidth
 * than luminance, so noise shows up as soft horizontal bands of cyan/magenta
 * rather than as fine grain. */
.vhs-chroma-noise {
  background-image: repeating-linear-gradient(
    0deg,
    rgba(0, 255, 200, 0.05) 0px,
    rgba(0, 255, 200, 0.05) 2px,
    transparent 2px,
    transparent 5px,
    rgba(255, 0, 160, 0.04) 5px,
    rgba(255, 0, 160, 0.04) 7px,
    transparent 7px,
    transparent 11px
  );
  opacity: 0.18;
  will-change: transform;
  /* One source -- see the note on --vhs-signal-dur and the perf-adaptation block. */
  --vhs-chroma-dur: 9s;
  animation: vhsChromaDrift calc(var(--vhs-chroma-dur) * var(--animation-scale, 1)) linear infinite;
}

@keyframes vhsChromaDrift {
  from {
    transform: translate3d(0, 0, 0);
  }
  to {
    transform: translate3d(0, 11px, 0);
  }
}

/*
 * Composite dot crawl. Where chroma and luminance share one signal they beat against each
 * other, producing a fine comb that creeps along colour edges.
 *
 * THE STRIPE PERIOD IS NOW THE SUBCARRIER, 2026-09-19. It was a flat 4px, which is a chosen
 * number: the real spacing is a fraction of the picture WIDTH, not a fixed pixel count, so
 * 4px is right at a viewport of about 753px and wrong at every size anyone uses.
 *
 * 3579545.45Hz / 15734.26Hz = 227.5 subcarrier cycles per line. That half cycle is the
 * whole mechanism -- a half cycle per line means the phase inverts on every successive
 * line, which is what makes the pattern diagonal and makes it crawl instead of sitting
 * still. Only the active 52.6us of each 63.56us line is on screen, so 188.28 cycles land
 * across the visible width. --crt-subcarrier-cycles is published from NTSC_CONSTANTS by
 * crt-color-system.js; dividing 100vw by it means this stays correct through any resize
 * with no JavaScript involved.
 *
 *   1280px -> 6.80px per cycle      1920px -> 10.20px
 *   1440px -> 7.65px per cycle      2560px -> 13.60px
 *
 * THE RATE IS EXAGGERATED AND THAT IS DELIBERATE. The NTSC colour sequence repeats over
 * four fields, so real dot crawl returns to phase at 59.94/4 = 14.98Hz -- which reads as a
 * shimmer, not a crawl, and at 60fps would alias badly. 1.9s is about 28x slower than life.
 * Same treatment as P22 persistence: the SPATIAL figure is exact, only the temporal one is
 * stretched, and it is stated here rather than hidden in a magic duration.
 */
/*
 * THE WAVEFORM IS BAND-LIMITED, 2026-09-20, and that is not a softening -- it is the only
 * shape this signal can have.
 *
 * The stops used to be a SQUARE wave: each colour held flat for a quarter period and then
 * stepped to transparent, with the step forced by duplicating the stop offset. A square
 * wave is its fundamental plus odd harmonics. Dot crawl is the 3.579545MHz subcarrier
 * leaking into the LUMA channel -- that crosstalk is the entire reason it is visible -- and
 * luma is 4.2MHz wide. The fundamental passes with 0.6MHz to spare. The third harmonic a
 * square wave needs sits at 10.74MHz, two and a half times outside the channel, so it is
 * not attenuated, it is absent. A composite decoder physically cannot put a hard edge here.
 *
 * So the stops are a cosine, sampled every eighth of a period: 1, 0.707, 0, -0.707, -1 and
 * back. Sign is phase, and phase is hue -- positive lobe cyan, negative lobe orange, which
 * is why the two colours were already opposite. Peak alphas are unchanged from the square
 * version, and the band-limited waveform is slightly MORE energetic over a period
 * (mean |cos| = 0.637 of peak, against a 0.25 duty cycle), so nothing dims.
 *
 * The flat zero zone either side of the crossing is the CHROMA limit, and it is the one
 * tunable here. Luma decides the waveform, but hue is carried by chroma at 1.3MHz, which
 * cannot resolve a single 3.58MHz cycle at all -- so there is a span around each zero
 * crossing where the decoder has no defined hue and the lobes wash together. Its width
 * comes from --crt-chroma-bleed, clamped to a fifth of the period so it can never swallow
 * the lobes and flatten the pattern to nothing.
 *
 * This replaces `filter: blur()`, which used to sit on this layer further down the file
 * under a comment reading "smears colour horizontally while luminance stays sharp". It did
 * neither. blur() is ISOTROPIC: it smeared vertically just as hard, and there is no
 * vertical bandwidth limit on a tube -- vertical resolution is set by the line count, not
 * by MHz. It also spent a full-viewport raster pass softening edges that should never have
 * been built. Baking the limit into the gradient costs nothing per frame.
 */
.composite-artifacts {
  --dot-crawl-period: calc(100vw / var(--crt-subcarrier-cycles, 188.28));
  --dot-crawl-crossover: min(var(--crt-chroma-bleed, 4.65px) * 0.25, var(--dot-crawl-period) * 0.2);

  background-image: repeating-linear-gradient(
    90deg,
    rgba(0, 200, 255, 0.035) 0,
    rgba(0, 200, 255, 0.025) calc(var(--dot-crawl-period) * 0.125),
    rgba(0, 200, 255, 0) calc(var(--dot-crawl-period) * 0.25 - var(--dot-crawl-crossover) * 0.5),
    rgba(255, 140, 0, 0) calc(var(--dot-crawl-period) * 0.25 + var(--dot-crawl-crossover) * 0.5),
    rgba(255, 140, 0, 0.021) calc(var(--dot-crawl-period) * 0.375),
    rgba(255, 140, 0, 0.03) calc(var(--dot-crawl-period) * 0.5),
    rgba(255, 140, 0, 0.021) calc(var(--dot-crawl-period) * 0.625),
    rgba(255, 140, 0, 0) calc(var(--dot-crawl-period) * 0.75 - var(--dot-crawl-crossover) * 0.5),
    rgba(0, 200, 255, 0) calc(var(--dot-crawl-period) * 0.75 + var(--dot-crawl-crossover) * 0.5),
    rgba(0, 200, 255, 0.025) calc(var(--dot-crawl-period) * 0.875),
    rgba(0, 200, 255, 0.035) var(--dot-crawl-period)
  );
  opacity: 0.14;
  will-change: transform;
  animation: compositeDotCrawl calc(var(--crt-dot-crawl-dur, 1.867s) * var(--animation-scale, 1))
    linear infinite;
}

/*
 * The crawl must travel EXACTLY ONE PATTERN PERIOD, or it ticks every cycle.
 *
 * This translated a flat `4px` while the pattern it is moving has period
 * --dot-crawl-period = 100vw / 188.28 -- about 6.8px at 1280px wide, and different at every
 * viewport size. So the pattern jumped roughly 2.8px at the end of every loop, and the
 * jump changed as the window resized. Identical defect to drifting the shadow mask on the
 * CSS gradient period instead of the real tile period, and to the moire drift multiplying
 * by its gain: a repeating pattern has to be animated by its own period and nothing else.
 *
 * The DURATION is derived too, from CRTPhysics.getDotCrawlTrueHz() -- see the note in
 * crt-color-system.js for the 4-field cycle and the stated visibility divisor. 1.867s is
 * the fallback, matching the literal that used to be here.
 */
@keyframes compositeDotCrawl {
  from {
    transform: translate3d(0, 0, 0);
  }
  to {
    transform: translate3d(var(--dot-crawl-period), 0, 0);
  }
}

/*
 * VHS tracking bar. The sixth empty layer.
 *
 * .vhs-sweep appears in all three page sources and in every list that positions these
 * layers and toggles them with the motion state, so it looks thoroughly wired. It had no
 * background, no animation and no content, and nothing in any script referenced it except
 * performance-monitor.js setting will-change on it. A 70-second observation of the running
 * page recorded zero activations, which is all it could ever record -- there was nothing
 * to activate.
 *
 * What it should be is the artifact it is named after: the soft horizontal band of
 * mistracking that drifts slowly up a worn tape. That is distinct from the two sweeps
 * that already exist -- .scanline-sweep fires on a background change, .crt-retrace-sweep
 * is the beam returning. This one is continuous, which is why it belongs in CSS rather
 * than on a scheduler.
 *
 * Transform and opacity only, over a static gradient, so it composites for free.
 */
.vhs-sweep {
  background: linear-gradient(
    to bottom,
    transparent 0%,
    rgba(255, 255, 255, 0.03) 35%,
    rgba(255, 255, 255, 0.08) 50%,
    rgba(210, 235, 255, 0.05) 62%,
    transparent 100%
  );
  background-size: 100% 16vh;
  background-repeat: no-repeat;
  will-change: transform;
  animation: vhsTrackingBar calc(19s * var(--animation-scale, 1)) linear infinite;
}

@keyframes vhsTrackingBar {
  from {
    transform: translate3d(0, 104vh, 0);
  }
  to {
    transform: translate3d(0, -18vh, 0);
  }
}

[data-motion='paused'] .vhs-sweep {
  animation: none;
}

/* Join the existing paused-state suppression. Without this the new layers would
 * keep animating after the user switches effects off. */
[data-motion='paused'] .vhs-signal-layer,
[data-motion='paused'] .crt-flicker-layer,
[data-motion='paused'] .vhs-chroma-noise,
[data-motion='paused'] .composite-artifacts,
[data-motion='paused'] .crt-phosphor-dots {
  animation: none !important;
  opacity: 0.04;
}

/* Respect the OS setting as well as the in-page toggle. */
@media (prefers-reduced-motion: reduce) {
  .vhs-signal-layer,
  .crt-flicker-layer,
  .vhs-chroma-noise,
  .composite-artifacts {
    animation: none !important;
  }
}

/* ===========================================================================
 * Consumers for the variables the NTSC engines set (added 2026-09-12).
 *
 * crt-interlacing-engine.js and crt-color-system.js each write a set of CSS
 * custom properties, following the pattern crt-shadowmask-engine.js established.
 * An audit found that NOT ONE of the nineteen variables they set was read by any
 * stylesheet. The engines computed correct values -- a real 33.37ms field period,
 * a real Planckian white point -- and nothing consumed them, so six of the panel's
 * thirteen controls moved and changed nothing.
 *
 * That is the same defect as the orphaned .scanlines-rgb rule and the .nojekyll
 * deletion: a component that is present, correct, and connected to nothing. These
 * rules are the missing half.
 *
 * Specificity note: the critical CSS is an inline <style> that comes AFTER the link
 * to this file, so a bare `#glitch-title .tg-letter` here would lose the cascade.
 * The convergence rules below lead with `html` to outrank it.
 * =========================================================================== */

/* ---- Interlacing ---------------------------------------------------------- */

/* Field alternation. The stylesheet's own interlaceShift is a fixed 1px hop; this
 * uses the engine's field offset and thermal jitter so the controls do something. */
/*
 * One whole scanline of vertical shift per field, scaled up from the engine's value:
 * Field Offset is expressed in lines and the pattern pitch is 4px, so the x4 makes the
 * default of 0.5 lines land on exactly 2px -- half the pitch, which is what swapping
 * fields means. Jitter is amplified for the same reason everything else here was: the
 * physical value is a fraction of a pixel.
 */
@keyframes interlaceFieldAlternate {
  0%,
  49.9% {
    transform: translate3d(0, 0, 0);
  }
  50%,
  100% {
    transform: translate3d(
      calc(var(--crt-interlace-jitter, 0.02px) * 30),
      calc(var(--crt-interlace-offset, 0.5px) * 4),
      0
    );
  }
}

/*
 * The interlace layer was invisible, and the arithmetic says it could not have been
 * anything else: the inherited pattern is a 1px line at rgba(0,0,0,0.08) on a 7px pitch,
 * shown at 0.35 opacity, which is under three percent darkening on one pixel in seven.
 * The field alternation then moved it by half a pixel. Nothing about that is perceptible,
 * so every control on the tab read as dead.
 *
 * This is an actual field pair. The pitch is 4px -- a 2px line and a 2px gap -- and the
 * alternation shifts by exactly one line, so odd and even fields genuinely swap the way
 * 480i does. Line contrast comes from the engine's intensity, so that control now sets
 * how pronounced the scan structure is rather than nudging a value nothing could see.
 *
 * mix-blend-mode is deliberately turned OFF here. The inherited rule sets `multiply`, and
 * this is a full-viewport layer that repaints thirty times a second -- a blended
 * full-viewport element forces the browser to recomposite the entire backdrop on every
 * paint, which is precisely what made this site unusable once before. Straight alpha on a
 * black line pattern is visually equivalent and costs nothing.
 */
.scanlines-interlaced {
  mix-blend-mode: normal;
  /*
   * A SCAN LINE IS NOT A STRIPE. It is the cross-section of an electron beam.
   *
   * This was a 50% duty square wave -- flat black for half the period, then an instantaneous
   * step to transparent, twice per 3.7px. That is the single most recognisable tell of a fake
   * CRT, and it is what made the page read as graph paper laid over a photograph rather than
   * as a tube. Nothing in a CRT has an edge like that: the beam spot has a roughly Gaussian
   * intensity profile, so each line is a soft hump whose skirts overlap its neighbours, and
   * the gap between lines is dimmer rather than absent.
   *
   * The profile below is a raised cosine, alpha = A(1 + cos 2-pi-t)/2, sampled every eighth
   * of a period: darkest exactly between two beams, zero exactly on one. Smooth everywhere,
   * no discontinuity to alias against the shadow mask, and it is the beam shape rather than a
   * blur applied to hide a shape that was wrong.
   *
   * SAMPLED, NOT BLURRED, AND THE REPO HAS DONE THIS BEFORE. `.composite-artifacts` had the
   * identical defect -- square stops with a `filter: blur()` over the top to soften the edges
   * the square wave should never have produced -- and was rebuilt as a cosine sampled every
   * eighth of a period on 2026-09-20. The conclusion recorded there applies here unchanged:
   * building a waveform the physics cannot produce is what creates the hard edges, and the
   * blur is only there to hide them. This costs one gradient and no raster pass.
   *
   * The period is still the ONE variable, because the shadow mask's moire is the beat against
   * exactly this number and a second copy would describe a different tube.
   */
  /*
   * 0.62, up from 0.42. Combined with --crt-interlace-opacity below this puts the effective
   * scan modulation at about 30% of the picture at the default intensity, against the 10% it
   * shipped at -- measured on the title glyphs as depth/mean = 0.052, i.e. a 5% ripple, which
   * is why Travis could not see scan lines at all. A real tube showing 240-odd visible lines on
   * a display with three or four times the pitch has a genuinely dark gap between lines; 10%
   * is a suggestion of one.
   *
   * It stays on the slider, so the ceiling at intensity 1.0 is about 50% rather than fixed.
   */
  /*
   * A CONSTANT, not intensity-scaled. Intensity was being applied TWICE -- once here and once
   * in the layer opacity that crt-interlacing-engine.js publishes as `intensity * 0.5` -- so
   * the effective modulation was intensity squared times a constant: 6% at the shipped
   * default, which is why Travis could not see scan lines. Same squaring defect this file
   * already records for .scanlines-interlaced::after, in a second place.
   *
   * 0.62 is the beam profile's peak absorption between lines. The CONTROL lives on the layer
   * opacity, so the slider moves the depth linearly and once.
   */
  --scan-a: 0.62;
  --scan-p: var(--crt-scanline-period, 4px);
  background-image: repeating-linear-gradient(
    to bottom,
    rgba(0, 0, 0, var(--scan-a)) 0px,
    rgba(0, 0, 0, calc(var(--scan-a) * 0.854)) calc(var(--scan-p) * 0.125),
    rgba(0, 0, 0, calc(var(--scan-a) * 0.5)) calc(var(--scan-p) * 0.25),
    rgba(0, 0, 0, calc(var(--scan-a) * 0.146)) calc(var(--scan-p) * 0.375),
    rgba(0, 0, 0, 0) calc(var(--scan-p) * 0.5),
    rgba(0, 0, 0, calc(var(--scan-a) * 0.146)) calc(var(--scan-p) * 0.625),
    rgba(0, 0, 0, calc(var(--scan-a) * 0.5)) calc(var(--scan-p) * 0.75),
    rgba(0, 0, 0, calc(var(--scan-a) * 0.854)) calc(var(--scan-p) * 0.875),
    rgba(0, 0, 0, var(--scan-a)) var(--scan-p)
  );
  /* The control applies HERE and only here. --scan-a above is the beam profile constant;
   * multiplying by intensity in both places squared it and produced a 6% ripple. */
  opacity: calc(var(--crt-interlace-intensity, 0.45) * var(--crt-interlace-enabled, 1));
  will-change: transform;
  animation-name: interlaceFieldAlternate;
  /*
   * DELIBERATELY NOT MULTIPLIED BY --animation-scale. This is the NTSC field rate, not a
   * decorative duration.
   *
   * At 33.37ms with steps(2) the layer alternates 59.934 times a second against NTSC's
   * 59.940 -- 0.01% off, and far too fast to see, which is the entire point: interlace is
   * invisible on a healthy set. The performance tiers scale this variable by 1.5 and 3, and
   * 3 puts a full-viewport layer at 100.11ms, which is ten cycles a second. That is not a
   * slower version of the effect, it is a strobe, and 10Hz sits inside the 3-30Hz band
   * crt-director.js's own rate limits exist to keep photosensitive users out of.
   *
   * It was invisible until 2026-09-23 because crt-interlacing-engine.js wrote
   * animationDuration inline and an inline style outranks a stylesheet, so the scale factor
   * here was silently discarded on every device. Removing that duplicate -- correct in
   * itself, it was a second copy of the period that dropped the tier factor -- is what would
   * have armed this. Fixing one thing is what brings the next one to life.
   *
   * Scaling a rate that is correct is not a degradation, it is a defect. Durations that
   * describe decorative motion still scale; see the twenty other consumers.
   */
  animation-duration: var(--crt-interlace-period, 33.37ms);
  animation-timing-function: steps(2, end);
  animation-iteration-count: infinite;
}

/*
 * Field blending is phosphor persistence: the previous field has not fully decayed
 * when the next is drawn, so the two overlap. A soft afterimage models that.
 *
 * --crt-interlace-intensity IS NOT APPLIED HERE, and that is the fix, not an omission.
 * A second rule further down used to restate this opacity as
 * `blend * intensity * 0.4`, which looked like it was adding the missing intensity
 * scaling. It was adding a SECOND one: `background: inherit` takes the parent's computed
 * gradient, whose black alpha is already `0.5 * intensity` (see the rule above), so the
 * afterimage carried intensity once through the pixels before anything multiplied its
 * opacity. Measured in Firefox rather than argued: halving intensity from 0.7 to 0.35
 * took the effective afterimage from 0.0833 to 0.0208 -- a QUARTER, not a half.
 *
 * It also silently retuned the thing. The later rule's `intensity * 0.4` is 0.28 at the
 * default against the 0.35 written here, so the standing value had been down 20% since
 * the day that rule was added, while this line went on reading as the live one. Same
 * shape as the .crt-vignette 0.85 that a grouped `calc(0.35 * ...)` flattened, and the
 * reason the CSS audit now looks for a selector declared twice at the same specificity.
 */
.scanlines-interlaced::after {
  content: '';
  position: absolute;
  inset: 0;
  background: inherit;
  opacity: calc(var(--crt-interlace-blend, 0.85) * 0.35);
  transform: translate3d(0, calc(var(--crt-interlace-offset, 0.5px) * -1), 0);
}

/* ---- Colour system -------------------------------------------------------- */

.scanlines-rgb {
  opacity: calc(0.18 * var(--crt-color-enabled, 1));
}

/* Chroma noise visibility tracks chroma bandwidth. */
.vhs-chroma-noise {
  opacity: var(--crt-chroma-noise-opacity, 0.18);
}

/* Colour temperature. The flicker layer doubles as the beam-colour layer, so the
 * white point tints the whole screen the way a miscalibrated tube does. */
.crt-flicker-layer {
  background: var(--crt-white-tint, rgba(232, 227, 216, 0.028));
}

/* Convergence error. Three guns that do not quite land together put a red fringe on
 * one side of a bright edge and a blue fringe on the other. Text shows it first,
 * which is exactly where it is most recognisable on a real tube.
 *
 * Luma bleeding widens the whole glow, because a wide luminance channel smears
 * brightness; chroma bleeding is handled on the colour layers above. */
/* The --crt-convergence-distortion term is crt-effects.js's thermal model widening the
 * fringes as the tube warms: the yoke and the mask expand at different rates, so a set
 * that converges perfectly cold drifts apart after twenty minutes. It stays at 0 until
 * CRTPhysics reports the tube warm. */
html #glitch-title .tg-letter {
  text-shadow:
    calc(var(--crt-conv-r, 0.2px) + var(--crt-convergence-distortion, 0) * 3px) 0 0
      rgba(255, 40, 40, 0.55),
    var(--crt-conv-g, 0px) 0 0 rgba(40, 255, 120, 0.4),
    calc(var(--crt-conv-b, -0.3px) - var(--crt-convergence-distortion, 0) * 3px) 0 0
      rgba(70, 130, 255, 0.55),
    /* The three convergence stops above are per-gun and stay. What followed them was a
     * hand-rolled halo at a fixed 2/6/12px -- correct at one viewport, and a SECOND model of
     * the same physics the picture layer already had. Same halo, one owner, tracks the
     * viewport. --crt-luma-bleed is folded into the shared definition, so the control that
     * widened this glow still widens it. */
      var(--crt-text-halation);
}

html #nav-list a {
  /* All three guns, not just red and blue. The green offset was omitted here because
   * green sits in the middle of the triad and shows least, which made Green Convergence
   * the one colour control with no visible consumer anywhere -- the title carries all
   * three, but the glitch engine writes the title's text-shadow inline and wins. */
  text-shadow:
    var(--crt-conv-r, 0.2px) 0 0 rgba(255, 40, 40, 0.35),
    var(--crt-conv-g, 0px) 0 0 rgba(40, 255, 120, 0.28),
    var(--crt-conv-b, -0.3px) 0 0 rgba(70, 130, 255, 0.35),
    var(--crt-text-halation);
}

/*
 * THE SPLIT TEXT. crt-text-warp.js wraps every word in .crt-w and every character in .crt-ch
 * so the beam can deflect them individually.
 *
 * BOTH MUST BE inline-block, AND THAT IS NOT A STYLE CHOICE. A CSS transform does nothing at
 * all to a non-replaced inline box -- it is written, reported by getComputedStyle, and
 * discarded. The first version of that module drove `#nav-list a`, which is display:inline,
 * and moved nothing while looking entirely healthy.
 *
 * white-space: nowrap ON THE WORD IS WHAT KEEPS WORDS WHOLE. Adjacent atomic inline boxes are
 * a line-break opportunity, so without it a line could break between any two letters --
 * "portfolio" wrapping as "portfol / io". The wrapper makes the word atomic again, which is
 * also what makes a word the right unit for the per-line transport: it cannot straddle two
 * lines, so every letter in it shares one Y.
 *
 * vertical-align: baseline is restated because inline-block defaults to baseline but an
 * inherited value elsewhere would change the whole page's leading, and this is the one
 * declaration that would do it silently across every paragraph at once.
 */
.crt-w {
  display: inline-block;
  white-space: nowrap;
  vertical-align: baseline;
}
.crt-ch {
  display: inline-block;
  vertical-align: baseline;
}

/*
 * THE BODY COPY IS DRAWN BY THE SAME THREE GUNS. Travis, 2026-09-28: "The text being affected
 * doesn't go deep enough."
 *
 * It was not deep enough here in a specific way: the title and the nav carried per-gun
 * convergence and the paragraphs did not, so the two most prominent elements fringed and
 * everything under them was perfectly registered -- on a page whose whole claim is that one
 * yoke draws all of it. A tube cannot converge one heading and misconverge another.
 *
 * FAINTER THAN THE NAV, AND THAT IS PHYSICS RATHER THAN RESTRAINT. Convergence error is a
 * fixed DISTANCE on the faceplate, so at body size it spans a larger fraction of a stroke and
 * reads harder for the same offset. The nav sits at 0.35/0.28/0.35 alpha over 0.75rem; this is
 * 0.22/0.18/0.22 over the reading size, which lands the fringe at a comparable proportion of
 * the glyph rather than a comparable opacity.
 *
 * The two black stops stay FIRST and unchanged. They are the legibility scrim measured at
 * 14.07:1 against the live background, and the fringes are tinted light, so putting them after
 * would have the halo fighting the contrast floor the scrim exists to hold.
 */
html main p,
html main li,
html main h2,
html main h3 {
  text-shadow:
    0 0 2px rgba(0, 0, 0, 0.95),
    0 0 10px rgba(0, 0, 0, 0.8),
    var(--crt-conv-r, 0.2px) 0 0 rgba(255, 40, 40, 0.22),
    var(--crt-conv-g, 0px) 0 0 rgba(40, 255, 120, 0.18),
    var(--crt-conv-b, -0.3px) 0 0 rgba(70, 130, 255, 0.22),
    var(--crt-text-halation);
}

/* Chroma bleeding used to be `filter: blur(--crt-chroma-bleed * 0.15)` here, under a
 * comment claiming it smeared colour horizontally while luminance stayed sharp. blur() is
 * isotropic, so it did exactly as much vertically -- and a tube has no vertical bandwidth
 * limit, only a line count. --crt-chroma-bleed now sets the width of the hue crossover in
 * the dot-crawl gradient itself, which is horizontal by construction and costs no raster
 * pass. See the waveform note above .composite-artifacts. */

/* The engines gate their own layers, but honour the global pause too. */
[data-motion='paused'] .scanlines-interlaced,
[data-motion='paused'] .scanlines-rgb {
  animation: none !important;
  opacity: 0.05;
}

/* The remaining engine variables. These are derived components that the engines also
 * fold into composite values, so it would be easy to leave them set and unread -- which
 * is precisely the smell these rules exist to eliminate. Every variable an engine
 * writes should move something. */

/* Beam colour at the screen edges. The white point is most obvious in the vignette,
 * where the tube's own colour shows against the darkened corners. */
.crt-vignette {
  box-shadow: inset 0 0 18vmin
    rgba(
      var(--crt-white-r, 255),
      var(--crt-white-g, 254),
      var(--crt-white-b, 250),
      calc(var(--crt-white-strength, 0) * 0.6)
    );
}

/* The `.scanlines-interlaced::after` override that sat here is gone. Its stated intent --
 * "interlace intensity scales the persistence afterimage as well as the layer opacity" --
 * was already satisfied by the inherited gradient, so restating it squared the intensity
 * and quietly dropped the afterimage 20% at the default. See the rule itself, above. */

/* ===========================================================================
 * Consumers for the REST of the engine variables (added 2026-09-12).
 *
 * Writing the block above and then adding tests/css-var-contract.test.js to guard it
 * immediately found twenty-three more of exactly the same defect, older and spread
 * wider: the geometry engine, the shadow-mask engine, CRTSystem's performance
 * adaptation and crt-effects' thermal model all wrote custom properties that no rule
 * anywhere read. The nineteen NTSC ones were not an isolated mistake, they were the
 * part of the pattern that happened to be new.
 *
 * These are the missing consumers. Every rule here is transform, opacity,
 * background-image or background-size -- the compositor-cheap set. Nothing new gets a
 * filter or a blend mode on a full-viewport layer; that combination turned the whole
 * page black once and is not worth revisiting.
 * =========================================================================== */

/* ---- Geometry ------------------------------------------------------------- */

/*
 * crt-geometry-engine.js models raster geometry properly: barrel and pincushion
 * coefficients, keystone, thermal drift and power-supply ripple, pre-computed into a
 * distortion lookup table. Nothing samples that table and nothing read the variables
 * either, so the entire Screen Geometry tab moved values in memory.
 *
 * CSS cannot do true barrel distortion without a shader, and a shader is not worth a
 * whole-page repaint here. What it can do is the part you actually notice on a tube:
 * the raster is slightly larger than the visible screen and is not perfectly
 * rectangular, so the image overscans and its edges are not quite square.
 *
 * Applied to the background layer only. The obvious alternative -- transforming the
 * page content -- would make .bg-container and every other `position: fixed` layer
 * resolve against the transformed ancestor instead of the viewport, which relocates
 * every effect layer on the page. Geometry error is a property of the raster, and the
 * raster is the picture, so the background is the correct target anyway.
 */
.bg-container {
  /*
   * Geometry applies instantly, never through a transition.
   *
   * The original cause is fixed at source: the critical CSS used to set
   * transition-duration on `.perf-low *` / `.perf-medium *`, which created a transition
   * on every property of every element rather than scaling existing ones, and those
   * transitions were observed wedged at currentTime 0 -- so changing a geometry variable
   * animated instead of applying, forever. That is now an inherited --transition-scale
   * that real transitions opt into.
   *
   * This stays as a guard. A physics control should track its slider immediately, and
   * anything that reintroduces a blanket transition should not silently deaden the panel
   * a second time.
   */
  transition-property: none;

  /*
   * Barrel, pincushion and keystone are NOT here any more, and must not come back here.
   *
   * They were a scaleX/scaleY plus a skew, which is a zoom and a shear. Barrel distortion
   * is radial: the middle of the picture bulges out and the corners pull in, and no
   * combination of scale and skew can express that, because every transform is affine and
   * barrel distortion is not. The two do not look alike.
   *
   * The real thing is an SVG displacement map on .bg-image below, generated from
   * crt-geometry-engine.js's own calculateDistortion(). What remains on this element is
   * the thermal drift, which genuinely is a translation of the whole raster.
   */
  animation: crtThermalDrift calc(41s * var(--animation-scale, 1)) ease-in-out infinite;

  /* Also the floor under the crossfade. bg-loader publishes the active image here
   * once a transition settles; if a .bg-image element is culled or its load fails,
   * the screen still shows the picture instead of going black. Already-decoded, so
   * it costs no extra request. */
  background-image: var(--bg-current, none);
  background-position: center;
  background-size: cover;
  background-repeat: no-repeat;
}

/*
 * Thermal drift and power ripple are the two ways a tube's geometry moves while you
 * watch it: the yoke warms over minutes and the picture creeps, and ripple on the
 * high-voltage supply breathes the raster size at mains frequency. Both are
 * amplitudes, so they drive one animation on the image layer rather than the
 * container -- .bg-container already carries the static geometry transform, and an
 * animation on the same element would replace it.
 */
/*
 * Thermal drift and power supply ripple were one 47-second animation sharing a single
 * transform, with amplitudes of about three pixels and half a percent. Both controls
 * were reported as doing nothing, and at that size over that period they were not
 * distinguishable from nothing.
 *
 * They are two different faults on two different timescales and they now get two
 * animations. Drift is the yoke warming over minutes and the picture wandering; ripple is
 * the high-voltage supply failing to hold the raster size steady, which is fast. Sharing
 * one keyframe set forced them to share a duration, which is why neither looked right.
 */
@keyframes crtThermalDrift {
  0% {
    transform: translate3d(0, 0, 0);
  }
  31% {
    transform: translate3d(
      calc(var(--crt-thermal-drift, 0.003) * 1600px),
      calc(var(--crt-thermal-drift, 0.003) * -1100px),
      0
    );
  }
  64% {
    transform: translate3d(
      calc(var(--crt-thermal-drift, 0.003) * -1300px),
      calc(var(--crt-thermal-drift, 0.003) * 1500px),
      0
    );
  }
  100% {
    transform: translate3d(0, 0, 0);
  }
}

/*
 * Ripple sits on .bg-image rather than the container because that is the element the
 * picture filter is on, and a transform is applied AFTER filtering on the same element.
 * Animating it therefore does not re-run the displacement map and three channel blurs
 * every frame, which a transform on the parent would.
 */
/*
 * Ripple is a vertical instability, NOT a scale. This matters for one specific reason and
 * it must not be changed back.
 *
 * A transform on a filtered element is applied after the filter, so it is normally free.
 * That is true of translate, which just moves an existing raster. It is NOT true of scale:
 * scaling changes the resolution the filter output has to be rendered at, so every step of
 * a scale animation re-runs the entire filter chain. At roughly three steps a second,
 * across every live background layer, that pegged a renderer at 100% CPU and hung the tab.
 *
 * A supply that cannot hold the raster steady shows as vertical instability anyway, so
 * this is no less faithful -- it is the cheap half of the same fault.
 */
@keyframes crtPowerRipple {
  0%,
  100% {
    transform: translate3d(0, 0, 0);
  }
  50% {
    transform: translate3d(0, calc(var(--crt-power-ripple, 0.001) * 900px), 0);
  }
}

.bg-image {
  /*
   * The real CRT geometry, and the per-gun convergence, chroma bleed and white point.
   * Defined in assets/js/crt-svg-filters.js and driven by the engines.
   *
   * On .bg-image and not .bg-container because opacity is applied after the filter on the
   * same element: the two-second crossfade between backgrounds therefore does not
   * re-filter on every frame.
   */
  /*
   * The picture filter is attached by class, not unconditionally.
   *
   * crt-svg-filters.js adds .crt-picture-filter to <html> only when the geometry or colour
   * engines are actually asking for something, and never on the low performance tier. An
   * identity filter chain is not free -- it still forces a full-viewport raster pass -- so
   * "all the sliders are neutral" has to mean no filter at all, not a filter that happens
   * to compute nothing.
   */
  animation: crtPowerRipple calc(0.28s * var(--animation-scale, 1)) ease-in-out infinite;
}

html.crt-picture-filter .bg-image {
  filter: url(#crt-picture);
}

[data-motion='paused'] .bg-image,
[data-motion='paused'] .bg-container {
  animation: none;
}

/* Effects off means the expensive path is detached too, not merely paused. */
[data-motion='paused'] .bg-image {
  filter: none;
}

/* ---- Shadow mask ---------------------------------------------------------- */

/*
 * crt-canvas-effects.js paints the real mask here as a generated repeating tile and
 * sets background-image, background-size and opacity inline, so it wins over these
 * rules whenever it succeeds. This is the fallback for when it does not: the manager
 * missing, the canvas unavailable, toDataURL refused. Before, that path left the
 * layer completely blank.
 *
 * The stripe period is built from the engine's own dot pitch. CSS millimetres are
 * fixed at 96dpi, which is the same conversion crt-shadowmask-engine.js uses, so
 * `0.2646mm` here is the same 1px triad cell the tile generator produces.
 */
/*
 * Phosphor afterglow from the modelled beam. Drawn by assets/js/crt-phosphor-beam.js.
 *
 * Added 2026-09-19 to give the physics engine's phosphor particle system somewhere to
 * render. The pool, the P22 decay constants and the 60fps decay loop had all been correct
 * since they were written, with no emitter and no renderer -- so the pool was allocated and
 * the loop ran over an array that was always empty.
 *
 * `screen` because phosphor emission ADDS light to what is already there; it does not
 * replace or darken it. Note there is no filter and no backdrop-filter in this rule: the
 * canvas is a plain bitmap the compositor uploads once per frame, which is the cheap half of
 * the rule this project keeps relearning. It sits just below .crt-scanlines so the afterglow
 * is cut by the same scan structure that produced it.
 */
.crt-phosphor-beam {
  position: fixed;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 40;
  pointer-events: none;
  mix-blend-mode: screen;

  /* Tracks how much of the phosphor pool is lit, published by crt-effects.js from
     getPhosphorStats().poolUtilization. A screen painting a bright frame glows harder than
     one painting a dark frame. Kept to a modest band so the layer never disappears and
     never dominates: 0.6 to 1.0 of the base 0.28. */
  opacity: calc(0.28 * (0.6 + 0.4 * var(--crt-phosphor-load, 0.5)));

  /*
   * Gated on the picture, for the same reason as the gun layers: this is emitted light.
   *
   * The comment above is about how HARD the beam glows on average, which is a global scalar
   * and was the only modulation this layer had. What it lacked was any notion of WHERE, so
   * the trail was painted at full strength across black areas where the beam is blanked --
   * 7.05 luma of lift over a flat black field, and the dominant source of visible texture in
   * those areas (hiding it dropped the structure of a black field from sd 18 to sd 5).
   *
   * A scanning beam is only visible because it is exciting phosphor, and beam current over
   * black is zero. Masking by picture luminance makes the trail appear where the picture is
   * lit and vanish where it is not, which is both the correct physics and the thing that
   * stops the effect reading as a sprite moving over a photograph.
   */
  mask-image: var(--crt-emission-mask);
  mask-mode: luminance;
  mask-size: cover;
  mask-position: center;
  mask-repeat: no-repeat;
}

[data-motion='paused'] .crt-phosphor-beam {
  display: none;
}

.crt-phosphor-dots {
  /*
   * ABOVE THE UI CHROME, which it was not.
   *
   * This div sits after <header> and <main> in the DOM but carried no z-index, so it painted
   * in the auto/0 band -- while `header` is z-index 20 and `nav` is 22. Positive z-index
   * paints after the auto band, so the two most prominent pieces of text on the page, the
   * title and the nav, were the only things on screen the shadow mask did not cover. The
   * scan lines (z 39) covered them the whole time, which is why the inconsistency was
   * invisible: the letters plainly had scan structure, so the mask looked like it was there
   * too.
   *
   * On a tube everything you can see is phosphor seen through the mask -- text is drawn by
   * the same beam as the picture. 37 puts it just under the vignette (38) and the rest of the
   * CRT stack (39-41) while clearing the chrome, so the ordering among the effect layers is
   * unchanged and only the escape is closed.
   */
  z-index: 37;

  /*
   * A SHADOW MASK OCCLUDES. It is a sheet of steel with holes in it, and it is visible only
   * because light is coming THROUGH it -- which is why an unlit area of a real tube shows no
   * mask structure whatsoever. The correct operator is therefore multiply, and this layer
   * carried no mix-blend-mode at all until 2026-09-24, so it composited `normal`: a straight
   * alpha lerp toward the tile's own colour.
   *
   * A lerp toward a coloured texture cannot occlude. Over a black picture it ADDS the mask's
   * phosphor colour instead of removing light, so the mask was painting itself onto the blacks
   * -- measured at 5.09 luma of lift over a flat black field, with the tile's structure plainly
   * visible in an area that should have been empty. That is the inverse of the thing being
   * modelled: a real mask is invisible in black and pronounced in white, and this was the other
   * way round.
   *
   * With multiply, opacity becomes exactly the right knob: compositing at alpha a gives an
   * effective transmission of 1 - a * (1 - tile), so --crt-mask-intensity now sets how deeply
   * the mask bites and 0 means a clear faceplate. Over black, tile times zero is zero at every
   * intensity, so the black floor is structurally unreachable rather than merely tuned small.
   */
  mix-blend-mode: multiply;

  background-image: repeating-linear-gradient(
    90deg,
    rgba(255, 40, 40, 0.5) 0,
    rgba(255, 40, 40, 0.5) 33.3%,
    rgba(40, 255, 90, 0.5) 33.3%,
    rgba(40, 255, 90, 0.5) 66.6%,
    rgba(60, 120, 255, 0.5) 66.6%,
    rgba(60, 120, 255, 0.5) 100%
  );
  /* A triad is three phosphors wide and, on a delta mask, two rows tall. Thermal
   * expansion grows the mask itself: a hot shadow mask is measurably larger, which
   * is the mechanism behind doming and mis-landing on a real tube. */
  --mask-period-x: calc(
    var(--crt-dot-pitch, 0.2646mm) * 3 * (1 + var(--crt-thermal-expansion, 0.002) * 20)
  );
  --mask-period-y: calc(
    var(--crt-dot-pitch, 0.2646mm) * 2 * (1 + var(--crt-thermal-expansion, 0.002) * 20)
  );

  background-size: var(--mask-period-x) var(--mask-period-y);

  /*
   * Moire drift.
   *
   * The shadow-mask engine has advanced a moirePhase since it was written, and nothing
   * drove it -- updateShadowMask() had no caller, exactly like the geometry oscillators.
   * Its only reader was applyMoirePattern(), inside the per-pixel getImageData path, so
   * even once driven it had nowhere affordable to go. This is the cheap route: shifting a
   * repeating background slides the pattern with no layer resize and no edge to expose.
   *
   * Moire is the beat between the mask pitch and the scan structure, and it creeps as the
   * mask expands -- so it drifts rather than animates. The phase is published at ~10Hz
   * rather than per frame for that reason; a creep does not need 60.
   *
   * Drifting by exactly ONE period means the 1 -> 0 wrap is invisible, which is why this
   * reuses a period rather than a chosen pixel count.
   *
   * --crt-mask-tile-x FIRST, because crt-canvas-effects.js sets background-size inline from
   * the generated canvas tile and that overrides this rule -- so the layer is really tiled
   * at 93.6px while --mask-period-x below describes the CSS gradient at 3.12px. Using the
   * wrong one wraps mid-tile and ticks visibly about once a second. The gradient period is
   * the fallback for when no tile has been generated.
   */
  background-position: calc(
      var(--crt-moire-phase, 0) * var(--crt-mask-tile-x, var(--mask-period-x))
    )
    0;
  /*
   * MATCHES crt-canvas-effects.js LAYER_OPACITY, which overrides this inline.
   *
   * This said `enabled * intensity * brightness * 0.25` while the inline value applied to
   * the same element says `enabled * intensity * 0.3`. The inline one wins, so this formula
   * has never rendered -- measured 0.21 against the 0.175 this would have given.
   *
   * The inline one is also the CORRECT one, which is why it is copied here rather than the
   * other way round. Intensity is how strongly the mask overlays the picture and the layer's
   * opacity is how that is applied; Phosphor Brightness is baked into the TEXTURE by
   * applyPhosphorResponse(). Multiplying opacity by brightness as well would count it twice
   * and collapse the two controls back into one, which is the exact thing they were
   * separated to stop.
   *
   * Kept rather than deleted because this is the value before the tile is generated, and
   * because a rule that silently disagrees with the inline it loses to is how this was
   * invisible. If the inline assignment is ever removed, this must remain the live formula.
   */
  opacity: calc(var(--crt-mask-enabled, 1) * var(--crt-mask-intensity, 0.7) * 0.3);
}

/*
 * MOIRE -- the beat between the shadow mask and the scan lines.
 *
 * WHY THIS IS A LAYER AND NOT PART OF THE MASK. It used to be three lines inside
 * applyPhosphorResponse(): a black stripe every 3 pixels, painted into the mask tile. That
 * is not moire by any reading. Moire is what you see where two periodic structures overlap,
 * and its period is DERIVED from both -- here the mask's 6px delta-row period and the
 * scanline gradient's 4px period, giving 6*4/|6-4| = 12px. A number picked by hand describes
 * neither structure, and painting it into the tile makes it a property of the mask, which it
 * is not: it would then scale with thermal expansion and drift with the mask, when a beat
 * does the opposite of both.
 *
 * THE AMPLIFICATION IS ALREADY IN THE PERIOD -- do not multiply by it again. Shift one
 * grating by d and the fringes move d * B/m, here 2x, which is why moire crawls on a tube
 * whose mask is creeping by microns. But --crt-moire-phase is one MASK period per cycle, and
 * one mask period is by definition one BEAT period of fringe travel: the two structures are
 * back in the same relationship, so the pattern is identical again. So drifting by exactly
 * one --crt-moire-period per cycle IS the amplified rate.
 *
 * An earlier version here multiplied by the gain as well. That ran at twice the physical
 * rate, and worse, it only wrapped seamlessly because the gain happens to be exactly 2 at
 * the default pitch -- move the Dot Pitch slider and the gain is fractional, so the wrap
 * would tick once a cycle. Same defect as drifting the mask on the gradient period instead
 * of the tile period, which is written up directly above.
 *
 * multiply, because a beat is the PRODUCT of two transmission patterns. An additive veil
 * would lighten the picture where the two structures cancel, which is backwards.
 */
.crt-phosphor-dots::before {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  mix-blend-mode: multiply;
  background-image: repeating-linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0.55) 0,
    rgba(0, 0, 0, 0) calc(var(--crt-moire-period, 12px) / 2),
    rgba(0, 0, 0, 0.55) var(--crt-moire-period, 12px)
  );
  background-position: 0 calc(var(--crt-moire-phase, 0) * var(--crt-moire-period, 12px));
  opacity: calc(var(--crt-mask-moire, 0.1) * var(--crt-mask-enabled, 1));
}

/*
 * Mask wear. Phosphor does not age evenly -- the centre of the screen has taken the
 * most beam-hours, so an old tube is dimmer and blotchier in the middle. The wear
 * control is the age of the tube, and at its default of 0.05 this is invisible, which
 * is correct for a tube in good condition.
 */
.crt-phosphor-dots::after {
  content: '';
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(ellipse 70% 60% at 50% 45%, rgba(0, 0, 0, 0.55), transparent 70%);
  opacity: calc(var(--crt-mask-wear, 0.05) * var(--crt-mask-enabled, 1) * 0.8);
}

/* ---- Performance adaptation ----------------------------------------------- */

/*
 * CRTSystem.adaptEffectIntensity() measures the frame rate and scales these three
 * down when the page cannot keep up. It has been doing that correctly, and publishing
 * the result to nobody -- so the adaptation ran and the effects never got lighter,
 * which is the failure mode the whole mechanism exists to prevent.
 *
 * Opacity is the right lever: fading a compositor layer is free, and a layer at low
 * opacity still costs the same to paint. The real saving is the animation speed
 * below, which reduces how often anything is recomposited at all.
 *
 * The /0.8 and /1 divisors normalise each factor to 1.0 at full performance, so these
 * rules reproduce the hand-tuned opacities exactly until the frame rate actually drops.
 * Without that, wiring the adaptation up would have silently restyled the page.
 */
/*
 * EVERY DURATION HERE REFERENCES ITS BASE RULE'S VARIABLE. Do not restate a number.
 *
 * These three blocks each set animation-duration as a LONGHAND, and each of their base
 * rules sets it through the `animation` SHORTHAND hundreds of lines earlier. Later longhand
 * beats earlier shorthand, so whatever is written here is the duration -- the base rule's is
 * decorative.
 *
 * That went wrong on 2026-09-20 in the same session that introduced it. The dot-crawl rate
 * was derived from CRTPhysics.getDotCrawlTrueHz() and published as --crt-dot-crawl-dur =
 * 1.8685s, the base rule was changed to use it, and THIS block still said 1.9s -- so the
 * derivation was overridden by the literal it replaced.
 *
 * It survived a browser check because the two numbers collide: 1.9 x 1.5 and 1.8685 x 1.5253
 * are both 2.85s. Reading a plausible computed value and stopping is exactly the failure this
 * repo keeps having, and the collision is why a contract test now asserts the reference
 * rather than the result.
 *
 * --vhs-signal-dur and --vhs-chroma-dur exist only to give those two a single source as
 * well. They agreed by luck before, which is the same setup one edit away from the same bug.
 */
.vhs-signal-layer {
  opacity: calc(0.05 * var(--adaptive-opacity, 0.8) / 0.8);
  animation-duration: calc(
    var(--vhs-signal-dur, 2.4s) * var(--animation-scale, 1) / var(--adaptive-animation-speed, 1)
  );
}

.vhs-chroma-noise {
  opacity: calc(var(--crt-chroma-noise-opacity, 0.18) * var(--adaptive-opacity, 0.8) / 0.8);
  animation-duration: calc(
    var(--vhs-chroma-dur, 9s) * var(--animation-scale, 1) / var(--adaptive-animation-speed, 1)
  );
}

.composite-artifacts {
  opacity: calc(0.14 * var(--adaptive-opacity, 0.8) / 0.8);
  animation-duration: calc(
    var(--crt-dot-crawl-dur, 1.867s) * var(--animation-scale, 1) /
      var(--adaptive-animation-speed, 1)
  );

  /*
   * A warming tube shifts warm: the cathodes run hotter and the picture yellows slightly.
   * Folded into the dot-crawl layer's own tint rather than given a new full-viewport
   * element of its own.
   *
   * THE COLOUR IS MODELLED, NOT CHOSEN. This was a flat rgba(255, 176, 96) with the thermal
   * progress as its opacity -- a hand-picked hue standing in for a model that was already
   * in crt-physics-enhanced.js and already correct. Each P22 phosphor has its own thermal
   * coefficient (red -0.002, green -0.0015, blue -0.003 per degree C), so the picture does
   * not GAIN red as it warms, it LOSES blue twice as fast as green. --thermal-cast carries
   * that ratio, normalised to the brightest gun so it is pure chroma; the amplitude stays
   * here as the alpha.
   */
  background-color: rgba(
    var(--thermal-cast, 255, 176, 96),
    calc(var(--thermal-color-shift, 0) * 0.035)
  );
}

/* Scanlines and vignette are the two heaviest-looking layers, so they carry the
 * intensity factor rather than the opacity one. The [data-motion='paused'] rules above
 * are more specific and still win, which is correct: pausing should override
 * adaptation, not negotiate with it. */
/*
 * SPLIT 2026-09-20, because grouping them flattened two different tuned values into one.
 *
 * These shared `opacity: calc(0.35 * var(--adaptive-intensity, 1))`. .crt-scanlines has no
 * other opacity anywhere, so 0.35 is genuinely its tuned value. .crt-vignette has carried
 * `opacity: 0.85` since the original CRT port -- and this grouped rule comes later, so it
 * won, and the vignette has rendered at 0.35 ever since 9cd1ffa wired the adaptation up.
 *
 * That contradicts this section's own stated invariant, a few lines up: "the /0.8 and /1
 * divisors normalise each factor to 1.0 at full performance, so these rules reproduce the
 * hand-tuned opacities exactly until the frame rate actually drops. Without that, wiring the
 * adaptation up would have silently restyled the page." It did silently restyle the page,
 * for one layer, by grouping it with a layer whose tuned value was different.
 *
 * Same shape as the `.perf-medium *` rules that flattened every animation-duration to one
 * constant: a selector list is not a place for a value that differs per selector.
 *
 * THIS IS A VISIBLE CHANGE -- the vignette's corner darkening goes back to roughly 2.4x what
 * it has been. A restoration rather than a new look, but it is the one thing in this pass
 * that alters the default picture, so it is called out rather than buried.
 */
/*
 * .crt-scanlines RETIRED 2026-09-20. It was a full-viewport fixed div at z-index 10 with
 * opacity 0.35 and `background-image: none` -- no background, no pseudo-element content, no
 * children, no animation. Measured in Firefox: it painted nothing, and had never painted
 * anything. The scanlines it is named for come from .scanlines-interlaced at z-index 39.
 *
 * The 2026-09-12 layer audit counted it among "four that had any styling", which was true of
 * a rule and false of a pixel: opacity and transition-property are not paint. It was also
 * listed in performance-monitor's heavy-effects selector, so the page took trouble to
 * disable a layer that showed nothing.
 *
 * This rule existed only because splitting it from .crt-vignette was needed to stop one
 * opacity flattening two tuned values -- with the element gone, the vignette keeps its own.
 */
.crt-vignette {
  opacity: calc(0.85 * var(--adaptive-intensity, 1));
}

/* Beam current. CRTSystem tracks how bright the displayed picture is, because a CRT
 * blooms and loses geometry when it is driven hard. Here it simply holds the
 * background back when the image is already hot, which keeps the title readable. */
.bg-image.active {
  opacity: calc(1 - var(--crt-background-intensity, 0) * 0.25);
}

/*
 * The thermal pair -- --thermal-color-shift and --crt-convergence-distortion -- are
 * consumed above, on .composite-artifacts and on the title's convergence fringes.
 * crt-effects.js only publishes them once CRTPhysics reports the tube warm, so both
 * sit at their defaults for the first seconds of a session and then drift in. That
 * gradualness is why it was easy to believe they had been working all along.
 */

/* ===========================================================================
 * The effect layers must not transition.
 *
 * THE BUG THIS GUARDS AGAINST
 * critical-css.njk used to carry `.perf-low *` and `.perf-medium *` rules setting both
 * animation-duration and transition-duration with !important, to slow effects down on
 * weaker hardware. The animation half does what it says: animation-name defaults to
 * `none`, so setting a duration on `*` only affects elements that already animate.
 *
 * The transition half did not, and the asymmetry was the whole defect.
 * transition-property defaults to `all`, so setting transition-duration on `*` did not
 * scale existing transitions -- it CREATED a transition on every property of every
 * element on the page. Every engine variable then drove a transition instead of a
 * style, and those transitions were observed stuck at currentTime 0 in playState
 * "running" indefinitely. The variables changed, the pixels did not, and the control
 * panel felt dead or mushy depending on which property was involved.
 *
 * That rule is fixed -- the tier scale is now an inherited --transition-scale custom
 * property that real transitions opt into. These declarations remain as a guard.
 *
 * These layers are driven by physics controls, where the correct behaviour is to track
 * the input immediately rather than ease toward it. None of them declared a transition
 * of their own, so nothing is being taken away -- this only undoes what `*` imposed.
 * Interactive elements are deliberately NOT in this list: #nav-list a has a real
 * colour/text-shadow hover transition and keeps it.
 * =========================================================================== */
.bg-container,
.crt-phosphor-dots,
.crt-phosphor-dots::after,
.scanlines-interlaced,
.scanlines-interlaced::after,
.scanlines-rgb,
.crt-flicker-layer,
.vhs-signal-layer,
.vhs-chroma-noise,
.composite-artifacts,
.crt-vignette {
  transition-property: none;
}

/*
 * The nav keeps its hover transition, but only on colour.
 *
 * Its text-shadow carries the colour system's per-gun convergence offsets and the luma
 * bleed radius, so while `all` was in force those four controls drove a transition
 * instead of the shadow and appeared dead. Naming colour alone keeps the hover fade
 * that was actually designed and lets convergence track the slider immediately.
 */
html #nav-list a {
  transition-property: color;
}

/*
 * The title letters take the same treatment. Their convergence fringes come from the
 * colour system and their transform/filter are rewritten inline by the glitch engine
 * about two thousand times a second, so an imposed `transition: all` is actively
 * fighting the effect rather than smoothing it. Nothing in this project ever declared a
 * transition here; it came entirely from the `*` rule.
 *
 * Both selectors lead with `html` because the critical CSS is an inline <style> that
 * comes AFTER the link to this file, so a bare #nav-list a or #glitch-title .tg-letter
 * loses the cascade at equal specificity.
 */
html #glitch-title .tg-letter {
  transition-property: none;
}

/*
 * THE ERROR PAGE NEEDS A SHORTER HERO, and the reason is not aesthetic.
 *
 * `header` is a full-viewport hero, so `main` starts below the fold. On the homepage that is
 * deliberate -- the rule above says so -- but on a 404 it means someone who followed a dead
 * link sees the front page, in full, with the words "No signal" scrolled off the bottom. They
 * learn nothing, which is the entire job of the page.
 *
 * Measured at 1440x900 before this rule: the <h1> sat at y=996, 96px past the bottom edge.
 *
 * Scoped to .error-page so no other page moves. The title stays, because the visitor still
 * needs to know whose site they have landed on.
 */
.error-page header {
  min-height: calc(var(--vh) * 34);
}

.error-page main {
  /* The hero's own bottom padding already separates them; without this the message sits
     immediately under the nav and reads as part of it. */
  padding-block: 2rem 4rem;
}

/* ===========================================================================
 * NO CONTENT BELOW THE FOLD. The page is ONE SCREEN.
 *
 * Travis: "The site isn't supposed to scroll... No content should be below the fold." The mini
 * windows are the route to content -- About, Contact and all twelve projects are served as
 * fragments and opened over the hero.
 *
 * MAKING IT UNREACHABLE WAS NOT THE SAME AS REMOVING IT, and that was my error. `overflow: hidden`
 * on body stops the gesture, so nothing scrolls -- but `main` still laid out below the fold and
 * the content was still there, clipped. Measured on production before this rule:
 *
 *     viewport     innerHeight   scrollHeight   below the fold
 *     390x844            844          5336          4492px
 *     430x932            932          5259          4327px
 *     768x1024          1024          3557          2533px
 *     1440x900           900          3682          2782px
 *
 * CLIPPED RATHER THAN display:none, deliberately. display:none would take About, Contact and the
 * twelve project entries out of the accessibility tree and out of what a crawler renders -- and
 * that markup is currently the only machine-readable copy of the work. This is the same technique
 * .sr-only uses: zero layout footprint, still announced, still indexed.
 *
 * The skip link still targets #main, which stays focusable through its tabindex="-1".
 *
 * SCOPED TO body.home, AND THAT IS NOT TIDINESS. /projects/ and every /projects/<slug>/ page use
 * the SAME stylesheet and the same <main>, and those pages exist to show their content -- an
 * unscoped rule blanks all nineteen of them. Caught by checking the built output before shipping:
 * _site/projects/eidolon/index.html carries 2710 characters inside <main>, and its body tag was
 * byte-identical to the homepage's. The class is added in src/index.njk and the root index.html,
 * which are the only two homepage sources.
 *
 * If the content should be genuinely deleted rather than hidden, that is a separate decision and
 * a template change, not a stylesheet one.
 * =========================================================================== */
body.home main {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip: rect(0, 0, 0, 0);
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* The scrim exists to hold WCAG contrast over the rotating photograph for text that is on
 * screen. With main clipped there is no such text, and a pseudo-element on a clipped box would
 * only add a paint for nothing. */
body.home main::before {
  content: none;
}

/* ===========================================================================
 * THE TITLE AND THE NAV ARE DRAWN BY THE TUBE, 2026-10-02.
 *
 * Travis approved drawing them into the picture "as a layer the shader processes like the
 * photograph". crt-webgl-tube.js renders both into the composed source and adds
 * html.crt-text-in-tube in the same frame that first shows them -- never before, so a page where
 * the tube fails keeps its DOM text exactly as it was.
 *
 * TRANSPARENT FILL, NOT TRANSPARENT COLOUR, AND NOT HIDDEN. The DOM text stays for links, focus,
 * selection and screen readers. `color` is left alone because the tube reads it per glyph --
 * hover state included -- and the fill is what goes. The shadows go because every one of them
 * (convergence, halation) is something the shader now does to the drawn text itself; leaving
 * them would draw a ghost of the fringes around a transparent glyph.
 *
 * The title's horizontal beam blur goes too: the picture pass band-limits the drawn title, and
 * an SVG filter over transparent letters is a full raster pass for nothing.
 * =========================================================================== */
html.crt-text-in-tube:not(.crt-text-measure) #glitch-title .tg-letter,
html.crt-text-in-tube:not(.crt-text-measure) #nav-list a,
html.crt-text-in-tube:not(.crt-text-measure) #nav-list a * {
  /* The letters too: crt-text-warp.js writes an inline text-shadow on a letter during a chroma
   * or bloom fault, which would otherwise paint a shadow round a transparent glyph. */
  -webkit-text-fill-color: transparent !important;
  text-shadow: none !important;
}
html.crt-text-in-tube #glitch-title {
  filter: none !important;
}
html.crt-text-in-tube #glitch-title .tg-letter::after {
  display: none !important;
}

/*
 * ONE SYNCHRONOUS MEASUREMENT AT LAYOUT POSITIONS. The tube draws the text where layout put it
 * and applies the yoke, the transport and the roll itself, so the boxes it reads must carry none
 * of the transforms the DOM warp and the glitch engine write. Scoped to the header, which holds
 * both: suspending transforms page-wide would also replay the drawer handle's slide every time
 * the text is re-measured. Added and removed inside one task, so it is never painted.
 */
html.crt-text-measure header,
html.crt-text-measure header * {
  transform: none !important;
  /* The individual transform properties as well: a letter fault writes `translate`. */
  translate: none !important;
  rotate: none !important;
  scale: none !important;
  transition: none !important;
}
