/* ==========================================================================
   nathantbenke.github.io: the one stylesheet
   Round 4: ORIGINAL space atmosphere (no template DNA): nebula gradient
   fields + three parallax star layers as real fixed elements (compositor-
   only motion), wide layout, clean bold H2s, product-shelf Labs, dark-chip
   toolchain. Perf rules: animated layers are transform/opacity only,
   observers are one-shot, no background-attachment, no backdrop-filter.
   ========================================================================== */

/* ---------- Fonts ---------- */

@font-face {
    font-family: 'Agustina';
    src: url('../fonts/agustina-logo.woff2') format('woff2');
    font-display: swap;
}

/* ---------- Tokens ---------- */

:root {
    color-scheme: dark light;

    --bg: #07050e;
    --bg-elev: #0e0b18;
    --text: #eae7f2;
    /* Raised from #a49fc0. That cleared WCAG AA at 8:1, but APCA put it at
       Lc 54 against the deepened base while the light theme sat at Lc 76:
       a real perceptual gap, and why muted copy read dim in dark mode only.
       #c4bfd9 is 11.4:1 / Lc 71, still a clear step below --text. */
    --text-muted: #c4bfd9;
    --border: #362a55;

    --spec-a: #67e8f9;
    --spec-b: #a78bfa;
    --spec-c: #f0abfc;
    --accent: var(--spec-b);
    --accent-strong: #c4b1ff;
    --magenta: #d63384;
    --gradient-spectral: linear-gradient(100deg, var(--spec-a), var(--spec-b), var(--spec-c));
    --gradient-name: linear-gradient(100deg, #8ecdf6, #b79dfc, #f394d6);
    --gradient-pill: linear-gradient(90deg, #7c3aed, #d63384);

    /* Nebula atmosphere, Luminous Vast discipline: deep quiet staging,
       the fields set depth, they are not the luminous event (the hero is). */
    --atmo:
        radial-gradient(1100px 780px at 12% 8%, rgba(104, 76, 214, 0.06), transparent 62%),
        radial-gradient(900px 640px at 88% 20%, rgba(40, 78, 214, 0.055), transparent 60%),
        linear-gradient(168deg, #090716 0%, #06050f 45%, #030208 100%);

    /* ---- v1.1 nebula: atmospheric depth behind the starfield (DARK ONLY) ----
       One master multiplier drives every sub-layer's opacity, so intensity is a
       single number to tune. Presets live in [data-nebula] below; 1 = medium.

       The tiles are SVG feTurbulence rendered ONCE into a background image.
       That is deliberate: it is how this layer gets soft, cloud-like structure
       without `filter: blur()`, which would be a live per-frame GPU pass. The
       tiles are small and upscaled hard by background-size - the bilinear
       upscale IS the blur, baked in at rasterize time and never recomputed. */
    --neb-i: 2.5;

    /* dust: black, alpha driven by the noise (patchy, ~50% coverage) */
    --neb-tile-dust: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='512' height='512'%3E%3Cfilter id='d' filterUnits='userSpaceOnUse' x='0' y='0' width='512' height='512' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.014' numOctaves='4' seed='11' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 0 0 0 0 0 0 0 0 0 0 0 1 0 0 0 -.42'/%3E%3C/filter%3E%3Crect width='512' height='512' filter='url(%23d)'/%3E%3C/svg%3E");
    /* emission: fixed violet-blue, alpha driven by the noise (different seed,
       so the filaments never land on top of the dust and cancel out) */
    --neb-tile-glow: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='512' height='512'%3E%3Cfilter id='e' filterUnits='userSpaceOnUse' x='0' y='0' width='512' height='512' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.0095' numOctaves='4' seed='29' stitchTiles='stitch'/%3E%3CfeColorMatrix values='0 0 0 0 .62 0 0 0 0 .55 0 0 0 0 1 1 0 0 0 -.55'/%3E%3C/filter%3E%3Crect width='512' height='512' filter='url(%23e)'/%3E%3C/svg%3E");
    /* fine grain: tiled 1:1. Doubles as dithering - five stacked near-black
       gradients band badly on 8-bit panels, and this is what kills it. */
    --neb-tile-grain: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='336' height='336'%3E%3Cfilter id='g' filterUnits='userSpaceOnUse' x='0' y='0' width='336' height='336' color-interpolation-filters='sRGB'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='.85' numOctaves='2' seed='5' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='336' height='336' filter='url(%23g)' opacity='.55'/%3E%3C/svg%3E");
    /* keeps the turbulence inside the cloud region instead of veiling the
       whole viewport (which would just read as a dirty screen) */
    /* Extra stops, and a longer tail. The old three-stop ramp reached
       transparent at 78% and that boundary was visible as a circular arc once
       the contouring made shallow ramps legible. More stops spread the same
       alpha change over more of the radius, so no single step is wide. */
    --neb-mask: radial-gradient(72vw 78vh at 64vw 61vh,
        #000 12%, rgba(0, 0, 0, 0.72) 38%, rgba(0, 0, 0, 0.42) 58%,
        rgba(0, 0, 0, 0.16) 76%, transparent 94%);

    /* star tiles: small (far), small (mid, reused at other scale), bright w/ glow */
    --star-alpha: 0.55;
    --star-tile: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='700'%20height='700'%3E%3Cg%20fill='%23ffffff'%3E%3Ccircle%20cx='23'%20cy='45'%20r='.9'%20opacity='.8'/%3E%3Ccircle%20cx='88'%20cy='132'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='160'%20cy='60'%20r='1.1'%20opacity='.9'/%3E%3Ccircle%20cx='240'%20cy='190'%20r='.7'%20opacity='.4'/%3E%3Ccircle%20cx='310'%20cy='90'%20r='.5'%20opacity='.6'/%3E%3Ccircle%20cx='395'%20cy='160'%20r='1'%20opacity='.7'/%3E%3Ccircle%20cx='470'%20cy='40'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='540'%20cy='120'%20r='.8'%20opacity='.85'/%3E%3Ccircle%20cx='620'%20cy='200'%20r='.5'%20opacity='.4'/%3E%3Ccircle%20cx='70'%20cy='290'%20r='.7'%20opacity='.6'/%3E%3Ccircle%20cx='180'%20cy='340'%20r='1.2'%20opacity='.9'/%3E%3Ccircle%20cx='300'%20cy='300'%20r='.5'%20opacity='.35'/%3E%3Ccircle%20cx='430'%20cy='330'%20r='.8'%20opacity='.6'/%3E%3Ccircle%20cx='560'%20cy='290'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='650'%20cy='360'%20r='1'%20opacity='.8'/%3E%3Ccircle%20cx='40'%20cy='430'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='150'%20cy='480'%20r='.8'%20opacity='.7'/%3E%3Ccircle%20cx='280'%20cy='450'%20r='.5'%20opacity='.4'/%3E%3Ccircle%20cx='390'%20cy='500'%20r='1.1'%20opacity='.85'/%3E%3Ccircle%20cx='520'%20cy='470'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='630'%20cy='520'%20r='.7'%20opacity='.6'/%3E%3Ccircle%20cx='90'%20cy='580'%20r='1'%20opacity='.8'/%3E%3Ccircle%20cx='220'%20cy='620'%20r='.6'%20opacity='.45'/%3E%3Ccircle%20cx='350'%20cy='660'%20r='.8'%20opacity='.7'/%3E%3Ccircle%20cx='480'%20cy='610'%20r='.5'%20opacity='.4'/%3E%3Ccircle%20cx='600'%20cy='650'%20r='.9'%20opacity='.75'/%3E%3C/g%3E%3C/svg%3E");
    --star-bright: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='900' height='900'%3E%3Cg%3E%3Ccircle cx='120' cy='90' r='7' fill='%23ffffff' opacity='.10'/%3E%3Ccircle cx='120' cy='90' r='1.8' fill='%23ffffff' opacity='.95'/%3E%3Ccircle cx='430' cy='210' r='6' fill='%2367e8f9' opacity='.14'/%3E%3Ccircle cx='430' cy='210' r='1.6' fill='%23c9f6ff' opacity='.9'/%3E%3Ccircle cx='740' cy='120' r='7' fill='%23ffffff' opacity='.10'/%3E%3Ccircle cx='740' cy='120' r='1.9' fill='%23ffffff' opacity='.9'/%3E%3Ccircle cx='250' cy='430' r='6' fill='%23f0abfc' opacity='.14'/%3E%3Ccircle cx='250' cy='430' r='1.5' fill='%23ffd9f6' opacity='.9'/%3E%3Ccircle cx='620' cy='520' r='7' fill='%23ffffff' opacity='.10'/%3E%3Ccircle cx='620' cy='520' r='1.8' fill='%23ffffff' opacity='.95'/%3E%3Ccircle cx='90' cy='700' r='6' fill='%23a78bfa' opacity='.14'/%3E%3Ccircle cx='90' cy='700' r='1.6' fill='%23e2d8ff' opacity='.9'/%3E%3Ccircle cx='800' cy='780' r='7' fill='%23ffffff' opacity='.10'/%3E%3Ccircle cx='800' cy='780' r='1.8' fill='%23ffffff' opacity='.9'/%3E%3Ccircle cx='460' cy='820' r='5' fill='%2367e8f9' opacity='.12'/%3E%3Ccircle cx='460' cy='820' r='1.4' fill='%23d8f9ff' opacity='.85'/%3E%3C/g%3E%3C/svg%3E");

    /* type scale */
    --fs--1: 0.9375rem;
    /* round-5 scale pass: base +1 step, display sizes matched to the
       old site's presence (hero ~4.75rem, H2 ~3rem at desktop) */
    --fs-0: 1.125rem;
    --fs-1: 1.375rem;
    --fs-2: 1.75rem;
    --fs-3: clamp(1.9rem, 3.2vw, 2.25rem);
    --fs-4: clamp(2.3rem, 4.5vw, 3rem);
    --fs-5: clamp(2.7rem, 5.5vw, 4.75rem);

    /* spacing */
    --space-1: 0.25rem;
    --space-2: 0.5rem;
    --space-3: 0.75rem;
    --space-4: 1rem;
    --space-5: 1.5rem;
    --space-6: 2rem;
    --space-7: 3rem;
    --space-8: 4rem;

    --font: system-ui, "Segoe UI", Roboto, Helvetica, Arial, sans-serif;
    --font-sig: 'Agustina', 'Segoe Script', cursive;
    --radius: 10px;
    --content-width: 78rem; /* ~1248px: desktop uses the screen */

    /* motion system: entrances are transform/opacity only, <300ms,
       strong ease-out, never scale-from-0, one-shot, reduced-motion no-op */
    --ease-out: cubic-bezier(0.23, 1, 0.32, 1);
    --enter-duration: 280ms;
}

:root[data-theme="light"] {
    --bg: #faf9fd;
    --bg-elev: #ffffff;
    --text: #1c1728;
    --text-muted: #4b4762;
    --border: #ddd8ec;
    --accent: #7c5ce0;
    --accent-strong: #5b3fc4;
    --gradient-name: linear-gradient(100deg, #2563a8, #5b3fc4, #b02a6f);
    --star-alpha: 0.3;
    --star-tile: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='700'%20height='700'%3E%3Cg%20fill='%23474766'%3E%3Ccircle%20cx='23'%20cy='45'%20r='.9'%20opacity='.8'/%3E%3Ccircle%20cx='88'%20cy='132'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='160'%20cy='60'%20r='1.1'%20opacity='.9'/%3E%3Ccircle%20cx='240'%20cy='190'%20r='.7'%20opacity='.4'/%3E%3Ccircle%20cx='310'%20cy='90'%20r='.5'%20opacity='.6'/%3E%3Ccircle%20cx='395'%20cy='160'%20r='1'%20opacity='.7'/%3E%3Ccircle%20cx='470'%20cy='40'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='540'%20cy='120'%20r='.8'%20opacity='.85'/%3E%3Ccircle%20cx='620'%20cy='200'%20r='.5'%20opacity='.4'/%3E%3Ccircle%20cx='70'%20cy='290'%20r='.7'%20opacity='.6'/%3E%3Ccircle%20cx='180'%20cy='340'%20r='1.2'%20opacity='.9'/%3E%3Ccircle%20cx='300'%20cy='300'%20r='.5'%20opacity='.35'/%3E%3Ccircle%20cx='430'%20cy='330'%20r='.8'%20opacity='.6'/%3E%3Ccircle%20cx='560'%20cy='290'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='650'%20cy='360'%20r='1'%20opacity='.8'/%3E%3Ccircle%20cx='40'%20cy='430'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='150'%20cy='480'%20r='.8'%20opacity='.7'/%3E%3Ccircle%20cx='280'%20cy='450'%20r='.5'%20opacity='.4'/%3E%3Ccircle%20cx='390'%20cy='500'%20r='1.1'%20opacity='.85'/%3E%3Ccircle%20cx='520'%20cy='470'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='630'%20cy='520'%20r='.7'%20opacity='.6'/%3E%3Ccircle%20cx='90'%20cy='580'%20r='1'%20opacity='.8'/%3E%3Ccircle%20cx='220'%20cy='620'%20r='.6'%20opacity='.45'/%3E%3Ccircle%20cx='350'%20cy='660'%20r='.8'%20opacity='.7'/%3E%3Ccircle%20cx='480'%20cy='610'%20r='.5'%20opacity='.4'/%3E%3Ccircle%20cx='600'%20cy='650'%20r='.9'%20opacity='.75'/%3E%3C/g%3E%3C/svg%3E");
    --atmo:
        radial-gradient(1100px 780px at 12% 8%, rgba(124, 58, 237, 0.10), transparent 62%),
        radial-gradient(900px 640px at 88% 20%, rgba(37, 99, 235, 0.07), transparent 60%),
        radial-gradient(1200px 800px at 78% 96%, rgba(214, 51, 132, 0.06), transparent 60%),
        linear-gradient(168deg, #efeafa 0%, #f6f3fc 45%, #faf9fd 100%);
}

@media (prefers-color-scheme: light) {
    :root:not([data-theme]) {
        --bg: #faf9fd;
        --bg-elev: #ffffff;
        --text: #1c1728;
        --text-muted: #4b4762;
        --border: #ddd8ec;
        --accent: #7c5ce0;
        --accent-strong: #5b3fc4;
        --gradient-name: linear-gradient(100deg, #2563a8, #5b3fc4, #b02a6f);
        --star-alpha: 0.3;
        --star-tile: url("data:image/svg+xml,%3Csvg%20xmlns='http://www.w3.org/2000/svg'%20width='700'%20height='700'%3E%3Cg%20fill='%23474766'%3E%3Ccircle%20cx='23'%20cy='45'%20r='.9'%20opacity='.8'/%3E%3Ccircle%20cx='88'%20cy='132'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='160'%20cy='60'%20r='1.1'%20opacity='.9'/%3E%3Ccircle%20cx='240'%20cy='190'%20r='.7'%20opacity='.4'/%3E%3Ccircle%20cx='310'%20cy='90'%20r='.5'%20opacity='.6'/%3E%3Ccircle%20cx='395'%20cy='160'%20r='1'%20opacity='.7'/%3E%3Ccircle%20cx='470'%20cy='40'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='540'%20cy='120'%20r='.8'%20opacity='.85'/%3E%3Ccircle%20cx='620'%20cy='200'%20r='.5'%20opacity='.4'/%3E%3Ccircle%20cx='70'%20cy='290'%20r='.7'%20opacity='.6'/%3E%3Ccircle%20cx='180'%20cy='340'%20r='1.2'%20opacity='.9'/%3E%3Ccircle%20cx='300'%20cy='300'%20r='.5'%20opacity='.35'/%3E%3Ccircle%20cx='430'%20cy='330'%20r='.8'%20opacity='.6'/%3E%3Ccircle%20cx='560'%20cy='290'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='650'%20cy='360'%20r='1'%20opacity='.8'/%3E%3Ccircle%20cx='40'%20cy='430'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='150'%20cy='480'%20r='.8'%20opacity='.7'/%3E%3Ccircle%20cx='280'%20cy='450'%20r='.5'%20opacity='.4'/%3E%3Ccircle%20cx='390'%20cy='500'%20r='1.1'%20opacity='.85'/%3E%3Ccircle%20cx='520'%20cy='470'%20r='.6'%20opacity='.5'/%3E%3Ccircle%20cx='630'%20cy='520'%20r='.7'%20opacity='.6'/%3E%3Ccircle%20cx='90'%20cy='580'%20r='1'%20opacity='.8'/%3E%3Ccircle%20cx='220'%20cy='620'%20r='.6'%20opacity='.45'/%3E%3Ccircle%20cx='350'%20cy='660'%20r='.8'%20opacity='.7'/%3E%3Ccircle%20cx='480'%20cy='610'%20r='.5'%20opacity='.4'/%3E%3Ccircle%20cx='600'%20cy='650'%20r='.9'%20opacity='.75'/%3E%3C/g%3E%3C/svg%3E");
        --atmo:
            radial-gradient(1100px 780px at 12% 8%, rgba(124, 58, 237, 0.10), transparent 62%),
            radial-gradient(900px 640px at 88% 20%, rgba(37, 99, 235, 0.07), transparent 60%),
            radial-gradient(1200px 800px at 78% 96%, rgba(214, 51, 132, 0.06), transparent 60%),
            linear-gradient(168deg, #efeafa 0%, #f6f3fc 45%, #faf9fd 100%);
    }
}

/* ---------- Base ---------- */

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

html { -webkit-text-size-adjust: 100%; }

/* ---------- Phone type scale ----------
   ONE base change rather than per-element bumps: raising the root font-size
   scales every rem-based token together, so titles, body, meta and captions all
   grow and the hierarchy is preserved exactly.

   56rem, not the 78rem hero stack point, because media-query rem always
   resolves against the INITIAL 16px (never the root override), so this
   breakpoint is stable, and 896px keeps 900px desktop untouched as required.

   --fs--1 also gets its own lift: a straight proportional bump leaves the
   bottom of the scale (small-caps labels, back links) still reading small on a
   phone, so the scale compresses slightly at the low end. It stays clearly
   below --fs-0, so nothing about the hierarchy inverts. */
@media (max-width: 56rem) {
    :root {
        font-size: 18px;
        --fs--1: 1rem;
    }
}

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

body {
    margin: 0;
    font-family: var(--font);
    font-size: var(--fs-0);
    line-height: 1.6;
    color: var(--text);
    background-color: var(--bg);
    overflow-x: clip;
}

/* ---------- Background stack: real fixed elements, compositor-only motion.
   JS moves .bg-stars-* with translate3d directly, so no root-var invalidation. */

.bg, .bg > div {
    position: fixed;
    inset: 0;
    pointer-events: none;
}
.bg { z-index: -1; contain: strict; }

.bg-atmo {
    background-image: var(--atmo);
    background-repeat: no-repeat;
    background-size: cover;
}

/* ---------- v1.1 nebula: a VOLUME behind everything ----------

   TWO ELEMENTS THAT COST ONE EXTRA COMPOSITED SURFACE.

   v1.1.2 stacked four full-bleed promoted planes. Each is a large
   semi-transparent surface the GPU must BLEND every frame, and blend cost
   scales with viewport area - at 4K/DPR2 that is ~206 megapixels of blending
   per frame before the starfield is even counted. Raster counts are blind to
   this (the layers genuinely do not re-rasterise), which is exactly why the
   headless harness cleared the nebula and a real 4K did not.

   So density is now BAKED INTO FEWER, DENSER LAYERS instead of stacked as thin
   ones:

     .neb-base   static. Emission field + both turbulence passes, one element,
                 one background stack. NOT promoted and NOT transformed, so it
                 rasterises into the same layer as .bg-atmo and adds ZERO new
                 surfaces to blend.
     .neb-grain  static and unpromoted, adjacent to .neb-base, so it folds into
                 that same layer. A separate element only so grain stays tunable
                 independently of the base.
     .neb-drift  the ONE moving layer. Promoted, transform-parallax, carries the
                 near wisps and the dust columns. The only surface the nebula
                 adds to the per-frame blend.

   Depth still reads because a static plane at infinity plus one drifting plane
   IS parallax, and the three star layers above supply three more rates on top.

   No live filter anywhere: softness is gradients plus upscaled pre-rendered
   turbulence (see the tile tokens above). */

/* `.bg > .bg-nebula`, not `.bg-nebula`: the shared `.bg, .bg > div { inset: 0 }`
   rule above is (0,1,1) and silently outranks a bare class selector. */
.bg > .bg-nebula { inset: 0 0 -320px 0; }

.bg-nebula > div {
    position: absolute;
    inset: 0;
}

/* Only the drifting plane is promoted. The other two are deliberately left
   un-promoted so the compositor folds them into the existing background layer
   instead of handing us two more full-bleed surfaces to blend every frame. */
.neb-drift { will-change: transform; }

/* Small viewports: no parallax, so nothing needs promoting. main.js makes the
   same decision for the transform writes - a per-frame transform on an
   un-promoted layer repaints every frame, which is worse than no parallax at
   all. If you move one, move the other. */
@media (max-width: 56rem) {
    .bg > .bg-nebula { inset: 0; }
    .neb-drift { will-change: auto; }
}

/* ART IS SIZED IN vh, NOT %.
   These boxes are viewport-height PLUS parallax slack, so percentages would
   re-place every gradient whenever the slack changed and the composition would
   drift down the screen. vh is measured against the viewport and the boxes are
   top-aligned to it, so the art stays put whatever the slack is. */

/* The dense static base: colour mass and both turbulence passes in one stack.
   background-image is painted top-first, so the dust tile sits over the glow
   tile - dust occludes emission, which is the right way round.

   NO REPEAT on the tiles. Tiling them was the v1.1.1 vertical seam: a 256px
   source blown up to viewport scale does not sample across the boundary between
   two copies, so each copy's edge pixels clamp and the discontinuity is
   magnified into a hard straight line (measured at 10.1x the mean horizontal
   derivative, exactly where the tile edge landed). One oversized copy has no
   interior boundary to show, and vw/vh sizing means no viewport outruns it. */
.neb-base {
    opacity: calc(0.38 * var(--neb-i));
    background-image:
        var(--neb-tile-dust),
        var(--neb-tile-glow),
        /* A whisper of a hot core, just right of the main dust column so the
           column has something to silhouette against. Tiny and warm: it should
           read as "something is lit back there", never as a second luminous
           event competing with the hero. */
        radial-gradient(closest-side at 68vw 56vh,
            rgba(255, 206, 158, 0.048), rgba(240, 171, 252, 0.026) 46%, transparent 72%),
        radial-gradient(30vw 30vh at 68vw 56vh, rgba(206, 104, 190, 0.070), transparent 72%),
        radial-gradient(54vw 53vh at 63vw 51vh, rgba(120, 86, 212, 0.088), transparent 74%),
        radial-gradient(50vw 48vh at 86vw 20vh, rgba(58, 168, 206, 0.064), transparent 72%),
        radial-gradient(54vw 53vh at 14vw 91vh, rgba(66, 112, 196, 0.048), transparent 74%);
    /* UPSCALE RATIO IS THE ARTIFACT. A 256px tile stretched to 190vw is ~14x on
       a 1920 screen, so every 1-level step in the source becomes a ~14px band -
       which is what printed the topographic contour rings through the cloud.
       The tiles are now 512px and stretched less, taking the ratio to ~5.6x and
       ~6.2x, and their baseFrequency was rescaled to keep the on-screen feature
       size the same. If you change either number, change the other. */
    background-size: 150vw 150vh, 165vw 165vh, auto, auto, auto, auto, auto;
    background-position: 18% 22%, 58% 40%, 0 0, 0 0, 0 0, 0 0, 0 0;
    background-repeat: no-repeat;
    /* Wide and gentle - a vignette, not a keyhole. It holds the turbulence away
       from the far edges (unmasked noise across a whole 4K viewport just reads
       as a dirty screen) without cropping the colour mass, which now lives in
       this same element and therefore shares the mask. */
    -webkit-mask-image: var(--neb-mask);
    mask-image: var(--neb-mask);
}

/* Separate element purely so grain stays tunable apart from the base. Static
   and unpromoted, so it costs no additional surface. */
.neb-grain {
    /* This is the dither, and it was too weak to be one: measured, the base
       spans ~37 grey levels across the whole viewport and adding grain moved
       that from 38 to 39. Shallow ramps at this amplitude quantise into visible
       steps unless something breaks them up. Raised until the level count
       actually moves, and no further - past ~0.05 it starts reading as film
       grain rather than as dither. */
    /* NOT raised further to fight the falloff's temporal stepping - that was
       tried at 0.042 and measured identical (26.9% max pixel change per step
       against 26.0%). Grain is a SEPARATE layer, so it is composited and
       rounded to integers after the gradient underneath has already been
       quantised; an integer offset shifts every pixel equally and cannot
       decorrelate threshold crossings. Dither only works here for the spatial
       contours, which is what this value is tuned for. */
    opacity: calc(0.030 * var(--neb-i));
    background-image: var(--neb-tile-grain);
    background-size: 336px 336px;
    background-repeat: repeat;
}

/* The one moving layer: near wisps here, dust columns in its rotated child. */
.neb-drift {
    opacity: calc(0.36 * var(--neb-i));
    background-image: var(--neb-tile-glow);
    background-size: 145vw 145vh;   /* same upscale-ratio rule as .neb-base */
    background-position: 34% 62%;
    background-repeat: no-repeat;
    -webkit-mask-image: var(--neb-mask);
    mask-image: var(--neb-mask);
}

/* JS owns .neb-drift's transform for the parallax and would wipe a static
   rotate written to the same property - hence the inner box holding it. */
.neb-drift-art {
    position: absolute;
    inset: 0;
    /* rotate: CSS cannot tilt an individual gradient, so the group tilts.
       scale: at 11deg a 1:1 box exposes bare corners, and it buys the columns
       real size - they are the "vast structure" half of the scale contrast.
       transform-origin is pinned to the viewport centre, not the box centre,
       because the box is taller than the viewport by the parallax slack. */
    transform: rotate(11deg) scale(1.5);
    transform-origin: 50% 50vh;
    background-repeat: no-repeat;
    background-image:
        /* Rim light on the lit flank of each column, offset so it runs ALONG
           the silhouette rather than washing over it. Wide and soft: a thin
           bright line at this scale reads as a scratch. */
        radial-gradient(3.4vw 22vh at 49.5vw 58vh, rgba(150, 224, 255, 0.115), transparent 70%),
        radial-gradient(2.8vw 15vh at 58.2vw 68vh, rgba(224, 172, 255, 0.090), transparent 70%),
        radial-gradient(2.2vw 11vh at 47.6vw 51vh, rgba(168, 232, 255, 0.070), transparent 70%),
        /* The dust itself. Large, soft and OPAQUE - the structure is carried by
           what it BLOCKS, not by more glow, which is what keeps the value
           histogram bimodal (one dark mass, one bright spike at the hero)
           rather than quietly adding a second bright region. */
        radial-gradient(9vw 30vh at 57vw 60vh, rgba(3, 2, 9, 0.80), transparent 76%),
        radial-gradient(7vw 22vh at 64vw 70vh, rgba(3, 2, 9, 0.70), transparent 76%),
        radial-gradient(5.5vw 16vh at 52vw 52vh, rgba(3, 2, 9, 0.58), transparent 76%);
}

/* ---------- Large-viewport degrade ----------
   Driven by data-degrade on :root (main.js). Each token removes real per-frame
   blend work; none of them changes the composition or the density.

   Blend cost is per DESTINATION PIXEL per frame, which is why the only levers
   here are "remove a surface" and "shrink the area a surface covers".
   Deliberately absent: slowing the drift. A layer costs exactly the same to
   blend whether it travels 200px or 2px, so that would buy nothing. */

/* Fold the drift layer in with .neb-base and .neb-grain: no promotion, no
   transform, no separate surface. The nebula then costs zero extra blends -
   which is why soloing base/grain already measured as near-free. What it
   costs is the nebula's own parallax; the art is identical. */
:root[data-degrade~="drift-static"] .neb-drift {
    will-change: auto;
    transform: none !important;
}

/* Keep the motion, blend fewer pixels. --neb-mask is a radial centred at
   64vw 61vh with a 68vw/74vh radius that reaches full transparency at 78%, so
   the far left and the top strip of this layer are already contributing
   nothing. Cropping them is invisible and takes roughly a fifth of the area.

   clip-path rather than a smaller box, deliberately: the art inside is placed
   in vw/vh from the ELEMENT's origin, so moving the origin would slide the
   whole composition sideways. Cropping leaves the coordinate system alone. */
:root[data-degrade~="drift-cropped"] .neb-drift {
    -webkit-clip-path: inset(6vh 0 0 14vw);
    clip-path: inset(6vh 0 0 14vw);
}

/* A harder crop than drift-cropped, for the case where the nebula must KEEP
   its motion on a big screen. Two star layers already cost 16.6 MP/frame at 4K
   and that floor cannot be moved, so the only way a moving drift fits alongside
   them is to make the drift itself much smaller. This crops to roughly the box
   the dust columns and rim lights actually occupy after their rotate/scale
   (leftmost content lands near 44vw, topmost near 43vh), which is about half
   the area of the full-bleed layer. More visible than drift-cropped - it does
   reach into where the mask is still faintly non-zero - so it is offered, not
   defaulted. */
:root[data-degrade~="drift-tight"] .neb-drift {
    -webkit-clip-path: inset(22vh 4vw 4vh 26vw);
    clip-path: inset(22vh 4vw 4vh 26vw);
}

/* Star layers are 3 of the 4 blended surfaces at full richness. -3 is the
   bright/glow tile and the most expensive per pixel, so it goes first. */
:root[data-degrade~="stars-2"] .bg-stars-3 { display: none; }
:root[data-degrade~="stars-1"] .bg-stars-2,
:root[data-degrade~="stars-1"] .bg-stars-3 { display: none; }

/* Intensity presets: density AT THE TOP OF THE PAGE. main.js fades the drifting
   plane to --neb-floor of this past the hero. No attribute = present. */
:root[data-nebula="subtle"]  { --neb-i: 1.2; }
:root[data-nebula="medium"]  { --neb-i: 1.8; }
:root[data-nebula="present"] { --neb-i: 2.5; }
/* `display: none`, not opacity 0 - "off" should cost nothing at all */
:root[data-nebula="off"] .bg-nebula { display: none; }

/* DARK ONLY. Light mode stays clean and flavourless, as specified. */
:root[data-theme="light"] .bg-nebula { display: none; }
@media (prefers-color-scheme: light) {
    :root:not([data-theme]) .bg-nebula { display: none; }
}


/* THE DEAD `inset: -140px 0 0 0` IS GONE, AND NOT REPLACED WITH A BIGGER ONE.

   That rule never applied: `.bg, .bg > div { inset: 0 }` above is (0,1,1) and
   outranks a bare class selector, so every star layer has always computed to
   exactly viewport height. The visible consequence was that the layers
   translated out from under the viewport on a long page - at the bottom of the
   home page .bg-stars-3 sat entirely ABOVE it, so the deep scroll had no bright
   stars at all.

   The obvious fix - give each layer enough bottom slack to cover its travel -
   would cost .bg-stars-3 nearly 1000px of extra height, and layer AREA is
   precisely what makes these full-bleed surfaces expensive to blend at 4K.

   So main.js wraps each layer's translate MODULO its own tile size instead.
   The tiles repeat, so a layer offset by exactly one tile is pixel-identical to
   one offset by zero: the parallax runs forever and the wrap is invisible.

   That bounds the slack at ONE TILE instead of the whole travel. It does not
   remove it - measured: with zero slack, .bg-stars-1 still left a 222px strip
   bare at the page bottom, because its travel (222px) never reaches its 700px
   tile and so the modulo never engages. Coverage needs height >= viewport +
   offset, and the offset can be anything up to one tile. Hence one tile each.

   The win is that this is now CONSTANT rather than growing with the page: the
   naive fix needed 978px on .bg-stars-3 today and more every time the page gets
   longer. Each layer's slack below MUST equal its background-size, and both
   must match main.js's STAR_TILE. Three places, one number. */
.bg-stars {
    opacity: var(--star-alpha);
    will-change: transform;
}
.bg > .bg-stars-1 { inset: 0 0 -700px 0; }
.bg > .bg-stars-2 { inset: 0 0 -460px 0; }
.bg > .bg-stars-3 { inset: 0 0 -900px 0; }
.bg-stars-1 { background: var(--star-tile); background-size: 700px 700px; }
.bg-stars-2 { background: var(--star-tile); background-size: 460px 460px; background-position: 130px 250px; opacity: calc(var(--star-alpha) * 0.8); }
.bg-stars-3 { background: var(--star-bright); background-size: 900px 900px; background-position: 60px 40px; opacity: 0.9; }

h1, h2, h3 { line-height: 1.2; margin: 0 0 var(--space-4); }

/* Clean bold section headings; underline is structure, not a glow event */
h2 {
    font-size: var(--fs-4);
    font-weight: 800;
    letter-spacing: -0.015em;
    text-align: center;
    padding-bottom: var(--space-3);
    background-image: linear-gradient(
        color-mix(in srgb, var(--accent) 55%, var(--border)),
        color-mix(in srgb, var(--accent) 55%, var(--border)));
    background-repeat: no-repeat;
    background-size: 4.5rem 3px;
    background-position: 50% 100%;
}

h3 { font-size: var(--fs-2); }

p { margin: 0 0 var(--space-4); }

a {
    color: var(--accent-strong);
    text-decoration-thickness: 1px;
    text-underline-offset: 2px;
}
a:hover { color: var(--accent); }

img, svg { max-width: 100%; }

::selection { background: var(--accent); color: var(--bg); }

:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 2px;
    border-radius: 2px;
}

.visually-hidden {
    position: absolute;
    width: 1px; height: 1px;
    margin: -1px; padding: 0;
    overflow: hidden;
    clip: rect(0 0 0 0);
    white-space: nowrap;
    border: 0;
}

.skip-link {
    position: absolute;
    left: var(--space-4);
    top: -3rem;
    z-index: 10;
    padding: var(--space-2) var(--space-4);
    background: var(--bg-elev);
    border: 1px solid var(--border);
    border-radius: var(--radius);
    transition: top 0.15s ease;
}
.skip-link:focus { top: var(--space-2); }

/* ---------- Header / nav (no backdrop-filter, for perf) ---------- */

.site-header {
    position: sticky;
    top: 0;
    z-index: 5;
    background: color-mix(in srgb, var(--bg) 93%, transparent);
    border-bottom: 1px solid var(--border);
}

.site-nav {
    max-width: var(--content-width);
    margin: 0 auto;
    padding: var(--space-3) clamp(var(--space-4), 4vw, var(--space-7));
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
}

.brand {
    color: var(--text);
    text-decoration: none;
    display: inline-flex;
    align-items: baseline;
    gap: var(--space-2);
}
.brand-bracket { color: var(--text-muted); font-size: var(--fs--1); }
.brand-sig {
    font-family: var(--font-sig);
    font-size: 1.5rem;
    letter-spacing: 0.02em;
}

.nav-links {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-4);
}

/* The nav is a wrapping bar with no hamburger, so below 56rem it breaks into a
   logo row and a links row. justify-content applies PER FLEX LINE, so one
   declaration on each container centres both rows whether they wrap or not,
   without forcing a wrap where a single row still fits. Desktop keeps
   space-between (logo left, links right). */
@media (max-width: 56rem) {
    .site-nav {
        justify-content: center;
        /* A persistent header that wraps to three rows eats the viewport, so
           the spacing tightens here. Type sizes and the 44px tap targets are
           untouched: this is padding and leading only. */
        padding-block: var(--space-2);
        row-gap: var(--space-1);
    }
    .nav-links {
        justify-content: center;
        row-gap: var(--space-1);
        column-gap: var(--space-3);
    }

    /* 1.6 leading on a 27px script face reserved 43px for a 27px glyph */
    .brand-sig { line-height: 1.15; }

    /* 32px of line-box is under the 44px touch minimum on a phone */
    .nav-links a { padding-block: 6px; }
}

.nav-links a {
    color: var(--text-muted);
    text-decoration: none;
    font-size: var(--fs-0);
}
.nav-links a:hover { color: var(--text); }

#theme-toggle {
    display: inline-flex;
    align-items: center;
    padding: var(--space-2);
    background: none;
    border: 1px solid var(--border);
    border-radius: 999px;
    color: var(--text-muted);
    cursor: pointer;
}
#theme-toggle:hover { color: var(--text); border-color: var(--text-muted); }

/* theme-swapped brand art (mirrors the toggle-icon pattern) */
.only-light { display: none; }
:root[data-theme="light"] .only-light { display: inline-block; }
:root[data-theme="light"] .only-dark { display: none; }
@media (prefers-color-scheme: light) {
    :root:not([data-theme]) .only-light { display: inline-block; }
    :root:not([data-theme]) .only-dark { display: none; }
}

.icon-moon { display: none; }
:root[data-theme="light"] .icon-moon { display: block; }
:root[data-theme="light"] .icon-sun { display: none; }
@media (prefers-color-scheme: light) {
    :root:not([data-theme]) .icon-moon { display: block; }
    :root:not([data-theme]) .icon-sun { display: none; }
}

/* ---------- Layout: wide ---------- */

main {
    max-width: var(--content-width);
    margin: 0 auto;
    padding: 0 clamp(var(--space-4), 4vw, var(--space-7)) var(--space-8);
}

/* The sticky header has no hamburger and wraps to two or three rows on a phone,
   so its height runs from 63px to ~190px. A fixed 5.5rem offset left anchored
   headings sitting BEHIND it at every narrow width. --header-h is published by
   main.js; the 5.5rem fallback covers no-JS and matches the desktop header. */
section,
main[id] { scroll-margin-top: calc(var(--header-h, 5.5rem) + var(--space-4)); }
section { margin-top: clamp(var(--space-7), 10vh, 6rem); }

/* ---------- Hero ---------- */

.hero {
    position: relative;
    padding-top: var(--space-7);
    min-height: min(72vh, 52rem);
    display: grid;
    align-content: center;
}

/* Fixed pixel dimensions: sizing this in % of the hero made it resize when
   font swap settled the hero's height, and Chrome scored that as CLS. */
.hero::before {
    content: "";
    position: absolute;
    top: -340px;
    left: -30vw;
    width: 120vw;
    height: 880px;
    z-index: -1;
    pointer-events: none;
    background:
        /* text-region scrim: keeps hero copy contrast-safe over the bloom */
        radial-gradient(560px 420px at 32% 62%,
            color-mix(in srgb, var(--bg) 68%, transparent) 45%,
            transparent 85%),
        radial-gradient(closest-side at 44% 44%,
            color-mix(in srgb, var(--spec-a) 26%, transparent) 0%,
            color-mix(in srgb, var(--spec-b) 17%, transparent) 34%,
            color-mix(in srgb, var(--spec-c) 7%, transparent) 58%,
            transparent 76%);
}

.hero-inner {
    display: grid;
    grid-template-columns: minmax(0, 1fr);
    gap: var(--space-6);
    align-items: center;
}

/* Both tracks are flexible. A fixed-max track (minmax(340px, 560px)) is GREEDY:
   grid maximizes it to its growth limit before the 1fr gets anything, which
   crushed the prose to 173px at an 832px viewport. A proportional pair keeps
   the split honest at every width instead. Side by side only starts at 75rem,
   because below that a large portrait leaves no room for real prose.

   The ratio is set by a hard measurement: the three hero buttons need 552px to
   stay on one row, so the prose track must clear that with a little slack. At
   the 1152px content width that leaves the portrait ~590px.

   column-gap is 0 here on purpose. The portrait carries ~130px of transparent
   margin before the subject starts and its left edge is mask-faded, so a grid
   gutter would only shrink both tracks to buy separation that already exists. */
@media (min-width: 78rem) {
    .hero-inner {
        grid-template-columns: minmax(0, 0.95fr) minmax(0, 1fr);
        column-gap: 0;
    }
    .hero-portrait-wrap {
        justify-self: end;
        width: 100%;
        max-width: 620px;
    }
}

.hero-portrait-wrap {
    position: relative;
    justify-self: start;
    width: min(620px, 88vw);
    max-width: 100%;
}

/* glow sits BEHIND the subject, never outlining the silhouette */
.hero-portrait-wrap::before {
    content: "";
    position: absolute;
    inset: 6% -4% 10% -4%;
    z-index: -1;
    pointer-events: none;
    background: radial-gradient(closest-side,
        color-mix(in srgb, var(--spec-b) 20%, transparent),
        transparent 75%);
}

.hero-portrait {
    display: block;
    width: 100%;
    height: auto;
    /* dissolve on every edge: vertical and horizontal fades intersected */
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 2%, #000 74%, transparent 97%),
        linear-gradient(to right, transparent 0, #000 6%, #000 94%, transparent 100%);
    -webkit-mask-composite: source-in;
    mask-image: linear-gradient(to bottom, transparent 0, #000 2%, #000 74%, transparent 97%),
        linear-gradient(to right, transparent 0, #000 6%, #000 94%, transparent 100%);
    mask-composite: intersect;
}

/* The text column is deliberately narrower than "Thomas-Benke" at display size.
   Without this the line breaks AT the hyphen and orphans "Thomas-" - so the
   surname is kept whole and allowed to run into the transparent margin left of
   the portrait, which is empty space by design (the mask fades that edge). */
.hero-name .nowrap { white-space: nowrap; }
.hero-text { position: relative; z-index: 1; }

/* The name is gradient text (background-clip: text), so any glyph outside the
   element's own box gets no paint and simply vanishes. Widening the box past
   the grid track is therefore required, not cosmetic: 33rem fits "Thomas-Benke"
   whole at the capped display size and still stops short of the subject. */
@media (min-width: 78rem) {
    .hero-name { width: 34.5rem; } /* measured: surname is 538px at the 76px cap */
}

.hero-name {
    font-size: var(--fs-5);
    font-weight: 800;
    letter-spacing: -0.02em;
    margin-bottom: var(--space-3);
    text-wrap: balance;
    background: var(--gradient-name);
    -webkit-background-clip: text;
    background-clip: text;
    color: transparent;
}

/* "I build …" ticker: size fluid so the longest item stays one line on
   most viewports; below 40rem a second line is reserved so cycling items
   can never shift the content beneath (CLS 0) */
.hero-positioning {
    font-size: clamp(1.35rem, 1rem + 1.6vw, 1.75rem);
    font-weight: 600;
    margin-bottom: var(--space-2);
    min-height: 1.6em;
}
@media (max-width: 40rem) {
    .hero-positioning { min-height: 3.2em; }
}
.ticker {
    color: var(--accent-strong);
}
.ticker.is-fading { opacity: 0; transition: opacity 0.28s ease; }

.hero-role {
    color: var(--text-muted);
    font-size: var(--fs-1);
    margin-bottom: var(--space-5);
}

.hero-actions {
    list-style: none;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-3);
    padding: 0;
    margin: 0 0 var(--space-6);
}

/* 48em let the stacked layout run to ~100 characters a line; 32em keeps the
   About block inside a comfortable measure without touching the desktop
   split, where the track is already narrower than this cap. */
.hero-about { max-width: 32em; }

/* ---------- Stacked-width composition ----------
   Bound to the hero's ACTUAL stack point, not an arbitrary mobile width. The
   two-column hero starts at 78rem, so this covers 0 to 1247px: exactly the
   range where the hero is one column. An earlier 52rem cutoff left an
   832-1247px band that was stacked AND left-tucked, which is the bug.

   The rule everywhere here is CENTRE THE BLOCK, NOT THE LINES: containers get
   centred, but multi-line prose keeps text-align: left, because centred body
   copy gives every line a ragged left edge and costs real readability. */
@media (max-width: 77.9375rem) {
    .hero-inner { justify-items: center; }
    .hero-text { text-align: center; }
    .hero-actions { justify-content: center; }
    .hero-portrait-wrap { justify-self: center; }

    /* block centres, paragraphs stay left-justified inside it. The 32em base
       cap is the right measure beside the portrait, but in a stacked column it
       became a narrow ribbon with ~95px of dead margin each side, so it opens
       up to 46em here: effectively the full column on a phone, still capped
       before the line length runs away at the top of the stacked range. */
    .hero-about {
        text-align: left;
        max-width: 40em;
        margin-inline: auto;
    }
}

/* ---------- Pills ---------- */

.btn {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    padding: var(--space-3) var(--space-6);
    font-size: var(--fs-0);
    border: 1px solid var(--border);
    border-radius: 999px;
    background: color-mix(in srgb, var(--bg-elev) 80%, transparent);
    color: var(--text);
    text-decoration: none;
    cursor: pointer;
    transition: border-color 0.2s ease, box-shadow 0.2s ease, transform 0.2s ease;
}
.btn:hover {
    border-color: var(--accent);
    color: var(--text);
    transform: translateY(-1px);
    box-shadow: 0 4px 18px color-mix(in srgb, var(--spec-b) 25%, transparent);
}

.btn-primary {
    background: var(--gradient-pill);
    border: 0;
    color: #fff;
}
.btn-primary:hover { color: #fff; }

/* ---------- Selected Work ---------- */

.work-grid {
    display: grid;
    grid-template-columns: repeat(auto-fill, minmax(min(24rem, 100%), 1fr));
    gap: var(--space-5);
}

.work-card {
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: color-mix(in srgb, var(--bg-elev) 72%, transparent);
    padding: var(--space-6);
    display: flex;
    flex-direction: column;
    transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}

.work-card:hover {
    border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
    transform: translateY(-2px);
    box-shadow: 0 8px 28px color-mix(in srgb, var(--spec-b) 16%, transparent);
}

.work-card--featured { grid-column: 1 / -1; }

@media (min-width: 42rem) {
    .work-card--featured {
        display: grid;
        grid-template-columns: minmax(0, 1.2fr) minmax(0, 1fr);
        column-gap: var(--space-6);
        align-content: start;
    }
    .work-card--featured > * { grid-column: 2; }
    .work-card--featured .media-facade {
        grid-column: 1;
        grid-row: 1 / span 6;
        margin: calc(-1 * var(--space-6));
        margin-right: 0;
        height: calc(100% + 2 * var(--space-6));
        aspect-ratio: auto;
        min-height: 18rem;
        border-radius: var(--radius) 0 0 var(--radius);
        border-bottom: 0;
        border-right: 1px solid var(--border);
    }
    .work-card--featured .card-meta { margin-top: 0; }
}

.work-card h3 { margin: 0 0 var(--space-1); }

.card-meta {
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    margin: var(--space-4) 0 var(--space-1);
}

.card-context {
    margin: 0;
    font-size: var(--fs--1);
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-muted);
}

.card-year {
    margin: 0 0 0 auto;
    flex-shrink: 0;
    font-size: var(--fs--1);
    color: var(--accent-strong);
    border: 1px solid var(--border);
    border-radius: 999px;
    padding: 0 var(--space-3);
}

.card-role {
    color: var(--accent-strong);
    font-size: var(--fs--1);
    font-weight: 600;
    margin-bottom: var(--space-2);
}

.card-award {
    font-size: var(--fs--1);
    font-weight: 600;
    margin-bottom: var(--space-2);
}

.card-tagline {
    font-weight: 600;
    margin-bottom: var(--space-2);
}

.card-desc { flex-grow: 1; }

/* ARCS case study */
.cs-label {
    margin: var(--space-4) 0 var(--space-1);
    font-size: var(--fs--1);
    font-weight: 700;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--accent-strong);
}
.case-study .cs-label:first-child { margin-top: 0; }
.cs-body { margin-bottom: 0; }
.cs-metrics {
    margin: 0;
    padding-left: 1.2em;
}
.cs-metrics li { margin-bottom: var(--space-1); }

.card-stack {
    color: var(--text-muted);
    font-size: var(--fs--1);
    margin-bottom: 0;
}

.card-links {
    margin: var(--space-2) 0 0;
    font-size: var(--fs--1);
}

.media-facade {
    position: relative;
    aspect-ratio: 16 / 9;
    margin: calc(-1 * var(--space-6)) calc(-1 * var(--space-6)) 0;
    border-radius: var(--radius) var(--radius) 0 0;
    overflow: hidden;
    background: var(--bg);
    border-bottom: 1px solid var(--border);
}

/* The poster is absolutely positioned, so it is NOT a grid item of .facade-btn.
   A <picture> wrapper WOULD be one, and it pushed the centred play icon into a
   second grid row. display: contents does not help either - that hoists the
   <source> element in as a grid item instead. Taking the wrapper out of flow
   entirely is the only version that leaves the icon centred. */
.media-facade picture { position: absolute; inset: 0; }

.facade-poster {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.facade-btn {
    position: absolute;
    inset: 0;
    width: 100%;
    display: grid;
    place-items: center;
    padding: 0;
    background: none;
    border: 0;
    cursor: pointer;
    color: rgba(15, 15, 20, 0.78);
}
.facade-btn:hover .play-icon { color: #ff0033; }
/* The ARCS poster is our own dark title card, so the neutral dark play mark
   would sink into it, so it carries the red at rest instead. */
.media-facade--titlecard .play-icon { color: #e02040; }
.media-facade--titlecard .facade-btn:hover .play-icon { color: #ff3355; }
.facade-btn:hover .facade-poster { opacity: 0.88; }
.play-icon {
    position: relative;
    z-index: 1;
    transition: color 0.15s ease;
    filter: drop-shadow(0 1px 8px rgba(0, 0, 0, 0.5));
}

.media-facade iframe {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    border: 0;
}

/* ---------- NTB Labs: product shelf ---------- */

.labs-grid {
    display: grid;
    grid-template-columns: 1fr;
    gap: var(--space-5);
    margin-bottom: var(--space-5);
}
@media (min-width: 42rem) {
    .labs-grid { grid-template-columns: 1fr 1fr; } /* the product shelf is a 2×2 */
}

.labs-card {
    border: 1px solid var(--border);
    border-radius: var(--radius);
    background: color-mix(in srgb, var(--bg-elev) 72%, transparent);
    padding: var(--space-6);
    transition: border-color 0.2s ease, transform 0.2s ease, box-shadow 0.2s ease;
}
.labs-card:hover {
    border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
    transform: translateY(-2px);
    box-shadow: 0 8px 28px color-mix(in srgb, var(--spec-b) 16%, transparent);
}

/* Uniform neutral plates: product color identity returns when each has a
   real mark (Sift already does). Shelf consistency over per-chip hues. */
.labs-chip {
    display: grid;
    place-items: center;
    width: 72px;
    height: 72px;
    border-radius: 16px;
    margin-bottom: var(--space-4);
    font-weight: 800;
    font-size: 1.5rem;
    color: #eae7f2; /* plate stays dark in both themes, so the letter must too */
    background: #10162a;
    border: 1px solid var(--border);
}

.labs-card { position: relative; }

.labs-card h3 {
    display: inline-block;
    margin: var(--space-4) var(--space-2) var(--space-2) 0;
}

/* whole tile is one outbound link (stretched-link pattern, card-scoped) */
.labs-link { color: var(--text); text-decoration: none; }
.labs-card .labs-link::after { content: ""; position: absolute; inset: 0; }
.labs-card:hover .labs-link { color: var(--accent-strong); }

/* section identity: the NTB Labs wordmark as the heading mark */
.labs-heading {
    line-height: 0;
    background-image: none;
    padding-bottom: var(--space-4);
}
.labs-wordmark { width: min(480px, 84vw); height: auto; }
.labs-statement, .labs-breadth { text-align: center; }
.labs-statement { margin-bottom: var(--space-1); }
.labs-breadth {
    color: var(--text-muted);
    margin-bottom: var(--space-6);
}
.labs-visit { display: flex; width: max-content; margin-inline: auto; }

/* media area matches Selected Work's full-bleed card language */
.labs-media {
    position: relative;
    aspect-ratio: 16 / 9;
    margin: calc(-1 * var(--space-6)) calc(-1 * var(--space-6)) 0;
    border-radius: var(--radius) var(--radius) 0 0;
    overflow: hidden;
    display: grid;
    place-items: center;
    background: color-mix(in srgb, var(--bg-elev) 80%, transparent);
    border-bottom: 1px solid var(--border);
}
.labs-lockup { width: 64%; max-width: 380px; height: auto; }
.labs-visual {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
}
.labs-media .labs-chip { margin: 0; width: 96px; height: 96px; font-size: 2rem; }

/* teased-pipeline cards: deliberate, matching "?" plates */
.labs-media--dev {
    grid-auto-flow: row;
    align-content: center;
    justify-items: center;
    gap: var(--space-2);
}
.labs-discipline {
    font-size: var(--fs--1);
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--text-muted);
}
.labs-media--dev {
    background:
        radial-gradient(340px 200px at 50% 45%,
            color-mix(in srgb, var(--spec-b) 7%, transparent), transparent 75%),
        color-mix(in srgb, var(--bg-elev) 80%, transparent);
}
.labs-mystery {
    font-size: 4rem;
    font-weight: 800;
    color: color-mix(in srgb, var(--accent) 55%, var(--text-muted));
}

/* ---------- Slideshows ---------- */

.slideshow {
    position: relative;
    aspect-ratio: 16 / 9;
    margin: calc(-1 * var(--space-6)) calc(-1 * var(--space-6)) 0;
    border-radius: var(--radius) var(--radius) 0 0;
    overflow: hidden;
    background: var(--bg);
    border-bottom: 1px solid var(--border);
}

.slides { display: flex; height: 100%; }
@media (prefers-reduced-motion: no-preference) {
    .slides { transition: transform var(--enter-duration) var(--ease-out); }
}

.slide {
    flex: 0 0 100%;
    width: 100%;
    height: 100%;
    object-fit: cover;
}

/* a facade living inside a slideshow: the track owns geometry */
.slideshow .media-facade {
    position: relative;
    flex: 0 0 100%;
    height: 100%;
    aspect-ratio: auto;
    margin: 0;
    border: 0;
    border-radius: 0;
}

.ss-nav {
    position: absolute;
    top: 50%;
    transform: translateY(-50%);
    z-index: 2;
    width: 40px;
    height: 40px;
    padding: 0 0 3px;
    display: none;
    place-items: center;
    border-radius: 50%;
    border: 1px solid var(--border);
    background: rgba(10, 8, 20, 0.65);
    color: #eae7f2;
    font-size: 1.5rem;
    line-height: 1;
    cursor: pointer;
}
.ss-prev { left: 10px; }
.ss-next { right: 10px; }
.ss-nav:hover { border-color: var(--accent); }

.ss-dots {
    position: absolute;
    left: 0;
    right: 0;
    bottom: 10px;
    z-index: 2;
    display: none;
    justify-content: center;
    gap: 8px;
}
.ss-dots button {
    width: 9px;
    height: 9px;
    padding: 0;
    border: 0;
    border-radius: 50%;
    background: rgba(234, 231, 242, 0.45);
    cursor: pointer;
}
.ss-dots button[aria-current="true"] { background: #eae7f2; }

.ss-ready .ss-nav { display: grid; }
.ss-ready .ss-dots { display: flex; }

/* Direct route to the video: the stills lead the story, but nobody should have
   to arrow through five of them to reach the tape. Ships `hidden` (no-JS has
   nothing to play) and hides itself again once the video slide is showing. */
.ss-play {
    position: absolute;
    top: 10px;
    right: 10px;
    z-index: 3;
    display: inline-flex;
    align-items: center;
    gap: 7px;
    padding: 7px 12px;
    border-radius: 999px;
    border: 1px solid var(--border);
    background: rgba(10, 8, 20, 0.72);
    color: #eae7f2;
    font: inherit;
    font-size: 0.8rem;
    line-height: 1;
    cursor: pointer;
    transition: border-color 0.2s ease, background-color 0.2s ease;
}
.ss-play[hidden] { display: none; }
.ss-play svg { color: #ff3355; }
.ss-play:hover {
    border-color: var(--accent);
    background: rgba(20, 14, 38, 0.9);
}

.status-chip--live {
    color: #7ee0a3;
    border-color: color-mix(in srgb, #7ee0a3 45%, var(--border));
}

.status-chip {
    display: inline-block;
    vertical-align: 0.35em;
    font-size: 0.72rem;
    letter-spacing: 0.05em;
    text-transform: uppercase;
    color: var(--text-muted);
    border: 1px solid var(--border);
    border-radius: 999px;
    padding: 0 var(--space-2);
}

.labs-card p { margin: 0; color: var(--text-muted); }

/* ---------- Standalone pages (projects.html) ---------- */
/* No hero, so the content column can be narrower than the home page's and the
   first section needs its own top breathing room under the sticky header. */

.page-narrow { max-width: 64rem; }
.page-narrow > section:first-of-type { margin-top: var(--space-6); }

.page-title {
    font-size: var(--fs-4);
    font-weight: 800;
    letter-spacing: -0.02em;
    text-align: center;
    margin: 0 0 var(--space-4);
}
.page-title--sub { font-size: var(--fs-3); }

.backlink {
    margin: var(--space-6) 0 0;
    font-size: var(--fs--1);
}
.backlink a { color: var(--text-muted); text-decoration: none; }
.backlink a:hover { color: var(--accent-strong); }
.backlink--end {
    margin-top: var(--space-8);
    text-align: center;
}
.backlink--end a { color: var(--text); }

/* current page in the nav: the one link that is not a destination */
.nav-links a[aria-current="page"] {
    color: var(--text);
    border-bottom: 2px solid var(--accent);
}

/* home page: the route into the full catalog, under Selected Work */
.work-more {
    margin: var(--space-6) 0 0;
    text-align: center;
}

/* ---------- More Projects: supporting tier ---------- */
/* Reads quieter than Selected Work by design. No card background, no border
   box, no hover lift: just a hairline rule between rows. The only shared
   language with the featured cards is the accent on links and the plate
   treatment on thumbnails. */

.mp-lede {
    text-align: center;
    color: var(--text-muted);
    max-width: 44em;
    margin: 0 auto var(--space-6);
}

.mp-list {
    list-style: none;
    margin: 0 auto;
    padding: 0;
    max-width: 58rem;
}

/* Media column is 240px on a dedicated catalog page: big enough to actually
   read a screenshot, still a row rather than a stacked card so nine entries
   stay scannable. Below 52rem that trade stops working (a 240px well leaves
   the prose under 220px), so the media moves above the text instead. */
.mp-item {
    display: grid;
    grid-template-columns: var(--mp-media-w, 320px) minmax(0, 1fr);
    gap: var(--space-5);
    align-items: start;
    padding: var(--space-5) 0;
    border-top: 1px solid color-mix(in srgb, var(--border) 60%, transparent);
}
.mp-item:last-child { border-bottom: 1px solid color-mix(in srgb, var(--border) 60%, transparent); }

/* text-only rows keep the same left edge as the rest of the list */
.mp-item--textonly .mp-body { grid-column: 2; }

.mp-media,
.mp-slideshow {
    width: var(--mp-media-w, 320px);
    aspect-ratio: 3 / 2;
    border-radius: 8px;
    border: 1px solid var(--border);
    background: var(--bg-elev);
    overflow: hidden;
}
.mp-media { display: block; height: auto; object-fit: cover; }

/* Reuses the Selected Work slideshow component, so only the geometry and the
   control sizing are overridden here: the card version bleeds to the card edge
   at 16:9 with 40px arrows, which is far too heavy for a 240px well. */
.mp-slideshow {
    position: relative;
    margin: 0;
}
.mp-slideshow .ss-nav {
    width: 30px;
    height: 30px;
    font-size: 1.15rem;
    padding: 0 0 2px;
}
.mp-slideshow .ss-prev { left: 8px; }
.mp-slideshow .ss-next { right: 8px; }
.mp-slideshow .ss-dots { bottom: 9px; gap: 7px; }
/* a dark ring so the dots survive bright media (the terrain shots are mostly
   white and green, which swallowed a plain light dot at this size) */
.mp-slideshow .ss-dots button {
    width: 8px;
    height: 8px;
    box-shadow: 0 0 0 1px rgba(6, 4, 14, 0.55);
}

/* raised from 52rem with the media: a 320px well needs more room beside it
   before the prose gets squeezed. Centring is bound to the SAME breakpoint on
   purpose, so an entry is never both stacked and left-tucked. Same rule as the
   hero: the block centres, the description keeps its left-justified lines. */
@media (max-width: 56rem) {
    .mp-item { grid-template-columns: minmax(0, 1fr); justify-items: center; }
    .mp-item--textonly .mp-body { grid-column: 1; }
    /* Full column width, NOT a fixed cap. A 420px cap left the well narrower
       than the text beneath it (420 against ~766 at an 850px viewport), and
       the page background either side of it read as letterboxing. */
    .mp-media,
    .mp-slideshow { --mp-media-w: 100%; }

    /* The well now spans the column, so 30px controls are both undersized
       against it and under the 44px touch-target minimum. */
    .mp-slideshow .ss-nav {
        width: 44px;
        height: 44px;
        font-size: 1.6rem;
        padding: 0 0 4px;
    }
    .mp-slideshow .ss-prev { left: 10px; }
    .mp-slideshow .ss-next { right: 10px; }
    .mp-slideshow .ss-dots { bottom: 12px; gap: 10px; }
    .mp-slideshow .ss-dots button { width: 11px; height: 11px; }

    /* These are scoped through .mp-item on purpose. .mp-name and .mp-desc
       declare their own text-align later in the file, and at equal specificity
       the later rule wins regardless of the media query, so the extra class
       is what actually makes this stick. */
    /* Token instead of the literal 0.84rem, so the provenance labels ride the
       phone type scale (--fs--1 = 18px here) rather than staying at 15.1px.
       Scoped to the mobile block: the desktop 0.84rem is deliberately kept, and
       scoped through .mp-item because the base .mp-tag rule sits later in the
       file and would otherwise win at equal specificity. */
    .mp-item .mp-tag { font-size: var(--fs--1); }

    .mp-item .mp-body { text-align: center; }
    .mp-item .mp-name { text-align: center; }
    .mp-item .mp-meta { justify-content: center; }
    .mp-item .mp-desc {
        text-align: left;
        margin-inline: auto;
    }
}

/* Entry names are h2 in the Projects group and h3 in Experiments, so they must
   explicitly shed the section-heading treatment (centred, spectral underline)
   that the element-level h2 rule would otherwise leak in. */
.mp-name {
    font-size: var(--fs-1);
    font-weight: 650;
    margin: 0 0 var(--space-2);
    text-align: left;
    padding-bottom: 0;
    background-image: none;
}

.mp-desc {
    margin: 0 0 var(--space-3);
    color: var(--text-muted);
    font-size: var(--fs-0);
    max-width: 42em;
}

.mp-meta {
    margin: 0;
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    gap: var(--space-2) var(--space-4);
    font-size: 1rem;
}

.mp-tag {
    color: var(--text-muted);
    font-size: 0.84rem;
    letter-spacing: 0.04em;
    text-transform: uppercase;
}

.mp-meta a {
    color: var(--accent-strong);
    text-decoration: none;
    border-bottom: 1px solid color-mix(in srgb, var(--accent) 40%, transparent);
}
.mp-meta a:hover { border-bottom-color: var(--accent); }



/* ---------- Experience timeline ---------- */

.timeline {
    --tl-progress: 1;
    position: relative;
    list-style: none;
    margin: 0;
    padding: 0;
}

.timeline::before {
    content: "";
    position: absolute;
    left: 27px;
    top: 0;
    bottom: 0;
    width: 2px;
    background: var(--border);
}

/* progress line grows via scaleY: transform-only, never height */
.timeline::after {
    content: "";
    position: absolute;
    left: 27px;
    top: 0;
    width: 2px;
    height: 100%;
    background: var(--accent);
    transform: scaleY(var(--tl-progress));
    transform-origin: top;
    transition: transform var(--enter-duration) var(--ease-out);
}

.timeline li {
    position: relative;
    display: grid;
    grid-template-columns: 56px 1fr;
    gap: var(--space-4);
    padding: 0 0 var(--space-5);
}
.timeline li:last-child { padding-bottom: 0; }

/* dark translucent logo chips (no white tiles) */
.tl-logo {
    grid-column: 1;
    position: relative;
    z-index: 1;
    width: 56px;
    height: 56px;
    object-fit: contain;
    border-radius: 12px;
    background: color-mix(in srgb, var(--bg-elev) 85%, transparent);
    border: 1px solid var(--border);
    padding: 6px;
    box-sizing: border-box;
}
.tl-logo--invert {
    filter: invert(1);
    background: #dde5c7;      /* inverts to the dark plate (#221a38) */
    border-color: #c9d5aa;    /* inverts to the border color (#362a55) */
}
:root[data-theme="light"] .tl-logo--invert { background: #dde5c7; border-color: #c9d5aa; }


.tl-body {
    grid-column: 2;
    background: color-mix(in srgb, var(--spec-b) 8%, color-mix(in srgb, var(--bg-elev) 80%, transparent));
    border: 1px solid var(--border);
    border-radius: var(--radius);
    padding: var(--space-5) var(--space-6);
}

.tl-org { font-size: var(--fs-1); }

@media (min-width: 52rem) {
    .timeline::before, .timeline::after { left: calc(50% - 1px); }

    .timeline li {
        display: block;
        width: calc(50% - 56px);
        padding-bottom: var(--space-6);
    }
    .timeline li:nth-child(even) { margin-left: calc(50% + 56px); }

    .tl-logo {
        position: absolute;
        top: 4px;
        width: 68px;
        height: 68px;
        padding: 8px;
    }
    .timeline li:nth-child(odd) .tl-logo { right: -90px; }
    .timeline li:nth-child(even) .tl-logo { left: -90px; }

    .tl-body { position: relative; }
    .tl-body::before {
        content: "";
        position: absolute;
        top: 16px;
        border: 8px solid transparent;
    }
    .timeline li:nth-child(odd) .tl-body::before {
        right: -16px;
        border-left-color: var(--border);
    }
    .timeline li:nth-child(even) .tl-body::before {
        left: -16px;
        border-right-color: var(--border);
    }
}

.tl-when {
    display: block;
    font-size: var(--fs--1);
    color: var(--text-muted);
}

.tl-org { display: block; }
.tl-what { display: block; }

.tl-more { margin-top: var(--space-2); }

.tl-more summary {
    display: inline-block;
    cursor: pointer;
    font-size: var(--fs--1);
    color: var(--accent-strong);
    list-style: none;
}
.tl-more summary::-webkit-details-marker { display: none; }
.tl-more summary::before { content: "▸ "; }
.tl-more[open] summary::before { content: "▾ "; }
.tl-more summary:hover { color: var(--accent); }

.tl-more p {
    margin: var(--space-2) 0 0;
    font-size: var(--fs--1);
    color: var(--text-muted);
    max-width: 46em;
}

@media (hover: hover) and (prefers-reduced-motion: no-preference) {
    .js-timeline-animate li {
        opacity: 0;
        transform: translateY(12px);
        transition: opacity var(--enter-duration) var(--ease-out),
                    transform var(--enter-duration) var(--ease-out);
    }
    .js-timeline-animate li.revealed {
        opacity: 1;
        transform: none;
    }
}

/* ---------- Toolchain: dark translucent chips, 96px tiles ---------- */

.tc-lede {
    text-align: center;
    color: var(--text-muted);
    max-width: 42em;
    margin: 0 auto var(--space-6);
}

.tc-group { margin-bottom: var(--space-6); }

/* familiar-but-not-daily tier: lighter header, smaller tiles */
.tc-group--lite h3 { opacity: 0.65; }
.tc-group--lite .tc-tiles { gap: var(--space-3); }
.tc-group--lite .tc-tile { width: 90px; }
.tc-group--lite .tc-chip { width: 78px; height: 78px; border-radius: 14px; }
.tc-group--lite .tc-chip img { width: 44px; height: 44px; }
.tc-group--lite .tc-chip img.tc-img--boxed { width: 40px; height: 40px; }
.tc-group--lite .tc-text { font-size: 0.85rem; }
.tc-group--lite .tc-name { font-size: 0.78rem; }
.tc-group:last-child { margin-bottom: 0; }

.tc-group h3 {
    text-align: center;
    font-size: var(--fs--1);
    letter-spacing: 0.04em;
    text-transform: uppercase;
    color: var(--text-muted);
    margin-bottom: var(--space-3);
}

.tc-tiles {
    list-style: none;
    margin: 0;
    padding: 0;
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--space-4);
}

.tc-tile { width: 124px; text-align: center; }

.tc-chip {
    display: grid;
    place-items: center;
    width: 112px;
    height: 112px;
    margin: 0 auto;
    background: color-mix(in srgb, var(--bg-elev) 78%, transparent);
    border: 1px solid var(--border);
    border-radius: 16px;
    transition: border-color 0.2s ease, transform 0.2s ease;
}
.tc-tile:hover .tc-chip {
    border-color: color-mix(in srgb, var(--accent) 55%, var(--border));
    transform: translateY(-2px);
}
.tc-chip img { width: 60px; height: 60px; object-fit: contain; }
.tc-chip img.tc-img--boxed { width: 52px; height: 52px; } /* full-bleed square marks read optically larger */

/* Wordmarks are wide and short: a square box letterboxes them down to nothing,
   so they get their own box sized by WIDTH. viewBoxes are cropped to the glyph
   (see tools note), otherwise the source padding shrinks them again. */
.tc-chip img.tc-img--wide { width: 96px; height: 30px; }
.tc-chip img.tc-img--wordmark { width: 84px; height: 14px; }

/* tools with no official mark: plain text label on the standard plate */
.tc-text {
    font-weight: 600;
    font-size: 0.95rem;
    line-height: 1.2;
    letter-spacing: 0.01em;
    color: #eae7f2;
    text-align: center;
    padding: 0 var(--space-2);
}

.tc-name {
    display: block;
    margin-top: var(--space-2);
    font-size: 0.85rem;
    line-height: 1.25;
    color: var(--text-muted);
}

/* light theme: chips keep dark plates so white-variant logos stay legible */
:root[data-theme="light"] .tc-chip,
:root[data-theme="light"] .tl-logo { background: #221a38; }
/* exception must follow the general plate rule (same specificity) */
:root[data-theme="light"] .tl-logo--invert { background: #dde5c7; border-color: #c9d5aa; }
@media (prefers-color-scheme: light) {
    :root:not([data-theme]) .tc-chip,
    :root:not([data-theme]) .tl-logo { background: #221a38; }
    :root:not([data-theme]) .tl-logo--invert { background: #dde5c7; border-color: #c9d5aa; }
}

/* ---------- NTB Labs list leftovers / Contact form ---------- */

.contact-lede { text-align: center; }
#contact-form { max-width: 40rem; margin-inline: auto; }

.form-row {
    display: grid;
    grid-template-columns: 1fr 1fr;
    gap: 0 var(--space-4);
}
@media (max-width: 34rem) {
    .form-row { grid-template-columns: 1fr; }
}

.form-field { margin-bottom: var(--space-4); }

.form-field label {
    display: block;
    font-size: var(--fs--1);
    margin-bottom: var(--space-1);
    color: var(--text-muted);
}

.form-field input,
.form-field textarea {
    width: 100%;
    padding: var(--space-2) var(--space-3);
    font: inherit;
    color: var(--text);
    background: color-mix(in srgb, var(--bg-elev) 85%, transparent);
    border: 1px solid var(--border);
    border-radius: var(--radius);
}
.form-field input:focus-visible,
.form-field textarea:focus-visible {
    outline: 2px solid var(--accent);
    outline-offset: 0;
    border-color: transparent;
}
.form-field [aria-invalid="true"] { border-color: #e0564f; }

.field-error {
    min-height: 1.2em;
    margin: var(--space-1) 0 0;
    font-size: var(--fs--1);
    color: #e0564f;
}
:root[data-theme="light"] .field-error { color: #b3261e; }

.hp-field { position: absolute; left: -9999px; }

.form-status { margin-top: var(--space-3); min-height: 1.5em; }

/* ---------- Footer ---------- */

.site-footer {
    border-top: 1px solid var(--border);
    margin-top: var(--space-8);
}
.site-footer p {
    max-width: var(--content-width);
    margin: 0 auto;
    padding: var(--space-5) clamp(var(--space-4), 4vw, var(--space-7));
    color: var(--text-muted);
    font-size: var(--fs--1);
}

/* ---------- Entrance motion: one-shot, transform/opacity only ---------- */

@media (prefers-reduced-motion: no-preference) {
    html.js-sections main > section:not(.hero) h2:not(.mp-name) {
        opacity: 0;
        transform: translateY(12px);
        transition: opacity var(--enter-duration) var(--ease-out),
                    transform var(--enter-duration) var(--ease-out);
    }
    html.js-sections main > section.in-view h2:not(.mp-name) {
        opacity: 1;
        transform: none;
    }

    html.js-sections .work-card,
    html.js-sections .labs-card,
    html.js-sections .tc-group {
        opacity: 0;
        transform: translateY(14px);
        transition: opacity var(--enter-duration) var(--ease-out),
                    transform var(--enter-duration) var(--ease-out);
        /* 80ms x 5 steps put the last card 400ms behind the first, and on top
           of the 280ms transition that is 680ms before it finished - long
           enough to scroll past a card while it was still blank. 45ms capped at
           3 steps keeps the sense of sequence at 135ms worst case. */
        transition-delay: calc(var(--stagger, 0) * 45ms);
    }
    html.js-sections section.in-view .work-card,
    html.js-sections section.in-view .labs-card,
    html.js-sections section.in-view .tc-group {
        opacity: 1;
        transform: none;
    }

    .work-card:nth-child(1), .labs-card:nth-child(1), .tc-group:nth-of-type(1) { --stagger: 0; }
    .work-card:nth-child(2), .labs-card:nth-child(2), .tc-group:nth-of-type(2) { --stagger: 1; }
    .work-card:nth-child(3), .labs-card:nth-child(3), .tc-group:nth-of-type(3) { --stagger: 2; }
    .work-card:nth-child(4), .labs-card:nth-child(4), .tc-group:nth-of-type(4) { --stagger: 3; }
    .work-card:nth-child(5), .tc-group:nth-of-type(5) { --stagger: 3; }
    .work-card:nth-child(6) { --stagger: 3; }
}

@media (prefers-reduced-motion: reduce) {
    *, *::before, *::after {
        animation-duration: 0.01ms !important;
        animation-iteration-count: 1 !important;
        transition-duration: 0.01ms !important;
    }
}
