/* The showcase: a word wall, and a deck of cards travelling over it.
 *
 * ONE PROPERTY, ONE OWNER. Nothing in this file writes `transform`. The wall
 * animates `translate`, each wall line holds a static `translate` stagger, the
 * rail animates `translate`, and a card holds a static `translate` (its fan
 * offset) plus a static `rotate` plus a hover-only `scale`. Because those are
 * three independent properties, a card can carry a permanent tilt AND a hover
 * pop AND ride a moving rail with no matrix arithmetic and no chance of one
 * system clobbering another.
 */

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

html, body {
  margin: 0;
  /* The document never scrolls, so it never reflows. Every responsive value is
   * a custom property inside a media query, and there is no resize handler.
   * overflow-x also absorbs the deliberate card bleed at phone widths. */
  height: 100svh;
  overflow: hidden;
}

body {
  background: var(--ground);
  color: var(--ink);
  font-family: var(--f-body);
  -webkit-font-smoothing: antialiased;
}

/* ============================================================ THE WORD WALL */

/* Sizing is derived from viewport HEIGHT and a line count, never from a width
 * clamp(). That is what guarantees the reference's "cropped by all four edges"
 * read at any viewport: the type is always exactly tall enough that --n lines
 * fill the screen, so the characters are always too wide to fit and always
 * spill past both edges.
 *
 * --lb is the line box. font-size x line-height == --lb by construction, so the
 * drift distance below can be expressed in line boxes and be exact.
 *
 * svh, NOT vh. On mobile Safari vh changes when the URL bar collapses, which
 * would re-solve font-size on twelve lines of 155px Anton mid-animation - a
 * full relayout and repaint of the largest painted element on the page. This
 * page never scrolls so the bar should never collapse, but svh removes the
 * whole class of bug for nothing.
 */
.wall {
  /* --n 4 / --lh 0.94 chosen off a rendered 4-up comparison, not from the
   * reference's numbers. The reference reads as ~0.78 leading, but that face is
   * lighter than Anton: at 0.78 Anton's caps collide and the wall turns into a
   * near-solid black mass at roughly 60% ink, which buries the cards and their
   * shadows. 0.94 opens a channel of ground between lines and keeps the phrase
   * legible in fragments, which matters because the phrase IS the joke.
   * 900 / 4 / 0.94 = 239px, about 15 characters visible at 1440. */
  --n: 4;
  --lh: 0.94;
  --lb: calc(100svh / var(--n));

  position: fixed;
  inset: 0;
  overflow: hidden;
  z-index: 0;

  font-family: var(--f-display);
  font-size: calc(var(--lb) / var(--lh));
  line-height: var(--lh);
  letter-spacing: -0.015em;
  color: var(--ink);

  pointer-events: none;
  user-select: none;
}

/* The inner block starts five line boxes above the viewport and drifts DOWN by
 * exactly five line boxes. Twelve lines is 5 above + 5 of viewport + 2 of
 * safety, so the visible band is covered at every point in the cycle. */
.wall__inner {
  position: absolute;
  top: calc(-5 * var(--lb));
  left: 0;
  right: 0;
  animation: wall-drift 108s linear infinite;
}

.wall__l { white-space: nowrap; }

/* The stagger is what stops twelve identical lines reading as a rigid grid
 * instead of as continuous wrapped text.
 *
 * Its PERIOD OF FIVE is load-bearing, not decorative. Repeating every five
 * lines makes the wall translationally periodic with period 5 x --lb, so
 * translating by exactly that distance returns a pixel-identical state. The
 * loop below has no seam to hide - only a coordinate reset. Change the number
 * of offsets here and the 5 in the keyframe must change with it.
 *
 * Offsets are in em so they scale with the type, and all are negative so the
 * left edge of every line still starts off-screen and no gap can open there. */
.wall__l:nth-child(5n+1) { translate: 0        0; }
.wall__l:nth-child(5n+2) { translate: -3.40em  0; }
.wall__l:nth-child(5n+3) { translate: -1.25em  0; }
.wall__l:nth-child(5n+4) { translate: -6.10em  0; }
.wall__l:nth-child(5n+5) { translate: -2.70em  0; }

@keyframes wall-drift { to { translate: 0 calc(5 * var(--lb)); } }

/* At 1440x900 with --n:5 the drift is 900px over 108s = 8.3 px/s, against the
 * deck's 62 px/s. A ratio of 0.13 reads as static-but-alive rather than as a
 * second thing travelling.
 *
 * The wall goes DOWN while the cards go UP. Opposing motion doubles the
 * apparent separation, which buys the depth at half the absolute speed - and
 * slower means less to repaint.
 *
 * The wall's period is 5 and the deck's is 4, deliberately coprime: the two
 * layers only realign every 20 cards, so the composition never visibly ticks.
 */

@media (max-width: 640px) {
  /* Counter-intuitive but correct: RAISING --n makes the type smaller, because
   * more lines have to fit the same height. On a phone the card owns the screen
   * and the wall should be texture, so --n goes up, not down.
   * 844 / 7 / 0.94 = 128px, about 6 characters visible. */
  .wall { --n: 7; }
}

/* ==================================================================== GRAIN */

/* Paper tooth over the wall. A 64x64 white-noise tile, deterministic so the
 * file hash is stable across regenerations (see probes/make-grain.mjs).
 *
 * Tiled PNG rather than an SVG feTurbulence filter: a filter would run over the
 * largest painted element on the page every time it moves. This decodes once
 * and then costs nothing.
 *
 * It sits ABOVE the wall and BELOW the cards, so the type reads as printed on
 * the stock while the screenshots stay clean - grain over a client's website
 * would look like a compression artefact. */
.grain {
  position: fixed;
  inset: 0;
  z-index: 1;
  pointer-events: none;
  background-image: url('images/grain.png?v=4e7634b7');
  background-repeat: repeat;
  mix-blend-mode: multiply;
}

/* ================================================================= THE DECK */

.deck-media {
  position: fixed;
  inset: 0;
  overflow: hidden;
  z-index: 2;
}

/* THE SEAMLESS LOOP, and why it cannot tick.
 *
 * The rail holds the card set TWICE, so its height is 2 x N x --pitch. It
 * travels exactly -50% of its own border box, which is N x --pitch - precisely
 * one set. Deriving the distance from the element's own box rather than writing
 * a pixel number is the whole trick: the classic version of this effect sizes
 * the rail with gap/margins, guesses -50%, lands 1-3px out, and ticks once per
 * loop in a way the eye catches even when it cannot name it.
 *
 * The second half of the guarantee is that --dx repeats with period N via
 * nth-child(4n+k). So the card arriving at any given position after the reset is
 * the same distance from its neighbour, at the same horizontal offset, as the
 * one that left. There is nothing to cross-fade and nothing to hide.
 *
 * linear is non-negotiable. Any easing puts a velocity discontinuity at the
 * reset and makes the loop visible however exact the distance is.
 *
 * MEASURED 17 Aug 2026, not assumed. Comparing the frame at t=0 against t=1
 * cycle, with the wall and inner drift frozen so only the rail is under test:
 *
 *                     desktop 1440@2      phone 390@3
 *   deliberate 1px error   mean 1.134       mean 2.799   <- calibration
 *   actual seam, 1 cycle   mean 0.653       mean 1.564
 *   actual seam, 2 cycles  mean 0.603       mean 1.627
 *   actual seam, 3 cycles  mean 0.606            -
 *
 * Two things matter in that table. The seam sits well below what a genuine
 * one-pixel error measures, and it does NOT GROW with cycle count - a systematic
 * sub-pixel offset would compound, so a flat reading across three cycles is what
 * proves the distance is exact rather than merely close. The residual is
 * stationary rasterisation noise on the antialiased edges of rotated cards,
 * which mix-blend-mode on the grain layer makes slightly non-deterministic.
 *
 * verify-deck.mjs re-derives the 1px calibration on every run rather than
 * hardcoding these numbers, so the check cannot rot as the composition changes.
 */
.rail {
  --card-w: 470px;
  --card-h: 646px;
  --plate-h: 34px;
  --pitch: 512px;   /* 134px overlap = 20.7% of card height */
  --rot: -8deg;
  --dur: 33s;       /* 4 x 512 = 2048px per cycle -> 62.1 px/s */

  position: absolute;
  top: calc(-1 * var(--pitch));
  left: 0;
  right: 0;
  height: calc(8 * var(--pitch));

  animation: travel var(--dur) linear infinite;
  /* Opens the page mid-cascade instead of with a card squarely centred, which
   * reads as "this was already running". Zero JS, just a negative delay. */
  animation-delay: calc(var(--dur) * -0.35);

  /* The ONLY promoted layer on the page. Eight cards each carrying a box-shadow
   * and a rounded clip would be ~40MB of GPU memory bought on a guess; the
   * shadows paint once into this layer and travel with it. Revisit only if
   * frames.mjs shows the worst frame demanding it. */
  will-change: translate;
}

@keyframes travel { to { translate: 0 -50%; } }

/* Speed: 62 px/s. The reference reel runs at roughly 400 px/s, which works for
 * a 15-second Pinterest loop and is unusable on a page a stranger opens cold -
 * nobody can read a plate and it is nauseating. Tuning range for /tune: below
 * ~48 px/s the deck reads sleepy and the plates feel like they are waiting to be
 * read; above ~80 px/s the plate text smears. */

.card {
  position: absolute;
  top: calc(var(--i) * var(--pitch));
  left: 50%;
  width: var(--card-w);
  height: var(--card-h);

  /* Three independent properties, one owner each. `translate` carries the fan
   * offset AND the -50% centring; `rotate` carries the permanent tilt; `scale`
   * is left free for hover. None of them is `transform`, so the travelling rail
   * above cannot collide with any of them. */
  translate: calc(-50% + var(--dx)) 0;
  rotate: var(--rot);

  border-radius: var(--radius);
  overflow: hidden;
  /* Same clip the overflow already implies, but stated as a promise rather than
   * left for the engine to discover - which is what keeps a rounded corner on an
   * animating subtree off a separate mask layer. */
  contain: paint;

  background: var(--paper);
  text-decoration: none;
  color: var(--ink);
  box-shadow:
    0 34px 68px -22px rgba(20, 18, 8, .34),
    0  4px 12px  -4px rgba(20, 18, 8, .18);
}

/* Stacking order within the rail: a card must sit UNDER the one below it in the
 * cascade, or the overlap reads as a deck being dealt upward instead of a
 * fanned stack. */
.card:nth-child(1) { --i: 0; z-index: 8; }
.card:nth-child(2) { --i: 1; z-index: 7; }
.card:nth-child(3) { --i: 2; z-index: 6; }
.card:nth-child(4) { --i: 3; z-index: 5; }
.card:nth-child(5) { --i: 4; z-index: 4; }
.card:nth-child(6) { --i: 5; z-index: 3; }
.card:nth-child(7) { --i: 6; z-index: 2; }
.card:nth-child(8) { --i: 7; z-index: 1; }

/* Fan offsets, PERIOD 4 = the card count. This is the half of the seam
 * guarantee that lives in the horizontal axis: the card replacing another at the
 * same position always carries the same --dx. */
.card:nth-child(4n+1) { --dx: -330px; }
.card:nth-child(4n+2) { --dx:  104px; }
.card:nth-child(4n+3) { --dx: -190px; }
.card:nth-child(4n+4) { --dx:  246px; }

.card__win {
  display: block;
  position: absolute;
  inset: 0 0 var(--plate-h) 0;
  overflow: hidden;
  background: var(--paper);
}

/* INNER DRIFT - travel DERIVED from the geometry, never eyeballed.
 *
 * The poster is --over times the height of its window, so it can travel by
 * exactly the excess and no further. Writing the keyframe as a percentage of the
 * image's OWN height is what makes it exact:
 *
 *   H = 1.34 F          poster height, F = window height
 *   travel = H - F = 0.34 F = 0.34/1.34 of H = 25.373% of H
 *
 * At -25.4% the poster's bottom edge lands flush with the window's bottom. One
 * decimal more and a strip of paper shows at the bottom of a card, once per
 * cycle, on the page a prospect opens cold. This is window-parallax applied
 * vertically: derive the travel so an edge cannot show.
 */
.card__poster {
  display: block;
  width: 100%;
  height: calc(100% * var(--over));
  object-fit: cover;
  object-position: top center;
  animation: drift 15s var(--e-inout) infinite alternate;
  animation-delay: var(--d);
}

.rail { --over: 1.34; }

@keyframes drift { to { translate: 0 -25.373%; } }

/* Desync so four cards are not scrolling in lockstep. Period 4 again, and it
 * MUST be period 4: if the two copies of a card carried different delays, the
 * card arriving after the reset would be at a different point in its scroll than
 * the one that left, and the seam the rail works so hard to hide would reappear
 * inside the cards. */
.card:nth-child(4n+1) .card__poster { --d:  -2.1s; }
.card:nth-child(4n+2) .card__poster { --d:  -8.4s; }
.card:nth-child(4n+3) .card__poster { --d:  -4.3s; }
.card:nth-child(4n+4) .card__poster { --d: -13.1s; }

/* `alternate` means the page scrolls down, eases to a stop, and comes back up.
 * Real pages do not scroll upward, so this is a compromise - but the honest
 * alternative (`normal` with a hard reset) snaps every 15s across four cards at
 * once, which is far more noticeable than a slow reversal. --e-inout decelerates
 * into each turnaround so it reads as a survey rather than a rewind. */

/* THE PLATE - what makes this a portfolio rather than a graphic.
 *
 * It sits INSIDE the card, on paper, so it reads as part of the artefact rather
 * than as UI floating over the composition.
 *
 * It also fixes a measurement problem. verify.mjs check 5 walks up the tree for
 * the first ancestor with a non-transparent background. A label floating over
 * the word wall has no opaque ancestor until <body>, so the check would read
 * --ground, report a comfortable 14:1, and be measuring a colour that is not
 * behind the text at all - the real pixels there are a black letterform. Giving
 * the plate its own opaque background makes the check measure reality. */
.card__plate {
  position: absolute;
  inset: auto 0 0 0;
  height: var(--plate-h);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--u);
  padding: 0 12px;
  background: var(--paper);
  border-top: 1px solid var(--line);

  font-family: var(--f-mono);
  font-size: 10px;
  letter-spacing: .16em;
  text-transform: uppercase;
  white-space: nowrap;
}

.card__brand  { color: var(--ink);   font-weight: 400; }   /* 16.56:1 on paper */
.card__status { color: var(--ink-2); }                     /*  6.38:1 on paper */

/* ================================================================ PHONE */

@media (max-width: 640px) {
  .rail {
    --card-w: 268px;
    --card-h: 368px;
    --plate-h: 30px;
    --pitch: 292px;      /* same 20.7% overlap ratio */
    --dur: 21s;          /* 4 x 292 = 1168px -> 55.6 px/s */
  }
  /* The fan must tighten or a card leaves the screen entirely: a -330px offset
   * on a 390px viewport puts the whole card off the left edge. These keep the
   * cascade legible while still letting the right edge bleed deliberately. */
  .card:nth-child(4n+1) { --dx: -54px; }
  .card:nth-child(4n+2) { --dx:  46px; }
  .card:nth-child(4n+3) { --dx: -30px; }
  .card:nth-child(4n+4) { --dx:  58px; }
  .card__plate { font-size: 9px; letter-spacing: .12em; padding: 0 9px; }
}

/* ====================================================== CORNER FURNITURE */

/* The <h1> lives here, not in the wall. Twelve repeats of one phrase as a
 * heading would be an SEO liability and a screen-reader disaster. */
.tag {
  position: fixed;
  z-index: 3;
  margin: 0;
  background: var(--paper);
  padding: 8px 12px;
  border-radius: 3px;
  font-family: var(--f-mono);
  font-size: 11px;
  font-weight: 400;
  line-height: 1.5;
  letter-spacing: .16em;
  text-transform: uppercase;
  color: var(--ink);
  box-shadow: 0 2px 10px -4px rgba(20, 18, 8, .3);
}

.tag--tl { top: 18px; left: 18px; }
.tag__sep { color: var(--ink-2); }

.tag--br {
  right: 18px;
  bottom: 18px;
  max-width: min(400px, calc(100vw - 36px));
  letter-spacing: .08em;
  text-transform: none;
}

/* The four real client URLs are not a footnote here, they are the evidence.
 * Four invented brands prove craft; only the shipped sites prove track record,
 * and a stranger who opens this cold needs both. Separating demos from clients
 * by FORMAT - cards versus a named list - keeps that honest without hanging an
 * apologetic label on every card. */
.tag__note { margin: 0 0 6px; color: var(--ink-2); font-size: 10.5px; }
.tag__note a { color: var(--ink); text-decoration-color: var(--line); }
.tag__note a:hover { text-decoration-color: currentColor; }

.tag__cta {
  display: inline-block;
  margin-top: 4px;
  color: var(--ink);
  font-size: 11px;
  letter-spacing: .16em;
  text-transform: uppercase;
  text-underline-offset: 3px;
}

@media (max-width: 640px) {
  /* On a phone the deck owns the screen. The bottom block keeps the client URLs
   * but drops to the smallest size that still measures above the AA floor, and
   * the top tag shortens rather than wrapping across two lines over the wall. */
  .tag { font-size: 10px; padding: 7px 10px; }
  .tag--tl { top: 12px; left: 12px; }
  .tag--br { right: 12px; bottom: 12px; left: 12px; max-width: none; }
  .tag__note { font-size: 10px; }
}

/* The corner chips sit over a full-screen deck, so they geometrically overlap
 * cards - verify.mjs check 7 reports this and is right to. It is intentional
 * corner furniture rather than a stray fixed header, and the check is left
 * failing rather than silenced: renaming an element until a probe goes quiet is
 * how a real collision gets missed later.
 *
 * What IS fixed is the consequence. A chip covering part of a card used to
 * swallow the click for that whole rectangle. Now only the chip's own links take
 * pointer events, so a card behind a chip stays clickable everywhere except on
 * the four words that are genuinely on top of it. */
.tag { pointer-events: none; }
.tag a { pointer-events: auto; }

/* ========================================================= MOTION GUARDS */

/* PAUSE ON INTENT. The highest-leverage rule in the file.
 *
 * Without it this is a hostile sales asset: four links moving at 62 px/s that a
 * buyer has to chase. With it, the mouse enters, everything stops dead, and four
 * plates can be read and one clicked. :focus-within gives a keyboard user the
 * same freeze when they tab into the deck.
 *
 * The pause is instant by design. A transitioned slow-down feels like lag. */
@media (hover: hover) and (pointer: fine) {
  .deck-media:hover .rail,
  .deck-media:focus-within .rail,
  .deck-media:hover .card__poster,
  .deck-media:focus-within .card__poster {
    animation-play-state: paused;
  }

  .card { transition: opacity var(--t-fast) var(--e-out), scale var(--t-fast) var(--e-out); }
  /* Dim the others with opacity, never filter: filter on eight layers is a
   * repaint per frame, opacity is a composited property. */
  .deck-media:hover .card { opacity: .5; }
  .card:hover { opacity: 1; scale: 1.02; }
}

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

/* REDUCED MOTION: a different LAYOUT, not a dead page.
 *
 * Simply stopping the marquee would leave about two of four cards on screen and
 * the rest permanently unreachable - the sales asset would break for the people
 * most likely to need it to work. So the page becomes a real page: a scrollable
 * column of tilted, shadowed, linked cards on a static type wall. The rotation
 * is deliberately KEPT; it is composition, not motion. */
@media (prefers-reduced-motion: reduce) {
  html, body { height: auto; overflow-y: auto; overflow-x: hidden; }
  .wall__inner { animation: none; }
  .deck-media { position: static; overflow: visible; }
  .rail {
    position: static; height: auto; animation: none;
    display: grid; justify-items: center; gap: 92px;
    padding: 132px 16px 168px;
  }
  .card { position: static; translate: 0 0; }
  .card:nth-child(n+5) { display: none; }   /* the duplicate set has no job here */
  .card__poster { animation: none; height: 100%; }
  .tag--tl { position: absolute; }
}
