/* =========================================================
   neel-parikh.com — Mark 4
   Tokens → reset → hero → phrases → chrome → work → a11y
   ========================================================= */

:root {
  /* Palette — sampled from frame 70 of the hero. Nothing invented. */
  --cloud:   #EBDCBF;
  --dune:    #D7C7AB;
  --grass:   #C7AF8E;
  --stone:   #9F9887;
  --moss:    #706B58;
  --shadow:  #453E2B;
  --deep:    #22231A;
  --void:    #10130E;

  --ink:     #FBF7F0;              /* never pure #FFF */
  /* .80, not .55 — measured against the scrimmed frame, dim text at .55
     landed at 2.5:1 over the bright cloud band. See design.md §4. */
  --ink-dim: rgba(251, 247, 240, .80);
  --ink-faint: rgba(251, 247, 240, .32);

  /* Soft halo behind anything sitting on the video. Buys ~2 points of
     contrast without darkening the frame any further. */
  --halo: 0 1px 28px rgba(16, 19, 14, .50), 0 0 2px rgba(16, 19, 14, .35);

  /* The halftone's grade. Declared once because it has to be pixel-for-
     pixel identical on the hero's dither layer and on the Work plate —
     those two show the same frame at the same framing, and the Work
     section scrolls up over the hero. Two identical images with a
     moving edge between them have no visible edge, and that hides the
     handover entirely. Any difference in treatment brings the seam back.

     A thin band at the top for the chrome — the halftone's sky is
     near-white and the nav was white on white over it — and then a long
     fall through the lower half, which is where the desktop is.

     THE LOWER HALF GOT MUCH DEEPER when the files went from one row
     along the floor to two rows beside a window. The old curve was flat
     until 62% because nothing lived above that line; the top row of
     folders now sits at 63% and its labels measured 2.4:1 against the
     cloud. The fall starts at 38% instead.

     Which turns out to be the better picture as well as the readable
     one: the sky stays bright, the bottom half drops into shadow, and
     the frame stops being a photograph with icons on it and starts
     being a lit room with a desk in it. */
  --plate-wash: linear-gradient(180deg,
    rgba(16,19,14,.74)   0%,
    rgba(16,19,14,.30)   8%,
    rgba(16,19,14,.05)  20%,
    rgba(16,19,14,0)    38%,
    rgba(16,19,14,.40)  50%,
    rgba(16,19,14,.70)  62%,
    rgba(16,19,14,.84)  76%,
    rgba(16,19,14,.90) 100%);

  /* Easing — built-in curves are too weak to read as intentional */
  --ease-out:  cubic-bezier(0.23, 1, 0.32, 1);
  --ease-move: cubic-bezier(0.77, 0, 0.175, 1);
  --ease-ui:   cubic-bezier(0.32, 0.72, 0, 1);
  /* Overshoots by ~5%. One use only: the folders arriving on the
     desktop. Overshoot on a response to a pointer reads as sloppy; on
     something appearing on a surface it reads as weight. */
  --ease-pop:  cubic-bezier(0.34, 1.46, 0.64, 1);

  /* The one colour not sampled from the video: the folder icon's cyan.
     It's already on screen six times, so the About window borrows it for
     its track position rather than introducing a seventh accent. */
  --folder-teal: #74E8E8;

  /* Type scale */
  --type-hero:  clamp(2.75rem, 7.5vw, 8rem);
  --type-line:  clamp(1.25rem, 2.6vw, 2.5rem);
  /* Sub sits under a 128px display word — at 1.15rem it disappeared
     entirely. Sized to hold its own against the hero line. */
  --type-sub:   clamp(1rem, 1.75vw, 1.6rem);
  --type-label: .8125rem;

  --track-hero:  -.03em;
  --track-line:  -.01em;
  --track-label:  .08em;

  --leading-tight: .95;
  --leading-line: 1.15;

  /* Two voices, one boundary. Panchang is the person shouting into the
     cloud; Sprat is the work itself. They never share a block. */
  --font-display: 'Panchang', ui-sans-serif, system-ui, sans-serif;
  --font-work: 'Sprat', Georgia, 'Times New Roman', serif;
  --font-ui: ui-sans-serif, -apple-system, 'Segoe UI', Roboto, sans-serif;
}

/* ---------- reset ---------- */
*, *::before, *::after { box-sizing: border-box; }
html { -webkit-text-size-adjust: 100%; }
body {
  margin: 0;
  background: var(--void);
  color: var(--ink);
  font-family: var(--font-ui);
  overflow-x: hidden;
}
/* clip, not hidden, wherever it's supported. `overflow-x: hidden` makes
   the body a scroll container, and a scroll container is what
   position: sticky measures itself against — which quietly breaks the
   sticky desktop stage in Work. `clip` cuts the overflow off without
   creating one. The hidden above stays as the floor for old Safari. */
@supports (overflow-x: clip) {
  body { overflow-x: clip; }
}
p, h1, h2, h3 { margin: 0; }
a { color: inherit; text-decoration: none; }
img, video { display: block; max-width: 100%; }

/* =========================================================
   HERO
   ========================================================= */
.hero {
  position: relative;
  height: 100svh;
  width: 100%;
  overflow: hidden;
}

.hero__media { position: absolute; inset: 0; }

.hero__video {
  width: 100%; height: 100%;
  object-fit: cover;
  /* No CSS filter here. The saturation/contrast lift is baked into the
     encode instead — running a filter on a 1920x1080 video costs a
     compositing pass every frame for an adjustment nobody can see. */
}

/* Centre-weighted radial, not a flat wash. Protects the text without
   killing the sky. Top third of the frame averages L=193 — white text
   there fails contrast outright without this.
   Values are measured, not eyeballed: composited over six frames of the
   loop and checked at every line's text box. Weakening any of the three
   stops drops a line below 4.5:1. */
/* The dither layer. Identical box and identical object-fit to the plate
   beneath it, so the crossfade is the same shot re-rendering rather than
   a cut to a second camera. Opacity is scrubbed by hero.js across the
   pinned tail; it starts at 0 and it starts paused.

   It sits UNDER the scrim, vignette and grain — those are the page's
   atmosphere and they belong to whatever is showing, not to one clip. */
.hero__dither {
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover;
  opacity: 0;
  will-change: opacity;
}
/* The halftone's own grade, brought in on the same scrub as the clip it
   belongs to. This is the same declaration the Work plate uses — see
   --plate-wash. It has to be, or the handover between them shows. */
.hero__ditherwash {
  position: absolute; inset: 0;
  pointer-events: none;
  background: var(--plate-wash);
  opacity: 0;
}

.hero__scrim {
  position: absolute; inset: 0;
  pointer-events: none;
  background:
    radial-gradient(120% 90% at 50% 45%, rgba(16,19,14,.68) 0%, rgba(16,19,14,0) 80%),
    linear-gradient(180deg, rgba(16,19,14,.52) 0%, rgba(16,19,14,0) 40%),
    linear-gradient(0deg,   rgba(16,19,14,.44) 0%, rgba(16,19,14,0) 30%);
}

/* Black vignette. Sits above the scrim so it darkens the corners without
   touching the centre where the display type lives — it pulls the eye to
   the middle of the frame and gives the whole thing more depth.
   Two stops rather than one: a wide soft falloff plus a tighter corner
   push, so the edges go dark without a visible ring. */
/* Black vignette. Sits above the scrim so it darkens the corners without
   touching the centre where the display type lives — it pulls the eye to
   the middle of the frame and gives the whole thing more depth.

   Four stops now, and the two new ones are load-bearing. Step 5 puts
   copy in the TOP-LEFT and BOTTOM-RIGHT corners of a lit frame, which
   the two centred stops below barely reach: measured 3.5:1 and 2.8:1
   against the cloud, both failing.

   So the light falls off from those two corners specifically, anchored
   AT them and fading inward. Anchoring at the corner is what keeps it
   reading as lighting rather than as a shape — the gradient has no
   visible extent, because the half of it that would show an edge is
   outside the frame. It also means it can be strong where the text ends
   and gone by the centre, instead of dimming the whole reveal.

   Corner-weighted rather than symmetrical because the composition is
   diagonal. If the copy ever moves, these move with it. */
.hero__vignette {
  position: absolute; inset: 0;
  pointer-events: none;
  background:
    radial-gradient(80% 62% at 2% 26%,  rgba(0,0,0,.88) 0%, rgba(0,0,0,.52) 42%, rgba(0,0,0,0) 78%),
    radial-gradient(80% 58% at 98% 76%, rgba(0,0,0,.88) 0%, rgba(0,0,0,.52) 42%, rgba(0,0,0,0) 78%),
    radial-gradient(125% 95% at 50% 48%, rgba(0,0,0,0) 38%, rgba(0,0,0,.42) 82%, rgba(0,0,0,.62) 100%),
    radial-gradient(78% 100% at 50% 50%,  rgba(0,0,0,0) 55%, rgba(0,0,0,.28) 100%);
}

/* The blackout. Fully opaque for the whole setup — the line, the
   hesitation, the word — and gone on the ask. JS owns the value; see
   REVEAL in hero.js.

   The first half of the page is a voice with no picture, which is what
   being marooned actually is. The risk of a dead black frame reading as
   a failed load is carried by the copy: there is always a line on
   screen from step 1, and step 0 gets the scroll cue at 1.1s.

   Sits above the scrim and vignette, below the grain — black with grain
   in it is film black, black without it is a CSS div. */
.hero__blackout {
  position: absolute; inset: 0;
  pointer-events: none;
  background: #000;
  opacity: 1;
}

/* Static grain. Dialled back from .05 to .025 now the hero is the ASCII
   treatment — that frame is already dense high-frequency texture, and
   grain on top of it is noise on noise. It still does its original job
   of softening compression artefacts, just quietly.
   Static, not animated: animated grain is a per-frame repaint for nothing. */
.hero__grain {
  position: absolute; inset: 0;
  pointer-events: none;
  opacity: .025;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='140' height='140'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='3' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='140' height='140' filter='url(%23n)'/%3E%3C/svg%3E");
}

/* ---------- phrase stage ---------- */
.hero__stage {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  padding: 0 6vw;
}

/* A phrase is one or more lines that share a position and a lifespan.
   Every phrase stacks in the same grid cell; the pos-- class moves it.
   Position lives on the phrase, animation lives on the .line inside —
   keeping them on separate elements means the focus-pull's scale never
   fights the placement transform. */
.phrase {
  grid-area: 1 / 1;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(.5rem, 1.4vh, 1.1rem);
  width: 100%;
  max-width: 60ch;
  line-height: var(--leading-line);
  text-align: center;
  /* Hidden by default — JS owns visibility from here */
  opacity: 0;
  visibility: hidden;
}

.pos--mid { }

/* Opposite corners. The distance between these two is the point: it's
   the hesitation the sentence is describing, made spatial, and it only
   works because they're on screen at the same time. */
.pos--tl {
  justify-self: start; align-self: center;
  transform: translate(3vw, -17svh);
  text-align: left; max-width: none;
}
.pos--br {
  justify-self: end; align-self: center;
  transform: translate(-3vw, 17svh);
  text-align: right; max-width: none;
}
/* These two never wrap. They're fragments — breaking "one must seek"
   across two lines turns a hesitation into a stack, and on a narrow
   screen the orphan reads as a layout bug rather than a pause. */
.pos--tl .line, .pos--br .line { white-space: nowrap; }

/* Except at step 5, where the same two corners carry sentences rather
   than fragments. "wave a hand or send a boat, lets join forces to do
   more." run through nowrap goes straight off the side of the screen.

   THE MEASURE LIVES ON THE LINE, NOT THE PHRASE. `ch` resolves against
   the element's own font-size, and .phrase never sets one — it inherits
   16px from the body while the line inside it runs at 37px. A 24ch
   measure on the phrase came out 192px wide and broke the sentence into
   seven lines. On .line the same unit means what it says.

   18ch, which lands the long one on four lines and "lets work
   together." on two — the shape it has in the layout. */
.phrase--wrap { max-width: none; position: relative; }
.phrase--wrap .line { white-space: normal; max-width: 18ch; }

/* A scrim that travels with the text.

   These two are the only lines on the site that sit in the CORNERS of a
   lit frame. .hero__scrim is centre-weighted — a radial at 50% 45% plus
   a band top and bottom — because every line it was measured against
   was near the middle. Put copy at 14% / 30% and it is protected by
   almost nothing: measured 3.5:1 top-left and 2.8:1 bottom-right against
   the cloud, both failing.

   Deepening the global scrim enough to fix it would have taken about
   0.69 alpha across the upper and lower thirds, which flattens the one
   frame the whole first half of the page is waiting for. So the
   protection is attached to the text instead, and goes where it goes.
   It is the same device as --halo, an order of magnitude larger. */
/* Protection for these two lines does NOT live here. It's on the frame —
   see .hero__vignette. Three attempts at putting it behind the text all
   failed for the same underlying reason, which is worth keeping:

   A radial sized to the text box protects an inscribed ellipse, and text
   is a rectangle — its corners overhang. 4.2:1 and 3.6:1.
   A radial sized past the box gets clipped by the box before it fades,
   drawing a hard-edged slab across the frame.
   A blurred rect measured fine, 7.7–11.6:1, and still read as a panel
   sitting on the picture. Passing the check is not the same as looking
   right, and a dark rectangle behind type on a photograph looks like a
   caption box on a stock image no matter how soft its edges are.

   The frame is the thing that should be darker, not the text's backing. */
.pos--tl.phrase--wrap { align-items: flex-start; }
.pos--br.phrase--wrap { align-items: flex-end; }

/* "a piece of the" / "Continent" is a set-up and a payoff, not a kicker
   and a headline. At the phrase's default 1.4vh they sit on top of each
   other; the gap is the beat between them. */
.phrase--split { gap: clamp(1.25rem, 5vh, 3.5rem); }

.pos--ask { transform: translateY(14svh); }

.pos--name {
  align-items: flex-start;
  justify-self: start;
  transform: translateY(26svh);
  text-align: left;
  gap: clamp(.7rem, 1.8vh, 1.4rem);
}

/* --blur-in is the focus-pull depth, read once by hero.js. It lives
   here rather than in JS because it's calibrated against font-size:
   8px reads as genuinely soft on a 40px line and as barely-touched on
   a 128px one. Blur is the expensive part of the reveal — Safari
   especially — so these stay modest and at most two lines are ever
   animating at once.

   No `contain` here. It existed to scope the scramble's per-frame
   textContent reflow; the focus pull animates filter, transform and
   opacity, none of which touch layout. And `contain: paint` in
   particular would clip --halo, a 28px blur that lives almost entirely
   outside the glyph box. Don't reintroduce it. */
.line {
  display: inline-block;
  font-family: var(--font-display);
  font-variant-ligatures: none;
  text-shadow: var(--halo);
  --blur-in: 8px;
}
/* Correct this time: filter, transform and opacity are exactly what
   the reveal animates. Dropped the moment the line settles. */
.line[data-animating] { will-change: filter, transform, opacity; }

.line--body    { font-size: var(--type-line); font-weight: 500; letter-spacing: var(--track-line); }
/* "Marooned." is tracked OUT, not in — the only positive tracking on
   the site. Everything else on this page is set tight; letting this one
   word breathe makes it read as a label on a specimen rather than a
   shout, which is the colder and better reading of the joke.
   The negative right margin cancels the trailing letter-space, which
   would otherwise push the optical centre half a track to the left. */
.line--hero {
  font-size: clamp(2.1rem, 6.6vw, 6rem);
  font-weight: 600;
  letter-spacing: .22em;
  margin-right: -.22em;
  line-height: var(--leading-tight);
  white-space: nowrap;
  --blur-in: 15px;
}
.line--sub     { font-size: var(--type-sub);  font-weight: 400; color: var(--ink-dim); letter-spacing: .015em; --blur-in: 5px; }
/* ---------- the wordmark ----------
   Calfine, with the two E's set in Panchang Extrabold. The repeated
   letter is the rule — a mix without one reads as a ransom note.

   Bigger than --type-hero: at 13vw it fills the frame the way the
   storyboard has it. Calfine is condensed so it takes the size without
   wrapping; Panchang is wide, so the swapped letters run at 0.8em to
   sit at the same apparent cap height. Both faces are baseline-aligned
   by default, which is what keeps the mix from looking accidental. */
.line--name {
  font-family: 'Calfine', var(--font-display), serif;
  font-size: clamp(2.6rem, 13vw, 13rem);
  font-weight: 400;
  letter-spacing: .005em;
  line-height: var(--leading-tight);
  white-space: nowrap;
}
.line--name i {
  font-style: normal;
  font-family: var(--font-display);
  font-weight: 800;
  font-size: .8em;
  letter-spacing: -.015em;
}

/* The mask the wordmark rises out of. Padding gives the halo and any
   overshoot somewhere to live at rest; the negative margin takes the
   space back so it doesn't open a gap in the stack. */
.mask {
  display: block;
  overflow: hidden;
  padding: .14em 0 .2em;
  margin: -.14em 0 -.2em;
}

/* The display word needs air beneath it before the sub-line lands. */

/* ---------- chrome (born at beat 5, then persistent) ----------
   Fixed, and a sibling of .hero rather than a child of it. As an
   absolutely-positioned child it scrolled away with the hero the moment
   it was born — so the reading-position rule, whose whole job is to be
   present from beat 5 down, was visible for about half a second. */
.chrome {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: 10;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1rem;
  padding: 1.6rem 6vw;
  text-shadow: var(--halo);
  opacity: 0;
  visibility: hidden;
}
/* Guarantees legibility over whatever ends up under it — and what ends
   up under it got much brighter. The chrome is born on the last step of
   the hero, and one gesture later the frame dissolves into the halftone,
   whose top half is near-white cloud. At .6 the nav was white-on-white
   there. Deeper, and extended past the bar's own height so it falls off
   rather than ending in a visible edge. */
.chrome::before {
  content: '';
  position: absolute;
  inset: 0 0 -70% 0;
  z-index: -1;
  background: linear-gradient(180deg, rgba(16,19,14,.86) 0%, rgba(16,19,14,.52) 45%, rgba(16,19,14,0) 100%);
  pointer-events: none;
}

.chrome__rule {
  position: absolute;
  top: 0; left: 0; right: 0;
  height: 1px;
  background: var(--ink-faint);
}
.chrome__rule-fill {
  display: block;
  height: 100%;
  width: 100%;
  background: var(--ink);
  transform: scaleX(0);
  transform-origin: 0 50%;
}

.chrome__mark {
  font-family: var(--font-display);
  font-weight: 700;
  font-size: var(--type-label);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
}

.chrome__nav {
  display: flex;
  gap: 1.75rem;
  font-size: var(--type-label);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: var(--ink-dim);
}
.chrome__nav a {
  display: inline-block;
  transition: color 180ms var(--ease-ui), transform 140ms var(--ease-ui);
}
@media (hover: hover) and (pointer: fine) {
  .chrome__nav a:hover { color: var(--ink); }
}
.chrome__nav a:active { transform: scale(.96); }
.footer__mail { display: inline-block; }
.footer__mail:active { transform: scale(.98); }

/* ---------- the step ledger ----------
   One dot per line, down the right edge, the current one filled.

   THIS REPLACED A CHEVRON. The chevron said "there is more" and that is
   half the answer — the actual anxiety in a gated scroll is not whether
   the page continues but how long it intends to hold you. Six dots say
   both, and say the second one for free.

   It is NOT the scroll cue. The cue answers "is this broken?" on a black
   opening frame, carries the digit grid, and leaves for good on first
   input. This answers "how much further?" and persists through the
   story. Different questions, different lifespans, different elements.

   Nothing here animates position and nothing loops. A dot changing
   colour is the whole transition — on a page arguing that one thing
   moves at a time, the progress indicator is the last place to spend a
   second movement. */
.hero__steps {
  position: absolute;
  top: 50%;
  right: 4vw;
  z-index: 4;
  transform: translateY(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: clamp(1.1rem, 3svh, 2.1rem);
  /* A statement, not a control. Clicking a dot does nothing, so it
     never takes the pointer and never suggests it might. */
  pointer-events: none;
  opacity: 0;
  transition: opacity 260ms var(--ease-ui);
}
.hero__steps[data-show] { opacity: 1; }

.hero__steps i {
  display: block;
  width: 8px; height: 8px;
  border-radius: 50%;
  /* .3 on black is quiet enough to ignore and present enough to count.
     .22 was right against the black setup frames and vanished on the lit
     island at step 5, which is the one frame where the ledger has real
     competition. The shadow is doing the same job — these end up over
     near-white halftone, not over nothing. */
  background: rgba(251, 247, 240, .3);
  filter: drop-shadow(0 1px 5px rgba(16, 19, 14, .95));
  transition: background-color 200ms var(--ease-ui),
              transform 200ms var(--ease-ui);
}
/* Filled and fractionally larger. Size alone would be too quiet and
   colour alone reads flat at 7px; together the active dot is findable
   without a glance costing anything. */
.hero__steps i.is-on {
  background: var(--ink);
  transform: scale(1.5);
}

/* ---------- manual play (reduced motion) ---------- */
/* ---------- scroll cue + matrix ----------
   The tumbling digit grid is decorative, not a loading state. Its job
   is to keep a pitch-black opening frame from reading as a page that
   failed to load — and it leaves with the cue on first input, so it
   never becomes ambient motion competing with a line.

   That distinction is why it earns its place: the rotating glyph cut
   from step 4 was perpetual motion sitting next to text. This runs only
   on the one frame that has nothing else on it.

   Adapted from a Uiverse component by PriyanshuGupta28
   (uiverse.io/PriyanshuGupta28/bright-bobcat-12), recoloured from
   matrix green to --ink. The green would have been the only saturated
   colour on a site whose palette is sampled entirely from one
   photograph. Binary digits fit now the hero is the ASCII treatment —
   the cue and the film are the same idea. */
.hero__cue {
  position: absolute;
  left: 50%; bottom: 5svh;
  transform: translateX(-50%);
  z-index: 4;
  display: flex; flex-direction: column; align-items: center; gap: .85rem;
  color: var(--ink-dim);
  font-size: var(--type-label);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  text-shadow: var(--halo);
  opacity: 0;
  transition: opacity 320ms var(--ease-out);
  pointer-events: none;
}
.hero__cue[hidden] { display: none; }   /* keeps the animation off the compositor until it's wanted */
.hero__cue[data-show] { opacity: 1; }

.matrix {
  width: 72px;
  height: 96px;
  position: relative;
  perspective: 800px;
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: 4px;
}

.digit {
  color: var(--ink);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
  font-size: 14px;
  line-height: 1;
  text-align: center;
  text-shadow: 0 0 8px rgba(251, 247, 240, .5);
  opacity: 0;
  animation: matrix-fall 2s infinite, matrix-flicker .5s infinite;
}
/* Stagger halved from the source component's 0.2s steps. At 0.2s the
   eighth digit doesn't arrive until 1.5s and the grid spends most of
   its life half-empty; at 0.09s it's fully alive inside 0.7s. */
.digit:nth-child(1) { animation-delay: .00s; }
.digit:nth-child(2) { animation-delay: .09s; }
.digit:nth-child(3) { animation-delay: .18s; }
.digit:nth-child(4) { animation-delay: .27s; }
.digit:nth-child(5) { animation-delay: .36s; }
.digit:nth-child(6) { animation-delay: .45s; }
.digit:nth-child(7) { animation-delay: .54s; }
.digit:nth-child(8) { animation-delay: .63s; }

.matrix__glow {
  position: absolute; inset: -20%;
  background: radial-gradient(circle, rgba(251,247,240,.09) 0%, transparent 70%);
  animation: matrix-pulse 2s infinite;
}

@keyframes matrix-fall {
  0%       { transform: translateY(-28px) rotateX(90deg);  opacity: 0; }
  20%, 80% { transform: translateY(0)     rotateX(0deg);   opacity: .8; }
  100%     { transform: translateY(28px)  rotateX(-90deg); opacity: 0; }
}
@keyframes matrix-flicker {
  0%, 19%, 21%, 100% { opacity: .8; }
  20%                { opacity: .2; }
}
@keyframes matrix-pulse {
  0%, 100% { opacity: .3; }
  50%      { opacity: .7; }
}

/* A grid of characters tumbling and flickering is a lot of motion, and
   flicker in particular is hostile to photosensitive users. Reduced
   motion holds the digits still at a steady opacity — the shape is
   still there, nothing moves, nothing strobes. */
@media (prefers-reduced-motion: reduce) {
  .digit, .matrix__glow { animation: none; }
  .digit { opacity: .55; }
  .matrix__glow { opacity: .3; }
}

/* =========================================================
   THE DESKTOP
   =========================================================
   The files sit on the hero's own dither layer. Not below it, not in a
   section of their own — on it. The halftone stops being a picture of
   the island and becomes the thing the island's laptop is showing, and
   the files arrive on that surface.

   TWO EARLIER VERSIONS ARE WORTH NOT REPEATING.

   The first put the plate in its own scrolling section at FULL BLEED,
   identical framing to the hero's dither layer, on the theory that two
   copies of one image with a moving edge between them have no visible
   edge. It leaves a hard horizontal seam across the viewport: the hero
   is still TRANSLATING as the section arrives, so one image is moving
   and the other is anchored and they never line up. There is no version
   of a wipe between a scrolling layer and a fixed one that hides itself.

   The second fixed the seam by making it a framed window that arrived
   over the dark page and seated itself. That worked, and it was still
   wrong: you scrolled through a full screen of the halftone only to
   land on another full screen of the halftone. The transition did all
   the work and was then immediately repeated.

   So there is one plate. The dissolve resolves into the desktop, the
   files pop in on it, and the pinned tail holds it there long enough to
   use. See TAIL and DESK_AT in hero.js. */
/* Files left, the person who made them right. Both bottom-aligned, so
   the icons stand on the same floor as the window rather than floating
   at some arbitrary height — it's a desktop, things sit on it.

   `auto` on the second column, not a fraction: the window is sized by
   its contents and the files take whatever is left. A fractional split
   made the window grow on wide screens, and a bitmap window that scales
   with the viewport stops looking like a window. */
.work {
  position: absolute;
  inset: 0;
  z-index: 3;                 /* above the media stack and the phrases */
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  align-items: end;
  gap: clamp(1.5rem, 4vw, 4.5rem);
  padding: 0 6vw 10svh;
  /* Nothing here is clickable until it's visible. Without this the
     folders are an invisible hit area over the whole lower half of the
     hero for the entire story. */
  pointer-events: none;
}
.hero[data-desk] .work { pointer-events: auto; }

/* Three across, two rows. Six in a single row spanned the full plate,
   which was fine when nothing shared it; against the window it left the
   icons thin and the labels wrapping. */
.work__grid {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  gap: clamp(1rem, 2.5vh, 2.25rem) clamp(.6rem, 1.6vw, 2rem);
  width: 100%;
  /* Capped, not fluid. Left to fill a 1440px column the three cells came
     out 380px wide with a 100px icon adrift in each — icons on a desktop
     sit near each other, they don't get distributed across the furniture.
     The leftover between the last column and the window is empty desk,
     which is what a desktop mostly is. */
  max-width: 48rem;
  margin: 0;
  padding: 0;
  list-style: none;
}

/* ---------- a folder ---------- */
.folder {
  position: relative;
  z-index: 1;
  /* The pop. Grown from the desktop floor rather than faded in — these
     are objects appearing on a surface, and a fade would say "another
     layer of the composition" instead.

     --ease-pop overshoots, which is the whole point and also why it's
     only used here: overshoot on a hover response reads as sloppy, on
     an arrival it reads as weight. Opacity runs shorter than transform
     so the icon is solid before it finishes settling.

     Six at 55ms apart lands the last one at ~660ms. That's over the
     300ms ceiling UI motion holds to, and it should be — this is an
     entrance, which is the narrative tier, not a response to a pointer. */
  opacity: 0;
  transform: scale(.8) translateY(10px);
  transform-origin: 50% 100%;
  transition:
    opacity 200ms var(--ease-ui) calc(var(--i) * 55ms),
    transform 420ms var(--ease-pop) calc(var(--i) * 55ms);
}
/* Hovered folder owns the layer — the fan is wider than the cell and
   has to cross its neighbours rather than slide under them. */
.folder:hover, .folder:focus-within { z-index: 5; }

.hero[data-desk] .folder { opacity: 1; transform: none; }

.folder__link {
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: .5rem;
  text-align: center;
}

.folder__icon {
  position: relative;
  display: block;
  width: clamp(58px, 7vw, 116px);
  aspect-ratio: 1;
}
/* z-index 2: the sheets live behind this, so they read as coming out
   from inside the folder rather than being stuck to the front of it.
   No pixelated rendering — the source art is a dithered 512px bitmap
   and nearest-neighbour downscaling to 100px destroys the pattern. */
.folder__img {
  position: relative;
  z-index: 2;
  width: 100%; height: auto;
  filter: drop-shadow(0 6px 14px rgba(16,19,14,.55));
  transition: transform 200ms var(--ease-ui);
}

/* ---------- the spill ----------
   Three sheets, anchored at a point just inside the folder's mouth and
   fanned upward. They start scaled down and fully behind the folder, so
   the first frame of the hover has nothing sticking out.

   PLACEHOLDER ART. Swap the three <img src> per folder for real case
   study stills; the geometry is driven entirely by :nth-child below and
   doesn't care what the image is. */
.folder__spill {
  position: absolute;
  left: 50%;
  bottom: 55%;
  z-index: 1;
  width: 0; height: 0;
}
.folder__sheet {
  position: absolute;
  bottom: 0; left: 0;
  width: clamp(74px, 8.5vw, 142px);
  height: auto;
  /* The reset's `img { max-width: 100% }` resolves against the
     containing block — which is a zero-width anchor point — and
     collapsed every sheet to nothing. The bug is invisible in devtools
     until you notice the width, because opacity and rotation were both
     applying correctly to an element with no area. */
  max-width: none;
  transform-origin: 50% 100%;
  transform: translate(-50%, 8%) scale(.55);
  opacity: 0;
  box-shadow: 0 10px 26px rgba(16,19,14,.6);
  transition:
    transform 220ms var(--ease-ui),
    opacity 160ms var(--ease-ui);
}
/* Fan geometry. Outer sheets travel less far and lean harder; the
   middle one goes highest and stays near-upright, which is what stops
   the spread reading as a symmetrical arc.

   The travel is deliberately short. The first pass cleared the folder
   entirely and the sheets floated above it with daylight in between —
   which is three pictures near a folder, not three pictures coming out
   of one. Their bottom edges have to stay tucked behind the folder's
   top, and the folder's z-index does the rest. */
.folder__sheet:nth-child(1) { --dx: -66%; --dy: -20%; --rot: -13deg; }
.folder__sheet:nth-child(2) { --dx:   0%; --dy: -44%; --rot:  -1deg; }
.folder__sheet:nth-child(3) { --dx:  64%; --dy: -16%; --rot:  12deg; }

/* Hover gated — touch fires :hover on tap and leaves it stuck open.
   :focus-within carries the same state for keyboard, so tabbing the row
   shows the same thing a mouse does. */
@media (hover: hover) and (pointer: fine) {
  .folder__link:hover .folder__sheet,
  .folder__link:focus-visible .folder__sheet {
    transform: translate(calc(-50% + var(--dx)), var(--dy)) rotate(var(--rot)) scale(1);
    opacity: 1;
  }
  /* 35ms apart, outermost first. The whole fan lands inside 290ms —
     under the 300ms ceiling everything that isn't narrative motion
     holds to. A hover that takes longer than that stops feeling like a
     response and starts feeling like a scene. */
  .folder__link:hover .folder__sheet:nth-child(1),
  .folder__link:focus-visible .folder__sheet:nth-child(1) { transition-delay: 0ms; }
  .folder__link:hover .folder__sheet:nth-child(3),
  .folder__link:focus-visible .folder__sheet:nth-child(3) { transition-delay: 35ms; }
  .folder__link:hover .folder__sheet:nth-child(2),
  .folder__link:focus-visible .folder__sheet:nth-child(2) { transition-delay: 70ms; }

  /* The folder lifts, and that's the whole hover state for the icon.
     The name doesn't brighten — it's already at full --ink for contrast
     reasons, and a colour change with nowhere to go is a transition
     that does nothing. The fan is the feedback. */
  .folder__link:hover .folder__img { transform: translateY(-3px) scale(1.04); }
}
/* Press gives. Scaling up-but-slightly-less than hover isn't feedback. */
.folder__link:active .folder__img { transform: scale(.96); }

/* Two lines of headroom whether the title needs them or not. Six cells
   where some titles wrap and some don't left the meta line sitting at
   six different heights — the row stopped reading as a row. Reserving
   the space costs one line of air under the short titles and buys a
   straight baseline across all of them. */
.folder__name {
  display: flex;
  align-items: flex-start;
  justify-content: center;
  min-height: 2.2em;
  font-family: var(--font-work);
  font-size: clamp(.85rem, 1.2vw, 1.2rem);
  font-weight: 300;
  line-height: 1.1;
  letter-spacing: -.005em;
  /* Full --ink, not --ink-dim. Measured against glyph coverage rather
     than the label's box, --ink-dim landed at 3.7:1 on the folder
     sitting over the bright cloud on the right. It's also the right
     hierarchy: the file name is the label, and the role/year under it
     is the annotation. */
  color: var(--ink);
  text-shadow: var(--halo);
}
/* Panchang, not Sprat. Sprat is a high-contrast didone — beautiful at
   the name size above and gone entirely at 11px.
   Not --ink-faint: at .32 over the bright half of the halftone this
   was legible in theory and invisible in practice. .75 is the floor that
   clears 4.5:1 against the worst pixel behind any of the six, at every
   breakpoint — measured against glyph coverage, not the label's box. */
.folder__meta {
  font-size: .6875rem;
  font-family: var(--font-display);
  font-weight: 400;
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  color: rgba(251, 247, 240, .75);
  text-shadow: var(--halo);
  white-space: nowrap;
}

/* =========================================================
   THE ABOUT WINDOW
   =========================================================
   The other half of the desktop. Files on the left, the person who made
   them on the right — which is the argument the whole page has been
   making, so it should land in one frame rather than two.

   Bitmap rules, held consistently or not at all: 2px borders, zero
   radius, one solid unblurred drop shadow, monospace chrome, integer
   pixel values instead of the fluid clamps used everywhere else. Every
   softness elsewhere on this page is doing a job; here softness is the
   thing being argued against, so it all goes.

   Colours are the same tokens as the rest of the site. The window reads
   as retro because of its EDGES, not because it brought its own palette
   — a black-and-electric-blue dialog would have been a second colour
   scheme on a page whose every value was sampled from one photograph. */
.about {
  --bd: 2px;
  width: clamp(248px, 25vw, 330px);
  border: var(--bd) solid var(--ink);
  /* Solid, unblurred, offset. A blurred shadow is a lighting model and
     these machines didn't have one — they had a second rectangle. */
  box-shadow: 5px 5px 0 rgba(16, 19, 14, .55);
  background: var(--deep);
  font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;

  /* Hidden until the desktop exists, exactly like the folders.
     Without this it is an opaque window sitting on the black opening
     frame for the whole story — `.work` is absolutely positioned over
     the hero from the first paint, and `pointer-events: none` stops it
     being clickable but does nothing about it being VISIBLE. The
     folders had their own reveal and the window didn't; it read as a
     leak, because it was one.

     It arrives 300ms behind them: six icons at 55ms apart finish at
     ~330ms, and a window landing in the middle of that would be a
     seventh object in the same stagger rather than the thing the files
     turn out to be sitting next to. */
  opacity: 0;
  transform: translateY(12px);
  transition:
    opacity 240ms var(--ease-ui) 300ms,
    transform 380ms var(--ease-pop) 300ms;
}
.hero[data-desk] .about { opacity: 1; transform: none; }

/* Inverted title bar, the way every one of these looked. */
.about__bar {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: .75rem;
  padding: 5px 7px;
  background: var(--ink);
  color: var(--void);
  border-bottom: var(--bd) solid var(--ink);
}
.about__title {
  font-size: .6875rem;
  letter-spacing: .04em;
}
/* Three hollow squares. They are not buttons and never pretend to be —
   no hover, no cursor change, nothing to click. Furniture that responds
   to the mouse but does nothing is worse than furniture that doesn't. */
.about__controls { display: flex; gap: 4px; }
.about__controls i {
  width: 9px; height: 9px;
  border: 2px solid var(--void);
}
.about__controls .is-close { background: var(--void); }

.about__body { padding: 12px; }

/* The sprite panel. Sunken by inverting the border weight — light on
   two sides, dark on two — which is the entire visual vocabulary those
   interfaces had for depth. */
.about__screen {
  border: var(--bd) solid var(--ink);
  background: var(--void);
  padding: 10px 8px 0;
  display: flex;
  justify-content: center;
}

/* 265 x 72 strip, five 53px cells. Stepped by background-position from
   JS; --pose is the frame index.

   image-rendering: pixelated is the whole point — the sprite is authored
   at 72px tall and shown at 216, so the browser must be told to keep the
   pixels square instead of smoothing them into the painting this was
   before it was quantised. */
.about__sprite {
  width: 159px;                       /* 53 x 3 */
  height: 216px;                      /* 72 x 3 */
  background-image: url('../media/neel-sprite.png');
  background-size: 795px 216px;       /* 265 x 3 */
  background-position: calc(var(--pose, 0) * -159px) 0;
  background-repeat: no-repeat;
  image-rendering: pixelated;
}

.about__name {
  margin-top: 12px;
  font-family: var(--font-work);
  font-size: 1.35rem;
  font-weight: 300;
  line-height: 1;
  color: var(--ink);
}
.about__role {
  margin-top: 5px;
  font-size: .625rem;
  letter-spacing: .08em;
  text-transform: uppercase;
  color: rgba(251, 247, 240, .75);
}
.about__bio {
  margin-top: 10px;
  font-family: var(--font-display);
  font-size: .8125rem;
  font-weight: 300;
  line-height: 1.45;
  color: rgba(251, 247, 240, .8);
}

/* A rule of dashes, not a border — the border would be a hairline and a
   hairline is the one thing a bitmap interface cannot draw. */
.about__now {
  margin-top: 12px;
  padding-top: 10px;
  border-top: var(--bd) dashed rgba(251, 247, 240, .3);
}
.about__label {
  font-size: .5625rem;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: rgba(251, 247, 240, .5);
}
/* Fixed height for two lines. The captions are different lengths and the
   window resizing under your cursor as you press Next is the tell that
   this is a web page pretending, rather than a window. */
.about__caption {
  margin-top: 4px;
  min-height: 2.6em;
  font-size: .75rem;
  line-height: 1.3;
  color: var(--ink);
}

/* Track position. Five segments, one per pose — a player's progress bar
   put to honest use as an index. */
.about__track {
  display: flex;
  gap: 3px;
  margin-top: 4px;
}
.about__track i {
  flex: 1;
  height: 6px;
  border: 2px solid rgba(251, 247, 240, .35);
}
.about__track i.is-on {
  background: var(--folder-teal, #74E8E8);
  border-color: var(--folder-teal, #74E8E8);
}

.about__transport {
  display: flex;
  gap: 6px;
  margin-top: 10px;
}
/* Real buttons, and both of them do something. The window has no play,
   pause or shuffle because there is nothing here to play, pause or
   shuffle — a transport of five dead glyphs and two live ones would be
   set dressing. */
.about__btn {
  flex: 1;
  padding: 7px 0;
  border: var(--bd) solid var(--ink);
  background: transparent;
  color: var(--ink);
  font: inherit;
  font-size: .6875rem;
  line-height: 1;
  cursor: pointer;
  transition: background-color 120ms linear, color 120ms linear;
}
@media (hover: hover) and (pointer: fine) {
  .about__btn:hover { background: var(--ink); color: var(--void); }
}
/* Inverts on press, like a real one. No scale — these windows had no
   transforms, and a button that shrinks is a different decade. */
.about__btn:active { background: var(--ink); color: var(--void); }

/* The gutter lives here now. It used to come from `.work { padding: … 6vw }`,
   which went when the plate took over the section — leaving the footer
   running edge to edge and the email address clipped off the right. */
/* --deep, not --void. Every colour on this page was sampled from the
   video; a neutral near-black below the fold throws that away and lands
   on the same dark-mode default every other portfolio uses. --deep is
   the frame's own shadow tone. It lives on the footer now rather than on
   a work section, because the footer is all there is down here. */
.footer {
  position: relative;
  z-index: 2;
  background: var(--deep);
  display: flex;
  flex-wrap: wrap;
  align-items: flex-end;
  justify-content: space-between;
  gap: 2rem;
  padding: 16vh 6vw 7vh;
}
.footer__line {
  font-family: var(--font-display);
  font-size: clamp(1.5rem, 3.6vw, 3rem);
  font-weight: 500;
  letter-spacing: var(--track-line);
  line-height: 1.1;
  max-width: 16ch;
}
.footer__mail {
  font-family: var(--font-display);
  font-size: var(--type-label);
  letter-spacing: var(--track-label);
  text-transform: uppercase;
  border-bottom: 1px solid var(--ink-faint);
  padding-bottom: 3px;
  transition: border-color 180ms var(--ease-ui);
}
@media (hover: hover) and (pointer: fine) {
  .footer__mail:hover { border-color: var(--ink); }
}

/* =========================================================
   ACCESSIBILITY
   ========================================================= */
/* fixed, not absolute — as an absolute element it positioned against the
   document, so focusing it while scrolled down parked it off-screen at
   the top of the page. */
.skip {
  position: fixed;
  left: 1rem; top: -6rem;
  z-index: 99;
  padding: .7rem 1rem;
  background: var(--ink);
  color: var(--void);
  font-size: var(--type-label);
  border-radius: 3px;
}
.skip:focus { top: 1rem; }

.visually-hidden {
  position: absolute !important;
  width: 1px; height: 1px;
  margin: -1px; padding: 0;
  overflow: hidden;
  clip: rect(0 0 0 0);
  white-space: nowrap; border: 0;
}

:focus-visible {
  outline: 2px solid var(--ink);
  outline-offset: 3px;
}

/* Reduced motion: gentler, not zero. Keep opacity, drop movement. */
@media (prefers-reduced-motion: reduce) {
  /* The plate becomes two stills. The hero video goes and its poster
     takes over as a background; the dither element STAYS IN THE LAYOUT,
     never played, showing its own poster — so the dissolve still runs,
     it's just a cross-fade between two photographs.

     The first version cut the dissolve entirely and revealed the files
     on the last step instead. That put the wordmark and six folders on
     the same screen, on top of each other. The sequence isn't
     decoration: the name has to leave before the work arrives, and
     that's true at every motion setting. Reduced motion means less
     movement, not fewer states. */
  .hero__video { display: none; }
  .hero__media { background: url('../media/hero-poster.jpg') center / cover no-repeat; }

  /* The step ledger needs no exception. Nothing in it moves — the active
     dot changes colour and gains a little size, both of which are
     transitions rather than travel. It's also information, and a page
     that hides how far through you are because you asked for less
     movement has misread the request. */

  /* No pop. The icons cross-fade in place; scaling six objects up off a
     surface is exactly the kind of movement the setting exists to
     remove. They still arrive — the sequence still has a moment where
     the work appears — it just doesn't travel. */
  .folder {
    transform: none;
    transition: opacity 250ms linear calc(var(--i) * 40ms);
  }
  .about { transform: none; transition: opacity 250ms linear 260ms; }

  /* The fan is a large positional move by definition, so the travel
     goes — but the sheets stay, because they're the actual content of
     the hover and not decoration. They sit at their fanned positions
     from the start and simply cross-fade in, so nothing ever moves. */
  .folder__sheet {
    transform: translate(calc(-50% + var(--dx)), var(--dy)) rotate(var(--rot));
    transition: opacity 160ms linear;
  }
  @media (hover: hover) and (pointer: fine) {
    .folder__link:hover .folder__sheet,
    .folder__link:focus-visible .folder__sheet {
      transform: translate(calc(-50% + var(--dx)), var(--dy)) rotate(var(--rot));
      transition-delay: 0ms;
    }
    .folder__link:hover .folder__img { transform: none; }
  }
}

/* =========================================================
   MOBILE
   ========================================================= */
@media (max-width: 640px) {
  .phrase { max-width: 24ch; }
  .pos--tl { transform: translate(0, -19svh); }
  .pos--br { transform: translate(0,  19svh); }
  .pos--ask { transform: translateY(11svh); }
  .pos--name { transform: translateY(23svh); }
  .line--hero { letter-spacing: .16em; margin-right: -.16em; }
  .chrome { padding: 1.1rem 6vw; }
  .chrome__nav { gap: 1.1rem; }
}

/* Six across needs about 900px before the icons start colliding with
   their own labels. Below that it wraps — and the desktop metaphor
   survives wrapping, because that's exactly what a Finder window does.

   What does NOT survive wrapping is the wash. --plate-wash darkens the
   top and the bottom and leaves the middle of the picture alone, which
   works when the files are one row along the floor of the frame. Two or
   three rows put labels straight through the middle — over the brightest
   part of the halftone, where 11px caps in --ink-dim measured 2.3:1.

   So on narrow screens the plate stops being a picture with type on it
   and becomes a wallpaper: the gradient is REPLACED by one flat veil,
   deep enough that every label clears AA against the worst pixel in the
   frame. Replaced, not stacked — stacking put the top gradient on top of
   the veil and crushed the sky to mud for no benefit, since the chrome
   sits above the plate at this width and never needed protecting. The
   halftone is near-white against near-black, so it survives being dimmed
   by two thirds and still reads as the same image. Dimming the wallpaper
   for the sake of the icons is what a desktop does. */
/* Below 1000px the two columns become two rows: files first, then the
   person. Work before bio at every width — the order is the argument. */
@media (max-width: 1000px) {
  .work {
    grid-template-columns: minmax(0, 1fr);
    align-content: center;
    align-items: stretch;
    justify-items: center;
    gap: clamp(1.25rem, 3.5svh, 2.5rem);
    padding: 8svh 6vw;
  }
  /* Same cap on both so the files and the window line up as one block.
     38rem, not 34: Sprat Extended is a wide face and "Project Three"
     wrapped at 34rem, which put every second label on two lines and the
     meta rows at different heights again. */
  .work__grid { row-gap: clamp(1.25rem, 3svh, 2.25rem); max-width: 38rem; }
  .about { width: 100%; max-width: 38rem; }
  .hero__ditherwash { background: rgba(16,19,14,.76); }

  /* Stacked, the window has width to spare and no height to spare — so
     it turns on its side: sprite beside the text instead of above it.
     Same window, laid out the way the space is shaped. */
  .about__body {
    display: grid;
    grid-template-columns: auto minmax(0, 1fr);
    gap: 12px;
    align-items: start;
  }
  .about__screen { grid-row: 1 / span 3; padding: 8px 6px 0; }
  .about__sprite { width: 106px; height: 144px; background-size: 530px 144px;
                   background-position: calc(var(--pose, 0) * -106px) 0; }
  .about__name { margin-top: 0; }
  .about__bio { display: none; }   /* the caption is doing this job here */
  .about__now { grid-column: 2; margin-top: 4px; }
  .about__track, .about__transport { grid-column: 1 / -1; }

  /* The desktop icon sizes are driven by vw, which collapses to the
     clamp floor the moment the row count drops — six across at 1400px
     and three across at 820px both landed on a 58px icon, and at three
     across it looked like a bug. Re-floor per breakpoint. */
  .folder__icon { width: clamp(76px, 11vw, 116px); }
  .folder__name { font-size: clamp(.9rem, 1.9vw, 1.15rem); }
  .folder__sheet { width: clamp(104px, 15vw, 160px); }
}

/* Two across, three rows. Three columns on a 390px screen gives each
   label about 110px, and `PRODUCT · 2026` set in caps with tracking is
   wider than that — the meta lines ran straight through each other into
   one unbroken string. Two columns costs a row of height the window can
   spare and gives every label the width it actually needs. */
@media (max-width: 560px) {
  .work { padding: 5svh 5vw; gap: 1.1rem; }
  .work__grid { grid-template-columns: repeat(2, minmax(0, 1fr)); row-gap: 1.1rem; }
  .folder__icon { width: clamp(58px, 17vw, 84px); }
  .folder__name { font-size: clamp(.85rem, 3.6vw, 1.05rem); min-height: 1.3em; }
  .folder__meta { font-size: .5625rem; }
  .folder__sheet { width: clamp(88px, 26vw, 130px); }
  .about__sprite { width: 79px; height: 108px; background-size: 397px 108px;
                   background-position: calc(var(--pose, 0) * -79px) 0; }
  .about__name { font-size: 1.15rem; }
  .about__caption { min-height: 2.4em; font-size: .6875rem; }
  /* A 16:9 frame cropped into a portrait viewport loses the cliff, which
     is the only thing in the shot with a silhouette. Bias down. */
  .hero__dither { object-position: 50% 58%; }
}

/* No-JS: the story reads as text and the files are a plain list under
   it. Everything JS would have animated into place is already in place,
   and the desktop layer stops being a layer — it can't float over a hero
   that no longer fills the screen. */
.no-js .phrase { opacity: 1; visibility: visible; position: static; transform: none; max-width: none; text-align: left; }
.no-js .hero { height: auto; padding: 20vh 0; }
.no-js .hero__stage { position: static; display: block; }
.no-js .chrome { opacity: 1; visibility: visible; }
.no-js .hero__blackout { opacity: 0; }   /* JS owns the reveal; without it, don't hide the frame */
.no-js .hero__dither, .no-js .hero__ditherwash { display: none; }
.no-js .work {
  position: static;
  padding: 14vh 6vw 6vh;
  pointer-events: auto;
}
.no-js .folder, .no-js .about { opacity: 1; transform: none; }
.no-js .folder__name, .no-js .folder__meta { text-shadow: none; }
