/* ========================================
   SHARED COMPONENTS - VistaFly

   Every pattern that previously existed 2-5
   times under different class names now lives
   here exactly once:

     .cta         <- home-cta / indoor-cta /
                     outdoor-cta / portfolio-cta
     .creds       <- stats-strip / faa-section /
                     faa-badge-inline / hero stats
     .price-card  <- edu-pricing-card / pricing-card
     .feature-row <- broadcast-feature-grid
     .hero-*      <- hero / indoor-hero / outdoor-hero

   Consumes tokens.css + global.css.
   ======================================== */


/* ============================================================
   BADGE
   ============================================================ */
.badge {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    padding: 0.45rem 0.9rem;
    border-radius: var(--radius-pill);
    background: var(--fill-bone);
    border: 1px solid var(--edge-bone);
    color: var(--bone-300);
    font-size: var(--fs-micro);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    line-height: var(--leading-flat);
    white-space: nowrap;
}

.badge svg,
.badge i {
    font-size: 0.9em;
    flex-shrink: 0;
}

/* nowrap is right for a two-word badge and catastrophic for a long one.
   Indoor's runs 54 characters, which at this size forces a ~510px
   minimum — wider than a phone — and because the hero centres its
   content, that pushed the ENTIRE hero off the right of the screen,
   not just the badge.

   Below the point where the heroes stack, the badge wraps instead. */
@media (max-width: 760px) {
    .badge {
        white-space: normal;
        align-items: flex-start;
        max-width: 100%;
        line-height: 1.4;
        text-align: start;
    }

    .badge svg,
    .badge i {
        margin-block-start: 0.15em;
    }
}

/* Live / broadcast state — the only place signal red appears */
.badge--live {
    background: var(--signal-live-dim);
    border-color: var(--signal-live-edge);
    color: var(--signal-live);
}

.badge--live .badge-dot {
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: currentColor;
    animation: live-pulse 1.8s var(--ease-in-out) infinite;
}

@keyframes live-pulse {
    0%, 100% { opacity: 1; transform: scale(1); }
    50%      { opacity: 0.35; transform: scale(0.82); }
}

.badge--neutral {
    background: var(--fill-02);
    border-color: var(--edge-soft);
    color: var(--text-mid);
}


/* ============================================================
   HERO — shared shell
   .hero-center : home. Content centered over live video.
   .hero-split  : indoor / outdoor / broadcast. Copy + 3D viewer.
   ============================================================ */
.hero-center,
.hero-split {
    position: relative;
    padding-inline: var(--gutter);
    /* Pull under the transparent nav */
    margin-block-start: calc(-1 * var(--nav-height));
    overflow: hidden;
}

.hero-center {
    min-height: 100svh;
    display: grid;
    place-items: center;
    text-align: center;
    padding-block: calc(var(--nav-height) + var(--space-9)) var(--space-10);
}

.hero-split {
    min-height: 100svh;
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-items: center;
    gap: clamp(2rem, 5vw, 5rem);
    max-width: var(--container-wide);
    margin-inline: auto;
    padding-block: calc(var(--nav-height) + var(--space-9)) var(--space-9);
}

/* ---- Defocus the video behind the hero ----

   The same treatment .page-hero::before applies on the portfolio, for
   the same reason: out of focus the footage stops being a competing
   scene and becomes an ambient colour field, which is what a
   background should be doing when type and a 3D viewer are layered
   over it. The motion and the colour survive; only the detail goes,
   and the detail was what competed.

   backdrop-filter on a layer INSIDE the header, not filter on
   .local-video-bg. That element is position:fixed across the whole
   viewport, so a filter there travels with it down the entire page and
   every translucent surface below shows blurred footage — the mistake
   portfolio.css documents having made first. This layer scrolls away
   with the header instead, which is the only place the treatment was
   ever meant to apply.

   z-index 0 puts it under .hero-visual (--z-base) and .hero-copy
   (--z-raised), so only the video is defocused and the drone viewer
   and type stay sharp — the whole point of separating them by depth.
   .hero-split is position:relative with z-index auto, so it creates no
   stacking context of its own and these three layers order directly
   against each other.

   Reaches exactly the three pages that use .hero-split: indoor,
   outdoor and broadcast. */
.hero-split::before {
    content: '';
    position: absolute;

    /* Full-bleed, because .hero-split is not.

       The header is a centred max-width box (--container-wide, 1400px)
       with margin-inline:auto, so inset:0 sized this layer to 1400px
       and left the video sharp down both edges of any wider screen —
       about 295px of it on a 1990px display.

       left/width rather than a transform: a transform here would make
       this pseudo-element a containing block, and it sits underneath
       positioned children that must keep resolving against
       .hero-split itself.

       100vw includes the scrollbar, so this overhangs the visible
       viewport by a few pixels either side. Harmless — it is a
       background wash and the parent clips it. */
    inset-block: 0;
    left: calc(50% - 50vw);
    width: 100vw;

    z-index: 0;
    pointer-events: none;
    -webkit-backdrop-filter: blur(13px) saturate(0.95) brightness(0.98);
    backdrop-filter: blur(13px) saturate(0.95) brightness(0.98);
}

/* Cheaper radius where it matters most, matching the portfolio's own
   700px step. */
@media (max-width: 700px) {
    .hero-split::before {
        -webkit-backdrop-filter: blur(11px) saturate(0.9) brightness(0.9);
        backdrop-filter: blur(11px) saturate(0.9) brightness(0.9);
    }
}

/* Viewer sits first in the DOM on some pages, second on others.
   These let layout order stay independent of source order. */
.hero-split > .hero-copy    { grid-column: 1; grid-row: 1; }
.hero-split > .hero-visual  { grid-column: 2; grid-row: 1; }
.hero-split--reverse > .hero-copy   { grid-column: 2; }
.hero-split--reverse > .hero-visual { grid-column: 1; }

.hero-copy {
    position: relative;
    z-index: var(--z-raised);
    max-width: 40rem;
}

.hero-center .hero-copy {
    margin-inline: auto;
}

.hero-copy > * + * {
    margin-block-start: var(--space-5);
}

.hero-title {
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    font-size: var(--fs-5xl);
    font-weight: var(--weight-semi);
    line-height: var(--leading-tight);
    letter-spacing: var(--track-display);
    text-wrap: balance;
}

.hero-center .hero-title {
    font-size: var(--fs-6xl);
}

.hero-tagline {
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    font-style: italic;
    font-size: var(--fs-2xl);
    font-weight: var(--weight-normal);
    letter-spacing: var(--track-tight);
    color: var(--bone-300);
    line-height: var(--leading-snug);
}

.hero-lede {
    font-size: var(--fs-lg);
    color: var(--text-mid);
    line-height: var(--leading-body);
    max-width: 46ch;
}

.hero-center .hero-lede {
    margin-inline: auto;
}

.hero-visual {
    position: relative;
    z-index: var(--z-base);
    width: 100%;
    aspect-ratio: 1 / 1;
    max-height: 70svh;
    justify-self: center;
}

.hero-visual canvas {
    width: 100% !important;
    height: 100% !important;
}

/* Scroll cue */
.hero-cue {
    position: absolute;
    bottom: var(--space-6);
    left: 50%;
    transform: translateX(-50%);
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-2);
    color: var(--text-faint);
    font-size: var(--fs-micro);
    letter-spacing: var(--track-micro);
    text-transform: uppercase;
    pointer-events: none;
}

.hero-cue::after {
    content: '';
    width: 1px;
    height: 40px;
    background: linear-gradient(to bottom, var(--edge-firm), transparent);
    animation: cue-drift 2.6s var(--ease-in-out) infinite;
}

@keyframes cue-drift {
    0%, 100% { opacity: 0.25; transform: scaleY(0.6); transform-origin: top; }
    50%      { opacity: 1;    transform: scaleY(1);   transform-origin: top; }
}

@media (max-width: 960px) {
    .hero-split {
        grid-template-columns: minmax(0, 1fr);
        text-align: center;
        min-height: auto;

        /* Twice the nav height, not once.

           The hero is pulled up by -nav-height so the video runs under
           the transparent bar. Content therefore starts at
           (padding-top - nav-height) from the top of the SCREEN, so a
           padding of nav+48 put it at 48px — underneath an 80px nav.
           Desktop hides this because the hero is 100svh with centred
           items; here min-height is auto and the padding decides. */
        padding-block: calc(var(--nav-height) * 2 + var(--space-6)) var(--space-8);
    }

    /* text-align does not centre flex children */
    .hero-split .btn-row {
        justify-content: center;
    }

    .hero-split > .hero-copy,
    .hero-split > .hero-visual,
    .hero-split--reverse > .hero-copy,
    .hero-split--reverse > .hero-visual {
        grid-column: 1;
    }

    .hero-split > .hero-copy   { grid-row: 1; }
    .hero-split > .hero-visual { grid-row: 2; }

    .hero-copy {
        margin-inline: auto;
    }

    .hero-lede {
        margin-inline: auto;
    }

    .hero-visual {
        max-width: 420px;
        max-height: none;
    }

    .hero-cue {
        display: none;
    }
}


/* ============================================================
   PAGE HERO — compact header for inner pages
   (portfolio, contact, legal)
   ============================================================ */
.page-hero {
    position: relative;
    display: grid;
    place-items: center;
    text-align: center;
    /* Twice the nav height — see the note on .hero-split. nav+80 landed
       content exactly on the nav's bottom edge with zero clearance. */
    padding: calc(var(--nav-height) * 2 + var(--space-4)) var(--gutter) var(--space-10);
    margin-block-start: calc(-1 * var(--nav-height));
    min-height: 52svh;
}

.page-hero-copy {
    max-width: var(--container-narrow);
}

.page-hero-copy > * + * {
    margin-block-start: var(--space-4);
}

.page-hero .hero-title {
    font-size: var(--fs-5xl);
}

.page-hero .hero-lede {
    margin-inline: auto;
}

/* ---- Tight variant: portfolio and contact ----

   Those two pages lead with their content, not their header — the
   flight log grid and the contact form are what people arrived for,
   and the base hero pushes both below the fold.

   The base reserves nav*2 (176px) of top padding, which is right
   when the header IS the page. Here nav + space-8 (128px) still
   clears the 80px fixed bar with room to spare, and the title drops
   one step so the block shrinks without looking cramped.

   Left alone: legal.html, which has its own --short modifier. */
/* Padding-top is exactly the nav height, and that number is doing
   two jobs at once.

   The header sits at the top of the page with the 80px nav fixed
   over it, so the band you can actually see runs from 80px to the
   header's bottom edge. place-items:center centres the copy in the
   PADDING BOX, not in that visible band — so any top padding beyond
   the nav's own height pushes the copy below the optical centre. At
   nav + space-8 (128px) the copy sat 24px low.

   padding-top: nav-height makes the content box start exactly where
   the nav stops, so its centre and the visible band's centre are the
   same point. Less padding than before, and centred rather than
   merely close.

   The corollary: padding-bottom must stay 0. Any bottom padding
   shortens the content box from below and drags the copy back up
   off centre. */
.page-hero--tight {
    min-height: 38svh;
    padding-block-start: var(--nav-height);
    padding-block-end: 0;
}

.page-hero--tight .hero-title {
    font-size: var(--fs-4xl);
}

@media (max-width: 780px) {
    /* Floor dropped: on a phone the padding alone outweighs any svh
       value, so the header sizes to its content.

       The invariant from the desktop rule still has to hold —
       padding-top minus padding-bottom must equal the nav height, or
       the copy drifts off the visible centre again. Here that is
       nav + space-6 against space-6, which keeps the same 80px
       difference while giving the copy room to breathe rather than
       starting hard against the nav's bottom edge. */
    .page-hero--tight {
        min-height: auto;
        padding-block-start: calc(var(--nav-height) + var(--space-6));
        padding-block-end: var(--space-6);
    }
}


/* ============================================================
   3D VIEWER CALLOUTS
   Cards anchored to points on a model. Shared by the indoor drone
   hero and the outdoor rig hero; positioning is done in callouts.js.
   ============================================================ */
/* The shared .hero-split clips with overflow:hidden, which keeps the
   ambient glows from spilling. But callouts need to sit outside the
   viewer square, and that clip cut them off no matter what the overlay
   allowed.

   overflow:clip + overflow-clip-margin lets content paint past the box
   by a set distance while still containing the glows and never creating
   a scroll container. The plain `hidden` above it is the fallback for
   browsers without clip-margin. */
.hero-split {
    overflow: hidden;
    overflow: clip;
    /* Generous on purpose, and now for two reasons.

       CALLOUTS: the outdoor viewer is pulled toward the page edge, so
       a card placed on the far side of the drone can sit well over
       300px outside this box — 260px was cutting them.

       THE HERO BLUR: .hero-split::before bleeds to 100vw out of a box
       capped at 1400px, so it overhangs by (viewport - 1400) / 2 on
       each side. At 700px that ran out at a 2800px viewport and the
       blur got trimmed back on an ultrawide — the same "not spanning
       the full width" symptom, just further out. 1200px carries it to
       3800px, past any display this will meet.

       This only sets how far content MAY paint; the glows never reach
       it, and callouts.js keeps cards inside the window regardless. */
    overflow-clip-margin: 1200px;
}

/* ============================================================
   SAFETY CALLOUTS
   ============================================================ */
/* inset:0 so the JS projection maths stays in the same coordinate space
   as the canvas — but overflow VISIBLE, so cards may sit outside the
   viewer square. The box is only ~580px and a card is 200px wide; if
   cards are confined to it they get pushed on top of the drone. The
   JS allows a margin beyond each edge to match. */
.callouts-container {
    position: absolute;
    inset: 0;
    overflow: visible;
    pointer-events: none;
    z-index: 5;
}

.safety-callout {
    position: absolute;
    opacity: 0;
    transition: opacity var(--dur-slow) var(--ease-out);
    pointer-events: auto;
}

.safety-callout.active {
    opacity: 1;
}

/* ---- Anchor dot ---- */
.callout-dot {
    position: absolute;
    width: 9px;
    height: 9px;
    border-radius: 50%;
    background: var(--bone-100);
    box-shadow:
        0 0 8px 2px rgba(228, 216, 196, 0.7),
        0 0 22px 5px rgba(228, 216, 196, 0.3);
    z-index: 3;
    will-change: left, top;
}

.callout-dot::after {
    content: '';
    position: absolute;
    top: 50%;
    left: 50%;
    width: 24px;
    height: 24px;
    border: 1px solid var(--edge-bone-hot);
    border-radius: 50%;
    transform: translate(-50%, -50%);
    animation: callout-ping 2.2s var(--ease-out) infinite;
}

@keyframes callout-ping {
    0%   { transform: translate(-50%, -50%) scale(1);   opacity: 0.8; }
    100% { transform: translate(-50%, -50%) scale(1.9); opacity: 0; }
}

/* ---- Leader line ---- */
.callout-line {
    position: absolute;
    height: 1px;
    transform-origin: left center;
    z-index: 2;
    overflow: hidden;
    will-change: left, top, transform;
}

.callout-line::before {
    content: '';
    position: absolute;
    inset: 0;
    background: repeating-linear-gradient(
        90deg,
        var(--bone-300) 0px,
        var(--bone-300) 4px,
        transparent 4px,
        transparent 9px
    );
    opacity: 0.65;
    animation: callout-dash 0.9s linear infinite;
}

@keyframes callout-dash {
    from { transform: translateX(0); }
    to   { transform: translateX(9px); }
}

/* ---- Label card ---- */
.callout-content {
    position: absolute;
    min-width: 180px;
    max-width: 236px;
    padding: var(--space-3) var(--space-4);
    z-index: 4;
    background: rgba(11, 11, 11, 0.92);
    backdrop-filter: blur(20px);
    -webkit-backdrop-filter: blur(20px);
    border: 1px solid var(--edge-soft);
    border-top-color: var(--edge-firm);
    border-radius: var(--radius-md);
    box-shadow: var(--shadow-lg), var(--inner-top);
    cursor: pointer;
    will-change: left, top;
    transition:
        border-color var(--dur-mid) var(--ease-out),
        transform var(--dur-mid) var(--ease-out),
        box-shadow var(--dur-mid) var(--ease-out);
}

.callout-content:hover {
    border-color: var(--edge-bone);
    border-top-color: var(--edge-bone-hot);
    transform: translateY(-2px);
    box-shadow: var(--shadow-xl), var(--inner-top);
}

.callout-title {
    font-size: var(--fs-micro);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-micro);
    text-transform: uppercase;
    color: var(--text-faint);
    margin-block-end: var(--space-2);
}

.callout-desc {
    font-size: var(--fs-2xs);
    font-weight: var(--weight-medium);
    color: var(--text-mid);
    line-height: var(--leading-snug);
}

.callout-stat {
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    font-variant-numeric: tabular-nums;
    font-size: var(--fs-lg);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-tighter);
    color: var(--bone-300);
    margin-block-start: var(--space-2);
    line-height: var(--leading-flat);
}

/* Detail expands on hover only — keeps the card small by default */
.callout-detail {
    max-height: 0;
    overflow: hidden;
    font-size: var(--fs-micro);
    color: var(--text-lo);
    line-height: var(--leading-snug);
    transition:
        max-height var(--dur-mid) var(--ease-out),
        margin-block-start var(--dur-mid) var(--ease-out),
        padding-block-start var(--dur-mid) var(--ease-out);
}

.callout-content:hover .callout-detail {
    max-height: 120px;
    margin-block-start: var(--space-3);
    padding-block-start: var(--space-3);
    border-block-start: 1px solid var(--edge-faint);
}

/* ---- Small screens: list mode ----
   A 230px card cannot be pinned to a 300px model without covering the
   thing it points at. So the anchoring is what gets dropped here, not
   the cards — the viewer stops being a fixed square, the canvas takes a
   set height, and the cards flow underneath as a plain list with every
   one open at once. callouts.js stops projecting at the same width. */
@media (max-width: 700px) {
    .hero-visual {
        display: flex;
        flex-direction: column;
        aspect-ratio: auto;
        max-height: none;
        max-width: none;
    }

    /* Beats the `height: 100% !important` on .hero-visual canvas, which
       would otherwise resolve against an auto-height parent. */
    .hero-visual > canvas {
        position: relative !important;
        width: 100% !important;
        height: min(64vw, 320px) !important;
        flex: none;
        order: 1;
    }

    .hero-visual > .drag-hint {
        position: static;
        transform: none;
        align-self: center;
        margin-block-start: var(--space-3);
        order: 2;
    }

    /* A swipeable rail, not a stack — matching every other card group
       at this width. Stacked, four cards added ~700px to the hero and
       pushed the last one over 1600px down the page, which is why they
       stopped reading as belonging to the model at all. As a rail they
       sit directly under it and cost one card's height. */
    .callouts-container {
        --rail-card: 80%;
        --rail-gap: var(--space-3);

        position: static;
        display: flex;
        align-items: stretch;
        gap: var(--rail-gap);
        margin-block-start: var(--space-5);

        /* Full bleed to the screen edges, same as the card rails */
        width: auto;
        max-width: none;
        margin-inline: calc(var(--gutter) * -1);
        /* Inline space comes from the end spacers below, not padding —
           see the MOBILE RAIL block for why. */
        padding-inline: 0;
        padding-block: var(--space-1) var(--space-2);

        overflow-x: auto;
        overflow-y: hidden;
        scroll-snap-type: x mandatory;
        overscroll-behavior-x: contain;
        -webkit-overflow-scrolling: touch;
        scrollbar-width: none;

        pointer-events: auto;
        order: 3;
    }

    .callouts-container::-webkit-scrollbar {
        display: none;
    }

    /* End spacers — same trick as the card rails, so the first and
       last callout can reach the centre of the screen. */
    .callouts-container::before,
    .callouts-container::after {
        content: '';
        flex: 0 0 max(0px, (100% - var(--rail-card)) / 2 - var(--rail-gap));
    }

    .safety-callout {
        position: static;
        opacity: 1;
        display: flex;
        flex: 0 0 var(--rail-card);
        min-width: 0;
        scroll-snap-align: center;
    }

    /* Dots are injected by global.js as the rail's next sibling, so in
       this flex column they would default to order 0 and render above
       the canvas. */
    .hero-visual > .rail-dots {
        order: 4;
    }

    /* The dot and leader only mean something against a projected point */
    .callout-dot,
    .callout-line {
        display: none;
    }

    .callout-content {
        position: static;
        min-width: 0;
        max-width: none;
        width: 100%;
        transform: none;
    }

    .callout-content:hover {
        transform: none;
    }

    /* Nothing moves here, so there is no vacated ground for the hit
       strip to cover. See HOVER LIFT in global.css. */
    .callout-content {
        --hover-lift: 0px;
    }

    /* No hover on touch, so the detail is open from the start */
    .callout-detail {
        max-height: none;
        margin-block-start: var(--space-3);
        padding-block-start: var(--space-3);
        border-block-start: 1px solid var(--edge-faint);
    }

    /* The palm comparison is hidden at this width too, but that rule
       lives in indoor.css — .hand-overlay is declared there, and that
       file loads after this one, so an equal-specificity rule here
       would lose. Same trap as the rail selectors below. */
}


/* ============================================================
   SPEC ROW — the hero stat trio
   Replaces .indoor-stats / .outdoor-stats
   ============================================================ */
.spec-row {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-6) var(--space-8);
}

.hero-center .spec-row,
.hero-split .spec-row {
    padding-block-start: var(--space-2);
}

@media (max-width: 960px) {
    .spec-row {
        justify-content: center;
    }
}

.spec {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: var(--space-2);
}

/* Hairline separators instead of boxes */
.spec + .spec {
    padding-inline-start: var(--space-8);
}

.spec + .spec::before {
    content: '';
    position: absolute;
    inset-block: 0.1rem;
    inset-inline-start: 0;
    width: 1px;
    background: var(--edge-soft);
}

.spec-value {
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    font-variant-numeric: tabular-nums;
    font-size: var(--fs-2xl);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-tighter);
    line-height: var(--leading-flat);
    color: var(--bone-300);
}

.spec-label {
    font-size: var(--fs-micro);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-micro);
    text-transform: uppercase;
    color: var(--text-lo);
    line-height: var(--leading-flat);
}

@media (max-width: 520px) {
    .spec-row {
        gap: var(--space-5);
    }

    .spec + .spec {
        padding-inline-start: var(--space-5);
    }
}

/* Three specs need roughly 476px of label width; a 360px screen has
   320px. They wrap either way, so make the wrap deliberate: two even
   columns, and drop the vertical rules, which read as stray marks once
   items sit on different rows. */
@media (max-width: 430px) {
    .spec-row {
        display: grid;
        grid-template-columns: 1fr 1fr;
        gap: var(--space-5) var(--space-4);
        justify-items: center;
    }

    /* Three specs in two columns leaves the last one orphaned in the
       left cell. Spanning it reads as deliberate rather than broken. */
    .spec-row > .spec:last-child:nth-child(odd) {
        grid-column: 1 / -1;
    }

    .spec {
        align-items: center;
        text-align: center;
    }

    .spec + .spec {
        padding-inline-start: 0;
    }

    .spec + .spec::before {
        display: none;
    }
}


/* ============================================================
   CARD — generic surface
   ============================================================ */
.card-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
    gap: var(--space-5);
}

.card-grid--2 {
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr));
}

.card-grid--3 {
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr));
}

.card {
    position: relative;
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
    padding: var(--space-7);
    background: var(--ground-300);
    border: 1px solid var(--edge-faint);
    border-radius: var(--radius-lg);
    box-shadow: var(--inner-top);
    transition:
        transform var(--dur-mid) var(--ease-out),
        border-color var(--dur-mid) var(--ease-out),
        background-color var(--dur-mid) var(--ease-out),
        box-shadow var(--dur-mid) var(--ease-out);
}

.card:hover {
    transform: translateY(-4px);
    background: var(--ground-400);
    border-color: var(--edge-bone);
    box-shadow: var(--shadow-lg), var(--inner-top);
}

/* Non-interactive variant — no lift */
.card--static:hover {
    transform: none;
    background: var(--ground-300);
    border-color: var(--edge-faint);
    box-shadow: var(--inner-top);
}

/* A card that does not move vacates no ground, so its hit strip has
   nothing to cover. See HOVER LIFT in global.css. */
.card--static {
    --hover-lift: 0px;
}

.card--glass {
    background: var(--fill-02);
    backdrop-filter: var(--blur-veil);
    -webkit-backdrop-filter: var(--blur-veil);
}

.card--feature {
    border-color: var(--edge-bone);
    background: linear-gradient(160deg, rgba(228, 216, 196, 0.07) 0%, var(--ground-300) 55%);
}

.card-icon {
    display: grid;
    place-items: center;
    width: 46px;
    height: 46px;
    border-radius: var(--radius-md);
    background: var(--fill-bone);
    border: 1px solid var(--edge-bone);
    color: var(--bone-300);
    font-size: var(--fs-lg);
    flex-shrink: 0;
}

.card-icon svg {
    width: 22px;
    height: 22px;
}

.card-title {
    font-family: var(--font-body);
    font-size: var(--fs-lg);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-tight);
    line-height: var(--leading-snug);
    color: var(--text-hi);
}

.card-body {
    font-size: var(--fs-base);
    color: var(--text-mid);
    line-height: var(--leading-body);
}

/* Oversized stat pinned in a card corner */
.card-stat {
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    font-variant-numeric: tabular-nums;
    font-size: var(--fs-3xl);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-display);
    line-height: var(--leading-flat);
    color: var(--bone-300);
    margin-block-start: auto;
    padding-block-start: var(--space-4);
}

/* Link cards */
a.card {
    color: inherit;
}

a.card .link-arrow {
    margin-block-start: auto;
    padding-block-start: var(--space-3);
}


/* ============================================================
   FEATURE ROW — visual beside copy
   Replaces .broadcast-feature-grid
   ============================================================ */
.feature-row {
    display: grid;
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    align-items: center;
    gap: clamp(2.5rem, 6vw, 5.5rem);
    max-width: var(--container);
    margin-inline: auto;
}

.feature-row--reverse .feature-visual { order: 2; }
.feature-row--reverse .feature-copy   { order: 1; }

.feature-visual {
    position: relative;
    width: 100%;
}

.feature-visual > img,
.feature-visual > video,
.feature-visual > canvas {
    width: 100%;
    border-radius: var(--radius-lg);
    filter: drop-shadow(var(--shadow-xl));
}

/* 3D viewer inside a feature row */
.feature-viewer {
    position: relative;
    width: 100%;
    max-width: 460px;
    margin-inline: auto;
    aspect-ratio: 1 / 1;
}

.feature-viewer canvas {
    width: 100% !important;
    height: 100% !important;
    filter: drop-shadow(var(--shadow-xl));
}

.feature-copy > * + * {
    margin-block-start: var(--space-5);
}

.feature-title {
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    font-size: var(--fs-3xl);
    font-weight: var(--weight-semi);
    line-height: var(--leading-tight);
    letter-spacing: var(--track-display);
    text-wrap: balance;
}

.feature-body {
    font-size: var(--fs-lg);
    color: var(--text-mid);
    line-height: var(--leading-body);
    max-width: var(--measure);
}

@media (max-width: 900px) {
    .feature-row {
        grid-template-columns: minmax(0, 1fr);
        gap: var(--space-8);
    }

    .feature-row--reverse .feature-visual,
    .feature-row--reverse .feature-copy {
        order: initial;
    }

    .feature-viewer {
        max-width: 340px;
    }
}


/* ============================================================
   POINT LIST — icon + title + line
   Replaces .broadcast-highlights
   ============================================================ */
.point-list {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
}

.point {
    display: flex;
    align-items: flex-start;
    gap: var(--space-4);
    padding: var(--space-4);
    background: var(--fill-01);
    border: 1px solid var(--edge-faint);
    border-radius: var(--radius-md);
    transition:
        border-color var(--dur-mid) var(--ease-out),
        background-color var(--dur-mid) var(--ease-out);
}

.point:hover {
    border-color: var(--edge-bone);
    background: var(--fill-02);
}

.point-icon {
    display: grid;
    place-items: center;
    width: 38px;
    height: 38px;
    flex-shrink: 0;
    border-radius: var(--radius-sm);
    background: var(--fill-bone);
    color: var(--bone-300);
    font-size: var(--fs-base);
}

.point-title {
    font-size: var(--fs-base);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-tight);
    color: var(--text-hi);
    margin-block-end: var(--space-1);
}

.point-body {
    font-size: var(--fs-sm);
    color: var(--text-lo);
    line-height: var(--leading-snug);
}


/* ============================================================
   MINI LIST — compact bullets inside a card
   ============================================================ */
.mini-list {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    padding-block-start: var(--space-4);
    border-block-start: 1px solid var(--edge-faint);
}

.mini-list li {
    position: relative;
    padding-inline-start: var(--space-5);
    font-size: var(--fs-base);
    color: var(--text-mid);
    line-height: var(--leading-snug);
}

.mini-list li::before {
    content: '';
    position: absolute;
    inset-inline-start: 0;
    top: 0.62em;
    width: 5px;
    height: 5px;
    border-radius: 50%;
    background: var(--bone-400);
}


/* ============================================================
   CREDENTIALS
   ONE definition. Previously this content appeared five times
   as .stats-strip, .faa-section, .faa-grid, .faa-badge-inline,
   and the two hero stat rows.
   ============================================================ */
.creds {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    align-items: stretch;
    gap: var(--space-6) 0;
    max-width: var(--container);
    margin-inline: auto;
    padding: var(--space-7) var(--space-5);
    background: var(--fill-01);
    border-block: 1px solid var(--edge-faint);
}

.cred {
    position: relative;
    flex: 1 1 220px;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-3);
    padding-inline: var(--space-5);
    text-align: center;
}

.cred + .cred::before {
    content: '';
    position: absolute;
    inset-block: 0;
    inset-inline-start: 0;
    width: 1px;
    background: var(--edge-faint);
}

.cred-icon {
    /* Scroll-scrubbed brightness. initCredGlow() in js/global.js
       writes --lit from 0 to 1 as the strip rises through the
       viewport; everything visual is derived from it here, so the
       script decides WHEN and the stylesheet decides what it looks
       like.

       Defaults to 1 — fully lit — so the icons are correct if the
       script never runs, is blocked, or throws upstream. Defaulting
       to 0 would mean any JS failure leaves the strip looking dead. */
    --lit: 1;

    color: var(--bone-300);

    /* The lit end is the strip's established resting tone; the unlit
       end is deliberately below it, because the brightening needs
       somewhere to travel from. 0.42 reads as a muted icon rather
       than a broken one. */
    opacity: calc(0.42 + 0.58 * var(--lit));

    /* Halo in the brand tone, growing with the value — this is what
       makes it read as light coming up rather than opacity being
       turned. drop-shadow rather than text-shadow so it follows the
       glyph of the icon font AND the artwork of the inline SVG
       variant handled below. */
    filter: drop-shadow(
        0 0 calc(var(--lit) * 12px)
        rgba(228, 216, 196, calc(var(--lit) * 0.42))
    );

    font-size: var(--fs-2xl);
    line-height: var(--leading-flat);

    /* No transition here on purpose: the value arriving from script is
       already damped frame by frame, and a CSS transition on top would
       lag the scroll position it exists to follow. */
    will-change: opacity, filter;
}

/* The script also pins --lit to 1 under reduced motion, so this is
   belt and braces for a JS-less load — plus it drops the halo, which
   is the part that reads as motion when it grows. */
@media (prefers-reduced-motion: reduce) {
    .cred-icon {
        --lit: 1;
        filter: none;
    }
}

.cred-icon svg {
    width: 26px;
    height: 26px;
}

.cred-label {
    font-size: var(--fs-base);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-tight);
    color: var(--text-hi);
    line-height: var(--leading-snug);
}

.cred-note {
    font-size: var(--fs-xs);
    color: var(--text-lo);
    line-height: var(--leading-snug);
}

@media (max-width: 760px) {
    .cred {
        flex: 1 1 45%;
    }

    /* Only the 2nd item in each row of two keeps its rule */
    .cred + .cred::before {
        display: none;
    }
}

/* Two per row: each credential is an icon, a label and a note, and at
   45% of a 360px screen that is a 162px column — the notes break to
   four or five lines. Below 480px they get the full width instead. */
@media (max-width: 480px) {
    .creds {
        gap: var(--space-5) 0;
        padding-inline: var(--space-3);
    }

    .cred {
        flex: 1 1 100%;
        flex-direction: row;
        align-items: flex-start;
        gap: var(--space-4);
        text-align: left;
        padding-inline: var(--space-2);
    }

    .cred-icon {
        flex-shrink: 0;
        font-size: var(--fs-xl);
        line-height: 1.2;
    }
}


/* ============================================================
   PRICING
   ONE definition. Replaces .edu-pricing-card (indoor, an
   "education" leftover) and .pricing-card (outdoor).
   ============================================================ */
.price-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 290px), 1fr));
    gap: var(--space-5);
    max-width: var(--container);
    margin-inline: auto;
    align-items: stretch;
}

.price-card {
    position: relative;
    display: flex;
    flex-direction: column;
    padding: var(--space-7);
    background: var(--ground-300);
    border: 1px solid var(--edge-faint);
    border-radius: var(--radius-lg);
    box-shadow: var(--inner-top);
    transition:
        transform var(--dur-mid) var(--ease-out),
        border-color var(--dur-mid) var(--ease-out),
        box-shadow var(--dur-mid) var(--ease-out);
}

.price-card:hover {
    transform: translateY(-4px);
    border-color: var(--edge-bone);
    box-shadow: var(--shadow-lg), var(--inner-top);
}

.price-card--featured {
    background: linear-gradient(165deg, rgba(228, 216, 196, 0.08) 0%, var(--ground-300) 58%);
    border-color: var(--edge-bone-hot);
    box-shadow: var(--shadow-lg), var(--inner-top);
}

.price-flag {
    position: absolute;
    top: 0;
    left: var(--space-7);
    transform: translateY(-50%);
    padding: 0.35rem 0.8rem;
    border-radius: var(--radius-pill);
    background: var(--bone-300);
    color: var(--text-on-bone);
    font-size: var(--fs-micro);
    font-weight: var(--weight-bold);
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    line-height: var(--leading-flat);
    white-space: nowrap;
}

.price-flag--quiet {
    background: var(--ground-500);
    color: var(--text-mid);
}

.price-eyebrow {
    font-size: var(--fs-micro);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-micro);
    text-transform: uppercase;
    color: var(--text-lo);
    margin-block-end: var(--space-3);
}

.price-name {
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    font-size: var(--fs-xl);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-tighter);
    color: var(--text-hi);
    line-height: var(--leading-snug);
}

.price-amount {
    display: flex;
    align-items: baseline;
    gap: var(--space-2);
    margin-block-start: var(--space-4);
}

.price-figure {
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    font-variant-numeric: tabular-nums;
    font-size: var(--fs-4xl);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-display);
    line-height: var(--leading-flat);
    color: var(--bone-300);
}

.price-unit {
    font-size: var(--fs-sm);
    color: var(--text-lo);
    letter-spacing: var(--track-wide);
}

.price-meta {
    margin-block-start: var(--space-2);
    font-size: var(--fs-sm);
    color: var(--text-lo);
}

.price-save {
    display: inline-flex;
    align-items: center;
    gap: var(--space-2);
    align-self: flex-start;
    margin-block-start: var(--space-3);
    padding: 0.3rem 0.7rem;
    border-radius: var(--radius-pill);
    background: var(--fill-bone);
    border: 1px solid var(--edge-bone);
    color: var(--bone-300);
    font-size: var(--fs-2xs);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-wide);
}

.price-features {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    margin-block: var(--space-6);
    padding-block-start: var(--space-5);
    border-block-start: 1px solid var(--edge-faint);
}

.price-features li {
    display: flex;
    align-items: flex-start;
    gap: var(--space-3);
    font-size: var(--fs-base);
    color: var(--text-mid);
    line-height: var(--leading-snug);
}

.price-features li i,
.price-features li svg {
    flex-shrink: 0;
    margin-block-start: 0.28em;
    color: var(--bone-400);
    font-size: 0.78em;
}

.price-features li svg {
    width: 12px;
    height: 12px;
    margin-block-start: 0.35em;
}

.price-note {
    font-size: var(--fs-xs);
    color: var(--text-faint);
    margin-block-end: var(--space-4);
}

.price-card .btn-primary,
.price-card .btn-secondary {
    margin-block-start: auto;
    width: 100%;
}

/* ---- Bundle row — compact secondary pricing ---- */
.bundle-head {
    text-align: center;
    margin-block: var(--space-10) var(--space-6);
}

.bundle-row {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 200px), 1fr));
    gap: var(--space-4);
    max-width: var(--container);
    margin-inline: auto;
}

.bundle {
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-2);
    padding: var(--space-6) var(--space-4);
    background: var(--fill-01);
    border: 1px solid var(--edge-faint);
    border-radius: var(--radius-md);
    text-align: center;
    transition:
        border-color var(--dur-mid) var(--ease-out),
        background-color var(--dur-mid) var(--ease-out),
        transform var(--dur-mid) var(--ease-out);
}

.bundle:hover {
    border-color: var(--edge-bone);
    background: var(--fill-02);
    transform: translateY(-3px);
}

.bundle-name {
    font-size: var(--fs-micro);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-micro);
    text-transform: uppercase;
    color: var(--text-lo);
}

.bundle-figure {
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    font-variant-numeric: tabular-nums;
    font-size: var(--fs-2xl);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-tighter);
    line-height: var(--leading-flat);
    color: var(--text-hi);
}

.bundle-per {
    font-size: var(--fs-xs);
    color: var(--text-lo);
}

.bundle-save {
    font-size: var(--fs-2xs);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-wide);
    color: var(--bone-400);
}


/* ============================================================
   CTA
   ONE definition. Replaces .home-cta, .indoor-cta,
   .outdoor-cta, and .portfolio-cta — four implementations
   of the same block.
   ============================================================ */
/* ---- Snap the CTA under the nav ----

   The section locks into place once its top reaches the bottom of
   the fixed header, instead of drifting past at whatever offset the
   scroll happened to stop at.

   proximity, never mandatory. Mandatory makes the browser override
   the reader's scroll position anywhere near a snap point, so on a
   page this long you cannot rest between sections — it fights you
   the whole way down. Proximity only engages when a scroll already
   ends close to the point, which is the "snap" feel without the
   hijack.

   The container has to be declared on html, not on .cta: snapping is
   a property of the scroller, and the document is what scrolls here.

   scroll-margin-top is what makes it lock to the BOTTOM of the
   header rather than the top of the viewport. Without it the
   section's top edge snaps to y=0, which is underneath the 80px
   fixed nav, and the eyebrow disappears behind the bar. */
.cta {
    text-align: center;

    /* Pinned, the way a ScrollTrigger pin behaves — the block comes
       to rest under the header and stays there while the page keeps
       moving, rather than scrolling away with everything else.

       No JS needed. sticky resolves against the nearest scrolling
       ancestor, which is the viewport, and .cta's containing block
       is body — so it stays put from the moment its top reaches the
       nav until body runs out underneath it. The footer is what
       ends the pin: it comes up the screen and covers the CTA,
       which is the release.

       That hand-off only works because .site-footer already sets
       position: relative with a solid --ground-000 background and
       the same z-index as .section. Equal z-index means paint order
       falls to DOM order, and the footer comes later. Give the
       footer a transparent background and the two will smear
       together.

       An earlier attempt used scroll-snap here. Wrong mechanism:
       snapping decides where a scroll comes to REST, it cannot hold
       an element in place while scrolling continues. */
    position: sticky;

    /* --nav-height-scrolled, NOT --nav-height.

       The bar shrinks from 80px to 70px once scrollY passes 30, and
       by the time this section is anywhere near pinning that has
       long since happened. Pinning at the full 80px left a 10px band
       of uncovered video between the bottom of the bar and the top
       of this block — a thin bright strip across the screen for the
       whole length of the pin.

       The header padding above still uses the full --nav-height,
       correctly: that one is measured at the top of the page, where
       the bar has not shrunk yet. */
    top: var(--nav-height-scrolled);
}

/* A pin has to fit. Below this the block is taller than the space
   under the header, so pinning would hold the eyebrow in view and
   push the buttons off the bottom — the one part that has to stay
   reachable. It scrolls normally instead. */
@media (max-height: 640px) {
    .cta {
        position: relative;
    }
}

/* ---- Fade to the headline ----

   Through the pin, everything but .cta-title dims away so the
   headline is left alone on screen. --cta-fade runs 1 -> 0 and is
   written by initCtaFade() in js/global.js; the default of 1 keeps
   the block fully visible if that script never runs.

   The title is deliberately absent from this list — it is the one
   thing that stays.

   The lift is tied to the same variable rather than a transition,
   so the copy rises as it dims and reverses exactly on scroll back.
   A transition would fight the per-frame updates and lag behind the
   scroll. */
/* Opacity is set per element further down, each against its own
   staggered variable. --cta-fade survives only as the master
   progress that drives .is-faded. */

/* Buttons at opacity 0 would still be clickable and still be a tab
   stop. initCtaFade() sets this once the fade is effectively done. */
/* pointer-events only. `visibility: hidden` was here too and it was
   a hard cut — visibility does not interpolate, so the buttons
   blinked out at the threshold and blinked back on the way up. The
   opacity curve already has them at zero by this point; this exists
   purely so an invisible button cannot be clicked or tabbed to. */
.cta.is-faded .btn-row {
    pointer-events: none;
}

/* Transform is reset in the matching block further down, alongside
   the title's — one place for all four. */
@media (prefers-reduced-motion: reduce) {
    .cta .eyebrow,
    .cta-lede,
    .cta .btn-row {
        opacity: 1;
    }
}

/* Written per frame by initCtaFade() in js/global.js, which solves
   it so .cta-title stays centred in the gap between the nav and the
   top of the rising footer. Defaults to 0 so the block sits where it
   normally would if that script never runs. */
.cta-inner {
    max-width: var(--container-narrow);
    margin-inline: auto;
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: var(--space-5);
}

/* ---- The lift ----

   --cta-lift is written per frame by initCtaFade() in js/global.js,
   solved so .cta-title stays centred in the gap between the nav and
   the top of the rising footer. It defaults to 0, so the block sits
   where it normally would if that script never runs.

   Applied to the CHILDREN, not to .cta-inner, and that is not a
   stylistic choice.

   .cta-inner carries data-aos="fade-up". AOS styles it with a
   doubled attribute selector —

       [data-aos^="fade"][data-aos^="fade"].aos-animate
           { transform: translate3d(0, 0, 0); }

   — which is specificity (0,3,0) and beats a plain .cta-inner rule
   at (0,1,0). Setting a transform on .cta-inner silently loses that
   fight: the value is written to the custom property, the property
   resolves, and AOS overwrites the result on every frame. Nothing
   errors and devtools shows the rule crossed out.

   These children carry no data-aos, so nothing competes for their
   transform. Moving all four together is visually identical to
   moving the wrapper.

   The fading elements fold the lift into the same translate as
   their own drift, because a second transform declaration would
   simply replace the first rather than compose with it. */
.cta-title {
    transform: translateY(var(--cta-lift, 0px));
    will-change: transform;
}

/* Each line carries its own fade variable and its own drift, so the
   block empties from the top rather than dimming as one slab. The
   distances grow down the stack — the buttons travel furthest and
   last, which reads as the group lifting away rather than three
   things sliding in parallel.

   Opacity and transform share the same eased variable, so brightness
   and motion stay locked together. Split them onto separate curves
   and the copy appears to slide after it has already gone. */
.cta .eyebrow {
    opacity: var(--fade-1, 1);
    transform: translateY(
        calc(var(--cta-lift, 0px) + (1 - var(--fade-1, 1)) * -14px)
    );
}

.cta-lede {
    opacity: var(--fade-2, 1);
    transform: translateY(
        calc(var(--cta-lift, 0px) + (1 - var(--fade-2, 1)) * -20px)
    );
}

.cta .btn-row {
    opacity: var(--fade-3, 1);
    transform: translateY(
        calc(var(--cta-lift, 0px) + (1 - var(--fade-3, 1)) * -28px)
    );
}

@media (prefers-reduced-motion: reduce) {
    .cta-title,
    .cta .eyebrow,
    .cta-lede,
    .cta .btn-row {
        transform: none;
    }
}

.cta-title {
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    font-size: var(--fs-4xl);
    font-weight: var(--weight-semi);
    line-height: var(--leading-tight);
    letter-spacing: var(--track-display);
    text-wrap: balance;
}

.cta-lede {
    font-size: var(--fs-lg);
    color: var(--text-mid);
    line-height: var(--leading-body);
    max-width: 52ch;
}

.cta .btn-row {
    margin-block-start: var(--space-2);
}

/* Optional direct-contact line under the buttons */
.cta-contact {
    display: flex;
    flex-wrap: wrap;
    justify-content: center;
    gap: var(--space-4) var(--space-8);
    margin-block-start: var(--space-5);
    padding-block-start: var(--space-6);
    border-block-start: 1px solid var(--edge-faint);
    width: 100%;
}

.cta-contact a {
    display: inline-flex;
    align-items: center;
    gap: var(--space-3);
    font-size: var(--fs-base);
    color: var(--text-mid);
    transition: color var(--dur-mid) var(--ease-out);
}

.cta-contact a:hover {
    color: var(--bone-300);
}

.cta-contact i {
    color: var(--bone-400);
    font-size: 0.9em;
}


/* ============================================================
   BROADCAST TEASER
   The compact cross-link that lives on indoor + outdoor and
   keeps the 3D model visible without repeating the full
   broadcast section.
   ============================================================ */
.teaser {
    position: relative;
    display: grid;
    grid-template-columns: minmax(0, 0.85fr) minmax(0, 1fr);
    align-items: center;
    gap: clamp(2rem, 5vw, 4.5rem);
    max-width: var(--container);
    margin-inline: auto;
    padding: clamp(2rem, 4vw, 3.5rem);
    /* Two layers, not one gradient: a bone tint fading out over a SOLID
       base. The old single-gradient version started at 6%-alpha bone,
       leaving the top-left corner of the card nearly transparent — which
       is exactly where the visual sits, and mix-blend-mode over a
       translucent backdrop puts the video's black box straight back. */
    background:
        linear-gradient(150deg, rgba(228, 216, 196, 0.08) 0%, rgba(228, 216, 196, 0) 60%),
        var(--ground-200);
    border: 1px solid var(--edge-bone);
    border-radius: var(--radius-xl);
    overflow: hidden;
}

.teaser::before {
    content: '';
    position: absolute;
    top: -40%;
    right: -10%;
    width: 45%;
    aspect-ratio: 1;
    border-radius: 50%;
    background: radial-gradient(circle, rgba(228, 216, 196, 0.10) 0%, transparent 70%);
    filter: blur(60px);
    pointer-events: none;
}

.teaser-visual {
    position: relative;
    width: 100%;
    max-width: 320px;
    aspect-ratio: 1 / 1;
    margin-inline: auto;
}

.teaser-visual canvas {
    width: 100% !important;
    height: 100% !important;
    filter: drop-shadow(var(--shadow-lg));
}

.teaser-copy {
    position: relative;
    z-index: var(--z-base);
}

.teaser-copy > * + * {
    margin-block-start: var(--space-4);
}

.teaser-title {
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    font-size: var(--fs-2xl);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-tighter);
    line-height: var(--leading-tight);
}

.teaser-body {
    font-size: var(--fs-base);
    color: var(--text-mid);
    line-height: var(--leading-body);
    max-width: 48ch;
}

/* ---- Wide variant ----
   For a teaser whose visual is the exploded view rather than the
   square 3D canvas. That slot caps at 320px and forces a 1:1 box,
   which a 720x544 clip cannot fill — it needs real width and its
   own aspect. Kept as a modifier so the indoor and outdoor teasers,
   which still use the square model viewer, are untouched. */
.teaser--wide {
    max-width: var(--container-wide);
    grid-template-columns: minmax(0, 1.3fr) minmax(0, 1fr);
    gap: clamp(1.5rem, 4vw, 3.5rem);
}

.teaser--wide .teaser-visual {
    max-width: none;
    aspect-ratio: auto;

    /* Pushed right, off the card's left edge.

       This does double duty. The assembly sits further into the card
       rather than hard against its padding — and because
       assembly-specs.js derives the callout rail's left limit by
       measuring this element against .teaser, every pixel here is
       also a pixel the labels and their leaders get to spread into.
       The two things that felt cramped were the same gap.

       The cost is width: the grid column is fixed, so the render
       loses whatever this takes. Kept modest for that reason. */
    margin-inline-start: clamp(1rem, 3vw, 3rem);
}

@media (max-width: 860px) {
    .teaser {
        grid-template-columns: minmax(0, 1fr);
        text-align: center;
    }

    .teaser-visual {
        max-width: 240px;
        order: 2;
    }

    .teaser--wide .teaser-visual {
        max-width: 460px;
    }

    .teaser-copy {
        order: 1;
    }

    .teaser-body {
        margin-inline: auto;
    }

    .teaser .btn-row {
        justify-content: center;
    }
}


/* ============================================================
   ASSEMBLY — scroll-scrubbed exploded view

   Shared: used in the broadcast page's feature row and in the
   home page's teaser.

   The source MP4 has a baked-in black background; H.264 carries
   no alpha channel, so it cannot be made transparent in the file.

   mix-blend-mode: lighten takes the per-channel max of the video
   and whatever is painted behind it, so every pixel of the black
   surround resolves to the backdrop colour and disappears, while
   the enclosure, PCB and accent lighting — all brighter — survive
   untouched. Because it is a per-pixel max rather than a match
   against one fixed colour, it works over a gradient too.

   'lighten' is deliberate over 'screen': screen would also wash
   out the dark grey enclosure, the product being nearly black.

   TWO REQUIREMENTS, both easy to break:

   1. The backdrop must be OPAQUE. Blending over a translucent
      surface composites against whatever shows through, and the
      black comes back. This is why .teaser carries a solid base
      layer under its gradient.

   2. No ancestor may have a transform. A transform forms an
      isolated stacking context, and the blend then composites
      against transparency instead of the backdrop — black box
      again. This is why neither host element carries data-aos.
   ============================================================ */
.assembly > video,
.assembly > canvas {
    width: 100%;
    height: auto;
    max-width: none;
    /* Both dropped: a drop-shadow on an opaque rectangle just
       redraws the box we are removing, and the radius clips a
       frame that no longer has visible edges. */
    border-radius: 0;
    filter: none;
    mix-blend-mode: lighten;

    /* The source frame ends a few pixels below the reflection. With
       the black surround blended away there is nothing to hide that
       edge, so the reflection would stop dead in a hard horizontal
       seam. Fade it out instead, so it falls off with distance the
       way a real reflection would.

       The first stop must sit BELOW the enclosure, which bottoms
       out around 82% of the frame height — start any higher and it
       eats the product rather than the reflection. */
    -webkit-mask-image: linear-gradient(
        to bottom,
        #000 0%,
        #000 82%,
        rgba(0, 0, 0, 0.55) 91%,
        transparent 100%
    );
    mask-image: linear-gradient(
        to bottom,
        #000 0%,
        #000 82%,
        rgba(0, 0, 0, 0.55) 91%,
        transparent 100%
    );
}


/* ============================================================
   ASSEMBLY SPEC CALLOUTS

   Spec cards pinned to the parts of the scroll-scrubbed exploded
   view. js/assembly-specs.js writes the offsets; this owns the
   look, the two layout modes, and the fallback.

   NOTE ON THE BLEND: these sit inside .assembly, whose canvas uses
   mix-blend-mode: lighten. The cards must NOT inherit that — they
   are opaque UI over the render, not part of it. They are siblings
   of the canvas rather than children, so the rule above (.assembly
   > video, .assembly > canvas) does not reach them. Keep them
   siblings; nesting one inside the media element's box would pull
   it into the blend and wash it out.
   ============================================================ */
.spec-callouts {
    list-style: none;
    margin: 0;
    padding: 0;
}

/* ---- Pinned mode ---- */
/* Only once JS confirms the scrub is live (.is-live) AND the viewport
   is wide enough for a gutter (.is-pinned). Both are added from
   script, so the default state below is the readable list. */
.spec-callouts.is-live.is-pinned {
    position: absolute;
    inset: 0;
    /* Cards reach left into .teaser's padding; .teaser clips at its
       border box, so that space is available and nothing is cut. */
    overflow: visible;
    pointer-events: none;
    z-index: 5;

    /* Opacity is driven per-frame from script as the box closes.
       No transition — it must track the scrub exactly, and a
       transition here would make it lag the parts it belongs to. */
}

.spec-callouts.is-live.is-pinned .spec-callout {
    position: absolute;
    inset: 0;
}

/* Base label type: the list-mode presentation, and what the pinned
   overrides below build on. */
.spec-callout-title {
    display: block;
    font-size: var(--fs-xs);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-wide);
    line-height: var(--leading-tight);
    color: var(--text-hi);
}


/* ---- Annotation, not a tooltip ----

   A right-aligned label, a hairline rule under it, and the leader
   continuing that same rule out to a dot on the part's edge: one
   unbroken stroke from the text to the hardware.

   This replaced a filled, rounded, drop-shadowed box. That box read
   as a UI tooltip parked on top of a product shot — four of them
   stacked down the render looked like notifications, not labels. An
   exploded view is a technical drawing, and the annotation it wants
   is a leader line, not a chrome panel. Removing the fill also lets
   the render's own darkness carry the contrast, so nothing is
   competing with the product for attention. */
.spec-callouts.is-live.is-pinned .spec-callout-card {
    position: absolute;
    /* Widening this is FREE, which is not obvious and was the reason
       the old value was too timid.

       While the rail governs rather than the clip floor, the card's
       right edge resolves to RAIL_X * width no matter how wide the
       card is — the extra width all goes leftward, into slack. So the
       leader does not shorten by a pixel. The old clamp was sized as
       though width traded against the leader, and left the two
       longest labels wrapping to two lines at the 1280px threshold
       while the other two sat on one. That inconsistency is what read
       as untidy, not the type size.

       These values clear the longest label (~116px) at every pinned
       width. Check against the floor before raising them further:
       once cardLeft hits railFloor the trade above stops holding and
       width starts eating the leader again. */
    width: clamp(124px, 9.6vw, 140px);
    /* Ragged edge on the left, hard terminus on the right, so all
       four labels end on the rail and the leaders start level. */
    text-align: right;
    pointer-events: auto;
}

.spec-callouts.is-live.is-pinned .spec-callout-title {
    display: block;
    /* Deliberately no rule under the label.

       The label is right-aligned inside a fixed-width box, so a
       border here spans the BOX, not the text — and every label is
       shorter than the box. On screen that put 40-60px of hairline
       underlining nothing to the left of each label, which read as
       the line having overshot. The leader alone carries the
       connection now. */
    font-size: 0.6875rem;
    letter-spacing: 0.085em;
    text-transform: uppercase;
    color: var(--bone-100);
    /* Unboxed text sits directly on the render, which is dark but
       not uniformly so — the fan throws red light across this area. */
    text-shadow: 0 1px 6px rgba(0, 0, 0, 0.9);
    transition: color var(--dur-mid) var(--ease-out);
}

.spec-callouts.is-live.is-pinned .spec-callout-card:hover .spec-callout-title {
    color: var(--text-hi);
}

/* Collapsed at rest in pinned mode: a 150px gutter has room for a
   label, not a paragraph. The detail is the reward for stopping on
   one — same pattern as .callout-detail on the 3D viewers.

   It expands BELOW the rule, and the rule is what the script aligns
   to, so opening one does not shift the label off its part. */
.spec-callouts.is-live.is-pinned .spec-callout-detail {
    display: block;
    overflow: hidden;
    max-height: 0;
    font-size: var(--fs-xs);
    line-height: var(--leading-body);
    color: var(--text-mid);
    text-shadow: 0 1px 6px rgba(0, 0, 0, 0.9);
    transition:
        max-height var(--dur-mid) var(--ease-out),
        margin-block-start var(--dur-mid) var(--ease-out);
}

.spec-callouts.is-live.is-pinned .spec-callout-card:hover .spec-callout-detail {
    max-height: 96px;
    margin-block-start: var(--space-2);
}

/* ---- Anchor dot ----
   Small and static. The 3D viewers' .callout-dot pings on a loop,
   which is right for one rotating cycling card and wrong for four
   at once beside an already-moving render. */
.spec-callout-dot {
    position: absolute;
    width: 6px;
    height: 6px;
    border-radius: 50%;
    background: var(--bone-100);
    box-shadow:
        0 0 0 3px rgba(228, 216, 196, 0.10),
        0 0 10px 2px rgba(228, 216, 196, 0.45);
    z-index: 3;
    will-change: left, top;
}

/* ---- Leader ----
   Always horizontal — the script writes left/top/width and never a
   rotation. Four leaders at four computed angles is what read as
   scattered; four parallel rules read as a system.

   Solid and brightening toward the part, so the stroke resolves
   into the dot. Not the marching dashes the 3D viewers use: that
   animation beside a scrubbing render is two motions competing. */
.spec-callout-line {
    position: absolute;
    height: 1px;
    /* Emerges from the label rather than butting onto it. The old
       ramp started at full --edge-bone the pixel after the text, so
       the line began on a hard edge that read as a second underline.
       Starting from nothing gives it somewhere to come from, and the
       brightening toward the dot then has further to travel. */
    background: linear-gradient(
        to right,
        rgba(228, 216, 196, 0) 0%,
        var(--edge-bone) 24%,
        var(--edge-bone-hot) 100%
    );
    z-index: 2;
    will-change: left, top, width;
}

/* ---- Desktop only ----

   Hidden outright whenever the pinned treatment is not running: any
   viewport under the PIN_AT threshold, reduced motion, a clip that
   failed to load, or the window before the scrub goes live.

   This used to fall back to a grid of cards under the visual so the
   specs stayed readable everywhere. That fallback is gone because it
   was never carrying anything new — .teaser-body already says
   "3D-printed PETG enclosure around a Raspberry 4 and active
   cooling... HDMI out to any display, or RTMP to the web", which is
   all four callouts in prose. On a narrow screen it was the same
   four facts a second time, stacked under the copy that had just
   made them.

   display:none rather than visibility or opacity, deliberately: it
   takes the duplicate out of the accessibility tree too, which is
   the half that mattered most. Note this is also the no-JS state, so
   the callouts are now genuinely an enhancement — acceptable only
   because the prose above covers them. */
.spec-callouts:not(.is-pinned) {
    display: none;
}


/* ============================================================
   STAGGERED REVEAL — see initReveal() in js/global.js

   .reveal-armed is applied FROM SCRIPT. Unarmed groups are never
   hidden, so copy cannot be stranded at opacity 0 if the script
   fails, is blocked, or the browser has no IntersectionObserver.

   Transforms here are confined to children of the group. Never put
   a reveal group on an ancestor of .assembly — see the stacking
   context warning in the ASSEMBLY block above.
   ============================================================ */
.reveal-armed [data-reveal] {
    opacity: 0;
    transform: translate3d(0, 18px, 0);
    transition:
        opacity 760ms cubic-bezier(0.16, 1, 0.3, 1),
        transform 760ms cubic-bezier(0.16, 1, 0.3, 1);
}

/* Scoped to the pending state so the composited layers are released
   once the reveal has run, rather than held for the life of the page. */
.reveal-armed:not(.reveal-in) [data-reveal] {
    will-change: opacity, transform;
}

.reveal-armed.reveal-in [data-reveal] {
    opacity: 1;
    transform: none;
}

/* Belt and braces: the script already bails before arming, but a
   group armed in one session and re-rendered under a changed OS
   setting must not animate. */
@media (prefers-reduced-motion: reduce) {
    .reveal-armed [data-reveal] {
        opacity: 1;
        transform: none;
        transition: none;
    }
}


/* ============================================================
   MEDIA TILE — portfolio + featured work
   ============================================================ */
.tile-grid {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 320px), 1fr));
    gap: var(--space-5);
    max-width: var(--container);
    margin-inline: auto;
}

.tile {
    position: relative;
    display: block;
    aspect-ratio: 16 / 9;
    border-radius: var(--radius-lg);
    overflow: hidden;
    background: var(--ground-300);
    border: 1px solid var(--edge-faint);
    box-shadow: var(--shadow-md);
    transition:
        transform var(--dur-mid) var(--ease-out),
        box-shadow var(--dur-mid) var(--ease-out),
        border-color var(--dur-mid) var(--ease-out);
}

.tile:hover {
    transform: translateY(-5px);
    border-color: var(--edge-bone);
    box-shadow: var(--shadow-lg);
}

.tile img {
    width: 100%;
    height: 100%;
    object-fit: cover;
    transition: transform var(--dur-slow) var(--ease-out);
}

.tile:hover img {
    transform: scale(1.05);
}

.tile-overlay {
    position: absolute;
    inset-inline: 0;
    bottom: 0;
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
    padding: var(--space-6) var(--space-5) var(--space-5);
    background: linear-gradient(to top, rgba(0, 0, 0, 0.88) 0%, rgba(0, 0, 0, 0.35) 55%, transparent 100%);
}

.tile-title {
    font-size: var(--fs-base);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-tight);
    color: var(--text-hi);
}

.tile-meta {
    font-size: var(--fs-micro);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-micro);
    text-transform: uppercase;
    color: var(--bone-400);
}


/* ============================================================
   MOBILE RAIL — card grids become one swipeable row

   Every card grid on the site collapses to a single column on a
   phone, which turns three cards into three screens of vertical
   scrolling and six safety cards into six. Below 700px the grid
   becomes a horizontal snap rail instead: one card at a time,
   the next one peeking in at the edge, dots underneath (built by
   js/global.js).

   700px is the breakpoint for a reason — it is the width where
   each of these grids drops to a single column anyway, so the
   rail never costs a row that was showing two cards side by side.

   Every selector is prefixed with .section deliberately.
   .flip-grid and .portfolio-grid are declared in outdoor.css and
   portfolio.css, which load AFTER this file; at equal specificity
   their base rule would win, media query or not. The extra class
   keeps the rail behaviour in one place instead of copied into
   every page stylesheet.
   ============================================================ */
@media (max-width: 700px) {
    .section .card-grid,
    .section .price-grid,
    .section .bundle-row,
    .section .tile-grid,
    .section .flip-grid,
    .section .portfolio-grid {
        /* The only two numbers a rail needs. The end spacers are
           derived from both, so changing the card width keeps the
           card centred without any other edit. */
        --rail-card: 80%;
        --rail-gap: var(--space-4);

        display: flex;
        align-items: stretch;
        gap: var(--rail-gap);

        /* Full bleed: the row runs edge to edge. width/max-width are
           reset because these grids also carry .shell or their own
           cap, and a fixed width swallows the negative margins. */
        width: auto;
        max-width: none;
        margin-inline: calc(var(--gutter) * -1);

        /* No inline padding — the ::before/::after spacers below do
           that job, and unlike padding they are measured against the
           same box as the card width. */
        padding-inline: 0;

        /* Vertical room inside the scroller: the "Most popular"
           flag hangs above its card, the shadow below it. Without
           this the overflow clips both. */
        padding-block: var(--space-4) var(--space-3);

        overflow-x: auto;
        overflow-y: hidden;
        scroll-snap-type: x mandatory;
        overscroll-behavior-x: contain;
        -webkit-overflow-scrolling: touch;

        /* The dots are the affordance — a scrollbar on top of them
           is noise. */
        scrollbar-width: none;
    }

    .section .card-grid::-webkit-scrollbar,
    .section .price-grid::-webkit-scrollbar,
    .section .bundle-row::-webkit-scrollbar,
    .section .tile-grid::-webkit-scrollbar,
    .section .flip-grid::-webkit-scrollbar,
    .section .portfolio-grid::-webkit-scrollbar {
        display: none;
    }

    .section .card-grid > *,
    .section .price-grid > *,
    .section .bundle-row > *,
    .section .tile-grid > *,
    .section .flip-grid > *,
    .section .portfolio-grid > * {
        flex: 0 0 var(--rail-card);
        /* Flex items default to min-width:auto, which lets a long
           unbreakable word push a card past its share. */
        min-width: 0;
        scroll-snap-align: center;
    }

    /* ---- End spacers ----
       A centred snap point is only reachable if there is scrollable
       room on both sides of it: without these, the first and last
       cards clamp at the edges and sit off-centre. Half the leftover
       width, less one gap, puts card one dead centre at rest and —
       because the row is then symmetric — the last card dead centre
       at the end of the scroll.

       Percentages here resolve against the same box the card width
       does, which is why the rail carries no inline padding: padding
       would resolve against the section instead and the two would
       never agree. They are pseudo-elements rather than real spacers
       so filtering (portfolio) can hide the last card without taking
       the trailing room with it. */
    .section .card-grid::before,
    .section .card-grid::after,
    .section .price-grid::before,
    .section .price-grid::after,
    .section .bundle-row::before,
    .section .bundle-row::after,
    .section .tile-grid::before,
    .section .tile-grid::after,
    .section .flip-grid::before,
    .section .flip-grid::after,
    .section .portfolio-grid::before,
    .section .portfolio-grid::after {
        content: '';
        flex: 0 0 max(0px, (100% - var(--rail-card)) / 2 - var(--rail-gap));
    }

    /* Bundles are three short lines of text — at 80% they read as
       mostly empty card. */
    .section .bundle-row {
        --rail-card: 58%;
    }
}


/* ---- Rail dots ---- */
.rail-dots {
    display: none;
}

@media (max-width: 700px) {
    .rail-dots {
        display: flex;
        justify-content: center;
        align-items: center;
        gap: var(--space-1);
        margin-block-start: var(--space-5);
    }

    /* 22px button around a 7px dot — the dot is the mark, the
       button is the tap target. */
    .rail-dot {
        display: grid;
        place-items: center;
        width: 22px;
        height: 22px;
        padding: 0;
        border: 0;
        background: none;
        cursor: pointer;
        -webkit-tap-highlight-color: transparent;
    }

    .rail-dot::before {
        content: '';
        width: 7px;
        height: 7px;
        border-radius: var(--radius-pill);
        background: var(--edge-firm);
        transition:
            width var(--dur-mid) var(--ease-out),
            background-color var(--dur-mid) var(--ease-out);
    }

    .rail-dot[aria-current='true']::before {
        width: 18px;
        background: var(--bone-300);
    }

    .rail-dot:focus-visible {
        outline: 2px solid var(--bone-300);
        outline-offset: 2px;
        border-radius: var(--radius-pill);
    }
}

@media (prefers-reduced-motion: reduce) {
    .rail-dot::before {
        transition: none;
    }
}


/* ============================================================
   AOS FALLBACK
   If AOS fails to load, elements must not stay invisible.
   ============================================================ */
[data-aos]:not(.aos-init) {
    opacity: 1;
    transform: none;
}


/* ============================================================
   PINNED PAGE-HERO STRIP

   The two-trigger treatment .cta uses at the foot of a page,
   brought to the top of one: a sticky pin, plus a scroll-driven
   condense that drops the secondary copy.

   Opt-in per page via .page-hero--pin, because .page-hero--tight is
   shared and not every page wants this. Currently portfolio and
   contact. The pin also needs a .pin-scope wrapper to bound it —
   see below; the class alone will stick to the end of the document.

   ---- Why top is negative ----

   .page-hero carries margin-block-start: calc(-1 * --nav-height),
   which pulls it up behind the transparent nav so the drift wall
   runs under the bar. Its box therefore rests at y = -80, and a
   sticky top of 0 would clamp it the instant the page loaded and
   shove the whole header down 80px before a single pixel was
   scrolled. The offset has to match the margin exactly; then the
   header is already at its sticky position at rest and nothing
   moves until it needs to.

   Because the box top sits 80px above the viewport and the nav
   covers another 70, padding-block-start has to clear BOTH before
   the copy lands. That is what the calc below is doing — it is
   not the usual nav clearance.

   ---- Why the grid passes underneath, and how it ends ----

   --z-raised puts the strip over .section (--z-base), so the log
   scrolls under it rather than covering it. That also means it must
   be out of the way before .cta, which pins to the same band and
   would otherwise be painted over.

   That ending is structural, not scripted: the strip sticks inside
   .pin-scope, which wraps the header and the log and stops exactly
   where the CTA starts. It runs out of containing block there and
   scrolls away on its own. Three scripted versions of this were
   opacity fades driven off sticky rects, and every one of them
   either emptied the strip too early or made the title visibly
   flicker. Nothing here fades now.
   ============================================================ */
.page-hero--pin {
    position: sticky;
    top: calc(-1 * var(--nav-height));
    z-index: var(--z-raised);

    /* Content-sized: the strip is only as tall as its own copy. */
    min-height: auto;
    padding-block-start: calc(
        var(--nav-height) + var(--nav-height-scrolled) + var(--space-5)
    );
    padding-block-end: var(--space-6);

    /* The pin spacer is NOT here — see .page-hero--pin + .section
       below for why it has to live on the section instead. */

    /* The strip is a band of screen that never scrolls away, so it
       would otherwise swallow every click landing in it — a tile on
       the portfolio grid, a field on the contact form. Almost nothing
       in here is interactive, so it has no business taking the
       pointer by default. See the exception below. */
    pointer-events: none;
}

/* The one thing in the strip that IS interactive.

   "Nothing in here is interactive" was true when the rule above was
   written. The review badge was added to .page-hero-copy afterwards,
   inherited the pointer-events: none with everything else, and became
   the only "Rate the flight" on the site that could not be clicked —
   on portfolio and contact, the two pages that pin. The click landed
   on .pin-scope instead and did nothing at all.

   Scoped to .review-mark rather than .review-badge deliberately. The
   badge is a full-width block; re-enabling the pointer on it would
   take back a whole horizontal band of the strip and reintroduce
   exactly the swallowing the rule above exists to prevent. The mark
   is width: fit-content, so it claims only the link itself.

   Nothing is needed for the condensed state: .page-hero--pin
   .review-badge collapses to max-height 0 with overflow hidden, and
   clipped content is not hit-tested. The link stops being clickable
   at the same moment it stops being visible. */
.page-hero--pin .review-mark {
    pointer-events: auto;
}

/* Lift the copy above the frost. Not optional, and not cosmetic.

   Both layers below are position:absolute at z-index 0. A copy block
   left non-positioned paints in the in-flow layer, which is BENEATH
   every positioned descendant at z-index >= 0 — so the frost ends up
   over the type and its backdrop-filter blurs the headline.

   Portfolio never showed it because portfolio.css already sets this
   for the drift-wall stacking. Contact has no drift wall and no such
   rule, so the moment the effect moved here it took the bug with it.
   The stacking belongs with the effect, not with one page's wall.

   3 keeps portfolio's existing order intact: wall 1, veil 2, copy 3. */
.page-hero--pin .page-hero-copy {
    position: relative;
    z-index: 3;
}

/* Keep focus and anchor scrolling clear of the strip.

   Browsers scroll a focused field to the top of the viewport, which
   on these pages is behind the strip: tab through the contact form
   and the field you are typing in disappears under it. scroll-padding
   tells the browser that the top of the scrollport is not usable, so
   it stops short.

   Scoped to pages carrying the pin via :has(), and falling back to
   nothing where that is unsupported — the effect is a refinement, not
   a requirement. Value matches the strip's own condensed height. */
:root:has(.page-hero--pin) {
    scroll-padding-top: calc(
        var(--nav-height-scrolled) + var(--space-5) + 4.5rem
    );
}

/* ---- Pin spacer ----

   What stops the page fighting the scroll, and worth knowing why it
   is on the section rather than on the strip.

   A sticky element still occupies its space in normal flow. The
   condense collapses the eyebrow, the lede, the badge and two margins
   with them — around 150px — so as the strip locks, the DOCUMENT gets
   150px shorter and every section under it is pulled up by that much
   on top of the scroll. Over a 150px window the page ran at roughly
   double speed while the header was closing and normal speed either
   side of it. Going down, the log leaps up to meet you. Going back
   up it is worse: the page runs away downward faster than you are
   scrolling, so the last stretch to the top feels like pushing
   against something, and then it stops dead.

   No easing fixes that. An earlier attempt smoothed the curve so the
   rate changed gradually rather than switching, and the resistance
   was still there — the page really was moving twice as fast as the
   input, and a smooth ramp into that is still a ramp into that.
   Giving back exactly what the collapse takes is the only fix.

   ---- Why not margin-block-end on the strip ----

   That was tried first and it is wrong in two ways at once, both
   because a margin is empty space rather than something that paints.

   It opened a transparent window between the strip and the section,
   and on these pages there is a position:fixed background video
   behind everything. The header defocuses it with a backdrop-filter
   on its own ::before, which by definition only covers the header —
   so the reserved strip below it showed the video sharp and
   full-colour, a hard-edged band of footage across the page.

   It also broke the ground. --strip-ground is measured in global.js
   as the overlap between the strip and its next sibling, and it
   drives BOTH the blur depth and the tint opacity on the two rules
   above. A margin pushed that sibling 150px further away, so the
   overlap never opened, the ground stayed at 0, and the strip lost
   its tint and kept its resting 14px blur.

   Padding on the section fixes both without a special case. The
   section paints its own background across the reserved space, so
   there is no window. And the section's BORDER box still starts
   exactly where it always did — only its content is pushed down — so
   the overlap measurement means what it meant before and needs no
   adjustment at all.

   The arithmetic: the section's content sits at
   H_full - collapsed + base + collapsed, which is constant. The
   sections below move at 1:1 with the scroll for the whole page.

   This is what a GSAP pin does with its pin-spacer, and for the same
   reason: pinning something that is still in flow without reserving
   its space moves everything after it.

   Two rules because the base padding differs and calc() cannot add
   to a value it does not know. The --lg rule comes second so it wins
   for a sibling carrying both classes. */
.page-hero--pin + .section {
    padding-block-start: calc(
        var(--section-py) + var(--condense, 0) * var(--collapsible-h, 0px)
    );
}

.page-hero--pin + .section--lg {
    padding-block-start: calc(
        var(--section-py-lg) + var(--condense, 0) * var(--collapsible-h, 0px)
    );
}

/* Bounds the pin: header + log, ending where the CTA begins.

   A sticky element sticks only while its containing block still has
   room, so this wrapper IS the pin duration. It adds no height and
   no styling — it exists purely to say where the strip stops. */
.pin-scope {
    position: relative;
}

/* Frosted, not opaque.

   Selector doubled on purpose. Both contact.css and portfolio.css
   define .page-hero::before for the resting blur, and both load
   AFTER this file — at equal specificity the later rule wins and
   this one would silently never apply. Two classes beats one.

   The log passing underneath is meant to come through — it is the
   depth cue that says this strip is floating over the page rather
   than being part of it. What it must not do is compete, so the
   defocus deepens as content arrives behind it: 14px at rest,
   where the strip sits over nothing but the background video and
   should look untouched, up to 38px once the grid is under it.

   Blur alone does not carry a white headline over moving posters,
   so the tint below rises with it. Together they turn the log into
   an ambient field: still visibly moving, no longer readable, and
   no longer asking to be read. A fully opaque ground would have
   been simpler and would have thrown away the whole effect. */
.page-hero.page-hero--pin::before {
    -webkit-backdrop-filter: blur(calc(14px + var(--strip-ground, 0) * 24px)) saturate(0.92);
    backdrop-filter: blur(calc(14px + var(--strip-ground, 0) * 24px)) saturate(0.92);
}

/* The tint that makes the headline hold.

   ::after rather than ::before: the blur owns ::before at the same
   depth, and a later pseudo-element paints over it. Both stay under
   .drift-wall (z-index 1), so the wall keeps running across the top
   of the tint rather than being flattened by it. */
.page-hero--pin::after {
    content: '';
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    background: rgba(6, 6, 6, 0.62);
    opacity: var(--strip-ground, 0);
}

/* ---- Condense: the secondary copy leaves as the strip locks ----

   The header opens at full height with all three lines, and closes to
   a title-only strip as soon as the visitor starts scrolling. Both
   states are real, and it reverses on the way back up.

   The two lines FADE AND COLLAPSE, not one or the other. Fading alone
   would hold the strip at its full height with a hole in the middle
   of it; collapsing is what actually makes the section smaller, which
   is the whole point of condensing.

   max-height is driven from a height measured in script rather than a
   guessed constant. A guess has to be generous enough for the lede to
   wrap on a narrow screen — and a generous cap does nothing at all
   until it finally drops below the real content height, so the
   collapse would sit still and then happen in a rush at the end
   instead of tracking the scroll. */
.page-hero--pin .eyebrow,
.page-hero--pin .hero-lede {
    display: block;
    overflow: hidden;
    opacity: calc(1 - var(--condense, 0));
}

.page-hero--pin .eyebrow {
    max-height: calc((1 - var(--condense, 0)) * var(--eyebrow-h, 2rem));
}

.page-hero--pin .hero-lede {
    max-height: calc((1 - var(--condense, 0)) * var(--lede-h, 2rem));

    /* The .page-hero-copy rhythm has to collapse with it, or the strip
       keeps a 16px gap where the lede used to be. */
    margin-block-start: calc((1 - var(--condense, 0)) * var(--space-4));
}

/* The review badge condenses with the rest of the secondary copy.

   It has to: portfolio and contact hold this header under the nav for
   the whole page, and a strip that is meant to be a title and a
   frosted band would otherwise carry a pill through the entire
   scroll. It belongs to the full header, not the locked one. */
.page-hero--pin .review-badge {
    display: block;
    overflow: hidden;
    opacity: calc(1 - var(--condense, 0));
    max-height: calc((1 - var(--condense, 0)) * var(--badge-h, 3rem));
    margin-block-start: calc((1 - var(--condense, 0)) * var(--space-5));
}

/* The title is the one line that survives the condense, and it takes
   no opacity of its own — the strip fades as a whole.

   It used to carry a second curve here to lead the band out. Nested
   opacities multiply, so the title was riding both at once, and the
   combined slope was steep enough that a pixel or two of jitter from
   the sticky rects behind the maths read as the text changing
   brightness by itself. One curve, one repaint. */

/* No pin and no fades. A strip that holds position while the page
   moves is motion, whatever the mechanism. */
@media (prefers-reduced-motion: reduce) {
    .page-hero--pin {
        position: static;
        opacity: 1;
    }

    .page-hero--pin::after {
        opacity: 0;
    }

    /* Full header, nothing collapsed, nothing faded. */
    .page-hero--pin .eyebrow,
    .page-hero--pin .review-badge,
    .page-hero--pin .hero-lede {
        max-height: none;
        margin-block-start: var(--space-4);
        opacity: 1;
    }

    .page-hero--pin .eyebrow {
        margin-block-start: 0;
    }
}


/* ============================================================
   REVIEWS — hero badge and the quote section

   Both are rendered by js/reviews.js from /data/reviews.json.
   Neither ships visible: the badge carries no href and the section
   is [hidden] in components/reviews.tpl until the script has
   something real to put in them.
   ============================================================ */

/* ---- The mark ----

   NOT a badge, and that distinction is the whole design.

   The first version was a rounded, blurred pill carrying a G and five
   outline stars. Every part of that is foreign to this site: nothing
   in the viewfinder has a fill, a radius, or a backdrop of its own.
   The hero is hairlines and type sitting directly on the footage, so a
   chip dropped into it reads as something bolted on from a plugin —
   which is exactly what "basic" means here.

   This speaks the grammar the hero already has instead. .vf-entry is a
   rule as its baseline, a mono micro-label above it, and the value in
   display type with an arrow; this is a fourth object in that system
   rather than a fifth style.

   Smaller than .vf-entry on purpose. Indoor and Outdoor are the two
   things the home page exists to send people to, and a review ask set
   at the same scale misjudges what the page is for. */
.review-mark {
    display: block;
    width: fit-content;
    padding-block-start: var(--space-3);
    border-block-start: 1px solid var(--edge-soft);
    transition: border-color var(--dur-mid) var(--ease-out);
}

.review-mark:hover {
    border-block-start-color: var(--bone-300);
}

.review-mark-meta {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    justify-content: flex-end;
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    color: var(--text-lo);
    margin-block-end: var(--space-2);
    transition: color var(--dur-mid) var(--ease-out);
}

.review-mark:hover .review-mark-meta {
    color: var(--bone-400);
}

/* The rating itself, in the label line. Bone rather than the label's
   muted grey because it is the one piece of data up there — the count
   beside it is context, the number is the claim. */
.review-mark-score {
    color: var(--bone-200);
}

/* Only ever drawn when there IS a rating, so these are never five
   empty outlines standing in for nothing. */
.review-mark-meta .review-stars {
    font-size: 0.9em;
}

/* Left-aligned contexts: the label line is a flex row, so its
   justification has to follow the block it sits in rather than
   staying right-aligned from the viewfinder. */
.hero-copy .review-mark-meta {
    justify-content: flex-start;
}

.page-hero-copy .review-mark-meta {
    justify-content: center;
}

.review-mark-value {
    display: inline-flex;
    align-items: baseline;
    gap: var(--space-3);
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    /* One size everywhere, and small.

       It was a clamp up to 1.6rem when this stood alone in the corner.
       It now sits in .vf-aside under two 11px mono labels, and a
       display line three times their height would make the quietest
       corner of the hero the loudest thing in it. Big enough to read
       as the action, small enough to stay a secondary ask. */
    font-size: 1.05rem;
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-display);
    line-height: 1;
    color: var(--text-hi);
    white-space: nowrap;
    transition: color var(--dur-mid) var(--ease-out);
}

.review-mark:hover .review-mark-value {
    color: var(--bone-200);
}

.review-mark-arrow {
    width: 0.42em;
    height: 0.42em;
    flex-shrink: 0;
    align-self: center;
    color: var(--bone-400);
    transition: transform var(--dur-mid) var(--ease-out);
}

.review-mark:hover .review-mark-arrow {
    transform: translate(2px, -2px);
}

.review-mark:focus-visible {
    outline: 1px solid var(--bone-300);
    outline-offset: var(--space-2);
}

/* ---- Placement: home hero ----

   A third item in .vf-aside, under Sound and Follow. No positioning
   of its own at all — that cluster is a right-aligned flex column
   with its own gap, so the mark just joins it.

   It was absolutely positioned in the top-right corner before, which
   was wrong twice over. It collided with the corner bracket (identical
   inset maths puts two things in the same place, not near each other),
   and more to the point it had no business being a free-standing
   object up there: it is a secondary ask, and the hero already has a
   home for secondary asks. Joining the cluster costs no positioning
   code and cannot collide with anything.

   There is deliberately NO .viewfinder .review-badge rule. It needs
   nothing, and the obvious placeholder — display: block — is actively
   harmful: an author rule beats the browser's [hidden] { display:
   none }, so it would render the empty div before the script fills
   it and hold a 24px flex gap in the cluster. css/modals.css hit the
   same trap and documents it. */

/* ---- Placement: every other hero ----

   Inline in the copy. Those heroes put a 3D viewer or centred copy in
   the corner the home page leaves empty, so an absolute mark would
   land on top of something. */
.hero-copy .review-badge,
.page-hero-copy .review-badge {
    display: block;
    margin-block-start: var(--space-6);
}

/* .page-hero centres its copy; a fit-content block would sit hard left
   of it. */
.page-hero-copy .review-mark {
    margin-inline: auto;
}

@media (prefers-reduced-motion: reduce) {
    .review-mark-arrow {
        transition: none;
    }

    .review-mark:hover .review-mark-arrow {
        transform: none;
    }
}


/* ---- Reviews section ---- */
.reviews {
    max-width: var(--container);
    margin-inline: auto;
}

.reviews-track {
    display: grid;
    grid-template-columns: repeat(auto-fit, minmax(min(100%, 280px), 1fr));
    gap: var(--space-5);
}

.review {
    display: flex;
    flex-direction: column;
    gap: var(--space-3);
    margin: 0;
    padding: var(--space-5);
    background: var(--fill-01);
    border: 1px solid var(--edge-faint);
    border-block-start-color: var(--edge-bone);
    border-radius: var(--radius-lg);
}

/* Stars appear on the CARDS only, never on the mark. Inside a card,
   next to a quote and an attribution, a five-star row is the
   convention people scan for. Standing alone over the hero it is the
   most templated object on the web — which is why the mark carries a
   number instead. */
.review-stars {
    display: inline-flex;
    gap: 0.15em;
    font-size: var(--fs-xs);
    color: var(--bone-300);
}

.review-text {
    margin: 0;
    font-size: var(--fs-base);
    line-height: var(--leading-body);
    color: var(--text-mid);
}

/* Quote marks are drawn rather than typed: a literal " in the JSON
   would have to be escaped by whoever pastes a review in, and would
   be wrong for any review that already opens with one. */
.review-text::before {
    content: '\201C';
    color: var(--bone-300);
    opacity: 0.5;
    margin-inline-end: 0.1em;
}

.review-text::after {
    content: '\201D';
    color: var(--bone-300);
    opacity: 0.5;
    margin-inline-start: 0.05em;
}

.review-by {
    display: flex;
    align-items: center;
    gap: var(--space-2);
    font-size: var(--fs-xs);
    /* Pushed to the bottom so cards of unequal quote length still
       line their attributions up across the row. */
    margin-block-start: auto;
}

.review-author {
    font-weight: var(--weight-semi);
    color: var(--text-hi);
}

.review-date {
    color: var(--text-lo);
}

.review-src {
    margin-inline-start: auto;
    font-weight: var(--weight-semi);
    color: var(--bone-300);
}

.reviews-foot {
    display: flex;
    justify-content: center;
    margin-block-start: var(--space-7);
}


/* ============================================================
   FLOATING THREAD BUTTON

   The way back into a conversation from any page. Markup lives in
   components/footer.tpl and js/global.js reveals it only for a
   browser that has actually written to us.

   Small, transparent, icon only. A filled pill in that corner is
   the universal shape of a support-chat widget, and a drone company
   does not want one of those bolted to every page. This reads as a
   site control instead: glass over whatever is behind it, a hairline
   edge, and it only commits to a colour on hover.
   ============================================================ */
.thread-fab {
    position: fixed;
    right: var(--space-5);

    /* Raised clear of the homepage's viewfinder brackets. .vf-frame
       insets its bottom edge by --space-6 (32px) and .vf-corner is
       22px tall, so the bottom-right bracket occupies 32-54px up from
       the viewport floor. A 42px button sitting at --space-5 spanned
       24-66px and cut straight through it.

       This button is fixed and sitewide while the brackets exist only
       on the hero, so it is the one that has to move: the frame is the
       thing being framed around, and a control drawn on top of it
       reads as part of the frame rather than as a control. */
    bottom: var(--space-9);
    z-index: 60;

    display: inline-flex;
    align-items: center;
    justify-content: center;
    width: 42px;
    height: 42px;

    color: var(--text-mid);
    text-decoration: none;

    background: rgba(255, 255, 255, 0.05);
    border: 1px solid var(--edge-soft);
    border-radius: 999px;

    /* Keeps the icon legible over the hero video and over the pale
       sections alike. Unsupported browsers just get the tint. */
    -webkit-backdrop-filter: blur(10px);
    backdrop-filter: blur(10px);

    transition: color var(--dur-fast) var(--ease-out),
                border-color var(--dur-fast) var(--ease-out),
                background var(--dur-fast) var(--ease-out);
}

.thread-fab:hover {
    color: var(--bone-300);
    background: rgba(255, 255, 255, 0.09);
    border-color: var(--edge-bone-hot);
}

.thread-fab i {
    font-size: var(--fs-sm);
}

/* Never rendered. The button is icon-only at every width; the text
   stays in the DOM so anything reading content rather than labels
   still finds it, and aria-label carries the accessible name. */
.thread-fab-text {
    position: absolute;
    width: 1px;
    height: 1px;
    margin: -1px;
    padding: 0;
    overflow: hidden;
    clip-path: inset(50%);
    white-space: nowrap;
}

@media (max-width: 640px) {
    .thread-fab {
        right: var(--space-4);

        /* Same clearance problem at this width. The brackets shrink to
           16px but keep the same 32px inset, so they occupy 32-48px —
           and the button moving closer to the edge made the overlap
           worse, not better. */
        bottom: calc(var(--space-6) + var(--space-5));
        width: 38px;
        height: 38px;
    }
}

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