/* ==========================================================================
   About + Résumé
   Loaded after site.css, which supplies the tokens, header and footer.
   Never loaded alongside case.css, so the two are free to define the same
   type tokens at their own scales.

   Both pages are built from one primitive — .row — a label column beside a
   content column. Everything (bio, experience, education, skills) is that
   same two-column rhythm, which is what makes a résumé and an about page
   read as one site rather than two templates.
   ========================================================================== */

:root {
  --w-page: 940px;

  --fs-page-title: clamp(34px, 3.265vw, 48px);  /* 48px @1470 — matches home */
  --fs-section: clamp(19px, 1.5vw, 22px);
  --fs-prose: clamp(17px, 1.29vw, 19px);

  --panel: #f5f5f5;
  --label-col: 168px;

  /* Portrait size lives in tokens because the grid column and the element
     have to agree on it. A percentage width here would resolve against an
     auto-sized column that is itself sized by the content — a circular
     dependency that collapses it to 0 wide. 3:4, matching the source image,
     so object-fit: cover crops nothing. */
  /* 400x520, up from 300x400. The portrait was the smaller half of a split it
     shares with a two-line title and a five-line lead, so the row looked
     bottom-heavy on the text side. Both values are whole 20px grid cells
     (20 x 26) so the frame sits on the same lattice as everything else.
     3:4 is the source image, so object-fit crops nothing. */
  --deck-w: 400px;
  --deck-h: 520px;
}

/* Left-aligned, not centred.

   Centring a 940px column inside a 1374px shell started the copy at x=265
   while the header mark sat at x=50 — two left axes on one page, and the one
   the eye anchors to is the header's. Aligning the column to the shell puts
   every line of the page on the same edge as the mark and the nav above it,
   which is the arrangement the landing page already uses. */
.m-page {
  /* The shift comes out of the width, or the column runs past the gutter it
     was just pushed away from — measured as 4px of horizontal overflow at
     390px before this. */
  width: calc(100% - var(--page-snap-x, 0px));
  max-width: var(--w-page);
  /* Left-aligned, and then nudged onto the dot column the header mark sits on
     by snapLogoToGrid, which writes --page-snap-x with the mark's own offset.
     Longhands: margin-inline takes at most two values, so a three-value
     shorthand here is invalid and gets dropped without a warning. */
  margin-inline-start: var(--page-snap-x, 0px);
  margin-inline-end: 0;
}

/* --- SF Pro (moved) ------------------------------------------------------

   These two pages used to be the only ones set in SF, via an .is-page block
   here that named --font-sf and lifted --fw-body from 350 to 400. Both went
   site-wide: body in site.css carries the face and the token carries the
   weight, so three rules that lived here (.is-page, .is-page .logo-wm and
   .is-page .hero__lead) are all statements of the default now and are gone.

   Nothing about these pages changed in the move. What changed is that the
   landing page and the four case studies joined them. See the note on body in
   site.css for the measurements that decided it.
   ------------------------------------------------------------------------- */

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

/* Left-aligned, unlike the centred case-study hero: these pages are read as
   a document, and a centred axis fights the label/content columns below.

   position: relative so the dot field below can anchor to it. Deliberately
   NOT isolation: isolate — the field sits at z-index -1 and has to stay
   behind this section's own content while still painting over the body. */
/* This went 150 -> 210 -> 150. The 210 put 274px above the eyebrow against
   150px below the portrait and opened a 140px hole between the nav and the
   mark, which is what made the band read as sitting low. Back at 150 the top
   gap is 214 against the same 150 below, and the nav-to-mark gap halves to 80.

   Two things follow this number on their own, so there is nothing to keep in
   sync by hand:
     dropMarkToImage  measures the portrait's document position and snaps the
                      mark MARK_GAP above it in whole grid cells
     setGridLift      writes --grid-lift from this section's distance to the
                      top of the document, so the dot field still reaches up
                      behind the header

   Both are in js/site.js. Changing this value is enough. */
.page-hero {
  padding-top: 150px;
  position: relative;
}

/* --- Hero dot field ------------------------------------------------------

   The landing page's backdrop, reused verbatim: .hero__grid and its tokens
   live in site.css and are not restated here. Only the top edge differs.

   On the home page the field is the first thing inside .hero, so top: 0 puts
   it at the top of the document. Here a .site-header sits above the hero, and
   a field starting at the section's own top would leave a bare strip behind
   the logo and nav. --grid-lift is that strip: the section's distance from the
   top of the document, measured and written by setGridLift in js/site.js
   because the header is a different height at each breakpoint. The fallback
   covers the desktop header, so a blocked script still gets most of it — 80
   since that header took a padding-bottom to sit clear of the copy once it
   pins, up from the 58 it stood at while it was 24 + a 40px row and nothing
   underneath.

   The height compensates so the field's BOTTOM does not move with the lift —
   otherwise raising the top would drag the whole fade up with it. */
.page-hero > .hero__grid {
  top: calc(-1 * var(--grid-lift, 80px));
  bottom: calc(0px - var(--grid-tail));
  /* Undo the section's own left offset rather than assuming it is centred.
     See the --field-left note in setGridLift. */
  left: calc(-1 * var(--field-left, var(--gutter)));
}

/* --- Hero night sky (retired) --------------------------------------------

   Kept, unused: /about/ and /resume/ carry the dot field above instead. The
   whole band is one modifier class, so putting it back is adding
   `page-hero--sky` plus the canvas to the hero and `is-sky` to the body.

   ---------------------------------------------------------------------------

   The About hero sits in a full-bleed dark band with a canvas starfield in
   it (buildHeroSky in js/site.js). The band is dark in BOTH palettes: a sky
   that followed the theme would be a white rectangle with invisible white
   stars in it half the time.

   That means the copy inside cannot use the page's ink tokens. Rather than
   hardcode colours onto each element, the section redefines the tokens for
   its own subtree, so the eyebrow, title, lead, button and portrait frame all
   follow without knowing anything about the sky. */

.page-hero--sky {
  position: relative;
  isolation: isolate;

  /* Distance from the top of the document to the top of this section, so the
     sky can reach up behind the header instead of starting under it. Measured
     and written by buildHeroSky in js/site.js: the header is a different
     height at each breakpoint (.logo goes 151px -> 118px, and --mono to 50px),
     so a hardcoded value would be wrong at two of three sizes. The fallback
     covers the desktop header, so a blocked script still gets most of it. */
  --sky-lift: 58px;

  /* Solid sky below the portrait, then the fade out. Separate values because
     the fade has to BEGIN below the portrait: start it higher and the sky
     washes out behind the photo instead of under it. */
  --sky-solid: 56px;
  --sky-fade: 104px;
  padding-bottom: calc(var(--sky-solid) + var(--sky-fade));

  --ink: #f2f2f1;
  --ink-soft: #9d9da6;
  --ink-invert: #0b0b10;
  --chip-bg: rgba(255, 255, 255, 0.13);
  --rule: rgba(255, 255, 255, 0.14);
  --hairline: rgba(255, 255, 255, 0.17);
  --card-hairline: rgba(255, 255, 255, 0.12);

  color: var(--ink);
}

/* The band. Broken out of the 940px .m-page column to the viewport edges with
   the same --sbw correction the home hero and footer use, so a classic
   scrollbar cannot push the page sideways.

   Two layers in one paint: a wide, very dim teal glow sitting low and left of
   centre, over a near-black ground. The glow is what stops the band reading
   as a plain black rectangle in dark mode, where the page behind it is
   already nearly this colour. */
/* Ground and starfield share one box: same breakout, same lift, same fade, so
   the stars can never drift out of register with the sky they sit in.

   The bottom is masked rather than cut. The 110px of page between the band and
   "Now" is the site's standard section rhythm and matches the résumé, so the
   distance was never the problem — the hard edge above it was, which read as
   an unwanted black strip. Fading the last --sky-fade pixels lets the sky
   dissolve into the page and the stars thin out, with no spacing changed. */
.page-hero--sky::before,
.page-hero--sky .sky {
  position: absolute;
  top: calc(-1 * var(--sky-lift));
  left: calc(50% - 50vw + var(--sbw, 0px) / 2);
  width: calc(100vw - var(--sbw, 0px));
  height: calc(100% + var(--sky-lift));
  pointer-events: none;
  -webkit-mask-image: linear-gradient(to bottom, #000 calc(100% - var(--sky-fade)), transparent 100%);
          mask-image: linear-gradient(to bottom, #000 calc(100% - var(--sky-fade)), transparent 100%);
}

.page-hero--sky::before {
  content: "";
  z-index: -2;
  background:
    radial-gradient(120% 90% at 22% 108%, rgba(29, 107, 99, 0.30), transparent 62%),
    radial-gradient(80% 70% at 88% -10%, rgba(63, 84, 122, 0.24), transparent 66%),
    #07070b;
}

/* The rows keep their own rhythm elsewhere; after a sky band the fade has
   already done the separating, so the full 110px would double it. */
.page-hero--sky + .rows { margin-top: 44px; }

/* The hero isolates, which puts its whole stacking context above the header
   that precedes it in the DOM — without this the band would paint over the
   nav. Lifting the header into its own context puts it back on top.
   The ink tokens are restated because body sets `color: var(--ink)` and
   children inherit the computed colour, not the variable: in light mode the
   nav would otherwise stay #111 on a near-black sky. */
.is-sky .site-header {
  position: relative;
  z-index: 1;
  --ink: #f2f2f1;
  --ink-soft: #9d9da6;
  /* The nav's hover panel and the logo tiles paint --tile-bg / --chip-bg, both
     of which are dark-on-light in the light palette and would vanish or blot
     against the night sky. The header is dark here whatever the OS theme says,
     so both take a white lift instead of tokens that only know about the page
     background. Alpha on the tile, so the stars keep showing through. */
  --tile-bg: rgba(255, 255, 255, 0.07);
  --chip-bg: rgba(255, 255, 255, 0.11);
  color: var(--ink);
}


.page-hero__eyebrow {
  color: var(--ink-soft);
  letter-spacing: 0.04em;
  text-transform: uppercase;
  font-size: var(--fs-chip);
}

.page-hero__title {
  margin-top: 26px;
  font-size: var(--fs-page-title);
  font-weight: 400;
  line-height: 1.2;
  letter-spacing: -0.021em;
}

/* masked line, same mechanic as the home and case heroes */
.page-hero__title .line {
  display: block;
  overflow: hidden;
  padding-bottom: 0.12em;
  margin-bottom: -0.12em;
}
.page-hero__title .line > span { display: block; }

/* --- Tiled page title ----------------------------------------------------

   The résumé's name, set in the wordmark's tiles rather than as type: two
   rows, DHरUV over CHOUDHARY, at four grid cells a tile. Same proportions as
   the header logo and the landing hero — letter 0.64 of the tile, gap a sixth
   of it — so the three read as one mark at three sizes.

   The tile size is the smaller of "four whole grid cells" and "whatever makes
   the nine-tile row fit the column". Solving the second: a row of n tiles with
   gaps of T/6 measures T(n + (n-1)/6), so nine tiles is T * 31/3 and the tile
   that exactly fills the column is width * 3/31. Above ~1036px viewport the
   column is its full 940px and the whole-cell branch wins at 80px, which is
   the lattice-aligned case; below that the mark scales with the column and
   gives up strict cell alignment rather than overflow.

   font-family is restated because the mark is not body copy and body is SF.
   Same reason .logo-wm and .hero__wordmark restate it in site.css — the three
   are one mark at three sizes and all three have to name the face, or the
   largest of them quietly becomes SF while the other two do not.
   ------------------------------------------------------------------------- */

.title-wm {
  /* The second branch is written against the viewport, NOT 100%. A percentage
     here is circular: --title-tile is consumed by grid-auto-columns on
     .title-wm__row, whose own width is shrink-to-fit from those very columns,
     so the percentage has nothing definite to resolve against and the tiles
     collapse — measured at 0.7px before this. min(--w-page, 100vw - gutters)
     is the same number the column resolves to, but as a definite length. */
  /* The divisor is 11 because the gap is a QUARTER of the tile, and the two
     numbers have to be derived together or the row stops filling its column.
     The long row is nine tiles and eight gaps: 9T + 8(T/4) = 11T, so a tile of
     column/11 makes that row exactly the column and nothing overflows. It was
     T/6 against a divisor of 31 (9T + 8T/6 = 31T/3), which filled the column
     just as exactly and could not be put on the lattice: a quarter of an 80px
     tile is 20 and a whole cell, a sixth is 13.33 and nothing. Every tile edge
     after the first sat between two dots, and there are 27 of them.

     Row one comes out at 6T (5 tiles, 4 gaps), so it is a whole number of cells
     too whenever T is. */
  --title-tile: min(
    calc(var(--grid-size) * 4),
    calc(min(var(--w-page), 100vw - 2 * var(--gutter)) / 11)
  );
  --title-gap: calc(var(--title-tile) / 4);

  display: grid;
  justify-items: start;
  gap: var(--title-gap);

  /* snapTitleColumnToGrid writes it — the distance from wherever the eyebrow
     above leaves this mark to the next dot row below. Padding rather than
     margin or a transform: margin would collapse against the eyebrow's own and
     a transform would move the painted box without moving the layout the snap
     re-measures on the next pass. The 0 fallback is the unsnapped position,
     which is what renders with JS off. */
  padding-top: var(--title-snap-y, 0px);
  font-family: "Montserrat", "Helvetica Neue", Helvetica, Arial, sans-serif;
  font-weight: 700;
  line-height: 1;
  letter-spacing: -0.02em;
  user-select: none;
}

/* auto-flow column so a row sizes to its own tile count — five on the first,
   nine on the second — without either needing a hardcoded template. */
.title-wm__row {
  display: grid;
  grid-auto-flow: column;
  grid-auto-columns: var(--title-tile);
  gap: var(--title-gap);
}

/* The field, inside the letters only — the landing page's trial, brought here.
   Same gradient, same --grid-size, same --grid-dot as .hero__grid-dots and
   .hero__tile, so all three are one field seen through different openings.

   The fourteen tiles stay in phase with each other because background-position
   wraps modulo background-size, and both the tile and the gap are whole cells
   wherever the whole-cell branch of --title-tile wins: 80 + 20 = 5 cells apart
   horizontally, and the rows are 80 + 20 = 5 apart vertically. Below that
   branch the mark scales with the column and gives the lattice up, which is
   what its own note already said it would.

   background-image, not the shorthand: --tile-bg has to survive under it. */
.title-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(--title-tile) * 0.64);
}

/* The mask, per tile. .page-hero__title .line above is tuned for one line of
   48px type and its 0.12em would shear these glyphs; this is the hero's pair.
   Same specificity (0,2,0), so it has to come after to win. */
.title-wm .line {
  padding-block: 0.14em;
  margin-block: -0.14em;
  padding-bottom: 0.14em;
  margin-bottom: -0.14em;
}

/* Khand needs materially more room — its font box is 1.53em against a
   line-height of 1, so it overflows its line box at both ends — and the
   0.09 padding is the measured baseline correction, the same one the header
   logo carries. Must follow .title-wm .line: both are (0,2,0). */
.title-wm__tile--deva {
  font-family: "Khand", "Montserrat", sans-serif;
  font-size: calc(var(--title-tile) * 0.77);
  padding-top: calc(var(--title-tile) * 0.09);
}
.title-wm__tile--deva .line {
  padding-block: 0.42em;
  margin-block: -0.42em;
  padding-bottom: 0.42em;
  margin-bottom: -0.42em;
}

/* --- "The Batman" headline ------------------------------------------------
   Scoped to .batman-title so the résumé page's own .page-hero__title, which
   shares every rule above, is untouched.

   The mask rules above are NOT modified — .line keeps its overflow: hidden and
   its 0.12em padding/negative-margin pair, and .line > span keeps display:
   block. Everything here is additive.
   -------------------------------------------------------------------------- */

.batman-title {
  font-family: "Impact", "Oswald", "Haettenschweiler", "Arial Narrow", sans-serif;
  font-weight: 700;
  /* Impact ships one weight. Without this, asking for 700 makes the browser
     smear a synthetic bold over forms that are already heavy and condensed,
     and the counters fill in. Oswald has a real 700 and takes it normally. */
  font-synthesis-weight: none;
  -webkit-font-synthesis: none;
  color: #ce1111;
  letter-spacing: -0.035em;   /* characters nearly touching */
  line-height: 0.98;
  text-transform: uppercase;
}

/* The filter goes on the animated span itself. GSAP owns `transform` and this
   owns `filter`, so the two never write the same property, and because the
   noise is generated in the element's own coordinate space the grime travels
   with the letters as they rise out of the mask rather than sitting still
   behind them. */
.batman-title .line > span {
  filter: url(#batman-grunge);
}

/* 1.8em of --fs-page-title. line-height stays at 1 deliberately: the .line
   aperture clips with overflow: hidden, and anything tighter pulls the cap
   height up past the top of the line box, which shears the tops off the
   letters at exactly the moment the reveal finishes. */
.batman-large {
  font-size: 1.8em;
  line-height: 1;
}

/* Holds a <defs> and nothing else — must not take part in layout. */
.svg-defs {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

.page-hero .hero__lead { margin-top: 28px; }

/* --- The résumé lead, one size down on desktop ---------------------------

   15px flat, replacing the site's fluid --fs-lead: clamp(17px, 1.32vw, 19.4px).
   On this page the summary is a preamble to the record below it, not the
   argument itself, and at 19.4px on a wide screen it was competing with the
   Experience entries it introduces.

   Scoped three ways on purpose, because .hero__lead is shared by the landing
   hero, /about/ and /404, and page.css is loaded by /about/ too:
     .is-resume    the body hook, so /about/ keeps its own size
     min-width     861, the same boundary --fs-lead's own breakpoints use
   Below 861 nothing here applies: tablet stays 18px and the phone stays 16px
   with the two-sentence split (see the .part rule further down). That does
   leave desktop smaller than both, which is deliberate and not a typo — the
   narrower measure on a phone is doing different work than a 700px column.

   Flat, not a clamp: this is the one lead on the site that no longer scales
   with the viewport, so it holds 15px from 861 all the way up. */
@media (min-width: 861px) {
  .is-resume .hero__lead { font-size: 15px; }
}

.page-hero__actions {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 16px;
}
/* .btn already carries its own top margin from site.css */
.page-hero__actions .btn { margin-top: 36px; }

/* --- The page actions are nav links, not pills -----------------------------
   Every actions row on /about/ and /resume/: both heroes and both outros. The
   pages close on Let's Chat, the bar hands the same words back once it scrolls
   past (see the contact handoff in site.css), and two treatments for one label
   read as two different controls. So they take the bar's build: the word, and
   the wordmark's tile arriving behind it on hover.

   GATED ON .js, and that is a correctness gate rather than a nicety. This is
   only half a treatment — the CSS takes the pill off and snapNavPanelsToGrid
   puts the whole-cell box on — so without the sizing pass a button ends up
   with neither, which is the state the 404 was left in when this selector
   first widened: 104.28x33.5 with no radius and no tile, measured at 1470.
   /404.html carries no <script> at all by design, so nothing there was ever
   going to size it. The class is written by the inline head script and taken
   back off by its own watchdog if motion never goes live, so a blocked or
   stalled script falls back to the pill too. Pill or tile, never neither.

   The pill is undone rather than overridden piecemeal — radius, padding and
   fill all come off, and the :hover lift with them, because a tile that
   appears and a button that rises are two affordances arguing.

   --nav-panel-x/y are DECLARED here, not inherited. They live on .nav, and
   this button is not inside one, so every calc() below referencing them would
   be invalid and the whole inset would be dropped — silently, no warning, just
   a rule that is not there. That is the same trap .foot__nav documents in
   site.css, and it is worth restating at every site that borrows the panel.

   isolation on the link keeps z-index: -1 inside its own stacking context, so
   the tile sits behind the word rather than joining the root context alongside
   the hero's other layers.

   Every width, not desktop only. This was gated to 861 and up, on the argument
   that below it the row is a stacked column where a pill is the clearer tap
   target. The tile is wanted on phones too now, so the gate is gone and the
   tap target is paid for directly instead — see the 620 block at the end. */
.js .page-hero__actions .btn {
  --nav-panel-x: 12px;
  --nav-panel-y: 3px;

  position: relative;
  isolation: isolate;

  /* The nav link's own two declarations, verbatim. snapNavPanelsToGrid now
     measures this row too (see its selector list), so the button's BORDER BOX
     is rounded out to whole dot cells exactly as a nav link's is — 120x40 for
     these two words at this size, against the 104.28x31.5 a fixed inset gave.
     The 4px fallback is not decorative: the sizing pass clears --panel-pad-y
     before measuring and subtracts 8 to recover the line box, so a different
     fallback here would make it compute the wrong height. */
  padding-block: var(--panel-pad-y, 4px);
  padding-inline: var(--panel-pad-x, 0px);

  border-radius: 0;
  background: none;
  color: var(--ink);
  font-size: var(--fs-ui);
  /* Whatever a nav link runs, which is the body weight on all three bars
     since the landing row gave up its 600 to keep its tiles the same whole
     number of cells as the others. Stated as the token rather than a literal
     so the two cannot drift apart again. */
  font-weight: var(--fw-ui, 400);
  line-height: 1.5;
  transition: none;
}

.js .page-hero__actions .btn:active {
  background: none;
  color: var(--ink);
  transform: none;
}
/* Hover gated: see the note on .nav a:hover::before in site.css. */
@media (hover: hover) {
  .js .page-hero__actions .btn:hover {
    background: none;
    color: var(--ink);
    transform: none;
  }
}


.js .page-hero__actions .btn::before {
  content: "";
  position: absolute;
  inset: calc(var(--nav-panel-y) * -1) calc(var(--nav-panel-x) * -1);
  z-index: -1;
  background: var(--tile-bg);
  opacity: 0;
  transition: opacity 240ms var(--ease);
}

/* Once the sizing pass has run, the tile IS the border box and the negative
   inset above has nothing left to correct — the same handoff .nav.is-lattice
   makes. Leaving the overhang on would push the tile a cell out of the shape
   it was just sized into. */
.js .page-hero__actions.is-lattice .btn::before { inset: 0; }

/* --- The lattice, edges only ----------------------------------------------

   Eight dots, placed on the tile: one at each corner and one at the midpoint
   of each edge. The same marks a design tool puts on a selected object, which
   is what the tile is being read as — the label, picked out.

   DELIBERATELY NOT DERIVED FROM THE DOT FIELD, and that is the change. This
   was four repeating gradients tiled at --grid-size, which made the marks a
   window onto the page's lattice: the count varied 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, so the dots fell back to the
   button's own rhythm and no longer agreed with the field behind them.

   Sized to the tile instead, none of that applies. Eight marks at any width,
   on any box, at any breakpoint, whether or not anything has been snapped —
   the corners are corners because they are positioned as corners, not because
   the arithmetic happened to land. --grid-size is not referenced here at all.

   no-repeat, and each layer's box is one dot wide: background-position then
   places that box rather than setting a pattern's phase, so `center top` is
   literally the middle of the top edge and needs no correction term.

   The ::after grows by one radius on every side, and each dot box is two. A
   dot centred on a corner is half outside the tile, and a background cannot
   paint outside its own box — so without the overhang all four corners come
   out quartered and the edge midpoints come out halved.

   pointer-events: none because on a phone this sits over the label's own box
   rather than overhanging it. */
.js .page-hero__actions .btn::after {
  content: "";
  position: absolute;
  inset: calc(var(--nav-panel-y) * -1 - var(--dot-r))
         calc(var(--nav-panel-x) * -1 - var(--dot-r));
  z-index: -1;
  pointer-events: none;
  /* The nav's marks, from the same tokens rather than a second copy of them —
     these buttons and the bars either side of them carry the same words, and
     two hand-maintained lists of eight gradients is exactly how the treatments
     this replaced drifted apart. See --tile-markers in site.css. */
  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);
}

/* The dot layer keeps its one radius of overhang either way; that is the dot's
   own geometry, not panel padding, so unlike the wash it never stands down. */
.js .page-hero__actions.is-lattice .btn::after {
  inset: calc(var(--dot-r) * -1);
}

/* :active alongside the other two: this row is a stacked column on a phone,
   where hover never fires and the tile would be a state nobody could reach.
   Same addition the nav rows take in site.css. */
.js .page-hero__actions .btn:focus-visible::before,
.js .page-hero__actions .btn:active::before,
.js .page-hero__actions .btn:focus-visible::after,
.js .page-hero__actions .btn:active::after { opacity: 1; }
/* Hover gated: see the note on .nav a:hover::before in site.css. */
@media (hover: hover) {
  .js .page-hero__actions .btn:hover::before,
  .js .page-hero__actions .btn:hover::after { opacity: 1; }
}

/* --- Let's Chat: an outlined tile that fills -------------------------------
   The button half of the treatment in site.css — see the note beside
   :is(.nav, .foot__nav) a[href^="mailto:"] there for why the box is the tile
   these already had rather than a new one, and why the colours are tokens.

   Restated here rather than shared, because it has to outrank what is above:
   the four rules this follows are (0,3,0) and (0,4,0) in a later stylesheet,
   so the site.css version loses on both specificity and order. Adding the
   attribute takes each selector one step past its counterpart.

   [href^="mailto:"], not .btn: /resume/ puts the PDF download in the same
   .page-hero__actions row, and that one is not the call to action.

   ::after is the eight marker dots, and they stand down for the same reason
   they do on the nav — the border is already drawing that rectangle, and on a
   filled hover state they would be marks on a solid block. */
.js .page-hero__actions .btn[href^="mailto:"] {
  /* Same weight as the nav and footer copies, from the same token. Restated
     rather than inherited for the reason the whole block is restated: .btn's
     own weight rules live in this sheet and outrank site.css. */
  font-weight: var(--fw-chat);
  transition: color 240ms var(--ease);
}

.js .page-hero__actions .btn[href^="mailto:"]::before {
  opacity: 1;
  background-color: transparent;
  /* The same dotted stroke the nav and footer rows draw, from the same tokens.
     The reasoning, and the pitch, are with --chat-stroke in site.css. One
     component, nine places, one set of numbers.

     Four tokens, and repeat is one of them. It was `no-repeat` written out
     here, which the eight-layer stroke reduces to a single dot per edge. */
  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);
}

.js .page-hero__actions .btn[href^="mailto:"]::after { background-image: none; }

/* The chase, on the buttons too — same keyframes, same tokens and same two mask
   layers as the nav rows, declared here only because this rule has to outrank
   the button's own ::before block above it. The colour swap is NOT repeated:
   --chat-ink is set once at :root in site.css and this outline is drawn from
   the same --chat-stroke, so it arrives already lit.

   See the note beside @keyframes chat-glow in site.css for why the stops are
   35 / 15 / 35 / 15, why each one is pushed out by the window's own radius, and
   why the mask layers need no mask-composite. */
@media (prefers-reduced-motion: no-preference) {
  .js .page-hero__actions .btn[href^="mailto:"]::before {
    -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;
  }
}

/* Dots go with the fill, same as the nav rows — and so does the mask, or the
   fill itself comes out at --chat-glow-rest. See the note there. */
.js .page-hero__actions .btn[href^="mailto:"]:focus-visible::before,
.js .page-hero__actions .btn[href^="mailto:"]:active::before {
  background-color: var(--ink);
  background-image: none;
  -webkit-mask-image: none;
          mask-image: none;
}

.js .page-hero__actions .btn[href^="mailto:"]:focus-visible,
.js .page-hero__actions .btn[href^="mailto:"]:active {
  color: var(--ink-invert);
}

/* Hover gated: see the note on .nav a:hover::before in site.css. */
@media (hover: hover) {
  .js .page-hero__actions .btn[href^="mailto:"]:hover::before {
    background-color: var(--ink);
    background-image: none;
    -webkit-mask-image: none;
            mask-image: none;
  }
  .js .page-hero__actions .btn[href^="mailto:"]:hover { color: var(--ink-invert); }
}

/* --- Phones: buy the tap target back --------------------------------------

   Below 620 snapNavPanelsToGrid bails outright (`if (cell < 20) return`, and
   --grid-size is 18 here), so no row on the site is latticed: the button keeps
   its fixed-padding fallback and .is-lattice never arrives.

   That used to matter to the marks and no longer does. While they were tiled
   from the dot field, an unsnapped box meant they could not agree with the
   field behind them and the phone got a visibly different treatment. The eight
   are placed on the tile now, so an unsnapped box is simply a box: same eight
   marks, same corners, nothing to reconcile. This block is only about the tap
   target.

   And that one would have been a real regression. The pill was carrying it —
   14/24 of padding for a box about 52px tall — and the bare tile's 4px
   fallback would have left roughly 32, under the 44 a thumb wants. So the
   padding is declared outright rather than inherited from a sizing pass that
   has already given up, and the overhang goes to zero so the tile is simply
   the border box: what you tap and what lights up are one rectangle. */
@media (max-width: 620px) {
  .js .page-hero__actions .btn {
    --nav-panel-x: 0px;
    --nav-panel-y: 0px;
    --panel-pad-x: 16px;
    --panel-pad-y: 12px;
  }

  /* The fourteen title tiles divide themselves down here, the same way
     .hero__tile does in the phone block of site.css and for the same reason
     given at length there: below 620 the tile is a fraction of a cell, so the
     page's pattern is cut differently in every box, and the field it could be
     phase-matched to is invisible anyway.

     Measured at 390: a 31.8px tile against an 18px cell is 1.77, and the first
     six tiles sat at 9.0, 5.2, 1.5, 2.3, 6.1 and 9.0 px out. Two dots across a
     15.9px derived cell puts the same pair at the same inset in all fourteen.

     THE OLD OBJECTION DOES NOT APPLY HERE, and it is worth saying why rather
     than letting the next reader rediscover it. A phone-only `100% /
     var(--tile-cols)` was pulled from this block once because background-size
     takes one value for ALL layers, and these tiles were carrying the eight
     --tile-markers gradients at the time: resizing the pattern resized the
     marks and pulled each off its corner. .title-wm__tile carries a single
     layer now, the field radial and nothing else, so there is nothing else for
     this to resize. Restore markers here and this rule has to go with them.

     Fallback 2, for JS off, which is also the right count at 320 and 390.
     snapTitleColumnToGrid writes the measured value. */
  .title-wm__tile {
    background-size: calc(100% / var(--title-dots, 2))
                     calc(100% / var(--title-dots, 2));
  }
}

/* --- Hero portrait -------------------------------------------------------
   A 300x400 card in the hero. This was three identical copies stacked on top
   of one another, pulled apart by a pointer parallax in site.js; both the
   duplicates and the parallax are gone, and the frosted reveal below is the
   interaction now. The class names stay — they are load-bearing across three
   layout modifiers and site.js — but there is one image behind them.

   Sizes match the template's three breakpoints (300x400 / 250x320 / 150x200).
   -------------------------------------------------------------------------- */

/* Three layouts for the same hero, so the arrangement is a class swap on the
   <section> rather than a rewrite:
     --split     text left, portrait right   (shipped)
     --stacked   text left, portrait centred beneath
     --centred   everything centred, portrait between title and lead
   All three collapse to the stacked arrangement under 860px. */

.page-hero--avatar { padding-bottom: 40px; }

/* --avatar and --sky are both single classes, and --avatar is declared later,
   so it was quietly winning and holding the band at 40px — which left the
   portrait 40px from the bottom edge and put it inside the fade. Restated at
   two classes so the sky's own value applies wherever the two are combined. */
.page-hero--avatar.page-hero--sky {
  padding-bottom: calc(var(--sky-solid) + var(--sky-fade));
}

/* Two children only — .page-hero__copy and .hero-avatar — so each modifier is
   just a way of arranging that pair. */

/* --- Trio: title | portrait | argument ------------------------------------

   Three columns with the portrait in the middle: the label and role on its
   left, the paragraph they set up on its right. The picture stops being a
   decoration parked beside the text and becomes the thing the page is built
   around.

   The hero breaks out to the shell's full content width rather than the 940px
   .m-page column the rows below use. At 940 the two text columns come out at
   222px each, which is four or five words a line — the layout only works with
   room, and the widening is what buys it (438px a side at 1470).

   min(100vw, --shell) because .shell is capped at 1800 and centred above that,
   so past 1800 the viewport is no longer the measure. --page-snap-x comes off
   the width for the same reason it does on .m-page: the column was pushed
   right onto the mark's dot column and the width has to give that back or the
   row overruns the gutter.
   -------------------------------------------------------------------------- */

.page-hero--trio {
  display: grid;
  grid-template-columns: minmax(0, 1fr) var(--deck-w) minmax(0, 1fr);

  /* center: both text columns sit on the portrait's horizontal axis.

     This has gone start -> center -> start -> center, so the trade is worth
     writing down once. Centring boxes of different heights puts their TOPS
     (Hleft - Hright) / 2 apart, and CSS cannot read either height. So the
     eyebrow and the lead can share a first line, or each column can sit on the
     picture's axis, but not both at once, and no offset fixes it because the
     required number is not knowable in a stylesheet.

     Centred is the choice: the portrait is the tallest thing in the row and
     the only one with a hard outline, so its middle band is the axis the eye
     reads the row against. The cost is that the eyebrow now starts about 16px
     above the lead rather than level with it. */
  align-items: center;
  gap: var(--gap);

  max-width: none;

  /* Symmetric about the viewport centre, so the MIDDLE column's axis is the
     page's axis and the portrait is genuinely centred rather than nearly.

     .m-page takes --page-snap-x to sit on the header mark's dot column; the
     hero gives it straight back, because a row shifted 2px right is a row
     whose centre is 2px right of the page's. The 2px shows up on the title's
     left edge instead, where nothing is being centred against it. */
  margin-left: calc(-1 * var(--page-snap-x, 0px));
  width: calc(min(100vw - var(--sbw, 0px), var(--shell)) - 2 * var(--gutter));
}

/* The portrait is the middle column, so it fills it rather than carrying its
   own width. Height still comes from the token, which keeps the 3:4 crop. */
.page-hero--trio > .hero-avatar {
  width: 100%;
  height: var(--deck-h);
}

/* The lead loses its own measure here: the column is the measure now, and a
   max-width on top of it would leave a ragged gap on the right. */
.page-hero--trio .page-hero__copy--trail .hero__lead { max-width: none; }

/* The 28px on .page-hero .hero__lead is the gap under a title. Here the lead is
   the first thing in its column — the title is on the far side of the portrait
   — so that margin is measuring from nothing and has to go, or it would land
   on top of the offset below. */
.page-hero--trio .page-hero__copy--trail > .hero__lead:first-child { margin-top: 0; }

/* The lead starts on the EYEBROW's line, not the title's.

   This carried `padding-top: calc(var(--fs-chip) * 1.5 + 26px)` — one eyebrow
   line box plus the title's 26px margin — which pushed the lead down to meet
   the h1's first line instead. That was right when the h1 was the role and the
   lead opened the argument; now the h1 IS the argument's first sentence, so
   the top line of the row belongs to the label and the paragraph together and
   the h1 hangs below it.

   Nothing replaces the offset: both columns are `align-items: start`, so with
   the padding gone the eyebrow and the lead simply both begin at the row's top
   edge. The two are different sizes (14px against 19.4px) so their line boxes
   differ, but the boxes start on one line, which is the alignment being asked
   for here. */
.page-hero--trio .page-hero__copy--trail { padding-top: 0; }
.page-hero__actions { margin-top: 28px; }

/* Under 860 the three columns stack in DOM order — title, portrait, argument —
   which is the same reading order, so nothing needs reordering. The width
   override goes with them; back inside the .m-page column. */
@media (max-width: 860px) {
  .page-hero--trio {
    grid-template-columns: 1fr;
    width: auto;
    margin-left: 0;
    gap: 32px;
  }
  .page-hero--trio > .hero-avatar {
    width: var(--deck-w);
    max-width: 100%;
  }
}

.page-hero--split {
  display: grid;
  grid-template-columns: minmax(0, 1fr) var(--deck-w);
  align-items: center;
  gap: var(--gap);
}

.page-hero--stacked > .hero-avatar { margin: 56px auto 0; }

.page-hero--centred {
  display: grid;
  justify-items: center;
  text-align: center;
}
.page-hero--centred > .hero-avatar { order: -1; margin-bottom: 48px; }
.page-hero--centred .hero__lead { max-width: 46em; }
.page-hero--centred .page-hero__actions { justify-content: center; }

.hero-avatar {
  position: relative;
  width: var(--deck-w);
  height: var(--deck-h);
}

/* One image. The stacking context (z-index 1/2/3), the will-change promotion
   and the backface-visibility hint all existed for the three-layer pointer
   follow; with that gone they were describing a deck that no longer exists.
   The image is now static, so nothing needs its own compositor layer. */
.hero-avatar__layer {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  border-radius: 12px;         /* the template's general radius token */

  /* inset, not an outer ring. Outside the border box it would be cut by the
     clip-path below; inside it draws the hairline onto the photograph's own
     edge, which is where it reads anyway. */
  box-shadow: inset 0 0 0 1px var(--card-hairline);

  /* Halation. The filter is declared inline in about/index.html, next to the
     image, so this rule only ever resolves on the page that defines it — the
     only page with a .hero-avatar__layer at all. */
  filter: url(#halation);

  /* And the bloom clipped to the frame's actual SHAPE.
     ----------------------------------------------------------------------
     The filter region (0/0/100%/100% on #halation) was doing this job, and it
     cannot: a filter region is a RECTANGLE and this frame is rounded. The
     photograph is clipped to the 12px radius, the blur then spreads its
     highlights outward, and everything between the rounded edge and the square
     region stays lit — four corner slivers and the rims between them.

     It reads as a warm outline around the card, and it is loudest exactly when
     the veil is up: #000 covers the rounded area, so the leaked bloom is the
     only thing still painting there. Measured before this line, at 1470 in
     dark: 5.2px of warm bleed past the top and left edges, and zero once
     `filter` was switched off — which is what pinned it on the filter rather
     than on the comet, whose own clip was innocent.

     clip-path is the fix rather than an overflow on the parent because
     clipping is applied AFTER filtering in the rendering order (filter, clip,
     mask, opacity), so this cuts the bloom itself and not merely its source.
     Same radius as the border, so nothing about the photograph changes. */
  clip-path: inset(0 round 12px);
}

/* --- Halation comet ------------------------------------------------------
   A moving halation source for the portrait. The static SVG filter above
   blooms the highlights the photograph already has; this is a second source
   that travels with the cursor, so light appears to pass over the picture
   rather than sit in a fixed grade.

   It used to run across the whole page. It does not any more: buildPageComet
   arms it only while the pointer is over [data-comet-host] and clips it to
   that element's frame, so the bloom lands where halation actually happens.
   Nothing below changes between the two modes — the confinement is entirely a
   clip-path and an is-lit class, both written by site.js.

   Fixed to the viewport, so clientX/clientY map straight onto it — see the
   note in buildPageComet on why it stays fixed even now that it is confined.

   Above the content rather than behind it. The body background is opaque, so
   anything painted below it would never be seen; screen blending on top adds
   light to whatever it crosses and leaves the dark page untouched where it
   does not. Alphas are deliberately low, from when this crossed body copy;
   over a photograph they read as a highlight rather than a lamp.

   overflow: hidden so a spark parked near an edge can never extend the
   scrollable area, and pointer-events: none so it is invisible to hit
   testing. It cannot swallow a click on the nav or the mail button.
   ------------------------------------------------------------------------- */

.page-comet {
  position: fixed;
  inset: 0;
  z-index: 60;                    /* over content, under the skip link (100) */
  overflow: hidden;
  pointer-events: none;
  mix-blend-mode: screen;
  opacity: 0;
  transition: opacity 0.55s var(--ease);
}
/* site.js adds this while the pointer is over the page */
.page-comet.is-lit { opacity: 1; }

/* Square, so the negative margins centre each spark on the tracked point.
   6.5vmax, not the 34vmax this started at: a comet is a small bright head,
   and at that size the effect read as a spotlight sweeping the page rather
   than a shooting star. vmax rather than px so the head stays a constant
   share of the viewport instead of shrinking to a dot on a large screen.
   JS writes translate3d + scale and nothing else. */
.page-comet__spark {
  position: absolute;
  left: 0;
  top: 0;
  width: 6.5vmax;
  aspect-ratio: 1;
  margin: -3.25vmax 0 0 -3.25vmax;
  border-radius: 50%;
  /* Tighter and brighter than the old wash. A small glow needs a hot core to
     register at all, and the steep falloff is what gives the head an edge
     without giving it an outline. */
  background: radial-gradient(circle closest-side,
    rgba(255, 222, 186, 0.85) 0%,
    rgba(255, 156, 96, 0.40) 32%,
    rgba(255, 104, 52, 0) 70%);
  will-change: transform;
}

/* The tail dims as well as tapers. Scale alone kept every segment equally
   bright, which read as a string of beads; falling opacity is what makes it
   diminish into nothing the way a real tail does. Head is nth-child(6). */
.page-comet__spark:nth-child(1) { opacity: 0.14; }
.page-comet__spark:nth-child(2) { opacity: 0.26; }
.page-comet__spark:nth-child(3) { opacity: 0.40; }
.page-comet__spark:nth-child(4) { opacity: 0.58; }
.page-comet__spark:nth-child(5) { opacity: 0.78; }
.page-comet__spark:nth-child(6) { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  /* site.js bails before building the loop; this makes it explicit rather
     than relying on the class never landing. */
  .page-comet { display: none; }
}

/* --- Frosted reveal ------------------------------------------------------
   Sits above all three layers and blurs whatever is painted behind it, so it
   frosts the photograph rather than tinting a flat panel over it. The rgba
   wash is what makes the white type legible whatever the photo underneath is
   doing; backdrop-filter alone would leave it fighting the image.

   pointer-events: none so the deck keeps receiving hover, focus and taps —
   without it the veil would swallow the pointer the moment it faded in and
   the effect would flicker on and off against its own hover state.
   ------------------------------------------------------------------------- */

.hero-avatar__veil {
  position: absolute;
  inset: 0;
  z-index: 4;
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  border-radius: 12px;                 /* matches the layers */
  text-align: center;

  /* Complete black. Not a frost, not a wash over the photograph — the image is
     gone while the veil is up, and the card is a hole with the type in it.

     No backdrop-filter at all now, and that is not just tidiness: an opaque
     background makes any grade or blur invisible, so keeping one would cost a
     backdrop sample every frame for nothing. Removing it also retires the
     @supports fallback that used to sit further down, since there is no longer
     an effect to fall back from. */
  background: #000;

  opacity: 0;
  pointer-events: none;
  transition: opacity 0.42s var(--ease);
}

/* Same poster treatment as .batman-title: Impact, the #CE1111 poster crimson,
   tight tracking, eroded by the grunge filter. The small variant of the filter
   is deliberate — see the comment on #batman-grunge-sm in about/index.html.

   GSAP never touches these; the rise is a CSS transition on transform, and the
   filter is a separate property, so the two do not collide. */
.hero-avatar__line {
  font-family: "Impact", "Oswald", "Haettenschweiler", "Arial Narrow", sans-serif;
  font-weight: 700;
  font-synthesis-weight: none;
  -webkit-font-synthesis: none;
  color: #ce1111;
  text-transform: uppercase;
  line-height: 1;
  letter-spacing: -0.03em;
  font-size: clamp(22px, 1.9vw, 27px);
  filter: url(#batman-grunge-sm);
  /* the lines rise a little as the frost comes in; transform only */
  transform: translateY(6px);
  transition: transform 0.5s var(--ease);
}

.hero-avatar__line--strong {
  margin-top: 2px;
  font-size: clamp(38px, 3.6vw, 52px);
  letter-spacing: -0.035em;
}

/* Revealed by hover wherever hovering is possible, or by the .is-revealed
   class that site.js toggles on tap where it is not.

   `and (pointer: fine)` used to be on this query and is the reason the veil
   never appeared on a touchscreen laptop. A machine with a touchscreen reports
   pointer: coarse as its PRIMARY pointer even while the trackpad is the thing
   actually being used, so the query failed and the reveal was left to a tap
   that nobody thinks to try on a laptop. The halation is not gated at all, so
   the portrait lit up under the cursor and Batman never came with it — hover
   looked like it was working and the veil looked broken.

   `hover: hover` alone is the right test, because hovering is precisely what
   this rule needs. Phones and pure touchscreens report hover: none and still
   fall through to the tap toggle, so nothing gains a sticky hover state. */
@media (hover: hover) {
  .hero-avatar:hover .hero-avatar__veil { opacity: 1; }
  .hero-avatar:hover .hero-avatar__line { transform: none; }
}

.hero-avatar.is-revealed .hero-avatar__veil { opacity: 1; }
.hero-avatar.is-revealed .hero-avatar__line { transform: none; }

/* Keyboard users get it too, without making a decorative overlay focusable. */
.hero-avatar:focus-within .hero-avatar__veil { opacity: 1; }
.hero-avatar:focus-within .hero-avatar__line { transform: none; }

@media (prefers-reduced-motion: reduce) {
  .hero-avatar__veil,
  .hero-avatar__line { transition: none; }
  .hero-avatar__line { transform: none; }
}

/* The @supports fallback for backdrop-filter is gone with the effect it
   guarded — an opaque #000 veil renders identically on every engine. */

/* --- Portrait ------------------------------------------------------------ */

.portrait {
  margin-top: 90px;
  overflow: hidden;
  border-radius: var(--radius-media);
  background: var(--tint, var(--panel));
  aspect-ratio: 3 / 2;
}
.portrait img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Placeholder state — the monogram floated in the tint, sized off the box so
   it holds the same frame the real photograph will drop into. */
.portrait--empty {
  display: grid;
  place-items: center;
}
.portrait--empty svg {
  width: 22%;
  min-width: 120px;
  height: auto;
  color: var(--placeholder);
}

/* --- Rows: the shared label + content primitive -------------------------- */

.rows { margin-top: 110px; }

.row {
  display: grid;
  grid-template-columns: var(--label-col) 1fr;
  gap: 24px var(--gap);
  padding-top: 34px;
  border-top: 1px solid var(--rule);
}
.row + .row { margin-top: 34px; }

.row__label {
  font-size: var(--fs-section);
  font-weight: var(--fw-heading-sm);
  line-height: 1.35;
  letter-spacing: -0.012em;
}

/* --- The résumé's section labels carry weight ------------------------------
   Experience, Selected work, Education, Skills and the rest. They ran
   --fw-heading-sm, which is 300 — SF Light — at --fs-section, and at that size
   Light reads as a caption rather than as the thing that names the section
   beside it. On a page that IS its sections, the label is the only navigation
   the eye has down a long column.

   600 rather than 500 or 700. It matches .entry__role, so a label and the job
   titles under it share a weight and the label leads on SIZE — 22px against
   19 — which is a quieter hierarchy than out-weighting them as well. 500 was
   not enough separation from the 400 body at this size, and Semibold is a real
   SF instance where 550 would be synthesised.

   /about/ keeps the 300. Its labels name a voice — How I work, Now, Elsewhere
   — beside prose that carries the page, and Light is what keeps them from
   competing with it. That is why this needed a page hook at all: the two pages
   shared every class, so .is-resume was added to the body to tell them apart.

   From 621 up, matching where the phone's own label treatment stops (uppercase,
   tracked, --ink-soft, in the 620 block below). Two states rather than three. */
@media (min-width: 621px) {
  .is-resume .row__label { font-weight: 600; }
}

.row__body {
  font-size: var(--fs-prose);
  font-weight: var(--fw-body);
  line-height: 1.65;
  text-wrap: pretty;
}
.row__body > p + p { margin-top: 22px; }

.row__body a,
.link-underline {
  text-decoration: underline;
  text-underline-offset: 5px;
  text-decoration-thickness: 1px;
  transition: opacity 0.3s var(--ease);
}
/* Hover gated: see the note on .nav a:hover::before in site.css. */
@media (hover: hover) {
  .row__body a:hover,
  .link-underline:hover { opacity: 0.55; }
}

/* --- Entries: one job, one degree ---------------------------------------- */

.entry + .entry {
  margin-top: 30px;
  padding-top: 30px;
  border-top: 1px solid var(--rule);
}

/* Role and dates on one baseline; the date is secondary, so it does not get
   its own column — that would leave a ragged empty gutter on short entries. */
.entry__head {
  display: flex;
  flex-wrap: wrap;
  align-items: baseline;
  justify-content: space-between;
  gap: 6px 20px;
}

.entry__role {
  font-size: var(--fs-prose);
  font-weight: 600;
  line-height: 1.35;
  letter-spacing: -0.012em;
}

/* Dates, org lines and chips live inside .row__body but are small and muted —
   they keep the UI weight, since 350 at 14px in grey reads as washed out. */
.entry__when {
  color: var(--ink-soft);
  font-size: var(--fs-chip);
  font-weight: var(--fw-ui);
  white-space: nowrap;
}

.entry__org {
  margin-top: 2px;
  color: var(--ink-soft);
  font-size: var(--fs-chip);
  font-weight: var(--fw-ui);
}

.entry__points {
  margin-top: 14px;
  font-size: clamp(15.5px, 1.19vw, 17.5px);
  line-height: 1.6;
}
.entry__points li {
  position: relative;
  padding-left: 18px;
}
.entry__points li + li { margin-top: 8px; }
.entry__points li::before {
  content: "";
  position: absolute;
  left: 2px;
  top: 0.72em;
  width: 5px;
  height: 1px;
  background: var(--ink-soft);
}

/* --- Chip lists (skills, tools) ------------------------------------------ */

.chips {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}
.chips li {
  padding: 7px 11px;
  border-radius: 100px;
  background: var(--chip-bg);
  color: var(--chip-ink);
  font-size: var(--fs-chip);
  font-weight: var(--fw-ui);
  line-height: 1.15;
  transition: background-color 0.35s var(--ease), color 0.35s var(--ease);
}
@media (hover: hover) {
  .chips li:hover {
    background: var(--chip-bg-hover);
    color: var(--ink);
  }
}
/* A chip is already its own affordance — the .row__body underline on top of
   the pill reads as a mistake. The hover fill carries it instead. */
.chips a { text-decoration: none; }

/* --- Chips take the bar's tile --------------------------------------------
   /resume/'s Contact row and /about/'s Elsewhere row are the two places these
   appear, and both are lists of things to CLICK: an address, a number, a
   profile. They were reading as static text in a grey pill while the three
   words in the bar directly above them — Work, About, Résumé — carried the
   wordmark's tile. Same job, two different controls.

   So they take the bar's build, the same borrow .page-hero__actions .btn makes
   further up this file: the word, and the tile arriving behind it on press.

   Copied from .nav a in site.css rather than from the .btn above, and the
   difference is which pseudo-element does what. The button splits the job —
   ::before paints the wash, ::after the eight marks — which uses up both. The
   nav packs wash and marks into ::before alone, as background-color plus
   background-image, and that leaves ::after free. Down here that matters: the
   touch block at the end of site.css grows tap targets with ::after, and a
   chip row on a phone needs one.

   GATED ON .js, for the reason the button block states at length: this is half
   a treatment and snapNavPanelsToGrid is the other half. Without the class the
   pill stays. Pill or tile, never neither.

   --nav-panel-x/y are DECLARED here, not inherited. They live on .nav and a
   chip is not inside one, so every calc() referencing them would be invalid
   and the inset would be dropped silently. Same trap .foot__nav and the button
   both carry a note about.

   The pill comes off the <li> and the treatment goes on the <a>, which is a
   fix as much as a move: the padding used to sit on the list item, so the grey
   area around a label was not part of the link and only the text itself was
   clickable. --panel-pad-* land on the anchor, so the tile and the hit area
   are now the same box.

   :has(> a), AND THAT IS LOAD-BEARING RATHER THAN TIDY. .chips is two
   components wearing one class. /resume/ runs four more of these rows for
   Skills — Product Strategy, RAG, SQL, Figma and 23 others — and not one of
   them is a link. They are labels, they are correctly pills, and an unscoped
   rule here would strip 27 of them to bare grey text with nothing to replace
   it, because there is no anchor in them for the tile to attach to.

   The anchor rules below need no such guard: a skills row has no <a> for them
   to match. Only this one touches the <li>, so only this one has to ask.

   Degradation is in the right direction too. A browser without :has() drops
   the whole rule as invalid and every chip keeps the pill it has today, which
   is the state this is improving on rather than a broken one. */
.js .chips li:has(> a),
.js .chips li:has(> a):hover {
  padding: 0;
  border-radius: 0;
  background: none;
  color: inherit;
}

.js .chips a {
  --nav-panel-x: 12px;
  --nav-panel-y: 3px;

  display: inline-flex;
  position: relative;
  isolation: isolate;

  /* The nav link's own two declarations, verbatim. The 4px fallback is not
     decorative: the sizing pass clears --panel-pad-y before measuring and
     subtracts 8 to recover the line box, so a different fallback here would
     make it compute the wrong height. */
  padding-block: var(--panel-pad-y, 4px);
  padding-inline: var(--panel-pad-x, 0px);

  color: var(--ink);
  font-size: var(--fs-ui);
  font-weight: var(--fw-ui, 400);
  line-height: 1.5;
  transition: none;
}

/* .nav a::before, verbatim — wash and marks in one layer. See the note there
   for why the inset carries a dot radius on top of the panel padding, and why
   these are longhands rather than the `background` shorthand. */
.js .chips a::before {
  content: "";
  position: absolute;
  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);
}

/* Once the sizing pass has run the tile IS the border box, so the panel
   padding has nothing left to correct — but the dot radius does, because that
   is the marks' own geometry rather than padding. The same handoff
   .nav.is-lattice makes. */
.js .chips.is-lattice { gap: var(--nav-lattice-gap); }
.js .chips.is-lattice a::before { inset: calc(var(--dot-r) * -1); }

/* :active is the tap, and on touch it is the only one of the three that
   fires. Hover gated: see the note on .nav a:hover::before in site.css. */
.js .chips a:focus-visible::before,
.js .chips a:active::before { opacity: 1; }
@media (hover: hover) {
  .js .chips a:hover::before { opacity: 1; }
}

/* --- Closing call to action ---------------------------------------------- */

.outro {
  margin-top: 130px;
  padding-bottom: 130px;
}

.outro__title {
  font-size: clamp(24px, 2.45vw, 36px);
  font-weight: 400;
  line-height: 1.4;
  letter-spacing: -0.016em;
  text-wrap: balance;
}

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

/* Same contract as site.css: hidden only while `.js` is set and
   `.motion-ready` is not, so a blocked script can never leave a blank page.
   Rows are not listed — they carry [data-reveal] and GSAP writes their start
   values synchronously before the hide lifts. */
.js:not(.motion-ready) .page-hero__title .line > span,
.js:not(.motion-ready) .hero-avatar { visibility: hidden; }

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

@media (max-width: 1180px) {
  .page-hero { padding-top: 110px; }
  .rows { margin-top: 84px; }
  .outro { margin-top: 100px; padding-bottom: 100px; }
}

@media (max-width: 860px) {
  :root {
    --fs-page-title: clamp(34px, 6.2vw, 44px);
    --fs-section: 19px;
    --fs-prose: 17px;
    --deck-w: 250px;
    --deck-h: 320px;
  }

  .page-hero { padding-top: 68px; }

  /* one column at this width, whichever modifier is on the section — the deck
     drops below the copy and centres itself */
  .page-hero--split,
  .page-hero--centred {
    grid-template-columns: 1fr;
    justify-items: start;
    text-align: left;
  }
  .page-hero--split > .hero-avatar,
  .page-hero--centred > .hero-avatar,
  .page-hero--stacked > .hero-avatar {
    order: 0;
    margin: 44px auto 0;
  }
  .page-hero--centred .page-hero__actions { justify-content: flex-start; }

  .portrait { margin-top: 56px; }
  .rows { margin-top: 64px; }

  /* label stacks above its content — the 168px gutter costs more than it
     buys once the content column drops below ~40em */
  .row {
    grid-template-columns: 1fr;
    gap: 14px;
    padding-top: 28px;
  }
  .row + .row { margin-top: 28px; }

  .outro { margin-top: 76px; padding-bottom: 72px; }
}

@media (max-width: 620px) {
  /* --- Phone: the portrait sits BESIDE the title, not under it -------------

     A full-width portrait was costing the whole first screen. At 400px the
     column is 360px, so `width: 100%` with the 400/520 crop made the picture
     360x468 — and stacked under a three-line title it pushed the lead, the
     button and everything after them clean below the fold. Measured on the
     400x748 frame: 590px of hero before the argument started.

     So the row pairs them instead. The title keeps the left axis — the same
     dot column the mark, the eyebrow and every row below sit on, which is the
     one alignment this page is strict about — and the portrait takes a fixed
     column on the right. The lead and the button span the full width beneath,
     where they read as the paragraph they are rather than a third column.

     126px is seven whole 18px grid cells (--grid-size drops to 18 at this
     breakpoint), so the picture's edges land on dot columns like everything
     else. It was 120px against a 12px cell; the cell changed, so the width
     followed it rather than staying a number that no longer divides.
     aspect-ratio, not a height, keeps the 400/520 crop identical to desktop —
     object-fit: cover then takes the same pixels at every size.

     Only /about/ has a .hero-avatar, and only --trio is in use, so this
     cannot reach another page. */
  .page-hero--trio {
    grid-template-columns: minmax(0, 1fr) 126px;
    align-items: start;
    column-gap: 18px;                 /* one cell */
    row-gap: 24px;
  }

  /* DOM order is title, portrait, argument — which is already the reading
     order, so only the third child needs placing. */
  .page-hero--trio > .page-hero__copy--trail {
    grid-column: 1 / -1;
  }

  .page-hero--trio > .hero-avatar {
    width: 126px;
    height: auto;
    aspect-ratio: 400 / 520;
  }

  /* The title has ~216px to work in beside the picture rather than the full
     360px. At 34px that is about twelve characters a line, which breaks
     "product manager" across four of them; a step down buys back two. */
  .page-hero--trio .page-hero__title { font-size: clamp(26px, 7vw, 34px); }

  /* The BATMAN veil, refitted to the smaller frame.

     Its sizes are clamp(22px, 1.9vw, 27px) and clamp(38px, 3.6vw, 52px), and
     below about 1150px viewport both sit on their FLOOR — floors picked when
     the portrait was 400px wide. In a 120px frame that put "BATMAN" at 115px,
     which is 96% of the frame: the word touched both edges and the joke read
     as a layout bug. On desktop it is 52px in a 400px frame, or 39%.

     Not scaled strictly to 39% here, which would mean 15px type. The grunge
     filter's flakes are an ABSOLUTE size (feTurbulence baseFrequency is in
     user units, not element-relative), so the smaller the type the larger the
     share of each stroke they eat — the reason #batman-grunge-sm exists at all,
     tuned for 21-38px. Below that range the letters stop being letters.

     26px is the compromise: "BATMAN" lands near 79px, two thirds of the frame,
     with about 20px of air each side, and stays inside the range the small
     filter was built for. */
  .hero-avatar__line { font-size: 15px; }
  .hero-avatar__line--strong { font-size: 26px; }

  .entry__head { justify-content: flex-start; }
  .entry__when { width: 100%; }
}

/* ==========================================================================
   Mobile type scale
   ==========================================================================

   The problem this fixes was not that one size was too big. It was that the
   whole scale was COMPRESSED. At 390px the page ran

     27.3  title      24  outro     19  label
     17    prose      15.5 bullets  13  eyebrow

   and the steps between the middle four are 0.88, 0.79, 0.89, 0.91. A scale
   needs roughly 1.2x steps to read as separate levels, so those four sat in
   one undifferentiated band. With no genuinely small text anywhere on the
   page the eye had nothing to rest against, which is why everything read as
   big rather than any one thing being oversized.

   So the fix opens the ratio rather than shrinking uniformly. The title holds
   its size; the body comes down a step; and the row labels stop being small
   headings and become actual labels — uppercase, tracked, soft ink — which is
   what gives the page a bottom to its scale.

   Line-heights come down with it. 1.65 was chosen for 19px prose in a 724px
   desktop column; on a 350px column at 16px it is just air, and it was
   inflating the page by about a tenth.

   Measured: /about/ 4364 -> 3903px, 5.2 screens to 4.6.
   ------------------------------------------------------------------------- */

@media (max-width: 620px) {
  :root {
    --fs-prose: 16px;
    --fs-section: 14px;
  }

  /* The label register the page was missing. --ink-soft is #8a8a88 on #0c0c0d,
     which is about 5.4:1 — clear of the 4.5:1 AA floor at this size. Positive
     tracking because uppercase needs it; the -0.012em these carry at desktop
     is set for mixed case and closes up small caps until they smear. */
  .row__label {
    text-transform: uppercase;
    letter-spacing: 0.08em;
    color: var(--ink-soft);
    font-weight: var(--fw-ui);
  }

  .row__body { line-height: 1.55; }
  .entry__points { font-size: 15px; line-height: 1.5; }
  .outro__title { font-size: 21px; }

  /* --- The résumé lead, split into its two sentences ----------------------

     The paragraph is written in two halves — what he is focused on, then what
     he is experienced in — and above this breakpoint that reads fine as one
     block. On a phone it did not: 16px over a 343px column is 7 lines at 390
     and 9 at 320, and "Experienced in" landed in the middle of line three
     where nothing distinguished it from the clause before it. The most-read
     paragraph on the page was the least scannable thing on it.

     So the halves become blocks here and the structure is simply visible.
     No word changes, and nothing is hidden: this is the same sentence count
     and the same text at every width. Above 620 the spans are inline and the
     whitespace between them collapses to a single space, which is byte for
     byte the paragraph that was always there.

     0.7em, not a full line: the gap has to separate the two halves without
     reading as two unrelated paragraphs, and at 16px that is 11px against the
     24px leading — clearly a break, clearly still one idea. */
  .hero__lead .part { display: block; }
  .hero__lead .part + .part { margin-top: 0.7em; }
}
