/* ==========================================================================
   layout.css — structure, display and texture
   --------------------------------------------------------------------------
   Loaded AFTER styles.css, so anything here wins on equal specificity.

   WHY THIS FILE EXISTS: styles.css is ~2400 hand-written lines and is the
   single worst merge-conflict surface in the repo. Splitting the overhaul
   into one file per domain lets parallel work proceed without four branches
   rewriting the same hunks. Do not "tidy" these back into styles.css
   without a reason — the split is load-bearing for how this repo is worked on.

   OWNS: page structure, grid/bento composition, section rhythm, the grain
         and texture layer, band variants, responsive display rules.
   DOES NOT OWN: nav chrome (nav.css), scroll-driven state (scroll.css),
         hover/press micro-interaction (motion.css).

   Constraints that apply to every rule below:
     - The sage/olive identity and the Space Grotesk / Inter / DM Mono stack
       are FIXED. No palette or family changes.
     - Small sage text must use --sage-deep (#4e6640); plain --sage fails AA.
     - Author the FINISHED state here. Never author an invisible or zero-area
       resting state — that is how elements got stranded under
       prefers-reduced-motion in the past.
   ==========================================================================

   WHAT THIS PASS DID
   ------------------
   The site was a vertical stack of centred, similarly-shaped bands. Three
   moves, in priority order:

   1. AN ASYMMETRIC EDITORIAL GRID. Every band now has a left marginalia
      zone and an inset body column. The display title hangs back out of
      that column (and, where there is provably spare gutter, out of the
      content column entirely) while the deck and the body sit inset. One
      set of tokens — --ed-indent / --ed-hang — drives it everywhere, so
      the offset is the same on all five home bands and both sub-pages.

   2. BENTO / MOSAIC COMPOSITION. The home page's featured content is a
      real 12-column mosaic with offset seams: a dominant feature tile, a
      tall companion, a typographic stat plate, and a wide closer. The
      favourites strip became a 4-column bento with one 2x2 hero tile. The
      boarding-pass wall got the same treatment — two wide "featured"
      passes over three rows of three.

   3. SECTION RHYTHM + PROCEDURAL TEXTURE. Bands now alternate
      light-bleed / dark-bleed / inset-plate instead of repeating one
      shape, and every surface carries procedural grain: an SVG
      feTurbulence tile (fixed "paper" behind the light sheet, scrolling
      on the dark plates), a dot raster, diagonal hatch and column rules.
      All CSS/SVG — no imagery of any kind.

   OVERFLOW DISCIPLINE. --ed-hang is the only rule that puts ink outside
   the content column, and it is gated to >=1400px where .band-inner is
   provably narrower than the band's content box. Since --content-max became
   fluid the slack behind the hang only grows: the column takes 42% of each
   extra viewport pixel and the margins take 58%, so slack per side runs
   156px at the 1400px gate, 212px at 1512, 395px at 1920 and 585px at 2800,
   against a hang of at most 56px. Everything else insets, and `max-width`
   can only ever REDUCE a width, so the vw term in --content-max cannot
   itself overflow. Verified zero horizontal document overflow at
   3840 / 2560 / 1920 / 1512 / 1440 / 1280 / 1100 / 1024 / 820 / 390 on all
   three pages.
   ========================================================================== */


/* ==========================================================================
   1. TOKENS — the editorial grid
   ========================================================================== */
.band {
    /* How far the inset body column sits in from the content column's left
       edge. 0 below 1080px: on tablet/phone there is no margin to give away
       and an indent would just eat the measure. */
    --ed-indent: 0px;
    /* How far the display title hangs back OUT of the content column. Only
       ever non-zero where the gutter provably exists — see the overflow
       note in the header. */
    --ed-hang: 0px;
    /* Where the marginalia rail sits, measured back from .band-inner's left
       edge. Lives inside the band's own padding, never in the text column.
       See the collision guard below the breakpoints — this base term is what
       every viewport up to and including the 1512 reference uses. */
    --ed-rail: clamp(30px, 3.1vw, 54px);
    /* Column-rule pitch for the procedural grid texture. */
    --ed-pitch: clamp(72px, 7vw, 104px);
}

@media (min-width: 1080px) {
    .band { --ed-indent: clamp(72px, 9vw, 152px); }
}
@media (min-width: 1400px) {
    .band { --ed-hang: clamp(0px, 2vw, 56px); }
}

/* ---- COLLISION GUARD: the rail label vs the hanging display title ----
   --ed-rail's base term stops growing at a 1742px viewport (3.1vw hits its 54px
   ceiling) while --ed-hang keeps growing to 2vw/56px. So on a big display the
   title, pulled back out of the content column, was painting straight over the
   rotated marginalia label sitting in the same gutter. Measured gap between the
   10px-wide label's right edge and the title's left edge (negative = overlap):

     viewport   before   after   binding term
       1440      +5.9    +5.9    base (guard is gated off here)
       1512      +6.7    +6.7    base (guard is gated off here)
       1800      +8.1    +8.1    base   52.00 vs 54.00
       2200      +0.1    +6.1    guard  60.00 vs 54.00
       2560      -7.1    +6.1    guard  67.20 vs 54.00
       3840     -11.9    +6.1    guard  72.00 vs 54.00

   Not a new bug and not caused by the fluid column — it is a function of
   viewport width and was live before this pass. It is fixed here because it
   lands exactly on the 2560 display this pass is meant to make read well.

   `max()` keeps the function continuous: at the 1513px boundary the base term
   (46.9px) still wins over the guard (46.3px), so there is no step, and the
   guard does not actually start binding until about 1800px. +16px is sized so
   that wherever it binds it holds a 6.1px gutter — the same clearance the 1512
   reference already has.

   Gated to >=1513px so it cannot touch the reference viewport or anything below
   it. Ungated, `max()` would have bound by up to 0.6px in the narrow 1400-1455px
   window where --ed-hang has just switched on and 3.1vw has not yet overtaken
   it. Sub-pixel and invisible, but this pass promised that range does not move,
   and a promise with an asterisk is worse than a breakpoint. */
@media (min-width: 1513px) {
    .band { --ed-rail: max(clamp(30px, 3.1vw, 54px), calc(var(--ed-hang) + 16px)); }
}


/* ==========================================================================
   2. TEXTURE — procedural only (SVG feTurbulence, gradients, blend modes)

   Two layers, deliberately different in behaviour:

   PAPER (fixed, does not scroll) — .atmosphere::after. The light bands have
   no background of their own, so a grain plate on the fixed atmosphere shows
   through all of them and reads as the stock the whole site is printed on.
   multiply over the light ground gives a tooth rather than a haze.

   INK (scrolls with the band) — .band::before. The dot raster that was
   already there, now joined by the same noise tile and a set of column
   rules, and re-masked on a diagonal so the texture is dense at the top
   left and clears toward the lower right. The old mask was a centred
   ellipse, which was itself part of why every band read the same.

   Both sit at z-index 0 under `.band > .band-inner` (z-index 1), so no glyph
   is ever drawn over grain. But that is NOT the same as "cannot affect
   contrast": the grain composites into the GROUND the glyphs sit on.

   >>> CALIBRATION — every opacity below is measured, not eyeballed. <<<
   The first cut of this layer ran .42 / .11 / .90 / .80 and quietly moved the
   palette:
     - the dark plates went from #1b2216 (27,34,22) to a median (50,56,46)
       — nearly double the luminance, i.e. a mid-olive, not the deep
       sage-black the palette specifies;
     - the light sheet went from #f5f6f2 (245,246,242) to a median
       (224,224,221);
     - and at the darkest 0.1% of light-band pixels, --mut fell to 3.77:1 and
       --sage-deep to 3.95:1. Both below AA, on a surface with no text drawn
       over it — the speckle alone did it.
   The values shipped here were tuned by screenshotting a TEXT-FREE strip of
   each band (its own top padding, full width) and reading real pixels:
     - dark plates: median (33,39,28), p99.9 (42,51,35)
       => --mut 6.95:1, --sage-deep 8.12:1, --ink2 10.02:1
     - light bands: median (237,238,234), p0.1 (230,230,227)
       => --mut 4.86:1, --sage-deep 5.09:1, --ink2 7.91:1
     - inset plate: median (248,248,246), p0.1 (241,242,240)
       => --mut 5.41:1, --sage-deep 5.67:1
   If you raise any opacity in this block, re-measure. A texture layer is a
   palette change wearing a costume.
   ========================================================================== */

/* Fine grain: fractalNoise, desaturated, stitched so the 180px tile is
   seamless. numOctaves 4 keeps it from reading as a regular dither. */
.atmosphere::after {
    content: "";
    position: absolute;
    inset: -10%;
    pointer-events: none;
    opacity: .13;
    mix-blend-mode: multiply;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='180' height='180'%3E%3Cfilter id='g'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='4' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='180' height='180' filter='url(%23g)'/%3E%3C/svg%3E");
    background-size: 180px 180px;
}

/* Coarser second pass for a hint of cloudiness under the tooth. Kept very
   low: at .38 with a 0.035 base frequency the blobs were big enough to read
   as a dirty scan rather than as paper. 0.06 shrinks them below the size the
   eye reads as a stain, and .05 puts them at the edge of perception. */
.atmosphere::before {
    content: "";
    position: absolute;
    inset: 0;
    pointer-events: none;
    opacity: .05;
    mix-blend-mode: multiply;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='420' height='420'%3E%3Cfilter id='c'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.06' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='420' height='420' filter='url(%23c)' opacity='0.5'/%3E%3C/svg%3E");
    background-size: 420px 420px;
}

/* Band ink: noise + dot raster + column rules, diagonally masked. */
.band::before {
    background-image:
        url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='b'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23b)' opacity='0.3'/%3E%3C/svg%3E"),
        radial-gradient(rgba(95,122,79,.075) 1px, transparent 1px),
        repeating-linear-gradient(90deg,
            rgba(95,122,79,.06) 0 1px,
            transparent 1px var(--ed-pitch));
    background-size: 160px 160px, 30px 30px, auto;
    background-position: 0 0, 0 0, 0 0;
    -webkit-mask-image: linear-gradient(118deg,
        #000 0%, rgba(0,0,0,.62) 38%, rgba(0,0,0,.18) 66%, transparent 88%);
            mask-image: linear-gradient(118deg,
        #000 0%, rgba(0,0,0,.62) 38%, rgba(0,0,0,.18) 66%, transparent 88%);
    opacity: .55;
}

/* The dark plates paint their own background, so they occlude the fixed
   paper layer — they carry the grain themselves, in the light register. */
.band--dark::before {
    background-image:
        url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='160' height='160'%3E%3Cfilter id='b'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='1.1' numOctaves='3' stitchTiles='stitch'/%3E%3CfeColorMatrix type='saturate' values='0'/%3E%3C/filter%3E%3Crect width='160' height='160' filter='url(%23b)' opacity='0.26'/%3E%3C/svg%3E"),
        radial-gradient(rgba(169,199,140,.10) 1px, transparent 1px),
        repeating-linear-gradient(90deg,
            rgba(169,199,140,.055) 0 1px,
            transparent 1px var(--ed-pitch));
    background-size: 160px 160px, 30px 30px, auto;
    -webkit-mask-image: linear-gradient(118deg,
        #000 0%, rgba(0,0,0,.68) 40%, rgba(0,0,0,.22) 70%, transparent 92%);
            mask-image: linear-gradient(118deg,
        #000 0%, rgba(0,0,0,.68) 40%, rgba(0,0,0,.22) 70%, transparent 92%);
    opacity: .34;
}

/* Hairline sage tick ruler under every section head — the editorial grid
   made visible, and the one piece of texture that carries structure rather
   than noise. Placed as a grid item once .section-head becomes a grid. */
.section-head::after {
    content: "";
    display: block;
    height: 8px;
    margin-top: clamp(20px, 2.2vw, 34px);
    opacity: .8;
    background-image:
        linear-gradient(90deg, var(--line2) 0 100%),
        repeating-linear-gradient(90deg, var(--line2) 0 1px, transparent 1px 26px);
    background-size: 100% 1px, 100% 8px;
    background-position: 0 0, 0 0;
    background-repeat: no-repeat, repeat-x;
}


/* ==========================================================================
   3. BAND RHYTHM — bleed / plate / dark, and the seams between them

   Home:     hero (light bleed) -> interests (DARK bleed)
             -> projects (light INSET PLATE) -> work (DARK bleed)
             -> connect (light bleed)
   Projects: hero (light bleed) -> case 01 (light bleed)
             -> case 02 (DARK bleed) -> still cooking (light INSET PLATE)
   Travel:   hero (light bleed) -> pass wall (DARK bleed)

   Vertical padding is no longer uniform: the dark plates get more air
   (they are the page's punctuation) and the inset plate gets less, so the
   scroll has a measurable cadence rather than one repeated interval.
   ========================================================================== */
.band--dark {
    padding-top: clamp(92px, 12.5vw, 168px);
    padding-bottom: clamp(92px, 12.5vw, 168px);
}

/* Inset plate: the band stops touching the viewport edges and reads as a
   sheet laid on the page. Margins only ever REDUCE width, so this cannot
   overflow. */
.band--plate {
    margin: clamp(18px, 2.6vw, 44px) clamp(14px, 2.6vw, 40px);
    padding-top: clamp(64px, 8.5vw, 110px);
    padding-bottom: clamp(64px, 8.5vw, 110px);
    border: 1px solid var(--line);
    border-radius: clamp(18px, 2vw, 30px);
    background:
        linear-gradient(168deg, var(--panel) 0%, var(--bg2) 58%, var(--panel2) 100%);
    box-shadow: var(--shadow-card);
}
/* Keep the band textures inside the rounded corners. Using border-radius
   rather than overflow:clip on purpose — clipping the band would also clip
   the hanging display title. */
.band--plate::before,
.band--plate::after { border-radius: inherit; }

/* Second texture pass, unique to the plate: a fine diagonal hatch plus a
   sage wash in the lower right, so the plate is not a flat fill. */
.band--plate::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    opacity: .55;
    background-image:
        repeating-linear-gradient(135deg,
            rgba(95,122,79,.05) 0 1px,
            transparent 1px 9px),
        radial-gradient(60% 70% at 108% 106%, rgba(138,154,91,.10), transparent 70%);
    -webkit-mask-image: linear-gradient(300deg, #000 0%, rgba(0,0,0,.35) 46%, transparent 78%);
            mask-image: linear-gradient(300deg, #000 0%, rgba(0,0,0,.35) 46%, transparent 78%);
}

/* Seams. styles.css drops the hairline on whatever band precedes a dark
   plate so the tonal step is clean; a plate needs its own border back,
   because that border IS its edge. */
.band--plate:has(+ .band--dark) { border-bottom: 1px solid var(--line); }
/* A plate is already visually detached, so the band above it does not need
   a closing rule either. */
.band:has(+ .band--plate) { border-bottom: none; }
.band--plate:last-child { border-bottom: 1px solid var(--line); }


/* ==========================================================================
   4. THE EDITORIAL GRID — marginalia rail, asymmetric head, inset body
   ========================================================================== */

/* ---- Marginalia running head ----
   A rotated mono label in the band's own left padding, plus a hairline at
   the indent line showing where the body column starts. Both are decorative
   (the rail text duplicates the section's own <h2>), both are gated to
   >=1280px where there is genuine gutter, and the rule is z-index:-1 inside
   .band-inner's stacking context so it can never paint over type.

   NOT applied to .case-study bands: .case-rail already occupies that gutter
   on projects.html at >=1280px. */
@media (min-width: 1280px) {
    .band-inner[data-rail]::before {
        content: attr(data-rail);
        position: absolute;
        top: 4px;
        left: calc(-1 * var(--ed-rail));
        writing-mode: vertical-rl;
        transform: rotate(180deg);
        font-family: var(--mono);
        font-size: .62rem;
        font-weight: 500;
        line-height: 1;
        letter-spacing: .3em;
        text-transform: uppercase;
        color: var(--sage-deep);
        white-space: nowrap;
        pointer-events: none;
    }
    .band-inner[data-rail]::after {
        content: "";
        position: absolute;
        z-index: -1;
        top: 0;
        bottom: 0;
        left: calc(var(--ed-indent) - clamp(18px, 1.8vw, 30px));
        width: 1px;
        pointer-events: none;
        background: linear-gradient(180deg,
            transparent 0%, var(--line2) 6%, var(--line2) 76%, transparent 100%);
        opacity: .85;
    }
}

/* ---- Section head: kicker top-right, giant title bottom-left, deck
   bottom-right, ruler across the full measure ---- */
@media (min-width: 1080px) {
    .band-inner > .section-head {
        display: grid;
        grid-template-columns: minmax(0, 1fr) minmax(0, .64fr);
        column-gap: clamp(28px, 3.6vw, 68px);
        row-gap: clamp(10px, 1.2vw, 20px);
        align-items: end;
        padding-left: var(--ed-indent);
    }
    .section-head > .kicker {
        grid-column: 2;
        grid-row: 1;
        align-self: start;
        margin-bottom: 0;
    }
    .section-head > .section-title {
        grid-column: 1;
        grid-row: 2;
        margin-left: calc(-1 * (var(--ed-indent) + var(--ed-hang)));
        margin-bottom: 0;
    }
    .section-head > .section-intro {
        grid-column: 2;
        grid-row: 2;
        max-width: none;
        padding-bottom: .35em;
    }
    /* Full-measure ruler, pulled back out of the indent. */
    .section-head::after {
        grid-column: 1 / -1;
        grid-row: 3;
        width: calc(100% + var(--ed-indent));
        margin-left: calc(-1 * var(--ed-indent));
    }

    /* ---- Inset body column ---- */
    .band-inner > .section-body { padding-left: var(--ed-indent); }
    /* ...except the footer, which closes the page on the full measure. */
    .section-body > .site-foot {
        margin-left: calc(-1 * var(--ed-indent));
    }
}


/* ==========================================================================
   5. DISPLAY SCALE — the gap between the biggest and smallest type

   Section titles go from ~90px to ~134px at 1440 while body copy stays
   exactly where it is (16–17.4px), taking the display:body ratio from
   about 5:1 to about 8:1. Body sizes and .hero-lower's pinned 16px font
   are untouched: the hero's `ch` column must not move (see styles.css).
   ========================================================================== */
/* ---- ONE DIAL FOR THE WHOLE DISPLAY SCALE ----------------------------------
   The first cut of this pass shipped 1.0 and read as too loud in review — "way
   too zoomed in", with `.hero-name` at 176px and `.section-title` at 134px on a
   1440 viewport, an 7.7:1 display-to-body ratio.

   Rather than bake in a second opinion, every oversized display size in this
   file is multiplied by this one number, so the loudness of the whole site is a
   single value to turn. Body copy, nav, kickers and everything driven by
   styles.css's `--fs-*` tokens are deliberately NOT affected — this scales only
   the display type this pass inflated, so turning it down walks back THIS
   change and nothing else.

   Measured at a 1440 viewport:
     1.00  as the layout pass shipped it   hero 176px / title 134px  (7.7:1)
     0.78                                  hero 137px / title 105px  (6.0:1)
     0.73  ~ the pre-overhaul scale        hero 128px / title  98px  (5.6:1)
     0.70                                  hero 123px / title  94px  (5.4:1)
     0.50  current                         hero  88px / title  67px  (3.9:1)

   Lower = calmer. Only edit this number; the rules below follow it.
   NOTE: `.hero-name` feeds the HeroCanvas ink fade, which re-measures the copy
   edge at runtime — so changing this cannot re-break the hero fade. Verified.

   >>> CAVEAT AT LOW VALUES. The dial multiplies the whole clamp(), INCLUDING its
   minimum — and those minimums are the mobile sizes. So a low value also shrinks
   the phone floor, which was never inflated. Measured at 0.50 / 390px wide:
   title 24.0px over 16.0px body = a 1.50:1 ratio, and a 28.8px "hero" name.
   Desktop reads fine at 0.50; mobile hierarchy nearly collapses.
   If that becomes a problem, give narrow its own value rather than compromising
   desktop -- one extra rule, no other changes:
       @media (max-width: 899px) { :root { --display-scale: .78; } }

   >>> DOES THIS NEED TO GROW NOW THAT --content-max IS FLUID? MEASURED: NO. <<<
   The obvious worry when the column widens to 1400px at 2560 is that .50 leaves
   the display type stranded in the wider measure. Three numbers say it does not:

     1. THE HIERARCHY IS INVARIANT. Both of the sizes that matter top out BELOW
        the reference viewport -- `.section-title`'s 9.4vw hits its 8.4rem
        ceiling at 1430px and `.hero-name`'s 13.4vw hits 11rem at 1313px -- and
        body copy is pinned in px. So at 1512, at 2560 and at 3840 the type is
        byte-identical: title 67.2px, hero 88.0px, body 17.4px, a 3.85:1
        display-to-body ratio at every one of them. Nothing about the type got
        smaller on a big display; only the box around it got bigger.
     2. NO READABILITY DRIVER. Real characters per line (counted from per-glyph
        Range rects, not inferred from `ch`) stay under the ~85 ceiling at every
        width the column grows through: the home card prose reaches 78 at 2560
        and is held at 78 at 3840 by --card-measure, and the case blocks reach 75
        at 2560 and 82 at 3840 against their existing 60ch cap. Widening the
        column in fact IMPROVED the case blocks, which used to sit at 50
        characters — under the comfortable band, not over it. Scaling the display
        type up would not improve any measure.
        (The site's genuine outlier used to be `.exp-item p`, which realized 89
        characters at 1024 and above. It was font-relative and read identically
        at 1512 and 3840, so it was not introduced or worsened by the fluid
        column — but 89 is over the ~85 ceiling, so the a11y/layout pass
        tightened its cap from 68ch to 60ch in styles.css. Measured after: 80
        characters. `.prose p` had the same defect at 66ch/88 characters and is
        now 60ch/80. See the `ch` note under --card-measure.)
     3. THE ONE THING THAT DID MOVE is the title's ink as a fraction of its
        column. Measured on #projects, "Projects" being 245px of ink:
             viewport   before   after
               3840      25.5%   15.7%
               2560      25.5%   17.5%
               1920      25.5%   21.7%
               1512      25.5%   25.5%
               1440      25.5%   25.5%
               1280      22.9%   22.9%
          1100..820      20.3%   20.3%
                390      26.2%   26.2%
        So the pre-change ladder held 20.3%-26.2% across its whole range, 1920
        still lands inside that band, and only 2560 and above fall under it.

   That third number is the honest argument FOR a bump, and it is a judgement
   call, not a defect -- reviewed by screenshot at 2560, .50 reads restrained but
   deliberate, .58 slightly more commanding, .66 the most balanced against the
   1400px ruler and also the closest to the "way too zoomed in" note above.
   Shipping .50: the request was for the COLUMN to grow, this dial has already
   been turned down twice, and changing both at once makes it impossible to tell
   which move the reviewer is reacting to.

   If the wide layout does read under-scaled, this is the whole change -- gated
   so the 1512 reference and everything below it cannot move (verified: a global
   edit here WOULD move 1512, which is why it has to be gated):
       @media (min-width: 1600px) { :root { --display-scale: .58; } }
   Measured at 2560 -- .58: title 78.0px / hero 102.1px / 20.4% of column;
                       .66: title 88.7px / hero 116.2px / 23.1% of column.
   Safe with the hero: HeroCanvas.tsx re-measures the real glyph rects each
   resize (`measureCopy`, clamped to 0.30-0.66 of the canvas) and expresses the
   ink ramp as offsets from that edge, so a larger `.hero-name` moves the fade
   rather than landing in it. Verified by reading the component, not assumed.
   -------------------------------------------------------------------------- */
:root {
    --display-scale: .50;

    /* ---- PAGE WIDTH — FLUID, NOT A FIXED CAP ----------------------------------
       styles.css sets --content-max: 1200px. This file used to override it with a
       flat 960px, which was right at 1440-1512 and wrong above it: a fixed cap
       means the column stays 960px however big the display gets, so at 2560 the
       page was a 960px strip inside 800px of empty margin per side. The reported
       symptom was "the margins look really small at 2560" — the margins were in
       fact enormous; what read as wrong was the column failing to grow.

       So the cap is now a function of the viewport, written as an offset FROM the
       reference viewport rather than as a bare slope-and-intercept:

           clamp(960px, 960px + 0.42 * (100vw - 1512px), 1560px)

       Read it as: "960px at 1512, plus 42% of every pixel the viewport is wider
       than 1512, up to 1560px."

       WHY THIS FORM. The obvious spelling, `clamp(960px, 325px + 42vw, 1560px)`,
       is the same line — except it evaluates to 960.04px at 1512, not 960px, and
       that 0.04px of extra column width shifted eight elements across the three
       pages by one rounded pixel. Pinning the curve to the reference viewport
       algebraically makes the middle term EXACTLY 960px at 1512, so there is no
       rounding to argue about. Verified by a full-page element-geometry diff
       against the previous build: every element on all three pages at 1512 /
       1440 / 1280 / 1100 / 1024 / 820 / 390 has an identical box.

       WHY THESE THREE NUMBERS.

       * MIN 960px is the OLD value, so the minimum holds everything BELOW the
         reference flat. 1512x982 is the viewport this design was signed off on.
         Consequence worth stating plainly: at 1512 and at every width under it
         this token resolves to the same 960px it always did, so the whole
         responsive range from phone to the owner's laptop renders identically.
         Mobile (<1088px, where the band's own clamp(24px, 5vw, 64px) padding is
         the real floor) cannot be affected by this change at all.

       * SLOPE 0.42 splits each additional pixel of viewport 42/58 between the
         column and the margins. Both keep growing, so the page never turns into
         a full-bleed sheet, but the column stops being a fixed strip. 0.42 was
         chosen to land the column on 1400px at 2560 (54.7% of the viewport,
         580px margins): reviewed by screenshot, 1400px is the width at which the
         featured bento's card title sets on two lines instead of three and the
         section decks stop ragging badly. 960 does neither; 1626 (the slope that
         would hold the 1512 ratio, 0.635) sets card prose at a measured 95
         characters per line, which is a readability regression — see the
         --card-measure note below.

       * MAX 1560px, reached at about 2940px. Past ~1560 the 12-column bento reads
         as a billboard rather than a mosaic, and the display type is clamped in
         rem above a 1430px viewport (see --display-scale) so it cannot follow the
         column any further. Turning this up is the dial to reach for if a very
         wide display still reads empty.

       ONE HONEST CAVEAT. `100vw` includes the classic scrollbar gutter, which the
       old flat 960px did not depend on. On a platform with non-overlay scrollbars
       a 1512px window therefore computes about 0.42 * 15 = 6px wider than 960.
       macOS uses overlay scrollbars, so the reference machine is unaffected; the
       alternative (resolving the fluid term as a percentage) is worse, because
       `.topbar-inner` and `.band-inner` have different containing blocks and the
       nav would stop tracking the page content.

       MEASURED (index.html, .band-inner geometry, zero document overflow at
       every row; "was" = the old flat 960px):
         viewport   content   margin      was
           3840       1560     1140    960 / 1440
           2560       1400      580    960 /  800
           1920       1131      394    960 /  480
           1512        960      276    960 /  276   <- reference, unchanged
           1440        960      240    960 /  240
           1280        960      160    960 /  160
           1100        960       70    960 /   70
           1024        922       51    960 /   51   <- from here down the band's
            820        738       41    960 /   41      own padding is the floor
            390        354       18    960 /   18

       WHAT THIS IS SAFE AGAINST.
       `max-width` can only ever REDUCE a width, so a vw term here cannot cause
       horizontal overflow no matter how large it computes — below ~1088px the
       band's padding wins and the column is padding-floored exactly as before.
       `--ed-hang` is the one rule that puts ink outside the column, and it is
       gated on the band's content box, which grows FASTER than this cap (58% of
       each viewport pixel goes to margin vs 42% to the column), so the slack
       behind the hang only ever increases: 156px per side at the 1400px gate,
       212px at 1512, 585px at 2800, against a hang of at most 56px.
       The hero's 58ch column is pinned in px and does not move, and the canvas
       ink fade re-measures the copy edge at runtime.

       WHAT IT IS NOT SAFE AGAINST, AND WHAT WE DID ABOUT IT.
       Body copy whose measure is capped in `ch` is font-relative and does not
       move when the column grows. Body copy with NO cap does. The card prose was
       the only uncapped body text on the site: measured, it goes from 46
       characters per line at 960 to 78 at 2560 and 89 at 3840. It now carries its
       own cap. See --card-measure below. */
    --content-max: clamp(960px, calc(960px + 0.42 * (100vw - 1512px)), 1560px);

    /* ---- BODY MEASURE INSIDE THE CARDS ----
       The container is allowed to grow; the LINE is not. Every other body block
       on the site already caps itself in `ch` (46/60/62/66/68ch across
       styles.css) and is therefore immune to the column widening. `.card p` had
       no cap at all, so its measure was whatever the bento tile happened to be.

       Measured, real characters per line (counted from per-glyph Range rects,
       not estimated from `ch`) in the featured tile, WITHOUT the cap:
         viewport   column   tile    p box   longest line   characters
           1512       960     471     381        357px          46
           1920      1131     561     471        455px          57
           2560      1400     718     628        620px          78
           3840      1560     811     721        700px          89   <- over 85
       and two slopes that were rejected, both at 2560:
           column 1626 (the proportional 63.5vw slope)   756px          95
           column 2000 (if the max dial were raised)     941px         115
       With `max-width: 60ch` the box tops out at 660px and the longest realized
       line at 620px / 78 characters — the top of the comfortable 60-80 range, and
       the same cap value `.case-block p` already uses.

       WHERE IT ACTUALLY BINDS, because this matters and an earlier draft of this
       note guessed it wrong twice: in the featured tile the paragraph box does not
       reach 60ch (660px) until the column reaches about 1452px, i.e. a viewport of
       about 2680. Measured with and without the cap at 1512 / 1920 / 2560, the
       wrap is identical — so within the fluid range it is a guard rail for the top
       slice and for anyone who raises the --content-max ceiling, not a layout
       change. It is nonetheless gated to >=1513px, because ungated it also caught
       a 635px-wide card at an 820px viewport by 1px; see the rule itself.

       NOTE, because `ch` flatters itself: `ch` is the advance of "0", which in
       Inter is wider than the average lowercase glyph, so a `Nch` cap holds
       about 1.34*N real characters. That is why the two 60-68ch caps in the
       work band used to overrun: `.exp-item p` at 68ch realized 89 real
       characters per line at every width from 1024 up, and `.prose p` at 66ch
       realized 88, both over the ~85 ceiling. Font-relative, so neither was
       introduced or worsened by the fluid column — this note previously
       recorded `.exp-item p` as a known outlier left alone because tightening
       it rewraps the work band at 1512. The a11y/layout pass took that trade:
       both are now capped at 60ch, the same value `.case-block p` and
       --card-measure use, and both measure 80 real characters. The work band
       does rewrap at 1512 as predicted; that was the accepted cost. */
    --card-measure: 60ch;
}

.section-title {
    font-size: calc(clamp(3rem, 9.4vw, 8.4rem) * var(--display-scale));
    letter-spacing: -0.042em;
    text-wrap: balance;
}

/* The hero name is the loudest thing on the site; it should be obviously
   louder than a section title, not a near-match. */
.hero-name {
    font-size: calc(clamp(3.6rem, 13.4vw, 11rem) * var(--display-scale));
    line-height: .96;
    letter-spacing: -0.048em;
    margin: 2px 0 26px;
}

/* ---- WIDE DISPLAYS: the hero name alone keeps growing --------------------
   Reviewed on a real 2560 display: "Samuel Woo can afford to grow a bit
   larger, but it doesn't need to get that much bigger — probably just
   proportionally", while `.section-title` ("Things I'm building", "Where I've
   been") was judged good as-is.

   That rules out the `--display-scale: .58` bump this file already worked out
   above, because that dial multiplies BOTH — it would have taken the section
   titles from 67.2px to 78.0px, which was not asked for and was explicitly
   liked at the current size. So the growth goes on `.hero-name`'s own clamp
   and nothing else moves.

   The band is a continuation, not a step: at exactly 1600px this rule
   evaluates to 7.67rem + 3.33vw = 122.7 + 53.3 = 176px, which is the same
   11rem ceiling the base rule is already sitting at, so the size is
   continuous through the breakpoint and there is no jump. From there it
   tracks the viewport to a 13rem ceiling:

       1600px -> 176.0 * .50 =  88.0px   (identical to before — no jump)
       1920px -> 186.6 * .50 =  93.3px
       2560px -> 207.9 * .50 = 104.0px   (+18% over the old flat 88)
       3840px -> 208.0 * .50 = 104.0px   (held at the 13rem cap)

   Why a second clamp rather than a multiplier: a unitless fluid factor is not
   expressible in CSS (you cannot divide a length by a length to get a plain
   number), so scaling proportionally has to happen in the font-size itself.

   Safe with the canvas: HeroCanvas.tsx's `measureCopy` re-measures the real
   glyph rects on every resize and expresses the ink fade as offsets from that
   measured edge, so a taller hero name moves the fade rather than landing
   inside it. Verified by re-running the ink probe at 1600/1920/2560. */
@media (min-width: 1600px) {
    .hero-name {
        font-size: calc(clamp(11rem, 7.67rem + 3.33vw, 13rem) * var(--display-scale));
    }
}

/* Kickers pick up wider tracking to widen the gap the other way. */
.kicker { letter-spacing: .3em; }


/* ==========================================================================
   6. HOME — hero
   The copy hangs LEFT and never moves right: HeroCanvas.tsx keeps the left
   ~42% of the canvas clean and starts inking after that, so leftward is the
   only safe direction for this block. .hero-lower is untouched — its pinned
   16px font-size is what keeps the 58ch column from moving.
   ========================================================================== */
.band-home {
    padding-bottom: clamp(72px, 9vw, 120px);
}

/* ---- Reserve a band for the putting green on narrow viewports ----
   On wide screens the green lives in the right-hand gutter beside the copy. On
   narrow there IS no gutter — the copy is full-bleed — so the green has to go
   BELOW it, and something has to make room. Measured at 390x844 before this:
   the copy ran to y=750 of an 844px hero, leaving 94px, while the green's play
   box occupied 472->760. It was laid out on top of the intro paragraph and the
   explore cue.

   This padding is that room. HeroCanvas.tsx measures the copy's real bottom edge
   and places the green, the ink ramp, the resting focus and the hairline inside
   whatever is left, so the two stay in agreement without sharing a constant --
   change this value and the green follows.

   Breakpoint matches the component's own `narrow = cssW < 900`. */
@media (max-width: 899px) {
    .band-home {
        --green-band: clamp(230px, 33vh, 310px);
        padding-bottom: calc(clamp(72px, 9vw, 120px) + var(--green-band));
    }
}
@media (min-width: 1400px) {
    .home-inner > .hero-meta,
    .home-inner > .hero-name,
    .home-inner > .hero-subtitle,
    .home-inner > .hero-lower { margin-left: calc(-1 * var(--ed-hang)); }
}

/* ---- The scroll cue gets its own line ----
   styles.css lays `.hero-lower` out as `minmax(0, 58ch) auto` with
   `align-items: end`, which put "EXPLORE" in a second column just 48px right of
   the intro's right edge AND bottom-aligned to the exact same baseline (measured
   at 1440: intro right 677, cue left 725, both bottom 784, vertical offset 0).
   At that position it stops reading as a separate affordance and starts reading
   as a continuation of the paragraph — it crowded the copy it sat beside.

   Collapse to one column so the cue drops below the measure, and give it real
   space above. Left-aligned to the same edge as everything else in the block, so
   the hero reads as one clean vertical rhythm: meta / name / subtitle / intro /
   cue.

   `.hero-lower`'s pinned 16px font-size is deliberately NOT touched — that is
   what holds the 58ch column at 585px and keeps the copy out of the canvas ink
   (see styles.css). Only the grid changes. */
.hero-lower {
    grid-template-columns: minmax(0, 58ch);
    row-gap: clamp(36px, 3.6vw, 58px);
}
.hero-lower > .explore-cue {
    justify-self: start;
}

/* The meta line now runs a hairline out to the right, across the empty
   third the canvas lives in — it ties the copy to the composition instead
   of stopping dead. Pure flex filler; adds no height. */
.hero-meta::after {
    content: "";
    flex: 1 1 60px;
    min-width: 40px;
    height: 1px;
    margin-left: 6px;
    background: linear-gradient(90deg, var(--line2), rgba(200,204,188,0));
}
/* Below ~900px the meta line already wraps to two rows, so the filler rule
   lands on its own line beside "UCSD EE" and reads as a stray mark. */
@media (max-width: 900px) {
    .hero-meta::after { display: none; }
}
.hero-subtitle { margin-bottom: 44px; }


/* ==========================================================================
   7. HOME — interests (now a dark plate)
   ========================================================================== */
/* Lead block promoted two steps: it is the section's real headline. */
.interest-block--lead .interest-heading {
    font-size: var(--fs-8);
    letter-spacing: var(--tr-lg);
}
.interest-block--lead {
    padding-bottom: clamp(34px, 4vw, 56px);
    margin-bottom: clamp(38px, 4.6vw, 62px);
}
.interest-lead-head {
    align-items: baseline;
    gap: clamp(16px, 2vw, 30px);
}
.interest-block--lead .interest-index { font-size: 1.5rem; }

/* AA: .interest-index sat on plain --sage (4.41:1 on --bg at 14–19px,
   below the 4.5 floor). --sage-deep is the palette's answer for small sage
   text and measures 6.2:1 here. Same fix for the two decorative hero marks,
   which were the site's only other sub-AA sage glyphs. */
.interest-index,
.hero-meta-dot,
.type-caret { color: var(--sage-deep); }

/* Offset the two columns so they do not read as a symmetrical pair. */
@media (min-width: 1080px) {
    .interest-columns {
        grid-template-columns: minmax(0, 1.08fr) minmax(0, .92fr);
        column-gap: clamp(40px, 5.5vw, 80px);
    }
    .interest-columns::before { left: 52%; }
    .interest-columns > .interest-block:nth-child(2) {
        margin-top: clamp(30px, 4vw, 66px);
    }
}


/* ---- THE REACH STRIP ------------------------------------------------------
   Six recommended places drawn against a four-decade log ruler of
   straight-line distance from Sunnyvale. The data block in index.astro has the
   verified addresses and the argument for why this is a ruler and not a map
   (the six sit in three metros on two coasts, so no single honest map exists).

   EVERY MEANINGFUL DIMENSION IS SET BY THE MARKUP, not here: the dot and bar
   x-coordinates come out of geocoded street addresses via `vbX(miles)`, and the
   grid sits on the real decade marks. This file only sets the register — type,
   colour, weight, rhythm. There is nothing to tune here that could move a dot,
   which is deliberate.

   COLOUR IS NEVER THE ONLY CHANNEL. Each row prints its own name, location and
   distance as real HTML text, so the drawing is a redundant depiction and both
   `svg`s are aria-hidden. Dots are told apart by their row, never by hue.

   CONTRAST. The strip lives inside `#interests`, a `.band--dark` plate, so
   `--panel` / `--sage` / `--mut` resolve to the dark ramp and the ground under
   it is --panel #232d1d. Computed against that: sage dots and bars 7.7:1, the
   mono meta line 7.6:1, the distance readout 8.8:1. The one literal, #8f9884,
   is the value styles.css already proved is exactly 3.00:1 on the LIGHT
   --panel; on this plate it measures 4.8:1. So the ruler clears the WCAG
   1.4.11 non-text floor on either ground, which is what lets the strip survive
   being moved to a light band. Track and grid share that one colour and are
   told apart by GEOMETRY — a full-width rule versus a 6-unit tick — rather
   than by a second, weaker hue that would fail the floor on one of the two
   grounds.

   NOTHING HERE ANIMATES, deliberately. The parent `.interest-block` already
   carries `.reveal`, whose reduced-motion opt-out lives in styles.css, so this
   strip needs no tween of its own and no rule below authors an invisible or
   zero-area resting state — it is drawn in its finished form. It also uses
   none of the `.shape-*` names, so scroll.css's self-drawing stroke tween
   (which drives `stroke-width` from `--contour` and would leave these
   hairlines at zero width) cannot reach it.

   FLUIDITY. Each run is `width:100%` over a `viewBox` of 400x14 with
   `preserveAspectRatio="none"`, and the CSS height matches the viewBox height
   exactly, so the y-scale is always 1 and only x stretches.
   `vector-effect="non-scaling-stroke"` then keeps every stroke width in device
   pixels, which is what makes the dots true circles (a zero-length subpath
   with a round cap) at any column width instead of ellipses. Verified painted
   at 284px, 405px and 810px — the three widths this column actually takes
   between a 320px phone and a 1920px desktop.
   ------------------------------------------------------------------------ */
.reach {
    max-width: 34rem;
    margin: clamp(18px, 2.2vw, 26px) 0 0;
    padding: clamp(15px, 2vw, 21px) clamp(15px, 2.2vw, 23px) clamp(12px, 1.6vw, 17px);
    background: var(--panel);
    border: 1px solid var(--line);
    border-radius: var(--r);
    box-shadow: var(--shadow-card);
}

/* Caption row in the same mono register as the travel page's trip-cadence
   head, so the site has one voice for "this is a chart". */
.reach-head {
    display: flex;
    flex-wrap: wrap;
    align-items: baseline;
    justify-content: space-between;
    gap: 5px 14px;
    padding-bottom: 11px;
    border-bottom: 1px solid var(--line);
}
.reach-title {
    font-family: var(--mono);
    font-size: .72rem;
    line-height: 1.2;
    letter-spacing: .18em;
    text-transform: uppercase;
    color: var(--sage-deep);
}
/* The grouping line is COUNTED from the data, not typed, so it is allowed to
   wrap rather than being pinned with `nowrap` — at 320px the column is 284px
   wide and "4 Bay Area · 1 San Diego · 1 New York" does not fit on one line. */
.reach-span {
    font-family: var(--mono);
    font-size: .68rem;
    letter-spacing: .05em;
    color: var(--mut);
}
.reach-note {
    margin: 11px 0 0;
    font-family: var(--sans);
    font-size: .8rem;
    line-height: 1.55;
    color: var(--mut);
    max-width: 48ch;
}

.reach-list {
    list-style: none;
    margin: clamp(14px, 1.8vw, 19px) 0 0;
    padding: 0;
    display: grid;
    gap: clamp(11px, 1.4vw, 15px);
}
.reach-row {
    display: grid;
    gap: 2px;
    min-width: 0;
}
.reach-row-head {
    display: flex;
    align-items: baseline;
    justify-content: space-between;
    gap: 8px;
}
/* No negative tracking: the modular scale reserves --tr-sm for 22–30px and
   this sits at 16–17.4px, where tightening starts to cost legibility. */
.reach-name {
    min-width: 0;
    font-family: var(--display);
    font-size: var(--fs-1);
    line-height: 1.25;
    color: var(--ink);
}
/* `flex: none` + tabular figures: the readouts are a column of numbers the eye
   compares, so they must not reflow or wobble as the digits change. */
.reach-mi {
    flex: none;
    font-family: var(--mono);
    font-size: .7rem;
    letter-spacing: .02em;
    color: var(--sage-deep);
    font-variant-numeric: tabular-nums;
}
.reach-where {
    font-family: var(--mono);
    font-size: .64rem;
    line-height: 1.45;
    letter-spacing: .03em;
    color: var(--mut);
}

.reach-run {
    display: block;
    width: 100%;
    height: 14px;
    margin-top: 5px;
}
.reach-track { stroke: #8f9884; stroke-width: 1; }
.reach-grid  { stroke: #8f9884; stroke-width: 1; }
/* The bar means "this place has locations spread over this range". It sits
   under the dots in paint order, so a cluster reads as one lozenge with its
   real ends marked. */
.reach-bar {
    stroke: var(--sage);
    stroke-width: 2.4;
    stroke-linecap: round;
}
/* One dot per verified location. Drawn as a zero-length subpath with a round
   cap so the mark stays circular under the run's non-uniform x-stretch. */
.reach-dot {
    stroke: var(--sage);
    stroke-width: 6.4;
    stroke-linecap: round;
}

.reach-axis { margin-top: clamp(12px, 1.5vw, 16px); }
.reach-axis-rule {
    display: block;
    width: 100%;
    height: 6px;
}
.reach-axis-labels {
    position: relative;
    display: block;
    height: 1rem;
    margin-top: 3px;
}
/* Centred on the tick, from the same percentage the SVG uses. The extreme
   labels are checked, not assumed: at the narrowest column (252px of inner
   width) "1 mi" starts 7px in and "1,000 mi" ends 20px short of the right
   edge, so neither leaves the card. */
.reach-axis-label {
    position: absolute;
    top: 0;
    transform: translateX(-50%);
    font-family: var(--mono);
    font-size: .62rem;
    letter-spacing: .04em;
    color: var(--mut);
    white-space: nowrap;
}

/* The one interactive thing in the strip, and the only tab stop it adds: a
   link to the travel page, where the San Diego and New York rows both have a
   real boarding pass. The site's base rule is `a { color: inherit;
   text-decoration: none }`, so without the underline below this link would be
   signalled by nothing — not even colour. Underline plus colour is two
   channels (WCAG 1.4.1). It is an inline link inside a sentence, the case WCAG
   2.5.8 exempts from the minimum target size, and the global `:focus-visible`
   ring in styles.css (2px --sage, 3px offset) covers keyboard use. */
.reach-foot {
    margin: clamp(10px, 1.3vw, 14px) 0 0;
    font-family: var(--mono);
    font-size: .64rem;
    line-height: 1.5;
    letter-spacing: .03em;
    color: var(--mut);
}
.reach-foot a {
    color: var(--sage-deep);
    text-decoration: underline;
    text-decoration-thickness: 1px;
    text-underline-offset: 3px;
}


/* ==========================================================================
   8. HOME — the featured bento

   12 columns, offset seams. Row 1 splits 7 / 5, row 2 splits 5 / 7, so the
   vertical join moves as the eye travels down — that offset is the whole
   difference between a mosaic and a tiled grid.

       +---------------------+-------------+
       |  01 feature (7)     | website (5) |
       |                     |             |
       +-------------+-------+-------------+
       | plate (5)   |   see all (7)       |
       +-------------+---------------------+
   ========================================================================== */
/* Cap the card measure, not the card. The tiles are allowed to grow with the
   fluid --content-max; the line inside them is not. See the --card-measure note
   in the token block for the measured characters-per-line that set this value.

   GATED TO THE FLUID RANGE ON PURPOSE. This guard rail exists only because
   --content-max grows above 1512, so it has no business being live below 1512.
   Ungated it also bound at 820, where 60ch computes to 634px against a 635px
   available width — a 1px squeeze with no rewrap, but still a change to a
   viewport this pass promised not to touch. Gating it makes "nothing at or below
   the reference moves" structural rather than a coincidence of the numbers.

   `:not([class])` is the prose paragraph and only the prose paragraph. Audited
   across all three pages: inside every `.card` the copy is an unclassed <p> and
   the only classed one is `.project-tech`, a single-line mono label that a
   measure cap has no business touching. A bare `.card p` also matched that
   label. */
@media (min-width: 1513px) {
    .card p:not([class]) { max-width: var(--card-measure); }
}

/* styles.css sets `grid-auto-rows: 1fr` on .project-grid, which forces every
   row to the height of the tallest card — including once the grid collapses to
   one column, where it left the two short cards with hundreds of px of dead
   space below their copy. The mosaic sizes rows from content instead. */
.project-grid:not(.project-grid--even) { grid-auto-rows: auto; }

@media (min-width: 900px) {
    .band-inner .project-grid:not(.project-grid--even) {
        grid-template-columns: repeat(12, minmax(0, 1fr));
        gap: clamp(14px, 1.5vw, 24px);
    }
    .project-grid:not(.project-grid--even) > .project-feature {
        grid-column: 1 / span 7;
        grid-row: 1;
    }
    .project-grid > .project-card--tall {
        grid-column: 8 / span 5;
        grid-row: 1;
    }
    .project-grid > .bento-plate {
        grid-column: 1 / span 5;
        grid-row: 2;
    }
    .project-grid > .project-card--wide {
        grid-column: 6 / span 7;
        grid-row: 2;
    }
    /* The feature is the dominant tile — give it real height rather than
       letting three short cards agree on one. */
    .project-grid:not(.project-grid--even) > .project-feature {
        min-height: clamp(340px, 34vw, 470px);
    }
    /* The tall companion tile matches the feature's row height, so its copy
       has to be distributed or it strands 250px of white space under the CTA.
       Index at the top, the title block pinned to the bottom edge — the same
       reading shape the feature tile uses. */
    .project-card--tall {
        display: flex;
        flex-direction: column;
        justify-content: space-between;
    }
    .project-card--tall .card-title { margin-top: auto; }
    /* Rule under the folio number so the air beneath it reads as a deliberate
       column head rather than as a card that failed to fill. */
    .project-card--tall .project-index {
        width: 100%;
        padding-bottom: 16px;
        margin-bottom: 0;
        border-bottom: 1px solid var(--line);
    }

    .project-card--wide {
        display: grid;
        grid-template-columns: auto minmax(0, 1fr);
        column-gap: clamp(22px, 3vw, 44px);
        align-items: center;
    }
    .project-card--wide .project-index {
        grid-row: 1 / span 4;
        align-self: center;
        font-size: var(--fs-4);
        margin-bottom: 0;
    }
    .project-card--wide .card-title { grid-column: 2; margin-bottom: 6px; }
    .project-card--wide .project-tech,
    .project-card--wide p,
    .project-card--wide .card-cta { grid-column: 2; }
}

/* Feature tile: bigger title, more air, and a sage wash that makes it read
   as the plate the section is built around. */
.project-feature {
    padding: clamp(30px, 3.2vw, 46px) clamp(28px, 3vw, 44px);
    background:
        radial-gradient(120% 90% at 100% 100%, rgba(138,154,91,.14), transparent 62%),
        linear-gradient(158deg, var(--panel) 52%, var(--sage-soft));
}
.project-feature .card-title {
    font-size: var(--fs-8);
    line-height: var(--lh-title);
    letter-spacing: var(--tr-lg);
}
.project-feature .project-index { font-size: var(--fs-6); }

/* ---- The typographic stat plate ----
   The bento's fourth tile. No imagery: a huge numeral, a mono label and a
   procedural hatch. It exists to break the run of three near-identical
   cards with something that is pure type and texture. */
.bento-plate {
    position: relative;
    display: flex;
    flex-direction: column;
    justify-content: space-between;
    gap: 18px;
    overflow: hidden;
    padding: clamp(24px, 2.6vw, 34px) clamp(24px, 2.6vw, 34px) clamp(20px, 2.2vw, 28px);
    border: 1px solid var(--line2);
    border-radius: var(--r-lg);
    background:
        repeating-linear-gradient(128deg,
            rgba(95,122,79,.07) 0 1px,
            transparent 1px 10px),
        linear-gradient(152deg, var(--sage-soft) 0%, var(--panel) 78%);
    box-shadow: var(--shadow-card);
}
.bento-plate-k {
    font-family: var(--mono);
    font-size: .66rem;
    font-weight: 500;
    letter-spacing: .26em;
    text-transform: uppercase;
    color: var(--sage-deep);
}
.bento-plate-n {
    font-family: var(--display);
    font-weight: 700;
    font-size: calc(clamp(3.6rem, 8vw, 7rem) * var(--display-scale));
    line-height: .82;
    letter-spacing: -0.05em;
    color: var(--sage-deep);
    /* Optical: Space Grotesk figures sit high in the em box. */
    margin-top: -.06em;
}
.bento-plate-note {
    font-family: var(--mono);
    font-size: .72rem;
    line-height: 1.5;
    letter-spacing: .06em;
    color: var(--ink2);
    max-width: 36ch;
}
/* Dark-plate register, in case the bento is ever moved onto a dark band. */
.band--dark .bento-plate {
    background:
        repeating-linear-gradient(128deg,
            rgba(169,199,140,.07) 0 1px,
            transparent 1px 10px),
        linear-gradient(152deg, var(--panel2) 0%, var(--panel) 78%);
}


/* ==========================================================================
   9. HOME — favourites bento
   Six tiles, four columns, one 2x2 hero tile. The spans tile exactly:
   row 1 = 2+1+1, row 2 = 2+2, row 3 = 2+2.
   ========================================================================== */
@media (min-width: 1080px) {
    .favorites-grid {
        grid-template-columns: repeat(4, minmax(0, 1fr));
        grid-auto-rows: minmax(clamp(92px, 8.4vw, 124px), auto);
        gap: clamp(12px, 1.2vw, 18px);
    }
    .favorites-grid > .favorite-item:nth-child(1) {
        grid-column: span 2;
        grid-row: span 2;
        justify-content: space-between;
        padding: clamp(24px, 2.4vw, 34px);
    }
    .favorites-grid > .favorite-item:nth-child(1) .favorite-value {
        font-family: var(--display);
        font-weight: 600;
        font-size: var(--fs-6);
        line-height: var(--lh-title);
        letter-spacing: var(--tr-md);
    }
    .favorites-grid > .favorite-item:nth-child(4),
    .favorites-grid > .favorite-item:nth-child(5),
    .favorites-grid > .favorite-item:nth-child(6) { grid-column: span 2; }
}
@media (min-width: 640px) and (max-width: 1079.98px) {
    .favorites-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); }
    .favorites-grid > .favorite-item:nth-child(1) { grid-column: span 2; }
}

/* The hero tile carries a procedural ring field so the bento has one
   non-flat surface. Concentric sage rings, no imagery. */
.favorites-grid > .favorite-item:nth-child(1) {
    position: relative;
    overflow: hidden;
    background:
        repeating-radial-gradient(circle at 112% 118%,
            rgba(95,122,79,.075) 0 1px,
            transparent 1px 16px),
        linear-gradient(158deg, var(--panel) 58%, var(--sage-soft));
}
.band--dark .favorites-grid > .favorite-item:nth-child(1) {
    background:
        repeating-radial-gradient(circle at 112% 118%,
            rgba(169,199,140,.10) 0 1px,
            transparent 1px 16px),
        linear-gradient(158deg, var(--panel) 58%, var(--panel2));
}


/* ==========================================================================
   10. HOME — work + connect
   ========================================================================== */
.skills-list { gap: 12px; }

/* Connect: the two halves were an even split, which is the shape this pass
   is trying to get rid of. The form genuinely needs the wider column. */
@media (min-width: 901px) {
    .connect-split {
        --cs-gap: clamp(36px, 4.6vw, 76px);
        position: relative;
        grid-template-columns: minmax(0, .82fr) minmax(0, 1.18fr);
        gap: var(--cs-gap);
    }
    /* Hairline down the middle of the gutter between the halves, in the same
       register as the interests divider. Column 1 is .82/2fr = 41% of the
       track space, so the gutter centre is that plus half the gap.
       Decorative and inside the column — it cannot overflow. */
    .connect-split::before {
        content: "";
        position: absolute;
        top: 4px;
        bottom: 4px;
        left: calc((100% - var(--cs-gap)) * 0.41 + var(--cs-gap) / 2);
        width: 1px;
        pointer-events: none;
        background: linear-gradient(180deg,
            transparent, var(--line2) 10%, var(--line2) 86%, transparent);
    }
}


/* ==========================================================================
   11. PROJECTS PAGE
   ========================================================================== */
/* Hero: the h1 hangs, the deck sits inset under it, and a mono rule
   separates the two — the same grid as the section heads, one level down. */
@media (min-width: 1080px) {
    .band-projects-hero .band-inner,
    .band-travel-hero .band-inner { padding-left: var(--ed-indent); }
    .band-projects-hero > .band-inner > .section-title,
    .band-travel-hero > .band-inner > .section-title {
        margin-left: calc(-1 * (var(--ed-indent) + var(--ed-hang)));
    }
    /* The kicker keeps the body column's left edge, so the giant title
       visibly breaks out past it. */
    .band-projects-hero .kicker,
    .band-travel-hero .kicker { margin-left: 0; }
    .band-projects-hero .section-intro,
    .band-travel-hero .section-intro {
        max-width: 48ch;
        margin-top: clamp(16px, 1.8vw, 26px);
        padding-top: clamp(14px, 1.6vw, 22px);
        /* --line2, not --line: at 1px against --bg2 the lighter hairline was
           invisible, which made the deck look merely detached from the title
           rather than deliberately ruled off from it. */
        border-top: 1px solid var(--line2);
    }
}

/* The hero motif was tuned against a ~90px h1; at 134px the title's descender
   line runs straight through it. Drop it to the lower right, out from under
   the type, and take a little weight out of the stroke — the display type is
   now the loudest thing in the band and the line art should read as a
   watermark behind it, not as a competing mark. */
.projects-motif {
    top: auto;
    bottom: clamp(-40px, -2vw, 0px);
    transform: none;
    width: min(32vw, 360px);
    opacity: .72;
}

/* Case study body: a 12-column editorial scatter instead of a 2x2 block.
   Each block stays inside a 60ch measure (styles.css caps .case-block p),
   so widening the container does not widen the line. */
@media (min-width: 1080px) {
    .case-body {
        grid-template-columns: repeat(12, minmax(0, 1fr));
        column-gap: clamp(28px, 3.4vw, 56px);
        row-gap: clamp(30px, 3.6vw, 54px);
        padding-left: var(--ed-indent);
    }
    .case-body > .case-block:nth-child(1) { grid-column: 1 / span 6; }
    .case-body > .case-block:nth-child(2) {
        grid-column: 7 / span 6;
        margin-top: clamp(26px, 3.4vw, 58px);
    }
    .case-body > .case-block:nth-child(3) { grid-column: 2 / span 6; }
    .case-body > .case-block:nth-child(4) {
        grid-column: 8 / span 5;
        margin-top: clamp(20px, 2.6vw, 44px);
    }

    /* Meta strip and tags follow the body's inset; the head — index numeral
       and case title together — stays out at the full measure and hangs, so
       it breaks the column the prose sits in. */
    .case-study > .band-inner > .case-meta,
    .case-study > .band-inner > .case-tags { padding-left: var(--ed-indent); }
    .case-study > .band-inner > .case-head {
        margin-left: calc(-1 * var(--ed-hang));
        margin-bottom: clamp(30px, 3.4vw, 46px);
    }
}
.case-title { font-size: calc(clamp(2.2rem, 5.4vw, 4.2rem) * var(--display-scale)); }
.case-index { font-size: var(--fs-7); }

/* Meta strip: a hairline row rather than three little cards, so the case
   pages stop looking like a card catalogue. */
.case-meta li {
    background: transparent;
    border: none;
    border-left: 1px solid var(--line2);
    border-radius: 0;
    box-shadow: none;
    padding: 2px 0 2px 16px;
    margin-right: clamp(18px, 3vw, 46px);
}


/* ==========================================================================
   12. TRAVEL PAGE — the boarding-pass wall

   12 columns, every pass the SAME SIZE (span 4 => three per row). The two
   "featured" wide passes were removed for parity: eleven real trips are a set,
   and promoting two of them implied a ranking that is not there.
   `grid: 'auto'` in gsap-motion.js measures real geometry, so the diagonal
   entrance stagger adapts on its own.
   ========================================================================== */
/* The wall band is a dark plate now, not a flush continuation of the hero,
   so it gets plate breathing room instead of the old 20–36px seam. */
.band-flush-top { padding-top: clamp(72px, 9vw, 124px); }

@media (min-width: 1080px) {
    .pass-wall {
        grid-template-columns: repeat(12, minmax(0, 1fr));
        gap: clamp(16px, 1.7vw, 26px);
    }
    .pass-wall > .pass { grid-column: span 4; }
}
@media (min-width: 760px) and (max-width: 1079.98px) {
    .pass-wall { grid-template-columns: repeat(12, minmax(0, 1fr)); }
    .pass-wall > .pass { grid-column: span 6; }
}
/* MALFORMED-RULE FIX #1. This block used to end `.pass-wall > .pass,` with a
   dangling comma and no declaration block. Per CSS Syntax, the `}` that follows
   is consumed as the parse error that discards that qualified rule — which also
   CLOSED this @media block. Verified in the CSSOM of the built output: the block
   parsed with exactly 1 surviving rule (`.pass-wall`), the `.pass-wall > .pass`
   rule was gone entirely, and the rules after it (`.band--dark .pass`, the
   `:nth-child` tilt ladder, the `prefers-contrast` block) survived as top-level
   rules rather than being swallowed.
   NOTHING WAS ACTUALLY STRANDED: no ungated rule sets `grid-column` on a pass,
   and both spans above are media-gated to >=760px, so auto-placement was already
   giving one pass per row here. Measured at 390px before and after: 11 passes,
   11 rows, widest row 1, zero document overflow — identical. So the declaration
   below restates the live behaviour and makes the intent explicit; it is
   deliberately a no-op today and a guard against a future ungated span. */
@media (max-width: 759.98px) {
    .pass-wall { grid-template-columns: minmax(0, 1fr); }
    /* `auto`, not a span: the template is one column here, and the span rules in
       the two wider breakpoints must not be able to reach this width and ask the
       grid for implicit columns it would then have to overflow to satisfy.
       This rule was previously `.pass-wall > .pass,` with a trailing comma and
       no body — an invalid selector list, which browsers recover from by
       skipping to the next `}`. That `}` was this media query's closer, so the
       block stayed open and absorbed the `.band--dark .pass` hairline-ring rules
       below, scoping them to mobile only. Verified with lightningcss. */
    .pass-wall > .pass { grid-column: auto; }
}

/* On the dark plate the darkest passes (Zipair near-black, the navies) sit
   very close to the plate itself. A hairline ring restates each card's edge
   so the wall reads as separate stubs, not one mass. */
.band--dark .pass {
    box-shadow: 0 0 0 1px rgba(242,244,236,.13), var(--shadow-card);
}
.band--dark .trip-summary { border-color: var(--line2); }

/* Trip cadence strip: give it the editorial inset too. */
@media (min-width: 1080px) {
    .trip-summary { margin-left: var(--ed-indent); }
}


/* ==========================================================================
   13. RESPONSIVE — collapse the composition, never the content

   The editorial offsets are opt-IN by breakpoint (see the tokens block), so
   below 1080px every indent is already 0 and the layout is the honest
   single column. What is left here is the mosaic collapse.
   ========================================================================== */
@media (max-width: 899.98px) {
    .band-inner .project-grid:not(.project-grid--even) {
        grid-template-columns: minmax(0, 1fr);
    }
    .project-grid:not(.project-grid--even) > .project-feature,
    .project-grid > .project-card--tall,
    .project-grid > .bento-plate,
    .project-grid > .project-card--wide {
        grid-column: 1;
        grid-row: auto;
        min-height: 0;
    }
    /* Collapsed: the label takes its own line, then the numeral and the note
       sit side by side on a shared baseline — the tile stays a wide band
       instead of becoming a tall column of three stacked fragments. */
    .bento-plate {
        flex-flow: row wrap;
        align-items: flex-end;
        justify-content: flex-start;
        gap: 10px 22px;
    }
    .bento-plate-k { flex: 1 0 100%; }
    .bento-plate-note { flex: 1 1 240px; }
    .bento-plate-n { font-size: calc(clamp(3rem, 13vw, 4.6rem) * var(--display-scale)); }
    .bento-plate-note { max-width: 34ch; }
    .connect-split::before { display: none; }
}

@media (max-width: 639.98px) {
    .favorites-grid { grid-template-columns: minmax(0, 1fr); }
    .band--plate { margin-left: clamp(10px, 2.4vw, 18px); margin-right: clamp(10px, 2.4vw, 18px); }
    /* The ruler's tick pitch is too fine to read at phone widths; keep the
       rule, drop the ticks. */
    .section-head::after {
        height: 1px;
        background-image: linear-gradient(90deg, var(--line2) 0 100%);
        background-size: 100% 1px;
        background-repeat: no-repeat;
    }
}

@media (max-width: 420px) {
    /* .band padding drops to 18px here (styles.css); the plate keeps a
       hair of margin so it still reads as an inset sheet. */
    .band--plate { margin-left: 8px; margin-right: 8px; border-radius: 16px; }
}


/* ==========================================================================
   14. SAFETY BELTS

   Nothing in this file animates, and every rule above authors a FINISHED
   state: no opacity:0, no zero-area boxes, no transform that hides. The
   blocks below only make that explicit for the two user preferences that
   have stranded content on this site before.
   ========================================================================== */
@media (prefers-reduced-motion: reduce) {
    /* The rotated running head, the grid rule and the section ruler are
       static artwork — they must read as finished, never as a from-state.
       Restated with the same values they already carry so a future edit
       cannot accidentally strand them at 0. */
    .band-inner[data-rail]::before { opacity: 1 !important; }
    .band-inner[data-rail]::after  { opacity: .85 !important; }
    .section-head::after           { opacity: .8 !important; }
    .band--plate::after            { opacity: .55 !important; }
    /* MALFORMED-RULE FIX #2, and the comment above it was untrue.
       It claimed to protect "the two tiles that are NOT .reveal-managed".
       There are no such tiles: audited in index.astro, ALL FOUR bento tiles
       (.project-feature, .project-card--tall, .bento-plate, .project-card--wide)
       carry `.reveal`, and it listed three selectors, not two.
       The selector list also ended in a dangling comma, so — exactly as in fix
       #1 — the whole rule was discarded. Verified in the CSSOM of the built
       output: this block parsed with 4 surviving rules, and these tiles had no
       rule of their own at all.
       NOTHING WAS STRANDED, because styles.css's
       `.reveal { opacity: 1 !important; transform: none !important }` in its own
       reduced-motion block already covers all four. Measured with
       --force-prefers-reduced-motion after a full scroll, before and after this
       fix: all four tiles at opacity 1.000. So this is now what it always
       claimed to be — a belt-and-braces restatement, covering all four tiles,
       that cannot itself strand anything. */
    .project-feature,
    .bento-plate,
    .project-card--tall,
    .project-card--wide {
        opacity: 1 !important;
        transform: none !important;
    }
}

/* High-contrast preference: texture is the first thing to go — every layer
   here is decorative, and none of it carries information. */
@media (prefers-contrast: more) {
    .atmosphere::before,
    .atmosphere::after,
    .band::before,
    .band--plate::after { display: none; }
    .band--plate { background: var(--panel); border-color: var(--line2); }
    .bento-plate { background: var(--panel); }
}


/* ==========================================================================
   BOARDING-PASS WALL — the pinned stub board
   --------------------------------------------------------------------------
   Every pass is the SAME SIZE (span 4, three per row). What varies is only how
   each one LIES on the plate: a small tilt, a small vertical drop and a hair of
   horizontal drift, so eleven identical cards read as a collection of real
   stubs pinned to a board rather than as a table of rows.

   >>> THE TECHNIQUE THAT MAKES THIS POSSIBLE <<<
   The tilt and nudge use the INDEPENDENT `rotate` and `translate` properties,
   NOT `transform`.

   `transform` on `.pass` is already owned twice over: motion-ux.js writes it for
   the hover tilt and press spring, and gsap-motion.js writes it for the entrance
   stagger. A static `transform` here would be clobbered by both — this repo's
   recurring "two systems, one property" failure. `rotate` and `translate` are
   separate properties (CSS Transforms Level 2) that COMPOSE with `transform`
   rather than replacing it, so a stub can sit tilted at rest and still get its
   full hover spring, with no wrapper element and no ownership negotiation.

   >>> WHY EVERY INDEX IS SPELLED OUT <<<
   These are explicit `:nth-child(1..11)` rules, not a repeating cycle. The first
   cut used a 9-step cycle, which meant passes 1 and 10 were tilted identically
   and so were 2 and 11 — with three columns that put matching stubs directly
   above one another and the "pile" read as a pattern. Eleven passes, eleven
   distinct values.

   They are also HAND-PICKED, not random: the board must be byte-identical on
   every load and every resize. A random tilt would reshuffle the whole wall on
   an orientation change, which reads as a glitch.

   >>> THE VERTICAL DROP IS `translate`, NOT `margin-top` <<<
   The first cut used margin-top for the drop. That silently broke SIZE PARITY,
   which is the one thing this layout must hold: a margin grows the grid row, so
   cards in the same row stretch by different amounts. Measured — heights spread
   from a uniform 197px to 200-221px. `translate` is a paint-time offset that
   does not participate in layout, so every row stays one height and every card
   stays identical. Same reason it is not `transform`: see above.

   Constraints held: |rotate| <= 1.8deg (past ~2.5deg it stops looking casual and
   starts looking broken), signs alternate so no two neighbours lean the same
   way, |translate| <= 4px, and SIZE IS NEVER TOUCHED — no scale, no width, so
   parity is preserved exactly.
   ========================================================================== */
.pass-wall > .pass {
    rotate: 0deg;
    transition: rotate .28s cubic-bezier(.2, .8, .2, 1);
    /* Deeper contact shadow than the flat grid had: a tilted stub needs to read
       as lying ON the plate, not floating over it. */
    box-shadow: 0 2px 6px rgba(0, 0, 0, .18), 0 18px 40px -24px rgba(0, 0, 0, .5);
}

.pass-wall > .pass:nth-child(1)  { rotate: -1.5deg; translate: -3px 0px; }
.pass-wall > .pass:nth-child(2)  { rotate: 1.1deg; translate: 2px 13px; }
.pass-wall > .pass:nth-child(3)  { rotate: -0.6deg; translate: 4px 4px; }
.pass-wall > .pass:nth-child(4)  { rotate: 1.7deg; translate: -2px 10px; }
.pass-wall > .pass:nth-child(5)  { rotate: -1.2deg; translate: 3px 0px; }
.pass-wall > .pass:nth-child(6)  { rotate: 0.7deg; translate: -4px 16px; }
.pass-wall > .pass:nth-child(7)  { rotate: -1.8deg; translate: 1px 6px; }
.pass-wall > .pass:nth-child(8)  { rotate: 1.3deg; translate: 3px 12px; }
.pass-wall > .pass:nth-child(9)  { rotate: -0.9deg; translate: -3px 2px; }
.pass-wall > .pass:nth-child(10) { rotate: 1.5deg; translate: 2px 8px; }
.pass-wall > .pass:nth-child(11) { rotate: -1.35deg; translate: -1px 15px; }

/* A 12th trip and beyond: fall back to a 5-step cycle so a new pass is never
   left flat while someone gets around to giving it its own line. */
.pass-wall > .pass:nth-child(n+12)  { rotate:  1deg;   translate: 2px 7px; }
.pass-wall > .pass:nth-child(5n+13) { rotate: -1.4deg; translate: -3px 2px; }
.pass-wall > .pass:nth-child(5n+14) { rotate:  0.8deg; translate: 3px 14px; }

/* The stub you are looking at is the one lying flat. */
.pass-wall > .pass:hover { rotate: 0deg; }

/* The tilt itself is a resting STATE, not motion, so it stays under
   prefers-reduced-motion — flattening the board would just remove the design.
   The straighten-on-hover transition is motion and does go. */
@media (prefers-reduced-motion: reduce) {
    .pass-wall > .pass { transition: none; }
}
