/**
 * Faina Muzyka portfolio — stylesheet.
 *
 * Plain CSS, no preprocessor or build step. Structure, top to bottom:
 *   Reset & tokens     — CSS custom properties for colour/type reused
 *                        throughout (--black, --mono, --display, etc.)
 *   Fixed video canvas  — #stage (js/main.js draws into it), plus the
 *                        grain/vignette overlays layered on top
 *   Header              — fixed compact header: brand, primary nav,
 *                        language toggle, scroll-progress bar
 *   Layout               — .section flex scaffolding shared by every
 *                        page section
 *   About, Projects,     — content-specific rules, in the same top-to-
 *   CV, Contact            bottom order they appear in index.html
 *   Reveal-on-scroll     — the .reveal / .is-visible fade-in pair
 *   Responsive           — one mobile breakpoint at 720px
 *   Reduced motion       — @media (prefers-reduced-motion: reduce)
 */

/* ===== Reset & tokens ===== */
* { margin: 0; padding: 0; box-sizing: border-box; }

:root {
  --black: #000000;
  --off-white: #f2f0ea;
  --white: #ffffff;
  --line: rgba(242, 240, 234, 0.16);
  --line-strong: rgba(242, 240, 234, 0.32);
  /* Global type swap to Cabinet Grotesk (see index.html <head>). The two
     custom-property names are historical — .osd-bio is the one deliberate
     holdout, hardcoded directly to the old JetBrains Mono / VT323 cassette
     stack so it's unaffected by changes here. */
  --mono: 'CabinetGrotesk', 'Archivo', 'Helvetica Neue', Arial, sans-serif;
  --display: 'CabinetGrotesk', 'Archivo', 'Helvetica Neue', Arial, sans-serif;
  --header-h: 5rem;
}

@media (prefers-reduced-motion: no-preference) {
  html { scroll-behavior: smooth; }
}

html, body {
  background: var(--black);
  color: var(--off-white);
  font-family: var(--mono);
  /* `clip`, not `hidden` — either clips horizontal overflow without also
     forcing overflow-y's computed value to `auto`, which is what `hidden`
     does per spec whenever the other axis isn't `visible`. That forced
     `auto` turns html/body into a scroll container in their own right,
     which breaks every `position: sticky` descendant (the archive preview
     panes) since their nearest scrolling ancestor is no longer the real
     viewport. */
  overflow-x: clip;
  scrollbar-width: thin;
  scrollbar-color: rgba(242,240,234,0.3) transparent;
}

/* Burnt-in optical title look: a tight dark fringe instead of any panel/box,
   so type stays legible over the raw video at any brightness. Inherits to
   every text node by default (text-shadow is an inherited property). */
body {
  text-shadow:
    0 0 2px rgba(0,0,0,0.95),
    0 0 8px rgba(0,0,0,0.8),
    0 1px 3px rgba(0,0,0,0.9);
}

::selection { background: var(--off-white); color: var(--black); }

/* ===== Fixed video canvas ===== */
#stage {
  position: fixed;
  inset: 0;
  width: 100vw;
  height: 100vh;
  z-index: 0;
  display: block;
  background: var(--black);
  filter: grayscale(0.08) contrast(1.08) brightness(0.92);
  /* Fully opaque through the top third or so, then a slow, gradual ease
     all the way down to true transparent (not just a very dark residue)
     by 80% of the viewport's height. The panel's own source frames carry
     a real seam of their own — most visible where two adjacent frames
     crossfade during scroll, since each one's lit "window" sits at a
     slightly different spot — and that seam always falls well below the
     panel's entrance point, never right at the very top. Fading the
     canvas out to nothing before reaching that depth means there's
     nothing left on screen for the seam to show up on, without having to
     chase its exact position. */
  -webkit-mask-image: linear-gradient(
    to bottom,
    black 0%,
    black 32%,
    rgba(0, 0, 0, 0.85) 45%,
    rgba(0, 0, 0, 0.55) 58%,
    rgba(0, 0, 0, 0.25) 70%,
    transparent 80%
  );
  mask-image: linear-gradient(
    to bottom,
    black 0%,
    black 32%,
    rgba(0, 0, 0, 0.85) 45%,
    rgba(0, 0, 0, 0.55) 58%,
    rgba(0, 0, 0, 0.25) 70%,
    transparent 80%
  );
  /* This mask isn't trying to track the panel's own moving edge — a mask
     tied to the viewport's position can't chase content that sweeps to a
     different screen position at every scroll depth. drawContain (in
     js/main.js) separately fades the panel's own opacity in over a short
     stretch of scroll progress right as it enters. */
}

/* Analog grain overlay */
.grain {
  position: fixed;
  inset: -50%;
  width: 200%;
  height: 200%;
  z-index: 1;
  pointer-events: none;
  opacity: 0.05;
  mix-blend-mode: overlay;
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='120' height='120'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='2' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)'/%3E%3C/svg%3E");
  animation: grain-shift 1.1s steps(4) infinite;
}
@keyframes grain-shift {
  0%   { transform: translate(0, 0); }
  25%  { transform: translate(-2%, 1%); }
  50%  { transform: translate(1%, -2%); }
  75%  { transform: translate(-1%, -1%); }
  100% { transform: translate(0, 0); }
}

/* Vignette blending canvas edges into black */
.vignette {
  position: fixed;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background:
    radial-gradient(ellipse at center, rgba(0,0,0,0) 45%, rgba(0,0,0,0.85) 100%),
    linear-gradient(to bottom, rgba(0,0,0,0.6) 0%, rgba(0,0,0,0) 12%, rgba(0,0,0,0) 88%, rgba(0,0,0,0.75) 100%);
}

/* Laboratory Archive concept: a deep, flat scrim over the whole canvas
   (on top of the grain/vignette, which stay untouched) so the site reads
   darker and more restrained throughout — the canvas draw logic in
   js/main.js is never touched, this is a pure compositing layer. */
.scrim {
  position: fixed;
  inset: 0;
  z-index: 2;
  pointer-events: none;
  background: rgba(0, 0, 0, 0.28);
}

/* ===== Header ===== */
.site-header {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  z-index: 5;
  /* Solid backing behind the nav, not just the border below it — without
     it the header is fully transparent, so anything scrolling underneath
     (the hero's own OSD bio list, in particular) shows straight through
     and visually collides with the nav text: both sets of glyphs and
     their shadows blend into a muddy dark smear as the OSD list's lines
     pass behind the brand/menu. A partial-opacity tint still let enough
     of that text bleed through to read as a faint ghost, so this needs
     to be fully opaque, not just darkened. */
  background: var(--black);
  border-bottom: 1px solid var(--line);
}
.header-row {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 1.5rem;
  padding: 0.9rem 1.75rem;
  min-height: var(--header-h);
}
.brand {
  display: flex;
  flex-direction: column;
  gap: 0.15rem;
  line-height: 1.2;
}
.brand-name,
.brand-role {
  font-family: var(--mono);
  font-size: 0.85rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: rgba(242,240,234,0.6);
}
.site-menu {
  display: flex;
  align-items: center;
  gap: 1.6rem;
  font-family: var(--mono);
  font-size: 0.85rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
}
.site-menu a {
  color: rgba(242,240,234,0.6);
  text-decoration: none;
  transition: color 0.2s ease;
  /* "À propos" (the FR label for About) has a space in it and was
     wrapping onto two lines whenever the nav ran short on room —
     nowrap keeps every label on one line, same as .archive-year
     elsewhere on the site for the same reason. */
  white-space: nowrap;
}
/* A thin "/" between every link but the first, same dimmed tone as the
   "|" between EN/FR — without it the nav read as one run-on line of
   words, only the .site-menu gap (not visible on its own) telling them
   apart. */
.site-menu a:not(:first-child) {
  position: relative;
}
.site-menu a:not(:first-child)::before {
  content: "/";
  position: absolute;
  right: 100%;
  margin-right: 0.65rem;
  color: rgba(242,240,234,0.3);
}
.site-menu a:hover { color: var(--off-white); }

.lang-toggle {
  display: flex;
  align-items: center;
  gap: 0.4rem;
  border: 1px solid var(--line-strong);
  padding: 0.3rem 0.55rem;
  flex-shrink: 0;
}
.lang-btn {
  background: none;
  border: none;
  color: rgba(242,240,234,0.5);
  font-family: var(--mono);
  font-size: 0.85rem;
  letter-spacing: 0.1em;
  padding: 0.1rem 0.15rem;
  cursor: pointer;
  transition: color 0.2s ease;
}
.lang-btn:hover { color: rgba(242,240,234,0.85); }
.lang-btn.active {
  color: var(--white);
  font-weight: 700;
}
.lang-sep {
  color: rgba(242,240,234,0.3);
  font-size: 0.85rem;
}

.nav-progress {
  display: block;
  width: 100%;
  height: 2px;
  background: var(--line);
  position: relative;
  overflow: hidden;
}
.nav-progress span {
  position: absolute;
  left: 0; top: 0; bottom: 0;
  width: 0%;
  background: var(--off-white);
}

/* ===== Layout ===== */
main {
  position: relative;
  z-index: 3;
}

/* ===== Hero ===== */
/* Full-bleed opener: a real, independently-looping <video> (not the
   #stage scroll-scrub canvas) filling the first 100vh from true y:0 — the
   fixed header floats on top of it, so main carries no top padding here.
   Opaque top to bottom, so nothing of the canvas/scrim/vignette behind it
   is visible for the length of this section. */
#hero {
  position: relative;
  width: 100%;
  /* Belt-and-braces against a real-device-only bug: on at least one iOS
     Safari test, this (and the video inside it) rendered a little wider
     than the actual visible screen — invisible in Chrome and in Chrome's
     own device emulation, which don't share whatever layout-viewport
     quirk was inflating the width there — silently pushing the video's
     right edge (and the tick marks near it) off past the screen edge,
     since html has overflow-x:clip rather than a scrollbar that would
     have made the extra width obvious. Capping at 100vw is a hard
     ceiling against that regardless of what's actually inflating it. */
  max-width: 100vw;
  /* Shortened from a full 100vh — the blue footage was visible for a
     long stretch before the ::after/::before darkening actually finished
     transitioning to the black section below. A shorter hero means that
     transition (and the black section it leads into) arrives sooner,
     without needing to touch the darkening curves themselves. */
  /* Raised from a flat 75vh to give the (now 5-item) OSD list more room
     above the fade before its last line starts running into it. Capped
     in vw as well as vh for the same reason as the mobile height
     formula: past a certain height-to-width ratio, object-fit:cover
     flips from cropping top/bottom (safe — the video's full width,
     including the tick marks' right edge, stays visible) to cropping
     the sides instead (unsafe). 76vw sits just under that flip point,
     so on any ordinarily wide browser window 96vh is what applies, and
     only on an unusually narrow/square one does the vw cap take over to
     keep the ticks from being cropped sideways. */
  height: min(96vh, 76vw);
  overflow: hidden;
  /* Full opacity until well after the hero's own footage has already
     gone dark — this mask's reveal has to stay behind #hero::after's own
     darkening curve (0.4 flat until 78%, not properly black until past
     90%), not run ahead of it. Starting the alpha fade too early (it used
     to start at 65%, while the tint was still only 0.4) let the fixed
     canvas behind the page peek through while the hero itself still read
     as visibly BLUE — the "lantern" panel showing up smeared across
     still-blue footage instead of emerging from black. Now it only starts
     easing once the tint is already deep (90%+), and only reaches fully
     transparent at the very end, once the hero's own content is already
     solid black. */
  -webkit-mask-image: linear-gradient(
    to bottom,
    black 0%,
    black 90%,
    rgba(0, 0, 0, 0.7) 94%,
    rgba(0, 0, 0, 0.35) 97%,
    transparent 100%
  );
  mask-image: linear-gradient(
    to bottom,
    black 0%,
    black 90%,
    rgba(0, 0, 0, 0.7) 94%,
    rgba(0, 0, 0, 0.35) 97%,
    transparent 100%
  );
}
#hero-video {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* #hero is only ever 75vh tall against a full 100vw width, so for any
     realistic window shape (this box's own aspect ratio comfortably
     exceeds the source's 1920x1080 ≈ 1.78) object-fit:cover is
     width-bound: cover scales to match the box's width exactly, so the
     full source width always survives and cropping only ever happens
     vertically, top and/or bottom. That makes the horizontal component
     of object-position a no-op here regardless of value — every earlier
     attempt to nudge the tick marks (burnt in around x:1077-1715,
     y:833-948, i.e. the source's lower-right area) into view by biasing
     the crop left/right changed nothing, because there was never any
     horizontal crop to bias. The vertical component is what actually
     matters: "top" anchored the crop to the source's TOP edge, which
     throws away the bottom of the frame first as the window gets
     taller/narrower relative to its width — exactly where the ticks
     are. Anchoring to the bottom instead keeps them the last thing to
     go, all the way down to fairly extreme window shapes. */
  object-position: center bottom;
  /* Slight brightness/contrast lift so the footage (including the tick
     marks burnt into it) still reads on a dim screen, not just a
     well-calibrated one. */
  filter: brightness(1.12) contrast(1.05);
  /* Desktop only (reset back to none in the mobile media query below):
     object-position's horizontal component is a no-op here (see above),
     since cover already shows the source's full width with nothing left
     to bias. A scale up is what actually crops some of it away instead —
     but anchored at the LEFT edge, not the right: scaling around a point
     stretches everything AWAY from that point, so anchoring at the right
     (tried first) pushed the ticks — sitting to the left of that corner —
     even further left instead of closer to it. Anchoring at the left
     edge instead keeps that edge fixed and stretches the right portion
     outward past the visible frame, cropping away the empty trailing
     margin beyond the ticks and pulling them toward the now-closer
     right edge. */
  transform: scale(1.1);
  transform-origin: 0% 100%;
  z-index: 1;
}
#hero::after {
  content: "";
  position: absolute;
  inset: 0;
  /* Flat 0.5 tint through most of the frame — untouched, no softening at
     the top — easing down into a deep near-black only over the bottom
     sliver, as a solid backstop under the ::before scanline texture so
     that layer's animated jitter can never uncover a sliver of still-blue
     video at the exact crop edge. Several stops rather than one sharp
     ramp so that darkening itself reads as a soft gradient, not a kink.
     Bumped up from a flat 0.4: on desktop widths narrow/square enough to
     make object-fit:cover crop the video's sides instead of its top/
     bottom, the tick marks sit at their fixed natural position in the
     footage (roughly 77-88% down) regardless of #hero's own height, and
     0.4 alone read as too bright there. */
  background: linear-gradient(
    to bottom,
    rgba(0, 0, 0, 0.5) 0%,
    rgba(0, 0, 0, 0.5) 78%,
    rgba(0, 0, 0, 0.65) 85%,
    rgba(0, 0, 0, 0.8) 90%,
    rgba(0, 0, 0, 0.92) 95%,
    rgba(0, 0, 0, 0.98) 100%
  );
  z-index: 2;
}

/* Hero-to-section handoff as a signal dropout, not a web gradient: a fine
   scanline raster (hard-edged repeating stripes, not a blur) sits over a
   handful of unevenly-sized, hard-stopped darkening bands — like a MiniDV
   deck losing tracking and tearing to black at a tape edit, rather than a
   smooth alpha fade. A slow, stepped background-position jitter keeps it
   rolling instead of static. The whole band is then masked from transparent
   (its own top) to fully opaque partway down, so the raster doesn't read as
   a rectangle dropped onto the video — it reads as the signal itself
   gradually losing lock. Kept short (only the bottom sliver of the frame)
   and masked to near-nothing over its own top third, so it stays clear of
   both the OSD bio readout (.osd-bio, anchored bottom:42%) and the
   footage's own on-screen tick marks lower in frame. */
#hero::before {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 14vh;
  min-height: 110px;
  z-index: 4;
  pointer-events: none;
  -webkit-mask-image: linear-gradient(
    to bottom,
    transparent 0%,
    rgba(0, 0, 0, 0.3) 28%,
    rgba(0, 0, 0, 0.75) 50%,
    black 66%
  );
  mask-image: linear-gradient(
    to bottom,
    transparent 0%,
    rgba(0, 0, 0, 0.3) 28%,
    rgba(0, 0, 0, 0.75) 50%,
    black 66%
  );
  background:
    repeating-linear-gradient(
      to bottom,
      rgba(0, 0, 0, 0.85) 0px,
      rgba(0, 0, 0, 0.85) 1px,
      rgba(0, 0, 0, 0) 1px,
      rgba(0, 0, 0, 0) 3px
    ),
    linear-gradient(
      to bottom,
      rgba(0, 0, 0, 0)    0%,  rgba(0, 0, 0, 0)    4%,
      rgba(0, 0, 0, 0.18) 4%,  rgba(0, 0, 0, 0.18) 15%,
      rgba(0, 0, 0, 0.1)  15%, rgba(0, 0, 0, 0.1)  23%,
      rgba(0, 0, 0, 0.4)  23%, rgba(0, 0, 0, 0.4)  33%,
      rgba(0, 0, 0, 0.28) 33%, rgba(0, 0, 0, 0.28) 41%,
      rgba(0, 0, 0, 0.62) 41%, rgba(0, 0, 0, 0.62) 53%,
      rgba(0, 0, 0, 0.5)  53%, rgba(0, 0, 0, 0.5)  61%,
      rgba(0, 0, 0, 0.82) 61%, rgba(0, 0, 0, 0.82) 75%,
      rgba(0, 0, 0, 0.95) 75%, rgba(0, 0, 0, 0.95) 89%,
      rgba(0, 0, 0, 1)    89%, rgba(0, 0, 0, 1)    100%
    );
  animation: hero-tear 2.4s steps(6) infinite;
}
@keyframes hero-tear {
  0%   { background-position: 0 0, 0 0; }
  20%  { background-position: 0 1px, 0 0; }
  45%  { background-position: 0 0, 0 -1px; }
  60%  { background-position: 0 2px, 0 0; }
  80%  { background-position: 0 0, 0 1px; }
  100% { background-position: 0 1px, 0 0; }
}

/* MiniDV camcorder OSD readout, bottom-left of the hero. Both languages
   live in the DOM at once; js/i18n.js toggles which .osd-lang carries
   .active (see updateOsdBio()) in step with the site's EN|FR switch. */
.osd-bio {
  position: absolute;
  /* Anchored from the top (header height + breathing room — the same
     clearance pattern used for .archive-preview below) rather than from
     the bottom, so the first line never crowds the header's dividing
     line. Content still comfortably clears the #hero::before raster band
     (14vh) at the bottom of the hero. */
  /* Trimmed from +3rem: with 5 items now in this list (the "Artist
     residencies" line was added later, after this offset was set), the
     block's total height had grown enough to run past #hero's own
     bottom edge, clipping the last line via #hero's overflow:hidden
     entirely. This buys back some of that room without touching the
     font-size (tuned separately to visually match the video's own tick
     marks) or the video/crop rules. */
  /* Eased back up from 0.75rem — raising #hero's own height (above) to
     fit all 5 items left a bit of slack between the text and the tick
     marks again, so some of it goes here instead as breathing room
     under the header rather than sitting unused right above the ticks. */
  top: calc(var(--header-h) + 1.5rem);
  left: 5%;
  z-index: 3;
  /* Wider than the English copy strictly needs, because the French
     translations of these same lines run noticeably longer — at 800px
     they wrapped onto enough extra lines that the "My Imprisonment" item
     grew tall enough to reach the tick marks burnt into the video below
     (their position is fixed footage, not something text length can
     push around). The extra width only ever shortens line-wrap on wide
     desktop windows; .osd-bio's own left:5% and the mobile override's
     max-width:none keep this from mattering on any narrower layout. */
  max-width: 1050px;
  /* Promotes this block to its own GPU-composited layer, so it's
     rasterized once and then just translated by the compositor while the
     page scrolls, instead of being re-rasterized at every intermediate
     subpixel scroll position. VT323 is a small pixel-styled font, so
     without this its hinting/antialiasing visibly reshuffles from frame
     to frame during a scroll — which reads as the letters "smearing" —
     even though nothing about the text itself is animating. */
  will-change: transform;
  /* Hardcoded rather than var(--mono) — this readout keeps the cassette
     monospace look even after --mono itself was repointed to Cabinet
     Grotesk for the rest of the site (see :root). */
  font-family: 'VT323', 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  font-size: 35px;
  /* Matched to how the OSD tick marks actually read on screen — their
     fill pixels alone are near-white, but each tick is a small white
     rectangle inside a thick black border, so the AVERAGE brightness a
     viewer perceives is much closer to a mid-gray than to white. Sampling
     just the fill color (an earlier attempt landed on ~rgb 250) made the
     text look noticeably brighter than the ticks side by side. Nudged up
     slightly from that mid-gray (172) so it still reads on a dim screen.
     Nudged back down since — #hero::after's dimming overlay sits below
     this text in stacking order (z-index 2 vs. 3 here), so when that
     overlay's own tint was later deepened to keep the ticks from reading
     too bright, it darkened the video but never touched the text; this
     brings the text back down to match by itself instead of piling on
     another overlay just for it. */
  color: rgba(160, 160, 160, 0.95);
  /* A light, crisp outline instead of a shadow — the same idea as the
     dark edge around the OSD tick marks burnt into the footage itself
     (bottom-right of the hero), a thin flat stroke rather than any blur.
     text-shadow with a 0px blur radius draws as a hard-edged offset, not
     a soft halo, and -webkit-text-stroke reinforces it on the two sides
     a single offset shadow can't reach. Overrides the global body{} soft
     glow (see its comment above) — that multi-layer blur repaints per
     scroll frame on a scrolling, absolutely-positioned element and
     visibly smears/trails behind the letters while the page moves. */
  text-shadow:
    1px 0 0 rgba(0, 0, 0, 0.55),
    -1px 0 0 rgba(0, 0, 0, 0.55),
    0 1px 0 rgba(0, 0, 0, 0.55),
    0 -1px 0 rgba(0, 0, 0, 0.55);
  -webkit-text-stroke: 0.5px rgba(0, 0, 0, 0.45);
}
/* Links out of the OSD readout (project titles jump down to their
   Films entry, venue names go straight to the venue's own site) keep
   the surrounding text's own color/shadow/stroke instead of a browser
   default blue — a plain underline instead of the archive-link
   pattern's border-bottom, since this text is small enough that a
   detached bottom border would drift from the letters' own baseline. */
.osd-bio a {
  color: inherit;
  text-decoration: none;
  /* A thin, dimmed rule instead of an arrow glyph or a plain bright
     underline — box-shadow rather than border-bottom so a second, offset
     dark shadow layer can sit right under the visible line and give it
     the same soft black edge .osd-bio's own text gets from its
     text-shadow/-webkit-text-stroke (a technique that only ever applies
     to glyphs, never to borders or decoration lines, which is why it had
     to be redone here rather than just inherited). */
  box-shadow:
    0 1px 0 0 rgba(160, 160, 160, 0.75),
    0 1.6px 0 0 rgba(0, 0, 0, 0.55);
  padding-bottom: 1px;
  transition: opacity 0.2s ease;
}
.osd-bio a:hover { opacity: 0.75; }
.osd-lang { display: none; }
.osd-lang.active {
  display: block;
  /* Continuous analog jitter + flicker, matched in amplitude to the OSD
     tick marks burnt into the footage itself (bottom-right of the hero) —
     a MiniDV deck's signal never quite holds still. Safe to translate
     again (position jitter used to read as the letters' outline
     smearing/trailing) now that .osd-bio's own will-change:transform
     keeps this block on its own composited layer, rasterized once rather
     than redrawn at each nudge. */
  animation: vcr-jitter 0.4s infinite;
}
.osd-lang ul {
  list-style: none;
  padding: 0;
  margin: 0;
}
.osd-lang li {
  /* Tightened from 12px/1.4 for the same reason as .osd-bio's top
     offset above — 5 items' worth of lines needed to shrink enough to
     stop the last one from being clipped by #hero's overflow:hidden. */
  margin-bottom: 5px;
  line-height: 1.2;
}
.osd-lang em { font-style: italic; }

@keyframes vcr-jitter {
  0%   { transform: translate(0, 0);          opacity: 0.9; }
  10%  { transform: translate(-0.6px, 0.2px);  opacity: 0.8; }
  20%  { transform: translate(0.6px, 0.6px);   opacity: 0.9; }
  30%  { transform: translate(-0.2px, -0.6px); opacity: 0.85; }
  45%  { transform: translate(0.4px, -0.2px);  opacity: 0.9; }
  60%  { transform: translate(-0.4px, 0.4px);  opacity: 0.85; }
  100% { transform: translate(0, 0);          opacity: 0.9; }
}

.section {
  position: relative;
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  padding: 5rem 1.75rem;
  /* Nav-anchor jumps (Home/About/Films/.../Contact in the header) land on
     these sections directly by id. Without this, the section's own top
     edge lands flush with the viewport top and the fixed header covers
     it — on mobile #bio in particular, its own padding-top is nowhere
     near tall enough to clear the header on its own, so the video (now
     the first thing in that section, see .bio-photo's order:-1) opened
     partly hidden underneath it. This clears the header first, then the
     section's own padding is what gives the small gap before its content
     starts, matching the same corner-clearing this already does for
     .section-head/.archive-row's own scroll-margin-top. */
  scroll-margin-top: var(--header-h);
}
/* Grid-heavy sections (Films, Arts, CV) can grow taller than 100vh once
   populated; top-align them instead of the base .section's vertical
   centering, which would otherwise overflow equally above and below the
   viewport and misalign scroll-to-anchor navigation. */
.section--wide { align-items: center; justify-content: flex-start; padding-top: 7rem; }

.panel-wide { max-width: 720px; margin: 0 auto; width: 100%; }

/* Bio layout shares the Films/Arts archive's 1200px container (same
   max-width, same 3rem gap) purely so its left edge lines up on the same
   vertical axis as the project lists below it. Equal-width columns: text
   left, photo right. */
.bio-layout {
  max-width: 1200px;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 3rem;
  align-items: start;
}
.bio-text { text-align: justify; }

/* Photo column: sticky within the bounds of its own grid row (the same
   technique as .archive-preview), so the video travels down alongside
   the bio text as the visitor reads, but can never drift past the bottom
   of the About section into the Films list that follows.
   margin-top lines its own starting position up with the rule under
   "01 — About" (the .tag's own height + spacing, ~28px/1.75rem) instead
   of the row's very top edge (which is level with "01 — About" itself).
   margin-bottom claws back the trailing 1rem margin that .bio-section
   .body-text puts under the last paragraph ("Based in Paris.") — without
   it, the sticky range runs 1rem past that line to the row's true bottom,
   so the photo would stop sliding slightly below "Based in Paris."
   instead of flush with it. */
.bio-photo {
  position: sticky;
  top: 15vh;
  margin-top: 1.75rem;
  margin-bottom: 1rem;
  align-self: start;
  display: flex;
  justify-content: center;
}
.bio-photo video {
  display: block;
  width: 100%;
  /* Keeps it a modest accent within the right column rather than a
     full-width block — the column itself is already the right half of
     the About layout, this just stops the video from filling all of it. */
  max-width: 500px;
  height: auto;
  /* Kept all but opaque on every edge now, left/right included — the
     fixed #stage canvas sits behind everything on the page, and its own
     scroll-driven "lantern" panel has a plain hard-edged rectangle (see
     drawContain() in js/main.js, nothing softens its own border there).
     A wider transparent band on any edge of this video let that sharp
     rectangle show straight through whenever the lantern happened to be
     scrolled behind it — two different edge treatments meeting read as a
     rough, mismatched patch of light rather than a clean vignette. A 4%
     feather on all four sides is just enough to avoid a hard cut of its
     own without leaving much of a gap for anything behind it to show
     through. */
  -webkit-mask-image:
    linear-gradient(to right, transparent 0%, black 4%, black 96%, transparent 100%),
    linear-gradient(to bottom, transparent 0%, black 4%, black 96%, transparent 100%);
  -webkit-mask-composite: source-in;
  mask-image:
    linear-gradient(to right, transparent 0%, black 4%, black 96%, transparent 100%),
    linear-gradient(to bottom, transparent 0%, black 4%, black 96%, transparent 100%);
  mask-composite: intersect;
}

.tag {
  font-family: var(--mono);
  font-size: 0.85rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: rgba(242,240,234,0.6);
  margin-bottom: 1.5rem;
  padding-bottom: 0.75rem;
  border-bottom: 1px solid var(--line);
}

.lede {
  font-family: var(--display);
  /* Distinguished from the body copy below it by size alone, not weight —
     Medium at a modestly larger size (~10-15% over .body-text's 0.95rem)
     reads as an intro/lede without tipping into a bold, heading-scale
     block of text. Same off-white as .body-text (e.g. "Sense of isolation
     and memory:" below it) so the two read as one consistent voice. */
  font-weight: 500;
  font-size: clamp(1.05rem, 1.3vw, 1.1rem);
  line-height: 1.6;
  letter-spacing: 0;
  color: rgba(242, 240, 234, 0.82);
  text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.9);
  margin-bottom: 1.5rem;
  /* Matches .body-text's rendered width (60ch there, ~522px) in a fixed
     unit rather than `ch` — `ch` is measured at the element's OWN
     font-size, and .lede's is larger than .body-text's, so the same `ch`
     count would still wrap wider and leave the two paragraphs' right
     edges and line breaks out of step. rem stays constant regardless of
     the local font-size, so both now wrap at the same point. */
  max-width: 32.6rem;
}

.body-text {
  font-family: var(--mono);
  font-weight: 400;
  /* Raised from 0.95rem for readability — this is the actual bio prose
     someone is meant to read, not a caption/label. */
  font-size: 1.05rem;
  line-height: 1.6;
  letter-spacing: 0.005em;
  color: rgba(242,240,234,0.82);
  max-width: 60ch;
}

.bio-label {
  font-size: 0.8rem;
  letter-spacing: 0.14em;
  text-transform: uppercase;
  color: rgba(242,240,234,0.5);
  margin-bottom: 0.5rem;
}

.bio-section { margin-top: 2rem; }
.bio-section .body-text { margin-bottom: 1rem; }

/* ===== Section headers ===== */
.section-head {
  max-width: 960px;
  margin: 0 auto 3rem;
  width: 100%;
  /* So a link that scrolls straight to this heading (the hero OSD's
     project links, see js/archive.js) doesn't land it flush behind the
     fixed header — same clearance pattern as .archive-row's own
     scroll-margin-top. */
  scroll-margin-top: calc(var(--header-h) + 1rem);
}
.section-note {
  font-family: var(--mono);
  font-weight: 500;
  font-size: 0.92rem;
  /* Matches .tag's dimmed off-white (the "02 — Selected Cinema Projects"
     label right above this line) so the two read as the same family of
     caption text, rather than this one standing out brighter. */
  color: rgba(242, 240, 234, 0.6);
  text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.9);
  max-width: 60ch;
}

/* ===== Films & Arts — Laboratory Archive: list + fixed 16:9 preview =====
   Two-panel layout: `.archive-list` (titles/years, left) and one shared
   `.archive-preview` (right). Every project has a same-sized image stacked
   in `.archive-frame`; js/archive.js only ever toggles which one carries
   `.is-active` — the cross-fade itself is a plain CSS opacity transition,
   so hovering a row swaps the frame instantly with no layout shift.
   Clicking a row expands `.archive-detail` via the same aria-expanded +
   0fr→1fr grid-rows trick used elsewhere on the site. */
.archive {
  max-width: 1200px;
  width: 100%;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 3rem;
  align-items: start;
}

.archive-list { list-style: none; }
.archive-item { border-bottom: 1px solid var(--line); }
.archive-row {
  display: flex;
  align-items: baseline;
  gap: 1rem;
  padding: 1.1rem 0;
  cursor: pointer;
  /* The hero's OSD readout links straight to a project's row here
     (#row-poti etc.) rather than just the section, so the fixed header
     needs its own clearance the same way .section's padding-top already
     gives the section as a whole — otherwise the row lands scrolled to
     flush under the header, half hidden behind it. */
  scroll-margin-top: calc(var(--header-h) + 1rem);
}
.archive-row:focus-visible { outline: 1px solid var(--line-strong); outline-offset: 4px; }
.archive-title {
  font-family: var(--mono);
  font-weight: 300;
  font-size: 1rem;
  letter-spacing: 0.01em;
  color: rgba(242,240,234,0.7);
  transition: color 0.25s ease;
}
.archive-year {
  font-family: var(--mono);
  font-weight: 300;
  font-size: 0.8rem;
  letter-spacing: 0.03em;
  color: rgba(242,240,234,0.4);
  margin-left: auto;
  white-space: nowrap;
}
.archive-toggle {
  font-family: var(--mono);
  font-weight: 300;
  font-size: 0.7rem;
  color: rgba(242,240,234,0.35);
  transition: color 0.25s ease;
  flex-shrink: 0;
}
.archive-row:hover .archive-title,
.archive-row[aria-expanded="true"] .archive-title,
.archive-row.is-active .archive-title { color: var(--white); }
.archive-row:hover .archive-toggle,
.archive-row[aria-expanded="true"] .archive-toggle,
.archive-row.is-active .archive-toggle { color: var(--off-white); }

.archive-detail {
  display: grid;
  grid-template-rows: 0fr;
  transition: grid-template-rows 0.45s cubic-bezier(0.16, 1, 0.3, 1);
}
.archive-row[aria-expanded="true"] + .archive-detail { grid-template-rows: 1fr; }
.archive-detail-inner { overflow: hidden; }
.archive-role {
  font-family: var(--mono);
  font-weight: 300;
  font-size: 0.78rem;
  letter-spacing: 0.05em;
  color: rgba(242,240,234,0.45);
  padding-top: 1rem;
}
.archive-desc {
  font-family: var(--mono);
  font-weight: 400;
  /* Raised from 0.95rem — the actual film/project synopsis text, read in
     full once a row is expanded, not a caption. */
  font-size: 1.05rem;
  line-height: 1.6;
  letter-spacing: 0.005em;
  color: rgba(242,240,234,0.75);
  padding-top: 0.5rem;
  padding-bottom: 1.5rem;
  max-width: 56ch;
}
.archive-link {
  display: inline-block;
  margin-bottom: 1.5rem;
  font-family: var(--mono);
  font-weight: 300;
  font-size: 0.8rem;
  letter-spacing: 0.03em;
  color: var(--off-white);
  text-decoration: none;
  border-bottom: 1px solid var(--line-strong);
  padding-bottom: 0.1rem;
  transition: opacity 0.2s ease;
}
.archive-link:hover { opacity: 0.7; }
.archive-links { display: flex; flex-wrap: wrap; gap: 1.25rem; margin-bottom: 1.5rem; }
.archive-links .archive-link { margin-bottom: 0; }

.archive-preview {
  position: sticky;
  top: calc(var(--header-h) + 2rem);
  /* Redundant with the grid's own `align-items: start`, but explicit here
     since a stretched sticky item (the grid default) can't scroll within
     its track at all — it would already fill the full row height. */
  align-self: start;
}
.archive-frame {
  position: relative;
  width: 100%;
  aspect-ratio: 16 / 9;
  overflow: hidden;
  border: 1px solid var(--line);
  background: #000000;
}
/* One .archive-frame-set per project, cross-fading between projects; each
   set holds that project's own 1-4 photos, cross-fading between those. */
.archive-frame-set {
  position: absolute;
  inset: 0;
  opacity: 0;
  transition: opacity 0.5s ease;
}
.archive-frame-set.is-active { opacity: 1; }
.archive-frame-set img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  /* Nibbles away any residual letterboxing baked into the source frames
     without over-cropping — a 16:9 pane needs far less correction than a
     square one did, since the footage itself was shot roughly 16:9. */
  transform: scale(1.06);
  filter: grayscale(0.1) contrast(1.05);
  opacity: 0;
  transition: opacity 0.4s ease;
}
.archive-frame-set img.is-active { opacity: 1; }
/* This one frame (the "26.02.2022" project) is measurably the brightest
   of every project photo — a blown-out, near-white snow scene — so it
   gets its own darkening on top of the shared filter above rather than
   dimming every photo in the archive to fix just this one. */
.archive-frame-set img[src*="date-1"] {
  filter: grayscale(0.1) contrast(1.05) brightness(0.8);
}

/* Photo-cycling controls, sitting below the frame rather than overlaid on
   it — keeps them legible without any backing panel or blur. */
.archive-frame-nav {
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 1rem;
  margin-top: 0.9rem;
  font-family: var(--mono);
  font-weight: 300;
  font-size: 0.75rem;
  letter-spacing: 0.04em;
}
.archive-frame-nav.is-hidden { visibility: hidden; }
.archive-nav-btn {
  background: none;
  border: 1px solid var(--line);
  color: rgba(242,240,234,0.6);
  font-family: var(--mono);
  font-size: 0.85rem;
  line-height: 1;
  padding: 0.3rem 0.7rem;
  cursor: pointer;
  transition: color 0.2s ease, border-color 0.2s ease;
}
.archive-nav-btn:hover { color: var(--white); border-color: var(--line-strong); }
.archive-nav-count { color: rgba(242,240,234,0.4); min-width: 3.5em; text-align: center; }

/* ===== Footer — centred contact block =====
   Kept snug against the border-top divider (small top padding) rather than
   matching the 7rem section gap, so the compact Contact block reads as
   sitting right on the dividing line instead of floating in its own
   section-sized slot; the taller bottom padding still leaves room for the
   watermark to sit two line-heights below the contact row. */
.site-footer {
  position: relative;
  padding: 1.75rem 1.75rem 6rem;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  gap: 0.7rem;
  border-top: 1px solid var(--line);
}
.footer-tag {
  font-family: var(--mono);
  font-size: 0.85rem;
  letter-spacing: 0.18em;
  text-transform: uppercase;
  color: rgba(242,240,234,0.6);
}
.footer-line {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: center;
  gap: 0.9rem;
  font-family: var(--mono);
  font-weight: 300;
  font-size: 0.8rem;
  letter-spacing: 0.03em;
  text-align: center;
}
.footer-link {
  display: inline-flex;
  align-items: center;
  gap: 0.45em;
  color: rgba(242,240,234,0.75);
  text-decoration: none;
  transition: opacity 0.2s ease;
}
.footer-link span {
  border-bottom: 1px solid var(--line-strong);
  padding-bottom: 0.05rem;
}
.footer-link:hover { opacity: 0.7; }
.footer-icon {
  width: 1em;
  height: 1em;
  flex: none;
}
.footer-sep { color: rgba(242,240,234,0.3); }

/* Two line-heights below the contact row, pinned to the page's own
   bottom-right corner via flex self-alignment (avoids the layout risk of
   position:absolute against a variable-height footer). */
.footer-watermark {
  align-self: flex-end;
  /* Belt-and-braces alongside align-self: it wasn't actually landing in
     the corner (still centered, same as .site-footer's other children)
     — margin-left:auto is the more foolproof way to push a flex item to
     the end of the cross axis, regardless of whatever was overriding
     align-self. Line-height tightened and the two-line split shortens
     this block enough to sit as its own neat corner note beside the
     contact row rather than a single long trailing line under it. */
  margin-left: auto;
  width: fit-content;
  margin-top: calc(2 * 1.85 * 0.8rem);
  text-align: right;
  line-height: 1.5;
  font-family: var(--mono);
  font-weight: 300;
  font-size: 0.8rem;
  letter-spacing: 0.03em;
  color: rgba(242,240,234,0.6);
}

/* ===== Reveal-on-scroll ===== */
.reveal {
  opacity: 0;
  transform: translateY(24px);
  transition: opacity 0.9s cubic-bezier(0.16, 1, 0.3, 1), transform 0.9s cubic-bezier(0.16, 1, 0.3, 1);
}
.reveal.is-visible {
  opacity: 1;
  transform: translateY(0);
}

/* `.archive` and `.bio-layout` each hold a sticky pane (.archive-preview /
   .bio-photo): ANY non-'none' computed transform on an ancestor — even a
   no-op translateY(0) — makes browsers treat that ancestor as the sticky
   element's positioning context instead of the real scrollport, which
   permanently breaks the sticky pinning. Once the reveal fade finishes,
   drop the transform entirely; both blocks still fade in via opacity,
   they just skip the slide. */
.archive.reveal,
.archive.reveal.is-visible,
.bio-layout.reveal,
.bio-layout.reveal.is-visible {
  transform: none;
}

/* ===== Responsive ===== */
@media (max-width: 860px) {
  .site-menu { gap: 1rem; font-size: 0.88rem; }
}

@media (max-width: 720px) {
  .header-row { flex-wrap: wrap; gap: 0.75rem; padding: 0.85rem 1.1rem; }
  .site-menu { order: 3; width: 100%; justify-content: space-between; gap: 0.5rem; }
  /* The desktop "/" separator is positioned assuming its 1.6rem gap —
     mobile's own gap is only 0.5rem to fit every label across one row,
     nowhere near enough room for it, so it was overlapping the previous
     label's last letter instead of sitting in the (much smaller) space
     between them. space-between already reads the labels as separate
     items here without a divider, so it's simplest to just drop it
     rather than re-tune its offset for a gap this tight. */
  .site-menu a:not(:first-child)::before { display: none; }
  :root { --header-h: 6.5rem; }
  .section { padding: 3.5rem 1.1rem; }
  /* A touch larger again than the desktop bump to these same two rules —
     read on a phone at arm's length rather than a monitor a bit further
     away. */
  .body-text, .archive-desc { font-size: 1.1rem; }
  /* Inline-per-accordion pattern: instead of one shared preview pinned
     above the list (tried first — see js/archive.js history), the same
     preview node gets physically moved (by js/archive.js's placeInline())
     into whichever row's own synopsis was just expanded, so the photo
     sits right there with the text being read rather than in a separate
     pane. It stays a normal grid item — and normally hidden — in its
     default slot; .is-inline is what JS adds once it's been relocated. */
  .archive { grid-template-columns: 1fr; gap: 1.5rem; }
  .archive-preview { display: none; }
  .archive-preview.is-inline {
    display: block;
    position: static;
    margin-bottom: 1.25rem;
  }
  .bio-layout { grid-template-columns: 1fr; gap: 1.5rem; }
  /* Desktop's DOM order (text, then photo) is what makes the photo a
     right-hand column there — on the single-column mobile layout it just
     means the video renders below the bio text instead. order:-1 flips
     it back above without touching the desktop grid at all. Equal
     margin-top/bottom centers it in the gap between #bio's own top
     padding (where the section's black background starts) and the bio
     text, rather than sitting closer to one than the other. */
  .bio-photo {
    position: static;
    order: -1;
    margin-top: 0;
    margin-bottom: 1.25rem;
  }
  /* #bio is a .section--wide, whose own padding-top (7rem, for the
     desktop top-aligned layout) gets overridden back down to the
     generic mobile .section padding (3.5rem) above — still much bigger
     than .bio-photo's own 1.25rem margin-bottom, so the video sat much
     closer to the bio text than to the black above it. Overriding
     #bio's padding-top specifically (by id, so it wins regardless of
     source order) down to match that same 1.25rem makes the two gaps
     equal — .bio-photo's own margin-top is zeroed out above so this is
     the only thing setting the top gap now, rather than the two adding
     together. */
  #bio { padding-top: 1.25rem; }
  /* .osd-bio was never given a mobile size — at the desktop 28px/800px-max
     it would each line-wrap into a tall, cramped stack on a narrow phone
     screen (VT323 is wide per-character even at small sizes), potentially
     tall enough to run into #hero::before's raster band at the bottom.
     Smaller type and an explicit right edge (not just a max-width, which
     doesn't stop it from crowding the right side) keep it comfortably
     inside the viewport with room to spare. */
  /* The flip/height/object-position experiment that used to live here
     (scaleY(-1) plus a shortened box, to move the tick marks to the top
     of the frame) is reverted: it left the video not rendering at all on
     every phone it was tested on, worse than the plain crop it was meant
     to fix. Mobile now loads its own pre-cropped portrait export
     (blue_mob.mp4, 1080x1920, see index.html's <source media>) with the
     tick marks already burnt in near the bottom of the frame. No
     mobile-specific #hero-video override is needed any more — the base
     rule's own object-position: center bottom already keeps whichever
     source is loaded anchored to its bottom edge, which is exactly what
     both videos' tick marks need. */
  /* The base #hero height (75vh) was tuned for the desktop 16:9 footage
     and is too short for the portrait mobile video: a shorter box means
     more of the source's bottom half gets cropped away before the tick
     marks (which stay pinned to the box's bottom via object-position),
     so they end up sitting higher up the box and colliding with the OSD
     text above them — worse on a squarer phone (e.g. SE-class, narrower
     width relative to height) than an elongated one (e.g. 12 Pro-class),
     since the narrower width scales the source down less, leaving less
     of it cropped and the ticks riding correspondingly higher. Taller
     fixes both at once: it pushes the ticks further from the text, and
     (SE's actual complaint) keeps the blue footage on screen longer
     before the fade to black. It's capped in vw rather than given a
     flat vh, because past a certain height-to-width ratio object-fit's
     crop flips from trimming the top/bottom (safe — full width, including
     the ticks' right edge, stays visible) to trimming the sides instead
     (unsafe — it would start eating into the ticks horizontally); 172vw
     is just under that flip point (177.8vw, the source's own 16:9-ish
     inverse ratio) on any phone, so this reaches as tall as each screen
     allows without ever crossing it. The 92vh cap only matters on
     phones squat enough that 172vw would otherwise exceed it. */
  #hero { height: min(100vh, 177vw); }
  /* The desktop-only corner-anchored zoom (see #hero-video's base rule)
     isn't needed here — mobile's own portrait source already frames the
     ticks directly — and would additionally risk cropping into their
     right edge on top of the already-tight mobile crop math. */
  #hero-video { transform: none; }
  /* The taller mobile #hero (above) pushes the tick marks up into the
     flat 0.4 portion of ::after's gradient (they used to sit right at
     the 75vh box's own 78-88% mark, where the gradient had already
     started deepening past 0.4) — so the extra darkening that used to
     land on them is gone and they read as too bright/raw white against
     it now. Bumping the flat tint itself for mobile (rather than
     re-tuning the gradient's stop percentages to chase the ticks' new
     position) keeps the fix independent of exactly where they land. */
  #hero::after {
    background: linear-gradient(
      to bottom,
      rgba(0, 0, 0, 0.55) 0%,
      rgba(0, 0, 0, 0.55) 78%,
      rgba(0, 0, 0, 0.65) 85%,
      rgba(0, 0, 0, 0.8) 90%,
      rgba(0, 0, 0, 0.92) 95%,
      rgba(0, 0, 0, 0.98) 100%
    );
  }
  /* A percentage rather than a fixed header-h + gap: the taller mobile
     #hero (above) leaves a lot of empty blue between the header and the
     tick marks if the text stays pinned right under the header, so this
     is pulled down to sit roughly in that middle stretch instead — a
     fraction of #hero's own (now variable, min(100vh, 177vw)) height
     scales with it automatically rather than needing its own per-device
     tuning. Trimmed down from 24% — on a squarer phone (SE-class), 100vh
     and 177vw both land close to their own safe ceiling already (see
     #hero's own comment), so there's no more height left to buy back;
     the only remaining lever to keep the text clear of the ticks there
     is starting it higher instead. */
  .osd-bio {
    top: 19%;
    left: 5%;
    right: 5%;
    max-width: none;
    font-size: 18px;
  }
  .osd-lang li { margin-bottom: 3px; line-height: 1.1; }
}

/* ===== Reduced motion ===== */
@media (prefers-reduced-motion: reduce) {
  .grain { animation: none; }
  .reveal { transition: opacity 0.3s ease; transform: none; }
  .archive-detail, .archive-frame-set, .archive-frame-set img { transition: none; }
  .osd-lang.active { animation: none; }
  #hero::before { animation: none; }
}
