/* ===========================================================================
   Views: project carousel (home), about, case study
   ---------------------------------------------------------------------------
   Structure follows Robin Noguier's model: the landing page is the work
   itself — one project per full screen — rather than a CV or a company index.
   Case studies stay a normal scrolling document.
   =========================================================================== */

#app { min-height: 100vh; min-height: 100svh; }

/* ===========================================================================
   ROUTE TRANSITIONS
   ---------------------------------------------------------------------------
   The browser cross-fades the old view into the new one. The outgoing frame
   leaves faster than the incoming one arrives, so the two overlap only briefly
   — a long symmetric fade over a full page of text reads as a smear.

   The site chrome is given its own name so it holds still rather than fading
   with everything behind it; it is the same header either side of the change.
   =========================================================================== */
::view-transition-old(root) {
  animation: vt-out 180ms var(--ease) both;
}
::view-transition-new(root) {
  animation: vt-in 420ms var(--ease-out) both;
}
@keyframes vt-out { to   { opacity: 0; transform: translateY(-8px); } }
@keyframes vt-in  { from { opacity: 0; transform: translateY(14px); } }

.chrome { view-transition-name: site-chrome; }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root),
  ::view-transition-new(root) { animation: none; }
}

/* ===========================================================================
   SITE CHROME — name left, About right. Deliberately quiet: it exists so the
   enormous project title has something to be loud against.
   =========================================================================== */
.chrome {
  position: fixed;
  top: 0; left: 0; right: 0;
  z-index: var(--z-nav);
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  padding: var(--sp-6) var(--gutter);
  pointer-events: none;
}
.chrome > * { pointer-events: auto; }
.chrome__name {
  display: flex;
  flex-direction: column;
  gap: 6px;
}
.chrome__name b { font-weight: 500; color: var(--ink-strong); }
.chrome__nav { display: flex; align-items: center; gap: var(--sp-5); }
.chrome__link {
  position: relative;
  color: var(--ink-muted);
  transition: color var(--dur-fast) var(--ease);
  /* The label text alone is 11px tall, under the 24px WCAG 2.2 target size.
     Padding grows the accepting box; the equal negative margin cancels it back
     out for layout, so the header's height and the gap between the two links
     is unchanged, at 375px and at 1920px alike. */
  padding-block: 7px;
  margin-block: -7px;
}
.chrome__link:hover,
.chrome__link[aria-current='page'] { color: var(--ink-strong); }
.chrome__link[aria-current='page']::after {
  content: '';
  position: absolute;
  /* -13px, not -6: the padding above pushed this link's own box 7px further
     down without moving the text inside it, so the underline offset grows by
     the same 7px to stay pinned under the text rather than drifting with the
     new, larger hit area. */
  left: 0; right: 0; bottom: -13px;
  height: 1px;
  background: var(--accent);
}

/* ===========================================================================
   DECK — one project per full screen
   =========================================================================== */
/* The carousel owns the viewport: the document itself never scrolls here, and
   every slide is positioned by an inline transform that js/carousel.js writes
   each frame from the slide's distance to the current position. No transitions
   — the easing lives in the interpolation, so the motion stays continuous
   rather than stepped. */
.deck {
  position: relative;
  height: 100vh;
  height: 100svh;
  overflow: hidden;
}
body:has(.deck) { overflow: hidden; }

.screen {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  justify-content: center;
  --screen-pad: clamp(6rem, 14vh, 9rem);
  padding-block: var(--screen-pad) calc(var(--screen-pad) + 1rem);
  opacity: 0;
  visibility: hidden;
  will-change: transform, opacity;
  /* No transform here. A transform driven by a CSS custom property through
     calc() cannot be composited: every change to the variable invalidates
     style and the value is recomputed on the main thread each frame. The
     carousel writes element.style.transform with resolved pixel values
     instead, which stays on the compositor's fast path. */
}

.screen .slide__text,
.screen .slide__title { will-change: transform; }

/* ===========================================================================
   PROJECT SLIDE
   =========================================================================== */
/* Measured off the reference: the text column is not vertically centred. It
   sits in the upper-left third, with the visual bleeding off the right edge. */
.slide {
  display: grid;
  /* 1.1fr -> 1.24fr: about 10% more width for the visual, taken from the gap
     and the text column, which had room to give. */
  grid-template-columns: minmax(0, 0.84fr) minmax(0, 1.24fr);
  gap: clamp(var(--sp-6), 5vw, var(--sp-9));
  align-items: start;
  padding-top: clamp(var(--sp-6), 9vh, var(--sp-9));
}
.slide__text {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--sp-5);
  max-width: 34rem;
}
.slide__meta { display: flex; align-items: center; gap: var(--sp-3); flex-wrap: wrap; }

/* The thesis of the page. */
.slide__title {
  font-size: var(--fs-display);
  line-height: var(--lh-tight);
}
.slide__lede {
  font-size: var(--fs-lead);
  line-height: var(--lh-snug);
  color: var(--ink-muted);
  max-width: var(--measure);
}
.slide__tags { margin-top: calc(var(--sp-1) * -1); }

/* --- The WebGL stage --------------------------------------------------------
   Draws every project's visual on a bendable mesh. When it is running the CSS
   frames step aside; when it is not, they carry on as before. */
.gl-stage {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  z-index: 0;
  pointer-events: none;
}
.deck[data-gl] .slide .visual { visibility: hidden; }
.deck[data-gl] .screen { z-index: 1; }
.deck[data-gl] .slide__text { position: relative; z-index: 2; }

/* --- The visual ------------------------------------------------------------
   A fixed perspective tilt, plus a velocity-driven lean. --vel is the frame
   delta from carousel.js, so the image banks into the movement and settles
   flat when the motion stops. Only used when WebGL is unavailable. */
.slide .visual { perspective: 1600px; }
.slide .visual .frame {
  transform:
    translate3d(0, calc(var(--vel, 0) * -2.5rem), 0)
    rotateX(calc(var(--vel, 0) * -6deg))
    rotateY(var(--deck-tilt))
    rotateZ(calc(var(--vel, 0) * 1.2deg));
  transform-origin: 50% 50%;
  will-change: transform;
}

/* Coming-soon projects are present but visibly not yet open. */
.slide--soon .slide__title { color: var(--ink-muted); }
.slide--soon .visual .frame { filter: grayscale(0.85) brightness(0.55); }

/* --- Deck chrome ------------------------------------------------------------
   The reference puts a vertical dot rail on the right edge and a thin progress
   bar down the left. Both are in the ink colour so they travel with the
   ground. */
/* Bottom left, under the text column, and horizontal.

   It used to be a vertical rail down the right edge. That edge is precisely
   where the project visual bleeds off the screen, so the rail sat on top of a
   screenshot: at best low-contrast, and against a light one — the Unipaas
   projects — invisible. A control you cannot find is not a control. The lower
   left is the one region of the composition that is always ground, whatever
   the slide, so the dots keep their contrast on every project. */
.deck-pager {
  position: fixed;
  left: var(--gutter);
  bottom: calc(var(--sp-6) + env(safe-area-inset-bottom, 0px));
  z-index: var(--z-pager);
  display: flex;
  flex-direction: row;
  align-items: center;
  gap: var(--sp-4);
}
.deck-pager .pager { display: none; }         /* the dots replace the arrows */

.deck-dots {
  display: flex;
  flex-direction: row;
  /* 20px, not 12: each dot's own 6px box plus its ::after hit-area expansion
     (see below) reaches 26px across, so anything under 20px center-to-center
     overlaps the neighbouring dot's hit area. 18px (6px box + 9px inset either
     side = exactly 24px) still measured 22-23px accepting in a real probe: a
     target that touches its neighbour with zero margin loses the shared
     boundary pixel to whichever element wins the tie, so "exactly 24" reads as
     just under it. 20px gap plus the 1px-larger inset below both build in a
     margin the measurement survives. */
  gap: 20px;
  align-items: center;
}
.deck-dots button {
  position: relative;
  width: 6px; height: 6px;
  border-radius: 50%;
  background: currentColor;
  color: var(--ink);
  /* 0.7, not the 0.32 this started at. Measured against each project's own
     ground, 0.32 put an inactive dot between 1.6:1 and 2.4:1 — under the 3:1
     WCAG asks of a non-text control, and on the pink Unipaas slides genuinely
     hard to find. 0.7 clears 3:1 on all four. The active dot is the accent at
     full strength, so the two are still easy to tell apart. */
  opacity: 0.7;
  transition: opacity var(--dur-fast) var(--ease),
              transform var(--dur-fast) var(--ease),
              background var(--dur-fast) var(--ease);
}
/* The dot is 6px because that is what it should look like. The thing you have
   to hit with a thumb is 24px, per WCAG target size: -10px, not -9, so the
   26px an isolated hit area measures is a pixel over the line rather than
   exactly on it, and survives a real elementFromPoint probe once the browser's
   own edge rounding takes a pixel off the top. */
.deck-dots button::after { content: ''; position: absolute; inset: -10px; }

.deck-dots button:hover { opacity: 1; }
.deck-dots button[aria-current='true'] {
  background: var(--accent);
  color: var(--accent);
  opacity: 1;
  transform: scale(1.5);
}

.pager__count {
  letter-spacing: 0.2em;
  opacity: 0.5;
}

/* Reading position down the left edge. */
.deck-progress {
  position: fixed;
  left: 0; top: 0; bottom: 0;
  width: 2px;
  z-index: var(--z-pager);
  background: color-mix(in srgb, var(--ink) 14%, transparent);
}
.deck-progress i {
  display: block;
  width: 100%;
  height: var(--p, 0%);
  background: var(--accent);
}

/* ===========================================================================
   ABOUT
   =========================================================================== */
.about {
  min-height: 100vh;
  min-height: 100svh;
  padding-block: clamp(7rem, 16vh, 10rem) var(--sp-9);
}
.about__head {
  display: grid;
  grid-template-columns: minmax(0, 13rem) minmax(0, 1fr);
  gap: clamp(var(--sp-5), 4vw, var(--sp-8));
  align-items: start;
  margin-bottom: var(--sp-9);
}
.about__portrait {
  aspect-ratio: 3 / 4;
  border-radius: var(--r-frame);
  overflow: hidden;
  background: var(--surface);
  filter: grayscale(1) contrast(1.05);
}
.about__portrait img { width: 100%; height: 100%; object-fit: cover; }
.about__title { font-size: var(--fs-hero); margin-bottom: var(--sp-5); }
.about__intro { display: flex; flex-direction: column; gap: var(--sp-5); }

.about__section { margin-bottom: var(--sp-9); }
.about__section-label { margin-bottom: var(--sp-5); }

/* Career list — companies live here, as credentials, not as navigation. */
.career { display: flex; flex-direction: column; }
.career__row {
  display: grid;
  grid-template-columns: minmax(0, 12rem) minmax(0, 1fr) auto;
  gap: var(--sp-5);
  align-items: baseline;
  padding-block: var(--sp-5);
  border-top: 1px solid var(--hairline-soft);
}
.career__row:last-child { border-bottom: 1px solid var(--hairline-soft); }
/* The company mark, standing where the name was. Height-constrained rather
   than width-constrained: these three logos have very different aspect ratios,
   and matching their optical weight means matching their cap height, not their
   box. */
/* --- Honors -----------------------------------------------------------------
   The same two-column rhythm as a career row, so About reads as one continuous
   list of credentials rather than three unrelated blocks. Title and dates left,
   the account right.
   =========================================================================== */
.honors {
  margin: 0;
  padding: 0;
  list-style: none;
  counter-reset: honor;
}
.honor {
  display: grid;
  grid-template-columns: minmax(0, 16rem) minmax(0, 1fr);
  gap: var(--sp-5) var(--sp-6);
  align-items: baseline;
  padding-block: var(--sp-6);
  border-top: 1px solid var(--hairline-soft);
}
.honor:last-child { border-bottom: 1px solid var(--hairline-soft); }

.honor__title {
  font-family: var(--font-display);
  font-size: var(--fs-section);
  line-height: var(--lh-tight);
  color: var(--ink-strong);
}
.honor__when {
  margin-top: var(--sp-2);
  font-size: var(--fs-micro);
  letter-spacing: 0.04em;
  color: var(--ink-faint);
}
.honor__body { max-width: var(--measure); }
.honor__body p { color: var(--ink-muted); }
.honor__body p + p { margin-top: var(--sp-3); }

/* A list of what a thing does, not a list of sentences: hanging accent marks
   rather than bullets, so it reads as a specification. */
.honor__list {
  margin: var(--sp-4) 0 0;
  padding: 0;
  list-style: none;
  display: grid;
  gap: var(--sp-2);
}
.honor__list li {
  position: relative;
  padding-left: var(--sp-5);
  font-size: var(--fs-small);
  line-height: var(--lh-snug);
  color: var(--ink-muted);
}
.honor__list li::before {
  content: '';
  position: absolute;
  left: 0;
  top: 0.62em;
  width: 10px;
  height: 1px;
  background: var(--accent);
}

/* The number is the point of the entry. Out of the paragraph, into the display
   face, so someone skimming lands on it. */
.honor__stat {
  display: flex;
  align-items: baseline;
  gap: var(--sp-4);
  margin-top: var(--sp-5);
  padding-top: var(--sp-4);
  border-top: 1px solid var(--hairline-soft);
}
.honor__stat-value {
  font-family: var(--font-display);
  font-size: clamp(2rem, 1.5rem + 1.8vw, 2.75rem);
  line-height: 1;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  flex: none;
}
.honor__stat-label {
  font-size: var(--fs-small);
  line-height: var(--lh-snug);
  color: var(--ink-muted);
  max-width: 22rem;
}

@media (max-width: 767px) {
  .honor { grid-template-columns: minmax(0, 1fr); gap: var(--sp-3); }
  .honor__stat { flex-direction: column; gap: var(--sp-2); }
}

/* --- Tool strip -------------------------------------------------------------
   Was a row of text chips. Every icon in the set ships solid black, so they
   are all flattened to ink: one weight, no competing brand colours, which is
   what a list of tools should look like next to the work it supports. */
.tool-strip {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-5);
  margin: 0;
  padding: 0;
  list-style: none;
}
.tool {
  display: flex;
  align-items: center;
  line-height: 0;
}
.tool__icon {
  height: var(--tool-icon-size);
  width: auto;
  max-width: calc(var(--tool-icon-size) * 1.3);
  object-fit: contain;
  filter: brightness(0) invert(1);
  opacity: 0.62;
  transition: opacity var(--dur-fast) var(--ease),
              transform var(--dur-fast) var(--ease);
}
.tool:hover .tool__icon { opacity: 1; transform: translateY(-2px); }

.career__logo {
  height: var(--logo-size);
  width: auto;
  max-width: 260px;
  object-fit: contain;
  object-position: left center;
}
/* For a mark whose artwork is too dark for this ground. brightness(0) flattens
   it to black and invert(1) lifts it to white, which works on any solid mark
   regardless of how many colours it started with. A stopgap until a light
   export exists — a wordmark rendered in one colour is a compromise, just a
   legible one. */
.career__logo--mono { filter: brightness(0) invert(1); opacity: 0.86; }

.career__co { font-family: var(--font-display); font-size: var(--fs-section); color: var(--ink-strong); }
.career__when {
  font-size: var(--fs-micro);
  letter-spacing: 0.04em;
  color: var(--ink-faint);
  margin-top: 4px;
}
.career__role { font-size: var(--fs-small); color: var(--ink-muted); }
.career__text { font-size: var(--fs-small); color: var(--ink-muted); line-height: var(--lh-snug); }

.funfact__media {
  border-radius: var(--r-frame);
  overflow: hidden;
  background: var(--surface-2);
}
.funfact__media img, .funfact__media video { width: 100%; height: auto; }
.funfact__caption {
  margin-top: auto;
  font-size: var(--fs-small);
  color: var(--ink-muted);
  line-height: var(--lh-snug);
}

/* ===========================================================================
   CASE STUDY — a normal scrolling document. The scroll is never intercepted.
   =========================================================================== */
.case { padding-bottom: var(--sp-10); }

.case__bar {
  position: sticky;
  top: 0;
  z-index: var(--z-nav);
  background: rgba(16, 14, 12, 0.82);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--hairline-soft);
}
.case__bar-inner {
  display: flex;
  align-items: center;
  gap: var(--sp-4);
  min-height: 60px;
  padding-block: var(--sp-2);
}
/* The way out, floating in the bottom-left corner rather than sitting in the
   bar. It is the one control that must stay reachable at any scroll depth, and
   the bar is a poor host for it: the title needs that width, and on a phone
   the top-left corner is the hardest place on the screen to reach. */
.case__back {
  position: fixed;
  left: var(--gutter);
  bottom: var(--sp-4);
  z-index: var(--z-nav);
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  min-height: 44px;
  padding: 0 var(--sp-4);
  border: 1px solid var(--hairline);
  border-radius: 999px;
  background: rgba(16, 14, 12, 0.82);
  backdrop-filter: blur(12px);
  color: var(--ink-muted);
  white-space: nowrap;
  transition: color var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease),
              transform var(--dur-fast) var(--ease);
}
.case__back:hover {
  color: var(--ink-strong);
  border-color: var(--accent);
  transform: translateX(-2px);
}
.case__back svg {
  flex: none;
  transition: transform var(--dur-fast) var(--ease);
}
.case__back:hover svg { transform: translateX(-2px); }
@media (prefers-reduced-motion: reduce) {
  .case__back:hover, .case__back:hover svg { transform: none; }
}
.case__bar-title { font-family: var(--font-display); font-size: 1.125rem; color: var(--ink-strong); }
.case__progress {
  height: 1px;
  background: var(--accent);
  width: var(--progress, 0%);
  transition: width 80ms linear;
}

.case__hero {
  display: grid;
  grid-template-columns: minmax(0, 1fr) minmax(0, 1.05fr);
  gap: var(--sp-8);
  align-items: center;
  padding-block: clamp(var(--sp-7), 8vh, var(--sp-9));
  min-height: min(76vh, 44rem);
}
.case__hero-title { font-size: var(--fs-hero); }
.case__hero-sub {
  font-family: var(--font-body);
  font-weight: 400;
  font-size: var(--fs-lead);
  color: var(--ink-muted);
  margin-block: var(--sp-4) var(--sp-5);
  line-height: var(--lh-snug);
}

/* --- Three-line summary -------------------------------------------------
   Sits between the hero and the long read. Someone with 30 seconds should be
   able to stop here and still know what happened. */
.case__summary {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(16rem, 100%), 1fr));
  gap: var(--sp-6);
  margin: 0;
  padding-block: var(--sp-7);
  border-top: 1px solid var(--hairline-soft);
  border-bottom: 1px solid var(--hairline-soft);
}
.case__summary dt { margin-bottom: var(--sp-3); color: var(--accent); }
.case__summary dd {
  margin: 0;
  font-size: var(--fs-small);
  line-height: var(--lh-snug);
  color: var(--ink);
  max-width: 26rem;
}

.case__body { display: block; }

/* Narrow screens: a horizontal strip under the title bar. It scrolls sideways
   rather than wrapping, so a long case study does not push the article down the
   page to make room for its own navigation. Desktop turns this into a fixed
   column beside the article, further down. */
.case__rail {
  /* Sticky rather than static: it sits directly under the bar and has to stay
     there, or the only way back to a section is to scroll to the top. */
  position: sticky;
  top: 61px;                      /* bar: 60px of content plus its 1px progress */
  z-index: calc(var(--z-nav) - 1);
  display: flex;
  flex-wrap: nowrap;              /* a strip, not a block of links. 14 sections
                                     wrapped is taller than the first section. */
  gap: var(--sp-1);
  overflow-x: auto;
  scrollbar-width: none;
  padding: var(--sp-2) var(--gutter);
  background: rgba(16, 14, 12, 0.82);
  backdrop-filter: blur(12px);
  border-bottom: 1px solid var(--hairline-soft);
  -webkit-overflow-scrolling: touch;
}
.case__rail::-webkit-scrollbar { display: none; }
.case__rail a {
  display: block;
  white-space: nowrap;
  padding: var(--sp-2) var(--sp-3);
  border-bottom: 2px solid transparent;
  font-size: var(--fs-small);
  color: var(--ink-faint);
  line-height: var(--lh-snug);
  transition: color var(--dur-fast) var(--ease), border-color var(--dur-fast) var(--ease);
}
.case__rail a:hover { color: var(--ink); }
.case__rail a[aria-current='true'] { color: var(--ink-strong); border-bottom-color: var(--accent); }

/* One column, with the visual under the copy rather than beside it.

   These are product screenshots — tables, side panels, 11px labels. Beside a
   text column the visual got 425px of an 1800px export, which is not a size
   anything in them can be read at, and a screenshot nobody can read is
   decoration. At the full measure it is roughly twice that and the interface
   is legible, which is the entire reason it is on the page.

   The cost is the alternating left/right rhythm the two-column layout gave the
   article. Worth it while the images are this dense; put
   `grid-template-columns: minmax(0,1fr) minmax(0,1.05fr)` back to undo it. */
.case-section {
  display: grid;
  grid-template-columns: minmax(0, 1fr);
  gap: var(--sp-6);
  align-items: center;
  padding-block: clamp(var(--sp-7), 9vh, var(--sp-9));
  scroll-margin-top: 6rem;
}
.case-section__text {
  display: flex;
  flex-direction: column;
  gap: var(--sp-4);
  align-items: flex-start;
  max-width: 46rem;   /* copy keeps a readable measure even when the row is wide */
}
.case-section__title { font-size: var(--fs-title); }



/* --- The rail as a side column ----------------------------------------------
   Fixed beside the article rather than above it. Reading position is easier to
   hold in a vertical list than in a strip that scrolls sideways, and the strip
   competed with the title bar directly above it.

   Only from 992px, which is where the layout stops stacking, and only because
   the height cap makes it free: a 3:2 visual can be at most about 1350px wide,
   and the content column here is wider than that on any screen this rail
   appears on.
   =========================================================================== */


/* --- Full-bleed visuals ------------------------------------------------------
   The visual escapes the shell and spans the viewport. A product screenshot is
   the evidence in a case study, and evidence at 877px inside a text column is
   evidence nobody can read.

   calc(50% - 50vw) is the standard break-out: the element sits in a centred
   container, so pulling it left by half the difference between the container
   and the viewport lands it on the left edge. */
.case-section > [data-reveal]:has(.visual),
.case-section .proto,
.case-section .viewer,
.case-section .scroll-frame {
  /* --sbw is the scrollbar width, measured in app.js. Without subtracting it,
     100vw is wider than the visible page and the whole document gains a
     horizontal scroll. */
  --bleed: calc(100vw - var(--sbw, 0px) - var(--rail-space, 0px));
  width: var(--bleed);
  max-width: var(--bleed);
  margin-left: calc(50% - var(--bleed) / 2);
  margin-right: calc(50% - var(--bleed) / 2);
}
/* The picture goes edge to edge; the words that describe it stay in the
   measure, lined up with the rest of the article. */
.case-section .proto__caption,
.case-section .viewer__note,
.case-section .viewer__tabs,
.case-section .scroll-frame__caption {
  max-width: var(--shell-max);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

/* Flat, now that the visual spans the viewport. A perspective rotation on a
   full-width element projects a bounding box wider than the screen — measured
   at 36px past the edge, which gave the whole document a horizontal scroll —
   and a tilted edge-to-edge screenshot reads as a mistake rather than a
   flourish. The parallax offset stays; it is vertical and costs nothing. */
.case-section .visual--tilt .frame,
.case-section .visual .frame {
  transform: translate3d(0, var(--py, 0px), 0);
}
.case-section .visual { perspective: none; }

/* Every visual obeys the same ceiling. The frame hugs the image rather than
   staying full width, and sits on the same left edge as the text above it —
   not centred, which is what put a narrower-than-column image out of line
   with the words describing it. */
.case-section .frame,
.case__hero .frame {
  width: fit-content;
  max-width: 100%;
  margin-inline: 0;
}
.case-section .frame img,
.case-section .frame video,
.case__hero .frame img,
.case__hero .frame video {
  max-height: var(--visual-max-h);
  width: auto;
  max-width: 100%;
}
.case-section .ph,
.case__hero .ph { max-height: var(--visual-max-h); }

/* --- Wipe -------------------------------------------------------------------
   Case-study visuals are uncovered left to right as they arrive, instead of
   only fading in. The frame keeps its own transform — clip-path works on the
   element's own box, so the wipe and the perspective tilt do not fight.

   Declared on the resting state so the transition exists in both directions;
   an image that is never revealed is simply never clipped. */
.case-section .frame,
.case__hero .frame {
  clip-path: inset(0 0 0 0);
  transition: clip-path var(--visual-wipe) var(--ease-out);
  transition-delay: calc(var(--i, 0) * var(--stagger));
}
.case-section [data-reveal]:not([data-revealed]) .frame,
.case__hero [data-reveal]:not([data-revealed]) .frame {
  clip-path: inset(0 100% 0 0);
}

@media (min-width: 992px) {
  /* Two real columns, both a defined percentage of the viewport rather than
     one fixed-width rail with the article taking "whatever is left". A
     content box with no actual max-width cannot be shared consistently by
     text and images — each element was falling back to its own idea of how
     wide it was allowed to be, which is how a 1180px image ended up sitting
     in a column the text measured at 1129px, centred rather than sitting on
     the same left edge as the words above it.

     20% rail, 70% content, and the 10% left over splits into two 5%
     breathing margins — one between the rail and the content, one on the
     outer edge — so the content column is centred in the space the rail
     leaves behind rather than jammed against it. */
  .case { --rail-w: 20vw; --content-w: 70vw; }

  .case__rail {
    position: fixed;
    top: 9.5rem;
    left: 0;
    width: var(--rail-w);
    padding-left: max(var(--gutter), 2rem);
    padding-right: var(--sp-4);
    max-height: calc(100vh - 16rem);   /* clears the back pill at the bottom */
    overflow-y: auto;
    flex-direction: column;
    gap: 1px;
    background: none;
    backdrop-filter: none;
    border: 0;
    z-index: var(--z-nav);
  }
  .case__rail a {
    white-space: normal;
    padding: var(--sp-2) 0 var(--sp-2) var(--sp-4);
    border-bottom: 0;
    border-left: 1px solid var(--hairline);
  }
  .case__rail a[aria-current='true'] {
    border-bottom-color: transparent;
    border-left-color: var(--accent);
  }

  /* The content column itself: a real width, centred in the 80vw the rail
     leaves behind, not "the rest of the viewport". Every descendant — text,
     headings, images, the prototype stage — shares this one box and its own
     inner gutter, so nothing has a wider or narrower idea of the column than
     anything else. */
  .case > .shell {
    max-width: var(--content-w);
    width: var(--content-w);
    margin-left: calc(var(--rail-w) + (100vw - var(--rail-w) - var(--content-w)) / 2 - var(--sbw, 0px) / 2);
    margin-right: 0;
  }

  /* No viewport break-out beside a rail: a visual fills the content column
     exactly like the text above it does, rather than escaping to the
     viewport edge. See the frame/image rules below for the alignment itself. */
  .case-section > [data-reveal]:has(.visual),
  .case-section .proto,
  .case-section .viewer,
  .case-section .scroll-frame {
    width: 100%;
    max-width: 100%;
    margin-left: 0;
    margin-right: 0;
  }
  /* The captions were pulled back to the measure to line up with the article.
     Inside the column they already do. */
  .case-section .proto__caption,
  .case-section .viewer__note,
  .case-section .viewer__tabs,
  .case-section .scroll-frame__caption {
    max-width: none;
    margin-inline: 0;
    padding-inline: 0;
  }
}
/* --- Prototype --------------------------------------------------------------
   A live prototype embedded in the case study. Until it is asked for, the
   stage holds a poster and a button; the click swaps in the iframe. That keeps
   a multi-megabyte Figma application off the critical path of a page that
   otherwise loads instantly.
   =========================================================================== */
.proto {
  margin: var(--sp-6) 0 0;
  width: 100%;
  align-self: stretch;   /* the text column is a flex column, aligned to start */
}
.proto__stage {
  position: relative;
  aspect-ratio: var(--proto-ratio, 16 / 10);
  /* Height capped, width derived from the ratio — but never past the column
     itself. The height-derived number alone does not know how wide its
     container actually is, and on a narrower column that let the stage run
     past the article's own edge instead of stopping at it. Left-aligned
     rather than centred, to sit on the same edge as the text beside it. */
  max-height: var(--visual-max-h);
  max-width: min(100%, calc(var(--visual-max-h) * var(--proto-ratio, 16 / 10)));
  margin-inline: 0;
  border-radius: var(--r-card);
  overflow: hidden;
  background: var(--surface);
  border: 1px solid var(--hairline);
  box-shadow: var(--shadow-lift);
}
.proto__poster,
.proto__frame {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
}
.proto__poster { object-fit: cover; opacity: 0.42; filter: saturate(0.5); }
.proto__frame { border: 0; display: block; }

.proto__load {
  position: absolute;
  inset: 0;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  gap: var(--sp-3);
  /* Deepens toward the middle so the cue stays legible over any poster. */
  background: radial-gradient(60% 60% at 50% 50%,
              rgba(0, 0, 0, 0.55), rgba(0, 0, 0, 0.2));
  transition: background var(--dur-fast) var(--ease);
}
.proto__load:hover { background: radial-gradient(70% 70% at 50% 50%,
                     rgba(0, 0, 0, 0.42), rgba(0, 0, 0, 0.12)); }

.proto__cue {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-3);
  padding: var(--sp-3) var(--sp-5);
  border-radius: var(--r-btn);
  background: var(--accent);
  color: #100E0C;
  font-size: var(--fs-label);
  font-weight: 600;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  line-height: 1;
  transition: transform var(--dur-fast) var(--ease);
}
.proto__load:hover .proto__cue { transform: scale(1.03); }
.proto__cue-icon { width: 12px; height: 12px; }
.proto__hint {
  font-size: var(--fs-micro);
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: rgba(255, 248, 240, 0.72);
}

.proto__caption {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-4);
  flex-wrap: wrap;
  margin-top: var(--sp-3);
  font-size: var(--fs-small);
  color: var(--ink-muted);
}
.proto__caption a {
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  color: var(--ink);
  border-bottom: 1px solid var(--hairline);
  padding-bottom: 2px;
  transition: border-color var(--dur-fast) var(--ease);
}
.proto__caption a:hover { border-bottom-color: var(--accent); }

/* --- Screen viewer -----------------------------------------------------------
   Step through the states of a feature without leaving the page. Panels are
   stacked in one box so switching does not reflow the article underneath —
   the stage keeps its height and only the image inside it changes.
   =========================================================================== */
.viewer {
  margin: var(--sp-6) 0 0;
  width: 100%;
  align-self: stretch;
}

.viewer__tabs {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  margin-bottom: var(--sp-4);
}
.viewer__tab {
  padding: var(--sp-2) var(--sp-4);
  border-radius: var(--r-chip);
  border: 1px solid var(--hairline);
  font-size: var(--fs-micro);
  font-weight: 500;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-muted);
  transition: color var(--dur-fast) var(--ease),
              border-color var(--dur-fast) var(--ease),
              background var(--dur-fast) var(--ease);
}
.viewer__tab:hover { color: var(--ink); border-color: var(--ink-faint); }
.viewer__tab[aria-selected='true'] {
  background: var(--accent);
  border-color: var(--accent);
  color: #100E0C;
}

.viewer__stage {
  position: relative;
  aspect-ratio: var(--viewer-ratio, 16 / 10);
  /* Same reasoning as .proto__stage just above: never past the column, and
     left-aligned to it rather than centred. */
  max-height: var(--visual-max-h);
  max-width: min(100%, calc(var(--visual-max-h) * var(--viewer-ratio, 16 / 10)));
  margin-inline: 0;
  border-radius: var(--r-card);
  overflow: hidden;
  background: var(--surface);
  border: 1px solid var(--hairline);
}
.viewer__panel {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease);
}
.viewer__panel[data-current] { opacity: 1; }
/* `hidden` is what keeps the other screens out of the accessibility tree; the
   stacking above is only how they crossfade. Without this the attribute's own
   display:none would win and there would be nothing to fade. */
.viewer__panel[hidden] { display: block; }
.viewer__panel img,
.viewer__panel video {
  width: 100%;
  height: 100%;
  /* The screens are not all the same shape — a modal is nearly square where a
     full window is 3:2. Contain, so none of them is ever cropped. */
  object-fit: contain;
}

/* --- Compare mode -----------------------------------------------------------
   The old screen underneath, the selected one clipped over it, and a divider
   between. --split drives the clip and the divider from one value, so they
   cannot drift apart.
   =========================================================================== */
/* Both halves live inside the panel, so a tab change swaps the pair together
   and the divider stays where it was put. */
.compare__before,
.compare__after {
  position: absolute;
  inset: 0;
  display: block;
  width: 100%;
  height: 100%;
}
.compare__before { object-fit: contain; }
.compare__after img,
.compare__after video { width: 100%; height: 100%; object-fit: contain; }

/* The new screen is revealed to the right of the divider. */
.viewer__stage--compare .compare__after {
  clip-path: inset(0 0 0 var(--split, 50%));
}

.compare__divider {
  position: absolute;
  top: 0;
  bottom: 0;
  left: var(--split, 50%);
  width: 2px;
  margin-left: -1px;
  background: var(--accent);
  z-index: 3;
  pointer-events: none;
}
/* The grip. Drawn on the divider rather than on the input, because the input
   is stretched across the whole stage to catch the drag. */
.compare__divider::after {
  content: '';
  position: absolute;
  top: 50%;
  left: 50%;
  width: 34px;
  height: 34px;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  background: var(--accent);
  box-shadow: 0 2px 12px rgba(0, 0, 0, 0.45);
}

.compare__tag {
  position: absolute;
  bottom: var(--sp-3);
  z-index: 3;
  padding: 4px 10px;
  border-radius: var(--r-chip);
  background: rgba(0, 0, 0, 0.62);
  color: rgba(255, 248, 240, 0.92);
  font-size: var(--fs-label);
  font-weight: 500;
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  line-height: 1.6;
  pointer-events: none;
}
.compare__tag--before { left: var(--sp-3); }
.compare__tag--after  { right: var(--sp-3); }

/* Invisible, and covering the stage: it is the drag surface and the keyboard
   control at once. Everything you can see is drawn by the rules above. */
.compare__range {
  position: absolute;
  inset: 0;
  z-index: 4;
  width: 100%;
  height: 100%;
  margin: 0;
  appearance: none;
  -webkit-appearance: none;
  background: transparent;
  cursor: ew-resize;
}
.compare__range::-webkit-slider-thumb {
  -webkit-appearance: none;
  width: 44px;          /* a real grab target, per WCAG target size */
  height: 100%;
  cursor: ew-resize;
  background: transparent;
}
.compare__range::-moz-range-thumb {
  width: 44px;
  height: 100%;
  border: 0;
  background: transparent;
  cursor: ew-resize;
}
.compare__range:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.viewer__note {
  margin-top: var(--sp-3);
  font-size: var(--fs-small);
  line-height: var(--lh-snug);
  color: var(--ink-muted);
  min-height: 2.6em;   /* holds its space so the page does not jump on switch */
}

@media (prefers-reduced-motion: reduce) {
  .viewer__panel { transition: none; }
}

/* --- Scroll frame -------------------------------------------------------------
   A stitched screenshot of a real page far taller than any viewport, shown
   the way it is actually seen: scrolled, not squashed into a box and not
   cropped down to one screenful. --scroll-frame-ratio drives the fixed-ratio
   box the same way --proto-ratio and --viewer-ratio do above; the image
   renders at its own natural width and the box itself scrolls to reveal the
   rest, instead of any object-fit trick that would hide part of it.

   Chrome (radius, border, shadow, background) matches .frame in
   components.css directly, so a scrollable screenshot reads as the same kind
   of object as a static one, not a new visual idea.
   =========================================================================== */
.scroll-frame {
  margin: var(--sp-6) 0 0;
  width: 100%;
  align-self: stretch;   /* the text column is a flex column, aligned to start */
}
.scroll-frame__stage {
  position: relative;
  aspect-ratio: var(--scroll-frame-ratio, 16 / 9);
  /* Same reasoning as .proto__stage and .viewer__stage above: capped by
     height, never past the column, left-aligned rather than centred. */
  max-height: var(--visual-max-h);
  max-width: min(100%, calc(var(--visual-max-h) * var(--scroll-frame-ratio, 16 / 9)));
  margin-inline: 0;
  border-radius: var(--r-frame);
  border: 1px solid var(--hairline-soft);
  box-shadow: var(--shadow-frame);
  background: var(--surface);
  overflow-x: hidden;
  overflow-y: auto;
  -webkit-overflow-scrolling: touch;
}
.scroll-frame__stage img {
  display: block;
  width: 100%;
  height: auto;   /* natural width, natural (tall) height, nothing cropped */
}
/* The fade is a sticky element placed right after the image and pulled back
   over it by a negative top margin equal to its own height. That keeps it
   pinned to the visible bottom edge of the scrolling box without adding any
   scroll height of its own and without a second, separately positioned
   element to keep in sync with the real one: it reads as "more below" only
   because there still is more below, and once the box is scrolled to the
   true end it slides off with the rest of the content, same as it would on a
   real page. */
.scroll-frame__fade {
  position: sticky;
  bottom: 0;
  left: 0;
  right: 0;
  display: flex;
  align-items: flex-end;
  justify-content: center;
  height: 3.5rem;
  margin-top: -3.5rem;
  padding-bottom: var(--sp-2);
  background: linear-gradient(to bottom, transparent, rgba(15, 13, 11, 0.88));
  color: var(--ink-faint);
  font-size: var(--fs-micro);
  letter-spacing: var(--tracking-label);
  text-transform: uppercase;
  pointer-events: none;
}
.scroll-frame__caption {
  margin-top: var(--sp-3);
  font-size: var(--fs-small);
  color: var(--ink-muted);
}

/* --- Metrics ----------------------------------------------------------------
   The numbers count up from zero when the row scrolls into view — reveal.js
   drives that. Tabular figures so the width does not jitter while counting. */
.metrics {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: var(--sp-5) var(--sp-6);
  margin: 0;
  padding-top: var(--sp-3);
  /* The text column is a flex column with align-items: flex-start, so every
     child shrinks to its content. Without this the row stacks into one narrow
     column and stops being a row. */
  align-self: stretch;
}
.metric__value {
  display: flex;
  align-items: baseline;
  gap: 0.2em;
  font-family: var(--font-display);
  font-size: clamp(2.25rem, 1.6rem + 2.4vw, 3.5rem);
  line-height: 1;
  color: var(--accent);
  font-variant-numeric: tabular-nums;
  font-feature-settings: 'tnum' 1;
}
.metric__affix {
  font-family: var(--font-body);
  font-size: var(--fs-small);
  font-weight: 500;
  color: var(--ink-muted);
}
.metric__label {
  margin: var(--sp-2) 0 0;
  font-size: var(--fs-small);
  line-height: var(--lh-snug);
  color: var(--ink-muted);
  max-width: 15rem;
}

/* ===========================================================================
   ENTRANCES
   ---------------------------------------------------------------------------
   Two mechanisms, because the two views answer different questions.

   [data-enter] — deck slides. "Has this slide just become the one I am on?"
   Keyed off data-active, so it replays every time you arrive at a project
   rather than once per page load.

   [data-reveal] — case study and about. "Has this scrolled into view?" Set
   once by reveal.js and never taken back.
   =========================================================================== */

@keyframes slide-enter {
  from { opacity: 0; transform: translateY(20px); }
  to   { opacity: 1; transform: none; }
}

/* --- Per-word heading rise -------------------------------------------------
   Each word sits in its own overflow-hidden mask and slides up out of it. The
   mask is what makes it read as type being set rather than text fading in.

   The padding/margin pair is not decoration: a mask clipped exactly to the
   line box shears the descenders off g, y and p, and Instrument Serif at
   display size has long ones. */
.w {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
  padding-bottom: 0.14em;
  margin-bottom: -0.14em;
}
.w > span { display: inline-block; }

@keyframes word-rise {
  from { transform: translateY(110%); }
  to   { transform: none; }
}

/* Same rule as the entrance below: the resting state is visible, and the
   hidden state lives only inside the keyframes, held by `backwards`. A word
   that never animates has to still be a word. */
.screen[data-active] .slide__title .w > span,
[data-words][data-revealed] .w > span {
  animation: word-rise 900ms var(--ease-out) backwards;
  animation-delay: calc(var(--wi, 0) * var(--word-stagger) + 60ms);
}

/* Two deliberate choices here.

   An animation, not a transition, because carousel.js writes an inline
   transform to .slide__title every frame for the layered drift, and an inline
   style beats a stylesheet rule. Animations sit above inline style in the
   cascade, so this is the one way the entrance and the drift can both exist.
   The animation wins for its ~700ms, then hands the title back.

   And no `[data-enter] { opacity: 0 }` base rule. The hidden state lives only
   in the keyframes, held during the delay by `backwards`. Written the other
   way round, an animation that never starts — a browser that paused it because
   the tab loaded in the background — would leave every slide's text invisible
   with nothing to turn it back on. Resting state is visible; the entrance is
   an enhancement on top. */
.screen[data-active] [data-enter] {
  animation: slide-enter var(--dur-reveal) var(--ease-out) backwards;
  animation-delay: calc(var(--i, 0) * var(--stagger));
}

[data-reveal] {
  opacity: 0;
  transform: translateY(24px);
  transition:
    opacity var(--dur-reveal) var(--ease-out),
    transform var(--dur-reveal) var(--ease-out);
  transition-delay: calc(var(--i, 0) * var(--stagger));
}
[data-reveal][data-revealed] { opacity: 1; transform: none; }

.case__foot {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-5);
  flex-wrap: wrap;
  padding-top: var(--sp-8);
  border-top: 1px solid var(--hairline-soft);
}

/* ===========================================================================
   Responsive
   =========================================================================== */
/* 991px is where .slide stops being two columns, so it is also where the whole
   desktop treatment of the deck has to stop. These used to be split across two
   breakpoints, which left 768–991px in a state that was neither: a stacked
   layout still carrying the side rail and the perspective tilt. carousel.js
   drops the WebGL layer at this same width, for the same reason. */
@media (max-width: 991px) {
  .slide { grid-template-columns: minmax(0, 1fr); gap: var(--sp-6); }
  .case__body { grid-template-columns: minmax(0, 1fr); }

  /* Flat. Rotated in a single column the visual reaches past the column edge
     and the deck, which clips, simply cuts it off. */
  .slide .visual .frame { transform: none; }

  /* Stacked, the visual has to leave room for the text above it. At full width
     a 3:2 cover is over half the viewport tall, which pushed the slide past
     the screen — and the deck clips, so the ends were simply eaten.

     Capped by height on the image, with the frame hugging what is left
     (`fit-content`) rather than staying full width. Capping the image inside a
     full-width frame would have centred it and left the frame's own surface
     showing down both sides — the dark border all over again. */
  .slide .visual .frame {
    width: fit-content;
    max-width: 100%;
    margin-inline: 0;   /* left-aligned, flush with the text above it */
  }
  .slide .visual img,
  .slide .visual video {
    max-height: 38vh;
    width: auto;
    max-width: 100%;
  }

  /* Same pager, centred — there is no text column to sit under once the
     layout stacks. */
  .deck-pager {
    left: 0; right: 0;
    bottom: calc(var(--sp-5) + env(safe-area-inset-bottom, 0px));
    justify-content: center;
  }
  .deck-pager .pager__btn { display: none; }
  .career__row { grid-template-columns: minmax(0, 1fr); gap: var(--sp-2); }
}

@media (max-width: 767px) {
  .about__head { grid-template-columns: minmax(0, 1fr); }
  .about__portrait { max-width: 11rem; }

  .case__hero { grid-template-columns: minmax(0, 1fr); gap: var(--sp-6); }
  .case__hero { min-height: 0; }

  /* Perspective tilt reads as a rendering bug at phone width — keep only the
     parallax offset. */
  .visual--tilt .frame { transform: translate3d(0, var(--py, 0px), 0); }

  .chrome { padding: var(--sp-5) var(--gutter); }
}

@media (max-width: 478px) {
  .overlay__panel { padding: var(--sp-5) var(--sp-4); }
}

/* ===========================================================================
   Reduced motion — [data-enter] and [data-reveal] both start at opacity 0, so
   they must be visible by default when motion is suppressed, or the page reads
   as blank.
   =========================================================================== */
@media (prefers-reduced-motion: reduce) {
  /* [data-reveal] starts at opacity 0 and must be forced visible or the page
     reads as blank. [data-enter] only needs its animation cancelled — its
     resting state is already visible. */
  [data-reveal] {
    opacity: 1 !important;
    transform: none !important;
    animation: none !important;
    transition: none !important;
  }
  .screen[data-active] [data-enter],
  .screen[data-active] .slide__title .w > span,
  [data-words][data-revealed] .w > span { animation: none !important; }

  /* The wipe and the magnetic pull are both motion nobody asked for. */
  .case-section .frame,
  .case__hero .frame,
  .case-section [data-reveal]:not([data-revealed]) .frame,
  .case__hero [data-reveal]:not([data-revealed]) .frame {
    clip-path: none !important;
    transition: none !important;
  }
  [data-magnetic] { transform: none !important; }
  /* Slide positioning is left to carousel.js, which writes a resolved inline
     transform and snaps `current` to whole numbers under reduced motion — so
     the active slide is already at offset zero. There used to be a rule here
     overriding that from a --d custom property; nothing has written --d since
     the transforms moved into JS, so it resolved to translateY(0) for every
     slide at once and only ever fought the real value. Removed.
     What does belong here is suppressing tilt, lean and layered drift. */
  .screen .slide__text,
  .screen .slide__title { transform: none !important; }
  .slide .visual .frame { transform: none !important; }
  .case__progress { transition: none; }
  html { scroll-behavior: auto; }
}
