/* Built from the Folio design system. Token values are unchanged. */

/* Self-hosted webfonts. Downloaded from Google Fonts so the site has no
   third-party runtime dependency. Latin + latin-ext subsets only. */
@font-face {
  font-family: 'Instrument Serif';
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url(../fonts/instrument-serif-italic-400-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Instrument Serif';
  font-style: italic;
  font-weight: 400;
  font-display: swap;
  src: url(../fonts/instrument-serif-italic-400-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Instrument Serif';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(../fonts/instrument-serif-normal-400-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Instrument Serif';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(../fonts/instrument-serif-normal-400-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(../fonts/jetbrains-mono-normal-400-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 400;
  font-display: swap;
  src: url(../fonts/jetbrains-mono-normal-400-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url(../fonts/jetbrains-mono-normal-400-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'JetBrains Mono';
  font-style: normal;
  font-weight: 500;
  font-display: swap;
  src: url(../fonts/jetbrains-mono-normal-400-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Schibsted Grotesk';
  font-style: italic;
  font-weight: 400 700;
  font-display: swap;
  src: url(../fonts/schibsted-grotesk-italic-400700-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Schibsted Grotesk';
  font-style: italic;
  font-weight: 400 700;
  font-display: swap;
  src: url(../fonts/schibsted-grotesk-italic-400700-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}
@font-face {
  font-family: 'Schibsted Grotesk';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url(../fonts/schibsted-grotesk-normal-400700-latin-ext.woff2) format('woff2');
  unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
}
@font-face {
  font-family: 'Schibsted Grotesk';
  font-style: normal;
  font-weight: 400 700;
  font-display: swap;
  src: url(../fonts/schibsted-grotesk-normal-400700-latin.woff2) format('woff2');
  unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
}

/* --- tokens/colors.css --- */
:root{
  /* Neutral ramp — cool-neutral paper through blue-black ink.
     n-500 / n-600 are the exact greys the portfolio site reaches for. */
  --n-0:#FFFFFF;
  --n-25:#FCFCFD;
  --n-50:#F6F7F9;
  --n-100:#F0F1F4;
  --n-150:#E7E9ED;
  --n-200:#DCDEE3;
  --n-300:#C3C6CD;
  --n-400:#9FA3AC;
  --n-500:#7C838F;
  --n-600:#6B7280;
  --n-700:#4B5261;
  --n-800:#2B303A;
  --n-900:#15181D;
  --n-950:#0C0E12;

  /* Accents — one primary, three gradient stops, one error red. All cool. */
  --azure-50:#EEF3F8;
  --azure-100:#DCE7F0;
  --azure-300:#9DB4CB;
  --azure-500:#4A6D93;
  --azure-600:#3C5A7B;
  --azure-700:#2E4762;

  /* Iridescent gradient stops — named so the signature gradient is
     composed of tokens, never literal hex. Order = gradient order. */
  --lagoon-100:#E6F0F2;
  --lagoon-500:#5C93A6;
  --lagoon-700:#3F6B7A;
  --periwinkle-100:#EAEDF7;
  --periwinkle-500:#7B8EC4;
  --periwinkle-700:#56679A;
  --lilac-100:#F0EDF6;
  --lilac-500:#9A8FC2;
  --lilac-700:#6F6496;

  --rose-100:#F7E9EC;
  --rose-500:#B05563;
  --rose-700:#8A3E4A;

  /* The signature gradient. Stops are azure → lagoon → periwinkle → lilac. */
  --gradient-iridescent:linear-gradient(96deg,var(--azure-500) 0%,var(--lagoon-500) 34%,var(--periwinkle-500) 68%,var(--lilac-500) 100%);
  --gradient-iridescent-soft:linear-gradient(96deg,var(--azure-300) 0%,var(--lagoon-500) 40%,var(--lilac-500) 100%);

  /* Iridescent dot ramp — ONE dataset, for the HeroField dot field only.
     Do not pick individual stops as UI colours; use the named accents above. */
  --irid-1:#5CA3B2;
  --irid-2:#76BAC7;
  --irid-3:#8AC9C3;
  --irid-4:#7CBEAE;
  --irid-5:#74ACD0;
  --irid-6:#8EB5D6;
  --irid-7:#809ABA;
  --irid-8:#96A6D6;
  --irid-9:#ACA8DC;
  --irid-10:#BCB1D6;
  --irid-11:#C6B3CB;

  /* ---- Semantic aliases ---- */
  --surface-canvas:var(--n-50);
  --surface-page:var(--n-25);
  --surface-card:var(--n-0);
  --surface-sunken:var(--n-100);
  --surface-inverse:var(--n-950);
  --surface-accent:var(--azure-500);
  --surface-accent-soft:var(--azure-50);

  --text-primary:var(--n-950);
  --text-secondary:var(--n-500);
  --text-muted:var(--n-600);
  --text-tertiary:var(--n-400);
  --text-inverse:var(--n-25);
  --text-accent:var(--azure-600);
  --text-error:var(--rose-500);
  --text-link:var(--n-950);
  --text-link-hover:var(--azure-600);

  --border-hairline:var(--n-150);
  --border-default:var(--n-200);
  --border-strong:var(--n-300);
  --border-inverse:rgba(255,255,255,.16);
  --border-focus:var(--azure-500);
  --border-error:var(--rose-500);

  --dot-grid:var(--n-300);
  --line-grid:var(--n-150);
  --scrim:rgba(12,14,18,.55);
  --glass:rgba(252,252,253,.72);
  --selection-bg:var(--lagoon-100);
}

/* --- tokens/typography.css --- */
:root{
  --font-display:"Schibsted Grotesk","Helvetica Neue",Helvetica,sans-serif;
  --font-sans:"Schibsted Grotesk","Helvetica Neue",Helvetica,sans-serif;
  --font-serif-accent:"Instrument Serif",Georgia,serif;
  --font-mono:"JetBrains Mono",ui-monospace,monospace;

  --fw-regular:400; /* @kind font */
  --fw-medium:500; /* @kind font */
  --fw-semibold:600; /* @kind font */
  --fw-bold:700; /* @kind font */

  /* Size scale (px) */
  --fs-micro:11px;
  --fs-tiny:12px;
  --fs-label:13px;
  --fs-body-xs:14px;
  --fs-body-s:15px;
  --fs-body:17px;
  --fs-body-l:20px;
  --fs-h3:24px;
  --fs-h3-l:27px;
  --fs-h2:32px;
  --fs-h2-l:34px;
  --fs-h1:40px;
  --fs-display-s:56px;
  --fs-display-m:72px;
  --fs-display-l:96px;
  --fs-display-xl:128px;

  /* Fluid display — the site's hero size. Scales on WIDTH, not vmin, so a
     short window doesn't collapse the headline while there's room to spare. */
  --fs-hero-fluid:clamp(28px,3.4vw,50px); /* @kind font */
  --fs-section-fluid:clamp(24px,2.4vw,34px); /* @kind font */

  --lh-tight:0.94; /* @kind font */
  --lh-display:1.02; /* @kind font */
  --lh-heading:1.12; /* @kind font */
  --lh-snug:1.14; /* @kind font */
  --lh-body:1.55; /* @kind font */
  --lh-prose:1.62; /* @kind font */
  --lh-loose:1.7; /* @kind font */

  --ls-display:-0.035em; /* @kind font */
  --ls-hero:-0.028em; /* @kind font */
  --ls-heading:-0.02em; /* @kind font */
  --ls-body:-0.01em; /* @kind font */
  --ls-label:0.09em; /* @kind font */

  /* Semantic type aliases */
  --type-hero:var(--fw-semibold) var(--fs-hero-fluid)/var(--lh-snug) var(--font-display);
  --type-display:var(--fw-bold) var(--fs-display-m)/var(--lh-tight) var(--font-display);
  --type-section:var(--fw-semibold) var(--fs-section-fluid)/var(--lh-heading) var(--font-display);
  --type-body:var(--fw-regular) var(--fs-body)/var(--lh-body) var(--font-sans);
  --type-label:var(--fw-medium) var(--fs-label)/1.2 var(--font-mono);
  --type-eyebrow:var(--fw-regular) var(--fs-micro)/1.2 var(--font-mono);
}

/* --- tokens/spacing.css --- */
:root{
  --sp-0:0px; --sp-1:2px; --sp-2:4px; --sp-3:8px; --sp-4:12px; --sp-5:16px;
  --sp-6:24px; --sp-7:32px; --sp-8:48px; --sp-9:64px; --sp-10:96px;
  --sp-11:128px; --sp-12:192px;

  --gutter:24px;
  --page-max:1440px;
  --measure-prose:62ch;
  --section-gap:var(--sp-11);
  --stack-gap:var(--sp-6);
  --dot-grid-size:26px;
  --nav-height:64px;
}

/* --- tokens/radius.css --- */
:root{
  --r-none:0px;
  --r-xs:4px;
  --r-sm:8px;
  --r-md:12px;
  --r-lg:20px;
  --r-xl:28px;
  --r-pill:999px;
  --r-card:var(--r-md);
  --r-media:var(--r-lg);
  --r-control:var(--r-pill);
}

/* --- tokens/elevation.css --- */
:root{
  --shadow-none:none;
  --shadow-hairline:0 0 0 1px var(--border-hairline);
  --shadow-xs:0 1px 2px rgba(11,11,10,.05);
  --shadow-sm:0 1px 3px rgba(11,11,10,.06),0 6px 14px -8px rgba(11,11,10,.08);
  --shadow-md:0 2px 6px rgba(11,11,10,.05),0 14px 30px -12px rgba(11,11,10,.12);
  --shadow-lg:0 4px 10px rgba(11,11,10,.05),0 30px 60px -20px rgba(11,11,10,.16);
  --shadow-inset-hairline:inset 0 0 0 1px var(--border-hairline);
  --shadow-pill:0 1px 2px rgba(11,11,10,.14),0 10px 22px -10px rgba(11,11,10,.35);
  --blur-glass:saturate(140%) blur(14px); /* @kind other */
}

/* --- tokens/motion.css --- */
:root{
  --ease-standard:cubic-bezier(.2,.6,.2,1); /* @kind other */
  --ease-entrance:cubic-bezier(.16,.84,.28,1); /* @kind other */
  --ease-exit:cubic-bezier(.4,0,1,1); /* @kind other */
  --dur-instant:80ms; /* @kind other */
  --dur-fast:160ms; /* @kind other */
  --dur-base:240ms; /* @kind other */
  --dur-slow:420ms; /* @kind other */
  --dur-slide:460ms; /* @kind other */
  --dur-reveal:720ms; /* @kind other */
  --dur-shimmer:34s; /* @kind other */
  --dur-marquee:60s; /* @kind other */
  --hover-lift:-2px;
  --press-scale:0.985; /* @kind other */
}

/* --- tokens/base.css --- */
*,*::before,*::after{box-sizing:border-box}
body{margin:0;background:var(--surface-page);color:var(--text-primary);font:var(--type-body);-webkit-font-smoothing:antialiased;text-rendering:optimizeLegibility}
h1,h2,h3,h4{margin:0;font-family:var(--font-display);letter-spacing:var(--ls-heading);font-weight:var(--fw-semibold)}
p{margin:0;text-wrap:pretty}
a{color:var(--text-link);text-decoration:none;transition:color var(--dur-fast) var(--ease-standard)}
a:hover{color:var(--text-link-hover)}
::selection{background:var(--selection-bg);color:var(--n-950)}
:focus-visible{outline:2px solid var(--border-focus);outline-offset:3px}
.folio-dotgrid{background-image:radial-gradient(var(--dot-grid) 1px,transparent 1px);background-size:var(--dot-grid-size) var(--dot-grid-size)}
.folio-linegrid{background-image:linear-gradient(var(--line-grid) 1px,transparent 1px),linear-gradient(90deg,var(--line-grid) 1px,transparent 1px);background-size:var(--dot-grid-size) var(--dot-grid-size)}

/* Iridescent utilities — the signature gradient, as text or as a hairline. */
.folio-gradient-text{background-image:var(--gradient-iridescent);-webkit-background-clip:text;background-clip:text;color:transparent;-webkit-text-fill-color:transparent}
.folio-gradient-border{padding:.75px;border-radius:var(--r-card);background:var(--gradient-iridescent)}
.folio-gradient-border>*{border-radius:calc(var(--r-card) - .75px);background:linear-gradient(rgba(255,255,255,.5),rgba(255,255,255,.5)),var(--surface-page)}

/* Shared motion vocabulary — declared once here so pages stop redefining it. */
@keyframes folioRise{from{opacity:0;transform:translateY(16px)}to{opacity:1;transform:none}}
@keyframes folioShimmer{to{transform:rotate(360deg)}}
@keyframes folioNudge{0%,62%,100%{transform:translateY(0)}28%{transform:translateY(5px)}}
@keyframes folioNudgeX{0%,62%,100%{transform:rotate(-90deg) translateY(0)}28%{transform:rotate(-90deg) translateY(5px)}}
@keyframes folioMarquee{from{transform:translateX(0)}to{transform:translateX(-50%)}}
.folio-rise{animation:folioRise var(--dur-reveal) var(--ease-standard) both}
@media (prefers-reduced-motion:reduce){
  *,*::before,*::after{
    animation-duration:.01ms !important;
    animation-iteration-count:1 !important;
    transition-duration:.01ms !important;
    scroll-behavior:auto !important;
  }
}


/* --- text contrast ----------------------------------------------------
   Both greys that carry most of the site's copy fell below the 4.5:1 that
   WCAG AA asks for against the page background (#FCFCFD). Each is moved to
   the next step on the same neutral ramp — the nearest value that passes, so
   the palette is unchanged and the shift is small.

     --text-secondary   was --n-500  #7C838F   3.72:1  (body copy, summaries)
     --text-tertiary    was --n-400  #9FA3AC   2.47:1  (eyebrows, captions)
     both now           --n-600  #6B7280       4.72:1

   To go back, delete this block. The originals are the two values above.
   -------------------------------------------------------------------- */
:root {
  --text-secondary: var(--n-600);
  --text-tertiary: var(--n-600);
}

/* --- site additions --------------------------------------------------- */

/* Keyboard users can jump past the nav. Visible only when focused. */
.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  padding: 10px 16px;
  background: var(--surface-card);
  color: var(--text-primary);
  border: 1px solid var(--border-default);
  border-radius: var(--r-control);
  font-family: var(--font-sans);
  font-size: 14px;
}
.skip-link:focus {
  left: 12px;
  top: 12px;
}

/* The design tool emitted unprefixed backdrop-filter only, and this file
   used to add the -webkit- twin with a blanket rule that hard-coded
   blur(9px). That is the header scrim's value; the selector matched every
   backdrop-filter on the site, so the puck's blur(26px) and blur(10px)
   layers were both served 9px on WebKit. build.py's mirror_backdrop_filter
   now writes the prefixed property per element from that element's own
   value, so there is nothing to do here. */

/* ======================================================================
   Narrow screens
   ----------------------------------------------------------------------
   The design is built for desktop and stays exactly as designed there.
   Everything below only applies under a breakpoint, so no desktop rule is
   affected. !important is needed because the export puts every layout
   value in an inline style attribute, which a stylesheet can't otherwise
   override.
   ====================================================================== */

/* Note: the case-study rows bleed past the right edge with
   calc(50% - 50vw). 100vw includes the scrollbar and 50% does not, so on a
   desktop browser with a classic scrollbar the page can scroll ~8px sideways.
   Clipping it at the html element removes the scroll but also widens the
   content by the scrollbar's width, which moves the whole desktop layout.
   Left alone deliberately; below 900px the bleed is removed outright. */

@media (max-width: 900px) {
  html {
    overflow-x: clip;
  }

  /* Two-column rails become one column. */
  [data-stack="rail"],
  [data-stack="hero"] {
    grid-template-columns: minmax(0, 1fr) !important;
    gap: 28px !important;
    margin-right: 0 !important;
    padding-left: 20px !important;
    padding-right: 20px !important;
  }
  [data-stack="rail"] > aside,
  [data-stack="rail"] > div:first-child {
    align-self: start !important;
    position: static !important;
  }

  /* The case-study image was a fixed 680px tall regardless of width, so at
     narrow widths it cropped a landscape screenshot into a vertical sliver.
     Track the width instead. */
  [data-hero-shot],
  [data-stack="rail"] a[style*="height: 680px"],
  [data-stack="rail"] div[style*="height: 680px"] {
    height: auto !important;
    aspect-ratio: 3 / 2 !important;
    border-radius: var(--r-media) !important;
    border-left-width: 1px !important;
    /* The panel is a grid item and the grid is align-items: stretch, so
       with height: auto it is asked to fill its row while the row is being
       sized from it — height depends on the row, the row depends on the
       height. Blink breaks that loop by using the aspect ratio; WebKit can
       resolve it to zero instead, which is a panel with a background image
       and no height to show it in: the image is loaded, painted into a 0px
       box, and reads as missing. Opting out of the stretch removes the
       circularity, so the height comes from the aspect ratio on both. */
    align-self: start !important;
  }

  /* Summaries were capped at 33ch to suit a 240px rail. */
  [data-stack="rail"] p {
    max-width: none !important;
  }
}

/* ---- the landing page's case study list, narrow screens ---------------
   Three faults, all of them desktop numbers surviving into a 393px screen.

   Space: each row carries `padding: 112px 0`, symmetrical so the hairline
   between two projects lands exactly halfway (see PROJECT_GAP in
   build.py). Symmetrical is still right; 112 is not. Two of them stack to
   224px of empty page between one project and the next, which on a phone
   is most of a screenful of nothing — enough that the case study images
   read as missing rather than as further down. Halved, the hairline still
   sits centred and a project and its image stay on screen together.

   Width: the section has 32px gutters and the 900px block above adds 20px
   of its own to each row, so the rows were inset 52px — 289px of usable
   width out of 393. The row's padding goes; the section's becomes 20px,
   matching main elsewhere in this file.
   -------------------------------------------------------------------- */
@media (max-width: 720px) {
  section#work {
    padding-bottom: 72px !important;
  }
  section#work [data-stack="rail"] {
    padding-top: 56px !important;
    padding-bottom: 56px !important;
    gap: 24px !important;
  }
  /* "Selected case studies" and the "2023 — 2026" range are a
     space-between row. At this width the heading takes two lines and the
     range is left hanging beside its second line; it drops below instead.
     The 104px of air above them is also a desktop figure. */
  section#work > div:first-child {
    flex-wrap: wrap !important;
    gap: 8px !important;
    padding-top: 56px !important;
  }

  /* -- the image moves up between the title and the summary --------------
     Stacked, the rail ran company → title → summary → accordion → image,
     which put the picture of the work at the very bottom, after three
     paragraphs and a set of collapsed rows. On a phone that is far enough
     down to read as absent.

     The image and the text are separate elements — the rail is one <aside>
     and the image is its sibling — so `order` alone cannot interleave them:
     it can only reorder items of the same container. display: contents
     dissolves the aside's box so its five children become grid items of the
     rail alongside the image, and then all six can be ordered together.
     Inheritance is unaffected: the aside carries the rail's --fs-* overrides
     as custom properties, and those still pass down through a contents box.

     Desktop never sees this — the aside stays a real two-column grid item
     above 720px. */
  section#work [data-stack="rail"] > [data-meta-rail] {
    /* !important because the export wrote display: block into the rail's
       inline style attribute, which outranks any plain declaration here. */
    display: contents !important;
  }
  /* The grid gap would now fall between every one of the six items,
     replacing the margins the rail was designed with. The margins are the
     spacing; the gap goes. */
  section#work [data-stack="rail"] {
    row-gap: 0 !important;
  }
  /* Only three things actually move; everything else has to stay where the
     markup put it. So the default is a middle value rather than a list of
     every child — items sharing an order keep their DOM order, so the two
     summary paragraphs and the "Contact me to learn more" pill stay in
     sequence between the image and the accordion without being named.

     Naming them individually is what broke it the first time: the pill is
     not on every rail, it had no order of its own, and order 0 beat all
     five of the numbers below it — so the one rail that had a pill opened
     with it, above the company name. */
  section#work [data-stack="rail"] > [data-meta-rail] > * { order: 4; }
  section#work [data-stack="rail"] > [data-meta-rail] > [data-rail-eyebrow] { order: 1; }
  section#work [data-stack="rail"] > [data-meta-rail] > h2 { order: 2; }
  section#work [data-stack="rail"] > [data-hero-shot] {
    order: 3;
    margin-top: 24px;
  }
  section#work [data-stack="rail"] > [data-meta-rail] > [data-rail-accordion] { order: 5; }
}

/* The gutter, at the width where main and the rest of the narrow layout
   also drop to 20px. The rows carry 20px of their own from the 900px block
   above; that comes off, so the inset is 20px in total rather than 52px. */
@media (max-width: 620px) {
  section#work {
    padding-left: 20px !important;
    padding-right: 20px !important;
  }
  section#work [data-stack="rail"] {
    padding-left: 0 !important;
    padding-right: 0 !important;
  }
}

/* The two-column about layout used to hold until 760px. Well before that it
   is already too narrow to be worth keeping: at ~1070px the right-hand
   column is down to ~590px, which is where the four interest tiles start
   truncating their own labels ("Architectu"). It now stacks at 1024px, and
   the stacked version gets spacing of its own rather than inheriting the
   28px that was only ever meant to separate two columns. */
@media (max-width: 1024px) {
  [data-stack="about"] {
    grid-template-columns: minmax(0, 1fr) !important;
    gap: clamp(48px, 11vw, 64px) !important;
  }
  /* portrait -> "BASED IN ..." -- 24px read as one crowded block on a phone */
  [data-stack="about"] > div:first-child {
    gap: clamp(32px, 8vw, 44px) !important;
  }
}

/* ---- header ----------------------------------------------------------
   The nav is a no-wrap flex row with a fixed gap. It fits down to about
   720px; below that the five items were wrapped onto a second full-width
   row, which worked but gave the header two lines of small, closely spaced
   links and no sense of hierarchy.

   Below 720px they collapse into a menu instead: the header goes back to
   one line — wordmark left, button right — and the same <nav> becomes a
   sheet that drops from under it. The nav element is not duplicated or
   moved, so there is one set of links in the markup and no way for the two
   to drift apart. site.js owns the open/closed state via [hidden].

   Everything here is inside the query, so at 721px and up the header keeps
   every declaration the design export gave it and the button is
   display: none.
   -------------------------------------------------------------------- */
@media (max-width: 720px) {
  [data-compact-nav] header {
    flex-wrap: nowrap !important;
    gap: 12px !important;
    padding: 14px 20px !important;
  }
  [data-compact-nav] header > a {
    font-size: 15px !important;
  }
  [data-compact-nav] header > a > span {
    display: none !important;   /* drop the "Product Designer" tagline */
  }

  [data-nav-toggle] {
    display: inline-flex !important;
    flex: 0 0 auto;
  }
  [data-nav-toggle]:hover,
  [data-nav-toggle][aria-expanded="true"] {
    background: var(--surface-card);
    border-color: var(--border-default);
  }
  [data-nav-toggle]:focus-visible {
    outline: 2px solid var(--azure-500);
    outline-offset: 2px;
  }

  /* The sheet. The header sits in a position: fixed bar whose inner
     container is position: relative, so anchoring to that container puts
     the panel directly under the header on every page without needing to
     know how tall the header is. */
  [data-compact-nav] header nav[data-nav-panel] {
    position: absolute !important;
    top: calc(100% + 4px) !important;
    left: 0 !important;
    right: 0 !important;
    display: flex !important;
    flex-direction: column !important;
    align-items: stretch !important;
    gap: 0 !important;
    margin: 0 20px !important;   /* line the sheet up with the wordmark */
    padding: 6px !important;
    background: var(--surface-card);
    border: 1px solid var(--border-default);
    border-radius: var(--r-card);
    box-shadow: var(--shadow-md);
    /* The bar it hangs off is pointer-events: none over the scrim; the
       panel itself has to take taps. */
    pointer-events: auto;
  }
  /* Closed is the default, and it is CSS rather than an attribute in the
     markup on purpose. site.js is deferred, so a `hidden` applied by
     script leaves a window in which the sheet is already styled as a sheet
     but not yet closed — one painted frame of the menu hanging open on
     every page load. Declaring it closed here means it is never open until
     something opens it. And because the rule lives inside the query, the
     nav above 720px keeps its own display and stays a plain row of links
     for a screen reader, which a `hidden` in the markup would not. */
  [data-compact-nav] header nav[data-nav-panel] {
    display: none !important;
  }
  [data-compact-nav] header nav[data-nav-panel][data-open="true"] {
    display: flex !important;
  }
  [data-compact-nav] header nav[data-nav-panel] a {
    display: flex;
    align-items: center;
    min-height: 44px;            /* the platform minimum tap target */
    padding: 0 12px !important;
    font-size: 15px !important;
    border-radius: var(--r-control);
  }
  [data-compact-nav] header nav[data-nav-panel] a:active {
    background: var(--surface-sunken);
  }
}

/* ---- reading list ---------------------------------------------------- */
/* Five columns in 375px left ~155px of usable width, truncating titles to
   one letter. Below 900px each book becomes a stacked card instead. */
@media (max-width: 900px) {
  [data-reading-header] {
    display: none !important;
  }
  [data-book] {
    grid-template-columns: minmax(0, 1fr) auto !important;
    gap: 4px 12px !important;
    padding: 14px 4px !important;
    align-items: start !important;
  }
  [data-book] > div:nth-child(1) { grid-column: 1; grid-row: 1; min-width: 0; }
  [data-book] > div:nth-child(2) { grid-column: 1; grid-row: 2; padding-left: 0 !important; }
  [data-book] > div:nth-child(3) { grid-column: 1; grid-row: 3; }
  [data-book] > div:nth-child(4) { grid-column: 1; grid-row: 4; margin-top: 4px; }
  [data-book] > div:nth-child(5) { grid-column: 2; grid-row: 1 / span 2; justify-content: flex-end !important; }
  /* the title can wrap now that it owns the full width */
  [data-book] > div:nth-child(1) span:first-child {
    overflow: visible !important;
    text-overflow: clip !important;
    white-space: normal !important;
  }
}

/* ---- rating key ------------------------------------------------------
   The key sits at the right of the heading row, after "Explore my curiosity"
   and before the Clear button, behind a "Key" label. Four rungs carrying a
   word each need about 560px, which the row only has on a laptop. Below that
   the words go and the emoji plus the score carry the meaning; the pill keeps
   its shape either way. */
@media (max-width: 1080px) {
  [data-rating-key] [data-key-word] {
    display: none;
  }
}
@media (max-width: 560px) {
  /* the pill reads as a key on its own once the row is this tight */
  [data-key-group] > span:first-child {
    display: none;
  }
}
@media (max-width: 420px) {
  /* last resort: the score alone, so the pill still fits one line */
  [data-rating-key] button {
    padding: 7px 9px !important;
  }
}

/* ---- touch targets --------------------------------------------------- */
/* Corner links and nav items were 17-19px tall. Padding grows the hit area
   without moving anything: the text box is unchanged. */
@media (pointer: coarse) {
  [data-compact-nav] header nav a,
  a[href^="mailto:"] {
    padding-top: 6px;
    padding-bottom: 6px;
  }
}

/* ---- narrow-screen pass 2 -------------------------------------------- */
/* Found by measuring each page at 375px rather than by eye. */

@media (max-width: 700px) {
  /* Job Builder Pro's four Outcomes cards stayed two-up, leaving 128px
     columns — body copy was breaking at about ten characters a line. */
  [data-stack="pair"] {
    grid-template-columns: minmax(0, 1fr) !important;
    gap: 16px !important;
  }
}

/* The Sara AI case study's three-up rows — the discharge/home/check-up
   stages, the physician/nurse/patient hand-off, the three template details.
   Two columns first, because at 900px three 260px cards still read; one
   column at the same width the pair grid gives up. */
@media (max-width: 900px) {
  [data-stack="trio"] {
    grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
  }
}

@media (max-width: 700px) {
  [data-stack="trio"] {
    grid-template-columns: minmax(0, 1fr) !important;
    gap: 16px !important;
  }
}

@media (max-width: 620px) {
  /* 32px of page padding plus 24px of card padding took 112px out of a
     375px screen, which is why the filter chips wrapped one to a row with a
     wide empty gutter beside them. */
  main {
    padding-left: 20px !important;
    padding-right: 20px !important;
  }
  [style*="padding: 22px 24px 24px"] {
    padding: 18px 16px 20px !important;
  }
}

/* ---- tap targets ------------------------------------------------------
   Corner links, the wordmark and the case-study Back link were 17-22px tall.
   Padding grows the touch area; the text box and the layout do not move,
   because the extra height is absorbed by a matching negative margin. */
@media (pointer: coarse) {
  [data-compact-nav] header > a,
  a[href$=".pdf"],
  a[href*="linkedin.com"],
  a[href^="mailto:"],
  [data-stack="rail"] ~ * a,
  main > div > div > a,
  section a[style*="font-family: var(--font-mono)"] {
    padding-top: 5px;
    padding-bottom: 5px;
    margin-top: -5px;
    margin-bottom: -5px;
  }
}

/* ---- hiding elements -------------------------------------------------
   The browser hides [hidden] with display:none from its own stylesheet,
   which an inline display: grid on the element beats. The reading list's
   rows all carry one, so filtering set the attribute on 136 rows and every
   one of them stayed on screen. */
[hidden] {
  display: none !important;
}

/* ======================================================================
   Hero, small screens
   ----------------------------------------------------------------------
   Two separate faults, with one root: every number in the hero is sized
   against the viewport's *smaller* dimension (78vmin), and on a phone in
   portrait that is the width — while the type inside it is sized in vw and
   the gaps are fixed pixels carried over from the desktop layout.

   Measured at 393x852 before this block: the content column was 92% as
   tall as the disc containing it. On a 1440x900 desktop it is 49%. That
   gap is the whole complaint — the copy did not so much overflow as fill
   the puck to its edges, and because the disc is a white radial gradient
   that fades to nothing at its rim, text sitting out at 92% sits where
   there is no longer any disc to sit on. Which is also why it read as
   low-contrast: the "puck" under the words was already transparent there.

   So the disc is sized from the width instead, the column inside it is
   pulled in, and the veil is made to hold its opaque core further out.
   Target is about 0.65 — looser than desktop, because at 393px a disc that
   only used the middle half would leave the type too small to read.
   ====================================================================== */
@media (max-width: 620px) {
  /* The disc is sized to the viewport width rather than to vmin. vmin on a
     phone in portrait is the width anyway, so this is nearly the same
     number — but it stays the width when the browser chrome moves, which
     vmin does not.

     Sized so the headline fits in the fewest lines it can. Measured at
     402x714, every step smaller adds a line and pushes the column's height
     up fast (380 -> 0.68 of the diameter, 360 -> 0.79, 314 -> 1.0, which is
     the overflow this all started as). Bigger only costs visible dot field
     for nothing, because by 402 the text has stopped rewrapping. The type
     inside is scaled separately, further down. */
  [data-js="puck"] {
    /* The puck is a flex item in a centring row, so flex-shrink: 1 quietly
       clamped it back to the viewport width and the rule below appeared to
       do nothing. It has to opt out of shrinking to be allowed its own
       size. */
    flex: 0 0 auto !important;
    width: min(100vw, 440px) !important;
    height: min(100vw, 440px) !important;
  }

  /* The one thing the design's own numbers cannot absorb at this size. The
     46px stack gap is a pixel value that does not scale with the disc, and
     at 46px it was most of what pushed the column out to the rim. The 13%
     side padding is left exactly as designed. */
  [data-puck-content] {
    gap: 24px !important;
  }

  /* The column filled the disc corner to corner once the disc came down to
     the viewport width — right size, but no air inside it. Everything in it
     comes down about 8%, which is enough to open up the middle without the
     type getting small: measured, it takes the column from 0.65 of the
     disc's diameter to 0.50, which is where the desktop sits (0.49).

     The sub-headline gains the most: at the old size it set in three lines
     and at this one it sets in two, so most of the whitespace comes back
     from a line that no longer has to exist. */
  [data-js="puck"] h1,
  [data-js="puck"] [style*="4.3vmin"] {
    font-size: clamp(19px, 5.6vw, 28px) !important;
  }
  /* The roulette is deliberately absent here: it sits inside the h1, as the
     first word of the sub-headline, so it has to take the headline size or it
     sets smaller than the sentence it starts. */
  [data-js="puck"] p {
    font-size: clamp(16px, 4.4vw, 21px) !important;
  }
  /* "Based in NYC" and the three stat figures. The stat labels are already
     at 12px, which is the floor the build's accessibility pass enforces, so
     they stay as they are. */
  [data-puck-content] > div:first-child {
    font-size: 12px !important;
    margin-bottom: -12px !important;
  }
  [data-puck-content] [style*="width: 86px"] > span:first-child {
    font-size: 18px !important;
  }

  /* The faintest possible edge, on the phone only. Not an outline around
     the disc — a suggestion that one is there.

     Half a pixel and rgba(11,11,10,.05). A full 1px in --border-hairline
     was tried and read as a drawn edge on a shape that is meant not to have
     one; this is thinner than the device can draw at 1x and lighter than
     any grey on the ramp (--n-150, the lightest border token, is roughly
     four times this contrast against the page). On a 3x phone 0.5px is a
     true 1.5-device-pixel hairline, which is the point — it is meant to be
     felt rather than seen.

     A 1x screen may round 0.5px away to nothing. That is acceptable: this
     rule only applies below 620px, where essentially every device is 2x or
     better, and the disc reads perfectly well without it — the contrast
     that does the real work is the dot field's density and the WebKit
     backdrop-filter fix, not this. */
  [data-puck-ring] {
    border: 0.5px solid rgba(11, 11, 10, 0.05);
    /* Without this the line is not faint — it is gone.

       The ring is the puck's first child, and two of the layers stacked on
       top of it carry backdrop-filter: blur(26px) and blur(10px). A
       backdrop-filter blurs everything painted beneath it, and a hairline
       under 10px of blur is not a hairline any more. Reproduced it in
       isolation both ways to be sure: identical rings, one below the blur
       layer and one lifted above it — the first vanishes completely, the
       second is crisp.

       z-index: 2 moves the ring later in paint order, so it is no longer
       part of the blur layers' backdrop. Below the content column, which is
       z-index: 3, and above everything else in the disc — including the
       white veil, so the line is not washed out by the fill it edges. */
    z-index: 2;
  }

  /* Note: there is deliberately nothing here for the disc's white veil. An earlier attempt at "more contrast on mobile" pushed
     the veil's opaque stops outward and strengthened the shadow, and it
     made things worse in exactly the way Atticus described — a bigger,
     whiter disc reads as a flat white blob against a near-white page and
     hides the dots that were the contrast in the first place. The disc is
     a lens, not a fill; the separation comes from the dots behind it being
     blurred and dimmed while the ones outside stay sharp. Whitening it
     destroys the very thing it is meant to be seen against.

     The hairline above is the opposite move: an edge with no fill at all.
     It went through 1px --border-default (too dark), 1px --border-hairline
     (still read as a drawn outline) and out again before landing at half a
     pixel of near-transparent ink. The separation that does the real work
     is still the one the design already had — blurred, dimmed dots inside
     against sharp ones outside — restored by the WebKit backdrop-filter fix
     and the dot field's density. The hairline only tops it off. */
}

/* ---- hero height and the corner cues ---------------------------------
   "Get in touch" and "Browse work" sit at bottom: 28px inside a section
   that is height: 100vh. On iOS, 100vh is the *large* viewport height —
   the height the page would have if the browser chrome were retracted —
   so with Safari's toolbar on screen, as it is whenever the page has not
   been scrolled yet, the bottom 60-90px of that 100vh is behind the
   toolbar. The cues live in exactly that strip, which is why they were
   half-hidden on first paint.

   100svh is the small viewport height: the height with the chrome shown.
   The hero then fits whether the toolbar is out or not, and the cues stay
   above it. 100vh is kept as the declaration before it so a browser that
   does not know svh still gets the old behaviour rather than nothing.

   viewport-fit=cover is deliberately not used. It would let the page draw
   under the notch and the home indicator, which changes the layout of
   every page on the site to solve a problem confined to this one.
   -------------------------------------------------------------------- */
@media (max-width: 720px) {
  section#main {
    height: 100vh;
    height: 100svh !important;
  }
  /* Clear of the home indicator as well as the toolbar. */
  section#main > a[href="resume.html"],
  section#main > a[href="#work"] {
    bottom: 40px !important;
  }
}

/* The gutter itself only narrows at 620, where main and the case study rows
   narrow too, so the cues line up with the content below them rather than
   with nothing. */
@media (max-width: 620px) {
  section#main > a[href="resume.html"] {
    left: 20px !important;
  }
  section#main > a[href="#work"] {
    right: 20px !important;
  }
}

/* ---- very wide screens -----------------------------------------------
   Case-study rows bleed to the right edge with margin-right: calc(50% - 50vw).
   Past about 1600px that pushes the whole row noticeably off-centre, because
   the text rail stays left while the image keeps growing to the right. Cap
   the bleed so the row stays centred on a large monitor. */
@media (min-width: 1600px) {
  [data-stack="rail"] {
    margin-right: 0 !important;
    max-width: 1440px;
    margin-left: auto !important;
  }

  /* Capping the bleed above brings the row's right edge on screen for the
     first time, which exposes two square corners the design never meant to
     be seen — below this width they sit off the side of the monitor. Match
     the left, and close the hairline frame that was also open on that side. */
  [data-hero-panel] {
    border-radius: var(--r-media) !important;
    border-right: 1px solid var(--border-hairline) !important;
  }
}

/* ---- case-study meta row ---------------------------------------------- */
@media (max-width: 760px) {
  [data-meta-row] {
    grid-template-columns: repeat(2, minmax(0, 1fr)) !important;
  }
}

/* ---- hero shot hover --------------------------------------------------
   The slow zoom was defined only in the landing page's own <style> block.
   Shared here so the case study's hero behaves the same way. */
[data-hero-shot] > [role="img"] {
  transition: transform 420ms cubic-bezier(.2, .6, .2, 1);
}
[data-hero-shot]:hover > [role="img"] {
  transform: scale(1.03);
}

/* ---- nudging arrows ---------------------------------------------------
   Every arrow on the site is the same "↓" glyph; the direction it points was
   set by a rotate() living inside its @keyframes. That made the direction a
   property of the animation rather than of the element, and anything that
   stopped the animation took the rotation with it. The site-wide
   prefers-reduced-motion reset does exactly that — it collapses every
   animation to one 0.01ms pass with fill-mode: none — so with iOS "Reduce
   Motion" on, the Back arrow and the Back-to-top arrow both reverted to
   pointing down. WebKit and Blink also disagree about what a finished
   unfilled animation leaves behind, which is why the same page pointed
   different ways on different devices.

   The angle is now a static declaration keyed off data-arrow, set in
   build.py from the animation the element asks for. The keyframes only
   travel. Because `rotate` and `transform` are separate properties they
   compose — rotate first, then the translate in the rotated frame — so the
   rendered motion is identical to the old combined transform, and the
   direction now holds whether the animation runs, is paused, or never
   starts at all. */
[data-arrow="up"]    { rotate: 180deg; }
[data-arrow="left"]  { rotate: 90deg; }
[data-arrow="right"] { rotate: -90deg; }

@keyframes folioNudgeLeft {
  0%, 62%, 100% { transform: translateY(0); }
  28%           { transform: translateY(5px); }
}

@keyframes folioNudgeUp {
  0%, 62%, 100% { transform: translateY(0); }
  28%           { transform: translateY(5px); }
}

/* folioNudgeX is the design system's own, and carries the same rotate() in
   its keyframes. Redefined here — this file is concatenated after the token
   files, so the later definition is the one that applies — so the landing
   page's arrow is fixed by the same rule rather than by a second mechanism.
   The token file itself is left untouched. */
@keyframes folioNudgeX {
  0%, 62%, 100% { transform: translateY(0); }
  28%           { transform: translateY(5px); }
}
[data-top]:hover {
  background: var(--n-950) !important;
  color: var(--n-0) !important;
  border-color: var(--n-950) !important;
}

/* ---- About page: lay the page out from its content, not from the window --
   <main> shipped as a hero: min-height 100vh, flex, align-items center. That
   is the right pattern for one short block on a landing page and the wrong
   one for a page carrying this much. Because the block is centred in a box
   that is always as tall as the window, every pixel the window gains gets
   split evenly above and below the content. On a 4K display that is ~350px
   of nothing at each end with the portrait and bio floating in the middle,
   and it pushes the photo strip clean off the bottom of the screen. The same
   rule starves a phone from the other side: there the content is TALLER than
   the window, so there is no slack anywhere and everything closes up. One
   box measured against the window cannot serve both ends of that range.

   So the page is laid out from the top now, with breathing room that scales
   gently on its own terms. The result is the same shape at 375px as at 4K,
   which is what the viewport-height version could never be.

   align-items: start on the grid matters as much as the min-height. With
   stretch, the short left column was pulled to the height of the bio and the
   two `margin-top: auto` blocks inside sprang to the bottom of it -- which
   is why "BASED IN" sat marooned a long way under the portrait. At start,
   both columns are their own height and those auto margins go inert. */
body:has([data-stack="about"]) main {
  min-height: 0 !important;
  align-items: flex-start !important;
  padding-top: clamp(16px, 3vh, 44px) !important;
  padding-bottom: clamp(40px, 6vh, 88px) !important;
}
[data-stack="about"] {
  margin-top: 0 !important;
  align-items: start !important;
}
/* Scoped to the two-column range on purpose. An unscoped gap here would sit
   later in the file than the stacked rule above and quietly outrank it. */
@media (min-width: 1025px) {
  [data-stack="about"] {
    gap: clamp(40px, 6vw, 72px) !important;
  }
  /* Centre the block between the dot grid and the photo strip. It was
     sitting high: the space above it is main's padding-top alone, while the
     space below it is main's padding-bottom AND the strip's own margin
     stacked on top of each other -- 27px against 99px on a 15" laptop. One
     padding on both sides with the strip's margin zeroed splits it evenly,
     and 7vh is deliberately the half of what those two used to add up to,
     so the page keeps exactly the vertical rhythm it has now and only the
     distribution changes. */
  body:has([data-stack="about"]) main {
    padding-top: clamp(56px, 7vh, 104px) !important;
    padding-bottom: clamp(56px, 7vh, 104px) !important;
  }
}
/* Four fixed columns is a promise the width cannot always keep. auto-fit
   lets the row drop to four, three and then two on its own, so a tile is
   never narrower than its own label -- which is what truncated
   "Architecture" at ~1070px. The floor is itself fluid because a fixed one
   cannot be right at both ends: 150px is what keeps four tiles honest on a
   wide screen, but on a 320px phone two tiles at 150px do not fit the
   ~256px left after page padding, and auto-fit answers that by stacking all
   eight one per row. 118px at that end keeps them two-up. */
[data-stack="quad"] {
  grid-template-columns: repeat(auto-fit, minmax(clamp(118px, 30vw, 150px), 1fr)) !important;
}

/* ---- Patents page -----------------------------------------------------
   Cards are collapsed on load; the summary is in the markup either way, so
   it is findable by in-page search and by anything that ignores JavaScript
   only insofar as the toggle needs it. */
[data-patent] button:hover span:first-child {
  color: var(--text-link);
}
[data-patent]:hover {
  border-color: var(--n-300) !important;
}
[data-patent][data-open="true"] {
  border-color: var(--n-300) !important;
  box-shadow: var(--shadow-sm) !important;
}
[data-patent] button:focus-visible {
  outline: 2px solid var(--azure-500);
  outline-offset: -2px;
}
/* The publication number is a reference, not something to read past the
   title. On a narrow screen it drops and the title takes the width. */
@media (max-width: 700px) {
  [data-patent-pub] {
    display: none !important;
  }
  [data-patent] button {
    gap: 12px !important;
    padding: 16px !important;
    align-items: flex-start !important;
  }
  [data-patent-panel] {
    padding: 0 16px 16px !important;
  }
}

/* ---- landing puck: the Patents stat is a link -------------------------
   It has to still read as one number in a row of three, so the affordance
   is the label picking up the site's iridescent gradient on hover rather
   than any change in weight or spacing. */
[data-stat-link] {
  border-radius: var(--r-sm);
  transition: transform 240ms cubic-bezier(.2, .6, .2, 1);
}
[data-stat-link]:hover span:last-child {
  background-image: var(--gradient-iridescent);
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent !important;
  -webkit-text-fill-color: transparent;
}
[data-stat-link]:hover {
  transform: translateY(-1px);
}

/* ---- footer CTA hover ---------------------------------------------------
   Matches the design system's secondary-button hover ([data-top], above):
   solid black instead of the iridescent glow this button used to show. */
[data-contact-cta-wrap] {
  display: inline-flex !important;
}
[data-contact-cta]:hover,
[data-contact-cta]:focus-visible {
  background: var(--n-950) !important;
  color: var(--n-0) !important;
  border-color: var(--n-950) !important;
}

/* The contact row is a two-row grid so the button can sit level with the
   heading rather than with the heading-plus-eyebrow block. On a phone the
   two columns become one and the button drops underneath. */
@media (max-width: 620px) {
  [data-contact-row] {
    grid-template-columns: minmax(0, 1fr) !important;
    /* 88px of air above the hairline was set against a 56px heading. The
       heading is smaller here, so the gap above it comes down with it. */
    padding-top: 56px !important;
  }

  /* --fs-display-s is a flat 56px. At 393px that runs the heading to four
     lines of very large type and pushes the buttons off the bottom of the
     screen. Scaled to the viewport here only — the token itself is
     untouched, so every other use of it, and this same heading above
     620px, is exactly as designed. */
  [data-contact-row] h2 {
    font-size: clamp(32px, 8.6vw, 44px) !important;
    max-width: 18ch !important;
  }

  [data-contact-actions] {
    grid-column: 1 !important;
    grid-row: 3 !important;
    justify-self: stretch !important;

    /* Three pills on one wrapping row left LinkedIn and Email me side by
       side with Download resume orphaned underneath — a 2 + 1 shape that
       reads as a mistake rather than a set. Stacked, they are one column
       of equal rows, and each is a full-width target rather than a 120px
       pill to be caught with a thumb. */
    flex-direction: column !important;
    align-items: stretch !important;
    flex-wrap: nowrap !important;
    gap: 12px !important;

    /* Stacked, the buttons sat 16px under the heading — the row's own gap,
       which is sized for the eyebrow above the heading, not for a stack of
       43px pills below it. 32px on top of that makes 48px, enough that the
       buttons read as their own group rather than as another line of the
       heading. Only in the stacked state: above this width they sit beside
       the heading and the row gap is the eyebrow's alone. (There was a
       row-gap: 24px here that never applied — the row carries an inline
       row-gap, which outranks a stylesheet declaration without
       !important.) */
    margin-top: 32px;
  }

  /* Download resume sits inside a wrapper this file turns into an
     inline-flex box further up, because it needs to be a real box to own a
     hover state. inline-flex shrinks to its content, so that one button
     stayed pill-width while the other two stretched. Both the wrapper and
     the button inside it have to be told to fill. */
  [data-contact-actions] [data-contact-cta-wrap] {
    display: flex !important;
  }
  [data-contact-actions] [data-contact-cta-wrap] > a {
    flex: 1 1 auto !important;
  }

  /* The gap here is 12px, and on a desktop browser narrowed to phone width
     that is exactly what you get. On an actual phone it collapsed to about
     2px, which is the bug Atticus saw and could not reproduce on a Mac.

     The cause is the touch-target block further up this file. It runs under
     @media (pointer: coarse) — true on a phone, false on a mouse — and it
     grows the hit area of small links without moving them by adding 5px of
     padding and taking 5px back as a negative margin. Its selector list
     includes a[href*="linkedin.com"], a[href^="mailto:"] and a[href$=".pdf"],
     which is all three of these buttons.

     That trick is right for a bare text link in a paragraph. These are
     43px pills that are already well past the minimum tap target, stacked
     in a flex column where the negative margins on two neighbours eat into
     the gap from both sides at once: 12 - 5 - 5 = 2. So the padding and the
     margin are both dropped here, and the inline 12px 22px the buttons
     already carry does the work. */
  [data-contact-actions] a[href*="linkedin.com"],
  [data-contact-actions] a[href^="mailto:"],
  [data-contact-actions] a[href$=".pdf"] {
    margin-top: 0 !important;
    margin-bottom: 0 !important;
    padding-top: 12px !important;
    padding-bottom: 12px !important;
  }
}

/* ---- case study "Learn more" pill ---------------------------------------
   Same solid-black hover as the other secondary buttons on the page. */
[data-learn-more]:hover,
[data-learn-more]:focus-visible {
  background: var(--n-950) !important;
  color: var(--n-0) !important;
  border-color: var(--n-950) !important;
}

/* ======================================================================
   Case study, narrow screens
   ----------------------------------------------------------------------
   Everything below is scoped to [data-case-study] — the <main> of the two
   case-study pages — and sits inside a media query, so no other page and no
   desktop rule is touched. The hooks these select on are added in
   build.py's tag_case_study_mobile(), which changes no rendered output on
   its own.
   ====================================================================== */

@media (max-width: 700px) {

  /* -- the dot band ----------------------------------------------------
     A magnetic dot field driven by pointermove. A touch screen never sends
     one, so on a phone it is a static 108px slab of dots between the nav
     and the headline — imposing, and doing none of the work it exists to
     do. It goes entirely, canvas and its reserved height with it, and
     site.js skips building it so the rAF loop never starts. */
  [data-dot-band] {
    display: none !important;
  }

  /* -- section rhythm ---------------------------------------------------
     128px between sections is right at desktop scale, where a section is
     also much taller. On a phone the same gap reads as the page having
     ended. 72px keeps the sections clearly separate at a proportionate
     size. */
  [data-case-study] [data-stack="rail"] {
    padding-top: 72px !important;
  }

  /* -- section 04, order versus condition -------------------------------
     Two cards side by side leave ~130px each, which broke "Robot A · Step
     1" over three lines. The row becomes a column and the arrow turns to
     point down: the same three parts, the same order, read top to bottom
     instead of left to right.

     The two panels have different connectors — a bare arrow, and an arrow
     under a "completes" label. Stacked, the labelled one lays its label
     beside the arrow rather than above it, so both connectors occupy one
     line and the two panels stay the same shape as each other. */
  [data-cond-grid] {
    grid-template-columns: minmax(0, 1fr) !important;
    gap: 32px !important;
    padding: 28px 20px !important;
  }
  [data-cond-row] {
    flex-direction: column !important;
    align-items: stretch !important;
    gap: 10px !important;
  }
  [data-cond-card] {
    flex: 0 0 auto !important;
    text-align: center;
  }
  [data-cond-connector] {
    flex-direction: row !important;
    align-items: center !important;
    justify-content: center !important;
    gap: 8px !important;
    min-height: 22px;
    text-align: center;
  }
  [data-cond-arrow] {
    rotate: 90deg;   /* → becomes ↓ */
    line-height: 1;
  }

  /* -- section 03, the robot scene --------------------------------------
     80.5% of a phone's width is a 300px-wide diagram of four robots. Full
     width, and the -98px that pulls the following copy up into the SVG's
     empty lower third scaled down with it. */
  [data-robot-figure] {
    margin: 24px 0px -40px !important;
  }
  [data-robot-figure] > div {
    width: 100% !important;
  }
  /* -- section 05, the dependency menus ---------------------------------
     Kept at the size it is drawn at and pushed in by 10%, which trims a
     little of the empty margin around the menus without changing the
     figure's footprint. The container already clips, and scale() does not
     affect layout, so the block stays exactly where it was and the crop is
     even on all four sides. Scaling the element rather than the image's own
     width also keeps the browser on the srcset candidate it already chose,
     so nothing is upscaled and the type stays sharp. */
  [data-wide-shot] img {
    transform: scale(1.1);
  }

  /* -- section 06, the screen recording ---------------------------------
     Same reasoning: a 2048x1094 recording of a UI detail. Full-bleed, and
     square-cornered because a panel that runs off both edges has no
     visible frame left for a radius to round. */
  [data-clip-shot] > div {
    border-radius: 0 !important;
  }

  /* The recording breaks out to the full width of the screen. There are
     two gutters between it and the edge — <main>'s, which is 32px above
     620px and 20px below it, and the rail's own 20px — so the bleed is
     measured against the viewport rather than against either of them. The
     content column is centred in the viewport, which is what makes
     calc(50% - 50vw) land exactly on its left edge. Any rounding against
     the scrollbar is absorbed by the html { overflow-x: clip } that already
     applies below 900px. The caption is deliberately left behind in the
     gutter, aligned with the copy it belongs to. */
  [data-clip-shot] > div {
    width: 100vw;
    max-width: 100vw;
    margin-left: calc(50% - 50vw);
    margin-right: calc(50% - 50vw);
  }
}

/* ---- robot scene, reduced motion --------------------------------------
   The loop is baked CSS keyframes, so a browser asking for reduced motion
   used to switch it off outright and leave the diagram frozen on its first
   frame — four robots parked off to one side, which is what an iPhone with
   "Reduce Motion" on shows. WCAG 2.2.2 asks for a way to stop motion, not
   for the content to be withheld, so the loop is now paused there instead,
   seeked to a composed frame, and this button starts it. Anyone whose
   browser is not asking for reduced motion never sees the button and the
   scene autoplays exactly as it always did. */
@media (prefers-reduced-motion: reduce) {
  [data-robot-toggle] {
    display: inline-flex !important;
  }
}
[data-robot-toggle]:hover,
[data-robot-toggle]:focus-visible {
  border-color: var(--n-300);
  color: var(--text-primary);
}

/* -- case-study hero, narrow screens ------------------------------------
   The frame is the same 3200x2480 shape as the source image, so fitting it
   to the screen shows the laptop at the size it occupies in the artwork —
   80% of the width, with a wide soft border all round. That reads as a
   small screenshot rather than a hero. On a phone the frame is squarer and
   the artwork is scaled past it, which crops the empty border away and
   brings the laptop up to ~92% of the width. 8:5 rather than the laptop's
   own 16:9 so that the crop tightens on the sides before it reaches the
   top and bottom edges, leaving the whole machine visible with a margin. */
@media (max-width: 700px) {
  [data-case-study] [data-hero-shot] {
    aspect-ratio: 8 / 5 !important;
  }
  [data-case-study] [data-hero-shot] > [role="img"] {
    background-size: 115% auto !important;
  }
}

/* ---- About: the travel photo strip -------------------------------------
   Two numbers came out of the design export and neither moved with the
   viewport: every frame was 560x400px, and the strip was pulled up into
   <main> by a flat -136px to sit in the dead space the centred hero layout
   left behind. Frames wider than the screen and a pull that assumed dead
   space which shrank as the window narrowed is what put the photos on top
   of the interest tiles at 393px.

   The pull is gone entirely -- <main> is laid out from the top now (see the
   About block above), so there is no dead space to reclaim and the strip is
   an ordinary block with ordinary space above it. The frames are fluid,
   capped at their drawn 560px so nothing changes on a desktop. */
[data-marquee-frame] {
  width: clamp(220px, 68vw, 560px) !important;
  margin-right: clamp(12px, 2.5vw, 24px) !important;
  align-self: flex-start;
}
/* Only where the marquee actually runs. will-change on an element that
   never animates is a composited layer held for nothing, which on a phone
   is the exact cost this strip is trying to avoid. */
@media (min-width: 1025px) {
  [data-marquee="travel"] {
    will-change: transform;
    backface-visibility: hidden;
  }
}

/* Below 1024px the strip is not animated at all: it is a scroll region the
   reader pushes with a thumb.

   The animation could not run on iOS in any case. The track is one element
   that the animation slides whole, so WebKit has to rasterise all of it
   before it can move it -- and at the shipped size that element was 21000px
   wide by 400px tall, about a 300MB backing store on a 3x screen. WebKit
   does not raise an error when a layer is that far past what it will
   allocate; it just declines to animate, which is exactly the symptom.
   Shrinking the frames helped but left the same fragile arrangement one
   photo away from the ceiling again.

   Handing the motion to the reader removes the problem rather than budgets
   around it: no composited layer, native momentum scrolling, nothing to run
   while the strip is off-screen, no motion the reader did not ask for, and
   they can stop on a photo and actually look at it. The second copy of the
   set exists only so the animation can loop seamlessly; with no animation
   it is 18 duplicate photos to scroll past, so it goes. */
@media (max-width: 1024px) {
  [data-marquee-wrap] {
    /* Only in the stacked range. Above 1025px the space over the strip is
       main's padding-bottom on its own, so that it matches the padding-top
       above the block and the block sits centred between the dot grid and
       the photos. A margin here as well is what made the gap below it
       almost four times the gap above. */
    margin-top: clamp(32px, 5vh, 72px) !important;
    overflow-x: auto !important;
    overflow-y: hidden !important;
    scroll-snap-type: x proximity;
    -webkit-overflow-scrolling: touch;
    scrollbar-width: none;
  }
  [data-marquee-wrap]::-webkit-scrollbar {
    display: none;
  }
  [data-marquee="travel"] {
    animation: none !important;
  }
  [data-marquee="travel"] > [aria-hidden="true"] {
    display: none !important;
  }
  [data-marquee-frame] {
    scroll-snap-align: start;
  }
  /* The region takes focus so it can be scrolled from a keyboard; that ring
     belongs around the strip, not inset under the first photo. */
  [data-marquee-wrap]:focus-visible {
    outline: 2px solid var(--text-primary);
    outline-offset: -2px;
  }
}

/* ======================================================================
   The landing page hero, under prefers-reduced-motion
   ----------------------------------------------------------------------
   Atticus's decision, taken with the trade-off stated: the hero animates
   for everyone, including visitors who have asked their browser for
   reduced motion. Someone who set that preference because motion makes
   them ill will see this hero anyway.

   It is confined to the hero. Everything else on the site still honours
   the preference — the case study's robot scene pauses and offers a Play
   button, the About page's marquee stops, smooth scrolling turns off.

   The site-wide reset near the top of site.css collapses every animation
   to one 0.01ms pass and every transition to 0.01ms, which is what
   actually froze these three. site-extra.css is concatenated after the
   token files, so restating the values here wins. To put the hero back
   under the preference, delete this block and change reduceHero to
   reduceMotion in site.js — the two are a pair.
   ====================================================================== */
@media (prefers-reduced-motion: reduce) {
  /* the puck's conic gradient, one turn every 34 seconds */
  [data-puck-shimmer] {
    animation-duration: 34s !important;
    animation-iteration-count: infinite !important;
  }
  /* "Get in touch" and "Browse work" */
  section#main a > [data-arrow] {
    animation-duration: 1.5s !important;
    animation-iteration-count: infinite !important;
  }
  /* The verb slides on a transition rather than a keyframe, so it needs
     the transition restored, not the animation. Without this the word
     would still change — it would just jump between words instead of
     rolling over. */
  [data-js="roulette-stack"] {
    transition-duration: 460ms !important;
  }
}
