/* ==========================================================================
   Thierry Rolin — Portfolio Home
   Fluid, proportional layout — reference design at 1440px, fluid from 1280px.

   Technique: every scaling value uses
     max( value-at-1280px , value-at-1440px-expressed-in-vw )
   so it scales strictly proportionally to the viewport width above 1280px
   (no upper cap — it keeps scaling on very wide screens, by design), and
   holds its 1280px size as a floor below that. Inspired by fluid clamp()
   type scales such as https://fluid-responsive-tambien.webflow.io/, adapted
   here to a single reference breakpoint instead of a min/max pair, and
   applied to spacing and sizing as well as type.
   ========================================================================== */

@font-face {
  font-family: "PP Neue Montreal";
  src: url("/fonts/PPNeueMontreal-Medium.woff2") format("woff2"),
    url("/fonts/PPNeueMontreal-Medium.woff") format("woff");
  font-weight: 500;
  font-style: normal;
  font-display: swap;
}
/* Thierry's own licensed PP Neue Montreal Medium files (Pangram Pangram),
   supplied Round 9 — woff2 first (smaller, wider modern support), woff as
   the fallback for older browsers. */

/* ==========================================================================
   Site-wide page transition — see page-transition.js.

   One simple effect used for every internal navigation on the site (was,
   across a couple of earlier rounds, a native View Transitions API
   attempt and then a hand-built shared-element image morph — both
   dropped per Thierry's explicit request in favour of this much simpler
   version): the current page's content fades out, a brief blank pause,
   then the destination page's content fades in. Body starts at
   opacity:0 so there's never a flash of unbuilt content before
   page-transition.js's ready() call (from each page's own script, once
   its real content exists) reveals it.

   prefers-reduced-motion visitors get the .pt-no-motion escape hatch
   (see page-transition.js) — no fade transition at all, just an
   immediately-visible page, same as every other continuous motion effect
   on this site opts out for them. */
body {
  opacity: 0;
  /* Matches page-transition.js's FADE_OUT_MS (both directions share this
     one rule/duration) — 350ms, slightly quicker than the original
     500ms, per Thierry's explicit request. */
  transition: opacity 350ms ease;
}

body.page-ready {
  opacity: 1;
}

body.page-fade-out {
  opacity: 0;
}

html.pt-no-motion body {
  opacity: 1;
  transition: none;
}

:root {
  /* ---- design tokens computed from the Figma frame (1440px reference) ---- */
  /* Flat 15px per Thierry's explicit Round 9 request — no longer fluid.
     Applies to body/nav/topbar/list text and everything that inherits it;
     button labels (--view-btn-font, --pj-detail-font where reused by a
     button) and the Project-page title (--pj-cover-title-font,
     --mobile-title-font) are explicitly exempted and stay on their own
     fluid tokens. */
  --font-base: 15px;                           /* 15px, flat — body/nav/topbar text */
  --topbar-size: max(34.6667px, 2.7083vw);     /* 39px  @1440 — topbar height + icon button size */
  /* Flat 3px per Thierry's explicit Round 11 request ("border-radius...3px
     pour .topbar__toggle-btn") — was fluid. Shared by .topbar__toggle-btn/
     -indicator here and, via project.css, .pj-close/.pj-nav-avatar, whose
     own sizes are pinned flat this round too (see that file's :root), so
     freezing this one radius token serves both asks at once rather than
     needing two separate overrides. */
  --btn-radius: 3px;                           /* 3px   @1440 — icon button corner radius */
  --margin: max(14.2222px, 1.1111vw);          /* 16px  @1440 — grid outer margin */
  --gutter: max(8.8889px, 0.6944vw);           /* 10px  @1440 — grid gutter */
  --list-gap: max(4.6667px, 0.3646vw);         /* 5.25px@1440 — project list row gap */
  --dot-size: max(4.4444px, 0.3472vw);         /* 5px   @1440 — active-project dot */
  /* Flat 4px per Thierry's explicit Round 12 request ("réduit la taille du
     point rouge à 4px. Taille fixe") — a SEPARATE token from --dot-size
     above on purpose: that one is the project list's own active-project
     dot (unrelated, untouched by this request), while this one is shared
     by the "View" (home) and "Visit site" (project) button dots only. */
  --btn-dot-size: 4px;                         /* 4px — View/Visit site button dot */
  --dot-gap: max(10.6667px, 0.8333vw);         /* 12px  @1440 — dot-to-label gap */
  --view-btn-w: max(51.5556px, 4.0278vw);      /* 58px  @1440 — "View" pill width */
  --view-btn-h: max(21.3333px, 1.6667vw);      /* 24px  @1440 — "View" pill height */
  --view-btn-radius: max(3.5556px, 0.2778vw);  /* 4px   @1440 — "View" pill radius */
  /* Flat 14px per Thierry's explicit Round 11 request ("Toutes les font
     sizes des boutons doivent être de 14px") — was fluid (13px@1440). Every
     button label token on the site funnels through this one (or
     --pj-detail-font in project.css, flattened the same way) rather than
     each button hardcoding its own size. */
  --view-btn-font: 14px;                       /* 14px — every button label */
  /* --toggle-height retired (Round 11): .topbar__toggle is height:auto now
     (see below), sized by its buttons' own padding instead of this token. */
  /* --toggle-track-radius retired (Round 11): .topbar__toggle's own
     border-radius is now the flat 6px Thierry explicitly asked for, set
     directly rather than through a token with just the one consumer. */
  --toggle-track-pad: max(3.5556px, 0.2778vw); /* 4px   @1440 — Work/Info toggle: track's own padding around its pill */
  --toggle-btn-pad-x: max(12.4444px, 0.9722vw);/* 14px  @1440 — Work/Info toggle: each button's horizontal padding */
  --topbar-top: max(12.4444px, 0.9722vw);      /* 14px  @1440 — topbar offset from top */
  --cover-radius: max(3.5556px, 0.2778vw);     /* 4px   @1440 — cover image corner radius */
  --cover-parallax-shift: max(124.4444px, 9.7222vw); /* 140px @1440 — max image pan inside its cover, see .cover img */
  --video-mask-w: max(435.5556px, 34.0278vw);  /* 490px @1440 — cover hover preview: video mask width */
  --video-mask-h: max(270.2222px, 21.1111vw);  /* 304px @1440 — cover hover preview: video mask height */
  --dot-hover-shift: max(21.3333px, 1.6667vw); /* 24px  @1440 — how far the active dot slides left as it hides on cover hover (3x its original 8px travel) */
  --cover-hover-ease: cubic-bezier(0.6, 0, 0, 1); /* shared easing for the whole cover-hover interaction (overlay, video mask, image shrink, View button, dot hide) */

  /* ---- project navigation: motion ----
     The cover track's own position is driven entirely, every animation
     frame, from script.js (Lenis-smoothed scroll, the idle-recenter glide,
     and goToProject()'s own click-to-navigate glide — see script.js for
     the full explanation) — no CSS transition/easing there. The list label
     and red dot are different: they only ever change in one discrete jump
     (a new project becomes active — see setActiveKey() in script.js), so a
     plain CSS transition is what actually animates that jump. They share
     one duration (--nav-transition-duration: the default below for an
     organic scroll crossing one project at a time, temporarily lengthened
     to NAV_JUMP_MS by goToProject()'s click-to-navigate jump, which can
     skip several projects at once) but NOT the same easing curve: the dot
     has its own (--dot-ease), separate from the label text's (--nav-ease). */
  --nav-transition-duration: 200ms;
  --nav-ease: cubic-bezier(0.6, 0, 0, 1);
  /* A more pronounced, symmetric ease-in-out than --nav-ease above (which
     only holds back at the start, then snaps quickly to its end value) —
     held back at BOTH ends so the dot reads as leaving slightly late and
     arriving slightly late relative to the project name's own color
     change, rather than moving in lockstep with it. */
  --dot-ease: cubic-bezier(0.85, 0, 0.15, 1);

  /* ---- colors ---- */
  --color-bg: #ffffff;
  --color-ink: #000000;
  --color-muted: rgba(0, 0, 0, 0.3);
  --color-red: #ff0001;
  --color-btn-bg: #f1f1f1;
  --color-cover-bg: #f0f0f0;

  --font-family: "PP Neue Montreal", "Inter", -apple-system, BlinkMacSystemFont,
    "Helvetica Neue", Arial, sans-serif;

  color-scheme: light;
}

*,
*::before,
*::after {
  box-sizing: border-box;
}

html,
body {
  height: 100%;
}

/* Round 14, item 1 ("Global: <p> doivent avoir un line-height: 1.2"): every
   paragraph on the site, full stop — a bare element selector so it also
   covers any <p> added later without needing its own override (info.html's
   own <p>s below already had explicit 1.4s removed to match). */
p {
  line-height: 1.2;
}

/* Shared by every real <a> across the site now that info.html joins
   project.html in having actual link TEXT inside anchors (the Work/Info
   toggle, in particular) — .cover/.info-view__social never showed the
   browser's default blue+underline since they only ever wrap an image or
   icon, but text-bearing links need this explicitly. Mirrors project.css's
   own "body.project-page a" rule, just page-agnostic since it's shared. */
a {
  text-decoration: none;
  color: inherit;
}

body {
  margin: 0;
  /* The desktop fluid system (below, everything outside the max-width:990px
     block) is designed for >=991px — the mobile layout, in that block,
     covers the rest down to a real small-phone width. */
  min-width: 320px;
  background: var(--color-bg);
  color: var(--color-ink);
  font-family: var(--font-family);
  font-weight: 500;
  font-size: var(--font-base);
  line-height: 1;
  -webkit-font-smoothing: antialiased;
  overflow: hidden;
}

button {
  font: inherit;
  color: inherit;
  background: none;
  border: 0;
  padding: 0;
  cursor: pointer;
}

ul {
  list-style: none;
  margin: 0;
  padding: 0;
}

/* ==========================================================================
   Top bar
   ========================================================================== */

.topbar {
  position: fixed;
  top: var(--topbar-top);
  left: var(--margin);
  right: var(--margin);
  height: var(--topbar-size);
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  column-gap: var(--gutter);
  align-items: center;
  /* Above everything else, including the hover-preview video (z-index 50,
     see .cover-hover-clip) and its own toggle at all breakpoints — the
     toggle must never be occluded by a cover's hover video. */
  z-index: 100;
  /* same inner width, column count and gutter as .stage__grid, so its
     columns line up exactly with the project list / cover below */
}

.topbar__logo {
  grid-column: 1;
  white-space: nowrap;
}

.topbar__tags {
  grid-column: 4 / span 3;
  white-space: nowrap;
}

.topbar__right {
  grid-column: 9 / span 4;
  display: flex;
  align-items: center;
  justify-content: flex-end;
}

/* ---- Work / Info toggle ----
   Round 8 redesign, per Thierry's explicit spec: the track is a flat 10%-
   black tint (was --color-btn-bg, #f1f1f1), now with a 40px backdrop blur
   layered on top of it (Round 9). The active state is no longer each
   button independently swapping its own background, but a single shared
   white rectangle (.topbar__toggle-indicator) that lives behind both
   buttons and SLIDES from one to the other — script.js measures the
   newly-active button's own offsetLeft/offsetWidth on click (and on load)
   and writes them to the indicator's transform/width, so this file only
   needs to define the sliding motion itself, not track "which" button is
   active. Purely a visual state switch for now — there's no separate
   "Info" view to switch to yet. */
.topbar__toggle {
  position: relative;
  /* Round 11: was a fixed --toggle-height — now intrinsic to its buttons'
     own padding instead (see .topbar__toggle-btn below), per Thierry's
     explicit request. */
  height: auto;
  display: flex;
  align-items: center;
  background: rgba(0, 0, 0, 0.1);
  backdrop-filter: blur(40px);
  -webkit-backdrop-filter: blur(40px);
  /* Flat 6px per Thierry's explicit Round 11 request — was fluid
     (var(--toggle-track-radius), 6px@1440), on both breakpoints now. */
  border-radius: 6px;
  padding: var(--toggle-track-pad);
  gap: var(--toggle-track-pad);
  /* Round 15, item 1 ("le toggle semble casser" — a black wedge appearing
     in one rounded corner): a known Chromium/WebKit rendering bug where
     backdrop-filter and border-radius on the same element occasionally
     fail to clip together correctly. overflow:hidden plus this
     radial-gradient mask is the standard cross-browser workaround — it
     forces the browser to re-derive the corner clip instead of trusting
     its (buggy) composited edge. Purely defensive: identical appearance
     in browsers where this never showed up. */
  overflow: hidden;
  -webkit-mask-image: -webkit-radial-gradient(white, black);
}

.topbar__toggle-indicator {
  position: absolute;
  top: var(--toggle-track-pad);
  bottom: var(--toggle-track-pad);
  left: 0;
  width: 0; /* script.js sets the real value before first paint */
  background: #ffffff;
  border-radius: var(--btn-radius);
  z-index: 0;
  pointer-events: none;
  /* Same shared easing as the rest of the site's hover/reveal motion,
     reused here rather than a new one-off curve. */
  transition: transform 400ms var(--cover-hover-ease), width 400ms var(--cover-hover-ease);
}

.topbar__toggle-btn {
  position: relative;
  z-index: 1;
  /* Round 11: was height:100% (stretching to fill the track's own fixed
     height) — now the track itself is height:auto (see .topbar__toggle
     above), sized BY this button's own vertical padding instead, so this
     needs to be intrinsic too rather than 100% of a now-intrinsic parent. */
  height: auto;
  padding-inline: var(--toggle-btn-pad-x);
  /* Explicit flat 6px top/bottom per Thierry's explicit Round 11 request —
     this (plus the track's own --toggle-track-pad around the outside) is
     what now actually determines the whole toggle's height. */
  padding-top: 6px;
  padding-bottom: 6px;
  border-radius: var(--btn-radius);
  background: transparent;
  white-space: nowrap;
  /* 1px smaller (at the 1440 reference) than the topbar's own text
     (--font-base, inherited from body) — expressed as an `em` fraction of
     that inherited size rather than a separate max(px,vw) token, so it
     stays exactly 1px-at-1440 smaller at every fluid breakpoint for free,
     without duplicating the fluid formula. Also already exactly 14px at
     every breakpoint today (--font-base is a flat 15px on both, so 14/15 ×
     15px = 14px) — already compliant with Thierry's Round 11 "every button
     label is 14px" request without needing its own change. */
  font-size: calc(14em / 15);
  transition: color 0.15s ease, opacity 0.15s ease;
}

/* Round 10: back to a plain opacity dim, per Thierry's explicit request —
   replacing Round 9's #FFF + mix-blend-mode:difference treatment (which
   also made a separate hover color impossible to express, since any
   hover color would itself run through the same blend). Applies on both
   desktop and mobile — the mobile-only opacity override this used to need
   (see the max-width:990px block) was already retired in Round 9 and
   stays retired. */
/* Round 14: both tabs are real <a href> links now (see index.html/
   info.html) — the active one links to the page already showing, so
   clicking it would otherwise still fade out and reload the same page for
   nothing. Purely an interaction guard; the visual state is unaffected. */
.topbar__toggle-btn.is-active {
  pointer-events: none;
}

.topbar__toggle-btn:not(.is-active) {
  color: var(--color-ink);
  opacity: 0.6;
}

.topbar__toggle-btn:not(.is-active):hover {
  opacity: 1;
}

.topbar__toggle-btn.is-active {
  background: transparent;
  color: var(--color-ink);
  opacity: 1;
}

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

/* ==========================================================================
   Stage (everything below the top bar — always vertically centered)
   ========================================================================== */

.stage {
  min-height: 100vh;
  min-height: 100dvh;
  display: flex;
  align-items: center;
}

.stage__grid {
  width: 100%;
  height: 100%;
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  column-gap: var(--gutter);
  padding-inline: var(--margin);
  align-items: center;
}

/* ---- left: "Selected work / '21 — '26" + project nav, top-aligned to
   each other as a pair, the pair itself vertically centered (its height
   is set by the taller project list, so the list sits centered and the
   eyebrow simply starts at the same top) ---- */

.text-column {
  grid-column: 1 / span 6;
  align-self: center;
  display: grid;
  grid-template-columns: repeat(6, 1fr);
  column-gap: var(--gutter);
  align-items: start;
  /* Round 10: establishes the containing block .projects's own
     position:absolute (see below) resolves its top:0 against — without
     this, .projects had no positioned ancestor at all and fell back to
     the viewport itself, landing pinned to the page's own top edge
     instead of lining up with .eyebrow. */
  position: relative;
}

.eyebrow {
  grid-column: 1 / span 2;
  opacity: 0.3;
}

.eyebrow p {
  margin: 0;
  line-height: 1.2;
  white-space: nowrap;
}

/* ---- middle: project navigation ---- */

.projects {
  grid-column: 4 / span 3;
  /* No padding-left here: the list text itself sits flush with this
     column's left edge, lining up with .topbar__tags above (both are
     grid-column 4/span 3) — see .projects__dot below for where the room
     for the dot actually comes from instead. */

  /* Round 10: taken out of flow — a grid item with position:absolute
     still resolves its grid-column placement against .text-column's own
     grid (so it stays in exactly the same column), but no longer
     contributes to .text-column's own auto-height. .projects (the list)
     is almost always TALLER than .eyebrow, and that height used to be
     what .text-column's own align-self:center centered on the screen —
     pushing the short, top-aligned eyebrow text up above true
     screen-center. With .projects no longer counted, .text-column's
     height is .eyebrow's alone, so centering it now centers the eyebrow
     itself, per Thierry's explicit request. `top: 0` then keeps the list
     starting flush with the eyebrow's own top edge — the second half of
     that request — regardless of how much taller the list runs. */
  position: absolute;
  top: 0;
}

.projects__dot {
  position: absolute;
  /* Sits *before* the text (outside .projects's own box, to its left)
     rather than making room for itself via padding on .projects — that
     padding used to push the list text off the shared grid column, out
     of alignment with .topbar__tags above it. */
  left: calc(-1 * (var(--dot-size) + var(--dot-gap)));
  top: 0;
  width: var(--dot-size);
  height: var(--dot-size);
  transform: translateY(0);
  /* script.js now moves this in one discrete jump per active-project
     change (matching .projects__item below) instead of interpolating
     continuously every frame with the free-scrolling cover track, so a
     transition here no longer fights that per-frame position — it's what
     actually animates the jump. Duration matches .projects__item (they're
     one visual unit, moving together, decoupled from the cover track's
     own separate motion) but the easing is its own, see --dot-ease. See
     --nav-transition-duration above for why the duration is a variable.

     The actual visible red circle is now an inner element, see
     .projects__dot-mark below — this outer one only ever carries the
     JS-driven row position (an inline style), which can't safely share
     one `transform` with a second, CSS-driven hover effect. */
  transition: transform var(--nav-transition-duration) var(--dot-ease);
}

.projects__dot-mark {
  display: block;
  width: 100%;
  height: 100%;
  border-radius: 50%;
  background: var(--color-red);
  /* Hides on cover hover — see .stage__grid:has(.cover:hover) below —
     with the exact same duration/easing as the cover's own "View" button,
     since both fire from the same trigger. */
  transition: transform 500ms var(--cover-hover-ease),
    opacity 500ms var(--cover-hover-ease);
}

.stage__grid:has(.cover:hover) .projects__dot-mark {
  transform: translateX(calc(-1 * var(--dot-hover-shift))) scale(0);
  opacity: 0;
}

.projects__list {
  display: flex;
  flex-direction: column;
  gap: var(--list-gap);
}

.projects__item {
  display: block;
  color: var(--color-muted);
  white-space: nowrap;
  text-align: left;
  /* A short, light crossfade — safe now that this only ever fires on a
     discrete active-project change (in lockstep with the dot, see
     .projects__dot above) rather than every per-frame scroll update: the
     desync this used to cause (found and fixed earlier) was specifically
     from a *slow* transition on something that could change again before
     it finished, while the cover/dot updated instantly — the default 200ms
     is short enough that a real fast scroll's active-project changes don't
     reliably outrun it, and even if one does, the crossfade just gets
     interrupted and restarts from wherever it was, rather than visibly
     lagging. See --nav-transition-duration above for why the duration is a
     variable (goToProject()'s click jump temporarily lengthens it). */
  transition: color var(--nav-transition-duration) var(--nav-ease);
}

.projects__item:hover {
  color: rgba(0, 0, 0, 0.45);
}

.projects__item.is-active {
  color: var(--color-ink);
}

/* ---- right: project cover ----
   A vertical filmstrip: the viewport is exactly one screen tall (100vh)
   and clips a track made of one slot per project (plus clone slots for
   a seamless infinite loop). Each slot centers its cover box vertically,
   so the active cover sits mid-screen — moving to the next/previous
   project translates the whole track by one slot, which makes the new
   cover rise from under the screen (or the previous one exit through
   the top), exactly like a continuous scroll.

   The distance BETWEEN slots (--cover-pitch, set by script.js from the
   real rendered cover height — see measurePitch()) is deliberately a
   little less than the 100vh viewport itself, so at rest ~5% of the
   next/previous cover's own height already pokes into view at the
   bottom/top edge, instead of the neighbors being fully hidden until
   scrolled to. The viewport stays exactly 100vh — only the per-project
   step shrinks. ---- */

.cover-viewport {
  grid-column: 7 / span 6;
  align-self: center;
  position: relative;
  width: 100%;
  height: 100vh;
  height: 100dvh;
  overflow: hidden;
}

.cover-track {
  width: 100%;
  transform: translateY(calc(
    var(--cover-offset, 0px) - var(--slot-index, 14) * var(--cover-pitch, 100vh)
  ));
  /* No CSS transition: script.js drives --slot-index every animation
     frame with a single continuous damp toward its scroll target —
     whether that target is actively moving (mid-scroll) or has just been
     rounded to the nearest project (settling). Using one JS-driven rule
     throughout, instead of handing off to a separate CSS-eased "arrival"
     animation once scrolling stops, is what keeps the settle reading as
     a direct continuation of the scroll rather than a distinct step —
     and lets new input interrupt it at any point with no jump. */
}

.spacing-100vh--mob {
  display: none;
}

.cover-viewport__top-mask {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  z-index: 1; /* above .cover-track (transformed, its own implicit stacking level) regardless of DOM order */
  /* height set by script.js (measurePitch()) to exactly the peek amount,
     COVER_REVEAL_REST * the real rendered cover height — 0 until then so
     it can never overshoot and cover more than intended. */
  height: 0;
  background: var(--color-bg);
  pointer-events: none;
  opacity: 1;
  transition: opacity 0.4s ease;
}

.cover-slot {
  height: var(--cover-pitch, 100vh);
  display: flex;
  align-items: center;
  /* Each slot's fixed position in the DOM (0-34, set inline in index.html)
     never changes, while --slot-index (inherited from #js-cover-track,
     where script.js updates it every frame) is the current continuous
     scroll position. Their difference is how far this slot currently is
     from being the centered/active one: 0 = centered, negative = still
     below center (hasn't arrived yet), positive = already past it and
     exiting through the top. Clamped to ±1 because beyond one slot's
     distance the cover isn't visible anyway. This is the one JS-free
     hook the parallax below (see .cover img) reads every frame for free,
     just by inheriting a var() — no extra per-frame JS. */
  --parallax-t: clamp(-1, calc(var(--slot-index, 14) - var(--slot-abs-index, 0)), 1);
}

.cover {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 701 / 539;
  border-radius: var(--cover-radius);
  overflow: hidden;
  background: var(--color-cover-bg);
}

.cover img {
  position: absolute;
  left: 0;
  /* Oversized (taller than its frame by 2x the max shift) and pre-shifted
     up by one shift so it's centered at rest (--parallax-t: 0) — see the
     transform below for how it then pans within that extra room as the
     slot approaches/leaves center, instead of scrolling at the exact same
     speed as its frame like a plain background would. */
  top: calc(-1 * var(--cover-parallax-shift));
  width: 100%;
  height: calc(100% + 2 * var(--cover-parallax-shift));
  object-fit: cover;
  pointer-events: none;
  transform: translateY(calc(var(--parallax-t, 0) * var(--cover-parallax-shift)));
  /* No transition: this rides the same continuous, JS-driven --slot-index
     as the cover track itself (see .cover-track above), so it needs to
     stay exactly in step with it frame-by-frame rather than trailing it
     with its own separate easing — including while hovered: the cover
     image itself no longer reacts to hover at all (see .cover__overlay
     and .video-mask below for what does). */
}

/* Hover overlay: a dark, heavily blurred veil between the (static) image
   and the video preview above it. */
.cover__overlay {
  position: absolute;
  inset: 0;
  background: rgba(0, 0, 0, 0.1);
  backdrop-filter: blur(100px);
  -webkit-backdrop-filter: blur(100px);
  opacity: 0;
  pointer-events: none;
  transition: opacity 500ms var(--cover-hover-ease);
}

.cover:hover .cover__overlay {
  opacity: 1;
}

.cover__view {
  position: absolute;
  right: var(--gutter);
  bottom: var(--gutter);
  /* Round 12: sized by its own content + padding now, per Thierry's
     explicit request ("padding: 6px 8px; gap: 8px") — was a fixed
     width/height (--view-btn-w/--view-btn-h). Those two tokens are no
     longer read anywhere in this file; left defined in :root in case
     something else picks them back up later. */
  padding: 6px 8px;
  border-radius: 3px;
  /* Black 10% bg + white 100% text. */
  background: rgba(0, 0, 0, 0.1);
  color: #ffffff;
  font-size: var(--view-btn-font);
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 8px;
  /* Always fully opaque — only its position animates on hover (see
     transform/transition below), never its opacity. */
  opacity: 1;
  /* Slides in from just past its own right edge (fully clear of the cover)
     rather than from a fixed offset. `100%` in a transform resolves
     against THIS element's own box (not its parent's), so this still
     works correctly now that the button's width is intrinsic rather than
     a known token — was translateX(calc(var(--view-btn-w) + var(--gutter))). */
  transform: translateX(calc(100% + var(--gutter)));
  transition: transform 500ms var(--cover-hover-ease);
}

.cover:hover .cover__view {
  transform: translateX(0);
}

.cover__view::before {
  content: "";
  /* Flat 4px per Thierry's explicit Round 12 request — see --btn-dot-size
     in :root above (a dedicated token, not the project list's own
     --dot-size). */
  width: var(--btn-dot-size);
  height: var(--btn-dot-size);
  border-radius: 50%;
  background: var(--color-red);
  flex: none;
}

/* Blinks at a plain text-cursor's own rhythm (hard on/off, no fade) —
   only while the "View" button is actually visible, so 34 idle covers
   aren't animating a dot nobody can see. Twice the speed of the original
   1s cadence, per Thierry's explicit request. */
.cover:hover .cover__view::before {
  animation: caret-blink 0.5s step-end infinite;
}

@keyframes caret-blink {
  0%,
  49% {
    opacity: 1;
  }
  50%,
  100% {
    opacity: 0;
  }
}

/* ==========================================================================
   Cover hover preview video
   A single shared element (not one per cover — see script.js), repositioned
   over whichever cover is currently hovered via `left`/`top` (px, JS-set)
   rather than DOM-reparented, both to keep the same media element (and
   therefore its play position) across different covers, and because it has
   to live outside .cover-track/.cover-viewport: those are `transform`-ed
   ancestors, which would turn this element's `position: fixed` into
   behaving like `position: absolute` relative to them instead of the
   viewport — see script.js for the full explanation.
   ========================================================================== */

.cover-hover-clip {
  position: fixed;
  top: 0;
  left: 0;
  width: 0;
  height: 0;
  overflow: hidden;
  border-radius: var(--cover-radius);
  pointer-events: none;
  z-index: 50;
  /* left/top/width/height set every frame by script.js's
     positionHoverLayer() to the currently-hovered cover's own live
     bounding box — this is what actually keeps the hover video from ever
     becoming visible past that cover's own edges (the mask below is
     larger than a cover at rest, by design, so without this clip it would
     spill over neighboring covers — worse still while scrolling, since a
     fast scroll can shrink covers via --cover-pitch faster than a fixed-
     size mask would otherwise account for). */
}

.video-mask {
  position: absolute;
  top: 50%;
  left: 50%;
  width: var(--video-mask-w);
  height: var(--video-mask-h);
  border-radius: var(--cover-radius);
  border: 1px solid rgba(255, 255, 255, 0.15);
  overflow: hidden;
  background: #000;
  /* Without this, the black background (border-box by default) extends
     underneath the border itself, so the border's 15%-white blends against
     solid black instead of whatever is actually behind the element —
     reading as a near-black line instead of the translucent white one
     actually specified. Padding-box stops the background at the border's
     inner edge, letting the border render as genuinely translucent white. */
  background-clip: padding-box;
  pointer-events: none;
  /* Centers on .cover-hover-clip's own box (i.e. on the hovered cover's
     own center, see there) — a plain, never-animated translate now: see
     clip-path below for what actually "appears"/"disappears".

     This used to be transform: scaleY(0)->scaleY(1), unfurling the mask
     in height from the center — but `transform` scales the ENTIRE
     rendered subtree, including the <video> inside, which was visibly
     squashing/stretching the video itself along with the mask rather
     than leaving it undistorted, per Thierry's explicit report. clip-path
     achieves the identical "grows in height from the center" reveal
     WITHOUT ever resizing the box (or its children) at all — the box
     stays at its real, final width/height throughout; only how much of
     it is clipped away changes. */
  transform: translate(-50%, -50%);
  clip-path: inset(50% 0% 50% 0%);
  /* Slower to appear than to disappear, and using the same easing as the
     View button's own transition (--cover-hover-ease) — both fire from
     the same hover trigger. */
  transition: clip-path 400ms var(--cover-hover-ease);
}

.video-mask.is-visible {
  clip-path: inset(0% 0% 0% 0%);
  transition: clip-path 800ms var(--cover-hover-ease);
}

.video-mask video {
  display: block;
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* A quick double-blink of the video itself while the mask reveals it —
   per Thierry's explicit request, layered on top of the mask's own
   reveal above (not a replacement for it). Hard on/off cuts (no easing),
   matching the same "clignotement" treatment as the View button's own
   dot (see .cover__view::before above) rather than a smooth fade. Only
   on reveal (.is-visible being added) — not on the way back out. */
@keyframes cover-video-blink {
  0%,
  100% {
    opacity: 1;
  }
  20% {
    opacity: 0;
  }
  40% {
    opacity: 1;
  }
  60% {
    opacity: 0;
  }
  80% {
    opacity: 1;
  }
}

.video-mask.is-visible video {
  animation: cover-video-blink 350ms step-end;
}

/* ==========================================================================
   Info page (info.html) — Round 14: was a same-page view swapped in over
   .stage on this page, toggled via body.is-info-view; now a dedicated page
   of its own (see info.html and script.js's own note on why), so none of
   that conditional show/hide plumbing is needed any more — this is simply
   that page's main content, always laid out. Mobile overrides live inside
   the max-width:990px block further down, alongside the rest of this
   file's mobile section.
   ========================================================================== */

.info-view {
  position: fixed;
  inset: 0;
  overflow-y: auto;
  /* Below .topbar (z-index:100) so the toggle stays clickable/on top. */
  z-index: 10;
  /* Round 15, item 3: vertically centers the same way .stage does (flex
     align-items:center on the OUTER element, centering .info-view__grid's
     own auto-height) instead of via .info-view__grid's own align-content —
     see that rule's comment for why that distinction now matters. */
  display: flex;
  align-items: center;
}

.info-view__grid {
  width: 100%;
  display: grid;
  grid-template-columns: repeat(12, 1fr);
  column-gap: var(--gutter);
  padding-inline: var(--margin);
  /* Round 15, item 3 ("toutes les infos de la page Info devraient se
     trouver à la même hauteur que Selected work"): was align-content:
     center, block-centering the WHOLE grid — since .info-view__stats runs
     far taller than .info-view__time, that centered the tall combined
     block and left __time's own top sitting well above true screen-center
     instead of level with "Selected work"'s own top. .text-column/
     .projects above solve the exact same problem the exact same way: take
     the taller column(s) out of flow (position:absolute, see __bio/
     __stats below) so only __time's short height sets this row's
     auto-height — .info-view's own flex centers THAT, landing __time
     exactly where .eyebrow lands. position:relative here is what makes
     this grid the containing block __bio/__stats resolve their `top:0`
     against while keeping their grid-column placement (same mechanism
     .text-column relies on for .projects). */
  align-items: start;
  position: relative;
}

.info-view__time {
  grid-column: 1 / span 2;
  color: var(--color-muted);
  font-size: 15px;
}

.info-view__time p {
  margin: 0;
}

.info-view__bio {
  /* Round 16, item 1: narrowed by one column — was 4/span 5. */
  grid-column: 4 / span 4;
  /* Round 15, item 3: out of flow — see .info-view__grid's own comment
     just above. Mobile resets this back to position:static (see the
     max-width:990px block further down), since that layout is a plain
     top-to-bottom stack instead. */
  position: absolute;
  top: 0;
}

.info-view__bio p {
  margin: 0 0 24px;
  max-width: 32em;
  font-size: 15px;
  color: var(--color-ink);
}

.info-view__socials {
  /* Round 13: regrouped into one pill-shaped tray (same rgba(0,0,0,0.1)
     background + backdrop-blur idea as .topbar__toggle above) instead of
     three individually-bordered squares — width:auto so the tray is only
     ever as wide as its 3 icons need. This div would otherwise default to
     filling .info-view__bio's whole column width (a plain block box's
     "auto" means "fill the container", not "hug my content") — inline-flex
     is what actually makes it shrink-wrap, same trick .topbar__toggle
     relies on by virtue of being a flex item of .topbar__right instead. */
  width: auto;
  display: inline-flex;
  gap: 4px;
  padding: 4px;
  border-radius: 6px;
  background: rgba(0, 0, 0, 0.1);
  backdrop-filter: blur(40px);
  -webkit-backdrop-filter: blur(40px);
  /* Round 15: same defensive fix as .topbar__toggle above, applied here
     pre-emptively — identical backdrop-filter + border-radius combo, so
     the same corner-clipping bug could show up here too. See that rule's
     comment for the full explanation. */
  overflow: hidden;
  -webkit-mask-image: -webkit-radial-gradient(white, black);
}

.info-view__social {
  width: var(--topbar-size);
  height: var(--topbar-size);
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  border-radius: 3px;
  /* Round 14, item 4: solid white, 100% opaque — was transparent (just the
     shared tray's own rgba(0,0,0,0.1) showing through behind each icon).
     Round 15, item 2: this white background must now stay put on hover —
     the transition/opacity dimming moved down onto the icon itself (see
     .info-view__social svg below), so this element no longer needs its
     own opacity transition at all. */
  background: #ffffff;
  color: var(--color-ink);
}

.info-view__social svg {
  transition: opacity 200ms var(--cover-hover-ease);
}

/* Round 15, item 2: only the icon dims to 60% on hover — was opacity:0.6 on
   .info-view__social itself, which also faded its own white background
   (item 4's whole point) back toward the tray's dark tint behind it. */
.info-view__social:hover svg {
  opacity: 0.6;
}

.info-view__stats {
  grid-column: 9 / span 4;
  display: flex;
  flex-direction: column;
  /* Round 16, item 2: 32px on both desktop and mobile — was 28px here,
     24px on mobile (see that override below, now removed). */
  gap: 32px;
  /* Round 15, item 3: out of flow — see .info-view__grid's own comment
     above .info-view__time. By far the tallest column, so the one that
     mattered most to pull out of the centering calculation. Mobile resets
     this back to position:static (see the max-width:990px block further
     down). */
  position: absolute;
  top: 0;
  /* Round 16, item 3 ("Rajoute 40vh d'espace négatif sous le contenu"):
     breathing room after the page's own lowest content — added here
     rather than on .info-view/.info-view__grid because those two now
     drive the item-3 vertical centering above (see their own comments);
     padding there would shrink that centering box and re-introduce the
     very misalignment Round 15 just fixed. .info-view__stats is by far
     the tallest column, i.e. the page's actual visual bottom, so its own
     padding-bottom is what .info-view (the scroll container) actually
     scrolls to. */
  padding-bottom: 40vh;
}

.info-view__stat-row {
  display: grid;
  grid-template-columns: 90px 1fr;
  column-gap: 12px;
}

.info-view__stat-label {
  color: var(--color-muted);
  font-size: 15px;
  text-align: right;
}

.info-view__stat-list {
  display: flex;
  flex-direction: column;
  gap: 4px; /* Round 14, item 2 — was 2px */
  font-size: 15px;
  color: var(--color-ink);
}

/* ==========================================================================
   Scroll proxy (drives Lenis — see script.js)
   ========================================================================== */

#js-scroll-proxy {
  position: fixed;
  inset: 0;
  overflow-y: scroll;
  visibility: hidden;
  pointer-events: none;
  -ms-overflow-style: none;
  scrollbar-width: none;
}

#js-scroll-proxy::-webkit-scrollbar {
  display: none;
}

@media (prefers-reduced-motion: reduce) {
  .cover-track,
  .projects__dot,
  .projects__dot-mark,
  .projects__item,
  .cover-viewport__top-mask,
  .cover img,
  .cover__overlay,
  .cover__view,
  .video-mask {
    transition: none !important;
  }

  .cover img {
    /* The parallax pan is a decorative motion effect layered on top of
       the scroll itself — switch it off the same way script.js already
       turns off the organic pitch-shrink effect for this preference. */
    transform: none !important;
  }

  .cover__view::before,
  .video-mask.is-visible video {
    /* A blinking dot/video is exactly the kind of motion this preference
       exists to suppress — leave both solid instead. */
    animation: none !important;
  }
}

/* ==========================================================================
   Mobile (max-width: 990px)
   A deliberately different layout, not a squeezed-down version of the
   desktop one — see the Figma mobile frame. Structurally: no 12-column
   grid, no infinite-loop filmstrip, no Lenis-smoothed virtual scroll — the
   page just scrolls natively, script.js takes a completely separate
   initMobile() path instead of the desktop render loop (see its own
   comment there for why).

   Sizing convention here deliberately differs from the desktop file header
   above: most values are FLAT (not fluid max(px,vw)) because phone widths
   (roughly 320–990px here) don't call for the same "keep scaling forever"
   treatment desktop monitors do — a 15px label shouldn't become a 40px
   label just because the phone is a bit wider. The one exception is the
   cover title (--mobile-title-font): its 48px reference size is large
   enough, relative to a narrow phone's width, that it's given the same
   floor-based fluid formula as the desktop tokens (see the file header),
   anchored to the Figma mobile frame's own 393px reference width instead
   of desktop's 1440px one.
   ========================================================================== */

@media (max-width: 990px) {
  :root {
    --font-base: 15px; /* flat — feeds body's font-size below, and therefore .topbar__toggle-btn's own calc(14em / 15) too (giving 14px) for free */
    --margin: 10px; /* flat — also .stage__grid's own side padding, unchanged below */
    --gutter: 10px;
    --toggle-track-pad: 4px;
    --mobile-title-font: max(32.5700px, 10.1781vw); /* 40px @393 (the Figma mobile frame's own width) */
    --mobile-gap: 10px; /* resting gap between covers — script.js's initMobile() breathes this up slightly while scrolling, see there */
  }

  html,
  body {
    height: auto;
  }

  body {
    overflow: visible; /* real, native page scroll — no Lenis, no virtual scroll proxy */
    /* Round 15, item 4: the containing block .info-view__time resolves its
       position:absolute against below — it has no positioned ancestor of
       its own (it lives inside <main>, not inside <header class="topbar">),
       so this is what lets it sit up alongside "Thierry Rolin" instead.
       No visual effect on its own (no offsets set here), and body has no
       other absolutely-positioned children anywhere else on the site that
       this could disturb. */
    position: relative;
  }

  #js-scroll-proxy {
    display: none; /* unused on mobile — see script.js's IS_MOBILE gate */
  }

  /* ---- topbar: two stacked lines, top-left; the toggle detaches to a
     fixed pill at the bottom of the screen (see .topbar__right) ---- */
  .topbar {
    position: static;
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: 4px;
    height: auto;
    padding-inline: var(--margin);
    padding-bottom: 0;
    /* Fixed breathing room above "Thierry Rolin" itself, independent of
       the dynamic peek-spacing below — that one can rest at 0 (see its
       own comment), but the page should never start with the logo flush
       against the very top edge of the screen. */
    margin-top: 16px;
    /* Set by script.js's initMobile() (measureMobileLead()) so exactly
       COVER_REVEAL_MOBILE (15%) of the second cover's own height peeks in
       at the bottom of the screen on load, matching the desktop top-mask's
       "encourage scrolling" role — 0 until then so it can never overshoot.
       Lives here (pushing the whole topbar+eyebrow+covers block down as a
       unit) rather than on .cover-track, so the two gaps below it — this
       topbar's own tagline-to-eyebrow gap and the eyebrow-to-covers gap —
       can stay fixed, design-locked values. */
    padding-top: var(--mobile-lead-gap, 0px);
  }

  .topbar__right {
    position: fixed;
    left: 50%;
    bottom: 1.5rem;
    top: auto;
    right: auto;
    transform: translateX(-50%);
    z-index: 100;
  }

  .topbar__right.is-docked-to-end {
    bottom: auto;
    top: var(--toggle-dock-top, 50%);
    transform: translate(-50%, -50%);
  }

  /* Round 11: mobile-only again — the toggle's own track goes darker here
     (30% black vs. desktop's 10%), so the inactive tab's label switches to
     white to stay legible against it (desktop keeps --color-ink, its own
     background is light enough not to need this). Opacity dimming
     (0.6 at rest, 1 on hover — see the shared base rule above) still
     applies on top of this color at both breakpoints, unchanged. */
  .topbar__toggle {
    background: rgba(0, 0, 0, 0.3);
  }

  .topbar__toggle-btn:not(.is-active) {
    color: #ffffff;
  }

  /* ---- stage: plain top-to-bottom flow instead of a centered 12-col grid ---- */
  .stage {
    display: block;
    min-height: auto;
  }

  .stage__grid {
    display: block;
    height: auto;
    width: 100%;
    /* padding-inline: var(--margin) is inherited unchanged from the base
       rule above — it now resolves to the flat 10px set on :root here. */
  }

  .text-column {
    display: block;
    width: 100%;
    /* Fixed 10px gap to the covers list below — see .eyebrow's margin-top
       just below for the other (12.5rem) fixed gap, above the tagline. */
    margin-bottom: 10px;
  }

  .eyebrow {
    display: flex;
    justify-content: space-between;
    align-items: baseline;
    opacity: 1;
    margin-top: 12.5rem;
  }

  .eyebrow p {
    opacity: 0.2;
  }

  /* No text project list on mobile — the project name lives on the cover
     itself instead, see .cover::after below. */
  .projects {
    display: none;
  }

  /* ---- covers: a plain vertical stack of the 7 real projects, full
     width, gapped — not the desktop filmstrip. No infinite loop, so the
     28 clone slots (everything but the "real", non-aria-hidden middle
     copy — see index.html) are simply hidden rather than rendered. ---- */
  .cover-viewport {
    height: auto;
    overflow: visible;
    width: 100%;
  }

  .cover-viewport__top-mask {
    display: none; /* desktop-only loop-seam mask */
  }

  /* Trailing one-screen pause after the last cover (no infinite loop on
     mobile). The Work/Info pill tracks the vertical centre of this space
     once it rises past the usual bottom dock — see dockMobileToggle(). */
  .spacing-100vh--mob {
    display: block;
    height: 100vh;
    height: 100dvh;
    pointer-events: none;
  }

  .cover-track {
    display: flex;
    flex-direction: column;
    gap: var(--mobile-gap, 10px);
    width: 100%;
    transform: none;
    /* The dynamic peek-spacing now lives on .topbar's padding-top instead
       (see there) — this gap is the fixed 10px one, set as .text-column's
       margin-bottom just above the covers list. */
  }

  .cover-slot {
    height: auto;
    display: block;
    width: 100%;
    /* Overrides the desktop formula above (which reads --slot-index, a
       filmstrip-only concept this layout has none of) with a flat 0 — the
       resting/no-shift value. script.js's initMobile() then drives this
       inline, per real slot, every frame from each cover's own position
       in the native page scroll (see updateMobileParallax() there),
       exactly like --slot-index does for desktop, just JS- instead of
       CSS-computed since there's no continuous filmstrip position to
       read a var() from here. */
    --parallax-t: 0;
  }

  .cover-slot:has(.cover[aria-hidden="true"]) {
    display: none;
  }

  .cover {
    aspect-ratio: 373 / 539; /* the Figma mobile frame's own cover proportions — taller/narrower than desktop's 701/539 */
  }

  /* .cover img keeps the base rule's oversize/pan (top/height/transform,
     see above) unchanged — same parallax mechanism as desktop, just fed
     by --parallax-t values script.js computes from native scroll position
     instead of the filmstrip's --slot-index. */

  /* Desktop-only hover elements: hidden outright rather than merely
     inert, since a touch device never fires the :hover that would reveal
     them, and hiding them removes their (empty) box from consideration
     entirely. */
  .cover__overlay,
  .cover__view {
    display: none;
  }

  .cover-hover-clip {
    display: none;
  }

  /* The project name is "constantly" shown (per the brief, i.e. unlike
     desktop's hover-only "View" pill) centered on every cover — reads its
     text straight off the `data-title` attribute already on each .cover
     anchor (::after, content: attr(...)).

     Round 10 redesign per Figma node 2333-6964: was a full-cover flat
     dark tint (::before, now removed — its contrast job is done by the
     pill's own background below instead) behind giant (40px) display
     text spanning the whole cover. Now a compact rounded "pastille" —
     20%-black, 64px-blurred, hugging just the text — centered on the
     cover instead of spanning it. That's a real drop in text scale (this
     reads as a small badge, not a display headline any more); Figma's
     asset API was rate-limited again this round so the exact size is my
     own reasonable pick rather than a pixel-matched value — flag it if
     it should come up or down. */
  .cover::after {
    content: attr(data-title);
    position: absolute;
    top: 50%;
    left: 50%;
    transform: translate(-50%, -50%);
    padding: 10px 16px;
    border-radius: 8px;
    background: rgba(0, 0, 0, 0.2);
    -webkit-backdrop-filter: blur(64px);
    backdrop-filter: blur(64px);
    color: #ffffff;
    font-size: 16px;
    letter-spacing: -0.02em;
    white-space: nowrap;
    pointer-events: none;
  }

  /* ---- Info view: mobile — normal in-flow content (native page scroll,
     same as .stage's own mobile treatment) instead of a fixed overlay,
     and a simple stacked block instead of the desktop 12-column grid. ---- */
  .info-view {
    position: static;
    overflow-y: visible;
    /* Undoes the desktop-only flex centering added for item 3 above — this
       is a plain top-to-bottom stack on mobile, always was. */
    display: block;
  }

  .info-view__grid {
    display: block;
    min-height: auto;
    /* Undoes the desktop-only position:relative added for item 3 above —
       nothing here needs it as a containing block on mobile. */
    position: static;
  }

  /* Round 15, item 4 ("Le bloc '15:48 GMT+2 France' n'est toujours pas
     aligné en haut avec 'Thierry Rolin'"): pulled out of the normal
     top-to-bottom stack and pinned to the same top-16px row "Thierry
     Rolin" sits on (see body's own position:relative above, which is what
     this resolves against) — still not position:fixed (Round 14, item 5's
     explicit point still holds: this scrolls away with the rest of the
     page exactly like every other absolutely-positioned-but-not-fixed
     element on the site, e.g. .projects above; it just no longer occupies
     space in the document's own top-to-bottom flow, so .info-view__bio
     below no longer needs to allow room for it either). */
  .info-view__time {
    position: absolute;
    /* top:0, not 16px — .topbar's own margin-top:16px collapses through
       body (nothing here establishes a new block-formatting context to
       stop it), so body's own box already starts 16px down from the
       viewport, flush with "Thierry Rolin" itself. Adding another 16px on
       top of that would double it. */
    top: 0;
    right: var(--margin);
    text-align: right;
  }

  /* Starts a fixed 40vh down from the top of the viewport, per Thierry's
     own spec — measured from wherever .info-view__bio's own flow position
     already is (right after the topbar; since item 4 above took
     .info-view__time out of flow, that's now the only thing setting this),
     not literally the viewport's own edge. */
  .info-view__bio {
    /* Undoes the desktop-only position:absolute added for item 3 above —
       this is back to a normal in-flow block on mobile, same as always. */
    position: static;
    /* Round 16, item 1. */
    width: 75vw;
    margin-top: 40vh;
    /* 96px down to "Capabilities" below (Thierry's explicit spec) — was a
       flat 32px shared with every other gap on this view; called out on
       its own now since it's a deliberately larger breathing room, not
       the same rhythm as the rest. */
    margin-bottom: 96px;
  }

  .info-view__bio p {
    max-width: none;
  }

  .info-view__stats {
    /* Undoes the desktop-only position:absolute added for item 3 above —
       this is back to a normal in-flow block on mobile, same as always. */
    position: static;
    /* Round 16, item 2: 32px now shared with desktop (see the base rule
       above) — was 24px here specifically. */
    /* 96px clears the fixed bottom Work/Info toggle pill (see
       .topbar__right) so it never sits over the last list item; the base
       rule's own 40vh (Round 16, item 3) is added on top of that, not
       replacing it — combined into one value since padding-bottom can
       only hold one. */
    padding-bottom: calc(96px + 40vh);
  }

  .info-view__stat-row {
    grid-template-columns: 100px 1fr;
  }
}
