/* ==========================================================================
   Site chrome — header, footer, and the shared page sections
   (about teaser, project grid, contact, about page).
   ========================================================================== */

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

.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  /* Solid, and the colour of whatever ground is directly beneath it. site.js
     samples the ground just under the header's bottom edge on every scroll and
     publishes it as --header-ground — warm stone over the hero, white over the
     projects band, wine over the About band, the page ground everywhere else.
     Sampling at the BOTTOM edge is what makes it seamless: the visible page
     starts there, so the header always matches the strip right below it and a
     section boundary simply disappears under the bar.

     This replaced a 15px backdrop blur with no tint (Oct 2026, V1c). The blur
     let content show through as a smear; a solid bar hides it outright. The
     fallback is the page ground, which is also what a page without JS gets. */
  background: var(--header-ground, var(--color-bg));
}

.site-header__inner {
  position: relative;
  z-index: 1;
}

/* --- Nav ink follows the ground --------------------------------------------
   The header takes the ground beneath it (see above), so over the wine band
   it IS wine — and dark ink on #470023 measures 1.13:1. The ink swaps with it:
   site.js flags when the sampled ground belongs to a .ground-ink section and
   the chrome takes --ink-on-dark, the same decoupling --hero-ink exists for. */
.site-header.on-dark-ground .site-nav > a,
.site-header.on-dark-ground .logomark,
.site-header.on-dark-ground .nav-toggle {
  color: var(--ink-on-dark);
}

.site-header.on-dark-ground .logomark {
  --logomark-ink: var(--ground-ink);
}

.site-header.on-dark-ground .site-nav > a:hover,
.site-header.on-dark-ground .site-nav > a:focus-visible {
  color: var(--on-band-accent);
}

/* The hairline, not a fill, over a dark section. --color-line is a light-ground
   value and disappears on the wine band, so the outline takes the same ink the
   labels do at an alpha that still reads as a hairline rather than a box. */
.site-header.on-dark-ground .site-nav__pill {
  border-color: color-mix(in srgb, var(--ink-on-dark) 55%, transparent);
}

/* The same relationship inverted: on light grounds the bar is the body ink, so
   over a dark section it is the dark-ground body ink. It was --color-highlight
   while the active label was accent-coloured and the two had to agree; the
   label is plain ink at a heavier weight now, and the bar follows it. */
.site-header.on-dark-ground .site-nav__bar {
  background: var(--ink-on-dark);
}

/* On the home page the hero photo runs up behind the header, so until the
   header sticks and paints its light bar, its links are standing on the photo
   rather than on the page ground — so they take the hero's ink, whichever way
   round the current art is. Only this page opts in; everywhere else the header
   sits on the light page ground from the start. */
.site-header--over-hero:not(.is-stuck) .site-nav > a {
  color: var(--hero-ink);
}

/* Logomark alone at the left page edge, every link in one group at the right
   — at every width. This used to be a grid on desktop that put the nav on the
   content line (the rail); V1c moved it to the edge so the header reads the
   same on every page, whatever grid the page body uses. */
.site-header__inner {
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--sp-6);
  min-height: var(--header-h);
}

/* The mark is inline SVG, not an <img>, so its plate can take `currentColor`
   and change on hover — a raster file could not be recoloured at all. The
   glyph is knocked out in the page colour rather than plain white, so it reads
   as a hole in the plate on whatever ground the header is standing on. */
.logomark {
  position: relative;
  /* The plate is two pseudo-elements at z-index -1; without a stacking context
     of its own they would sink behind the header instead of sitting under the
     glyph. Same arrangement as .btn. */
  isolation: isolate;
  display: block;
  width: 1.75rem;
  height: 1.75rem;
  flex: none;
  /* The corner is CSS, not baked into the SVG: that keeps it a true 4px tied
     to --radius-sm rather than a proportion that grows if the mark is ever
     rendered larger. It lives on the link now rather than on the <svg>,
     because the plate it rounds is no longer a rect inside that <svg>. */
  border-radius: var(--radius-sm);
  overflow: hidden;
  /* The plate at rest. --logomark-ink is the glyph, knocked out of it. */
  color: var(--color-dark);
  --logomark-ink: var(--color-light);
}

/* The plate in two layers, exactly as .btn paints its fill: the resting colour
   underneath, the highlight above it behind a circular wipe out of the
   top-left corner, on the hero shape's easing. The glyph is a real element on
   top of both, so it stays solid all the way through the wipe rather than
   being re-cut by it — which is why the mark is drawn as glyph-only now and
   the plate rect came out of the SVG. */
.logomark::before,
.logomark::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
}

.logomark::before {
  background: currentColor;
}

.logomark::after {
  background: var(--color-highlight);
  clip-path: circle(0% at 0% 0%);
  transition: clip-path var(--btn-fill-out) var(--ease);
}

/* Over the violet band the wipe takes the band's accent (see motion.css). */
.site-header.on-dark-ground .logomark::after {
  background: var(--on-band-accent);
}

.logomark:hover::after,
.logomark:focus-visible::after {
  clip-path: circle(var(--btn-fill-reach) at 0% 0%);
  transition-duration: var(--btn-fill-dur);
  transition-timing-function: var(--btn-fill-ease);
}

.logomark__mark {
  display: block;
  width: 100%;
  height: 100%;
}

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

.site-nav {
  display: flex;
  align-items: center;
  gap: clamp(var(--sp-5), 4vw, var(--sp-12));
}

.site-nav > a {
  font-family: var(--font-mono);
  font-size: var(--fs-nav);
  letter-spacing: var(--ls-label);
  color: var(--color-text);
}

/* Weight, not colour. The nav is DM Mono, and only two faces are loaded for it
   — 400 and 500 — so this is the component's 500/600 pair mapped onto the two
   real faces this site has. Asking for 600 would render identically to 500:
   the browser matches down to the heaviest face available rather than
   synthesising, measured at 68.55px for both.

   Nothing here sets a colour any more. The bar below is the indicator; the
   label just gets heavier. */
.site-nav > a[aria-current="page"] {
  --u: 1;
  font-weight: var(--fw-medium);
}

/* The accent is the site's hover colour everywhere else, but it cannot hold
   here: at a 70% tint with the About band passing behind the header it falls
   to 3.82:1. Deepening the ink instead measures 6.42 on every ground the
   header stands over, and the sliding pill is already carrying most of the
   hover signal anyway.

   A text-shadow would not fix this — WCAG grades foreground against
   background, and a shadow changes neither. */
.site-header:not(.on-dark-ground) .site-nav > a:hover,
.site-header:not(.on-dark-ground) .site-nav > a:focus-visible {
  color: var(--nav-hover-ink);
}

/* --- Nav: sliding hover pill + active bar --------------------------------- */
/* Ported from a React/Tailwind tabs component. Same two moving parts — a
   highlight that slides to whatever is hovered, and a bar that slides to the
   current item — rebuilt as two JS-inserted spans, because nothing here
   renders components. The component's hex values were already this palette:
   #373135 is --color-dark and #BEB2BB33 is --color-line at 20%, so both are
   tokens now and follow the theme instead of being frozen.

   Both spans are created in site.js, so with no JS the nav keeps the per-link
   underline it has always had and nothing below applies. */

@media (min-width: 48rem) {
  .js .site-nav {
    position: relative;
    /* The pill needs room around the text, which the links did not have. Three
       compensations put the TEXT back exactly where it was, all derived from
       the same --nav-pad-x so they cannot drift apart if the padding moves:

       gap                  — between items: 12px either side comes off the
                              48px gap, so label-to-label spacing is unchanged.
       margin-inline-start  — at the leading edge: without it the first label
                              sat 12px inside the content line while the nav
                              BOX sat on it, so the nav was the one thing on
                              the page not aligned to the grid.
       margin-inline-end    — same defect at the trailing edge, which is the
                              one that shows below 62rem where the nav is
                              pushed right instead of starting on the line.

       Logical properties, not left/right, so the alignment survives a change
       of writing direction. The padding itself is untouched — the hit area
       and the pill still cover the full 12px either side. */
    gap: calc(clamp(var(--sp-5), 4vw, var(--sp-12)) - 2 * var(--nav-pad-x));
    margin-inline-start: calc(-1 * var(--nav-pad-x));
    margin-inline-end: calc(-1 * var(--nav-pad-x));
  }

  .js .site-nav > a {
    position: relative;
    z-index: 1;
    padding: var(--nav-pad-y) var(--nav-pad-x);
  }

  /* The bar replaces the per-link underline; running both would draw two
     rules under the current page, one of them the wrong width now that the
     link box is padded. */
  .js .site-nav > a::after {
    display: none;
  }

  .site-nav__pill,
  .site-nav__bar {
    position: absolute;
    top: 0;
    left: 0;
    pointer-events: none;
    opacity: 0;
    /* Position and size are set inline by script. transform rather than left
       so the slide is composited; width still costs a layout, but on three
       items that is nothing. */
    transition: transform var(--dur) var(--ease),
      width var(--dur) var(--ease),
      opacity var(--dur-fast) var(--ease);
  }

  /* Until the first measurement lands, both must not animate in from zero. */
  .site-nav:not(.is-measured) .site-nav__pill,
  .site-nav:not(.is-measured) .site-nav__bar {
    transition: none;
  }

  /* Outline, not a fill. The supplied component draws #BEB2BB33 — the line
     colour at 20% — as a filled 6px square; this is the same colour at full
     strength as a 1px hairline on --radius-sm, so the highlight belongs to the
     same family as every other rule on the site instead of being the one
     filled shape in the chrome. Box-sizing is border-box globally, so the
     border costs no width: the pill still measures exactly the link's box. */
  .site-nav__pill {
    z-index: 0;
    border: 1px solid var(--color-line);
    border-radius: var(--radius-sm);
  }

  .site-nav.is-hovering .site-nav__pill {
    opacity: 1;
  }

  .site-nav__bar {
    z-index: 0;
    height: 2px;
    border-radius: 1px;
    /* The body ink, as the component draws it — see --nav-bar-ink. */
    background: var(--nav-bar-ink);
  }

  .site-nav__bar.is-on {
    opacity: 1;
  }
}

/* The contact group exists for the panel alone — in the desktop header row it
   would sit beside About / Projects / Contact as three more nav items. */
@media (min-width: 48rem) {
  .nav-contact {
    display: none;
  }
}

/* Below the breakpoint the nav is the full-page panel; neither belongs there.
   Nor does CV as a main item: the panel's main items are Projects, About and
   Contact, and CV sits in the contact group under the rule. */
@media (max-width: 47.999rem) {
  .site-nav__pill,
  .site-nav__bar,
  .site-nav > .site-nav__cv {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .site-nav__pill,
  .site-nav__bar {
    transition: opacity var(--dur-fast) var(--ease);
  }
}

/* --- Mobile menu ---------------------------------------------------------- */
/* Below 48rem the three links become a full-page panel behind a hamburger.
   48rem, not the site's usual 62rem rail collapse: three short words never
   crowd, so this is a reading decision rather than a fitting one, and a
   hamburger at 992px for "About / Projects / Contact" reads as hiding
   something that fits. The breakpoint is written literally in the two queries
   below and in site.js — search for "47.999rem" to move it.

   There is no second copy of the nav. The panel IS .site-nav, restyled: one
   set of links, so aria-current can never disagree between the two. And the
   header's own logomark and button simply stay on top of it — the panel is
   fixed inside the sticky header, so it shares the header's stacking context
   and the mark lands in exactly the position it already occupied. That is why
   the logo needs no markup of its own up there, and why the hamburger can
   literally become the X instead of handing over to a second button. */

.nav-toggle {
  display: none;
}

@media (max-width: 47.999rem) {
  /* Only with JS. Without it the panel could open but never close, so the
     plain inline nav below is what ships instead. */
  .js .nav-toggle {
    display: grid;
    place-items: center;
    /* 44px — the smallest comfortable touch target. The negative margin is
       exactly the padding that centring a 20px icon in a 44px box creates,
       so what lines up with the gutter is the ICON, matching the logomark
       opposite it, rather than the invisible hit area. */
    width: 2.75rem;
    height: 2.75rem;
    margin-right: -0.75rem;
    padding: 0;
    border: 0;
    background: none;
    color: var(--color-text);
    cursor: pointer;
  }

  /* Both live above the panel, which is a later sibling in the same stacking
     context and would otherwise paint over them. */
  .js .nav-toggle,
  .js .site-header__inner > .logomark {
    position: relative;
    z-index: 1;
  }

  .nav-toggle__icon {
    grid-area: 1 / 1;
    width: 1.25rem;
    height: 1.25rem;
  }

  /* One button, two glyphs, driven by the state the button already publishes
     to assistive tech — so the icon cannot drift out of sync with aria. */
  .nav-toggle[aria-expanded="false"] .nav-toggle__icon--close,
  .nav-toggle[aria-expanded="true"] .nav-toggle__icon--open {
    display: none;
  }

  .js .site-nav {
    position: fixed;
    inset: 0;
    z-index: 0;
    flex-direction: column;
    align-items: flex-start;
    justify-content: center;
    gap: var(--sp-5);
    /* Clears the header so the first item never sits under the mark. */
    padding: calc(var(--header-h) + var(--sp-10)) var(--gutter) var(--sp-16);
    background: var(--color-bg);
    overflow-y: auto;
    overscroll-behavior: contain;
    opacity: 0;
    /* visibility, not just opacity: `hidden` is set by JS, and between the
       first paint and a deferred script running this would otherwise be a
       transparent full-screen layer swallowing clicks. */
    visibility: hidden;
  }

  /* .site-nav sets display:flex, and an author rule beats the browser's own
     [hidden] { display: none } no matter how specific. Without this the closed
     panel stays a full-screen invisible layer that eats every click on the
     page — the exact trap the lightbox fell into. */
  .js .site-nav[hidden] {
    display: none;
  }

  /* --- Opening and closing -------------------------------------------------
     The hero's own keyframes, reused verbatim. `hero-shape-in` is the corner
     morph — translate up-left, scale down, circular clip opening from 0% at
     0% 0% — and `hero-line-in` is the fade-and-rise. Nothing new is defined
     here; only the tempo tokens differ. That is the point: one motion
     signature, two speeds.

     The panel must stay painted while it collapses, so closing is its own
     state rather than the absence of the open one. */
  .js .site-nav.is-open,
  .js .site-nav.is-closing {
    visibility: visible;
  }

  .js .site-nav.is-open {
    animation: hero-shape-in var(--menu-in-panel-dur) var(--hero-in-shape-ease) both;
  }

  /* Reverse, not a mirrored replay: the same curve run backwards at 180ms,
     with no item stagger, so the menu reads as snapping shut rather than
     performing a second entrance. --ease rather than the hero's curve, whose
     reverse would loiter at the start and then bolt. */
  .js .site-nav.is-closing {
    animation: hero-shape-in var(--menu-out-dur) var(--ease) reverse both;
  }

  .js .site-nav.is-open > a,
  .js .site-nav.is-open .nav-contact a {
    animation: hero-line-in var(--menu-in-item-dur) ease-out both;
  }

  .js .site-nav.is-open > a:nth-of-type(1) {
    animation-delay: var(--menu-in-items-at);
  }

  .js .site-nav.is-open > a:nth-of-type(2) {
    animation-delay: calc(var(--menu-in-items-at) + var(--menu-in-item-step));
  }

  .js .site-nav.is-open > a:nth-of-type(3) {
    animation-delay: calc(var(--menu-in-items-at) + var(--menu-in-item-step) * 2);
  }

  .js .site-nav > a {
    font-family: var(--font-sans);
    font-size: clamp(2rem, 11vw, 3rem);
    font-weight: 400;
    line-height: 1.15;
    letter-spacing: -0.02em;
    /* The label tracking is for 14px mono and opens 48px sans far too wide. */
    text-transform: none;
  }

  /* The panel resets weight for its own 48px sans, which lands on exactly the
     specificity of the site-wide current-page rule and, being later in the
     file, was winning — so the open menu showed no current page at all once
     that rule stopped setting a colour. Stated here instead, where the rest of
     the panel's type lives. DM Sans is variable 300-700, so unlike the mono
     header this is a real 500. */
  .js .site-nav > a[aria-current="page"] {
    font-weight: var(--fw-medium);
  }

  /* Secondary group at the foot of the panel. Deliberately the label voice —
     mono at --fs-label against the main items' 41px sans — so it reads as an
     aside and the three nav items stay the menu's subject.

     margin-top: auto pushes it to the bottom of the flex column; the panel's
     justify-content: center then only distributes what is left above it. */
  /* Under a hairline that spans the panel's content width — align-self
     stretch, because the column's items otherwise shrink to their text and the
     rule would stop where the email address does. */
  .nav-contact {
    align-self: stretch;
    margin-top: auto;
    padding: var(--sp-6) 0 0;
    border-top: 1px solid var(--color-line);
    list-style: none;
    display: grid;
    gap: var(--sp-3);
  }

  .nav-contact a {
    font-family: var(--font-mono);
    font-size: var(--fs-nav);
    letter-spacing: var(--ls-label);
    color: var(--color-muted);
    transition: color var(--dur-fast) var(--ease);
  }

  .nav-contact a:hover,
  .nav-contact a:focus-visible {
    color: var(--color-text);
  }

  /* Each one a beat behind the last nav item, continuing the same stagger
     rather than arriving as a block. */
  .js .site-nav.is-open .nav-contact li:nth-child(1) a {
    animation-delay: calc(var(--menu-in-items-at) + var(--menu-in-item-step) * 3);
  }

  .js .site-nav.is-open .nav-contact li:nth-child(2) a {
    animation-delay: calc(var(--menu-in-items-at) + var(--menu-in-item-step) * 4);
  }

  .js .site-nav.is-open .nav-contact li:nth-child(3) a {
    animation-delay: calc(var(--menu-in-items-at) + var(--menu-in-item-step) * 5);
  }

  /* The panel paints the page ground across the whole viewport, so the
     header's own ground — wine, if the menu opened over the About band —
     would read as a bar floating across the top of the menu. */
  .nav-open .site-header,
  .nav-open .site-header.is-stuck {
    background: transparent;
  }

  /* The hero-ink rule assumes the links are standing on the hero photo. Once
     the panel is up they are standing on the page. */
  .nav-open .site-header--over-hero:not(.is-stuck) .site-nav > a {
    color: var(--color-text);
  }
}

/* Opacity only: no corner morph, no stagger, nothing that moves. The panel
   still has to be painted while it fades out, which is what .is-closing is
   for here too. */
@media (max-width: 47.999rem) and (prefers-reduced-motion: reduce) {
  .js .site-nav {
    transition: opacity 120ms linear;
  }

  .js .site-nav.is-open,
  .js .site-nav.is-closing,
  .js .site-nav.is-open > a,
  .js .site-nav.is-open .nav-contact a {
    animation: none;
  }

  .js .site-nav.is-open {
    opacity: 1;
  }

  .js .site-nav.is-closing {
    opacity: 0;
  }
}

/* --- Page head (inner pages) --------------------------------------------- */

.page-head {
  padding-top: clamp(var(--sp-10), 7vw, var(--sp-24));
  padding-bottom: var(--space-block);
}

/* Title on the line, year range / counter hard right */
.page-head__title-row {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--sp-6);
}

/* A year range must never break across two lines */
.page-head__title-row > .mono {
  flex: none;
  white-space: nowrap;
}

/* --- About teaser (home) -------------------------------------------------- */

/* On the site's six-column grid, exactly as the grid reference shows: the
   paragraph and the logos box both span 4 of 6, and the button below starts
   on the same left edge at its natural width. The old cap was `58ch`, which
   is not a character count — DM Sans's zero is wide, so it measured far
   longer than intended. The grid sets the measure now. */
.about-teaser__body {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  column-gap: var(--grid-gutter);
}

.about-teaser__body > * {
  grid-column: 1 / 5;
}

/* Its own step in the scale, not --fs-lead: that token is shared by the About
   page and every case-study overview, eight files, so moving it would resize
   all of them. Fluid like --fs-h1 — 36px is the ceiling, not a fixed size. */
.about-teaser__body > .lead {
  font-size: var(--fs-about-lead);
  line-height: 1.22;
  letter-spacing: -0.02em;
}

@media (max-width: 61.999rem) {
  .about-teaser__body {
    grid-template-columns: 1fr;
  }
  .about-teaser__body > * {
    grid-column: 1;
  }
}

/* The same gap the button gets below the block — one token, so the two spaces
   cannot drift apart. */
.about-teaser__logos {
  margin-top: var(--space-cta);
}

/* Subordinate to the section label in the rail: same mono caption style, but
   it sits on the content line directly above the box it names rather than
   becoming a second heading. */
.about-teaser__logos > .mono {
  margin-bottom: var(--sp-3);
}

/* Hairline box, the same structure as the facts strip and the projects grid:
   the container draws top and left, each cell draws right and bottom, so every
   line is drawn exactly once and the outer edges never double. */
.workplaces {
  display: grid;
  grid-template-columns: repeat(3, minmax(0, 1fr));
  border-top: 1px solid var(--color-line);
  border-left: 1px solid var(--color-line);
  border-radius: var(--radius-sm);
  overflow: hidden;
  list-style: none;
  padding: 0;
  margin: 0;
}

.workplaces li {
  display: flex;
  border-right: 1px solid var(--color-line);
  border-bottom: 1px solid var(--color-line);

  /* The cell is the container the logos size against, so they scale with it
     rather than with the viewport. Below about 62rem a third of the row is
     narrower than the Elementor lockup wants, and all three step down together
     instead of that one being squeezed on its own. */
  container-type: inline-size;
}

/* Not a link. These are a statement of where the work was done, not navigation
   off the site — and the <svg role="img" aria-label> on each already names the
   company, so nothing accessible was lost when the anchors came out. */
.workplaces__cell {
  display: flex;
  align-items: center;
  justify-content: center;
  width: 100%;
  padding: var(--sp-6) var(--sp-4);
  color: inherit;
}

/* Below the rail breakpoint a third of the row is narrow enough that the side
   padding is what caps the logos, not the design. Giving half of it back buys
   about 20% on all three — they are still in proportion, just less starved. */
@media (max-width: 61.999rem) {
  .workplaces__cell {
    padding: var(--sp-5) var(--sp-2);
  }
}

/* The three brand logos are INLINE <svg> in index.html, not <img>. That is the
   whole mechanism: an <img>-referenced SVG is a separate document, so its
   `currentColor` resolves against its own root and no CSS here can reach it —
   which is why these used to need filter hacks (grayscale on light, and
   brightness(0) invert(1) to knock them out on the dark band).

   Inlined, every path inherits `color`. Nothing below sets one, so they take
   the ground's own text colour: on .ground-ink that is --ink-on-dark, and if
   the About ground ever changes again the logos follow it with no edit here.
   Three different brand colours read as one line, by construction.

   Normalised to one optical height rather than one box height — the three
   lockups have different cap heights and aspect ratios, so matching their
   boxes would make them look mismatched. */
/* ONE BOX HEIGHT IS NOT ONE OPTICAL SIZE, which is what this used to assume.
   Each lockup carries a different amount of air inside its own viewBox, so a
   shared `height` sized the BOXES alike and the letterforms nothing alike.
   Rasterised at 600px and measured, the letterform height as a fraction of the
   box is:

     Elementor  0.483   lowercase, and the box also holds the circular mark
     Melio      0.553   lowercase
     Wix        0.995   all caps, box is tight to the glyphs

   At the old uniform 1.25rem that rendered as 9.7 / 11.1 / 19.9px — Wix twice
   the size of Elementor, which is exactly the complaint.

   So each is sized to land on one letterform height instead. x-height for the
   two lowercase marks; for all-caps WIX, cap height at 1.35x the x-height,
   which is where caps sit against lowercase in most faces — matching its caps
   TO an x-height would make it look shrunken. Heights below are that target
   divided by each mark's own fraction. Re-measure before changing one: the
   numbers are consequences, not preferences. */
.workplaces__logo {
  width: auto;
  opacity: 0.62;
  /* Never shrink. A flex item that shrinks keeps its declared HEIGHT while its
     box narrows, and preserveAspectRatio then letterboxes the artwork inside
     that box — so the logo silently renders smaller than it says it is. That
     is what put Elementor at a 5px letterform on a 320 screen while Melio held
     11px: only the widest lockup was being squeezed. */
  flex-shrink: 0;
}

/* Elementor is the binding one: 400:70 is by far the widest lockup, so what it
   can fit is what the row can fit. --logo-h is its optical height capped by the
   space actually available, and the other two are fixed ratios of it — so the
   three step down in proportion and the balance holds at every width. */
.workplaces li {
  --logo-h: min(1.43rem, calc((100cqw - 2 * var(--sp-4)) / 5.714));
}

/* Must come AFTER the line above, not beside the padding it pairs with: same
   specificity, so source order is what decides, and declaring it earlier meant
   the wider padding kept winning and the logos never grew. */
@media (max-width: 61.999rem) {
  .workplaces li {
    --logo-h: min(1.43rem, calc((100cqw - 2 * var(--sp-2)) / 5.714));
  }
}

.workplaces__logo--elementor {
  height: var(--logo-h);
}

/* 1.25 / 1.43 */
.workplaces__logo--melio {
  height: calc(var(--logo-h) * 0.874);
}

/* 0.94 / 1.43 */
.workplaces__logo--wix {
  height: calc(var(--logo-h) * 0.657);
}

/* --- Section CTA ---------------------------------------------------------- */
/* The shared closing-action pattern: a button BELOW the whole block, on the
   content line, reading as a summary action for the section rather than as
   something attached to the last element in it. Used by the About teaser,
   Selected projects and the next-project nav. */

/* margin-top, not the `margin` shorthand: this file loads after base.css, so a
   shorthand here resets margin-left and silently cancels .rail-offset — which
   is exactly what had been holding the "All projects" button at the container
   edge instead of on the content line. */
.section-cta {
  margin-top: var(--space-cta);
}

/* Outlined at rest, filled on hover — and the fill arrives as a circular wipe
   out of the top-left corner, which is the hero shape's entrance at button
   scale (--btn-fill-ease IS --hero-in-shape-ease).

   Three colours, set per ground rather than per class, so a CTA can move
   between sections without being restyled:
     --btn-ink      border, label and icon at rest
     --btn-fill     what the wipe paints
     --btn-on-fill  label and icon once the fill is under them
   The light-ground case is the default, since the page is light nearly
   everywhere; .ground-ink swaps all three below. */
.btn {
  --btn-ink: var(--color-dark);
  --btn-fill: var(--color-dark);
  --btn-on-fill: var(--color-light);

  position: relative;
  /* The fill is a negative-z-index pseudo-element, which needs a stacking
     context of its own here or it would sink behind the section's ground and
     never be seen. Isolating also puts it in front of the button's own border,
     which is what lets the fill cover that border rather than leave a hairline
     of the resting ink ringing the filled state. */
  isolation: isolate;
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  /* One pixel out of each edge pays for the border, so adding the outline did
     not grow the button: the filled hover state is the same box the old solid
     button was. */
  padding: calc(var(--sp-3) - 1px) calc(var(--sp-5) - 1px);
  border: 1px solid var(--btn-ink);
  border-radius: var(--radius-sm);
  background: transparent;
  color: var(--btn-ink);
  font-family: var(--font-sans);
  /* The body step, like every other piece of running text it sits beside. */
  font-size: var(--fs-body);
  line-height: 1;
  transition: color var(--btn-ink-dur) var(--ease);
}

/* The border keeps the resting ink through the whole hover — it is never seen
   against the fill, because the fill covers it, and holding it still means the
   part the wipe has not reached yet still reads as the outlined button rather
   than flashing to the hover colour ahead of the fill. */

.btn::before {
  content: "";
  position: absolute;
  /* Out over the border, not inset from it */
  inset: -1px;
  z-index: -1;
  border-radius: calc(var(--radius-sm) + 1px);
  background: var(--btn-fill);
  clip-path: circle(0% at 0% 0%);
  transition: clip-path var(--btn-fill-out) var(--ease);
}

.btn:hover,
.btn:focus-visible {
  color: var(--btn-on-fill);
}

.btn:hover::before,
.btn:focus-visible::before {
  clip-path: circle(var(--btn-fill-reach) at 0% 0%);
  transition-duration: var(--btn-fill-dur);
  transition-timing-function: var(--btn-fill-ease);
}

/* Inherits the button's colour, so one icon serves both grounds and both
   hover states without a second fill being declared anywhere. */
.btn__icon {
  width: 1.125rem;
  height: 1.125rem;
  flex: none;
}

/* The dark bands. --ink-on-dark rather than plain white, matching every other
   piece of text down here. There is no accent in either hover any more: the
   brief is light-on-dark and dark-on-light, and --color-accent was invisible
   against this band anyway (1.88:1 — it IS this band, lightened). */
.ground-ink .btn {
  --btn-ink: var(--ink-on-dark);
  --btn-fill: var(--ink-on-dark);
  --btn-on-fill: var(--color-dark);
}

@media (prefers-reduced-motion: reduce) {
  .btn,
  .btn::before,
  .btn:hover::before,
  .btn:focus-visible::before {
    transition: none;
  }
}

/* --- Projects page gallery ------------------------------------------------ */
/* Whitespace, not rules. This was a hairline box grid — the container drawing
   its top and left, each cell its right and bottom, zero gap — and it is now
   separated by the spacing scale alone: the between-items step in both axes,
   40 on small screens and 56 on the desktop grid, the same pair the Selected
   projects rows use. The cover rectangle is the only boxed thing left.

   The cover's background is NOT set here beyond what was already there. Every
   cover ships its own ground baked into the file; --color-plate is the
   fallback for the one export that is transparent. */

.project-grid {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--gallery-row-pad);
}

/* Two columns, so a future 6th project completes a third row. The column gap is
   the same step as the row gap — at this width the grid reads as a field of
   equal air, and a narrower gutter would bond the pairs into rows.

   67rem, not the 48rem this used to split at, and again the number is measured.
   Halving the grid halves the card, and the stacked meta line — company, year
   AND content type in one string — needs 364px. A card is `0.4 x viewport - 60`
   once the rail and this gap are taken out, so 364px of card arrives at a
   1060px window. Below that the cards stay full width, where the line has
   room. At the old 48rem the card was 333px and the line wrapped. */
@media (min-width: 67rem) {
  .project-grid--two {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  /* Two lines reserved for the tagline, so the cards in a row come out the
     same height. Measured: from 67rem every tagline is exactly two lines.
     A floor rather than a line-clamp on purpose — a tagline that ever needs a
     third line grows the card instead of losing the end of its sentence.
     Below 67rem the cards stack, so their heights don't meet, and on phones
     the taglines need three lines anyway. 2 x the 1.5 line-height. */
  .project-grid--two .project-card__desc {
    min-height: 3em;
  }

  /* The caption on 90% of the card. On hover it slides --caption-shift (16)
     into the plate without narrowing, which put the longest lines on — or
     past — the frame's right edge. At 90% they clear it by 36–49 from 67rem
     up, and every tagline still sets in two lines. Grid only: stacked and on
     phones there is no hover to clear, and the lost width cost a line. */
  .project-grid--two .project-card__col {
    width: 90%;
  }
}

.project-grid > li {
  display: flex;
}

.project-card {
  display: flex;
  flex-direction: column;
  width: 100%;
  color: inherit;
}

.project-card__media {
  position: relative;
  overflow: hidden;
  background: var(--color-plate);
  border-radius: var(--radius-lg);
  aspect-ratio: var(--cover-aspect);
}

/* Each project's cover ground, set where the project is named: on its card,
   which is a link to it (home and /projects), and on its own case-study page,
   whose <body> carries data-project. The colours are in tokens.css. */
[href$="/projects/connect-domain"],
[data-project="connect-domain"] {
  --cover-ground: var(--cover-connect-domain);
}
[href$="/projects/ai-security-scanner"],
[data-project="ai-security-scanner"] {
  --cover-ground: var(--cover-ai-security-scanner);
}
[href$="/projects/approval-workflows"],
[data-project="approval-workflows"] {
  --cover-ground: var(--cover-approval-workflows);
}
[href$="/projects/batch-combined-payments"],
[data-project="batch-combined-payments"] {
  --cover-ground: var(--cover-batch-combined-payments);
}
[href$="/projects/recurring-payments"],
[data-project="recurring-payments"] {
  --cover-ground: var(--cover-recurring-payments);
}

/* A transparent cover: the frame supplies the ground. */
.project-card__media--tint {
  background: var(--cover-ground);
}

.project-card__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  transition: transform var(--dur-slow) var(--ease);
}

.project-card:hover .project-card__media img,
.project-card:focus-visible .project-card__media img {
  transform: scale(1.018);
}

/* Cover to caption: the same 24 the card used to carry as padding. */
.project-card__body {
  display: flex;
  flex-direction: column;
  gap: var(--sp-2);
  margin-top: var(--sp-6);
}

/* Company and year, the caption's quiet line. */
.project-card__meta {
  font-family: var(--font-mono);
  font-size: var(--fs-meta);
  color: var(--color-muted);
  letter-spacing: 0.01em;
}

/* The tagline, a step below the name. Same relationship the home page's
   gallery card has between its name and its description — the gallery card is
   a two-tier thing now, not a one-line label. */
.project-card__desc {
  /* No margin-top: the column's gap owns every step. */
  font-size: var(--fs-small);
  line-height: 1.5;
  /* Ink, as the name above it. Muted, it made the caption read as alternating
     stripes — mono grey, sans ink, sans grey, mono ink — and it is the claim,
     not metadata. The home card's one-liner has always been ink. */
  color: var(--color-text);
}

.project-card__name {
  font-size: var(--fs-card-name);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-normal);
  transition: color var(--dur-fast) var(--ease);
}

.project-card:hover .project-card__name,
.project-card:focus-visible .project-card__name {
  color: var(--color-accent);
}

/* The shared .tags component carries a 16px top margin for the Selected
   projects rows. Here the column gap already does that work, and the two would
   stack. Two classes, because .tags is defined later in this file and would
   otherwise win on source order. */
/* One of two deliberate exceptions to the single step (the other is the call
   to action, below). The tags are a different kind of thing from the three
   lines of type above them, so they get 12 on top of the base --sp-1 — 16 in
   total. These are the only places in the card where a margin is allowed to
   add to the gap, and they are added on purpose. */
.project-card__body .project-card__tags {
  margin-top: var(--sp-3);
}

/* The caption stacks at every width — company and year, name, tagline, tags,
   then the call to action. */
.project-card__col {
  display: flex;
  flex-direction: column;
  /* ONE step between every element in the card — company/year, name, tagline,
     tags. The gap is the only thing that sets it, so nothing can accumulate:
     a child adding its own margin-top lands on top of this and produces a
     step nobody asked for. That is exactly what the tagline did at 8 + 12. */
  gap: var(--sp-1);
  min-width: 0;
}

/* The call to action — "View case study" or "Get a sneak peek", by depth — is
   the home cards' link (.home-card__cta), the footer's contact link at body
   size: underlined at rest, the line and an arrow on the card's hover. A span,
   because the card is already the link. The second deliberate margin: 20 on
   the base step, 24 in all, the same gap the home card keeps above its own. */
.project-card__cta {
  align-self: flex-start;
  margin-top: var(--sp-5);
  font-size: var(--fs-body);
}

/* --- Selected projects (home page) ---------------------------------------- */
/* One project per row on the same six columns the About teaser uses: the image
   spans 4 of 6 and the text block the remaining 2, so the image's left edge
   lands on exactly the line the About paragraph starts on. The list is the
   rail's second cell, which is what ties the two together — a .rail-offset
   block would only look aligned.

   A hairline closes every row, the last one included; that rule is what
   separates the list from the button below it.

   Plain scroll: no stagger, no parallax, no pinned reveal. A scroll effect can
   later be hung on .gallery-card or on each <li> without touching any of the
   structure here. */

.gallery {
  display: grid;
  grid-template-columns: 1fr;
}

/* The whole row is the link, padding and rule included, so the hit area is the
   row rather than just the image and the words. */
.gallery-card {
  display: grid;
  grid-template-columns: 1fr;
  gap: var(--sp-5);
  padding-bottom: var(--gallery-row-pad);
  color: inherit;
  /* The only affordance. Nothing moves and nothing is drawn on hover — the
     custom arrow cursor is the whole signal, which is why no arrow is set in
     the layout. Falls back to `pointer` where the SVG cursor is refused. */
  cursor: var(--cursor-arrow);
}

/* The hairline sits at the TOP of rows 2 and 3 rather than the bottom of every
   row, so each row owns the rule that introduces it. That is what lets a row
   carry its own rule up over the row it covers when the list stacks; a rule
   belonging to the row underneath would be left behind. The last row still
   closes the list, which is what separates it from the button below. */
.gallery > li + li .gallery-card {
  padding-top: var(--gallery-row-pad);
  border-top: 1px solid var(--color-line);
}

.gallery > li:last-child .gallery-card {
  border-bottom: 1px solid var(--color-line);
}

/* Two columns from the same width the rail resolves at, so the row splits and
   the label steps into the rail on one breakpoint rather than two. */
@media (min-width: 62rem) {
  .gallery-card {
    grid-template-columns: repeat(6, minmax(0, 1fr));
    column-gap: var(--grid-gutter);
  }

  .gallery-card__media {
    grid-column: 1 / 5;
    /* Never stretched by the grid: a text block taller than the image would
       otherwise pull the media box past its aspect ratio and re-crop the
       cover. The row grows instead, and the tags follow it down. */
    align-self: start;
  }

  .gallery-card__body {
    grid-column: 5 / 7;
  }
}

.gallery-card__media {
  overflow: hidden;
  background: var(--color-plate);
  border-radius: var(--radius-sm);
  aspect-ratio: var(--cover-aspect);
}

.gallery-card__media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* Stretched to the image's height by the grid, so the tags below can take the
   slack and land on the image's bottom edge. */
.gallery-card__body {
  display: flex;
  flex-direction: column;
}

.gallery-card__meta {
  font-family: var(--font-mono);
  font-size: var(--fs-caption);
  letter-spacing: 0.01em;
  color: var(--color-muted);
}

.gallery-card__name {
  margin-top: var(--sp-3);
  font-size: var(--fs-card-title);
  font-weight: var(--fw-medium);
  /* Tighter tracking and leading, as larger type wants */
  line-height: 1.15;
  letter-spacing: var(--ls-tight);
}

.gallery-card__desc {
  margin-top: var(--sp-3);
  font-size: var(--fs-small);
  line-height: 1.5;
  /* No measure cap: the two-column cell is the measure. The old one was 34ch,
     which is not 34 characters — see CLAUDE.md. */
}

/* `auto` is what puts them on the image's bottom edge; the padding is the floor
   for when there is no slack to take — a stacked row, or a title long enough to
   fill the column. */
.gallery-card__body > .tags {
  margin-top: auto;
  padding-top: var(--sp-6);
}

/* --- Selected projects: the stacked scroll --------------------------------
   Each project comes to rest under the header and the next one rises over it,
   so three projects read as a short deck instead of a long scroll. No shadow,
   no dimming, no movement beyond the scroll itself — the covering is the whole
   effect.

   Three things make it work, and each is load-bearing:

   1. The list is block flow, not grid. A sticky GRID item is confined to its
      own row track, so it could never travel over the row above it. In block
      flow the <ul> is the containing block and the rows can overlap. The grid
      was one column with no gap, so this changes nothing visually.

   2. Every row is opaque. Without a background the row underneath shows
      straight through the one passing over it.

   3. Rows 2 and 3 rest 57px higher than row 1 — their hairline and the gap
      above it scroll PAST the stick point rather than resting on it, so what
      comes to rest on the label's line is the content, not the rule
      introducing it. 57 is --gallery-row-pad plus the 1px rule.

   The last row needs no rule of its own: it sits flush with the bottom of the
   <ul>, so its sticky range is zero and it simply scrolls, which is what
   releases the section back to normal scrolling.

   Below 62rem the rail collapses and the label no longer sits beside the list,
   so there is no line to come to rest on and the list scrolls plainly. */

@media (min-width: 62rem) {
  .gallery {
    display: block;
  }

  #projects .rail > h2,
  .gallery > li {
    position: sticky;
    top: var(--stack-top);
  }

  .gallery > li {
    background: var(--ground-white);
  }

  .gallery > li + li {
    top: calc(var(--stack-top) - var(--gallery-row-pad) - 1px);
  }
}

/* No override here any more. --space-cta is a flat 56 at every width — the
   between-sections step — and all three CTAs on the site take it. */

/* --- Tags ----------------------------------------------------------------- */

.tags {
  display: flex;
  flex-wrap: wrap;
  gap: var(--sp-2);
  margin-top: var(--sp-4);
}

/* --radius-sm, like every other small control on the site — these were the one
   thing drawn as pills, which is what the wireframe showed but not what the
   rest of the site does. */
.tag {
  padding: 0.3em 0.8em;
  border-radius: var(--radius-sm);
  background: var(--color-chip);
  font-family: var(--font-mono);
  font-size: var(--fs-tag);
  letter-spacing: 0.01em;
  color: var(--color-dark);
  line-height: 1.4;
}

/* --- Contact -------------------------------------------------------------- */

/* Project pages only: two sentences above the headline, naming the project
   just read (build/components.py, contact_lead).

   Size: --fs-lead, the site's lead-in size and the contact links' size, so the
   block is one headline with supporting text at one size either side of it.
   A size of its own (briefly --type-2) read as a near miss of body, link and
   headline at once.

   Colour: ink, like every other piece of reading text on the site. Muted is
   for 14px labels and captions; on this it made the sentence read as the
   "Contact" label's metadata rather than as something said to the visitor.

   Breaks: each sentence is a block, so the line breaks fall between the two
   sentences wherever both fit, never at "I'd / be". A sentence that has to
   wrap is balanced, and the project name never splits. Same measure as the
   headline, so the two share a right edge as well as a left one. 24 below,
   the label's own step, so label, lead and headline read as one group. */
.contact__lead {
  max-width: 66%;
  margin-bottom: var(--sp-6);
  font-size: var(--fs-lead);
  line-height: 1.4;
}

.contact__lead > span {
  display: block;
  text-wrap: balance;
}

.contact__name {
  white-space: nowrap;
}

/* Headline then links, both on the line — per the wireframe */
.contact__grid {
  display: grid;
  gap: var(--space-block);
  justify-items: start;
}

/* Four of six, as the case-study titles are — but written as a percentage,
   because there is no grid here to span and one would be redundant for a
   single element. 66% is what 4 columns plus 3 gutters works out to against
   the content width: 65.2% at 992, 65.7% at 1440, 65.9% at 1920. The gutter
   is fixed so the true figure drifts under a point across that whole range.

   NOT `ch`, which this was: DM Sans's zero is much wider than its average
   glyph, so a `ch` measure never means what it says. */
.contact__headline {
  max-width: 66%;
}

/* Below the rail breakpoint it runs full width, the same way
   .cs-overview__title drops to 1 / -1. */
@media (max-width: 61.999rem) {
  .contact__headline,
  .contact__lead {
    max-width: none;
  }
}

.contact__links {
  display: grid;
  gap: var(--sp-3);
  font-size: var(--fs-lead);
  justify-items: start;
}

/* Underlined at rest. On hover an icon slides out of zero width, and because
   the rule is a border on the link itself it extends to cover the icon —
   the underline grows rather than a second one appearing. */
.contact-link {
  display: inline-flex;
  align-items: center;
  padding-bottom: 0.1em;
  border-bottom: 1px solid currentColor;
  color: var(--link-ink);
  transition: color var(--dur-fast) var(--ease);
}

.contact-link:hover,
.contact-link:focus-visible,
/* The project cards, home and /projects, use this link as their call to
   action. The card is the link there, so hovering or focusing anywhere on it puts this one
   in its hover state — the same declarations, not a copy of them. */
.home-card:hover .contact-link,
.home-card:focus-visible .contact-link,
.project-card:hover .contact-link,
.project-card:focus-visible .contact-link {
  color: var(--link-hover);
}

.contact-link__icon {
  display: inline-flex;
  align-items: center;
  width: 0;
  margin-left: 0;
  opacity: 0;
  overflow: hidden;
  transition: width var(--dur) var(--ease), margin-left var(--dur) var(--ease),
    opacity var(--dur) var(--ease);
}

.contact-link:hover .contact-link__icon,
.contact-link:focus-visible .contact-link__icon,
.contact-link.is-copied .contact-link__icon,
.home-card:hover .contact-link__icon,
.home-card:focus-visible .contact-link__icon,
.project-card:hover .contact-link__icon,
.project-card:focus-visible .contact-link__icon {
  width: 0.95em;
  margin-left: 0.45em;
  opacity: 1;
}

.contact-link__icon svg {
  flex: none;
  width: 0.95em;
  height: 0.95em;
}

/* Copy swaps to a tick once the address is on the clipboard */
.contact-link .i-check {
  display: none;
}

.contact-link.is-copied .i-copy {
  display: none;
}

.contact-link.is-copied .i-check {
  display: block;
}

.contact-link.is-copied {
  color: var(--color-accent);
}

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

/* --- Footer (contact + close, one component) --------------------------------
   One block at the bottom of every page: the contact pitch, a hairline, and a
   quiet closing row. The nav's "Contact" is an anchor to #contact on this
   element, which is why it works from any page — smooth scrolling is already
   global in base.css, with a reduced-motion override.

   Its ground is named here rather than inherited, so it never depends on which
   section happens to sit above it. */
.site-footer {
  position: relative;
  isolation: isolate;
  /* The bottom padding clears the band rather than being a round number:
     everything readable stays above the gradient, which is the whole point of
     the shape. Derived, so resizing the band moves the content with it. */
  /* Exactly --fc-h: the closing row below is given the strip's own height and
     centres itself in it, so the padding only has to clear the lower band. */
  padding-block: var(--about-band-pad) var(--fc-h);
  background: transparent;
}

/* The closing row sits in the strip of ground that survives to the LEFT of the
   raised step — between the raised band's top edge and the lower band's — at
   every width, narrow included. That only works while the step leaves enough
   room beside it, which is what --fc-w's narrow override guarantees below. */

/* The ground, with the corner taken out of it. Clipping this layer rather than
   the footer means the content above is untouched by the clip. */
.site-footer__plate {
  position: absolute;
  inset: 0;
  z-index: -1;
  /* White, as the hero: everything that touches the gradient stands on
     white. The section above the footer is never white (tokens.css). */
  background: var(--ground);

  /* The cut, built from points so it stays correct at any width — no fixed
     asset for the shape, only for the gradient behind it.

     A polygon has no radii, so each junction where the diagonal meets a footer
     edge is a real circular fillet of radius --fc-r, sampled into six segments.
     The points are generated, not placed by hand: tangent distance
     t = r / tan(theta/2), centre at r / sin(theta/2) along the angle bisector,
     then the arc walked in equal steps. Six segments across a ~57 degree turn
     is under 10 degrees each, past the point the eye reads a curve as faceted.

     The offsets are fixed px because they scale with --fc-r rather than with
     the corner. The fillet is solved for the corner's nominal 140x90, and
     since --fc-w and --fc-h clamp at slightly different rates the tangent
     angle drifts a little at small widths — well under a pixel at r = 10. */
  clip-path: polygon(
    0 0,
    100% 0,
    calc(100%) calc(100% - var(--fc-h2) - 12.00px),
    calc(100% - 0.41px) calc(100% - var(--fc-h2) - 8.89px),
    calc(100% - 1.61px) calc(100% - var(--fc-h2) - 6.00px),
    calc(100% - 3.51px) calc(100% - var(--fc-h2) - 3.51px),
    calc(100% - 6.00px) calc(100% - var(--fc-h2) - 1.61px),
    calc(100% - 8.89px) calc(100% - var(--fc-h2) - 0.41px),
    calc(100% - 12.00px) calc(100% - var(--fc-h2)),
    calc(100% - var(--fc-w) + 12.00px) calc(100% - var(--fc-h2)),
    calc(100% - var(--fc-w) + 8.89px) calc(100% - var(--fc-h2) + 0.41px),
    calc(100% - var(--fc-w) + 6.00px) calc(100% - var(--fc-h2) + 1.61px),
    calc(100% - var(--fc-w) + 3.51px) calc(100% - var(--fc-h2) + 3.51px),
    calc(100% - var(--fc-w) + 1.61px) calc(100% - var(--fc-h2) + 6.00px),
    calc(100% - var(--fc-w) + 0.41px) calc(100% - var(--fc-h2) + 8.89px),
    calc(100% - var(--fc-w)) calc(100% - var(--fc-h2) + 12.00px),
    calc(100% - var(--fc-w)) calc(100% - var(--fc-h) - 12.00px),
    calc(100% - var(--fc-w) - 0.41px) calc(100% - var(--fc-h) - 8.89px),
    calc(100% - var(--fc-w) - 1.61px) calc(100% - var(--fc-h) - 6.00px),
    calc(100% - var(--fc-w) - 3.51px) calc(100% - var(--fc-h) - 3.51px),
    calc(100% - var(--fc-w) - 6.00px) calc(100% - var(--fc-h) - 1.61px),
    calc(100% - var(--fc-w) - 8.89px) calc(100% - var(--fc-h) - 0.41px),
    calc(100% - var(--fc-w) - 12.00px) calc(100% - var(--fc-h)),
    0 calc(100% - var(--fc-h))
  );
}

/* Behind the cut, spanning the footer and as tall as the raised step, so the
   whole stepped opening is filled by one image. `cover` keeps the export's own
   2880x1800 proportions — the band is much wider than that, so it crops the
   top and bottom rather than stretching. */
.site-footer__gradient {
  position: absolute;
  left: 0;
  right: 0;
  bottom: 0;
  z-index: -2;
  width: 100%;
  height: var(--fc-h2);
  object-fit: cover;
  object-position: center bottom;
  pointer-events: none;
}

.site-footer__inner {
  position: relative;
  z-index: 1;
}

/* The quiet closing row. No rule above it — the space does the separating,
   as it does between sections elsewhere. Padded clear of the corner so the two
   never crowd — the reserve is the corner's own width plus a gap, so it
   tracks the corner instead of being a number guessed against it. */
.site-footer__close {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--sp-4) var(--sp-8);
  /* The contact block ends where the footer's own area begins, so it gets a
     clear gap rather than running down to meet the row. */
  margin-top: calc(var(--space-block) * 1.5);
  /* The row IS the strip beside the raised step — it takes that strip's height
     and centres its content in it, instead of being placed near its edge by a
     padding value that had to be re-guessed every time the band moved. */
  min-height: var(--fc-strip);
}

/* Two credits, one shown at a time. `display: none` rather than clipping or
   visually-hidden: the other line is not an alternative reading of the same
   text, it is a different sentence, and only one should reach a screen reader.
   Both carry [data-year], which site.js fills in for every match. */
.site-footer__credit--wide {
  display: none;
}

/* 68rem, not the rail's 62rem. The long credit is ~55 characters of mono, and
   at 992 the cell it sits in is squeezed from both sides — the rail takes 20%
   off the left and the step's reserve takes --fc-w off the right — leaving
   about 470px for ~462px of text. It wrapped to two lines inside a 49px strip.
   This is the width where it has real room. */
@media (min-width: 68rem) {
  .site-footer__credit--wide {
    display: inline;
  }

  .site-footer__credit--narrow {
    display: none;
  }
}

/* The reserve that keeps the copyright clear of the raised step lives on the
   copyright, not on the row. --rail is a PERCENTAGE of the row's content box,
   so padding on the row shrank the basis and put the second cell at 239 rather
   than on the content line at 312. */
.site-footer__close > p {
  padding-right: calc(var(--fc-w) + var(--sp-8));
}

/* Desktop: "Top" belongs in the rail, on the same vertical line as every other
   label on the page — and specifically as "Scroll" in the hero, which it
   answers. The copyright stays on the content line with everything else.
   The element carries no rail-offset for this reason: it spans the full width
   and places its own two cells. */
@media (min-width: 62rem) {
  .site-footer__close {
    display: grid;
    grid-template-columns: var(--rail) minmax(0, 1fr);
    align-items: center;
    gap: 0;
  }
}

@media (max-width: 47.999rem) {
  /* Too narrow to hold both the reserve and the text; the step shrinks instead
     and the row sits beside it. */
  .site-footer__close > p {
    padding-right: 0;
  }
}

/* At the very bottom of the range the row and the step compete for one line.
   Tighten the row rather than stack it — "Top" stays beside the copyright. */
@media (max-width: 22.5rem) {
  .site-footer__close {
    gap: var(--sp-4);
  }
}


.to-top {
  flex: none;
  display: inline-flex;
  align-items: center;
  gap: var(--sp-2);
  font-family: var(--font-mono);
  font-size: var(--fs-label);
  letter-spacing: var(--ls-label);
  color: var(--color-muted);
  transition: color var(--dur) var(--ease);
}

.to-top:hover,
.to-top:focus-visible {
  border-color: var(--color-line-strong);
  color: var(--color-accent);
  transform: translateY(-2px);
}

/* --- About page ----------------------------------------------------------- */

/* The About page lays its copy on the same six-column grid the case studies
   use, so the two pages read as one system. The prose spans FOUR of six rather
   than the three a case-study paragraph gets: nothing competes with it here —
   no figures, no tables sharing the row — so it can afford the wider measure.
   The 70% inset is the site's usual rule, unchanged.

   The max-width is the part that matters. Span-4-at-70% is proportional, so on
   its own it keeps growing: 69 characters at 1440, but 94 at 1920 and 119 at
   2400 — the very problem this was meant to fix. Capping at the prose measure
   holds it between 58 and 70 across every width. */
.about-body {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  row-gap: var(--grid-row-gap);
  column-gap: var(--grid-gutter);
  align-content: start;
}

.about-body > * {
  grid-column: 1 / -1;
}

/* Full 4 of 6, not the 70%-of-4 it used to be. That cap existed when the
   About page was one undifferentiated sheet and the prose had nothing to line
   up with; now the Experience entries below span the same four columns, so the
   two read as one column line down the page. */
.about-body > .lead {
  grid-column: 1 / 5;
  width: 100%;
  max-width: none;
  font-size: var(--fs-body);
}

@media (max-width: 61.999rem) {
  .about-body {
    row-gap: var(--sp-5);
  }

  .about-body > .lead {
    grid-column: 1 / -1;
    width: 100%;
  }
}

.about-body strong {
  font-weight: var(--fw-semibold);
}

/* --- About page bands ------------------------------------------------------
   The case-study band rhythm, borrowed: --cs-band-pad top and bottom, so the
   space between two grounds is twice that — 40px each at narrow, 80px each
   from ~1450px up, giving the 160px gap where the colour changes.

   (The brief called this "the pattern on the Projects page". That page has no
   alternating grounds; the pattern with these exact numbers is the case
   studies' .cs-section. Same token, so the two stay in step.)

   The page head and the bio share --ground, which IS the page colour, so they
   are one section to the eye. --continues drops the top padding between them —
   the head already sets that space, and doubling it would open a 136px hole
   inside a single band. */
.about-band {
  padding-block: var(--about-band-pad);
}

.about-band--continues {
  padding-top: 0;
}

/* --- Experience ----------------------------------------------------------- */
/* A square logo plate, then company/years, role and a one-paragraph summary.
   No rules between entries — the spacing separates them, as elsewhere. */

/* The only thing separating one role from the next is this gap — there are no
   rules between entries — so it has to be big enough to group each logo with
   its own description rather than with the row below. */
/* The only thing separating one role from the next is this gap — there are no
   rules between entries — so it has to be big enough to group each logo with
   its own description rather than with the row below.

   The list is laid on the same six columns the bio above it uses, with the
   same gutter, so "4 of 6" means the identical left edge and width in both
   places rather than two measurements that merely look alike. */
.experience {
  display: grid;
  grid-template-columns: repeat(6, minmax(0, 1fr));
  column-gap: var(--grid-gutter);
  row-gap: clamp(4rem, 7vw, 6.5rem);
}

/* Stacked, not side by side: the identity row on top, the description beneath.
   Every entry is the same four columns — no per-entry variation.

   Two tracks inside that: the logo plate, then everything textual. The
   description sits in the second track, so it starts on the same line as the
   company, the role and the focus rather than running back out to the logo's
   edge. `auto` for the first track means the indent is the plate's real width
   plus the gap — it tracks the logo if that ever resizes, instead of being a
   number copied from it that would quietly go stale.

   `display: contents` on the lead is what makes it possible: it dissolves the
   wrapper so the logo, the identity block and the description all become items
   of the SAME grid and can share its columns. Without it the description could
   only align to the lead as a whole, not to the text inside it. */
.experience__item {
  grid-column: 1 / 5;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr);
  column-gap: var(--sp-6);
  align-items: start;
}

.experience__lead {
  display: contents;
}

.experience__item > .experience__body {
  grid-column: 2;
  margin-top: var(--sp-5);
}

/* Narrow screens: the entry spans the full width, exactly as .about-body
   collapses. The two-track structure inside it is unchanged — the description
   still starts on the identity line at every width. */
@media (max-width: 61.999rem) {
  .experience__item {
    grid-column: 1 / -1;
  }
}

/* Square on purpose: the logos vary in shape, the plate keeps the column tight */
/* Square brand tiles, full bleed. These are the companies' own square marks,
   so the plate IS the logo rather than a container holding one — the artwork
   runs edge to edge and the plate's own background only shows through where a
   tile has white in it (Wix). White, not --color-surface, for exactly that
   reason: Wix's tile has a white ground baked in, and a grey plate behind it
   would draw a visible seam around the wordmark. */
.experience__logo {
  display: grid;
  place-items: center;
  width: clamp(3rem, 4.5vw, 3.5rem);
  aspect-ratio: 1;
  background: var(--color-plate);
  border-radius: var(--radius-sm);
  overflow: hidden;
}

.experience__logo img {
  width: 100%;
  height: 100%;
  object-fit: cover;
}

/* The plate is a link to the company. These tiles are full-colour brand marks
   rather than the muted wordmarks on the home page, so there is no colour to
   bring up on hover — a slight lift does the job instead. */
a.experience__logo {
  transition: transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}

a.experience__logo:hover,
a.experience__logo:focus-visible {
  transform: translateY(-2px);
  box-shadow: var(--shadow-soft);
}

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

  a.experience__logo:hover,
  a.experience__logo:focus-visible {
    transform: none;
  }
}

.experience__meta {
  font-family: var(--font-mono);
  font-size: var(--fs-caption);
  letter-spacing: 0.01em;
  color: var(--color-muted);
}

.experience__role {
  margin-top: var(--sp-2);
  font-size: var(--fs-h3);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-normal);
}

/* Everything after the first comma in the role — the specialism. Body size, so
   it reads as a qualifier under the title rather than part of it. */
.experience__focus {
  margin-top: var(--sp-1);
  font-size: var(--fs-body);
  line-height: 1.4;
  color: var(--color-muted);
}

/* Same measure as the description above it — this was the page's other
   offender, running to 106 characters off that misleading `62ch`.
   No margin-top: that was for when this sat under the role. As a column it has
   to start level with the logo, and the stacked case gets its space from the
   row-gap instead. */
/* No measure cap. The entry's own four columns ARE the measure now, and the
   brief is explicit that the description matches the bio's width exactly — a
   504px cap on top of a 715px column would quietly break that alignment. */
.experience__body {
  max-width: none;
}
