/* ========================================
   FLIGHT LOG - VistaFly

   Two views over the same data. The grid is the showreel; the
   list is the log. Both are rendered by js/portfolio.js from
   /data/flightlog.json — nothing in this file assumes a fixed
   number of entries.

   The mobile rail below 700px lives in components.css, not here.
   Its selectors are .section-prefixed specifically so they outrank
   the .portfolio-grid rule in this file. Do not raise the
   specificity of that rule without reading the MOBILE RAIL block
   there first.
   ======================================== */

/* Well under the other pages (0.48–0.52). Four things dim this header
   — this scrim, the video's own brightness filter, the veil gradient
   and the wall over the top — and they multiply rather than add. The
   first pass set each to a sane-looking value independently and the
   stack came out near black. */
body {
    --scrim: 0.1;

    /* Header height, and the one dial for the block below. Matches
       the min-height on .page-hero--tight; if that changes, change
       this with it or the video stops being centred in the header. */
    --hero-h: 38svh;
}

/* Hero padding lives entirely in components.css under
   .page-hero--tight. It was duplicated here, and because both
   selectors are a single class this file simply won overriding it —
   including inside the mobile media query, which broke the
   padding-top minus padding-bottom invariant that keeps the copy
   centred. One owner only. */

/* ---- Centre the video in the header ----

   object-position cannot do this, which is worth writing down
   because it looks like it should.

   The video is position:fixed with inset:0 — it covers the whole
   viewport, while this header is only the top --hero-h of it. So the
   header always samples the UPPER band of the rendered frame.
   object-position only chooses which part of the SOURCE fills that
   full-viewport box; it has no way to move the centre up into the
   top third. Raising it to 72% and then 88% moved the crop and left
   the header looking at the same band.

   Moving the element is what works. Lift it and over-size it so its
   own centre lands at the header's centre, while its bottom edge
   still reaches the viewport floor — the CTA further down the page
   shows this same fixed video and would otherwise sit on bare
   ground:

     top    = hero - 100vh          (negative: pulls it up)
     height = 100vh - top = 200vh - hero

   At a 38svh header: top -62vh, height 162vh, centre at 19vh, which
   is the middle of the header. Bottom lands exactly on 100vh.

   The cost is zoom. A 162vh-tall element under object-fit:cover
   scales the footage up to fill it, so the frame is tighter than
   before. Unavoidable — covering the viewport after a lift means
   over-sizing. Lower --hero-h for less lift and less zoom. */
.local-video-bg,
.bg-video,
.bg-poster {
    top: calc(var(--hero-h) - 100vh);
    height: calc(200vh - var(--hero-h));
}

.bg-poster {
    background-image: url('/images/herobg-poster.jpg');
    background-size: cover;
    background-position: center;
}

/* ---- Defocus the background video ----
   Three image layers share this header — the video, the drift wall,
   and the type. The problem was never the count, it was that all
   three sat at the same visual depth: the video keeps full detail at
   the normal scrim, so you read the rider in it, and its warm-and-cool
   cast bleeds up through the drift tiles and stains them.

   Out of focus it stops being a competing scene and becomes an
   ambient colour field, which is the job a background should be doing
   when something is layered over it. The motion and the colour
   survive; only the detail goes, and the detail was what competed.

   filter on the video element rather than backdrop-filter on a plate
   above it: the video is position:fixed, so a backdrop-filter would
   have to recompute the blur every frame the page scrolls. This is
   one static GPU-composited layer that costs nothing to scroll past.

   scale() because blur samples beyond the element's edges and would
   otherwise feather the video into transparency at the frame.

   Only the header and the CTA ever show it on this page — every
   section between them is .surface-solid.

   Blur does the separating, not darkness. Defocus alone is enough to
   stop the video competing for attention, and it is the one treatment
   that costs nothing in visibility — the footage stays bright, warm
   and clearly moving. Brightness is pulled back only far enough to
   keep white type legible over it. */
/* The defocus is bounded to the header, not applied to the video.

   filter: on .local-video-bg was the first approach and it blurred
   too much. That element is position:fixed across the whole
   viewport, so the blur travels with it down the entire page —
   every translucent surface below shows blurred footage, and with
   the CTA now pinned and only 72% opaque the soft wash sat on
   screen the whole way down.

   backdrop-filter on a layer inside .page-hero blurs only what is
   behind the header, which is where the treatment was ever meant to
   apply. The trade the old comment worried about — a fixed element
   forcing the blur to recompute every scrolled frame — does not
   arise: this layer scrolls away with the header rather than
   staying pinned to the viewport.

   z-index 0 puts it under the drift wall (1), the veil (2) and the
   copy (3), so only the video is defocused. The tiles stay sharp,
   which was the whole point of separating them by depth. */
.page-hero::before {
    content: '';
    position: absolute;
    inset: 0;
    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);
}

.flightlog {
    max-width: var(--container-wide);
    margin-inline: auto;
}


/* ============================================================
   DRIFT WALL — the header band

   Built by js/flightlog-drift.js. Purely decorative: aria-hidden,
   inert to the pointer, nothing in it is clickable. That is what
   earns it the right to move continuously — the grid below cannot,
   because you have to be able to hit a specific card there.

   Tile geometry (--tile-w / --tile-h / --tile-gap) is written by the
   script, which derives its scroll wrap length from the same numbers.
   Do not hard-code them here or the loop will visibly jump.
   ============================================================ */
.page-hero {
    /* clip, not hidden: the stage is deliberately oversized and
       rotated, and its corners must not force a scrollbar. */
    overflow: clip;
}

/* Stacking in the header, deepest first:
   defocused video -> drift wall -> veil -> type.

   The header copy sits above everything. .page-hero is already
   display:grid with all children in the same cell. */
.page-hero-copy {
    position: relative;
    z-index: 3;
}

.drift-wall {
    position: absolute;
    inset: 0;
    z-index: 1;
    pointer-events: none;
    perspective: 1250px;
    /* Fades to nothing at every edge so the band dissolves into the
       page rather than ending on a hard line. Three stops rather than
       two: a straight ramp from opaque to nothing still has a visible
       shoulder where it begins, and on a field of hard-edged tiles
       that shoulder reads as a seam. */
    -webkit-mask-image:
        radial-gradient(ellipse 96% 84% at 50% 45%,
            #000 14%, rgba(0, 0, 0, 0.55) 54%, rgba(0, 0, 0, 0) 88%);
    mask-image:
        radial-gradient(ellipse 96% 84% at 50% 45%,
            #000 14%, rgba(0, 0, 0, 0.55) 54%, rgba(0, 0, 0, 0) 88%);
    opacity: 0;
    transition: opacity var(--dur-glacial) var(--ease-out);
}

/* Set once the tiles exist, so the wall fades up instead of popping
   in half-built as the posters decode. */
.drift-wall.is-ready {
    opacity: 0.52;
}

.drift-stage {
    position: absolute;
    /* Oversized on purpose: once rotated, a stage the size of the
       header would show its own corners inside the frame. These must
       match STAGE_W / STAGE_H in js/flightlog-drift.js, which derives
       the column count from them — -20% each side is a 1.4 factor. */
    inset: -45% -20%;
    display: flex;
    justify-content: center;
    /* Wider between columns than between tiles: the rotation compresses
       the horizontal axis, so an equal gap reads tighter across than it
       does down. */
    gap: var(--tile-gap-x, 56px);
    transform:
        translate3d(var(--drift-x, 0px), var(--drift-y, 0px), 0)
        rotateX(15deg)
        rotateZ(-13deg);
    transform-style: preserve-3d;
    transition: transform var(--dur-slow) var(--ease-out);
    will-change: transform;
}

.drift-col {
    display: flex;
    flex-direction: column;
    flex: 0 0 var(--tile-w, 352px);
    gap: var(--tile-gap, 30px);
    /* Each column is one composited layer that only ever translates. */
    will-change: transform;
}

/* No border, no drop shadow, and a generous radius.

   The 1px hairline was the harshest edge anywhere in the header: a
   bright rectangle outlined against a dark ground, repeated sixty
   times. It reads as UI chrome, which is exactly what a background
   texture must not do. A drop shadow did the same thing more softly
   — it draws the eye to the boundary rather than the image.

   The mask below feathers each tile's own edges so they dissolve
   into the header instead of stopping on a line. 88%/100% is a tight
   feather: enough to kill the hard corner, not so much that the
   tiles turn into blobs. */
.drift-tile {
    width: var(--tile-w, 352px);
    height: var(--tile-h, 198px);
    flex: 0 0 auto;
    border-radius: var(--radius-lg);
    overflow: hidden;
    background: transparent;
    -webkit-mask-image:
        radial-gradient(ellipse 112% 112% at 50% 50%, #000 88%, transparent 100%);
    mask-image:
        radial-gradient(ellipse 112% 112% at 50% 50%, #000 88%, transparent 100%);
}

.drift-tile img {
    display: block;
    width: 100%;
    height: 100%;
    object-fit: cover;
    object-position: center;
    /* Near-monochrome on purpose. At 0.55 grayscale each tile kept
       enough of its own colour to read as a separate photograph, so
       the wall looked like a second gallery arguing with the one
       below. Pushed to 0.92 the tiles stop being pictures and become
       one material — and the ambient colour then comes from the
       defocused video underneath, which is a single coherent source
       rather than nine competing ones. */
    filter: grayscale(0.92) brightness(0.74) contrast(0.94);
}

/* ---- Clearing ----
   A pocket of near-black directly under the type, falling off to
   nothing well before the edges. This is what makes the header read
   as one composition instead of three: the wall and the video stay
   fully present at the sides and top, and the middle is simply quiet
   enough for a headline to own.

   Tighter and deeper than the first pass. A wide, shallow scrim dims
   everything a little, which flattens the whole header without ever
   actually clearing the type — the worst of both. */
.drift-veil {
    position: absolute;
    inset: 0;
    z-index: 2;
    pointer-events: none;
    background:
        /* Tight and local. This is the only layer that is allowed to
           go properly dark, because it is the only one whose job is
           legibility — and it covers maybe a third of the header, so
           the video stays visible everywhere around it. */
        radial-gradient(
            ellipse 40% 58% at 50% 50%,
            rgba(4, 4, 4, 0.82) 0%,
            rgba(4, 4, 4, 0.54) 42%,
            rgba(4, 4, 4, 0.16) 70%,
            rgba(4, 4, 4, 0) 86%
        ),
        /* Anchors the band top and bottom so it does not float: the
           nav needs a dark bed, and the grid below needs the header
           to resolve into it rather than stop on a line. The middle
           is left almost open. */
        linear-gradient(
            to bottom,
            rgba(4, 4, 4, 0.52) 0%,
            rgba(4, 4, 4, 0) 24%,
            rgba(4, 4, 4, 0.03) 56%,
            rgba(4, 4, 4, 0.86) 100%
        );
}

/* Below the rail breakpoint the header is short and the wall reads as
   clutter behind the title rather than as depth. */
@media (max-width: 700px) {
    /* The wall runs on phones too, at the small geometry from
       geometry() in js/flightlog-drift.js. A few adjustments it needs
       here rather than there: */

    /* A phone header is short and narrow, so the wall occupies a far
       larger share of it than on a desktop. Backing the opacity off
       keeps it a texture instead of the main event. */
    .drift-wall.is-ready {
        opacity: 0.4;
    }

    /* Tighter mask. Over a short header the desktop falloff barely
       reaches transparent before the edge, which puts a visible band
       across the top and bottom of the frame. */
    .drift-wall {
        -webkit-mask-image:
            radial-gradient(ellipse 104% 76% at 50% 45%,
                #000 8%, rgba(0, 0, 0, 0.45) 46%, rgba(0, 0, 0, 0) 82%);
        mask-image:
            radial-gradient(ellipse 104% 76% at 50% 45%,
                #000 8%, rgba(0, 0, 0, 0.45) 46%, rgba(0, 0, 0, 0) 82%);
    }

    /* The title takes up most of a narrow header, so its clearing has
       to be proportionally wider or the type sits on moving tiles. */
    .drift-veil {
        background:
            radial-gradient(
                ellipse 78% 52% at 50% 50%,
                rgba(4, 4, 4, 0.84) 0%,
                rgba(4, 4, 4, 0.56) 44%,
                rgba(4, 4, 4, 0.14) 72%,
                rgba(4, 4, 4, 0) 88%
            ),
            linear-gradient(
                to bottom,
                rgba(4, 4, 4, 0.58) 0%,
                rgba(4, 4, 4, 0.04) 26%,
                rgba(4, 4, 4, 0.12) 60%,
                rgba(4, 4, 4, 0.9) 100%
            );
    }

    /* Blur is cheaper at a smaller radius and phones need the help,
       but it still has to out-soften the wall or the two compete. */
    .page-hero::before {
        -webkit-backdrop-filter: blur(11px) saturate(0.9) brightness(0.9);
        backdrop-filter: blur(11px) saturate(0.9) brightness(0.9);
    }

    /* One flat composited layer per column, and nothing inside one can
       affect anything outside it. On a phone this is the difference
       between the wall scrolling smoothly and the whole header
       juddering. */
    .drift-col {
        contain: layout paint;
    }
}

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


/* ============================================================
   TOOLBAR

   Filters on the left, instrumentation on the right, one hairline
   under the whole thing. The rule matters more than it looks: it
   is what makes the controls read as the header of a document
   rather than as buttons floating above a grid.
   ============================================================ */
.log-toolbar {
    display: flex;
    flex-wrap: wrap;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-4) var(--space-6);
    padding-block-end: var(--space-4);
    margin-block-end: var(--space-8);
    border-block-end: 1px solid var(--edge-faint);
}

.portfolio-filter {
    display: flex;
    flex-wrap: wrap;
    gap: var(--space-2);
    min-width: 0;
}

.filter-btn {
    padding: 0.55rem 1.15rem;
    border-radius: var(--radius-pill);
    background: transparent;
    border: 1px solid var(--edge-soft);
    color: var(--text-lo);
    font-size: var(--fs-xs);
    font-weight: var(--weight-semi);
    letter-spacing: var(--track-label);
    text-transform: uppercase;
    line-height: var(--leading-flat);
    cursor: pointer;
    transition:
        color var(--dur-mid) var(--ease-out),
        border-color var(--dur-mid) var(--ease-out),
        background-color var(--dur-mid) var(--ease-out);
}

.filter-btn:hover {
    color: var(--text-hi);
    border-color: var(--edge-bone);
}

.filter-btn.active {
    background: var(--bone-300);
    border-color: var(--bone-300);
    color: var(--text-on-bone);
}

.log-controls {
    display: flex;
    align-items: center;
    gap: var(--space-4);
    margin-inline-start: auto;
}

/* Mono, because it is a readout. It also stops the toolbar from
   twitching every time the count changes width between filters. */
.log-count {
    font-family: var(--font-mono);
    font-size: var(--fs-2xs);
    letter-spacing: var(--track-wide);
    text-transform: uppercase;
    color: var(--text-faint);
    white-space: nowrap;
    font-variant-numeric: tabular-nums;
}

.log-sort select {
    appearance: none;
    -webkit-appearance: none;
    padding: 0.5rem 2rem 0.5rem 0.85rem;
    border-radius: var(--radius-sm);
    border: 1px solid var(--edge-soft);
    background-color: transparent;
    /* Inline chevron rather than a font-icon: it belongs to the
       control's box, so it cannot drift out of alignment when the
       select resizes. */
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 12 8'%3E%3Cpath fill='none' stroke='%23ffffff' stroke-opacity='.45' stroke-width='1.5' d='M1 1.5 6 6.5l5-5'/%3E%3C/svg%3E");
    background-repeat: no-repeat;
    background-position: right 0.7rem center;
    background-size: 10px;
    color: var(--text-mid);
    font-family: var(--font-body);
    font-size: var(--fs-2xs);
    letter-spacing: var(--track-wide);
    cursor: pointer;
    transition: border-color var(--dur-mid) var(--ease-out), color var(--dur-mid) var(--ease-out);
}

.log-sort select:hover {
    border-color: var(--edge-bone);
    color: var(--text-hi);
}

.log-sort select option {
    background: var(--ground-200);
    color: var(--text-hi);
}

.view-toggle {
    display: flex;
    gap: 2px;
    padding: 3px;
    border-radius: var(--radius-sm);
    border: 1px solid var(--edge-soft);
    background: var(--fill-01);
}

.view-toggle button {
    display: grid;
    place-items: center;
    width: 32px;
    height: 30px;
    padding: 0;
    border: 0;
    border-radius: var(--radius-xs);
    background: transparent;
    color: var(--text-faint);
    font-size: var(--fs-2xs);
    cursor: pointer;
    transition:
        background-color var(--dur-fast) var(--ease-out),
        color var(--dur-fast) var(--ease-out);
}

.view-toggle button:hover {
    color: var(--text-hi);
}

.view-toggle button.active {
    background: var(--fill-03);
    color: var(--bone-300);
}

.log-empty {
    padding-block: var(--space-10);
    text-align: center;
    color: var(--text-lo);
    font-size: var(--fs-body);
}


/* ============================================================
   VIEW SWITCH

   These outrank .section .portfolio-grid in components.css (three
   simple selectors against two), which is what lets list view win
   on a phone where the grid would otherwise still be a rail.
   ============================================================ */
.flightlog[data-view='grid'] .flightlog-list-wrap {
    display: none;
}

.flightlog[data-view='list'] .portfolio-grid {
    display: none;
}


/* ============================================================
   GRID — mosaic

   Six columns, standard entries spanning two and featured entries
   spanning three. The point is that the row rhythm never repeats
   exactly: 3-up, then 2-up, then 3-up. A uniform auto-fit grid
   gives every project identical weight, which is the wrong claim
   to make about a portfolio — some of this work is better than
   the rest and the layout should say so.

   grid-auto-flow: dense backfills the gaps that mixed spans leave,
   so a featured entry can sit anywhere in the manifest without
   punching a hole in the layout.
   ============================================================ */
/* Twelve columns, and every row packed to exactly twelve by
   js/portfolio.js. Widths vary; row heights do not.

   The old version was six columns with dense auto-flow and a fixed
   16:9 on each tile. Two things went wrong with that. Dense flow
   backfills gaps wherever it can, so the order was decided by
   whatever happened to fit — which is how a lone card ends up
   stranded on the last row. And a fixed aspect ratio makes height a
   function of width, so a wide tile and a narrow tile sitting side by
   side could never align, and every row had a ragged underside.

   Here the row height is set by the grid and the tiles crop to fill
   it. Proportions differ from tile to tile, which is the variety;
   the edges line up exactly, which is the discipline. */
.portfolio-grid {
    display: grid;
    grid-template-columns: repeat(12, minmax(0, 1fr));
    grid-auto-rows: clamp(190px, 21vw, 340px);
    gap: var(--space-5);
    margin-inline: auto;
}

.portfolio-item {
    /* Overwritten inline by layoutSpans(). This is the pre-paint
       fallback and the value the mobile rail falls back to. */
    grid-column: span 4;
    display: flex;
    flex-direction: column;
    min-width: 0;
    perspective: 1000px;
}

/* Featured no longer means a wider span — the row plan decides that,
   and it hands the widest slot in a row to a featured entry when one
   is present. The class still drives the larger title. */

/* portfolio.js toggles .hidden when filtering. */
.portfolio-item.hidden {
    display: none;
}

/* ---- Depth wall ----
   Each card owns its own perspective rather than sharing one declared
   on the grid. A single grid-level vanishing point makes the cards at
   the edges lean away as though the whole page were one rigid object;
   per-card perspective keeps every tile reading as its own plane.

   --tilt-x / --tilt-y / --shift-x / --shift-y are written by
   js/portfolio.js on pointermove and simply *removed* on leave, so
   the values fall back to 0 and the transition below animates the
   return to rest. There is no JS animation loop — the transition is
   the damping. */
.portfolio-item {
    perspective: 1000px;
}

.video-container {
    position: relative;
    /* No aspect-ratio. Height comes from the grid row so everything in
       a row aligns; the poster crops to fill whatever shape it lands
       in. The mobile rail puts the ratio back, because there is no
       grid row to inherit a height from once it becomes a flex row. */
    flex: 1 1 auto;
    min-height: 0;
    border-radius: var(--radius-lg);
    overflow: hidden;
    background: var(--ground-300);
    border: 1px solid var(--edge-faint);
    box-shadow: var(--shadow-md);
    cursor: pointer;
    transform:
        rotateX(var(--tilt-x, 0deg))
        rotateY(var(--tilt-y, 0deg))
        translateY(var(--lift, 0px));
    /* Short enough that the card feels stuck to the cursor, long
       enough to damp the jitter of raw pointer deltas. */
    transition:
        transform 160ms var(--ease-out),
        border-color var(--dur-mid) var(--ease-out),
        box-shadow var(--dur-mid) var(--ease-out);
}

/* ---- Hover is driven from the WRAPPER, not from the card ----

   .video-container lifts 6px and is tilted up to 6.5 degrees by
   js/portfolio.js. An element that moves cannot also be the element
   that decides whether it is hovered: with the cursor near an edge,
   the card translated out from under it, :hover dropped, the card
   fell back under the cursor, and :hover returned — a full state
   change every frame. The border, the poster scale, the caption
   reveal, the tilt and the preview video all thrashed together, at
   frame rate, for as long as the cursor sat on that edge.

   .portfolio-item wraps exactly one child and that child fills it,
   so the hit area is identical at rest — but the wrapper carries only
   the perspective and never transforms, so nothing this rule does can
   move the thing testing for it. Same treatment, no feedback path.

   .portfolio-grid:hover .portfolio-item:not(:hover) below was already
   written against the wrapper for the same reason; this brings the
   rest of the component into line with it.

   :focus-visible stays on the card: focus is not hit-tested, so it
   has no edge to oscillate on, and the card is what takes the focus. */
.portfolio-item:hover .video-container,
.video-container:focus-visible {
    --lift: -6px;
    border-color: var(--edge-bone);
    box-shadow:
        0 28px 64px rgba(0, 0, 0, 0.58),
        inset 0 1px 0 rgba(255, 255, 255, 0.07);
}

.video-container:focus-visible {
    outline: 2px solid var(--bone-300);
    outline-offset: 3px;
}

/* The poster drifts against the tilt, which is what sells the frame
   as a window with something behind it rather than a printed card.
   scale(1.08) is the margin the drift travels into — --shift-* is
   capped at 3.2% in the script, comfortably inside the 4% overhang,
   so no edge is ever exposed. */
.video-thumbnail {
    width: 100%;
    height: 100%;
    object-fit: cover;
    transform:
        translate3d(var(--shift-x, 0%), var(--shift-y, 0%), 0)
        scale(var(--poster-scale, 1));
    transition:
        transform 300ms var(--ease-out),
        opacity var(--dur-mid) var(--ease-out);
}

.portfolio-item:hover .video-container {
    --poster-scale: 1.08;
}

/* Set by attachPosterFallbacks in global.js once every thumbnail size
   has 404'd. Hiding the element leaves the container's own background
   showing, which is a calm empty frame rather than a broken-image
   glyph sitting under the title. */
.video-thumbnail.is-missing,
.drift-tile img.is-missing {
    display: none;
}


/* ---- Sibling dimming ----
   Hovering one card drops every other one back. Done in CSS rather
   than by toggling classes from the pointer handler: :not(:hover)
   already describes exactly the set we want, and the browser
   recomputes it far more cheaply than a script walking 60 nodes. */
@media (hover: hover) {
    .portfolio-item {
        transition:
            opacity var(--dur-mid) var(--ease-out),
            filter var(--dur-mid) var(--ease-out);
    }

    .portfolio-grid:hover .portfolio-item:not(:hover) {
        opacity: 0.42;
        filter: grayscale(0.82);
    }
}

/* ---- Hover preview ----
   The clip sits on top of the poster and cross-fades in. The
   poster stays underneath rather than being removed: if the
   preview stalls or the file is missing, the tile is already
   showing the right image and nothing flickers. */
.preview-video {
    position: absolute;
    inset: 0;
    width: 100%;
    height: 100%;
    object-fit: cover;
    opacity: 0;
    pointer-events: none;
    /* Matches the poster exactly, so the cross-fade between the two
       does not also shift the framing. */
    transform:
        translate3d(var(--shift-x, 0%), var(--shift-y, 0%), 0)
        scale(var(--poster-scale, 1));
    transition:
        opacity var(--dur-mid) var(--ease-out),
        transform 300ms var(--ease-out);
}

.video-container.is-previewing .preview-video {
    opacity: 1;
}

.video-container.is-previewing .video-thumbnail {
    opacity: 0;
}

/* ---- Corner instrumentation ----
   Record number and runtime. Set as bare type on a shadow rather than
   in blurred pill chips: a chip is a piece of UI sitting on top of a
   photograph, and two of them in the corners of every tile made the
   wall read as a dashboard. Type alone reads as a plate marking. */
.tile-record,
.tile-duration {
    position: absolute;
    z-index: 2;
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    letter-spacing: var(--track-label);
    line-height: var(--leading-flat);
    font-variant-numeric: tabular-nums;
    text-shadow:
        0 1px 3px rgba(0, 0, 0, 0.9),
        0 1px 16px rgba(0, 0, 0, 0.7);
    pointer-events: none;
    opacity: 0.72;
    transition: opacity var(--dur-mid) var(--ease-out);
}

.portfolio-item:hover .tile-record,
.portfolio-item:hover .tile-duration {
    opacity: 1;
}

.tile-record {
    inset-block-start: var(--space-4);
    inset-inline-start: var(--space-4);
    color: var(--bone-300);
}

/* Top-right, not bottom-right: the caption owns the lower band now. */
.tile-duration {
    inset-block-start: var(--space-4);
    inset-inline-end: var(--space-4);
    color: var(--text-mid);
}

/* ---- Play control ----
   Sits in the caption row, baseline-aligned with the title. A disc in
   the middle of the frame covers the shot, fights the tilt, and is the
   single most generic thing a video grid can do — every stock template
   has one. Docked to the caption it becomes part of the composition.

   At rest it is a hairline ring at low opacity. On hover it fills with
   bone. Nothing about it obscures the footage at any point. */
.play-button {
    flex: 0 0 auto;
    display: grid;
    place-items: center;
    width: 42px;
    height: 42px;
    margin-block-end: 2px;
    border-radius: 50%;
    background: transparent;
    border: 1px solid rgba(255, 255, 255, 0.38);
    color: var(--text-hi);
    opacity: 0.66;
    transform: scale(0.94);
    pointer-events: none;
    transition:
        transform var(--dur-mid) var(--ease-spring),
        background-color var(--dur-mid) var(--ease-out),
        border-color var(--dur-mid) var(--ease-out),
        color var(--dur-mid) var(--ease-out),
        opacity var(--dur-mid) var(--ease-out);
}

.play-button svg {
    width: 14px;
    height: 14px;
    fill: currentColor;
    /* The path is drawn around its own centroid, so no optical nudge. */
    display: block;
}

/* Only claims the pointer when it has a summary to show. */
.play-button[data-term] {
    pointer-events: auto;
    cursor: pointer;
}

.portfolio-item:hover .play-button,
.video-container:focus-visible .play-button {
    transform: scale(1);
    background: var(--bone-300);
    border-color: var(--bone-300);
    color: var(--text-on-bone);
    opacity: 1;
    box-shadow: 0 8px 24px rgba(228, 216, 196, 0.22);
}

/* ---- Caption, laid over the image ----
   The scrim is a three-stop gradient rather than a flat panel: these
   thumbnails range from a dark oak canopy to a white stucco house in
   full sun, and a fixed tint either disappears on one or muddies the
   other. It also stops well short of the top so the frame the shot was
   composed for stays readable. */
.portfolio-info {
    position: absolute;
    z-index: 2;
    inset-inline: 0;
    inset-block-end: 0;
    display: flex;
    align-items: flex-end;
    justify-content: space-between;
    gap: var(--space-5);
    min-width: 0;
    padding: var(--space-10) var(--space-5) var(--space-5);
    /* Eased rather than linear. A straight ramp has a visible edge
       where it meets the image; weighting the stops toward the bottom
       puts the falloff where the eye does not look for it. The top
       half of every frame stays untouched. */
    background: linear-gradient(
        to top,
        rgba(4, 4, 4, 0.92) 0%,
        rgba(4, 4, 4, 0.74) 18%,
        rgba(4, 4, 4, 0.44) 38%,
        rgba(4, 4, 4, 0.16) 62%,
        rgba(4, 4, 4, 0.04) 82%,
        rgba(4, 4, 4, 0) 100%
    );
    pointer-events: none;
}

/* flex-start, not the default stretch. Stretched children are as wide
   as the card, which turned the term underline into a rule running the
   full width of the tile. Shrink-to-fit puts it under the word. */
.tile-copy {
    display: flex;
    flex-direction: column;
    align-items: flex-start;
    gap: var(--space-1);
    min-width: 0;
}

.tile-copy > * {
    max-width: 100%;
}

/* The card is the click target, so the caption must not swallow the
   pointer — except on the one element that has a tooltip to show. */
.portfolio-info .log-term {
    pointer-events: auto;
}

/* Every title takes the display serif, not just the featured ones.
   Mixing Fraunces on two cards with Montserrat on the rest read as an
   inconsistency rather than a hierarchy — at a glance you see two
   typefaces, not two tiers. Hierarchy comes from size and column span
   instead, which is what the mosaic is already saying. */
.portfolio-info h3 {
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    font-size: var(--fs-xl);
    font-weight: var(--weight-medium);
    letter-spacing: var(--track-tighter);
    line-height: var(--leading-tight);
    color: var(--text-hi);
    /* Cheap insurance against a blown-out sky directly behind a
       descender, which the gradient alone does not always cover. */
    text-shadow: 0 1px 14px rgba(0, 0, 0, 0.6);
}

.portfolio-item.is-featured .portfolio-info h3 {
    font-size: var(--fs-2xl);
}

/* ---- Eyebrow: category · project type ----
   One line, baseline-aligned, wrapping as a unit. The category is the
   bone accent and the project type sits a step quieter, so the pair
   reads as label-then-qualifier rather than as two competing tags. */
.tile-eyebrow {
    display: flex;
    align-items: baseline;
    flex-wrap: wrap;
    gap: 0 var(--space-2);
    margin-block-end: 2px;
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    letter-spacing: var(--track-micro);
    text-transform: uppercase;
    line-height: var(--leading-snug);
    text-shadow: 0 1px 10px rgba(0, 0, 0, 0.7);
}

.portfolio-info .tile-meta {
    color: var(--bone-300);
}

/* A step quieter than the category, so the pair reads as
   label-then-qualifier rather than as two competing tags.

   A solid hairline at low alpha, not a dotted rule. Dotted reads as
   a spell-check squiggle at 11px; a hairline reads as typography and
   still says "there is more here" when it brightens on hover. */
.tile-eyebrow .log-term {
    color: var(--text-lo);
    letter-spacing: var(--track-label);
    border-block-end: 1px solid rgba(255, 255, 255, 0.2);
}

.tile-eyebrow .log-term:hover {
    color: var(--bone-300);
    border-block-end-color: var(--edge-bone-hot);
}

.tile-sep {
    color: var(--text-faint);
}

/* ---- Hover-revealed flight data ----
   Held back at rest so the resting wall is only label and title. The
   grid-rows trick animates from nothing to auto, which a height
   transition cannot do without a hard-coded pixel value that breaks
   the moment a line wraps. */
.tile-subline {
    display: grid;
    grid-template-rows: 0fr;
    opacity: 0;
    transform: translateY(4px);
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    letter-spacing: var(--track-wide);
    text-transform: uppercase;
    color: var(--text-mid);
    text-shadow: 0 1px 10px rgba(0, 0, 0, 0.7);
    transition:
        grid-template-rows var(--dur-mid) var(--ease-out),
        opacity var(--dur-mid) var(--ease-out),
        transform var(--dur-mid) var(--ease-out);
}

/* The row is a grid track, so the text needs a wrapper that can be
   clipped to it. min-height:0 lets the track actually reach zero. */
.tile-subline > span {
    overflow: hidden;
    min-height: 0;
}

.portfolio-item:hover .tile-subline,
.video-container:focus-visible .tile-subline {
    grid-template-rows: 1fr;
    opacity: 1;
    transform: translateY(0);
    margin-block-start: var(--space-1);
}


/* ============================================================
   LIST — the actual log

   Rows on the left, a sticky preview on the right. Sticky rather
   than a floating panel positioned by script: the browser already
   knows how to keep a box in view while its neighbour scrolls,
   and doing it in CSS means it cannot fall out of sync with the
   scroll position on a fast flick.
   ============================================================ */
/* The list takes the width it needs and the preview sits beside it.
   Previously a fixed 1fr + 320px split reserved the panel's column
   whether or not anything was in it — and it is empty until a row is
   hovered, so the default state was a third of the section given over
   to nothing. min-content on the panel column collapses it until it
   has content. */
.flightlog-list-wrap {
    display: grid;
    grid-template-columns: minmax(0, 1fr) min-content;
    gap: var(--space-8);
    align-items: start;
}

.flightlog-list-col {
    min-width: 0;
}

/* Track list is written by columnsInUse() in js/portfolio.js, which
   drops Location, Date and Run when no entry populates them. Hiding
   the cells without dropping the tracks would leave their gaps behind,
   which is why the template is dynamic rather than static. */
.log-head,
.log-row {
    display: grid;
    grid-template-columns: var(--log-cols, 68px minmax(0, 1fr) 150px 86px 58px 24px);
    align-items: center;
    gap: var(--space-5);
}

.log-head {
    padding: 0 var(--space-4) var(--space-3);
    border-block-end: 1px solid var(--edge-soft);
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    letter-spacing: var(--track-micro);
    text-transform: uppercase;
    color: var(--text-faint);
}

.flightlog-list {
    display: flex;
    flex-direction: column;
}

.log-row {
    width: 100%;
    /* Tighter than it was. With no location, date or runtime to carry,
       the old padding left each row mostly empty and the list read as
       a sparse table rather than a log. */
    padding: var(--space-3) var(--space-4);
    border: 0;
    border-block-end: 1px solid var(--edge-faint);
    background: transparent;
    text-align: start;
    cursor: pointer;
    /* The bone edge is drawn as a border that is transparent at
       rest, not added on hover — a border appearing from nothing
       shifts every row below it by a pixel. */
    border-inline-start: 2px solid transparent;
    transition:
        background-color var(--dur-fast) var(--ease-out),
        border-inline-start-color var(--dur-fast) var(--ease-out);
}

.log-row.hidden {
    display: none;
}

.log-row:hover,
.log-row:focus-visible {
    background: var(--fill-01);
    border-inline-start-color: var(--bone-300);
}

.log-row:focus-visible {
    outline: 2px solid var(--bone-300);
    outline-offset: -2px;
}

.log-record {
    font-family: var(--font-mono);
    font-size: var(--fs-2xs);
    letter-spacing: var(--track-wide);
    color: var(--bone-400);
    font-variant-numeric: tabular-nums;
}

.log-identity {
    display: flex;
    flex-direction: column;
    gap: 2px;
    min-width: 0;
}

.log-name {
    font-size: var(--fs-body);
    font-weight: var(--weight-medium);
    letter-spacing: var(--track-tight);
    color: var(--text-hi);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.log-row:hover .log-name {
    color: var(--bone-200);
}

/* Five facts on one line will overrun on some entries. Truncating
   is correct here — the full set is in the preview panel and in
   the modal, and a wrapping subline would break the row grid's
   consistent height, which is most of what makes a log scannable. */
/* Matches the grid eyebrow: a solid hairline, not a dotted rule. At
   11px dotted reads as a spell-check squiggle, and there are up to
   two of them per row here. */
.log-sub .log-term {
    border-block-end: 1px solid rgba(255, 255, 255, 0.18);
}

.log-sub .log-term:hover {
    border-block-end-color: var(--edge-bone-hot);
}

.log-sub {
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    letter-spacing: var(--track-wide);
    text-transform: uppercase;
    color: var(--text-faint);
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

/* A term that carries a definition on hover. The dotted underline is
   the whole affordance — a tooltip nobody knows to hover for is a
   secret. cursor: help confirms it the moment they try.

   inline-block so the underline tracks the text width even when the
   element is a flex child, which would otherwise stretch it. Colour
   is inherited rather than set, so the same element works in the dim
   subline, on a filter pill, and in the modal's meta row. */
.log-term {
    display: inline-block;
    border-block-end: 1px dotted var(--edge-firm);
    cursor: help;
    transition:
        color var(--dur-fast) var(--ease-out),
        border-color var(--dur-fast) var(--ease-out);
}

.log-term:hover {
    color: var(--bone-300);
    border-block-end-color: var(--edge-bone-hot);
}

/* The pill draws its own box; a second underline inside it is noise. */
.filter-btn.log-term,
.filter-btn[data-term] {
    display: inline-flex;
    border-block-end-style: solid;
    cursor: pointer;
}


/* ---- Tooltip ----
   Fixed to the viewport and parented to <body>, so neither the
   caption's overflow clipping nor the card's rounded corners can
   cut it. Positioned by js/portfolio.js. */
.log-tooltip {
    position: fixed;
    z-index: var(--z-modal);
    max-width: 264px;
    padding: var(--space-3) var(--space-4);
    border-radius: var(--radius-sm);
    border: 1px solid var(--edge-firm);
    background: rgba(17, 17, 17, 0.97);
    backdrop-filter: blur(10px);
    -webkit-backdrop-filter: blur(10px);
    box-shadow: var(--shadow-lg);
    color: var(--text-mid);
    /* Every element that can own a tooltip is uppercase mono. The
       tooltip is prose and must not inherit any of that. */
    font-family: var(--font-body);
    font-size: var(--fs-2xs);
    font-weight: var(--weight-normal);
    line-height: var(--leading-snug);
    letter-spacing: var(--track-normal);
    text-transform: none;
    text-shadow: none;
    pointer-events: none;
    opacity: 0;
    transform: translateY(4px);
    transition:
        opacity var(--dur-fast) var(--ease-out),
        transform var(--dur-fast) var(--ease-out);
}

.log-tooltip[hidden] {
    display: none;
}

.log-tooltip.is-visible {
    opacity: 1;
    transform: translateY(0);
}

.log-tooltip::after {
    content: '';
    position: absolute;
    /* Set by script — the box clamps at viewport edges, so the arrow
       cannot simply sit at 50%. */
    left: var(--arrow-x, 50%);
    top: 100%;
    margin-left: -5px;
    border: 5px solid transparent;
    border-top-color: rgba(17, 17, 17, 0.97);
}

.log-tooltip.is-below::after {
    top: auto;
    bottom: 100%;
    border-top-color: transparent;
    border-bottom-color: rgba(17, 17, 17, 0.97);
}

@media (prefers-reduced-motion: reduce) {
    .log-tooltip {
        transition: none;
        transform: none;
    }
}

.log-cell {
    font-family: var(--font-mono);
    font-size: var(--fs-2xs);
    letter-spacing: var(--track-wide);
    color: var(--text-lo);
    font-variant-numeric: tabular-nums;
    overflow: hidden;
    text-overflow: ellipsis;
    white-space: nowrap;
}

.log-dur {
    text-align: end;
}

.log-go {
    display: grid;
    place-items: center;
    font-size: var(--fs-micro);
    color: var(--text-faint);
    opacity: 0;
    transform: translateX(-4px);
    transition:
        opacity var(--dur-fast) var(--ease-out),
        transform var(--dur-fast) var(--ease-out);
}

.log-row:hover .log-go,
.log-row:focus-visible .log-go {
    opacity: 1;
    transform: translateX(0);
    color: var(--bone-300);
}


/* ---- Sticky preview panel ---- */
/* Zero-width until it has something to show, so the list runs full
   width at rest instead of sitting next to an empty box. */
.log-preview {
    position: sticky;
    top: calc(var(--nav-height) + var(--space-5));
    width: 0;
    padding: 0;
    border: 0;
    overflow: hidden;
    opacity: 0;
    transition:
        opacity var(--dur-mid) var(--ease-out),
        width var(--dur-mid) var(--ease-out);
}

.log-preview.is-loaded {
    display: flex;
    flex-direction: column;
    gap: var(--space-4);
    width: 320px;
    padding: var(--space-4);
    border: 1px solid var(--edge-faint);
    border-radius: var(--radius-lg);
    background: var(--fill-01);
    opacity: 1;
    overflow: visible;
}

.log-preview-frame {
    position: relative;
    aspect-ratio: 16 / 9;
    border-radius: var(--radius-md);
    overflow: hidden;
    background: var(--ground-300);
}

.log-preview-frame img {
    width: 100%;
    height: 100%;
    object-fit: cover;
}

.log-preview-meta {
    display: flex;
    flex-direction: column;
    gap: var(--space-1);
}

.log-preview-record {
    font-family: var(--font-mono);
    font-size: var(--fs-micro);
    letter-spacing: var(--track-micro);
    color: var(--bone-400);
}

.log-preview-meta h3 {
    font-family: var(--font-display);
    font-variation-settings: var(--display-axes);
    font-size: var(--fs-lg);
    font-weight: var(--weight-medium);
    letter-spacing: var(--track-tight);
    color: var(--text-hi);
}

.log-preview-meta p {
    font-size: var(--fs-sm);
    color: var(--text-lo);
}

.log-preview-craft {
    font-family: var(--font-mono);
    font-size: var(--fs-micro) !important;
    letter-spacing: var(--track-wide);
    text-transform: uppercase;
    color: var(--text-faint) !important;
}


/* ============================================================
   BREAKPOINTS

   The grid steps 6 -> 4 columns rather than collapsing, so the
   mosaic keeps its mixed rhythm on a tablet instead of turning
   into a plain two-up.
   ============================================================ */
@media (max-width: 1240px) {
    .flightlog-list-wrap {
        grid-template-columns: minmax(0, 1fr) 280px;
        gap: var(--space-6);
    }
}

@media (max-width: 1080px) {
    /* The grid stays twelve columns — planRows() switches to pairs
       only at this width, so a tile never drops below about 40% of the
       container and the caption always has room. */
    .portfolio-grid {
        grid-auto-rows: clamp(180px, 26vw, 300px);
    }

    /* Below this the panel is narrower than the poster it holds is
       tall, and the row it is describing is off the side of the
       screen anyway. */
    .log-preview {
        display: none;
    }

    .flightlog-list-wrap {
        grid-template-columns: minmax(0, 1fr);
    }
}

@media (max-width: 820px) {
    .log-head,
    .log-row {
        grid-template-columns: 60px minmax(0, 1fr) 58px 24px;
    }

    /* Location and date leave the row and stay in the subline,
       which already carries the rest of the record. */
    .log-head > :nth-child(3),
    .log-head > :nth-child(4),
    .log-loc,
    .log-date {
        display: none;
    }
}

@media (max-width: 700px) {
    /* .portfolio-grid becomes a flex rail here — see components.css —
       so there is no grid row to take a height from. The tile goes
       back to setting its own shape. */
    .video-container {
        aspect-ratio: 16 / 9;
        flex: 0 0 auto;
    }

    .log-toolbar {
        gap: var(--space-3);
        margin-block-end: var(--space-6);
    }

    .log-controls {
        width: 100%;
        justify-content: space-between;
    }

    .portfolio-filter {
        flex-wrap: nowrap;
        overflow-x: auto;
        scrollbar-width: none;
        /* Bleed to the screen edges so the row looks scrollable */
        margin-inline: calc(-1 * var(--gutter));
        padding-inline: var(--gutter);
        scroll-snap-type: x proximity;
    }

    .portfolio-filter::-webkit-scrollbar {
        display: none;
    }

    .filter-btn {
        flex: 0 0 auto;
        scroll-snap-align: start;
    }

    .play-button {
        width: 38px;
        height: 38px;
    }

    .play-button svg {
        width: 13px;
        height: 13px;
    }

    /* A rail card is ~80% of a phone, so the caption is a much larger
       share of the frame than it is on a desktop tile. Trimming the
       scrim's reach keeps the shot itself visible. */
    .portfolio-info {
        padding: var(--space-8) var(--space-4) var(--space-4);
    }
}

@media (max-width: 560px) {
    .log-head,
    .log-row {
        grid-template-columns: 52px minmax(0, 1fr) 50px;
        gap: var(--space-3);
    }

    .log-head > :nth-child(5),
    .log-head > :nth-child(6),
    .log-go {
        display: none;
    }

    .log-row {
        padding-inline: var(--space-2);
    }
}

@media (pointer: coarse) {
    .filter-btn,
    .view-toggle button,
    .log-sort select {
        min-height: 44px;
    }

    /* No hover on touch, so the button never reaches its hover state.
       It resolves to the filled bone treatment from the start rather
       than sitting forever in a resting state nobody will see resolve. */
    .play-button {
        transform: scale(1);
        background: var(--bone-300);
        border-color: var(--bone-300);
        color: var(--text-on-bone);
        opacity: 1;
    }

    /* And the flight data cannot be hover-only where there is no
       hover — it is simply always open. */
    .tile-subline {
        grid-template-rows: 1fr;
        opacity: 1;
        transform: none;
        margin-block-start: var(--space-1);
    }

    .log-row {
        min-height: 56px;
    }
}


/* ============================================================
   REDUCED MOTION

   js/portfolio.js already refuses to create preview elements at
   all under this setting. These rules cover the transitions that
   run regardless of whether a preview exists.
   ============================================================ */
@media (prefers-reduced-motion: reduce) {
    .video-container,
    .video-thumbnail,
    .play-button,
    .preview-video,
    .tile-subline,
    .portfolio-item,
    .log-row,
    .log-go,
    .log-preview {
        transition: none;
    }

    /* A reveal nobody sees animate is just hidden information. */
    .tile-subline {
        grid-template-rows: 1fr;
        opacity: 1;
        transform: none;
        margin-block-start: var(--space-1);
    }

    .video-container:hover {
        transform: none;
    }

    .video-container:hover .video-thumbnail {
        transform: none;
    }
}
