/* ==========================================================================
   Dhruv Choudhary — portfolio

   Type sizes and spacing below were measured off the design at a 1470px
   viewport, so every vw value resolves to the design's exact pixel there and
   scales smoothly either side of it.
   ========================================================================== */

/* Akira Expanded Super Bold used to be self-hosted here for the wordmark. The
   wordmark is Montserrat tiles now and nothing else ever used the face, so the
   @font-face and the .otf preload in index.html both went with it. The file is
   still in fonts/ if it is ever wanted back — note its licence is personal-use
   only (see fonts/Read Me). */

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

:root {
  --bg: #ffffff;
  --ink: #111111;
  --ink-soft: #8c8c8c;

  --chip-bg: #f1f1f1;
  --chip-ink: #5b5b5b;
  --chip-bg-hover: #e7e7e7;
  --rule: #e8e8e8;

  --gutter: clamp(20px, 3.265vw, 48px);   /* 48px @1470 */
  --gap: clamp(20px, 3.265vw, 48px);

  --fs-hero: clamp(34px, 3.265vw, 48px);  /* 48px @1470 */
  --fs-lead: clamp(17px, 1.32vw, 19.4px); /* 19.4px @1470 */
  --fs-ui: clamp(15px, 1.156vw, 17px);    /* 17px @1470 — cards, footer */
  /* The two nav rows, a step up from the rest of the UI scale. --fs-ui put
     them at 17 on a 1470, which is card-caption size for the only row of
     controls on the page.
     Same shape of clamp, same 15px floor, so NOTHING BELOW ~1160 MOVES — a
     phone is on the floor either way and this was asked for on the desktop.
     The ceiling is where it differs: 19 at 1470 and up.
     19, not 20+: the tiles behind these words are snapped to whole dot cells
     by snapNavPanelsToGrid, and a label that grows past its cell count pushes
     the row a whole cell wider. Measured at 1470, 1180 and 861 — the row still
     brackets the mark and no page overflows. */
  --fs-nav: clamp(15px, 1.29vw, 19px);    /* 19px @1470 — both nav rows */
  --fs-chip: clamp(12.5px, 0.952vw, 14px);

  /* Work-card ramp. Every line of a card used to be --fs-ui, so the name, the
     descriptor and the blurb all measured 17px and the block under each image
     read as one undifferentiated grey paragraph. Three steps now — 24 / 14 /
     16 @1470 — which is the same eyebrow / title / body ladder the About and
     case pages already run, only one size band down. */
  --fs-card-title: clamp(20px, 1.633vw, 24px);   /* 24px @1470 */
  --fs-card-blurb: clamp(14.5px, 1.088vw, 16px); /* 16px @1470 */

  /* Two text weights for running copy and everything else.

     --fw-body was 350, a notch below regular chosen for Montserrat's variable
     axis. SF has no 350 instance, so it either rounds to Regular or gets
     synthesised into a smear depending on the platform. 400 is a real drawn
     weight and says so. /about/ and /resume/ already ran at 400 for exactly
     this reason before the face went site-wide; this is that same call, moved
     up to the token where it now belongs. */
  --fw-body: 400;
  --fw-ui: 400;
  /* Small headings (card titles, section titles, row labels) run light rather
     than regular — the size already signals hierarchy, so the weight doesn't
     have to. 300 is SF Light, a genuine instance, and it holds at 17-22px on a
     dark background. Proven on the About and Résumé pages first. */
  --fw-heading-sm: 300;
  /* Let's Chat, and nothing else. The one call to action ran at exactly the
     weight of the links beside it — 400 on a desktop, 500 in the phone bars —
     so the only thing marking it as the action was a dotted outline at 1.9:1.
     600 is SF Semibold, a genuine instance, and it reads as a step up from
     Regular at 17px without tipping into the display weights the wordmark
     owns. One token because it is one component in nine places; see
     :is(.nav, .foot__nav) a[href^="mailto:"] for the other eight numbers. */
  --fw-chat: 600;
  /* The three nav labels, both bars, every width. They ran at --fw-ui's 400 on
     a desktop and 500 only below 620, which meant the row got LIGHTER as the
     screen got bigger — the opposite of what a bar of controls wants, and the
     desktop is where the whole first screen is competing for the eye.

     500, not 600. 600 is --fw-chat, and the gap between the two is the only
     thing left saying which of the four words is the action: at equal weight
     Let's Chat has an outline and nothing else. One step of separation from
     prose keeps the row reading as controls; two would make it a headline.

     One number now instead of two. The 500 that used to live in the phone
     block is gone, because this is that same 500 said once. */
  --fw-nav: 500;

  /* SF Pro. It cannot be self-hosted — Apple licenses it for use on Apple
     platforms and does not ship it as a webfont, and macOS only exposes it as
     the system UI face (SFNS.ttf), not as a family you can name. -apple-system
     and BlinkMacSystemFont are the hooks that resolve to the real San
     Francisco on Apple devices; the two quoted names catch machines that have
     Apple's developer download installed. Everything after is the fallback for
     Windows and Android, which have no SF at all. */
  --font-sf: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
             system-ui, "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Arial,
             sans-serif;

  --lh-hero: 1.375;
  --shell: 1800px;
  --radius-media: 8px;

  --ease: cubic-bezier(0.22, 1, 0.36, 1);

  /* theming helpers — light values; overridden by the dark palette below */
  --ink-invert: #ffffff;                       /* text on top of --ink */
  --media-frame: #ffffff;                      /* card + figure backdrop */
  --card-hairline: rgba(17, 17, 17, 0.07);
  --card-hairline-hover: rgba(17, 17, 17, 0.12);
  --card-lift: 0 18px 38px -18px rgba(17, 17, 17, 0.30);
  /* Phones are drawn by an SVG device frame, so their shadow is a
     drop-shadow() tracing the squircle — NOT a box-shadow round the element
     box. Hence no spread value: drop-shadow() has no fourth length. */
  --phone-drop: 0 12px 16px rgba(17, 17, 17, 0.24);
  --phone-drop-hover: 0 20px 24px rgba(17, 17, 17, 0.32);
  --pill-bg: #e9e9e9;
  --hairline: #d3d9d8;
  --ok: #2e8b57;
  --placeholder: #d8d8d8;

  /* Hero dot backdrop. --grid-size is the spacing, --grid-dot the resting
     dot, --grid-dot-lit/-size the one under the pointer, and --grid-fade the
     point down the hero where the field starts dissolving (the fade is
     vertical only — the sides stay sharp). */
  --grid-dot: #d4d4d4;
  --grid-dot-lit: rgba(17, 17, 17, 0.34);
  --grid-dot-size: 8px;
  --grid-size: 20px;
  --grid-fade: 62%;
  /* How far the field runs past the hero's bottom, and — because the footer's
     field is anchored at `top: calc(0px - var(--grid-tail))` — also what sets
     the PHASE of the footer's dot rows against the footer's own top rule.

     140 put that rule exactly halfway between two rows, every time, at every
     viewport. The field's dots sit at fieldTop + half a cell + n cells, and the
     footer's top edge is fieldTop + tail, so the rule lands on a row only when
     tail is half a cell above a whole number of them. 140 - 10 = 130, which is
     not a multiple of 20, so it never could.

     150 is, and it works at BOTH cell sizes: 150 - 10 = 140 = 7 x 20, and
     150 - 6 = 144 = 12 x 12. The extra 10px of fade past the hero is inside a
     mask and invisible; what it buys is the footer's rules on the lattice. */
  --grid-tail: 150px;

  /* Hero letter tiles — a faint wash so the dot field reads through them. */
  --tile-bg: rgba(17, 17, 17, 0.028);

  /* --- Tile markers ------------------------------------------------------
     Eight dots placed ON a highlighted tile: one per corner, one per edge
     midpoint. The marks a design tool puts on a selected object, which is what
     a hovered tile is being read as.

     DELIBERATELY NOT DERIVED FROM THE DOT FIELD. These used to be the field
     itself, tiled at --grid-size and showing through the tile — which meant the
     dot COUNT changed with the tile's width, and the whole thing only lined up
     where snapNavPanelsToGrid had rounded the box out to whole cells. Below 620
     that pass bails (`if (cell < 20) return`), so phones got a visibly
     different treatment from everything else.

     Sized to the tile instead, none of that applies: eight marks at any width,
     on any box, snapped or not. --grid-size is not referenced here. --grid-dot
     still is, but only for the COLOUR — the geometry is entirely local.

     Tokens rather than three copies of the same block, because nav links,
     footer nav links and the hero buttons all carry this and drifting apart is
     exactly what happened to the treatments they replaced. background-size and
     -repeat take one value each: the spec repeats a short list across all
     layers, so one declaration covers all eight. */


  /* --- Tile markers ------------------------------------------------------
     Eight dots placed ON a highlighted tile: one per corner, one per edge
     midpoint. The marks a design tool puts on a selected object, which is what
     a hovered tile is being read as.

     DELIBERATELY NOT DERIVED FROM THE DOT FIELD. These used to be the field
     itself, tiled at --grid-size and showing through the tile — which meant the
     dot COUNT changed with the tile's width, and the whole thing only lined up
     where snapNavPanelsToGrid had rounded the box out to whole cells. Below 620
     that pass bails (`if (cell < 20) return`), so phones got a visibly
     different treatment from everything else.

     Sized to the tile instead, none of that applies: eight marks at any width,
     on any box, snapped or not. --grid-size is not referenced here. --grid-dot
     still is, but only for the COLOUR — the geometry is entirely local.

     Tokens rather than three copies of the same block, because nav links,
     footer nav links and the hero buttons all carry this and drifting apart is
     exactly what happened to the treatments they replaced. background-size and
     -repeat take one value each: the spec repeats a short list across all
     layers, so one declaration covers all eight. */
  --dot-r: 1px;
  --tile-markers:
    radial-gradient(circle, var(--grid-dot) var(--dot-r), transparent var(--dot-r)),
    radial-gradient(circle, var(--grid-dot) var(--dot-r), transparent var(--dot-r)),
    radial-gradient(circle, var(--grid-dot) var(--dot-r), transparent var(--dot-r)),
    radial-gradient(circle, var(--grid-dot) var(--dot-r), transparent var(--dot-r)),
    radial-gradient(circle, var(--grid-dot) var(--dot-r), transparent var(--dot-r)),
    radial-gradient(circle, var(--grid-dot) var(--dot-r), transparent var(--dot-r)),
    radial-gradient(circle, var(--grid-dot) var(--dot-r), transparent var(--dot-r)),
    radial-gradient(circle, var(--grid-dot) var(--dot-r), transparent var(--dot-r));
  --tile-marker-size: calc(var(--dot-r) * 2) calc(var(--dot-r) * 2);
  /* Reading around the tile: three across the top, two halfway down the sides,
     three across the bottom. Each layer's box is one dot wide and no-repeat, so
     these place a box rather than setting a pattern's phase — `center top` is
     literally the middle of the top edge and needs no correction term. */
  --tile-marker-pos:
    left top, center top, right top,
    left center, right center,
    left bottom, center bottom, right bottom;

  /* --- The pinned bar's own padding ----------------------------------------
     Above and below the link row, on all four page types. One token because
     three rules set it — the landing bar has one for each side of 620 and the
     inner and case pages share a third — and three copies of a number that is
     supposed to be the same number is how they drift.

     It was calc(var(--grid-size) / 2): half a cell, which gave 10 up here and
     9 on a phone, and tracked the lattice for free. 15 does not divide the
     cell, so the tie to --grid-size is given up and the two values are stated.

     The bar comes to 70 on the desktop (15 + a 40px link box + 15) and 58 on a
     phone (13 + 32 + 13). Nothing downstream restates either of those: the
     hero's clearance, the inner header's own padding and the skip link's
     scroll-margin all read --bar-h, which setBarHeight measures. */
  --bar-pad: 15px;

  /* --- Let's Chat: the dotted stroke ---------------------------------------
     Four runs, one per edge, drawn as background rather than as a border.

     WHY NOT border-style: dotted. Because it is not adjustable. The browser
     picks the pitch from the border WIDTH — one dot on, one off — so a 1px
     dotted border is a 2px pitch and nothing can change it but making the
     stroke thicker, which is not what a finer line wants. At that density it
     read as a solid rule with a texture, which is what it was doing here.

     repeating-linear-gradient instead: a 1px hard stop, then transparent out
     to --chat-dot-pitch. Hard stops rather than a radial, so each dot is a
     crisp 1px square on the pixel grid instead of an antialiased smudge — at
     this size a round dot is mostly its own antialiasing.

     The pitch is a REQUEST, not a fixed number, and that is the whole of what
     was wrong before. Two constraints on the value: too tight and it goes back
     to reading solid, too loose and it becomes the eight marks the other links
     already use, which this component stands down on purpose. 6px puts about 17
     dots along a desktop tile's top edge and 6 down its side.

     WHY NOT repeating-linear-gradient, which is what this was. Because a
     repeating gradient keeps its pitch exactly and stops wherever the box ends,
     so the run ends on whatever remainder is left. The tile is 102 x 42 on a
     desktop nav, and 102 is not a multiple of 6: the top run's last dot landed
     at 96 with the right-hand column at 101, which reads as two dots jammed
     together at one corner and a hole at the next. The bottom-right corner had
     no dot at all, because that is the one corner where two runs END rather
     than start, and neither of them ends on it. Measured at 99 x 37 it was
     worse — a visible pair at both right-hand corners.

     background-repeat: round is the primitive for exactly this. It scales the
     tile so a whole number of them spans the box, so the pitch comes out near
     6 and lands flush at both ends: 5.94 across a 102px edge, 5.86 down a 41px
     one. The eye cannot see a tenth of a pixel of pitch; it can see a corner
     that does not close.

     Corners then need saying, and this is the fifth case the old note claimed
     not to need. With `round` the last tile's dot sits one pitch back from the
     far edge, so all four corners get their own 1px layer. Eight layers, and
     the four runs no longer have to be told to reach the ends. */
  /* --- The field's GAP, and nothing else it has ---------------------------
     One number is taken from the lattice: the pitch. --grid-size, so the marks
     sit on the same rhythm the letters do and follow the breakpoint on their
     own — 20 on a desktop, 18 on a phone, no second number to keep in step.

     It was 6px. That is a big loosening, about 5 dots along a desktop tile's
     top edge where there were 17, and it is the point: 6px read as a fine
     broken rule at arm's length, and at the field's own pitch the marks read
     as marks.

     --grid-dot for the colour, so the marks are the field's own grey rather
     than the label's white. Two of the three things that make a dot now come
     from the lattice — its colour and its gap — and the third does not:

     THE SHAPE STAYS SQUARE. The field draws a 1px-RADIUS circle; this draws a
     1px hard-stop square. At that size a square is a crisp pixel on the grid
     and a circle is mostly its own antialiasing, and the difference matters
     here in a way it does not in the field: these eight layers have to close
     four corners, and a radial sits at its tile's CENTRE while a square sits
     at its START. Rounding the dot puts a pair of marks half a pitch apart at
     every corner, or — with the corner layers dropped to avoid that — leaves
     all four corners open, which reads as a rounded rectangle rather than the
     square one this is.

     Not currentColor any more, and that costs the inversion it used to get for
     free. It does not matter: the hover state removes the stroke outright (see
     below), so there is no state where these marks are drawn over the fill. */
  /* --- Onto the D tile's dot columns ---------------------------------------
     The last step of the alignment, and the one that makes the two lattices one
     lattice. Everything above puts the outline on the field's PITCH; this puts
     it on the field's COLUMNS.

     Measured before it, by scanning the rendered pixels rather than the CSS —
     the leftmost mark of each lattice, and the run behind it:

       1470   chat 49.5 / runs 59.5, 79.5      D 59.5, 79.5, 99.5
        390   chat 26.5 / runs 35.5, 53.5      D 27.0, 44.5, 62.5

     So the desktop runs were already on the D's columns and the leftmost mark
     was ten short of them; the phone runs were half a cell off outright.

     Two terms up here:

       --grid-size / 2   where a radial puts its dot inside its own cell, which
                         is where the D tile's dots sit relative to its edge
       --dot-r           the one pixel this box already overhangs its link by,
                         so a corner mark is not clipped

     Resolving to 11, which is exactly the gap the desktop had.

     It read `... - var(--tile-phase-x, 0px)` for as long as the letters were
     phase-matched to the page field, which is what made the phone come out at
     1 rather than 11. That phase is gone — see .hero__tile in the phone block
     for why it could not hold below 620 — so the phone states its own value in
     the phone :root rather than deriving it from a variable that no longer
     exists. Nothing about the desktop changed. */
  --chat-lattice-x: calc(var(--grid-size) / 2 + var(--dot-r));

  /* Slow enough to be noticed rather than watched. At 6s a 288px perimeter is
     about 48px a second, which is a drift rather than a movement: the eye
     catches it in peripheral vision and does not follow it. */
  --chat-glow-time: 6s;
  --chat-dot-pitch: var(--grid-size);

  /* --- The travelling glow --------------------------------------------------
     Nothing moves round this rectangle. What was here was a NINTH background
     layer — one extra mark in --grid-dot-lit running the perimeter over the
     eight static ones — and that is an object arriving, which is what it read
     as. The outline's own dots brighten and dim in sequence instead, one
     closing as the next opens, so what travels is a glow and not a thing.

     A chase, in the marquee sense: the light appears to move because the
     fixtures light in turn. Built as a travelling mask, the same mechanism as a
     skeleton shimmer with the window following a perimeter instead of sweeping
     across.

     ONE ELEMENT, and that constraint is what shapes everything below. The
     obvious build is two copies of the outline — a dim one and a lit one with a
     window cut in it — and there is no second pseudo-element to put it on:
     ::after is the 44px tap target on the nav rows (see the block near the
     bottom of this file, which lists what is already spoken for) and the eight
     marker dots on the /about/ and /resume/ buttons.

     So the eight layers are painted permanently at GLOW colour and a mask
     decides how much of that shows. One colour at two brightnesses, which one
     element can hold; two colours, which it cannot. See --chat-glow-rest for
     the number that makes the resting state come out unchanged, and the
     ::before rule for the mask itself. */
  --chat-ink: var(--grid-dot);
  --chat-stroke:
    linear-gradient(to right, var(--chat-ink) 0 2px, transparent 2px),
    linear-gradient(to right, var(--chat-ink) 0 2px, transparent 2px),
    linear-gradient(to bottom, var(--chat-ink) 0 2px, transparent 2px),
    linear-gradient(to bottom, var(--chat-ink) 0 2px, transparent 2px),
    linear-gradient(var(--chat-ink) 0 0),
    linear-gradient(var(--chat-ink) 0 0),
    linear-gradient(var(--chat-ink) 0 0),
    linear-gradient(var(--chat-ink) 0 0);
  /* 2px, which is the field's dot measured rather than described: --grid-dot
     is painted by a radial with a 1px RADIUS, so the mark is two pixels across.
     These were 1px squares and read as half the weight of the lattice they sit
     on. Both axes change — the run's thickness and the mark's length are the
     same number. */
  --chat-stroke-size:
    var(--chat-dot-pitch) 2px, var(--chat-dot-pitch) 2px,
    2px var(--chat-dot-pitch), 2px var(--chat-dot-pitch),
    2px 2px, 2px 2px, 2px 2px, 2px 2px;
  /* The two axes are independent per layer: the horizontal runs round across
     and never repeat down, the vertical ones the reverse, and the corners are
     single marks. This has to be a token because it varies per layer — writing
     `background-repeat: no-repeat` by hand would flatten all eight.

     The four corner layers are back with the square dot they were drawn for. A
     1px mark sits at the START of its tile, so each run's first dot lands on a
     corner and these four close the far ends. (They were briefly dropped while
     the dot was a radial, which sits at the tile's CENTRE instead — that put a
     pair of dots half a pitch apart at all four corners.) */
  --chat-stroke-repeat:
    round no-repeat, round no-repeat,
    no-repeat round, no-repeat round,
    no-repeat, no-repeat, no-repeat, no-repeat;
  /* Flush to the box, all eight. The runs were offset half a pitch so their
     first mark matched a hero tile's, which put this lattice on TWO phases at
     once: the corners and the vertical runs sat on the box's edge, the
     horizontal runs half a cell in from it. Measured on a 1470, the outline's
     leftmost mark was at 49.5 while its own top run started at 59.5.

     One phase for the whole outline, and the box is then moved as a unit onto
     the D tile's columns — see the translate below. */
  /* All eight, and nothing here moves any more. The orbit used to write this
     property, which meant the keyframes had to restate all eight static values
     at every step just to move the ninth — background-position is a single
     property and an animation has to name every layer. The glow writes
     mask-position instead, on two mask layers of its own, so background-position
     is static again and these eight are declared in exactly one place. */
  --chat-stroke-pos:
    left top, left bottom, left top, right top,
    left top, right top, left bottom, right bottom;

  /* --- What the glow costs the resting state: nothing -----------------------
     0.54 is not a taste value. The outline is painted in --grid-dot-lit and
     then held down by a flat mask, so the resting dot is (lit x rest)
     composited over the page. Solving that back to today's --grid-dot:

       dark    rgba(240,240,239,.42) over #0c0c0d  ->  #404040   at 0.543
       light   rgba(17,17,17,.34)    over #ffffff  ->  #d4d4d4   at 0.531

     Two palettes whose lit token is white on one and black on the other, and
     they land 0.012 apart. One constant serves both, and the button at rest
     stays within about 1/255 of what it was before any of this.

     Reach is one pitch either side of centre, stated as geometry rather than
     tuned by eye: centred on a dot, that dot is at full and its neighbours are
     at zero; centred between two, both sit at half. That is the handover — one
     closing as the next opens — and it is what --chat-glow-size means. (The
     runs repeat with `round`, so the real pitch differs from nominal by the
     rounding error. It softens the handover and nothing else.) */
  --chat-glow-rest: 0.54;
  --chat-glow-r: var(--chat-dot-pitch);
  --chat-glow-size: calc(var(--chat-glow-r) * 2);

  /* The scrim under a pinned mobile bar. Mobile only — nothing above 620
     (landing) or 860 (inner and case pages) has a pinned bar to read it.

     Four surfaces were tried across the strip and each failed the same way,
     which took a while to see: it was never the WEIGHT that was wrong, it was
     the EDGE. Opaque, half-strength, dotted, absent — every one of them draws a
     hard horizontal line wherever its bottom happens to land, and on a phone
     that is usually straight across a card's top corner. That line is what
     reads as the page being cut off, not the darkness above it.

     Per-word plates were the fifth, and they trade one edge for eight: over a
     bright card the gaps between the words show through as bright slots and the
     row comes apart into fragments.

     So: no edge. The scrim is opaque enough to read against where the words
     are, then dissolves over --bar-fade into nothing, and there is no boundary
     anywhere for the eye to catch. The page does not stop at the bar; it fades
     out under it.

     88%, measured at ten scroll positions down a 390 with the bar hidden so
     what is read is the real backdrop. Nav ink #f0f0ef, against the
     95th-percentile backdrop and against the brightest pixels of a card
     screenshot passing under:

       4.2% (--tile-bg)   3.01:1    1.09:1     nothing but the tile
       50%                7.30:1    3.21:1     the wash
       72%               11.41:1    7.55:1
       88%               14.96:1   13.03:1     <- here

     It can afford to be this heavy precisely because it has no edge: what made
     88% unacceptable before was the 12px blur and the hard line under it, not
     the fill. Built from --bg rather than stated per palette, so it is a wash
     of whatever the page background is — a light black on the dark palette, and
     on the light one a white one, which is the same idea the other way up. */
  --bar-scrim: color-mix(in srgb, var(--bg) 88%, transparent);

  /* How far past the bar the scrim takes to reach nothing. Under one cell and
     the falloff is visible as a band in its own right; much over two and it
     starts dimming content well clear of the bar. 26 is between them. */
  --bar-fade: 26px;

}

/* --- Dark theme ---------------------------------------------------------
   Follows the OS, and data-theme on <html> overrides either way. Both
   selectors are (0,1,1), so they beat the plain :root blocks that case.css
   and page.css declare later in the cascade. The palette is written twice
   because CSS can't share one declaration block between a media query and
   an attribute selector — keep the two lists in sync.
   ------------------------------------------------------------------------ */

@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) {
    --bg: #0c0c0d;
    --ink: #f0f0ef;
    --ink-soft: #8a8a88;
    --ink-invert: #0c0c0d;
    --chip-bg: #1c1c1e;
    --chip-ink: #a8a8a6;
    --chip-bg-hover: #26262a;
    --rule: #232326;
    --panel: #141416;
    --panel-2: #1c1c1f;
    --media-frame: #141416;
    --card-hairline: rgba(255, 255, 255, 0.09);
    --card-hairline-hover: rgba(255, 255, 255, 0.16);
    --card-lift: 0 18px 38px -18px rgba(0, 0, 0, 0.75);
    --phone-drop: 0 14px 20px rgba(0, 0, 0, 0.72);
    --phone-drop-hover: 0 22px 30px rgba(0, 0, 0, 0.82);
    --pill-bg: #232326;
    --hairline: #34343c;
    --ok: #4cbf7e;
    --placeholder: #3a3a3e;
    --grid-dot: #404040;
  --grid-dot-lit: rgba(240, 240, 239, 0.42);
  --tile-bg: rgba(255, 255, 255, 0.042);
  }
}

:root[data-theme="dark"] {
    --bg: #0c0c0d;
  --ink: #f0f0ef;
  --ink-soft: #8a8a88;
  --ink-invert: #0c0c0d;
  --chip-bg: #1c1c1e;
  --chip-ink: #a8a8a6;
  --chip-bg-hover: #26262a;
  --rule: #232326;
  --panel: #141416;
  --panel-2: #1c1c1f;
  --media-frame: #141416;
  --card-hairline: rgba(255, 255, 255, 0.09);
  --card-hairline-hover: rgba(255, 255, 255, 0.16);
  --card-lift: 0 18px 38px -18px rgba(0, 0, 0, 0.75);
  --phone-drop: 0 14px 20px rgba(0, 0, 0, 0.72);
  --phone-drop-hover: 0 22px 30px rgba(0, 0, 0, 0.82);
  --pill-bg: #232326;
  --hairline: #34343c;
  --ok: #4cbf7e;
  --placeholder: #3a3a3e;
  --grid-dot: #404040;
  --grid-dot-lit: rgba(240, 240, 239, 0.42);
  --tile-bg: rgba(255, 255, 255, 0.042);
}

/* --- The site is dark, and only dark ------------------------------------

   Every page carries data-theme="dark" on <html>, so :root[data-theme="dark"]
   above wins outright: it is a top-level rule at (0,2,0) against the plain
   :root light block's (0,1,0), which means the OS setting no longer decides
   anything. It used to. The site shipped following prefers-color-scheme, and a
   phone in light mode served the whole thing white.

   That was inherited rather than chosen. The template was sold as a dual-theme
   portfolio, and the second palette came with it. Nothing on the site ever set
   data-theme, so a reader could not opt into either one: they got whatever
   their phone happened to be set to. Meanwhile the design is art-directed for
   near-black — the dot lattice, the tile wash, the mark — and every share asset
   (og image, LinkedIn card) is dark, so a light-mode reader tapped a dark card
   and landed somewhere that looked like a different site.

   `dark` alone, not `light dark`: this tells the browser to render form
   controls, scrollbars and the pre-paint canvas dark too. Left as `light dark`
   the first paint can flash white before the stylesheet lands.

   THE LIGHT PALETTE IS DELIBERATELY STILL HERE, in the :root block near the
   top of this file. It is contrast-checked and complete, and it is the escape
   hatch: remove the attribute from the nine <html> tags and put `light dark`
   back and the site follows the OS again. Do not delete it to tidy up. */
html { color-scheme: dark; }

/* --- Reset --------------------------------------------------------------- */

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

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

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  /* One face for everything that is read. Montserrat is a geometric DISPLAY
     face and it was doing text work on the case studies, which are the longest
     copy on the site: at the 640px prose column it filled a line with 54.9
     characters against SF's 66.6, so the measure was running short of the
     comfortable band on the pages that most needed to be inside it. Same
     sentence at 19px/400 is 723.5px in Montserrat and 585px in SF.

     Montserrat survives on the three mark components, which name it
     themselves — see .logo-wm, .hero__wordmark and .title-wm in page.css. */
  font-family: var(--font-sf);
  font-size: var(--fs-ui);
  font-weight: var(--fw-ui);
  line-height: 1.5;
  font-feature-settings: "kern" 1, "liga" 1, "calt" 1;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
  text-rendering: optimizeLegibility;
}

h1, h2, h3, p, ul, ol, figure { margin: 0; }
/* ol is used for ordered content (TOC, redesigned page flow) where the
   numbering is either absent or baked into the label — never auto-numbered */
ul, ol { padding: 0; list-style: none; }
img, svg { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }

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

.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
}

.skip-link {
  position: fixed;
  top: 12px;
  left: 12px;
  z-index: 100;
  padding: 10px 18px;
  background: var(--ink);
  color: var(--ink-invert);
  border-radius: 100px;
  font-size: 15px;
  transform: translateY(-200%);
  transition: transform 0.25s var(--ease);
}
.skip-link:focus-visible { transform: none; }

/* Clear the pinned bar, for the same reader the skip link above is for.

   Every in-page jump on this site parks its target at y=0, which since the bar
   pins is behind it: the landing skip link and its Work link at #work, the
   inner pages' skip link at #main, and on a case page the five links the rail
   generates (that pair is in case.css, beside the rail that makes them).

   Bar plus one cell of air, so a heading arrives on the lattice rather than
   flush against the bar's own edge. 80 for the landing bar's 60, 100 for the
   inner header's 80. The phone values are stated separately, later, off their
   own smaller bars — they win on cascade order and are not touched here.

   #main matters more than it looks: it is the skip link's target on /about/
   and /resume/, so without this the accessibility path is the one route that
   lands under the bar. */
#work { scroll-margin-top: 80px; }
#main { scroll-margin-top: 100px; }

/* --- Shell --------------------------------------------------------------- */

.shell {
  width: 100%;
  max-width: var(--shell);
  margin-inline: auto;
  padding-inline: var(--gutter);
}

/* Home only: the gutter itself lands on a dot column.

   The wordmark and the nav are put on the lattice by snapTilesToGrid and
   snapNavToGrid. The lead, the work grid and the footer were not — they sat on
   the raw gutter, so the page ran two left axes at once: 48 against the mark's
   50 at 1470, and 20 against 30 at 400. Small enough to look like a mistake
   rather than a choice, and it widens with the cell.

   The inner pages solved this with --page-snap-x on .m-page. The home page has
   no .m-page — its content is directly in .shell — so the shell's own padding
   is the equivalent single number, and moving it moves every block inside at
   once. The wordmark's own shift then resolves to 0, because the tile is
   already on a dot when snapTilesToGrid next measures it.

   padding-INLINE, not padding-left, and that is load-bearing: the shell has to
   stay symmetric or every full-bleed breakout inside it (the hero dot field,
   the footer's mirrored field, the status bar) stops resolving to viewport 0.
   They are all `calc(50% - 50vw)`, which only lands on the edge when the
   containing block is centred. Symmetric padding keeps all three correct with
   no change of their own.

   snapHomeGutterToGrid in js/site.js writes it; the 0px fallback means a page
   with no JS is exactly what it was before. */
.is-home .shell {
  padding-inline: calc(var(--gutter) + var(--home-snap-x, 0px));
}

/* --- Header -------------------------------------------------------------- */

.site-header {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  padding-top: 24px;

  /* The header reserves the bar, whatever the bar currently is.
     ----------------------------------------------------------------------
     This box is what holds the top of an inner or case page open once the nav
     has left the flow, and it used to do it by arithmetic that happened to
     agree: 24px of padding under a 40px mark is 64, against a bar of 60. Four
     pixels of margin, and nobody had written down that it was load-bearing.
     Taking --bar-pad from 10 to 15 put the bar at 70 and the agreement went
     the other way, with six pixels of page under an opaque strip.

     Read rather than restated, so the two cannot disagree again. setBarHeight
     measures the bar after the pass that sizes its links; the fallback is the
     desktop height, which over-reserves on a phone rather than under-reserving.

     min-height, not height: on a phone the 860 block gives this box a much
     larger padding so the mark can sit below the bar, and that has to win. */
  min-height: var(--bar-h, 70px);
}

/* --- The pinned bar's surface: one treatment, both bars, every width --------
   The --bar-scrim note lists four surfaces tried across this strip and why each
   failed, and the desktop bar went on to try three of them again before landing
   back here. Worth recording, because the failures look different at 1470 and
   are the same failure:

     opaque --bg          a black band across a page whose whole surface is
                          dots — the one horizontal strip with no lattice in it
     opaque + the dots    matches the field exactly, and then the bar crosses
                          the /about/ portrait and paints dots over where a
                          photograph is. Right against one backdrop, wrong
                          against the next
     translucent, no fade the copy and the image read through the row

   The token's conclusion covers all three: "it was never the WEIGHT that was
   wrong, it was the EDGE." Any surface with a bottom line has to agree with
   whatever that line lands on, and it cannot, because what it lands on changes
   as the page moves. Dots are the sharpest version of the trap — they agree
   with the field perfectly and disagree with everything else.

   So: no edge, at any width. Opaque enough to read against where the words are,
   then dissolving over --bar-fade into nothing. There is no boundary anywhere
   for the eye to catch, so there is nothing to match against the image, the
   copy or the field. The page does not stop at the bar; it fades out under it.

   That makes the desktop treatment identical to the phone's, which is why this
   is one rule again rather than a solid version up here and a scrim down there.
   The two selectors are the two elements that actually pin: .nav--hero at every
   width, and on an inner page the NAV rather than the header, since above 861
   the header stays in flow so the mark can scroll away with the hero. The
   header's own scrim lives in the 860 block, where the header is what sticks.

   No blur anywhere, and none was ever added: backdrop-filter would be a new
   compositing layer over an 8000px case page, and the dissolve does the work.

   A pseudo-element rather than a background on the bar itself, because the box
   has to be TALLER than the bar for the falloff to happen outside it, and
   z-index: -1 keeps it behind the links inside each bar's own stacking context.

   Gated on .is-stuck, and the gate is load-bearing rather than tidy: at rest
   the only thing behind either bar is the dot field, and a scrim over three
   rows of dots erases them. The surface arrives once there is something worth
   covering. site.js/buildStickyNav writes the class, and has always written it
   at every viewport — only this CSS was ever breakpointed. */
.nav--hero.is-stuck::before {
  content: "";
  position: absolute;
  z-index: -1;
  inset: 0;
  pointer-events: none;
  background: var(--bg);
}

/* --- .is-away: the bar stands down once the footer can do its job ----------
   The top bar exists to keep Work / About / Résumé reachable. The footer row
   carries the same links, so from the moment it is on screen the bar is a
   second copy of navigation the reader already has — and it is the copy sitting
   on top of the content. buildStickyNav watches the footer's own Let's Chat and
   writes the class when it comes into view; see its note for why that trigger
   rather than a scroll direction.

   Only .nav--hero is handled here, and the split still matters even though the
   two sides now agree. The class always lands on the element buildStickyNav
   picked, but WHICH element may move is not the same thing: the landing bar is
   .nav--hero and is fixed, so translating it is always right. On an inner page
   the header stays in flow so the mark can scroll away with the hero, and only
   the nav pins — moving the header would drag DHरUV up the page. That rule sits
   with the rest of the pin, in "The nav leaves the flow" further down.

   This used to be a genuine split, with the phone translating its whole sticky
   header out of a media query of its own. There is one bar at every width now
   and that branch is gone.

   -100% of its own height, so the number follows the bar rather than being
   restated anywhere. `transform` composes with the `translate` property the
   nav snap writes — individual transform properties resolve before `transform`
   — so the two do not fight and neither has to know about the other. */
.nav--hero { transition: transform 0.26s var(--ease); }
.nav--hero.is-away { transform: translateY(-100%); }

/* A header with no mark in it — the résumé, whose name is the page title
   below. space-between with one child resolves to flex-start, which would
   throw the nav across to the left; this puts it back where it sits on every
   other page. */
.site-header--solo { justify-content: flex-end; }

/* --- Centred mark --------------------------------------------------------
   /about/ puts the portrait on the page's centre axis and the mark above it on
   the same one, so the two read as a single column. Three tracks rather than
   space-between: 1fr on each flank is what centres the middle one against the
   PAGE, not against whatever width the nav happens to be.

   The mark gives up its dot-column snap here. It cannot be both centred on the
   image and on the lattice — those are different x values — and the whole
   point of the arrangement is that the mark and the image share an axis.
   Its vertical snap is untouched, so it still sits on a dot row.

   Not applied below 860px: the header wraps there, and a centred mark over a
   full-width nav row reads as a mistake. -------------------------------- */
@media (min-width: 861px) {
  .site-header--centred {
    display: grid;
    grid-template-columns: 1fr auto 1fr;
    align-items: center;
  }
  .site-header--centred .logo { grid-column: 2; justify-self: center; }
  .site-header--centred .nav { grid-column: 3; justify-self: end; }

  /* --mark-drop lowers the mark out of the header row until it sits just above
     the portrait, so the two read as one stacked unit rather than a brand at
     the top of the page and a picture much further down. dropMarkToImage in
     site.js measures the distance and writes it in whole cells, which is what
     keeps the mark on the dot row --logo-snap-y put it on.

     A relative offset, not a margin: this must NOT change the header's height,
     because the header's height is what places the hero, which is what the
     drop is measured against. Margins would put that in a loop; `top` paints
     the mark somewhere else and leaves the layout alone.

     The nav does not come with it — snapHeaderNavToGrid reads --logo-snap-y
     directly rather than the resolved `top`, so it takes the mark's grid snap
     and not its drop, and stays on the header row where it belongs. */
  .site-header--centred .logo-wm {
    left: 0;
    top: calc(var(--logo-snap-y, 0px) + var(--mark-drop, 0px));
  }

  /* --- DHरUV carries the lattice, one letter at a time ----------------------
     /about/ drops the mark out of the header row to sit above the portrait, so
     on this page those five tiles are the hero rather than a logo in a corner —
     which is why they get the field and the small marks on the case pages do
     not.

     It divides exactly, and that is not luck. --logo-tile is
     calc(var(--grid-size) * 2), so a tile is two cells square at every width
     this rule applies to: four dots per letter, one per quadrant, each on the
     tile's own centre lines. Painted from the tile's top-left, so the pattern
     belongs to the rectangle rather than to a phase inherited from a field that
     no longer exists anywhere else on the page.

     Longhands rather than `background`, for the reason the nav tiles give: the
     shorthand would drop the wash, and the tile needs both. --tile-bg carries
     an alpha, so the dots sit ON the wash rather than punching through it, and
     the whole thing still transitions to --chip-bg on hover — background-color
     is the only animated property and it is untouched by the image above it. */
  .site-header--centred .logo-wm__tile {
    background-image: radial-gradient(var(--grid-dot) 1px, transparent 1px);
    background-size: var(--grid-size) var(--grid-size);
  }

  /* --- Pinned, on the desktop too -----------------------------------------
     The bar pinned on a phone and scrolled away here, which on a landing page
     that is mostly work grid and on case studies that run seven screens left
     one route back to Work / About / Résumé: scroll to the top.

     THE NAV PINS, NOT THE HEADER, and that is the whole design of this block.

     Sticking the whole <header> was the first attempt and it took DHरUV with
     it. On /about/ the mark is dropped ~140px below the header row by
     --mark-drop so it sits directly above the portrait — that IS the page's
     composition — and a sticky header carries that offset down the viewport:
     a 227px wordmark travelling over the copy for the length of the page.
     Collapsing the drop on .is-stuck fixed the travelling but produced the
     other wrong answer, the mark snapping up INTO the bar and living there.

     The mark belongs to the hero. So the header stays in flow and scrolls away
     with the hero exactly as it always did, mark and all, and the nav alone
     leaves the flow. That is the arrangement the home page has always had —
     .nav--hero pinned, the wordmark below it not — so the four page types now
     pin the same element rather than two different ones.

     Full width with the row pushed to its end, rather than a short bar sitting
     where the nav sits. Fixed resolves percentages against the viewport, so a
     bar that spans it can paint its own background edge to edge and needs no
     full-bleed breakout at all; the ::before both bars share stops being a
     special case. It also leaves the row's other end free, which is what the
     Let's Chat handoff below fills.

     31 rather than 30. It was raised to clear .case-toc, a positioned sibling
     later in the DOM on a case page; that rail is gone, and the value stays
     because .logo-wm is now pinned to 32 against it (see its note). Still
     under .page-comet (60) and the skip link (100), which has to stay
     clickable on top of a pinned bar.

     No padding-bottom is added anywhere. An earlier pass gave the header 16px
     of it to stop copy kissing the nav, which moved every page down; with the
     nav out of flow the header's own box is unchanged and nothing below it
     moves at all. */
     The rules that do it are NOT here. They sit after the base
     `.site-header .nav` block further down, which declares position: relative
     and would otherwise win on source order at equal specificity — the two are
     (0,2,0) apiece, so the later one takes it. Search for "the nav leaves the
     flow". This block keeps only what has no such conflict. */

}

/* --- The landing bar, pinned above the phone breakpoint --------------------
   The same treatment .nav--hero already takes at 620 and below, for the same
   reason, now that the reason is not phone-shaped.

   Fixed rather than sticky, and that is forced rather than chosen: a sticky
   element is held by its own parent's box, and this nav's parent is .hero,
   which ends around y=700 at 1470 — before the work grid starts. Sticky would
   need the nav moved out to .shell, and that move would take the landing dot
   field's origin (.hero__grid is absolute inside .hero and starts at document
   0 by construction), buildHeroGrid's pointer region (its listeners are on
   .hero, so the highlight dots would stop responding across the top strip) and
   snapNavToGrid's entire frame of reference with it. Three regressions to buy
   a keyword.

   The trade fixed asks for instead is one thing: out of flow means the hero no
   longer reserves the bar's height, so .hero pays for it in padding below.

   padding-left rebuilds .shell's column, because fixed resolves percentages
   against the viewport rather than the shell — the same expression
   .is-home .shell uses, and the same one the phone rule already rebuilds from.
   margin-left survives on top of it and still carries --nav-shift-x and
   --nav-panel-x, so the first link's TILE lands on the D tile's left edge
   exactly as it does now.

   Half a cell top and bottom: with the lattice's two-cell link box between them
   the bar is three whole cells and holds three complete rows of dots (10, 30,
   50), with the tile's own top and bottom edges on the first and third. That is
   the vertical half of "every edge on a dot", and with the row pinned it is CSS
   that has to supply it — the same argument the phone block makes at an 18px
   cell. */
@media (min-width: 621px) {
  .nav--hero {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 50;
    /* The shell's gutter on both sides, and nothing added to it — which is the
       whole point, because the other two bars pad with exactly this and the
       three are now one component seen on three pages.

       Both ends used to be measured against this page's own furniture instead.
       The left rebuilt the gutter PLUS --home-snap-x, the 2px that lands the
       home page's column on a dot; the right closed on --wm-row-w, the
       wordmark's measured row width, so Résumé's tile would sit over the V
       rather than short of it. Each was right about the landing page and wrong
       about the set: measured at 1470 it put this row's links at 1110 / 1210 /
       1310 against 1142 / 1222 / 1322 on /about/ and /resume/, so moving
       between pages shifted the same three words 32px sideways.

       That is the trade, stated rather than buried: the bar no longer brackets
       DHरUV. It cannot do both — the mark's whole-cell tiles stop up to a cell
       and a half short of the content edge, which is 10px at 1470 but 60px at
       900, so a bar closing on the mark is a bar that disagrees with the other
       two by a different amount at every width. Matching the bars is the one
       that a reader can actually check, because they see it by navigating;
       nobody compares a nav row to a wordmark across a page load.

       A fixed box resolves percentages against the viewport, so the gutter has
       to be rebuilt here rather than inherited from .shell. Same expression as
       the inner bars', which is why the numbers land identically. */
    /* LEFT takes --home-snap-x as well; the right does not, and the asymmetry
       is deliberate.

       --home-snap-x is the 2px at 1470 (11.5 at 1180) that lands this page's
       column on a dot column, and .shell spends it on both sides — so the mark
       starts at 50 while a bar padded by the bare gutter starts at 48. Two
       pixels of nothing, until the tile started carrying the lattice: the box's
       dots then ran two pixels out of step with the dots in the letters
       directly below it, and that is visible precisely because the two are the
       same pattern at the same pitch.

       The RIGHT end stays on the plain gutter, so Work / About / Résumé still
       land identically on all three pages — the alignment argued for at length
       above, and the one a reader checks by navigating, is untouched. What
       moves is the left end: Let's Chat here, and on the inner pages a contact
       link that does not appear until the hero has scrolled past.

       Inherited rather than re-derived: snapHomeGutterToGrid writes
       --home-snap-x on .shell and this nav is inside it. The 0 fallback covers
       every page that has no such snap, which is every page but this one. */
    padding-left: calc(var(--gutter) + var(--home-snap-x, 0px));
    padding-right: var(--gutter);
    padding-block: var(--bar-pad);
  }

  /* --- Why this row cannot also bracket DHरUV --------------------------------
     There was a .nav--hero.is-lattice rule here that padded both ends from the
     mark's own measured geometry — snapHero publishes --wm-left and --wm-row-w
     — so Let's Chat's tile landed on the D's left edge and Résumé's on the V's
     right. It worked exactly, 0.00px at every width from 900 to 2560. It is
     gone because it can only be right about one page at a time.

     The two alignments are mutually exclusive, and the reason is the mark, not
     the bar. --wm-row-w is a whole number of cells by construction, so the V's
     right edge lands wherever the last whole tile ends — up to a tile and a gap
     short of the content edge. Measured: 1410 against a 1422 content edge at
     1470, and 1330 against 1350 at 1400. A bar closing on the mark therefore
     sits 12px inside the other two bars at 1470 and 20px at 1400.

     /about/ and /resume/ cannot follow it there. They have no wordmark, so
     there is nothing on those pages to measure — the number would have to be
     re-derived from the landing page's tile-sizing algorithm AND its shell
     capping, on pages that have neither, and two copies of that arithmetic
     would drift the first time either changed.

     So the axis that is shared wins. The three bars pad with the same
     var(--gutter) and the same expression, and land identically: Work at 1142,
     About at 1222, Résumé at 1322 on all three at 1470. The cost is that
     Résumé's hover tile ends 12px past the V, which is visible on one screen.
     The gain is that the row does not jump when you navigate, which is visible
     on every navigation.

     To put it back: restore the rule above this note. Both properties are still
     published — nothing in snapHero was removed. */

  /* Let's Chat holds the left end alone; the other three group at the right.

     margin-right: auto on the one item, NOT space-between on the row. This is
     the same call the inner pages' contact handoff makes and it is worth
     restating: space-between spreads all four evenly and breaks the group up,
     which is the opposite of what "the three links sit together at the right"
     means. An auto margin absorbs the free space at one point instead, so the
     chat link sits on the gutter and the other three stay exactly where the
     row's end puts them.

     > a, child rather than descendant, so nothing nested inside a link can
     pick it up. :first-child rather than naming the chat link, because the
     markup's first item IS the left end by definition — reorder the nav and
     this follows it. */
  .nav--hero > a:first-child { margin-right: auto; }

  /* The height the bar stopped reserving, plus two cells of clear field,
     ceiled onto a dot row. snapNavToGrid computes it — see its pinned branch,
     which reproduces the phone block's hand-set 99 from the same expression —
     and the literal is the no-JS floor. It has to clear the bar or a blocked
     script paints DHरUV underneath it.

     The wordmark rises 20px against what this page did before (130 to 110).
     A bar that no longer holds 50px of air above itself cannot also hold the
     mark where that air used to put it; the two cells below the bar are the
     part worth keeping, and they are kept. */
  .hero { padding-top: var(--hero-pad-top, 110px); }
}

.logo {
  display: block;
  width: 151px;      /* renders 53px tall from the 284x100 viewBox */
  color: var(--ink);
}
.logo svg { width: 100%; height: auto; }

/* Logo: on hover the geometric marks collapse away and the C closes up
   against the D, resolving the mark into a plain "DC" monogram.
   The viewBox width never changes, so the header never reflows. */
.logo__marks > * {
  transform-box: fill-box;
  transform-origin: center;
  transition: opacity 0.32s var(--ease), transform 0.42s var(--ease);
}
.logo__c {
  transition: transform 0.58s var(--ease);
}

.logo:hover .logo__marks > *,
.logo:focus-visible .logo__marks > * {
  opacity: 0;
  transform: scale(0.35);
}
/* nearest the C goes first, so the collapse reads right-to-left */
.logo:hover .logo__marks > *:nth-child(5) { transition-delay: 0s; }
.logo:hover .logo__marks > *:nth-child(4) { transition-delay: 0.03s; }
.logo:hover .logo__marks > *:nth-child(3) { transition-delay: 0.06s; }
.logo:hover .logo__marks > *:nth-child(2) { transition-delay: 0.09s; }
.logo:hover .logo__marks > *:nth-child(1) { transition-delay: 0.12s; }

/* 195 (C's left edge) - 106 (tucked beside the D) = 89 user units */
.logo:hover .logo__c,
.logo:focus-visible .logo__c {
  transform: translateX(-89px);
}

/* Inner pages carry the compact monogram, matching the reference: the full
   mark belongs to the homepage, the collapsed form to everything below it —
   and at a much smaller size (~18px tall against the homepage's 53px). */
.logo--mono {
  width: 50px;                    /* 284:100 viewBox -> 17.6px tall */
}
.logo--mono .logo__marks { opacity: 0; }
.logo--mono .logo__c { transform: translateX(-89px); }

/* --- Inner-page logo: the hero wordmark at header scale -------------------

   The same DHरUV lockup the home page opens with — five tiles, one letter
   each, the centre one the Devanagari र in Khand — shrunk into the corner the
   DC monogram used to hold. Same tokens (--tile-bg, --ink), same proportions,
   so the corner mark and the headline are visibly one thing.

   DELIBERATELY NOT .hero__wordmark / .hero__tile. Both of those names are
   load-bearing elsewhere:
     - snapTilesToGrid() in site.js resizes .hero__wordmark to whole dot-grid
       cells, which would blow a 22px logo up to ~240px tiles;
     - `.hero__wordmark .line > span` is in the pre-paint visibility:hidden
       list and is revealed by buildLoadTimeline, which only runs on the home
       page — an inner-page logo using it would stay invisible forever.
   It keeps the .logo class, though, so it still rides the header's existing
   reveal (site.js: '.site-header .logo, .nav a').

   Ratios are lifted from the hero rather than guessed. There the tile is 240px
   at 1470 and the Latin letters are 154px (0.64) with the र at 185px (0.77) —
   Khand's cap sits lower in its em, so the larger size is what makes the two
   optically match. No mask here, so the र needs no padding compensation: it
   is free to overflow its own tile.
   ------------------------------------------------------------------------- */
/* Proportioned off the hero, sized off the dot grid.

   The tile is two whole grid cells, so it is square, lattice-derived and
   responsive for free — --grid-size drops to 12px under 620px and the mark
   follows. The GAP is the hero's ratio rather than a whole cell: measured on
   the landing page, its tiles run 240px with 40px between them, so the gap is
   exactly a sixth of the tile and the letter is 0.64 of it. A one-cell gap
   here made that ratio 0.5 — three times too loose, and the mark read as five
   separate boxes instead of one word.

   That is a real trade, so it is written down: keeping both would need a tile
   six cells wide (120px) for a one-cell gap to be a sixth of it, which is no
   longer a header logo. Proportion wins, and snapLogoToGrid still puts the
   mark's own top-left corner on a dot, so the row sits ON the lattice — its
   top and bottom edges run along dot rows for the full width. Only the inner
   tile edges no longer land on dot columns.

   position: relative carries the snap. NOT transform — the header's entrance
   (site.js: '.site-header .logo, .nav a') tweens y, and GSAP would overwrite
   it. Relative offsets compose with a transform instead of fighting it, and
   unlike margins they do not feed back into the header's align-items: center
   and move the thing being measured. */
.logo-wm {
  --logo-tile: calc(var(--grid-size) * 2);
  --logo-gap: calc(var(--logo-tile) / 6);   /* the hero's 40:240 */

  display: grid;
  grid-template-columns: repeat(5, var(--logo-tile));
  gap: var(--logo-gap);
  width: auto;                    /* overrides .logo's fixed 151px */

  position: relative;
  left: var(--logo-snap-x, 0px);
  top: var(--logo-snap-y, 0px);

  /* The mark has to out-stack the page content it is painted over.
     .site-header--centred drops it down out of the header row with --mark-drop
     until it sits just above the portrait, which puts it INSIDE the area
     .page-hero occupies. Both are position: relative with z-index auto, and
     the hero comes later in the document, so the hero was painting on top: the
     mark was visible but the anchor under it was unclickable, and
     elementFromPoint at its centre returned the <section>, not the link.
     Raising it here restores the click without touching the drop geometry.

     32, and the number is now set by the BAR rather than by the hero. Above
     861 the nav is position: fixed with left: 0 / right: 0 and z-index: 31 —
     a full-viewport-width box lying across the header band, whatever its
     content is doing at the right-hand end. On a case study the mark sits in
     that band rather than dropped below it, so the bar was covering it:
     elementFromPoint at the mark's top-left, centre and bottom-right all
     returned NAV.nav at 1470, and the link was dead on every case page while
     working on /about/, where --mark-drop moves it out of the band.

     Above the bar rather than pointer-events: none on the bar, because the bar
     is solid once stuck and clicks have to stop at it then. Nothing is lost by
     this ordering: the header scrolls away with the hero, so the two are only
     ever on screen together while the bar is transparent. */
  z-index: 32;

  /* The mark is not body copy, so it does not follow body's face. Stated here
     rather than inherited: body is SF now, and without this line the mark
     would quietly become SF on every page and stop being the same object as
     the landing hero. The र names Khand on --deva, which beats this anyway. */
  font-family: "Montserrat", "Helvetica Neue", Helvetica, Arial, sans-serif;
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.02em;
  user-select: none;
}

/* The field, inside the letters — the third of the three marks to take it,
   after .hero__tile on the landing page and .title-wm__tile on /resume/. Same
   gradient, same --grid-dot, same --grid-size, so all three are one field seen
   through different openings rather than three patterns that resemble each
   other.

   The tile is two cells here (--logo-tile is --grid-size * 2), so each letter
   shows a 2x2 patch of the lattice; at the 860 breakpoint it drops to 20px and
   shows roughly one. Sparse, but it is the page's own dot at the page's own
   pitch, which is the whole point of taking it from the token rather than
   sizing a pattern to the tile.

   background-image, not the shorthand: --tile-bg is the wash underneath and it
   has to survive. */
.logo-wm__tile {
  aspect-ratio: 1;
  display: grid;
  place-items: center;
  background: var(--tile-bg);
  background-image: radial-gradient(var(--grid-dot) 1px, transparent 1px);
  background-size: var(--grid-size) var(--grid-size);
  font-size: calc(var(--logo-tile) * 0.64);
  transition: background-color 0.28s var(--ease);
}

.logo-wm__tile--deva {
  font-family: "Khand", "Montserrat", sans-serif;
  font-size: calc(var(--logo-tile) * 0.77);

  /* Baseline correction. Khand's font box is 1.53em (ascent 1.05 + descent
     0.48) against Montserrat's ~1.22em, so centring the two line boxes does
     NOT put their baselines on the same line — the taller box hangs its
     baseline higher in the cell. Measured by ink-bounds scan at 5x, not by
     eye: the Latin letters bottom out at 46.0-46.2 and the र at 45.0, so it
     was sitting a full pixel high at a 22px tile.

     border-box, so the padding eats into the centred content box and moves
     the glyph down by half of it — hence 0.09, not 0.045. Expressed against
     --logo-tile so the correction scales with the mark at every breakpoint. */
  padding-top: calc(var(--logo-tile) * 0.09);
}

/* The monogram's hover collapsed the marks into a DC. There is nothing to
   collapse here, so the affordance is the panels coming up — the same
   token step the footer's icon tiles use. */
.logo-wm:focus-visible .logo-wm__tile {
  background: var(--chip-bg);
}
/* Hover gated: see the note on .nav a:hover::before. */
@media (hover: hover) {
  .logo-wm:hover .logo-wm__tile { background: var(--chip-bg); }
}

.nav {
  display: flex;
  align-items: center;
  gap: 35px;
  /* Both bars, from one token — see --fs-nav. On the element rather than the
     link so the flex gap and the snapped panels are sized against the same
     number the labels are. */
  font-size: var(--fs-nav);
}

/* Grid snap for the inner-page header nav, written by snapHeaderNavToGrid in
   site.js. Scoped to .site-header so the landing page's .nav--hero, which is
   not inside a header and uses margins instead, is untouched.

   position: relative for the same reason .logo-wm uses it: the header's
   entrance tween ('.site-header .logo, .nav a') writes transforms, and a
   relative offset composes with a transform rather than being overwritten by
   it. It also keeps the offset out of the header's align-items: center, so
   moving the nav does not move the thing being measured. */
.site-header .nav {
  position: relative;
  /* - --nav-panel-x for the same reason .nav--hero adds it: this row is
     right-aligned, so the edge that stands on a page axis is the LAST link's
     right edge — exactly the content edge on a case page, 1422 at 1470. Pull
     the row left by the hover panel's side padding and it is the panel that
     lands there, not the word. Same trade as the landing page, mirrored.
     snapHeaderNavToGrid writes only --nav-snap-x, so this composes with it. */
  left: calc(var(--nav-snap-x, 0px) - var(--nav-panel-x));
  top: var(--nav-snap-y, 0px);
}

/* Hidden everywhere by default, shown only by the handoff below.

   It must be `display: none` at base level rather than inside the desktop
   block: the phone header shares one row with the mark and was measured at 305
   of a 350px column with three links, so a fourth pushes the row over and takes
   the page into horizontal overflow. Scoping the hide to 861+ did exactly that,
   measured at 390. display, not opacity, so it also leaves the accessibility
   tree while it is not a thing anyone can reach. */
.nav__chat { display: none; }

/* --- The nav leaves the flow, and the mark does not -------------------------
   Placed here rather than in the 861 block above, because the base rule
   directly above declares `position: relative` at the same (0,2,0) specificity
   and would win on source order. Same reason the chat link's hide is at base
   level. See the note up there for the argument; this is the implementation.

   In short: sticking the whole <header> took DHरUV with it, because /about/
   drops the mark ~140px below the header row to sit above the portrait and a
   sticky header carries that offset down the page. The mark belongs to the
   hero, so the header stays in flow and scrolls away with it, and the nav alone
   pins. That is the arrangement the home page has always had.

   Full width with the row pushed to its end, rather than a short bar where the
   nav happens to sit: a fixed box spanning the viewport paints its own
   background edge to edge with no full-bleed breakout, and it leaves the other
   end of the row free for the contact handoff to fill.

   EVERY WIDTH, not 861 and up. This block was inside a desktop media query and
   the phone ran a different bar underneath it: the whole <header> went sticky,
   so the mark rode down the screen inside the row and the surface was a
   translucent scrim rather than the solid one. Two bars, two behaviours, one
   site. Unwrapping the query is the entire change — the declarations below are
   the ones that were already here, and the phone's own rules (the sticky
   header, its scrim, its is-away) came out of the 860 block to match.

   What the phone gives up by joining: the mark no longer travels with the bar,
   so it needs a row of its own below it rather than sharing one. See the
   header's padding in the 860 block for where that row came from. */
/* --centred is named explicitly, and it has to be. /about/ puts its header in
   a three-track grid and places the nav with
   `.site-header--centred .nav { grid-column: 3; justify-self: end }`, which is
   (0,2,0) — the same weight as `.site-header .nav` — and sits in an earlier
   block. Measured at 1470 without this: /resume/ and a case page gave a bar
   spanning 0->1470 while /about/ gave 1094->1470, the nav's own content width
   parked in its grid column. Same declarations, one page out of three wrong,
   which is the signature of a specificity tie rather than a bad value.

   justify-self and grid-column are reset rather than out-weighted, so the
   fixed box is sized by its own inset pair and nothing else. */
.site-header .nav,
.site-header--centred .nav {
  position: fixed;
  top: 0;
  left: 0;
  right: 0;
  width: auto;
  grid-column: auto;
  justify-self: stretch;
  z-index: 31;
  justify-content: flex-end;
  align-items: center;
  padding-inline: var(--gutter);
  padding-block: var(--bar-pad);

  /* The base rule's left/top are offsets against a relative box; here they
     would be POSITIONING and would drag the bar off the corner it is pinned
     to. Overridden above, and the snap re-applied as a translate instead.
     snapHeaderNavToGrid stands down entirely once it reads position: fixed,
     so this only has to hold for the frame before it does. */
  translate: var(--nav-snap-x, 0px) var(--nav-snap-y, 0px);
}

/* Solid, once something is passing underneath. Same call as the landing bar
   and for the same reason: at 88% the copy and the portrait read straight
   through the row, and the --bar-fade dissolve turned the bottom edge into a
   band that content crossed rather than went behind. The nav is fixed with
   both edges pinned, so its own background already spans the viewport and no
   ::before is needed. */
/* --solo's own min-height is gone; the base rule covers it.

   The case it existed for is still real: /resume/ has no mark, so once the nav
   leaves the flow the header has no children at all and collapses to its 24px
   padding, taking the whole page up with it. Measured at 1470 before it
   existed, 24px against the 64 every other header held.

   It said `calc(var(--grid-size) * 2 + 24px)` — two cells plus the padding, the
   row the mark would have occupied. That is one derivation of 64 and the base
   rule's four-pixel margin over the bar was another, and both were really
   trying to say "as tall as the bar". Now one rule says it, from the measured
   height, and this page gets the same answer as every other one.

   Nothing replaces it. Two min-heights at the same (0,1,0) weight, one of them
   a stale copy, is exactly the tie this file keeps losing. */

.site-header.is-stuck .nav { background: var(--bg); }

/* Desktop's half of the handover: here the NAV is the pinned element, so the
   nav is what leaves. See the .is-away note at base level for the rule. */
.site-header .nav { transition: transform 0.26s var(--ease); }
.site-header.is-away .nav { transform: translateY(-100%); }


/* --- The contact handoff -------------------------------------------------
   /about/ closes its hero with a Let's Chat button, and above the footer it
   is the page's only call to action. The pinned bar scrolls over it and from
   there down there is nothing to click, so the bar picks it up as it passes.

   buildChatHandoff in site.js watches the hero's own button against the bar's
   bottom edge and writes .is-chat-handoff on <html> at the crossing — the
   threshold a reader actually sees, rather than a scroll distance that
   happens to correlate with it today.

   It lands at the LEFT end of the row, the end the three links leave empty,
   so nothing shifts sideways when it arrives.

   margin-right: auto, NOT justify-content: space-between on the row. The
   row's job is to hold Work / About / Résumé together at the right, and
   space-between does the opposite — measured at 1470 it spread all four
   evenly, putting Work at 490 and About at 896 with the group broken up. An
   auto margin on the first item absorbs the free space at that one point
   instead, so the chat link sits on the gutter and the other three stay
   exactly where flex-end had already put them: Résumé's tile still lands on
   the content edge at 1422. It also means the rule needs no counterpart for
   /resume/ and the case pages, which have no chat link and would otherwise
   have had their rows spread by a class that is not about them. */
.is-chat-handoff .site-header .nav__chat {
  display: inline-flex;
  margin-right: auto;
  animation: chat-in 0.3s var(--ease) both;
}

@keyframes chat-in { from { opacity: 0; } to { opacity: 1; } }

.site-footer__links a {
  position: relative;
  padding-block: 4px;
}

/* Both paddings come from custom properties so snapNavPanelsToGrid can size
   the link's BORDER BOX in whole dot cells (see .nav.is-lattice below). The
   fallbacks are the unpadded row this had before, which is what renders with
   JS off and below the 620px breakpoint. */
.nav a {
  position: relative;
  /* See --fw-nav. Declared here rather than in the phone block, which is where
     it used to be and where it only ever covered the small end. */
  font-weight: var(--fw-nav);
  padding-block: var(--panel-pad-y, 4px);
  padding-inline: var(--panel-pad-x, 0px);
  /* A label is one word-group and never breaks inside itself. Without this,
     320px was splitting "Let's Chat" across two lines INSIDE its own link —
     measured 58.6px against its natural 76.5 — which reads as broken rather
     than as a wrapped row. The row itself may still wrap; an item just moves
     down whole. */
  white-space: nowrap;
}

/* Hover panel for the top nav — the wordmark's tile, borrowed.
   ------------------------------------------------------------------------
   The link sits directly above DHरUV, where every letter has a padded panel
   behind it, so the obvious affordance for the nav is the same panel arriving
   on hover. Same token, not a lookalike: --tile-bg is what .hero__tile paints.

   Translucent, and that is the whole point of using --tile-bg rather than the
   solid --chip-bg. The dot field runs behind all of this. A tile at 2.8% alpha
   lets the dots read straight through it, which is why the wordmark sits ON
   the grid rather than punching a hole in it; an opaque fill blanked the four
   or five dots under the hovered word and the lattice visibly broke every time
   the pointer crossed the nav. Any replacement fill has to keep an alpha.

   Square-cornered, for the reason .hero__tile is: a radius pulls the edge off
   the dot it should run through.

   The panel is a ::before rather than a background on the link, so that it can
   paint OUTSIDE the link's box in the fallback below. isolation on the link
   keeps its z-index: -1 inside the link's own stacking context; without it the
   panel joins the root context alongside .hero__grid, which is also negative,
   and the paint order becomes a DOM-order accident.

   The underline wipe below now belongs to the footer alone. Run both and the
   rule sits inside the panel, which reads as two affordances arguing; the
   tiles it is echoing have no underline either. */
.nav {
  --nav-panel-x: 12px;
  --nav-panel-y: 3px;
}

.nav a {
  isolation: isolate;
}

/* --- The lattice, kept where it can be aligned ------------------------------
   The dot field came off the whole site, and these tiles are where it comes
   back. Not as decoration: a tile that carries dots reads as a window onto the
   grid the page is built on, and the reason it can is that these particular
   rectangles are the only ones whose geometry is guaranteed.

   snapNavPanelsToGrid rounds every nav tile out to whole dot cells, so a 60x40
   tile is exactly 3 cells by 2 and the pattern divides into it with nothing
   left over. Painted from the tile's own top-left at --grid-size, the dots land
   on the tile's own rhythm rather than on a phase inherited from somewhere off
   screen — which is what went wrong when the bar carried the field: it agreed
   with the background and then crossed a photograph.

   background-color and background-image as longhands, not `background`: the
   shorthand would reset the other, and both the wash and the dots have to be
   there — the wash is what the tile IS and the dots are what it shows. */
.nav a::before {
  content: "";
  position: absolute;
  /* One dot radius larger than the tile on every side. A mark centred on a
     corner is half outside the tile, and a background cannot paint beyond its
     own box — without this the four corners come out quartered and the four
     edge midpoints halved. The wash grows by the same pixel; at 2.8% alpha
     that is not a visible edge, and the marks are. */
  inset: calc(var(--nav-panel-y) * -1 - var(--dot-r))
         calc(var(--nav-panel-x) * -1 - var(--dot-r));
  z-index: -1;
  background-color: var(--tile-bg);
  /* Eight marks on the tile, not the dot field seen through it. See the
     --tile-markers note in the token block for what changed and why the old
     centred-field version could not hold below 620. */
  background-image: var(--tile-markers);
  background-repeat: no-repeat;
  background-size: var(--tile-marker-size);
  background-position: var(--tile-marker-pos);
  opacity: 0;
  transition: opacity 240ms var(--ease);
}
/* :active is the tap, and it is the ONLY hover-like state a touch device gets
   here. Holding the tile permanently open, which is what both bars used to do
   below their breakpoints, reads as every word being pressed at once. */
.nav a:focus-visible::before,
.nav a:active::before {
  opacity: 1;
}

/* --- :hover is a real pointer's state, and only a real pointer's ------------
   "A phone never fires :hover" is what the note here used to say, and it is
   wrong. A touch browser SYNTHESISES hover on tap and then leaves it applied,
   because there is no pointer to move away — it clears when something else is
   tapped, or on the next navigation, or not at all. Tapping Work on a phone
   left its tile lit for as long as the reader stayed on the page, which is
   what the tile is specifically not supposed to mean. The in-page anchors are
   the worst of it: #work does not navigate, so nothing ever comes along to
   clear the state.

   (hover: hover) is the media feature for "the primary input can actually
   hover". Every hover state on the site is gated on it from here down, and the
   pattern is the same each time: :focus-visible and :active stay ungated,
   because both are real on touch, and only the :hover half moves inside.

   Not (pointer: fine) as well, which two blocks further down this file do use.
   That pair is right for the card lift, an ornament that a stylus or a coarse
   pointer has no business triggering. This is an affordance: anything that can
   hover should see it. */
@media (hover: hover) {
  .nav a:hover::before { opacity: 1; }
}

/* --- The tile on the lattice --------------------------------------------
   snapNavPanelsToGrid in site.js adds .is-lattice once it has sized every link
   in whole dot cells. What that buys is stated as a chain:

     every tile is a whole number of cells wide
     every gap between tiles is a whole number of cells
     the row's anchor edge is on a dot column
     => EVERY tile edge is on a dot column

   because each edge is the anchor plus a sum of whole cells. Same argument
   vertically. So no code has to place the tiles individually — the snaps that
   already exist put the anchor edge on the lattice, and the sizing does the
   rest. That is the whole trick, and it is why .hero__wordmark gets its
   alignment from whole-cell tiles plus whole-cell gaps too.

   It only works with REAL padding on the link. With a uniform flex gap and
   per-item negative insets the tiles come out different widths from their
   words, so the clear space between them ends up uneven — 31px on one pair,
   12px on the next. Padding makes the link's border box the tile, so the clear
   IS the flex gap, identical for every pair, and the panel is inset: 0.

   The row offsets are cancelled here for the same reason. Without lattice the
   word is what the snaps put on the axis and the row is nudged by
   --nav-panel-x so the tile lands there instead; with real padding the tile IS
   the border box, so the snaps already target it and a nudge would double it. */
.nav.is-lattice {
  gap: var(--nav-lattice-gap);
}

.nav.is-lattice a::before {
  /* The overhang the panel padding was correcting is gone once the box has
     been sized, but the dot radius is not — that one is the marks' own
     geometry, not padding, so unlike the rest of the inset it never stands
     down. Plain `inset: 0` here would quarter every corner mark. */
  inset: calc(var(--dot-r) * -1);
}

.nav--hero.is-lattice { margin-left: var(--nav-shift-x, 0px); }
.site-header .nav.is-lattice { left: var(--nav-snap-x, 0px); }

/* On alignment — why both navs carry a --nav-panel-x offset.
   ------------------------------------------------------------------------
   The panel is centred on its word, so on the outermost link it reaches past
   the row. Left unhandled that put the first panel on the home page at x=38
   against the one left edge the rest of the page is squared to — the D tile,
   the lead and the work grid all at 50.

   Panel edge on the axis and word edge on the axis are mutually exclusive:
   whichever one sits at 50, the other is --nav-panel-x away from it. The panel
   wins, because it is the element that reads as a tile and so is the one that
   has to line up with the tiles; the words move inward by that much.

   The alternative was zeroing the outer inset on the edge link so the panel
   grew inward only. It aligns just as well but leaves the outermost word flush
   against its own panel with no breathing room on that side, which looks
   clipped — visibly so on the case pages, where the row ends hard on the
   content edge.

   Each row is offset toward its own anchor, which is not the same end for all
   of them — measured, not assumed:

     home             left-aligned under the wordmark; row moves RIGHT
     inner, >620px    right-aligned in the header; row moves LEFT
     inner, <=620px   the header wraps and snapHeaderNavToGrid switches to
                      snapping the LEFT edge, so the offset flips with it */

.site-footer__links a::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: right center;
  transition: transform 280ms var(--ease);
}
.site-footer__links a:focus-visible::after {
  transform: scaleX(1);
  transform-origin: left center;
}
/* Hover gated: see the note on .nav a:hover::before. */
@media (hover: hover) {
  .site-footer__links a:hover::after {
    transform: scaleX(1);
    transform-origin: left center;
  }
}

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

/* The wordmark is the top of the composition now that the DC label is gone,
   and it sits flush against the top of the page. position: relative so the
   grid backdrop below can pin to this section. z-index stays auto — no
   stacking context — so .hero__grid at z-index -1 drops behind the hero's
   content but still paints above the body canvas. */
/* The old wordmark bled its leading off the top of the viewport, so this was 0.
   A tile has a background and cannot, it needs to sit inside the page.

   snapNavToGrid in site.js overwrites --hero-pad-top with whatever value lands
   the nav's top on a dot row; the clamp is the no-JS fallback. */
.hero {
  padding-top: var(--hero-pad-top, clamp(28px, 3.4vw, 50px));
  position: relative;
}

/* --- Grid backdrop --------------------------------------------------------
   Scoped to .hero and nothing else — the work grid, the footer and every
   inner page are untouched.

   This was a ::before. It is real elements now because the hover highlight
   needs siblings to move; one pseudo-element cannot carry a trail.

   Full-bleed out of .shell's gutter using the same --sbw correction as the
   wordmark — vw counts the scrollbar and the shell doesn't. overflow: hidden
   clips the highlight dots to the hero so none of them can escape into the
   work section below.
   ------------------------------------------------------------------------- */
/* The mask fades DOWN only, and sits on the container so the highlight dots
   dissolve on exactly the same curve as the field they sit in.

   It used to be a radial ellipse, which softened the left and right edges as
   well — that was the blur at the sides. A vertical gradient leaves the grid
   sharp all the way into both viewport edges, which also suits a wordmark
   that runs edge to edge.

   The bottom still fades, and the box runs --grid-tail past the hero so it
   can: the hero ends on the lead's last line, and cutting the grid dead there
   would draw a full-width horizontal seam across the page. The tail moves the
   whole falloff into the empty band above the work grid. For a hard bottom
   edge as well, delete the two mask lines and set --grid-tail to 0. */
.hero__grid {
  position: absolute;
  z-index: -1;
  top: 0;
  bottom: calc(0px - var(--grid-tail));
  left: calc(50% - 50vw + var(--sbw, 0px) / 2);
  width: calc(100vw - var(--sbw, 0px));
  overflow: hidden;
  pointer-events: none;      /* the listeners live on .hero, not on this */
  -webkit-mask-image: linear-gradient(to bottom, #000 var(--grid-fade), transparent 100%);
  mask-image: linear-gradient(to bottom, #000 var(--grid-fade), transparent 100%);
}

/* The dot field. One radial-gradient tiled at --grid-size: a hard stop at 1px
   makes a 2px dot, and with no explicit background-position each dot sits at
   the centre of its own cell — (10, 10) at the default 20px tile. That centre
   offset is what buildHeroGrid snaps the highlight to.

   The pattern is declared once for both places that use it. Both boxes break
   out to the same 100vw off the same left edge, so their columns stay in
   phase down the page even though they are 3000px apart. */
.hero__grid-dots,
:is(.is-home, .is-page) .site-footer::before {
  background-size: var(--grid-size) var(--grid-size);
  background-image: radial-gradient(var(--grid-dot) 1px, transparent 1px);
}

.hero__grid-dots {
  position: absolute;
  inset: 0;
}

/* --- The dot field, switched off ------------------------------------------
   Every field on the site: the landing hero's, the one /about/ and /resume/
   carry, the three chase dots that follow the pointer, and the footer's. Both
   palettes, both breakpoints, every page.

   It came off because of the bar. A pinned bar has to put something between
   its words and the page, and on a page made of dots every surface was right
   against one backdrop and wrong against the next — a fill read as the one
   band with no lattice in it, the lattice painted on matched the field exactly
   and then crossed the /about/ portrait and drew dots over a photograph. With
   no field anywhere, a solid bar and the page background are the same colour
   and the question stops existing.

   OPACITY, not display, and that is the whole reason this is two declarations
   rather than a deletion. The field's box is what setGridLift, snapLogoToGrid,
   snapTilesToGrid, snapTitleColumnToGrid and snapHeaderNavToGrid all measure
   against — every tile, the wordmark, both nav rows and the footer row are
   placed by reading its origin. display: none zeroes those rects and the page
   comes apart. Kept in layout and invisible, the lattice goes on doing its
   alignment work unseen, which is also what makes this reversible.

   To bring the dots back: delete this block. Nothing else was removed — the
   markup, the tokens, buildHeroGrid and every snap are untouched. */
.hero__grid,
:is(.is-home, .is-page) .site-footer::before {
  opacity: 0;
}

/* Hover highlight. Three dots chase the pointer at different speeds, so they
   string out into a trail while the cursor moves and collapse into one lit
   dot when it stops — the same lagged-follow idiom as the about page's
   avatar deck.

   The loop snaps them to dot centres, so each one lands exactly on top of a
   real dot and reads as that dot swelling rather than as a fourth dot
   appearing between them. Only transform and opacity ever change: both
   composite, so the follow costs no layout and no repaint. */
.hero__grid-dot {
  position: absolute;
  top: 0;
  left: 0;
  width: var(--grid-dot-size);
  height: var(--grid-dot-size);
  border-radius: 50%;
  background: var(--grid-dot-lit);
  opacity: 0;
  transition: opacity 420ms var(--ease);
  will-change: transform, opacity;
  backface-visibility: hidden;
}
/* Trailing copies sit fainter than the one under the cursor; the loop also
   scales them down, so the trail tapers as well as fades. */
.hero__grid.is-live .hero__grid-dot--1 { opacity: 0.3; }
.hero__grid.is-live .hero__grid-dot--2 { opacity: 0.58; }
.hero__grid.is-live .hero__grid-dot--3 { opacity: 1; }

/* Nav under the wordmark, on the page's left gutter — the same axis as the
   lead, the actions and the work grid. It was centred, which read as a
   masthead but made a third alignment axis out of three 17px links. .nav's own
   flex gap already spaces the items equally; this only anchors and spaces it. */
/* Top navbar: first thing on the page, with the wordmark beneath it. It was
   under the wordmark before, which is why the margin was on top.

   Everything below is snapped to the dot lattice by snapNavToGrid in site.js,
   which writes the three custom properties. The fallbacks are the unsnapped
   values, so with JS off the nav still lays out, it just does not sit on the
   grid:

     --nav-shift-x  the same shift the wordmark tiles took, so the first link
                    starts on the D's left edge rather than on the gutter
     --nav-h        height rounded up to whole cells, which puts the bottom on
                    a dot row given the top is already on one
     --nav-gap      the space to the wordmark, in whole cells

   min-height rather than height, and align-items: center, so rounding the
   height up centres the links in the taller box instead of clipping them. */
.nav--hero {
  justify-content: flex-start;
  align-items: center;
  /* + --nav-panel-x: the row is nudged right by exactly the hover panel's
     side padding, so it is the PANEL's left edge that lands on the page axis
     and not the word's. The first link's panel now starts at the same x as the
     D tile below it, which is the edge everything on this page is squared to.

     The cost is that the words at rest begin 12px inside that edge, and the
     trade is deliberate: the two cannot both sit at 50, and the hovered
     rectangle is the thing that visibly reads as a tile and so has to line up
     with the tiles. snapNavToGrid only ever writes --nav-shift-x, so adding to
     it here composes rather than fights. */
  margin-left: calc(var(--nav-shift-x, 0px) + var(--nav-panel-x));
  min-height: var(--nav-h, auto);
  margin-bottom: var(--nav-gap, clamp(22px, 2.8vw, 40px));
}

/* The face declaration that used to sit here is gone: SF was scoped to
   .is-home's hero nav and lead back when the rest of the page was Montserrat,
   and body carries it for the whole site now. Only the weights remain, and
   they still have to be stated.

   Heavier than the --fw-body/--fw-ui defaults. Both numbers are real SF
   instances, Medium and Semibold, so they render as drawn weights rather than
   a synthesised smear; 450 or 550 would just round to the nearest one. The nav
   stays a step above the lead, which is the relationship the two had before.

   .hero__lead is unscoped: the landing hero and the About hero are the same
   component and took this weight for the same reason, so one rule replaces the
   .is-home and .is-page pair that used to say it twice. */
.hero__lead { font-weight: 500; }
/* The landing row ran 600 at every width and now runs it only on a phone.

   It is a sizing fix rather than a taste one. /about/ and /resume/ inherit the
   body's 400, and snapNavPanelsToGrid rounds every tile out to whole dot cells
   — so a heavier word can cross a cell boundary its lighter twin does not.
   Measured at 1470, "Work" took four cells here against three there: an 80px
   tile against 60, putting this row's left edge at 1122 while the other two sat
   at 1142 and About and Résumé already agreed to the pixel.

   The 600 moves to the phone block rather than disappearing. Down there the
   landing bar is its own component — the inner pages' nav is inside a header
   sharing a row with the mark — so nothing is being matched across pages and
   the heavier row is what that bar has always been. */

.hero__title {
  font-size: var(--fs-hero);
  font-weight: 400;
  line-height: var(--lh-hero);
  letter-spacing: -0.021em;
}

/* --- Wordmark: DHरUV, as five tiles ---------------------------------------
   Promoted from the footer, where this treatment was first built. One letter
   per tile, Devanagari र standing in for the R.

   Gutter to gutter, NOT edge to edge: as a plain block in .shell the grid
   already measures the content box, so the D's tile starts on the same axis as
   Work / About / Résumé, the lead paragraph and the work grid — one left edge
   for everything on the page.

   Montserrat, not Akira Expanded. Taking the footer element as it stood means
   the site's display face is retired; the wordmark was its only consumer.

   Each letter keeps the site's masked-rise rig inside its own tile, so
   buildLoadTimeline's existing `.hero__wordmark .line > span` selector now
   matches five elements instead of one and its stagger fires per letter.
   -------------------------------------------------------------------------- */

/* Sized in whole dot-grid cells. snapTilesToGrid() in site.js measures the
   available width, works out the largest tile that leaves five tiles and four
   whole-cell gaps inside it, and writes --tile-w / --tile-gap plus a shift on
   each axis. The shift nudges the row onto the DOTS — the gutter is 48px and
   the dot columns are at 10, 30, 50... so without it nothing lines up.

   The fallback values are the fluid layout this had before, so with JS off the
   tiles still fill the row; they just do not sit on the grid. */
.hero__wordmark {
  display: grid;
  grid-template-columns: repeat(5, var(--tile-w, 1fr));
  gap: var(--tile-gap, clamp(6px, 0.75vw, 12px));
  justify-content: start;
  margin-left: var(--tile-shift-x, 0px);
  /* padding, NOT margin. The nav directly above carries a margin-bottom, and
     two adjacent siblings' vertical margins collapse to the larger of the two
     — so a margin-top of a few px here was silently swallowed and the tiles
     never moved. Padding does not collapse. */
  padding-top: var(--tile-shift-y, 0px);
  color: var(--ink);

  /* Same reason .logo-wm restates it: the mark is not body copy. body is SF,
     and these five tiles are the one thing on the page that has to stay
     Montserrat or the header mark and the headline stop matching. */
  font-family: "Montserrat", "Helvetica Neue", Helvetica, Arial, sans-serif;
}

/* Square, and square-cornered. Both matter for the alignment:

   - aspect-ratio: 1 means a tile whose WIDTH is a whole number of grid cells
     is automatically a whole number of cells TALL, so the horizontal snap
     gives the vertical one for free.
   - no border-radius. A rounded corner pulls the edge away from the dot it is
     supposed to run through, which is exactly the alignment that was asked
     for — the four corner dots have to sit on the corners. */
/* --- The field, inside the letters only ------------------------------------
   On trial: the dot lattice stops running across the hero and shows through the
   five tiles instead, so the letters read as windows onto it rather than as
   boxes sitting on top of it.

   Same declaration pair as .hero__grid-dots, deliberately — same gradient, same
   --grid-size, same --grid-dot — so this is the field seen through a smaller
   opening and not a second pattern that resembles it.

   The five stay in phase with each other for free, and that is worth stating
   because it looks like luck. background-position wraps modulo background-size,
   so two boxes show the same phase whenever the distance between them is a
   whole number of cells — and snapTilesToGrid sizes both the tile and the gap
   in whole cells, so tile + gap always is: 240 + 40 = 14 cells at 1470,
   200 + 20 = 11 at 1180, 300 + 20 = 16 at 1680. No per-tile offset to carry.

   Below 620 the tiles go proportional (repeat(5, 1fr) with a 2.6% gap) and that
   argument lapses, so each tile restarts its own pattern. With whole tiles
   between whole gaps at that size nobody can see the seam, and the alternative
   is a per-tile index for a discontinuity of a few pixels.

   background-image, not the `background` shorthand: --tile-bg is the wash the
   letters already sit on and it has to survive underneath. */
.hero__tile {
  aspect-ratio: 1;
  display: grid;
  place-items: center;
  background: var(--tile-bg);
  background-image: radial-gradient(var(--grid-dot) 1px, transparent 1px);
  background-size: var(--grid-size) var(--grid-size);
  font-size: clamp(34px, 10.5vw, 155px);
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.02em;
  user-select: none;
}

/* The र in Khand, set larger than the Latin letters so the two optically
   match — Khand's cap sits lower in its em than Montserrat's. Its own tile, so
   unlike the old single-line lockup there is nothing for it to align against
   and no sidebearing to compensate. */
.hero__tile--deva {
  font-family: "Khand", "Montserrat", sans-serif;
  font-size: clamp(41px, 12.6vw, 186px);
}

/* The mask. overflow: hidden is what turns the entrance into a reveal rather
   than a slide — the letter starts below its own tile and rises into it.

   The 0.14em padding/negative-margin pair gives the glyph room to sit inside
   the clip without the layout growing. Khand's font box is 1.53em tall
   (ascent 1.05 + descent 0.48) against a line-height of 1, so the र overflows
   its line box at both ends and needs materially more room than the Latin
   letters — hence the separate, larger pair below. Measured, not guessed: see
   the ink-bounds check in the plan. */
.hero__wordmark .line {
  display: block;
  overflow: hidden;
  padding-block: 0.14em;
  margin-block: -0.14em;
}
.hero__tile--deva .line {
  padding-block: 0.42em;
  margin-block: -0.42em;
}

/* block, not inline-block. The old value existed only so fitWordmark() could
   measure this element; with the fitter gone a block is correct and keeps the
   rise axis clean. */
.hero__wordmark .line > span { display: block; }

/* The Devanagari headline used to have to join the H and the U as one
   continuous run of ink, which is why the र carried hand-measured sidebearings
   and why a bridging box was tried and abandoned (a box edge snaps to the pixel
   grid, a glyph outline is antialiased, and the two never agree). Tiles retire
   that problem entirely: each letter is centred in its own panel and nothing
   touches anything. */

/* each line masked so it can rise into view */
.hero__title .line {
  display: block;
  overflow: hidden;
  /* descender room inside the mask, taken back out of the layout */
  padding-bottom: 0.12em;
  margin-bottom: -0.12em;
}
.hero__title .line > span { display: block; }

/* One column on the page's left gutter — the same axis as the nav above and
   the work grid below. This replaced a two-column grid whose right-hand cell
   held a meta pair; that split left a 570px void across the middle and put
   the lead's measure at the mercy of the column rather than its own max-width.
   flex column so the actions row contributes no stray line box below itself. */
.hero__intro {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  margin-top: 48px;                          /* clear of the nav */
}

.hero__lead {
  max-width: 700px;
  font-size: var(--fs-lead);
  font-weight: var(--fw-body);
  line-height: 1.5;
  text-wrap: pretty;
}
/* Grey context, solid affiliation — the site's meta idiom, now inline at the
   end of the paragraph instead of in a block of its own. Two runs carry it on
   the landing hero: the intro sentence in full, and B.Tech on the degree line,
   which leaves "@IIT Roorkee '26" as the one thing set solid. */
.hero__lead .soft { color: var(--ink-soft); }


/* --- The degree keeps its own line -----------------------------------------
   The <br> before "B.Tech @IIT Roorkee '26" was hidden above 620 for one pass,
   to get the block down from three lines to two by letting the degree flow on
   behind the sentence. It reads better on its own line and it is back.

   What pays for it is the measure. The paragraph is no longer held to a
   reading column; --lead-w runs it to the U tile's right edge, which is 1080px
   at 1470 against the sentence's own 936 at 16px — so the sentence takes one
   line, the degree takes the second, and the block is still two lines. The
   break is doing the separating rather than the wrapping.

   Below about 1250 the mark is narrower than the sentence and it goes to three:
   two of sentence and one of degree. That is the same shape a phone has always
   had, and it is the width band where the mark cannot buy the line back. */
@media (min-width: 621px) {
  /* ...and the measure in em, which is what makes two lines hold at every
     desktop width rather than at some of them.

     700px is not one measure, it is two: --fs-lead clamps from 17px to 19.4px,
     so the same box is 41em of text at the bottom of that range and 36em at the
     top. Hiding the break got 861 through 1280 to two lines and left 1470 and
     up at three, because the type had grown into a box that had not.

     Measured at 17 / 18.48 / 19.4px, sweeping the box:

       37em   38em   39em   39.5em   40em
        3      3      3       2       2      at every width tested

     39.5 is the edge and 40 is the one taken, so a font that renders a hair
     wider on a real machine than in a headless one does not put the third line
     back. It is 680px at 17px against today's 700 and 776px at 19.4px, so the
     line gets shorter where the type is small and longer where it is large —
     which is the whole point of sizing a column in em.

     .is-home, because .hero__lead is shared with /about/, where the paragraph
     sits in a column beside the portrait and is measured against that.

     --lead-w overrides all of that when it is there: snapHero writes it as the
     distance from the mark's left edge to the U tile's right one — four tiles
     and three gaps — so the paragraph ends under the U and the V is left
     standing clear at the end of the row. It is an alignment, not a reading
     measure, and it is a long line by any other standard: 1080px at 1470,
     about 130 characters. The 40em stays as the fallback, so a blocked script
     or a phone (where the tiles are proportional and there is no whole cell to
     measure) still gets a sane column. */
  .is-home .hero__lead {
    max-width: var(--lead-w, 40em);

    /* 16, flat, replacing --fs-lead's clamp(17px, 1.32vw, 19.4px) on this one
       paragraph. Scoped to .is-home so /about/'s hero, which shares the class
       and sits in a narrow column beside the portrait, keeps the token. It is
       also the size the phone block already sets, so the paragraph is now one
       size everywhere instead of three.

       Flat rather than a clamp of its own: the whole reason --fs-lead scales is
       to hold a constant measure in a box that does not move, and this box now
       moves with the mark — --lead-w is four tiles and three gaps, so it is
       already tracking the viewport. Two things scaling against each other is
       how the third line kept reappearing.

       14 was tried first and gave the box away: the sentence is 1132px wide at
       that size, so above about 1600 it fit on one line and stopped reaching
       the U at all, and at 1470 the first line broke at 973 against the U's
       1130. At 16 the natural width is back over the box at every width the
       mark is wide enough to matter.

       The 40em fallback beside it is 640px rather than 680, since em follows
       the type. That only applies with JS off. */
    font-size: 16px;
    line-height: 1.5;
  }
}

/* Keeps a hyphenated term whole. A line is allowed to break after a hyphen, so
   "Non-CS" was splitting into "Non-" and "CS" across two lines on a phone. */
.nb { white-space: nowrap; }

/* Copy that earns its place on a wide column and not on a phone. Shows by
   default and is switched off in the 620 block, so the short version is the
   exception a reader has to opt into by being on a small screen, rather than
   the baseline every other width has to opt out of. */
.wide-only { display: inline; }

/* The home hero has no actions row: "View work" is gone (the work grid is the
   next thing on the page, so the pill only restated a scroll) and contact has
   moved into .nav--hero beside Résumé. .hero__actions / .hero__contact went
   with it — the nav's own underline wipe now carries the contact link. */

.btn {
  display: inline-block;
  margin-top: 36px;
  padding: 14px 24px;
  border-radius: 100px;
  background: var(--chip-bg);
  color: var(--ink);
  line-height: 1.1;
  transition: background-color 0.35s var(--ease), color 0.35s var(--ease),
    transform 0.35s var(--ease);
}
/* Hover gated: see the note on .nav a:hover::before. Without it a tapped
   button kept the inverted fill and the -2px lift for the rest of the visit. */
@media (hover: hover) {
  .btn:hover {
    background: var(--ink);
    color: var(--ink-invert);
    transform: translateY(-2px);
  }
}
.btn:active { transform: translateY(0); }

/* The hero's primary action, and the only one on the page that has to be
   found rather than merely read.

   Everywhere else .btn rests on --chip-bg, which is 1.15:1 against --bg in
   dark and 1.13:1 in light — the label is legible but the pill itself is
   invisible, so the control reads as text with padding. Here the resting
   state takes what used to be the hover fill (17.15:1), and hover moves to
   the lift alone plus a slight softening, so the two states stay distinct.

   Scoped to .hero on purpose: the same class carries the contact buttons on
   /about and /resume, which are secondary actions and should stay quiet. */
.hero .btn {
  background: var(--ink);
  color: var(--ink-invert);
}
@media (hover: hover) {
  .hero .btn:hover {
    background: var(--ink);
    color: var(--ink-invert);
    opacity: 0.86;
    transform: translateY(-2px);
  }
}

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

.work {
  padding-top: 84px;
  padding-bottom: 123px;
}

/* The two gaps are set independently on purpose. column-gap stays on --gap
   (48px at 1470) — untouched.

   row-gap was 30px, well under the column gap, which read as cramped: a card's
   own title and blurb sit directly under its image, so a vertical gap has to
   clear that text block as well as separate the rows, while a horizontal gap
   only has to separate two images. It now scales to 84px at 1470, matching
   .work's own padding-top so the band between the rows reads as the same
   interval as the band above them. */
.work__grid {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  column-gap: var(--gap);
  row-gap: clamp(44px, 5.71vw, 84px);
}

.card__link { display: block; }

/* Titles carry a name, a descriptor and sometimes a metric, so one card in a
   row can wrap to two lines while its neighbour doesn't. Pinning the tags to
   the bottom of the (grid-stretched) card keeps the chip rows on one line
   across the row no matter how the titles break. */
.work__grid .card {
  display: flex;
  flex-direction: column;
}
.work__grid .card__tags {
  margin-top: auto;
  padding-top: 20px;
}

/* Media stack — one element per transform, so the entrance clip, the
   scroll drift and the hover scale never overwrite one another. */
/* Whole image, always. Two rules make that true and they depend on each other:
     - aspect-ratio matches the artwork's own ratio (every card asset is
       authored at 1800x1023 / 1900x1080 = 1.759), so nothing letterboxes;
     - object-fit:contain below guarantees nothing is cut even if a future
       image arrives at some other ratio — it would sit on the frame instead.
   The old 108% parallax track is gone: an overhang hides part of the image by
   definition, which is exactly what we no longer want. */
.card__media {
  position: relative;
  aspect-ratio: 1800 / 1023;
  overflow: hidden;
  border-radius: var(--radius-media);
  background: var(--media-frame);
  /* hairline only — a screenshot with white corners on a white page needs an
     edge to read as a card. Safe to delete for a fully borderless look. */
  box-shadow: inset 0 0 0 1px var(--card-hairline);
  transition: transform 0.45s var(--ease), box-shadow 0.45s var(--ease);
}

.card__mask {                     /* entrance clip reveal */
  position: absolute;
  inset: 0;
  clip-path: inset(0%);           /* explicit end state so GSAP can interpolate */
}

/* Flush with the mask — no overhang. The previous -4%/108% track existed to
   feed a scroll-linked Y drift, but that drift can only work by keeping part
   of the image outside the visible box. Full visibility wins. */
.card__parallax {
  position: absolute;
  inset: 0;
}

.card__img {
  width: 100%;
  height: 100%;
  object-fit: contain;            /* never crop; rest on --media-frame instead */
}

/* Hover: lift the whole card rather than scaling the image inside a clipped
   box. A scale zooms past the edges and re-crops the artwork; a translate
   cannot. Pure CSS on one compositor-friendly property, so it costs nothing
   and needs no JS. */
@media (hover: hover) and (pointer: fine) {
  .card:hover .card__media {
    transform: translateY(-6px);
    box-shadow: inset 0 0 0 1px var(--card-hairline-hover), var(--card-lift);
  }
}

/* Only elements that actually animate get promoted. The parallax track and the
   image no longer transform at all, so keeping them here would allocate a
   compositor layer per card for nothing. */
.hero__title .line > span,
.hero__wordmark .line > span { will-change: transform; }

/* The product name is the title's own text node and the descriptor is
   .card__client, so the two can be weighted separately without touching the
   markup: name bold, descriptor left light and grey.

   This base pair is what the compact "More projects" strip at the foot of a
   case study uses — three small cards, no blurb, so "Name, Descriptor" on one
   17px line is the right density there and stays as it was. The home grid
   overrides it below. */
.card__title {
  /* Sora 700, the case studies' heading face. A card IS the case study's
     heading in miniature — the same four product names the four page titles
     carry — so the two are set in the same face and the strip at the foot of a
     case study rhymes with the title at the top of it. 600 goes to 700 with the
     face, the same step .case-hero__title takes. */
  font-family: var(--font-sora);
  margin-top: 25px;
  font-size: var(--fs-ui);
  font-weight: 700;
  line-height: 1.35;
  text-transform: uppercase;
  /* -0.012em before. Caps do not want negative tracking — see the note on
     .case-hero__title in case.css for the argument, and the same size-to-air
     ladder it sets: 48px takes 0.01, 22px takes 0.05. This strip runs 17px and
     is a NAME rather than a label, so it sits below where that ladder would
     put it. A product name spread to label tracking stops reading as a name. */
  letter-spacing: 0.03em;
}
/* The descriptor stays SF, and that is the point of the pair. Sora is doing
   the naming; the category label under it is ordinary text, and a card whose
   two halves are both Sora loses the distinction the weights were carrying.
   Stated rather than inherited, because .card__client is a CHILD of the title
   and would otherwise take the face from the rule above. */
.card__client {
  font-family: var(--font-sf);
  color: var(--ink-soft);
  font-weight: var(--fw-heading-sm);
}

/* --- The descriptor does NOT come with it ----------------------------------
   text-transform inherits, and .card__client is a child of the title, so
   without this "Search & Direct Booking" and "Property Management System
   (PMS)" come up shouting alongside the name. The pair is meant to read as a
   name over a label, not as two lines of caps.

   Set on .card__title's child rather than on .card__client outright, so the
   footer and anywhere else the class might land later are unaffected.

   Note the caps are a TRANSFORM, not a spelling change: the DOM still reads
   "Spacez One: Console", which is what a screen reader announces, what
   find-in-page matches, and what gets copied out of the page. This started as
   a .card__title--caps modifier on the Spacez card alone; when all four went
   uppercase the modifier had nothing left to distinguish and came out, along
   with the class on the four cards that carried it. */
.card__title .card__client { text-transform: none; }

/* --- Home grid: three type steps, not one -------------------------------
   The card carries three different jobs — which product, what kind of thing
   it is, what it does — and they were arriving at one size, one after the
   other, so nothing led. Each now sits at its own step:

     name        24px, ink, medium         the anchor
     descriptor  14px, grey, uppercase     a label, not a second title
     blurb       16px, grey, 36em          the sentence

   Size does the separating, so the name no longer needs 600 to be found (it
   drops to 500), and the descriptor moving off the name's line means a long
   one — "Property Management System (PMS)" — can't push the name into a wrap
   and leave one card in a row two lines taller than its neighbour. */
.work__grid .card__title {
  margin-top: 24px;
  font-size: var(--fs-card-title);
  /* 500 on the grid was the counterpart to the strip's 600: size was already
     doing the separating, so the name did not need weight as well. Both are 700
     now — the face is the heading signal here, and Sora at 500 beside a 24px
     Sora page title on the other side of the click reads as a different
     component rather than the same one. */
  font-weight: 700;
  line-height: 1.15;
  /* -0.018em before, positive now for the same reason the strip's is: caps.
     0.02 against the strip's 0.03, because this is 24px and that is 17 — the
     larger the caps, the less air they need. */
  letter-spacing: 0.02em;
  /* Only the brand word shouts. The base rule above uppercases the whole title
     and that is still right for the compact "More projects" strip at the foot
     of a case study, where three names on one 17px line want to read as one
     run of caps. On the home grid the names are the page's own headings at
     24px, and SPACEZ EDGE / SPACEZ ONE: CONSOLE / SPACEZ CAPTAINS put the
     product and its qualifier at the same pitch, so nothing tells you which
     word is the company. Dropping the transform here and putting it back on
     .card__brand alone leaves SPACEZ Edge — brand, then name.

     none rather than capitalize: the DOM already carries the intended casing
     ("Spacez One: Console"), so there is nothing to correct, and capitalize
     would also hit "one" and "console" in any title later written lowercase.
     Scoped to .work__grid so the strip is untouched, same as every other
     override in this block. */
  text-transform: none;
}

/* The one word that stays in caps. Still a TRANSFORM, not a spelling: the DOM
   reads "Spacez", which is what a screen reader announces and what copies out
   of the page — the same argument the base .card__title note makes.

   Keeps the 0.02em the title sets. Caps want the air; the mixed-case remainder
   beside them is carrying it too, which is a compromise rather than a choice.
   If the descenders in "Console" start to look loose, the fix is a negative
   tracking on the title with the positive restated here, not a value in
   between that suits neither. */
.work__grid .card__title .card__brand { text-transform: uppercase; }

/* Own line, and set as a label: uppercase at chip size with positive tracking
   reads as a different class of text from the blurb below it, which matters
   because both are --ink-soft and would otherwise be two grey runs in a row. */
.work__grid .card__client {
  display: block;
  margin-top: 7px;
  font-size: var(--fs-chip);

  /* Lighter than the blurb under it, not equal to it. Both ran 400 and sat in
     the same --ink-soft, so the descriptor and the sentence below arrived at
     one voice and the eye had nothing to order them by once the caps came off.
     300 puts the label a step BELOW the thing it labels, which is the right way
     round: the blurb is the content and this names its category.

     --fw-heading-sm is the token the base .card__client already used before the
     home grid overrode it to --fw-ui; this is a return to it rather than a new
     number, and Light is a real SF instance. */
  font-weight: var(--fw-heading-sm);
  line-height: 1.3;

  /* Sentence case, not uppercase. It read as a system label stamped on the
     work rather than as the plain description of what the thing is, and
     "Property Management System (PMS)" set in caps with 0.07em of tracking is
     a mouthful before it is a descriptor.

     The tracking goes with it. Positive letter-spacing exists to open up caps,
     which smear without it; on lower case at 14px it just loosens the word.
     Size and colour still separate this from the blurb below, which was the
     job the caps were doing. */
  letter-spacing: 0;
}

/* One or two sentences under the title: what the project is, and where there
   is one, what it moved.

   No max-width any more, and that is the point. It ran at 36em, which held the
   line near 65 characters and stopped the text 575.77px into a 661px card —
   85px short of the image above it, so every card ended on a ragged edge the
   picture did not share. The blurb now runs the full column and the copy is
   written to land on two full lines against it rather than being trimmed to a
   measure. Two lines is the cap; the words do the holding, not the box. */
.card__blurb {
  margin-top: 14px;
  color: var(--ink-soft);
  font-size: var(--fs-card-blurb);
  font-weight: var(--fw-body);
  line-height: 1.55;
  text-wrap: pretty;
}

.card__tags {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
  margin-top: 20px;
}

.card__tags li {
  padding: 7px 11px;
  border-radius: 100px;
  background: var(--chip-bg);
  color: var(--chip-ink);
  font-size: var(--fs-chip);
  line-height: 1.15;
  white-space: nowrap;
  transition: background-color 0.35s var(--ease), color 0.35s var(--ease);
}
@media (hover: hover) {
  .card:hover .card__tags li {
    background: var(--chip-bg-hover);
    color: var(--ink);
  }
}

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

.site-footer {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: 24px;
  padding-block: 70px 60px;
  border-top: 1px solid var(--rule);
  position: relative;        /* anchors the dot field below */
}

/* The hero's dot field, mirrored at the foot of the page. It fades UP rather
   than down and starts --grid-tail above the footer's rule, so the falloff
   happens in the preceding section's bottom padding instead of against the
   border. The two fields bookend the page — dots under the wordmark, dots
   under the sign-off, plain ground through the middle.

   .is-home AND .is-page: /about/ and /resume/ carry the same .hero__grid at
   the top and the same .site-footer--full at the bottom, so scoping this to
   the home page left them bookended on one side only. .is-case is deliberately
   out: a case study opens on a title rather than a dot field, so a footer
   field there would be the only one on the page and would read as an
   ornament rather than as the other half of a pair.

   Static by design: the pointer-tracking highlight stays in the header.

   Same full-bleed breakout and --sbw correction as the hero, and z-index -1
   with the footer at z-index auto, so this drops behind the footer's content
   and its top rule but still paints above the body canvas. A pseudo-element
   is enough here precisely because nothing has to move inside it. */
:is(.is-home, .is-page) .site-footer::before {
  content: "";
  position: absolute;
  z-index: -1;
  top: calc(0px - var(--grid-tail));
  bottom: 0;
  left: calc(50% - 50vw + var(--sbw, 0px) / 2);
  width: calc(100vw - var(--sbw, 0px));
  pointer-events: none;
  -webkit-mask-image: linear-gradient(to top, #000 var(--grid-fade), transparent 100%);
  mask-image: linear-gradient(to top, #000 var(--grid-fade), transparent 100%);
}

.site-footer__links {
  display: flex;
  align-items: center;
  gap: 35px;
}

/* --- Landing-page footer -------------------------------------------------
   A modifier, not a replacement: the inner pages keep the one-row footer
   above. Two bands over the dot field, which is untouched — the tiles are the
   only tinted surface and they sit at 3% so the dots read straight through.
   ------------------------------------------------------------------------ */

/* 58px -> 30px. See the band note on .foot__top.

   Two classes, deliberately. The responsive blocks near the bottom of this file
   set `.site-footer { padding-block: 56px 48px }` at 1180 and again at 860, for
   the ONE-ROW footer the inner pages used to run. Both are (0,1,0) and both are
   declared after this, so a single class here loses to them from 1180 down and
   the band silently went back to 56px of air. (0,2,0) settles it. */
.site-footer.site-footer--full {
  display: block;
  padding-block: 30px 0;
}

/* One row, one baseline: nav left, socials right.
   ------------------------------------------------------------------------
   The band this row sits in was 138.8px of rule-to-rule for 44px of content —
   94.8px of it padding — and BELOW it sat another 48.7px of band holding
   nothing at all, left over from when the five letter tiles lived down here
   before they were promoted to the hero. 187px of footer for one row of links.

   Two changes, and they meet in the middle. This row's own rule is gone, which
   removes the empty band with it, so the footer is back to the two rules it
   reads as: its own at the top and the status bar's at the bottom. Then the
   space is 30 above and 30 below a 40px row:

     rule -> 30 -> [ 40px row ] -> 30 -> rule    = 100px, five whole cells

   down from 187.4px. Both rules land on dot rows (see --grid-tail), and the
   row is centred between them. The tiles themselves cannot also be on a row —
   100px of band around a 40px tile leaves 30, which is a cell and a half — and
   that is the trade for 100 rather than 80 or 120. The rules are the long
   horizontal lines the eye actually reads, so they are the ones that get it. */
.foot__top {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 24px 40px;
}

/* Both gaps are whole cells now — 34 and 12 were neither, so the two groups
   sat on different phases of the lattice and the row read as two unrelated
   clusters rather than one system. snapFooterRowToGrid in site.js then puts
   the nav's left edge and the icons' right edge on dot columns, and whole-cell
   gaps carry that alignment through every item between them. */
/* center, not baseline: the links carry hover tiles now (see below), and tiles
   line up by their boxes, not by the baseline of the text inside them. */
.foot__nav {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 40px;
  font-size: clamp(18px, 1.5vw, 22px);

  /* Relative, not a margin: the footer's entrance tween writes transforms, and
     a relative offset composes with one rather than being overwritten. */
  position: relative;
  left: var(--foot-nav-snap-x, 0px);
}

/* The header nav's hover tile, verbatim — same component, same
   snapNavPanelsToGrid pass, same --tile-bg at 2.8% so the dots read through it.
   The footer is the other end of one page and had a different affordance
   (a dim to --ink-soft) on what is the same list of links.

   It lands at 40px tall here, which is the social tiles' height, so the two
   groups become the same object at the same size on one lattice — the row now
   reads as one system rather than text on the left and tiles on the right. */
.foot__nav a {
  position: relative;
  isolation: isolate;
  padding-block: var(--panel-pad-y, 4px);
  padding-inline: var(--panel-pad-x, 0px);
}

/* The footer row takes the same tile, off the same two properties. It goes
   through the identical sizing pass, so its boxes are whole cells too. */
.foot__nav a::before {
  content: "";
  position: absolute;
  /* Same one-radius overhang as .nav a::before, for the same reason. */
  inset: calc(var(--nav-panel-y) * -1 - var(--dot-r))
         calc(var(--nav-panel-x) * -1 - var(--dot-r));
  z-index: -1;
  background-color: var(--tile-bg);
  background-image: var(--tile-markers);
  background-repeat: no-repeat;
  background-size: var(--tile-marker-size);
  background-position: var(--tile-marker-pos);
  opacity: 0;
  transition: opacity 240ms var(--ease);
}
.foot__nav a:focus-visible::before,
.foot__nav a:active::before { opacity: 1; }
/* Hover gated: see the note on .nav a:hover::before. */
@media (hover: hover) {
  .foot__nav a:hover::before { opacity: 1; }
}

/* --- Let's Chat: an outlined tile that fills -------------------------------
   The one call to action on the site, in all nine places it appears — both nav
   rows on three pages, all three footers, and the .btn pair on /about/ and
   /resume/ (that half lives in page.css, where the button's own tile rules are
   and where it takes the specificity to beat them).

   Selected by href rather than by a class on nine elements. Every one of these
   is the same mailto and nothing else on the site is a Let's Chat, so the
   attribute IS the definition; a tenth is picked up by writing the link.

   THE BOX IS NOT A NEW BOX. It is the tile every one of these links already
   had — sized by snapNavPanelsToGrid in whole dot cells where that pass runs,
   and by the --nav-panel-x/y fallback where it does not — so the outline lands
   on exactly the rectangle that used to light up on hover, at every width and
   on both bars. Nothing here sets a width, and that is the point: the size
   follows the lattice by inheritance rather than by a second measurement that
   could disagree with the first.

   border, not outline, and it costs no layout: the ::before is absolutely
   positioned, and box-sizing is border-box globally, so the stroke is drawn
   INSIDE the box and the rectangle's outer edge stays on the cell it was
   snapped to. An outline would have grown it by a pixel on every side.

   The eight marker dots stand down here. They are the other links' hover
   treatment — a tile picked out the way a design tool marks a selection — and
   they have nothing to say inside a border that is already drawing the same
   rectangle, let alone on top of a solid fill.

   --ink / --ink-invert rather than #fff / #000, so the inversion is a real
   inversion in both palettes: white box with black text on the dark one, and
   the reverse on light, where a white fill would be a white rectangle on a
   white page. On the palette this was asked for it resolves to exactly the two
   colours named. */
:is(.nav, .foot__nav) a[href^="mailto:"] {
  /* Heavier than its neighbours, at every width. The two rules this has to
     beat are `.nav a` in the phone block and `.foot__nav a` above, both
     (0,1,1); this selector is (0,2,1), so it wins on specificity and does not
     depend on where it sits in the file — which matters, because one of the
     two is 900 lines further down. */
  font-weight: var(--fw-chat);
  transition: color 240ms var(--ease);

}

/* --- The button is one box again -----------------------------------------
   What was here: a negative margin pulling the LINK left so its text landed on
   the wordmark's outer edge, and a translate pushing the ::before back right so
   the outline did not come with it. Two boxes, 22.76px apart at 1470, and the
   text ended up outside the rectangle that is supposed to contain it —
   measured, TEXT 50.0-126.5 against an OUTLINE at 60.0-162.0, so the outline's
   left column was drawn through "Let".

   One box now. The link carries --chat-lattice-x as a POSITIVE margin, which
   puts its own border box where the outline already was, and the ::before goes
   back to being drawn on that box with no transform of its own. The outline
   does not move: it was at 60.0 and 27.0 before this and it is there after,
   because the number that used to translate the pseudo-element now moves the
   element it belongs to.

   The text follows the box, as it always did — link.left + --panel-pad-x, the
   padding snapNavPanelsToGrid sized the tile for. So it sits inside the
   rectangle by that padding on the left and the rest on the right, and all four
   sides enclose it.

   Same token in both states, and it resolves per breakpoint on its own: 11 on a
   desktop, 1 on a phone. See --chat-lattice-x for what the three terms are. */
.nav a[href^="mailto:"] {
  margin-left: var(--chat-lattice-x);
}

/* --- The chase ------------------------------------------------------------
   The glow window goes round the rectangle once every --chat-glow-time, and
   the only thing it moves is the mask. The dots hold still and take their turn
   at being lit.

   THE STOPS ARE NOT EVEN QUARTERS, and that is what makes it read as one
   continuous travel rather than four separate moves. mask-position interpolates
   linearly in percent, so equal time on each edge would send the window across
   the long edge at more than twice the speed of the short one. The stops are
   the perimeter's own proportions: a 102x42 tile is 288 around, so the top and
   bottom are 102/288 of it and the sides 42/288 — 35 / 15 / 35 / 15. The
   phone's box is 91x38, the same ratio to within a percent, so one set of stops
   serves both.

   EVERY STOP IS PUSHED OUT BY THE WINDOW'S OWN RADIUS, and skipping that is the
   failure that looks nearly right. Percentage positioning aligns the LAYER's
   p% point with the BOX's p% point, so a plain `0% 0%` puts a 40px circle's
   CENTRE 20px inside the corner — and the dots are on the edge, so they would
   get the tail of the falloff and never the middle of it. calc(0% - r) puts the
   centre on 0 and calc(100% + r) puts it on the far edge. Two calc positions
   interpolate linearly the same as two plain ones, so the centre still travels
   each edge at a constant rate.

   Percentages rather than lengths for the rest of it, so nothing here has to
   know what the tile measures today.

   Both mask layers are named at every stop, because mask-position is a single
   property. The flat one is `center` throughout — it covers the box, so where
   it sits is not a question, and it is only restated because the syntax
   requires it.

   linear, because a light travelling a rail at a varying speed reads as
   something dragging rather than something circulating.

   Nothing to pause on hover: the filled state drops the mask and the stroke
   together, so the animation has nothing left to act on. */
@keyframes chat-glow {
  0% {
    -webkit-mask-position: calc(0%   - var(--chat-glow-r)) calc(0%   - var(--chat-glow-r)), center;
            mask-position: calc(0%   - var(--chat-glow-r)) calc(0%   - var(--chat-glow-r)), center;
  }
  35% {
    -webkit-mask-position: calc(100% + var(--chat-glow-r)) calc(0%   - var(--chat-glow-r)), center;
            mask-position: calc(100% + var(--chat-glow-r)) calc(0%   - var(--chat-glow-r)), center;
  }
  50% {
    -webkit-mask-position: calc(100% + var(--chat-glow-r)) calc(100% + var(--chat-glow-r)), center;
            mask-position: calc(100% + var(--chat-glow-r)) calc(100% + var(--chat-glow-r)), center;
  }
  85% {
    -webkit-mask-position: calc(0%   - var(--chat-glow-r)) calc(100% + var(--chat-glow-r)), center;
            mask-position: calc(0%   - var(--chat-glow-r)) calc(100% + var(--chat-glow-r)), center;
  }
  100% {
    -webkit-mask-position: calc(0%   - var(--chat-glow-r)) calc(0%   - var(--chat-glow-r)), center;
            mask-position: calc(0%   - var(--chat-glow-r)) calc(0%   - var(--chat-glow-r)), center;
  }
}

/* Off when the reader has asked for less movement — the CSS half of the same
   bail site.js makes for every GSAP path. A decorative loop is exactly what
   that preference is about.

   The COLOUR SWAP AND THE MASK LIVE IN HERE TOO, not just the animation, and
   that is deliberate: with all three inside one block, the reduced-motion path
   falls through to the declarations that were there before any of this — eight
   dots in --grid-dot, no mask, nothing moving. It is the old button exactly,
   rather than a still frame of the new one.

   --chat-ink is overridden AT :root, which is the one thing about this that
   cannot be moved. A custom property's inner var() is substituted on the
   element that DECLARES it, so --chat-stroke bakes in whatever --chat-ink is on
   :root and a redeclaration further down is simply not seen. Setting it on the
   ::before compiles fine, changes nothing, and leaves the outline masked to 54%
   of a colour that was never swapped. */
@media (prefers-reduced-motion: no-preference) {
  :root { --chat-ink: var(--grid-dot-lit); }

  :is(.nav, .foot__nav) a[href^="mailto:"]::before {
    /* Two layers: the window, and a flat sheet that holds everything else down
       to --chat-glow-rest. They union without being asked to — mask-composite
       starts at `add` — so nothing here depends on that property, which is the
       one part of masking with a patchy history.

       closest-side on a square layer is a circle inscribed in it, so the
       gradient's own radius is --chat-glow-r and the falloff is linear from the
       centre out to it. Alpha, not luminance: a CSS gradient under the default
       mask-mode is read for its alpha channel, which is why the sheet is black
       at 54% rather than 54% grey. */
    -webkit-mask-image:
      radial-gradient(circle closest-side, #000 0%, transparent 100%),
      linear-gradient(rgb(0 0 0 / var(--chat-glow-rest)) 0 0);
            mask-image:
      radial-gradient(circle closest-side, #000 0%, transparent 100%),
      linear-gradient(rgb(0 0 0 / var(--chat-glow-rest)) 0 0);
    -webkit-mask-size: var(--chat-glow-size) var(--chat-glow-size), 100% 100%;
            mask-size: var(--chat-glow-size) var(--chat-glow-size), 100% 100%;
    -webkit-mask-repeat: no-repeat, no-repeat;
            mask-repeat: no-repeat, no-repeat;
    animation: chat-glow var(--chat-glow-time) linear infinite;
  }
}

:is(.nav, .foot__nav) a[href^="mailto:"]::before {
  opacity: 1;
  background-color: transparent;
  /* The rectangle, dotted. Drawn as background runs rather than as a border,
     because border-style: dotted fixes its own pitch at twice the border width
     and 2px reads as a solid rule. See --chat-stroke for the whole argument;
     --chat-dot-pitch is the only number to tune.

     All four properties come from tokens, repeat included. It used to be
     `no-repeat` written out here, which was right for four full-length runs and
     is wrong for eight layers that each need their own pair of values — a flat
     no-repeat leaves one dot per edge. Whenever --chat-stroke changes shape,
     these four change together, which is why they are four tokens and not one
     token and three literals.

     No border at all. The box is unchanged: the stroke sits in the same
     outermost pixel the border occupied, since an absolutely positioned box
     with `inset` is the same rectangle either way. */
  border: 0;
  background-image: var(--chat-stroke);
  background-repeat: var(--chat-stroke-repeat);
  background-size: var(--chat-stroke-size);
  background-position: var(--chat-stroke-pos);
  transition: background-color 240ms var(--ease);
}

/* Filled, and the dots go with the fill rather than sitting on top of it. They
   are currentColor, and currentColor inverts here — so left in place they would
   come back as a dotted outline in the label's new colour, drawn around a solid
   block. The tile that fills IS the affordance; it needs no edge.

   THE MASK GOES TOO, and this is the one line here that is invisible until
   somebody points at the button. A mask applies to the whole element, and that
   includes background-color — so the fill this rule is drawing would come out
   at --chat-glow-rest with a circle of full white sliding round it. A grey
   block with black text on it, which is the only real contrast regression the
   glow had available to it. */
:is(.nav, .foot__nav) a[href^="mailto:"]:focus-visible::before,
:is(.nav, .foot__nav) a[href^="mailto:"]:active::before {
  background-color: var(--ink);
  background-image: none;
  -webkit-mask-image: none;
          mask-image: none;
}

:is(.nav, .foot__nav) a[href^="mailto:"]:focus-visible,
:is(.nav, .foot__nav) a[href^="mailto:"]:active {
  color: var(--ink-invert);
}

/* Hover gated: see the note on .nav a:hover::before. This is the pair that
   stuck hardest, because the filled state inverts the label — a tapped Let's
   Chat stayed a solid block with reversed text until something else was
   touched. */
@media (hover: hover) {
  :is(.nav, .foot__nav) a[href^="mailto:"]:hover::before {
    background-color: var(--ink);
    background-image: none;
    -webkit-mask-image: none;
            mask-image: none;
  }
  :is(.nav, .foot__nav) a[href^="mailto:"]:hover { color: var(--ink-invert); }
}


.foot__nav.is-lattice { gap: var(--nav-lattice-gap); }
/* Keeps the dot radius, same as .nav.is-lattice a::before. */
.foot__nav.is-lattice a::before { inset: calc(var(--dot-r) * -1); }
/* The face declaration here is gone too, and this one is the clearest case for
   why the site went to one face at all: this component used to come out in
   Montserrat on the landing page and SF on /about/, because .is-home only put
   SF on .nav--hero and .is-page put it on everything. One component, three
   pages, two faces. body settles it now.

   500 rather than the hero nav's 600: this runs at 18-22px against the hero's
   17px, so the larger size carries the weight. Medium and Semibold are both
   real SF instances, which is why the site never asks for 450 or 550. */
/* The dim-to---ink-soft hover is gone; the tile above carries it now, as it
   does in the header. Dimming the label AND lifting a panel behind it are two
   answers to one gesture. */
.foot__nav a { font-weight: 500; }

/* --- The footer row, sized like the header's ------------------------------
   Same type as the pinned bar, so the same words come out the same size at the
   top and the bottom of a page. This row ran 22px at weight 500 against the
   header's 17px at 400, and the difference did not stop at the type:
   snapNavPanelsToGrid rounds every tile out to whole dot cells, so the larger
   text bought wider tiles — measured at 1470, Work was an 80px tile down here
   against 60 up there, and About 80 against 79.98.

   Both rows are already 40px tall and both are already on the lattice, so
   matching the type is the whole change; the sizing pass does the rest and the
   two rows now produce identical boxes from identical words.

   Placed AFTER the weight rule above rather than inside the 861 block further
   up the file. Both selectors are (0,1,1) and the later one wins, which is the
   second time that ordering has bitten this stylesheet — the first was the
   landing bar's own font-weight, a few hundred lines up.

   Desktop only. The phone footer stacks its nav above the socials with room to
   spare, and 22px is the size that row was tuned at against 44px social tiles. */
@media (min-width: 861px) {
  .foot__nav { font-size: var(--fs-ui); }
  .foot__nav a { font-weight: var(--fw-ui, 400); }
}

.foot__icons {
  display: flex;
  align-items: center;
  gap: 20px;
  position: relative;
  left: var(--foot-icons-snap-x, 0px);
}
/* Fixed square tiles. The size lives here AND on the svg width/height
   attributes in the markup, so an unstyled render still gets 20px marks
   rather than the SVG intrinsic 300x150.

   44 -> 40, which is two whole cells. 44 is not a whole number of anything on
   this grid, so no snap could ever have put both of a tile's edges on dot
   columns while it stayed that size. */
.foot__icons a {
  width: 40px;
  height: 40px;
  display: grid;
  place-items: center;
  border-radius: 10px;
  background: var(--chip-bg);
  color: var(--ink);
  transition: background-color 0.28s var(--ease), transform 0.28s var(--ease);
}
@media (hover: hover) {
  .foot__icons a:hover {
    background: var(--chip-bg-hover);
    transform: translateY(-2px);
  }
}
.foot__icons svg {
  width: 20px;
  height: 20px;
  display: block;
}

/* Status bar, full-bleed on the same --sbw correction as the dot field, so it
   reads as a strip across the foot of the page rather than a box in the
   column. The padding puts the text back on the site gutter. */
.foot__base {
  /* 48 -> 30, matching the 30 above the row: with .foot__top's own rule gone,
     this border IS the band's lower edge, so the row has to be centred between
     the two. */
  margin-top: 30px;
  margin-inline: calc(50% - 50vw + var(--sbw, 0px) / 2);
  width: calc(100vw - var(--sbw, 0px));
  /* + --home-snap-x, because this bar breaks OUT of .shell to span the
     viewport and so never inherits the snapped gutter the rest of the page
     sits on. Without it the clock sat at the raw gutter while the row directly
     above it sat on the dot column: 20 against 27 at 400, 48 against 50 at
     1470. The token is 0 anywhere but the home page, so this is inert on
     /about/ and /resume/. */
  padding: 18px calc(var(--gutter) + var(--home-snap-x, 0px));
  border-top: 1px solid var(--rule);
  /* Opaque --bg, not a tint. The dot field runs to the bottom of the footer,
     and anything translucent here lets it show through behind the clock. A
     solid bar is what makes the dots stop at this rule. */
  background: var(--bg);

  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 6px 24px;

  font-family: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas, monospace;
  font-size: clamp(11.5px, 0.88vw, 13px);
  letter-spacing: 0.01em;
  color: var(--ink-soft);
}
/* Fixed-width digits, or the seconds shove the sentence sideways every tick. */
.foot__base [data-clock] { font-variant-numeric: tabular-nums; }

/* The row wraps here — nav on one line, icons under it — so the band is two
   rows tall and the 30/30 above and below stays, with .foot__top's own row-gap
   carrying the space between them. Both gaps drop to one cell so the two
   groups keep the whole-cell rhythm they have on the wide layout. */
@media (max-width: 760px) {
  .site-footer--full { padding-block: 30px 0; }
  .foot__top { gap: 20px; }
  .foot__nav { gap: 20px; }
  .foot__icons { gap: 20px; }
  .foot__icons a { width: 40px; height: 40px; }
  .foot__base { margin-top: 30px; justify-content: flex-start; }
}

/* --- Motion -------------------------------------------------------------- */

/* Pre-paint hide. Active only while `.js` is set AND `.motion-ready` is not.
   site.js adds `.motion-ready` on every path it can reach (including "GSAP
   failed to load"), and the <head> watchdog drops `.js` if site.js never
   runs at all — so no combination of failures leaves content invisible.
   GSAP writes its own start values before the hide is lifted, so there is
   no flash of the final layout. */
.js:not(.motion-ready) :is(
  .site-header .logo,
  .nav a,
  .hero__title .line > span,
  .hero__wordmark .line > span,
  .hero__lead,
  .btn,
  .card,
  .site-footer > *
) {
  visibility: hidden;
}

@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }

  /* site.js bails before building anything, so only the CSS affordances
     (underline wipe, logo collapse, button fill) remain — shortened. */
  *,
  *::before,
  *::after {
    animation-duration: 0.01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important;
  }
}

/* --- Responsive ---------------------------------------------------------- */

@media (max-width: 1180px) {
  /* .hero padding-top is a clamp now — it already scales, so the old fixed
     overrides here and at 860px would only push the wordmark back down. */
  /* The hover panel spends the gap: two neighbours each claim --nav-panel-x,
     so the clear space between them is gap - 2x. It steps down with the gap to
     keep the panels from meeting. 26 - 20 leaves 6px here. */
  .nav { gap: 26px; --nav-panel-x: 10px; }
  .site-footer__links { gap: 26px; }
  .work { padding-bottom: 96px; }
  .site-footer { padding-block: 56px 48px; }
}

@media (max-width: 860px) {
  :root {
    --fs-hero: clamp(34px, 6.2vw, 44px);
    --fs-lead: 18px;
    --fs-ui: 16px;
    --fs-chip: 13px;
    /* One column here, so a card is full-bleed and the ramp can stay wide:
       21 / 13 / 15.5 keeps the same three steps without the title getting
       shouty against a phone-width image. */
    --fs-card-title: 21px;
    --fs-card-blurb: 15.5px;
  }

  .hero__intro { margin-top: 34px; }         /* same drop, scaled down */
  .hero__lead { max-width: 34em; }
  .btn { margin-top: 30px; }


  .work {
    padding-top: 72px;
    padding-bottom: 80px;
  }
  .work__grid {
    grid-template-columns: 1fr;
    row-gap: 44px;
  }
  /* both selectors, or the (0,2,0) home-grid rule keeps its 24px here */
  .card__title,
  .work__grid .card__title { margin-top: 20px; }
  .card__tags { margin-top: 16px; }

  .logo { width: 118px; }
  /* Same specificity as .logo above, so the modifier has to restate itself or
     the inner-page monogram inflates to 118px here — more than twice the size
     it carries on desktop. */
  .logo--mono { width: 50px; }
  /* Same trap for the wordmark logo: .logo's width would override the grid's
     intrinsic sizing and squeeze the five tiles into 118px. The tile size
     itself is not restated — it tracks --grid-size, which is what keeps the
     mark on the lattice at every width. */
  .logo-wm { width: auto; }
  .site-footer { padding-block: 44px 40px; }
}

@media (max-width: 620px) {
  /* A 20px cell is a big fraction of a 350px row: snapping five tiles and four
     gaps to it left the tiles 40px wide, with 80px of the row unused. A finer
     lattice buys that size back.

     12 did that but overshot — 33 dots across a phone reads as texture rather
     than as a grid, so nothing on the page looked aligned TO anything. 18 is
     the middle: 22 dots, and the wordmark gives up only 6px.

     Worked through snapTilesToGrid at a 400px viewport, where the row has
     350px to spend:

       cell 12   tile 60   gap 12   row 348   33 dots
       cell 14   tile 56   gap 14   row 336   29
       cell 18   tile 54   gap 18   row 342   22   <- here
       cell 20   tile 40   gap 20   row 280   20

     15 and 16 are worse than either neighbour (the gap blows out to two cells
     at 15, and 16 leaves 56px of slack), which is why the ladder skips them. */
  :root {
    --grid-size: 18px;

    /* The 860 block above raises --fs-lead from the base 17 to 18 — a tablet
       size, and it was still landing on phones. On a 346px measure that set
       the hero lead in five 27px lines directly under the wordmark, which is
       what made it read as oversized rather than as supporting copy. 16px is
       the browser default and takes it to four 24px lines. */
    --fs-lead: 16px;

    /* The bar's padding comes down with everything else at this width. 15 is
       three quarters of a 20px cell; three quarters of 18 is 13.5, and 13 is
       the integer below it — see the note beside the landing bar's
       padding-block for why a half pixel is not worth having here. */
    --bar-pad: 13px;

    /* The chat tile brackets the MARK down here, not the mark's dot columns.

       On the desktop this token is 11: half a cell plus the dot radius, which
       lands the outline's runs on the D tile's own dot columns. Applying the
       same rule on a phone would push the chat tile 11px inside the D tile's
       left edge, and that edge is the one alignment on this layout anybody can
       check — the nav row and the wordmark are stacked 40px apart with their
       ends side by side, which is the whole reason both rows were made to span
       the column. A dot column agreeing at the cost of the two box edges
       disagreeing is the wrong way round at this size.

       So: just the overhang. --dot-r is the pixel the ::before already reaches
       past its link, so cancelling it puts the drawn rectangle's left edge on
       the D tile's left edge and nothing else moves. This is the value the
       token resolved to before, by subtracting a phase that no longer exists;
       it is stated now rather than arrived at. */
    --chat-lattice-x: var(--dot-r);
  }

  /* The row question is settled by there being one thing left in it.

     This header has wrapped and un-wrapped twice. First the mark and the nav
     needed ~387px of a 350px column, so the nav dropped underneath and the
     header stood 104px tall; then the bar was pinned, 104px became far too tall
     for it, and the two were squeezed onto one row by taking the mark down to a
     20px tile — 113px of mark against 176px of nav, 313px of the 350.

     Neither arrangement is live. The nav is fixed now and out of the flow
     entirely, so the header's only child is the mark and there is nothing left
     to wrap, fit or share a gap with. What remains is the alignment, kept
     because the mark is a grid of tiles and centring it in its own row is what
     puts its dots on the same rows the bar's tiles use. */
  .site-header { align-items: center; }
  /* 18 - 14 leaves 4px between panels, and the nav band is 36px here rather
     than 40, so the vertical inset comes in a step too. */
  .nav { gap: 18px; --nav-panel-x: 7px; --nav-panel-y: 2px; }

  /* No phone-only pattern size here, and the gap it leaves is deliberate.

     There was a `background-size: calc(100% / var(--tile-cols, 3)) 50%` on both
     rows, fed by a per-link count from snapNavPanelsToGrid. It was solving the
     problem --tile-markers now solves at base level for every width at once:
     the tiles down here are not whole cells, so a --grid-size pattern left a
     remainder and the dots crowded one edge. Dividing the tile closed it; eight
     marks sized to the tile makes the question not arise.

     It has to be GONE rather than merely redundant. background-size is one
     property, the marker layers need --tile-marker-size, and this rule sat
     later in the cascade — so it resized all eight of them to a third of the
     tile and every mark landed a sixth of the way in from the corner it is
     supposed to sit on. See --tile-markers in the token block. */

  /* The panel-wins correction, moved off `left` and onto the padding.

     It was `left: calc(var(--nav-snap-x) - var(--nav-panel-x))`, and that was
     right for a nav in the flow: position: relative, so `left` is an offset
     against where the box already sits, and pulling it back by the panel's
     overhang put the TILE on the page's right axis rather than the word.
     Measured at 390 before it existed, the last tile ended at 376.0 against a
     column that ends at 370.0 — the furthest right thing on the page.

     The nav is fixed now, and on a fixed box `left` is not an offset, it is the
     inset that pins the bar to the screen's edge. The base rule declares
     left: 0 and this one, later in the cascade and the same (0,2,0) weight,
     would have won and dragged the whole bar 7px off the corner. Same trap the
     desktop block calls out for `translate`, arriving from the other side.

     Padding is the mechanism a fixed box has: the row's right edge comes in by
     the gutter plus the panel's overhang, so what lands on the axis is the
     tile. Only below 620, because from 621 up snapNavPanelsToGrid gives the
     links real padding and adds .is-lattice — the tile IS the border box up
     there, there is no overhang left, and the term would double-count. */
  /* The page's own axis, not the raw gutter. --page-snap-x is the offset
     snapLogoToGrid (or snapTitleColumnToGrid, on /resume/) puts the mark and
     the whole column on, and this row has to sit on it too or the bar runs a
     different left edge from everything under it. Measured at 390: 20..370
     against the mark's 27..363, and against the landing bar's 27..363, which
     consumes its own --home-snap-x in exactly this shape. Three bars, two
     axes, and the phone is where it shows because the rows are wide there.

     Readable here because both snaps now write the property on <html> rather
     than on .m-page — the header is that column's SIBLING. */
  .site-header .nav {
    padding-inline: calc(var(--gutter) + var(--page-snap-x, 0px) + var(--nav-panel-x));
  }

  .site-footer {
    flex-direction: column;
    align-items: flex-start;
    gap: 20px;
  }
  .site-footer__links { gap: 24px; }
}

/* ==========================================================================
   Device frame — iPhone 17 Pro. Reusable anywhere a phone screen is shown.

     <span class="device">
       <img class="device__screen" src="…" alt="…" width="804" height="1748">
     </span>

   Give the .device a height (or width) from the outside; the aspect-ratio
   supplies the other axis.

   The frame is the vector mockup in images/device/iphone-17-pro.svg, laid over
   the screenshot as a background. That file is worth reading before changing
   anything here:

   - Its corners are true squircles — long bezier chains, not ellipse
     quadrants. CSS border-radius cannot draw this shape, which is why the
     frame is an image rather than a box.
   - The screen aperture is genuinely knocked out of the artwork, so the
     screenshot simply sits underneath and shows through.
   - The Dynamic Island and speaker cutout are masked back IN, so they paint
     over the screenshot the way they do on a real device.
   - The bezel is opaque for only 14 units around the aperture, but the corner
     arc runs 201.6 units. A screenshot is therefore NOT covered at the
     corners by the bezel — its square corners protrude about 45 units past
     the device outline. That is what iphone-17-pro-screen.svg is for: it
     carries the same aperture path and masks the screenshot to it.

   Aperture, in the SVG's own 880 x 1832 coordinate space:
       x 38   y 42   w 804   h 1748
   That is 402 x 874pt at 2x — the real iPhone 17 Pro screen. The percentages
   below are those four numbers over 880 and 1832. Screens must be authored at
   804 x 1748, or any 0.46 ratio; anything else gets cropped by object-fit.
   ========================================================================== */

.device {
  position: relative;
  display: block;
  aspect-ratio: 880 / 1832;
}

.device__screen {
  position: absolute;
  left: 4.3182%;                             /*   38 / 880  */
  top: 2.2926%;                              /*   42 / 1832 */
  width: 91.3636%;                           /*  804 / 880  */
  height: 95.4148%;                          /* 1748 / 1832 */
  display: block;
  /* Cut to the aperture's own squircle. The mask file's viewBox IS the
     aperture, and this box is sized to the aperture, so 100% 100% maps the
     two 1:1 — no rounding slack at the edge. */
  -webkit-mask: url(../images/device/iphone-17-pro-screen.svg) center / 100% 100% no-repeat;
          mask: url(../images/device/iphone-17-pro-screen.svg) center / 100% 100% no-repeat;
  object-fit: cover;
}

/* One cached request, however many phones are on the page. Stretched to the
   box rather than `contain`, so rounding can never leave a hairline gap at the
   edge — the box already carries the SVG's own aspect ratio. */
.device::after {
  content: '';
  position: absolute;
  inset: 0;
  background: url(../images/device/iphone-17-pro.svg) center / 100% 100% no-repeat;
  pointer-events: none;
}

/* ==========================================================================
   Spacez Captains card — scoped to .card--captains only.

   Three app screens laid out in HTML rather than baked into one SVG, so each
   can animate and be hovered independently. An SVG loaded through <img>
   renders CSS animations but receives no pointer events, so :hover inside it
   could never fire.

   No .card__img here on purpose: site.js only attaches its entrance tween when
   it finds one, so leaving it off hands hover to CSS with nothing to fight
   over. The mask reveal and card lift still run.

   The placard has NO surface of its own — no fill, no border, no radius. The
   screens sit directly on the page background so this stops reading as a card.

   Sizing is driven by HEIGHT, not width. Setting height and letting width
   follow means the screens can never overflow the box vertically, and the
   percentage is tuned per breakpoint so three of them always clear the sides
   with margin left over — filling the box edge to edge is not the goal.
   ========================================================================== */

.card--captains .card__media {
  background: none;
  box-shadow: none;
  border-radius: 0;
}

.card--captains .card__parallax {
  display: flex;
  align-items: center;                       /* centred, not top-anchored */
  justify-content: center;
  gap: 3.5%;
}

/* Each .phone is also a .device, so the shape, the corner radius and the
   aperture all come from the frame — nothing here should restate them.
   flex: 0 0 auto keeps the flex layout from resolving a width of its own and
   overriding the one the aspect-ratio derives from the height. */
.card--captains .phone {
  flex: 0 0 auto;
  height: 86%;
  width: auto;
  /* drop-shadow, not box-shadow: the silhouette is the squircle body plus the
     side buttons, and box-shadow would trace the element's rectangle instead.
     will-change promotes the phone so the blur is rasterised once and the
     hover transform only moves the finished layer. */
  filter: drop-shadow(var(--phone-drop));
  will-change: transform;
  transition: transform 0.55s var(--ease), filter 0.55s var(--ease);
}

/* Idle drift — the three breathe out of phase so the set never looks frozen.
   Transform-only, so it stays on the compositor. */
.card--captains .phone--1 { animation: captainsFloat 7s var(--ease) infinite; }
.card--captains .phone--2 { animation: captainsFloat 7s var(--ease) 1.15s infinite; }
.card--captains .phone--3 { animation: captainsFloat 7s var(--ease) 2.3s infinite; }

@keyframes captainsFloat {
  0%, 100% { transform: translateY(0); }
  50%      { transform: translateY(-2.4%); }
}

/* Hover — the set fans apart and lifts, so all three screens read clearly */
@media (hover: hover) and (pointer: fine) {
  .card--captains:hover .phone {
    animation-play-state: paused;
    filter: drop-shadow(var(--phone-drop-hover));
  }
  .card--captains:hover .phone--1 { transform: translate(-6%, -1%) rotate(-1.8deg); }
  .card--captains:hover .phone--2 { transform: translateY(-4.5%); }
  .card--captains:hover .phone--3 { transform: translate(6%, -1%) rotate(1.8deg); }
}

@media (prefers-reduced-motion: reduce) {
  .card--captains .phone { animation: none; }
  .card--captains:hover .phone--1,
  .card--captains:hover .phone--2,
  .card--captains:hover .phone--3 { transform: none; }
}

/* Small screens. These are portrait screens in a landscape box, so width is
   what runs out first — three across a 350px card caps each at about 110px no
   matter how tall the box is. Below the two-column breakpoint the box gets a
   little taller and the height share comes down, which keeps all three on one
   row without shrinking them into thumbnails. */
@media (max-width: 620px) {
  .card--captains .card__media { aspect-ratio: 3 / 2; }
  .card--captains .phone { height: 78%; }
  .card--captains .card__parallax { gap: 3%; }

  /* --- The landing lead stays a caption on phones -------------------------
     This was briefly set at 24-30px to give the first screen a voice. It gave
     it too much: at 27px the paragraph ran seven lines and pushed the first
     work card to y=531, so the opening screenful was a nav, a logo and a wall
     of text, and the work — the reason anyone is here — was a scroll away.

     16px, the size the rest of the page's body copy runs at. Measured on a 390
     against the same 844 viewport:

       27px   7 lines   246px tall   first card at 531
       16px   5 lines   120px tall   first card at 405

     126px recovered by the size alone. Cutting the copy was the other lever
     and it is much weaker — dropping the middle sentence saves a further 24px,
     dropping everything but the first sentence 72px — so the paragraph keeps
     all three sentences and the degree line. The type size was the problem,
     not the words.

     620, not 860. This belongs with the rest of the phone layout — the finer
     dot lattice, the wrapped header, the stacked cards all switch here.

     .is-home scopes it to the landing page: /about/ and /resume/ use the same
     .hero__lead class for a paragraph that sits in a stacked column beside a
     portrait and wants no part of this. */

  /* ...and the other lever, taken after all. The note above rates cutting copy
     as much weaker than the type size, and that stands: 16px bought 126px and
     this buys a line. But the two are not alternatives, and once the size had
     done its work the middle sentence was the only thing still on the screen
     that the reader did not need in order to know who this is and what he
     claims. See the .wide-only span in index.html for which sentence and why.

     The type size stays exactly where it is. This is the second cut, not a
     replacement for the first. */
  .wide-only { display: none; }

  .is-home .hero__lead {
    font-size: 16px;
    line-height: 1.5;

    /* --- The sentence measures the mark ------------------------------------
       The paragraph sits directly under DHरUV and its job on this screen is to
       be the line under the name. So its measure is the mark's: the D tile's
       left edge to the V tile's right edge, both of them, on every line.

       Two things were stopping it, and only one of them was obvious.

       max-width was 34em from the 860 block, which is 544px — wider than a
       phone column, so it looked inert and mostly was. At 620 it is not: the
       column is 566 there and the cap cut the paragraph 22px short of the V for
       no reason anyone could see. none, so the mark is the only thing setting
       the width.

       text-wrap: balance was the real one, and it was doing its job correctly.
       balance evens the lines out by finding the narrowest width that still
       takes the same number of lines — which on this paragraph at 390 meant
       lines ending 118, 79 and 73px short of the V, all three tidily agreeing
       with each other about a column that is not the one they are in. It was
       chosen against `pretty`, whose greedy fill left one line long, one short
       and a stub. Neither reaches the edge; balance just misses it evenly.

       justify reaches it. Every line but a block-final one is set flush to both
       edges, so the paragraph's left edge is the D's and its right edge is the
       V's, by construction rather than by luck of where the words fall. The
       cost is real and worth naming: at 390 the first line has to find 47px
       across seven word spaces, so they open to roughly two and a half times
       their natural width on that one line. Four lines of 16px text is a short
       enough block for that to read as set rather than as sprung.

       text-align-last is left at its default. The last line of each block — the
       sentence's tail and the degree line under it — stays ragged, which is
       what stops the paragraph looking like a justified column of two lines and
       two accidents.

       hyphens stay off (the default), deliberately. Hyphenation is the usual
       companion to a narrow justified measure and it is the wrong trade here:
       the .nb spans in the markup exist because a break after a hyphen made
       "Non-" and "CS" read as a word that is not there. */
    max-width: none;
    text-align: justify;
  }

  /* --- The phone hero's vertical rhythm ----------------------------------
     Three blocks, evenly spaced: the nav, the name, the sentence.

     It was 18px then 34px, which is neither even nor on the lattice — 34 is
     not a whole number of the 18px cell this breakpoint uses, so the paragraph
     started between two dot rows. Two cells for both gaps makes the rhythm one
     number instead of two and lands each block on a row.

     snapNavToGrid rounds this margin to whole cells and writes --nav-gap, so
     36 is passed through rather than re-rounded; the value has to be a whole
     multiple of the cell for that to be a no-op. */
  /* The bar is out of flow, so the hero reserves its height here instead.

     99 = the bar's 54 (half a cell of padding, a two-cell tile, half a cell
     again) plus 45 of clear field beneath it. Both parts are chosen for the
     lattice rather than picked: 99 is a dot row (9 + 5 cells), so the mark's
     top edge lands on one, and it is where the mark sits — nothing above it
     moves when the bar's own height is re-derived.

     Stated flat, not through --hero-pad-top. snapNavToGrid stands down on
     phones for exactly the reason this rule exists (see its note), so nothing
     writes that property here and the fallback would be the base clamp's 28. */
  .hero { padding-top: 99px; }

  /* Same offset for in-page jumps. "Work" is href="#work", and without it the
     section scrolls flush to y=0 and the bar sits on the first card's own top
     edge. 72 is the bar's 54 plus one cell of clear. */
  #work { scroll-margin-top: 72px; }

  /* 36 above the mark, 54 below it — not the even 36/36 this had.

     Even spacing made the three blocks one list, and they are not: the nav and
     the mark are the header, and the sentence is what the header introduces.
     Equal gaps gave the mark no more to do with the nav above it than with the
     paragraph below, so the whole thing read as one continuous run of text
     rather than a masthead and a line under it.

     Half a step more under the mark is enough to group the first two and
     separate the third. It costs 18px of the 126px the smaller type just
     recovered, and the first card still lands at 423 on a 390. */
  .nav--hero { margin-bottom: 36px; }
  .hero__intro { margin-top: 54px; }

  /* --- The mark fills the column, on a cell of its own ---------------------
     The row spans the column at every width AND lands on one lattice, which
     for a while looked like a choice between the two.

     It was five equal fractions and a 2.6% gap. That filled the column — 336
     at 390, 266 at 320, right at every width between, with no step to fall
     off — and it was chosen over the desktop snap because an 18px cell put
     five whole-cell tiles and four whole-cell gaps at 252px of a 336px column,
     leaving the V 84px short of the screen. Filling won, and the cost was that
     tile + gap came to 3.83 cells, so nothing in the row agreed with anything
     else about where a dot goes.

     Both, once the CELL is what gives way rather than the row. snapTilesToGrid
     now derives it from the column instead of taking the page's 18: five tiles
     of three cells and four gaps of one is nineteen cells, so the cell is the
     column over nineteen and the row fills it exactly by construction. At 390
     that is a 17.68px cell, which is the page's 18 to within a third of a
     pixel — the number was not given up, it was arrived at from the other end.
     The tile count rises with the column so the cell stays near 18 rather than
     stretching; see the branch in site.js.

     The letters are 53.1 wide at 390 against the 60.2 the fractions gave, and
     the gap 17.7 against 8.7. That is the whole price: a slightly smaller
     letter set slightly further apart, in exchange for every dot in the mark
     being on one rhythm.

     Fallbacks are the old fractions verbatim, so a blocked script still gets a
     row that fills the column — it just has five separate grids in it, which
     is what every phone had until now. */
  .hero__wordmark {
    grid-template-columns: repeat(5, var(--tile-w, 1fr));
    gap: var(--tile-gap, 2.6%);
    margin-left: 0;                /* the snap's --tile-shift-x, stood down */
  }

  /* Each letter divides its own dots, so the five come out identical.
     ----------------------------------------------------------------------
     The version before this kept the page's 18px cell and put each tile in
     PHASE with the dot field, so a dot inside a letter would be the
     continuation of the dot beside it. On the desktop that works, and the
     reason is an invariant the snap supplies: tile and gap are both whole
     cells up there, so tile + gap is a whole number of cells and every tile
     lands on the same phase.

     Below 620 the snap stands down and the row is repeat(5, 1fr) with a 2.6%
     gap. At 390 that is a 60.22 tile and an 8.74 gap: 68.96px, or 3.83 cells.
     Each tile is 0.83 of a cell out of step with the one before it, so the
     phase walks across the row instead of holding. Measured, the distance
     from a tile's left edge to its first dot column:

       D 18.0   H 21.1   र 24.1   U 9.1   V 12.2

     and on the right, 6.2 / 3.2 / 0.1 / 15.1 / 12.0 — the D carrying a dot on
     its left edge, the र one on its right, the U a 15px bare band. Five boxes
     that are meant to read as one word, each cut differently.

     The other half of it: the field being matched to is INVISIBLE. The block
     at .hero__grid sets opacity: 0 site-wide. So the phase bought an alignment
     to something nobody can see and paid for it with the one thing they can.

     Dividing the tile is the fix, and the objection it was dropped for goes
     with the field. It does give the letter a rhythm of its own — 20.07px at
     390 against the page's 18 — but there is no longer a second grid on screen
     for that to disagree with, and in exchange every letter gets the same dots
     at the same half-cell inset from all four of its edges. Which is what the
     desktop tiles already look like: 240px tile, 20px cell, dots at 10 to 230.

     Fallback 3, for JS off, and it is the right answer at 320 and 390 anyway.
     snapTilesToGrid writes the measured count; see its phone branch. */
  .hero__tile {
    background-size: calc(100% / var(--tile-dots, 3))
                     calc(100% / var(--tile-dots, 3));
  }

  /* --- The nav row measured against the mark -----------------------------
     The nav and DHरUV are the two things stacked at the top of a phone screen,
     and they were two different widths on two different rhythms — the nav laid
     out from the gutter with a flat gap, the mark from its tile grid. The
     header only reads as one object if they share both edges.

     Both now span the column, so the nav takes the same box inset by the hover
     box's own padding on each side. Then:

       Work's hover box   lands on the D tile's left edge
       Let's Chat's box   lands on the V tile's right edge

     because it is the BOX that has to hit the edge, not the word, and the box
     reaches --nav-panel-x past its word on both sides. */
  /* --- Pinned to the top of the screen ------------------------------------
     fixed rather than sticky, and that is forced. A sticky element is held by
     its own parent's box, and this nav's parent is .hero — so it would unstick
     the moment the hero scrolled past, around y=500, which is before the work
     grid even starts. Sticky would need the nav moved out to .shell, which
     changes the desktop layout to solve a phone problem.

     Out of flow means the hero no longer reserves its height, so .hero takes a
     padding-top above instead of the snapped --hero-pad-top (snapNavToGrid
     stands down on phones — see its note).

     Pinned, the row always has something moving underneath it, and it covers
     none of it: no fill at rest, none on scroll. See the no-surface note
     below for what was tried and why none of it stayed. */
  .nav--hero {
    position: fixed;
    top: 0;
    left: 0;
    right: 0;
    z-index: 50;

    /* Not margin/width: fixed makes percentages resolve against the viewport,
       not .shell, so the column is rebuilt from the same expression .shell
       uses — gutter plus the home page's dot-column snap — and then the hover
       box's own padding, so it is the BOX and not the word that lands on the
       column edge. Work's box then sits on the D tile's left edge and Let's
       Chat's on the V's right, which is the whole reason this row spans.

       That last term is the fallback. Once snapNavPanelsToGrid succeeds the
       tile IS the link's border box, there is no overhang left to correct, and
       the .is-lattice rule below drops it. */
    margin-left: 0;
    width: auto;
    padding-inline: calc(var(--gutter) + var(--home-snap-x, 0px) + var(--nav-panel-x));

    /* 13 top and bottom here, 15 on the desktop, both from --bar-pad. It was
       half a cell — 9 against a 32px link box, so the bar came to 50 and cut on
       a cell boundary with the tile's own edges on dot rows 9 and 45. That
       argument was about a lattice this page no longer paints, and 15 does not
       divide a 20px cell anyway, so the pair is stated rather than derived. See
       the token for what each value makes the bar.

       13 rather than 13.5, which is what three quarters of an 18px cell gives.
       An integer keeps the row's top on a whole pixel, and the tile marks are
       1px hard-stop gradients — half a pixel of offset spreads each one across
       two device pixels and takes the crispness the marks exist for. */
    padding-block: var(--bar-pad);

    /* Nothing, at any scroll position. The real field paints the strip this
       occupies at rest, and once the page moves it is the page's own content
       back there — visibly, which is the point. What separates the links from
       it is the plate each one carries; see the .is-stuck note below. */
    background: none;

    /* The desktop arrangement, on the phone too: Let's Chat alone at the left
       end, Work / About / Résumé held together at the right.

       This row used to be four equal taps spanning the column — space-between,
       gap 0, and `order: 1` on the chat link to send it to the far end. Even
       spacing made the four one list, and they are not one list: three of them
       go somewhere on this site and the fourth opens a mail client. The bar on
       every other page already said so by grouping.

       The mechanism is the one the inner pages use, not a second invention:
       flex-end plus an auto margin on the first item, which absorbs the free
       space at that one point and leaves the other three exactly where flex-end
       put them. So the chat tile lands on the D tile's left edge and Résumé's on
       the V's right — the same two edges the old arrangement bracketed, with
       different words on them.

       gap is real again, and the note it replaces was right for its layout: a
       flex gap is a MINIMUM, applied before space-between distributes anything,
       so at 18px it pushed the last box 1.61px past the V and only gap 0 could
       land both ends. Nothing distributes here — the auto margin takes the slack
       — so the gap is free to be the cell, and the tiles need it: at
       --nav-panel-x 7 they meet at 14 and clear at 18.

       The one width this does not fit is 320: four words are 222.94 and three
       18px gaps make 276.94 against a 266px column. See the 360 block below. */
    justify-content: flex-end;
    flex-wrap: nowrap;
    gap: var(--grid-size);
  }

  /* Same item, opposite job. It carried `order: 1` to send the chat link to the
     end of an evenly-spread row; now it holds the left end and the auto margin
     is what pushes everything else away from it. */
  .nav--hero > a:first-child { margin-right: auto; }

  /* The tile, held open rather than waited for.

     Per WORD, not a bar. The row used to paint itself in color-mix(--bg 88%)
     behind a 12px blur once the page moved, which on the dark palette is a
     solid black strip closing off the top of the page.

     What each link carries instead is the panel the hero letters already have:
     --tile-bg, the same ~4% wash that sits behind D H र U V two rows below.
     Four tiles over five, with the dots reading through every one of them.

     A phone has no hover, so opacity 0 here means the panels would never be
     seen at all — the state this reveals is the only one a touch reader gets.
     The panel itself is already built (.nav a::before, sized to --nav-panel-x/y
     or to the lattice); this only turns it on. */
  /* 500, down from the 600 this row carried on a phone. At 16px on a 390
     column, 600 against body copy's 400 read as a heading rather than as a bar,
     and it was the loudest type on the first screen — louder than the lead
     directly under the mark. 500 keeps a step of separation from prose, so the
     row still reads as controls, and matches .foot__nav a at the other end of
     the page: the two bars now agree, which they never have.

     Nothing measures this. The weight mattered above 620 because
     snapNavPanelsToGrid rounds each tile out to whole cells and a heavier word
     can cross a boundary its lighter twin does not — but that pass bails below
     620 (`cell < 20`), so down here the weight is free to be chosen on looks. */
  /* The 500 that was here is now --fw-nav on the base .nav a rule, so it holds
     at every width instead of only below 620. Nothing changed on a phone; what
     changed is that the desktop stopped being the lighter of the two.

     Kept as a note rather than deleted, because the reason it was written this
     way is still worth knowing: it first read `.is-home .nav--hero a`, which
     put the weight on the landing row and left the inner pages' links at 400
     beside a footer running 500. The two bars carry the same words at the same
     size in the same place; a weight apart is the kind of difference you see
     without being able to name it. */


  /* The phone's translucent scrim is gone; the base rule's solid fill stands.

     What was here overrode `background: var(--bg)` with --bar-scrim under a
     --bar-fade dissolve, plus the full-bleed breakout the falloff needed. The
     argument was that a fill reads as a lid on a mostly-empty field while a
     dissolve reads as content passing beneath — and it was a good argument
     about a page that had a dot field running the full height behind the bar.
     That field came off the site. There is nothing behind the strip now but the
     page's own content, which is exactly what an opaque edge is for.

     No breakout to restore either: .nav--hero is fixed with left and right both
     0, so its own background already spans the viewport. */

  /* --- The row brackets the mark, and that is the alignment that is checkable
     ------------------------------------------------------------------------
     There was a .nav--hero.is-lattice rule here that laid this row out from its
     left edge in whole dot cells, so all six inner tile edges landed on dot
     columns. It is gone, along with the phone branch in snapNavPanelsToGrid
     that fed it. Both are worth explaining rather than just deleting.

     What it cost: 336px of column at a 390 viewport is 18.67 cells, and no
     whole-cell row can close that remainder, so the last tile stopped on the
     last dot column that fit and the row ended short of the mark. Measured,
     Let's Chat's tile ended at 350.97 against the V tile's 363.03 — 12.06px of
     daylight at the one edge in the whole layout where a tile edge and a letter
     edge sit next to each other and can be compared by eye.

     Why the trade was wrong, not merely unlucky: below 620 THE WORDMARK IS NOT
     ON THE LATTICE. It is repeat(5, 1fr) with a 2.6% gap and --tile-shift-x
     stood down, so it spans the column with proportional gaps and nothing about
     it touches a dot column. The nav was keeping faith with a grid its own
     reference row had already left, and the disagreement surfaced at the only
     edge anyone can check.

     So both rows span the column now. The base rule above does it with
     space-between and gap 0, and its padding-inline carries the --nav-panel-x
     term so it is the tile, not the word, that lands on the column edge: Work's
     tile on the D's left, Let's Chat's on the V's right. Measured after, at
     320 / 390 / 430, the right edges agree to within 0.03px and the left to 0.

     Given up, and named: the six inner tile edges no longer land on dots.
     Nobody can see those. The 12px could not be missed. */

  /* The footer's row takes the same treatment, for the same reason.

     It carries the identical four links and the identical hover-box idiom, and
     its left edge was already on the page axis — but it sat as a flow row with
     a 40px gap and stopped at 329 of a 373px column, so the two rows bracketed
     different widths on the same screen. Spanning it puts Work's box on the D
     tile's left edge and Let's Chat's on the V's right edge, top and bottom of
     the page alike.

     gap: 0 for the reason spelled out above: a flex gap is a minimum, taken
     out before space-between distributes, so any non-zero value here pushes
     the last box past the edge it is supposed to land on.

     The size comes down with it. The footer row is set a step larger than the
     header's (clamp(18px, 1.5vw, 22px) against 16px), which reads fine when it
     can wrap onto two lines but not when four labels have to share one phone
     row — at 360 it would need more text than the row has. */
  .foot__nav {
    /* Declared here, not inherited. --nav-panel-x/y are set on .nav, and
       .foot__nav is not a .nav — so every calc() below referencing them was
       INVALID and the whole declaration was being dropped, which is why the
       first attempt at this changed nothing. Same reason .foot__nav a::before
       never rendered: its `inset` is built from both tokens, and one undefined
       var takes the property with it. Silent in every case — no error, no
       warning, just a rule that quietly is not there. */
    --nav-panel-x: 7px;
    --nav-panel-y: 2px;

    margin-left: var(--nav-panel-x);
    width: calc(100% - 2 * var(--nav-panel-x));
    justify-content: space-between;
    flex-wrap: nowrap;
    gap: 0;
    font-size: 16px;
  }

  /* The socials centre on the screen once the row above them spans it.

     .foot__top is a wrapping flex row, so on a phone the nav and the icons are
     already on separate lines — the icons just inherited the row's
     space-between and sat hard left, under Work. Full width plus centring puts
     them on the viewport's own axis, which is the one thing on this page that
     is symmetric rather than left-squared, and reads as a deliberate close to
     the column above.

     left goes back to 0: --foot-icons-snap-x is snapFooterRowToGrid holding
     their RIGHT edge on a dot column, which is the correct behaviour while
     they sit at the end of a shared row and exactly wrong once they are
     centred — it would drag them off centre by up to a cell. */
  .foot__icons {
    width: 100%;
    justify-content: center;
    left: 0;
  }
}

/* The narrowest phones still in use (iPhone SE 1st gen at 320, and 319 once a
   scrollbar is counted). Four labels at 16px need 222.9px of text in a 251px
   row, which leaves 9.4px between words — the hover boxes reach 7px each side
   and would overlap by 4.6. One step down to 14px is 198.1px of text, 17.6px
   between words and 3.6px of clearance. Above 340 there is room for 16px, so
   this only bites where it has to.

   There was a three-step ramp here — 13px under 378, 12px under 360 — and it
   existed for one reason: to shrink the four labels until a whole-cell row fit
   the column, since 16px needs 324px of row and a 390 viewport has 336. That
   row is gone (see the note beside .nav--hero in the phone block), so the ramp
   was buying nothing and costing three points of type on the two commonest
   phone widths there are. Back to the one step that is about legibility.

   Both bars, not just the landing one. It read `.nav--hero a` while the inner
   pages held three links and never came close to the limit; they hold four now
   whenever the contact handoff is up, which is the same four labels in the same
   column and so the same 4.6px overlap. Applied at rest rather than only during
   the handoff, because a bar that changes type size mid-scroll is worse than
   one that is a point smaller on the narrowest phone there is. */
@media (max-width: 340px) {
  .nav a { font-size: 14px; }
}

@media (max-width: 430px) {
  .card--captains .phone { height: 80%; }
  .card--captains .card__parallax { gap: 2.5%; }
}

/* ==========================================================================
   Touch ergonomics
   ==========================================================================

   Two mobile-only problems, both measured on a 390px phone with real touch
   emulation rather than a narrow desktop window (which reports hover: hover
   and takes entirely different code paths).

   Nothing in this section changes layout. The hit areas grow through absolutely
   positioned pseudo-elements, which are outside flow, so every link stays
   exactly where it was to the pixel. Padding plus a cancelling negative margin
   would have worked too, but it would have changed .logo-wm's offsetHeight, and
   dropMarkToImage measures that.

   Which pseudo-element each one gets is not arbitrary — two are already spoken
   for and were checked before writing this:
     .nav a::before             the hover panel
     .site-footer__links a::after   the underline wipe
   ------------------------------------------------------------------------- */

@media (hover: none), (max-width: 620px) {
  /* Apple's minimum is 44x44. Measured before: nav links 32 tall, footer nav
     27, the mark 24, the case CTA 37, footer icon tiles 40 square. Each inset
     below is half the shortfall for that specific element, so all of them land
     at 44 or just over. */
  .nav a::after,
  .foot__nav a::after,
  .logo-wm::after,
  .case-cta__link::after,
  .store-badge::after,
  .more__head .link-underline::after,
  .site-footer__links a::before,
  .chips a::after,
  .foot__icons a::after {
    content: "";
    position: absolute;
    /* Vertical only. These rows are already wider than 44px, and stretching
       them sideways would overlap the neighbouring link's hit area. */
    left: 0;
    right: 0;
    top: calc(-1 * var(--tap-grow));
    bottom: calc(-1 * var(--tap-grow));
  }

  .nav a::after { --tap-grow: 6px; }                      /* 32 -> 44 */
  .foot__nav a::after { --tap-grow: 9px; }                /* 27 -> 45 */
  .logo-wm::after { --tap-grow: 12px; }                   /* 20 -> 44 */
  .case-cta__link::after { --tap-grow: 4px; }             /* 37 -> 45 */
  .store-badge::after { --tap-grow: 6px; }                /* 32 -> 44 */
  .more__head .link-underline::after { --tap-grow: 10px; }/* 24 -> 44 */
  .site-footer__links a::before { --tap-grow: 6px; }      /* 32 -> 44 */
  /* The contact chips, once they stop being pills. The <li> used to carry
     7px of padding and the <a> was only as tall as its text; the treatment
     moved the box onto the anchor, and below 620 snapNavPanelsToGrid bails so
     --panel-pad-y falls back to 4 and the anchor measures 32. Same shortfall
     as the bars, same correction. A skills chip is not a link and never
     reaches this rule. */
  .chips a::after { --tap-grow: 6px; }                    /* 32 -> 44 */

  /* The only one that needs width as well: a 40px square tile, and its
     neighbours are 20px away, so 2px a side cannot reach them. */
  .foot__icons a::after { --tap-grow: 2px; left: -2px; right: -2px; }

  /* The pseudo-element needs the link itself as its containing block. These
     four are static by default; the other three are already relative and are
     deliberately not restated, because .site-footer__links a's underline is
     positioned against it and must not move. */
  .foot__nav a,
  .foot__icons a,
  .case-cta__link,
  .store-badge,
  .more__head .link-underline { position: relative; }
  /* .chips a is already position: relative under .js — it has to be, for the
     tile's ::before. Restated for the no-JS path, where the anchor is a plain
     inline box inside a pill and the grow would otherwise resolve against the
     page. */
  .chips a { position: relative; }

  /* The footer clock and "Last updated" line bottom out at the 11.5px floor of
     their clamp on any screen under about 1420px, which is below the smallest
     size the rest of the site considers readable (--fs-chip floors at 12.5).
     Scoped rather than raising the clamp floor itself, because that would also
     lift it at 1180 on desktop. */
  .foot__base { font-size: 12.5px; }
}

/* ==========================================================================
   The pinned bar (mobile)
   ==========================================================================

   One bar, two states. The site nav is pinned so Work / About / Résumé are
   always a tap away, and on a case study the section rail takes the same slot
   while you are actually inside the article, handing it back at both ends.

   Two bars was the alternative and it does not fit: the header was 104px and
   the rail 49px, which is 153px of an 844px screen, 18%, permanently gone.

   The swap is a class on <html>, not a scroll threshold. buildStickyBar in
   site.js observes the sections themselves, so "reading" means a section is
   genuinely on screen rather than "scrolled past N pixels" — no threshold to
   tune, and no hysteresis needed because an IntersectionObserver does not
   oscillate the way a scroll comparison does.
   ------------------------------------------------------------------------- */

@media (max-width: 860px) {
  /* The header no longer sticks, and the mark takes a row of its own
     ------------------------------------------------------------------------
     Down here the HEADER used to be the sticky bar — mark and nav in one row,
     travelling together — while every width above 861 pinned the nav alone and
     let the mark scroll away. Two bars on one site. The nav's pin came out of
     its media query (see "The nav leaves the flow" at base level) and this is
     the other half: the phone header goes back into the flow.

     Which forces the padding. The bar is 50px tall here (half a cell, a 32px
     link box, half a cell again) and it is SOLID from 4px of scroll — so a mark
     sitting at y=17, where the shared row used to put it, is behind an opaque
     surface for every scroll position except the very first. It cannot share
     the strip with a bar that fills the strip. Three cells of top padding put
     it at 54, clear of the bar's 50 and on a dot row, with a cell of air under
     it before the page starts.

     That padding was `calc(var(--bar-h) + var(--grid-size)) var(--grid-size)`
     for as long as there was a mark to clear. There is not, at any width below
     861 — see the block directly under this one — so what is left to reserve is
     the bar and nothing else, and the whole expression collapses to --bar-h.
     The rule moved down there with the hide, since the two are one decision.

     --bar-h is MEASURED, by setBarHeight in site.js, and the first version of
     this rule is why. It said `--grid-size * 3`, which is the bar's height only
     where the link box is 32: from 621 up the row is latticed and the box is
     40, so the bar came to 60 against a padding of 60 and the mark's top landed
     at 58, two pixels behind it. The fallback is the taller of the two, so a
     JS-less render over-clears rather than under-clears. */
  .site-header { background: none; }

  /* --- No inner page carries the mark down here, and none carries its row ---
     Above 861 the mark is doing a job on each page it appears on. /about/ has
     --centred put it on the portrait's axis and dropMarkToImage lower it until
     the two read as one stacked unit; a case page keeps it in the corner as the
     route home while the article runs seven screens under it.

     Neither job survives the breakpoint. The centring grid is off (see the note
     on --centred: a centred mark over a full-width nav row reads as a mistake)
     and the drop bails with it, and the case pages' corner is no longer a
     corner — the bar spans the viewport and the mark has been pushed onto a row
     below it. What is left on both is five tiles alone between the bar and the
     page, which is the one arrangement they were never designed for. They go.

     display, not visibility or opacity: the header entrance tweens autoAlpha
     over '.site-header .logo', and GSAP writes visibility inline at the end of
     that tween, so either of those would fade the mark back in on the first
     frame. display is also what takes the link out of the tab order, which
     matters more — an invisible focus stop is worse than a visible mark.
     snapLogoToGrid handles the missing tile; see its note for what it measures
     instead, since --page-snap-x still has to be written from somewhere.

     Home stays reachable everywhere: the bar's Work link points at the landing
     page, which is what the mark carried.

     And then the row it occupied, on all three inner page types. The padding
     above is sized to clear the bar and give the mark a cell of air on each
     side of it; with nothing there to clear, the bar's own height is the whole
     requirement. /resume/ is in this list even though it never had a mark to
     hide — its header has held that same empty row since the nav left the flow,
     and leaving it out would be two inner pages opening at 58px against a third
     at 96. The air under the bar comes from the page below it now:
     .page-hero's 68px on /about/ and /resume/, and .case-hero's own padding on
     a case study. */
  .site-header .logo-wm { display: none; }

  .site-header { padding-block: var(--bar-h, 60px) 0; }

  /* --- The bar picks up the contact link the mark's end of the row left free -
     A case page is the one page type with nothing to click between the bar and
     the footer, and it is also the longest. /about/ and /resume/ already solve
     this with .nav__chat on the scroll handoff — the bar offers contact once
     the page's own Let's Chat has scrolled past it — but that needs a hero
     button to hand off from, and a case study has none, so buildChatHandoff
     bails there and .is-chat-handoff never fires.

     So on a case study it is simply on. Nothing to collide with either: the two
     pages that could double up on the offer are the two that keep the handoff,
     and their rule is untouched.

     margin-right: auto rather than space-between on the row, exactly as the
     handoff does it — the three links stay grouped at the right where flex-end
     put them, and the free space is absorbed at this one point. */
  .is-case .site-header .nav__chat {
    display: inline-flex;
    margin-right: auto;
  }

  /* Bar height plus a cell — the same expression the padding above uses, and
     the same rule the desktop value follows. The bar is 50 or 60 here rather
     than 80's worth of header, so the desktop offset would throw a skip link
     well past its own heading.

     Stated for this breakpoint rather than left to inherit, because #main had
     no scroll-margin at all before the bars pinned and the desktop value is
     the one that would have been inherited by default — over-clearing is
     harmless to look at and easy to miss, which is exactly why it is worth
     pinning down rather than shrugging at. */
  #main { scroll-margin-top: calc(var(--bar-h, 60px) + var(--grid-size)); }

  /* The tiles are NOT held open here any more, and the argument that used to
     hold them open was half right.

     It ran: hover does not exist on a phone, so a hover-only affordance is one
     nobody gets — the links would be bare words while DHरUV sat below them
     carrying five padded tiles. Right about the conclusion, wrong twice over
     about hover. A phone has :active, which is the tap itself, so the
     affordance has a state to live in; and eight lit tiles at rest is not an
     affordance at all, it is a permanent decoration that says every word is
     currently pressed.

     Both bars reveal on :active, in the base rules beside
     .nav a:focus-visible::before and .foot__nav a:focus-visible::before.
     Nothing is scoped to a breakpoint: :active is simply what a pointer device
     does on press and what a finger does on touch, so one selector covers both.

     The second thing it had wrong is that a touch browser DOES fire hover — it
     synthesises it on tap and then has no way to take it back. That is a
     separate bug and it is fixed separately; see the (hover: hover) note on
     .nav a:hover::before. */

  /* The header's own scrim is GONE, and nothing replaces it here.

     It was a full-bleed ::before carrying --bar-scrim under a --bar-fade
     dissolve, and it existed because the header was the sticky element and sits
     inside .shell, so its box stops at the gutter and needed the breakout. The
     nav is what pins now, and a fixed box with left: 0 and right: 0 already
     spans the viewport — so the solid fill on `.site-header.is-stuck .nav` up
     at base level paints the whole strip with no pseudo-element at all.

     Solid, not the scrim, and that is the point of the change rather than a
     side effect: the translucent-with-a-falloff treatment was the phone's alone
     by the end, and a bar that reads one way on a laptop and another on a phone
     is two bars. See the --bar-scrim token's own note for the three surfaces
     that were tried before the solid one, and why the argument there ("it was
     never the WEIGHT that was wrong, it was the EDGE") stopped applying once
     the dot field came off the page and the bar had nothing left to disagree
     with. */

  /* Two whole cells at this breakpoint. Sized down when the mark shared the row
     with the nav and had to give width back; it keeps the size now that it has
     its own row, because 40px tiles would make a 227px mark the loudest thing
     on a 350px column. */
  .site-header .logo-wm { --logo-tile: 20px; }

  /* The .case-toc block that shared this slot is gone with the rail. It sat
     here at z-index 31, faded in by .is-reading while a section was on screen,
     and hid .site-header underneath so the two did not show through each
     other. A case study keeps its own header at every scroll position now. */
}
