/* Etpaar case-study modules — the day3 mechanics.
   Spec: portfolio-polish/PAGE-TEMPLATE-SPEC.md §2 §4 §5 §7 §8 §10.
   Loaded last on /work/<slug>, so it wins the cascade over pages.css and
   project.css without either of them having to know about it.

   PROGRESSIVE BY DEFAULT. Every rule that hides something, pins something, or
   moves something is written under `html.case-js`, a class public/js/
   case-modules.js adds to the document only when it is actually running AND
   motion is allowed. So: no JavaScript, a failed script, `prefers-reduced-
   motion: reduce`, or the site's own calm switch all land on the same static
   page — plates visible, scroller a plain horizontal strip. The one exception
   is the hero, whose pre-reveal state has to exist at first paint (§4); it is
   gated on `prefers-reduced-motion: no-preference` and released by a failsafe
   timer in the document head as well as by case-modules.js. */

#proj{
  /* rem = 1vw at the reference viewport (§2): gallery gap and plate radius
     14.4px, card radius 9px, scroller gap 13.5px at 1440. */
  --case-gap:1vw;
  --case-r-plate:1vw;
  --case-r-card:.625vw;
  --case-gap-card:.9375vw;
  /* The plate column: 82vw, never wider than this page's own content box
     (shell 1340 less its two gutters). 1180.8 CSS at 1440. A length, not a
     percentage, so the same token can drive the scroller's transforms. */
  --case-col:min(82vw, min(100vw, 1340px) - 2 * clamp(24px, 5vw, 64px));
  /* Scroller item. §7.2 writes it as min(45vw − 4.46875rem, 42.4063rem); with
     rem = 1vw that is min(40.53125vw, 42.4063vw), so the cap can never bind
     and the item is exactly half the plate column less one card gap — two
     items fill the mask, which is the whole point of the counter-translation.
     Written in terms of --case-col here rather than as a bare 40.53125vw,
     because on this site the column is the page shell (1340 less its gutters)
     above ~1500px and a viewport-relative item would stop being half of it.
     At the reference 1440 the two agree exactly: (1180.8 − 13.5) / 2 = 583.65,
     §7.4's number. The previous `min(40.53125vw, 610.65px)` had frozen §7.2's
     rem cap into a px literal, which unlike the spec's cap DID bind. */
  --case-item:calc((var(--case-col) - var(--case-gap-card)) / 2);
  --ease-wipe:cubic-bezier(.76,0,.24,1);        /* ≈ power4.inOut, max err 3pp */
  /* §4's exact alternative. The bezier above is 97.98% at tween 1000ms, where
     the §4/§11.1 table asks for ≥99% with no tolerance column — a shortfall
     inherent to the approximation, not to this code. This linear() is the
     easing §4 offers instead and it reads 99.4% at 1000ms. Applied under
     @supports below so a browser without linear() keeps the bezier. */
  --ease-wipe-exact:linear(0, .0005 10%, .009 20%, .046 30%, .148 40%, .5 50%, .852 60%, .954 70%, .991 80%, .9995 90%, 1);
  --ease-gallery:cubic-bezier(.165,.84,.44,1);
  /* The scroller's list is wider than the column by design and hangs off the
     right edge. `clip` (not `hidden`) does not create a scroll container, so
     the sticky stage inside still pins to the viewport. */
  overflow-x:clip;
}
@media (max-width:991px){
  #proj{--case-col:calc(min(100vw, 1340px) - 2 * clamp(24px, 5vw, 64px));}
}
@media (max-width:767px){
  #proj{--case-gap:8px;--case-r-plate:16px;--case-r-card:10px;--case-gap-card:8px;--case-item:80vw;}
}

/* No colour cast on any plate. The house grade (sepia .22 / saturate .92 /
   contrast 1.05) is retired: neutrality comes from the photograph, not from a
   filter, and the filter was also washing flat logo plates off their brand
   hexes. §8 acceptance asserts computed filter === 'none'. */
#proj img{filter:none;}

/* ===== §4 hero: the downward wipe ===================================== */

#proj .proj-banner .case-hero__media{
  display:block;
  position:relative;
  border-radius:8px;
  overflow:hidden;
  box-shadow:0 34px 66px -36px rgba(60,30,10,.42);
}
#proj .case-hero__media img{display:block;width:100%;height:auto;border-radius:8px;}
/* §4's overlay, and the reason it is inside the clipped node: it has to be
   revealed BY the wipe together with the image, never laid over a finished
   image afterwards (the "bright-then-dim" failure the acceptance clause names).
   Its strength is a token because §4's .6 exists to carry white H1 type over
   the image — that composition is the §3.1 masthead, which this build does not
   render: here the banner is a standalone figure with nothing on top of it, and
   a 60% black veil over it would only darken the client's work. So the overlay
   ships structurally at 0 and §3.1 raises the token to .6 when the H1 moves
   onto the image. */
#proj .case-hero__media{--case-hero-shade:0;}
#proj .case-hero__media::after{
  content:"";position:absolute;inset:0;border-radius:8px;pointer-events:none;
  background:rgba(0,0,0,var(--case-hero-shade));
}

@media (prefers-reduced-motion: no-preference){
  /* Pre-reveal: a zero-height rect at the top of the frame. Rendered
     server-side (html.hero-pending) so there is no flash of a full image
     before the animation arms, and no empty frame either — the ground under
     the wipe is the page, and the wipe does not start until the bitmap has
     decoded. */
  html.hero-pending #proj .case-hero__media{clip-path:polygon(0 0,100% 0,100% 0,0 0);}
  html.hero-ready #proj .case-hero__media{animation:case-hero-wipe 1.2s var(--ease-wipe) both;}
  @supports (animation-timing-function: linear(0, 1)){
    html.hero-ready #proj .case-hero__media{animation-timing-function:var(--ease-wipe-exact);}
  }
}
@keyframes case-hero-wipe{
  from{clip-path:polygon(0 0,100% 0,100% 0,0 0);}
  to{clip-path:polygon(0 0,100% 0,100% 100%,0 100%);}
}
/* Motion off: the image is simply present. */
body.calm #proj .case-hero__media{clip-path:none !important;animation:none !important;}

/* ===== §4 H1 companion: the per-character stagger =====================
   The spec calls this the default, not an option, and gives it its own
   acceptance test. The characters are split at build time (project-template.js
   `splitH1`), so the markup is the same with JavaScript off; the H1 carries an
   aria-label of the plain client name so nothing is read letter by letter.
   The site's own letterpress entrance (pages.css `pginh`) is suppressed on
   this H1 — two entrances on one element read as neither. */
#proj .proj-hero h1.case-h1{overflow:clip;animation:none !important;clip-path:none !important;}
#proj .case-h1 .cw{display:inline-block;white-space:nowrap;}
#proj .case-h1 .ch{display:inline-block;white-space:pre;}
@media (prefers-reduced-motion: no-preference){
  /* Pre-reveal gate, server-rendered with the hero's own (§4). */
  html.hero-pending #proj .proj-hero h1.case-h1{opacity:0;}
  html.hero-ready #proj .case-h1 .ch{
    animation:case-ch-in .3s var(--ease-wipe) both;
    animation-delay:calc(var(--i) * 40ms);
  }
}
@keyframes case-ch-in{from{opacity:0;}to{opacity:1;}}
body.calm #proj .proj-hero h1.case-h1{opacity:1 !important;}
body.calm #proj .case-h1 .ch{animation:none !important;opacity:1 !important;}

/* ===== §5 gallery: two-track grid, data-driven pairs, scroll reveal ==== */

#proj .case-gallery{
  display:flex;
  flex-wrap:wrap;
  gap:var(--case-gap);
  justify-content:flex-start;
  align-items:flex-start;
  width:var(--case-col);
  margin-inline:auto;
}
#proj .case-gallery .plate{
  position:relative;
  width:100%;
  margin:0;
  /* A full plate keeps its master's proportions: cropping a 4:3 or portrait
     master into a 16:9 box to make the grid tidy is the day3 defect (§10.4),
     not the pattern. 16:9 masters — which is what new plates are authored at —
     land on exactly the day3 box, 1180.8 x 664.2 at 1440. */
  aspect-ratio:var(--ar, 16 / 9);
}
#proj .case-gallery .plate--half{
  width:calc(50% - var(--case-gap) / 2);
  aspect-ratio:16 / 9;                     /* pair slots are 16:9 by contract */
}
#proj .case-gallery .plate picture{position:absolute;inset:0;display:block;}
#proj .case-gallery .plate img{
  position:absolute;
  inset:0;
  width:100%;
  height:100%;
  margin:0;
  object-fit:cover;
  border-radius:var(--case-r-plate);
  box-shadow:none;
}
@media (max-width:767px){
  /* A plate designed to be read at 577px is unreadable at 170 (§5). */
  #proj .case-gallery .plate--half{width:100%;}
}

/* Reveal: 0.6s, opacity + scale + a slide that matches the plate's own side.
   The transform ends at none, so the bitmap never rests scaled. */
#proj .case-gallery .plate{
  transition:opacity .6s var(--ease-gallery),transform .6s var(--ease-gallery);
}
html.case-js #proj .case-gallery .plate:not(.is-in){opacity:0;transform:scale(.9) translateY(10%);}
html.case-js #proj .case-gallery .plate--half[data-side="left"]:not(.is-in){transform:scale(.9) translateX(-10%);}
html.case-js #proj .case-gallery .plate--half[data-side="right"]:not(.is-in){transform:scale(.9) translateX(10%);}
#proj .case-gallery .plate.is-in{opacity:1;transform:none;}
/* a tint under each plate, so a photo still loading is a quiet panel and not
   a hole; a hairline ring so pale plates don't dissolve into the cream page */
#proj .case-gallery .plate{background:rgba(20,18,31,.05);border-radius:var(--case-r-plate);}
#proj .case-gallery .plate::after{content:'';position:absolute;inset:0;border-radius:var(--case-r-plate);
  box-shadow:inset 0 0 0 1px rgba(20,18,31,.07);pointer-events:none;}
@media (max-width:767px){
  /* phones: a plain rise. A plate shrunk to 90% and half faded is what read
     as a broken image at the bottom of the screen. */
  html.case-js #proj .case-gallery .plate:not(.is-in),
  html.case-js #proj .case-gallery .plate--half[data-side]:not(.is-in){transform:translateY(24px);}
  #proj{--case-gap:12px;--case-r-plate:12px;--case-r-card:12px;}
  #proj .proj-banner .case-hero__media,#proj .case-hero__media img{border-radius:12px;}
  #proj .case-hero__media{background:rgba(20,18,31,.05);}
}

/* ===== §7 lateral scroller ============================================ */

#proj .lat{--p:0;--cols:1;--item-w:var(--case-item);
  --list-w:calc(var(--cols) * var(--item-w) + (var(--cols) - 1) * var(--case-gap-card));
  position:relative;}
#proj .lat[data-rows="1"]{--item-w:var(--case-col);}
#proj .lat__stage{
  display:flex;
  align-items:center;
  padding:clamp(24px,3.125vw,45px) 0;       /* §2 image-section padding */
}
/* §7.2 gives the mask --mask-w: var(--col) with margin-left: var(--col-left)
   (13vw desktop, 5vw mobile). Here it is the plate column, centred in the page
   shell — the same margin-inline:auto the gallery column uses — because this
   case study sits inside the existing site frame rather than on day3's
   full-bleed page, and a mask hung off a 13vw left edge would not line up with
   anything else on it. Measured consequence, recorded so it is a decision and
   not a drift: at 390 the mask is 342px (shell less its 24px gutters) where
   §7.2 specifies 90vw = 351px at margin-left 5vw. Same reason the column is
   centred rather than at §2's --col-left. */
#proj .lat__mask{width:var(--case-col);margin-inline:auto;}
#proj .lat__list{
  display:grid;
  grid-template-rows:repeat(2,auto);
  grid-auto-flow:column;                    /* fills down first: 1 top-left, 2 below it */
  gap:var(--case-gap-card);
  width:max-content;
  margin:0;padding:0;list-style:none;
}
#proj .lat[data-rows="1"] .lat__list{grid-template-rows:auto;}
#proj .lat__item{
  width:var(--item-w);
  aspect-ratio:16 / 9;
  overflow:hidden;
  border-radius:var(--case-r-card);
  position:relative;
}
#proj .lat__item img{position:absolute;inset:0;width:100%;height:100%;object-fit:cover;}
/* The section that follows keeps the rule the sibling selector in project.css
   can no longer draw, now that the scroller sits between two sections. */
#proj .lat + .proj-sec{border-top:1px solid rgba(20,18,31,.07);}

/* Pinned pan. The wrapper is as tall as the pan is long; the stage sticks for
   all of it. Mask and list are counter-translated — mask +p x maskW, list
   −p x listW — so at p=1 the last item's right edge lands exactly on the
   column's right edge with no item-count arithmetic. */
html.case-js #proj .lat{min-height:calc(var(--cols) * 100vh);}
html.case-js #proj .lat__stage{position:sticky;top:0;min-height:100vh;}
html.case-js #proj .lat__mask{transform:translate3d(calc(var(--p) * var(--case-col)),0,0);will-change:transform;}
html.case-js #proj .lat__list{transform:translate3d(calc(var(--p) * -1 * var(--list-w)),0,0);will-change:transform;}

/* §7.5 point 3: the only way for a keyboard user to get past 3600px of pinned
   scroll in one action. Visually hidden until focused; the target after the
   wrapper carries scroll-margin so the sticky navbar does not cover it. */
#proj .lat__skip{
  position:absolute;width:1px;height:1px;margin:-1px;padding:0;border:0;
  overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap;
}
#proj .lat__skip:focus,#proj .lat__skip:focus-visible{
  position:static;width:max-content;height:auto;margin:0 auto 12px;
  padding:10px 18px;clip:auto;clip-path:none;overflow:visible;display:block;
  font-family:var(--ff-mono);font-size:.75rem;letter-spacing:.14em;
  text-transform:uppercase;text-decoration:none;color:inherit;
  border:1px solid currentColor;border-radius:999px;background:var(--paper,#fff);
}
#proj #after-lateral{display:block;scroll-margin-top:120px;}
#proj .lat__mask:focus-visible{outline:2px solid currentColor;outline-offset:4px;}

/* Static fallback (no JS, reduced motion, calm): a plain horizontal strip
   that scrolls by itself, with snap. Never day3's 168px-wide mobile plates. */
#proj .lat__mask{overflow-x:auto;scroll-snap-type:x mandatory;scrollbar-width:none;}
#proj .lat__item{scroll-snap-align:start;}
html.case-js #proj .lat__mask{overflow:visible;scroll-snap-type:none;}
/* Phones: no pin. Four screens of held scrolling to pan 3.5px-text
   thumbnails was the worst moment on a case study; here it is the static
   strip, one row, swiped natively with snap (case-modules.js adds .lat--swipe). */
html.case-js #proj .lat.lat--swipe{min-height:0;}
html.case-js #proj .lat--swipe .lat__stage{position:static;min-height:0;}
html.case-js #proj .lat--swipe .lat__mask{transform:none;overflow-x:auto;scroll-snap-type:x mandatory;
  width:100%;padding-inline:var(--gutter,24px);scroll-padding-inline:var(--gutter,24px);overscroll-behavior-x:contain;}
html.case-js #proj .lat--swipe .lat__list{transform:none;grid-template-rows:auto;}
#proj .lat--swipe{--item-w:86vw;}

/* ===== §2's html.no-js contract ======================================
   This build inverts the spec: instead of `no-js` being present until a head
   script removes it, `case-js` is ADDED only when the script runs AND motion
   is wanted, so a blocked script, reduced motion and the site's calm switch
   all land on the same static page rather than only the first of them.
   Behaviourally that is a superset of §2. But §5 and §7.2 hang their fallbacks
   off `html.no-js`, and a component written to the spec against a class that
   never exists would silently do nothing — so the class is now rendered too
   (Site.astro) and removed by the head script, and these rules give it
   somewhere to land. Both selectors resolve; neither is load-bearing alone. */
html.no-js #proj .case-gallery .plate{opacity:1;transform:none;}
html.no-js #proj .lat{min-height:auto;}
html.no-js #proj .lat__stage{position:static;}
html.no-js #proj .lat__mask{overflow-x:auto;transform:none;}
html.no-js #proj .lat__list{transform:none;}
html.no-js #proj .proj-hero h1.case-h1{opacity:1;}
