@import url("system/tokens.css");

:root {
  /* hero shares the page background (2026-10-11): the scroll lift + cursor parallax drift
     the photo, and a distinct hero backdrop showed as a grey band in the exposed gap.
     --hero-bg/-hi now alias --paper so every consumer (theme-toggle icon, meta theme-color)
     still resolves; the .hero element itself paints transparent. */
  --hero-bg: var(--paper);
  --hero-bg-hi: var(--paper);
  --hero-text: #141517;
  --hero-text-2: #4b4e53;
  --tab-bg: #1c1d20;
  --tab-text: #ffffff;
  /* light page colour = the light hero photo's VISIBLE bottom edge (2026-10-11), so the
     photo blends into the page like dark mode does. Sampled from full-original.jpg away
     from the figure: the visible edge sits ~10% above the file's last row (.hero__media
     bottom inset -10% + scale), where the backdrop is #f8f8f6 (token default #f4f4f2).
     Light only — the dark block below / tokens.css still set dark --paper. */
  --paper: #f8f8f6;
}
:root[data-theme="dark"] {
  --hero-bg: var(--paper);
  --hero-bg-hi: var(--paper);
  --hero-text: #ffffff;
  --hero-text-2: rgba(255, 255, 255, 0.7);
  --tab-bg: rgba(255, 255, 255, 0.08);
  --tab-text: #ffffff;
}

*, *::before, *::after { box-sizing: border-box; }
html { scroll-behavior: smooth; }
/* Browser chrome follows the page theme, not the OS (2026-10-12 mobile audit): html was
   transparent with color-scheme "light dark", so a phone in OS dark mode could paint the
   canvas, overscroll and Safari's status-bar tint dark on the light page after a reload.
   html now paints --paper; color-scheme is set inline on <html> by the index.html pre-paint
   script and by hero.js after each toggle's fade (not here: switching it mid-fade restarts
   every colour transition). */
:root { background-color: var(--paper); }
:root.no-t, :root.no-t *, :root.no-t *::before, :root.no-t *::after { transition: none !important; }

/* sections receive programmatic focus after the nav glide (hero.js) — no focus ring on them */
section[tabindex="-1"]:focus { outline: none; }
body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--font);
  font-size: var(--fs-body);
  line-height: 1.45;
  -webkit-font-smoothing: antialiased;
  overflow-x: clip;
  transition: background-color 500ms var(--ease-out), color 500ms var(--ease-out);
}
::selection { background: var(--ink); color: var(--paper); }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; padding: 0; cursor: pointer; }
:focus-visible { outline: 2px solid currentColor; outline-offset: 4px; border-radius: 6px; }
img { display: block; max-width: 100%; }

/* no hover cursors on any plain text */
p, span, h1, h2, h3, .credit, .role p, .open, .marquee__item { cursor: default; }
/* 2026-10-12: page-wide arrow. Text outside the list above (the footer name .ft__mark is a
   div, the about facts 8+ / 45+ / 10+ are <b>) still showed the I-beam text cursor. The
   arrow is inherited by everything; links/buttons keep the hand (rules below) and the
   contact form fields keep the I-beam. */
html { cursor: default; }
input, textarea, [contenteditable] { cursor: text; }
a, button, [role="switch"] { cursor: pointer; }
a p, a span, a svg, a div { cursor: pointer; }
/* the roles block is not a link cluster ? kill the hand on the inner divs that
   the rule above would otherwise hit */
.role a, .role a p, .role a div, .role a svg { cursor: default; }
/* these three stay plain text (arrow, not hand) even though they are magnetic
   links ? !important so the page-wide :root.mag-cursor latch can't flip them to a hand
   while they're pulled/engaged. The latch rule (:root.mag-cursor *) has specificity
   (0,2,0), which beats a bare .open (0,1,0) even with !important on both, so we repeat
   the class to outrank it: .open.open = (0,2,0), and .role__line.role__line * = (0,2,0). */
.open.open.open, .open.open.open *, .role__line.role__line.role__line, .role__line.role__line.role__line * { cursor: default !important; }
/* interactive links must receive pointer events on their text too, or hover never fires */
.ix, .ix * { pointer-events: auto; }

/* Magnetic latch: while the cursor is inside a magnetic element's catch zone the JS
   keeps .mag-hover on it (a pointer-driven :hover twin) ? the pull translates the
   element, which would otherwise slip out from under a still cursor and make :hover
   flicker off and on. The pull is SHARED (several elements can be engaged at once),
   but the "selected" visuals must be EXCLUSIVE: the JS grants .mag-ink to at most one
   element ? the one the cursor is deepest into ? and all hover rules are written
   `:is(:hover, .mag-ink)`, so only the single element you'd click right now shows the
   active color/weight/knob. Companion: `:root.mag-cursor` is on <html> while ANY
   magnetic element is engaged (they're all clickable) ? it holds the hand cursor
   page-wide so the cursor can't flicker arrow?hand as a pulled box slips out from
   under a still cursor. The plain-text items (the .open badge + .role__line title
   lines) are excluded: their `cursor: default !important` above wins, so they keep
   the arrow even while pulled. */
:root.mag-cursor, :root.mag-cursor * { cursor: pointer !important; }
/* The whole roles block is plain text, never a hand ? even while the magnetic latch is
   on. The latch rule above is (0,2,0); :root.mag-cursor .role* = (0,3,0) outranks it, and
   the descendant selector covers the container's dead space above/below the text lines. */
:root.mag-cursor .role, :root.mag-cursor .role * { cursor: default !important; }
/* The latch hands the WHOLE page a pointer while any magnetic element is engaged, but the
   hero's non-interactive surfaces must never show it. The role/open badges sit over the
   photo, and their catch radii reach past the .role box: hovering the thin bands just
   above "open to senior roles" or just below "AI-Driven UX" lands on .hero__media (the
   photo) while a just-vacated badge is still engaged, and the page-wide hand leaked onto
   the photo there. The marquee name is likewise decorative. These stay arrow even while
   the latch is on ? (0,3,0) outranks :root.mag-cursor * (0,2,0). */
:root.mag-cursor .hero__media, :root.mag-cursor .hero__media *,
:root.mag-cursor .marquee, :root.mag-cursor .marquee * { cursor: default !important; }
/* Location tab: the hand anywhere inside the black pill: padding, text spans and globe
   included. The plain-text rule (`span { cursor: default }`) and the globe's
   animated layers must never drop it back to the arrow. */
.locate.locate, .locate.locate * { cursor: pointer !important; }

.hero {
  position: relative; min-height: 100svh; overflow: hidden; isolation: isolate;
  background-color: var(--paper); /* opaque (same colour as the page) so the scroll cue's
     difference blend has a real backdrop inside this isolated group; was transparent. */
  /* no separate hero backdrop: --hero-bg now equals --paper,
     so when the scroll lift + parallax drift the photo up, the gap reads as the same
     page background ? no grey band. Photo is z-1 now (was 0) so it sits above the body bg. */
  color: var(--hero-text);
  transition: color 500ms var(--ease-out);
}
.hero::before { content: none; } /* radial highlight removed with the hero backdrop */
.hero__media { position: absolute; inset: -1.5% -2% -10%; z-index: 1; will-change: transform; }
.hero__media img {
  width: 100%; height: 100%; object-fit: cover; object-position: 50% 0;
  animation: hero-breathe 22s cubic-bezier(0.45, 0, 0.55, 1) infinite;
  transform: scale(1.02); /* resting overscan: the scroll lift + cursor parallax can
     pull the photo up/far enough to expose its bottom edge; a slight permanent zoom
     hides the seam without changing the crop's framing in any noticeable way */
  will-change: transform;
}
@keyframes hero-breathe {
  0%, 100% { transform: scale(1); }
  50%      { transform: scale(1.035); }
}

/* ---- theme cross-fade (2026-10-11) ----
   Light ↔ dark is a pure fade: NO element moves, resizes or re-stacks on toggle. The dark
   photo (pixel-aligned with the light one) sits stacked inside the SAME parallax layer and
   shares the same breathe animation, so it fades in exactly where the light photo is.
   The dark figure cut-out (.hero__media--fig, z 3) sits above the name (z 2) and only its
   opacity changes, so in dark the white name reads as passing behind the figure — without
   ever touching the name's z-index. Both imgs stay rendered (opacity 0, never display:none)
   so their breathe animations remain in lock-step with the light photo. */
/* --theme-ease (2026-10-12 mobile audit, "theme changes after a long delay"): was --ease-io
   (0.65,0,0.35,1). Its flat start left the first ~130ms after a tap visibly unchanged
   (review/mobile/bug-audit.log), so the toggle felt laggy. This curve starts moving on the
   first frame and still lands softly; same 700ms, so everything still fades together. */
:root { --theme-t: 700ms; --theme-ease: cubic-bezier(0.25, 0.4, 0.25, 1); }
.hero__photo--dark { position: absolute; inset: 0; }
.hero__photo--dark,
.hero__media--fig img { opacity: 0; transition: opacity var(--theme-t) var(--theme-ease); }
.hero__media--fig { z-index: 3; pointer-events: none; }
/* feathered silhouette edge (SVG #fig-feather in index.html): eats the dark halo pixels and
   softens the cut so the sliding name fades into the body. Tune erode radius / blur there. */
.hero__media--fig img { filter: url(#fig-feather); }
/* Pre-promote the layers that change during a toggle so the cross-fade is pure compositing:
   the figure's opacity fade doesn't create a new layer mid-animation, and the name's colour
   change repaints only its own layer, never the moving marquee's neighbours. */
.hero__photo--dark,
.hero__media--fig img { will-change: transform, opacity; }
:root[data-theme="dark"] .hero__photo--dark,
:root[data-theme="dark"] .hero__media--fig img { opacity: 1; }
/* landing straight in dark: until full-dark.jpg arrives, never flash the light photo — hide
   it so the CSS graphite vignette (dark-bg.css ::before, identical tones) shows instead.
   Not applied during a toggle (.theme-fade), where the light photo must stay under the
   fading-in dark one; afterwards it's fully covered anyway. */
:root[data-theme="dark"]:not(.theme-fade) .hero__photo:not(.hero__photo--dark) { visibility: hidden; }

/* While .theme-fade is on <html> (hero.js, one --theme-t window after a toggle) every colour
   in the hero eases with the photo — same duration, same ease-in-out. Only colour-type
   properties are listed: inline JS transforms (parallax, marquee, magnetic) must never get
   a CSS transition, or they would lag. Elements that already animate a transform/width/
   opacity keep those transitions explicitly below. */
/* 2026-10-12: widened from the hero to the WHOLE page. Before, only the hero eased (700ms)
   while the cue and everything below it (cards, timeline, values, footer) snapped at once,
   and some text ran its own 320ms .ix fade, so a toggle read as several fades in a row.
   Now every colour on the page moves on this one timeline. */
/* :root:root lifts specificity to (0,3,0) so it outranks per-component colour transitions
   such as the footer links (.ft--b .ft__col a, 200ms). Hero rules below are more specific
   still and keep their transform/width transitions alongside the colour. */
:root:root.theme-fade body,
:root:root.theme-fade *,
:root:root.theme-fade *::before,
:root:root.theme-fade *::after {
  transition-property: color, background-color, border-color, box-shadow, fill, stroke;
  transition-duration: var(--theme-t);
  transition-timing-function: var(--theme-ease);
}
/* Double-fade fix (2026-10-12). These elements have no colour of their own: they inherit it
   (the name's track/h1 from .marquee, the card text from .card's parent). Given a colour
   transition too, they chased their parent's colour frame by frame, restarting a new 700ms
   transition each frame, so they lagged behind. When .theme-fade came off at ~780ms, the
   h1 snapped back to the lagged start colour and faded a SECOND time (light->dark trace in
   review/mobile/df-out5.txt: name white at 729ms, back to rgb(32,33,36) at 771ms, white
   again at 1423ms). With colour left out here they simply inherit the parent's single fade.
   review/mobile/fade-starts.js lists any element whose transition starts late. */
:root:root.theme-fade .marquee__track,
:root:root.theme-fade .marquee__item,
:root:root.theme-fade .card,
:root:root.theme-fade .card__meta,
:root:root.theme-fade .card__meta h3,
:root:root.theme-fade .hero .open,
:root:root.theme-fade .hero .open *,
:root:root.theme-fade .hero .ai-i *,
/* touch screens: the invisible 44px hit-area ::befores (pointer: coarse, below) inherit
   colour too and would chase it the same way */
:root:root.theme-fade :is(.nav__link, .theme, .open, .locate-pill, .about__cta, .ai-tip__x)::before { transition-property: background-color, border-color, box-shadow, fill, stroke; }
:root.theme-fade .hero .theme__knob {
  transition: transform 420ms var(--ease-out), width 420ms var(--ease-out),
    color var(--theme-t) var(--theme-ease), background-color var(--theme-t) var(--theme-ease);
}
:root.theme-fade .hero .theme__knob svg { transition: transform 600ms var(--ease-out), color var(--theme-t) var(--theme-ease); }
:root.theme-fade .hero .locate {
  transition: transform 900ms var(--ease-out), color var(--theme-t) var(--theme-ease),
    background-color var(--theme-t) var(--theme-ease);
}
:root.theme-fade .hero .roll > span { transition: transform 520ms var(--ease-out), color var(--theme-t) var(--theme-ease); }
:root.theme-fade .hero .scrolldown { transition: opacity 260ms var(--ease-out), color var(--theme-t) var(--theme-ease); }
:root.theme-fade .hero .hero__photo--dark,
:root.theme-fade .hero .hero__media--fig img { transition: opacity var(--theme-t) var(--theme-ease); }

/* nav */
.nav {
  position: absolute; inset: 0 0 auto; z-index: 10;
  display: flex; align-items: center; justify-content: space-between;
  padding: calc(var(--gap) * 0.9) var(--gap);
  font-size: var(--fs-small); font-weight: 450;
}
.credit { cursor: default; color: var(--ink-3); }
.nav__links { position: relative; display: flex; align-items: center; gap: clamp(1.4rem, 2.6vw, 2.6rem); }
.nav__link { position: relative; display: inline-block; padding: .35em 0; font-weight: 400; }
/* hover: bump weight 400 ? 500 and turn ink-black (color comes from the .ix pattern). */
@media (hover: hover) {
  .nav__link:is(:hover, .mag-ink), .nav__link:focus-visible { font-weight: 500; }
}

/* hover H1: underline grows from the left, leaves to the right */
[data-hover="1"] .nav__link::after {
  content: ""; position: absolute; left: 0; right: 0; bottom: .1em; height: 1px; background: currentColor;
  transform: scaleX(0); transform-origin: right; transition: transform 420ms var(--ease-out);
}
@media (hover: hover) { [data-hover="1"] .nav__link:is(:hover, .mag-ink)::after, [data-hover="1"] .nav__link:focus-visible::after { transform: scaleX(1); transform-origin: left; } }

/* hover H2: the word rolls up and an identical copy rolls in */
.roll { display: inline-grid; overflow: hidden; vertical-align: top; }
.roll > span { grid-area: 1 / 1; transition: transform 520ms var(--ease-out); }
.roll > span + span { transform: translateY(105%); }
[data-hover="2"] .nav__link:is(:hover, .mag-ink) .roll > span:first-child { transform: translateY(-105%); }
[data-hover="2"] .nav__link:is(:hover, .mag-ink) .roll > span + span { transform: translateY(0); }

/* hover H3: one soft pill glides to the hovered link */
.glide {
  position: absolute; z-index: -1; top: 50%; left: 0; height: 2.1em; width: 0;
  border-radius: 999px; background: color-mix(in srgb, currentColor 10%, transparent);
  transform: translate(var(--gx, 0), -50%); opacity: 0;
  transition: transform 460ms var(--ease-out), width 460ms var(--ease-out), opacity 300ms var(--ease-out);
  pointer-events: none;
}
[data-hover="3"] .nav__links.has-glide .glide { opacity: 1; }
.glide { display: none; }
[data-hover="3"] .glide { display: block; }

/* theme switch */
.theme {
  position: relative; width: 54px; height: 30px; border-radius: 999px;
  border: 1.5px solid currentColor;
  display: inline-flex; align-items: center; padding: 3px; margin-left: .4rem;
  cursor: pointer;
  transition: color var(--ix-t) var(--ix-ease);
}
.theme__knob {
  position: relative; width: 22px; height: 22px; border-radius: 999px; background: currentColor;
  display: grid; place-items: center; pointer-events: none; cursor: pointer;
  transition: transform 420ms var(--ease-out), width 420ms var(--ease-out);
}
/* the knob is pointer-events:none so the button gets the hover, but force the hand
   cursor on the knob + icon too so it never falls back to the default arrow */
.theme__knob, .theme__knob * { cursor: pointer; }
.theme__knob svg { width: 12px; height: 12px; color: var(--hero-bg); transition: transform 600ms var(--ease-out); }
.theme:is(:hover, .mag-ink) .theme__knob { width: 27px; }
.theme:is(:hover, .mag-ink) .theme__knob svg { transform: rotate(40deg); }
.theme:active .theme__knob { width: 30px; }
:root[data-theme="dark"] .theme__knob { transform: translateX(24px); }
:root[data-theme="dark"] .theme:is(:hover, .mag-ink) .theme__knob { transform: translateX(19px); }
:root[data-theme="dark"] .theme:active .theme__knob { transform: translateX(16px); }
.theme .i-moon { display: none; }
:root[data-theme="dark"] .theme .i-sun { display: none; }
:root[data-theme="dark"] .theme .i-moon { display: block; }

/* running name */
/* --mq-b: the name's distance from the hero bottom. 3.5vh alone shrank on short screens while
   the 48px cue did not, so the pill ran into the letters (2026-10-12, review/mobile/
   cg-before.txt: clearance 7px at 1440x900, 0 at 1024x768, -4 at 890x626). The floor keeps
   the caps-to-bottom gap at >= 61px, which is exactly the 1440x900 value, so desktop is unchanged. */
.hero { --mq-b: max(3.5vh, calc(61px - var(--fs-name) * 0.1366)); }
.marquee { position: absolute; left: 0; right: 0; bottom: var(--mq-b); z-index: 2; white-space: nowrap; pointer-events: none; user-select: none; color: var(--tab-bg); }
:root[data-theme="dark"] .marquee { color: var(--tab-bg); }
.marquee--tone { display: none; }
:root[data-theme="dark"] .marquee--tone { display: block; color: #808080; mix-blend-mode: saturation; }
.marquee__track { display: inline-flex; will-change: transform; }
.marquee__item { margin: 0; padding-right: .42em; font-size: var(--fs-name); font-weight: 450; line-height: 1; letter-spacing: -0.025em; }

/* scroll cue ? horizontally centered, vertically centered in the gap between
   the BOTTOM OF THE CAP-HEIGHT GLYPHS (L/S/E/N/K/O/D/M/I ? everything except the
   Y descender) and the bottom of the hero. The name sits with its marquee box at
   bottom:3.5vh, but the box bottom includes the font descent (~0.1366em of
   --fs-name), so the caps baseline is that much ABOVE the box bottom. The true gap
   from caps to hero is therefore 3.5vh + descent. The cue is 48px tall, so we offset
   by half of (gap - 48px). Color matches the marquee name (--tab-bg light / #fff
   dark). Non-interactive. Scroll-aware (hero.js): on the first scroll down the pill
   dips, springs up and fades away; back at the top it slides in from above. The
   animation runs on .scrolldown__pill so this wrapper's centering transform is never
   touched. While hidden (.is-out) the arrow bounce is paused. */
.scrolldown {
  position: absolute; left: 50%; transform: translateX(-50%);
  bottom: calc((var(--mq-b) + (var(--fs-name) * 0.1366) - 48px) / 2);
  z-index: 3; pointer-events: none; user-select: none;
  /* Same colour as the name text (2026-10-11, replaced the old white + mix-blend-mode:
     difference inversion, which showed the inverse of the backdrop instead of the text
     colour): light = var(--tab-bg) #1c1d20 (the marquee name), dark = #fff. Ring and
     arrow both use currentColor, so they always match and ease with the theme fade. */
  color: var(--tab-bg);
}
:root[data-theme="dark"] .scrolldown { color: #fff; }
.scrolldown__pill { transform-origin: 50% 100%; } /* squash/stretch from the base */
.scrolldown.is-out .scrolldown__arrow { animation-play-state: paused; }
.scrolldown__pill {
  display: grid; place-items: center; width: 30px; height: 48px;
  /* 2.5px ring (was a 2px border): the cue already uses the name's exact colour, but a 2px
     anti-aliased line over the photo read as a lighter grey next to the solid letters.
     Drawn as an inset shadow because Chrome rounds border widths down to whole pixels. */
  box-shadow: inset 0 0 0 2.5px currentColor; border-radius: 999px;
  color: inherit;
}
/* arrow centred in the pill (equal padding top and bottom), bouncing with a smooth,
   symmetric ease-in-out ? no opacity, just a clean up-and-down wave. Stroke width
   matches the pill border (2px) so the ring and the arrow read at the same weight. */
/* stroke-width is in the 16-unit viewBox drawn at 14px (x0.875): 2.86 units = 2.5px, the
   same weight as the ring (the old 2 units was only 1.75px, thinner than the ring). */
.scrolldown__arrow { width: 14px; height: 14px; stroke-width: 2.86; animation: scrolldown-bounce 1.7s ease-in-out infinite; }
@keyframes scrolldown-bounce {
  0%, 100% { transform: translateY(-4px); }
  50%      { transform: translateY(4px); }
}

/* light-mode cue inversion along the glyphs (markup + how it works: index.html .cue-x).
   Exact colours: overlap = --paper, everything else untouched, and the photo is never
   affected. Light only: in dark the name passes behind the figure. Opacity eases with the
   theme fade instead of popping. */
.cue-x {
  position: absolute; inset: 0; z-index: 3; pointer-events: none; user-select: none;
  isolation: isolate; background: #000; mix-blend-mode: lighten;
  opacity: 1;
}
:root[data-theme="dark"] .cue-x { opacity: 0; }
/* 2026-10-12: no opacity fade on this layer any more. Half-faded, the blended group drew a
   pale blurred ring around the cue's stroke for the whole toggle (frames in
   review/mobile/cue-before-fade-*.png). It is switched off for the duration of the fade
   instead. It only shows where the pill crosses a letter, which never happens at rest, so
   switching it is invisible and the cue does a clean colour-only transition. */
:root.theme-fade .hero .cue-x { transition: none; visibility: hidden; }
.cue-x .cue-x__cue { color: var(--paper); z-index: auto; }
.cue-x__name { position: absolute; inset: 0; background: #000; mix-blend-mode: multiply; }
.cue-x__name .marquee { color: #fff; z-index: auto; }

/* location tab */
.locate {
  /* 5em of the tab sits off-screen left (square end, clipped by .hero's
     overflow); the same 5em is added to the left padding so the text stays
     put. One continuous piece ? the magnetic pull right can never open a gap. */
  position: absolute; left: -5em; top: 0; bottom: 0; z-index: 4;
  display: flex; align-items: center; gap: 1.6em;
  margin-block: auto; height: fit-content;
  padding: 1.05em 1.05em 1.05em calc(var(--gap) + 5em); border-radius: 0 999px 999px 0;
  background: var(--tab-bg); color: var(--tab-text);
  font-size: var(--fs-small); line-height: 1.2; font-weight: 450;
  transition: background-color 500ms var(--ease-out), transform 900ms var(--ease-out);
}
.locate__text > span { display: block; }

/* Dennis-style 3D wireframe globe ? verbatim technique from dennissnellenberg.com */
.globe {
  width: 4.2em; height: 4.2em; border-radius: 50%; overflow: hidden;
  position: relative; flex: none;
}
.globe-wrap {
  position: absolute; top: 50%; left: 50%; width: 100%; height: 100%;
  display: block; border-radius: 50%; overflow: hidden;
  transform: translate(-50%, -50%) rotate(30deg);
  animation: globe-oscillate 5.4s cubic-bezier(0.35, 0, 0.65, 1) infinite;
  box-shadow: inset 0 0 0 .125em var(--tab-text);
}
@keyframes globe-oscillate {
  0%   { transform: translate(-50%, -50%) rotate(15deg); }
  50%  { transform: translate(-50%, -50%) rotate(-15deg); }
  100% { transform: translate(-50%, -50%) rotate(15deg); }
}

/* Vertical wireframe circles */
.globe .circle {
  position: absolute; top: 50%; left: 50%; width: 100%; height: 100%;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  box-shadow: inset .1em 0 0 .08em var(--tab-text);
  animation: circle-spin 2.7s linear infinite;
  font-size: .75em;
  will-change: width, border-radius, box-shadow;
}
@keyframes circle-spin {
  0%   { border-radius: 50%; box-shadow: inset .1em 0 0 .08em var(--tab-text); width: 100%; background: transparent; }
  49.9%{ border-radius: 50%; box-shadow: inset .1em 0 0 .08em var(--tab-text); background: transparent; }
  50%  { border-radius: 0%; width: .175em; background: var(--tab-text); box-shadow: inset 0 0 0 0 transparent; }
  50.1%{ border-radius: 50%; box-shadow: inset -.1em 0 0 .08em var(--tab-text); background: transparent; }
  100% { border-radius: 50%; box-shadow: inset -.1em 0 0 .08em var(--tab-text); width: 100%; }
}
.globe .circle:nth-child(1) { animation-delay: -1.8s; }
.globe .circle:nth-child(2) { animation-delay: -.9s; }

/* Horizontal ellipse */
.globe .circle-hor {
  position: absolute; top: 50%; left: 50%;
  width: 150%; height: 55%;
  transform: translate(-50%, -50%);
  border-radius: 50%;
  box-shadow: inset 0 0 0 .15em var(--tab-text);
  font-size: .75em;
}

/* Center horizontal line */
.globe .circle-hor-middle {
  position: absolute; top: 50%; left: 50%;
  width: 150%; height: .15em;
  transform: translate(-50%, -50%);
  border-radius: 0%;
  background: var(--tab-text);
  font-size: .75em;
  will-change: transform;
}
.locate-pill { display: none; }

/* role ? individual lines with hover */
.role { position: absolute; z-index: 4; right: calc(var(--gap) * 2.2); top: 38%; font-size: var(--fs-role); line-height: 1.3; letter-spacing: -0.01em; display: flex; flex-direction: column; align-items: flex-end; }
.role { gap: 0; }
.role p { margin: 0; }
.role__line { display: inline-block; position: relative; padding: .1em 0; color: var(--hero-text); cursor: default; }
.role__line * { cursor: default; } /* overruled by [data-magnetic-cursor="pointer"] above */
.role__line [data-label] { display: none; }
[data-label-on="1"] .role__line [data-label="1"], [data-label-on="2"] .role__line [data-label="2"], [data-label-on="3"] .role__line [data-label="3"] { display: block; }

.open { display: inline-flex; align-items: center; justify-content: flex-end; gap: .35em; margin-bottom: 0; font-size: 16px; font-weight: 450; color: inherit; letter-spacing: 0; position: relative; padding: .3em 0; --mag-y: -5px; transform: translateY(var(--mag-y)); }
.open svg { width: 16px; height: 16px; flex: none; cursor: default; } /* overruled by [data-magnetic-cursor="pointer"] above */
.open p { margin: 0; }
/* Footer availability dot. Timing is the XNP post-checkout ping (xnp-company-site-v2
   pages.css .welcome-dot: 2s, pure ease-out, out and gone by 72%, then a rest beat), but
   what travels is a soft GLOW, not a ring: a blurred radial halo of the accent that blooms
   out from behind the dot and fades. transform + opacity only, so it stays on the compositor. */
/* isolation on the wrapper (not the dot): the glow's z-index:-1 then sits behind the dot
   but above the footer background */
.ft__avail { display: inline-flex; align-items: center; gap: .55em; isolation: isolate; }
.avail-dot { position: relative; flex: none; width: 7px; height: 7px; border-radius: 50%; background: var(--accent); }
.avail-dot::before {
  content: ""; position: absolute; inset: -5px; border-radius: 50%; pointer-events: none; z-index: -1;
  background: radial-gradient(circle, color-mix(in srgb, var(--accent) 70%, transparent) 0%,
    color-mix(in srgb, var(--accent) 30%, transparent) 45%, transparent 70%);
  filter: blur(1.5px);
  animation: avail-glow 2s cubic-bezier(0, 0, .2, 1) infinite;
}
@keyframes avail-glow {
  0%   { transform: scale(.6);  opacity: 1; }
  72%  { transform: scale(1.7); opacity: 0; }
  100% { transform: scale(1.7); opacity: 0; }
}
/* reduced motion: a still, faint halo, present but not moving */
@media (prefers-reduced-motion: reduce) { .avail-dot::before { animation: none; transform: scale(1); opacity: .5; } }

/* ============ work section (#work) ? rounded grid cards ============ */
.next { padding: clamp(5rem, 10vw, 9rem) var(--gap) 4rem; max-width: 1440px; margin: 0 auto; }
.work__head { display: flex; align-items: flex-end; justify-content: space-between; gap: 1rem; margin-bottom: 2rem; }
.work__head-left { display: flex; flex-direction: column; gap: .35rem; }
.work__eyebrow {
  margin: 0; font-size: var(--fs-small); font-weight: 450; letter-spacing: .14em;
  text-transform: uppercase; color: var(--ink-3);
}
.work__head h2 { margin: 0; font-size: clamp(1.5rem, 2.4vw, 2.2rem); font-weight: 450; letter-spacing: -0.015em; }
.work__head > span { color: var(--ink-3); font-size: var(--fs-small); }

/* Work 1 ? rounded grid cards (dousanmiao.com) */
.work__cards { display: grid; grid-template-columns: 1fr 1fr; gap: clamp(2rem, 3.5vw, 3.5rem) clamp(12px, 1.6vw, 24px); }
.card { display: block; }
.card__media {
  position: relative; aspect-ratio: 4 / 3; border-radius: var(--radius-card);
  /* flat colour + a currentColor sheen instead of a var()-built gradient: gradients can't
     be transitioned, so the old one snapped on a theme toggle while everything else faded.
     color is the ink, so the sheen eases with the theme fade. */
  color: var(--ink);
  background-color: var(--paper-2);
  background-image: linear-gradient(150deg, transparent 0%, color-mix(in srgb, currentColor 5%, transparent) 100%);
  transition: transform 500ms var(--ease-out);
}
@media (hover: hover) { .card:is(:hover, .mag-ink) .card__media { transform: scale(0.985); } }
/* card video (when a card has one): fills the rounded frame under the tag + arrow.
   On touch screens hero.js plays it while the card is in view. */
.card__media video { position: absolute; inset: 0; width: 100%; height: 100%; object-fit: cover; border-radius: inherit; }
.card__media:has(video) { overflow: hidden; isolation: isolate; }
.card__media video ~ .card__tag, .card__media video ~ .card__arrow { z-index: 1; }
.card__tag {
  position: absolute; top: 14px; left: 14px; padding: .34em .8em; border-radius: 999px;
  background: var(--tab-bg); color: var(--tab-text); font-size: 12px; font-weight: 500; letter-spacing: .02em;
}
.card__arrow {
  position: absolute; top: 14px; right: 14px; width: 40px; height: 40px; border-radius: 999px;
  display: grid; place-items: center; background: var(--tab-bg); color: var(--tab-text);
  opacity: 0; transform: scale(.6);
  transition: opacity 320ms var(--ix-ease), transform 420ms var(--ease-out);
}
.card__arrow svg { width: 16px; height: 16px; }
@media (hover: hover) { .card:is(:hover, .mag-ink) .card__arrow, .card:focus-visible .card__arrow { opacity: 1; transform: scale(1); } }
.card__meta { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; padding: .85em .2em 0; }
.card__meta h3 { margin: 0; font-size: 1.05rem; font-weight: 450; letter-spacing: -0.01em; flex: none; }
.card__meta p { margin: 0; color: var(--ink-3); font-size: var(--fs-small); text-align: right; flex: 1; min-width: 0; transition: color var(--ix-t) var(--ix-ease); }
@media (hover: hover) { .card:is(:hover, .mag-ink) .card__meta p { color: var(--ink-2); } }

/* ---------- experience (scroll timeline) ---------- */
/* .xp__fill is a spine that draws across with scroll
   (--xp-p: 0..1 set by hero.js, per-item .is-on activates as the fill reaches it). */
/* Section gap: 1.5x the old space above "8+ Years" (.next 4rem bottom + .xp clamp top).
   Used above the headline AND below the core-values cards so both gaps match. */
:root { --xp-gap: calc(1.5 * (4rem + clamp(2rem, 4vw, 4rem))); }
/* top = --xp-gap minus the 4rem .next already contributes; bottom = 0 (gap comes from .about--b top) */
.xp { display: block; padding: calc(var(--xp-gap) - 4rem) var(--gap) 0; max-width: 1440px; margin: 0 auto; }
.xp__headline { margin: 0 0 .4rem; font-size: clamp(1.5rem, 2.4vw, 2.2rem); font-weight: 450; letter-spacing: -0.015em; }
.xp__label {
  margin: 0 0 2.4rem; font-size: var(--fs-small); font-weight: 450; letter-spacing: .14em;
  text-transform: uppercase; color: var(--ink-3);
}

.xp__track {
  position: relative;
  display: flex; gap: clamp(1.5rem, 4vw, 4rem);
  padding-top: calc(var(--xp-line-y, 8px) + 18px); /* clear the line + node row */
}
/* track line (full, faint) + progress fill (accent, drawn left->right by --xp-p) */
.xp__track::before {
  content: ""; position: absolute; left: 0; right: 0; top: var(--xp-line-y, 8px); height: 1.5px;
  background: var(--line); border-radius: 2px;
}
.xp__fill {
  position: absolute; left: 0; top: var(--xp-line-y, 8px); width: 100%; height: 1.5px;
  background: var(--accent); border-radius: 2px;
  /* --xp-b: the end bounce from hero.js (a small speed-based overshoot past the end, springing back) */
  transform-origin: left center; transform: scaleX(calc(var(--xp-p, 0) * (1 + var(--xp-b, 0))));
}

.xp__item {
  position: relative; display: block; flex: 1 1 0; min-width: 0;
  text-decoration: none; color: inherit;
}

.xp__co { margin: 0; font-size: var(--fs-role); font-weight: 500; letter-spacing: -0.02em; line-height: 1.15; color: var(--ink); }
.xp__ro { margin: .3em 0 0; color: var(--ink-2); font-size: var(--fs-body); }
.xp__yr { color: var(--ink-3); font-size: var(--fs-small); font-variant-numeric: tabular-nums; }

/* ---------- obsessions ? the through-line of every role ---------- */
/* Complements the timeline: short, scannable labels in a balanced grid. A hairline
   rule separates it from the timeline; the label ties the two halves together. */
/* 2026-10-12: the timeline -> Core Values gap is the page's section gap (--xp-gap, the
   same space as work cards -> "8+ Years"). Was clamp(3rem, 6vw, 5rem): 80px at 1440, 42px on phones. */
.xp__obsess { margin-top: var(--xp-gap); }
.xp__obsess-label {
  margin: 0 0 1.6rem; font-size: var(--fs-small); font-weight: 450; letter-spacing: .14em;
  text-transform: uppercase; color: var(--ink-3);
}
/* Obsessions: cards ? the only display concept. */
.ob { margin: 0; padding: 0; list-style: none; }
.ob--cards { display: grid; }
.ob__ic { flex: 0 0 auto; color: var(--accent); }
.ob__t { margin: 0; color: var(--ink); }
.ob__d { margin: 0; color: var(--ink-2); }

/* Concept 1 ? Cards */
.ob--cards { grid-template-columns: repeat(3, 1fr); gap: 1.2rem; }
.ob--cards .ob__item {
  display: flex; flex-direction: column; align-items: flex-start; gap: .7rem;
  padding: 1.5rem 1.4rem 1.4rem; border: 1px solid var(--line); border-radius: var(--radius-card);
  background: var(--paper);
  /* transform is driven by the magnetic JS (data-magnetic) — never transition it here */
  transition: box-shadow 300ms var(--ease-out);
}
/* Magnetic lean replaces the old lift; no darker stroke. Shadow at half the old
   strength (22% → 11%) and only on .mag-ink, so it shows on ONE card at a time
   even when neighbours are leaning together. */
@media (hover: hover) { .ob--cards .ob__item.mag-ink {
  box-shadow: 0 10px 28px -14px color-mix(in srgb, var(--ink) 11%, transparent);
} }
.ob--cards .ob__ic { width: 26px; height: 26px; }
.ob--cards .ob__t { font-size: 1.05rem; font-weight: 550; letter-spacing: -0.01em; }
.ob--cards .ob__d { font-size: var(--fs-small); line-height: 1.45; }





/* node (dot / tick) ? sits centered on the line, fills as the spine reaches it */
.xp__dot, .xp__tick {
  /* item boxes start 18px below the line (track padding), so the node offsets up to sit on the line */
  position: absolute; top: -18px; left: 0; transform: translateY(-50%);
  display: grid; place-items: center; border-radius: 50%;
  background: var(--paper); border: 1.5px solid var(--line);
  transition: background var(--ix-t) var(--ix-ease), border-color var(--ix-t) var(--ix-ease);
}
.xp__dot { width: 26px; height: 26px; }
.xp__dot svg { width: 13px; height: 13px; color: var(--on-dark); opacity: 0; transform: scale(.2); transition: opacity 260ms var(--ease-out), transform 320ms var(--ease-out); }
.xp__item.is-on .xp__dot { background: var(--accent); border-color: var(--accent); }
.xp__item.is-on .xp__dot svg { opacity: 1; transform: scale(1); }
/* Check reveal: the goxnp.com home page "seal" (company-site index.html, .ct__done .seal):
   the circle pops in with overshoot (ct-sealin 620ms), the tick draws on (ct-tick 460ms,
   +260ms), then the circle gives one small settle bump (ct-settle 340ms, +700ms). Same
   curves and timings. Uses the independent `scale` property, because the dot's `transform`
   already centres it on the line. Removing .is-on (scroll back up) resets it, so it replays. */
.xp__dot svg path { stroke-dasharray: 30; stroke-dashoffset: 30; }
.xp__item.is-on .xp__dot {
  animation: xp-sealin 620ms cubic-bezier(.34,1.56,.64,1) both, xp-settle 340ms cubic-bezier(.34,1.56,.64,1) 700ms both;
}
.xp__item.is-on .xp__dot svg { opacity: 1; transform: none; transition: none; }
.xp__item.is-on .xp__dot svg path { animation: xp-tick 460ms cubic-bezier(.65,0,.35,1) 260ms both; }
@keyframes xp-sealin { 0% { opacity: 0; scale: .3; } 60% { opacity: 1; } 100% { opacity: 1; scale: 1; } }
@keyframes xp-tick { to { stroke-dashoffset: 0; } }
@keyframes xp-settle { 0% { scale: 1; } 45% { scale: 1.09; } 100% { scale: 1; } }
@media (prefers-reduced-motion: reduce) {
  .xp__item.is-on .xp__dot { animation: none; }
  .xp__dot svg path, .xp__item.is-on .xp__dot svg path { animation: none; stroke-dashoffset: 0; }
}
/* Dark mode: checkmark matches the dark background color */
:root[data-theme="dark"] .xp__item.is-on .xp__dot svg { color: #121315; }
@media (prefers-color-scheme: dark) {
  :root:not([data-theme="light"]) .xp__item.is-on .xp__dot svg { color: #121315; }
}

/* ---------- variation 1 ? classic spine ---------- */
.xp--spine { --xp-line-y: 8px; }
.xp--spine .xp__yr { display: block; margin: 0 0 .5em; }

/* ---------- variation 2 ? gutter ---------- */
/* Year rides up on the line as a "ticket"; the body moves up to sit directly
   under it, leaving a generous empty gutter beneath ? airy, index-like. */
.xp--gutter { --xp-line-y: 8px; }
.xp--gutter .xp__track { padding-top: calc(var(--xp-line-y, 8px) + 26px); }
.xp--gutter .xp__dot { top: -26px; }
.xp--gutter .xp__yr {
  position: absolute; top: -26px; left: 30px; transform: translateY(-50%);
  margin: 0; padding: .22em .6em;
  background: var(--paper); border: 1px solid var(--line); border-radius: 999px;
  white-space: nowrap;
}
.xp--gutter .xp__item.is-on .xp__yr { border-color: var(--accent); }

/* ---------- variation 3 ? rail ---------- */
/* Chunky vertical ticks on the line, wide columns, big display years ?
   an editorial index page. */
.xp--rail { --xp-line-y: 10px; }
.xp--rail .xp__track { padding-top: calc(var(--xp-line-y, 10px) + 22px); gap: clamp(2rem, 5vw, 5rem); }
.xp--rail .xp__track::before, .xp--rail .xp__fill { height: 2px; }
.xp--rail .xp__dot {
  width: 7px; height: 26px; border-radius: 4px; top: -22px; left: 0;
  border-width: 0; background: var(--line);
}
.xp--rail .xp__dot svg { display: none; }
.xp--rail .xp__item.is-on .xp__dot { background: var(--accent); }
.xp--rail .xp__yr {
  display: block; margin: 0 0 .35em;
  font-size: clamp(1.5rem, 2.6vw, 2.2rem); font-weight: 450; letter-spacing: -0.02em; color: var(--ink);
}

/* about ? split editorial: photo left, copy right */
.about--b { display: grid; grid-template-columns: 0.85fr 1.15fr; gap: clamp(2rem, 5vw, 5rem); align-items: center; padding: var(--xp-gap) var(--gap); max-width: 1440px; margin: 0 auto; }
/* 2026-10-12: bottom padding is the section gap too (--xp-gap, as above), so the space under
   "Get in touch" matches the rest of the page. Was clamp(6rem, 12vw, 10rem): 96px on phones, 160px at 1440. */
.about__media { margin: 0; border-radius: var(--radius-card); overflow: hidden; background: var(--paper-2); }
.about__media img { width: 100%; height: 100%; object-fit: cover; }
/* Hover: a subtle zoom toward the pointer, inside the rounded frame (the figure's
   overflow:hidden clips it). hero.js sets --zx/--zy (pointer position, eased so the focus
   glides instead of jumping) and toggles .is-zoom. Zoom in: 450ms ease-in-out; zoom out a
   touch slower (600ms) so letting go feels soft. Mouse/trackpad only; off under reduced
   motion. isolation keeps the corner clip crisp while the image is layered (Safari). */
@media (hover: hover) and (prefers-reduced-motion: no-preference) {
  .about__media { isolation: isolate; }
  .about__media img {
    transform-origin: var(--zx, 50%) var(--zy, 50%);
    /* Scale is driven by a spring in hero.js (--zs), not a CSS transition: Apple-style
       motion that keeps its velocity when you reverse mid-zoom and settles naturally. */
    transform: scale(var(--zs, 1)); will-change: transform;
  }
  /* click toggles a big zoom (hero.js): the cursor says which way the next click goes */
  .about__media { cursor: zoom-in; }
  .about__media.is-zoomed { cursor: zoom-out; }
  .about__media img { user-select: none; -webkit-user-drag: none; }
}
.about__title { margin: 0 0 1.2rem; font-size: clamp(2.2rem, 3.8vw, 3.5rem); font-weight: 450; line-height: 1.16; letter-spacing: -0.018em; }
.about__p { margin: 0; max-width: 46ch; color: var(--ink-2); }
.about__p + .about__p { margin-top: 1em; }
.about__facts { display: flex; justify-content: space-between; gap: clamp(1.6rem, 3.5vw, 3.2rem); margin: 2.6rem 0 2rem; padding-top: 1.6rem; border-top: 1px solid var(--line); }
.about__fact { display: grid; grid-template-rows: auto 1fr; gap: .25em; }
.about__fact b { font-size: clamp(1.35rem, 2vw, 1.9rem); font-weight: 450; letter-spacing: -0.015em; }
.about__fact span { color: var(--ink-3); font-size: var(--fs-small); }
@media (max-width: 760px) {
  /* ---- Phone hero: "Poster" (concept A, picked 2026-10-12) ----
     The desktop composition scaled down: full-bleed photo, name along the bottom, roles
     right-aligned above it, location as a half pill out of the left edge.
     Bottom stack, measured from the hero's bottom edge:
       --cue-b    space under the scroll cue (clears the home indicator)
       48px       the cue itself
       --cue-gap  space between the cue and the bottom of the name's capitals
     The name's box includes the font descent (0.1366em), so it is pulled down by that much
     to put the capitals, not the box, at the gap. Roles sit just above the name. */
  .hero {
    --cue-b: max(1.25rem, calc(env(safe-area-inset-bottom) + .75rem));
    --cue-gap: 1.25rem;
    --name-b: calc(var(--cue-b) + 48px + var(--cue-gap) - var(--fs-name) * 0.1366);
  }
  /* nav: all three links on the left, theme toggle pinned top right */
  .credit { display: none; }
  .nav__links { flex: 1; gap: 1.4rem; }
  .theme { margin-left: auto; }
  /* nav links + theme toggle in the name's ink instead of the resting .ix grey (dark mode
     is already white via dark-bg.css, which sets the same variable) */
  .nav .ix { --ix-color: var(--hero-text); }
  .hero__media { inset: -1.5% -2% -10%; height: auto; }
  .hero__media img { object-fit: cover; object-position: 50% 0; }
  .marquee { top: auto; bottom: var(--name-b); }
  /* 2026-10-12: on phones the name passes IN FRONT of the dark figure cut-out
     (.hero__media--fig, z 3), not behind it. Equal z-index + later in the DOM = on top.
     Set in both themes so nothing re-stacks on toggle: in light the cut-out is opacity 0,
     and the light cue copy (.cue-x, z 3) comes later in the DOM, so it stays above the name. */
  .marquee { z-index: 3; }
  /* 2026-10-12: dark mode put the white roles and the white name over the white t-shirt.
     A dark scrim rises from the bottom of the figure cut-out, under the name (same z, earlier
     in the DOM) and the roles (z 4). Picked over a text shadow / moving the roles because it
     also fixes the name (mockups: review/mobile/shirt-*-390x844.png). Fades with the photo. */
  .hero__media--fig::after {
    content: ""; position: absolute; inset: 0; pointer-events: none; opacity: 0;
    background: linear-gradient(to top, rgba(18,19,21,.78) 0%, rgba(18,19,21,.45) 38%, rgba(18,19,21,0) 62%);
    transition: opacity var(--theme-t) var(--theme-ease);
  }
  :root[data-theme="dark"] .hero__media--fig::after { opacity: 1; }
  /* the page-wide .theme-fade rule limits transitions to colours; keep this opacity fade */
  :root:root.theme-fade .hero .hero__media--fig::after { transition: opacity var(--theme-t) var(--theme-ease); }
  .scrolldown { bottom: var(--cue-b); }
  .role {
    left: auto; right: var(--gap); top: auto; bottom: calc(var(--name-b) + var(--fs-name) * 0.95);
    align-items: flex-end; text-align: right; font-size: 1.2rem;
    /* 2026-10-12 (owner): the "i" hangs past "UI" (absolute, .2em + 16px), so it touched the
       screen edge. Reserve its width so the "i", not the text, ends on the --gap line. */
    padding-right: calc(16px + .2em);
  }
  .open { margin-bottom: 0; justify-content: flex-end; }
  .locate { display: none; }
  .locate-pill {
    position: absolute; z-index: 4; left: 0; top: calc(var(--gap) * 3.2);
    display: inline-flex; align-items: center; gap: .55em; padding: .4em .85em .4em var(--gap);
    border-radius: 0 999px 999px 0; background: var(--tab-bg); color: var(--tab-text); font-size: var(--fs-small); font-weight: 450;
  }
  .locate-pill .globe { width: 1.9em; height: 1.9em; }
  .locate-pill .globe-wrap { box-shadow: inset 0 0 0 .06em var(--tab-text); }
  .locate-pill .circle { font-size: .35em; }
  .locate-pill .circle-hor, .locate-pill .circle-hor-middle { font-size: .35em; }
  .work__cards { grid-template-columns: 1fr; }
  /* horizontal timeline: keep the row, but let it swipe sideways with snap */
  .xp__track { gap: 1.6rem; overflow-x: auto; scroll-snap-type: x mandatory; -webkit-overflow-scrolling: touch; padding-bottom: .5rem; scrollbar-width: none; }
  .xp__track::-webkit-scrollbar { display: none; }
  .xp__item { flex: 0 0 72%; scroll-snap-align: start; }
  .xp__co { font-size: clamp(1.35rem, 5.6vw, 1.7rem); }
  /* obsessions: two columns on narrow screens, slightly tighter (the gap above stays
     --xp-gap, set in the base rule) */
  .xp__obsess-grid { grid-template-columns: 1fr 1fr; gap: .55rem 1rem; }
  .about--b { grid-template-columns: 1fr; align-items: start; }
  .about__media { max-width: 26rem; }
}

/* ? Get in touch CTA ? */
.about__cta {
  display: inline-flex; align-items: center; justify-content: center; gap: .5em;
  padding: .65em 1.6em; border-radius: 999px;
  background: var(--ink); color: var(--paper);
  font-size: var(--fs-small); font-weight: 450; letter-spacing: 0.01em;
}
.about__cta-arrow { width: 14px; height: 14px; flex: none; transition: transform 260ms var(--ease-out); }
.about__cta:hover .about__cta-arrow { transform: translateX(3px); }
.about__cta--up:hover .about__cta-arrow { transform: translateY(-3px); }

/* ============ footer ============ */
/* ---------- tools: one-colour logo row between the last section and the footer ----------
   No heading. Every logo is drawn in one colour (--ink-3) at one optical height, spread
   evenly across the content width; wraps to rows on narrow screens. */
/* Full-bleed marquee drifting right. hero.js clones the set (clones aria-hidden) until the
   track covers the viewport plus one set, so even ultra-wide screens never show a gap, and
   drives translateX every frame. Spacing is margin-right (not gap) so one set's width is
   exactly its period and the loop is seamless. Edges fade out softly. */
.tools {
  /* The hover (slow-down) zone hugs the logos: the same small padding above and below.
     The space before the footer is margin, outside the zone; the negative top margin
     cancels the top padding so the logos sit exactly where they did. */
  width: 100%; overflow: hidden; padding: .75rem 0;
  margin: -.75rem 0 calc(clamp(3.5rem, 7vw, 6rem) - .75rem);
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
          mask-image: linear-gradient(90deg, transparent, #000 6%, #000 94%, transparent);
}
.tools__list {
  margin: 0; padding: 0; list-style: none;
  display: flex; flex-wrap: nowrap; align-items: center; width: max-content;
  color: var(--ink-3); will-change: transform;
}
.tools__i { display: flex; flex: none; line-height: 0; margin-right: clamp(3rem, 6vw, 6rem); }
/* 1.5x the previous size (22-28px -> 33-42px) */
.tools__i svg { height: clamp(33px, 3.15vw, 42px); width: auto; display: block; }
.sr-only { position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0 0 0 0); white-space: nowrap; border: 0; }
@media (max-width: 720px) {
  .tools__i { margin-right: 2.75rem; }
  .tools__i svg { height: 30px; }
}
/* reduced motion: no drift, the logos sit centred and wrap */
@media (prefers-reduced-motion: reduce) {
  .tools { -webkit-mask-image: none; mask-image: none; padding-inline: var(--gap); }
  .tools__list { width: auto; flex-wrap: wrap; justify-content: center; gap: 2rem 0; }
}

.ft a:not(.about__cta) { color: inherit; text-decoration: none; }
/* footer · name wordmark: link columns + the hero name, full-bleed, cropped at the bottom edge */
.ft--b { max-width: 1440px; margin: 0 auto; padding: 0 var(--gap); overflow: hidden; }
.ft__cols { display: grid; grid-template-columns: 2fr 1fr 1fr 1fr; gap: 2rem; padding: 3rem 0 clamp(3rem, 6vw, 5rem); border-top: 1px solid var(--line); font-size: var(--fs-small); }
.ft__col { display: flex; flex-direction: column; gap: .5rem; align-items: flex-start; }
.ft__col p { margin: 0; }
.ft__h { color: var(--ink-3); letter-spacing: .14em; text-transform: uppercase; font-size: .85em; margin-bottom: .4rem !important; }
.ft__lead { font-size: clamp(1.3rem, 2vw, 1.8rem); line-height: 1.2; max-width: 18ch; margin-bottom: 1rem !important; letter-spacing: -0.01em; }
.ft--b .ft__col a:not(.about__cta), .ft--b .ft__col p:not(.ft__h, .ft__lead) { color: var(--ink-2); transition: color 200ms; }
.ft--b .ft__col a:not(.about__cta):hover { color: var(--ink); }
/* sized so the name spans the content width (≈1360px at 1440); line-height leaves room for the y descenders */
.ft__mark { font-size: 14.4vw; font-weight: 500; letter-spacing: -0.045em; line-height: 1; white-space: nowrap; margin: 0 -.04em; padding-bottom: .24em; color: var(--ink); }
@media (min-width: 1440px) { .ft__mark { font-size: 207px; } }
.ft__legal { display: flex; justify-content: space-between; padding: 1.2rem 0 1.4rem; border-top: 1px solid var(--line); font-size: var(--fs-small); color: var(--ink-3); }

@media (max-width: 760px) {
  .ft__cols { grid-template-columns: 1fr 1fr; }
  .ft__col--lead { grid-column: 1 / -1; }
  /* "Based in" spans both columns on its own 2-column grid (same 1fr 1fr + 2rem gap as
     .ft__cols, so the columns line up): heading + city on the left, the clock in the
     Elsewhere column on the same row as the city. */
  .ft__col--based { grid-column: 1 / -1; display: grid; grid-template-columns: 1fr 1fr; column-gap: 2rem; row-gap: .5rem; }
  .ft__col--based .ft__h { grid-column: 1; grid-row: 1; }
  .ft__col--based p:nth-of-type(2) { grid-column: 1; grid-row: 2; }
  .ft__col--based [data-ft-time] { grid-column: 2; grid-row: 2; }
  /* Get in touch: full width and taller. Back to top: same size, secondary (outlined).
     Phones only; above 760px both keep the desktop pill. */
  .about__cta { display: flex; width: 100%; min-height: 52px; padding: .9em 1.6em; }
  .about__cta--up { background: transparent; color: var(--ink); box-shadow: inset 0 0 0 1.5px var(--ink); }
}

@media (prefers-reduced-motion: reduce) {
  .marquee__track { flex-wrap: wrap; }
  .marquee__item[aria-hidden] { display: none; }
  .marquee__item { white-space: normal; padding-left: var(--gap); font-size: min(var(--fs-name), 19vw); }
  *, *::before, *::after { transition-duration: 1ms !important; animation: none !important; }
  /* timeline: show everything filled, no scroll-driven reveal */
  .xp__fill { transform: scaleX(1); }
  .xp__dot, .xp__tick { background: var(--accent); border-color: var(--accent); }
  .xp__dot svg { opacity: 1; transform: none; }
  .xp--rail .xp__body { opacity: 1; transform: none; }
}

/* ============ phones, tablets & touch ============ */
/* Core values cards: 3 columns only where they fit. Tablet portrait gets 2, phones 1
   (3 fixed columns made the page 429px wide on every phone). */
@media (max-width: 900px) { .ob--cards { grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 1rem; } }
@media (max-width: 560px) {
  .ob--cards { grid-template-columns: minmax(0, 1fr); gap: .75rem; }
  .ob--cards .ob__item { padding: 1.25rem 1.2rem; }
}

/* swipe areas: keep the gesture inside (no browser back-swipe / scroll chaining) */
.xp__track { overscroll-behavior-x: contain; }

/* notch / home-indicator safe areas (viewport-fit=cover is set in index.html) */
.ft__legal { padding-bottom: max(1.4rem, env(safe-area-inset-bottom)); }

/* Touch screens: 44px minimum hit areas without changing how anything looks.
   An invisible ::before grows the target around the element's centre. */
@media (pointer: coarse) {
  html { -webkit-tap-highlight-color: transparent; }
  .about__cta { position: relative; }
  .nav__link::before, .theme::before, .open::before,
  .locate-pill::before, .about__cta::before, .ai-tip__x::before {
    content: ""; position: absolute; left: 50%; top: 50%;
    width: max(100%, 44px); height: max(100%, 44px); transform: translate(-50%, -50%);
  }
  /* role lines are stacked tight, so overlapping hit areas would catch the wrong line:
     grow the lines themselves instead */
  .role__line { padding-block: .3em; }
  /* footer links are stacked: give each one a real 44px row instead of overlapping hit areas */
  .ft--b nav.ft__col { gap: 0; }
  .ft--b .ft__col a:not(.about__cta) { display: inline-flex; align-items: center; min-height: 44px; }
  /* press feedback instead of hover */
  .about__cta:active { opacity: .8; }
  .locate-pill:active { opacity: .85; }
}

/* Phone hero + cards (2026-10-12, from the 320/390 screenshots) */
/* Nav: the credit line ran into "Projects" at 390 and wrapped to 3 lines at 320.
   The footer already says "Designed by Dmitriy", so the nav drops it on phones. */
.credit { white-space: nowrap; }
@media (max-width: 440px) { .credit { display: none; } }
/* Short phones (iPhone SE / 320x640 class): the role lines sat on top of the photo's
   face. The photo keeps its size but drops lower (the hero clips the legs), and the
   role lines get slightly smaller, so the two no longer overlap. */
@media (max-width: 760px) and (max-height: 700px) {
  .role { font-size: 1.1rem; }
}
/* Project cards: the description was squeezed into a narrow right-hand column
   (3-5 words a line). On phones it stacks under the title instead. */
@media (max-width: 560px) {
  .card__meta { flex-direction: column; align-items: flex-start; gap: .25rem; }
  .card__meta p { text-align: left; }
}

/* Touch screens: a tap leaves :hover stuck on, so undo the hover-only looks there. */
@media (hover: none) {
  .about__cta:hover .about__cta-arrow, .about__cta--up:hover .about__cta-arrow { transform: none; }
  .theme:hover:not(:active) .theme__knob { width: 22px; }
  .theme:hover:not(:active) .theme__knob svg { transform: none; }
  :root[data-theme="dark"] .theme:hover:not(:active) .theme__knob { transform: translateX(24px); }
  .ft--b .ft__col a:not(.about__cta):hover { color: var(--ink-2); }
}

/* ============ Experience: vertical timeline on phones ============
   The spine runs down the left; hero.js sees flex-direction: column and drives the
   fill top-to-bottom, sizing it from the first to the last dot (--xp-head / --xp-tail). */
@media (max-width: 760px) {
  .xp__track {
    flex-direction: column; gap: 2rem; overflow: visible; scroll-snap-type: none;
    padding: 0 0 0 calc(26px + 1.1rem);
  }
  .xp__track::before, .xp__fill {
    left: 12.25px; right: auto; width: 1.5px; height: auto;
    top: var(--xp-head, 10px); bottom: var(--xp-tail, 0px);
  }
  .xp__fill { transform-origin: center top; transform: scaleY(calc(var(--xp-p, 0) * (1 + var(--xp-b, 0)))); }
  .xp__item { flex: none; scroll-snap-align: none; }
  /* dot centred on the year line, in the left gutter */
  .xp__dot { top: -3px; left: calc(-26px - 1.1rem); transform: none; }
}
@media (max-width: 760px) and (prefers-reduced-motion: reduce) { .xp__fill { transform: none; } }

/* ============ About title ============
   "products people" never splits. The old hard <br> after "products" forced a break even
   where the first line had already wrapped, leaving "products" alone on its own line. */
.about__nw { white-space: nowrap; }


/* ============ Phone round (2026-10-12, 11-item list) ============
   Everything below is phone-only (<=760px); desktop is untouched. */

/* Lag: portrait phones load the *-m photo crops (index.html <picture>). The figure crop has
   the #fig-feather edge baked into its alpha, so the live SVG filter, which re-filtered a
   full-screen layer on every frame of the breathe zoom, is off. Same media query as the
   <source> tags, so the filter is only dropped where the pre-feathered file is shown. */
@media (max-width: 760px) and (max-aspect-ratio: 3/4) {
  .hero__media--fig img { filter: none; }
}

/* Phone pill globe (markup: index.html .gh). Fixed 1.25px lines in px (the old globe's em
   lines came out ~0.4px, soft). Same motion as the CSS globe: three meridians sweep edge to
   edge and the whole globe rocks +-15deg. A meridian is a half-ellipse (right border of a
   circle) squashed by scaleX 1 -> -1 with a sine ease, which is cos(pi t): a line on a turning
   sphere. Only `transform` animates, on HTML boxes, so it runs on the compositor: no layout
   and no repaint per frame. The old width/border-radius keyframes forced a page layout every
   frame (~600 per probe run: review/mobile/perf-sw.err "baseline" vs "noGlobe"; an SVG
   transform version still laid out 60x/s, review/mobile/lb.txt). */
.locate-pill .globe { position: relative; border-radius: 50%; overflow: hidden; box-shadow: inset 0 0 0 1.25px currentColor; }
/* Clip fix (2026-10-12 mobile audit, "globe spin has issues"): iOS Safari ignores
   overflow:hidden + border-radius on a box whose children run compositor transform
   animations, so the meridians / equator showed as square strips past the circle while
   spinning. A mask forces a real round clip on both wrappers; isolation keeps it on the GPU. */
.locate-pill .globe, .gh { -webkit-mask-image: radial-gradient(circle, #000 70.5%, transparent 71%); mask-image: radial-gradient(circle, #000 70.5%, transparent 71%); isolation: isolate; }
.gh { position: absolute; inset: 0; border-radius: 50%; overflow: hidden; animation: gh-rock 5.4s cubic-bezier(0.35, 0, 0.65, 1) infinite; }
.gh > i { position: absolute; display: block; }
.gh__m { left: 0; right: 50%; top: 0; bottom: 0; transform-origin: 100% 50%; animation: gh-spin 2.7s cubic-bezier(0.37, 0, 0.63, 1) infinite; }
.gh__m::before { content: ""; position: absolute; top: 0; bottom: 0; left: 0; width: 200%; border-radius: 50%; box-shadow: inset 1.25px 0 0 0 currentColor; }
.gh__m { overflow: hidden; }
.gh__m--2 { animation-delay: -1.8s; }
.gh__m--3 { animation-delay: -.9s; }
.gh__eq { left: -25%; right: -25%; top: 50%; height: 55%; transform: translateY(-50%); border-radius: 50%; box-shadow: inset 0 0 0 1.25px currentColor; }
.gh__mid { left: 0; right: 0; top: 50%; height: 1.25px; margin-top: -.625px; background: currentColor; }
@keyframes gh-rock { 0%, 100% { transform: rotate(15deg); } 50% { transform: rotate(-15deg); } }
@keyframes gh-spin { 0% { transform: scaleX(1); } 100% { transform: scaleX(-1); } }
@media (prefers-reduced-motion: reduce) { .gh, .gh__m { animation: none; } .gh { transform: rotate(15deg); } }

@media (max-width: 760px) {
  /* 2: scroll cue at the theme toggle's line weight (ring + arrow, .cue-x copy too). The ring
     is a real 1.5px border like the toggle's, so each browser rounds both the same way (Chrome
     draws 1.5px borders as 1px). hero.js copies the toggle's computed border width into --tw
     for the arrow: viewBox 16 units drawn at 14px, so width / 0.875. */
  .scrolldown__pill { box-shadow: none; border: 1.5px solid currentColor; }
  .scrolldown__arrow { stroke-width: calc(var(--tw, 1.5px) / 0.875); }

  /* 4: one clock for the toggle too. The knob slid in 420ms ease-out while every colour ran
     --theme-t ease-in-out, so the switch finished long before the page did. */
  :root.theme-fade .hero .theme__knob {
    transition: transform var(--theme-t) var(--theme-ease), width 420ms var(--ease-out),
      color var(--theme-t) var(--theme-ease), background-color var(--theme-t) var(--theme-ease);
  }

  /* 5: Miami pill a little taller (.4em -> .5em top and bottom = +2.8px) */
  .locate-pill { padding-block: .5em; }

  /* 6: dark mode gets the cue inversion too. The name's "y" descender runs through the top of
     the cue. Light already paints the overlap in --paper (.cue-x, light rules above). Dark
     inverts the recipe: white group, cue copy in the dark --paper, name copy black and
     screened over it, so the group is dark only where pill AND glyph overlap and white
     everywhere else. Blended with `darken`, white leaves the page untouched and the overlap
     turns the white pill dark over the white letter. Switched while .cue-x is hidden for
     the toggle (.theme-fade rule above), so nothing visibly swaps. Phones only: on desktop
     the name passes behind the dark figure, where an inversion would draw unseen letters. */
  :root:root[data-theme="dark"] .cue-x { opacity: 1; background: #fff; mix-blend-mode: darken; }
  :root:root[data-theme="dark"] .cue-x .cue-x__name { background: #fff; mix-blend-mode: screen; }
  :root:root[data-theme="dark"] .cue-x .cue-x__name .marquee { color: #000; }

  /* 8: footer links 1px up (14 -> 15px) */
  .ft--b .ft__col a:not(.about__cta) { font-size: 15px; }

  /* 9: Get in touch + Back to top a few px bigger (14 -> 16px text, 52 -> 56px tall) */
  .about__cta { font-size: 16px; min-height: 56px; }

  /* 10: footer lead set exactly like the About title ("I'll help you ship products people
     understand."): same size, weight, leading and tracking */
  .ft__lead { font-size: clamp(2.2rem, 3.8vw, 3.5rem); font-weight: 450; line-height: 1.16; letter-spacing: -0.018em; max-width: none; }
}


/* ============ Phone "Get in touch" under the work cards (T1, 2026-10-12) ============
   Phones only: a second way in right after the six cards, outlined (secondary) like
   "Back to top", full width, 56px (the phone .about__cta rules above). -> LinkedIn, new tab. */
.about__cta--work { display: none; }
.about__cta--work:hover .about__cta-arrow { transform: translateX(3px); }
@media (max-width: 760px) {
  .about__cta--work { display: flex; margin-top: 2.5rem; }
}
@media (hover: none) { .about__cta--work:hover .about__cta-arrow { transform: none; } }

/* ============ AI proof tip (2026-10-12) ============
   The "i" hangs off the top right of "AI-Driven UX/UI" (absolute, so the line never shifts)
   inside .role__wrap, which now carries the magnetic pull, so the mark moves with the text.
   The card (.ai-tip, end of <body>) is position: fixed and placed by hero.js every frame from
   the mark's rect, so it rides the pull, the phone bob and scroll. hero.js drives all motion
   (one spring, three looks: ?ai=1 unfold / 2 grow / 3 rise); CSS only sets the rest state. */
.role__wrap { position: relative; display: block; }
/* 2026-10-12 (owner): the mark is the exact twin of the "Available for Hire" circle: 16px box,
   same 16-unit viewBox, r=7 ring, 1.3 stroke (.open svg), so ring size and line weight match.
   Same colour as the role text beside it (--hero-text) and no hover/open colour change. */
.ai-i {
  position: absolute; left: 100%; top: .2em; margin-left: .2em;
  width: 16px; height: 16px; display: grid; place-items: center;
  color: var(--hero-text); border-radius: 50%;
}
.ai-i svg { width: 16px; height: 16px; display: block; }
.ai-i:focus-visible { outline-offset: 2px; }
/* the "i" is a real control: the hand, beating the roles block's plain-text arrow rules
   (up to :root.mag-cursor .role * = (0,3,0) !important) */
.role .ai-i.ai-i.ai-i, .role .ai-i.ai-i.ai-i * { cursor: pointer !important; }
/* only the "AI-Driven UX/UI" label set (?label=1, the default) gets the mark */
:root:not([data-label-on="1"]) .ai-i { display: none; }

.ai-tip { position: fixed; left: 0; top: 0; z-index: 60; width: min(20rem, calc(100vw - 2rem)); will-change: transform; }
.ai-tip[hidden] { display: none; }
.ai-tip__p {
  position: relative; padding: .9rem .9rem 1rem; border-radius: 16px;
  background: #fff; color: var(--ink); /* white, not --paper: the card has to lift off the page */
  box-shadow: 0 0 0 1px rgba(0, 0, 0, .07), 0 11px 28px -7px rgba(0, 0, 0, .16), 0 3px 9px -4px rgba(0, 0, 0, .09); /* halved blur + strength, owner 2026-10-12 */
  transform-origin: var(--ai-ox, 100%) var(--ai-oy, 0%);
}
:root[data-theme="dark"] .ai-tip__p { background: var(--paper-2); box-shadow: 0 0 0 1px rgba(255, 255, 255, .1), 0 9px 25px -6px rgba(0, 0, 0, .3); }
.ai-tip__x {
  position: absolute; top: .55rem; right: .55rem; width: 28px; height: 28px;
  display: grid; place-items: center; border-radius: 50%; color: var(--ink-3);
  transition: color var(--t-control) var(--ease-io), background-color var(--t-control) var(--ease-io);
}
.ai-tip__x svg { width: 14px; height: 14px; }
@media (hover: hover) { .ai-tip__x:hover { color: var(--ink); background: var(--line); } }
.ai-tip__k { margin: .1rem 2rem .7rem .15rem; font-size: 11px; letter-spacing: .14em; text-transform: uppercase; color: var(--ink-3); }
.ai-tip__img { display: block; border-radius: 10px; overflow: hidden; box-shadow: 0 0 0 1px var(--line); background: #0d1117; }
.ai-tip__img img { width: 100%; height: auto; }
.ai-tip__t { margin: .8rem .15rem 0; font-size: var(--fs-small); line-height: 1.45; color: var(--ink-2); }
.ai-tip__nw { white-space: nowrap; } /* "by-hand." never breaks at its hyphen (owner's copy unchanged) */
/* Desktop fit (fit 2, picked by the owner 2026-10-12; the side variant was removed). The square
   proof image is cropped from the top to 230px, which keeps the count, the graph and the Less/More
   legend and drops only the "Contribution activity / Year" row. At 1440x900 the card is then ~404px
   and fits between the line (bottom 464 + 10 gap) and the 12px screen edge (888), so it no longer
   covers "AI-Driven UX/UI". (260px, the first idea, still overlapped by ~20px.) */
.ai-tip .ai-tip__img img { height: 230px; object-fit: cover; object-position: top; }
@media (pointer: coarse) {
  .ai-i::before { content: ""; position: absolute; left: 50%; top: 50%; width: 44px; height: 44px; transform: translate(-50%, -50%); }
}

