/* ============================================================
   shutter kif. — black & white, gritty, minimal
   Composed sections sit on a 16:9 stage carrying the exact PSD
   coordinates in percent.
   ============================================================ */

@font-face{font-family:'Helv';src:url('../assets/fonts/Helvetica.woff') format('woff'),url('../assets/fonts/Helvetica.ttf') format('truetype');font-weight:400;font-style:normal;font-display:swap}
@font-face{font-family:'Helv';src:url('../assets/fonts/Helvetica-Oblique.woff') format('woff'),url('../assets/fonts/Helvetica-Oblique.ttf') format('truetype');font-weight:400;font-style:italic;font-display:swap}
@font-face{font-family:'Helv';src:url('../assets/fonts/Helvetica-Bold.woff') format('woff'),url('../assets/fonts/Helvetica-Bold.ttf') format('truetype');font-weight:700;font-style:normal;font-display:swap}
@font-face{font-family:'VCR';src:url('../assets/fonts/VCR.woff') format('woff'),url('../assets/fonts/VCR.ttf') format('truetype');font-weight:400;font-display:swap}

:root{
  --light:#ffffff;
  --dark:#000000;
  --on-light:#000000;
  --on-dark:#ffffff;

  /* paper = the tone actually painted behind the cut-outs.
     --el-inv / --el-blend are derived from it, never from a section name. */
  --paper:var(--dark);
  --el-inv:1;
  --el-blend:screen;

  --f:'Helv','Helvetica Neue',Helvetica,Arial,sans-serif;
  --f-vcr:'VCR','Helv',monospace;
  --ease:cubic-bezier(.16,1,.3,1);
  --gutter:clamp(1rem,3.5vw,3rem);
  --fade-t:7svh;        /* seam bleed, per side — 0 where there is no seam */
  --fade-b:7svh;
}

/* the angle is its own animatable property so the parallax translate on the
   same element stays live instead of being frozen into the keyframes */
@property --spin{syntax:'<angle>';inherits:false;initial-value:0deg}

/* invert — every surface and every cut-out flips together */
body.is-invert{
  --light:#000000;
  --dark:#ffffff;
  --on-light:#ffffff;
  --on-dark:#000000;
}

*,*::before,*::after{box-sizing:border-box}
html{scroll-behavior:smooth;-webkit-text-size-adjust:100%}
body{
  margin:0;background:var(--dark);color:var(--on-dark);
  font-family:var(--f);line-height:1.5;
  overflow-x:hidden;-webkit-font-smoothing:antialiased;
}
body.is-booting{overflow:hidden}
img{max-width:100%;display:block}
a{color:inherit;text-decoration:none}
::selection{background:currentColor;color:var(--light)}
:focus-visible{outline:2px solid currentColor;outline-offset:4px}

/* A single fixed backdrop behind everything has its tone interpolated on
   scroll, so where two sections differ the tones bleed instead of meeting at a
   hard seam. Each stage paints its own opaque paper over it and fades that
   paper out at the viewport edges, which is where the bleed shows through. */
.backdrop{position:fixed;inset:0;z-index:-1;background:var(--dark)}
.sec{position:relative;overflow:hidden}

/* ---- paper, and everything derived from it -------------------------------
   The cut-outs are dark artwork printed on an opaque white rectangle (star-a,
   star-b, dice, cards) or dark artwork on transparency (cd-a, cd-b, ic-mail).
   Either way the rule is the same: the artwork must end up LIGHTER than the
   paper and screen onto it, or DARKER than the paper and multiply into it.
   Anything else leaves the source rectangle sitting on top of the type — that
   was the black-box bug on the first and last pages.                        */
.sec--light{--paper:var(--light);color:var(--on-light);--el-blend:multiply;--el-inv:0}
.sec--dark {--paper:var(--dark); color:var(--on-dark); --el-blend:screen;  --el-inv:1}
body.is-invert .sec--light{--el-blend:screen;  --el-inv:1}
body.is-invert .sec--dark {--el-blend:multiply;--el-inv:0}

/* Tracking-overlay ink. The overlay composites with `difference` (see .trk),
   so one neutral grey reads the same faint grey over black paper, over white
   paper and over the cut-outs themselves — no per-paper theming, and it can
   never disappear against the white artwork the way a plain white hairline did.
   Same trick the controls use. */
:root{--trk:rgba(146,146,146,.42);--trk-hi:rgba(190,190,190,.52)}

.el,.boot__img,.lay--icons img{filter:invert(var(--el-inv));user-select:none;-webkit-user-drag:none}
/* max-width:none is load-bearing. The base `img{max-width:100%}` clamps any
   cut-out authored wider than its stage — and when only ONE axis is clamped
   while the other is an explicit length, the aspect ratio breaks and the
   artwork renders stretched. These are art-directed cut-outs that are meant to
   bleed past the frame; let them. */
.el{position:absolute;pointer-events:none;z-index:1;max-width:none;
    mix-blend-mode:var(--el-blend);transform:translateY(var(--ty,0))}

/* ============ LOADER ============ */
.boot{
  position:fixed;inset:0;z-index:200;background:var(--light);color:var(--on-light);
  --el-inv:0;
  display:flex;flex-direction:column;align-items:center;justify-content:center;gap:1.6rem;
  transition:opacity .5s var(--ease),visibility .5s;
}
body.is-invert .boot{--el-inv:1}
.boot.is-done{opacity:0;visibility:hidden}
/* `pixelated` is load-bearing, not a look: the ident is rasterised at 96px
   wide and scaled up here, so without it the browser would smooth the word
   back into an ordinary anti-aliased headline. */
.boot__img{width:min(230px,44vw);height:auto;image-rendering:pixelated}
.boot__bar{width:min(230px,44vw);height:2px;background:currentColor;opacity:.18;position:relative;overflow:hidden}
.boot__bar span{position:absolute;inset:0 100% 0 0;background:var(--on-light);transition:right .16s linear}
.boot__pct{font-family:var(--f-vcr);font-size:.95rem;letter-spacing:.14em;color:var(--on-light);opacity:.55}

/* ============ FIXED CONTROLS ============ */
.ctl{
  position:fixed;right:var(--gutter);bottom:calc(var(--gutter) - .3rem);z-index:95;
  display:flex;flex-direction:column;align-items:flex-end;gap:.25rem;
  mix-blend-mode:difference;
  opacity:0;transition:opacity .8s var(--ease) .2s;
}
.is-lit .ctl{opacity:1}
.ctl__b{
  background:none;border:0;padding:.18rem 0;cursor:pointer;
  font-family:var(--f);font-size:clamp(.72rem,1vw,.86rem);color:#fff;
  line-height:1.35;position:relative;
}
.ctl__b::after{content:'';position:absolute;left:0;right:100%;bottom:.1rem;height:1px;background:currentColor;transition:right .35s var(--ease)}
.ctl__b:hover::after,.ctl__b[aria-pressed="true"]::after{right:0}

/* ---- the invert switch --------------------------------------------------
   Structure and mechanism from Uiverse.io by elijahgummer (MIT) — the credit
   is kept because that licence requires it.

   Re-skinned, because the original is a moulded grey object with a red/green
   LED and stacked drop shadows, and this page has neither colour nor shadow.
   Two things had to change:

   · `.ctl` composites with `difference`, so colour here is not decorative, it
     is arithmetic. Black is the identity (invisible), white is a full flip.
     Everything below is therefore authored in greys against that, which is
     what lets one switch read over black paper, over white paper and over the
     cut-outs — the same trick the counter and the tracking overlay use.
   · The LED is monochrome. The site spends its single colour exception on the
     Instagram mark; a second one would stop it being an exception. Off is a
     dim ring, on is a lit dot, which is also how the open control already
     signals itself. */
.switch{
  position:relative;width:74px;height:24px;box-sizing:border-box;padding:2px;
  border:1px solid currentColor;border-radius:2px;opacity:.55;
  transition:opacity .35s var(--ease);
}
.switch:hover,.switch:focus-within{opacity:1}
.switch input[type="checkbox"]{
  position:absolute;inset:0;z-index:1;width:100%;height:100%;
  opacity:0;cursor:pointer;margin:0;
}
.switch input[type="checkbox"]+label{
  position:relative;display:block;left:0;width:50%;height:100%;
  background:currentColor;border-radius:1px;
  transition:left .45s var(--ease);
}
.switch input[type="checkbox"]:checked+label{left:50%}
/* the LED, knocked out of the block rather than glowing on top of it */
.switch input[type="checkbox"]+label::before{
  content:'';position:absolute;left:6px;top:50%;
  width:5px;height:5px;margin-top:-2.5px;border-radius:50%;
  background:transparent;box-shadow:inset 0 0 0 1px rgba(0,0,0,.85);
  transition:background .35s var(--ease);
}
.switch input[type="checkbox"]:checked+label::before{background:rgba(0,0,0,.85)}
/* grip ridges — dark grooves cut into the block */
.switch input[type="checkbox"]+label i{
  display:block;position:absolute;top:50%;left:62%;
  width:2px;height:10px;margin-top:-5px;
  background:rgba(0,0,0,.55);
}
.switch input[type="checkbox"]+label i::before,
.switch input[type="checkbox"]+label i::after{
  content:'';position:absolute;width:100%;height:100%;background:inherit;
}
.switch input[type="checkbox"]+label i::before{left:-5px}
.switch input[type="checkbox"]+label i::after{left:5px}

/* ============ COMP STAGE ============
   COVER, not contain: the comps are full-bleed, so on a window wider than 16:9
   the stage grows past the viewport and the section crops it. Contain left bare
   backdrop down both sides — that was the "zoomed out" bug.
   The stage also carries the paper the cut-outs blend against, because
   container-type creates a stacking context and isolates the blend from
   anything painted outside it. The mask is anchored to the VIEWPORT, not to the
   stage box, so the seam fade stays on screen no matter how much of the stage
   is cropped away — --vtop/--vbot are where the viewport edges land inside the
   stage, and they change with how the stage is aligned in its section. */
/* Absolutely placed, not grid-aligned: once the stage overflows its section,
   Chrome falls back to `start` for align-self and the crop anchor silently
   moves to the top — which is what cropped the contact marks off the bottom. */
.stage{
  position:absolute;inset:0;margin:auto;   /* centred WITHOUT transform — the
     data-rise reveal owns `transform` on this element and would cancel it */
  width:max(100vw, calc(100svh * 16 / 9));
  height:max(100svh, calc(100vw * 9 / 16));
  container-type:size;
  background:var(--paper);
  --vtop:calc(50% - 50svh);
  --vbot:calc(50% + 50svh);
  -webkit-mask-image:linear-gradient(to bottom,
      transparent var(--vtop),
      #000 calc(var(--vtop) + var(--fade-t)),
      #000 calc(var(--vbot) - var(--fade-b)),
      transparent var(--vbot));
          mask-image:linear-gradient(to bottom,
      transparent var(--vtop),
      #000 calc(var(--vtop) + var(--fade-t)),
      #000 calc(var(--vbot) - var(--fade-b)),
      transparent var(--vbot));
}
/* First and last pages have no seam above / below them, so they get the full
   frame instead of fading into nothing. */
.hero .stage{--fade-t:0}
/* The LAST page gets the full frame below it, whichever page that currently
   is — contact used to be last and carried this. Keyed off the section order
   rather than a section name so appending another page can never reintroduce
   the hard seam this fade exists to remove. */
.sec:last-of-type .stage{--fade-b:0}
.lay{position:absolute;margin:0;z-index:5;line-height:1;letter-spacing:-.02em;font-weight:400;transform:translateY(var(--ty,0))}

/* tracking overlay — above the cut-outs, under the type, never interactive */
.trk{
  position:absolute;inset:0;width:100%;height:100%;z-index:3;
  pointer-events:none;mix-blend-mode:difference;
  opacity:0;transition:opacity 1.6s var(--ease) .25s;
}
.trk.is-on{opacity:1}

/* ---- 01 front ---- */
.hero{height:100svh;display:grid;place-items:center}
.el--star-a{left:66.30%;top:-12.41%;width:31.46%}
.el--star-b{left:-10.78%;top:12.13%;width:60.78%}
/* the stars turn, very slowly and against each other — one revolution is over
   two minutes, so it reads as drift rather than as an animation */
.el--star-a,.el--star-b{transform:translateY(var(--ty,0)) rotate(var(--spin))}
.el--star-a{animation:spin-cw 128s linear infinite}
.el--star-b{animation:spin-ccw 173s linear infinite}
@keyframes spin-cw {to{--spin:360deg}}
@keyframes spin-ccw{to{--spin:-360deg}}
/* The die is a live canvas, so unlike the PNG cut-outs it has no intrinsic
   aspect to hold its height up — it gets one explicitly. It is square, and it
   keeps the cut-out's blending, inversion and drift untouched. It is also the
   only cut-out that accepts a pointer: everything else stays inert. */
/* Half size, re-centred on the same point so the composition does not shift.
   `top` is NOT simply halved: the element is square, but the stage is 16:9, so
   its height as a percentage of the stage is width × 16/9 — 31.20%, not 17.55.
   Centre stays at 50.00% / 50.46%. */
.el--dice{left:41.23%;top:34.86%;width:17.55%;aspect-ratio:1;
          pointer-events:auto;cursor:pointer}
/* The scene is rendered as the die actually looks — bright glass on black —
   because glass cannot be lit "in negative": refraction and specular have no
   meaningful inverse. This one flip turns that render into the dark-artwork-
   on-white the paper system feeds on, and the wrapper's invert(--el-inv) then
   does the usual job in both polarities. */
#diceCanvas{width:100%;height:100%;display:block;filter:invert(1)}
/* Positioned off the GAP to the die, not off the stage centre. The two words
   are different widths (chow is ~9% of the stage, .dev ~7.2%), so centring the
   pair as a group leaves visibly uneven air either side of the die. Equal gaps
   read as balanced even though the block is not symmetric. Both sit 1.8% clear
   of the die's box, which spans 41.23%–58.78%. */
.lay--shutter{left:30.41%;top:43.0%;font-size:4cqw;font-style:italic}
.lay--kif    {left:60.58%;top:43.0%;font-size:4cqw;font-style:italic}

.cue{position:absolute;bottom:1.5rem;left:50%;transform:translateX(-50%);z-index:6;padding:.6rem}
.cue i{display:block;width:1px;height:34px;background:currentColor;opacity:.3;position:relative;overflow:hidden}
.cue i::after{content:'';position:absolute;inset:0;background:currentColor;animation:sd 2s cubic-bezier(.65,0,.35,1) infinite}
@keyframes sd{0%{transform:translateY(-100%)}60%,100%{transform:translateY(100%)}}

/* ---- 02 work ---- */
.machine{height:100svh;display:flex;flex-direction:column;position:relative}
/* The flower sits in its own stage BEHIND the WebGL canvas, which is now
   genuinely transparent. Being an ordinary cut-out is the whole point: it keys
   off the paper like every other one — dark on white paper, light on black,
   never a white box on black — and it takes the rotation and the tracker for
   free. It replaced a leopard photograph that could do none of that. */
.machine .stage{z-index:1}
/* Sized and centred in container units, never with a centring transform or the
   independent `translate` property — the tracker reads offsetLeft/offsetTop
   plus the computed `transform`, and either of those would desync the contour
   from the artwork. Source is 1248x1152, so height = width / 1.0833. */
/* Sized against the VIEWPORT, not the stage: the stage overflows on a cover
   crop, so a stage-relative size would bleed the petals off screen. Source is
   1248x1152, so width = height * 1.0833 and half of that is 0.5417.
   Never centred with a transform or the independent `translate` property — the
   tracker reads offsetLeft/offsetTop plus the computed `transform`, and either
   would desync the contour from the artwork. */
.el--flower{
  height:104svh;width:auto;
  left:calc(50cqw - 56.3svh);
  top:calc(50cqh - 52svh);
  transform:translateY(var(--ty,0)) rotate(var(--spin));
  animation:spin-cw 214s linear infinite;
}
.machine__stage{position:relative;flex:1;min-height:0;z-index:2}
#pspCanvas{position:absolute;inset:0;width:100%;height:100%;display:block;touch-action:pan-y}
/* deliberately NOT inverted: the bloom and the screen are emitted light,
   and inverting turns the highlights into black smears */
.machine__loading{position:absolute;inset:auto 0 50% 0;text-align:center;z-index:3;transition:opacity .5s}
.machine__loading.is-off{opacity:0;pointer-events:none}
.spin{display:inline-block;width:11px;height:11px;border:1px solid currentColor;border-top-color:transparent;border-radius:50%;opacity:.5;animation:spin .9s linear infinite}
@keyframes spin{to{transform:rotate(360deg)}}

.rail{
  position:absolute;left:var(--gutter);bottom:clamp(1.6rem,5vh,3.2rem);z-index:6;
  display:flex;align-items:baseline;gap:.45rem;font-family:var(--f-vcr);color:var(--on-dark);
}
.rail b{font-size:clamp(1.8rem,4vw,3rem);font-weight:400;line-height:1;font-variant-numeric:tabular-nums}
.rail i{font-style:normal;font-size:.9rem;opacity:.45}
.rail__open{
  margin-left:.7rem;align-self:center;width:36px;height:36px;display:grid;place-items:center;
  border:1px solid currentColor;font-size:1rem;line-height:1;
  transition:background .25s,color .25s,transform .4s var(--ease);
}
.rail__open:hover{background:var(--on-dark);color:var(--dark);transform:translate(2px,-2px)}
/* `display:grid` above outranks the UA's [hidden] rule, so a withdrawn open
   control needs saying twice — see the link check in psp.js select() */
.rail__open[hidden]{display:none}
.rail.is-swap b{animation:flick .3s steps(2) both}
@keyframes flick{0%{opacity:0}50%,100%{opacity:1}}

.keys{position:absolute;right:var(--gutter);bottom:clamp(1.6rem,5vh,3.2rem);z-index:6;display:flex;gap:.3rem;color:var(--on-dark)}
kbd{font-family:var(--f-vcr);font-size:.8rem;border:1px solid currentColor;opacity:.45;padding:.1rem .35rem;min-width:24px;text-align:center}

/* ---- 03 about ---- */
.about{height:100svh;display:grid;place-items:center}
.el--cd-a  {left:-5.99%;top:-23.61%;width:41.35%}
.el--cd-b  {left:82.08%;top:45.83%;width:29.90%}
.lay--abouth{left:50%;top:26%;transform:translate(-50%,var(--ty,0));font-family:var(--f-vcr);font-size:3.3cqw;letter-spacing:.06em}
/* The copy grew when it was rewritten in the owner's own voice; the column
   keeps its width and its justified shape (that narrow block IS the layout),
   so the type size absorbs the extra lines instead. Starts a little higher to
   buy back headroom under the heading. `.sec` clips at overflow:hidden, so
   anything that does not fit here is silently cut, not scrolled. */
.lay--bio{
  left:50%;top:36%;width:44%;margin-left:-22%;
  /* Sized against BOTH axes. In cqw alone the type grows with the stage's
     width while the height available to it does not, so a wide-and-short
     window (a laptop, a half-height browser) silently clipped the last lines.
     Taking the smaller of the two makes the copy shrink when the room does. */
  font-size:min(1.34cqw, 2.28cqh);line-height:1.34;letter-spacing:0;
  text-align:justify;text-justify:inter-word;hyphens:auto;
}
.lay--bio p{margin:0}
.lay--bio p+p{margin-top:.15em}
/* The one line that gets to shout. It switches to VCR — the face the machine
   speaks in everywhere else on this site — rather than a weight or a colour
   invented for it, so the emphasis belongs to the same system as the counter,
   the key hints and the boot percentage. VCR sets large for its point size, so
   it is stepped down to sit on the same baseline as the prose around it. */
.bio__hit{
  font-family:var(--f-vcr);font-weight:400;font-style:normal;
  font-size:.82em;letter-spacing:.06em;
  padding:0 .18em;
  box-shadow:inset 0 -0.34em 0 rgba(128,128,128,.28);
}

/* ---- 04 contact ---- */
/* Bottom-aligned, not centred: the comp puts the mail and Instagram marks at
   89.7% of a 1080-tall canvas, so centring the cover crop ate them. Cropping
   off the top instead costs nothing — the cards already start above the frame. */
.contact{height:100svh;display:grid;place-items:center}
.contact .stage{
  top:auto;bottom:0;margin-block:0;
  --vtop:calc(100% - 100svh);--vbot:100%;
}
.el--cards{left:26.67%;top:-3.52%;width:46.67%}
.lay--contact{left:23.54%;top:40.0%;font-size:3.78cqw}
.lay--me     {left:64.27%;top:40.0%;font-size:3.78cqw}
/* The comp placed two marks at a fixed 45.94%, which happened to centre that
   pair on the stage. Four marks would have grown the row off to the right, so
   the row is now centred about the same axis instead of pinned to an offset
   that only held for one particular count — add a fifth and nothing moves.
   Centred with translate(), NOT the independent `translate` property: `.lay`
   carries the parallax drift on `transform`, and a second declaration would
   cancel it. Same shape as .lay--abouth above. */
.lay--icons{left:50%;top:89.7%;transform:translate(-50%,var(--ty,0));
            display:flex;align-items:center;gap:1.1%}
.lay--icons img{width:100%;transition:transform .4s var(--ease)}
/* sized per mark, not per position — the envelope is the only wide glyph */
.ic--mail{width:3.70cqw}
.ic--ig,.ic--gh,.ic--li{width:3.02cqw}
.lay--icons a:hover img{transform:translateY(-4px)}
/* The IG mark is the one brand-coloured glyph and keeps its colour in both
   polarities. It has to out-specify `.lay--icons img` above (0,1,1) or the
   blanket invert wins and the gradient comes back cyan — which is what a bare
   `.icons__ig` (0,1,0) did. Qualifying it with the row and the element takes
   it to 0,2,1, and being unconditional it covers is-invert too. */
.lay--icons img.icons__ig{filter:none}

/* ---- 05 builds ---- */
.builds{height:100svh;display:grid;place-items:center}
/* The one stage that paints no paper. The funnel composites with `difference`
   like the tracking overlay, so it has to blend against the scrolling backdrop
   tone itself — an opaque sheet of paper in front of it would leave nothing to
   differ from. The type keeps its cqw coordinate system by staying inside the
   stage, and the stage stays in front of the canvas. */
.builds .stage{background:transparent;z-index:1}
#warpCanvas{
  position:absolute;inset:0;width:100%;height:100%;
  z-index:0;pointer-events:none;mix-blend-mode:difference;
}
.lay--buildsh{left:50%;top:13%;transform:translate(-50%,var(--ty,0));
              font-family:var(--f-vcr);font-size:3.3cqw;letter-spacing:.06em}
/* The heading is replaced by a dot matrix at runtime (js/scatter.js). Height
   is set in em so it still scales off the font-size above and the layout is
   unchanged whether the script runs or not. overflow:visible matters: dots
   throw themselves well outside the type's own box, and an SVG clips to its
   viewBox by default — without this the scatter would be sliced off. */
.lay--buildsh svg{display:block;height:1.05em;width:auto;overflow:visible}
.lay--builds{left:50%;top:25%;width:58%;margin-left:-29%;
             list-style:none;padding:0;font-size:1.22cqw}
.lay--builds li{display:flex;align-items:flex-start;gap:1.2em}
.lay--builds li+li{margin-top:1.05em}
.lay--builds .build{
  display:flex;align-items:baseline;gap:.9em;position:relative;padding:.28em 0;flex:1 1 auto;
}
/* claim on top, stack underneath — the row carries what the thing actually
   does and what it was built with, rather than four words and an arrow */
.lay--builds .build__t{display:block;flex:1 1 auto}
.lay--builds u{display:block;margin-top:.35em;text-decoration:none;
  font-family:var(--f-vcr);font-size:.78em;letter-spacing:.04em;opacity:.34}
/* the same hairline that sweeps under the fixed controls — one link idiom for
   the whole site rather than a second one invented for this page */
.lay--builds a.build::after{content:'';position:absolute;left:0;right:100%;bottom:0;
  height:1px;background:currentColor;opacity:.55;transition:right .45s var(--ease)}
.lay--builds a.build:hover::after{right:0}
.lay--builds b{font-family:var(--f-vcr);font-weight:400;letter-spacing:.05em;flex:0 0 22%;line-height:1.3}
.lay--builds i{display:block;font-style:italic;opacity:.62;line-height:1.35}

/* ---- star control -------------------------------------------------------
   Function borrowed from the Uiverse.io "Star on GitHub" button by
   elijahgummer (MIT). Rebuilt rather than pasted: the original is Tailwind
   utility classes and an animated five-stop rainbow gradient. There is no
   Tailwind on this site to resolve them, and the rainbow would be the only
   colour on the page besides the one the Instagram mark is allowed.
   So: the same control — mark, star, live count — in the page's own ink.
   The count is only shown once there is one; a row of zeroes reads worse
   than an invitation does. */
.lay--builds .star{
  display:inline-flex;align-items:center;gap:.4em;flex:0 0 auto;
  font-family:var(--f-vcr);font-style:normal;font-size:.78em;
  padding:.2em .5em;border:1px solid currentColor;
  opacity:.38;transition:opacity .3s var(--ease),transform .3s var(--ease);
}
.lay--builds a.star:hover{opacity:1;transform:translate(1px,-1px)}
.lay--builds .star svg{width:1em;height:1em;fill:currentColor;display:block}
.lay--builds .star em{font-style:normal;letter-spacing:.05em}
.lay--builds .star--none{border-style:dashed;opacity:.28}

/* ============ MOTION ============
   Nothing here is a page transition — each piece drifts at its own rate as you
   scroll, so a section reads as a moving composition rather than a flat still. */
.el,.lay{will-change:transform}
.rev{opacity:0;filter:blur(6px)}
.rev.is-shown{opacity:1;filter:none;visibility:visible;
  transition:opacity 1.1s var(--ease),filter 1.1s var(--ease)}
/* cut-outs keep their invert while revealing — a bare `filter:none` here would
   drop the polarity and bring the black boxes straight back */
.el.rev{filter:invert(var(--el-inv)) blur(9px)}
.el.rev.is-shown{filter:invert(var(--el-inv));
  transition:opacity 1.1s var(--ease),filter 1.1s var(--ease)}
.rev.is-shown.d1{transition-delay:.08s}
.rev.is-shown.d2{transition-delay:.16s}

/* ============ REVEAL ============ */
[data-rise]{opacity:0;transform:translateY(18px);filter:blur(6px)}
.is-lit [data-rise]{opacity:1;transform:none;filter:none;transition:opacity .9s var(--ease) .1s,transform 1s var(--ease) .1s,filter .9s var(--ease) .1s}

/* ============ NARROW SCREENS ============
   The 16:9 stage would shrink the type past legibility, so let it fill the
   width and grow taller instead. The section grows with it, and the
   viewport-anchored mask no longer makes sense, so it comes off. */
@media (max-width:820px){
  .keys{display:none}

  /* ---- ONE coordinate system, same as desktop, only the aspect changes ----
     The desktop composition is rigid because there is a stage of definite
     size, every element is placed in percent OF THAT STAGE, and every type
     size is in `cqw` — so the whole thing scales as a single piece.

     The old mobile block threw all of that away. It made the stage
     variable-height and `container-type:normal`, then placed elements in
     percent against that moving box while sizing type in `vw` against the
     *viewport*. Two coordinate systems that disagree the moment a phone's
     aspect ratio differs from whatever it was eyeballed at — which is exactly
     what "everything is all over the place" looks like.

     So: keep the machinery, drop the second system. The stage is the viewport,
     with a definite size so container units resolve, and every position and
     type size below is authored against it.

     It also stays ABSOLUTE. Making it a flow item turned it into a flex item
     inside .machine, where it claimed the full 100svh, collapsed
     .machine__stage to zero height, and the PSP never rendered at all —
     resize() bails on a zero-size canvas. */
  .hero,.about,.contact,.builds{height:100svh;min-height:0}
  .stage{
    position:absolute;inset:0;margin:0;
    width:100%;height:100%;
    container-type:size;
    -webkit-mask-image:none;mask-image:none;
  }
  /* No cut-out may inherit a height from the desktop rules. This is what
     stretched the flower: the desktop rule sets `height:104svh; width:auto`
     and the mobile rule only overrode the width, so it rendered at the phone's
     width by the desktop's height — squashed into a completely wrong aspect. */
  .el{height:auto}

  /* Every value below is percent of the stage, or cqw — one system.
     `cqw` and not `vw`: if the stage ever stops being exactly the viewport,
     the type still scales with the composition instead of drifting out of it. */

  /* 01 front — one word, "chow.dev", sitting under the dice.
     The two halves are separate elements and always will be (the copy is
     frozen), so they are butted against the stage's centre line instead:
     `shutter` ends exactly at 50%, `kif.` starts exactly at 50%. They meet
     perfectly as one centred word without anything needing to know how wide
     the text renders — no font metrics, no magic numbers, no drift when the
     webfont swaps in. */
  .el--star-a{left:44%;top:2%;width:64%;height:auto}
  .el--star-b{left:-24%;top:66%;width:98%;height:auto}
  /* the dice is sized by HEIGHT so the band it occupies is the same fraction
     of the screen on every phone — width-sized art moves vertically as the
     aspect changes, which is what put the type on top of it */
  .el--dice{width:auto;height:16cqh;left:calc(50cqw - 8cqh);top:28cqh}
  .lay--shutter,.lay--kif{top:57cqh;font-size:11cqw;white-space:nowrap}
  .lay--shutter{left:auto;right:50%;text-align:right}
  .lay--kif    {left:50%;right:auto;text-align:left}

  /* 03 about */
  .lay--abouth{left:50%;top:14%;transform:translate(-50%,var(--ty,0));font-size:7.5cqw}
  .lay--bio{left:7%;top:24%;width:86%;margin-left:0;font-size:4.4cqw;text-align:left}
  .el--cd-a{left:-18%;top:-2%;width:62%;height:auto}
  .el--cd-b{left:58%;top:74%;width:54%;height:auto}

  /* 04 contact — `contact` above the card, `me.` below it, both centred.
     Card sized by HEIGHT for the same reason as the dice: it then occupies a
     fixed 26%–70% band whatever the phone's aspect, so the two words can sit
     just outside it without ever landing on top of it. Source is 896x1184, so
     width = height * 0.757 and half of that is 0.3785. */
  .el--cards{width:auto;height:44cqh;left:calc(50cqw - 16.65cqh);top:26cqh}
  .lay--contact,.lay--me{
    left:0;right:0;width:100%;text-align:center;
    font-size:12cqw;white-space:nowrap;
  }
  .lay--contact{top:15cqh}
  .lay--me     {top:73cqh}
  /* centred by the flex box itself, not by a left offset that only happens to
     look centred at one particular width */
  /* the flex box centres the row here, so the desktop translate(-50%) must be
     undone or the whole row sits half a screen to the left */
  .lay--icons{left:0;width:100%;justify-content:center;top:82%;gap:4%;
              transform:translateY(var(--ty,0))}
  .ic--mail{width:12cqw}
  .ic--ig,.ic--gh,.ic--li{width:9.8cqw}

  /* 05 builds — the rows stack instead of sitting on one baseline; a 26%
     name column at phone widths would wrap every title. */
  .lay--buildsh{left:50%;top:7%;transform:translate(-50%,var(--ty,0));font-size:7.5cqw}
  .lay--builds{left:7%;top:18%;width:86%;margin-left:0;font-size:3.1cqw}
  .lay--builds li{flex-direction:column;align-items:flex-start;gap:.35em}
  .lay--builds li+li{margin-top:1.1em}
  .lay--builds .build{display:block;gap:0;width:100%}
  .lay--builds b{display:block;font-size:1.2em;flex:none}
  .lay--builds i{margin-top:.2em}
  /* the star control keeps its label at phone widths — it is the only call to
     action on the page, so hiding it would leave the row with no destination */
  .lay--builds .star{align-self:flex-start}

  /* 02 work — behind the machine, aspect preserved, centred in container units
     so it stays centred at any phone aspect. Source is 1248x1152, so
     width = height * 1.0833 and half of that is 0.5417. */
  .el--flower{width:auto;height:74cqh;left:calc(50cqw - 40.1cqh);top:13cqh}
}

/* ============ REDUCED MOTION ============ */
@media (prefers-reduced-motion:reduce){
  html{scroll-behavior:auto}
  .cue i::after,.rail.is-swap b{animation:none}
  .el--star-a,.el--star-b,.el--flower{animation:none;transform:translateY(var(--ty,0))}
  .rev{opacity:1;filter:none;visibility:visible;transition:none}
  .el.rev,.el.rev.is-shown{opacity:1;filter:invert(var(--el-inv));transition:none}
  .el,.lay{--ty:0 !important}
  [data-rise]{opacity:1;transform:none;filter:none;transition:none}
  .lay--icons img,.rail__open,.ctl__b::after,.lay--builds a::after{transition:none}
  /* the funnel stops turning on its own — warp.js holds uTime, so it still
     answers the scroll but never moves by itself */
}
