@font-face {
    font-family: "Keykode Regular";
    src: url("/assets/fonts/JHKeykodeMono-620.woff2");
    font-weight: normal;
    font-style: normal;
    /* font-variant: small-caps; */
}
@font-face {
    font-family: "Keykode Extended";
    src: url("/assets/fonts/JHKeykodeMono-850.woff2");
    font-weight: normal;
    font-style: normal;
    text-transform: uppercase;
    /* font-variant: small-caps; */
}
@font-face {
    font-family: "Waldenburg";
    src: url("/assets/fonts/KMRWaldenburg-Medium.woff2") format("woff2");
    font-weight: normal;
    font-style: normal;
    text-transform: uppercase;
    /* font-variant: small-caps; */
}
:root {
    --font-size-small: 9px;
    --font-size-medium: 13px;
    --font-size-medium-waldenburg: 13px;
    --columncount: 30;

    /* -- Theme palette (dark = default) ------------------------------
       Follows OS via prefers-color-scheme (see header.php + home.php).
       The pic-invert button is a session-only override; html.is-light-mode
       otherwise tracks the system. */
    --c-bg: #0e0e0e;                       /* page background            */
    --c-fg: #ffffff;                     /* text / icons               */
    --c-surface: #050505;                /* dark cells / player slots  */
    --c-strip-top: #1a1a1a;              /* filmstrip gradient top     */
    --c-strip-bottom: #0d0d0d;           /* filmstrip gradient bottom  */
    --c-hover: #2C2C2C;                  /* list-row hover             */
    --c-overlay: rgba(0, 0, 0, 0.72);    /* credits overlay scrim      */
    --c-overlay-strong: rgba(0, 0, 0, 0.55);
    --c-track: rgba(255, 255, 255, 0.28);/* progress-bar track         */
}
/* Light mode: swap the palette (black ⇄ white, dark grays → light).
   Keyed on <html> (class toggled on both html + body in home.php) so the
   variables cascade to the root background as well as all descendants. */
html.is-light-mode {
    --c-bg: #FCFCFC;
    --c-fg: #0e0e0e;
    --c-surface: #f2f2f2;
    --c-strip-top: #e8e8e8;
    --c-strip-bottom: #f4f4f4;
    --c-hover: #d9d9d9;
    --c-overlay: rgba(255, 255, 255, 0.82);
    --c-overlay-strong: rgba(255, 255, 255, 0.7);
    --c-track: rgba(0, 0, 0, 0.28);
}
html {
    background: var(--c-bg);
    color: var(--c-fg);
    height: 100%;
    hyphens: auto;
    -webkit-hyphens: auto;
    -ms-hyphens: auto;
    /* Disable browser scroll anchoring on the root scroll container.
       The sticky <header> contains the .infofoldout, which grows in
       flow when opened. With anchoring ON (Chrome/Edge/Firefox
       default), the browser detects that layout shift and silently
       bumps scrollY by the same amount to keep the currently-visible
       content "anchored" — so when scrolled past the top, the header
       appears to expand OVER the content instead of pushing it down.
       Turning anchoring off lets the foldout's growth push the rest
       of the page down at any scroll position, matching the behavior
       you see at scrollY = 0 (and Safari's behavior, which has no
       scroll anchoring). */
    overflow-anchor: none;
}
* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
    font-family: "Keykode Regular";
    line-height: 1;
}
a {
    color: inherit;
    text-decoration: none;
    font-family: "Keykode Regular";
    text-transform: uppercase;
    font-size: var(--font-size-medium);
    /* font-variant: small-caps; */
}
img {
    display: block;
}
h1, h2, h3, h4, h5, h6 {
    margin: 0;
    padding: 0;
    font-weight: unset;
    font-family: "Keykode Regular";
    text-transform: uppercase;
    line-height: 1;
    font-size: var(--font-size-medium);
    /* font-variant: small-caps; */
}

p {
    margin: 0;
    padding: 0;
    font-family: "Keykode Regular";
    text-transform: uppercase;
    line-height: 1;
    font-size: var(--font-size-medium);
    hyphens: auto;
    -webkit-hyphens: auto;
    -ms-hyphens: auto;
    /* font-variant: small-caps; */
}
body {
    background: var(--c-bg);
    min-height: 100vh;
    min-height: 100dvh;
    display: flex;
    flex-direction: column;
}

/* Header */
header {
    display: grid;
    grid-template-columns: repeat(var(--columncount), 1fr);
    padding: 8px;
    padding-bottom: 20px;
    /* background: black; */
    position: sticky;
    top: 0;
    z-index: 9999;
    flex: 0 0 auto;
}
.infofoldout {
    display: grid;
    grid-template-columns: repeat(var(--columncount), 1fr);
    grid-column: 1 / -1;
    column-gap: 8px;
    /* Collapsed by default. The grid-template-rows: 0fr → 1fr trick
       gives us a smooth height transition without measuring content
       in JS — 1fr in an auto-sized row resolves to the children's
       natural height, so we get true auto-collapse animation.
       Children need min-height: 0 + overflow: hidden so they can be
       clipped below their intrinsic content size when collapsed. */
    grid-template-rows: 0fr;
    row-gap: 0;
    padding-top: 0;
    overflow: hidden;
    transition:
    grid-template-rows 0.45s cubic-bezier(0.4, 0, 0.2, 1),
    padding-top       0.45s cubic-bezier(0.4, 0, 0.2, 1),
    row-gap           0.45s cubic-bezier(0.4, 0, 0.2, 1);
    font-size: var(--font-size-medium);
    font-family: "Waldenburg";
}
.infofoldout * {
    font-family: "Waldenburg";
    font-size: var(--font-size-medium-waldenburg);
    text-transform: none;
    hyphens: auto;
    line-height: 1.2;
}
.infofoldout > * {
    min-height: 0;
    overflow: hidden;
}
body.is-info-open .infofoldout {
    grid-template-rows: 1fr;
    padding-top: 16px;
}
.infotextcontainer {
    display: flex;
    flex-direction: column;
    gap: 8px;
    grid-column: 2 / span 5;
    padding-right: 8px;
    /* padding-left: 15px; */
}
.infotextcontainer p strong {
    font-weight: unset;
    hyphens: none;
    word-wrap: nowrap;
}
.selectedclientscontainer {
    display: flex;
    flex-direction: column;
    grid-column: 7 / span 5;
}
.selectedclientscontainer p strong {
    font-weight: unset;
    hyphens: none;
    word-wrap: nowrap;

}
.contactinfocontainer {
    grid-column: 12 / span 3;
}
main {
    flex: 1 0 auto;
    display: flex;
    flex-direction: column;
    padding: 0;
}
.site-title {
    font-family: "Keykode Extended";
    font-size: var(--font-size-medium);
    grid-column: 1 / span 4;
    transition: opacity 0.25s ease;
    /* Acts as a "home" link → returns to the landing (stage) view. */
    cursor: pointer;
    /* Stacking parent for the two label spans. The label sits in flow
       (so the title takes up its natural width); the exit label is
       absolutely positioned over it and crossfades in via opacity. */
    position: relative;
    display: inline-block;
}
.site-title * {
    font-family: "Keykode Extended";
}
.site-title__label,
.site-title__exit {
    transition: opacity 0.25s ease;
}
/* Exit label overlays the regular label and starts hidden. Toggling
   body.is-stage-mode.show-exit-hint crossfades them. See
   updateStageExitHint() in home.php for the trigger logic. */
.site-title__exit {
    position: absolute;
    top: 0;
    left: 0;
    opacity: 0;
    pointer-events: none;
    /* Whitespace stripped from the surrounding flow so the regular
       label's width dictates the title's bounding box. */
    white-space: nowrap;
}
/* The site title used to crossfade into an "Exit" label while
   show-exit-hint was active. Disabled by design — the title stays
   as the site title in stage mode. .site-title__exit's base
   opacity:0 / pointer-events:none rule (above) keeps the dormant
   label invisible. show-exit-hint now only drives the cursor-
   following project-number label and the native-cursor hide/swap
   rules below. */

/* -- Cursor label: shows the active stage card's project number and
   follows the cursor while show-exit-hint is on. A DOM element
   rather than a CSS `cursor: url()` so we can use the page font
   (custom cursors only render with system fonts) and have a smooth
   crossfade. The native cursor is hidden in this state via the rule
   below so the label acts as a full replacement, not a tooltip.
   Position is driven from JS by setting `transform` on every
   mousemove — see the cursorLabelEl block in home.php. */
.cursor-label {
    position: fixed;
    top: -17px;
    left: -22px;
    z-index: 99999;
    padding: 0;
    /* Match the project-number styling inside .project-header
       exactly: Keykode Extended for the whole string, small-caps for
       every character EXCEPT the first letter. The header markup
       achieves this structurally — `<p>P</p>` sits outside
       `.project-number-inner` (which carries font-variant:
       small-caps), so the leading "P" renders normal-extended and
       the digits + dashes following it inherit small-caps. With a
       single text node here we can't split the DOM, but
       ::first-letter overriding font-variant back to normal
       reproduces the same visual rule. */
    font-family: "Keykode Extended";
    font-size: var(--font-size-medium);
    font-variant: small-caps;
    /* White + difference inverts against whatever is behind the label
       (dark video → light text, light stills → dark text). */
    color: #fff;
    mix-blend-mode: difference;
    line-height: 1;
    pointer-events: none;
    opacity: 0;
    transition: opacity 0.2s ease;
    will-change: transform;
}
.cursor-label::first-letter {
    font-variant: normal;
}
.cursor-label--stillnumber {
    font-family: "Keykode Extended";
    font-size: var(--font-size-medium);
    font-variant: small-caps;
}
.cursor-label--stillnumber::first-letter {
    font-variant: small-caps;
}
/* Arrow variant: when the cursor sits over an expanded-mode prev/next
   nav zone, the label shows a directional arrow instead of "Exit". The
   page font has no arrow glyph, so fall back to a system font and bump
   the size/weight a touch so the chevron reads clearly as a cursor. */
.cursor-label--arrow {
    font-family: system-ui, -apple-system, "Segoe UI", sans-serif;
    font-variant: normal;
    font-size: calc(var(--font-size-medium) * 1.6);
    line-height: 1;
}
/* Default expanded cursor: a small dot when no text label applies. */
.cursor-label--dot {
    top: 0;
    left: 0;
    width: 6px;
    height: 6px;
    font-size: 0;
    background: #fff;
    border-radius: 50%;
}
.cursor-label--dot::first-letter {
    font-variant: normal;
}
/* The cursor-label is reused across two contexts and the show-exit-hint
   class drives visibility in BOTH:
     - body.is-stage-mode.show-exit-hint     → label text = project number
     - body.has-expanded-player.show-exit-hint → label text = "BACK" / "PLAY" / "PAUSE"
   The two body modes are mutually exclusive (entering one removes the
   other) so a single .show-exit-hint hook is enough. The JS that sets
   the label's textContent lives in home.php's updateExpandedExitHint()
   and updateCursorLabelForStageCard(). */
body.is-stage-mode.show-exit-hint .cursor-label,
body.has-expanded-player.show-exit-hint .cursor-label {
    opacity: 1;
}
/* Hide the native cursor while the Exit label is active so the user
   sees a single visual indicator instead of pointer + label. !important
   needed to win over per-element `cursor: pointer` / `cursor: zoom-out`
   declarations cascading inside stage / expanded mode. */
body.is-stage-mode.show-exit-hint,
body.is-stage-mode.show-exit-hint *,
body.has-expanded-player.show-exit-hint,
body.has-expanded-player.show-exit-hint * {
    cursor: none !important;
}
/* …but when the cursor is over the PAGE header (interactive chrome —
   site title, zoom buttons, etc.), show the native pointer instead.
   The header sits outside the click-to-exit zone so show-exit-hint
   stays on; we just exempt the header itself from the cursor swap.
   Both selectors carry an extra `header` token so they out-specify the
   blanket `cursor: none !important` rule above. */
body.is-stage-mode.show-exit-hint header,
body.is-stage-mode.show-exit-hint header *,
body.has-expanded-player.show-exit-hint header,
body.has-expanded-player.show-exit-hint header * {
    cursor: pointer !important;
}
/* Same exemption for expanded-mode video controls (play, scrub, mute). */
body.has-expanded-player.show-exit-hint .filmstrip__video-controls,
body.has-expanded-player.show-exit-hint .filmstrip__video-controls * {
    cursor: pointer !important;
}
/* Same exemption for interactive controls inside the per-project
   header (credits <summary>, title link). The JS hint logic excludes
   these from triggering the Exit label, so we need a matching CSS
   carve-out for the native cursor — otherwise the blanket
   `cursor: none !important` above would still hide the pointer over
   the button the user is about to click. */
body.has-expanded-player.show-exit-hint .project-header a,
body.has-expanded-player.show-exit-hint .project-header a *,
body.has-expanded-player.show-exit-hint .project-header button,
body.has-expanded-player.show-exit-hint .project-header button *,
body.has-expanded-player.show-exit-hint .project-header summary,
body.has-expanded-player.show-exit-hint .project-header summary *,
body.has-expanded-player.show-exit-hint .project-header details,
body.has-expanded-player.show-exit-hint .project-header [role="button"],
body.has-expanded-player.show-exit-hint .project-header [role="button"] * {
    cursor: pointer !important;
}
/* Title sits above the player visually but clicks pass through to the
   video (exit → expanded). Pointer cursor while over the title bbox
   is driven by body.is-over-stage-title in JS. */
body.is-stage-mode.is-over-stage-title,
body.is-stage-mode.is-over-stage-title * {
    cursor: pointer !important;
}
body.is-stage-mode.is-over-stage-title .cursor-label {
    opacity: 0 !important;
    visibility: hidden;
}
body.is-stage-mode.is-over-stage-title.show-exit-hint .cursor-label {
    opacity: 0 !important;
    visibility: hidden;
}
/* Hide the cursor-following label while the user is over the page
   header — the pointer cursor reads as "click this control", the
   label would just clutter the chrome. :has() lets us react to the
   descendant hover state from a parent selector. */
body.is-stage-mode.show-exit-hint:has(header:hover) .cursor-label,
body.has-expanded-player.show-exit-hint:has(header:hover) .cursor-label {
    opacity: 0 !important;
    visibility: hidden;
    mix-blend-mode: normal;
}
/* Idle auto-hide: in expanded view the BACK / EXIT label is part of the
   video-player chrome, so it fades out with the thumb slider after the
   user stops moving (body.is-thumbs-hidden) and returns on the next
   mouse move. The mousemove handler repositions the label before
   removing is-thumbs-hidden, so it always reappears at the cursor.
   Stage mode uses the same delay via body.is-stage-hint-hidden. */
body.has-expanded-player.show-exit-hint.is-thumbs-hidden .cursor-label,
body.is-stage-mode.show-exit-hint.is-stage-hint-hidden .cursor-label {
    opacity: 0;
    visibility: hidden;
    mix-blend-mode: normal;
}
/* Mobile / touch: there's no real cursor to follow or replace, so the
   project-number / Exit / arrow labels are meaningless and the
   cursor:none swap would only risk hiding a stylus pointer. Suppress
   the label entirely and never blank the native cursor. */
@media (max-width: 768px), (hover: none), (pointer: coarse) {
    .cursor-label {
        display: none !important;
    }
    body.is-stage-mode.show-exit-hint,
    body.is-stage-mode.show-exit-hint *,
    body.has-expanded-player.show-exit-hint,
    body.has-expanded-player.show-exit-hint * {
        cursor: auto !important;
    }
}
.todays-date {
    display: flex;
    flex-direction: row;
    line-height: 1;
    grid-column: 3;
}
.date-year {
    font-family: "Keykode Extended";
    font-size: var(--font-size-medium);
    align-self: flex-end;
}
.date-month-day {
    font-family: "Keykode Extended";
    font-size: var(--font-size-medium);
    font-variant: small-caps;
    text-transform: uppercase;
    /* font-size: var(--font-size-small); */
    align-self: flex-end;
}
.info-settings {
    display: flex;
    flex-direction: row;
    gap: 7px;
    grid-column: 6;
    justify-content: space-between;
}
.info-settings * {
    line-height: 1;
    font-family: "Keykode Regular";
    font-size: var(--font-size-medium);
    /* font-variant: small-caps; */
}
.info-btn {
    /* margin-right: 14px; */
    cursor: pointer;
    opacity: 0.35;
}
.zoom-controller {
    display: flex;
    flex-direction: row-reverse;
    gap: 8px;
    font-family: "Keykode Regular";
    font-size: var(--font-size-medium);
    justify-content: flex-end;
}
.controlcontainer {
    display: flex;
    gap: calc(8px*5);
    grid-column: 8 / span 4;
}
/* Zoom steps: the currently selected step is at full opacity (white);
   the others sit dimmer and lift back to full on hover. */
.zoom-step {
    cursor: pointer;
    opacity: 0.4;
    transition: opacity 0.2s ease;
}
.zoom-step.is-active {
    opacity: 1;
    cursor: default;
}
.zoom-step:not(.is-active):hover {
    opacity: 1;
}
.zoom-step__label-mobile {
    display: none;
}
/* The pic-invert button stays at full opacity outside stage mode; info
   is dimmed by default and lifts on hover / when the foldout is open.
   In stage mode see the rules near body.is-stage-mode below. */
.info-btn,
.pic-invert-btn {
    cursor: pointer;
    transition: opacity 0.2s ease;
    font-family: "Keykode Regular";
    font-size: var(--font-size-medium);
}
.info-btn:hover,
body.is-info-open .info-btn {
    opacity: 1;
}

/* -- Pic-invert button --------------------------------------------- */
/* Two icon glyphs stacked on the same baseline; we crossfade between
   them via opacity when body.is-pics-inverted toggles. Resetting the
   <button> defaults so it inherits the surrounding text styles. */
.pic-invert-btn {
    position: relative;
    display: inline-flex;
    align-items: center;
    /* justify-content: center; */
    appearance: none;
    background: none;
    border: 0;
    margin: 0;
    padding: 0;
    color: inherit;
    font: inherit;
    line-height: 1;
    cursor: pointer;
    font-family: "Keykode Regular";
    font-size: var(--font-size-medium);
    grid-column: 4;
}
.pic-invert-btn__icon {
    display: inline-block;
    transition: opacity 0.25s ease;
}
/* The "normal" glyph holds the button's width; the "alt" glyph overlays
   it and starts invisible. The crossfade now follows body.is-light-mode
   (the button toggles light/dark). The separate image-invert feature
   below still keys off body.is-pics-inverted for possible future use. */
.pic-invert-btn__icon--inverted {
    position: absolute;
    top: 0;
    left: 0;
}
.pic-invert-btn__icon--inverted { opacity: 0; }
body.is-light-mode .pic-invert-btn__icon--normal   { opacity: 0; }
body.is-light-mode .pic-invert-btn__icon--inverted { opacity: 1; }

/* -- Global image inversion ---------------------------------------- */
/* Applied to raster images, videos and embedded players only.
   filter:invert(1) on an <iframe> inverts the entire embedded
   document's painted output, so Vimeo player frames invert too.
   SVGs (footer logo, UI icons) are intentionally excluded — they're
   part of the chrome, not content. */
img, video, iframe {
    transition: filter 0.4s ease;
}
body.is-pics-inverted img,
body.is-pics-inverted video,
body.is-pics-inverted iframe {
    filter: invert(1);
}
/* The blurred player poster already has a filter chain (blur +
   saturate). Compose invert onto the end so we don't drop the blur. */
body.is-pics-inverted .filmstrip__player-poster {
    filter: blur(28px) saturate(1.15) invert(1);
}
@keyframes blink-dot {
    0% {
        background-color: red;
    }
    50% {
        background-color: #ddd; /* grey */
    }
    100% {
        background-color: red;
    }
}
.record-dot {
    color: red;
    height: 6px;
    width: 6px;
    border-radius: 50%;
    background-color: red;
    align-self: center;
    justify-self: center;
    animation: blink-dot 1.85s infinite;
    grid-column: 5;
}
.media-controller {
    display: flex;
    gap: 5px;
    align-content: center;
    grid-column: 7;
}
.media-controller * {
    line-height: 1;
    font-family: "Keykode Regular";
    font-size: var(--font-size-medium);
}
.photo-btn {
    cursor: pointer;
}
.film-btn {
    cursor: pointer;
}

/* Project List Home */
.project-header {
    display: grid;
    grid-template-columns: repeat(var(--columncount), 1fr);
    /* padding: 3px 0 4px 0; */
    transition: padding 0.4s cubic-bezier(0.4, 0, 0.2, 1);
    background: var(--c-bg);
}
number {
    font-family: "Keykode Extended";
    font-size: var(--font-size-medium);
    /* align-self: flex-end; */
}
.project-title-first {
    grid-column: 6 / span 6;
    cursor: pointer;
    align-self: baseline;
    padding: 3px 0 4px 0;
}
.project-header number {
    /* grid-column: 1; */
    /* padding-left: 60px; */
    /* justify-self: center; */
}
.project-number-second {
    display: flex;
    flex-direction: row;
    grid-column: 18 / span 2 !important;
    /* padding-left: 30px; */
    /* justify-self: center; */
    font-family: "Keykode Extended";
    align-self: baseline;
}
.project-number-second * {
    font-family: "Keykode Extended";
}
.project-title-second {
    grid-column: 22 / span 6 !important;
    cursor: pointer;
    padding: 3px 0 4px 0;
}
.project-number {
    display: flex;
    flex-direction: row;
    grid-column: 2 / span 2;
    /* padding-left: 15px; */
    /* justify-self: center; */
    font-family: "Keykode Extended";
    align-self: baseline;
}
.project-number * {
    font-family: "Keykode Extended";
}
.project-number-inner {
    display: flex;
    flex-direction: row;
    /* gap: 5px; */
    font-variant: small-caps;
}
.project-number-inner p {
    line-height: unset;
}
.project-list {
    padding: 8px;
    padding-top: 0;
    display: flex;
    flex-direction: column;
    /* gap: 12px; */
    background: transparent;
    flex: 1 0 auto;
}
.stage-phantom-slide {
    display: none;
}
.project-card {
    display: flex;
    flex-direction: column;
    /* gap: 5px; */
    scroll-margin-top: 25px;
    background: var(--c-bg);
}
.project-body {
    display: grid;
    grid-template-columns: 1fr;
    align-items: start;
}

/* -- List mode (zoom-step "0") ---------------------------------------
   Compressed browse view: only project headers, stacked like a list.
   The credits panel lives inside .project-body (same as filmstrip
   mode), so we hide the body by default but reveal it when that
   card's credits <details> is open — the existing credits JS sets
   [open] on [data-credits-primary]. Filmstrip stays hidden in list
   mode even while credits are expanded.
   ------------------------------------------------------------------ */
body.is-list-mode .project-body {
    /* display: none; */
}
body.is-list-mode .project-card:has([data-credits-primary][open]) .project-body {
    display: grid;
}
body.is-list-mode .project-body .filmstrip:not(.is-expanded):not(.is-zoom-animating) {
    /* display: none !important; */
    height: 0 !important;
}
/* List fold/unfold: animate container height only (see is-list-folding in
   home.php). --strip-row-h stays put so thumbs clip, not squash. */
body.is-list-folding .project-body .filmstrip.is-zoom-animating:not(.is-expanded) {
    transition: height 0.42s cubic-bezier(0.4, 0, 0.2, 1);
}
body.is-list-folding.is-list-mode .project-body .filmstrip.is-zoom-animating:not(.is-expanded) {
    height: 0 !important;
}
/* Stack description + credits below the header instead of overlaying
   the (hidden) filmstrip grid cell. */
body.is-list-mode .project-body > * {
    grid-row: auto;
}
/* In expanded view entered from list mode, we keep `is-list-mode` on the body
   (so the zoom controller stays on "0"), but the expanded layout needs the
   credits panel and filmstrip to share the same grid cell again (overlay
   behavior). */
body.has-expanded-player.is-list-mode .project-body > * {
    grid-row: 1;
}
body.is-list-mode .project-card {
    /* border-bottom: 1px solid rgba(255, 255, 255, 0.18); */
}
body.is-list-mode .project-card:last-of-type {
    border-bottom: none;
}
body.is-list-mode .project-header {
    padding-bottom: 0px;
    transition: padding 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
body.is-list-mode .project-header {
    padding-top: 0px;
    transition: padding 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
body.is-list-mode .project-header:first-of-type {
    /* padding-top: 3px; */
    transition: padding 0.4s cubic-bezier(0.4, 0, 0.2, 1);
}
/* List browse: header must win hit-testing over the credits fold
   (Safari routes taps to .project-info-container otherwise). */
body.is-list-mode:not(.has-expanded-player) .project-header {
    position: relative;
    z-index: 1002;
    touch-action: manipulation;
}
body.is-list-mode:not(.has-expanded-player) .project-card {
    cursor: pointer;
    -webkit-tap-highlight-color: transparent;
}
body.is-list-mode:not(.has-expanded-player) .project-body .project-info-container {
    z-index: 1;
}
/* List → stage: fade project rows out before the layout swap (see
   fadeListThenApplyStage() in home.php). */
body.is-list-exiting-stage .project-list {
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.45s cubic-bezier(0.22, 0.61, 0.36, 1);
}
/* The credits panel (.project-info-container, first in source) and the
   filmstrip (wrapped in .filmstrip-fold, second in source) STACK in two
   auto-sized grid rows rather than overlaying a single cell. This lets
   .project-body's height be the sum of the two: as one folds open the
   other folds closed, so the height interpolates smoothly between the
   filmstrip height and the credits height (no dip, no jump). When credits
   are closed the panel is 0-height, so the filmstrip sits flush at the
   top exactly as before. */
.project-body > * {
    grid-column: 1;
}
/* Containing block for the filmstrip-fold when it lifts out of flow during
   the credits crossfade (filmstrip-rows mode only; stage mode already makes
   .project-body position:absolute). */
body:not(.is-stage-mode):not(.is-list-mode) .project-body {
    position: relative;
}
.project-body .filmstrip {
    z-index: 1;
}
.project-body .project-info-container {
    z-index: 1001;
}

.project-list h2 {
    /* grid-column: 3 / span 3; */
    /* padding-left: 8px;
    padding-bottom: 8px; */
}
summary {
    font-family: "Keykode Regular";
    font-size: var(--font-size-medium);
    text-transform: uppercase;
    line-height: 1;
    cursor: pointer;
    list-style: none;
    outline: none;
}
summary::marker {
    content: "";
}
summary::-webkit-details-marker {
    display: none;
}
.project-credits-button {
    grid-column: 12 / span 2;
    /* left: -12px; */
    position: relative;
    align-self: baseline;
}
.project-credits-button .credit-summary {
    text-align: left !important;
}
.project-credits-button-second {
    /* justify-self: center; */
    grid-column: 28 / span 2;
    padding: 3px 0 4px 0;
}
.project-credits-button-second .credits-summary {
    text-align: right !important;
}
.project-info-container {
    display: grid;
    grid-template-columns: repeat(var(--columncount), 1fr);
    /* gap: 12px; */
    /* padding-bottom: 80px; */
    /* Collapsed by default. Same grid-template-rows: 0fr → 1fr trick as
       .infofoldout in the header: a 1fr track in an auto-sized row
       resolves to the children's natural height, so we get a smooth
       fold-out/fold-in without measuring content in JS. On desktop both
       .description-text and .credits-text sit on row 1 (non-conflicting
       columns), so a single 0fr track collapses them together. Children
       need min-height: 0 + overflow: hidden to clip below their natural
       height while collapsed. The open state is keyed off the credits
       <details> [open] attribute (set by the JS in home.php). */
    grid-template-rows: 0fr;
    overflow: hidden;
    /* Fade the credits in/out alongside the height fold (slightly faster
       than the fold) so the panel crossfades with the filmstrip rather
       than snapping. */
    opacity: 0;
    /* Stage/list mode reveal the credits with this pure-CSS height fold
       (the filmstrip has no height in those modes, so nothing fights it).
       Filmstrip-rows browse uses the same fold with a JS-timed opacity
       fade on the strip first (see home.php). */
    transition:
        grid-template-rows 0.4s cubic-bezier(0.4, 0, 0.2, 1),
        opacity 0.45s ease 0.08s;
}
.project-info-container > * {
    min-height: 0;
    overflow: hidden;
}
/* Desktop: pass children through to the container grid. Mobile
   overrides this to a flex column inside the single fold track. */
.project-info-fold-inner {
    display: contents;
}
.project-info-fold-inner > * {
    min-height: 0;
    overflow: hidden;
}
.project-card:has([data-credits-primary][open]) .project-info-container {
    grid-template-rows: 1fr;
    opacity: 1;
}

/* -- Filmstrip-rows browse: credits fold below the strip ----------------
   Filmstrip stays pinned to row 1 (top). Opening: opacity fade while
   the strip keeps its height, then it collapses instantly and the credits
   fold out on row 2 — no height swap / slide-up between the two.
   Height is locked to --filmstrip-reserved-h during transitions so the
   card never dips to zero between filmstrip and credits. Closing: both
   layers share grid row 1 so credits fold in at the top while the strip
   expands behind them, then the strip fades back in. */
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .filmstrip-fold {
    grid-row: 1;
    transition: opacity 0.42s cubic-bezier(0.4, 0, 0.2, 1);
}
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-info-container {
    grid-row: 2;
    align-self: start;
    transition: grid-template-rows 0.42s cubic-bezier(0.4, 0, 0.2, 1);
}
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-body {
    align-content: start;
}
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-credits-height-locked .project-body {
    min-height: var(--filmstrip-reserved-h);
    overflow: hidden;
}
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-credits-height-locked.is-credits-opening .project-body,
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-credits-height-locked.is-credits-closing .project-body {
    transition: min-height 0.42s cubic-bezier(0.4, 0, 0.2, 1);
}
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-credits-height-locked:has([data-credits-primary][open]) .project-info-container {
    align-content: start;
}
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-filmstrip-faded .filmstrip-fold {
    opacity: 0;
    pointer-events: none;
}
/* Keep the strip fade on the fold only — nested player/cell opacity
   transitions fight the fold and read as a first-frame jerk when the
   player / stills row is active. */
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-filmstrip-faded .filmstrip,
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-filmstrip-faded .filmstrip__cell,
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-filmstrip-faded .filmstrip__player,
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-filmstrip-faded .filmstrip__curated {
    transition: none !important;
}
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-filmstrip-collapsed .filmstrip-fold {
    grid-template-rows: 0fr;
}
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-credits-closing .filmstrip-fold,
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-credits-opening .filmstrip-fold {
    grid-row: 1;
    grid-column: 1;
    align-self: start;
    width: 100%;
    z-index: 0;
    transition:
        opacity 0.42s cubic-bezier(0.4, 0, 0.2, 1),
        grid-template-rows 0.42s cubic-bezier(0.4, 0, 0.2, 1);
}
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-credits-closing .filmstrip-fold {
    grid-template-rows: 1fr;
}
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-credits-opening .project-info-container {
    grid-row: 1;
    grid-column: 1;
    align-self: start;
    width: 100%;
    z-index: 1;
    position: relative;
    isolation: isolate;
    transition: grid-template-rows 0.42s cubic-bezier(0.4, 0, 0.2, 1);
}
/* Soft reveal veil: copy stays fully opaque while the grid fold runs;
   the scrim dissolves in sync so the last pixels of expansion don't snap. */
body:not(.is-stage-mode):not(.has-expanded-player) .project-card.is-credits-opening .project-info-container::after {
    content: '';
    position: absolute;
    inset: 0;
    background: linear-gradient(
        to bottom,
        var(--c-bg) 0%,
        var(--c-bg) 55%,
        color-mix(in srgb, var(--c-bg) 72%, transparent) 100%
    );
    opacity: 1;
    pointer-events: none;
    z-index: 1;
    transition: opacity 0.46s cubic-bezier(0.33, 1, 0.38, 1) 0.06s;
}
body:not(.is-stage-mode):not(.has-expanded-player) .project-card.is-credits-opening.is-credits-revealed .project-info-container::after {
    opacity: 0;
}
body:not(.is-stage-mode):not(.is-list-mode):not(.has-expanded-player) .project-card.is-credits-closing .project-info-container {
    grid-row: 1;
    grid-column: 1;
    align-self: start;
    width: 100%;
    z-index: 1;
    opacity: 1;
}

/* Suppress credits / filmstrip fold animations during stage, expanded,
   and zoom layout swaps so closed panels never flash or animate. */
body.is-layout-switching .project-info-container,
body.is-layout-switching .filmstrip-fold {
    transition: none !important;
}
body.is-layout-switching .project-card:not(:has([data-credits-primary][open])) .project-info-container {
    grid-template-rows: 0fr !important;
    opacity: 0 !important;
    visibility: hidden !important;
    pointer-events: none !important;
}
body.is-layout-switching .project-card.is-filmstrip-faded .filmstrip-fold,
body.is-layout-switching .project-card.is-filmstrip-collapsed .filmstrip-fold,
body.is-layout-switching .project-card.is-credits-closing .filmstrip-fold,
body.is-layout-switching .project-card.is-credits-opening .filmstrip-fold {
    opacity: 1 !important;
    grid-template-rows: 1fr !important;
}
body.is-layout-switching .project-card.is-credits-height-locked .project-body {
    min-height: 0 !important;
}
body.is-layout-switching .project-card.is-credits-opening .project-info-container::after {
    display: none !important;
}
/* Direct-expand entry: keep browse stills hidden while the layout
   switch peels credits classes off the active expanded card. */
body.has-expanded-player.is-layout-switching:not(.is-stage-mode) .project-card.is-player-active .filmstrip.is-expanded .filmstrip__cell,
body.has-expanded-player.is-layout-switching:not(.is-stage-mode) .project-card.is-player-active .filmstrip.is-expanded .filmstrip__curated {
    opacity: 0 !important;
    pointer-events: none !important;
}
body.is-layout-switching.is-stage-exiting:not(.is-stage-mode) .stage-background {
    opacity: 1 !important;
    background: var(--c-bg) !important;
    pointer-events: none;
}

/* -- Filmstrip fold ---------------------------------------------------
   Wraps the filmstrip so it can fold away (height) and fade (opacity)
   when the credits panel opens, mirroring the panel's fold. The two live
   in stacked rows of .project-body, so while the panel grows 0fr → 1fr
   this shrinks 1fr → 0fr and the container height moves smoothly between
   them. Open (filmstrip visible) is the default; the closed/collapsed
   state is keyed off the credits <details> [open] attribute.
   overflow:hidden clips the strip as it collapses. The fullscreen stage
   player (position:fixed) is NOT immune to ancestor opacity — see the
   stage-mode override on .filmstrip-fold when credits are open. --------------------------------------------------- */
.filmstrip-fold {
    display: grid;
    grid-template-rows: 1fr;
    transition:
        grid-template-rows 0.42s cubic-bezier(0.4, 0, 0.2, 1),
        opacity 0.42s cubic-bezier(0.4, 0, 0.2, 1);
}
/* The fold needs an AUTO-height child to collapse: .filmstrip itself has
   an explicit height (var(--strip-row-h)), and a definite-height grid
   item won't shrink under a 0fr track — which is why the strip kept its
   height and left a gap. This inner wrapper is content-sized (so the 1fr
   track resolves to the strip's height) and, with min-height:0 +
   overflow:hidden, collapses cleanly to 0 when the track does, clipping
   the fixed-height strip inside it. Same bounds as the strip's own
   overflow:hidden, so it adds no new clipping to the stage/expanded
   players (which are position:fixed/absolute anyway). */
.filmstrip-fold-inner {
    min-height: 0;
    overflow: hidden;
}
/* List browse: filmstrip stays hidden until expanded — only the header
   (and optionally the credits panel) are visible. Without this, closing
   credits uncollapses the fold and the stills flash inline. NEVER apply
   opacity:0 in stage mode — the fullscreen stage player lives inside
   .filmstrip-fold. Expanded view overrides below. */
body.is-list-mode:not(.has-expanded-player) .filmstrip-fold {
    grid-template-rows: 0fr;
    opacity: 0;
    pointer-events: none;
    overflow: hidden;
}
/* Stage mode: keep the fold (and its fixed player) fully visible while
   credits overlay in the lower panel. */
body.is-stage-mode .project-card.is-stage-active:has([data-credits-primary][open]) .filmstrip-fold {
    grid-template-rows: 1fr;
    opacity: 1;
}
/* -- Expanded view: credits OVERLAY the player -----------------------
   In the expanded (fullscreen-ish) player view the credits must not fold
   the filmstrip away or resize the card. Instead the panel sits on top of
   the video as a translucent overlay and folds out with the same 0fr → 1fr
   height animation as stage mode. The JS (openPanel/closePanel) skips the
   body-height FLIP in this mode, so we only need to (a) keep the filmstrip
   in place and (b) absolutely position the credits panel over it.
   Scoped to the active expanded card. ------------------------------- */
body.has-expanded-player:not(.is-stage-mode) .project-card.is-player-active .project-body {
    /* Containing block for the absolute-positioned credits overlay.
       Without this, list → expanded can briefly anchor the panel to the
       card/viewport instead of the player area, making it appear "on top"
       of everything. */
    position: relative;
}
body.has-expanded-player:not(.is-stage-mode) .project-card.is-player-active:has([data-credits-primary][open]) .filmstrip-fold {
    /* Cancel the slide-up/lift used in filmstrip-rows browse mode — the
       player stays exactly where it is while the credits overlay it. */
    position: static;
    transform: none;
    opacity: 1;
    pointer-events: auto;
    grid-template-rows: 1fr;
    z-index: auto;
}
body.has-expanded-player:not(.is-stage-mode) .project-card.is-player-active .project-info-container {
    position: absolute;
    inset: 0;
    z-index: 1003; /* above the player + its controls */
    /* Same height fold as stage mode (0fr → 1fr), overlaid on the player. */
    grid-template-rows: 0fr;
    overflow: hidden;
    /* Transparent so the video stays visible underneath. */
    background: transparent;
    /* padding: 64px 0 80px; */
    opacity: 0;
    pointer-events: none;
    transition:
        grid-template-rows 0.4s cubic-bezier(0.4, 0, 0.2, 1),
        opacity 0.3s ease;
}
body.has-expanded-player:not(.is-stage-mode) .project-card.is-player-active:has([data-credits-primary][open]) .project-info-container {
    grid-template-rows: 1fr;
    opacity: 1;
    pointer-events: auto;
    overflow: auto;
}
/* List → expanded: cancel list-mode stack layout so credits overlay the
   player at inset:0 instead of inheriting list-mode offsets. */
body.has-expanded-player.is-list-mode:not(.is-stage-mode) .project-card.is-player-active .project-info-container {
    position: absolute;
    inset: 0;
    z-index: 1003;
    grid-template-rows: 0fr;
    overflow: hidden;
    background: transparent;
    margin-top: 0;
    padding-bottom: 0;
    order: unset;
}
body.has-expanded-player.is-list-mode:not(.is-stage-mode) .project-card.is-player-active:has([data-credits-primary][open]) .project-info-container {
    grid-template-rows: 1fr;
    opacity: 1;
    pointer-events: auto;
    overflow: auto;
}
body.has-expanded-player.is-list-mode:not(.is-stage-mode) .project-card.is-player-active .project-body {
    display: block;
    position: relative;
}
body.has-expanded-player.is-list-mode:not(.is-stage-mode) .project-card.is-player-active .filmstrip-fold {
    order: unset;
    grid-template-rows: 1fr;
    opacity: 1;
    pointer-events: auto;
    overflow: visible;
}
body.has-expanded-player:not(.is-stage-mode) .project-card.is-player-active:has([data-credits-primary][open]) .project-info-container,
body.has-expanded-player:not(.is-stage-mode) .project-card.is-player-active:has([data-credits-primary][open]) .project-info-container * {
    color: var(--c-fg);
    /* text-shadow:
        0 1px 10px rgba(0, 0, 0, 0.9),
        0 0 28px rgba(0, 0, 0, 0.55); */
}
/* Drop the fold-only bottom padding hack on credits-text in overlay mode;
   the container's own padding handles spacing and the text is free to
   scroll. */
body.has-expanded-player:not(.is-stage-mode) .project-card.is-player-active:has([data-credits-primary][open]) .credits-text {
    padding-bottom: 0;
}
/* Black scrim over the video while credits are open (expanded overlay).
   Opacity-based dimming lets the page background bleed through in light
   mode; a semi-transparent black overlay keeps the effect theme-independent.
   Desktop only — mobile expanded stacks credits below the player. */
@media (min-width: 769px) {
    body.has-expanded-player:not(.is-stage-mode).has-open-credits .project-card.is-player-active .filmstrip__player::after {
        content: '';
        position: absolute;
        inset: 0;
        background: var(--c-bg);
        z-index: 2;
        pointer-events: none;
        transition: opacity 0.3s ease;
    }
    body.has-expanded-player:not(.is-stage-mode).has-open-credits .project-card.is-player-active .filmstrip__video-controls {
        opacity: 0;
    }
}

.credits-text {
    grid-column: 12 / span 5;
    /* padding-bottom is moved onto the open-state rule below. As a direct
       grid child of .project-info-container its padding can't be clipped
       by overflow:hidden, so an 80px bottom padding here would stop the
       fold from ever collapsing fully (the panel would stay ~80px tall
       and visible when closed). */
}
.project-card:has([data-credits-primary][open]) .credits-text {
    padding-bottom: 16px;
}
.description-text {
    grid-column: 2 / span 10;
    /* padding-left: 15px; */
    padding-right: 12px;
}
.credits-text * {
    font-family: "Waldenburg";
    font-size: var(--font-size-medium-waldenburg);
    text-transform: none;
    font-variant: normal;
    line-height: 1.2;
    position: relative;
    /* left: +12px; */
    padding-top: 6px;
}
.description-text * {
    font-family: "Waldenburg";
    font-size: var(--font-size-medium-waldenburg);
    text-transform: none;
    font-variant: normal;
    line-height: 1.2;
    position: relative;
    /* left: +12px; */
    padding-top: 8px;
}
body:not(.is-stage-mode):not(.has-expanded-player)
  .project-card:is(:has([data-credits-primary][open]), .is-credits-closing)
  .description-text > :first-child,
body:not(.is-stage-mode):not(.has-expanded-player)
  .project-card:is(:has([data-credits-primary][open]), .is-credits-closing)
  .credits-text > :first-child {
    padding-top: 3px;
}
/* List mode keeps top padding on the first block while credits fold. */
body.is-list-mode:not(.is-stage-mode):not(.has-expanded-player)
  .project-card:is(:has([data-credits-primary][open]), .is-credits-closing)
  .description-text > :first-child,
body.is-list-mode:not(.is-stage-mode):not(.has-expanded-player)
  .project-card:is(:has([data-credits-primary][open]), .is-credits-closing)
  .credits-text > :first-child {
    padding-top: 4px;
}

/* Stills Strips */

/* Register row height as an animatable length so zoom-step changes can
   transition ONE property per strip instead of height + width on every
   cell/player (Safari struggles with hundreds of simultaneous layout
   transitions; Chrome tolerates it better). */
@property --strip-row-h {
    syntax: '<length>';
    inherits: true;
    initial-value: 400px;
}
@property --filmstrip-dim-opacity {
    syntax: '<number>';
    inherits: true;
    initial-value: 0;
}

.filmstrip {
    --strip-row-h: 400px;
    position: relative;
    display: flex;
    flex-wrap: nowrap;
    gap: 0;
    width: 100%;
    height: var(--strip-row-h);
    overflow: hidden;
    background: linear-gradient(var(--c-strip-top), var(--c-strip-bottom));
    /* border-radius: 6px; */
    cursor: pointer;
    transition: opacity 0.25s ease;
    /* Height/width transitions are opt-in via .is-zoom-animating only.
       Applying them globally breaks stage↔expanded FLIP (strip animates
       from height:0 → target while toRect is measured) and makes
       scrollIntoView undershoot when expanding from small zoom steps. */
}
/* Zoom-step changes only — toggled by applyZoom() in home.php. */
.filmstrip.is-zoom-animating {
    contain: layout style;
    transition: opacity 0.25s ease, --strip-row-h 0.2s ease 0.2s;
}
/* Expanded view + stage FLIP: snap layout instantly. */
.filmstrip.is-expanded,
.filmstrip.is-stage-flip {
    transition: none !important;
}
.filmstrip.is-stage-flip .filmstrip__player {
    transition: none !important;
}
.filmstrip.is-playing {
    background: var(--c-bg);
}

/* Filmstrip-rows browse mode: pointer hover lifts the hovered project row
   (header + filmstrip) to full opacity and gently dims the others as a
   click-to-play cue. Scoped to the pre-activation browse state only —
   once a player is active the existing has-active-player card dimming
   takes over. Class-driven (not :has(:hover)) so Safari doesn't restyle
   every row on each mousemove. */
@media (hover: hover) and (pointer: fine) {
    body:not(.has-active-player):not(.has-expanded-player):not(.is-stage-mode):not(.is-list-mode) .project-list.is-hover-dim .project-card:not(.is-hover-target) {
        opacity: 0.75;
    }
    body:not(.has-active-player):not(.has-expanded-player):not(.is-stage-mode):not(.is-list-mode) .project-list.is-hover-dim .project-card.is-hover-target {
        opacity: 1;
    }

    /* List mode (zoom 0): same opacity cue on project rows instead of the
       former grey background on .project-header:hover. */
    body.is-list-mode:not(.has-active-player):not(.has-expanded-player) .project-list.is-hover-dim .project-card:not(.is-hover-target) {
        opacity: 0.35;
    }
    body.is-list-mode:not(.has-active-player):not(.has-expanded-player) .project-list.is-hover-dim .project-card.is-hover-target {
        opacity: 1;
    }

    /* Hover browse cue — opacity only (no transform). */
    .project-list.is-hover-dim .project-card {
        transition: opacity 0.38s cubic-bezier(0.22, 0.61, 0.36, 1);
    }

    /* Safari desktop (filmstrip-rows browse): card opacity forces a repaint of
       every thumbnail in the row. A theme-colored overlay on the filmstrip
       fold achieves the same dim cue with a single flat layer per row. */
    html.is-safari-desktop body:not(.has-expanded-player):not(.is-stage-mode):not(.is-list-mode) .project-list.is-hover-dim .project-card:not(.is-hover-target),
    html.is-safari-desktop body:not(.has-expanded-player):not(.is-stage-mode):not(.is-list-mode) .project-list.is-hover-dim .project-card.is-hover-target {
        opacity: 1;
    }
    html.is-safari-desktop body:not(.is-stage-mode):not(.is-list-mode) .filmstrip-fold {
        position: relative;
        --filmstrip-dim-opacity: 0;
        transition:
            grid-template-rows 0.42s cubic-bezier(0.4, 0, 0.2, 1),
            opacity 0.42s cubic-bezier(0.4, 0, 0.2, 1),
            --filmstrip-dim-opacity 0.38s cubic-bezier(0.22, 0.61, 0.36, 1);
    }
    html.is-safari-desktop body:not(.is-stage-mode):not(.is-list-mode) .filmstrip-fold::after {
        content: "";
        position: absolute;
        inset: 0;
        z-index: 2;
        background: var(--c-bg);
        opacity: var(--filmstrip-dim-opacity);
        pointer-events: none;
    }
    html.is-safari-desktop body:not(.has-expanded-player):not(.is-stage-mode):not(.is-list-mode) .project-list.is-hover-dim .project-card:not(.is-hover-target) .filmstrip-fold {
        --filmstrip-dim-opacity: 0.25;
    }
    html.is-safari-desktop body:not(.has-expanded-player):not(.is-stage-mode):not(.is-list-mode) .project-list.is-hover-dim .project-card.is-hover-target .filmstrip-fold {
        --filmstrip-dim-opacity: 0;
    }

    /* Safari desktop: hover-activation (has-active-player). */
    html.is-safari-desktop body.has-active-player:not(.has-open-credits):not(.is-stage-mode):not(.has-expanded-player):not(.is-list-mode) .project-list.is-hover-dim .project-card:not(.is-player-active) .filmstrip-fold,
    html.is-safari-desktop body.has-active-player:not(.has-open-credits):not(.is-stage-mode):not(.has-expanded-player):not(.is-list-mode) .project-card:not(.is-player-active) .filmstrip-fold {
        --filmstrip-dim-opacity: 0.65;
    }
    html.is-safari-desktop body.has-active-player:not(.has-open-credits):not(.is-stage-mode):not(.has-expanded-player):not(.is-list-mode) .project-list.is-hover-dim .project-card.is-player-active .filmstrip-fold,
    html.is-safari-desktop body.has-active-player:not(.has-open-credits):not(.is-stage-mode):not(.has-expanded-player):not(.is-list-mode) .project-card.is-player-active .filmstrip-fold {
        --filmstrip-dim-opacity: 0;
    }
    html.is-safari-desktop body.has-active-player:not(.has-open-credits):not(.is-stage-mode):not(.has-expanded-player):not(.is-list-mode) .project-card:not(.is-player-active) {
        opacity: 1;
    }
    html.is-safari-desktop body.has-open-credits:not(.is-stage-mode):not(.has-expanded-player):not(.is-list-mode) .project-card:not(:has([data-credits-primary][open])):not(.is-filmstrip-faded):not(.is-filmstrip-collapsed):not(.is-credits-closing) {
        opacity: 1;
    }
    html.is-safari-desktop body.has-open-credits:not(.is-stage-mode):not(.has-expanded-player):not(.is-list-mode) .project-card:not(:has([data-credits-primary][open])):not(.is-filmstrip-faded):not(.is-filmstrip-collapsed):not(.is-credits-closing) .filmstrip-fold {
        --filmstrip-dim-opacity: 0.65;
    }
    html.is-safari-desktop body.has-open-credits:not(.is-stage-mode):not(.has-expanded-player):not(.is-list-mode) .project-card:has([data-credits-primary][open]) .filmstrip-fold,
    html.is-safari-desktop body.has-open-credits:not(.is-stage-mode):not(.has-expanded-player):not(.is-list-mode) .project-card.is-filmstrip-faded .filmstrip-fold,
    html.is-safari-desktop body.has-open-credits:not(.is-stage-mode):not(.has-expanded-player):not(.is-list-mode) .project-card.is-filmstrip-collapsed .filmstrip-fold,
    html.is-safari-desktop body.has-open-credits:not(.is-stage-mode):not(.has-expanded-player):not(.is-list-mode) .project-card.is-credits-closing .filmstrip-fold {
        --filmstrip-dim-opacity: 0;
    }
}

.filmstrip__cell {
    flex: 0 0 auto;
    height: 100%;
    width: calc(var(--strip-row-h) * var(--ratio, 1.7778));
    /* Split out from the old `background: #050505` shorthand so an
       inline `background-image: url(<thumbhash-uri>)` set in
       filmstrip.php paints the blurred placeholder behind the
       lazy-loading <img>. The dark color remains as a fallback when
       no thumbhash is available (or when its alpha leaves gaps). */
    background-color: var(--c-surface);
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
    overflow: hidden;
    transition: opacity 0.3s ease;
}
.filmstrip.is-zoom-animating .filmstrip__cell {
    transition: opacity 0.3s ease;
}
/* Player + cell widths track --strip-row-h via calc(); only the strip's
   row-height variable is transitioned (see @property above). */
.filmstrip.is-zoom-animating .filmstrip__player {
    transition: opacity 0.3s ease;
}
.filmstrip.is-expanded .filmstrip__cell,
.filmstrip.is-stage-flip .filmstrip__cell {
    transition: opacity 0.25s ease;
}
.filmstrip__img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* ThumbHash fade-in: every <img> rendered by filmstrip.php carries
   `data-th-img`. It starts invisible so the parent cell's inline
   background-image (a ~1KB base64 thumbhash data URI, decoded
   server-side by tobimori/kirby-thumbhash) shows as a blurred
   placeholder. Once the browser finishes downloading the real
   pixel data, home.php adds `.is-loaded` and the image fades over
   the placeholder. After the fade the placeholder is fully
   covered and doesn't compose any further. */
[data-th-img] {
    opacity: 0;
    transition: opacity 0.4s ease;
}
[data-th-img].is-loaded {
    opacity: 1;
}
.filmstrip__player {
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    width: calc(var(--strip-row-h) * var(--strip-ratio, 1.7778));
    background: var(--c-surface);
    opacity: 0;
    pointer-events: none;
    cursor: pointer;
    z-index: 2;
    /* background: var(--c-fg); */
    /* Clip everything inside the player to its rectangle. The blurred
       .filmstrip__player-poster is intentionally oversized (scale 1.08)
       and has a wide blur halo that would otherwise bleed past the
       player's edges — overflow:hidden gives the player a hard
       rectangular edge regardless of what's painting inside. Also clips
       the stage-mode iframe's oversize cover-math (min-height/width
       extends past 100vw/100vh on some aspect ratios). */
    overflow: hidden;
    /* transition: opacity 0.3s ease, width 0.4s cubic-bezier(0.4, 0, 0.2, 1), background 0.3s ease; */
}
.filmstrip__player iframe,
.filmstrip__player video {
    position: relative;
    display: block;
    width: 100%;
    height: 100%;
    border: 0;
    object-fit: contain;
    pointer-events: none;
    /* Universal fade-in: every player iframe/video starts invisible and
       only paints once .is-revealed is set by bindPlayerMediaReveal()
       (after the browser reports loaded + a short settle delay). The
       blurred .filmstrip__player-poster sitting behind takes over as
       the "loading state", so the user never sees the iframe's bare
       black warm-up frame. */
    opacity: 0;
    transition: opacity 0.45s ease;
    z-index: 1;
}
.filmstrip__player iframe.is-revealed,
.filmstrip__player video.is-revealed {
    opacity: 1;
}
/* Blurred poster sitting behind the iframe/video while the media
   loads. Removed from the DOM by fadeAndRemovePoster() once
   .is-revealed lands on the media so the blur compositor layer is
   destroyed (otherwise it can paint above z-indexed siblings). */
.filmstrip__player-poster {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: contain;
    filter: blur(28px) saturate(1.15);
    transform: scale(1.08);
    transform-origin: center center;
    z-index: 0;
    pointer-events: none;
    opacity: 1;
    transition: opacity 0.45s ease;
    /* overflow: hidden; */
}
.filmstrip__player-poster.is-hidden {
    opacity: 0;
}
.filmstrip.is-playing .filmstrip__player {
    opacity: 1;
    pointer-events: auto;
}
/* On hover, the rest of the row's stills stay visible but recede into
   the background so the player reads as the focal element. They keep
   their layout slot (the player sits over the leftmost cell only); the
   remaining cells flow to the right of the player as faded thumbnails. */
.filmstrip.is-playing .filmstrip__cell {
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.25s ease;
}
.filmstrip.is-expanded .filmstrip__player {
    width: 100%;
    height: 100%;
    background: transparent;
    /* cursor: zoom-in; */
}
/* Expanded project nav: cross-dissolve poster ↔ video in sync. */
html.is-expanded-nav-crossfade .filmstrip.is-expanded .filmstrip__player iframe,
html.is-expanded-nav-crossfade .filmstrip.is-expanded .filmstrip__player video,
html.is-expanded-shell.is-expanded-nav-crossfade .filmstrip.is-expanded .filmstrip__player iframe,
html.is-expanded-shell.is-expanded-nav-crossfade .filmstrip.is-expanded .filmstrip__player video {
    transition: opacity 0.55s ease;
}
html.is-expanded-nav-crossfade .filmstrip.is-expanded .filmstrip__player-poster,
html.is-expanded-shell.is-expanded-nav-crossfade .filmstrip.is-expanded .filmstrip__player-poster {
    transition: opacity 0.55s ease;
}
/* Desktop expanded: transparent hit layer above iframe/video so wheel and
   click stay on the page shell (cross-origin Vimeo swallows them). */
@media (min-width: 769px) {
    .filmstrip.is-expanded .filmstrip__player iframe,
    .filmstrip.is-expanded .filmstrip__player video {
        pointer-events: none !important;
    }
    .filmstrip.is-expanded .filmstrip__player-hitlayer {
        position: absolute;
        inset: 0;
        z-index: 3;
        pointer-events: auto;
        background: transparent;
        margin-bottom: 40px;
    }
}
/* Expanded-mode prev/next click zones. Two transparent side bands sit
   ABOVE the player (z-index 4 > player's 2) over its left/right edges,
   leaving the centre free for the existing "click to escalate to stage"
   gesture. Hidden entirely outside expanded view so they never block the
   hover-to-expand click. Rendered by filmstrip.php, wired in home.php. */
.filmstrip__nav {
    display: none;
}
.filmstrip.is-expanded .filmstrip__nav {
    display: block;
    position: absolute;
    top: 0;
    /* Stop above the thumb slider so prev/next never overlaps a thumb.
       Mirrors the player's height math; falls to full height when the
       slider auto-hides (body.is-thumbs-hidden) below. */
    bottom: calc(var(--curated-thumb-h, 0px) + 22px);
    width: 22%;
    z-index: 4;
    margin: 0;
    padding: 0;
    border: 0;
    background: transparent;
    /* Native cursor is replaced by the arrow cursor-label (see home.php
       updateExpandedExitHint + the .cursor-label--arrow rule). */
    cursor: none;
    -webkit-appearance: none;
    appearance: none;
}
.filmstrip.is-expanded .filmstrip__nav--prev { left: 0; }
.filmstrip.is-expanded .filmstrip__nav--next { right: 0; }
/* When the slider is hidden the player fills the strip; let the nav
   zones follow so the whole player edge stays navigable. */
body.is-thumbs-hidden .filmstrip.is-expanded .filmstrip__nav {
    bottom: 0;
}
.filmstrip.is-expanded[data-has-curated] .filmstrip__player {
    /* The remaining strip height after the curated-thumb slider takes
       its slice. --curated-thumb-h is the row height the strip would
       have at the densest zoom step ("12"), set by justifyStrip() in
       home.php. +22 accounts for the slider's 8px padding-top and the
       14px number row beneath each .filmstrip__thumb (see
       .filmstrip.is-expanded .filmstrip__thumbs and .filmstrip__thumb
       padding rules below). */
    height: calc(100% - var(--curated-thumb-h, 12%) - 22px);
}
/* When the thumb slider auto-hides, the player reclaims the 12%
   it left for thumbs and fills the strip. Coordinated with the
   thumbs opacity fade (also 0.35s) so the strip transitions as a
   single unit. The transition itself is armed by .has-thumbs-anim
   (added by expandPlayer one frame AFTER .is-expanded) so the
   initial sizing — and the stage→expanded FLIP that uses transform
   against the post-expand layout box — doesn't animate height
   underneath the FLIP. See expandPlayer() in home.php. */
.filmstrip.has-thumbs-anim[data-has-curated] .filmstrip__player {
    transition: height 0.35s ease;
}
body.is-thumbs-hidden .filmstrip.is-expanded[data-has-curated] .filmstrip__player {
    height: 100%;
}
/* When the expanded player is showing a still instead of the video,
   contain the image inside the player frame. */
.filmstrip__player img.filmstrip__player-still {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: contain;
    background: transparent;
    pointer-events: none;
}

/* Curated stills: visible next to the player when the strip is in the
   active (hover) "is-playing" state. They sit absolutely to the right of
   the player and share the leftover row width. */
.filmstrip__curated {
    position: absolute;
    top: 0;
    bottom: 0;
    left: calc(var(--strip-row-h) * var(--strip-ratio, 1.7778));
    right: 0;
    display: flex;
    flex-wrap: nowrap;
    gap: 0;
    z-index: 2;
    opacity: 0;
    pointer-events: none;
    overflow: hidden;
    transition: opacity 0.25s ease;
}
.filmstrip__curated-cell {
    /* Fixed width matching a regular strip cell so curated stills are
       never squeezed. Visible count is capped in JS (justifyStrip) to
       `count - 1` of the current zoom row, so the player + curated
       together never exceed the row width. */
    flex: 0 0 auto;
    width: calc(var(--strip-row-h) * var(--strip-ratio, 1.7778));
    height: 100%;
    /* See .filmstrip__cell for the background split rationale —
       inline `background-image` carries the ThumbHash placeholder. */
    background-color: var(--c-surface);
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
    overflow: hidden;
    cursor: pointer;
}
.filmstrip__curated-cell img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}
/* Curated stills are no longer shown next to the player on hover —
   only the video plays and the rest of the row dims (see
   .is-playing .filmstrip__cell above). The curated container stays
   hidden in the filmstrip layout (the base .filmstrip__curated rule
   leaves it at opacity 0 / pointer-events none). The curated stills
   reappear inside the .filmstrip__thumbs slider in expanded mode. */
.filmstrip.is-expanded .filmstrip__curated {
    display: none;
}

/* Thumb slider: visible only in expanded mode, beneath the big player.
   First entry is the video (data-thumb-video), the rest are curated stills. */
.filmstrip__thumbs {
    display: none;
}
.filmstrip.is-expanded .filmstrip__thumbs {
    display: flex;
    position: absolute;
    bottom: 0;
    left: 0;
    right: 0;
    /* Container height is the frame height (--curated-thumb-h, sized
       like a single cell at zoom step "12") plus the 8px padding-top
       on this container and the 14px number row below each thumb.
       Keep in sync with the .filmstrip.is-expanded[data-has-curated]
       .filmstrip__player rule above, which subtracts the same total
       from 100% so the player + slider together fill the strip. */
    height: calc(var(--curated-thumb-h, 12%) + 22px);
    align-items: center;
    justify-content: flex-start;
    /* gap: 6px; */
    /* padding: 6px 8px; */
    padding-top: 8px;
    background: var(--c-bg);
    z-index: 4;
    /* Auto-hide on inactivity (see the .is-thumbs-hidden rule below
       and the THUMBS_HIDE_DELAY_MS logic in home.php). The transition
       is declared on the visible state so the FADE-OUT animates; the
       fade-in happens when body.is-thumbs-hidden is removed and uses
       the same transition value. */
    opacity: 1;
    transition: opacity 0.35s ease;
}
/* Inactivity auto-hide: in expanded mode, the thumb slider acts like
   a video player's chrome — it fades out after the user has been
   still for a moment (THUMBS_HIDE_DELAY_MS) and reappears on the
   next mouse/touch/key activity. pointer-events:none while hidden
   so a cursor sitting over the now-invisible strip doesn't intercept
   clicks meant for the player below. */
body.is-thumbs-hidden .filmstrip.is-expanded .filmstrip__thumbs {
    opacity: 0;
    pointer-events: none;
}

/* Expanded-mode video controls: minimal chrome along the bottom edge of
   the player. Fades with the thumb slider on inactivity
   (body.is-thumbs-hidden). Hidden while the player is in still-mode. */
.filmstrip__video-controls {
    display: none;
}
.filmstrip.is-expanded .filmstrip__video-controls {
    display: block;
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    z-index: 3;
    pointer-events: auto;
    opacity: 1;
    transition: opacity 0.35s ease;
}
/* Leave the left/right nav bands (22% each) free for prev/next clicks. */
.filmstrip.is-expanded[data-has-curated] .filmstrip__video-controls {
    left: 22%;
    right: 22%;
}
/* Desktop vertical (9:16): width/left are set in px by
   syncExpandedVerticalControlsLayout() in home.php. */
@media (min-width: 769px) {
    .filmstrip.is-expanded.is-vertical-expanded-video .filmstrip__video-controls {
        left: var(--expanded-controls-left, 0) !important;
        right: auto !important;
        width: var(--expanded-controls-width, 100%) !important;
        transform: none !important;
    }
}
.filmstrip.is-expanded .filmstrip__video-controls.is-inactive {
    opacity: 0;
    pointer-events: none;
}
body.is-thumbs-hidden .filmstrip.is-expanded .filmstrip__video-controls:not(.is-inactive) {
    opacity: 0;
    /* Keep controls tappable while visually hidden — pointer-events:none
       let taps fall through to the player / Vimeo iframe and toggle
       play/pause instead of mute/unmute (especially on mobile Safari). */
    pointer-events: auto;
}
.filmstrip__video-controls-inner {
    display: flex;
    align-items: center;
    gap: 14px;
    padding: 14px 18px 16px;
    /* background: linear-gradient(to top, rgba(0, 0, 0, 0.72) 0%, rgba(0, 0, 0, 0.28) 70%, transparent 100%); */
}
.filmstrip__video-btn {
    flex: 0 0 auto;
    margin: 0;
    padding: 0;
    border: 0;
    background: transparent;
    touch-action: manipulation;
    /* Controls overlay the video, so they stay white in BOTH themes
       (not theme-flipped). */
    color: #fff;
    font-family: "Keykode Regular", monospace;
    font-size: var(--font-size-medium);
    letter-spacing: 0.04em;
    line-height: 1;
    cursor: pointer;
    -webkit-appearance: none;
    appearance: none;
}
.filmstrip__video-btn:hover,
.filmstrip__video-btn:focus-visible {
    opacity: 0.65;
}
.filmstrip__video-progress {
    flex: 1 1 auto;
    min-width: 0;
    padding: 6px 0;
    cursor: pointer;
    touch-action: none;
}
.filmstrip__video-progress-track {
    position: relative;
    height: 1px;
    background: rgba(255, 255, 255, 0.28);
}
.filmstrip__video-progress-fill {
    position: absolute;
    top: 0;
    left: 0;
    height: 100%;
    width: 100%;
    background: #fff;
    pointer-events: none;
    transform: scaleX(0);
    transform-origin: left center;
    will-change: transform;
}
.filmstrip__video-time {
    flex: 0 0 auto;
    min-width: 3.2em;
    font-family: "Keykode Extended", monospace;
    font-variant: small-caps;
    font-size: var(--font-size-medium);
    letter-spacing: 0.04em;
    color: #fff;
    text-align: right;
    font-variant-numeric: tabular-nums;
}
.filmstrip__thumb {
    position: relative;
    height: 100%;
    background: transparent;
    border: none;
    cursor: pointer;
    /* Reserve space below the frame for the absolute-positioned number.
       Keeping the number out of flex flow ensures the thumb's intrinsic
       width is driven by the frame alone — otherwise the number's text
       width ("01 / 05") would widen narrow thumbs and create visible gaps
       between frames. */
    padding: 0 0 14px 0;
    margin: 0;
    overflow: visible;
    flex: 0 0 auto;
    display: block;
}
.filmstrip__thumb-frame {
    display: block;
    /* Frame fills the thumb's content-box height (thumb height minus the
       reserved number padding); width is derived from that height via the
       per-thumb aspect-ratio. */
    height: 100%;
    width: auto;
    aspect-ratio: var(--thumb-ratio, var(--strip-ratio, 1.7778));
    /* See .filmstrip__cell for the background split rationale —
       inline `background-image` carries the ThumbHash placeholder. */
    background-color: #111;
    background-size: cover;
    background-position: center;
    background-repeat: no-repeat;
    overflow: hidden;
    position: relative;
    opacity: 0.55;
    transition: opacity 0.15s ease, outline-color 0.15s ease;
    outline: 1px solid transparent;
    outline-offset: 0;
}
.filmstrip__thumb:hover .filmstrip__thumb-frame {
    opacity: 0.85;
}
.filmstrip__thumb.is-active .filmstrip__thumb-frame {
    opacity: 1;
    /* outline-color: #fff; */
}
.filmstrip__thumb.is-active {
    cursor: default;
}
.filmstrip__thumb img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
}
.filmstrip__thumb-number,
.filmstrip__thumb-label {
    /* Pinned to the bottom of the thumb and constrained to the thumb's
       (== frame's) width so the number never widens the thumb. */
    position: absolute;
    left: 0;
    right: 0;
    bottom: 0;
    font-family: "Keykode Extended";
    font-variant: small-caps;
    font-size: var(--font-size-medium);
    line-height: 1;
    color: var(--c-fg);
    text-align: left;
    opacity: 0.75;
    letter-spacing: 0.04em;
    overflow: hidden;
    white-space: nowrap;
    pointer-events: none;
    text-transform: uppercase;
}
.filmstrip__thumb.is-active .filmstrip__thumb-number,
.filmstrip__thumb.is-active .filmstrip__thumb-label {
    opacity: 1;
}
/* .filmstrip__thumb-badge {
    position: absolute;
    bottom: 3px;
    left: 3px;
    padding: 1px 4px;
    font-family: "Keykode Regular";
    font-size: 9px;
    line-height: 1;
    color: var(--c-fg);
    background: var(--c-overlay-strong);
    pointer-events: none;
} */
.project-card {
    /* Transitions are declared here (not inside `body.is-stage-mode .project-card`)
       so that browsers reliably animate them: if the transition property itself
       changes at the same time as `opacity`/`transform`, the change is treated
       as instant. Keeping the declarations on the base selector means the
       transition is "always armed" and any subsequent value change animates. */
    transition: opacity 0.45s ease, transform 0.5s cubic-bezier(0.2, 0.6, 0.2, 1);
    transform-origin: center center;
}
/* Filmstrip browse: dim inactive cards while a player is active. When
   credits are open on another project, the credits-focus rule below
   takes over instead so only the credits card stays bright. */
body.has-active-player:not(.has-open-credits):not(.is-stage-mode):not(.has-expanded-player) .project-card:not(.is-player-active) {
    opacity: 0.35;
}
/* Same dimming when credits are open in filmstrip-rows / list browse
   (not stage or expanded — those layouts handle focus differently).
   Sequential open/close keeps the animating card bright via the
   is-filmstrip-* / is-credits-closing classes until [open] is set. */
body.has-open-credits:not(.is-stage-mode):not(.has-expanded-player) .project-card:not(:has([data-credits-primary][open])):not(.is-filmstrip-faded):not(.is-filmstrip-collapsed):not(.is-credits-closing) {
    opacity: 0.35;
}
body.has-open-credits:not(.is-stage-mode):not(.has-expanded-player) .project-card:has([data-credits-primary][open]),
body.has-open-credits:not(.is-stage-mode):not(.has-expanded-player) .project-card.is-filmstrip-faded,
body.has-open-credits:not(.is-stage-mode):not(.has-expanded-player) .project-card.is-filmstrip-collapsed,
body.has-open-credits:not(.is-stage-mode):not(.has-expanded-player) .project-card.is-credits-closing {
    position: relative;
    z-index: 1001;
}
/* Mobile inline preview: inactive project credits are not tappable while
   another filmstrip player is active in the row. */
@media (max-width: 768px) {
    body.has-active-player:not(.has-expanded-player):not(.is-stage-mode) .project-card:not(.is-player-active) .project-credits-button,
    body.has-active-player:not(.has-expanded-player):not(.is-stage-mode) .project-card:not(.is-player-active) .project-credits-button-second {
        pointer-events: none;
    }
}
/* body::before {
    content: '';
    position: fixed;
    inset: 0;
    background: #fff;
    z-index: 998;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.3s ease;
}
body.has-expanded-player::before {
    opacity: 1;
} */
body.has-expanded-player .project-card:not(.is-player-active) {
    display: none;
}
/* Desktop expanded: pin the active card in a fixed shell below the site
   header so wheel navigation swaps projects without page scroll jitter. */
@media (min-width: 769px) {
    html.is-expanded-shell:has(body.has-expanded-player),
    html.is-expanded-shell body.has-expanded-player {
        overflow: hidden;
    }
    html.is-expanded-shell body.has-expanded-player .project-card.is-player-active {
        position: fixed;
        top: var(--expanded-view-top, 0);
        left: 0;
        right: 0;
        bottom: 0;
        /* Match header + .project-list horizontal padding (8px). */
        padding-left: 8px;
        padding-right: 8px;
        box-sizing: border-box;
        z-index: 1000;
        display: flex;
        flex-direction: column;
        background: var(--c-bg);
        overflow: hidden;
    }
    html.is-expanded-shell body.has-expanded-player .project-card.is-player-active .project-header {
        flex: 0 0 auto;
        position: relative;
        top: auto;
        z-index: 1;
    }
    html.is-expanded-shell body.has-expanded-player .project-card.is-player-active .project-body {
        flex: 1 1 auto;
        min-height: 0;
        display: flex;
        flex-direction: column;
    }
    html.is-expanded-shell body.has-expanded-player .project-card.is-player-active .filmstrip-fold {
        flex: 1 1 auto;
        min-height: 0;
    }
    /* Subtle crossfade while wheel-navigating between projects. */
    html.is-expanded-shell.is-expanded-nav-crossfade .project-card.is-expanded-nav-outgoing {
        display: flex !important;
        flex-direction: column;
        position: fixed;
        top: var(--expanded-view-top, 0);
        left: 0;
        right: 0;
        bottom: 0;
        padding-left: 8px;
        padding-right: 8px;
        box-sizing: border-box;
        z-index: 1001;
        background: var(--c-bg);
        overflow: hidden;
        opacity: 0;
        pointer-events: none;
        transition: opacity 0.52s ease;
    }
    html.is-expanded-shell.is-expanded-nav-crossfade .project-card.is-expanded-nav-outgoing.is-expanded-nav-visible {
        opacity: 1;
    }
    html.is-expanded-shell.is-expanded-nav-crossfade .project-card.is-player-active.is-expanded-nav-incoming {
        opacity: 0;
        transition: opacity 0.52s ease;
    }
    html.is-expanded-shell.is-expanded-nav-crossfade .project-card.is-player-active.is-expanded-nav-incoming.is-expanded-nav-visible {
        opacity: 1;
    }
}
body.has-active-player .project-card.is-player-active {
    position: relative;
    z-index: 1001;
}
body.has-active-player .project-card.is-player-active .filmstrip {
    z-index: 1000;
}
footer {
    width: 100%;
    background: var(--c-bg);
    padding: 8px;
    display: grid;
    grid-template-columns: repeat(var(--columncount), 1fr);
    /* gap: 8px; */
    flex: 0 0 auto;
}
footer * {
    font-family: "Keykode Regular";
    font-size: var(--font-size-medium);
    text-transform: uppercase;
    line-height: 1;
}
.copyright {
    /* padding-left: 30px; */
    grid-column: 1 / span 2;
}
.copyright-inner  {
    display: flex;
    flex-direction: row;
    gap: 11px;
}
.copyright * {
    font-family: "Keykode Extended" ;
}
.copyright-year {
    font-family: "Keykode Extended" ;
    font-variant: small-caps;
}
.separator {
    grid-column: 3 / span 2;
}
.imprint {
    grid-column: 6 / span 2;
    /* justify-self: end; */
    cursor: pointer;
}
.imprint a {
    color: inherit;
    text-decoration: none;
}
.imprint a:hover {
    opacity: 0.65;
}

/* ---------------------------------------------------------------------------
   Imprint page
   --------------------------------------------------------------------------- */
.imprint-page {
    /* min-height: 100vh; */
    display: flex;
    flex-direction: column;
    padding: 8px;
    padding-top: 0;
    background: var(--c-bg);
}
.imprint-page__inner {
    flex: 1 1 auto;
    display: grid;
    grid-template-columns: repeat(var(--columncount), 1fr);
    padding-top: 8px;
    padding-bottom: 48px;
}
.imprint-page__address {
    grid-column: 1 / span 4;
    margin-bottom: 24px;
}
.imprint-page__text * {
    text-transform: unset;
}
.imprint-page__text {
    grid-column: 6 / -1;
}
.imprint-page__text p {
    margin-bottom: 16px;
    text-transform: unset;
}
.imprint-page__address,
.imprint-page__text {
    font-family: "Waldenburg";
    font-size: var(--font-size-medium-waldenburg);
    line-height: 1.35;
}
.imprint-page__address *,
.imprint-page__text * {
    font-family: "Waldenburg";
    text-transform: unset;
}
.imprint-page__footer {
    flex: 0 0 auto;
    padding: 0;
}

/* Imprint page: header chrome is inactive — dim everything except the
   site title (home link) and the light/dark toggle. */
body.is-imprint-page .info-settings,
body.is-imprint-page .settings-container,
body.is-imprint-page .infofoldout {
    opacity: 0.35;
    pointer-events: none;
}
body.is-imprint-page .pic-invert-btn {
    opacity: 1;
    pointer-events: auto;
}
body.is-imprint-page .site-title {
    opacity: 1;
    pointer-events: auto;
}
body.is-imprint-page .site-title__link {
    color: inherit;
    text-decoration: none;
    cursor: pointer;
}
body.is-imprint-page .site-title__link:hover {
    opacity: 0.65;
}
@media (max-width: 768px) {
    .imprint-page__address,
    .imprint-page__text {
        grid-column: 1 / -1;
    }
}
@media (max-width: 15400px) {
    :root {
        --font-size-small: 6px;
        --font-size-medium: 10px;
        --font-size-medium-waldenburg: 9.5px;
    }
    .project-credits-button {
        /* left: -9px; */
    }
}

/* ---------------------------------------------------------------------------
   Stage Mode (active when zoom = 100%)
   The project list collapses into a fullscreen, scroll-snapped hero view:
   one shared background <video>/<iframe> plays the active project's preview,
   the active project's header is centered over it, and scrolling navigates
   project-to-project without scrolling the video itself.
   --------------------------------------------------------------------------- */

.stage-background {
    /* --video-aspect is set per project (width / height of the video) so the
       iframe can be sized to fully cover the viewport regardless of ratio.
       Defaults to 16:9 if no project is mounted yet. */
    --video-aspect: 1.7778;
    position: fixed;
    inset: 0;
    z-index: 1;
    /* Follows the active theme during enter/exit so WAAPI fades don't
       flash the wrong colour. Once stage is fully open, body.is-stage-open
       switches this to fixed black for consistent credits dimming. */
    background: var(--c-bg);
    /* The actual enter/exit animation (opacity + scale ease) is driven via
       the Web Animations API from home.php; CSS just provides the rest
       state and the transform-origin. */
    opacity: 0;
    pointer-events: none;
    transform-origin: center center;
    overflow: hidden;
}
/* <video> respects object-fit natively. */
.stage-background video {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    border: 0;
    display: block;
    pointer-events: none;
    z-index: 1;
    opacity: 0;
    transition: opacity 0.45s ease;
}
.stage-background video.is-loaded {
    opacity: 1;
}
/* Iframes don't respect object-fit; the inner Vimeo player letterboxes to
   its own aspect ratio. To make the actual video cover the viewport, we
   oversize the iframe past viewport bounds (whichever dimension is shorter
   for that video's aspect) and center it via translate. */
.stage-background iframe {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 100vw;
    height: calc(100vw / var(--video-aspect, 1.7778));
    min-height: 100vh;
    min-width: calc(100vh * var(--video-aspect, 1.7778));
    max-width: none;
    max-height: none;
    transform: translate(-50%, -50%);
    border: 0;
    display: block;
    pointer-events: none;
    z-index: 1;
    opacity: 0;
    transition: opacity 0.55s ease;
}
.stage-background iframe.is-loaded {
    opacity: 1;
}

html.is-stage-mode,
body.is-stage-mode {
    scroll-snap-type: y mandatory;
    overscroll-behavior-y: none;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
    -ms-overflow-style: none;
}
/* iOS: scroll-snap fights programmatic scroll during expanded→stage
   entry and snaps back to project 1. Disabled for the brief entry pass
   (see beginStageEntryScroll in home.php). */
html.is-stage-mode.is-stage-entry-scroll,
body.is-stage-mode.is-stage-entry-scroll {
    scroll-snap-type: none !important;
}
html.is-stage-mode.is-stage-entry-scroll .project-card,
body.is-stage-mode.is-stage-entry-scroll .project-card {
    scroll-snap-align: none;
}

/* Desktop Safari workaround: root scroll-snap can dead-end. When
   `is-safari-desktop` is present, make BODY the only scroll container. */
html.is-safari-desktop.is-stage-mode {
    overflow: hidden;
    height: 100%;
    scroll-snap-type: none;
}
body.is-safari-desktop.is-stage-mode {
    height: 100%;
    overflow-y: auto;
    scroll-snap-type: y mandatory;
    -webkit-overflow-scrolling: auto;
}
html.is-stage-mode::-webkit-scrollbar,
body.is-stage-mode::-webkit-scrollbar {
    display: none;
    width: 0;
    height: 0;
}
body.is-stage-mode {
    overflow-x: hidden;
}

body.is-stage-mode .stage-background {
    opacity: 1;
    transform: scale(1);
    pointer-events: auto;
    /* cursor: zoom-out; */
    /* The enter/exit easing comes from WAAPI (animateStageBgIn/Out). These
       CSS values match the end keyframes so once the animation completes
       and releases control, other state-driven rules can take over
       without a visual jump. */
}
body.is-stage-mode.is-stage-open .stage-background {
    background: #0e0e0e;
}
body.is-stage-mode .project-list {
    padding: 0;
    background: transparent;
}
body.is-stage-mode .project-card,
body.is-stage-mode .project-card:not(.is-player-active) {
    min-height: 100vh;
    min-height: 100dvh;
    height: 100vh;
    height: 100dvh;
    position: relative;
    z-index: 5;
    padding: 0;
    background: transparent;
    scroll-snap-align: center;
    scroll-snap-stop: always;
    scroll-margin: 0;
    /* Every card stays fully opaque in stage mode. Each card's header
       is vertically centred in its own slide (top: 50%), so when
       scroll-snap centres the card the header lands on the viewport
       centre. Inactive cards still show no player — see .filmstrip
       display:none rules below — so only one video is ever on screen.
       The doubled selector is needed because the generic
       `body.has-active-player .project-card:not(.is-player-active)`
       (specificity 0,3,1) would otherwise drop inactive cards to
       opacity 0.35 and trigger the .project-card opacity transition
       once the new card gains .is-player-active. We match that
       specificity (0,3,1) with the :not() variant so order-of-source
       wins it for stage mode.
       We also disable the opacity transition entirely in stage mode —
       opacity is constant at 1, so any transient class-change shouldn't
       animate. */
    opacity: 1;
    transition: none;
    pointer-events: none;
}
body.is-stage-mode .project-card.is-stage-active {
    /* pointer-events stays `none` so clicks land on the fullscreen
       player overlay (which exits stage mode); interactive children
       like the project header re-enable pointer-events below. */
    pointer-events: none;
    /* CRITICAL: drop the stacking context on the ACTIVE card.
       Inactive cards still get z-index: 5 (above stage-background) from
       the rule above. But the active card holds three positioned
       children — .filmstrip__player (fixed, z 2), .project-body
       (absolute, z 1000) and .project-header (absolute, z 1001). If the
       card creates its own stacking context, the player's position:fixed
       + compositor-layer promotion paints above .project-body inside the
       card's context, regardless of z-index. Letting the active card
       fall back to z-index: auto lets those three children float up to
       the root stacking context where z-index ordering is reliably
       honored by the compositor. */
    z-index: auto;
}

body.is-stage-mode .project-card:not(.is-stage-active) .project-header {
    pointer-events: none;
}

body.is-stage-mode .project-header {
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    transform: translateY(-50%);
    z-index: 1001;
    padding: 0 8px;
    color: var(--c-fg);
    background: transparent;
    pointer-events: auto;
}
body.is-stage-mode .project-card.is-stage-active .project-header {
    /* Clicks on the title / number band pass through to the player
       below; credits toggles re-enable hits on themselves only. */
    pointer-events: none;
}
body.is-stage-mode .project-card.is-stage-active .project-credits-button,
body.is-stage-mode .project-card.is-stage-active .project-credits-button-second {
    pointer-events: auto;
}
body.is-stage-mode .project-header *,
body.is-stage-mode .project-header a {
    /* Stage chrome sits over the fullscreen video, so it stays white in
       BOTH themes (not theme-flipped) for legibility. */
    color: #fff;
}
body.is-stage-mode .project-card.is-stage-active .project-title-first,
body.is-stage-mode .project-card.is-stage-active .project-title-second {
    pointer-events: none;
    cursor: pointer;
}

body.is-stage-mode .project-body {
    position: absolute;
    top: 50%;
    left: 0;
    right: 0;
    z-index: 1000;
    margin-top: 4px;
    padding: 0 8px;
    pointer-events: none;
}
body.is-stage-mode .project-body > * {
    pointer-events: auto;
}

/* All filmstrips hidden in stage mode... */
body.is-stage-mode .filmstrip {
    display: none !important;
}
/* ...except the active card's, which we reveal as a zero-height,
   no-layout-contribution shell — the only thing visible inside it is
   the player, promoted to fullscreen via position:fixed below. The
   rows, curated stills and thumb slider are hidden so they don't paint
   in the background. */
body.is-stage-mode .project-card.is-stage-active .filmstrip {
    display: block !important;
    height: 0 !important;
    min-height: 0 !important;
    padding: 0 !important;
    overflow: visible;
}
body.is-stage-mode .project-card.is-stage-active .filmstrip__rows,
body.is-stage-mode .project-card.is-stage-active .filmstrip__curated,
body.is-stage-mode .project-card.is-stage-active .filmstrip__thumbs {
    display: none !important;
}
/* The actual stage video: the SAME filmstrip player that the user just
   saw in expanded mode, now visually re-laid-out to cover the viewport.
   The iframe inside is never re-parented — only its CSS box changes —
   so Vimeo/the <video> element keep playing without a reload.

   Z-index 2 is intentionally LOW. The active card is set to
   z-index: auto (see the .is-stage-active rule above), so this player,
   .project-body (z-index 8) and .project-header (z-index 10) all
   participate in the root stacking context and the compositor honors
   the z-index ordering — body and header paint above the player. */
body.is-stage-mode .project-card.is-stage-active .filmstrip__player {
    position: fixed;
    top: 0;
    left: 0;
    width: 100vw;
    height: 100vh;
    z-index: 2;
    opacity: 1;
    transition: opacity 0.3s ease;
    pointer-events: auto;
    /* Isolate the iframe/video from the cursor-label's mix-blend-mode
       (difference) so Safari doesn't flatten the player into the label's
       blend group and blank it when the label hides over the header. */
    isolation: isolate;
    /* cursor: zoom-out; */
    /* Transparent so the stage-background poster (loading state for new
       projects scrolled to in stage mode) can show through behind the
       iframe before it has data. */
    background: transparent;
}
/* Cover-iframe / cover-video sizing identical to the old
   .stage-background trick, but applied to the in-place player so we
   don't need to move the element. --strip-ratio cascades from the
   parent .filmstrip. */
body.is-stage-mode .project-card.is-stage-active .filmstrip__player iframe,
body.is-stage-mode .project-card.is-stage-active .filmstrip__player video {
    position: absolute;
    top: 50%;
    left: 50%;
    width: 100vw;
    height: calc(100vw / var(--strip-ratio, 1.7778));
    min-height: 100vh;
    min-width: calc(100vh * var(--strip-ratio, 1.7778));
    max-width: none;
    max-height: none;
    transform: translate(-50%, -50%);
    object-fit: cover;
    pointer-events: none;
    /* Opacity fade-in is inherited from the universal
       .filmstrip__player iframe rule above (opacity 0 → 1 on
       .is-revealed). Visibility split:
         .is-loaded   — browser says ready (set by attachMediaReadyHandler)
         .is-revealed — explicit green-light (bindPlayerMediaReveal adds
                        it after a small settle delay that hides the
                        typical black starting frame; in stage mode the
                        delay is longer, see MEDIA_REVEAL_STAGE_MS). */
}
/* Match the iframe/video's `object-fit: cover` framing on the blurred
   poster while in stage mode. Outside stage mode the poster uses
   `object-fit: contain` so it lines up seamlessly with the contained
   iframe in the filmstrip layout; in stage mode the player is
   promoted to fullscreen and the iframe is oversized to cover the
   viewport — the poster needs to follow suit so the underlying blur
   crop matches what the video will paint when it fades in. */
body.is-stage-mode .project-card.is-stage-active .filmstrip__player-poster {
    object-fit: cover;
}
/* Hide the still-image overlay (used by the thumb slider in expanded
   mode) in stage view — stage mode is always the video. */
body.is-stage-mode .project-card.is-stage-active .filmstrip__player img.filmstrip__player-still {
    display: none;
}
/* Safety net: if the mobile expanded carousel survives a frame, don't
   let its horizontal scroller capture touch in stage mode. */
body.is-stage-mode .filmstrip__player-carousel {
    overflow: hidden !important;
    touch-action: none !important;
    pointer-events: none !important;
}
body.is-stage-mode .project-info-container {
    color: #fff;
    max-height: 45vh;
    background: transparent;
    transition:
        grid-template-rows 0.4s cubic-bezier(0.4, 0, 0.2, 1),
        opacity 0.3s ease,
        background 0.3s ease;
}
body.is-stage-mode .project-card.is-stage-active:has([data-credits-primary][open]) .project-info-container {
    /* Dark scrim on the credits panel only — video stays visible above. */
    /* background: rgba(0, 0, 0, 0.58); */
}
/* Slight video dim while credits are open (player only — not the backdrop,
   and not stacked with a second dim layer). Black scrim via ::after so
   light-mode page background doesn't bleed through opacity dimming. */
body.is-stage-mode.has-open-credits .project-card.is-stage-active .filmstrip__player::after {
    content: '';
    position: absolute;
    inset: 0;
    background: rgba(0, 0, 0, 0.45);
    z-index: 2;
    pointer-events: none;
    transition: opacity 0.3s ease;
}
body.is-stage-mode .project-info-container,
body.is-stage-mode .project-info-container * {
    color: #fff;
    /* text-shadow:
        0 1px 10px rgba(0, 0, 0, 0.85),
        0 0 24px rgba(0, 0, 0, 0.45); */
}

body.is-stage-mode footer {
    display: none;
}

/* Header stays at top but lets the stage video show through.
   Text/icons get a subtle shadow so they remain legible on any video. */
body.is-stage-mode header {
    position: fixed;
    left: 0;
    right: 0;
    top: 0;
    background: transparent;
}
body.is-stage-mode header,
body.is-stage-mode header * {
    color: #fff;
}
/* Stage-mode dim is applied per BUTTON rather than to the
   .info-settings container — that way hovering a single button can
   lift only that one to full opacity. (Dimming the container instead
   would compound multiplicatively with any child opacity, so lifting
   a single child back to "1" inside a 0.35 container would require an
   invalid >1 opacity value.) Inactive zoom steps are already dimmed
   outside stage mode (see .zoom-step base rule); the stage-mode rule
   below just pushes their dim a touch deeper to match info/pic-invert. */
body.is-stage-mode .info-btn,
body.is-stage-mode .zoom-step:not(.is-active) {
    opacity: 0.35;
    transition: opacity 0.25s ease;
}
body.is-stage-mode .pic-invert-btn {
    opacity: 0;
    pointer-events: none;
    cursor: default;
}
body.is-stage-mode .info-btn:hover,
body.is-stage-mode.is-info-open .info-btn,
body.is-stage-mode .zoom-step:not(.is-active):hover {
    opacity: 1;
}
body.is-stage-mode .site-title {
    opacity: 1;
}
/* media query for mobile screen sizes */
@media (max-width: 768px) {
    :root {
        --font-size-small: 5px !important;
        --font-size-medium: 9px !important;
        --font-size-medium-waldenburg: 9px !important;
        --columncount: 30 !important;
    }
    header {
        padding-bottom: 12px;
        padding-top: 0;
        padding-left: 0;
        padding-right: 0;
    }
    .site-title {
        font-size: var(--font-size-medium);
        grid-column: 1 / span 11;
        padding: 8px;
    }
    .info-settings {
        grid-column: 13 / span 3;
        padding: 8px;
    }
    .controlcontainer {
        grid-column: 18 / -1;
        justify-content: space-between;
    }
    .pic-invert-btn {
        padding: 8px;
    }
    .pic-invert-btn__icon--inverted {
        top: unset;
        left: unset;
    }
    .settings-container {
        justify-content: space-between;
        width: 100%;
    }
    .zoom-controller {
        width: 100%;
        justify-content: space-between;
    }
    .zoom-step {
        padding: 8px;
    }
    .zoom-step[data-zoom-step="1"] {
        display: none;
    }
    .zoom-step[data-zoom-step="2"] {
        display: none;
    }
    .zoom-step[data-zoom-step="3"] .zoom-step__label-desktop {
        display: none;
    }
    .zoom-step[data-zoom-step="3"] .zoom-step__label-mobile {
        display: inline;
    }
    body.is-mobile-filmstrip-zoom:not(.has-expanded-player):not(.is-stage-mode) .project-card .project-header,
    body.is-mobile-filmstrip-zoom:not(.has-expanded-player):not(.is-stage-mode) .project-card .project-info-container {
        cursor: pointer;
        -webkit-tap-highlight-color: transparent;
    }
    .project-header {
        padding: 0;
        align-items: center;
    }
    body.is-list-mode .project-header:first-of-type {
        padding-top: 0px;
    }
    .project-number {
        grid-column: 1 / span 7;
        padding: 3px 0 4px 0;
    }
    .project-number-second {
        display: none;
    }
    .project-title-second {
        display: none;
    }
    .project-credits-button-second {
        display: none;
    }
    .project-number {
        grid-column: 1 / span 7;
    }
    .project-number * {
        line-height: 1.3;
    }
    .project-title-first {
        grid-column: 9 / span 16;
        display: block;
    }
    .project-title-first a {
        display: block;
        padding: 3px 0 4px 0;
    }
    .project-credits-button {
        grid-column: 25 / -1;
    }
    .credits-summary {
        text-align: right;
        padding: 3px 0 4px 0;
    }
    .description-text {
        grid-column: 1 / -1;
    }
    .credits-text {
        grid-column: 1 / -1;
    }
    .project-info-fold-inner {
        display: flex;
        flex-direction: column;
        min-height: 0;
        overflow: hidden;
    }
    .project-card:has([data-credits-primary][open]) .credits-text {
        padding-bottom: 0;
    }
    body.is-stage-mode .project-info-container {
        max-height: 50vh;
    }
    .project-info-container {
        /* Single 0fr → 1fr track; description + credits stack inside
           .project-info-fold-inner at their natural height. */
        grid-template-rows: 0fr;
    }
    .project-info-fold-inner {
        grid-column: 1 / -1;
    }
    .project-card:has([data-credits-primary][open]) .project-info-container {
        grid-template-rows: 1fr;
        padding-bottom: 16px;
    }
    .filmstrip.is-expanded[data-has-curated] .filmstrip__video-controls {
        left: 0%;
        right: 0%;
    }
    .infofoldout {
        /* On mobile the three foldout containers stack full-width:
        .infotextcontainer (row 1), .selectedclientscontainer (row 2),
        .contactinfocontainer (row 3). Desktop uses a single 0fr → 1fr
        track; mobile needs one explicit track per row so every row
        collapses in lockstep — an implicit third row would stay visible
        when closed. Padding-top + row-gap live on the open state only. */
        grid-template-rows: 0fr 0fr 0fr;
        padding-top: 0;
        row-gap: 0;
        padding-left: 8px;
        padding-right: 8px;
    }
    body.is-info-open .infofoldout {
        grid-template-rows: 1fr 1fr 1fr;
        padding-top: 0px;
        /* row-gap: 8px; */
    }
    .infofoldout a {
        text-decoration: none;
    }
    .infotextcontainer {
        grid-column: 1 / -1;
        padding-right: 0;
    }
    .selectedclientscontainer {
        grid-column: 1 / -1;
    }
    .contactinfocontainer {
        grid-column: 1 / -1;
        grid-row: 3;
    }
    .contactinfocontainer p {
        /* justify-self: end; */
    }
    p {
        line-height: 1.3;
    }
    .copyright {
        padding-left: 0;
        grid-column: 1 / span 9;
    }
    .separator {
        grid-column: 12 / span 12;
    }
    .imprint {
        grid-column: -1;
    }
    /* iOS: lock slide height to svh. A full-height phantom slide after the
       last project gives the final card room to centre (like an extra snap
       step) without letting scroll land on a blank project. */
    body.is-stage-mode .stage-phantom-slide {
        display: block;
        flex: 0 0 100svh;
        height: 100svh;
        pointer-events: none;
        scroll-snap-align: none;
        visibility: hidden;
    }
    body.is-stage-mode .project-card,
    body.is-stage-mode .project-card:not(.is-player-active) {
        min-height: 100svh;
        height: 100svh;
        scroll-snap-stop: normal;
    }
    body.is-stage-mode {
        --stage-chrome-offset: calc(50dvh - 50svh);
    }
    body.is-stage-mode .project-header {
        top: 50%;
        transform: translateY(calc(-50% + var(--stage-chrome-offset, 0px)));
    }
    /* Credits panel only — not .project-body, which would re-anchor the
       fixed stage player (transform creates a containing block). */
    body.is-stage-mode .project-info-container {
        transform: translateY(var(--stage-chrome-offset, 0px));
    }

    /* Mobile-only: let each filmstrip scroll its stills horizontally.
       justifyStrip() (home.php) shows every cell on mobile, so the row
       overflows the strip width — this turns that overflow into a
       touch-scrollable strip. Scoped to the browse rows: stage mode
       hides the strip, and expanded mode needs the player clipped, so
       both keep the base `overflow: hidden`. Scrollbar hidden to match
       the rest of the mobile chrome. */
    body:not(.is-stage-mode) .filmstrip:not(.is-expanded) {
        overflow-x: auto;
        overflow-y: hidden;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: none;
        -ms-overflow-style: none;
    }
    body:not(.is-stage-mode) .filmstrip:not(.is-expanded)::-webkit-scrollbar {
        display: none;
        width: 0;
        height: 0;
    }
    /* Keep the video player pinned on the left while stills scroll away
       to the right (active browse rows only — expanded uses the carousel). */
    body:not(.is-stage-mode) .filmstrip:not(.is-expanded).is-playing .filmstrip__player {
        position: sticky;
        left: 0;
        flex: 0 0 auto;
        z-index: 3;
    }

    /* -- Mobile expanded view --------------------------------------------
       Stack the filmstrip (player + curated stills) above the credits
       panel instead of overlaying. Credits stay permanently folded out
       below the player (no toggle). Also collapse inactive cards out of
       flow so the active project can snap flush under the header. */
    html:has(body.has-expanded-player),
    body.has-expanded-player {
        overscroll-behavior-y: none;
    }
    body.has-expanded-player.is-expanded-nav-swapping {
        overflow: hidden;
    }
    body.has-expanded-player .project-card:not(.is-player-active) {
        display: none;
    }
    body.has-expanded-player:not(.is-stage-mode) .project-card.is-player-active .project-body {
        /* Use flex so `order` reliably stacks fold + credits. */
        display: flex;
        flex-direction: column;
    }
    body.has-expanded-player:not(.is-stage-mode) .project-card.is-player-active .filmstrip-fold {
        order: 1;
    }
    /* Credits always folded out below the player — no toggle fold. */
    body.has-expanded-player:not(.is-stage-mode) .project-card.is-player-active .project-info-container {
        order: 2;
        position: static;
        inset: auto;
        z-index: auto;
        overflow: hidden;
        padding: 0;
        padding-bottom: 16px;
        grid-template-rows: 1fr;
        opacity: 1;
        pointer-events: auto;
        margin-top: 0;
        transition: margin-top 0.35s ease;
    }
    /* List → expanded keeps body.is-list-mode; list-mode expanded overrides
       elsewhere can flip the body back to block/grid and break ordering.
       Re-assert the mobile stacked layout with list-specific selectors. */
    body.has-expanded-player.is-list-mode:not(.is-stage-mode) .project-card.is-player-active .project-body {
        display: flex;
        flex-direction: column;
    }
    body.has-expanded-player.is-list-mode:not(.is-stage-mode) .project-card.is-player-active .filmstrip-fold {
        order: 1;
        grid-template-rows: 1fr;
        opacity: 1;
        pointer-events: auto;
        overflow: visible;
    }
    body.has-expanded-player.is-list-mode:not(.is-stage-mode) .project-card.is-player-active .project-info-container {
        order: 2;
    }
    /* List → expanded keeps body.is-list-mode so the zoom control stays on "0".
       That mode has a more-specific desktop override that absolutely positions
       the credits panel as an overlay. On mobile we always want the stacked
       layout, so explicitly override the list-specific rule here. */
    body.has-expanded-player.is-list-mode:not(.is-stage-mode) .project-card.is-player-active .project-info-container {
        order: 2;
        position: static;
        inset: auto;
        z-index: auto;
        overflow: hidden;
        padding: 0;
        padding-bottom: 16px;
        grid-template-rows: 1fr;
        opacity: 1;
        pointer-events: auto;
        margin-top: 0;
        transition: margin-top 0.35s ease;
    }
    body.has-expanded-player.is-thumbs-hidden:not(.is-stage-mode) .project-card.is-player-active:has([data-filmstrip][data-has-curated]) .project-info-container {
        margin-top: calc(-1 * var(--curated-thumb-chrome, 55px));
        position: relative;
        z-index: 1;
        overflow: visible;
    }
    body.has-expanded-player:not(.is-stage-mode) .project-card.is-player-active .project-info-container,
    body.has-expanded-player:not(.is-stage-mode) .project-card.is-player-active .project-info-container * {
        color: var(--c-fg) !important;
        text-shadow: none;
    }
    body.has-expanded-player:not(.is-stage-mode) .project-card.is-player-active .project-credits-button {
        opacity: 1;
        pointer-events: auto;
        cursor: pointer;
    }
    /* Mobile expanded: player size is fixed for the session — curated
       stills may fade but the player must not reclaim their space. */
    body.has-expanded-player .filmstrip.is-expanded[data-has-curated] .filmstrip__player,
    body.has-expanded-player.is-thumbs-hidden .filmstrip.is-expanded[data-has-curated] .filmstrip__player {
        height: calc(100% - var(--curated-thumb-h, 12%) - 22px);
    }
    body.has-expanded-player .filmstrip.has-thumbs-anim[data-has-curated] .filmstrip__player {
        transition: none;
    }
    body.has-expanded-player.is-thumbs-hidden .filmstrip.is-expanded .filmstrip__nav {
        bottom: calc(var(--curated-thumb-h, 0px) + 22px);
    }
    /* Mobile expanded: swipe through stills instead of side tap zones. */
    .filmstrip.is-expanded .filmstrip__nav {
        display: none !important;
    }
    /* Let credits slide up through the faded thumb zone — the strip
       bg would otherwise paint over them (light theme reads as white). */
    body.has-expanded-player .filmstrip.is-playing.is-expanded.has-thumbs-anim {
        background: transparent;
    }
    body.has-expanded-player.is-thumbs-hidden .filmstrip.is-playing.is-expanded.has-thumbs-anim .filmstrip__thumbs {
        background: transparent;
    }
    /* Crossfade while swiping / scrolling between projects in mobile expanded. */
    html.is-expanded-nav-crossfade body.has-expanded-player .project-card.is-expanded-nav-outgoing {
        display: flex !important;
        flex-direction: column;
        position: fixed;
        top: var(--expanded-view-top, 0);
        left: 0;
        right: 0;
        bottom: 0;
        padding: 0 8px;
        box-sizing: border-box;
        z-index: 1002;
        background: var(--c-bg);
        overflow: hidden;
        opacity: 0;
        pointer-events: none;
        will-change: opacity;
        transition: opacity 0.52s ease;
    }
    html.is-expanded-nav-crossfade body.has-expanded-player .project-card.is-expanded-nav-outgoing.is-expanded-nav-visible {
        opacity: 1;
    }
    html.is-expanded-nav-crossfade body.has-expanded-player .project-card.is-player-active.is-expanded-nav-incoming {
        opacity: 0;
        will-change: opacity;
        transition: opacity 0.52s ease;
    }
    html.is-expanded-nav-crossfade body.has-expanded-player .project-card.is-player-active.is-expanded-nav-incoming.is-expanded-nav-visible {
        opacity: 1;
    }
    body:not(.is-stage-mode):not(.has-expanded-player)
    .project-card:is(:has([data-credits-primary][open]), .is-credits-closing)
    .credits-text > :first-child {
        padding-top: 3px;
    }
    /* List mode keeps top padding on the first block while credits fold. */
    body.is-list-mode:not(.is-stage-mode):not(.has-expanded-player)
    .project-card:is(:has([data-credits-primary][open]), .is-credits-closing)
    .credits-text > :first-child {
    padding-top: 8px;
    }
    
}

/* Touch expanded: native horizontal scroll through video + stills. */
@media (max-width: 768px), (hover: none), (pointer: coarse) {
    /* Block native Vimeo / <video> tap handling in expanded view so
       custom controls own play/pause and sound (carousel slides also
       set this per-slide; this covers players without a carousel). */
    .filmstrip.is-expanded .filmstrip__player > iframe,
    .filmstrip.is-expanded .filmstrip__player > video,
    .filmstrip.is-expanded .filmstrip__player > .filmstrip__player-poster {
        pointer-events: none !important;
    }
    .filmstrip__player.has-player-carousel {
        overflow: hidden;
    }
    .filmstrip__player-carousel {
        display: flex;
        flex-direction: row;
        width: 100%;
        height: 100%;
        overflow-x: auto;
        overflow-y: hidden;
        /* Vertical swipes pass through to the page for project nav /
           credits scroll; horizontal swipes stay on the carousel. */
        touch-action: pan-x;
        scroll-snap-type: x mandatory;
        -webkit-scroll-snap-type: x mandatory;
        -webkit-overflow-scrolling: touch;
        scroll-behavior: smooth;
        scrollbar-width: none;
        -ms-overflow-style: none;
    }
    .filmstrip__player-carousel::-webkit-scrollbar {
        display: none;
        width: 0;
        height: 0;
    }
    .filmstrip__player-slide {
        flex: 0 0 100%;
        width: 100%;
        height: 100%;
        scroll-snap-align: center;
        scroll-snap-stop: always;
        position: relative;
        overflow: hidden;
        background: #0e0e0e;
    }
    .filmstrip__player-slide .filmstrip__player-still,
    .filmstrip__player-slide iframe,
    .filmstrip__player-slide video,
    .filmstrip__player-slide .filmstrip__player-poster {
        display: block;
        width: 100%;
        height: 100%;
        object-fit: contain;
    }
    .filmstrip__player-slide iframe,
    .filmstrip__player-slide video {
        pointer-events: none;
    }
    .filmstrip__player.has-player-carousel .filmstrip__video-controls {
        z-index: 7;
    }
    .filmstrip__player-still.filmstrip__player-still--incoming,
    .filmstrip__player-still.filmstrip__player-still--outgoing {
        position: absolute;
        inset: 0;
        z-index: 2;
    }
    .filmstrip__player-still.filmstrip__player-still--incoming {
        opacity: 0;
        transition: opacity 0.35s ease;
        z-index: 3;
    }
    .filmstrip__player-still.filmstrip__player-still--incoming.is-visible {
        opacity: 1;
    }
    .filmstrip__player-still.is-fading-out,
    .filmstrip__player iframe.is-fading-out,
    .filmstrip__player video.is-fading-out,
    .filmstrip__player-poster.is-fading-out {
        opacity: 0 !important;
        transition: opacity 0.35s ease;
    }
}