/* ============================================================
   PROJECTS — the cycling showcase.

   Motion is driven by custom properties that assets/js/carousel.js
   writes every frame:
     --pos   fractional rail position (2.37 = between slide 3 and 4)
     --skew  current velocity, used for a subtle liquid lean
     --d     signed distance of this slide from centre
     --o     fade amount for this slide (1 = centred)

   And by two classes:
     .is-seen    this slide has been reached at least once
     .is-arrive  one-shot, added for about a second on each arrival
   ============================================================ */

.projects {
  position: relative;
  flex: 1 1 auto;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: clamp(1rem, 2.6vh, 2rem);
  padding-block: clamp(1.25rem, 3.5vh, 3rem);
  overflow: hidden;
  isolation: isolate;
}

/* ---------- the sliding rail ---------- */

.projects__viewport {
  overflow: hidden;
  cursor: grab;
  touch-action: pan-y;          /* vertical scrolling stays native, we take horizontal */
  -webkit-user-select: none;
  user-select: none;
  transition: opacity 0.18s linear;
}
.projects__viewport.is-dragging { cursor: grabbing; }

/* Only used under reduced motion: the rail dissolves, swaps underneath and
   comes back, so the slide changes without anything flying across. */
.projects__viewport.is-cutting { opacity: 0; }

.projects__track {
  display: flex;
  align-items: center;
  will-change: transform;
  transform: translate3d(calc(var(--pos, 0) * -100%), 0, 0);
}

.slide {
  flex: 0 0 100%;
  min-width: 0;
  padding-inline: var(--gutter);
  opacity: var(--o, 1);
}

.slide__inner {
  display: grid;
  grid-template-columns: minmax(0, 1.06fr) minmax(0, 0.94fr);
  align-items: center;
  gap: clamp(1.25rem, 3vw, 3rem);
  max-width: var(--maxw);
  margin-inline: auto;
  /* parallax: content drifts a little slower than the rail itself */
  transform: translate3d(calc(var(--d, 0) * 5%), 0, 0);
}

/* ---------- media column ---------- */

.slide__media-col { display: flex; flex-direction: column; gap: 0.6rem; min-width: 0; }

.panel--viewport {
  overflow: hidden;
  /* the liquid lean, plus a little depth on the off-centre slides */
  transform: skewX(var(--skew, 0deg)) scale(calc(0.96 + 0.04 * var(--o, 1)));
  transform-origin: center;
}

.media {
  position: relative;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  background: var(--bg-deep);
}

/* ---------- the About portrait is square ---------- */

/* Projects stay 16/9. Only the opening slide is square, which suits a
   portrait and sets it apart from the work without using a different
   frame. */
.slide--about .media { aspect-ratio: 1 / 1; }
/* Only the picture fades while a thumbnail swaps in — the camera furniture
   around it stays put. */
.media > * { transition: opacity 0.2s linear; }
.media.is-swapping > * { opacity: 0; }

/* Arrival sweep — one pass of light across the picture, the way a render
   target refreshes. */
.media::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 3;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(104deg,
    transparent 42%,
    color-mix(in srgb, var(--accent) 22%, transparent) 50%,
    transparent 58%);
}
.slide.is-arrive .media::before { animation: fx-sweep 0.8s var(--ease); }

@keyframes fx-sweep {
  0%   { opacity: 0; transform: translateX(-60%); }
  25%  { opacity: 1; }
  100% { opacity: 0; transform: translateX(60%); }
}

/* Four camera-style corner brackets, drawn with eight thin gradients on a
   single element so the markup stays clean. */
.media::after {
  content: "";
  position: absolute;
  inset: 9px;
  z-index: 3;
  pointer-events: none;
  --c: color-mix(in srgb, var(--accent) 80%, transparent);
  --t: 1px;
  --l: 16px;
  background:
    linear-gradient(var(--c), var(--c)) 0 0        / var(--l) var(--t) no-repeat,
    linear-gradient(var(--c), var(--c)) 0 0        / var(--t) var(--l) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 0     / var(--l) var(--t) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 0     / var(--t) var(--l) no-repeat,
    linear-gradient(var(--c), var(--c)) 0 100%     / var(--l) var(--t) no-repeat,
    linear-gradient(var(--c), var(--c)) 0 100%     / var(--t) var(--l) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 100%  / var(--l) var(--t) no-repeat,
    linear-gradient(var(--c), var(--c)) 100% 100%  / var(--t) var(--l) no-repeat;
}
.slide.is-arrive .media::after { animation: fx-brackets 0.7s var(--ease-snap); }

@keyframes fx-brackets {
  0%   { inset: 2px; opacity: 0; }
  40%  { opacity: 1; }
  100% { inset: 9px; opacity: 1; }
}

.media__img,
.media__video,
.media iframe {
  width: 100%;
  height: 100%;
  border: 0;
  object-fit: cover;
}

/* Without this the browser starts its own image drag the moment you grab a
   picture, and the rail never sees the swipe. The matching draggable="false"
   is set in carousel.js; this covers the rest. */
.media img, .media video,
.thumb img, .thumb video {
  -webkit-user-drag: none;
  -webkit-touch-callout: none;
  user-select: none;
}

.media__empty {
  display: grid;
  place-items: center;
  height: 100%;
  padding: 1.5rem;
  text-align: center;
  font-family: var(--font-mono);
  font-size: 0.75rem;
  line-height: 1.8;
  color: var(--muted);
}

/* YouTube click-to-play poster. No iframe loads until someone asks for it,
   which keeps the page fast even with a dozen projects. */
.media__play { display: block; width: 100%; height: 100%; position: relative; }
.media__play .media__img {
  transition: transform 0.9s var(--ease), filter 0.6s var(--ease);
  filter: saturate(0.88) contrast(1.04);
}
.media__play:hover .media__img { transform: scale(1.035); filter: saturate(1) brightness(1.06); }

.media__playicon { position: absolute; inset: 0; display: grid; place-items: center; }
.media__playicon span {
  display: grid;
  place-items: center;
  width: clamp(2.9rem, 4.4vw, 3.6rem);
  aspect-ratio: 1;
  border-radius: var(--radius-sm);
  background: color-mix(in srgb, var(--accent) 92%, #000);
  color: #05070a;
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--accent) 35%, transparent),
    0 0 0 7px color-mix(in srgb, var(--accent) 9%, transparent),
    0 14px 40px -10px color-mix(in srgb, var(--accent) 80%, transparent);
  transition: transform 0.4s var(--ease-snap), box-shadow 0.4s var(--ease);
}
.media__play:hover .media__playicon span {
  transform: scale(1.1);
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--accent) 50%, transparent),
    0 0 0 11px color-mix(in srgb, var(--accent) 10%, transparent),
    0 16px 50px -10px color-mix(in srgb, var(--accent) 95%, transparent);
}
.media__playicon svg { width: 40%; height: 40%; fill: currentColor; stroke: none; translate: 6% 0; }

.media__caption {
  font-family: var(--font-mono);
  font-size: 0.68rem;
  letter-spacing: 0.03em;
  color: var(--muted);
  min-height: 1.3em;
  padding-left: 0.1rem;
}

/* ---------- thumbnail strip ---------- */

.thumbs {
  display: flex;
  gap: 0.4rem;
  margin: 0;
  padding: 0;
  list-style: none;
  flex-wrap: wrap;
}
.thumb {
  position: relative;
  display: block;
  width: clamp(3.2rem, 5vw, 4.4rem);
  aspect-ratio: 16 / 10;
  border-radius: var(--radius-sm);
  overflow: hidden;
  border: 1px solid var(--line-soft);
  filter: saturate(0.6) brightness(0.8);
  transition: filter 0.3s var(--ease), transform 0.3s var(--ease-snap),
              border-color 0.3s var(--ease), box-shadow 0.3s var(--ease);
}
.thumb img, .thumb video { width: 100%; height: 100%; object-fit: cover; }
.thumb:hover { filter: saturate(0.9) brightness(1); transform: translateY(-2px); }
.thumb[aria-current="true"] {
  filter: none;
  border-color: color-mix(in srgb, var(--accent) 70%, transparent);
  box-shadow: 0 0 0 1px color-mix(in srgb, var(--accent) 40%, transparent);
}
.thumb__badge {
  position: absolute;
  inset: 0;
  display: grid;
  place-items: center;
  background: rgba(3, 4, 6, 0.4);
}
.thumb__badge svg { width: 28%; height: 28%; fill: #fff; stroke: none; }

/* ---------- details column ---------- */

.slide__body {
  display: flex;
  flex-direction: column;
  gap: 0.85rem;
  min-width: 0;
  position: relative;
  padding-left: clamp(0.9rem, 1.6vw, 1.5rem);
}

/* The accent spine down the left edge of the details column. */
.slide__body::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0.2rem;
  bottom: 0.2rem;
  width: 1px;
  background: linear-gradient(to bottom,
    transparent,
    color-mix(in srgb, var(--accent) 65%, transparent) 15%,
    color-mix(in srgb, var(--accent) 20%, transparent) 70%,
    transparent);
}

/* Staggered entrance: each child rises in turn the first time its slide is
   reached, then stays put (is-seen) so already-visited slides keep their
   text while you drag past them. */
.slide__body > * {
  opacity: 0;
  transform: translate3d(0, 16px, 0);
  filter: blur(7px);
  transition: opacity 0.6s var(--ease-liquid),
              transform 0.9s var(--ease-liquid),
              filter 0.55s var(--ease-liquid);
  transition-delay: calc(var(--i, 0) * 65ms);
}
.slide.is-seen .slide__body > * { opacity: 1; transform: none; filter: none; }

/* Every later arrival re-settles the column. It starts BEFORE the rail has
   finished moving (carousel.js fires it at ARRIVE_AT), so the text is
   already flowing into place as the slide lands rather than catching up
   afterwards.

   --dir is the direction of travel, so the rows trail behind the slide and
   are pulled in, the way something suspended in liquid would lag the jar. */
.slide.is-arrive .slide__body > * {
  animation: fx-settle 0.85s var(--ease-liquid) backwards;
  animation-delay: calc(var(--i, 0) * 60ms);
}
@keyframes fx-settle {
  0% {
    opacity: 0.18;
    transform: translate3d(calc(var(--dir, 1) * 18px), 7px, 0);
    filter: blur(7px);
  }
  55% { filter: blur(1.2px); }
  100% { opacity: 1; transform: none; filter: none; }
}

.slide__body > *:nth-child(1) { --i: 0; }
.slide__body > *:nth-child(2) { --i: 1; }
.slide__body > *:nth-child(3) { --i: 2; }
.slide__body > *:nth-child(4) { --i: 3; }
.slide__body > *:nth-child(5) { --i: 4; }
.slide__body > *:nth-child(6) { --i: 5; }
.slide__body > *:nth-child(7) { --i: 6; }
.slide__body > *:nth-child(8) { --i: 7; }

.slide__idx {
  display: flex;
  align-items: center;
  gap: 0.6rem;
  font-family: var(--font-mono);
  font-size: 0.64rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: var(--muted);
}
.slide__idx b {
  font-weight: 500;
  color: color-mix(in srgb, var(--accent) 70%, var(--text));
}
.slide__idx::after {
  content: "";
  flex: 1 1 auto;
  height: 1px;
  background: linear-gradient(to right, var(--line), transparent);
}

.slide__title {
  font-size: clamp(1.7rem, 1.1rem + 2.5vw, 2.9rem);
  color: #fff;
}

.slide__summary {
  font-size: clamp(0.95rem, 0.9rem + 0.3vw, 1.1rem);
  line-height: 1.5;
  color: var(--text);
}

/* The landing slide: discipline under the name, nothing else. Deliberately
   quiet, because the showreel next to it is what the slide is for. */
.slide__kicker {
  font-family: var(--font-mono);
  font-size: 0.78rem;
  letter-spacing: 0.22em;
  text-transform: uppercase;
  color: color-mix(in srgb, var(--accent) 55%, var(--text));
}

/* Role and year as a property sheet, the way an engine shows them. */
.props {
  display: grid;
  grid-template-columns: auto 1fr;
  gap: 0 1rem;
  margin: 0;
  padding: 0.5rem 0;
  border-block: 1px solid var(--line-soft);
}
.props dt {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: var(--muted);
  line-height: 1.9;
}
.props dd {
  margin: 0;
  font-size: 0.8rem;
  line-height: 1.9;
  color: var(--dim);
}

.slide__text {
  display: flex;
  flex-direction: column;
  gap: 0.65rem;
  color: var(--dim);
  font-size: 0.875rem;
  line-height: 1.7;
  max-width: 54ch;
}
.slide__text a {
  color: color-mix(in srgb, var(--accent) 75%, var(--text));
  text-underline-offset: 3px;
  text-decoration-color: color-mix(in srgb, var(--accent) 40%, transparent);
}
.slide__text a:hover { text-decoration-color: currentColor; }

.tags { display: flex; flex-wrap: wrap; gap: 0.3rem; margin: 0; padding: 0; list-style: none; }
.tags li {
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  padding: 0.28rem 0.5rem;
  border-radius: var(--radius-sm);
  border: 1px solid var(--line-soft);
  border-left: 2px solid color-mix(in srgb, var(--accent) 55%, transparent);
  background: var(--surface);
  color: var(--dim);
}

.slide__links { display: flex; flex-wrap: wrap; gap: 0.5rem; }

/* ---------- the transport bar ---------- */

/* The slide pads by --gutter and then centres a --maxw column inside that.
   The transport has to do the same sum or it sits 100-odd px to the right of
   the panel it belongs to. */
.transport {
  display: flex;
  align-items: center;
  gap: clamp(0.6rem, 2vw, 1.4rem);
  width: 100%;
  max-width: calc(var(--maxw) + 2 * var(--gutter));
  margin-inline: auto;
  padding-inline: var(--gutter);
}

/* Prev and next — square and mechanical. They normally say Prev and Next,
   and swap to the name of the next section when one is coming up. The
   min-width is what stops the scrubber jumping when the label changes. */
.tbtn {
  display: inline-flex;
  align-items: center;
  justify-content: center;
  gap: 0.5rem;
  flex: 0 0 auto;
  min-width: 7.75rem;
  padding: 0.5rem 0.75rem;
  border: 1px solid var(--line-soft);
  border-radius: var(--radius-sm);
  background: var(--surface);
  font-family: var(--font-mono);
  font-size: 0.64rem;
  letter-spacing: 0.16em;
  text-transform: uppercase;
  color: var(--dim);
  transition: background 0.25s var(--ease), border-color 0.25s var(--ease),
              color 0.25s var(--ease), opacity 0.25s var(--ease);
}
.tbtn svg { width: 0.85rem; height: 0.85rem; }
.tbtn:hover:not(:disabled) {
  background: color-mix(in srgb, var(--accent) 12%, transparent);
  border-color: color-mix(in srgb, var(--accent) 55%, transparent);
  color: var(--text);
}
.tbtn:active:not(:disabled) { transform: translateY(1px); }
.tbtn:disabled { opacity: 0.25; cursor: default; }

.tbtn__label {
  max-width: 9rem;
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* Lit up while the button is pointing at a different section. */
.tbtn.is-section:not(:disabled) {
  background: color-mix(in srgb, var(--accent) 9%, transparent);
  border-color: color-mix(in srgb, var(--accent) 42%, transparent);
  color: color-mix(in srgb, var(--accent) 45%, var(--text));
}

/* The scrubber: one tick per project over a progress rule. */
.scrub {
  position: relative;
  flex: 1 1 auto;
  min-width: 0;
  height: 2.1rem;
  display: flex;
  align-items: center;
}

/* Inset by half a tick at each end so 0% and 100% of the fill land exactly
   on the first and last tick. */
.scrub__rule {
  position: absolute;
  left: 0.8rem; right: 0.8rem;
  top: 50%;
  height: 1px;
  background: var(--line-soft);
  overflow: hidden;
}
.scrub__rule span {
  display: block;
  height: 100%;
  width: var(--progress, 0%);
  background: linear-gradient(to right,
    color-mix(in srgb, var(--accent) 25%, transparent),
    var(--accent));
  transition: background 0.9s var(--ease);
}

/* Ticks sit at 0%, 50%, 100% of the rule for three projects, so the filled
   portion of the rule always ends exactly on the current tick. */
.ticks {
  position: relative;
  z-index: 1;
  display: flex;
  justify-content: space-between;
  align-items: center;
  width: 100%;
  height: 100%;
}

.tick {
  position: relative;
  flex: 0 0 auto;
  width: 1.6rem;
  height: 100%;
  display: grid;
  place-items: center;
}
/* The visible mark. The button around it stays wide so it is easy to hit. */
.tick::after {
  content: "";
  width: 1px;
  height: 0.5rem;
  background: var(--muted);
  transition: height 0.35s var(--ease-snap), background 0.35s var(--ease),
              box-shadow 0.35s var(--ease), width 0.35s var(--ease);
}
/* The first tick of each section stands a little taller, so the groups are
   readable along the rule without labelling them. */
.tick[data-boundary="1"]::after { height: 0.95rem; background: var(--dim); }

.tick:hover::after { height: 1.1rem; background: var(--text); }
.tick[aria-current="true"]::after,
.tick[data-boundary="1"][aria-current="true"]::after {
  width: 2px;
  height: 1.5rem;
  background: var(--accent);
  box-shadow: 0 0 10px color-mix(in srgb, var(--accent) 70%, transparent);
}

.counter {
  flex: 0 0 auto;
  font-family: var(--font-mono);
  font-size: 0.72rem;
  letter-spacing: 0.1em;
  color: var(--muted);
  font-variant-numeric: tabular-nums;
}
.counter b {
  color: color-mix(in srgb, var(--accent) 60%, #fff);
  font-weight: 500;
  display: inline-block;
}
.counter.is-bumped b { animation: fx-bump 0.5s var(--ease); }
@keyframes fx-bump {
  0%   { transform: translateY(0.6em); opacity: 0; filter: blur(4px); }
  100% { transform: none; opacity: 1; filter: none; }
}

.projects__hint {
  text-align: center;
  font-family: var(--font-mono);
  font-size: 0.62rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: var(--muted);
  opacity: 0.65;
}
.projects__hint kbd { text-transform: none; letter-spacing: 0; }

/* ---------- narrow screens ---------- */

/* A square panel stands nearly twice as tall as a 16/9 one at the same
   width, so on the two-column layout the About slide gives the photo a
   narrower column and caps it against the viewport height. Without the cap
   the portrait pushes the transport bar off the bottom of a laptop screen.

   Scoped to the wide layout on purpose: below 900px everything is a single
   column, and these rules are specific enough to override that if they were
   allowed to apply there. */
@media (min-width: 901px) {
  .slide--about .slide__inner {
    grid-template-columns: minmax(0, 0.82fr) minmax(0, 1.18fr);
  }
  .slide--about .slide__media-col { max-width: min(100%, 54vh); }
}

@media (max-width: 900px) {
  .slide__inner { grid-template-columns: minmax(0, 1fr); gap: 1.4rem; }
  .projects { justify-content: flex-start; }
  .projects__hint { display: none; }
  .slide__body { padding-left: 0.9rem; }
}

@media (max-width: 620px) {
  .tbtn__label { display: none; }
  .tbtn { padding: 0.5rem; min-width: 0; }
  .thumb { width: 3rem; }
  .transport { gap: 0.5rem; }
}

/* ---------- calm version ---------- */

@media (prefers-reduced-motion: reduce) {
  .panel--viewport { transform: none; }
  .slide__inner { transform: none; }
  .slide__body > * { opacity: 1; transform: none; filter: none; }
  .media::before { display: none; }

  /* The one transition that survives the blanket kill in base.css. Fading
     is motion-safe in a way that sliding is not, so it is what replaces the
     travel rather than leaving a hard cut. */
  .projects__viewport { transition: opacity 0.18s linear !important; }
}
