/* ---------------------------------------------------------------
   Yerazik - static, zero-dependency. Everything animatable is
   transform/opacity only, so nothing here can trigger layout.
----------------------------------------------------------------*/

:root{
  --ink: #000;
  --rule: rgba(0,0,0,.22);

  /* The two knobs dev mode (press T) edits live */
  --pad: 12.1vw;                        /* breathing room at the screen edges */
  --maxw: 1130px;                       /* centred content column */
  --proj-gap: 54px;                    /* vertical space between projects;
                                           goes negative to interlock them */
  --hero-scale: 1.16;                   /* multiplies the whole hero type curve */

  /* The wordmark's face. Dev mode (Z / X) swaps these four together -
     scale and tracking belong to the face, not to the wordmark. */
  --wm-font: "Bebas Neue", sans-serif;
  --wm-scale: 1.06;
  --wm-ls: .005em;
  --wm-weight: 400;
  --bg-img: url("temp.jpg");            /* the wall itself; dev mode (B) swaps
                                           it for background.svg */
  --shadow-img: url("shadows.svg");     /* the cast window shadow, multiplied
                                           over the wall above */
  --shadow-strength: 1;                 /* 0 hides the cast shadow entirely */

  /* Plant-shadow placement, driven by WASD / R in dev mode (press T) */
  --plant-x: -24px;
  --plant-y: 1520px;
  --plant-rot: 0deg;

  /* The top-left branch - the bottom mass flipped over - on YGHJ / U in
     dev mode */
  --branch-x: 0px;
  --branch-y: 0px;
  --branch-rot: 0deg;

  /* The top-right vine has its own rig, driven by the arrow keys / O */
  --vine-x: 48px;
  --vine-y: -1616px;
  --vine-rot: 379deg;
  --bg-zoom: 1;                         /* scales the wall photo only. The .bg
                                           box already matches the photo's
                                           portrait ratio, so 1 shows it whole;
                                           <1 pulls back, >1 crops in */
  --ground: #e9e6e0;                    /* shows either side of the image */

  --zoom: 1;                            /* dev-only virtual-viewport scale */

  --ease: cubic-bezier(.22,1,.36,1);
  --sans: "Helvetica Neue", Helvetica, -apple-system, BlinkMacSystemFont,
          "Segoe UI", Inter, Arial, sans-serif;
}

*,*::before,*::after{ box-sizing:border-box; margin:0; padding:0; }

html{ scroll-behavior:auto; -webkit-text-size-adjust:100%; }

body{
  font-family:var(--sans);
  color:var(--ink);
  background:var(--ground);
  overflow-x:hidden;
  -webkit-font-smoothing:subpixel-antialiased;
  text-rendering:optimizeLegibility;
}

/* ---------- stage / virtual viewport ---------- */

.stage{ position:relative; }

.js .stage{
  background:var(--ground);
  position:fixed;
  top:0; left:0;
  width:calc(100% / var(--zoom));
  height:calc(100svh / var(--zoom));
  transform:scale(var(--zoom));
  transform-origin:0 0;
  overflow:hidden;
}

/* ---------- background scene ----------

   A cream wall in afternoon sun, drawn rather than photographed.

   The trick: the wall is painted at its *shaded* tone, and the window
   is four bright panes laid over it. The gaps between the panes are
   therefore the frame shadows, so when the whole group is blurred once,
   the frame softens exactly the way real penumbra does. Nothing here
   animates except the plant layers.
------------------------------------------------------------------*/

.bg{
  position:absolute;
  top:0; left:0;
  width:100%; height:100%;
  z-index:0;
  overflow:hidden;
  pointer-events:none;
  /* the drawn wall shows wherever the zoomed photo does not reach */
  background:var(--wall);
  --wall:
    radial-gradient(128% 104% at 20% 26%,
      #ece7dc 0%, #e7e1d4 46%, #e1dbcc 78%, #dcd6c6 100%)
    #e5dfd3;

  --bar: 5.6vmax;                         /* frame thickness, pre-blur */
  --tilt: 11deg;                          /* sun angle; see the media queries */
  /* drives the plant composition; independent of --bg-zoom */
  --pw: clamp(760px, 88vw + 30vh, 2100px);
}
.bg > *{ position:absolute; pointer-events:none; }

/* The wall photo. BG zoom scales this layer alone - the plant shadows
   keep their size and are placed over it with WASD / R in dev mode. */
.bg::before{
  content:"";
  position:absolute;
  inset:0;
  background:var(--bg-img) center / cover no-repeat;
  transform:scale(var(--bg-zoom));
  transform-origin:50% 50%;
}

/* The cast window shadow, laid over the wall rather than baked into it.
   Multiply means the greys darken the photo underneath instead of covering
   it, so the wall's own grain and colour still read through the shadow.
   It shares --bg-zoom so the two layers stay registered to each other. */
.bg::after{
  content:"";
  position:absolute;
  inset:0;
  background:var(--shadow-img) center / cover no-repeat;
  mix-blend-mode:multiply;
  opacity:var(--shadow-strength);
  transform:scale(var(--bg-zoom));
  transform-origin:50% 50%;
}
.bg.drawn::before,
.bg.drawn::after{ display:none; }

@media (orientation:portrait){
  .bg{ --pw: clamp(620px, 120vw + 34vh, 1600px); }
}

/* The frame must cross the wall over roughly the same share of the
   viewport whatever its shape, so the tilt tracks the aspect ratio. */
@media (max-aspect-ratio:1/1){ .bg{ --tilt:16deg; } }
@media (max-aspect-ratio:3/4){ .bg{ --tilt:22deg; } }

/* The photo carries its own window light and ledge, so the drawn ones
   sit this out. Put class="bg drawn" on the scene to swap the photo for
   the fully drawn wall instead. */
.win, .falloff, .ledge{ display:none; }
.bg.drawn{ background:var(--wall); }
.bg.drawn .win,
.bg.drawn .falloff,
.bg.drawn .ledge{ display:block; }

/* --- window light (static) --- */

.win{
  left:66%; top:47%;          /* the point where the two frame shadows cross */
  width:0; height:0;
  transform:rotate(var(--tilt));  /* the frame runs down to the right */
  filter:blur(1.9vmax);
}
.pane{
  position:absolute;
  background:rgba(255,253,247,.58);
  border-radius:1.4vmax;
}
.p-tl{ right:calc(var(--bar)/2); bottom:calc(var(--bar)/2); width:150vmax; height:118vmax; }
.p-bl{ right:calc(var(--bar)/2); top:calc(var(--bar)/2);    width:150vmax; height:82vmax;  }
.p-tr{ left:calc(var(--bar)/2);  bottom:calc(var(--bar)/2); width:118vmax; height:118vmax; opacity:.34; }
.p-br{ left:calc(var(--bar)/2);  top:calc(var(--bar)/2);    width:118vmax; height:82vmax;  opacity:.56; }

/* --- global falloff: open upper-left, cool top-right corner --- */

.falloff{
  inset:0;
  background:
    linear-gradient(62deg,
      rgba(255,255,255,0) 40%, rgba(122,112,92,.07) 74%, rgba(122,112,92,.12) 100%),
    linear-gradient(to bottom,
      rgba(122,112,92,.05), rgba(255,255,255,0) 20%,
      rgba(255,255,255,0) 70%, rgba(122,112,92,.09)),
    radial-gradient(92% 64% at 13% 20%,
      rgba(255,255,255,.30), rgba(255,255,255,0) 66%);
}

/* --- plant shadows (the only animated part) --- */

/* The .bg box is as tall as the whole photo, but the shadows belong to the
   light at the top of the page - so pin them to the first viewport of it. */
.plants{
  left:0; right:0; top:0; bottom:auto;
  height:calc(100svh / var(--zoom));
  color:#a79d86;
  mix-blend-mode:multiply;
  /* dev mode nudges these three; the leaves animate inside them */
  transform:translate3d(var(--plant-x), var(--plant-y), 0) rotate(var(--plant-rot));
  transform-origin:14% 86%;
}

/* The pointer's handle on the bottom-left mass. breeze.js writes a few
   pixels of lean here; the per-leaf drift keeps running underneath. */
.lean{
  position:absolute; inset:0;
  transform-origin:8% 96%;
  will-change:transform;
}

.leaf{
  --k:1;
  --s:calc(var(--pw) * var(--k));
  position:absolute;
  width:var(--s); height:var(--s);
  left:calc(var(--s) * -.11);
  bottom:calc(var(--s) * -.09);
  transform-origin:10% 94%;      /* pivot near the pot, not the leaf tips */
  --sway:1;                      /* this layer's share of the draught */

  /* How far the draught carries, at --sway:1. Wider than it is tall,
     because wind is mostly sideways. Every layer reads these three. */
  --ax:13px;
  --ay:7px;
  --ar:1.1deg;

  will-change:translate, rotate;

  /* Two cycles at once, on two separate properties.

     Waypoints cannot do this. An eased curve between stops drops the
     velocity to zero at every one of them, so the leaves park - which is
     what "it stops moving" was. A point going round an ellipse at constant
     speed never stops and never turns a corner, so leaf-drift is twelve
     stops around one, interpolated linearly.

     One ellipse on its own is still a visible loop, so rotate runs a second
     cycle on a period that shares no multiple with the first. The two never
     return to the same combined position, and the sum never repeats inside
     any time anyone will sit here. translate and rotate are independent
     animatable properties, so both ride the same element with no wrapper
     and no per-frame JS - the compositor owns all of it. */
  animation-name:leaf-drift, leaf-turn;
  animation-duration:7s, 4.9s;
  animation-iteration-count:infinite;
  animation-timing-function:linear;
}

/* Deepest layer is biggest and softest. The two periods and the negative
   delays are per layer: no pair of periods shares a multiple, no two layers
   share a period, and every layer starts somewhere else in its cycle - so
   the nine of them never line up, here or thirty minutes from now.

   Periods are around ten seconds, not forty. Forty was arithmetic, not
   observation: thirteen pixels spread over forty seconds is a third of a
   pixel a second, which is under what the eye reads as motion at all -
   so the wall looked dead between the load gust and the next one. */
.l1{ --k:1.24; --sway:1;    opacity:.5;  filter:blur(1.15vmax);
     animation-duration:7.7s, 5.2s;     animation-delay:-2.7s, -1.1s; }
.l2{ --k:1.02; --sway:.88;  opacity:.5;  filter:blur(.85vmax);
     animation-duration:6.6s, 4.6s;     animation-delay:-6.8s, -4.9s; }
.l3{ --k:.82;  --sway:.76;  opacity:.46; filter:blur(.6vmax);
     animation-duration:8.8s, 5.7s;     animation-delay:-1.5s, -5.8s; }
.l4{ --k:.70;  --sway:.64;  opacity:.38; filter:blur(.75vmax);
     animation-duration:6s, 4.1s;       animation-delay:-7.9s, -2.1s; }
.l5{ --k:.95;  --sway:.82;  opacity:.34; filter:blur(.5vmax);
     animation-duration:7.2s, 5s;       animation-delay:-4.8s, -6.3s; }

/* The vine. It used to run its own rig - two nested pendulums driven off
   phasors in breeze.js, on top of its own leaf turn - which is why the
   top-right corner was always the busiest thing on the wall. Same draught
   as everything else now, and the smallest share of it: it hangs high and
   alone up there, so it shows every pixel it is given. */
.l6{ --k:.88;  --sway:.5;   opacity:.40; filter:blur(.9vmax);
     animation-duration:9.2s, 6.1s;     animation-delay:-3.6s, -7.4s; }

/* --- the gust ---

   One breath of wind on load, nothing more: the mass pivots at its root and
   swings back through smaller and smaller counter-turns until the ambient
   drift is all that's left. No travel, no fade - the leaves are where they
   were the whole time, they just got caught. Rotating about the stem (not
   the box centre) is what keeps it a sway instead of a slide: the tips
   move, the root barely does.

   The shape is deliberately lopsided. The catch stays abrupt - a hard curve
   into the first peak, inside a quarter second, on the same beat as the
   type cascade - but the peak itself is shallow, and everything after it
   runs long and eased. Per-keyframe timing functions are what buy that:
   the first segment snaps, the rest glide. A gust hits all at once and
   leaves slowly; an even sway on one curve reads mechanical.

   How slowly it leaves is the whole trick. Each counter-swing used to be
   under half the last, which spends the gust in under two seconds and
   drops the wall onto the ambient drift like a step. They are about .7 of
   the one before now, over twice the time - by the time the swings are
   down to a tenth of a degree they are already smaller than the draught
   that carries on underneath, so there is no moment where it visibly
   hands over. It just stops being the loudest thing in the room. */
.gust{
  position:absolute; inset:0;
  transform-origin:8% 96%;        /* down at the pot */

  /* Two winds on one pivot. The opening gust is a one-shot and owns
     `transform`; the recurring one is a loop and owns `rotate`, which is a
     separate animatable property - so they add up on the same element
     without either having to know about the other, and the big one can
     still be ringing down while the small ones have already started. */
  animation:gust 9.4s ease-in-out .1s 1 both,
            gust-pulse 3s ease-in-out infinite;
}
/* Same breath, same instant, same direction - only the pivot differs, and
   only because these two hang from different corners. */
.gust-b{ transform-origin:92% 4%; }

@keyframes gust{
  0%  { transform:rotate(0deg);   animation-timing-function:cubic-bezier(.1,.86,.24,1); }
  4%  { transform:rotate(-.95deg); }
  14% { transform:rotate(.64deg); }
  26% { transform:rotate(-.44deg); }
  39% { transform:rotate(.31deg); }
  52% { transform:rotate(-.22deg); }
  65% { transform:rotate(.16deg); }
  78% { transform:rotate(-.11deg); }
  90% { transform:rotate(.06deg); }
  100%{ transform:rotate(0deg); }
}

/* A breath every three seconds, a third the size of the opening one and
   the same shape - catch, overswing, settle. It never quite finishes
   settling before the next one arrives, which is what keeps it from
   reading as a metronome: the mass is always somewhere in the middle of a
   swing rather than waiting at rest for the next tick. All three masses
   are on the same three seconds, so it is one draught crossing the room
   and not three rooms with their own weather. */
@keyframes gust-pulse{
  0%  { rotate:0deg;    animation-timing-function:cubic-bezier(.16,.84,.3,1); }
  13% { rotate:-.3deg;  }
  42% { rotate:.17deg;  }
  72% { rotate:-.08deg; }
  100%{ rotate:0deg;    }
}

/* --- the top-left branch ---

   Pinned to the first viewport like .plants, and driven by the same drift
   keyframes - it is literally l1/l3/l5 with the group flipped about the
   horizontal, so every stem hangs into the corner. Smaller and thinner than
   the mass it came from: this one is an accent over the type, not a wall of
   leaves, and at full weight it would fight the wordmark. */
.branch{
  left:0; right:0; top:0; bottom:auto;
  height:calc(100svh / var(--zoom));
  color:#a79d86;
  mix-blend-mode:multiply;
  transform:translate3d(var(--branch-x), var(--branch-y), 0) rotate(var(--branch-rot));
  transform-origin:10% 8%;
}

.gust-c{ transform-origin:6% 4%; }

/* The flipped leaves anchor to the top edge instead of the bottom one */
.leaf.tl{
  top:calc(var(--s) * -.09);
  bottom:auto;
  transform-origin:10% 6%;
}
.t1{ --k:.92; --sway:.72; opacity:.34; filter:blur(1.05vmax);
     animation-duration:8.2s, 5.5s;     animation-delay:-5.5s, -3.3s; }
.t2{ --k:.74; --sway:.6;  opacity:.30; filter:blur(.7vmax);
     animation-duration:6.4s, 4.4s;     animation-delay:-2.3s, -5.1s; }
.t3{ --k:.62; --sway:.48; opacity:.24; filter:blur(.55vmax);
     animation-duration:8.5s, 5.9s;     animation-delay:-8.7s, -4.4s; }

/* Its own placement rig, so the drift keyframes on .l6 stay free to run
   inside it - the same trick .plants uses for the bottom-left mass. */
.vine{
  position:absolute; inset:0;
  transform:translate3d(var(--vine-x), var(--vine-y), 0) rotate(var(--vine-rot));
  transform-origin:86% 14%;
}

/* l6 hangs from the opposite corner, so it anchors and pivots there too */
.l6{
  left:auto; bottom:auto;
  right:calc(var(--s) * -.11);
  top:calc(var(--s) * -.09);
  transform-origin:90% 6%;
}


/* --- the draught ---

   leaf-drift traces an ellipse, wider than it is tall because wind is
   mostly sideways; leaf-turn is a sine about the stem. Both are read at
   constant speed, both are scaled by the layer's own --sway, and neither
   ever holds still. The reach is --ax / --ay / --ar on .leaf; the numbers
   below are only the twelve points of the circle. */

@keyframes leaf-drift{
        0%{ translate:calc(var(--sway) * var(--ax)) 0; }
   8.3333%{ translate:calc(var(--sway) * var(--ax) * 0.866) calc(var(--sway) * var(--ay) * 0.5); }
  16.6667%{ translate:calc(var(--sway) * var(--ax) * 0.5) calc(var(--sway) * var(--ay) * 0.866); }
       25%{ translate:0 calc(var(--sway) * var(--ay)); }
  33.3333%{ translate:calc(var(--sway) * var(--ax) * -0.5) calc(var(--sway) * var(--ay) * 0.866); }
  41.6667%{ translate:calc(var(--sway) * var(--ax) * -0.866) calc(var(--sway) * var(--ay) * 0.5); }
       50%{ translate:calc(var(--sway) * var(--ax) * -1) 0; }
  58.3333%{ translate:calc(var(--sway) * var(--ax) * -0.866) calc(var(--sway) * var(--ay) * -0.5); }
  66.6667%{ translate:calc(var(--sway) * var(--ax) * -0.5) calc(var(--sway) * var(--ay) * -0.866); }
       75%{ translate:0 calc(var(--sway) * var(--ay) * -1); }
  83.3333%{ translate:calc(var(--sway) * var(--ax) * 0.5) calc(var(--sway) * var(--ay) * -0.866); }
  91.6667%{ translate:calc(var(--sway) * var(--ax) * 0.866) calc(var(--sway) * var(--ay) * -0.5); }
      100%{ translate:calc(var(--sway) * var(--ax)) 0; }
}

@keyframes leaf-turn{
        0%{ rotate:0deg; }
   8.3333%{ rotate:calc(var(--sway) * var(--ar) * 0.5); }
  16.6667%{ rotate:calc(var(--sway) * var(--ar) * 0.866); }
       25%{ rotate:calc(var(--sway) * var(--ar)); }
  33.3333%{ rotate:calc(var(--sway) * var(--ar) * 0.866); }
  41.6667%{ rotate:calc(var(--sway) * var(--ar) * 0.5); }
       50%{ rotate:0deg; }
  58.3333%{ rotate:calc(var(--sway) * var(--ar) * -0.5); }
  66.6667%{ rotate:calc(var(--sway) * var(--ar) * -0.866); }
       75%{ rotate:calc(var(--sway) * var(--ar) * -1); }
  83.3333%{ rotate:calc(var(--sway) * var(--ar) * -0.866); }
  91.6667%{ rotate:calc(var(--sway) * var(--ar) * -0.5); }
      100%{ rotate:0deg; }
}

/* --- ledge / baseboard --- */

.ledge{
  left:-8%; right:-8%; bottom:-4%;
  height:26%;
  transform:rotate(1.1deg);      /* the reference is a hair off level */
  filter:blur(.45vmax);
  background:linear-gradient(to bottom,
    rgba(255,255,255,0) 0 40%,
    rgba(124,114,94,.13) 42%,
    rgba(255,255,255,.32) 45%,
    rgba(255,255,255,.20) 62%,
    rgba(124,114,94,.07) 66%,
    rgba(255,255,255,.24) 70%,
    rgba(255,255,255,.10) 100%);
}

/* Barely there - just enough to keep black type off a bare white patch */
.veil{
  position:absolute; inset:0; z-index:1; pointer-events:none;
  background:linear-gradient(to bottom,
    rgba(238,236,231,.18), rgba(238,236,231,.06) 45%, rgba(238,236,231,.22));
}

/* 1px of tooth - pure CSS, no image request */
.grain{
  position:absolute; inset:0; z-index:2; pointer-events:none; opacity:.5;
  background-image:radial-gradient(rgba(0,0,0,.05) .5px, transparent .5px);
  background-size:3px 3px;
}

/* ---------- scrolling content ---------- */

.content{
  position:relative;
  z-index:3;
  padding:0 var(--pad);
  will-change:transform;
  transform:translate3d(0,0,0);
}
.js .content{
  position:absolute;
  top:0; left:0;
  width:100%;
}

.inner{
  max-width:var(--maxw);
  margin-inline:auto;
}

/* ---------- hero ---------- */

.hero{
  min-height:100svh;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  text-align:center;
  position:relative;
  padding:14vh 0 12vh;
}

.eyebrow{
  font-size:1rem;
  letter-spacing:.2em;
  text-transform:uppercase;
  opacity:.78;
}

.wordmark{
  /* The face is a dev-mode pick - Z / X cycle the shortlist. Scale,
     tracking and weight ride along with it, because no two display faces
     sit in the same optical box and a single font-size would leave one
     towering over the next. The fallbacks here are the plain sans look. */
  font-family:var(--wm-font, var(--sans));
  font-size:calc(clamp(3.75rem, 15vw, 15rem) * var(--hero-scale) * var(--wm-scale, 1));
  line-height:.82;
  letter-spacing:var(--wm-ls, -.045em);
  font-weight:var(--wm-weight, 500);

  /* The cursor latches onto the wordmark, so its box has to be the type's
     box. In a centred flex column the h1 is already shrink-to-fit, but the
     .82 line-height crops the em box tighter than the glyphs - the padding
     lets the bracket clear the caps, and the negative margins hand back the
     space so nothing below moves. */
  padding:.12em .1em;
  margin:.16em -.1em -.12em;
}

.lede{
  margin-top:clamp(1.5rem,3vw,2.5rem);
  max-width:30ch;
  font-size:calc(clamp(1.1875rem,1.85vw,1.5rem) * min(var(--hero-scale), 1.35));
  line-height:1.55;
  opacity:.86;
}

/* ---------- projects ---------- */

/* The spacing is a margin rather than a flex gap on purpose: gap cannot go
   below zero, and a negative one is the whole point - left and right sit on
   opposite sides of the column, so they can overlap vertically without ever
   covering each other. */
.work{
  display:flex;
  flex-direction:column;
  gap:0;
  padding:clamp(6rem,14vh,12rem) 0;
}
.project + .project{ margin-top:var(--proj-gap); }

.project{
  position:relative;   /* keeps the paint order sane once they interlock */
  display:flex;
  flex-direction:column;
  gap:1.25rem;
  width:min(46%, 560px);
}
.project.left { align-self:flex-start; }
.project.right{ align-self:flex-end; }

/* Linked cards are anchors; the rest are plain articles. Keep them
   visually identical - the whole card is the hit area. */
a.project{ color:inherit; text-decoration:none; }
a.project:focus-visible{ outline:1px solid var(--rule); outline-offset:12px; }


/* Every project is a print in a mat: an outer edge, a border set in from
   it, and the work itself shrunk to sit inside that border rather than
   running under it. --mat is what keeps the two in register - the border
   and whatever fills it are inset by the same amount, so changing it once
   moves both. */
.frame{
  --mat:clamp(10px,2.2%,22px);
  position:relative;
  aspect-ratio:4/3;
  border:1px solid var(--rule);
  background:rgba(255,255,255,.16);
  overflow:hidden;
}
.frame::after{
  content:"";
  position:absolute; inset:var(--mat);
  border:1px solid var(--rule);
  pointer-events:none;
  z-index:1;                          /* the border stays over the print */
}

/* Placeholder until a project has its own imagery: a plain white field,
   the same size and place the real screenshots take. */
.frame-in{
  position:absolute; inset:var(--mat);
  background:#fff;
}

/* BSFFR is three shots of one round - the door, the vote, the reveal - so
   the frame plays them in order instead of picking one. Each holds four
   seconds and dissolves into the next over half of one; the incoming shot
   is later in the DOM, so it fades in on top of the outgoing one and the
   handoff never shows the frame underneath. The cycle is tied to .in, the
   class the scroll observer already sets, so it costs nothing offscreen. */
.shots{
  position:absolute; inset:var(--mat);
  background:#0a0a0a;                 /* what the dissolves happen over */
}
.shots img{
  position:absolute; inset:0;
  width:100%; height:100%;
  object-fit:cover;
  opacity:0;
}
.shots img:first-child{ opacity:1; }   /* before the card is ever in view */

.project.in .shots img{ animation:shot-cycle 13.5s linear infinite; }
.project.in .shots img:nth-child(2){ animation-delay:4.5s; }
.project.in .shots img:nth-child(3){ animation-delay:9s; }

@keyframes shot-cycle{
  0%    { opacity:0 }
  3.7%  { opacity:1 }
  33.3% { opacity:1 }
  37%   { opacity:0 }
  100%  { opacity:0 }
}

.meta{
  text-align:left;
}
.meta h2{
  font-size:clamp(1.375rem,2.6vw,2.125rem);
  font-weight:500;
  letter-spacing:-.025em;
  line-height:1.1;
}
.meta p{
  margin-top:.45rem;
  font-size:1.125rem;
  letter-spacing:.01em;
  opacity:.8;
}

/* ---------- project detail ----------

   The description and stack live in the empty half of the column, on the
   card's outer side: a left card sets them to its right, a right card to
   its left. Both are simply there - they are what the project is, and
   reading them should not cost a hover. --detail-drop carries the block
   down from the top of the frame so it reads against the middle of the
   card rather than its top edge. */

.detail{
  --detail-drop:clamp(2rem,6vw,5rem);
  position:absolute;
  top:var(--detail-drop);
  width:min(74%, 360px);
  pointer-events:none;              /* never steals the card's hit area */
}
.detail p{
  font-size:1.125rem;
  line-height:1.6;
  opacity:.88;
}
.stack{
  display:flex;
  flex-wrap:wrap;
  gap:.4rem;
  margin-top:1.1rem;
  padding-top:1.1rem;
  border-top:1px solid var(--rule);
  list-style:none;
}
.stack li{
  padding:.3rem .6rem;
  border:1px solid var(--rule);
  border-radius:2px;
  font-size:.8125rem;
  letter-spacing:.1em;
  text-transform:uppercase;
  opacity:.82;
}

/* Side */
.project.left  .detail{
  left:calc(100% + clamp(1.5rem,3vw,2.75rem));
}
.project.right .detail{
  right:calc(100% + clamp(1.5rem,3vw,2.75rem));
  text-align:right;
}
.project.right .stack{
  justify-content:flex-end;
}

/* The hovered or cursor-latched card still clears its interlocked
   neighbour, there is just nothing left to open. */
.project.is-cursor{ z-index:3; }

@media (hover:hover){
  .project:hover{ z-index:3; }
}

/* ---------- outro ---------- */

.outro{
  min-height:92svh;
  display:flex;
  flex-direction:column;
  align-items:center;
  justify-content:center;
  gap:clamp(1.75rem,4vh,2.75rem);
  text-align:center;
  padding-bottom:12vh;
}

.cta{
  --h: clamp(3.25rem, 6vw, 4.25rem);
  position:relative;
  display:inline-flex;
  align-items:center;
  justify-content:center;
  height:var(--h);
  padding:0 clamp(1.75rem,3.5vw,2.75rem);
  border:1px solid var(--ink);
  border-radius:calc(var(--h)/2);
  color:var(--ink);
  text-decoration:none;
  font-size:clamp(1.0625rem,1.5vw,1.25rem);
  letter-spacing:-.01em;
  overflow:hidden;
  isolation:isolate;
  transition:color .5s var(--ease);
}
.cta::before{
  content:"";
  position:absolute; left:0; right:0; bottom:0; height:145%; z-index:-1;
  background:var(--ink);
  transform:translate3d(0,100%,0);
  transition:transform .55s cubic-bezier(.32,.72,0,1);
}

@media (hover:hover){
  .cta:hover{ color:#fff; }
  .cta:hover::before{ transform:translate3d(0,0,0); }
}
.cta:focus-visible{ outline:2px solid var(--ink); outline-offset:4px; }

.fine{
  font-size:1rem;
  letter-spacing:.12em;
  text-transform:uppercase;
  opacity:.72;
}

/* ---------- reveal ---------- */

[data-r]{
  opacity:0;
  transform:translate3d(0,26px,0);
  transition:opacity .9s var(--ease), transform 1.1s var(--ease);
  will-change:transform,opacity;
}
[data-r].in{
  opacity:1;
  transform:translate3d(0,0,0);
  will-change:auto;
}

/* Hero cascades in on load */
.hero [data-r]:nth-child(1){ transition-delay:.08s; }
.hero [data-r]:nth-child(2){ transition-delay:.18s; }
.hero [data-r]:nth-child(3){ transition-delay:.34s; }
.hero [data-r]:nth-child(4){ transition-delay:.5s;  }

/* A project card reveals as image-then-caption, not as one block */
.project[data-r]{ opacity:1; transform:none; }
.project[data-r] .frame,
.project[data-r] .meta,
.project[data-r] .detail{
  opacity:0;
  transform:translate3d(0,24px,0);
  transition:opacity .9s var(--ease), transform 1.1s var(--ease);
}
.project[data-r] .meta{ transition-delay:.12s; }
.project[data-r] .detail{ transition-delay:.22s; }
.project[data-r].in .frame,
.project[data-r].in .meta,
.project[data-r].in .detail{
  opacity:1;
  transform:translate3d(0,0,0);
}

/* Outro */
.outro [data-r]:nth-child(2){ transition-delay:.12s; }
.outro [data-r]:nth-child(3){ transition-delay:.24s; }

/* ---------- responsive ---------- */

@media (max-width:820px){
  .project{ width:100%; }
  .work{ gap:clamp(5rem,14vh,9rem); }

  /* There is no empty half to open into, so the detail is simply part of
     the card - always visible, in the flow, under the caption. */
  .detail,
  .project.left .detail,
  .project.right .detail{
    position:static;
    width:100%;
    text-align:left;
    margin-top:-.25rem;
  }
  .project.left .stack,
  .project.right .stack{
    justify-content:flex-start;
  }
}

/* ---------- dev overlay (press T) ---------- */

.dev{
  position:fixed;
  right:16px; bottom:16px;
  z-index:9999;
  width:268px;
  padding:14px 16px 16px;
  border-radius:12px;
  background:rgba(16,16,16,.92);
  color:#fff;
  font:12px/1.4 ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  box-shadow:0 12px 40px rgba(0,0,0,.35);
  backdrop-filter:blur(10px);
  user-select:none;
}
.dev h3{
  display:flex;
  align-items:center;
  justify-content:space-between;
  gap:12px;
  font-size:10px;
  letter-spacing:.18em;
  text-transform:uppercase;
  opacity:.5;
  font-weight:400;
  margin-bottom:12px;
}
.dev h3 button{
  all:unset;
  cursor:pointer;
  width:18px; height:18px;
  margin:-4px -4px -4px 0;
  border-radius:5px;
  text-align:center;
  line-height:18px;
  font-size:13px;
  opacity:.7;
}
.dev h3 button:hover{ background:rgba(255,255,255,.12); opacity:1; }

/* Collapsed: the title bar and its toggle, nothing else. */
.dev.min{ width:auto; padding:10px 12px; }
.dev.min h3{ margin-bottom:0; }
.dev.min > :not(h3){ display:none; }
.dev label{ display:block; margin-bottom:11px; }
.dev .row{
  display:flex;
  justify-content:space-between;
  margin-bottom:5px;
  opacity:.8;
}
.dev .row b{ font-weight:400; color:#8ef0c4; }
.dev input[type=range]{
  width:100%;
  height:3px;
  -webkit-appearance:none;
  appearance:none;
  background:rgba(255,255,255,.2);
  border-radius:2px;
  outline:none;
}
.dev input[type=range]::-webkit-slider-thumb{
  -webkit-appearance:none;
  appearance:none;
  width:12px; height:12px;
  border-radius:50%;
  background:#fff;
  cursor:pointer;
}
.dev input[type=range]::-moz-range-thumb{
  width:12px; height:12px; border:0; border-radius:50%; background:#fff; cursor:pointer;
}
.dev .btns{ display:flex; gap:8px; margin-top:4px; }
.dev button{
  flex:1;
  padding:7px 0;
  border:1px solid rgba(255,255,255,.2);
  border-radius:7px;
  background:transparent;
  color:#fff;
  font:inherit;
  cursor:pointer;
}
.dev button:hover{ background:rgba(255,255,255,.1); }
.dev .hint{ margin-top:10px; opacity:.35; font-size:10px; }

/* ---------- target cursor ----------

   Drawn by target-cursor.js: a dot plus four corner brackets that spin
   idly and latch onto anything with .cursor-target. The wrapper is a
   0x0 box, so left/top 50% on the children is simply its centre; JS
   owns every transform below except the dot's.

   `difference` is what makes a white cursor readable on a cream wall -
   it inverts whatever is behind it. Switch to `normal` (and set
   blend:false in the JS config) if you want the literal hexes.
--------------------------------------------------------------------*/

.tc-hide-cursor,
.tc-hide-cursor *{ cursor:none !important; }

.target-cursor{
  position:fixed;
  top:0; left:0;
  width:0; height:0;
  pointer-events:none;
  z-index:9999;
  mix-blend-mode:difference;
  opacity:0;
  transition:opacity .2s linear;
  will-change:transform;
}
.target-cursor.is-live{ opacity:1; }

.tc-dot{
  position:absolute;
  left:0; top:0;
  width:4px; height:4px;
  border-radius:50%;
  background:var(--tc-color, #fff);
  transform:translate(-50%,-50%);
  transition:transform .3s var(--ease), background-color .15s ease-out;
  will-change:transform;
}

.tc-corner{
  position:absolute;
  left:0; top:0;
  width:12px; height:12px;
  border:3px solid var(--tc-color, #fff);
  transition:border-color .15s ease-out;
  will-change:transform;
}

/* Each bracket keeps only the two sides that face its own corner */
.tc-corner:nth-of-type(1){ border-right:none; border-bottom:none; }
.tc-corner:nth-of-type(2){ border-left:none;  border-bottom:none; }
.tc-corner:nth-of-type(3){ border-left:none;  border-top:none; }
.tc-corner:nth-of-type(4){ border-right:none; border-top:none; }

.target-cursor.is-on .tc-corner{ border-color:var(--tc-color-on, #fff); }
.target-cursor.is-on .tc-dot{ background:var(--tc-color-on, #fff); }

@media (prefers-reduced-motion:reduce){
  .target-cursor{ display:none; }
  .tc-hide-cursor, .tc-hide-cursor *{ cursor:auto !important; }
}

/* Dev panel open: target-cursor.js drops .tc-hide-cursor and hides the
   bracket, so aiming a slider works. This is the belt to that braces. */
.dev-on .target-cursor{ display:none; }

/* ---------- motion / a11y ---------- */

@media (prefers-reduced-motion:reduce){
  .js .stage{ position:relative; width:100%; height:auto; transform:none; overflow:visible; }
  .js .content{ position:relative; transform:none !important; }
  .bg{ position:fixed; inset:0; width:100%; height:100%; transform:none !important; }
  .veil,.grain{ position:fixed; }
  .leaf{ animation:none !important; }
  .gust{ animation:none !important; }
  [data-r]{ opacity:1 !important; transform:none !important; transition:none !important; }

  /* One shot, held. */
  .shots img{ animation:none !important; opacity:0 !important; }
  .shots img:first-child{ opacity:1 !important; }
}
