/* os.css — the page IS the monitor.
   ---------------------------------------------------------------------------
   No drawn bezel. The viewport is the tube: corner bulge, glass glare,
   scanlines, and chromatic aberration applied to the whole surface.

   DELIBERATELY DOM, NOT CANVAS. The reference renders its entire site into a
   <canvas> with one aria-label, which costs selectable text, crawlability and
   screen-reader access. Every CRT effect here is CSS or an SVG filter, so the
   same look sits over real HTML that a recruiter can select and a crawler can
   read. The nostalgia is a surface treatment, not a rendering strategy.

   Sequence: DARK (a single pulsing dot) -> POWER (tube switch-on) -> BOOT
   (short, and sci-fi rather than technical) -> FACE (the machine opens its
   eyes) -> READY (an ordinary scrollable page).
   --------------------------------------------------------------------------- */

:root {
  --display: "Helvetica Neue", Helvetica, Arial, sans-serif;
  --body: system-ui, -apple-system, "Helvetica Neue", Arial, sans-serif;
  --mono: ui-monospace, "SF Mono", Menlo, Consolas, monospace;

  --void:   #050706;      /* never pure black — it smears on OLED */
  --ink:    #0A0F0D;
  --fg:     #DCE6DD;
  --fg-dim: #7E8C84;
  --amber:  #F0A83C;
  --warm:   #FF6B35;
  --cool:   #2FD9C5;
}

* { box-sizing: border-box; }

/* [hidden] MUST WIN. The attribute maps to `display:none` in the UA stylesheet,
   which any author rule setting `display` on a class beats — so .below, which is
   display:grid, stayed on screen while its .hidden property read true. That is
   how the prose flashed between hello. and the eyes: the property was a claim,
   the computed style was the fact. Never trust element.hidden again without
   checking what the element actually DOES. */
[hidden] { display: none !important; }

html, body { height: 100%; }
body {
  margin: 0;
  background: var(--void);
  color: var(--fg);
  font-family: var(--body);
  font-size: 17px;
  line-height: 1.6;
  -webkit-font-smoothing: antialiased;
  overflow-x: clip;
}

/* ============================================================== THE TUBE === */

/* everything the reader sees lives in here, so the glass sits over all of it */
.tube { position: relative; min-height: 100%; }

/* the phosphor layer — the only thing the chroma filter touches, because
   filtering the glare layers too would smear the highlights */
/* HALATION added on the researched numbers: light scattering inside the glass
   makes bright areas bleed into the dark around them. Emulation guidance puts
   realistic halation at radius 30 / opacity 0.12, so that is literally what
   this is. It is the difference between text that sits ON black and text that
   is GLOWING THROUGH glass. */
.phosphor {
  position: relative; z-index: 1;
  /* ⚠️ THE DROP-SHADOW CAME OFF .phosphor, AND IT WAS THE "SEAM".
     `drop-shadow` does not draw a box — it draws a blurred copy of the
     element's ALPHA SILHOUETTE. On .phosphor that means every shape inside
     gets a 30px amber glow traced around it, which is exactly right for the
     copy and the pixel icons, and exactly wrong for a photographic
     illustration with a complex cut-out edge: every tree and every ridge came
     with a warm rim.

     Juan reported it for five rounds as a border, a seam, a transition. It was
     never any of those and nothing done to the IMAGE could reach it — not the
     matte, not the fade, not the encoder, not the resolution. Found by
     differencing a render against the same render with only this line changed
     and amplifying: the difference is a glowing outline of the treeline, peak
     48/255. The picture was never the problem; the page was drawing around it.

     The glow now goes on .phosphor's children instead, so everything that
     should bloom still blooms and the illustration is left alone.

     ⚠️ AND THE CHROMA FILTER HAD TO FOLLOW IT, FOR A COMPLETELY DIFFERENT
     REASON. The line above used to read "the chroma filter stays here, because
     that one is wanted on everything." It was wanted on everything, and it was
     also the second rim.

     An SVG filter defaults to `color-interpolation-filters: linearRGB`, so the
     subtree is converted to linear light, filtered, and converted back — and
     Chrome does that round trip in 8 bits. Near black, linear 8-bit has almost
     no resolution: sRGB 8 is linear 0.0024, which is 0.6 of a step, and it
     lands on 0. Everything inside .phosphor below about sRGB 13 came out PURE
     BLACK. The page's own background is #050706, and it sits OUTSIDE .phosphor
     so it is never filtered. So wherever the illustration kept a patch of its
     own dark sky opaque — which is along the terrain line, by construction —
     the page went from 6.5 to 0 against an unfiltered 6.5 surround. A step of
     five levels, tracing every tree and ridge. That is the border Juan kept
     reporting after the drop-shadow was fixed.

     Measured, world present vs the bare page, at 1440 and at 2560:
         chroma on .phosphor    0.58% of the frame darkened, mean 0.078
         chroma on the children 0.00%                        mean 0.014
     and the copy, icons and chrome move by mean 0.036 / peak 3 — i.e. not at
     all. Setting color-interpolation-filters="sRGB" also fixes the crush, but
     it changes the aberration everywhere else (mean 0.85, peak 56 on the copy),
     so it trades a rim for a different tube.

     ⚠️ .phosphor KEEPS A FILTER, and `opacity(1)` is not decoration. A filter
     makes the element a containing block for fixed-position descendants, and
     .boot, .crt-glass and .crt-bulge are all `position: fixed` inside it.
     Dropping to `filter: none` would re-parent them to the viewport. A CSS
     filter FUNCTION operates in sRGB, so it holds the containing block without
     reintroducing the linear round trip. */
  filter: opacity(1);
}
.phosphor > *:not(.world) {
  filter: drop-shadow(0 0 30px rgba(240, 168, 60, .12)) url(#crt-chroma);
}

/* ============================================================= THE FIELD ===
   The background: a phosphor dot field that breathes with whatever the machine
   is playing.

   NOT a canvas. Canvas is on this repo's rejected list and the reason still
   holds — the page IS a CRT, and every effect is CSS or an SVG filter over real
   HTML. So the division of labour is: JS reads an AnalyserNode and writes three
   numbers to custom properties; CSS does all the rendering. Nothing is drawn.

   It has to work with no audio at all — /work opened directly has had no user
   gesture, so no audio context can exist — so the drift runs on a CSS animation
   and the audio only ever MODULATES what is already alive.

   Different every visit: --f-seed-x/y/r are randomised once at load, so the
   field lands at a different offset and angle each time, and the bed's loop
   position and the hum's LFO drift are never in the same phase twice. */

.field {
  position: fixed; inset: -20%; z-index: 0; pointer-events: none;
  /* the dots: one gradient, no elements, infinitely repeating */
  /* REVERTED to the original weights. Raising these was the wrong fix for
     "I can't see anything" — the real cause was that a returning visitor never
     got audio at all. Brightening the glow to compensate just made the tube
     look washed out, and the thing that should be visible is the DRAWING, not
     a hotter background. */
  background-image:
    radial-gradient(circle at 50% 50%,
      rgba(240, 168, 60, calc(.030 + var(--aud-mid, 0) * .085)) 0 1.1px,
      transparent 1.9px);
  background-size: var(--f-gap, 27px) var(--f-gap, 27px);
  transform:
    translate3d(var(--f-seed-x, 0px), var(--f-seed-y, 0px), 0)
    rotate(var(--f-seed-r, 0deg));
  opacity: calc(.55 + var(--aud-level, 0) * .45);
  will-change: opacity;
  animation: drift 96s linear infinite;
}
@keyframes drift {
  from { background-position: 0 0; }
  to   { background-position: var(--f-gap, 27px) calc(var(--f-gap, 27px) * -1); }
}

/* THE STARS — born on a peak, turned by the tube.

   Four rings, each rotating about a point near the hero at its own speed, so a
   star orbits at whatever speed the ring it landed in is turning. Four speeds
   reads as depth. All of it is compositor work: the rings rotate in CSS, the
   stars twinkle in CSS, and JS only ever adds and removes elements. A per-frame
   JS loop moving ninety elements would fight everything else on the page. */
.stars {
  position: fixed; inset: 0; z-index: 0; pointer-events: none;
  overflow: hidden;
}
.stars__ring {
  position: absolute; inset: 0;
  /* ⚠️ 50% 50%, AND IT MUST MATCH stars.js's DISC CENTRE. This was 38% 46% so the
     sky turned around the hero — but the placement disc was centred there too and
     capped at 0.52 * min(W,H), which on a wide screen could not reach the right
     edge and left the field visibly left-leaning. The disc is now centred and
     sized to cover the viewport; a disc centred on the rotation origin maps onto
     itself under rotation, so these two values are a PAIR. Change one and stars
     sweep in and out of frame. */
  transform-origin: 50% 50%;
  animation: orbit linear infinite;
  will-change: transform;
}
.stars__ring--1 { animation-duration: 95s; }
.stars__ring--2 { animation-duration: 150s; animation-direction: reverse; }
.stars__ring--3 { animation-duration: 215s; }
.stars__ring--4 { animation-duration: 300s; animation-direction: reverse; }
@keyframes orbit { to { transform: rotate(360deg); } }

/* ─── VARIANT D — Juan's pick, Aug 31 2026 ────────────────────────────────────
   "somewhere in between stars and those embers that come out of a fire", and a
   bit more obvious. So: STAR GEOMETRY — small, hard-edged, a pixel rather than a
   dot — carrying an ember's colour and the faintest lift. It still reads as a sky
   that happens to be warm, which is what keeps it behind the work instead of
   performing in front of it.

   Compared side by side against four alternatives in _lab/embers.html: A as
   shipped, B the same sky turned up, C true embers, D this, E full fire. That lab
   stays in the repo as the record of what the other four looked like.

   --star-scale is the one dial. Juan judged these at 1.6, which is where it sits.

   ⚠️ THE RISE RUNS ONCE, NOT INFINITELY. In the lab a particle lived 7-16s and
   never finished its climb, so `infinite` was invisible there. Production stars
   live 60-150s against a 34-72s climb, so they WOULD loop — and a looping
   translate snaps the particle back to the bottom mid-sky. Hence the two-value
   iteration-count and fill-mode: the twinkle repeats forever, the climb happens
   once and holds where it ended. */
.stars { --star-scale: 1.6; }

.stars__s {
  position: absolute;
  width: calc(3px * var(--star-scale)); height: calc(3px * var(--star-scale));
  border-radius: 1px;
  background: linear-gradient(180deg, #ffe0a8, var(--amber) 60%, #e2761f);
  box-shadow: 0 0 calc(9px * var(--star-scale)) rgba(255, 150, 60, .55);
  opacity: 0;
  transition: opacity 1.4s ease;
  animation-name: twinkle, star-rise;
  animation-duration: var(--s-flick, 5s), var(--s-float, 60s);
  animation-delay: var(--s-dflick, 0s), var(--s-dfloat, 0s);
  animation-timing-function: ease-in-out, linear;
  animation-iteration-count: infinite, 1;
  animation-fill-mode: none, forwards;
}
.stars__s.is-on { opacity: calc(var(--s-peak, .7) * var(--s-depth, 1)); }

/* depth: smaller and dimmer the further back it sits. The far tier keeps a
   cooler cast so the field is not one flat colour. */
.stars__s--d0 { --s-depth: 1; }
.stars__s--d1 { --s-depth: .66; }
.stars__s--d2 { --s-depth: .38;
  width: calc(2px * var(--star-scale)); height: calc(2px * var(--star-scale));
  background: linear-gradient(180deg, #f6ffe9, #e2fff6 70%, #cfe6d8);
  box-shadow: 0 0 calc(6px * var(--star-scale)) rgba(226, 255, 246, .34); }

/* the phosphor rule once more: a star does not blink evenly, it flares and
   falls off */
@keyframes twinkle {
  0%, 100% { filter: brightness(.55); }
  8%       { filter: brightness(1.35); }
  40%      { filter: brightness(.75); }
}

/* the lift. Both axes live on ONE keyframe because two transform animations on
   one element fight over the same property. */
@keyframes star-rise {
  from { transform: translate(0, 0); }
  to   { transform: translate(var(--s-wander, 0px), calc(-1 * var(--s-climb, 60px))); }
}

@media (prefers-reduced-motion: reduce) { .stars { display: none; } }

/* the bloom: the tube's own light swelling on the low end. This is the layer
   that actually reads as "the music is in the room" — the dots give texture,
   the bloom gives breath. */
.field__bloom {
  position: fixed; inset: 0; z-index: 0; pointer-events: none;
  background: radial-gradient(
    calc(60% + var(--aud-low, 0) * 26%) calc(46% + var(--aud-low, 0) * 22%)
      at 50% 58%,
    rgba(240, 168, 60, calc(.020 + var(--aud-low, 0) * .055)) 0%,
    rgba(240, 168, 60, calc(.008 + var(--aud-low, 0) * .022)) 42%,
    transparent 74%);
  will-change: background;
}

/* the high end is a fine horizontal shimmer — the mask catching the beam */
.field__hi {
  position: fixed; inset: 0; z-index: 0; pointer-events: none;
  background: repeating-linear-gradient(0deg,
    rgba(226, 255, 246, calc(var(--aud-high, 0) * .030)) 0 1px,
    transparent 1px 4px);
  opacity: calc(var(--aud-high, 0) * 1.0);
  will-change: opacity;
}

/* Reduced motion keeps the field — it is texture, not movement — but stops it
   travelling and stops it reacting. A background that pulses to music is
   exactly what this setting is asking us not to do. */
@media (prefers-reduced-motion: reduce) {
  .field { animation: none; opacity: .5; }
  .field__bloom, .field__hi { display: none; }
}

/* Corner falloff, NOT geometric barrel distortion — worth naming accurately.
   Real CRT curvature sits around 0.04 as a baseline, and warping the whole
   page by that much would bend the text for no legibility gain. The corner
   darkening carries the read of a curved tube without distorting a word. */
.crt-bulge {
  position: fixed; inset: 0; z-index: 40; pointer-events: none;
  background: radial-gradient(126% 108% at 50% 50%, transparent 54%, rgba(0, 0, 0, .92) 96%);
}
/* Scanlines, a soft top glare, and a vertical mask kept DELIBERATELY faint.
   Emulation guidance is that mask structures do not reproduce convincingly
   below roughly 5K pixel density — on a normal display a strong mask just
   aliases. At .014 it reads as texture rather than as a fake shadow mask. */
.crt-glass {
  position: fixed; inset: 0; z-index: 41; pointer-events: none;
  background:
    linear-gradient(180deg, rgba(220, 240, 235, .045), transparent 22%),
    repeating-linear-gradient(rgba(0, 0, 0, .22) 0 1px, transparent 1px 3px),
    repeating-linear-gradient(90deg, rgba(255, 255, 255, .014) 0 1px, transparent 1px 3px);
}
/* the tube's own glow, breathing very slightly */
.crt-glow {
  position: fixed; inset: 0; z-index: 39; pointer-events: none;
  background: radial-gradient(60% 50% at 50% 45%, rgba(240, 168, 60, .05), transparent 70%);
  animation: breathe 7s ease-in-out infinite;
}
@keyframes breathe { 0%, 100% { opacity: .8 } 50% { opacity: 1 } }

/* ============================================================ 1 · DARK ==== */

.gate {
  position: fixed; inset: 0; z-index: 50;
  display: grid; place-items: center;
  background: var(--void);
  cursor: pointer;
  transition: opacity .45s ease;
}
.gate[hidden] { display: none; }
/* The beacon is not a round dot. It obeys the same square-pixel rules as the
   eyes, because it IS an eye — shut, waiting. Pressing it is what opens it. */
.gate__dot {
  --px: 15px;
  display: grid;
  grid-template-columns: repeat(3, var(--px));
  gap: calc(var(--px) * .3);
  animation: beacon 1.9s linear infinite;
}
.gate__dot i {
  height: var(--px); border-radius: 2px;
  background: var(--amber);
  box-shadow: 0 0 16px rgba(240, 168, 60, .8), 0 0 48px rgba(240, 168, 60, .25);
}
.gate__dot i:nth-child(1), .gate__dot i:nth-child(3) { background: rgba(240, 168, 60, .22); box-shadow: none; }

/* PHOSPHOR, not a sine wave. Real phosphor snaps on and decays to about 10%
   over tens of milliseconds, so the pulse is a fast attack with a long fall.
   A symmetric ease-in-out is what makes fake CRTs read as fake. */
@keyframes beacon {
  0%   { opacity: 1;   transform: scaleY(1); }
  6%   { opacity: .96; }
  55%  { opacity: .52; }
  100% { opacity: .34; transform: scaleY(.94); }
}
.gate__hint {
  position: absolute; bottom: 16%;
  font-family: var(--mono); font-size: 11px; letter-spacing: .28em;
  text-transform: uppercase; color: var(--fg-dim);
  animation: fadein 1.1s ease 2.4s both;
}
.gate__hint b { color: var(--amber); font-weight: 400; }
@keyframes fadein { from { opacity: 0 } to { opacity: .85 } }

/* ============================================================ 2 · POWER ===
   REBUILT on how a CRT actually powers on, which is not the reverse of how it
   powers off. The famous collapsing hot line and the white dot are the SWITCH-
   OFF signature: deflection stops while the capacitors still discharge through
   an emitting cathode. Playing that backwards is a widespread myth and it was
   what this file used to do.

   The real order is:
     1. the switch closes — degauss coil fires, a heavy thunk and a colour
        shimmer that decays as the PTC thermistor's resistance climbs
     2. NOTHING. The screen stays black for seconds while the heater brings the
        cathode to emission temperature. Compressed here, but kept.
     3. the picture fades UP out of black — dim, bloomed and overscanned, then
        settling in brightness, geometry and focus as the HV stabilises.
   Sources are cited in the README. */

.power {
  position: fixed; inset: 0; z-index: 49; pointer-events: none;
  display: grid; place-items: center;
  overflow: hidden;
  /* the dark hold IS this background. It stays opaque through the heater warm-
     up and only then lets the raster show through. */
  background: var(--void);
  animation: pwr-dark 2.10s linear both;
}
.power[hidden] { display: none; }
@keyframes pwr-dark {
  0%    { background-color: var(--void); }
  54%   { background-color: var(--void); }   /* the wait — the heater warming */
  76%   { background-color: rgba(5, 7, 6, .35); }
  100%  { background-color: rgba(5, 7, 6, 0); }
}

/* the instant of the switch: no picture, just the supply slamming on */
.power__snow {
  position: absolute; inset: -20%;
  background-image:
    repeating-conic-gradient(#fff 0deg 1deg, #000 1deg 2deg),
    repeating-linear-gradient(97deg, rgba(255,255,255,.5) 0 2px, transparent 2px 4px);
  background-size: 3px 3px, 5px 5px;
  opacity: 0;
  animation: snow .28s steps(3) forwards;
}
@keyframes snow {
  0%   { opacity: .26; transform: translate(0, 0); }
  45%  { opacity: .13; transform: translate(-2px, 3px); }
  100% { opacity: 0;   transform: translate(3px, -2px); }
}

/* THE DEGAUSS. The coil is driven straight off the mains and its field decays
   as the thermistor heats, so the shimmer is an AC wobble that dies out over
   about a second — never a single flash. Colour, because a magnetised mask is
   exactly what throws the beams onto the wrong phosphors. */
.power__degauss {
  position: absolute; inset: -6%;
  background:
    repeating-linear-gradient(0deg,
      rgba(255, 40, 60, .22) 0 3px,
      rgba(40, 255, 190, .16) 3px 6px,
      rgba(60, 90, 255, .22) 6px 9px,
      transparent 9px 17px);
  mix-blend-mode: screen;
  filter: blur(1.1px);
  opacity: 0;
  animation: degauss .78s cubic-bezier(.3, .1, .5, 1) both;
}
@keyframes degauss {
  0%   { opacity: .95; transform: translate3d(0,0,0)      skewX(0deg)    scaleY(1);    }
  12%  { opacity: .78; transform: translate3d(-9px,4px,0)  skewX(-1.4deg) scaleY(1.05); }
  26%  { opacity: .62; transform: translate3d(7px,-6px,0)  skewX(1.1deg)  scaleY(.96);  }
  41%  { opacity: .44; transform: translate3d(-5px,3px,0)  skewX(-.7deg)  scaleY(1.03); }
  58%  { opacity: .27; transform: translate3d(4px,-2px,0)  skewX(.5deg)   scaleY(.99);  }
  76%  { opacity: .13; transform: translate3d(-2px,1px,0)  skewX(-.2deg)  scaleY(1.01); }
  100% { opacity: 0;   transform: translate3d(0,0,0)       skewX(0deg)    scaleY(1);    }
}

/* EMISSION. The glow rises out of black late — after the dark hold — and it
   rises, it does not snap. Bloomed at first because the beam is unfocused and
   the HV has not settled, tightening as it comes up to temperature. */
.power__warm {
  position: absolute; inset: 0;
  background: radial-gradient(120% 86% at 50% 50%,
    rgba(226, 255, 246, .30) 0%,
    rgba(226, 255, 246, .13) 45%,
    rgba(226, 255, 246, .04) 72%,
    transparent 100%);
  opacity: 0;
  animation: warm 2.10s cubic-bezier(.35, .05, .3, 1) both;
}
@keyframes warm {
  0%   { opacity: 0;   filter: blur(18px); transform: scaleY(.86); }
  52%  { opacity: 0;   filter: blur(18px); transform: scaleY(.86); }  /* still dark */
  68%  { opacity: .55; filter: blur(11px); transform: scaleY(.97); }
  82%  { opacity: .78; filter: blur(5px);  transform: scaleY(1.01); }
  100% { opacity: .34; filter: blur(0px);  transform: scaleY(1); }
}

/* THE RASTER SETTLING — this is the part that has to happen to the real
   content, not to an overlay, or it is a light show rather than a screen
   warming up. Geometry overscans and pulls in; brightness climbs. Deliberately
   NOT a filter change: .phosphor's filter carries the chroma url(), and a list
   containing url() will not interpolate. */
.warming .phosphor {
  transform: scale(1.05);
  opacity: .18;
}
.phosphor {
  transition: transform .95s cubic-bezier(.2, .7, .25, 1),
              opacity   .95s cubic-bezier(.3, .6, .3, 1);
}

/* ============================================================= 3 · BOOT === */

.boot {
  /* OVERLAYS the stage rather than stacking above it in flow. Stacked, the
     eyes woke a full viewport below the boot text and were never seen. */
  position: fixed; top: 0; left: 0; right: 0; bottom: 0; z-index: 20;
  /* height stated outright: the box was collapsing to its content (160px), so
     align-content:center had nothing to centre the lines within and they sat
     at the top of the screen */
  height: 100svh;
  background: var(--void);
  /* CENTRED, because this is the machine addressing you rather than the page
     being read. The hand-off to Juan's words is what moves everything left. */
  display: grid; align-content: center; justify-items: center;
  text-align: center;
  padding: 8vh 7vw;
  font-family: var(--mono);
  font-size: clamp(12px, 1.35vw, 15px);
  letter-spacing: .04em;
  color: var(--fg-dim);
}
.boot[hidden] { display: none; }
.boot__l { margin: 0 0 6px; white-space: pre-wrap; }
.boot__l b { color: var(--amber); font-weight: 400; }
.boot__l i { color: var(--cool); font-style: normal; }
.boot__l--big {
  font-family: var(--display); font-weight: 800; text-transform: lowercase;
  letter-spacing: -.03em; font-size: clamp(26px, 4vw, 44px);
  color: var(--fg); margin: 4px 0 18px;
}
.cursor {
  display: inline-block; width: .58em; height: 1.05em;
  background: var(--amber); vertical-align: -.16em; margin-left: 2px;
  animation: blink 1.05s steps(1) infinite;
}
@keyframes blink { 0%, 49% { opacity: 1 } 50%, 100% { opacity: 0 } }

/* ============================================================= 4 · FACE === */
/* Not a portrait. The machine's own face: two pixel eyes that open, blink and
   track the pointer a little, so it reads as looking back at you. */

/* The eyes are centred on screen while they are alone, then GLIDE to their
   place in the hero as the copy arrives. --fx/--fy are measured in JS, because
   the delta between "centre of the viewport" and "top-left of the hero" is not
   something CSS can know. Removing the class animates the transform back to 0,
   which is the whole trick. */
.face70--center { transform: translate(var(--fx, 0px), var(--fy, 0px)); }

.face70 {
  transition: transform .85s cubic-bezier(.22, .8, .24, 1);
  display: grid; grid-auto-flow: column; gap: clamp(20px, 3vw, 34px);
  justify-content: start; align-items: center;
  /* HUG THE EYES. As a stretched grid item this box spanned the whole content
     column, so its centre already equalled the viewport centre and the centring
     maths correctly computed a zero offset — I was measuring the container, not
     the eyes. Shrink-to-fit makes the box and the eyes the same thing. */
  justify-self: start;
  padding: 0 0 clamp(22px, 4vh, 40px);
}
.eye {
  --px: clamp(8px, 1.2vw, 12px);
  display: grid;
  grid-template-columns: repeat(3, var(--px));
  grid-template-rows: repeat(3, var(--px));
  gap: calc(var(--px) * .3);
  transform: translate(var(--ex, 0px), var(--ey, 0px));
  transition: transform .5s cubic-bezier(.2, .7, .3, 1);
}

.eye i {
  background: var(--amber);
  border-radius: 2px;
  box-shadow: 0 0 12px rgba(240, 168, 60, .7);
  /* persistence: the glow lags the pixel, the way phosphor lags the beam */
  transition: box-shadow 240ms cubic-bezier(.1, .7, .2, 1), background-color 60ms linear;
}
/* dim the corners into a rounded shape and burn the centre as a pupil —
   a fully-lit grid reads as a block, not an eye */
.eye i:nth-child(1), .eye i:nth-child(3),
.eye i:nth-child(7), .eye i:nth-child(9) { background: rgba(240, 168, 60, .20); box-shadow: none; }
.eye i:nth-child(5) {
  background: #FFF2DC;
  box-shadow: 0 0 16px rgba(255, 242, 220, .95), 0 0 30px rgba(240, 168, 60, .6);
}
.eye--r i { animation-delay: .035s; }        /* the lids are never quite in sync */

/* ------------------------------------------------------------- THE BLINK ---
   REBUILT. It used to be scaleY(.06) on each of the nine pixels: every pixel
   squashing in place at once, closing and opening at the same speed. Three
   things wrong with that. A pixel that deforms is off the grid — tier one says
   pixels switch, they do not stretch. There was no intermediate state, so it
   read as a flicker. And real lids do not move symmetrically.

   Pixel-art practice is a LID SWEEP, row by row, and a blink closes faster
   than it opens. So the rows switch in sequence:

     closing   [###] -> [.##] -> [..#] -> [...]    top covered first
     opening   [...] -> [..#] -> [.##] -> [###]    bottom revealed first

   which is what a curtain from the top actually does: the edge travels down
   covering top-then-bottom, and travels back up uncovering bottom-then-top.

   90ms to close, 60ms shut, 130ms to open. Driven from JS on an irregular
   timer rather than an infinite CSS loop, because a metronome blink is the
   other way this reads as a machine pretending. */
.eye i { opacity: 1; }

/* THE LID CLOSES TO THE CENTRE, not to the floor.
   The first rebuild swept top-to-bottom like a curtain. Anatomically both lids
   move — the upper does most of the travel, the lower rises to meet it — so the
   aperture closes toward a LINE rather than down to the floor. On a three-row
   grid that is the whole difference: a top-down sweep's middle states are
   asymmetric, and at nine pixels asymmetry reads as the eye sliding rather than
   closing. Every state here is symmetric, and the half-closed state is a lit
   slit — which is exactly where the bright pupil pixel already sits.

     closing   [###] -> [.#.] -> [...]     top and bottom together
     opening   [...] -> [.#.] -> [###]     the slit reopens outward

   Still faster to close than to open: 85ms shut, 60 held, 135 open.
   Specificity is deliberate — .face70--wake stays on the element for good, so
   the blink rules have to out-rank it rather than merely follow it. */
.face70.blink .eye i                                { animation: lid-edge .28s linear both; }
.face70.blink .eye i:nth-child(n+4):nth-child(-n+6) { animation-name: lid-core; }

@keyframes lid-edge {         /* top and bottom rows — first out, last back */
  0%,    14.2% { opacity: 1; }
  14.3%, 76.7% { opacity: 0; }
  76.8%, 100%  { opacity: 1; }
}
@keyframes lid-core {         /* the middle row survives as a slit, briefly */
  0%,    30.3% { opacity: 1; }
  30.4%, 51.7% { opacity: 0; }
  51.8%, 100%  { opacity: 1; }
}

/* The earlier curtain sweep, kept so the two can be compared in _lab.
   ⚠️ These MUST be scoped to .blink as well as .blink--curtain. Written as
   `.face70.blink--curtain .eye i` they applied with no .blink present, which
   set an animation-name with NO duration — so the animation ran at 0s, parked
   in `finished`, and adding .blink afterwards only changed the duration. A
   name that never changes never restarts, so the eye simply never blinked
   while every computed property read exactly right. */
.face70.blink.blink--curtain .eye i                                { animation-name: lid-top; }
.face70.blink.blink--curtain .eye i:nth-child(n+4):nth-child(-n+6) { animation-name: lid-mid; }
.face70.blink.blink--curtain .eye i:nth-child(n+7)                 { animation-name: lid-bot; }
@keyframes lid-top { 0%, 10.7% { opacity: 1; } 10.8%, 84.2% { opacity: 0; } 84.3%, 100% { opacity: 1; } }
@keyframes lid-mid { 0%, 21.4% { opacity: 1; } 21.5%, 68.8% { opacity: 0; } 68.9%, 100% { opacity: 1; } }
@keyframes lid-bot { 0%, 32.1% { opacity: 1; } 32.2%, 53.5% { opacity: 0; } 53.6%, 100% { opacity: 1; } }

/* WAKING opens from the slit outward — the same shape, run once and slow */
.face70--wake .eye i                                { animation: wake-edge .62s linear both; }
.face70--wake .eye i:nth-child(n+4):nth-child(-n+6) { animation-name: wake-core; }
@keyframes wake-core { 0%, 19.9% { opacity: 0; } 20%,   100% { opacity: 1; } }
@keyframes wake-edge { 0%, 54.9% { opacity: 0; } 55%,   100% { opacity: 1; } }

/* ================================================================ THE ROLL ==
   A failing vertical hold: the frame slips and a bright band sweeps through.
   The reference leans on this harder than any other effect. Rare and brief on
   purpose — held longer it stops being a tube and starts being an effect. */

.roll {
  position: fixed; inset: 0; z-index: 42; pointer-events: none; opacity: 0;
  background: linear-gradient(180deg,
    transparent 0%, rgba(226, 255, 246, .05) 42%, rgba(226, 255, 246, .13) 50%,
    rgba(226, 255, 246, .05) 58%, transparent 100%);
}
.rolling .roll { animation: rollband .76s linear; }
@keyframes rollband {
  0%   { opacity: 0; transform: translateY(-100%); }
  8%   { opacity: 1; }
  92%  { opacity: 1; }
  100% { opacity: 0; transform: translateY(100%); }
}
/* the picture itself slips while the band passes */
.rolling .phosphor { animation: rollslip .76s cubic-bezier(.4, 0, .6, 1); }
@keyframes rollslip {
  0%, 100% { transform: translateY(0); }
  14%      { transform: translateY(-7px); }
  50%      { transform: translateY(3px); }
  78%      { transform: translateY(-2px); }
}

/* ============================================================== THE ICONS ==
   The objects from the workbench direction, brought back as Juan asked — not
   as drawn things this time but as PIXEL SPRITES, so they obey tier one and
   belong to the machine's own vocabulary. Each is an 8x8 grid: the machine is
   rendering him in the only alphabet it has. */

/* the icons are anchors now, not spans — kill the link chrome and give them a
   real hover/focus state, since anything that goes somewhere has to look like
   it does and be reachable by keyboard */
a.icon { text-decoration: none; color: inherit; border-radius: 3px; }
a.icon:hover .spr i.on,
a.icon:focus-visible .spr i.on { background: var(--hot, #FF6A3D); box-shadow: 0 0 14px rgba(255,106,61,.85); }
a.icon:hover .icon__l,
a.icon:focus-visible .icon__l { color: var(--amber); }
a.icon:focus-visible { outline: 1px solid var(--amber); outline-offset: 6px; }

.icons { display: flex; flex-wrap: wrap; gap: clamp(20px, 3vw, 34px); margin-top: 38px; }
.icon { display: grid; justify-items: start; gap: 9px; }
.spr {
  --p: clamp(3px, .38vw, 4px);
  display: grid;
  grid-template-columns: repeat(8, var(--p));
  grid-auto-rows: var(--p);
  gap: 1px;
}
.spr i { background: transparent; border-radius: 1px; }
.spr i.on { background: var(--amber); box-shadow: 0 0 7px rgba(240, 168, 60, .55); }
/* the icons carry the same phosphor lag as the eyes */
.icon .spr i { transition: box-shadow 240ms cubic-bezier(.1, .7, .2, 1), background-color 120ms linear; }
.icon:hover .spr i.on { background: var(--warm); box-shadow: 0 0 12px rgba(255, 107, 53, .9); }
.icon__l {
  font-family: var(--mono); font-size: 9px; letter-spacing: .16em;
  text-transform: uppercase; color: var(--fg-dim);
}

/* ================================================================ GLITCH ==
   Kept, and kept SMALL: a brief chromatic tear fired only alongside a roll
   rather than on its own timer. Barrel warp was considered and REJECTED —
   real curvature baselines near 0.04, and warping the page that much bends the
   text for nothing. Burn-in was also REJECTED: on a personal site a permanent
   ghost reads as a dirty screen rather than as history. */
.glitching .phosphor { animation: tear .2s steps(2) 2; }
@keyframes tear {
  0%   { transform: translateX(0); }
  50%  { transform: translateX(-3px); }
  100% { transform: translateX(0); }
}

/* ============================================================ 5 · READY === */

.wrap { max-width: 940px; margin: 0 auto; padding: 0 7vw; }

/* LEFT, EVERYWHERE. The centred variant is retired: the eyes, the boot text and
   the headline all hang off one edge, so the sequence reads as one surface
   instead of three compositions. */

/* ⚠️ THE COPY'S PLACE IN THE STACK IS STATED, NOT INHERITED. It was winning by
   paint order rather than by rule: `.world` is positioned at z-index 0 and
   `.stage` was static, and which of those paints on top depends on details of
   the paint-order spec that no one should have to hold in their head — and that
   moved the moment `.phosphor`'s children were given a filter, because a filter
   makes an element a stacking context. It happened to keep working. Saying it
   outright costs one line and removes a whole class of silent breakage: the
   copy is above the illustration, always, whatever else changes. */
.stage {
  min-height: 100svh; display: grid; align-content: center; padding-block: 10vh;
  position: relative; z-index: 2;
}

/* the copy is held back until the eyes have opened — the gap is the reveal */
.stage__copy > * {
  opacity: 0; transform: translateY(14px);
  transition: opacity .7s ease, transform .7s cubic-bezier(.2, .8, .25, 1);
}
.stage--full .stage__copy > * { opacity: 1; transform: none; }
.stage--full .stage__copy > *:nth-child(1) { transition-delay: .00s; }
.stage--full .stage__copy > *:nth-child(2) { transition-delay: .07s; }
.stage--full .stage__copy > *:nth-child(3) { transition-delay: .14s; }
.stage--full .stage__copy > *:nth-child(4) { transition-delay: .21s; }
.stage--full .stage__copy > *:nth-child(5) { transition-delay: .28s; }

/* the boot text dissolves rather than cutting */
.boot { transition: opacity .45s ease; }
/* --out fades it; the script then sets [hidden] so the eyes are genuinely
   alone on the screen rather than sitting on top of dimmed boot text */
.boot--out { opacity: 0; pointer-events: none; }
/* full-bleed blackout behind the measured boot column */
.boot::before { content: ""; position: fixed; inset: 0; z-index: -1; background: var(--void); }
.boot--out::before { opacity: 0; transition: opacity .55s ease; }
.stage[hidden] { display: none; }

.eyebrow {
  font-family: var(--mono); font-size: 11px; letter-spacing: .24em;
  text-transform: uppercase; color: var(--amber); margin: 0 0 20px;
  text-shadow: 0 0 12px rgba(240, 168, 60, .5);
}
.lede {
  font-family: var(--display); font-weight: 800; letter-spacing: -.035em;
  line-height: .98; text-transform: lowercase;
  font-size: clamp(30px, 5.4vw, 62px); margin: 0 0 24px; color: var(--fg);
  text-wrap: balance;
}
.lede em { font-style: normal; color: var(--warm); text-shadow: 0 0 30px rgba(255, 107, 53, .55); }
.say { margin: 0; max-width: 54ch; font-size: clamp(16px, 1.5vw, 18px); color: var(--fg-dim); }

.say strong { color: var(--fg); font-weight: 600; }

.links { display: flex; gap: 12px; flex-wrap: wrap; margin-top: 30px; }
.lk {
  font-family: var(--mono); font-size: 12px; letter-spacing: .12em;
  text-transform: uppercase; text-decoration: none;
  padding: 12px 18px; border-radius: 6px;
  border: 1px solid rgba(220, 230, 221, .18); color: var(--fg);
  background: rgba(220, 230, 221, .04);
  transition: border-color .2s, box-shadow .2s, color .2s;
}
.lk:hover { border-color: var(--cool); box-shadow: 0 0 22px rgba(47, 217, 197, .3); color: var(--fg); }
.lk--go {
  background: var(--amber); border-color: var(--amber); color: #14100A;
  box-shadow: 0 0 26px rgba(240, 168, 60, .45);
}
.lk--go:hover { background: var(--warm); border-color: var(--warm); color: #14100A; }

/* the chrome that stays on screen */
.hud {
  position: fixed; z-index: 45; left: 0; right: 0; bottom: 0;
  display: flex; align-items: center; gap: 16px;
  padding: 12px 4vw;
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .18em;
  text-transform: uppercase; color: var(--fg-dim);
  pointer-events: none;
}
.hud > * { pointer-events: auto; }
.hud__r { margin-left: auto; display: flex; align-items: center; gap: 14px; }
.hud b { color: var(--fg); font-weight: 400; }
.led {
  display: inline-block; width: 7px; height: 7px; border-radius: 50%;
  background: var(--warm); box-shadow: 0 0 10px rgba(255, 107, 53, .9);
  animation: beacon 2.6s ease-in-out infinite;
}
.btn-x {
  background: none; border: 1px solid rgba(220, 230, 221, .2); color: var(--fg-dim);
  font: inherit; letter-spacing: .16em; text-transform: uppercase;
  padding: 6px 11px; border-radius: 4px; cursor: pointer;
}
.btn-x:hover { color: var(--fg); border-color: var(--amber); }
.btn-x[aria-pressed="true"] { color: var(--amber); border-color: var(--amber); }

.skip {
  position: fixed; z-index: 52; right: 4vw; top: 20px;
  font-family: var(--mono); font-size: 10.5px; letter-spacing: .18em;
  text-transform: uppercase;
  background: none; border: 1px solid rgba(220, 230, 221, .22); color: var(--fg-dim);
  padding: 7px 12px; border-radius: 4px; cursor: pointer;
}
.skip:hover { color: var(--amber); border-color: var(--amber); }
.skip[hidden] { display: none; }

/* --------------------------------------------------------------- guards */

@media (prefers-reduced-motion: reduce) {
  /* the whole sequence is motion. Anyone who has asked for less gets the page. */
  .gate, .power, .boot { display: none !important; }
  .crt-glow, .gate__dot, .led, .cursor, .eye i, .roll, .phosphor, .spr i { animation: none !important; }
  .stage { display: grid !important; }
}

@media (max-width: 620px) {
  body { font-size: 16px; }
  .boot { padding: 10vh 7vw; }
  .hud { font-size: 9.5px; letter-spacing: .12em; gap: 10px; }
  .hud__r { gap: 9px; }
}

/* ------------------------------------------------ official platform marks ---
   GitHub's Invertocat and LinkedIn's [in], used as profile links. Both are
   permitted for exactly this — GitHub allows an octocat "on your website to
   link to your GitHub account", and LinkedIn's [in] logo page allows it "as a
   hyperlink to your LinkedIn profile" (its general policies page does not say
   so, which is why the specific page is the one that governs).

   Both licences turn on the mark being UNMODIFIED and in an approved colour.
   That is the reason these do not participate in the amber palette or in the
   hover colour change every other link gets: recolouring a trademark is
   modifying it. The mark is pinned to --fg through every state; the LABEL next
   to it is ordinary text and behaves like the rest of the row. */
.lk--brand { display: inline-flex; align-items: center; gap: 9px; }
.brandmark {
  width: 13px; height: 13px; flex: none;
  fill: var(--fg);            /* NOT currentColor — hover must not tint it */
  opacity: .92;
}
.lk--brand:hover .brandmark,
.lk--brand:focus-visible .brandmark { fill: var(--fg); opacity: 1; }

/* Icon-only social buttons. The label is gone from the page but NOT from the
   accessibility tree — each carries an aria-label, because a link whose entire
   content is a decorative SVG announces as "link" and nothing else.

   Square rather than pill: with no text the horizontal padding that suits a
   word makes an icon look lost in a room. The mark grows a little to carry the
   button on its own. */
/* ⚠️ NO BOX ON THE BRAND MARKS. Juan's read, and it settles an inconsistency
   this row already had: the three text buttons are LABELS — a border draws the
   hit area around a word, which a word does not otherwise have. A logo is
   already a shape, so a box around it draws a second, competing one, and the
   two brand marks ended up looking like the only controls wearing a frame.

   ⚠️ The touch target does NOT shrink with the border. The box is what carried
   the 42px hit area, so removing it naively would leave a 16px tap target — the
   accessibility regression hiding inside a purely visual note. The element keeps
   its size and loses only its paint. */
.lk--icon {
  padding: 0;
  width: 42px; height: 42px;
  justify-content: center; gap: 0;
  border-color: transparent;
  background: none;
  box-shadow: none;
}
.lk--icon:hover, .lk--icon:focus-visible {
  border-color: transparent;
  background: none;
  /* the mark itself answers, since there is no frame left to light up */
  box-shadow: none;
}
.lk--icon .brandmark {
  width: 19px; height: 19px;
  transition: filter .2s, opacity .2s;
}
.lk--icon:hover .brandmark,
.lk--icon:focus-visible .brandmark {
  filter: drop-shadow(0 0 10px rgba(47, 217, 197, .55));
}
/* focus must stay visible with the border gone — it is the one state that
   cannot rely on a hover-only cue */
.lk--icon:focus-visible {
  outline: 2px solid var(--cool);
  outline-offset: 3px;
}

@media (max-width: 560px) {
  .lk--icon { width: 46px; height: 46px; }   /* comfortably past the 44px touch target */
}

/* ====================================================== THE EYES MORPH ======
   Juan's idea: the machine's face becomes the icon of the page you are opening,
   and lands at the top of it.

   ONE 8x8 GRID, always. Morphing two 3x3 eye clusters into an 8x8 sprite has no
   honest cell-to-cell mapping, so nothing is deformed: the eyes are DRAWN on the
   8x8 grid as two clusters, and the morph is the grid flying to the destination
   while its lit cells cross-fade to the sprite's. A pixel switches; it never
   stretches — the same rule the blink follows, and the reason the blink is a row
   sweep rather than a squash.

   It sits OUTSIDE .phosphor, like the audio graph and the stars, because the
   router swaps .phosphor's contents and anything that must survive a navigation
   cannot live inside it. */
.morph {
  position: fixed; z-index: 46;
  display: grid;
  grid-template-columns: repeat(8, 1fr);
  grid-template-rows: repeat(8, 1fr);
  gap: 8%;
  opacity: 0; pointer-events: none;
  /* not display:none — the flight needs a laid-out box to measure and move */
  visibility: hidden;
}
.morph.is-on { opacity: 1; visibility: visible; }
.morph.is-done { opacity: 0; transition: opacity .25s ease; }

/* the flight itself. Position and size are what animate; the cells only switch
   on and off, which is why this stays on the compositor. */
.morph.is-flying {
  transition: left .72s cubic-bezier(.65, 0, .2, 1),
              top .72s cubic-bezier(.65, 0, .2, 1),
              width .72s cubic-bezier(.65, 0, .2, 1),
              height .72s cubic-bezier(.65, 0, .2, 1);
}

.morph i {
  border-radius: 1px;
  background: transparent;
  transition: background .28s ease, box-shadow .28s ease;
}
.morph i.on {
  background: var(--amber);
  box-shadow: 0 0 10px rgba(240, 168, 60, .75);
}

/* the mark the eyes land on, at the top of /code and /film */
.page-mark {
  /* ⚠️ width: max-content is load-bearing. A block-level grid FILLS its
     container, so this box was 412px wide with its 48px of columns jammed
     against the left edge — and because the box's centre still matched the
     viewport's, every centring check passed while the pixels sat off to one
     side. Worse, the morph measures this element to know where to fly, so it
     was flying to a 412px target instead of a 48px one. */
  display: grid;
  width: max-content;
  grid-template-columns: repeat(8, var(--pp));
  grid-auto-rows: var(--pp);
  gap: calc(var(--pp) * .2);
  --pp: clamp(4px, 0.7vw, 6px);
  margin-inline: auto;
  margin-block: 0 clamp(14px, 2.2vh, 22px);

  /* HIDDEN BY DEFAULT. The morph reveals it on landing; morph.js reveals it
     immediately when no flight is coming. This used to be hidden from JS inside
     the morph, which is inherently one frame too late — os.js paints the sprite
     and inserts it BEFORE dispatching the event the morph listens on, so the
     browser could paint the icon before anything hid it. That was the flash. */
  opacity: 0;
  transition: opacity .3s ease;
}
.page-mark.is-shown { opacity: 1; }
.page-mark i { background: transparent; border-radius: 1px; }
.page-mark i.on {
  background: var(--amber);
  box-shadow: 0 0 7px rgba(240, 168, 60, .6);
}

/* a face flying across the screen is exactly what this setting is asking us not
   to do — the mark is simply already there */
@media (prefers-reduced-motion: reduce) {
  .morph { display: none; }
}

/* Visually hidden, still announced. The standard clip pattern: the element has
   real dimensions and stays in the accessibility tree and in the DOM for search,
   it just is not painted. NOT display:none and NOT visibility:hidden — both
   remove it from the accessibility tree, which is exactly what this is trying to
   avoid. */
.vh {
  position: absolute !important;
  width: 1px; height: 1px;
  margin: -1px; padding: 0; border: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  clip-path: inset(50%);
  white-space: nowrap;
}

/* ---------------------------------------------- icon-only controls ---------
   Juan's rule, and it is a good one: a control that can be an icon should be an
   icon. "Sound off" is three words for the thing every interface on earth draws
   as a speaker, and on a HUD that is otherwise pixels and mono it was the one
   element shouting in sentences.

   ⚠️ The words did not disappear, they moved. The button carries an aria-label
   AND a title, so it still announces "Sound on — turn off" to a screen reader
   and still shows a tooltip on hover. An icon-only control that announces as
   "button" is the one way this change could have cost something real. */
/* NO BOX. .btn-x draws a bordered pill, which is right for a worded control and
   wrong for a glyph — a 30px box around an 8x8 speaker is more chrome than
   icon, and it was the thing that made the HUD look boxy. The glyph carries its
   own state through colour, the way every other pixel on this site does. */
.btn-icon {
  display: inline-flex; align-items: center; justify-content: center;
  width: 26px; height: 26px; padding: 0;
  border: none !important;
  background: none !important;
  color: var(--fg-dim);
}
.btn-icon:hover { color: var(--fg); }
.btn-icon[aria-pressed="true"] { color: var(--amber); }
.spr--snd {
  display: grid;
  grid-template-columns: repeat(8, 2px);
  grid-auto-rows: 2px;
  gap: 0;
}
.spr--snd i { background: transparent; }
.spr--snd i.on {
  background: currentColor;
  box-shadow: 0 0 5px rgba(240, 168, 60, .5);
}
