/* The site's single stylesheet, written mobile first.

   The base rules are the phone layout. Larger screens are reached with
   `@media (min-width: 900px)` only — there is no max-width query anywhere in
   this build, and if one is ever wanted the base rules are wrong.

   Everything below the token block references a token. The handoff called its
   colours, geometry, type scale and tracking final, so each one is written down
   once, here, and never re-typed into a rule. */

/* ---------- Font ---------- */
/* One variable file covering 400–700, self-hosted and preloaded from <head>.
   The 100–900 range is the face's own; declaring it is what stops the browser
   treating this as a single weight and synthesising the rest. */
@font-face {
  font-family: "Archivo";
  src: url("assets/fonts/archivo-latin-var.woff2") format("woff2");
  font-weight: 100 900;
  font-style: normal;
  font-display: swap;
}

/* ---------- Tokens ---------- */
:root {
  /* Ink

     Contrast ledger — measured, not argued. Every alpha this build writes,
     against every ground the design can put it on, composited and taken against
     WCAG 2.1's relative-luminance formula. Micro type here is 9.5–10px, which is
     *not* large text, so AA is 4.5:1 and never 3:1.

     The three grounds are the hero gradient's bright centre stop, its dark outer
     stop, and the page below the hero.

       alpha  ground    ratio     verdict
       .5     #7a2296   3.22:1    FAILS
       .5     #240634   5.12:1    PASSES
       .5     #1b0524   5.25:1    PASSES
       .55    #7a2296   3.59:1    FAILS
       .55    #240634   5.98:1    PASSES
       .55    #1b0524   6.15:1    PASSES
       .62    #7a2296   4.16:1    FAILS
       .62    #240634   7.34:1    PASSES
       .62    #1b0524   7.58:1    PASSES
       .68    #7a2296   4.70:1    PASSES
       .68    #240634   8.66:1    PASSES
       .68    #1b0524   8.98:1    PASSES
       .8     #7a2296   5.93:1    PASSES
       .8     #240634  11.73:1    PASSES
       .8     #1b0524  12.25:1    PASSES

     `npm test` recomputes every row above from `scripts/lib/contrast.mjs` and
     fails if a number here stops describing the colours beside it, so this table
     is evidence rather than a note somebody once wrote.

     What the table settles: no ink in this build sits on the gradient's bright
     stop. That stop is the focal point of `at 50% 8%` and the portrait covers
     it; the header nav, which the rebuild plan flagged as the risk, measures
     8.8:1 in a browser because what is actually behind it is the top of the
     photograph under the scrim, at about rgb(31,9,48). Every token below stands
     exactly as the handoff wrote it.

     What the table does *not* settle, and could not: the hero's ground is a
     photograph. Two rows of micro type land on lit regions of it at some
     viewports and measured under the line there — see `.meta` and `.stat-l`,
     each of which carries its own measured alpha and the ratio that justified
     it. That measurement is `npm run verify:design`'s `measuredContrast`, which
     reads the pixels the browser painted, because no declaration in this file
     can name the colour of a forearm. */
  --ink: #ffffff;
  --ink-2: rgba(255, 255, 255, .68);
  --micro: rgba(255, 255, 255, .55);
  --muted: rgba(255, 255, 255, .5);

  /* Ground and surfaces */
  --ground: #1b0524;
  --hero-gradient: radial-gradient(120% 90% at 50% 8%,
    #7a2296 0%, #5a1478 38%, #3a0b4e 72%, #240634 100%);
  --tile: #2a0a3a;

  /* The card, which is the unit this design is built out of. Two surfaces: the
     solid one for content that leads, and a translucent one for content that
     supports. The translucent value is deliberately not `--card` at an alpha —
     it is a colour of its own, because it is composited over a gradient that
     changes down the page and a single alpha would read as four different
     greys between the hero and the footer. */
  --card: #17151b;
  --card-recessed: rgba(19, 17, 23, .86);
  --card-pad: clamp(20px, 2vw, 28px);

  /* Two layers, and neither does the other's job. The wide, soft, heavily
     offset layer lifts the card off the ground; the tight one keeps its bottom
     edge from dissolving into the gradient. The recessed pair is the same
     shape, shallower, so a card that sits back also sits closer. */
  --card-shadow:
    0 28px 72px -14px rgba(0, 0, 0, .8),
    0 8px 20px -8px rgba(0, 0, 0, .6);
  --card-shadow-recessed:
    0 24px 64px -16px rgba(0, 0, 0, .7),
    0 6px 16px -8px rgba(0, 0, 0, .5);

  /* The accent as light rather than as fill, for the one card that is the
     action on this page. `color-mix` in oklab because a straight rgba of the
     accent goes muddy against the near-black surface it is cast from. */
  --card-glow:
    0 30px 76px -16px color-mix(in oklab, #7a2296 58%, transparent),
    0 28px 72px -14px rgba(0, 0, 0, .72),
    0 8px 20px -8px rgba(0, 0, 0, .6);

  /* The rule, and the accent.

   `--rule` is the one line weight this build draws, and after the redesign it
   draws exactly one line: the nav indicator under the current section. The 2px
   rules that used to divide every section, the footer and the cells of the work
   grid are all gone — a page of cards floating over a gradient divides with
   space, and a hard near-white line across a soft ground was the last piece of
   the design this one replaced.

   `--rule-solid` went with them. It was the same line flattened against the
   ground, and it existed for one mechanism: a card drew its own 2px frame, so
   the gap where two cards met was painted twice and two layers of an 85% white
   composite brighter than one. No card draws a frame now, nothing paints that
   colour twice, and a token kept past the thing it was for is a value the next
   reader has to work out the purpose of. */
  --rule: rgba(255, 255, 255, .85);

  /* The accent, and the only one. The vermilion this build used to carry is
     gone from every surface: the redesign has one colour that is not a grey or
     a ground, and it is the purple the gradient is already made of — so the
     40px rule under a card head, the filled badge on a highlighted project and
     the focus ring are all the same family as the ground behind them.

     `--accent-soft` is the tint that carries small type — an eyebrow at 11.5px
     in the accent itself measures under the line against the card surface, and
     the tint is what clears it. `--focus` is lighter again, because a focus
     ring has to be found at a glance against both the card and the ground. */
  --accent: #7a2296;
  --accent-soft: #c69ad8;
  --focus: #a86dc0;

  /* Geometry */
  --radius: 0;
  --gap-1: 8px;
  --gap-2: 16px;
  --gap-3: 24px;
  --gap-4: 28px;

  /* The gap between cards in a grid, which the handoff draws as fluid rather
     than fixed: tight on a phone where the cards are already narrow, open on a
     desktop where they are not. It is built out of the scale rather than typed
     as a third number — the two ends *are* `--gap-2` and `--gap-3` — so the one
     fluid spacing in this design is still the scale's own, and there is no
     value here that a reader has to recognise as arbitrary. */
  --gap-grid: clamp(var(--gap-2), 2vw, var(--gap-3));

  /* Clearance for the fixed bar, for the rules that have to stand clear of it.
     There is one of those — the name block on a phone, which is dropped by this
     plus 14vh — and so there is one value: the desktop composition is anchored
     to the hero rather than pushed down it, and a second value declared where
     nothing reads it is a number that goes wrong quietly.

     It is clearance rather than a mirror of the bar's measured height, which is
     47px on a phone and 55px above the breakpoint — the row is one line of 10px
     type between its paddings. 58px was that height when the brand wrapped to two
     lines (#24); it is kept because it is what the name block's position was
     drawn against and because both bars stand inside it, and because a rule that
     clears a bar by a few pixels more than the bar is not wrong in the direction
     that matters. */
  --header-h: 58px;

  /* Mobile first: 20px gutters. The handoff's 40px arrives at 900px. */
  --pad-x: 20px;

  /* Type scale. --fs-stat is the handoff's size, restored by the 900px block;
     the phone rule that shrinks it does so without a token, because a token
     whose value is true at only one width is worse than no token. */
  --fs-wordmark: clamp(44px, 6.4vw, 96px);
  --fs-stat: 30px;
  --fs-name: 11px;
  --fs-micro: 9.5px;
  --fs-label: 10px;

  /* The card language's own micro scale. It is larger and far less tracked than
     `--fs-micro`/`--micro-ls`, and that is the point: 9.5px at .26em was drawn
     for uppercase captions over a photograph, and the same treatment inside a
     card reads as a caption on a card rather than as part of one. It also has
     room to clear 4.5:1 where the old scale did not. */
  --fs-eyebrow: 11.5px;
  --eyebrow-ls: .08em;

  /* Tracking */
  --wordmark-ls: -.03em;
  --stat-ls: -.02em;
  --micro-ls: .26em;
  --label-ls: .2em;

  /* Motion. Nothing in this site changes state without passing through these. */
  --ease: cubic-bezier(.22, 1, .36, 1);
  --ease-in-out: cubic-bezier(.65, 0, .35, 1);
  --dur-fast: 180ms;
  --dur: 320ms;
  --dur-slow: 620ms;

  /* The step between cards that arrive together, so a row reads left to right
     rather than as a block. The rebuild plan writes it into the rule as a bare
     `60ms`; Global Constraints allows no bare `ms` anywhere outside this block,
     and a stagger is a duration like every other one above it. */
  --stagger: 60ms;

  /* What `prefers-reduced-motion: reduce` substitutes for every duration in the
     table. Naming it keeps that block from being the one place in the stylesheet
     that writes a raw time, and it says out loud that collapsing the motion is a
     value of this system rather than a hack at the bottom of the file. */
  --dur-none: 0s;
}

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

html, body {
  margin: 0;
  background: var(--ground);
  color: var(--ink);
  font-family: Archivo, system-ui, -apple-system, sans-serif;
  -webkit-font-smoothing: antialiased;
}

/* Anchor navigation is a move rather than a cut, so a visitor who follows `Work`
   keeps their bearings — the page travels the distance instead of replacing
   itself with somewhere else.

   It is on the root element because that is what scrolls, and it is the one
   declaration in this stylesheet the reduced-motion block *has* to beat: a long
   animated scroll is the single most uncomfortable thing this page could do to
   somebody who asked for less motion. That block is last in the file and every
   declaration in it is `!important`, so `scroll-behavior: auto` wins here
   regardless of source order — which is why this rule can be written plainly
   rather than hedged.

   It also reaches `window.scrollTo(x, y)`, which is `behavior: auto` and so
   defers to this. Anything that means to move the page instantly — a test
   harness reading the document, say — has to say `behavior: 'instant'` and
   mean it. */
html { scroll-behavior: smooth; }

/* Never the browser default: on this ground it is close to invisible. The ring
   takes `--focus` rather than `--accent` because the accent is the same purple
   family as the gradient it would be drawn over, and a ring the colour of what
   is behind it is not a ring. */
:focus-visible {
  outline: 2px solid var(--focus);
  outline-offset: 2px;
}

/* ---------- Links ---------- */
/* What a link is on this site, stated once for the whole document.

   It has been established three times now by accident and never on purpose: the
   header's three nav links shipped in `rgb(0, 0, 238)` with an underline through
   10px uppercase type, and the fix for that styled `.site-nav a` — so the strip's
   two repository tiles, which are anchors wearing the same class as five plain
   boxes, shipped in the same blue with the same underline one layer down. Both
   times the cause was a rule written for the links somebody was looking at. This
   is the rule for the links nobody has written yet: the contact rows, the résumé
   download, the project cards, the 404 page.

   `color: inherit` rather than a token, because taking the surrounding ink is
   the whole of what a link's colour should be here — in the header that is the
   row's `--ink-2`, in a tile it is the tile's `--ink`, and neither has to name
   itself twice for the link inside it to be right. It is also what makes the
   ink hold in the state no screenshot is ever taken in: a browser resolves a
   visited link from this same cascade and may only let it differ where a rule
   selects it as visited, so an unqualified rule is a visited link that is the
   same link. Nothing in this stylesheet selects `:visited` or `:link`, and the
   check that says so is in `npm test`, because `getComputedStyle` returns the
   unvisited answer by design and no measurement of the page can see it.

   No underline, in any state. Under the header's tracked micro type it cuts
   through the row; under a 12px repository name inside a 204px square it draws a
   line across the composition; the handoff's own stylesheet resets every link in
   the document in one rule and this is that rule. Where a link needs to *look*
   like a link — a contact row, say — that is a positive decision taken at the
   component, and it is now a decision rather than the absence of one.

   This says nothing about focus. The page-wide `:focus-visible` rule above
   already rings every focusable element with the accent at a 2px offset, and a
   link is a focusable element: what is asserted per link is that the ring is
   still there, not that this rule re-states it. */
a {
  color: inherit;
  text-decoration: none;
}

/* ---------- Skip link ---------- */
/* The first thing in the document and the first thing focus reaches, and it has
   to be two contradictory things at once: reachable, so `display: none` is out —
   a link that is not in the accessibility tree is not a skip link — and absent,
   because "Skip to work" rendered above the hero is 17px of assistive furniture
   standing between a visitor and the design. Out of the flow and translated off
   the top edge of the window is what satisfies both: a real, focusable box that
   nobody who is not looking for it ever sees.

   Out of the flow is the load-bearing half. In it, the link's own line box sizes
   the top of the page and pushes the hero down by its height — which is how the
   hero came to start 17px lower than the window it is meant to fill.

   The accent as a background is one of the two uses Global Constraints permits
   it, and this element is one of the two it names. It needs a ground of its own
   because when it does appear it appears over the portrait, and white text on a
   photograph is legible only where the photograph happens to be dark.

   `transform` is what moves it, and the only thing that moves it: the offset is
   a percentage of its own height, so the link clears the top of the window
   whatever the type in it turns out to measure, and the browser can hand the
   move to the compositor rather than laying the page out again on every frame.
   That is also why nothing here shifts the page as it arrives — a transformed
   element occupies exactly the space it always did. */
.skip {
  position: fixed;
  top: 0;
  left: var(--pad-x);
  z-index: 20;
  padding: 12px var(--gap-2);
  background: var(--accent);
  /* Issue #48, and the accent change that settled it.

     The old vermilion accent could not carry either ink at 10px: white on
     `#ec3013` measured 4.20:1 against the 4.5:1 AA asks of type this size, so
     the link ran inverted — the ground colour on the accent, at 4.57:1 — and
     the inversion was flagged for design sign-off and never resolved.

     `#7a2296` is dark enough that the question goes the other way. White on it
     is 7.36:1, comfortably clear, and the link reads as what it is: white type
     on the page's one accent, the same treatment as every other piece of ink on
     this design. The inversion is gone with the colour that forced it.

     Measured in a browser rather than computed here: `measuredContrast` reads
     this link held open at the top of the window, which is the only position it
     is ever read in. */
  color: var(--ink);
  font-size: var(--fs-label);
  letter-spacing: var(--label-ls);
  text-transform: uppercase;
  transform: translateY(-120%);
  transition: transform var(--dur) var(--ease);
}

/* `:focus` rather than `:focus-visible`: this link is only ever reached by a
   keyboard, and it has to appear for every way of reaching it — including the
   ones a browser's focus-visible heuristic does not count as one. The ring it
   carries there is the page-wide `:focus-visible` rule, unchanged. */
.skip:focus { transform: translateY(0); }

/* Still rendered, still laid out, still painted — into a box nobody can see,
   which is why it is still in the accessibility tree. `display: none` and
   `visibility: hidden` would take it out of both, and the whole point of this
   class is to carry text that only a screen reader reads.

   Out of the flow as well as out of sight: an in-flow copy of a string takes
   part in sizing the box around it, and the boxes this class is used in are
   sized to something else.

   The two resets are not tidiness. A browser exposes text as it is rendered,
   not as it is written, so an inherited `text-transform: uppercase` reaches the
   accessibility tree and a name meant to be said is shouted and, in some screen
   readers, spelled out. Tracking leaks the same way. Text that exists only to
   be read aloud must not inherit how the text around it is set. */
.visually-hidden {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  text-transform: none;
  letter-spacing: normal;
}

/* ---------- Site header ---------- */
/* A bar across the top of the window, painted over the hero. The handoff draws
   it inside the hero and it lives outside the hero here, because a single-page
   portfolio needs navigation that survives the fold and the nav indicator needs
   a parent that does not scroll away — so what makes the two the same picture at
   scroll position 0 is this rule: fixed at `top: 0`, over the portrait, taking
   up no room in the flow.

   Taking up no room is the point. In the flow this element is 78px of the
   window that the hero then starts below, which moves every pixel of the
   composition down, adds 78px to the document, and puts the bottom of the hero
   under a scrollbar the design does not have.

   The stacking order is not tidiness. `.hero` follows this element in the
   document and is positioned itself, so positioned siblings paint in document
   order and the header would be drawn underneath the portrait — visible only
   where the picture happens not to reach, which is nowhere.

   It is still one left edge with everything under it, so it takes the same
   `--pad-x` the hero does and moves to 40px with it at 900px. The vertical
   padding is the handoff's 22px on a desktop and one step less on a phone,
   where 22px above and below a 10px row is most of the space a small screen has
   above the fold.

   The row itself is the handoff's micro type — 10px uppercase at .2em, which is
   `--fs-label` and `--label-ls`, the pair the token block names for the header,
   the nav and the skip link. Set in the browser's own 16px sentence case this
   bar is in exactly the right place and none of the right type, and no
   measurement of a box can tell: it took a screenshot to see it.

   The ink is `--ink-2`. The handoff writes `rgba(255,255,255,.62)` and `:root`
   names no .62; `--ink-2` at .68 is the nearest ink it does name, and it is the
   nearest one on the safe side. This row is the one place the plan's contrast
   step flags as a risk — 10px type is not large text, so it owes 4.5:1, and near
   the top-centre of the gradient the handoff's own alpha does not quite clear
   it. Brighter is the direction that helps.

   Everything in the row takes this colour or is deliberately distinct from it,
   which is what makes the ink one value rather than three: the brand raises it
   and the links inherit it.

   The two halves are held apart by `space-between` and by a gap rather than by
   a width on either of them, and the size of that gap is the whole of #24. At
   the handoff's own numbers — a 24px row gap and 28px between the links — the
   brand, the gap and the nav measure about 340px together, more than the 320px
   a 360px phone has between its gutters, so the brand took a second line. It
   overflowed nothing and overlapped nothing, which is why it survived every
   assertion this build had: it took the same word break the name block below it
   takes, and looked deliberate.

   So the row is tightened below the breakpoint and restored above it. The nav
   drops to `--gap-2` and the row to `--gap-1`, which takes 40px out of a line
   that was 20px too long; the 900px block puts back the handoff's 28px and 24px,
   where there is width to spend on them. Both are values from the 8/16/24/28
   scale — a gap is a token in this build and not a number chosen to make
   something fit.

   Below 340px of viewport the row runs out again, and there the wrap is still
   the right failure: a nav pushed past the edge of a fixed bar does not scroll,
   and so is simply lost.

   The bar moves, and it moves by `transform` and by nothing else. A header that
   got out of the way by changing its height, its `top` or its `position` would
   lay the page out again on every frame of the move — and it would take the
   composition under it with it, because those are the properties everything
   below reads. `translateY` is the same bar in a different place, handed to the
   compositor, and the document it sits on top of never knows. */
.site-header {
  position: fixed;
  inset: 0 0 auto 0;
  z-index: 10;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gap-1);
  padding: 18px var(--pad-x);
  font-size: var(--fs-label);
  letter-spacing: var(--label-ls);
  text-transform: uppercase;
  color: var(--ink-2);
  transition: transform var(--dur) var(--ease), background-color var(--dur-fast) var(--ease);
}

/* Out of the way, on the way down. `-100%` is the bar's own height, so it clears
   the top of the window whatever the row inside it turns out to measure — the
   same reason the skip link is moved by a percentage rather than by a number.

   `main.js` decides when: down the page and past the first screenful, and never
   while the bar holds the focused element, because a focused link translated out
   of the window is a keyboard visitor pressing keys against something they
   cannot see. */
.site-header.is-hidden { transform: translateY(-100%); }

/* A ground, once the hero is no longer behind it. On the hero the bar is drawn
   over the portrait and the handoff gives it nothing of its own; one screenful
   down it stands over the portfolio's own copy, and 10px tracked type over a
   card's name is two things a visitor has to read apart from each other.

   `background-color` because it is one of the three properties Global
   Constraints allows a transition on, and the bar arrives and leaves often
   enough that a ground snapping in reads as a flicker. */
.site-header.is-stuck { background-color: var(--ground); }

/* The name of the site, at the ink above the row's. The handoff's .92 is not a
   value the token block names and `--ink` is the ink above `--ink-2`, which is
   the relationship being stated: the brand is not a fourth nav link. */
.site-header .brand { color: var(--ink); }

/* Positioned because the indicator inside it is taken out of the flow against
   it, and a flex row of its own so the three links carry a gap between them that
   is not the row's.

   16px here and the handoff's 28px above the breakpoint. The two gaps together
   are what closed #24: three links set 28px apart are 201.5px wide, and with the
   brand's 114.5px and the row's own 24px the header measured 340px against the
   320px a 360px phone has between its gutters. Two link gaps at 16px take 24px
   out of that, and `--gap-1` on the row above takes another 16px — 40px against
   a 20px overrun. The desktop keeps the handoff's own spacing, restored in the
   900px block. */
/* The phone gap is the tightest step on the scale, and the third nav link is
   why. With Work and About the row measured comfortably; Projects joined it when
   the two grids became two sections, and at 360px the brand (111.16px) and the
   nav (185.84px) plus the header's own 8px came to exactly the 305px content
   box — which wraps the brand onto a second line at the first rounding error.
   Two gaps at 8px instead of 16 buys 16px of slack and keeps the row one line.
   The 900px block restores the handoff's 28px, where there is width to spend. */
.site-nav {
  position: relative;
  display: flex;
  gap: var(--gap-1);
}

/* The links, at the row's colour and with the user agent's underline off. Both
   halves matter and both were missing: an anchor that declares no colour is
   painted `rgb(0, 0, 238)`, and an underline under 10px uppercase type tracked
   at .2em cuts through the row and is the loudest thing in the hero.

   Those two declarations are now also the document's, stated for every link in
   the `a` rule above — which is the point of that rule, and the reason the same
   defect could not reach the strip's tiles. They are left here because this rule
   is where the header's own tests read them, and because a reader of the header
   should be able to see what its links are without holding the whole stylesheet
   in their head. Nothing below re-states them a third time.

   `color: inherit` rather than a second reference to `--ink-2`, so the row
   states its ink once and the links cannot drift away from it — and so that the
   colour holds in every state a link has, including the visited one no
   screenshot is ever taken in.

   The hover is an opacity change and not a colour change, deliberately. Global
   Constraints allows exactly three properties to be transitioned — `transform`,
   `opacity` and `background-color` — and `color` is not among them; a colour
   transition here would render identically to a visitor while breaking a rule
   the whole build is held to. So the link sits at .9 of the row's ink at rest
   and comes up to all of it under the pointer, which is the same brightening
   expressed in a property the compositor can run. The .9 is not an arbitrary
   dimming: .9 of `--ink-2` composites to `rgba(255,255,255,.612)`, which is the
   handoff's own .62 to within a hundredth, so the row at rest is the row the
   design was signed off on and the hover is the headroom above it.

   Nothing here draws a line under a hovered link. Per the design rules a
   per-link border is never toggled on and off — the single indicator that slides
   between the links owns that job, and it arrives with the plan that animates
   it. */
.site-nav a {
  color: inherit;
  text-decoration: none;
  opacity: .9;
  transition: opacity var(--dur-fast) var(--ease);
}

/* The keyboard gets the same lift as the pointer, on top of the page-wide accent
   ring: a focused link that is still the dimmest thing in the row is a focus
   nobody can follow. The link of the section the visitor is actually in takes
   the same lift, so the row says where they are twice — in the ink, and in the
   mark under it — and the second is what a visitor sees from across a screen. */
.site-nav a:hover,
.site-nav a:focus-visible,
.site-nav a.is-active { opacity: 1; }

/* One element, and the whole of what marks the current section.

   Per-link borders are never toggled on and off. That shape cannot animate —
   there is nothing continuous between a border on one link and a border on
   another — and it makes every link in the row carry state that has to be kept
   in step. This is one span that moves.

   It is 1px wide on purpose, and that is the trick the whole thing turns on:
   with the box a single pixel and its origin on its own left edge,
   `translateX(n px) scaleX(w)` puts its left edge n pixels along the nav and
   makes it exactly w pixels wide. So `main.js` writes one `transform` — the two
   numbers it measured off the link — and never a width. A width would be a
   layout on every frame of every move; a transform is the compositor's.

   Absolutely positioned, which it was already: an empty flex item is still a
   flex item, and left in the nav's flow this span would take its own share of
   the 28px gap and push the row 28px off the gutter while painting nothing.

   `scaleX(0)` at rest, because the page loads on the hero and the hero is not
   one of the three destinations. It collapses to nothing rather than sitting
   under Work, and it collapses by the same transform and the same transition as
   every other move it makes.

   2px tall, like every other rule in this build, and in the rule colour: what
   this is, is one of those lines, moved under whichever link the visitor is at.
   It sits `--gap-1` below the nav's own box so it reads as underlining the row
   rather than as touching the type. */
.nav-indicator {
  position: absolute;
  left: 0;
  bottom: calc(var(--gap-1) * -1);
  width: 1px;
  height: 2px;
  background: var(--rule);
  transform: scaleX(0);
  transform-origin: 0 50%;
  transition: transform var(--dur) var(--ease);
}

/* ---------- The ground ---------- */
/* One layer behind the whole document, fixed to the viewport. Everything else
   on this page is a card floating over it, which is the design in one sentence.

   Fixed rather than sitting at the top of the flow because the alternative is
   each section painting its own background and the seams between them showing
   as the page scrolls. It is also what makes the motion story cheap: the
   parallax (#88) translates this element and nothing else.

   It is 150vh so that a translated layer never runs out of ground at the bottom
   of a long scroll and exposes the body behind it.

   `pointer-events: none` because it covers the viewport and everything the
   visitor can act on is on top of it. Without this the layer takes every click
   on the page. */
.bg {
  position: fixed;
  left: 0;
  right: 0;
  top: 0;
  z-index: 0;
  height: 150vh;
  pointer-events: none;
  background-color: var(--ground);
  background-image: var(--hero-gradient);
}

/* The portrait is cropped, never fitted: a full-bleed image made of a real
   <img> so the browser can pick an encoding and a width for it. It covers the
   top 73.4% of the layer and stops — below that the gradient carries on alone,
   which is what lets the cards further down the page sit on colour rather than
   on a photograph.

   The off-centre `object-position` is load-bearing: it keeps the head in frame
   as the viewport gets shorter, which centring does not. */
.bg > picture > img {
  position: absolute;
  left: 0;
  top: 0;
  width: 100%;
  height: 73.4%;
  object-fit: cover;
  object-position: 50% 12%;
  opacity: .86;
}

/* The scrim carries the layer from portrait to ground. Six stops rather than a
   flat wash: nearly clear across the upper third so the photograph is still a
   photograph, then thickening from the middle down until it has resolved into
   the ground colour at the bottom. Without the lower half of it the boundary
   where the portrait ends would read as a horizontal seam across the page. */
.scrim {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: linear-gradient(180deg,
    rgba(36, 6, 52, .4) 0%,
    rgba(36, 6, 52, .05) 22%,
    rgba(36, 6, 52, .3) 48%,
    rgba(27, 5, 36, .56) 66%,
    rgba(27, 5, 36, .74) 82%,
    rgba(27, 5, 36, .86) 100%);
}

/* ---------- Hero ---------- */
/* Taller than the window on purpose. The inner grid is exactly one viewport and
   everything in it is bottom-aligned, so the composition lands on a baseline
   near the fold and the quarter-screen below it is what says the page carries
   on — the work a scroll cue would otherwise do with a chevron nobody reads.

   `min-height` on both because a percentage of a short window is a composition
   with no room in it: under 940px the hero scrolls rather than compressing. */
.hero {
  position: relative;
  z-index: 1;
  height: 125vh;
  min-height: 940px;
}

/* Everything under the hero has to be lifted over the ground for the same
   reason the hero is, and this is not a nicety.

   `.bg` is `position: fixed` with `z-index: 0`, which makes it a positioned box
   in the root stacking context — and a positioned box at z-index 0 paints
   *above* every non-positioned block in the same context, however far down the
   document it sits. So the hero, which is `position: relative; z-index: 1`,
   rendered correctly while Work, Projects and About were painted behind a
   fixed layer that covers the viewport: three sections of the page, invisible,
   with nothing in the markup or in their own rules looking wrong.

   `npm run verify:design` is what found it, by reading the pixels behind every
   run of text and reporting that it could not measure a single one below the
   fold. Nothing in `npm test` could have: every rule involved is correct on its
   own, and the defect is in how two of them compose. */
main,
footer {
  position: relative;
  z-index: 1;
}

/* Two columns that become one without a media query. `auto-fit` with a
   `minmax(min(100%, 360px), 1fr)` track collapses the moment two 360px columns
   no longer fit, and `min(100%, …)` is what keeps the track from being wider
   than its container on a 360px phone.

   `align-items: end` is the whole composition: identity and cards sit on one
   baseline regardless of how tall either column turns out to be. */
.hero-inner {
  position: relative;
  z-index: 2;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 360px), 1fr));
  align-items: end;
  gap: clamp(32px, 4vw, 64px);
  height: 100vh;
  min-height: 820px;
  padding: clamp(104px, 14vh, 180px) var(--pad-x) clamp(44px, 6vh, 80px);
}

.hero-identity,
.hero-cards {
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
}

.hero-identity { gap: clamp(20px, 3vh, 32px); }
.hero-cards { gap: clamp(16px, 2vh, 24px); }

/* ---------- Hero: the name ---------- */
/* The wordmark is `ben` and the accessible name is `Benoit Meunier`. The
   heading is hidden from assistive technology and the name is given once, as
   text, by the visually hidden span beside it — so a screen reader announces a
   person rather than a syllable.

   `width: max-content` so the block is exactly as wide as the mark, which is
   what the role row below it aligns to. */
.name {
  position: relative;
  width: max-content;
}

.name h1 {
  margin: 0;
  font-size: var(--fs-wordmark);
  line-height: .9;
  letter-spacing: var(--wordmark-ls);
  font-weight: 700;
  color: rgba(255, 255, 255, .94);
}

/* The role, stated in full, pulled up under the mark: the wordmark's .9 line
   height leaves a band of empty leading below the baseline, and a row placed at
   the container's own gap sits visibly adrift of the letters above it. The
   negative margin closes that band rather than reducing the gap token, which
   every other pair in this column is spaced by. */
.role {
  margin: clamp(-30px, -2.8vh, -16px) 0 0;
  font-size: clamp(11.5px, 1.13vw, 16.2px);
  line-height: 1.2;
  letter-spacing: .055em;
  color: rgba(255, 255, 255, .8);
}

/* 46ch, which is the measure the handoff sets. A line longer than about 75
   characters loses the reader on the return sweep; this is a short paragraph
   beside a photograph and it is set well inside that. */
.hero-blurb {
  margin: 0;
  max-width: 46ch;
  font-size: clamp(14px, 1.1vw, 15px);
  line-height: 1.65;
  color: rgba(255, 255, 255, .78);
  text-wrap: pretty;
}

/* ---------- The panel ---------- */
/* One object, repeated. A grey surface, square corners, a two-layer shadow, and
   inside it a fixed rhythm: eyebrow, title, a 40px rule, body, tags. Everything
   on this page that is not the ground or the identity is one of these.

   The shadow is two layers because one cannot do both jobs. The wide, soft,
   heavily-offset layer is what lifts the card off the ground; the tight one
   under it is what keeps the bottom edge from dissolving into the gradient. A
   single shadow tuned for either reads as a smudge or as a hard drop.

   Square corners are the build's standing rule — `--radius` is 0 and is stated
   rather than defaulted — and this is the element most likely to tempt someone
   into softening it. */
.panel {
  display: flex;
  flex-direction: column;
  gap: var(--gap-2);
  padding: var(--card-pad);
  background: var(--card);
  border-radius: var(--radius);
  box-shadow: var(--card-shadow);
}

/* The second surface, for content that supports rather than leads: the figures
   in the hero, the career timeline in About. It sits back through alpha and a
   shallower shadow rather than through a different shape, so the page has two
   depths and one vocabulary. */
.panel-recessed {
  background: var(--card-recessed);
  box-shadow: var(--card-shadow-recessed);
}

/* The one card that carries the accent as light. Contact is the action on this
   page, and the glow is how it is marked without giving it a different shape or
   a loud fill. It is stated as a whole shadow rather than as an extra layer so
   there is one declaration to read. */
.panel-glow { box-shadow: var(--card-glow); }

.panel-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--gap-2);
}

.panel-label {
  font-size: var(--fs-eyebrow);
  letter-spacing: var(--eyebrow-ls);
  color: var(--micro);
}

/* The status beside the label is the one place in the hero that takes the soft
   accent. It is a state — whether Benoit is open to offers — and a state is
   exactly what colour is for on this page. */
.panel-status {
  font-size: var(--fs-eyebrow);
  letter-spacing: var(--eyebrow-ls);
  color: var(--accent-soft);
}

/* The signature of the whole design: a 2px bar of accent, 40px wide, under the
   head of every card. It is the only rule left on this page — the grid-drawn
   lines the previous design separated its cells with are gone — so it is what
   makes a stack of grey boxes read as one system. */
.rule-mark {
  display: block;
  flex: none;
  height: 2px;
  width: 40px;
  background: var(--accent);
}

/* Half length, for a card whose head is a rule and nothing else. */
.rule-mark-sm { width: 20px; }

/* ---------- Hero: the contact routes ---------- */
/* A grid rather than a list, so two routes sit side by side where there is room
   for them and stack where there is not. The 2px column gap is the only place
   the old design's rule spacing survives, and here it is just a gap.

   Each route is 48px tall at minimum: that is the target size a fingertip needs,
   and these are the three things on this page a recruiter is most likely to be
   reaching for on a phone. */
.routes {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 190px), 1fr));
  gap: 2px var(--gap-2);
}

/* The negative margin is what lets the hover state be wider than the text
   without the row's ink sliding inward: the padding opens the hit area, the
   margin pulls the box back so the label still starts on the card's own left
   edge. */
.routes a {
  display: flex;
  align-items: center;
  gap: 12px;
  min-height: 48px;
  padding: 8px 10px;
  margin: 0 -10px;
  border-radius: var(--radius);
  background: transparent;
  transition: background-color var(--dur-fast) var(--ease);
}

.routes a:hover { background-color: rgba(255, 255, 255, .05); }

/* The mark is a 28px square with a hairline around it, holding either two
   letters or a drawn glyph. It is what makes three rows of small type scan as
   three destinations rather than as a paragraph. */
.route-mark {
  display: flex;
  align-items: center;
  justify-content: center;
  flex: none;
  width: 28px;
  height: 28px;
  border: 1px solid rgba(255, 255, 255, .28);
  border-radius: var(--radius);
  font-size: 10px;
  font-weight: 700;
  letter-spacing: .04em;
  color: var(--ink);
}

.route-text {
  display: flex;
  flex-direction: column;
  gap: 3px;
  min-width: 0;
}

.route-kind {
  font-size: var(--fs-eyebrow);
  letter-spacing: var(--eyebrow-ls);
  color: var(--muted);
}

/* `overflow-wrap: anywhere` because these are handles and filenames — 
   `benoit-meunier-98b8551b1` has nothing in it to break at, and a word that
   cannot break is a word that decides how wide its column is. */
.route-val {
  font-size: 13px;
  font-weight: 600;
  line-height: 1.25;
  color: var(--ink);
  overflow-wrap: anywhere;
}

.route-out {
  font-weight: 400;
  color: var(--muted);
}

/* ---------- Hero: the stat row ---------- */
/* Four figures, two by two on a phone and four across from the 900px block.
   Four across a phone leaves 78px a cell once the gutters are taken out, and the
   labels stop fitting long before the numbers do — which matters here more than
   usual, because each label states the window its figure was counted over and a
   figure without its window overstates itself.

   This row sits inside a recessed card now, which is what retired the two
   remediations it used to carry. The row is no longer painted over the
   photograph, so there is no forearm to measure against: the ground under it is
   a known, opaque surface at every viewport, and the label can take the same
   `--micro` every other quiet piece of type on this page takes. The 2px rule
   that used to separate it from the strip above is gone with the strip; the
   card's own 20px accent mark is the head of this block now.

   The figure is 24px rather than the handoff's 30px, and it is the one size in
   the hero written as a literal: `--fs-stat` carries the 30px the 900px block
   restores, and a token whose value is true at only one width is worse than no
   token. */
.stats {
  display: grid;
  grid-template-columns: repeat(2, 1fr);
  gap: var(--gap-2) var(--gap-3);
}

.stat-n {
  font-size: 24px;
  line-height: 1;
  font-weight: 700;
  letter-spacing: var(--stat-ls);
  color: var(--ink);
}

/* The label under each figure. It reads at the card language's own micro scale
   rather than the 9.5px/.26em the hero used over the portrait: the tracking that
   made an uppercase caption legible on a photograph is not what a label inside a
   card wants, and the larger size is what carries the contrast the old row had
   to buy with a hand-measured alpha. */
.stat-l {
  margin-top: var(--gap-1);
  font-size: var(--fs-eyebrow);
  line-height: 1.3;
  letter-spacing: var(--eyebrow-ls);
  color: var(--micro);
}

/* ---------- Section chrome ---------- */
/* What a section below the hero is, stated once for the three that wear it:
   nothing in this block names the Work section, and nothing in this stylesheet
   reaches a section through its id.

   A band of padding, and nothing else. It used to carry a 2px rule above it —
   the same near-white line that divided every band of the previous design from
   the one before it, and the thing that made the page below the fold read as
   one site. The cards do that now. A page of objects floating over a gradient
   needs no line to say where one group ends and the next begins: the space
   between them already says it, and a hard white rule drawn across a soft
   ground is the one element on this page that belongs to the design this one
   replaced.

   The padding is the phone's 56px, raised to the handoff's 80px by the 900px
   block, and it takes `--pad-x` sideways like everything else on the page: one
   left edge from the wordmark down. */
.section {
  padding: 56px var(--pad-x);
  /* Where an anchor stops. The header is fixed, so a section scrolled to its
     own top edge starts underneath it and the label a visitor followed the link
     to read is the one thing the bar is covering. `scroll-margin` is the
     browser's own answer to that — it changes where the scroll lands and
     nothing about the box — and it is the bar's own token rather than a number,
     so it moves if the row ever does. */
  scroll-margin-top: var(--header-h);
}

/* The label and its note. Stacked on a phone, because two pieces of tracked
   micro type on one 390px row leave neither of them room to say anything; the
   900px block puts them on opposite ends of a single row. `align-items:
   flex-start` rather than the default stretch so the two boxes are as wide as
   their words and no wider — a stretched span is a click target that isn't one
   and an underline that would run the width of the page the day a note becomes
   a link. */
.section-head {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: var(--gap-1);
  margin-bottom: var(--gap-3);
}

/* The section's own heading, set in the handoff's micro type rather than at a
   browser's idea of an <h2>. The size is the 9.5px end of the micro scale and
   the tracking is the .26em that makes a line of it readable — the same pair
   the meta row and the stat labels take, which is what keeps one voice across
   the page. The level is what a screen reader is handed and the size is what an
   eye is handed, and this is the one element in the build where those two
   disagree on purpose: a section heading here is a label, not a headline. */
/* The section head is the only type on this page that is not on a card, and the
   ground under it is a photograph. `.bg` is fixed, so the portrait is behind the
   whole document at every scroll position — the sections do not scroll off it,
   they scroll across it — and `npm run verify:design` measured this label at
   3.57:1 against his forearm and the note beside it at 4.04:1, both under the
   4.5:1 AA asks of type this size.
   
   Two changes, and only one of them is ink. The row moves to the card language's
   eyebrow scale — 11.5px at .08em instead of 9.5px at .26em — because the
   tracking that made an uppercase caption legible over a photograph is not what
   this row needs and the extra size is contrast for free. The label then takes
   primary ink, which measures 7.64:1 against the worst pixel found, and the note
   takes `--ink-2` at 4.54:1 against the same. Neither is a hand-measured literal:
   the elements moved up the existing scale rather than each acquiring an alpha
   of its own, which is what the old `.meta` and `.stat-l` remediations had to do
   and what retired with them. */
.section-label {
  margin: 0;
  font-size: var(--fs-eyebrow);
  font-weight: 500;
  letter-spacing: var(--eyebrow-ls);
  text-transform: uppercase;
  color: var(--ink);
}

/* The aside beside the label — "26 years · Montreal" here, a résumé link or a
   response time in the sections that follow. One step quieter than the label it
   sits opposite, because it qualifies the section rather than naming it. */
.section-note {
  font-size: var(--fs-eyebrow);
  letter-spacing: var(--eyebrow-ls);
  text-transform: uppercase;
  color: var(--ink-2);
}

/* ---------- The grids ---------- */
/* One grid rule for both collections. `auto-fit` with a `minmax(min(100%, 340px), 1fr)`
   track is the whole responsive behaviour: three across a desktop, two on a
   tablet, one on a phone, at the widths the cards themselves stop fitting rather
   than at a breakpoint somebody picked.

   `min(100%, 340px)` and not a bare `340px`: on a 360px phone with 20px gutters
   the container is 320px, and a track with a 340px floor is wider than the box
   holding it, which slides the page sideways. */
.work-grid {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 340px), 1fr));
  gap: var(--gap-grid);
}

/* ---------- The card ---------- */
/* The unit. It wears the panel's surface and shadow — the same object as the
   contact card and the stats card in the hero — and adds the body rhythm every
   card in both grids states itself in: eyebrow, title, rule, blurb, then what it
   counted and what it is built with.

   `overflow: hidden` because a work card's image plate sits flush to its top
   edge, and a square-cornered card clips nothing on its own.

   No border. The previous design drew a 2px grid through the gutters and let the
   container's colour through as the lines between cells; this one separates
   cards with shadow and space, and a card that also drew its own frame would be
   both designs at once. */
.card {
  display: flex;
  flex-direction: column;
  overflow: hidden;
  background: var(--card);
  border-radius: var(--radius);
  box-shadow: var(--card-shadow);
}

/* The plate. It sits flush to the card's top edge, outside the body's padding,
   which is what `overflow: hidden` on the card is for — a square-cornered box
   clips nothing on its own.

   `aspect-ratio` rather than a height: the ratio is the composition and the
   width is whatever column the card landed in, so there is no number here to
   keep in step with the grid. The three sources are not all 16/9 — one is 4/3 —
   and `object-fit: cover` is what makes them one shape rather than three.

   The ground behind it is the deep end of the gradient, so a plate that has not
   arrived yet is a dark panel the size of the one that will, rather than a hole
   the card collapses around. */
.card-plate {
  position: relative;
  aspect-ratio: 16 / 9;
  background: #240634;
}

/* The ratio is stated on the image, not only on the box around it, and the
   difference is not academic: `height: 100%` needs a parent with a definite
   height, and inside a flex column the plate takes *its* height from the image —
   so the image sized itself from the file and the box followed. Two of the three
   sources are 16/9 and hid it; the third is 4/3 and rendered 54px taller than
   its neighbours, which is the kind of misalignment that reads as sloppiness
   rather than as a bug.

   With the ratio on the image its height is decided before its content is, and
   `object-fit: cover` crops whatever shape the source happens to be. A fourth
   plate at any ratio at all now lands the same height as the three beside it. */
.card-plate img {
  display: block;
  width: 100%;
  aspect-ratio: 16 / 9;
  height: auto;
  object-fit: cover;
}

/* `flex: 1` on the body so that cards in a row are the same height and their
   chip rows sit on one line — the blurb takes the slack, which is the only
   element in the card that can absorb it without looking stretched. */
.card-body {
  display: flex;
  flex-direction: column;
  gap: var(--gap-2);
  flex: 1;
  padding: var(--card-pad);
}

/* Where and when, above the title. It takes the soft accent on a work card and
   on a public project, and the quieter treatment on a private one — see
   `.card-private` below, which is the disclosure and not a decoration. */
.card-eyebrow {
  font-size: var(--fs-eyebrow);
  letter-spacing: var(--eyebrow-ls);
  color: var(--accent-soft);
}

.card-name {
  margin: 0;
  font-size: 20px;
  font-weight: 700;
  letter-spacing: var(--stat-ls);
  line-height: 1.1;
}

/* The title of a public project is the link, and it is the only link on the
   card. The card itself is not an anchor: it is a tall object with a paragraph
   and a row of chips in it, and wrapping all of that in one `<a>` gives a screen
   reader a link whose accessible name is the whole card.

   The hover is the same gesture the hero's contact routes make — a wash of white
   behind the text — for the same reason: `background-color` is one of the three
   properties a browser hands to the compositor, and warming the ink of a title
   instead would cost a paint of every glyph on every frame. It is stated on the
   anchor rather than on `.card-name`, so the seven cards with nothing to open
   stay inert: a card that lights up under the pointer is an invitation to click
   something that does nothing. */
.card-name a {
  display: inline;
  border-radius: var(--radius);
  background: transparent;
  transition: background-color var(--dur-fast) var(--ease);
}

.card-name a:hover { background-color: rgba(255, 255, 255, .08); }

.card-out {
  font-weight: 400;
  color: var(--muted);
}

/* `flex: 1` so the chips of a short card sit level with the chips beside it. */
.card-desc {
  margin: 0;
  flex: 1;
  font-size: 13px;
  line-height: 1.55;
  color: var(--ink-2);
  text-wrap: pretty;
}

/* The disclosure a repository the reader cannot open carries, and the reason it
   is not the accent: `Private` is a fact about a repository, and the accent on
   this page marks a state worth acting on. A private project rendered in the
   same purple as a public one says the two are the same kind of thing with a
   different word on them. It is brighter than the public eyebrow rather than
   dimmer, because a disclosure nobody can see is not a disclosure. */
.card-private {
  font-weight: 600;
  color: var(--ink-2);
}

/* The employer, inside a work card's eyebrow. It carries the eyebrow's own tint
   and is marked as its own element so the two facts in that line — who, and
   when — are separable by a stylesheet without the renderer having to decide
   how they are set. */
.card-org { color: inherit; }

/* ---------- The card: figures and chips ---------- */
/* The row some projects carry, above a hairline divider. It exists because
   eight cards of identical shape read as one undifferentiated block; a project
   that has counted something about itself breaks the column with it. */
.card-metrics {
  display: flex;
  flex-wrap: wrap;
  gap: var(--gap-3);
  padding-top: var(--gap-1);
  /* A divider inside the card, not a frame around it — the same reading and the
     same ink as the career timeline's rows. */
  border-top: 1px solid rgba(255, 255, 255, .14);
}

.metric-n {
  font-size: 22px;
  line-height: 1;
  font-weight: 700;
  letter-spacing: var(--stat-ls);
  color: var(--ink);
}

.metric-l {
  margin-top: 6px;
  font-size: var(--fs-eyebrow);
  letter-spacing: var(--eyebrow-ls);
  color: var(--micro);
}

/* The stack, as chips. It used to be one line of muted type with the
   technologies joined by a separator, which is a sentence a reader has to read
   to the end of; a row of outlined chips is a set they can scan for the one they
   came looking for. */
.chips {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--gap-1);
}

.chip {
  padding: 4px 8px;
  border: 1px solid rgba(255, 255, 255, .14);
  border-radius: var(--radius);
  font-size: 10.5px;
  letter-spacing: .04em;
  color: var(--ink-2);
}

/* The one filled chip, leading the row. It is the only surface in either grid
   that takes the accent, and it is reserved for a claim the project has measured
   about itself rather than for a technology it happens to use — which is what
   keeps it from becoming a second chip style. */
.chip-lead {
  border-color: transparent;
  background: var(--accent);
  font-weight: 600;
  color: var(--ink);
}

/* ---------- About ---------- */
/* Two cards: the prose, and the career it belongs to. Same `auto-fit` rule as
   the grids above, with a wider floor because a paragraph needs more measure
   than a card of chips does. `align-items: start` so the shorter of the two
   keeps its own height instead of stretching to match. */
.about {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 380px), 1fr));
  gap: var(--gap-grid);
  align-items: start;
}

/* The prose card is the one place on this page with a real measure to hold. */
.about-prose {
  max-width: 62ch;
  font-size: 15px;
  line-height: 1.65;
  color: var(--ink-2);
  text-wrap: pretty;
}

.about-prose p { margin: 0 0 var(--gap-2); }
.about-prose p:last-child { margin-bottom: 0; }

.about-prose strong {
  font-weight: 600;
  color: var(--ink);
}

/* ---------- About: the career ---------- */
/* The rows are the recessed card's whole content, so the list carries the
   spacing rather than the card carrying a gap between two children. */
.career .timeline { margin-top: var(--gap-3); }

.timeline {
  margin: 0;
  padding: 0;
  list-style: none;
  display: flex;
  flex-direction: column;
}

/* Two cells side by side where there is room and stacked where there is not —
   the same `auto-fit` behaviour as every other pair on this page, at the width a
   date range and a job title stop fitting beside each other.

   A rule above every row and one below the last, so the block is closed at both
   ends rather than trailing off. These are 1px because they divide rows inside a
   card rather than sections of the page — the same reading as the metric row's
   divider, and the same ink. */
.timeline li {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 140px), 1fr));
  gap: var(--gap-1) var(--gap-3);
  padding: var(--gap-2) 0;
  border-top: 1px solid rgba(255, 255, 255, .14);
}

.timeline li:last-child { border-bottom: 1px solid rgba(255, 255, 255, .14); }

.timeline .years {
  font-size: var(--fs-eyebrow);
  line-height: 1.2;
  letter-spacing: var(--eyebrow-ls);
  color: var(--muted);
}

.timeline .role-name {
  display: block;
  font-size: 15px;
  line-height: 1.3;
  font-weight: 600;
  color: var(--ink);
}

.timeline .org {
  display: block;
  font-size: 13px;
  line-height: 1.45;
  color: var(--ink-2);
}

/* ---------- The footer ---------- */
/* The last band of the page. It carried the same 2px rule the sections did and
   loses it for the same reason: the line was the previous design's way of
   dividing one band from the next, and this design divides with space. It is
   the micro type the whole page speaks its quiet rows in, and it takes
   `--pad-x` sideways like everything else: one left edge from the wordmark down
   to the last line of the document. */
footer {
  padding: var(--gap-4) var(--pad-x);
  font-size: var(--fs-micro);
  line-height: 1.2;
  letter-spacing: var(--micro-ls);
  text-transform: uppercase;
  color: var(--muted);
}

/* ---------- Scroll reveal ---------- */
/* Sections arrive as a visitor reaches them, so the page has a rhythm rather
   than presenting itself all at once.

   Every rule here is scoped to `.js`, and that guard is the point of the whole
   section. These rules set `opacity: 0` and wait for a script to put it back; if
   one of them ever applied in a document where scripting never runs, nothing
   would ever put it back and the section would be hidden permanently. The class
   is set on the root element by the inline script in <head>, before the first
   paint — scripting off, the class never appears, these rules never match, and
   the page renders whole. There is no code path here where content depends on
   JavaScript having run.

   Two shapes of selector, for one reason. `.reveal` is carried in the committed
   markup by the six hand-written revealables, which is what makes the guard
   load-bearing: the marker really is in the document a scripting-off visitor
   receives, so a rule that lost its `.js` really would hide something, and the
   check that says so is a real browser with scripting disabled rather than a
   reading of the selector. The Work cards are written between the `gh:*` markers
   by `npm run build`, which renders the portfolio and knows nothing about
   motion, so they are named structurally instead.

   `opacity` and `transform`, and nothing else, ever. Those are the two a browser
   can hand to the compositor; a reveal that moved a margin or a height would lay
   the whole page out again on every frame of a 620ms transition, on exactly the
   mid-range phone a recruiter is holding. `--dur-slow` is the token the table
   names for scroll reveals. */
.js .reveal,
.js .work-grid .card {
  opacity: 0;
  transform: translateY(18px);
  transition: opacity var(--dur-slow) var(--ease), transform var(--dur-slow) var(--ease);
}

.js .reveal.is-in,
.js .work-grid .card.is-in {
  opacity: 1;
  transform: none;
}

/* Cards in a group arrive in sequence rather than snapping in as a block, so
   the eye has somewhere to go. `--i` is the card's place in the group that
   arrived with it, set by `main.js`; the `0` fallback is what a card renders at
   before the script has assigned one, which is also what it renders at forever
   if the script never runs at all. */


/* The card's transition, stated once as all three of the properties a card
   moves: the two the reveal runs, over `--dur-slow`, and the background the
   pointer runs, over `--dur-fast`.

   It is one declaration rather than two because a `transition` shorthand
   replaces — it does not merge — so a second rule naming only the reveal's two
   properties drops the third silently. That is exactly how the section shipped
   once, with a background that snapped on every card and the rule that gave it
   a duration sitting correctly written 300 lines up. It is stated here rather
   than in the shared reveal rule because a `background-color` there would be a
   claim about the section heads and the timeline, which have no background to
   change. */
.js .work-grid .card {
  transition:
    opacity var(--dur-slow) var(--ease),
    transform var(--dur-slow) var(--ease),
    background-color var(--dur-fast) var(--ease);

  /* Inside the same rule as the shorthand, and that is the whole point. A
     `transition` shorthand resets every longhand it does not name —
     `transition-delay` included — so a delay declared in a rule above this one
     is silently zeroed and the stagger disappears with no rule looking wrong.
     That is the same trap the hover fell into once, in the other direction. */
  transition-delay: calc(var(--i, 0) * var(--stagger));
}

/* ---------- Desktop ---------- */
/* The only media query in this stylesheet, and it is far shorter than it was.

   The previous hero was the handoff's 1440×900 artboard reconstructed with
   absolute positions — the name at 38% of the section, the strip at 58%, the
   meta row 34px above that, the stat row 34px off the bottom edge — and this
   block existed to restore all of it over a phone layout that was a different
   composition rather than the same one narrower. Every one of those anchors is
   gone. The redesign's hero is a two-column grid that collapses to one column on
   its own, at the width its own tracks stop fitting, so there is nothing here to
   re-place: the layout is the same layout at every width.

   What is left is what genuinely differs above 900px — a wider gutter, the
   header's own spacing, and the stat row going from two-up to four. */
@media (min-width: 900px) {
  /* The handoff's gutter, raised once. Every rule that spends it moves with it
     and none of them names a width. */
  :root { --pad-x: 40px; }

  /* The handoff's header spacing, restored: 22px of vertical padding, the row's
     own 24px between the brand and the nav, and 28px between the nav's links.
     The base rules tighten the last two to fit a 360px phone on one line — #24 —
     and this is where the design gets them back, because from here on there is
     width to spend on them. */
  .site-header {
    gap: var(--gap-3);
    padding: 22px var(--pad-x);
  }

  .site-nav { gap: var(--gap-4); }

  /* Four figures across, which is the handoff's row, and the figure at the size
     the handoff sets. Below this width the same four are two-by-two and the
     figure is 24px — a phone leaves 78px a cell, and the labels stop fitting
     long before the numbers do. */
  .stats { grid-template-columns: repeat(4, minmax(0, 1fr)); }

  .stat-n { font-size: var(--fs-stat); }

  /* The handoff's section padding. Everything else about the chrome is already
     right at this width and is left alone. */
  .section { padding: 80px var(--pad-x); }

  /* The label and its note come off the stack and onto one row, against
     opposite ends of it — there is room for both here, and a note tucked under
     its own label on a 1440px page reads as a second heading. Aligned on the
     baseline rather than on the box, because the two are different inks of the
     same type and what lines up is the type. */
  .section-head {
    flex-direction: row;
    align-items: baseline;
    justify-content: space-between;
    gap: var(--gap-3);
    margin-bottom: var(--gap-4);
  }

  /* The next step up the gap scale. A card here is a 300px-and-up column rather
     than the full width of a phone, and the room around its text is the only
     thing making it a card rather than a cell of a table. */
  .card { padding: var(--gap-4); }

  /* The name comes up with the width, as everything else on this page does. */
  .card-name { font-size: 20px; }

  /* A posting becomes two cells: the years in a column of their own and the
     posting beside them. 140px holds `2020 — Present` at the eyebrow size and
     its tracking with room to spare, and it is a track width rather than a
     spacing value — the gap between the two cells is the scale's own 24px.

     `align-items: baseline` is what lines the years up with the role rather than
     with the top of the box it is in: they are two different sizes of type, and
     what a reader sees as aligned is the type. It is also why nothing here nudges
     the years down by a few pixels — a literal offset tuned to one pair of font
     sizes is wrong the moment either changes.

     The About grid itself says nothing here: it is `auto-fit`, so it collapses
     at the width its own tracks stop fitting rather than at this breakpoint. */
  .timeline li {
    grid-template-columns: 140px 1fr;
    align-items: baseline;
    gap: var(--gap-3);
  }

  /* The rows in one row, however many of them there are — and a track function
     rather than a count, on purpose.

     `repeat(auto-fit, minmax(220px, 1fr))` — Task 8's own — fits three columns
     at the first pixel of this block, which leaves the fourth row alone on a
     second line beside two cells nothing is painted over: an empty cell has no
     row above it, so what shows through is the grid's own background, and the
     rule colour that is a 2px line between two rows becomes a solid block of it.
     That is the `auto-fill` defect the Work grid met, arriving from the other
     direction — every track occupied and the last row not.

     `repeat(4, …)` fixed that by writing the count down, and the count was
     wrong within one plan: the Contact section is five rows once the résumé is
     offered in both formats, and this same class carries the one row the 404
     page needs, which under four columns painted three-quarters of the width in
     solid rule colour. A written-down count is a second place the content is
     stated, and it is the place nobody updates.

     `grid-auto-flow: column` takes the count from the content instead: one
     implicit column per row, so the cell count and the row count are the same
     number by construction, at every width in this block and for any list this
     class is ever put around. `minmax(0, 1fr)` on the auto columns rather than a
     bare `1fr`, because a bare `1fr` track floors at its own content and an
     email address is what would be holding it open. */
  .contact-list {
    grid-auto-flow: column;
    grid-auto-columns: minmax(0, 1fr);
  }

  /* The next step up the gap scale, as the card takes at this width. */
  .contact-list a { padding: var(--gap-4); }

  /* The handoff's figure size. This is the only rule that reads `--fs-stat`:
     the phone rule that shrinks it writes a literal, because a token whose
     value is true at only one width is worse than no token. */
  .stat-n { font-size: var(--fs-stat); }
}

/* ---------- Reduced motion ---------- */
/* A visitor who has asked their system for less motion has asked for it
   everywhere, and a page that animates anyway can make them physically unwell.
   This is the last block in the file and every declaration in it is
   `!important`, both deliberately: it has to beat whatever any later rule
   declares, and `!important` is what makes that true regardless of where in the
   cascade that rule ends up — a `scroll-behavior: smooth` written after this
   block would otherwise win on source order alone.

   The universal selector is the other half. Scoping this to the elements
   somebody remembered means the next transition anybody adds is one nobody
   collapsed; against `*` it reaches everything, including everything not written
   yet.

   Collapsing the durations is only half the preference. The other half is
   `main.js`, which shows every revealable immediately instead of leaving it at
   `opacity: 0` — including when the setting is switched on mid-session, which is
   the case that otherwise leaves content permanently invisible. Nothing here can
   do that: this block can stop the fade, but only the script can decide the
   thing should be visible at all. */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    transition-duration: var(--dur-none) !important;
    transition-delay: var(--dur-none) !important;
    animation-duration: var(--dur-none) !important;
    animation-iteration-count: 1 !important;
    scroll-behavior: auto !important;
  }
}
