/* ============================================================
   EN¿GMA MADE — home.

   Three full-bleed photographs you can page through. Each one
   carries its own puzzle; solving it reveals a way through to
   a product.

   Slide 1 geometry comes from the original Webflow stylesheet
   rather than being eyeballed: .parent is 457x220 because ENIGMA
   is exactly 457px of letter at natural size, in rows of 110.
   ============================================================ */
:root{
  --grey:#575757;              /* sampled off the live page */
  --ease:cubic-bezier(.22,.61,.36,1);
  --spring:cubic-bezier(.16,1.02,.3,1);
  --pad:clamp(18px,4vw,64px);
  --nav-h:76px;
  /* One letter's height, and therefore the row height, the distance the
     hover roll travels and half the wordmark's box. It is a variable
     because the mobile rules shrink the letters to ~70% by width only:
     the artwork is scale-down-fitted, so it drew 77px tall inside a row
     still fixed at 110, leaving 16px of dead space above and below each
     row and a 33px gap between ENIGMA and MADE that desktop does not
     have. Measured, not guessed. Change this and the row, the roll and
     the arrow stay in step. */
  --lh:110px;
}
*{box-sizing:border-box}
/* .reward sets display:inline-flex, which outranks the UA sheet's [hidden]
   — without this the reward link is in the layout on every slide, merely
   transparent, and still catching the odd stray click. */
[hidden]{display:none !important}
html,body{height:100%}
body{
  margin:0;background:#000;color:#fff;overflow:hidden;
  font:400 15px/1.6 var(--font-body);
  -webkit-font-smoothing:antialiased;
}
img{display:block;max-width:100%}

/* ============================================================
   STAGE + BACKGROUNDS
   ============================================================ */
.section{
  position:relative;width:100vw;height:100svh;overflow:hidden;
}
.bg{
  position:absolute;inset:0;z-index:0;
  background-position:50% 50%;background-size:cover;
  opacity:0;transition:opacity 900ms var(--ease);
}
.bg.on{opacity:1}

/* The parallax canvas. Same layer as the .bg divs and later in the DOM,
   so it paints over them without needing a z-index of its own — and
   everything already aimed at #bgs (the opening fade, the fade to black
   on a solve) carries it along for free.

   It is opaque and covers them completely once .on, so the .bg divs are
   not doing double duty: they are the first paint, the fallback for a
   device that gets no canvas, and the layer that takes back over if a
   slide's textures are slow or a GL context is lost. That is why the
   fade is 260ms rather than instant — the swap between the two layers
   should never be a cut. */
.px{
  position:absolute;inset:0;z-index:0;
  width:100%;height:100%;display:block;
  opacity:0;transition:opacity 260ms var(--ease);
  pointer-events:none;
}
.px.on{opacity:1}
/* Photographs are bright and the type is white — without a scrim the
   wordmark disappears into the sky. */
.section::after{
  content:'';position:absolute;inset:0;z-index:1;pointer-events:none;
  background:
    linear-gradient(to bottom, rgba(0,0,0,.6), transparent 20%),
    linear-gradient(100deg, rgba(0,0,0,.55), rgba(0,0,0,.1) 62%);
}

/* the curtain that closes when a puzzle is solved */
.black{
  position:absolute;inset:0;z-index:5;background:#000;
  opacity:0;pointer-events:none;
  transition:opacity 1400ms var(--ease);
}
.solved .black{opacity:1}

/* ============================================================
   NAV
   ============================================================ */
/* Geometry and the underline live in base.css — the two pages have to
   agree or the mark jumps when you cross between them.

   The nav used to fade out on a solve, which left the reveal with no
   way off it except the reward link or Again. Solving a puzzle is the
   moment someone is most likely to want the next one, so the menu
   stays. It lifts above the curtain rather than sitting under it. */
.solved .nav{ z-index:7 }

/* ============================================================
   SLIDES
   ============================================================ */
.slide{
  position:absolute;inset:0;z-index:3;
  display:flex;flex-direction:column;justify-content:center;align-items:flex-start;
  opacity:0;pointer-events:none;
  transition:opacity 600ms var(--ease);
}
.slide.on{opacity:1;pointer-events:auto}
/* .black sits at z-index 5. A child of .slide can't rise above it on its own,
   because .slide (z-index 3) creates the stacking context they're trapped in —
   so the ancestors lift, not just the E and the arrow. */
.solved .slide.on{z-index:6}
.solved .parent{z-index:6}

/* ---------- paging ---------- */
/* A hairline chevron and nothing else — no disc, no blur, no border.
   Hit area stays 44px for thumbs; only the stroke is visible. */
.pager{
  position:absolute;z-index:10;top:50%;translate:0 -50%;
  width:46px;height:56px;padding:0;border:0;border-radius:0;background:none;
  color:rgba(255,255,255,.62);cursor:pointer;display:grid;place-items:center;
  transition:color .24s var(--ease), transform .34s var(--spring);
}
.pager svg{
  width:22px;height:22px;fill:none;stroke:currentColor;
  stroke-width:1;stroke-linecap:round;stroke-linejoin:round;
  filter:drop-shadow(0 1px 6px rgba(0,0,0,.55));   /* legibility over a photo, not decoration */
}
.pager.prev{left:clamp(4px,1.6vw,22px)}
.pager.next{right:clamp(4px,1.6vw,22px)}
@media (hover:hover){
  .pager:hover{color:#fff}
  .pager.prev:hover{transform:translateY(-50%) translateX(-3px)}
  .pager.next:hover{transform:translateY(-50%) translateX(3px)}
}

/* ---------- phones ----------
   Halfway up the screen the arrows sat directly on top of the puzzle —
   on the sliding board they were over live tiles. Down here they flank
   the dots on a line of their own, out of the board's way, and they get
   a ring so they read as controls rather than as decoration. A hairline
   chevron floating over a photograph is invisible on a phone held at
   arm's length. */
@media (max-width:720px){
  .pager{
    top:auto;translate:none;
    bottom:calc(env(safe-area-inset-bottom, 0px) + 16px);
    width:44px;height:44px;border-radius:50%;
    border:1px solid rgba(255,255,255,.34);
    background:rgba(0,0,0,.34);
    backdrop-filter:blur(6px);-webkit-backdrop-filter:blur(6px);
    color:#fff;
  }
  .pager svg{width:19px;height:19px;stroke-width:1.5}
  .pager.prev{left:calc(50% - 92px)}
  .pager.next{right:calc(50% - 92px)}
}
.solved .pager{opacity:0;pointer-events:none;transition:opacity 500ms var(--ease)}

.dots{
  position:absolute;z-index:10;left:50%;translate:-50% 0;bottom:clamp(18px,3.4vh,34px);
  display:flex;gap:10px;
}
.dot{
  width:7px;height:7px;border-radius:50%;padding:0;cursor:pointer;
  border:1px solid rgba(255,255,255,.6);background:none;
  transition:background .24s var(--ease), transform .3s var(--spring);
}
.dot[aria-current="true"]{background:#fff;transform:scale(1.25)}
.solved .dots{opacity:0;pointer-events:none;transition:opacity 500ms var(--ease)}
/* after the base rule, or it loses the cascade to it — the dots share
   the bottom line with the arrows on a phone */
@media (max-width:720px){
  .dots{bottom:calc(env(safe-area-inset-bottom, 0px) + 34px)}
}

/* ============================================================
   SLIDE 1 — the wordmark puzzle
   ============================================================ */
.parent{
  position:relative;z-index:3;
  display:flex;flex-wrap:wrap;flex-direction:row;
  justify-content:flex-start;align-items:center;
  width:457px;height:calc(var(--lh) * 2);
  margin:15vh auto auto var(--pad);
}
/* The button is a window one letter tall. Everything about the rollover
   depends on that height matching the artwork exactly: the stack inside
   travels --lh, which is the height the letter is actually DRAWN at, so
   the twin lands precisely where the original stood. It used to be a
   hard 110px on the assumption that only the width changed on a phone —
   it doesn't; scale-down shrinks the artwork in both directions.

   overflow:hidden is what makes it a window rather than two letters. */
.letter{
  position:relative;display:block;border:0;padding:0;background:none;
  cursor:pointer;line-height:0;overflow:hidden;height:var(--lh);
  /* 320ms on the lean so it tracks the pointer, 420ms on opacity for the
     spend. The roll has its own, longer, timing on .roll below. */
  transition:opacity 420ms var(--ease), transform 320ms var(--spring);
}
.letter img{pointer-events:none;display:block}
.roll{display:block;transition:transform 520ms var(--spring)}
.letter:hover .roll,
.letter:focus-visible .roll{transform:translateY(calc(var(--lh) * -1))}
.letter:focus-visible{outline:2px solid #fff;outline-offset:3px}

/* A spent key holds still. The lean is written inline by home.js and
   cleared there; this is the belt to that braces — the roll is refused
   outright, so a pointer resting on a letter as it is used doesn't drag
   the twin halfway up the window. */
.letter.used{opacity:.38;cursor:default;transform:none}
.letter.used:hover{transform:none}
.letter.used:hover .roll{transform:none}

@media (prefers-reduced-motion:reduce){
  .letter:hover .roll,
  .letter:focus-visible .roll{transform:none}
}

/* everything goes but the one E */
.solved .letter{opacity:0;transition:opacity 900ms var(--ease)}
.solved .letter.keep{opacity:1;z-index:7}

@keyframes wrong{
  0%,100%{transform:translateX(0)}
  20%{transform:translateX(-7px)} 40%{transform:translateX(6px)}
  60%{transform:translateX(-4px)} 80%{transform:translateX(3px)}
}
.parent.wrong{animation:wrong 420ms var(--ease)}

/* ---------- the reward arrow, sitting in the N's slot ----------
   The E stays where it is and the arrow takes the space beside it,
   so the finished screen reads "E>". `left` is the E's width, which
   is why it changes at the mobile breakpoint. */
.arrow{
  position:absolute;z-index:7;left:64px;top:0;
  width:64px;height:var(--lh);display:grid;place-items:center;
  opacity:0;transform:translateX(-14px);pointer-events:none;
}
.solved .arrow{
  opacity:1;transform:none;pointer-events:auto;
  transition:opacity 700ms var(--ease) 900ms, transform 900ms var(--spring) 900ms;
}
.arrow img{width:100%}
.arrow:hover{transform:translateX(9px);transition:transform 400ms var(--spring)}

/* ---------- the answer row ---------- */
.slots{
  position:relative;z-index:3;display:flex;
  width:252px;height:41px;
  margin:auto auto 25vh var(--pad);
}
.slot{position:relative;width:28px;height:41px;flex:0 0 28px}
.slot img{position:absolute;inset:0}
/* The letter plates were drawn in the same mid grey as the blank, which
   made a solved slot look barely different from an empty one. Filter
   rather than a second set of PNGs: brightness(0) flattens the artwork to
   black and invert(1) takes it to white, alpha untouched. */
.slot .fill{opacity:0;filter:brightness(0) invert(1);transition:opacity 340ms var(--ease)}
.slot.on .fill{opacity:1}
.slot.on .blank{opacity:0;transition:opacity 340ms var(--ease)}
.slot.hint .fill{opacity:1}          /* the freebie E, as on the original */
.solved .slots{opacity:0;visibility:hidden;transition:opacity 700ms var(--ease), visibility 0s linear 700ms}

/* ============================================================
   SLIDE 2 — the grid  ("Twenty Six")
   ============================================================ */
/* One shape everywhere: a 26-key pad, four across, seven down. The old
   desktop board (six rows of twenty-six) was 4px a box on a phone and a
   different puzzle on each device. No scrim behind it — the boxes are
   outlines on the photograph and nothing else. */
.code{
  position:relative;z-index:3;margin:auto;
  /* Sized by the height it has, not only the width. Seven rows of square
     cells is a tall object; picking the width from viewport width alone
     let the board grow until its top row sat under the mark and the menu.
     4 columns of c plus 3 gaps against 7 rows of c plus 6 gaps works out
     at roughly 0.57 of the free height, and the reserve is the header,
     the pips and the row of controls at the bottom. */
  --reserve:250px;
  width:min(
    360px,
    calc(100vw - var(--pad) * 2),
    calc((100svh - var(--reserve)) * 0.57)
  );
  display:grid;gap:clamp(7px,1.1vh,11px);
}
@media (min-width:721px){ .code{--reserve:210px} }
.row26{display:grid;grid-template-columns:repeat(4,1fr);gap:clamp(7px,1.1vh,11px)}

/* The ring is an inset box-shadow rather than a border, because the hover
   state thickens it: a border-width change reflows the box and the whole
   grid twitches, where a shadow is painted and can be interpolated. */
.box{
  position:relative;aspect-ratio:1/1;padding:0;cursor:pointer;overflow:hidden;
  border:0;border-radius:2px;
  box-shadow:inset 0 0 0 1px rgba(255,255,255,.78);
  background:transparent;color:#fff;font:inherit;font-weight:600;
  font-size:15px;
  display:grid;place-items:center;
  transition:background .18s var(--ease), box-shadow .2s var(--ease);
}
/* The fill. A pseudo-element wiping up from the bottom rather than a
   background colour, for the same reason the try-on button uses one:
   a background cannot be animated directionally. */
.box::before{
  content:'';position:absolute;inset:0;background:#fff;
  transform:scaleY(0);transform-origin:bottom center;
  transition:transform 420ms var(--wipe);
}
/* The stack has to fill the key or translateY(-90%) only lifts the
   numeral by its own line box and it never clears the window. */
.box .roll{display:grid;width:100%;height:100%}
.box .roll>span{
  grid-area:1/1;display:grid;place-items:center;
  width:100%;height:100%;position:relative;z-index:1;
}
.box .n-in{
  opacity:0;color:#0a0a0a;font-weight:800;font-size:var(--n-big);
  letter-spacing:-.02em;transform:translateY(90%);
  transition:transform 440ms var(--spring), opacity 240ms var(--ease);
}
/* Keyboard only. The pointer half of this lives in the (hover:hover)
   block below — see the note there; these two rules used to be out here
   with it and that is what stuck on a phone. */
.box:focus-visible::before{transform:scaleY(1)}
.box:focus-visible .n-in{transform:translateY(0);opacity:1}
/* The numbers are always readable, just barely — enough to count by
   without turning the grid into a page of figures. No shadow: they are
   meant to sit in the photograph rather than float above it, and over a
   pale frame a faint numeral is allowed to go quiet. */
.box .n{
  opacity:.2;
  transition:transform 440ms var(--spring), opacity 240ms var(--ease);
}

/* Hover: the resting number leaves through the top as its heavy twin
   comes up from underneath, and the ring firms up behind the fill.

   EVERY :hover rule for a key belongs in here, with no exceptions.

   A touch browser applies :hover on tap and holds it until you tap
   something else — there is no pointer to leave, so the hover-out never
   runs. The fill and the heavy numeral were declared outside this block
   (only the ring and the resting numeral were guarded), so on a phone a
   tapped key kept its white fill and black number after the tick or the
   cross had faded: the state had nothing to take it away.

   Guarding it here rather than clearing it in JS on pointerup is the fix
   because it stops the state ever being entered, instead of entering it
   and then racing to undo it. */
@media (hover:hover){
  .box:hover{box-shadow:inset 0 0 0 2px rgba(255,255,255,.95)}
  .box:hover .n{transform:translateY(-90%);opacity:0}
  .box:hover::before{transform:scaleY(1)}
  .box:hover .n-in{transform:translateY(0);opacity:1}
  /* .box.used .n sets opacity:1 and comes after the hover rule, so
     without this a spent key would show both numbers at once over the
     white fill — the big one standing still and its twin rolling up
     through it. */
  .box.used:hover .n{opacity:0;transform:translateY(-90%)}
}
.box:focus-visible .n{transform:translateY(-90%);opacity:0}

/* A key that has been used inverts the pair: its number is the thing you
   want to see, and its ring drops back to say the key is spent. Still
   clickable on purpose — a code with a repeated letter needs it twice.

   The number arrives at full weight with a short overshoot, so the moment
   of getting one right is legible from across the board and not only in
   the pips underneath. */
.box.used{box-shadow:inset 0 0 0 1px rgba(255,255,255,.2)}
.box.used .n{
  opacity:1;font-size:var(--n-big);font-weight:800;letter-spacing:-.02em;
  animation:num-set 520ms var(--spring);
}
@keyframes num-set{
  0%{transform:scale(1)} 38%{transform:scale(1.42)} 100%{transform:scale(1)}
}
/* Keyboard twin of the .box.used:hover rule in the block above. */
.box.used:focus-visible .n{opacity:0;transform:translateY(-90%)}
@media (prefers-reduced-motion:reduce){
  .box.used .n{animation:none}
  .box:focus-visible .n{transform:none;opacity:0}
  .box:focus-visible .n-in{transform:none}
}
/* Same rule as above for the pointer, and guarded the same way — a phone
   set to reduce motion is still a phone, and `.box:hover .n{opacity:0}`
   left unguarded would strand a tapped key with no numeral at all. */
@media (prefers-reduced-motion:reduce) and (hover:hover){
  .box:hover .n{transform:none;opacity:0}
  .box:hover .n-in{transform:none}
}
.box:focus-visible{outline:1px solid #fff;outline-offset:2px}
/* Above the fill AND above the two numerals, which sit at z-index 1 so
   they clear the fill themselves. Without this the tick is drawn behind
   a numeral that happens to be mid-roll. */
.box svg{
  position:absolute;inset:22%;width:56%;height:56%;z-index:2;
  fill:none;stroke-width:2;stroke-linecap:round;stroke-linejoin:round;opacity:0;
}
/* Right and wrong are the same white key with a different mark drawn on
   it, and both are cleared within half a second. Two colours would be a
   third and fourth colour on a page that has black, white and a
   photograph. */
/* Last, so a mark beats hover — the pointer is still on the key you just
   clicked, and the tick has to be what you see. */
.box.right,.box.wrong{background:#fff;box-shadow:inset 0 0 0 1px #fff}
/* The fill is already white underneath, so it is held down rather than
   animated away — a wipe retracting behind a mark that is being drawn on
   top of it is two things happening in the same half-second. */
.box.right::before,.box.wrong::before{transform:scaleY(1);transition:none}
.box.right .tick{stroke:#0a0a0a;opacity:1;stroke-dasharray:32;stroke-dashoffset:32;animation:draw 420ms var(--ease) forwards}
.box.wrong .cross{stroke:#0a0a0a;opacity:1;stroke-dasharray:26;stroke-dashoffset:26;animation:draw 300ms var(--ease) forwards}
/* Both numerals go — the mark is the whole message for that half-second,
   and the incoming twin would otherwise ride up behind the tick. */
.box.right .n,.box.wrong .n,
.box.right .n-in,.box.wrong .n-in{opacity:0;animation:none}
@keyframes draw{to{stroke-dashoffset:0}}

@keyframes shake26{
  0%,100%{transform:translateX(0)} 20%{transform:translateX(-8px)}
  40%{transform:translateX(7px)} 60%{transform:translateX(-4px)} 80%{transform:translateX(3px)}
}
.code.bad{animation:shake26 420ms var(--ease)}

/* ---------- progress: six pips, one per letter of the code ---------- */
.pips{display:flex;justify-content:center;gap:9px;margin-top:12px}
.pip{
  width:20px;height:1px;background:rgba(255,255,255,.34);
  transition:background .3s var(--ease), height .3s var(--ease);
}
.pip.on{background:#fff;height:2px}

/* ---------- the finale: the alphabet, with the answer picked out ---------- */
#code-out{
  position:absolute;inset:0;z-index:8;
  display:none;flex-direction:column;align-items:center;justify-content:center;
  gap:clamp(4px,1vh,12px);pointer-events:none;
}
body.solved-26 #code-out{display:flex}
/* Once it's solved the board itself gets out of the way — otherwise the
   ticked grid shows through the reveal. visibility, not just opacity,
   so a stalled transition can't leave ghosts behind. */
body.solved-26 .slide,
body.solved-26 .pager,
body.solved-26 .slots,
body.solved-26 .reset,
body.solved-26 .reward,
body.solved-26 .dots{
  opacity:0 !important;visibility:hidden;
  transition:opacity 600ms var(--ease), visibility 0s linear 600ms;
}
/* The same grid the puzzle was played on — four columns, same width, same
   gap — with the boxes gone and the letters left standing. It reads as the
   board resolving rather than as a different screen arriving. Twenty-six
   letters in fours leaves two empty cells at the bottom right, and that is
   where the way out goes. */
.outgrid{
  display:grid;grid-template-columns:repeat(4,1fr);
  /* the same expression the board uses, so the letters land exactly
     where the keys were */
  --reserve:250px;
  --gap:clamp(7px,1.1vh,11px);
  --w:min(
    360px,
    calc(100vw - var(--pad) * 2),
    calc((100svh - var(--reserve)) * 0.57)
  );
  width:var(--w);
  gap:var(--gap);
  /* Every row is exactly one key tall, stated rather than inferred. The
     rows used to be sized by their tallest child, so the arrow — which
     shares the last row with Y and Z — grew that row and shoved those two
     letters down out of the grid the keys had been sitting in. */
  grid-auto-rows:calc((var(--w) - var(--gap) * 3) / 4);
  align-items:stretch;
}
@media (min-width:721px){ .outgrid{--reserve:210px} }
@keyframes fadeup{from{opacity:0;transform:translateY(10px)}to{opacity:1;transform:none}}
.lo{
  /* The row already is a square cell; asking for aspect-ratio as well
     would let a letter size its own row again. */
  display:grid;place-items:center;
  font-family:var(--font-display);
  /* Bigger than the numbers were on the board: with the boxes gone the
     letters have to carry the grid themselves, or twenty-six small marks
     on black read as scatter rather than as an alphabet. */
  font-size:clamp(21px,6vw,28px);font-weight:500;
  color:rgba(255,255,255,.15);          /* the also-rans */
  opacity:0;animation:fadeup 620ms var(--ease) forwards;
  animation-delay:calc(900ms + var(--i,0) * 26ms);
}
.lo.hit{color:#fff;font-weight:700}
.bigarrow{
  grid-column:3 / span 2;                /* the gap the alphabet leaves */
  display:grid;place-items:center;
  pointer-events:auto;opacity:0;
  animation:fadeup 700ms var(--ease) 1750ms forwards;
  transition:transform 380ms var(--spring);
}
/* Capped by the row as well as by the column, so the arrow can never be
   the thing that decides how tall the last row is. */
.bigarrow img{width:min(100%,108px);max-height:100%;height:auto;object-fit:contain}
.bigarrow:hover{transform:translateX(9px)}

/* ============================================================
   SLIDE 3 — the sliding picture puzzle
   ------------------------------------------------------------
   Fifteen tiles cut from the photograph plus one hole. Tiles are
   absolutely placed and moved with transform, not reordered in
   the DOM — the browser can then interpolate the slide, and the
   tile keeps its identity for assistive tech.
   ============================================================ */
/* Three across, four down, cut from the same photograph the slide is
   already showing (bg/03-tall.jpg), so the board carries the whole
   picture and finishing it puts the picture back together.

   The board is the only element with a size. Tiles, the missing piece
   and the gaps are all percentages of it, so one scale() on .pz grows
   the finished photograph out to fill the screen and everything inside
   comes with it — nothing has to be re-measured. */
.pz{
  --cols:3; --rows:4;
  --gap:clamp(3px,.5vw,7px);
  /* one cell, declared on the board so tiles and the hole share it */
  --w:calc((100% - (var(--cols) - 1) * var(--gap)) / var(--cols));
  --h:calc((100% - (var(--rows) - 1) * var(--gap)) / var(--rows));
  position:relative;z-index:3;margin:auto;
  height:min(66svh, 640px);aspect-ratio:900/1350;max-width:min(78vw,460px);
  transform-origin:50% 50%;
  transition:transform 1100ms var(--spring);
}
/* Grown to fill the screen once it's whole — the photograph, entire. */
.pz.grown{transform:scale(var(--k,1))}

.pt{
  position:absolute;left:0;top:0;
  width:var(--w);height:var(--h);
  padding:0;border:0;cursor:pointer;
  /* image, size and position are all set inline by home.js.
     A relative url() held in a custom property resolves against the
     STYLESHEET, not the document — via this file that became
     /assets/css/assets/img/... and every tile 404'd. Inline styles
     resolve against the document, which is what we want. */
  background-repeat:no-repeat;
  box-shadow:0 0 0 1px rgba(255,255,255,.5) inset, 0 6px 18px rgba(0,0,0,.5);
  /* The tile's home position is held in two custom properties rather
     than written straight into transform, so the hover and the refusal
     can offset or scale it without restating the calc — and, more to
     the point, so a keyframe can move the tile without needing
     @property to interpolate a custom property. */
  --bx:calc(var(--c) * (100% + var(--gap)));
  --by:calc(var(--r) * (100% + var(--gap)));
  transform:translate(var(--bx), var(--by));
  transition:transform 220ms var(--ease), box-shadow 220ms var(--ease),
             filter 220ms var(--ease);
}
/* A tile that just moved flares its edge for the length of the slide, so
   the eye follows the piece rather than hunting for what changed. Timed
   to the 220ms transform with a little left over to fade back down. */
@keyframes ptmoved{
  0%{box-shadow:0 0 0 1px rgba(255,255,255,.5) inset, 0 6px 18px rgba(0,0,0,.5)}
  25%{box-shadow:0 0 0 2px rgba(255,255,255,1) inset,
                0 0 20px rgba(255,255,255,.4), 0 6px 18px rgba(0,0,0,.5)}
  100%{box-shadow:0 0 0 1px rgba(255,255,255,.5) inset, 0 6px 18px rgba(0,0,0,.5)}
}
.pt.moved{animation:ptmoved 520ms var(--ease)}
/* Once the picture is whole the seams are gone; a flare would draw one
   back on. */
.pz.done .pt{animation:none}
@media (prefers-reduced-motion:reduce){ .pt.moved{animation:none} }
/* only the tiles beside the hole can go anywhere */
.pt[data-movable="false"]{cursor:default}

/* Hover only where there is one — otherwise a tap leaves a tile lit up
   on a phone with no way to un-light it.

   The board sorts itself under the pointer: a tile that can move rises
   off it, a tile that cannot sinks back. Two directions from the same
   gesture, so you learn the rule by moving the mouse rather than by
   clicking and being refused. */
@media (hover:hover){
  .pt[data-movable="true"]:hover{
    z-index:4;
    transform:translate(var(--bx), var(--by)) scale(1.045);
    box-shadow:0 0 0 2px #fff inset, 0 18px 40px rgba(0,0,0,.78);
  }
  .pt[data-movable="false"]:hover{
    filter:brightness(.82) saturate(.9);
    box-shadow:0 0 0 1px rgba(255,255,255,.22) inset, 0 4px 12px rgba(0,0,0,.5);
  }
}

/* The refusal. A tile that cannot reach the hole shudders in place —
   short, and horizontal, because every legal move on this board is a
   slide and a shudder is the one movement that reads as "not that".

   Built on --bx/--by so it works from wherever the tile happens to be
   sitting; a fixed translateX would snap every tile to the origin for
   the length of the animation. */
@keyframes pt-shudder{
  0%,100%{transform:translate(var(--bx), var(--by))}
  15%{transform:translate(calc(var(--bx) - 5px), var(--by))}
  38%{transform:translate(calc(var(--bx) + 4px), var(--by))}
  62%{transform:translate(calc(var(--bx) - 3px), var(--by))}
  85%{transform:translate(calc(var(--bx) + 2px), var(--by))}
}
.pt.no{animation:pt-shudder 300ms var(--ease)}
@media (prefers-reduced-motion:reduce){ .pt.no{animation:none} }

/* ---------- the missing twelfth ----------
   The hole holds the last piece of the photograph. It fades in when the
   other eleven are home, so "solved" and "the picture is whole" are the
   same moment. */
.pz-hole{
  position:absolute;left:0;top:0;
  width:var(--w);height:var(--h);
  background-repeat:no-repeat;
  opacity:0;
  transform:translate(
    calc(var(--c) * (100% + var(--gap))),
    calc(var(--r) * (100% + var(--gap)))
  );
  pointer-events:none;
  transition:opacity 900ms var(--ease);
}
.pz.done .pz-hole{opacity:1}

/* solved: the seams close up and it becomes one photograph again */
.pz.done{--gap:0px}
.pz.done .pt{box-shadow:none;cursor:default}

@media (max-width:560px){
  .pz{height:min(62svh,560px);max-width:86vw}
}

/* ---------- finished: the chrome steps out of the photograph's way ----------
   This slide doesn't use the black curtain the other two do. The reward
   of finishing it IS the picture, so nothing is allowed to cover it. */
body.solved-24 .nav,
body.solved-24 .pager,
body.solved-24 .dots{
  opacity:0;pointer-events:none;
  transition:opacity 700ms var(--ease);
}
/* The wallpaper behind is a different crop of the same frame. Once the
   board has grown, two crops of one photograph meeting down a hard edge
   reads as a mistake — so the wallpaper goes and the finished picture
   sits on black. */
body.solved-24 #bgs{opacity:0;transition:opacity 900ms var(--ease)}
body.solved-24 .section::after{opacity:0;transition:opacity 900ms var(--ease)}
body.solved-24 .reward{
  opacity:1;pointer-events:auto;
  transition:opacity 800ms var(--ease) 1500ms;
}
body.solved-24 .reset{
  opacity:1;pointer-events:auto;
  transition:opacity 700ms var(--ease) 1800ms;
}

/* ---------- the way out, once solved ---------- */
.reward{
  position:absolute;z-index:7;left:var(--pad);bottom:25vh;
  display:inline-flex;align-items:center;gap:16px;text-decoration:none;color:#fff;
  font-size:11px;letter-spacing:.3em;text-transform:uppercase;font-weight:600;
  opacity:0;pointer-events:none;
}
.solved .reward{
  opacity:1;pointer-events:auto;
  transition:opacity 700ms var(--ease) 1200ms;
}
.reward .dash{
  width:70px;height:1px;
  background:repeating-linear-gradient(to right,#fff 0 9px,transparent 9px 18px);
  transition:width 500ms var(--spring);
}
.reward:hover .dash{width:104px}

/* Slide 3 swaps the dashed label for the arrow the other two end on, and
   centres it — over a full-bleed photograph a rule and a word in the
   corner read as a caption on the picture, not as the way out of it. */
.reward.as-arrow{
  /* centred with `translate`, nudged with `transform` — two separate
     properties, so the hover cannot fight the centring */
  left:50%;translate:-50% 0;bottom:clamp(24px,7vh,64px);
  gap:0;padding:0;
  transition:transform 380ms var(--spring);
}
.reward.as-arrow img{width:clamp(84px,10vw,132px)}
.reward.as-arrow:hover{transform:translateX(9px)}
/* "Again" drops down to the arrow's line and crosses to the left — the
   progress ring is parked in the bottom-right corner and the two were
   sitting on top of each other. */
body.solved-24 .reset{
  bottom:clamp(30px,8vh,72px);
  right:auto;left:var(--pad);
}

.reset{
  position:absolute;z-index:7;right:var(--pad);bottom:25vh;
  background:none;border:0;color:rgba(255,255,255,.5);cursor:pointer;
  font:inherit;font-size:9.5px;letter-spacing:.28em;text-transform:uppercase;
  opacity:0;pointer-events:none;
}
.solved .reset{opacity:1;pointer-events:auto;transition:opacity 600ms var(--ease) 1500ms}
.reset:hover{color:#fff}

/* ============================================================
   MOBILE — letter widths straight from the original stylesheet
   ============================================================ */
@media screen and (max-width:479px){
  /* The letters are ~70% of their desktop width here, and the artwork is
     scale-down-fitted, so it draws 77px tall. The row has to come down to
     match or every line carries 16px of dead space and the two words drift
     apart. 110 x 0.70 = 77. */
  .parent{--lh:77px;width:360px;margin-top:22vh;margin-left:20px;align-content:flex-start}
  /* The height MUST come down with the row. Each img carries height="110"
     as an attribute, and an attribute is still a height — leave it and the
     img box stays 110 tall inside a 77px window, so the window shows the
     letterboxing and the top two thirds of the letter, and the two-up roll
     travels 77 through a 154px stack. Both numbers come from --lh. */
  .letter img{object-fit:scale-down;height:var(--lh)}
  .letter[data-k="e1"] img,.letter[data-k="e2"] img{width:45px}
  .letter[data-k="n"]  img{width:50px}
  .letter[data-k="i"]  img{width:24px}
  .letter[data-k="g"]  img{width:49px}
  .letter[data-k="m1"] img{width:56px}
  .letter[data-k="a1"] img{width:96px}
  .letter[data-k="m2"] img{width:60px}
  .letter[data-k="a2"] img{width:50px}
  .letter[data-k="d"]  img{width:50px}
  /* the E is 45 wide here, so the arrow moves in to match */
  .arrow{left:45px;width:50px}
  .slots{margin-left:20px}
  .reward,.reset{bottom:19vh}
  .reset{right:20px}
  .reward{left:20px}
}
@media (prefers-reduced-motion:reduce){
  *{animation-duration:.01ms !important;transition-duration:.01ms !important}
}

/* ============================================================
   THE HOLD

   The page used to arrive mid-thought: the background photograph, the
   nav, the wordmark and the two puzzles you have not chosen yet all
   resolving at once, each on its own timing. This gives it a beginning
   — black, the mark, then the first puzzle.

   The overlay's RESTING state is hidden and the keyframes make it
   opaque, not the other way round. A browser that refuses the
   animation therefore shows the page, not a black sheet nailed over
   it. That is the whole reason it is built backwards.
   ============================================================ */
.intro{
  position:fixed;inset:0;z-index:400;
  display:grid;place-items:center;
  background:#000;
  opacity:0;visibility:hidden;pointer-events:none;
}
.intro-mark{
  width:clamp(64px,9vw,104px);height:auto;fill:#fff;
  opacity:0;
}

@media (prefers-reduced-motion: no-preference){
  /* Opaque, hold, then out. `forwards` so it stays gone. */
  @keyframes intro-sheet{
    0%   { opacity:1;visibility:visible }
    58%  { opacity:1;visibility:visible }
    100% { opacity:0;visibility:hidden }
  }
  /* The mark arrives a beat after the black, so the black reads as
     deliberate rather than as a slow page. */
  @keyframes intro-mark{
    0%   { opacity:0;transform:scale(.94) }
    30%  { opacity:1;transform:scale(1) }
    70%  { opacity:1;transform:scale(1) }
    100% { opacity:0;transform:scale(1.03) }
  }
  .booting .intro     { animation:intro-sheet 1500ms var(--ease) forwards }
  .booting .intro-mark{ animation:intro-mark  1400ms var(--ease) forwards }

  /* Everything that frames the page waits for the sheet rather than
     sitting under it. The sheet is opaque for the first 870ms and then
     fades, so anything not held back shows through as a ghost of the
     finished page during the hand-off. */
  .booting .nav,
  .booting .pager,
  .booting .dots,
  .booting .reset,
  .booting #prog,
  .booting .seal{ animation:intro-fade 700ms var(--ease) 1150ms backwards }

  /* The first puzzle, and only the first. `.slide.on` is the one the
     markup ships with, and the two behind it are left entirely alone —
     they are not meant to be seen until they are chosen, and animating
     them at all is what put half a sliding puzzle on screen for a
     frame. */
  .booting .slide.on{ animation:intro-rise 900ms var(--ease) 1050ms backwards }
  .booting #bgs     { animation:intro-fade 1100ms var(--ease) 950ms backwards }

  @keyframes intro-fade{ from{opacity:0} to{opacity:1} }
  @keyframes intro-rise{ from{opacity:0;transform:translateY(14px)} to{opacity:1;transform:none} }
}

/* ---------- arriving on a specific puzzle ----------
   `deep` is set on <html> in the head before anything paints, when the
   URL names a puzzle other than 21. It does two things and then gets
   out of the way:

     · no opening titles — you asked for 24, not for the logo
     · slide 21 stays down, even though the markup ships it `.on`,
       so the puzzle you did not ask for is never drawn at all

   home.js drops the class the moment it has switched to the right
   slide, at which point that slide fades up through the ordinary
   .slide transition. Until then this rule outranks `.slide.on` on
   specificity, which is the whole trick. */
html.deep .intro,
html.deep .intro-mark{ animation:none }
html.deep .slide.on{ opacity:0 }

/* ============================================================
   TILT TO LOOK — the phone-only prompt.

   Bottom centre, above the dots and clear of both corner controls.
   It is a pill rather than a dialog on purpose: the thing it is
   asking for is optional decoration, and a modal for that would be
   out of all proportion to what is on offer.
   ============================================================ */
.tilt-ask{
  position:fixed;z-index:150;
  left:50%;bottom:calc(env(safe-area-inset-bottom, 0px) + 74px);
  translate:-50% 0;
  display:inline-flex;align-items:center;gap:9px;
  padding:11px 17px;border-radius:999px;cursor:pointer;
  border:1px solid rgba(255,255,255,.22);
  background:rgba(10,10,12,.72);
  -webkit-backdrop-filter:blur(12px);backdrop-filter:blur(12px);
  color:rgba(255,255,255,.86);
  font-family:var(--font-body);font-size:12px;font-weight:600;letter-spacing:.01em;
  opacity:0;transform:translateY(10px);
  transition:opacity 380ms var(--ease), transform 480ms var(--spring),
             border-color 200ms var(--ease);
}
.tilt-ask.on{opacity:1;transform:none}
.tilt-ask:hover{border-color:rgba(255,255,255,.5)}
.tilt-ask:disabled{opacity:.5}
.tilt-ask svg{
  width:15px;height:15px;flex:0 0 auto;
  fill:none;stroke:currentColor;stroke-width:1.7;
  stroke-linecap:round;stroke-linejoin:round;
}
/* A small rock, so the icon says "tilt" without a caption doing all
   the work. Three cycles and it stops — a permanent animation on a
   dismissible pill is nagging. */
@media (prefers-reduced-motion: no-preference){
  @keyframes tilt-rock{
    0%,100%{ transform:rotate(-13deg) }
    50%    { transform:rotate(13deg) }
  }
  .tilt-ask.on svg{
    transform-origin:50% 60%;
    animation:tilt-rock 1.6s var(--ease) .5s 3 both;
  }
}
/* It shares the bottom line with the dots on a short screen; give it
   the row above rather than letting the two overlap. */
@media (max-height:560px){
  .tilt-ask{bottom:calc(env(safe-area-inset-bottom, 0px) + 96px)}
}
/* The puzzle is over and the reward is taking the screen. */
.solved .tilt-ask,.solved-24 .tilt-ask{opacity:0;pointer-events:none}
