/* ==========================================================================
   PORTFOLIO
   A spiral of cards, turned by the scroll.

     a pinned stage        nothing scrolls past. The stage holds still and
                           the scroll drives a sequence through it
     an intro that resolves an opening state that scrubs into the spiral
                           rather than cutting to it
     the spiral            the work stands on a helix. Scrolling turns it and
                           climbs it, so each card comes round to face you
     a palette cross-fade  the ground moves between paper and ink as you pass
                           through, and the masthead moves with it
     card detail           picking one brings it forward and puts the rest
                           behind glass

   An earlier version was a scattered cloud of works flown through, modelled
   on museum.unesco.org. It did not read as anything. The spiral replaced it.

   CSS 3D on a scroll timeline, because this site ships with no build step
   and no dependencies. The geometry is real: the cards occupy depth and turn
   through it. The material is not, so there is no lighting model and no
   shadows cast between cards.

   ONE TIMELINE, NAMED. Every part reads --gal off the stage. An anonymous
   view() would give each element a timeline made from its own box, so a
   small card would finish its move in the moment it crossed the sheet and
   every bit of staging here would appear to be ignored.
   ========================================================================== */

.gal{
  position:relative;
  /* the length of the journey. The only number that changes its pace. */
  height:calc(var(--sheet-h, 100svh) * 6);
  view-timeline-name:--gal;
  view-timeline-axis:block;
}

/* THE STAGE. Sticky, one sheet tall, and everything happens inside it. */
.gal__stage{
  position:sticky; top:0;
  height:var(--sheet-h, 100svh);
  overflow:hidden;
  perspective:1400px;
  perspective-origin:50% 48%;
}

/* the ground, crossing from paper to ink as you go in and back on the way
   out. This is the palette cross-fade, and it is why the works are lit from
   a different side in the middle of the journey. */
.gal__ground{
  position:absolute; inset:0;
  background:var(--wall);
  opacity:0;
  animation:galGround linear both;
  animation-timeline:--gal;
  animation-range:contain 0% contain 100%;
}
@keyframes galGround{
  0%,14%   { opacity:0; }
  30%,74%  { opacity:.93; }
  90%,100% { opacity:0; }
}

/* ---------------------------------------------------------------- INTRO */
.gal__intro{
  position:absolute; inset:0; z-index:3;
  display:grid; place-content:center; text-align:center;
  gap:1.2rem; padding:0 var(--gut);
  animation:galIntro linear both;
  animation-timeline:--gal;
  animation-range:contain 0% contain 18%;
}
@keyframes galIntro{
  0%   { opacity:1; transform:scale(1);    filter:blur(0); }
  70%  { opacity:1; transform:scale(1.06); filter:blur(0); }
  100% { opacity:0; transform:scale(1.16); filter:blur(6px); }
}
.gal__introT{
  font-family:var(--f-hi);
  font-size:clamp(2.2rem,7vw,5.4rem); line-height:1;
  max-width:14ch; margin:0 auto;
}
.gal__introD{
  font-family:var(--f-mono); font-size:.66rem;
  letter-spacing:.24em; text-transform:uppercase; color:var(--ink-faint);
}

/* --------------------------------------------------------------- SPIRAL

   The cards stand on a helix and the scroll turns it.

   THE GEOMETRY, WHICH IS THE WHOLE TRICK
   Card i sits at angle i * --step around a vertical axis, pushed out to the
   radius, and lifted i * --rise. That is a spiral staircase of cards:

       rotateY(i * step) translateZ(R) translateY(i * rise)

   rotateY first, then translateZ, is what makes each card face outward
   instead of all of them facing the same way. Reverse the two and you get a
   flat stack.

   WHY BOTH TRANSFORMS ON THE RIG MOVE TOGETHER
   Scroll turns the rig by -angle AND lowers it by -rise, in lockstep. Both
   the card's angle and its height are linear in i, so a single pair of
   linear animations keeps whichever card is facing you at eye level, for
   free, with no per-card work at all. Break the lockstep and cards arrive
   at the front above or below the middle of the screen.

   The numbers are shared with portfolio.js, which reads them off this
   element to place the cards. They are declared here because CSS is where
   the size of the thing is decided. */
.spiral{
  position:absolute; inset:0;
  transform-style:preserve-3d;

  /* The pitch of the helix. Rise is what stops it being a carousel, and the
     ratio between the two is the whole character: too much rise per turn and
     it reads as a diagonal staircase rather than a spiral.

     46 degrees is the compromise. Backfaces are hidden, so only the cards
     within 90 degrees of the front are on screen: much wider than this and
     there are two cards visible at a time and the stage looks empty, much
     tighter and the turn stops reading as a turn. */
  --step: 46deg;                          /* turn between one card and the next */
  --rise: 118px;                          /* climb between one card and the next */
  /* Kept well inside the stage's perspective. The card facing you sits at
     +radius, so it is magnified by p / (p - radius): at 460 against a 900px
     perspective that is 2x and it swallows the screen. */
  --radius: clamp(200px, 25vw, 370px);

  animation:spinRig linear both;
  animation-timeline:--gal;
  animation-range:contain 4% contain 94%;
}
/* The ends are set by portfolio.js from the number of cards, so adding work
   to the index lengthens the turn instead of leaving the last few cards
   parked off the end of it. The fallbacks are the eight that ship. */
@keyframes spinRig{
  from{ transform:translateY(var(--from-y, 118px))  rotateY(var(--from-a, 46deg)); }
  to  { transform:translateY(var(--to-y, -944px))   rotateY(var(--to-a, -368deg)); }
}

/* THE APPROACH, and a trap worth understanding before touching any of it.

   perspective on an ancestor only reaches its OWN children. For the cards to
   share one 3D space with the stage's perspective, every element between the
   two has to carry transform-style:preserve-3d. This wrapper did not, and a
   flat wrapper flattens everything under it into its own plane: the spiral
   rendered as a row of edge-on slivers, which looks like the transforms
   being ignored rather than like a projection problem.

   AND IT CANNOT FADE. opacity below 1 makes an element a grouping element,
   which flattens it again no matter what transform-style says. Same for
   filter. So the cards are held back geometrically instead: the rig starts
   a long way off and comes in. That is a better entrance anyway, and it is
   the reason there is no opacity anywhere on this branch. */
.gal__veil{
  position:absolute; inset:0;
  transform-style:preserve-3d;
  animation:galApproach linear both;
  animation-timeline:--gal;
  animation-range:contain 0% contain 22%;
}
@keyframes galApproach{
  0%,25%  { transform:translateZ(-1700px); }
  100%    { transform:translateZ(0); }
}

.work{
  position:absolute; left:50%; top:50%;
  width:clamp(160px,19vw,280px);
  margin:0;
  transform-style:preserve-3d;
  /* --a and --y are set per card by portfolio.js */
  transform:
    translate(-50%,-50%)
    rotateY(var(--a))
    translateZ(var(--radius))
    translateY(var(--y));
  /* The far half of the helix is facing away from you. Without this you read
     its cards through their own backs, mirrored, which looks like a bug
     rather than like the back of a card. */
  backface-visibility:hidden;
}
.work__in{
  display:block; width:100%;
  background:var(--paper-hi);
  padding:.7rem;
  box-shadow:0 2px 4px rgba(0,0,0,.3), 0 18px 40px rgba(0,0,0,.35);
  text-decoration:none; color:inherit;
  transition:transform .45s var(--ease), box-shadow .45s var(--ease);
  cursor:pointer;
}
.work__in:hover{
  transform:translateY(-6px) scale(1.04);
  box-shadow:0 4px 8px rgba(0,0,0,.35), 0 26px 60px rgba(0,0,0,.45);
}
.work__img{
  display:block; width:100%; aspect-ratio:3.5/2;
  object-fit:contain; background:var(--paper);
  padding:8%;
}
.work__cap{
  display:block; margin-top:.55rem;
  font-family:var(--f-mono); font-size:.56rem;
  letter-spacing:.14em; text-transform:uppercase; color:var(--ink-faint);
  text-shadow:none;
}

/* No idle drift on the cards. The rig is already turning and climbing, and a
   second motion on top of it reads as wobble rather than as life. */

/* --------------------------------------------------------------- CLOSING */
.gal__end{
  position:absolute; inset:0; z-index:3;
  display:grid; place-content:center; text-align:center;
  gap:1.4rem; padding:0 var(--gut);
  opacity:0; pointer-events:none;
  animation:galEnd linear both;
  animation-timeline:--gal;
  animation-range:contain 84% contain 100%;
}
@keyframes galEnd{
  0%   { opacity:0; transform:translateY(30px); pointer-events:none; }
  60%,100%{ opacity:1; transform:translateY(0); pointer-events:auto; }
}

/* The scroll hint, gone the moment you start.
   It deliberately does NOT carry .on-dark. That rule is a whole `animation`
   shorthand at higher specificity, so it would replace this one outright and
   the hint would sit there through the entire flight, recoloured but never
   fading. It is gone before the ground crosses, so it needs no flip. */
.gal__hint{
  position:absolute; left:50%; bottom:clamp(1.5rem,4vw,3rem); z-index:4;
  transform:translateX(-50%);
  font-family:var(--f-mono); font-size:.6rem;
  letter-spacing:.24em; text-transform:uppercase; color:var(--ink-faint);
  animation:galHint linear both;
  animation-timeline:--gal;
  animation-range:contain 0% contain 12%;
}
@keyframes galHint{ 0%,40%{ opacity:1; } 100%{ opacity:0; } }


/* ---------------------------------------------------------- THE MASTHEAD
   It has to cross with the ground, or it sits on the dark stage as a bar of
   paper with the flight happening underneath it.

   The masthead is a PRECEDING SIBLING of the stage, and timeline lookup only
   walks up ancestors, so it cannot see --gal on its own. timeline-scope on
   the sheet is what lifts the name to the common ancestor and makes it
   referenceable from anywhere inside. Without that line the two rules below
   silently do nothing at all.

   Hover and current-page keep their feedback through the underline: an
   animation beats a normal declaration, so the colour on :hover would never
   win. The border is untouched and does the work. */
.sheet{ timeline-scope:--gal; }

.nav{ background:none; }
.nav::before{
  content:''; position:absolute; inset:0; z-index:-1;
  background:linear-gradient(var(--paper) 68%, rgba(215,208,198,.92) 86%, rgba(215,208,198,0));
  animation:navGround linear both;
  animation-timeline:--gal;
  animation-range:contain 0% contain 100%;
}
@keyframes navGround{
  0%,16%   { opacity:1; }
  30%,74%  { opacity:0; }
  90%,100% { opacity:1; }
}
/* And a dark one fading in behind it, because the works fly THROUGH the
   masthead and light type on a passing card is unreadable. Two grounds
   crossing over each other, one per palette, is how the reference does it. */
.nav::after{
  content:''; position:absolute; inset:0; z-index:-1;
  background:linear-gradient(rgba(23,19,16,.94) 46%, rgba(23,19,16,.6) 74%, rgba(23,19,16,0));
  opacity:0;
  animation:navGroundDark linear both;
  animation-timeline:--gal;
  animation-range:contain 0% contain 100%;
}
@keyframes navGroundDark{
  0%,16%   { opacity:0; }
  30%,74%  { opacity:1; }
  90%,100% { opacity:0; }
}
.nav a{
  animation:navInk linear both;
  animation-timeline:--gal;
  animation-range:contain 0% contain 100%;
}
@keyframes navInk{
  0%,16%   { color:var(--ink-soft); }
  30%,74%  { color:rgba(232,225,214,.86); }
  90%,100% { color:var(--ink-soft); }
}

/* Type inside the stage has to survive the ground going dark under it. The
   ground crosses at 30%, so these cross with it. */
.gal__stage .on-dark{
  animation:galFlip linear both;
  animation-timeline:--gal;
  animation-range:contain 0% contain 100%;
}
@keyframes galFlip{
  0%,16%   { color:var(--ink); }
  30%,74%  { color:var(--paper-hi); }
  90%,100% { color:var(--ink); }
}

/* --------------------------------------------------------------- DETAIL
   Picking a work brings it forward and puts the rest behind glass. A
   <dialog>, so the browser handles the focus trap, the escape key and the
   inert background rather than three bugs written by hand. */
.sheetdlg{
  border:0; padding:0; max-width:min(92vw,860px); width:100%;
  background:transparent; color:var(--ink);
  overflow:visible;
}
.sheetdlg::backdrop{ background:rgba(23,19,16,.82); backdrop-filter:blur(3px); }
.sheetdlg[open]{ animation:dlgIn .5s var(--ease) both; }
@keyframes dlgIn{
  from{ opacity:0; transform:translateY(24px) scale(.96); }
  to  { opacity:1; transform:translateY(0) scale(1); }
}
.dlg{
  display:grid; grid-template-columns:minmax(0,1.25fr) minmax(0,1fr);
  gap:clamp(1.4rem,3vw,2.6rem);
  padding:clamp(1.4rem,3vw,2.4rem);
  background:url('img/paper.jpg') repeat, var(--paper);
  background-size:900px auto;
  box-shadow:0 30px 80px rgba(0,0,0,.6);
}
@media (max-width:760px){ .dlg{ grid-template-columns:1fr; } }
.dlg__img{
  width:100%; aspect-ratio:3.5/2; object-fit:contain;
  background:var(--paper-hi); padding:7%;
  box-shadow:0 1px 2px rgba(42,36,28,.3), 0 12px 26px rgba(42,36,28,.2);
}
.dlg__t{
  font-family:var(--f-body); font-weight:400;
  font-size:clamp(1.4rem,3vw,2.1rem); line-height:1.14; margin-bottom:.8rem;
}
.dlg__x{
  position:absolute; top:-1.2rem; right:-1.2rem;
  width:2.6rem; height:2.6rem; border-radius:50%;
  border:1px solid var(--paper-lo); background:var(--paper-hi);
  cursor:pointer; font-size:1rem; line-height:1; color:var(--ink);
  text-shadow:none;
}
.dlg__wrap{ position:relative; }

/* --------------------------------------------------------------- INDEX
   Everything in the cloud, listed flat underneath it. The journey is the
   experience; this is how anyone gets to a specific piece of work without
   flying through the whole thing, and it is what search engines read. */
.ix{
  max-width:var(--wrap); margin:0 auto;
  padding:clamp(3rem,8vw,6rem) var(--gut);
}
.ix__grid{
  display:grid; grid-template-columns:repeat(auto-fill,minmax(230px,1fr));
  gap:clamp(1.2rem,3vw,2.4rem);
}
.ix__item{ margin:0; }
.ix__item img{
  width:100%; aspect-ratio:3.5/2; object-fit:contain;
  background:var(--paper-hi); padding:7%;
  box-shadow:0 1px 2px rgba(42,36,28,.26), 0 10px 22px rgba(42,36,28,.16);
  cursor:pointer;
  transition:transform .4s var(--ease), box-shadow .4s var(--ease);
}
.ix__item img:hover{
  transform:translateY(-4px);
  box-shadow:0 2px 4px rgba(42,36,28,.3), 0 18px 36px rgba(42,36,28,.22);
}
.ix__cap{
  margin-top:.7rem;
  font-family:var(--f-mono); font-size:.62rem;
  letter-spacing:.14em; text-transform:uppercase; color:var(--ink-faint);
}

/* On a small screen the flight is both unreadable and expensive, so the
   stage stops flying and the index does the whole job. Nothing is lost but
   the journey, and the journey was never where the work was. */
@media (max-width:760px), (prefers-reduced-motion: reduce){
  .gal{ height:auto; }
  .gal__stage{ position:relative; height:auto; overflow:visible; perspective:none; padding:clamp(3rem,12vw,6rem) 0; }
  .gal__ground, .gal__veil, .spiral, .gal__hint{ display:none; }
  .gal__intro, .gal__end{
    position:relative; inset:auto; animation:none; opacity:1;
    transform:none; filter:none; pointer-events:auto; padding:2rem var(--gut);
  }
  .gal__stage .on-dark{ animation:none; color:var(--ink); }
  /* the ground never darkens here, so the masthead must not cross either */
  .nav::before{ animation:none; opacity:1; }
  .nav::after{ animation:none; opacity:0; }
  .nav a{ animation:none; }
}
