/* ==========================================================================
   SPINE — scroll-linked drawing, markers, and card activation.
   Geometry is fitted in js/spine.js; this file only drives it.

   ARCHITECTURE NOTE — the same inversion the hero uses, for the same reason.
   THE RESOLVED STATE IS THE DEFAULT: line fully drawn, cards fully visible,
   markers at full strength. Nothing animates until js/spine.js puts
   .spine-live on <html>, which it only does once the path has actually been
   fitted and reduced-motion is off.

   Written the other way round — cards hidden until scroll reveals them — a
   failed script, an unsupported timeline or a thrown error leaves the entire
   project grid blank. An earlier revision of this file did exactly that.
   Portfolio content must never be behind a script.
   ========================================================================== */

.work {
  position: relative;
  view-timeline-name: --spine;
  view-timeline-axis: block;
}

.spine {
  position: absolute;
  inset: 0;
  z-index: 0;              /* behind the cards, above the paper */
  pointer-events: none;
}

.work__grid { position: relative; z-index: 1; }

.spine__svg {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  max-width: none;
  overflow: visible;
}


/* --- Resting state = finished --------------------------------------------
   The line is continuous and each stop is a dot. Progress still rides on a
   mask rather than a dash animation, because the mask reveals the line and
   its dots together — a dashoffset on the path alone could not.

   The geometry is drawn twice: once in .spine__ghost at reduced strength for
   the route ahead of the head, once in .spine__live at full strength behind
   it, with only the live layer masked. */

.spine__live { mask: url(#spine-reveal); }

.spine__line,
.spine__ghost-line {
  stroke: var(--kumkumam);
  stroke-width: var(--spine-w);
  stroke-linecap: round;
  fill: none;
}

.spine__dots circle,
.spine__ghost-dots circle,
.spine__cap,
.spine__ghost-cap {
  fill: var(--kumkumam);
}

.spine__reveal {
  /* Wide enough to cover the dots, not just the line — a mask narrower than
     the marks it reveals would clip them into slivers as the head passes. */
  stroke-width: calc(var(--spine-w) * 5);
  stroke-dasharray: 1000 1000;              /* pathLength normalised to 1000 */
  stroke-dashoffset: 0;                     /* resting: fully revealed */
}

/* 7px flat. At clamp(3px, 0.42vw, 6px) the stroke was too light to carry a
   19-44px swing — the curve read as a wobble rather than a gesture. */
/* Declared on .work, not on .spine, so the card's paper cross-bar (work.css)
   and the drawn line read the same number. Two hardcoded 7s in two files was
   one edit away from a visible mismatch. */
.work { --spine-w: 7px; }

/* THE GHOST CARRIES THE TWO STATES.
   With deviation as the marker there is no separate element to colour, so
   "colour is earned by arrival" moves onto the route itself: the whole path
   is present at reduced strength ahead of the head, and the drawn portion
   sits over it at full kumkumam. Approaching and arrived are then true at
   every point on the line, not only at the six stops.

   Reduced strength is opacity over paper, never an interpolated fill —
   animating fill from a color-mix() to the token produced oklab(1 43 37),
   which renders yellow. */
.spine__ghost { opacity: 0; }   /* resting: the line is already complete */


/* --- Live: scroll drives everything --------------------------------------
   animation-range for the markers and cards is written inline per element by
   spine.js, because animation-range cannot read a custom property and each
   one sits at its own measured fraction along the fitted path. */

.spine-live .spine__reveal {
  stroke-dashoffset: 1000;
  animation: spine-draw 1s linear forwards;
  animation-timeline: --spine;
  animation-range: cover 0% cover 92%;
}

@keyframes spine-draw {
  to { stroke-dashoffset: 0; }
}

.spine-live .spine__ghost { opacity: 0.26; }


/* --- The coil ------------------------------------------------------------
   A coil is absent, then draws in at full --kumkumam as the head arrives,
   then STAYS. Scrolling back up above it reverses the draw, because a
   scroll timeline is scrubbed rather than played — that reversal is free.

   THE ONLY STATE IS DRAWN OR NOT DRAWN. No reduced strength before arrival,
   no fade after the head passes. Opacity is never animated: visibility comes
   entirely from the reveal mask, which starts fully retracted, so nothing
   needs to fade anything in or out.

   Because they persist, several coils are visible at once further down the
   page. That is intended now — the earlier "only one on screen" rule went
   with the fading. */

.card__coil { opacity: 1; }

/* Live: retracted until the head reaches it. animation-fill-mode is
   `forwards`, so before its range the base style below applies (hidden) and
   after it the final keyframe persists (drawn). */
.spine-live .coil__mask {
  stroke-dashoffset: 1000;
  animation: coil-draw 1s linear forwards;
  animation-timeline: --spine;
}

@keyframes coil-draw {
  to { stroke-dashoffset: 0; }
}

@media (prefers-reduced-motion: reduce) {
  .spine-live .coil__mask { animation: none; stroke-dashoffset: 0; }
}

/* Fallback: spine.js toggles .is-drawn once the head has passed, and it is
   not removed until the head goes back above. */
.spine-live.no-scroll-timeline .coil__mask {
  animation: none;
  stroke-dashoffset: 1000;
  transition: stroke-dashoffset var(--t-base) var(--ease);
}
.spine-live.no-scroll-timeline .card.is-drawn .coil__mask { stroke-dashoffset: 0; }


/* --- The card follows the coil -------------------------------------------
   Index, title, subtitle, year and cover fade in TOGETHER, starting once the
   coil has closed. The project resolves as one object: the coil is the
   arrival, and this is what arrives.

   This reverses an earlier decision recorded here, which held that nothing
   but the coil should move because withholding the work until it is scrolled
   to is a cost. That cost is real and it is being paid deliberately — the
   fade is short, it runs on the second half of the card's approach, and by
   the time a project reaches reading position it is fully resolved.

   RANGE COMES FROM THE CARD, VIA CUSTOM PROPERTIES. These elements sit at
   different depths of the card's subtree and animation-range does not
   inherit; custom properties do, so spine.js writes --fade-from/--fade-to
   once per card and every element below reads the same pair.

   The base opacity is scoped inside .spine-live, which spine.js only adds
   after the route has been fitted successfully. A thrown error, an
   unsupported timeline or reduced motion therefore all leave the cards at
   full strength rather than blank — the failure mode is "no animation", never
   "no content". */

.spine-live .card__index,
.spine-live .card__title,
.spine-live .card__sub,
.spine-live .card__year,
.spine-live .card__media {
  opacity: 0;
  animation: card-arrive var(--t-base) linear forwards;
  animation-timeline: --spine;
  animation-range: var(--fade-from, 0%) var(--fade-to, 100%);
}

@keyframes card-arrive {
  to { opacity: 1; }
}

/* Fallback: no scroll timeline, so spine.js toggles .is-drawn instead. The
   transition delay stands in for the gap the range provides above — the coil
   closes, then the card follows. */
.spine-live.no-scroll-timeline .card__index,
.spine-live.no-scroll-timeline .card__title,
.spine-live.no-scroll-timeline .card__sub,
.spine-live.no-scroll-timeline .card__year,
.spine-live.no-scroll-timeline .card__media {
  animation: none;
  opacity: 0;
  transition: opacity var(--t-base) var(--ease) var(--t-quick);
}

.spine-live.no-scroll-timeline .card.is-drawn .card__index,
.spine-live.no-scroll-timeline .card.is-drawn .card__title,
.spine-live.no-scroll-timeline .card.is-drawn .card__sub,
.spine-live.no-scroll-timeline .card.is-drawn .card__year,
.spine-live.no-scroll-timeline .card.is-drawn .card__media { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  .spine-live .card__index,
  .spine-live .card__title,
  .spine-live .card__sub,
  .spine-live .card__year,
  .spine-live .card__media {
    animation: none;
    transition: none;
    opacity: 1;
  }
}


/* --- Fallback ------------------------------------------------------------
   No animation-timeline: spine.js writes stroke-dashoffset from a
   rAF-throttled scroll listener and toggles .is-active. Animations are off
   here so both paths can never drive the same property at once. */

.spine-live.no-scroll-timeline .spine__reveal,
.spine-live.no-scroll-timeline .card {
  animation: none;
}

.spine-live.no-scroll-timeline .card__text,
.spine-live.no-scroll-timeline .card__media {
  transform: translateY(1.25rem);
  opacity: 0;
  transition: transform var(--t-quick) var(--ease), opacity var(--t-quick) var(--ease);
}

.spine-live.no-scroll-timeline .card.is-active .card__text,
.spine-live.no-scroll-timeline .card.is-active .card__media {
  transform: translateY(0);
  opacity: 1;
}


/* --- Reduced motion ------------------------------------------------------
   Full bypass (§4, §12). spine.js will not add .spine-live at all, so the
   resting state above already is the bypass — line complete, cards active.
   This block is the belt to that braces: if the class arrives by any other
   route, the sequence is neutralised rather than merely sped up. */

@media (prefers-reduced-motion: reduce) {
  .spine-live .spine__reveal {
    animation: none;
    stroke-dashoffset: 0;
  }
  .spine-live .spine__ghost { opacity: 0; }
  .spine-live .card { animation: none; }
  .spine-live .card__text,
  .spine-live .card__media {
    transform: none;
    opacity: 1;
    transition: none;
  }
}


/* --- Narrow: one column, stack set back from the rail -------------------- */

@media (max-width: 46rem) {
  /* The whole stack clears the rail by the same gutter, so the coil, title,
     subtitle, year and cover all align to one left edge rather than sitting
     at varying distances from the line. Applied on the link — and with
     :nth-child(n) so it beats work.css's (0,3,1) placement rules rather than
     losing to them, which is the specificity trap this layout hit before. */
  .work__grid > li:nth-child(n) .card__link {
    grid-column: 1;
    padding-inline: calc(var(--rail-x) + var(--rail-gutter)) var(--gutter);
  }

  /* No outward bleed in one column — the cover already spans the row. */
  .work__grid > li:nth-child(n) .card__media {
    margin-inline: 0;
  }

  /* And so no correction for it either. work.css nudges the text by half the
     bleed to keep it centred on the cover; with the bleed off, that nudge is
     the only thing left moving, and it knocked the type 5px off the cover it
     was meant to line up with. */
  .work__grid > li:nth-child(n) .card__text {
    translate: none;
  }

  .card { grid-template-columns: 1fr; }

  /* Smaller again, and it sits beside its dot rather than over the rail —
     the stack's own gutter already places it clear. */
  .card__coil { width: clamp(32px, 9vw, 44px); }

  /* Option C on narrow too: the block centres, so the text measure centres
     within the column rather than hugging the rail. */
  .card__text { width: min(var(--text-measure), 100%); margin-inline: auto; }

  .card__sub { max-width: none; }
}
