/* ============ START OF MAEZKEV — VANILLA REBUILD ============ */

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

/* ============ START OF CORNER SHAPE — SQUIRCLE ============ */
/* corner-shape only does anything where a border-radius is already declared,
   so this bends every existing corner and invents none. It's set from the base
   selector rather than per component, which keeps the shape one decision
   instead of a hundred, and leaves any rule below free to opt out — a plain
   class or id beats the universal selector. Browsers without CSS Borders 4
   drop the line and keep the circular corner, which is the same shape, softer. */
*,
*::before,
*::after {
  corner-shape: var(--corner);
}

/* ============ END OF CORNER SHAPE ============ */

/* ============ SELECTION ============
   The accent wash rather than the platform's own blue, so text a recruiter
   copies out of a case study is picked out in the site's colour. White on the
   wash keeps it readable over every surface here, blue keywords included. The
   PDF viewer's text layer sets its own, which outranks this. */
::selection {
  background: var(--c-blue-wash-strong);
  color: var(--c-white);
}

html {
  /* The whole page is sized from here. 265 of the values in this stylesheet
     are rem — type, spacing, and --wrap, the content column itself — so the
     root is the one number that scales all of them together. Everything left
     in px is a hairline, a border, a blur radius or an outline, and those are
     meant to stay put at any size.

     Below 1440px the floor holds and nothing changes: the middle term is worth
     exactly 1rem at 1440px, so every layout down to mobile renders as before.
     Above it the page grows with the viewport, reaching 1.9x at 3840px.

     1.9 rather than a rounder 1.5 because of what a 4K panel actually is. At
     native resolution a 27" 4K runs about 163 pixels to the inch against the
     96 a stylesheet assumes, so a 16px line lands near 10.6pt — smaller than
     the 12pt it reads as on an ordinary display. 1.5x did not close that gap;
     it only narrowed it. This does, and on a 32" panel it errs slightly large,
     which is the right way round to be wrong.

     The floor and ceiling are rem rather than px on purpose. rem inside this
     declaration resolves against the browser's own base size, so a reader who
     has set their default larger keeps that — which the flat 16px this
     replaces quietly overrode. */
  font-size: clamp(1rem, 0.46rem + 0.6vw, 1.9rem);
  scroll-padding-top: var(--header-h);
  scroll-behavior: smooth;
  /* The scrollbar here is a styled one, so it takes real layout width. Holding
     its gutter open means the lightbox can freeze the page by turning overflow
     off without the content jumping sideways to fill it. Not every browser
     reserves the gutter once overflow is hidden, so the viewer measures what
     it actually got and pays back the difference in padding. */
  scrollbar-gutter: stable;
  overflow-x: hidden;
}

/* Scroll lock, held while the lightbox or the search is up. On the root and
   never on body: body with overflow hidden becomes a scroll container of its
   own under the root's overflow-x, and the header's position: sticky sticks
   to that instead — which never scrolls, so the bar went off the top with
   the page. */
html.has-lightbox,
html.has-palette {
  overflow: hidden;
}

/* The site's pointer, as the system draws it (--cursor, in the tokens), for
   wherever the drawn one is not on screen; the drawn one, where it is, hides
   it (has-cursor, below). Links and anything else clickable take the ring;
   fields keep the system's text caret, which is the one cursor that says
   something no picture here does. :where() keeps all three weightless, so
   every rule that names its own cursor still has it. */
html {
  cursor: var(--cursor);
}

:where(a[href], button:not(:disabled), [role="button"], summary, label[for], select) {
  cursor: var(--cursor-pointer);
}

:where(input:not([type="button"], [type="submit"], [type="reset"], [type="checkbox"], [type="radio"]), textarea, [contenteditable="true"]) {
  cursor: text;
}

body {
  margin: 0;
  background: var(--c-bg);
  color: var(--c-white);
  font-family: var(--font);
  font-size: var(--fs-base);
  font-weight: var(--fw-light);
  line-height: var(--lh-body);
  -webkit-font-smoothing: antialiased;
  /* `clip`, not `hidden`: hidden would make body a scroll container and
     break the pinned timeline's position: sticky. */
  overflow-x: clip;
}

/* ============ START OF SCROLLBAR — BLUE HANDLE ON NOTHING ============ */
html {
  scrollbar-color: var(--c-blue) transparent;
  /* Firefox: thumb, track */
  scrollbar-width: thin;
}

::-webkit-scrollbar {
  width: 0.625rem;
  height: 0.625rem;
}

::-webkit-scrollbar-track,
::-webkit-scrollbar-corner {
  background: transparent;
}

::-webkit-scrollbar-thumb {
  background: var(--c-blue);
  border-radius: var(--radius-pill);
  /* A transparent border clipped to the content box insets the handle, so it
     reads as a floating pill rather than filling the gutter edge to edge. */
  border: 0.1875rem solid transparent;
  background-clip: content-box;
}

::-webkit-scrollbar-thumb:hover {
  background: var(--c-blue-hover);
  background-clip: content-box;
}

/* ============ END OF SCROLLBAR — BLUE HANDLE ON NOTHING ============ */

/* ============ START OF PAGE TRANSITIONS ============ */
/* The browser holds a snapshot of the page being left over the one arriving
   and animates between the two. It buys no router and no client-side
   rendering — the pages stay separate documents, fetched and parsed the way
   they always were. Both ends of a navigation have to opt in, which every page
   here does by way of this one shared stylesheet; browsers that don't know the
   at-rule drop it and navigate exactly as before. */
@view-transition {
  navigation: auto;
}

/* Liquid glass. The page being left sinks back where it stands — dimming
   into the dark and leaning in a touch, as if seen through thicker glass.
   The page arriving rises over it as a pane of glass of its own: rounded, a
   rim of light on its edge, see-through as it comes, then solid as it lands — overshooting by a hair and settling
   back, the way liquid does. The header holds still through all of it
   (named further down), so the change happens under a bar that never moves.

   A slow device gets the plain curtain instead: the old page lifts away, the
   new one rises, over the grey backdrop. */
:root {
  --glass-rise: cubic-bezier(.32, .72, .24, 1);
  --curtain-ease: cubic-bezier(.76, 0, .24, 1);
  --curtain-cell: clamp(2.5rem, 5vmin, 3.75rem);
  --curtain-backdrop:
    url("../assets/compressed/maezkev-logo.svg") center / clamp(4.5rem, 10vmin, 6.5rem) no-repeat,
    repeating-linear-gradient(to right, rgba(255, 255, 255, .07) 0 1px, transparent 1px var(--curtain-cell)),
    repeating-linear-gradient(to bottom, rgba(255, 255, 255, .07) 0 1px, transparent 1px var(--curtain-cell)),
    radial-gradient(120% 90% at 50% 45%, #4A4A4E 0%, #38383C 45%, #26262A 100%);
}

/* Under both pages. The frosted page covers it edge to edge, so it is only
   ever seen around the plain curtain on a slow device. */
::view-transition {
  background: var(--c-bg);
}

/* Stacked, not blended: plus-lighter, the default, is for a cross-fade. */
::view-transition-image-pair(root) {
  isolation: auto;
}

::view-transition-old(root),
::view-transition-new(root) {
  mix-blend-mode: normal;
}

::view-transition-old(root) {
  animation: glass-frost .5s var(--ease) both;
}

/* The pane: rounded, a hairline of light round its edge (an outline, drawn
   over the picture rather than under it), and a shadow under its leading
   edge. All three are gone by the time it lands, so it lands as the page. */
::view-transition-new(root) {
  overflow: clip;
  outline: 1px solid rgba(255, 255, 255, .28);
  outline-offset: -1px;
  box-shadow: 0 -1.25rem 3.5rem -1rem rgba(0, 0, 0, .6);
  animation: glass-rise .85s var(--glass-rise) .22s both;
}

/* No blur, on either page. An animated blur cannot be handed to the
   graphics chip, so it held the whole change to the page's own frame rate —
   half the frames of it, measured, came out late, which was the stutter. A
   blur that stays still runs on the chip but costs it a whole Retina screen
   of blurring a frame, and it dropped frames outright. Sinking back into the
   dark reads as the same depth, and moves on the chip alone. */
@keyframes glass-frost {
  to {
    transform: scale(1.04);
    opacity: .45;
  }
}

@keyframes glass-rise {
  0% {
    transform: translateY(70%) scale(.92);
    border-radius: 2rem;
    outline-color: rgba(255, 255, 255, .28);
    opacity: .55;
  }

  55% {
    opacity: 1;
  }

  /* A hair past where it belongs, then back: liquid, not mechanical. */
  78% {
    transform: translateY(-.6%) scale(1.004);
    border-radius: 1.25rem;
    outline-color: rgba(255, 255, 255, .2);
  }

  100% {
    transform: none;
    border-radius: 0;
    outline-color: transparent;
    opacity: 1;
  }
}

/* The plain curtain, for a slow device (see lite in main.js). */
:root.is-lite::view-transition {
  background: var(--curtain-backdrop);
}

:root.is-lite::view-transition-old(root),
:root.is-lite::view-transition-new(root) {
  overflow: clip;
  outline: 0;
  box-shadow: 0 1.5rem 4rem -1rem rgba(0, 0, 0, .6);
}

:root.is-lite::view-transition-old(root) {
  animation: curtain-lift .6s var(--curtain-ease) both;
}

:root.is-lite::view-transition-new(root) {
  animation: curtain-rise .7s var(--curtain-ease) .3s both;
}

@keyframes curtain-lift {
  0% {
    transform: none;
    border-radius: 0;
  }

  25% {
    border-radius: 1.5rem;
  }

  100% {
    transform: translateY(-104%) scale(.94);
    border-radius: 1.5rem;
  }
}

@keyframes curtain-rise {
  0% {
    transform: translateY(104%) scale(.94);
    border-radius: 1.5rem;
  }

  75% {
    border-radius: 1.5rem;
  }

  100% {
    transform: none;
    border-radius: 0;
  }
}

/* The bar is the same on every page, so it is lifted out of the transition
   and cross-faded where it stands. The pages changing behind a header that
   holds still is the whole reason for naming it separately.

   Named on the inner bar and not on .site-header itself, which is the element
   the eye would call the header. A view-transition-name makes an element a
   backdrop root, and .site-header is the ancestor of the four progressive-blur
   layers — rooted there, they have nothing behind them left to sample but the
   header's own transparent background, and the blur silently renders as
   nothing at all. The blur is on show at every moment of every page; the held
   bar is under a second of one navigation. So the name goes to the one child
   that has no backdrop-filter under it, and both survive. */
.header-inner {
  view-transition-name: site-header;
}

::view-transition-old(site-header),
::view-transition-new(site-header) {
  animation-duration: .3s;
  animation-timing-function: var(--ease-soft);
}

/* What the script falls back to where cross-document transitions aren't
   available, drawn over the page being left: a sheet of frosted glass comes
   over it (.page-ink, blurring what is behind it), and the site's own dark
   rises close behind as a pane with the same rim of light — so the last
   thing seen is the colour the next page opens on, and the cut to it can't
   be seen. Lite mode takes the blur out of the glass, as it does everywhere,
   and leaves it a tint. */
.page-ink,
.page-fade {
  position: fixed;
  inset: 0;
  /* Over the custom cursor, which has nothing left to point at. */
  z-index: 10000;
  pointer-events: none;
  visibility: hidden;
}

.page-ink {
  background: rgba(60, 60, 64, .28);
  -webkit-backdrop-filter: blur(18px) saturate(1.5) brightness(1.08);
  backdrop-filter: blur(18px) saturate(1.5) brightness(1.08);
  opacity: 0;
}

.page-fade {
  border-radius: 2rem 2rem 0 0;
  background: var(--c-bg);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .28), 0 -1.25rem 3.5rem -1rem rgba(0, 0, 0, .6);
  transform: translateY(104%);
}

.page-ink.is-in,
.page-fade.is-in {
  visibility: visible;
  /* Swallows a second click while the first one is still on its way. */
  pointer-events: auto;
}

.page-ink.is-in {
  animation: glass-in .4s var(--ease) both;
}

.page-fade.is-in {
  animation: curtain-up .5s var(--glass-rise) .18s both;
}

@keyframes glass-in {
  to {
    opacity: 1;
  }
}

@keyframes curtain-up {
  to {
    transform: none;
  }
}

@media (prefers-reduced-motion: reduce) {

  /* The blanket rule further down reaches *::before and *::after; these
     pseudo-elements are neither, so they have to be said outright. With no
     animation left to wait on the transition ends at once and the page simply
     swaps, which is the point. */
  ::view-transition-group(*),
  ::view-transition-old(*),
  ::view-transition-new(*) {
    animation: none !important;
  }
}

/* ============ END OF PAGE TRANSITIONS ============ */

h1,
h2,
h3,
h4,
h5,
h6 {
  margin: 0;
  font-weight: var(--fw-semibold);
  line-height: var(--lh-heading);
}

p {
  margin: 0;
}

img,
svg {
  display: block;
  max-width: 100%;
}

a {
  color: inherit;
  text-decoration: none;
}

/* ============ START OF LINK UNDERLINE ============ */
/* One shared definition for every plain-text link on the site — grouped onto
   whichever selector needs it rather than repeated per component. .link-underline
   is the class to reach for on any future inline link; the others already carry
   their own selector, so joining them here costs nothing in the markup.

   The line is a pseudo-element, not text-decoration: a native underline can
   only fade in, not grow — and it can only be revealed by hover/focus, not
   animated onto the page in a single call. Sitting a fifth of an em below the
   text rather than tight against it keeps it clear of descenders (g, y, p),
   and scaleX rather than width means revealing it costs a composite, not a
   layout pass. */
.link-underline,
.hero__status-text a,
.footer-list a,
.case-meta__link {
  position: relative;
}

.link-underline::after,
.hero__status-text a::after,
.footer-list a::after,
.case-meta__link::after {
  content: "";
  position: absolute;
  left: 0;
  right: 0;
  bottom: -0.2em;
  height: 1px;
  background: currentColor;
  transform: scaleX(0);
  transform-origin: left;
  transition: transform .3s var(--ease);
}

.link-underline:hover::after,
.link-underline:focus-visible::after,
.hero__status-text a:hover::after,
.hero__status-text a:focus-visible::after,
.footer-list a:hover::after,
.footer-list a:focus-visible::after,
.case-meta__link:hover::after,
.case-meta__link:focus-visible::after {
  transform: scaleX(1);
}

/* ============ END OF LINK UNDERLINE ============ */

ul {
  margin: 0;
  padding: 0;
  list-style: none;
}

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

.skip-link {
  position: absolute;
  left: -624.9375rem;
  top: 0;
  z-index: 100;
  padding: var(--space-16) var(--space-24);
  background: var(--c-blue);
  color: var(--c-white);
}

.skip-link:focus {
  left: 0;
}

/* ============ START OF BUTTONS ============ */
.btn {
  position: relative;
  isolation: isolate;
  overflow: hidden;
  /* clips the water to the pill */
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  /* Bottom padding runs 2px short of the top: Poppins' baseline sits low in the
     line box, so an even pad reads bottom-heavy. */
  padding: var(--space-16) var(--space-24) calc(var(--space-16) - 2px);
  border: 1px solid var(--c-navy);
  border-radius: var(--radius-4xl);
  /* A <button> takes the browser's own face rather than the page's; a link
     inherits it. Said here so either element reads in Poppins. */
  font-family: inherit;
  font-size: var(--fs-base);
  font-weight: var(--fw-regular);
  line-height: var(--lh-none);
  white-space: nowrap;
  cursor: var(--cursor-pointer);
  /* The label flips as the water passes it, so it never sits half-legible in a
     blend of the outgoing and incoming colours. */
  transition: background-color .3s var(--ease), color .3s var(--ease), border-color .3s var(--ease), transform .2s ease-out;
}

.btn--sm {
  padding: var(--space-12) var(--space-16);
  border-radius: var(--radius-4xl);
  font-size: var(--fs-sm);
}

.btn>* {
  position: relative;
  z-index: 1;
}

/* Primary: green glass — the fill, a gloss across its upper half, light on
   the rim and a glow of its own colour under it. Secondary: clear glass with
   the blue in the words, filling with blue on hover under the same gloss —
   background-color alone changes, so the fill eases in rather than
   snapping. */
.btn--primary {
  background: linear-gradient(180deg, rgba(255, 255, 255, .28), rgba(255, 255, 255, 0) 55%), var(--c-green);
  border-color: rgba(255, 255, 255, .22);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, .45), inset 0 -1px 0 rgba(0, 0, 0, .12),
    0 .5rem 1.25rem -.5rem rgba(71, 207, 115, .5);
  color: var(--c-bg);
}

/* A button's hover waits, wherever the cursor's glass is about, until the
   button is fully hovered — the ring half over it, the pointer on it — when
   the glass starts to wrap it. Until then the script marks it .is-reaching.
   In :where(), so the wait adds nothing to the rule's weight. */
.btn--primary:hover:where(:not(.is-reaching)),
.btn--primary:focus-visible {
  /* Keep the fill green on hover */
  border-color: var(--c-green);
}

.btn--secondary {
  background: var(--glass-clear);
  border-color: var(--glass-edge);
  box-shadow: var(--glass-rim-bright);
  -webkit-backdrop-filter: var(--glass-clear-blur);
  backdrop-filter: var(--glass-clear-blur);
  color: var(--c-blue);
}

.btn--secondary:hover:where(:not(.is-reaching)),
.btn--secondary:focus-visible {
  color: var(--c-bg);
  border-color: var(--c-blue);
  background-color: var(--c-blue);
}

/* Staggered Rolling Text Animation */
.btn__text-roller {
  display: inline-flex;
}

.btn__word {
  display: inline-flex;
}

.btn__char {
  display: inline-flex;
  overflow: hidden;
}

.btn__char-inner {
  display: inline-flex;
  position: relative;
  transition: transform 0.5s cubic-bezier(0.16, 1, 0.3, 1);
  transition-delay: calc(var(--char-index) * 0.02s);
}

.btn__char-inner::after {
  content: attr(data-char);
  position: absolute;
  top: 100%;
  left: 0;
}

.btn:hover:where(:not(.is-reaching)) .btn__char-inner,
.btn:focus-visible .btn__char-inner {
  transform: translateY(-100%);
}

/* ============ END OF BUTTONS ============ */

/* ============ START OF HEADER ============ */
.site-header {
  position: sticky;
  top: 0;
  z-index: 50;
  background: transparent;
}

/* Progressive blur: four backdrop-filter layers of rising radius, each masked
   to end sooner than the one below it. Where they overlap the blur compounds,
   so it is heaviest at the top and tapers to nothing past the bar. */
.nav-blur {
  position: absolute;
  top: 0;
  left: 0;
  right: 0;
  height: calc(var(--header-h) + 4.125rem);
  pointer-events: none;
}

.nav-blur span {
  position: absolute;
  inset: 0;
  display: block;
}

.nav-blur span:nth-child(1) {
  -webkit-backdrop-filter: blur(1px);
  backdrop-filter: blur(1px);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 70%, transparent 100%);
  mask-image: linear-gradient(to bottom, #000 0%, #000 70%, transparent 100%);
}

.nav-blur span:nth-child(2) {
  -webkit-backdrop-filter: blur(0.25rem);
  backdrop-filter: blur(0.25rem);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 45%, transparent 75%);
  mask-image: linear-gradient(to bottom, #000 0%, #000 45%, transparent 75%);
}

.nav-blur span:nth-child(3) {
  -webkit-backdrop-filter: blur(0.625rem);
  backdrop-filter: blur(0.625rem);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 25%, transparent 50%);
  mask-image: linear-gradient(to bottom, #000 0%, #000 25%, transparent 50%);
}

.nav-blur span:nth-child(4) {
  -webkit-backdrop-filter: blur(1.25rem);
  backdrop-filter: blur(1.25rem);
  -webkit-mask-image: linear-gradient(to bottom, #000 0%, #000 8%, transparent 28%);
  mask-image: linear-gradient(to bottom, #000 0%, #000 8%, transparent 28%);
}

.header-inner {
  /* Overrides .container's centered 71.25rem wrap: the bar runs closer to the
     viewport edges instead of sitting inside the content measure. */
  max-width: none;
  /* Center the logo and button in the gap between the viewport edges and the main content (71.25rem wide) */
  padding-inline: max(var(--gutter), calc((50vw - 35.625rem) / 2));
  position: relative;
  /* above the blur layers */
  z-index: 1;
  display: flex;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-16);
  min-height: var(--header-h);
}

/* A circle round the mark, though nothing draws it: it is the shape the
   cursor's glass takes round the logo when the two merge (the merge reads it
   off the element, and pours 6px of glass beyond it — 72px across in all),
   and the shape of its focus ring. Round, not the site's squircle: the mark
   is a coin, not a button.

   Centred on the mark by eye rather than by its box. The box is square on
   the artwork, but the weight is not: a solid stem on the left against a
   thin triangle on the right puts the ink's centre of mass 3.9px left of the
   middle, and a circle round the box leaves the right side looking empty. So
   the circle is centred 2px left, half way between the two — 8px of padding
   on the left, 4px on the right — and the negative margins give the same
   back, so the mark itself does not move. */
.brand {
  display: flex;
  padding: .375rem .25rem .375rem .5rem;
  margin: -.375rem -.25rem -.375rem -.5rem;
  border-radius: var(--radius-circle);
  corner-shape: round;
}

.brand img {
  width: 3rem;
  height: 3rem;
}

/* The command palette's hint. Built by the script only where a pointer hovers,
   and hidden here below the desktop line as well, so a narrow window keeps the
   header to the logo and the one button. margin-left: auto is what keeps it
   beside that button — the bar is space-between, which would otherwise stand
   it in the middle. */
.cp-hint {
  display: none;
}

@media (hover: hover) and (pointer: fine) and (min-width: 55.0625rem) {
  /* A quiet aside to the button, not a second one: shorter than Work With Me
     and centred on it, a hairline and next to no fill, and a touch dimmed
     until the pointer or the keyboard comes to it — then it reads in full. */
  .cp-hint {
    display: inline-flex;
    align-items: center;
    gap: var(--space-4);
    height: 2rem;
    margin-left: auto;
    padding: 0 0.625rem;
    border: 1px solid var(--c-hairline);
    border-radius: var(--radius-pill);
    background: rgba(255, 255, 255, .03);
    font: inherit;
    font-size: var(--fs-3xs);
    color: var(--c-muted);
    opacity: .7;
    cursor: var(--cursor-pointer);
    transition: color .2s var(--ease), border-color .2s var(--ease),
      background-color .2s var(--ease), opacity .2s var(--ease);
  }

  .cp-hint svg {
    width: .75rem;
    height: .75rem;
    margin-right: .125rem;
  }

  .cp-hint kbd {
    min-width: 1.125rem;
    padding: .0625rem .25rem;
    border: 1px solid var(--c-hairline);
    border-radius: var(--radius-xs);
    background: rgba(255, 255, 255, .03);
    font-family: inherit;
    font-size: var(--fs-3xs);
    line-height: 1.4;
    text-align: center;
    color: inherit;
  }

  .cp-hint:hover:where(:not(.is-reaching)),
  .cp-hint:focus-visible {
    border-color: var(--glass-edge);
    background-color: rgba(255, 255, 255, .06);
    color: var(--c-white);
    opacity: 1;
  }
}

.nav-toggle {
  display: none;
  flex-direction: column;
  justify-content: center;
  /* 2px bar + 0.3125rem gap = the 0.4375rem pitch the open-state transforms assume, so
     this pair stays literal and stays together. */
  gap: 0.3125rem;
  width: 2.75rem;
  height: 2.75rem;
  padding: 0;
  background: none;
  border: 0;
  cursor: var(--cursor-pointer);
}

.nav-toggle span {
  display: block;
  height: 2px;
  width: 1.5rem;
  margin-inline: auto;
  background: var(--c-white);
  transition: transform .3s var(--ease), opacity .2s linear;
}

.nav-toggle[aria-expanded="true"] span:nth-child(1) {
  transform: translateY(0.4375rem) rotate(45deg);
}

.nav-toggle[aria-expanded="true"] span:nth-child(2) {
  opacity: 0;
}

.nav-toggle[aria-expanded="true"] span:nth-child(3) {
  transform: translateY(-0.4375rem) rotate(-45deg);
}

.mobile-nav {
  position: relative;
  /* above the blur layers */
  z-index: 1;
  border-top: 1px solid var(--c-line);
  padding: var(--space-24) var(--gutter) var(--space-32);
  background: var(--c-bg);
}

.mobile-nav[hidden] {
  display: none;
}

.mobile-nav__actions {
  display: grid;
  gap: var(--space-16);
  justify-items: start;
}

/* ============ END OF HEADER ============ */

/* ============ START OF HERO ============ */
.hero {
  position: relative;
  /* anchors the scroll cue */
  overflow: hidden;
  /* clips the parallax layers as they lag */
  display: flex;
  align-items: stretch;
  min-height: 100vh;
  /* The header is sticky, so it holds 5.25rem of flow above this section. Pull the
     hero back up under it and pad the content down instead — otherwise the top
     of the page is a band of body background rather than the gradient. */
  margin-top: calc(var(--header-h) * -1);
  padding: calc(var(--header-h) + var(--space-48)) var(--gutter) var(--space-64);
}

/* Particle field. pointer-events stay off — the pointer is tracked on the
   section itself, so the canvas never intercepts a click. */
.hero__particles {
  position: absolute;
  inset: 0;
  z-index: 0;
  pointer-events: none;
}

/* Positioned children paint above the canvas without needing a negative index. */
.hero__left,
.hero__right {
  position: relative;
  z-index: 1;
}

.hero__left {
  display: flex;
  flex-direction: column;
  justify-content: center;
  align-items: flex-end;
  flex: 0 0 30%;
  padding-right: var(--space-32);
}

.hero__kicker {
  font-size: var(--fs-base);
  font-weight: var(--fw-regular);
  /* Stated rather than inherited: this was an h5 and took the heading
     line-height with it. As a paragraph it would fall to the body's 1.6 and
     grow its line box, which shifts the whole centred column beneath it. */
  line-height: var(--lh-heading);
  color: var(--c-white);
}

.hero__status {
  display: flex;
  align-items: center;
  gap: var(--space-16);
}

/* Availability dot — solid core with a halo that swells and fades out. The
   halo starts at opacity 0 so it stays invisible under reduced motion. */
.status-dot {
  position: relative;
  flex: none;
  width: 0.625rem;
  height: 0.625rem;
  border-radius: var(--radius-circle);
  corner-shape: round;
  background: var(--c-green);
}

.status-dot::after {
  content: "";
  position: absolute;
  inset: 0;
  border-radius: var(--radius-circle);
  corner-shape: round;
  background: var(--c-green);
  opacity: 0;
  animation: statusPulse 2.4s ease-out infinite;
}

@keyframes statusPulse {
  0% {
    transform: scale(1);
    opacity: .5;
  }

  70% {
    transform: scale(4);
    opacity: 0;
  }

  100% {
    transform: scale(4);
    opacity: 0;
  }
}

.hero__status-text {
  font-size: var(--fs-3xl);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-none);
  color: var(--c-white);
}

.hero__status-text a {
  transition: color .3s var(--ease);
}

.hero__status-text a:hover {
  color: var(--c-green);
}

/* The card behind "Open to Work": what Kevin does now, the roles he is after,
   where and what time it is there, and the two places to go next. It opens on
   hover and on focus, stays open while it is hovered or has focus — Tab from
   the link goes straight on into its links — and closes on Escape, which is
   what WCAG 1.4.13 asks of content that appears on hover. Touch screens never
   see it: there the link simply goes to LinkedIn, as it always did. */
.hero__status {
  position: relative;
}

.status-card {
  display: none;
}

@media (hover: hover) and (pointer: fine) {
  .status-card {
    position: absolute;
    top: calc(100% + var(--space-16));
    right: 0;
    z-index: 5;
    display: block;
    width: 23rem;
    padding: var(--space-16);
    border: 1px solid var(--glass-edge);
    border-radius: var(--radius-lg);
    background: var(--glass-frost);
    -webkit-backdrop-filter: var(--glass-frost-blur);
    backdrop-filter: var(--glass-frost-blur);
    box-shadow: var(--glass-rim), var(--shadow-menu);
    text-align: left;
    opacity: 0;
    visibility: hidden;
    transform: translateY(-.25rem);
    /* Visibility waits out the fade on the way out, and not on the way in. */
    transition: opacity .2s var(--ease), transform .2s var(--ease), visibility 0s linear .2s;
  }

  /* The gap between the link and the card is part of the hover, or crossing it
     on the way to reading the card would close it. */
  .status-card::before {
    content: "";
    position: absolute;
    right: 0;
    bottom: 100%;
    left: 0;
    height: var(--space-16);
  }

  .hero__status:hover .status-card,
  .hero__status:focus-within .status-card {
    opacity: 1;
    visibility: visible;
    transform: none;
    transition: opacity .2s var(--ease), transform .2s var(--ease), visibility 0s;
  }

  .hero__status.is-dismissed .status-card {
    opacity: 0;
    visibility: hidden;
  }

  /* Opened upwards where there is no room for it below (see initStatusCard),
     with the bridge across the gap moved to match. */
  .hero__status.is-above .status-card {
    top: auto;
    bottom: calc(100% + var(--space-16));
    transform: translateY(.25rem);
  }

  .hero__status.is-above .status-card::before {
    top: 100%;
    bottom: auto;
  }

  .hero__status.is-above:hover .status-card,
  .hero__status.is-above:focus-within .status-card {
    transform: none;
  }
}

.status-card__title {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: var(--space-8);
  margin: 0 0 var(--space-4);
  font-size: var(--fs-sm);
  font-weight: var(--fw-semibold);
  color: var(--c-white);
}

.status-card__dot {
  flex: none;
  width: .5rem;
  height: .5rem;
  border-radius: var(--radius-circle);
  corner-shape: round;
  background: var(--c-green);
}

/* Under the title, lined up with its words rather than its dot. */
.status-card__now {
  margin: 0 0 var(--space-12) calc(.5rem + var(--space-8));
  font-size: var(--fs-2xs);
  color: var(--c-muted);
}

.status-card__facts {
  display: grid;
  gap: var(--space-12);
  margin: 0;
}

.status-card__facts div {
  display: grid;
  grid-template-columns: 4.75rem 1fr;
  gap: var(--space-8);
}

.status-card__facts dt {
  font-size: var(--fs-2xs);
  color: var(--c-muted);
}

.status-card__facts dd {
  margin: 0;
  font-size: var(--fs-2xs);
  line-height: var(--lh-snug);
  color: var(--c-white);
}

/* The roles as chips, so the three read at a glance rather than as a list. */
.status-card__roles {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-4);
  margin: 0;
  padding: 0;
  list-style: none;
}

.status-card__roles li {
  padding: .125rem var(--space-8);
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-pill);
  background: var(--glass-clear);
  box-shadow: var(--glass-rim);
  font-size: var(--fs-3xs);
  line-height: var(--lh-snug);
}

/* The roles rolling through one at a time after "Open to", as words alone,
   in the title's own type (see initStatusCard): each one holds, then the
   next climbs into its place, and the edges of the window fade so a word
   arrives rather than appearing. Only while the card is open; closed,
   nothing is rolling. */
.status-card__roles.is-rolling {
  display: inline-flex;
  flex-direction: column;
  flex-wrap: nowrap;
  gap: 0;
  box-sizing: content-box;
  height: 1.5rem;
  /* A word's space after "Open to" rather than the title's wider gap, so
     the two read as one line. */
  margin-left: -.25rem;
  overflow: hidden;
  -webkit-mask-image: linear-gradient(to bottom, transparent, #000 30%, #000 70%, transparent);
  mask-image: linear-gradient(to bottom, transparent, #000 30%, #000 70%, transparent);
}

.status-card__roles.is-rolling li {
  display: flex;
  flex: none;
  align-items: center;
  height: 1.5rem;
  padding: 0;
  border: 0;
  border-radius: 0;
  background: none;
  box-shadow: none;
  line-height: var(--lh-none);
  font-size: inherit;
  font-weight: inherit;
  white-space: nowrap;
}

.hero__status.is-live .status-card__roles.is-rolling li {
  animation: roleRoll 7.5s var(--ease) infinite;
}

/* Three roles and the first again: a hold, then a climb, three times, landing
   back on the first exactly where it started. In rem rather than %, which
   keeps the keyframes on the compositor. */
@keyframes roleRoll {
  0%,
  26% {
    transform: translateY(0);
  }

  33.33%,
  59.33% {
    transform: translateY(-1.5rem);
  }

  66.66%,
  92.66% {
    transform: translateY(-3rem);
  }

  100% {
    transform: translateY(-4.5rem);
  }
}

/* ---- The dial ----
   Drawn by the script; the stylesheet colours it and moves it. Green is
   Jakarta and blue is the reader, as everywhere else on the card: the status
   dot's green, the site's own blue for whoever is looking. */
.status-card__facts .status-card__clock {
  display: block;
}

.status-card__clock dd {
  display: flex;
  align-items: center;
  gap: var(--space-16);
}

.status-dial {
  flex: none;
  width: 8rem;
  height: 8rem;
  overflow: visible;
}

.status-dial__sky {
  pointer-events: none;
}

.status-dial__sun {
  fill: rgba(255, 255, 255, .7);
}

.status-dial__moon {
  fill: rgba(255, 255, 255, .35);
}

.status-dial__ticks path {
  stroke: rgba(255, 255, 255, .16);
  stroke-width: 1;
  stroke-linecap: round;
}

.status-dial__ticks .is-major {
  stroke: rgba(255, 255, 255, .4);
}

.status-dial__track {
  fill: none;
  stroke: rgba(255, 255, 255, .07);
  stroke-width: 6;
}

/* Each working day draws itself in along its ring as the card opens. */
.status-dial__band {
  fill: none;
  stroke-width: 6;
  stroke-linecap: round;
  stroke-dasharray: 1 1;
  stroke-dashoffset: 1;
}

.status-dial__band--there {
  stroke: var(--c-green);
}

.status-dial__band--yours {
  stroke: var(--c-blue);
}

/* Where the two days meet, lit between the rings once the reader's has
   turned into place. */
.status-dial__band--meet {
  stroke: var(--c-white);
  stroke-width: 2;
}

.status-dial__yours,
.status-dial__hand,
.status-dial__probe {
  transform-box: view-box;
  transform-origin: 70px 70px;
}

.status-dial__hand path {
  stroke: rgba(255, 255, 255, .75);
  stroke-width: 1.5;
  stroke-linecap: round;
}

.status-dial__pin--there,
.status-dial__pulse {
  fill: var(--c-green);
}

.status-dial__pin--yours {
  fill: var(--c-blue);
}

.status-dial__pin--there,
.status-dial__pin--yours {
  stroke: var(--c-bg);
  stroke-width: 1.5;
}

/* Now, in Jakarta, beats like the status dot it stands for. */
.status-dial__pulse {
  opacity: 0;
  transform-box: fill-box;
  transform-origin: center;
}

.hero__status.is-live .status-dial__pulse {
  animation: statusPulse 2.4s ease-out infinite;
}

.status-dial__probe {
  opacity: 0;
  transition: opacity .15s var(--ease);
}

.status-dial__probe path {
  stroke: var(--c-white);
  stroke-width: 1;
  stroke-dasharray: 2 2.5;
}

.status-dial.is-probing .status-dial__probe {
  opacity: 1;
}

.status-dial__hand {
  transition: opacity .15s var(--ease);
}

.status-dial.is-probing .status-dial__hand {
  opacity: .3;
}

.status-dial__read {
  fill: var(--c-white);
  font-size: 15px;
  font-weight: var(--fw-semibold);
  font-variant-numeric: tabular-nums;
  letter-spacing: -.01em;
  text-anchor: middle;
}

.status-dial__sub {
  fill: var(--c-muted);
  font-size: 8.5px;
  text-anchor: middle;
}

/* No offset to read for the reader: Jakarta's ring alone. */
.status-dial.is-alone .status-dial__yours,
.status-dial.is-alone .status-dial__pin--yours {
  display: none;
}

/* Opened: the hand sweeps on to now, the reader's ring turns by the hours
   between the two clocks, and the days draw in — the overlap last, once there
   is one to show. Closed, the script puts it all back without a transition,
   so the next open plays again. */
.hero__status.is-live .status-dial__band {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset .8s var(--ease-soft) .1s;
}

.hero__status.is-live .status-dial__band--meet {
  transition-delay: 1.05s;
}

.hero__status.is-live .status-dial__hand {
  transition: transform .9s var(--ease-soft) .1s, opacity .15s var(--ease);
}

.hero__status.is-live .status-dial__yours {
  transition: transform 1s var(--ease-soft) .3s;
}

/* Asked for less motion, the dial is simply drawn as it stands: the blanket
   rule takes the durations to nothing, and this takes away the waits, which
   it leaves alone — the overlap would otherwise appear a second late. */
@media (prefers-reduced-motion: reduce) {
  .hero__status.is-live .status-dial__band,
  .hero__status.is-live .status-dial__hand,
  .hero__status.is-live .status-dial__yours {
    transition-delay: 0s !important;
  }
}

/* Beside the dial, the place and its time in words. */
.status-card__where {
  display: flex;
  flex-direction: column;
  gap: .125rem;
  min-width: 0;
}

.status-card__city {
  font-size: var(--fs-3xs);
  color: var(--c-muted);
}

.status-card__time {
  font-size: var(--fs-sm);
  font-weight: var(--fw-semibold);
  color: var(--c-white);
}

/* What the rings are, keyed in their own colours. */
.status-card__legend {
  display: grid;
  gap: .3125rem;
  margin-top: var(--space-12);
}

.status-card__key {
  display: flex;
  align-items: center;
  gap: .375rem;
  font-size: var(--fs-3xs);
  line-height: var(--lh-snug);
  color: var(--c-muted);
}

.status-card__key::before {
  content: "";
  flex: none;
  width: .75rem;
  height: .1875rem;
  border-radius: var(--radius-pill);
  background: var(--c-green);
}

.status-card__key--yours::before {
  background: var(--c-blue);
}

.status-card__key--meet {
  color: var(--c-white);
}

.status-card__key--meet::before {
  height: .125rem;
  background: var(--c-white);
}

/* Said to a screen reader, kept off the screen. */
.visually-hidden {
  position: absolute !important;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* How far the clock there is from the reader's own — filled in by the script,
   and simply absent where it cannot tell. */
.status-card__gap {
  display: block;
  font-size: var(--fs-3xs);
  color: var(--c-muted);
}

.status-card__gap:empty {
  display: none;
}

/* Where to go from here. LinkedIn leads, in the green of the status; the
   resume opens in the site's own viewer. Email stays with Work With Me. The
   cursor's glass merges with either, and the gap between them is the one
   between the header's hint and Work With Me: room for the glass round one,
   leaning towards the pointer, to stay off the other. */
.status-card__actions {
  display: flex;
  gap: var(--space-16);
  margin-top: var(--space-12);
  padding-top: var(--space-12);
  border-top: 1px solid var(--c-hairline);
}

.status-card__action {
  display: inline-flex;
  align-items: center;
  padding: var(--space-8) var(--space-12);
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-pill);
  background: var(--glass-clear);
  box-shadow: var(--glass-rim);
  font-size: var(--fs-2xs);
  font-weight: var(--fw-medium);
  line-height: var(--lh-none);
  color: var(--c-white);
  transition: border-color .2s var(--ease), background-color .2s var(--ease), color .2s var(--ease);
}

.status-card__action:hover:where(:not(.is-reaching)),
.status-card__action:focus-visible {
  border-color: var(--glass-edge-strong);
  background-color: rgba(255, 255, 255, .06);
}

.status-card__action--main {
  border-color: rgba(71, 207, 115, .4);
  color: var(--c-green);
}

.status-card__action--main:hover:where(:not(.is-reaching)),
.status-card__action--main:focus-visible {
  border-color: var(--c-green);
  background-color: rgba(71, 207, 115, .12);
}

.status-card__action:focus-visible {
  outline: 2px solid var(--c-blue);
  outline-offset: 2px;
}

.hero__right {
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: var(--space-4);
  flex: 1 1 70%;
}

/* The h1 holding the three lines of the name. It stands in for what used to be
   three sibling headings, so it carries the column and the gap they had from
   .hero__right — without it the lines would close up by the gap between them. */
.hero__title {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
}

.hero__name {
  overflow: hidden;
  /* the mask the line rises out of */
  font-size: var(--fs-hero);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-heading);
  color: var(--c-white);
}

.hero__name>span {
  display: block;
}

/* Staged entrance. Pure CSS, so it needs no trigger class and cannot leave the
   hero blank if the script fails. `backwards` rather than `both`: it holds the
   from-state through the delay but releases the element once it lands, leaving
   transform and opacity free for the scroll parallax to drive. */
@media (prefers-reduced-motion: no-preference) {
  .hero__name>span {
    animation: heroLine .9s var(--ease-soft) backwards;
  }

  .hero__name:nth-of-type(1)>span {
    animation-delay: .10s;
  }

  .hero__name:nth-of-type(2)>span {
    animation-delay: .20s;
  }

  .hero__name:nth-of-type(3)>span {
    animation-delay: .30s;
  }

  /* The role rises the way the name does, out of a mask, rather than fading
     up from nothing. Chrome does not count text at zero opacity as painted, so
     the fade held the page's largest text (and its LCP) back until it had
     finished: 1.5s on a throttled phone, against 0.25s for the wipe, which
     paints from its first frame. The timing is unchanged. */
  .hero .rotator {
    animation: heroWipe .8s var(--ease-soft) .48s backwards;
  }

  .hero__actions {
    animation: heroRise .8s var(--ease-soft) .60s backwards;
  }

  .hero__left {
    animation: heroRise .8s var(--ease-soft) .70s backwards;
  }

  .hero__cue {
    animation: heroCue .8s var(--ease-soft) .95s backwards;
  }
}

@keyframes heroWipe {
  from {
    clip-path: inset(-10% -5% 100% -5%);
    transform: translateY(1.125rem);
  }

  to {
    clip-path: inset(-10% -5% -10% -5%);
    transform: none;
  }
}

@keyframes heroLine {
  from {
    transform: translateY(110%);
  }

  to {
    transform: translateY(0);
  }
}

@keyframes heroRise {
  from {
    opacity: 0;
    transform: translateY(1.125rem);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

/* Scroll cue — a rail with a light travelling down it. */
.hero__cue {
  position: absolute;
  z-index: 1;
  left: 50%;
  bottom: var(--space-24);
  transform: translateX(-50%);
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-8);
  font-size: var(--fs-3xs);
  font-weight: var(--fw-regular);
  letter-spacing: var(--ls-caps-wide);
  text-transform: uppercase;
  color: var(--c-muted);
  transition: color .3s var(--ease);
}

.hero__cue:hover,
.hero__cue:focus-visible {
  color: var(--c-blue);
}

.hero__cue-line {
  position: relative;
  width: 1px;
  height: 2.875rem;
  overflow: hidden;
  background: var(--c-line);
}

.hero__cue-line i {
  position: absolute;
  inset: 0 0 auto 0;
  height: 45%;
  background: var(--c-blue);
  animation: cueTravel 2.1s var(--ease) infinite;
}

@keyframes cueTravel {
  0% {
    transform: translateY(-100%);
  }

  100% {
    transform: translateY(222%);
  }
}

@keyframes heroCue {
  from {
    opacity: 0;
    transform: translate(-50%, 1rem);
  }

  to {
    opacity: 1;
    transform: translate(-50%, 0);
  }
}

.rotator {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  /* em, not a spacing token: the word gap has to track the rotator's own
     font-size, which changes at the mobile breakpoint. */
  gap: .4em;
  font-size: var(--fs-2xl);
  font-weight: var(--fw-semibold);
  text-transform: capitalize;
  margin-top: var(--space-8);
}

.rotator__prefix {
  color: var(--c-white);
}

.rotator__slot {
  display: inline-block;
  overflow: hidden;
  height: 1.4em;
  padding: 0 var(--space-8) 0 0;
  color: var(--c-blue);
  vertical-align: bottom;
}

.rotator__inner {
  display: block;
  transition: transform .6s var(--ease);
}

.rotator__inner b {
  display: block;
  height: 1.4em;
  line-height: 1.4em;
  font-weight: var(--fw-semibold);
}

.hero__actions {
  margin-top: var(--space-24);
}

/* ============ END OF HERO ============ */

/* ============ START OF SECTIONS ============ */
.section {
  padding: var(--section-y) 0;
}

/* A label, not a control — it deliberately shares no styling with .btn, so it
   has no hover, no focus ring and nothing to click. */
.section__tag {
  /* Positioned for the flicks drawn off its corner (initDoodles in main.js).
     The sticky project labels say sticky in a more specific rule, and keep
     it. */
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  margin-bottom: var(--space-48);
  padding: var(--space-16) var(--space-24) calc(var(--space-16) - 2px);
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-4xl);
  background: var(--glass-clear);
  -webkit-backdrop-filter: var(--glass-clear-blur);
  backdrop-filter: var(--glass-clear-blur);
  box-shadow: var(--glass-rim-bright), 0 4px 30px rgba(0, 0, 0, 0.1);
  font-size: var(--fs-base);
  font-weight: var(--fw-regular);
  line-height: var(--lh-none);
  white-space: nowrap;
  color: var(--c-white);
}

.section__tag svg {
  flex: none;
}

.section--journey {
  background: radial-gradient(at center right, var(--c-plum) 0%, var(--c-bg) 35%);
}

.section--statement {
  background: radial-gradient(at center center, var(--c-navy) 0%, var(--c-bg) 55%);
}

.section--projects {
  background: radial-gradient(at center center, var(--c-navy) 0%, var(--c-bg) 65%);
}

/* ============ END OF SECTIONS ============ */

/* ============ START OF CAREER JOURNEY — STACKED FLIP ============

   Four entries share one stage. In the base state they are an ordinary
   vertical list, and each keeps its own <article>, so a date, role, company
   and description still read together and in order — for a screen reader,
   for anyone without JS, and for anyone who has asked for reduced motion.

   Once the script has measured the section it adds .is-stacked and the four
   move into the same box, one over another, each turned on X by how far it
   sits from the middle of the scroll.

   The detail columns either side are the point of the layout. They are what
   fills the margin a wide display would otherwise leave empty, which is why
   the card is the only column given a width and the asides take 1fr each:
   the wider the screen, the more room the writing gets.
   ================================================================= */
.section--journey {
  padding: 0;
}

.journey-scroll {
  position: relative;
}

/* Base / no-JS / reduced-motion: a plain stacked list, nothing pinned. */
.journey-sticky {
  display: flex;
  flex-direction: column;
  gap: var(--space-32);
  padding-block: var(--section-y);
}

.is-stacked .journey-sticky {
  position: sticky;
  top: 0;
  justify-content: center;
  gap: var(--space-24);
  height: 100vh;
  /* svh, not vh or dvh. On a phone vh is the tall viewport, so a pinned
     section is cropped while the address bar is showing; dvh is honest but
     changes as that bar retracts, and a pin that resizes mid-scroll drags the
     whole stack with it. svh is the small viewport and it does not move. */
  height: 100svh;
  padding-block: calc(var(--header-h) + var(--space-24)) var(--space-32);
  overflow: hidden;
}

.journey-head {
  flex: none;
  /* Above the stack. Entries travel upward past the top of the stage, and the
     stage does not clip them — only the pin does — so without this a card on
     its way out paints over the badge naming the section. The script keeps
     entries between 10 and 100. */
  position: relative;
  z-index: 300;
}

.journey-head .section__tag {
  margin-bottom: 0;
}

/* ============ Stage ============ */
.jn-stage {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: center;
  flex: 1 1 auto;
  min-height: 0;
}

.jn-entries {
  display: flex;
  flex-direction: column;
  gap: var(--space-48);
  width: 100%;
}

.is-stacked .jn-entries {
  display: block;
  position: relative;
  flex: 1 1 auto;
  min-height: 0;
  /* One vanishing point for all four. Set here rather than per card so they
     read as a single stack turning, not four unrelated flips. */
  perspective: 1600px;
  perspective-origin: 50% 45%;
}

/* ============ One entry: detail | card | detail ============

   Deliberately not held to --wrap. That column is sized for reading a page of
   body text and is the reason the section looked marooned on a wide display:
   the asides ended up inside the same 1140px the rest of the page uses, which
   is the opposite of laying them out in the whitespace.

   The width is a share of the viewport instead, so the section keeps the same
   proportion of the screen at any resolution — which is the one thing a rem
   width cannot do, since the root only grows 1.5x while a 4K viewport is 2.7x
   a 1440px one. The rem ceiling is there so an ultrawide does not stretch a
   single row across a metre of glass.

   The card follows the same rule for the same reason. The asides do not: text
   has a measure, so they are capped in rem and pushed against the card with
   justify-self, which keeps the writing beside what it describes rather than
   drifting off toward the screen edge. */
.jn-entry {
  display: grid;
  grid-template-columns: minmax(0, 1fr) clamp(17rem, 26vw, 36rem) minmax(0, 1fr);
  align-items: center;
  gap: clamp(1.25rem, 3vw, 5rem);
  width: min(79vw, 120rem);
  margin-inline: auto;
}

.jn-aside {
  min-width: 0;
  max-width: 30rem;
}

.jn-aside--left {
  justify-self: end;
}

.jn-aside--right {
  justify-self: start;
}

/* The five numbers the script writes every frame, registered so a write
   stops at the entry. An unregistered custom property inherits, so each one
   sent the whole card — the logo, the date, the role, the badge, the
   description, the duration mark — back through style resolution on every
   scroll frame, for values not one of those elements reads. Non-inheriting,
   a write restyles the entry and nothing under it.

   The initial values are the fallbacks the transform below already used, so
   a browser without @property ignores these and behaves exactly as before —
   only slower. */
@property --jn-y {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

@property --jn-z {
  syntax: "<length>";
  inherits: false;
  initial-value: 0px;
}

@property --jn-rot {
  syntax: "<angle>";
  inherits: false;
  initial-value: 0deg;
}

@property --jn-scale {
  syntax: "<number>";
  inherits: false;
  initial-value: 1;
}

@property --jn-op {
  syntax: "<number>";
  inherits: false;
  initial-value: 1;
}

.is-stacked .jn-entry {
  position: absolute;
  inset-inline: 0;
  top: 50%;
  transform-style: preserve-3d;
  /* The script writes the five numbers below and nothing else. Composition
     stays here, in one place, where it can be read and tuned. */
  transform:
    translate3d(0, calc(-50% + var(--jn-y, 0px)), var(--jn-z, 0px))
    rotateX(var(--jn-rot, 0deg))
    scale(var(--jn-scale, 1));
  opacity: var(--jn-op, 1);
  will-change: transform, opacity;
  pointer-events: none;
}

.is-stacked .jn-entry.is-current {
  pointer-events: auto;
}

/* ============ The whitespace columns ============ */
.jn-aside--left {
  text-align: right;
}

.jn-aside--right {
  text-align: left;
}

/* ============ Time between one role and the next ============

   A hairline standing in the gap above each date, as long as the time that
   passed before that role started. The dates are the only part of this section
   that makes a reader do arithmetic, so the line does the arithmetic for them
   and the label names the answer.

   Out of the flow on purpose. In it the line would grow the left aside and
   push the date off the card's centre line, and it would be counted into the
   entry height the stack measures its travel from. Absolute, it costs neither.

   aria-hidden in the markup: the dates already state the timeline, and a bare
   "1 year" announced ahead of "Feb 2024" is a fragment, not a fact. */
.jn-aside--left {
  position: relative;
}

.jn-gap {
  /* Length is the duration — the markup carries the months, this carries what
     a month is worth. The floor keeps a short step visible as a tick rather
     than as nothing; the ceiling stops a long gap from running off the top of
     the stage. */
  --jn-gap-h: min(
    clamp(1.5rem, calc(0.75rem + var(--jn-months, 0) * 0.4rem), 7rem),
    var(--jn-gap-room, 7rem));
  position: absolute;
  right: 0;
  /* Halfway between this card and the one before it. The script publishes the
     travel between two entries as --jn-lift; half of that above the aside's
     centre line — which is the card's centre line — puts the mark in the
     daylight between the two cards it measures, rather than tucked above the
     date of one of them. */
  bottom: calc(50% + var(--jn-lift, 22rem) / 2 - var(--jn-gap-h) / 2);
  height: var(--jn-gap-h);
  margin: 0;
}

/* Faded at both ends, so it reads as a trace between two dates rather than a
   rule with two hard stops. */
.jn-gap::before {
  content: "";
  position: absolute;
  inset-block: 0;
  right: 0;
  width: 1px;
  background: linear-gradient(
    to bottom,
    transparent,
    var(--c-line) 30%,
    var(--c-line) 70%,
    transparent);
}

.jn-gap__label {
  position: absolute;
  top: 50%;
  right: var(--space-12);
  transform: translateY(-50%);
  font-size: var(--fs-3xs);
  letter-spacing: .04em;
  text-transform: uppercase;
  white-space: nowrap;
  color: var(--c-muted);
}

/* Reduced motion gets the plain list instead: no travel to be halfway along,
   and the entries only 3rem apart. The mark goes back above the date, short
   enough to stay clear of the entry above it. Same encoding, scaled to the
   room it actually has. */
.journey-scroll:not(.is-stacked) .jn-gap {
  --jn-gap-h: clamp(0.75rem, calc(0.4rem + var(--jn-months, 0) * 0.12rem), 1.75rem);
  bottom: calc(100% + var(--space-12));
}

.jn-date {
  display: block;
  font-size: var(--fs-2xl);
  font-weight: var(--fw-semibold);
  color: var(--c-white);
  letter-spacing: -.01em;
}

.jn-role {
  margin-top: var(--space-8);
  font-size: var(--fs-md);
  color: var(--c-muted);
}

.jn-badge {
  display: inline-flex;
  align-items: center;
  margin-top: var(--space-12);
  padding: .1875rem var(--space-8);
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-pill);
  /* Clear glass without the blur: the entry is turned in 3D every frame, and
     all there is behind it is the section's own gradient. */
  background: var(--glass-clear);
  box-shadow: var(--glass-rim);
  font-size: var(--fs-3xs);
  font-weight: var(--fw-medium);
  letter-spacing: .02em;
  text-transform: uppercase;
  color: var(--c-muted);
}

/* Salaried work reads blue; the internships stay in the muted outline, so the
   two kinds of entry are told apart at a glance rather than by reading. */
.jn-badge--full-time {
  border-color: var(--c-blue-wash-strong);
  background-color: var(--c-blue-wash);
  color: var(--c-blue);
}

.jn-desc {
  font-size: var(--fs-md);
  line-height: 1.7;
  color: var(--c-muted);
  /* A measure, not a pixel width: the column grows with the screen but the
     line length stays readable, which is the limit that actually matters. */
  max-width: 36ch;
}

.jn-aside--left .jn-desc {
  margin-left: auto;
}

/* ============ Card ============ */
.jn-card {
  position: relative;
  display: flex;
  flex-direction: column;
  align-items: center;
  gap: var(--space-16);
  padding: var(--space-32) var(--space-24);
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-xl);
  background: var(--glass-card);
  box-shadow: var(--glass-rim-bright), var(--shadow-card);
  text-align: center;
  /* The card catches the light of the turn; without a backface the reverse
     side would show through as the entry passes the centre. */
  backface-visibility: hidden;
}

.jn-card__media {
  display: grid;
  place-items: center;
  /* 150% of the 3.5rem it was. The source marks are 100px square, so even at
     this size they are still being scaled down and stay sharp. */
  width: 5.25rem;
  height: 5.25rem;
}

.jn-card__media img {
  max-width: 100%;
  max-height: 100%;
  width: auto;
  height: auto;
  object-fit: contain;
}

.jn-card__company {
  /* A step below the role beside it. The logo above already names the company
     once, so this line is confirming it rather than announcing it — and the
     names are long enough that a size down buys back a wrapped line on the
     narrow cards without touching a word of them. */
  font-size: var(--fs-base);
  font-weight: var(--fw-semibold);
  color: var(--c-white);
  text-wrap: balance;
}

/* ============ Narrow screens ============
   There is no margin to lay anything out in, so the asides go back under the
   card and the whole thing is the plain list again. The script does not add
   .is-stacked below this width, so nothing here has to undo a transform. */
@media (max-width: 55rem) {
  /* A share of the viewport is the wrong measure on a phone — 79vw would leave
     the card in a gutter of its own. Back to the page's ordinary column. */
  .jn-entry {
    grid-template-columns: 1fr;
    gap: var(--space-16);
    justify-items: center;
    text-align: center;
    width: 100%;
    max-width: var(--wrap);
    padding-inline: var(--gutter);
  }

  .jn-aside {
    max-width: none;
    justify-self: center;
  }

  .jn-aside--left,
  .jn-aside--right {
    text-align: center;
  }

  .jn-aside--left {
    order: 1;
  }

  .jn-card {
    order: 2;
    width: 100%;
    max-width: 22rem;
    /* Tighter than the desktop card, and set here rather than under
       .is-stacked so it holds whether the stack is running or not — a reader
       on reduced motion gets the same card, not the roomy desktop one. */
    padding: var(--space-16);
    gap: var(--space-12);
  }

  .jn-aside--right {
    order: 3;
  }

  .jn-desc {
    max-width: 42ch;
    margin-inline: auto;
  }

  /* The stack runs down here too, so the entry has to survive being turned in
     a space roughly a third the height it gets on a desktop. Everything below
     buys that room back. */
  .is-stacked .jn-entries {
    /* A much nearer vanishing point. 1600px across a phone is so shallow an
       angle that the turn reads as a squash rather than a rotation. */
    perspective: 900px;
  }


  /* Desktop only. Down here the asides sit under the card, so there is no
     margin for the line to stand in and no date above it to reach back to. */
  .jn-gap {
    display: none;
  }

  .jn-card__media {
    width: 4.125rem;
    height: 4.125rem;
  }

  .is-stacked .jn-date {
    font-size: var(--fs-xl);
  }

  .is-stacked .jn-desc {
    font-size: var(--fs-sm);
    max-width: 34ch;
  }
}

/* ============ END OF CAREER JOURNEY — STACKED FLIP ============ */

/* ============ START OF STATEMENT ============ */
.statement {
  display: flex;
  flex-direction: column;
  align-items: center;
  justify-content: center;
  text-align: center;
  padding: var(--space-64) var(--space-24);
  border-radius: var(--radius-3xl);
  /* Frosted, because the icons fly past behind it: the glow that follows the
     pointer, over the glass. */
  background: radial-gradient(circle at var(--mouse-x, 50%) var(--mouse-y, 50%), rgba(255, 255, 255, 0.08) 0%, transparent 50%), var(--glass-frost);
  box-shadow: var(--glass-rim-bright), 0 0 80px var(--c-blue-wash);
  border: 1px solid var(--glass-edge);
  backdrop-filter: var(--glass-frost-blur);
  -webkit-backdrop-filter: var(--glass-frost-blur);
  transition: transform 0.1s;
}

.statement__line {
  max-width: 100%;
}

.statement__line b {
  font-size: var(--fs-statement);
  /* --fw-bold was never defined, so this resolved to nothing and the weight
     fell through to the h3's own 600. Naming the weight it was already
     rendering at changes no pixels, and takes the last claim on Poppins 700
     with it — which is why the font request no longer asks for it. */
  font-weight: var(--fw-semibold);
  text-transform: capitalize;
  background: linear-gradient(135deg, var(--c-white) 20%, var(--c-blue-hover) 50%, var(--c-white) 80%);
  background-size: 200% auto;
  -webkit-background-clip: text;
  background-clip: text;
  color: transparent;
  display: inline-block;
  transition: background-position 0.5s var(--ease);
}

.statement:hover .statement__line b {
  background-position: 200% center;
  transition: background-position 2s linear;
}

.clip {
  display: block;
  overflow: hidden;
  /* Matched top and bottom. The overflow here exists for the wipe, and padding
     on one side only left the glyphs sitting a pixel above the centre of the
     box — and clipped the ascenders on the way in. The wipe is horizontal
     (inset from the right), so vertical padding cannot affect it. */
  padding-block: .08em;
}

.clip b {
  display: block;
}

.is-visible .clip b {
  animation: clipIn 1s var(--ease) both;
}

@keyframes clipIn {
  from {
    clip-path: inset(0 100% 0 0);
    transform: translateY(.15em);
  }

  to {
    clip-path: inset(0 0 0 0);
    transform: translateY(0);
  }
}

.statement__line:last-child .clip b {
  animation-delay: .35s;
}

/* ============ END OF STATEMENT ============ */

/* ============ START OF UI FIELD ============

   A depth field of interface frames. Each card starts far back, travels toward
   the viewer, grows and brightens on the way, and is sent to the back again
   once it has passed. Perspective does the scaling, so the script only ever
   moves a card along z and the browser works out how big that makes it.

   The component is portable on purpose: one empty <div data-ui-field>, this
   block, and initUiField(). Nothing here knows which section it sits in, so
   moving it means moving the div — the only thing its host needs is
   position: relative and something to clip with.

   Only transform and opacity are animated, so the field stays on the
   compositor and never asks the page for layout while it runs.
   ============================================ */
.ui-field {
  position: absolute;
  inset: 0;
  z-index: 0;
  overflow: hidden;
  pointer-events: none;
  /* The vanishing point. Shallower than this and frames at the edges rush past
     at an angle that reads as splay rather than approach. */
  perspective: 1000px;
  perspective-origin: 50% 50%;
  /* Paint containment: a card crossing the edge is clipped here rather than
     being allowed to invalidate anything outside the section. */
  contain: layout paint;
}

.ui-field__card {
  position: absolute;
  left: 50%;
  top: 50%;
  /* Its size at z = 0. Perspective takes it from roughly a quarter of this at
     the far wall to about a third again on the closest pass. */
  width: 1.2rem;
  /* Square, which a 24x24 glyph already is — this only stops a stray viewBox
     from stretching one. */
  aspect-ratio: 1;
  /* Centred on its point by margin, half its own size, rather than by a
     -50% in the transform: a percentage there would keep the flight off the
     compositor. */
  margin: -0.6rem 0 0 -0.6rem;
  /* No tile, no shadow, no radius. A glyph carries its own silhouette, and a
     plate behind it at this size would read as a smudge rather than a card. */
  color: var(--c-line);
  /* Starts invisible, and fades up out of the distance on the way in. */
  opacity: 0;
  will-change: transform, opacity;
  /* One pass from the far wall to the near plane. The script supplies the
     scatter (on translate) and the depth each card starts at, as a negative
     delay. Running here rather than in a frame loop is the point: transform
     and opacity are the two properties the compositor can move without the
     main thread, and it will only take them as plain numbers. */
  animation: ui-field-fly var(--ui-field-duration, 17.733s) linear var(--delay, 0s) 1 both;
}

/* Held while the section is out of view. */
.ui-field.is-paused .ui-field__card {
  animation-play-state: paused;
}

/* No flight at all under reduced motion: the script sets each card down still,
   at its depth, instead. Pausing would not be enough — the blanket
   reduced-motion rule shortens every animation to almost nothing, and a flight
   that ends at once is recycled and restarted every frame. */
@media (prefers-reduced-motion: reduce) {
  .ui-field__card {
    animation: none;
  }
}

/* Depth only: the scatter is the card's own translate, which composes ahead
   of this. -2400px and 260px are FAR and PAST in home.js, which works the
   pass's duration out from them. The 45% keyframe is the fade: the first 45%
   of the depth is spent coming up out of nothing, the rest at full strength. */
@keyframes ui-field-fly {
  0% {
    transform: translateZ(-2400px);
    opacity: 0;
  }

  45% {
    opacity: 1;
  }

  100% {
    transform: translateZ(260px);
    opacity: 1;
  }
}

.ui-field__card svg {
  display: block;
  width: 100%;
  height: 100%;
}

/* The statement is the reason the field exists, so it stays above it — and its
   backdrop-filter blurs whatever is passing behind the glass. */
.section--statement {
  position: relative;
  overflow: hidden;
}

.section--statement>.container {
  position: relative;
  z-index: 1;
}

/* ============ END OF UI FIELD ============ */

/* ============ START OF PROJECTS ============ */
.projects {
  display: grid;
  gap: var(--section-y);
}

.project-block {
  display: block;
}

/* A deck of cards rather than a grid. Each one comes to rest under the header
   and stays there while the next climbs over it, so the work is read one at a
   time in the order it is laid out instead of taken in at a glance.

   Nothing here is pinned and no scroll is intercepted — the page moves at its
   own speed the whole way through, and a browser that ignores position: sticky
   is left with the same cards one under another, which is the same reading in
   a plainer form.

   Every card sits a step lower than the one before it, so the cards already
   dealt keep an edge showing above the current one rather than being buried.
   --i is a card's place in its own stack and comes from the markup. */
/* The label comes to rest with the deck rather than scrolling off above it.
   Three cards deep into a stack there is otherwise nothing on screen saying
   which group is being looked at, and the cards themselves never say so —
   naming the group once at the top only works while the top is still in view.

   It has to carry a background now that cards pass beneath it: an outline pill
   with nothing behind it would show whatever screenshot happens to be sliding
   under. Glass rather than a flat fill, so the card is still felt going past.

   A sticky label outlives its own deck if left to itself. A card releases when
   the bottom of its stack reaches the card's resting line plus a whole card
   height; the label releases when that same bottom reaches its own line plus
   fifty-odd pixels. The label therefore stays pinned for roughly a card's
   height after the last card has gone, and rides down over whatever is under
   it. --label-shift is the script carrying it back off in step with the deck,
   which is why the sticky part waits for has-sticky-label: with no script to
   take it away again, a label that never leaves is worse than one that never
   sticks. */
.project-block.has-sticky-label .section__tag {
  position: sticky;
  top: calc(var(--header-h) + var(--space-24));
  /* Above the cards, which are positioned too and would otherwise win it on
     document order alone. Well under the header's own 50. */
  z-index: 3;
  transform: translate3d(0, var(--label-shift, 0px), 0);
  /* Frosted rather than clear once it sticks: the cards' pictures slide
     under it, and the label has to read over them. */
  background: var(--glass-frost);
  -webkit-backdrop-filter: var(--glass-frost-blur);
  backdrop-filter: var(--glass-frost-blur);
}

.project-block .section__tag {
  margin-bottom: var(--space-24);
}

.pstack {
  /* Clears the label stuck above it. --tag-h is measured by the script, which
     is the only thing that knows what the pill came out at once the webfont
     landed; the fallback is what it is designed to be, so a stack that never
     hears from the script still parks below the label rather than under it. */
  --pstack-top: calc(var(--header-h) + var(--space-24) + var(--tag-h, 3.125rem) + var(--space-16));
  --pstack-step: 0.875rem;
  display: flex;
  flex-direction: column;
  gap: var(--space-32);
}

/* A card can only come to rest for as long as the stack below it has room to
   give, and the last card of a deck has none — it arrives at its line and is
   pushed straight back off, so the single card of a one-card deck could never
   rest at all. This is that room: the last card holds for a beat, which is the
   only reason there is anything for the label to sit over.

   It has to be a box in the flow rather than padding on the stack. A sticky
   element is confined to its containing block's *content* box, so padding
   there buys no travel at all — the card reads as sticky, is handed the right
   resting line, and still never leaves its place in the flow. */
.pstack::after {
  content: "";
  display: block;
  flex: none;
  height: 34vh;
}

.pcard {
  position: sticky;
  top: calc(var(--pstack-top) + var(--i, 0) * var(--pstack-step));
  /* Under the label, over nothing else. */
  z-index: 1;
  display: block;
  /* Kept short enough that the card plus its offset always clears the viewport
     — a sticky box taller than the screen never comes to rest at the top, and
     the whole deal falls apart on a short window. */
  height: clamp(21rem, 62vh, 32rem);
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-3xl);
  background: var(--c-surface-2);
  /* The frame is the mask. What is inside it is taller than the card and
     drifting, and this is the line that crops it back to a window. */
  overflow: hidden;
  isolation: isolate;
  /* Settles back into the deck as cards arrive over it. --recede is written
     per frame by the script and adds up one layer at a time — 0 while a card
     is on top with nothing yet climbing over it, then a step for each card
     that has — so a card starts full size and only shrinks as it is actually
     covered, ending up smaller the more layers end up on top of it. With no
     script at all every card simply sits at rest. */
  transform: scale(calc(1 - var(--recede, 0) * .05));
  /* From the top edge, which is the sliver that stays on show. */
  transform-origin: 50% 0;
  transition: border-color .4s var(--ease), box-shadow .4s var(--ease);
}

.pcard:hover {
  border-color: var(--c-line);
  box-shadow: var(--shadow-lift);
}

.pcard:focus-visible {
  outline: 2px solid var(--c-blue);
  outline-offset: 3px;
}

.pcard__media {
  position: absolute;
  inset: 0;
  z-index: 0;
}

.pcard__img {
  width: 100%;
  /* The overshoot the drift has to travel through. */
  height: 122%;
  object-fit: cover;
  /* --pan runs 0 to 1 across the card's pass through the viewport, and the
     picture covers its own overshoot in that time. Half by default, so the
     shot is centred whether or not anything ever sets it. */
  transform: translate3d(0, calc(var(--pan, .5) * -18%), 0);
  transition: scale .6s var(--ease);
}

.pcard:hover .pcard__img {
  scale: 1.04;
}

/* The glass edge: light caught on the rim, laid over the picture and the
   body both, since either can be what fills the corner. */
.pcard::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 4;
  border-radius: inherit;
  box-shadow: var(--glass-rim-bright);
  pointer-events: none;
}

/* Reads the card down as it goes under the next one. Over the body as well as
   the picture, so the type dims with everything else. */
.pcard::after {
  content: "";
  position: absolute;
  inset: 0;
  z-index: 3;
  background: var(--c-bg);
  opacity: calc(var(--recede, 0) * .6);
  pointer-events: none;
}

.pcard__body {
  position: relative;
  z-index: 2;
  height: 100%;
  display: flex;
  flex-direction: column;
  justify-content: flex-end;
  gap: var(--space-16);
  padding: var(--space-32);
  /* Carries the type over whatever the picture happens to be doing behind it,
     and the picture is a screenshot — it can be near-white directly under a
     line of body copy. Ramped rather than flat so the shot is only paid for
     where the words actually are, and held near-opaque across the whole depth
     the copy occupies rather than only at the very bottom. */
  background: linear-gradient(to top,
      rgba(11, 13, 18, .97) 0%,
      rgba(11, 13, 18, .95) 50%,
      rgba(11, 13, 18, .6) 70%,
      rgba(11, 13, 18, .15) 88%,
      transparent 100%);
}

.pcard__title {
  font-size: var(--fs-5xl);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-tight);
  letter-spacing: var(--ls-tight);
  color: var(--c-white);
}

.pcard__desc {
  max-width: 52ch;
  font-size: var(--fs-sm);
  line-height: var(--lh-snug);
  color: var(--c-muted);
}

.pcard__facts {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-8);
}

.pcard__facts span {
  padding: var(--space-4) var(--space-8);
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-pill);
  background: var(--glass-clear);
  box-shadow: var(--glass-rim);
  font-size: var(--fs-2xs);
  color: var(--c-muted);
}

.pcard__cta {
  display: inline-flex;
  align-items: center;
  gap: var(--space-8);
  font-size: var(--fs-sm);
  font-weight: var(--fw-medium);
  color: var(--c-blue-hover);
}

.pcard__cta i {
  font-style: normal;
  transition: translate .3s var(--ease);
}

.pcard:hover .pcard__cta i {
  translate: 0.25rem 0;
}

/* A project not written up yet. A card, not a link, so nothing on it answers
   the pointer the way a case study's card does: no lift, no zoom. */
.pcard--pending:hover {
  border-color: var(--glass-edge);
  box-shadow: none;
}

.pcard--pending:hover .pcard__img {
  scale: none;
}

/* In place of a screenshot, a screen still to be filled in: an inventory
   list drawn in outline, a box to tick, a name and a status for each row, on
   a faint grid under a wash of blue, with Ukirama's mark where the app's own
   would sit. The sheet is dashed, the way a space kept for something is. */
.pcard__draft {
  background:
    radial-gradient(60% 50% at 50% 10%, rgba(13, 139, 209, .22), transparent 70%),
    linear-gradient(rgba(255, 255, 255, .035) 1px, transparent 1px) 0 0 / 2rem 2rem,
    linear-gradient(90deg, rgba(255, 255, 255, .035) 1px, transparent 1px) 0 0 / 2rem 2rem,
    var(--c-surface-2);
}

.pcard__sheet {
  position: absolute;
  top: 18%;
  left: 50%;
  translate: -50% 0;
  display: grid;
  gap: var(--space-16);
  width: min(36rem, 82%);
  padding: var(--space-16) var(--space-24) var(--space-32);
  border: 1px dashed rgba(255, 255, 255, .24);
  border-radius: var(--radius-xl);
  background: linear-gradient(160deg, rgba(255, 255, 255, .07), rgba(255, 255, 255, .02) 60%), rgba(18, 21, 28, .6);
  box-shadow: var(--glass-rim), 0 1.5rem 3rem rgba(0, 0, 0, .3);
}

.pcard__sheet-bar,
.pcard__row {
  display: flex;
  align-items: center;
  gap: var(--space-12);
}

.pcard__sheet-bar {
  padding-bottom: var(--space-12);
  border-bottom: 1px solid var(--glass-edge);
}

.pcard__sheet-bar span,
.pcard__row span {
  flex: none;
  height: .5rem;
  border-radius: var(--radius-pill);
  background: rgba(255, 255, 255, .12);
}

.pcard__sheet-bar span:first-of-type {
  width: 34%;
  height: .625rem;
  background: rgba(255, 255, 255, .2);
}

.pcard__sheet-bar span:last-of-type {
  width: 20%;
  height: 1.25rem;
  margin-left: auto;
  border: 1px solid var(--glass-edge);
  background: none;
}

/* The tick box, the name, and the status at the far end. */
.pcard__row span:first-child {
  width: .75rem;
  height: .75rem;
  border: 1px solid rgba(255, 255, 255, .2);
  border-radius: .1875rem;
  background: none;
}

.pcard__row span:nth-child(2) {
  width: var(--w, 40%);
}

.pcard__row span:last-child {
  width: 3rem;
  height: .75rem;
  margin-left: auto;
  background: rgba(13, 139, 209, .32);
}

.pcard__row:nth-of-type(2) { --w: 46%; }
.pcard__row:nth-of-type(3) { --w: 32%; }
.pcard__row:nth-of-type(4) { --w: 52%; }
.pcard__row:nth-of-type(5) { --w: 26%; }
.pcard__row:nth-of-type(6) { --w: 40%; }

/* The website's sheet: a page rather than a list, its headline, a line under
   it and the button to try it, then the features in a row. */
.pcard__hero {
  display: grid;
  justify-items: center;
  gap: var(--space-12);
  padding-block: var(--space-8);
}

.pcard__hero span {
  height: .875rem;
  border-radius: var(--radius-pill);
  background: rgba(255, 255, 255, .2);
}

.pcard__hero span:nth-child(1) { width: 62%; }
.pcard__hero span:nth-child(2) { width: 44%; }

.pcard__hero span:nth-child(3) {
  width: 54%;
  height: .5rem;
  background: rgba(255, 255, 255, .1);
}

.pcard__hero span:nth-child(4) {
  width: 6.5rem;
  height: 1.5rem;
  margin-top: var(--space-4);
  background: rgba(13, 139, 209, .45);
}

.pcard__tiles {
  display: grid;
  grid-template-columns: repeat(3, 1fr);
  gap: var(--space-12);
}

.pcard__tiles span {
  height: 3.5rem;
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-md);
  background: rgba(255, 255, 255, .04);
}

/* The mark has no ground of its own, and dark blue on the dark card all but
   disappears: it is given the white tile an app icon would have. */
.pcard__mark {
  flex: none;
  width: 2rem;
  height: 2rem;
  padding: .125rem;
  border-radius: var(--radius-sm);
  background: #fff;
  object-fit: contain;
}

/* Where a case study's card says See Project: what is still to come, in a
   dashed pill to match the outline above it. */
.pcard__pending {
  display: inline-flex;
  align-self: flex-start;
  align-items: center;
  gap: var(--space-8);
  padding: var(--space-4) var(--space-12);
  border: 1px dashed var(--glass-edge-strong);
  border-radius: var(--radius-pill);
  font-size: var(--fs-2xs);
  font-weight: var(--fw-medium);
  color: var(--c-muted);
}

.pcard__pending svg {
  width: 1rem;
  height: 1rem;
  color: var(--c-blue-hover);
}

/* ============ END OF PROJECTS ============ */

/* ============ START OF FOOTER + UTILITIES ============ */
/* The same footer on every page — the home page, the four case studies and the
   seven error pages. It is the only navigation the deeper pages carry besides
   the header's one button, and on an error page it is the only way out at all,
   which is why the whole thing goes there rather than a copyright line. */
.site-footer {
  margin-top: var(--section-y);
  padding-top: var(--space-64);
  border-top: 1px solid var(--c-hairline);
  background: var(--c-bg);
  font-size: var(--fs-sm);
  color: var(--c-muted);
}

.footer-inner {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-48);
  justify-content: space-between;
  padding-bottom: var(--space-64);
}

/* Takes what is left after the columns have had theirs, down to a measure that
   still reads as a sentence rather than a column of two-word lines. */
.footer-cta {
  flex: 1 1 20rem;
  max-width: 28rem;
}

.footer-cta__title {
  margin-bottom: var(--space-24);
  font-size: var(--fs-3xl);
  font-weight: var(--fw-semibold);
  line-height: var(--lh-heading);
  letter-spacing: var(--ls-tight);
  color: var(--c-white);
}

/* ---- Rising words ----
   A heading split into its words by the script (splitWords in main.js), each
   in a box that clips it, so a word can rise into view from just below its
   own line. The padding gives the clip room for ascenders and descenders, and
   the margin takes it back, so the heading sets exactly as it would whole.
   The case study titles rise as the page opens (case-study.css); this
   closing line rises as the footer comes up. */
.rise-word {
  display: inline-block;
  overflow: clip;
  padding-block: .15em;
  margin-block: -.15em;
  vertical-align: top;
}

.rise-word__in {
  display: inline-block;
}

@media (prefers-reduced-motion: no-preference) {
  .footer-cta__title.has-rise .rise-word__in {
    transform: translateY(1.3em);
  }

  .site-footer.is-risen .footer-cta__title.has-rise .rise-word__in {
    transform: none;
    transition: transform .8s var(--ease-soft) calc(var(--w) * 55ms);
  }
}

@keyframes riseIn {
  from {
    transform: translateY(1.3em);
  }

  to {
    transform: none;
  }
}

.footer-nav {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-48);
}

.footer-col__title {
  margin-bottom: var(--space-16);
  font-size: var(--fs-2xs);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--c-white);
}

.footer-list {
  display: grid;
  gap: var(--space-8);
}

.footer-list a {
  color: var(--c-muted);
  transition: color .25s var(--ease);
}

.footer-list a:hover,
.footer-list a:focus-visible {
  color: var(--c-blue-hover);
}

.footer-bottom {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: var(--space-12);
  padding-block: var(--space-24);
  border-top: 1px solid var(--c-hairline);
  font-size: var(--fs-2xs);
}

/* The legal pair sits on the copyright line rather than in Navigate, which is
   for places a reader might actually want to go. These are the ones you keep
   because you have to, so they belong in the small print with the notice. */
.footer-legal {
  display: flex;
  gap: var(--space-16);
  margin: 0;
  padding: 0;
  list-style: none;
}

.footer-legal a {
  color: inherit;
  transition: color .2s var(--ease);
}

.footer-legal a:hover,
.footer-legal a:focus-visible {
  color: var(--c-white);
}

@media (max-width: 55rem) {
  /* Stacked and centred, so neither the notice nor the links end up orphaned
     on a line of their own against the opposite edge. */
  .footer-bottom {
    justify-content: center;
    text-align: center;
  }
}

/* ============ START OF FOOTER RESPONSIVE ============ */
@media (max-width: 55rem) {
  .footer-inner {
    gap: var(--space-32);
  }

  .footer-nav {
    /* Two up rather than three across, which at this width would leave every
       case study title broken over three lines. */
    gap: var(--space-32);
  }

  .footer-col {
    flex: 1 1 9rem;
  }

  .footer-cta__title {
    font-size: var(--fs-2xl);
  }
}

/* ============ END OF FOOTER RESPONSIVE ============ */

.to-top {
  position: fixed;
  right: var(--space-24);
  bottom: var(--space-24);
  z-index: 40;
  display: grid;
  place-items: center;
  width: 2.75rem;
  height: 2.75rem;
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-xl);
  background: var(--glass-clear);
  -webkit-backdrop-filter: var(--glass-clear-blur);
  backdrop-filter: var(--glass-clear-blur);
  box-shadow: var(--glass-rim-bright), 0 .5rem 1.5rem rgba(0, 0, 0, .3);
  color: var(--c-white);
  cursor: var(--cursor-pointer);
  opacity: 0;
  visibility: hidden;
  transform: translateY(0.625rem);
  transition: opacity .3s var(--ease), transform .3s var(--ease), visibility .3s, border-color .5s var(--ease-soft);

  /* Water animation requirements */
  overflow: hidden;
  isolation: isolate;
}

.to-top>* {
  position: relative;
  z-index: 1;
}

.to-top::before {
  content: "";
  position: absolute;
  left: -20%;
  bottom: -60%;
  width: 140%;
  height: 160%;
  z-index: 0;
  /* Round, not squircle: this radius draws the meniscus dome itself rather
     than easing a corner, and a superellipse would flatten it into a lid. */
  border-radius: 46% 48% 0 0 / 26% 24% 0 0;
  corner-shape: round;
  transform: translateY(100%);
  transition: transform .7s var(--ease-soft);
  /* Light rising in the glass, rather than a grey fill. */
  background: linear-gradient(180deg, rgba(255, 255, 255, .2), rgba(255, 255, 255, .08));
}

.to-top.is-visible {
  opacity: 1;
  visibility: visible;
  transform: none;
}

.to-top:hover:where(:not(.is-reaching))::before,
.to-top:focus-visible::before {
  transform: translateY(-18%);
  animation: waterWobble 3.4s ease-in-out infinite;
}

.to-top:hover:where(:not(.is-reaching)),
.to-top:focus-visible {
  border-color: var(--glass-edge-strong);
}

/* How far down the page the reader is, traced round the button. ::after,
   because ::before is the water that rises on hover; inset 0 rather than over
   the border, because the button clips to its padding box for that water and
   would cut a ring laid on the border in half. It follows the button's own
   squircle through the universal corner-shape rule. Above the water, so the
   hover does not drown it. */
@media (hover: hover) and (pointer: fine) {
  .to-top.has-progress::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 2;
    padding: 2px;
    border-radius: calc(var(--radius-xl) - 1px);
    background: conic-gradient(var(--c-blue) calc(var(--to-top-progress, 0) * 1turn), transparent 0);
    -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
    -webkit-mask-composite: xor;
    mask: linear-gradient(#000 0 0) content-box exclude, linear-gradient(#000 0 0);
    pointer-events: none;
  }
}

/* Scroll reveal.

   An animation rather than a transition, and filled backwards only. A
   transition here would replace whatever `transition` the element already
   declares — a project card's hover lift, a button's fill — and a forwards
   fill would pin `transform` afterwards, outranking those same hovers as well
   as the scale a card holds while it sits back in the deck. Backwards holds
   the first frame through a delay and nothing after: without it, a delayed
   reveal stood fully visible for the length of its delay, then vanished and
   faded back in. Once landed, the element has control straight back. */
.reveal {
  opacity: 0;
}

.reveal.is-visible {
  opacity: 1;
  animation: revealUp .8s var(--ease-soft) var(--reveal-delay, 0s) backwards;
}

@keyframes revealUp {
  from {
    opacity: 0;
    transform: translateY(2.5rem);
  }

  to {
    opacity: 1;
    transform: none;
  }
}

/* Custom cursor. The one thing on the site that stays a true circle: the dot
   and the ring read as a pointer, not as a piece of the interface, so they sit
   outside the squircle theme. */
#cursor,
#follower {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 10001;
  pointer-events: none;
  border-radius: var(--radius-circle);
  corner-shape: round;
  opacity: 0;
  transition: opacity .3s linear;
}

/* A bead of blue glass: too small to show anything through it, so it is the
   light on it that makes it glass — a glint on the upper left and a hairline
   rim. */
#cursor {
  width: 0.5rem;
  height: 0.5rem;
  background: radial-gradient(circle at 35% 30%, rgba(255, 255, 255, .8), rgba(255, 255, 255, 0) 50%), var(--c-blue);
  box-shadow: 0 0 0 1px rgba(255, 255, 255, .2), 0 .125rem .375rem rgba(0, 0, 0, .35);
}

/* The ring draws nothing itself: its blue edge is on the glass inside it
   (below), so the edge bends with the glass as it changes shape. The border
   stays, transparent, because it is part of the size. */
#follower {
  width: 2.25rem;
  height: 2.25rem;
  border: 1px solid transparent;
  transition: opacity .3s linear, width .25s var(--ease), height .25s var(--ease),
    background-color .25s var(--ease), border-radius .25s var(--ease), border-color .25s var(--ease);
}

body.has-cursor #cursor,
body.has-cursor #follower {
  opacity: 1;
}

/* The lightbox keeps the site's cursor. It is the only pointer these pages
   have — `cursor: none` is set on everything above — so standing it down over
   the viewer left the controls with nothing under the pointer at all. What the
   native zoom-in and grab cursors were saying, the ring now says itself. */

/* Over something interactive the ring takes over on its own: the dot drops
   out and a faint wash of the accent tints the glass in its place (below).
   The circle keeps its size — only the tint marks the change. */
body.has-cursor #cursor.is-hover {
  opacity: 0;
}

/* Pressing is the one thing that resizes the ring. */
#follower.is-down {
  width: 1.625rem;
  height: 1.625rem;
}


/* The ring with words in it, over anything that asks for them (data-cursor).
   It grows into a lens rather than washing, so a card that is one big link
   reads as one: the whole ring says what the click will do. These come after
   the hover rules on purpose — the cards are links, so .is-hover is on too,
   and at equal weight the later rule is the one that holds. */
#follower {
  display: grid;
  place-items: center;
}

.follower__label {
  font-size: var(--fs-3xs);
  font-weight: var(--fw-semibold);
  letter-spacing: .06em;
  text-transform: uppercase;
  white-space: nowrap;
  color: #fff;
  /* The glass behind it can be over a near-white screenshot. */
  text-shadow: 0 1px 0.375rem rgba(0, 0, 0, .45);
  opacity: 0;
  transform: scale(.6);
  transition: opacity .2s var(--ease), transform .25s var(--ease);
}

#follower.is-label {
  width: 5.5rem;
  height: 5.5rem;
  border-color: transparent;
  background: transparent;
}

#follower.is-label .follower__label {
  opacity: 1;
  transform: none;
}

#follower.is-label.is-down {
  width: 5rem;
  height: 5rem;
  background: transparent;
}

/* The ring's glass, on the plain ring and the labelled one alike: a lens
   the page shows through, bent (in Blink) and brightened, with light caught
   on its rim. It is an element of its own, not the ring's ::before, for two
   reasons. The script can draw the glass out like a drop as it moves while
   the words on it stay upright. And the ring is restyled every frame it
   moves, which restyles its pseudo-elements with it, where a child is left
   alone.

   Its visibility is left to inherit the ring's, so whatever hides the ring
   (the PDF viewer does) hides the glass with it — by not setting it at all,
   since an explicit `inherit` has the browser restyle the glass every time
   the ring moves. The rim is inset shadows rather than a ::after for the same
   reason, and will-change is there so a new stretch is a transform handed to
   the compositor instead of the glass painted again.

   The plain ring is nearly clear: a glare on the upper left, and a blur of a
   pixel, so what it passes over stays legible through it. */
.follower__glass {
  position: absolute;
  /* Out to the border's outer edge, which the ring itself no longer draws. */
  inset: -1px;
  z-index: -1;
  border-radius: var(--radius-circle);
  corner-shape: round;
  will-change: transform;
  background: radial-gradient(70% 60% at 30% 20%, rgba(255, 255, 255, .18), transparent 70%);
  -webkit-backdrop-filter: blur(1px) saturate(1.6) brightness(1.12);
  backdrop-filter: blur(1px) saturate(1.6) brightness(1.12);
  box-shadow:
    inset 0 0 0 1px rgba(28, 162, 236, .6),
    inset 1px 1px 0 rgba(255, 255, 255, .45),
    inset -1px -1px 0 rgba(255, 255, 255, .15),
    0 .25rem .75rem rgba(0, 0, 0, .25);
  transition: opacity .25s var(--ease), visibility .25s, background-color .25s var(--ease);
}

/* Merging with a button, the ring hands itself over to the liquid (below),
   which starts out drawn exactly where the ring is, the same size: the swap
   is made in one frame, since a crossfade shows the lens brightening the
   button through the fading glass. Coming back, the drop has already let go
   of the button, and the two fade across. */
#follower.is-merged .follower__glass {
  opacity: 0;
  transition: none;
}

#follower.is-hover .follower__glass {
  background-color: var(--c-blue-wash);
}

#follower.is-hover.is-down .follower__glass {
  background-color: var(--c-blue-wash-strong);
}

/* Labelled, it is a thicker glass with a tint to it. Top to bottom: the
   glare, a haze under the words so they hold over a near-white screenshot,
   and the tint. The haze fades out before the rim, which is where the glass
   has something to show. */
#follower.is-label .follower__glass {
  background:
    radial-gradient(80% 60% at 30% 12%, rgba(255, 255, 255, .16), transparent 70%),
    radial-gradient(closest-side, rgba(11, 13, 18, .32), rgba(11, 13, 18, 0)),
    rgba(13, 139, 209, .1);
  -webkit-backdrop-filter: blur(8px) saturate(1.6) brightness(1.05);
  backdrop-filter: blur(8px) saturate(1.6) brightness(1.05);
  box-shadow:
    inset 1px 1px 0 rgba(255, 255, 255, .6),
    inset -1px -1px 0 rgba(255, 255, 255, .25),
    inset 0 0 0 1px rgba(255, 255, 255, .1),
    inset 0 0 0.5rem rgba(255, 255, 255, .1),
    inset 0 -0.5rem 0.875rem -0.5rem rgba(13, 139, 209, .45),
    0 0.75rem 1.75rem -0.25rem rgba(0, 0, 0, .45);
}

#follower.is-label.is-down .follower__glass {
  background-color: var(--c-blue-wash-strong);
}

/* Where it can, the glass bends the light too. Only Blink draws an SVG filter
   behind an element, so the script builds #cursor-lens and sets has-lens there
   alone; everywhere else keeps the glass above, blurred. The rim magnifies
   what is under it the way a drop of water would, so the blur is dropped to
   almost nothing for the bending to read. */
#follower.has-lens .follower__glass {
  backdrop-filter: url(#cursor-lens) blur(.5px) saturate(1.6) brightness(1.12);
}

#follower.has-lens.is-label .follower__glass {
  backdrop-filter: url(#cursor-lens) blur(1px) saturate(1.6) brightness(1.05);
}

/* Asked for less see-through, the ring is an outline again, still tinted
   over a link, and the labelled one is the solid blue disc it was. */
@media (prefers-reduced-transparency: reduce) {

  .follower__glass,
  #follower.has-lens .follower__glass,
  #follower.has-lens.is-label .follower__glass {
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }

  #follower.is-label .follower__glass {
    background: var(--c-blue);
  }

  #follower.is-label.is-down .follower__glass {
    background: var(--c-blue-hover);
  }
}

/* The liquid a button merge is drawn in (see initCursor): the ring and a
   case round the button, run together by an SVG filter that grows a neck
   between them and lights the one outline they make. Over the whole screen
   so it can reach any button, but it only has something in it — and is only
   visible — while a merge is on. The shapes themselves are never seen: the
   filter reads only their outline. */
.cursor-liquid {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 10001;
  width: 100%;
  height: 100%;
  overflow: visible;
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  transition: opacity .22s linear, visibility .22s;
}

.cursor-liquid.is-on {
  opacity: 1;
  visibility: visible;
  transition: none;
}

.cursor-liquid ellipse,
.cursor-liquid path {
  fill: #fff;
}

/* A button's edge in the liquid, told apart by colour: the filter shows it
   only near the white (see the script). */
.cursor-liquid .liquid-skin {
  fill: #0f0;
}

.cursor-liquid .liquid-neck {
  stroke: #fff;
  stroke-linecap: round;
}

/* The mask: the liquid is hidden over a button (black) except where its case
   has reached (white), stroked a pixel wider so the case's rim is kept. */
.cursor-liquid .liquid-hole {
  fill: #000;
}

.cursor-liquid .liquid-keep {
  fill: #fff;
  stroke: #fff;
  stroke-width: 2px;
}

/* The filter's own element: it draws nothing and must take no room, or the
   end of the page grows by a line. */
.cursor-lens {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

/* The custom cursor replaces the native one outright. `has-cursor` is only
   set once the pointer has actually moved, so the arrow is never hidden
   before its stand-in exists.

   Except inside [data-native-cursor]. The resume's PDF viewer points with its
   tools — a pen, a highlighter, an eraser, a pin — and a dot and ring laid
   over those say nothing about what a click will put on the page. Anything
   carrying the attribute is left out of the blanket rule and reset to auto,
   which it has to be: cursor inherits, so without the reset it would take
   `none` straight from the body. The dot and ring are hidden for as long as
   the viewer holds the screen. */
@media (hover: hover) and (pointer: fine) {

  body.has-cursor,
  body.has-cursor *:not([data-native-cursor], [data-native-cursor] *) {
    cursor: none !important;
  }

  body.has-cursor [data-native-cursor] {
    cursor: auto;
  }

  html.has-native-cursor #cursor,
  html.has-native-cursor #follower,
  html.has-native-cursor .cursor-liquid {
    visibility: hidden;
  }

  /* At once, with the ring: left to itself the glass would play out its
     fade first. */
  html.has-native-cursor .follower__glass {
    transition: none;
  }
}

/* ============ Copied ============
   The one confirmation the site gives — an email copied, a section link
   copied — set on whatever was clicked. Above it by default; below when the
   script says there is no room, which is the header. Under the site cursor,
   over everything else. */
.toast {
  position: fixed;
  z-index: 10000;
  display: flex;
  align-items: center;
  gap: var(--space-8);
  padding: var(--space-8) var(--space-16);
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-pill);
  background: var(--glass-frost);
  -webkit-backdrop-filter: var(--glass-frost-blur);
  backdrop-filter: var(--glass-frost-blur);
  box-shadow: var(--glass-rim-bright), var(--shadow-menu);
  font-size: var(--fs-2xs);
  font-weight: var(--fw-medium);
  white-space: nowrap;
  color: var(--c-white);
  pointer-events: none;
  opacity: 0;
  visibility: hidden;
  transform: translate(-50%, -100%);
  translate: 0 .25rem;
  transition: opacity .2s var(--ease), translate .2s var(--ease), visibility 0s linear .2s;
}

.toast.is-below {
  transform: translate(-50%, 0);
  translate: 0 -.25rem;
}

.toast.is-shown {
  opacity: 1;
  visibility: visible;
  translate: 0 0;
  transition: opacity .2s var(--ease), translate .2s var(--ease), visibility 0s;
}

.toast svg {
  flex: none;
  width: .875rem;
  height: .875rem;
  color: var(--c-green);
}

@media (prefers-reduced-motion: reduce) {
  .toast,
  .toast.is-below,
  .toast.is-shown {
    translate: none;
  }
}

/* ============ END OF FOOTER + UTILITIES ============ */

/* ============ START OF SCROLL TEXT ============ */
.section--scroll-text {
  position: relative;
  min-height: 200vh;
}

.scroll-text-container {
  position: sticky;
  top: 0;
  height: 100vh;
  display: flex;
  align-items: center;
  justify-content: center;
  padding: 0 var(--space-32);
}

.scroll-text-paragraph {
  font-family: var(--font-display);
  font-size: var(--fs-4xl);
  line-height: 1.2;
  font-weight: 500;
  color: var(--c-white);
  max-width: 75rem;
  margin: 0 auto;
}

/* One span per word once the script has split it, each kept whole: a phone
   would otherwise break "e-commerce" and "eight-person" at the hyphen, and
   leave an "e-" hanging at the end of a line. */
.scroll-text-paragraph span {
  opacity: 0.15;
  white-space: nowrap;
  transition: opacity 0.1s;
}

/* The keywords a recruiter is scanning for — the domains, the years, the
   team, the flagship — in the accent the hero already uses for the role
   rotating under the name, so the page has one way of saying "this part".

   Colour only. The paragraph is set at 500, and a bold run inside display
   type this size reads as shouting rather than as emphasis.

   #0D8BD1 on the page is 5.2:1, so it clears AA even before counting this
   as large text. */
.scroll-text-paragraph strong {
  font-weight: inherit;
  color: var(--c-blue);
}

/* Once the script has split the paragraph, a keyword waits its turn: it dims
   and brightens in the body colour like every word around it, and takes the
   accent only when the reveal has fully reached it. Blue on a word still at
   15% was the highlight arriving before the word did.

   The rule above stays for the paragraph as written — no script, no reveal,
   everything lit — where there is nothing to wait for. */
.scroll-text-paragraph.is-split strong span {
  color: var(--c-white);
  transition: opacity .1s, color .3s var(--ease);
}

.scroll-text-paragraph.is-split strong span.is-lit {
  color: var(--c-blue);
}

/* And once the whole keyword is lit, it is underlined as if with a marker —
   one quick, slightly wavering stroke — swept in from its first letter
   (is-marked, set by the scroll text's script). Each line of a keyword that
   wraps gets its own stroke. The padding only lowers the stroke to sit under
   the letters; it moves nothing. Without the script the paragraph is all lit,
   so every keyword is underlined from the start. */
.scroll-text-paragraph strong {
  padding-bottom: .06em;
  background: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 12' preserveAspectRatio='none'%3E%3Cpath d='M2 7.5C40 4.6 84 8.6 124 6.2S180 4.8 198 6.6' fill='none' stroke='%230D8BD1' stroke-width='3' stroke-linecap='round' vector-effect='non-scaling-stroke'/%3E%3C/svg%3E") no-repeat 0 100% / 0% .28em;
  -webkit-box-decoration-break: clone;
  box-decoration-break: clone;
  transition: background-size .6s var(--ease-soft);
}

.scroll-text-paragraph strong.is-marked,
.scroll-text-paragraph:not(.is-split) strong {
  background-size: 100% .28em;
}

@media (max-width: 768px) {
  .scroll-text-paragraph {
    font-size: var(--fs-2xl);
  }
}
/* ============ END OF SCROLL TEXT ============ */

/* ============ START OF SCROLL VELOCITY MARQUEE ============ */
.section--marquee {
  position: relative;
  overflow: hidden;
  padding: var(--space-64) 0;
  display: flex;
  flex-direction: column;
  gap: var(--space-16);
}

.marquee-row {
  display: flex;
  white-space: nowrap;
  will-change: transform;
}

.marquee-content {
  display: flex;
  white-space: nowrap;
}

.marquee-content span {
  font-family: var(--font-display);
  font-size: clamp(4rem, 10vw, 12rem);
  font-weight: 900;
  text-transform: uppercase;
  line-height: 0.9;
  padding-right: var(--space-24);
  color: var(--c-white);
}

.marquee-content span.outline {
  color: transparent;
  -webkit-text-stroke: 2px var(--c-line);
}

/* An outlined word fills in under the pointer. The rows keep moving, so the
   fill travels across the words as they pass beneath it rather than sitting
   on one. Blue rather than white, or the filled word would be lost among the
   solid ones either side of it. */
@media (hover: hover) and (pointer: fine) {
  .marquee-content span.outline {
    transition: color .35s var(--ease), -webkit-text-stroke-color .35s var(--ease);
  }

  .marquee-content span.outline:hover {
    color: var(--c-blue);
    -webkit-text-stroke-color: var(--c-blue);
  }
}
/* ============ END OF SCROLL VELOCITY MARQUEE ============ */

/* ============ START OF CONTEXT MENU ============ */
.ctx-menu {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 9000;
  min-width: 14.5rem;
  padding: var(--space-8);
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-lg);
  background: var(--glass-frost);
  -webkit-backdrop-filter: var(--glass-frost-blur);
  backdrop-filter: var(--glass-frost-blur);
  box-shadow: var(--glass-rim), var(--shadow-menu);
  transform-origin: top left;
  animation: ctxIn .14s var(--ease) both;
}

.ctx-menu[hidden] {
  display: none;
}

@keyframes ctxIn {
  from {
    opacity: 0;
    transform: scale(.96);
  }

  to {
    opacity: 1;
    transform: scale(1);
  }
}

.ctx-menu__group[hidden] {
  display: none;
}

/* `:not([hidden])` matters: `+` matches on DOM order, so without it the link
   group still draws a separator above the next group while it is display:none,
   leaving a stray line across the top of the menu. */
.ctx-menu__group:not([hidden])+.ctx-menu__group {
  margin-top: var(--space-8);
  padding-top: var(--space-8);
  border-top: 1px solid var(--c-hairline);
}

/* The lit row's glass (see the script): one drop for the whole menu, running
   from row to row under the rows' words — the search's blue glass. */
.ctx-menu__drop {
  position: absolute;
  top: 0;
  left: var(--space-8);
  right: var(--space-8);
  height: 0;
  border: 1px solid var(--glass-pick-edge);
  border-radius: var(--radius-sm);
  background: var(--glass-pick);
  box-shadow: var(--glass-pick-lift);
  pointer-events: none;
  opacity: 0;
  transition: opacity .15s var(--ease);
}

.ctx-menu__drop.is-on {
  opacity: 1;
}

.ctx-menu__item {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  gap: var(--space-8);
  width: 100%;
  padding: var(--space-8) var(--space-16);
  border: 0;
  border-radius: var(--radius-sm);
  background: none;
  color: var(--c-white);
  font-family: inherit;
  font-size: var(--fs-sm);
  font-weight: var(--fw-regular);
  line-height: var(--lh-none);
  text-align: left;
  cursor: var(--cursor-pointer);
  transition: color .15s var(--ease);
}

/* The glass under the row is the drop's, and the row's words stay white on
   it, as a picked search result's do; only the icon brightens. */
.ctx-menu__item:hover,
.ctx-menu__item:focus-visible {
  outline: none;
}

.ctx-menu__item svg {
  flex: none;
  opacity: .6;
}

.ctx-menu__item:hover svg,
.ctx-menu__item:focus-visible svg {
  opacity: 1;
}

.ctx-menu__item.is-done {
  color: var(--c-green);
}

.ctx-menu__item.is-done svg {
  opacity: 1;
}

.ctx-menu__ext {
  margin-left: auto;
  font-size: var(--fs-2xs);
  opacity: .5;
}

/* ============ END OF CONTEXT MENU ============ */

/* ============ START OF RESPONSIVE ============ */
@media (max-width: 55rem) {
  :root {
    --header-h: 4.25rem;
  }

  /* padding-inline is inherited as var(--gutter) to align with container */

  .header-inner>.btn {
    padding: 0.6rem 0.8rem;
    border-radius: var(--radius-sm);
    font-size: 0.7rem;
  }

  /* Hamburger menu and its related display overrides have been removed to show the Work With Me button instead. */

  .hero {
    flex-direction: column;
    justify-content: center;
    gap: var(--space-32);
    padding-top: calc(var(--header-h) + var(--space-24));
  }

  .hero__left {
    order: 2;
    align-items: flex-start;
    flex: none;
    padding-right: 0;
  }

  .hero__right {
    order: 1;
    flex: none;
  }

  .hero__name {
    font-size: var(--fs-4xl);
    line-height: var(--lh-tight);
  }

  /* Viewport-relative on purpose: the address has to shrink to fit one line on
     a 20rem screen, which no fixed step on the scale can promise. */
  .hero__status-text {
    font-size: 5vw;
  }

  .rotator {
    font-size: var(--fs-lg);
  }

  .hero__actions {
    display: none;
  }

  .hero__cue {
    display: none;
  }

  .section {
    padding: var(--section-y-sm) 0;
  }

  /* The journey is never stacked down here: there is no margin to lay the
     detail out in, so the entries stay the plain vertical list the base
     styles describe and the script leaves them alone. Only the rhythm
     tightens. */
  .journey-sticky {
    gap: var(--space-24);
    padding-block: var(--section-y-sm);
  }

  /* 4rem above and below is symmetric, so it was never what threw the centring
     off — but around two or three lines of 28px type it is most of the card.
     Down here the padding is the thing that should give way, not the text. */
  /* The section was only ever as tall as the card inside it plus the section
     padding — around 360px, which left the icon field almost no depth to read
     in. A floor gives it room; svh rather than vh so a retracting address bar
     does not resize it mid-scroll. align-content keeps the card centred in
     whatever height that works out to. */
  .section--statement {
    min-height: 75svh;
    display: grid;
    align-content: center;
  }

  .statement {
    align-items: center;
    padding: var(--space-48) var(--space-24);
  }

  /* Shorter cards and a tighter step: the header is smaller down here, and a
     phone has to fit the whole card plus the deal above it on one screen. */
  .pstack {
    --pstack-step: 0.625rem;
    gap: var(--space-24);
  }

  .pcard {
    height: clamp(18rem, 56vh, 25rem);
  }

  .pcard__body {
    gap: var(--space-8);
    padding: var(--space-24);
    /* The card is shorter here but the copy is not, so it reaches further up
       the ramp — far enough on the brighter screenshots to take the body text
       under 4.5:1. The opaque part is carried higher to meet it, which leaves
       the top third of the card as the part still showing its picture. */
    background: linear-gradient(to top,
        rgba(11, 13, 18, .97) 0%,
        rgba(11, 13, 18, .96) 62%,
        rgba(11, 13, 18, .7) 78%,
        rgba(11, 13, 18, .18) 92%,
        transparent 100%);
  }

  .pcard__title {
    font-size: var(--fs-2xl);
  }
}

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

  *,
  *::before,
  *::after {
    animation-duration: .01ms !important;
    animation-iteration-count: 1 !important;
    transition-duration: .01ms !important;
  }

  /* No reveal at all, rather than one shortened to nothing: held backwards
     through a delay, it would keep the element hidden for that long. */
  .reveal,
  .reveal.is-visible {
    opacity: 1;
    transform: none;
    animation: none;
  }

  /* The script writes neither --pan nor --recede when motion is unwanted, so
     the cards already sit still. The picture is the exception: its overshoot
     exists only to be travelled through, and unused it is just a crop. */
  .pcard__img {
    height: 100%;
    transform: none;
  }
}

/* ============ END OF RESPONSIVE ============ */

/* ============ START OF COMMAND PALETTE ============ */
/* The palette is a sheet of glass over the page, and the page stays in view
   behind it — dimmed and a little soft, so the glass has something to be
   glass over. The fade is on the veil and the sheet, not on the whole: a
   parent part-way through a fade is as far down as a blur can see, and the
   sheet came up clear, frosting over only once the fade was done. */
#command-palette {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  height: 100vh;
  z-index: 10000;
  display: flex;
  align-items: flex-start;
  justify-content: center;
  /* Clear of the bar and of the blur that runs on below it (.nav-blur): the
     bar stays over the veil, and its blur would frost the top of the sheet. */
  padding-top: max(15vh, calc(var(--header-h) + 4.125rem));
  pointer-events: none;
  visibility: hidden;
  transition: visibility 0.3s;
}

/* The bar stays while the search is up: over the veil, sharp, and working.
   Under the cursor still, which comes after it in the page at the same
   height. It goes back under only once the veil has faded on the way out,
   or it would drop behind a veil still on its way down. */
.site-header {
  transition: z-index 0s linear .3s;
}

body.has-command-palette .site-header {
  z-index: 10001;
  transition: none;
}

body.has-command-palette #command-palette {
  pointer-events: auto;
  visibility: visible;
}

body.has-command-palette #command-palette .cp-backdrop,
body.has-command-palette #command-palette .cp-dialog {
  opacity: 1;
}

#command-palette .cp-backdrop {
  position: absolute;
  top: 0;
  left: 0;
  width: 100%;
  height: 100%;
  background: rgba(11, 13, 18, .56);
  -webkit-backdrop-filter: blur(3px) saturate(.8);
  backdrop-filter: blur(3px) saturate(.8);
  z-index: 0;
  opacity: 0;
  transition: opacity 0.3s var(--ease);
}

#command-palette .cp-dialog {
  position: relative;
  z-index: 1;
  width: 90%;
  max-width: 37.5rem;
  background: var(--glass-sheet);
  -webkit-backdrop-filter: var(--glass-sheet-blur);
  backdrop-filter: var(--glass-sheet-blur);
  border: 1px solid var(--glass-edge-strong);
  /* Rounder than the site's other panels: on its own it is a single bar, and
     it reads as one to type in. The results' drop is 8px in from the edge, so
     it is 8px less round — the two curves stay parallel in the corners. */
  border-radius: var(--radius-4xl);
  box-shadow: var(--glass-rim-bright), inset 0 0 1.5rem rgba(255, 255, 255, .03), var(--shadow-lift);
  overflow: hidden;
  opacity: 0;
  transform: scale(0.95);
  transition: opacity 0.3s var(--ease), transform 0.4s cubic-bezier(0.175, 0.885, 0.32, 1.275);
  display: flex;
  flex-direction: column;
}

/* The light pooled where it comes in, at the top left: the sheet has a
   surface, not only a rim. Under the words, not over them. */
#command-palette .cp-dialog::before {
  content: "";
  position: absolute;
  inset: 0;
  z-index: -1;
  border-radius: inherit;
  background: radial-gradient(90% 60% at 12% 0%, rgba(255, 255, 255, .08), transparent 70%);
  pointer-events: none;
}

body.has-command-palette #command-palette .cp-dialog {
  transform: scale(1);
}

#command-palette .cp-header {
  display: flex;
  align-items: center;
  padding: 0 var(--space-24);
}

#command-palette .cp-header > svg {
  width: 1.25rem;
  height: 1.25rem;
  color: var(--c-muted);
}

/* The search, and behind its words the rest of the word it could run on to
   (see the palette's script): in the same type and the same place, so the
   grey starts at the caret. The words typed are in it too, unseen, to push
   the grey along to there. */
#command-palette .cp-field {
  position: relative;
  display: flex;
  flex: 1;
  min-width: 0;
}

#command-palette .cp-ghost {
  position: absolute;
  inset: 0;
  overflow: hidden;
  padding: var(--space-24) var(--space-16);
  font-family: var(--font);
  font-size: var(--fs-lg);
  line-height: normal;
  white-space: pre;
  color: var(--c-muted);
  pointer-events: none;
}

#command-palette .cp-ghost[hidden] {
  display: none;
}

#command-palette .cp-ghost-typed {
  color: transparent;
}

/* The key that takes it, drawn the way the header's ⌘K is. */
#command-palette .cp-ghost kbd {
  display: inline-block;
  min-width: 1.125rem;
  margin-left: var(--space-8);
  padding: .0625rem .25rem;
  border: 1px solid var(--c-hairline);
  border-radius: var(--radius-xs);
  background: rgba(255, 255, 255, .03);
  font-family: inherit;
  font-size: var(--fs-3xs);
  line-height: 1.4;
  text-align: center;
  vertical-align: .2em;
}

#command-palette .cp-input {
  flex: 1;
  min-width: 0;
  background: transparent;
  border: none;
  color: var(--c-white);
  font-family: var(--font);
  font-size: var(--fs-lg);
  padding: var(--space-24) var(--space-16);
  outline: none;
}

#command-palette .cp-input::placeholder {
  color: var(--c-muted);
}

/* Clears what is typed, once there is something to clear: the round x a
   search on a phone has, in the grey of the search icon across from it and
   white under the pointer. Drawn out past the padding by the room round the
   glyph, so it is the glyph, not the button, that lines up with the search
   icon. Out of the Tab order — select-all and delete do the same from the
   keys — so Tab still goes from the search to the chips. */
#command-palette .cp-clear {
  flex-shrink: 0;
  display: grid;
  place-items: center;
  width: 2rem;
  height: 2rem;
  margin-right: -0.375rem;
  padding: 0;
  border: 0;
  border-radius: var(--radius-circle);
  background: none;
  color: var(--c-muted);
  cursor: var(--cursor-pointer);
  transition: color .2s var(--ease), opacity .2s var(--ease), transform .2s var(--ease);
}

#command-palette .cp-clear[hidden] {
  display: none;
}

#command-palette .cp-clear svg {
  width: 1.25rem;
  height: 1.25rem;
}

#command-palette .cp-clear:hover:where(:not(.is-reaching)) {
  color: var(--c-white);
}

@starting-style {
  #command-palette .cp-clear {
    opacity: 0;
    transform: scale(.6);
  }
}

/* The chips under the search: all of it, the pages, or the words on them.
   Outlined when not picked; the one picked is lit by a drop of clear glass
   that runs from chip to chip, clear where the result's is blue — blue stays
   with the result Enter will open. */
/* Drawn up under the search rather than the search made shorter over them,
   so nothing in the search moves as they come and go. */
#command-palette .cp-filters {
  margin-top: calc(-1 * var(--space-8));
  padding: 0 var(--space-24) var(--space-16);
  border-bottom: 1px solid var(--glass-edge);
}

/* Opened, it is only the search. The chips and the list come in with the
   first letter typed — faded and settled down into place where the browser
   can start a style from nothing — and go again once it is all deleted. */
#command-palette .cp-dialog.is-idle .cp-filters,
#command-palette .cp-dialog.is-idle .cp-results {
  display: none;
}

#command-palette .cp-filters,
#command-palette .cp-results {
  transition: opacity .25s var(--ease), transform .25s var(--ease);
}

@starting-style {
  #command-palette .cp-filters,
  #command-palette .cp-results {
    opacity: 0;
    transform: translateY(-.25rem);
  }
}

#command-palette .cp-filters-track {
  position: relative;
  display: flex;
  gap: var(--space-8);
}

#command-palette .cp-filter-drop {
  position: absolute;
  top: 0;
  bottom: 0;
  left: 0;
  width: 0;
  border: 1px solid var(--glass-edge-strong);
  border-radius: var(--radius-pill);
  background: linear-gradient(135deg, rgba(255, 255, 255, .18), rgba(255, 255, 255, .05) 50%, rgba(255, 255, 255, .1));
  box-shadow: var(--glass-rim-bright), 0 .25rem .75rem rgba(0, 0, 0, .2);
  pointer-events: none;
}

#command-palette .cp-filter {
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  gap: .375rem;
  padding: .375rem .75rem;
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-pill);
  background: none;
  font-family: var(--font);
  font-size: var(--fs-2xs);
  font-weight: var(--fw-medium);
  line-height: var(--lh-none);
  color: var(--c-muted);
  cursor: var(--cursor-pointer);
  transition: color .2s var(--ease), border-color .2s var(--ease);
}

#command-palette .cp-filter:hover:where(:not(.is-reaching)) {
  border-color: var(--glass-edge-strong);
  color: var(--c-white);
}

/* Its outline is the drop's, once the drop is there. */
#command-palette .cp-filter[aria-pressed="true"] {
  border-color: transparent;
  color: var(--c-white);
}

#command-palette .cp-filter:focus-visible {
  outline: 2px solid var(--c-blue);
  outline-offset: 2px;
}

#command-palette .cp-filter-count {
  font-size: var(--fs-3xs);
  font-variant-numeric: tabular-nums;
  color: var(--c-muted);
}

#command-palette .cp-filter-count:empty {
  display: none;
}

#command-palette .cp-filter[aria-pressed="true"] .cp-filter-count {
  color: rgba(254, 254, 254, .7);
}

#command-palette .cp-results {
  position: relative;
  max-height: 25rem;
  overflow-y: auto;
  padding: var(--space-8);
  list-style: none;
  margin: 0;
  scrollbar-width: thin;
  scrollbar-color: rgba(255, 255, 255, .16) transparent;
}

#command-palette .cp-highlight {
  position: absolute;
  top: 0;
  left: var(--space-8);
  right: var(--space-8);
  border: 1px solid var(--glass-pick-edge);
  border-radius: var(--radius-3xl);
  /* The drop of blue glass that slides to whichever result is picked. */
  background: var(--glass-pick);
  box-shadow: var(--glass-pick-lift);
  /* Where it is and how long, the script moves itself, as a drop. */
  transition: opacity 0.1s;
  pointer-events: none;
  z-index: 0;
  opacity: 0;
}

#command-palette .cp-item {
  position: relative;
  z-index: 1;
  display: flex;
  align-items: center;
  padding: var(--space-12) var(--space-16);
  border-radius: var(--radius-3xl);
  cursor: var(--cursor-pointer);
  color: var(--c-muted);
  font-size: var(--fs-base);
  transition: color 0.15s var(--ease);
  text-decoration: none;
}

#command-palette .cp-item.is-selected,
#command-palette .cp-item:hover {
  color: var(--c-white);
}

#command-palette .cp-item-title {
  font-weight: 500;
  line-height: var(--lh-snug);
  margin-right: var(--space-12);
}

/* A place to go: its name beside an icon, on a tile of the sheet's glass
   that lights with the row. Drawn out past the row's padding, so the row is
   no taller for it. The icon is in the row's colour. */
#command-palette .cp-item-icon {
  flex-shrink: 0;
  display: grid;
  place-items: center;
  width: 2rem;
  height: 2rem;
  margin: calc(-1 * var(--space-4)) var(--space-12) calc(-1 * var(--space-4)) calc(-1 * var(--space-4));
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-sm);
  background: rgba(255, 255, 255, .04);
  box-shadow: var(--glass-rim);
  transition: background-color 0.15s var(--ease), border-color 0.15s var(--ease);
}

#command-palette .cp-item-icon svg {
  width: 1.125rem;
  height: 1.125rem;
}

#command-palette .cp-item.is-selected .cp-item-icon,
#command-palette .cp-item:hover .cp-item-icon {
  border-color: var(--glass-edge-strong);
  background-color: rgba(255, 255, 255, .1);
}

/* The sprite the icons are drawn from, put into the palette. Never shown
   itself. */
#command-palette .cp-icons {
  position: absolute;
  width: 0;
  height: 0;
  overflow: hidden;
}

/* The quieter words — what a result is, which page, where on it — are in
   the text grey rather than the lines' one. They say which page a found
   line is on, and over a bright picture the glass is lighter than the page
   it was set on. */
#command-palette .cp-item-category {
  flex-shrink: 0;
  font-size: var(--fs-sm);
  color: var(--c-muted);
  white-space: nowrap;
  margin-left: auto;
}

#command-palette .cp-empty {
  padding: var(--space-32) var(--space-24);
  text-align: center;
  color: var(--c-muted);
  font-size: var(--fs-base);
}

/* The names of the two lists under All, the chips' own: Pages, then
   Content, under a line of its own. */
#command-palette .cp-group,
#command-palette .cp-note,
#command-palette .cp-said {
  padding: var(--space-8) var(--space-16);
  font-size: var(--fs-2xs);
  font-weight: var(--fw-medium);
  letter-spacing: var(--ls-caps);
  text-transform: uppercase;
  color: var(--c-muted);
}

#command-palette .cp-group--next {
  margin-top: var(--space-8);
  padding-top: var(--space-16);
  border-top: 1px solid var(--glass-edge);
}

#command-palette .cp-note,
#command-palette .cp-said {
  letter-spacing: normal;
  text-transform: none;
  font-weight: var(--fw-light);
}

/* Over the lists when a word was Indonesian: the English it was also
   looked for as. */
#command-palette .cp-said b {
  font-weight: var(--fw-medium);
  color: var(--c-white);
}

/* A line of text found on a page: the words themselves, with where on the
   site they are under them, set off by a rule down the left the way a
   quotation is. The rule stands in the column the pages' icons are in, so
   the words of both lists start in the same place. */
#command-palette .cp-item--text {
  align-items: stretch;
}

#command-palette .cp-item--text::before {
  content: "";
  flex-shrink: 0;
  width: 2px;
  margin: var(--space-4) calc(1rem - 1px + var(--space-12)) var(--space-4) calc(1rem - var(--space-4) - 1px);
  border-radius: 1px;
  background: var(--glass-edge-strong);
  transition: background-color 0.15s var(--ease);
}

#command-palette .cp-item--text.is-selected::before,
#command-palette .cp-item--text:hover::before {
  background: var(--c-blue-hover);
}

#command-palette .cp-item-body {
  display: flex;
  flex-direction: column;
  gap: var(--space-4);
  min-width: 0;
}

#command-palette .cp-item-snippet {
  display: -webkit-box;
  -webkit-box-orient: vertical;
  -webkit-line-clamp: 2;
  line-clamp: 2;
  overflow: hidden;
  font-size: var(--fs-sm);
  line-height: var(--lh-snug);
}

#command-palette .cp-item-snippet mark {
  background: none;
  color: var(--c-blue-hover);
  font-weight: var(--fw-medium);
}

/* One line, however deep the part of the page is: the page's name comes
   first, and that is the part that has to show. */
#command-palette .cp-item-where {
  overflow: hidden;
  font-size: var(--fs-2xs);
  color: var(--c-muted);
  white-space: nowrap;
  text-overflow: ellipsis;
}

/* Asked for less see-through, the veil is the solid dark it was and the page
   behind it is not blurred; the sheet is already solid (see the tokens). */
@media (prefers-reduced-transparency: reduce) {
  #command-palette .cp-backdrop {
    background: var(--c-overlay);
    -webkit-backdrop-filter: none;
    backdrop-filter: none;
  }
}

/* The words a search landed on, lit on the page itself until the next click
   or key — the site's blue, where the browser's own find would be yellow. */
::highlight(site-find) {
  background-color: var(--c-blue);
  color: var(--c-white);
}

/* ============ END OF COMMAND PALETTE ============ */

/* ============ START OF FLOURISHES ============ */
/* The small motions that finish a page off (START OF FLOURISHES in main.js
   has the reasons for each). */

/* A label decoding: the random glyphs, held to the width the word will
   take, so nothing beside it moves while they settle. */
.decode {
  display: inline-block;
  white-space: nowrap;
}

/* The resume's badges, one after another, row by row (initCascade). */
@media (prefers-reduced-motion: no-preference) {
  .has-cascade > * {
    opacity: 0;
    transform: translateY(.5rem) scale(.92);
  }

  .has-cascade.is-in > * {
    opacity: 1;
    transform: none;
    transition:
      opacity .45s var(--ease-soft) calc(var(--i, 0) * 45ms),
      transform .55s var(--ease-soft) calc(var(--i, 0) * 45ms);
  }
}

/* Now and then a light passes across the green buttons — Work With Me and
   Connect on LinkedIn, the two ways in — the way light catches glass when
   it moves. Under the label (the label rides above, see .btn > *), on the
   compositor, and not on a slow device or for anyone who asked for less
   motion. */
@media (prefers-reduced-motion: no-preference) {
  :root:not(.is-lite) .btn--primary::after {
    content: "";
    position: absolute;
    inset: 0;
    z-index: 0;
    pointer-events: none;
    background: linear-gradient(105deg, transparent 36%, rgba(255, 255, 255, .45) 50%, transparent 64%);
    transform: translateX(-110%);
    /* Even-paced across: an ease-out spent most of the pass off the button. */
    animation: ctaSheen 7s cubic-bezier(.45, 0, .55, 1) 2.4s infinite;
  }

  /* The mark gives a little wiggle when it is pointed at. */
  .brand:hover img {
    animation: logoWiggle .7s var(--ease) both;
  }
}

@keyframes ctaSheen {
  0% {
    transform: translateX(-110%);
  }

  18%,
  100% {
    transform: translateX(110%);
  }
}

@keyframes logoWiggle {
  0%,
  100% {
    transform: none;
  }

  25% {
    transform: rotate(-12deg) scale(1.08);
  }

  55% {
    transform: rotate(7deg) scale(1.04);
  }

  80% {
    transform: rotate(-3deg);
  }
}

/* ============ END OF FLOURISHES ============ */

/* ============ START OF DOODLES ============ */
/* The pen marks on every page (initDoodles in main.js): lines only, laid
   over the page and never in its way. Each stroke is one path long (pathLength
   1), dashed so the whole of it is gap, and drawn by sliding the dash in —
   from its first point to its last, in the order the script numbered them
   (--d), after the doodle's own wait (--delay). Taking is-drawn away runs it
   back the other way, which is how the role's underline is rubbed out
   before it is drawn again under the next one. */
/* What they hang in: anything that is not positioned already. */
.hero__title,
.hero .rotator,
.hero__actions,
.footer-cta,
.case-hero,
.case-block__title,
.case-stat,
.case-closing__text,
.case-more,
.resume-head {
  position: relative;
}

.doodle {
  position: absolute;
  top: 0;
  left: 0;
  z-index: 2;
  overflow: visible;
  /* The reset caps every svg at its container's width; a doodle often hangs
     off something narrower than itself (a hairline, a badge), and is meant to. */
  max-width: none;
  pointer-events: none;
  fill: none;
  stroke: var(--c-blue);
  stroke-width: 2.25;
  stroke-linecap: round;
  stroke-linejoin: round;
}

/* The problem's scribble, in the red its panel is ruled in. */
.doodle--red {
  stroke: var(--c-red);
}

.doodle path {
  stroke-dasharray: 1 2;
  stroke-dashoffset: 1.01;
  transition: stroke-dashoffset .3s var(--ease);
}

.doodle.is-drawn path,
.site-footer.is-risen .doodle path {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset var(--t, .7s) var(--ease-soft) calc(var(--delay, 0s) + var(--d, 0s));
}

.doodle--wave {
  --t: .6s;
}

.doodle--flicks {
  stroke-width: 2.75;
  --t: .35s;
}

/* Off the top right corner of a section's label, small. Elsewhere the
   script places them, to the size of what they mark. */
.section__tag > .doodle--flicks {
  top: -1.05rem;
  left: auto;
  right: -1.15rem;
  width: 1.5rem;
  height: 1.5rem;
}

.doodle--heart {
  --t: .8s;
}

/* ---- The career journey's marks (initDoodles in main.js) ----
   Drawn as an entry comes to the front of the stack and taken back as it
   leaves; in the plain list, once, as each is seen. */
.jn-date,
.jn-badge {
  position: relative;
}

.jn-date > .doodle--swoosh {
  top: 92%;
  left: auto;
  right: -.1em;
  width: 4.6em;
  height: .6em;
  --t: .5s;
}

.jn-card > .doodle--flicks {
  top: -1.4rem;
  left: auto;
  right: -1.4rem;
  width: 1.9rem;
  height: 1.9rem;
}

/* At the foot of the time-between line, pointing down at the date it ends on. */
.jn-gap > .doodle--drop {
  top: auto;
  left: auto;
  right: -.75rem;
  bottom: -1.1rem;
  width: 1.5rem;
  height: 1.875rem;
  stroke-width: 2;
  --t: .45s;
}

.jn-badge > .doodle--sparkle {
  top: -1rem;
  left: auto;
  right: -1.15rem;
  width: 1.15rem;
  height: 1.15rem;
}

.is-stacked .jn-entry.is-current .doodle path,
.journey-scroll:not(.is-stacked) .jn-entry.is-seen .doodle path {
  stroke-dashoffset: 0;
  transition: stroke-dashoffset var(--t, .7s) var(--ease-soft) calc(var(--delay, 0s) + var(--d, 0s));
}

/* Once drawn, the arrows lean in towards what they point at every few
   seconds, and the sparkle twinkles. Not on a slow device, and not for
   anyone who asked for less motion — for them every doodle is simply there,
   drawn, from the start. */
@media (prefers-reduced-motion: no-preference) {
  :root:not(.is-lite) .doodle--arrow.is-drawn {
    animation: doodleNudge 3.4s var(--ease) calc(var(--delay, 0s) + 1.6s) infinite;
  }

  :root:not(.is-lite) .site-footer.is-risen .doodle--arrow {
    animation: doodleNudge 3.4s var(--ease) 2s infinite;
  }

  :root:not(.is-lite) .doodle--sparkle.is-drawn {
    animation: doodleTwinkle 4.2s ease-in-out calc(var(--delay, 0s) + 1s) infinite;
  }
}

@media (prefers-reduced-motion: reduce) {
  .doodle path {
    stroke-dashoffset: 0;
    transition: none;
  }
}

@keyframes doodleNudge {
  0%,
  72%,
  100% {
    transform: none;
  }

  82% {
    transform: translateX(-.375rem);
  }

  91% {
    transform: translateX(.0625rem);
  }
}

@keyframes doodleTwinkle {
  0%,
  100% {
    transform: none;
  }

  50% {
    transform: scale(.8) rotate(14deg);
  }
}

/* ============ END OF DOODLES ============ */

/* ============ START OF ANALYTICS CONSENT ============ */
/* The question before any analytics loads (START OF ANALYTICS CONSENT in
   main.js): a pane of the same frosted glass as the menus, at the foot of the
   screen on the side away from the back-to-top button, rising in once the
   page has settled. Above the case-study contents bar, below the menus. */
.consent {
  position: fixed;
  left: var(--space-24);
  bottom: var(--space-24);
  z-index: 200;
  display: grid;
  gap: var(--space-16);
  width: min(24rem, calc(100vw - 2 * var(--space-24)));
  padding: var(--space-16) var(--space-16) var(--space-16) var(--space-24);
  border: 1px solid var(--glass-edge-strong);
  border-radius: var(--radius-3xl);
  background: var(--glass-sheet);
  -webkit-backdrop-filter: var(--glass-sheet-blur);
  backdrop-filter: var(--glass-sheet-blur);
  box-shadow: var(--glass-rim-bright), 0 1.25rem 3rem -1rem rgba(0, 0, 0, .6);
  animation: consentRise .6s var(--ease-soft) both;
}

.consent__text {
  padding-top: var(--space-8);
  font-size: var(--fs-sm);
  line-height: var(--lh-body);
  color: var(--c-muted);
}

.consent__lead {
  display: block;
  margin-bottom: var(--space-4);
  font-size: var(--fs-base);
  font-weight: var(--fw-semibold);
  color: var(--c-white);
}

.consent__link {
  color: var(--c-blue);
  text-decoration: underline;
  text-underline-offset: .2em;
  transition: color .3s var(--ease);
}

.consent__link:hover,
.consent__link:focus-visible {
  color: var(--c-white);
}

.consent__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-8);
}

@keyframes consentRise {
  from {
    opacity: 0;
    transform: translateY(1.5rem) scale(.96);
  }
}

@media (prefers-reduced-motion: reduce) {
  .consent {
    animation: none;
  }
}

/* A phone: the full width between the gutters, clear of the home bar. */
@media (max-width: 40rem) {
  .consent {
    left: var(--space-16);
    right: var(--space-16);
    bottom: calc(var(--space-16) + env(safe-area-inset-bottom, 0px));
    width: auto;
  }
}
/* The same question on the privacy policy, answerable again at any time. */
.consent-setting {
  display: grid;
  gap: var(--space-12);
  margin-block: var(--space-16) var(--space-24);
  padding: var(--space-16) var(--space-24);
  border: 1px solid var(--glass-edge);
  border-radius: var(--radius-xl);
  background: var(--glass-card);
  box-shadow: var(--glass-rim);
}

.consent-setting .case-block__text {
  margin: 0;
}

.consent-setting__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-8);
}

.consent-setting .btn:disabled {
  opacity: .4;
  cursor: not-allowed;
}
/* ============ END OF ANALYTICS CONSENT ============ */

/* ============ START OF LITE MODE ============ */
/* A device that cannot keep up with the page (see lite in main.js). The
   tokens take the blur out of the glass; this takes it out of everything
   else that blurs what is behind it on its own account — the header's four
   layers, the cursor, the veils behind the palette and the viewers — so no
   new one can be missed. The blur was the most expensive thing on screen:
   redrawn for every pixel under it each time anything behind it moved, which
   on a slow graphics chip is every frame of a scroll. */
.is-lite *,
.is-lite *::before,
.is-lite *::after {
  -webkit-backdrop-filter: none !important;
  backdrop-filter: none !important;
}

/* The header's progressive blur, said with a fade instead: the page's dark at
   the top, thinning to nothing past the bar, so what scrolls under the bar is
   still put behind it. */
.is-lite .nav-blur {
  background: linear-gradient(to bottom, var(--c-bg) 0%, rgba(11, 13, 18, .88) 55%, rgba(11, 13, 18, 0) 100%);
}

.is-lite .nav-blur span {
  display: none;
}

/* The palette's veil darkens instead of blurring, as it does when asked for
   less see-through. */
.is-lite #command-palette .cp-backdrop {
  background: var(--c-overlay);
}

/* The custom cursor stands down (initCursor), and the system pointer is back. */
.is-lite #cursor,
.is-lite #follower {
  display: none;
}

/* ============ END OF LITE MODE ============ */

/* ============ END OF MAEZKEV — VANILLA REBUILD ============ */