/* Self-hosted variable font (replaces the Google Fonts call). The single TTF
   covers the whole 100–900 weight range used across the page. */
@font-face {
    font-family: "Lexend Deca";
    src: url('/LexendDeca-VariableFont_wght.ttf') format('truetype-variations');
    font-weight: 100 900;
    font-style: normal;
    font-display: swap;
}

* {
    margin: 0;
    padding: 0;
    box-sizing: border-box;
    user-select: none;
    font-family: "Lexend Deca", sans-serif;
    font-weight: 600;
    text-decoration: none;
}

/* ── Layout model ──────────────────────────────────────────────────────────────
   The page is a "stage": a box exactly the size of the visible area, which never
   scrolls and never moves. Every scroll happens INSIDE it — in #scroller, and
   independently in the image panel.

   Two facts make the whole thing static, and they depend on each other:

   1. THE DOCUMENT IS LOCKED (html, body { overflow: hidden }). Because it can
      never scroll, the browser's chrome can never retract — so the visible height
      is a CONSTANT, not something to track. That constant is --stage-h, and it is
      plain CSS: no JavaScript, no measurement, no resize listeners.
      (The cost is real and deliberate: the URL bar will never collapse. Undoing
      that means unlocking the document and making it the scroller again — see the
      note on #scroller.)

   2. <body> IS THE CONTAINING BLOCK for the stage layers, because it is
      position:relative and they are position:absolute. Since the document cannot
      scroll, absolute and fixed are behaviourally identical here — and absolute
      resolves against the stage box, which is what we want, whereas fixed resolves
      against the initial containing block, which on mobile is the LARGE viewport
      (chrome retracted) and would put anything bottom-anchored behind the chrome.
      This is load-bearing: it is why #navDiv's bottom clears the browser bar.
      An earlier version got the same effect with transform:translateY() on <body>,
      which works only as a side effect (a transform establishes a containing block
      for fixed descendants) and drags in hit-testing that doesn't follow the paint,
      a forced stacking context and a whole-page compositing layer. Don't go back.

   viewport-fit=cover (index.php) extends the stage under the system bars, so
   anything anchored to an edge pays it back with env(safe-area-inset-*) — see
   #navDiv and #contactDiv. On Chrome 135+ Android that inset is live and non-zero
   in a normal tab, not only in an installed PWA.

   TWO ARRANGEMENTS, chosen by ONE condition (see the single two-column block near
   the bottom of this file; everything above it is the stacked base):
     stacked   — image panel across the top half, headline list below it. The
                 selection line sits on the panel's bottom edge. Needs vertical
                 room, since it spends half the stage on the panel.
     2-column  — image panel is a left column, headline list a right column. The
                 selection line moves to the top of the scroller, so the topmost
                 headline is the selected one. Spends width instead of height.
   Which is why the condition is not a width alone: a phone held sideways is wide
   and short, so it wants the 2-column arrangement for the same reason a desktop
   does. The block spells this out.

   Every SIZE on the page comes from the fluid scale in :root below — there is no
   second breakpoint, and no bare px font-size anywhere. Anything that has to fit
   vertically as well as horizontally clamps against svh too; see the note there.

   Stacking order (page level; .still has its own local ladder, see below):
     0  #intro        fullscreen video, behind everything
     1  #scroller     the one real scroller
     2  .content-buffer   image panel, over the scroller
     3  #navDiv
     4  body::before  top edge mask
     5  body::after   contact scrim — over the mask, so the dimming has no seam
     6  #contactDiv   contact card, the only thing above the scrim */
:root {
    /* ── Geometry ── */
    /* THE STAGE HEIGHT — the one number the whole layout derives from.
       svh is the viewport with the browser chrome EXPANDED, which (see the layout
       model above) is the only state this page can ever be in, so this is exact
       rather than an estimate. dvh where available: it is identical while the
       chrome is up, but self-corrects in the cases svh cannot see — Chrome's
       dynamic bottom "chin" retracting, or a manual "hide toolbar". It cannot
       jitter here, because jitter comes from scrolling and this document can't.
       Do NOT reintroduce 100vh: it is an alias for 100lvh (chrome retracted) and
       is exactly the "too tall, bottom swallowed" bug. */
    --stage-h: 100svh;
    /* The divider line where the image panel ends. Desktop overrides it (the panel
       becomes a column). */
    --divider: calc(var(--stage-h) * (11 / 20));
    /* Breathing room between the selection line and where a headline settles. */
    --snap-offset: 0px;
    /* THE selection line: where a headline settles and, by definition, which
       headline counts as active. Everything downstream derives from this —
       #scroller's scroll-padding-top, both spacers, and script.js, which reads it
       back off the resolved scroll-padding-top rather than re-deriving the
       geometry from the panel. */
    --snap-line: calc(var(--divider) + var(--snap-offset));
    /* How far the list runs on past the selected headline before it is cut off.
       A section hugs its text and carries no vertical spacing at all (the * reset
       at the top zeroes every margin, and #scroller section sets only side
       padding), so one section IS one line box of --fs-headline — the 1.25 is
       Lexend Deca's `normal` line-height. Adjust --sections-ahead alone; the cut
       follows the fluid type at every size because --section-h is derived, not
       typed in. #scroller masks itself to --list-cut. */
    --section-h: calc(var(--fs-headline) * 1.25);
    --sections-ahead: 6;
    --list-cut: calc(var(--snap-line) + var(--sections-ahead) * var(--section-h));
    /* How long the cut takes to happen. Measured BACK from --list-cut, so that
       stays the honest end of the list and --sections-ahead keeps meaning what it
       says: one section's worth of fade = four solid headlines and a fifth that
       dissolves. 0px puts the hard edge back. */
    --list-fade: calc(var(--section-h) * 3);
    /* Desktop two-column split: the panel's width/height and its left inset. The
       headline column takes the rest, so these three are the only numbers that
       define the desktop layout. Unused on mobile. */
    --panel-width: 45%;
    --panel-height: 80%;
    /* Zero, deliberately: the panel already carries --grid-gap of padding of its
       own (see .content-buffer), so with no margin on top of it the space to the
       LEFT of the images is exactly the space BETWEEN them — the stills sit in one
       even 5px frame instead of a wide left margin and hairline gutters. Still a
       token, and still the only inset in the layout: the headline column and the
       top mask are both positioned off it, so all three move together if it ever
       goes back to being a number. */
    --panel-inset: 0px;
    --max-width: 1600px;

    /* ── Fluid scale ───────────────────────────────────────────────────────────
       Every size that used to be a bare px literal now comes from one of these,
       and every one of them is a clamp(): a floor for the narrowest phone, a
       viewport-linked middle term, and a ceiling so nothing runs away on a 4K
       desktop. Two rules govern how they're written:

       1. The middle term is `rem + vw`, never a bare vw. A bare vw ignores the
          user's browser font-size entirely — the value stops responding to
          text-zoom, which is how "fluid type" so often ends up an accessibility
          regression. Keeping a rem in the sum preserves that response.

       2. Several carry a `min(…, Nsvh)` cap. Width alone is the wrong input for
          anything that has to FIT vertically: a phone in landscape is 926px
          "desktop-wide" and 428px pocket-short, and a width-only clamp hands it
          a headline sized for a monitor. The svh term is the second axis, so the
          shorter dimension wins whenever it's the binding one. svh (not vh) for
          the same reason --stage-h uses it: this document can't scroll, so the
          chrome is always up and svh is exact rather than optimistic.

       The anchors are chosen so a typical phone (~390px) gets what it gets
       today — this widens the range at both ends, it doesn't shift the middle. */
    --fs-headline: clamp(2rem, min(1.37rem + 3.34vw, 9svh), 3.25rem);
    --fs-nav:      clamp(0.8125rem, 0.71rem + 0.52vw, 1rem);
    --fs-cta:      var(--fs-nav);      /* the CONTACT button; desktop blows it up */
    --fs-credits:  clamp(0.75rem, 0.68rem + 0.35vw, 1rem);
    --fs-body:     clamp(0.75rem, 0.7rem + 0.25vw, 0.95rem);
    --fs-title-lg: clamp(1.5rem, 1.2rem + 1.6vw, 2.25rem);
    --fs-link:     clamp(1.125rem, 0.95rem + 0.9vw, 1.75rem);

    /* ── Spacing ──
       --gutter is the page's side margin: 10px on a 320px phone (i.e. unchanged),
       opening up to 24px by desktop. Everything that touches a page edge uses it,
       so the whole page breathes from one number. */
    --gutter: clamp(0.625rem, 0.35rem + 1.4vw, 1.5rem);
    /* The gap between stills is deliberately NOT fluid. It's a hairline grid rule,
       not spacing — scaling it makes the panel look gappy on a tablet without
       making anything fit better, and it's baked into .still.portrait's width and
       into the srcset `sizes` in index.php. Keep the three in step. */
    --grid-gap: 5px;
    /* Minimum comfortable hit area (WCAG 2.5.5 AAA / iOS HIG / Material). Applied
       as a min-height + centring rather than padding, so it can enlarge a control
       without changing how the control looks. */
    --tap: 44px;

    /* ── Contact transition ───────────────────────────────────────────────────
       The contact overlay is TWO layers that move as one gesture: the black
       (body::after) and the card over it (#contactDiv). On the stacked layout
       they wipe in from the left, black first and card a beat behind, so the
       black reads as the card's own shadow arriving ahead of it. Closing runs it
       backwards — card out first, black last — which is why the lead delay is
       declared per direction rather than as one number.

       All of it is tokens, and the two-column block redefines them rather than
       restating the rules. That is the whole reason they exist: on desktop the
       card is a centred box, not a sheet, so it has nowhere to slide FROM — there
       --sheet-out and --sheet-in are the same centring transform, the wipe
       silently becomes a no-op, and the black goes back to being a dim that
       fades. One set of rules, two behaviours, no duplicated declarations. */
    --sheet-ms:     320ms;      /* the card's travel */
    --sheet-lead:   120ms;      /* how long the card waits for the black, opening */
    --sheet-ease:   cubic-bezier(0.22, 0.61, 0.36, 1);
    --sheet-out:    translateX(-100%);
    --sheet-in:     translateX(0);
    --scrim-ms:     320ms;      /* the black's travel */
    --scrim-lead:   120ms;      /* how long the black waits for the card, closing */
    --scrim-bg:     #000000;
    --scrim-out:    translateX(-100%);
    --scrim-in:     translateX(0);
    /* The stacked black is opaque and moves; the desktop dim doesn't move and
       fades instead. Both states are tokens so one rule covers both. */
    --scrim-o-out:  1;
    --scrim-o-in:   1;

    /* ── Colours ──
       --ink is the real text colour and is never themed; --ink is the one
       body.intro-active flips to white. Anything that keeps its own light
       background while the video is up (the contact card) points back at --ink. */
    --ink: #2C2C2C;
    --background: #EBEBEB;
    --off-background: #e3e3e3;

    /* Loading placeholder: how long one sweep through a still's sampled colours
       takes (see @keyframes tint). Shared by the stills and the intro placeholder. */
    --tint-dur: 32s;
}

@supports (height: 100dvh) {
    :root { --stage-h: 100dvh; }
}

html, body {
    overflow: hidden;            /* the document lock — see the layout model */
    background-color: var(--background);
    height: var(--stage-h);
}

body {
    /* The stage box, and the containing block for every absolutely-positioned
       layer below. Both halves are load-bearing — see point 2 of the layout model. */
    position: relative;
    /* ── Y AXIS ONLY, and that goes for every overscroll-behavior on this page ──
       `overscroll-behavior` is a shorthand: writing it unqualified sets the X axis
       too, and a non-auto X is precisely what tells the browser to suppress its
       horizontal swipe-to-go-back. Every one of these declarations was written for
       a vertical reason — no rubber-band here, no scroll chaining out of the panel
       and the contact sheet — and the back gesture was collateral damage, on the
       main page where there is nothing to go back to but the site you came from.
       There is nothing to lose by naming the axis: this document cannot scroll at
       all, and the three scrollers below are all overflow-x:hidden, so the X axis
       has no overscroll of its own to govern in the first place. */
    overscroll-behavior-y: none;   /* no rubber-band */
}

/* The one real scroller: the full stage, with the image panel laid over its top
   half. Headlines live in normal flow in here between two tall spacers.
   position:absolute, not fixed — the document can't scroll, so they render
   identically, but absolute resolves against <body> (the stage) instead of the
   initial containing block (the large viewport). Same for every layer below. */
#scroller {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: var(--stage-h);
    overflow-y: auto;
    overflow-x: hidden;
    overscroll-behavior-y: none;   /* axis named on purpose — see body */
    scroll-behavior: smooth;
    /* Native CSS scroll-snap on all devices — one headline per step. */
    scroll-snap-type: y mandatory;
    /* Aligns the snap landing and anchor-link jumps to the selection line.
       script.js reads this back to decide which headline is active. */
    scroll-padding-top: var(--snap-line);
    z-index: 1;
    /* The list stops --sections-ahead headlines past the selection line. A mask,
       not a shorter box: the mask applies to the scroller's OWN box, so the edge
       is a fixed place on the stage and the headlines scroll up under it, while
       the sections beyond it keep their height, their snap positions and their
       scrollable extent — they are simply not painted. Sizing the element down
       instead would take the scroll range with it.
       The last headline dissolves over --list-fade rather than being sliced: a
       hard edge cuts glyphs in half mid-scroll, which reads as a rendering fault
       rather than as the list ending. Set --list-fade to 0 for the hard cut. */
    -webkit-mask-image: linear-gradient(to bottom,
        #000 calc(var(--list-cut) - var(--list-fade)), transparent var(--list-cut));
    mask-image: linear-gradient(to bottom,
        #000 calc(var(--list-cut) - var(--list-fade)), transparent var(--list-cut));
}

/* ── Image area: two stacked buffers, only .active is visible ─────────────────
   Mobile: full width across the top half. Desktop: a left column (see the
   desktop block). */
.content-buffer {
    position: absolute;
    top: 0;
    width: 100%;
    max-width: var(--max-width);
    height: var(--divider);
    /* Explicitly one-axis. `overflow: auto` let a too-wide child (a long
       .credit-name was the case that surfaced it) turn the panel into a
       horizontal scroller, which on a touch device steals the horizontal swipe. */
    overflow-y: auto;
    overflow-x: hidden;
    overscroll-behavior-y: contain;   /* axis named on purpose — see body */
    scroll-snap-type: y proximity;
    padding: 0 var(--grid-gap);
    /* Wrapping row, not a column: it's what lets two portrait stills share a row
       (see .still.portrait). Landscape stills are width:100%, so they still take
       a row each and the reading order is unchanged.
       align-items:flex-start is load-bearing — the default `stretch` would set
       each still's height to the row's, overriding the aspect-ratio that reserves
       its space. */
    display: flex;
    flex-flow: row wrap;
    align-items: flex-start;
    column-gap: var(--grid-gap);
    opacity: 0;
    pointer-events: none;
    z-index: 2;                  /* above #scroller, so the panel scrolls itself */
    background-color: var(--background);
    border-bottom: 5px solid var(--background);
    /* No height transition. The panel's height derives from --stage-h, exactly like
       both spacers and #scroller — but those have no transition, so animating this
       one made the panel lag the rest of the stage by 300ms on every viewport
       change and tore the divider line. Now nothing animates and they move as one. */
    -ms-overflow-style: none;    /* IE and Edge */
    scrollbar-width: none;       /* Firefox */
}

/* Hide scrollbar for Chrome, Safari and Opera */
.content-buffer::-webkit-scrollbar {
    display: none;
}

.content-buffer.active {
    opacity: 1;
    pointer-events: auto;
}

/* Off-screen buffers (prefetched, cached, or simply not the active one) skip their
   own layout and paint entirely. content-visibility:hidden, NOT :auto — every
   buffer is absolutely positioned at top:0 over the same box, so :auto would judge
   them on-screen and skip nothing. hidden also pauses their colour-cycle and keeps
   their lazy stills from fetching until shown. Width and height are explicit (above,
   and in the desktop block), so size containment has nothing to suppress; the srcset
   `sizes` are viewport-relative (vw), so the eager hero still picks the right
   variant while hidden. Positioning + credit-fitting happen on activation
   (activateBuffer), because a hidden buffer's geometry reads 0. */
.content-buffer:not(.active) {
    content-visibility: hidden;
}

/* ── Intro: looping self-hosted background video, fullscreen behind everything ──
   Not a .content-buffer — it's a full-stage background sized exactly like
   #scroller, sitting BEHIND the transparent #scroller. Decorative →
   pointer-events none so touches reach #scroller. Fades out when .active is
   removed; the <video> fades in when script.js adds .ready (its first frame). */
#intro {
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: var(--stage-h);
    z-index: 0;
    overflow: hidden;
    opacity: 0;
    pointer-events: none;
    transition: opacity 0.4s ease;
}

#intro.active {
    opacity: 1;
}

#intro video {
    position: relative;
    z-index: 1;                  /* above the placeholder */
    width: 100%;
    height: 100%;
    object-fit: cover;
    display: block;
    opacity: 0;                  /* no flash before the first frame */
    transition: opacity 0.4s ease;
}

#intro video.ready {
    opacity: 1;
}

/* Landing/intro placeholder: sits behind the video and cycles through the landing
   image's sampled colours (from ish_intro.txt, set inline) until the first frame is
   ready, then it's stopped. Same @keyframes tint the stills use. */
.intro-ph {
    position: absolute;
    inset: 0;
    z-index: 0;
    background: var(--background);
    animation: tint var(--tint-dur) ease-in-out infinite alternate;
}

#intro.video-ready .intro-ph {
    animation: none;                /* first frame ready → stop the cycle */
    /* Deliberately NOT opacity:0. The video sits ON TOP of this layer and fades in
       over 0.4s, so hiding the placeholder here left the page background showing
       underneath for that whole fade — a slow pale wash, which is the very thing
       the placeholder exists to prevent. Staying opaque gives the fade something
       to cross to; script.js removes the node once the video is actually up. */
}

/* ── Stills ────────────────────────────────────────────────────────────────────
   Each image lives in a box whose height is reserved via aspect-ratio. While the
   photo loads, the box background cycles through the per-zone colours the renderer
   sets inline on it (--zaa … --zcc; see @keyframes tint above). The opaque image
   sits above the box and covers it on load.
   Local stacking inside .still: box bg 0 < img 1 < .play-btn / .video-frame 2 <
   .consent-banner 3. Every layer above the image needs an EXPLICIT z-index — the
   img's z-index:1 beats an `auto` sibling even later in the DOM. */
.still {
    position: relative;
    width: 100%;
    margin-top: var(--grid-gap);
    /* Makes every still a query container, so the things drawn ON it (the PLAY
       label, the consent banner) size against the BOX rather than the viewport.
       That distinction is the whole point here: a portrait still is half-width,
       so on one screen two stills differ in size by 2x and a vw-based size is
       wrong for at least one of them. cqi is right for both, automatically.
       Safe on this element specifically: its width is always explicit (100% or
       calc(50% - …)), so inline-size containment never has an intrinsic width to
       suppress, and it is already position:relative — so it was already the
       containing block that containment would otherwise newly make it. */
    container-type: inline-size;
    flex-shrink: 0;      /* keep the reserved aspect-ratio height — overflow:hidden
                            drops the flex min-height floor, which would otherwise let
                            the fixed-height panel squish stills instead of scrolling */
    overflow: hidden;
    background: var(--background);   /* the box the colour-cycle animates, behind the photo */
}

/* ── Loading placeholder: cycle the photo's own sampled colours ─────────────────
   Each still box carries ~55 colours sampled from its photo, set inline by the
   renderer as custom properties --zaa … --zcc (a low-res, left→right colour map of
   the frame — the same values that used to fill the Ishihara dot sprite; that
   render layer is archived in /archive/ish-dots). While the photo loads, the box
   background sweeps smoothly through them: @keyframes tint walks background-color
   across all 55 in zone order, and `alternate` eases back down instead of hard-
   wrapping --zcc→--zaa. It stops the instant the photo lands (.loaded, added to the
   box by markLoaded) — by then the opaque <img> covers the box anyway — and while a
   buffer is inactive. A still with no colour data resolves every stop to
   --background, so the sweep is simply a no-op (static background). */
@keyframes tint {
  0% { background-color: var(--zaa, var(--background)); }
  1.85% { background-color: var(--zab, var(--background)); }
  3.7% { background-color: var(--zac, var(--background)); }
  5.56% { background-color: var(--zad, var(--background)); }
  7.41% { background-color: var(--zae, var(--background)); }
  9.26% { background-color: var(--zaf, var(--background)); }
  11.11% { background-color: var(--zag, var(--background)); }
  12.96% { background-color: var(--zah, var(--background)); }
  14.81% { background-color: var(--zai, var(--background)); }
  16.67% { background-color: var(--zaj, var(--background)); }
  18.52% { background-color: var(--zak, var(--background)); }
  20.37% { background-color: var(--zal, var(--background)); }
  22.22% { background-color: var(--zam, var(--background)); }
  24.07% { background-color: var(--zan, var(--background)); }
  25.93% { background-color: var(--zao, var(--background)); }
  27.78% { background-color: var(--zap, var(--background)); }
  29.63% { background-color: var(--zaq, var(--background)); }
  31.48% { background-color: var(--zar, var(--background)); }
  33.33% { background-color: var(--zas, var(--background)); }
  35.19% { background-color: var(--zat, var(--background)); }
  37.04% { background-color: var(--zau, var(--background)); }
  38.89% { background-color: var(--zav, var(--background)); }
  40.74% { background-color: var(--zaw, var(--background)); }
  42.59% { background-color: var(--zax, var(--background)); }
  44.44% { background-color: var(--zay, var(--background)); }
  46.3% { background-color: var(--zaz, var(--background)); }
  48.15% { background-color: var(--zba, var(--background)); }
  50% { background-color: var(--zbb, var(--background)); }
  51.85% { background-color: var(--zbc, var(--background)); }
  53.7% { background-color: var(--zbd, var(--background)); }
  55.56% { background-color: var(--zbe, var(--background)); }
  57.41% { background-color: var(--zbf, var(--background)); }
  59.26% { background-color: var(--zbg, var(--background)); }
  61.11% { background-color: var(--zbh, var(--background)); }
  62.96% { background-color: var(--zbi, var(--background)); }
  64.81% { background-color: var(--zbj, var(--background)); }
  66.67% { background-color: var(--zbk, var(--background)); }
  68.52% { background-color: var(--zbl, var(--background)); }
  70.37% { background-color: var(--zbm, var(--background)); }
  72.22% { background-color: var(--zbn, var(--background)); }
  74.07% { background-color: var(--zbo, var(--background)); }
  75.93% { background-color: var(--zbp, var(--background)); }
  77.78% { background-color: var(--zbq, var(--background)); }
  79.63% { background-color: var(--zbr, var(--background)); }
  81.48% { background-color: var(--zbs, var(--background)); }
  83.33% { background-color: var(--zbt, var(--background)); }
  85.19% { background-color: var(--zbu, var(--background)); }
  87.04% { background-color: var(--zbv, var(--background)); }
  88.89% { background-color: var(--zbw, var(--background)); }
  90.74% { background-color: var(--zbx, var(--background)); }
  92.59% { background-color: var(--zby, var(--background)); }
  94.44% { background-color: var(--zbz, var(--background)); }
  96.3% { background-color: var(--zca, var(--background)); }
  98.15% { background-color: var(--zcb, var(--background)); }
  100% { background-color: var(--zcc, var(--background)); }
}

.still {
    animation: tint var(--tint-dur) ease-in-out infinite alternate;
}

.still.loaded,
.content-buffer:not(.active) .still {
    animation: none;                /* photo landed, or buffer hidden → stop */
}

.still img {
    position: relative;
    z-index: 1;                     /* above the placeholder layer */
    width: 100%;
    display: block;
    opacity: 0;
    /* Fade the photo in over the (now frozen) colour-cycle. Safe because markLoaded
       gates .loaded on decode(), so the bitmap is paintable before the fade starts —
       no empty-frame flash. .still.loaded stops the cycle at the same moment, so the
       photo crossfades over whatever colour the sweep had reached. */
    transition: opacity 0.4s ease;
}

.still img.loaded {
    opacity: 1;
}

.still.scroll-img {
    scroll-snap-align: end;
}

/* A vertical still at full width would be taller than the panel, so it takes
   half a row and pairs up with the next one. The renderers add .portrait from
   the stored width/height (CSS can't test an element's own aspect-ratio).
   An odd one out simply sits alone in its half — deliberately not widened,
   since full width is the thing this avoids. */
.still.portrait {
    width: calc(50% - var(--grid-gap) / 2);   /* two of these + the column-gap = 100% */
}

/* The odd one out when the count is uneven. Wrapping would strand it at the END
   of the list, which is the project's first image (its hero) — the renderers put
   .solo on the FIRST still instead, and this margin claims the rest of the row so
   the next still wraps. It has to be a margin: flexbox breaks lines on the
   hypothetical size, which max-width/flex-basis would clamp back down. */
.still.portrait.solo {
    margin-right: 50%;
}

/* ── PLAY label + inline video on the hero still ────────────────────────────────
   The whole .still[data-video] box is the click/tap target (see script.js).
   "PLAY" is plain text, vertically centred on the image. */
.still[data-video] {
    cursor: pointer;
}

.play-btn {
    position: absolute;
    z-index: 2;                 /* above .still img (z-index: 1) */
    left: 70%;
    bottom: 30%;
    transform: translateY(-50%);
    pointer-events: none;       /* clicks fall through to the image box */
    color: #ffffff;
    /* Sized off the still (see container-type on .still), not the viewport: at a
       flat 30px this label was ~40% of the width of a portrait still on a small
       phone and clipped past the box's right edge. As a share of the container it
       reads the same at every size — and left:70% + ~2.2em of text stays inside
       100% across the whole clamp range, so it can never clip again.
       The px line first is the fallback for engines without cq units: an unknown
       unit invalidates the whole declaration, leaving the previous one standing. */
    font-size: 1.875rem;
    font-size: clamp(1rem, 9cqi, 2rem);
    font-weight: 600;
    text-transform: lowercase;
}

.still.playing .play-btn,
.still.consenting .play-btn {
    display: none;              /* hide the label once the video (or banner) is up */
}

/* The iframe fills the reserved image box, so it overlays the still exactly.
   width AND height are explicit: an <iframe> is a replaced element, so inset:0
   alone won't stretch its width — it would fall back to the default ~300px. */
.video-frame {
    position: absolute;
    z-index: 2;                 /* above .still img (z-index: 1) */
    inset: 0;
    width: 100%;
    height: 100%;
    border: 0;
    background: #000000;
}

/* ── Consent banner ────────────────────────────────────────────────────────────
   Shown on the hero still instead of the iframe until the visitor allows the
   YouTube embed (see the consent gate in script.js). It fills the still's box and
   splits it across the LONGER side — .wide (landscape) side by side, otherwise
   stacked — into 38% "watch on YouTube" then 62% explanation.
   Colours come in as two custom properties, read off the project's .credits strip
   at build time. The 38% section is the same two values swapped, so the inversion
   is one declaration rather than a second colour lookup.
   Sizing is all em off this one font-size, so a short hero scales the whole panel
   down instead of overflowing it. That font-size used to be computed in JS from
   the box's measured pixels — but it was computed ONCE, when the banner opened,
   so rotating the phone left the text sized for the old orientation. It is a
   container query now: cqi tracks the still live, and the two ratios below are
   the same min(width,height)/24 the JS used, resolved for each case (a .wide
   still is landscape, so its height — and therefore its binding dimension — is
   roughly 9/16 of the width the unit reports). */
.consent-banner {
    position: absolute;
    z-index: 3;                 /* above .video-frame and .play-btn (both 2) */
    inset: 0;
    display: flex;
    flex-direction: column;     /* portrait still → split across its height */
    overflow: hidden;
    line-height: 1.35;
    font-weight: 300;
    cursor: default;
    font-size: 0.8125rem;                        /* fallback: no cq units */
    font-size: clamp(0.6875rem, 4.2cqi, 0.9375rem);
}

.consent-banner.wide {
    flex-direction: row;        /* landscape still → split across its width */
    font-size: clamp(0.6875rem, 2.4cqi, 0.9375rem);
}

.consent-banner section {
    display: flex;
    flex-direction: column;
    justify-content: center;
    padding: 1.1em;
    overflow: hidden;
    min-width: 0;               /* both needed, or the flex children refuse to  */
    min-height: 0;              /* shrink below their content and overflow      */
}

.cc-main {
    flex: 0 0 62%;
    background-color: var(--cb-bg);
    color: var(--cb-fg);
}

.cc-alt {
    flex: 0 0 38%;
    background-color: var(--cb-fg);   /* inverted against .cc-main */
    color: var(--cb-bg);
}

.cc-text {
    margin: 0 0 0.9em;
    font-size: 0.95em;
}

.cc-allow {
    font: inherit;
    font-size: 1.5em;
    font-weight: 400;
    align-self: flex-start;
    padding: 0.45em 1.2em;
    border: 1px solid currentColor;
    border-radius: 50px;        /* matches the pill nav buttons */
    background: transparent;
    color: inherit;
    cursor: pointer;
}

/* Explicit vars, not currentColor: `color` is set in the same rule, so a
   currentColor background would resolve to the NEW colour and vanish. */
.cc-allow:hover {
    background-color: var(--cb-fg);
    color: var(--cb-bg);
}

.consent-banner a {
    color: inherit;
    text-decoration: underline;
}

.cc-legal {
    margin-top: 0.8em;
    font-size: 0.9em;
    opacity: 0.75;
}

.cc-yt {
    font-size: 1.1em;
}

/* ── Credits strip ───────────────────────────────────────────────────────────── */
.credits {
    width: 100%;                 /* full row in the wrapping panel */
    font-weight: 300;
    font-size: var(--fs-credits);
    background-color: var(--background);
    color: var(--ink);
    padding: var(--grid-gap);
    margin-top: var(--grid-gap);
}

/* Each credit is a row: position on the left, name on the right, dotted leader
   filling the gap between them (table-of-contents style).
   The row itself never wraps. When a long name won't fit, the leader closes down
   to a short run of dots and the NAME wraps inside its own column — so the name
   keeps its place on the first line and its continuation lines start where that
   column starts, rather than the whole name dropping below the position.

   Which of the three gives way, and in what order, is not expressed with
   flex-shrink — shrinking is distributed in proportion to shrink-factor times
   base size, so asking three items to share it crushes all three at once and the
   position gets an ellipsis while the name still had room to wrap. It is
   expressed with what each item is ALLOWED to be instead:
     position  cannot shrink, but is capped so it can never take the whole row
     leader    cannot shrink below a legible run of dots, and grows into any slack
     name      the only shrinkable item, so it absorbs the shortfall alone
   The three numbers below are in step with each other: the position leaves 10ch,
   the leader's basis and the two gaps account for 3.5ch of it, and the 6.5ch that
   remains is the name's floor — which is why the name needs no min-width of its
   own, and must not be given one. */
.credits p {
    display: flex;
    align-items: baseline;     /* the name's FIRST line sits on the position's baseline */
    gap: 0.5ch;
}

.credit-position {
    flex: 0 0 auto;            /* never gives up width to the name */
    /* The one guard on that: a label long enough to leave the name nothing gets
       clamped here instead of pushing the row off the panel. It takes an ellipsis
       at that point, which is the only truncation left anywhere in this row. */
    max-width: calc(100% - 10ch);
    white-space: nowrap;
    overflow: hidden;
    text-overflow: ellipsis;
}

/* THE DOTS — an element rather than a ::after on the position, which is where
   they used to live. Inside the position, this ~370-character string was part of
   that item's base size and of its intrinsic width, so it drove both the flex
   sizing and (with the position shrinkable) how much of the label survived.
   Out here it is inert: basis 2.5ch is what the gap closes down to when the row
   is tight, grow 1 opens it back up to fill the slack when it isn't, and shrink 0
   keeps it from being eaten entirely — a leader that vanishes reads as a mistake.
   aria-hidden on the element, because as a ::after on the position this string
   was that span's text and screen readers read the label followed by several
   hundred full stops. */
.credit-leader {
    flex: 1 0 2.5ch;
    overflow: hidden;
    white-space: nowrap;
}

/* The string has to be longer than the widest gap it can ever be asked to cross,
   because it fills from the left and simply stops when it runs out. The panel is
   45vw on the two-column layout, so that width tracks the display: at 0.228em per
   dot and a 16px cap on --fs-credits, 368 dots covered to about 3000px of viewport
   and fell 300px short on a 4K panel. 736 covers ~2700px of gap, i.e. past an
   ultrawide, and it costs nothing — the whole run is one repeated byte to gzip. */
.credit-leader::after {
    content: "............................................................................................"
             "............................................................................................"
             "............................................................................................"
             "............................................................................................"
             "............................................................................................"
             "............................................................................................"
             "............................................................................................"
             "............................................................................................";
    color: inherit;   /* follow the credits font colour (per-project or CSS default) */
}

/* The only item that yields, and it yields by WRAPPING, not by truncating: it is
   a normal-flow block, so its second and later lines begin at its own left edge —
   which is what keeps a wrapped name from running back under the leader.

   min-width is 0, NOT a floor like 6ch. A floor here is a floor always, not only
   while shrinking: a two-character name got a six-character box, its text sat at
   the right of that box because the column is right-aligned, and the dots stopped
   four characters short of it. Short names are common in credits — first names,
   initials — so that gap showed up on scattered rows and nowhere else.
   Nothing is lost by removing it, because the floor is already structural: the
   position above may occupy at most `100% - 10ch`, and the leader and the two
   gaps account for 3.5ch of that, so the name is always left at least 6.5ch to
   wrap into. The two rules are in step — change one and check the other.
   0 rather than the flex default of `auto`, so that a single word longer than the
   column can break instead of forcing the row wider than the panel. */
.credit-name {
    flex: 0 1 auto;
    min-width: 0;
    /* Right, so a wrapped name keeps the same flush right column as a short one.
       That alignment is only safe because fitCreditNames() in script.js narrows
       this box to its widest rendered line first. Without that step the box stays
       as wide as the row could spare while the first line breaks at a word and
       comes out narrower, and right-aligning pushes that line off the leader —
       which is exactly the 25-35px of blank between the last dot and the first
       letter that this pairing exists to avoid. CSS alone cannot do it: a block
       does not re-fit to its content after wrapping, so the width has to be
       measured. If that script ever stops running, this line is the thing to
       revert — `left` is the degradation, and it is a correct-looking one. */
    text-align: right;
    overflow-wrap: break-word;
}

/* ── Headline track ────────────────────────────────────────────────────────────
   Everything here is scoped to #scroller: <section> is also the consent banner's
   element, and these rules (width, snap alignment, and the desktop column offset)
   would otherwise land on it and drag it out of its own box.

   Spacers let the first and last headline reach the selection line. The two are
   NOT the same height once the line isn't centred: the top one only has to push
   the first headline down onto the line, while the bottom one has to leave room
   for everything BELOW the line, so the last headline can still scroll up to it.
   On mobile (line at 50%) both work out to half the stage; on desktop (line at
   the top) the top one collapses to nothing and the bottom one is a full stage. */
.spacer-top {
    height: var(--snap-line);
}

.spacer-bottom {
    height: calc(var(--stage-h) - var(--snap-line));
}

/* Headlines hug their text. One headline per step via native CSS scroll-snap
   (scroll-snap-type lives on #scroller) — same on every device. */
#scroller section {
    width: 100%;
    max-width: var(--max-width);
    margin-left: auto;
    margin-right: auto;
    padding-left: var(--gutter);
    padding-right: var(--gutter);
    scroll-snap-align: start;
    scroll-snap-stop: always;
}

#scroller h2 {
    font-size: var(--fs-headline);
}

/* Dimming: non-active headlines are washed out, the active one is full contrast. */
#scroller section h2 {
    color: var(--off-background);
    text-transform: uppercase;
    /* Only reachable where the ticker isn't (desktop, and reduced-motion), since
       nowrap wins over both. A single unbroken word longer than the column would
       otherwise push the section wide; balance keeps a two-line title from
       leaving one word alone on the second line. */
    overflow-wrap: break-word;
    text-wrap: balance;
    transition: color 0.3s ease;
}

#scroller section.active h2 {
    transition: color 0.3s ease;
    color: var(--ink);
}



/* ── Mobile: titles too wide for the screen ticker sideways instead of wrapping ─
   Pure CSS. The clip + query container is the <section>; the <h2> is the sliding
   text. The keyframe distance (100cqw − 100%) is the container width minus the
   text width — negative only when the title overflows, clamped to 0 when it fits,
   so short titles never move. It eases out to reveal the end, pauses, and eases
   back (newscast-style). Mobile-only; desktop titles are unchanged. */
@supports (container-type: inline-size) {   /* else fall back to normal wrapping */
    /* 899.98px, matching the desktop block's 900px — not 767.98px. The mobile
       arrangement runs all the way up to the desktop breakpoint, so the ticker has
       to as well; the old 768px cutoff left large phones in landscape and small
       tablets on the mobile layout with desktop title behaviour. */
    @media (max-width: 899.98px) {
        #scroller section {
            overflow: hidden;
            container-type: inline-size;   /* 100cqw = the title's available width */
        }

        #scroller section a {              /* project titles are wrapped in a link */
            display: block;
        }

        #scroller section h2 {
            white-space: nowrap;
            width: max-content;            /* so 100% below = the text's own width */
            max-width: none;
        }

        #scroller section.active h2 {
            animation: title-ticker 12s ease-in-out infinite;
        }
    }
}

@keyframes title-ticker {
    0%,  10%  { transform: translateX(0); }
    45%, 55%  { transform: translateX(min(0px, 100cqw - 100%)); }
    90%, 100% { transform: translateX(0); }
}

#scroller #scroll-hint h2 {
    text-transform: lowercase;
}

/* ── Over the intro video: all text/nav white, floating on the footage ──────────
   script.js adds .intro-active on the scroll-hint section alone — deliberately NOT
   gated on the video being ready, so a device that never loads the clip still gets
   the intro layout instead of being stranded in the non-intro one. The cost is that
   white text can land on the placeholder's light ground during load (or forever, if
   the video never arrives); see the .intro-ph note. Override the text tokens
   (headlines and nav read their colour from these, so the existing
   `transition: color` animates the white↔black change); --background stays light so
   the contact overlay/credits remain solid, and the video-overlapping backgrounds
   are cleared surgically. */
body.intro-active {
    --ink: #ffffff;
    --off-background: rgba(255, 255, 255, 0.205);
}

body.intro-active #navDiv {
    background: transparent;
    border-top-color: transparent;
}

body.intro-active .nav {
    background: transparent;
    color: var(--background);
}

/* CONTACT button inverts colours normally → give it a visible outline over video. */
body.intro-active #contactTxt {
    background: transparent;
    color: #ffffff;
}

/* ── Bottom nav ───────────────────────────────────────────────────────────────
   Anchored to the stage's bottom edge (<body> is its containing block — see the
   layout model), plus the system inset on the three edges it touches. That inset
   is NOT a PWA-only concern: viewport-fit=cover means Chrome 135+ on Android
   draws this page behind the navigation bar / dynamic "chin" in an ordinary tab
   too, and without the env() the nav sits under it. env() resolves to 0 wherever
   there is nothing to clear, so this one declaration covers browser, installed
   PWA and desktop alike — no media query.
   left + max-width keep it left-anchored: the box is over-constrained, so
   `right` gives way. */
#navDiv {
    position: absolute;
    left: calc(var(--gutter) + env(safe-area-inset-left, 0px));
    right: calc(var(--gutter) + env(safe-area-inset-right, 0px));
    bottom: calc(var(--gutter) + var(--grid-gap) + env(safe-area-inset-bottom, 0px));
    max-width: var(--max-width);
    z-index: 3;                  /* above the image panel and the scroller */
    display: flex;
    justify-content: space-between;
    /* The name and the button are different heights (the button carries a 44px
       hit area), so without this the h1's text sat above the button's centre. */
    align-items: center;
    /* space-between distributes the SLACK; it does nothing once there is none.
       The gap is the actual guarantee that the two never touch at 320px. */
    gap: var(--grid-gap);
    padding: var(--grid-gap);
    pointer-events: none;          /* the nav itself is never a target, only its children */
}

/* The name and the occupation are separate flex items, which is the only way to
   give the line a break opportunity: the markup is `ONNO WEGENER<span>colorist`
   with no whitespace between them and a generated "|", so there is nothing there
   for the line breaker to break at, and the row could only overflow. Measured in
   the real font, "ONNO WEGENER | colorist" is 12.9em — at any legible size that
   is wider than a 280px Galaxy Fold has left once CONTACT is placed. As two
   items it simply wraps, and only where it has to: at 320px and up it still sits
   on one line. */
#navDiv h1 {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
}

.nav {
    /* Was `medium` — a flat 16px. Between that, the two fixed paddings and the
       "| colorist" suffix, this row measured ~331px and clipped on a 320px phone
       (and clipped harder on a 280px Fold). Fluid, it lands at 13px there and
       still reaches 16px by the time there's room for it. */
    font-size: var(--fs-nav);
    color: var(--ink);
    font-weight: 600;
    text-decoration: none;
    padding: 5px 10px;
    border-radius: 50px;
    /* Lets the h1 wrap instead of overflowing on the very narrowest screens —
       a flex item's automatic minimum size is its content, so without this it
       refuses to give any width back no matter what. */
    min-width: 0;
    pointer-events: visible;          /* the name is a link, so it is a target */
}

/* The occupation, sharing the h1 with the name. The pipe is a pseudo-element so
   it's decoration, not content — screen readers read "Onno Wegener colorist".
   Both inherit the h1's colour, so the intro-active override reaches them too. */
.role,
.role::before {
    color: inherit;
}

.role::before {
    content: "|";
    margin: 0 0.6ch;
}

#contactTxt {
    background-color: var(--ink);
    color: var(--background);
    font-size: var(--fs-cta);
    /* min-height + centring rather than vertical padding: it guarantees the 44px
       hit area at the smallest font size without inflating the pill once the font
       grows past it (and the desktop block, which zeroes the padding to run this
       at ~7rem, is then unaffected by it). */
    min-height: var(--tap);
    display: inline-flex;
    align-items: center;
    padding: 0 1em;
    flex-shrink: 0;             /* the name yields first, never the button */
    border: none;
    transform: translate(-1rem,0);
}

/* Hover only where a pointer can actually leave again. On touch the browser
   emulates :hover on tap and holds it until something else is tapped — closing
   the card doesn't clear it, so the button stayed inverted and read as "still
   pressed". :active below is the touch feedback; it releases on lift. */
@media (hover: hover) {
    #contactTxt:hover {
        cursor: pointer;
        background-color: var(--off-background);
        color: var(--ink);
    }
}

/* On a pointer device this cancels the hover inversion for the duration of the
   press, so the click flashes back to the resting colours. */
#contactTxt:active {
    color: var(--background);
    background-color: var(--ink);
}

/* Touch has no hover to cancel, so the same rule would be invisible — it has to
   invert instead. This is the press feedback the emulated hover used to provide
   by accident, minus the part that stuck around after the card closed. */
@media (hover: none) {
    #contactTxt:active {
        background-color: var(--off-background);
        color: var(--ink);
    }
}

/* ── Contact overlay ────────────────────────────────────────────────────────────
   #contactDiv is the card itself — a fullscreen sheet on mobile, a centred card
   on desktop. The page behind it is dimmed by the scrim below. script.js toggles
   .open on the card and .contact-open on <body>.

   The scrim has to be a body pseudo-element: #contactDiv can't host it, because
   its own translate makes it the containing block for any positioned child of
   its own, so such a child could never cover the stage. It also means clicks on
   the scrim land on <body> — which is exactly what the outside-click handler in
   script.js expects.
   inset:0 is safe here BECAUSE it is absolute: it resolves against <body>, i.e.
   exactly the stage. As `fixed` it would resolve against the large viewport and
   overhang the bottom. */
body::after {
    content: "";
    position: absolute;
    inset: 0;
    background: var(--scrim-bg);
    z-index: 5;                  /* over the top mask, under the card */
    transform: var(--scrim-out);
    opacity: var(--scrim-o-out);
    pointer-events: none;        /* invisible AND untouchable while closed */
    /* CLOSING. The black is the last thing to leave, so it waits out the card's
       exit before starting its own — that is the only difference between this
       transition and the open one below. */
    transition: transform var(--scrim-ms) var(--sheet-ease) var(--scrim-lead),
                opacity   var(--scrim-ms) var(--sheet-ease) var(--scrim-lead);
}

body.contact-open::after {
    transform: var(--scrim-in);
    opacity: var(--scrim-o-in);
    pointer-events: auto;
    cursor: pointer;             /* the whole scrim is a dismiss target */
    /* OPENING. The black leads — no delay. */
    transition: transform var(--scrim-ms) var(--sheet-ease),
                opacity   var(--scrim-ms) var(--sheet-ease);
}

/* Mobile: a fullscreen sheet covering the whole stage. Desktop shrinks it to a
   centred card (see the desktop block). Note the consequence on mobile — the
   sheet leaves no "outside" to tap, so the × is the only way back out. */
#contactDiv {
    /* Opt out of the intro-active theme: the card keeps its own light background,
       so the white text tokens would leave it unreadable (which is the DEFAULT
       state — the intro video is what's playing the first time CONTACT is hit). */
    --ink: var(--ink);
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: var(--stage-h);
    /* Still display:none while closed, deliberately: the card holds a lazy <img>
       and its own scroll container, and keeping it merely invisible would put
       both live on every page load for a panel most visitors never open. The
       cost of that choice is that display isn't animatable — hence the discrete
       transition below, which holds `flex` for exactly as long as the slide out
       needs and only then drops the box. */
    display: none;
    transform: var(--sheet-out);
    /* CLOSING. The card leaves first (no delay), and `display` flips to none only
       once it is off screen. Browsers without allow-discrete / @starting-style
       simply skip the animation and show/hide instantly — which is precisely the
       behaviour this replaced, so there is nothing to fall back to. */
    transition: transform var(--sheet-ms) var(--sheet-ease),
                display   var(--sheet-ms) allow-discrete;
    overflow-y: auto;            /* a short phone must still reach the bottom */
    /* Don't chain scroll to the stage behind it. Y only, like the rest — the card
       is where the back gesture is most likely to be used, since it is what the
       gesture now closes. */
    overscroll-behavior-y: contain;
    /* Padded off the system bars: the sheet runs edge to edge under
       viewport-fit=cover, so without this the first and last rows sit under the
       status bar / nav chin. env() is 0 where there is nothing to clear. */
    padding: calc(var(--gutter) * 1.5 + env(safe-area-inset-top, 0px))
             calc(var(--gutter) * 1.5 + env(safe-area-inset-right, 0px))
             calc(var(--gutter) * 1.5 + env(safe-area-inset-bottom, 0px))
             calc(var(--gutter) * 1.5 + env(safe-area-inset-left, 0px));
    background-color: var(--background);
    z-index: 6;                  /* the only thing above the scrim */
    cursor: default;             /* clicking the card no longer closes it */
}

#contactDiv.open {
    display: flex;
    transform: var(--sheet-in);
    /* OPENING. --sheet-lead is the card hanging back so the black arrives first. */
    transition: transform var(--sheet-ms) var(--sheet-ease) var(--sheet-lead),
                display   var(--sheet-ms) allow-discrete;
}

/* Where the card animates FROM on the frame it first gets a box. Without this
   there is no previous computed style to interpolate from — display:none has
   none — and the card would just appear at --sheet-in. It reads the same token
   as the closed state, so on desktop (where that token is the centring
   transform) start and end are identical and nothing moves. */
@starting-style {
    #contactDiv.open {
        transform: var(--sheet-out);
    }
}

/* Close affordance, pinned to the card's own top-right corner (the card is
   position:absolute, so it's the containing block for this). */
.contact-close {
    position: absolute;
    /* Its own insets, not the card's padding: it is absolutely positioned against
       the card, so padding doesn't move it — and under viewport-fit=cover it would
       land under the status bar. On the mobile sheet this is the ONLY way out. */
    top: env(safe-area-inset-top, 0px);
    right: env(safe-area-inset-right, 0px);
    font: inherit;
    font-size: clamp(1.5rem, 1.3rem + 0.8vw, 2rem);
    line-height: 1;
    /* On the mobile sheet this is the ONLY way out, and it was a ~32x30px target.
       A centred 44px box keeps the glyph where it was and makes it hittable. */
    min-width: var(--tap);
    min-height: var(--tap);
    display: grid;
    place-items: center;
    padding: 0;
    border: none;
    background: transparent;
    color: var(--ink);
    cursor: pointer;
}

.contact-close:hover {
    opacity: 0.6;
}

/* Contact card — ported from the standalone contact.html, re-scoped under
   #contactDiv and switched onto the site's colour tokens (the global font/reset
   already apply). Clicks on it don't close the overlay (script.js closes on
   anything outside it), so the links stay usable.
   Centred with auto margins rather than the parent's align/justify-center: when
   the content is taller than the sheet, auto margins collapse to 0 and the whole
   card stays scrollable, where centring would push its top out of reach. */
#contactDiv .contact {
    display: flex;
    flex-flow: column;
    width: 100%;
    max-width: var(--max-width);
    margin: auto;
    color: var(--ink);
    cursor: default;
    /* The card is the query container for its own internals (see the @container
       below). It has to be: on desktop this card is a centred box whose width has
       nothing to do with the viewport's, so a media query asking about the
       viewport is asking the wrong question — it can report 1600px while the card
       itself is 420px wide and needs the stacked arrangement. */
    container: contact / inline-size;
}

#contactDiv .headlineDiv {
    font-size: var(--fs-title-lg);
    padding: var(--gutter);
}

#contactDiv .midsection {
    display: flex;
    align-items: flex-end;
}

#contactDiv .aboutmeDiv {
    text-transform: uppercase;
    font-size: var(--fs-body);
    flex: 1;
    display: flex;
    justify-content: flex-end;
    padding: var(--gutter);
    width: 50%;
}

/* A measure, not a percentage: 34ch holds this one-line enquiry note at two
   comfortable lines whatever the font-size resolves to, where the old 62% got
   narrower on a phone (where it needed to be wider) and wider on a desktop. */
#contactDiv .aboutmeDiv p {
    width: min(100%, 34ch);
}

#contactDiv .fotoDiv {
    flex: 1;
    padding: var(--grid-gap);
    width: 50%;
}

#contactDiv .fotoDiv img {
    max-width: 55%;
    width: 100%;
    height: auto;
    align-self: flex-end;
}

#contactDiv .linksDiv {
    display: flex;
    flex-direction: column;
    align-items: flex-end;
    padding-top: var(--gutter);
}

#contactDiv .linksDiv a {
    color: var(--ink);
    font-size: var(--fs-link);
    width: fit-content;
    margin-right: clamp(1rem, 0.5rem + 2vw, 1.875rem);
    font-weight: bold;
    /* Full-height rows: these are the primary actions on this card and were
       ~24px tall on a phone. */
    min-height: var(--tap);
    display: inline-flex;
    align-items: center;
}

#contactDiv .linksDiv a:hover {
    text-decoration: underline;
}

/* Wraps rather than overflowing: three legal links at a fixed size ran past the
   card on a 320px screen. */
#contactDiv .impressumDiv {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    width: 100%;
    padding: var(--grid-gap);
    font-size: var(--fs-body);
}

#contactDiv .impressumDiv a {
    color: var(--ink);
    padding: 0 var(--gutter);
    min-height: var(--tap);
    display: inline-flex;
    align-items: center;
}

#contactDiv .impressumDiv a:hover {
    text-decoration: underline;
}

/* Narrow card → photo above the copy, both full width. Asked of the card, not
   the viewport (see container: contact above), so it fires at the moment the
   two-column midsection actually stops fitting — which on the mobile sheet is
   the same ~600px as before, and on desktop is now correct instead of never. */
@container contact (max-width: 600px) {
    #contactDiv .midsection {
        flex-direction: column-reverse;
    }

    #contactDiv .fotoDiv {
        display: flex;
        justify-content: flex-end;
        width: 100%;
    }

    #contactDiv .aboutmeDiv {
        flex-grow: 0;
        align-self: flex-start;
        justify-content: flex-start;
        width: 100%;
    }
}

/* ── Two columns ───────────────────────────────────────────────────────────────
   Everything above is the stacked base; this block turns it into two columns and
   moves the selection line to the top. Keeping it in one place means the wide
   arrangement is readable as a whole instead of scattered across the file.

   TWO conditions, not one — the comma is OR:

     (min-width: 900px)                      a real desktop or a tablet
     (min-width: 600px) and (max-height: 500px)   any phone held sideways

   Width alone was wrong in both directions, and a phone in landscape is where it
   showed. Above the old line, an iPhone Pro Max at 926x428 counted as "desktop"
   and got a panel 80% of a 428px stage under a CONTACT set in 112px type. Below
   it, an iPhone at 812x375 counted as "mobile" and got the stacked layout — a
   187px-tall panel trying to show an image that reserves 451px, so the top half
   of the screen was a sliver of a photo.

   Both are the same mistake: the stacked arrangement needs vertical room (it
   spends half the stage on the panel) and the two-column one doesn't (it spends
   width instead). So the second condition routes short-and-wide viewports here
   deliberately — this IS the landscape-phone layout, and it always was. The
   sizes below then carry svh caps so it survives the 375px stage it now has to
   fit into; see the --fs-* tokens at the top of the file. */
@media (min-width: 900px), (min-width: 600px) and (max-height: 500px) {
    :root {
        /* Wider column and more room above/below than the stacked layout has, so
           the headline can go up — but the svh term caps it on a landscape phone,
           which reaches this block at ~375px of stage. */
        --fs-headline: clamp(2.25rem, min(1.46rem + 1.75vw, 9svh), 4rem);
        /* The name can grow a little too, or it reads as a caption next to the
           CONTACT type. Continuous with the mobile token at exactly 900px. */
        --fs-nav: clamp(1rem, 0.7rem + 0.55vw, 1.5rem);
        /* The big CONTACT. Was a flat 7rem, which is 112px whether the window is
           4K or 900px wide — at 900px it was two thirds of the row. Now it only
           reaches 7rem where 7rem fits (~1930px), and the svh term keeps it from
           overrunning the bottom of a short stage, since it hangs off the panel's
           80% line rather than the stage's bottom edge. */
        --fs-cta: clamp(2.5rem, min(5.8vw, 12svh), 7rem);

        /* The contact overlay stops being a sheet here and becomes a centred
           card, so there is nothing for it to slide in from — a wipe only reads
           as a wipe when the thing wiping covers the edge it comes from.
           Making both ends of the travel the same centring transform turns the
           whole animation off without touching a single rule: the transform
           never changes, so no transition fires, and @starting-style resolves to
           the same value it would end at. --sheet-ms goes to 0 with it, since its
           only remaining job would be holding the hidden card on screen.
           The black goes back to what it is here — a dim around the card, and the
           click-outside target — so it stops moving and fades instead. */
        --sheet-out:   translate(-50%, -50%);
        --sheet-in:    translate(-50%, -50%);
        --sheet-ms:    0ms;
        --sheet-lead:  0ms;
        --scrim-out:   none;
        --scrim-in:    none;
        --scrim-bg:    rgba(0, 0, 0, 0.55);
        --scrim-o-out: 0;
        --scrim-o-in:  1;
        --scrim-ms:    200ms;
        --scrim-lead:  0ms;
        /* The panel is a column now, so the divider no longer splits the stage —
           it just keeps the mobile fallback out of the way. */
        --divider: calc(var(--stage-h) * 0.99);
        /* The headline column sits beside the panel rather than under it, so the
           selection line goes to the top of the scroller: whichever headline is
           at the top is the active one. This also collapses .spacer-top. */
        --snap-line: var(--snap-offset);
    }

    /* Left column. */
    .content-buffer {
        max-width: none;
        width: var(--panel-width);
        height: var(--panel-height);
        margin-left: var(--panel-inset);
    }

    /* Right column: takes the width the panel leaves, pushed clear of it with a
       margin rather than a relative offset, so the box actually occupies the
       space it appears to. */
    #scroller section {
        max-width: none;
        width: calc(100% - var(--panel-width) - var(--panel-inset));
        margin-left: calc(var(--panel-width) + var(--panel-inset));
        margin-right: 0;
        /* The channel between the two columns, narrowed from --gutter. The base
           padding is a page MARGIN — right for an edge the text would otherwise
           run into, wrong for an inner seam, where it stacked on top of the
           panel's own --grid-gap padding and left ~29px of dead air. Matching the
           panel's inner padding instead gives both boxes the same 5px inset, so
           the seam reads as one gutter of the image grid rather than a gap. The
           right side keeps --gutter: that one IS a page edge. */
        padding-left: var(--grid-gap);
    }

    /* The fullscreen mobile sheet becomes a centred card, so the scrim (and the
       page behind it) stays visible around the edges — which is also what makes
       "click outside to close" reachable here. */
    #contactDiv {
        top: 50%;
        left: 50%;
        /* No `transform` here — the centring IS --sheet-out / --sheet-in, which the
           base rules already apply in both states (see the token block in :root).
           Declaring it a third time would give the centring two sources that have
           to agree, and the transition tokens are the ones that must win. */
        /* An explicit width, not fit-content. fit-content resolves to the card's
           MAX-CONTENT width — the longest line of text in it — so the card was
           sized by its copy rather than by its layout, which is why it came out
           slim while .contact inside it was still asking for its full 900px.
           The first term is that 900px plus the card's own padding (1.5 * gutter
           per side), so .contact gets exactly its --max-width; the second keeps a
           gutter of scrim showing at every width, which on desktop is also the
           only click-outside target. */
        width: min(calc(var(--max-width) + 3 * var(--gutter)), calc(100% - 2 * var(--gutter)));
        height: fit-content;
        max-height: calc(var(--stage-h) - 2 * var(--gutter));   /* never taller than the stage */
    }

    /* Masks the top edge of the stage (see the stacking list). Below the contact
       scrim, so the dimming covers this strip too instead of leaving a seam. */
body:not(.intro-active)::before {
    content: "";
    position: absolute;
    top: 0;
    left: 0;
    width: calc(var(--panel-width) + var(--panel-inset));
    height: 17px;
    background: var(--background);
    z-index: 4;
}

body:not(.intro-active) #navDiv {
    position: absolute;
    top: var(--panel-height);
    bottom: auto;
    padding: 0;
    /* Hung off the panel's left edge, not the page's. The base rule insets the nav
       by --gutter because on mobile it floats over the bottom of the stage with
       nothing to line up with; here it sits directly under the images, so the name
       starts where they do — --grid-gap, the panel's own padding. The safe-area
       term stays: this block also catches a notched phone in landscape. */
    left: calc(var(--grid-gap) + env(safe-area-inset-left, 0px));
    /* width:auto, NOT 100%. The base rule sets both left and right (each carrying
       its safe-area inset), so a width of 100% over-constrained the box — `right`
       lost, and the nav ran 100vw from an already-inset left edge, i.e. past the
       right edge of the screen. On a notched phone in landscape, which now lands
       in this block, that inset is ~44px and it put CONTACT under the notch.
       Letting left/right define the box is what they were written to do. */
    width: auto;
    max-width: none;
    /* Restores the pre-existing look here: the base rule centres the two items,
       which is right when they're a 13px name and a 44px pill, but this layout
       pairs a small name with ~7rem type and hangs both from the panel's edge. */
    align-items: flex-start;
}

body.intro-active #contactTxt {
    display: none;
}


/* CONTACT button inverts colours normally → give it a visible outline over video. */
body #contactTxt {
    background: transparent;
    color: var(--ink);
    font-size: var(--fs-cta);
    margin-right: 10%;
    padding: 0;
    min-height: 0;              /* the 44px floor is moot at this size */
    transform: translate(0, -2rem);
}

/* Guarded for the same reason as the mobile rule above: this block also catches
   landscape phones (the min-width:600px / max-height:500px arm), which are touch. */
@media (hover: hover) {
    body #contactTxt:hover {
        text-decoration: underline;
        background: transparent;
        color: var(--ink);
    }
}



}

/* Reduced-motion: no ticker — fall back to normal wrapping; static dot placeholders. */
@media (prefers-reduced-motion: reduce) {
    #scroller section.active h2 {
        animation: none;
        white-space: normal;
        width: auto;
    }

    /* The scroller is the one place on this page that animates POSITION, and it
       does it on every snap and every anchor jump — which is exactly the motion
       this query is about. Snapping still works; it just arrives instantly. */
    #scroller {
        scroll-behavior: auto;
    }

    /* The contact wipe is a large-area movement across the whole screen, which is
       the class of motion this setting is most specifically about. Zeroing the
       durations leaves the overlay working exactly as before it was animated —
       it appears and disappears — with no rule disabled. */
    :root {
        --sheet-ms:   0ms;
        --sheet-lead: 0ms;
        --scrim-ms:   0ms;
        --scrim-lead: 0ms;
    }

    /* No colour sweep — rest on one representative tint (the central zone) so a
       still still hints at its photo's colour without any motion. */
    .still {
        animation: none;
        background-color: var(--zbb, var(--background));
    }
    .intro-ph {
        animation: none;
    }
    .still img {
        transition: none;           /* photo appears instantly, no fade */
    }
}
