/* bensonshen.com - flattened from the Claude Design source.
   The design used inline styles plus `style-hover` attributes; those became
   real classes and :hover rules here. */

/* Self-hosted rather than loaded from Google. Fonts served from a third party
   put two extra origins and a two-hop dependency chain (googleapis for the CSS,
   then gstatic for the files) in front of the first paint; served from here
   they ride the connection the HTML already opened. Both are subset to the
   same `latin` range Google ships - wide enough that anything typed into the
   contact form still renders. Licences sit next to the files.

   Instrument Sans is the variable face with its width axis pinned and its
   weight axis clamped to the 400-600 the design actually uses, so one file
   covers all three weights. JetBrains Mono only ever renders at 400. */
@font-face {
  font-family: 'Instrument Sans';
  src: url('/assets/fonts/instrument-sans-var.woff2') format('woff2-variations');
  font-weight: 400 600;
  font-style: normal;
  font-display: swap;
}

@font-face {
  font-family: 'JetBrains Mono';
  src: url('/assets/fonts/jetbrains-mono-400.woff2') format('woff2');
  font-weight: 400;
  font-style: normal;
  font-display: swap;
}

:root {
  /* Rotating this shifts every accent at once - it was the `accentShift`
     prop in the design file. */
  --hue-shift: 0deg;

  --bg: #fafbfc;
  --ink: #16181c;
  --ink-88: rgba(22, 24, 28, 0.88);
  --ink-76: rgba(22, 24, 28, 0.76);
  --ink-70: rgba(22, 24, 28, 0.7);
  --ink-66: rgba(22, 24, 28, 0.66);
  --ink-62: rgba(22, 24, 28, 0.62);
  --ink-58: rgba(22, 24, 28, 0.58);
  --ink-52: rgba(22, 24, 28, 0.52);
  --ink-45: rgba(22, 24, 28, 0.45);
  --ink-40: rgba(22, 24, 28, 0.4);
  --ink-36: rgba(22, 24, 28, 0.36);
  --ink-30: rgba(22, 24, 28, 0.3);

  --accent: oklch(0.55 0.15 255);
  --accent-strong: oklch(0.5 0.16 255);

  --glass: rgba(252, 253, 255, 0.72);
  --glass-hover: rgba(252, 253, 255, 0.77);
  --glass-border: rgba(255, 255, 255, 0.75);
  --glass-border-hover: rgba(255, 255, 255, 0.82);
  --glass-blur: blur(28px) saturate(180%);
  --glass-shadow: 0 1px 2px rgba(22, 24, 28, 0.03), 0 14px 40px rgba(22, 24, 28, 0.06),
    inset 0 1px 0 rgba(255, 255, 255, 0.9);

  --hairline: rgba(22, 24, 28, 0.06);
  --wash: rgba(22, 24, 28, 0.038);

  /* Surfaces nested *inside* a glass card can't use the glass treatment
     themselves - white on white with a white border disappears. They keep the
     translucency but take an ink-tinted edge, and either sit above the card
     (raised) or sink into it (well). */
  --raised: rgba(255, 255, 255, 0.7);
  --raised-hover: rgba(255, 255, 255, 0.86);
  --raised-border: rgba(22, 24, 28, 0.11);
  --raised-border-hover: rgba(22, 24, 28, 0.17);
  --raised-shadow: 0 1px 2px rgba(22, 24, 28, 0.04), 0 6px 16px rgba(22, 24, 28, 0.05),
    inset 0 1px 0 rgba(255, 255, 255, 0.75);

  --well: rgba(22, 24, 28, 0.035);
  --well-hover: rgba(22, 24, 28, 0.052);
  --well-border: rgba(22, 24, 28, 0.13);
  --well-shadow: inset 0 1px 2px rgba(22, 24, 28, 0.05);

  /* Form fields are lit rather than recessed - an ink wash read as disabled
     against the card. The ink border and inset shadow still bound the field, so
     it doesn't need to be darker than its surroundings to look like a field. */
  --input: rgba(255, 255, 255, 0.5);
  --input-hover: rgba(255, 255, 255, 0.62);
  --input-focus: rgba(255, 255, 255, 0.72);

  /* The dark buttons are the same material seen from the other side: tinted
     glass, translucent enough for the colour field to bleed through, lit along
     the top edge. Kept above 0.8 alpha so the label stays past 14:1. */
  --tint: rgba(22, 24, 28, 0.86);
  --tint-hover: rgba(22, 24, 28, 0.78);
  --tint-border: rgba(255, 255, 255, 0.13);
  --tint-border-hover: rgba(255, 255, 255, 0.2);
  --tint-shadow: 0 8px 24px rgba(22, 24, 28, 0.16), inset 0 1px 0 rgba(255, 255, 255, 0.15);

  /* Focus reads as glass catching light: a cool, desaturated edge and a soft
     halo. A saturated blue would fight the rest of the palette, but the edge
     still clears 3:1 against the surface behind it. */
  --focus-edge: oklch(0.6 0.038 255);
  --focus-halo: oklch(0.6 0.038 255 / 0.15);

  --mono: 'JetBrains Mono', ui-monospace, SFMono-Regular, Menlo, monospace;
  --sans: 'Instrument Sans', -apple-system, BlinkMacSystemFont, Helvetica, Arial, sans-serif;

  --ease: cubic-bezier(0.16, 1, 0.3, 1);
  --ease-soft: cubic-bezier(0.22, 1, 0.36, 1);

  --shell: 1040px;
  /* Border-box: 1px border + 12px padding a side, so the 40px icon pill
     (24px icon, 8px around it) needs 66px to sit evenly inside the rail. */
  --rail-w: 66px;
  --rail-w-open: 198px;
}

/* The design commits to a single light treatment - the glass surfaces and
   the blurred colour field only read correctly against a near-white ground.
   Declared so the browser paints form controls and scrollbars to match. */
:root {
  color-scheme: light;
}

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

html {
  scroll-behavior: smooth;
  background: var(--bg);
  overflow-x: clip;
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--ink);
  font-family: var(--sans);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

a {
  color: var(--ink);
  text-decoration: none;
}

a:hover {
  color: var(--accent);
}

::selection {
  background: oklch(0.72 0.11 255 / 0.28);
}

:focus-visible {
  outline: 2px solid var(--focus-edge);
  outline-offset: 3px;
  border-radius: 6px;
}

h1,
h2,
h3 {
  font-weight: 600;
  margin: 0;
}

.skip-link {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  padding: 12px 18px;
  border-radius: 0 0 12px 0;
  background: var(--ink);
  color: var(--bg);
  font-size: 14px;
}

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

.mono {
  font-family: var(--mono);
}

/* Off-screen but still read aloud - used for the copy-to-clipboard live region. */
.sr-only {
  position: absolute;
  width: 1px;
  height: 1px;
  padding: 0;
  margin: -1px;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* ---------------------------------------------------------------- surfaces */

.glass {
  background: var(--glass);
  border: 1px solid var(--glass-border);
  backdrop-filter: var(--glass-blur);
  -webkit-backdrop-filter: var(--glass-blur);
  box-shadow: var(--glass-shadow);
}

.glass-hover {
  transition: background 0.7s var(--ease-soft), border-color 0.7s var(--ease-soft),
    box-shadow 0.7s var(--ease-soft);
}

.glass-hover:hover {
  background: var(--glass-hover);
  border-color: var(--glass-border-hover);
}

/* ------------------------------------------------------------- background */

.field {
  position: fixed;
  inset: 0;
  pointer-events: none;
  z-index: 0;
  filter: hue-rotate(var(--hue-shift));
}

.field span {
  position: absolute;
  border-radius: 50%;
  display: block;
}

.field .b1 {
  top: -20vh;
  left: -12vw;
  width: 68vw;
  height: 68vw;
  background: radial-gradient(circle at 50% 50%, oklch(0.82 0.07 255 / 0.5), transparent 64%);
  filter: blur(70px);
  animation: driftA 30s ease-in-out infinite;
}

.field .b2 {
  top: 34vh;
  right: -20vw;
  width: 60vw;
  height: 60vw;
  background: radial-gradient(circle at 50% 50%, oklch(0.86 0.05 310 / 0.42), transparent 66%);
  filter: blur(80px);
  animation: driftB 38s ease-in-out infinite;
}

.field .b3 {
  bottom: -24vh;
  left: 22vw;
  width: 58vw;
  height: 58vw;
  background: radial-gradient(circle at 50% 50%, oklch(0.88 0.05 200 / 0.4), transparent 68%);
  filter: blur(90px);
  animation: driftA 44s ease-in-out infinite reverse;
}

@keyframes driftA {
  0%,
  100% {
    transform: translate3d(0, 0, 0) scale(1);
  }
  50% {
    transform: translate3d(3%, -5%, 0) scale(1.1);
  }
}

@keyframes driftB {
  0%,
  100% {
    transform: translate3d(0, 0, 0) scale(1.06);
  }
  50% {
    transform: translate3d(-4%, 4%, 0) scale(0.96);
  }
}

@keyframes rise {
  from {
    opacity: 0;
    transform: translateY(16px);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* The site mark. A circle rather than the rounded square of
   public/images/favicon.svg - in the pill nav it sits inside a 999px bar
   alongside 999px links, and a squarish tile was the only hard corner there. */
.logo {
  flex: none;
  display: grid;
  place-items: center;
  width: 24px;
  height: 24px;
  border-radius: 50%;
  background: var(--ink);
  color: var(--bg);
}

.logo svg {
  display: block;
  width: 14px;
  height: 14px;
}

/* -------------------------------------------------------------------- boot */

/* The first-paint cover: the mark on the page's own background, drawn over
   the document for about half a second while the fonts swap in, then faded
   out. It sits *on top of* the page rather than standing in for it - nothing
   is hidden, no content waits on it, and a crawler reading the markup never
   encounters it at all. Same rule as the reveal block below.

   A CSS animation runs the whole thing, so there is no state for a script to
   get wrong: the fade always ends, whether or not main.js loads. main.js only
   sweeps the finished element out of the DOM afterwards. `pointer-events` is
   off from the first frame, so even the impossible case - a browser that
   applies the stylesheet but runs no animations - leaves the page usable.

   Under prefers-reduced-motion the override at the foot of this file collapses
   the animation to nothing, and the page is simply there on arrival. */
.boot {
  position: fixed;
  inset: 0;
  z-index: 200;
  display: grid;
  place-items: center;
  background: var(--bg);
  pointer-events: none;
  animation: bootOut 0.8s var(--ease-soft) forwards;
}

.boot-mark {
  width: 46px;
  height: 46px;
  animation: bootMark 0.8s var(--ease-soft) forwards;
}

.boot-mark svg {
  width: 26px;
  height: 26px;
}

/* Held opaque for the first half so the mark reads, then out. `visibility`
   flips only on the final keyframe - it does not interpolate - which is what
   takes the layer out of the hit-testing and accessibility trees for good. */
@keyframes bootOut {
  0%,
  55% {
    opacity: 1;
  }
  100% {
    opacity: 0;
    visibility: hidden;
  }
}

@keyframes bootMark {
  0% {
    opacity: 0;
    transform: scale(0.8);
  }
  45% {
    opacity: 1;
    transform: scale(1);
  }
  100% {
    opacity: 1;
    transform: scale(1.05);
  }
}

/* ------------------------------------------------------------- rail (nav) */

.rail {
  position: fixed;
  left: 22px;
  top: 50%;
  transform: translateY(-50%);
  z-index: 50;
  display: flex;
  flex-direction: column;
  gap: 2px;
  padding: 12px;
  width: var(--rail-w);
  overflow: hidden;
  border-radius: 22px;
  transition: width 0.5s var(--ease), background 0.4s ease, box-shadow 0.4s ease;
}

/* Opens on pointer or on keyboard focus. Every :hover rule in the two navs is
   gated on a real pointer: a touch device leaves :hover stuck on whatever was
   tapped last until something else is tapped, so an ungated hover state on a
   nav link outlives the tap and sits there looking live next to the link the
   scroll-spy has since moved to - two highlighted sections at once. */
.rail:focus-within {
  width: var(--rail-w-open);
  background: rgba(252, 253, 255, 0.9);
  box-shadow: 0 1px 2px rgba(22, 24, 28, 0.05), 0 22px 60px rgba(22, 24, 28, 0.14),
    inset 0 1px 0 rgba(255, 255, 255, 0.9);
}

@media (hover: hover) {
  .rail:hover {
    width: var(--rail-w-open);
    background: rgba(252, 253, 255, 0.9);
    box-shadow: 0 1px 2px rgba(22, 24, 28, 0.05), 0 22px 60px rgba(22, 24, 28, 0.14),
      inset 0 1px 0 rgba(255, 255, 255, 0.9);
  }
}

.rail-head {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 8px;
  margin-bottom: 8px;
  white-space: nowrap;
}

.rail-label {
  font-size: 14px;
  font-weight: 500;
  color: var(--ink-62);
  opacity: 0;
  transition: opacity 0.3s ease, color 0.3s ease;
}

.rail-head .rail-label {
  font-weight: 600;
  color: var(--ink);
  letter-spacing: -0.015em;
}

.rail:focus-within .rail-label {
  opacity: 1;
}

@media (hover: hover) {
  .rail:hover .rail-label {
    opacity: 1;
  }
}

.rail a {
  display: flex;
  align-items: center;
  gap: 14px;
  padding: 8px;
  border-radius: 14px;
  white-space: nowrap;
  transition: background 0.25s ease, box-shadow 0.25s ease;
}

.rail-icon {
  flex: none;
  width: 24px;
  height: 24px;
  display: grid;
  place-items: center;
  color: var(--ink-45);
  transition: color 0.3s ease;
}

/* Stays ahead of the [aria-current] rule below: same specificity, so the
   active link keeps its own background while the pointer is over it. */
@media (hover: hover) {
  .rail a:hover {
    background: rgba(22, 24, 28, 0.075);
    box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.6);
  }
}

.rail a[aria-current='true'] {
  background: rgba(255, 255, 255, 0.62);
  box-shadow: inset 0 1px 0 rgba(255, 255, 255, 0.9), 0 1px 6px rgba(22, 24, 28, 0.05);
  backdrop-filter: blur(14px) saturate(160%);
  -webkit-backdrop-filter: blur(14px) saturate(160%);
}

.rail a[aria-current='true'] .rail-icon {
  color: var(--accent-strong);
}

.rail a[aria-current='true'] .rail-label {
  color: var(--ink);
  font-weight: 600;
}

/* ---------------------------------------------------------- pill nav (sm) */

.pill-nav {
  position: fixed;
  top: 14px;
  left: 50%;
  transform: translateX(-50%);
  z-index: 50;
  display: none;
  align-items: center;
  gap: 3px;
  padding: 6px;
  border-radius: 999px;
  max-width: calc(100vw - 24px);
  overflow-x: auto;
  scrollbar-width: none;
  transition: background 0.5s ease, box-shadow 0.5s ease;
}

.pill-nav::-webkit-scrollbar {
  display: none;
}

.pill-nav.scrolled {
  background: rgba(252, 253, 255, 0.9);
  box-shadow: 0 1px 2px rgba(22, 24, 28, 0.04), 0 10px 34px rgba(22, 24, 28, 0.09);
}

/* Head of the row, mirroring .rail-head on desktop. It scrolls with the links
   rather than pinning: the bar only overflows below ~360px, and a sticky tile
   there just leaves the first label half-covered. */
.pill-nav .logo {
  margin: 0 7px 0 5px;
}

.pill-nav a {
  padding: 8px 14px;
  border-radius: 999px;
  font-size: 14px;
  font-weight: 500;
  color: var(--ink-62);
  white-space: nowrap;
  transition: background 0.3s ease, color 0.3s ease;
}

/* Same ordering rule as the rail: before [aria-current], not after. */
@media (hover: hover) {
  .pill-nav a:hover {
    background: rgba(22, 24, 28, 0.05);
    color: var(--ink);
  }
}

.pill-nav a[aria-current='true'] {
  background: rgba(22, 24, 28, 0.07);
  color: var(--ink);
}

/* ------------------------------------------------------------------ shell */

.shell {
  position: relative;
  z-index: 1;
  max-width: var(--shell);
  margin: 0 auto;
  padding-right: 24px;
  padding-bottom: 140px;
  /* Keeps the column optically centred on wide screens while giving the rail
     its gutter as the window narrows. */
  padding-left: max(24px, calc(112px - max(0px, (100vw - var(--shell)) / 2)));
}

section {
  scroll-margin-top: 100px;
}

/* ------------------------------------------------------------------- hero */

.hero {
  min-height: 100vh;
  display: flex;
  flex-direction: column;
  justify-content: center;
  gap: 40px;
  padding: 150px 0 90px;
}

.hero-grid {
  display: grid;
  grid-template-columns: minmax(0, 1.25fr) minmax(240px, 0.75fr);
  /* The copy runs to the edge of its column, so the gutter is the only thing
     separating a full-width line from the portrait. */
  gap: clamp(32px, 6vw, 88px);
  align-items: center;
}

.hero-copy {
  display: flex;
  flex-direction: column;
  align-items: flex-start;
  gap: 30px;
}

.badge {
  display: flex;
  align-items: center;
  gap: 10px;
  padding: 7px 16px;
  border-radius: 999px;
  align-self: flex-start;
  font-size: 13px;
  color: var(--ink-62);
  animation: rise 0.9s var(--ease) both;
}


.hero h1 {
  font-size: clamp(46px, 7.4vw, 96px);
  line-height: 0.97;
  letter-spacing: -0.038em;
  animation: rise 1s var(--ease) 0.06s both;
}

.lede {
  margin: 0;
  font-size: clamp(18px, 2.1vw, 25px);
  line-height: 1.48;
  letter-spacing: -0.012em;
  color: var(--ink-58);
  max-width: 44ch;
  text-wrap: pretty;
  animation: rise 1s var(--ease) 0.12s both;
}

.cta-row {
  display: flex;
  flex-wrap: wrap;
  gap: 12px;
  animation: rise 1s var(--ease) 0.18s both;
}

.btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 14px 24px;
  border-radius: 999px;
  font-size: 15px;
  font-weight: 500;
}

.btn-primary {
  background: var(--tint);
  color: var(--bg);
  border: 1px solid var(--tint-border);
  backdrop-filter: blur(20px) saturate(180%);
  box-shadow: var(--tint-shadow);
  transition: background 0.45s var(--ease-soft), border-color 0.45s var(--ease-soft);
}

.btn-primary:hover {
  background: var(--tint-hover);
  border-color: var(--tint-border-hover);
  color: var(--bg);
}

.btn-ghost {
  color: var(--ink-88);
}

.btn-ghost:hover {
  color: var(--ink);
}

/* The card tilts a few degrees toward the cursor. main.js writes --rx/--ry (and
   --gx/--gy for the highlight); with no JS, no pointer, or reduced motion the
   variables stay at their defaults and this is a plain photo card. */
.portrait {
  aspect-ratio: 4 / 5;
  perspective: 900px;
  animation: rise 1s var(--ease) 0.22s both;
}

.portrait-card {
  position: relative;
  height: 100%;
  border-radius: 28px;
  overflow: hidden;
  transform: rotateX(var(--rx, 0deg)) rotateY(var(--ry, 0deg)) translateZ(0);
  /* Long enough to settle gracefully on the way out. While the pointer is
     actually moving, main.js swaps in a shorter duration - but not so short
     that the card snaps to the cursor; the slight lag is what smooths it. */
  transition: transform 0.9s var(--ease-soft), box-shadow 0.9s var(--ease-soft);
}

.portrait.is-tilting .portrait-card {
  transition-duration: 0.28s, 0.9s;
  box-shadow: 0 1px 2px rgba(22, 24, 28, 0.04), 0 26px 60px rgba(22, 24, 28, 0.1),
    inset 0 1px 0 rgba(255, 255, 255, 0.9);
}

/* A soft highlight that sits under the cursor, so the tilt reads as a surface
   catching light rather than the image just skewing. */
.portrait-glare {
  position: absolute;
  inset: 0;
  pointer-events: none;
  background: radial-gradient(
    circle at var(--gx, 50%) var(--gy, 50%),
    rgba(255, 255, 255, 0.14),
    transparent 66%
  );
  opacity: 0;
  transition: opacity 0.6s var(--ease-soft);
}

.portrait.is-tilting .portrait-glare {
  opacity: 1;
}

/* --------------------------------------------------------- image slots */

/* Stands in for the design's <image-slot>. The hatched tile and its caption
   are the container; a real <img> layers over them once the file exists, and
   removes itself if the file 404s (see main.js). */
.slot {
  position: relative;
  background-color: var(--wash);
  background-image: repeating-linear-gradient(
    135deg,
    rgba(22, 24, 28, 0.045) 0 1px,
    transparent 1px 9px
  );
  display: flex;
  align-items: flex-end;
  padding: 14px;
  overflow: hidden;
}

.slot-caption {
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.06em;
  text-transform: uppercase;
  color: var(--ink-40);
}

.slot img {
  position: absolute;
  inset: 0;
  width: 100%;
  height: 100%;
  object-fit: cover;
  display: block;
}

/* ------------------------------------------------------------- section head */

.section-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 24px;
  margin-bottom: 36px;
}

h2 {
  font-size: clamp(30px, 4vw, 46px);
  letter-spacing: -0.032em;
}

.eyebrow {
  font-family: var(--mono);
  font-size: 11.5px;
  letter-spacing: 0.05em;
  color: var(--ink-40);
}

.section-head a {
  font-size: 14px;
  color: var(--ink-52);
}

.section-head a:hover {
  color: var(--ink);
}

.more-link {
  display: inline-flex;
  align-items: center;
  gap: 4px;
}

.more-link .arrow {
  /* Optically centred against lowercase text, which the arrow's mathematical
     middle sits slightly below. */
  margin-top: 1px;
  transition: transform 0.4s var(--ease-soft);
}

.more-link:hover .arrow {
  transform: translateX(1.5px);
}

/* ------------------------------------------------------------------- work */

#work,
#experience,
#stack,
#contact {
  padding-top: 100px;
  padding-bottom: 40px;
}

#work {
  padding-top: 90px;
}

#experience {
  padding-top: 120px;
}

#contact {
  padding-bottom: 0;
}

.card-list {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.work-card {
  border-radius: 24px;
  padding: 28px;
  display: grid;
  grid-template-columns: 1fr 1fr;
  gap: 28px;
  align-items: stretch;
}

.work-body {
  display: flex;
  flex-direction: column;
  gap: 14px;
}

.tag-row {
  display: flex;
  flex-wrap: wrap;
  gap: 8px;
}

.kicker {
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.07em;
  color: var(--ink-36);
  text-transform: uppercase;
}

.work-card h3 {
  font-size: clamp(21px, 2.3vw, 27px);
  letter-spacing: -0.026em;
  line-height: 1.16;
}

.work-card p {
  margin: 0;
  font-size: 15px;
  line-height: 1.62;
  color: var(--ink-66);
  text-wrap: pretty;
  flex: 1;
}

.tag {
  padding: 7px 12px;
  border-radius: 10px;
  font-family: var(--mono);
  font-size: 11px;
  background: var(--wash);
  border: 1px solid rgba(22, 24, 28, 0.05);
  color: var(--ink-58);
}

.work-card .slot {
  border-radius: 16px;
  border: 1px solid var(--hairline);
  min-height: 250px;
}

/* ------------------------------------------------------------- experience */

/* The role reads top-to-bottom - who and when on a header row, then the work
   beneath it at full width. The old two-column layout squeezed the bullets
   into 60% of the card while the meta column sat mostly empty. */
.role-card {
  border-radius: 24px;
  padding: 26px 28px;
  display: flex;
  flex-direction: column;
  gap: 18px;
}

.role-head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: 10px 20px;
  flex-wrap: wrap;
  padding-bottom: 16px;
  border-bottom: 1px solid var(--hairline);
}

.role-id {
  display: flex;
  flex-direction: column;
  gap: 4px;
  min-width: 0;
}

.role-company {
  font-size: 19px;
  font-weight: 600;
  letter-spacing: -0.022em;
}

.role-title {
  font-size: 14.5px;
  color: var(--ink-58);
  line-height: 1.4;
}

/* Dates sit in a chip on the right so the dateline scans as one column down
   the stack of cards. */
.role-when {
  display: inline-flex;
  align-items: center;
  gap: 7px;
  flex: none;
  padding: 5px 11px;
  border-radius: 999px;
  background: var(--well);
  border: 1px solid var(--well-border);
  font-family: var(--mono);
  font-size: 11.5px;
  letter-spacing: 0.03em;
  white-space: nowrap;
  color: var(--ink-58);
}

.role-when.is-current {
  color: var(--ink-70);
}

.dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: oklch(0.7 0.16 150);
  flex: none;
}

.role-bullets {
  display: flex;
  flex-direction: column;
  gap: 11px;
  margin: 0;
  padding: 0;
  list-style: none;
  /* Full-width lines run past 100 characters in the shell, which is past the
     point where the eye loses the next line. */
  max-width: 90ch;
}

.role-bullets li {
  position: relative;
  padding-left: 21px;
  font-size: 15px;
  line-height: 1.6;
  color: var(--ink-70);
  text-wrap: pretty;
}

/* Drawn rather than list-style: a real marker can be aligned to the cap height
   of the first line and tinted to match the surrounding ink. */
.role-bullets li::before {
  content: '';
  position: absolute;
  left: 4px;
  top: 0.6em;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: rgba(22, 24, 28, 0.26);
}

/* ------------------------------------------------------- earlier roles */

.more-roles {
  margin-top: 18px;
}

.more-roles summary {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  padding: 10px 18px;
  border-radius: 999px;
  background: var(--raised);
  border: 1px solid var(--raised-border);
  box-shadow: var(--raised-shadow);
  backdrop-filter: blur(14px) saturate(160%);
  font-size: 14.5px;
  color: var(--ink-70);
  cursor: pointer;
  list-style: none;
  transition: background 0.35s var(--ease-soft), border-color 0.35s var(--ease-soft);
}

/* Safari draws its own disclosure triangle unless this is cleared. */
.more-roles summary::-webkit-details-marker {
  display: none;
}

.more-roles summary:hover {
  background: var(--raised-hover);
  border-color: var(--raised-border-hover);
}

.more-roles summary:focus-visible {
  outline: none;
  border-color: var(--focus-edge);
  box-shadow: var(--raised-shadow), 0 0 0 3px var(--focus-halo);
}

.more-chev {
  transition: transform 0.3s var(--ease-soft);
}

.more-roles[open] .more-chev {
  transform: rotate(180deg);
}

.more-roles .more-close,
.more-roles[open] .more-open {
  display: none;
}

.more-roles[open] .more-close {
  display: inline;
}

.more-roles[open] .card-list {
  margin-top: 18px;
}

/* Education closes the timeline in the same shell as the roles, but shallower -
   it's a header row with no bullets under it. */
.edu-card {
  margin-top: 18px;
  border-radius: 24px;
  padding: 22px 28px;
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  flex-wrap: wrap;
  gap: 10px 20px;
}

/* ------------------------------------------------------------------ stack */

.section-note {
  margin: 0 0 34px;
  font-size: 17px;
  line-height: 1.6;
  color: rgba(22, 24, 28, 0.56);
  max-width: 52ch;
  text-wrap: pretty;
}

#stack h2 {
  margin-bottom: 14px;
}

.chip-row {
  display: flex;
  flex-wrap: wrap;
  gap: 10px;
}

.chip {
  padding: 12px 18px;
  border-radius: 14px;
  font-size: 14.5px;
  letter-spacing: -0.006em;
  color: var(--ink-76);
}

/* ---------------------------------------------------------------- contact */

.contact-card {
  border-radius: 32px;
  padding: clamp(32px, 5vw, 64px);
}

.contact-card h2 {
  font-size: clamp(30px, 4.4vw, 52px);
  letter-spacing: -0.036em;
  max-width: 22ch;
  text-wrap: pretty;
  margin-bottom: 14px;
}

.contact-card > p {
  margin: 0 0 34px;
  font-size: 17px;
  line-height: 1.6;
  color: var(--ink-58);
  max-width: 48ch;
  text-wrap: pretty;
}

/* Fixed counts rather than auto-fit: the four cards should read as one block,
   and auto-fit happily leaves a 3 + 1 row wherever the container lands between
   two of its steps. Every state here is 4x1, 2x2 or 1x4. */
.link-grid {
  display: grid;
  grid-template-columns: repeat(4, minmax(0, 1fr));
  gap: 12px;
}

.link-card {
  display: flex;
  align-items: center;
  gap: 9px;
  padding: 14px 13px;
  border-radius: 16px;
  background: var(--raised);
  border: 1px solid var(--raised-border);
  box-shadow: var(--raised-shadow);
  backdrop-filter: blur(14px) saturate(160%);
  -webkit-backdrop-filter: blur(14px) saturate(160%);
  /* No transform on hover - these are a static reference block, not buttons,
     and a row of them jumping under the cursor reads as noise. */
  transition: background 0.35s var(--ease-soft), border-color 0.35s var(--ease-soft);
}

.link-card:hover {
  background: var(--raised-hover);
  border-color: var(--raised-border-hover);
}

.link-card:focus-visible {
  outline: none;
  border-color: var(--focus-edge);
  box-shadow: var(--raised-shadow), 0 0 0 3px var(--focus-halo);
}

/* Cards for handles with no URL are buttons that copy themselves, so they need
   the type defaults a <button> brings with it stripped back to the card's. */
button.link-card {
  position: relative;
  font: inherit;
  color: inherit;
  text-align: left;
  cursor: pointer;
  appearance: none;
}

/* Nothing marks the card as copyable at rest - the confirmation is the whole
   affordance, popping above the card on a successful copy and fading out. */
.copy-tip {
  position: absolute;
  bottom: calc(100% + 8px);
  left: 50%;
  transform: translate(-50%, 4px);
  padding: 5px 11px;
  border-radius: 999px;
  background: var(--tint);
  border: 1px solid var(--tint-border);
  backdrop-filter: blur(20px) saturate(180%);
  -webkit-backdrop-filter: blur(20px) saturate(180%);
  box-shadow: var(--tint-shadow);
  color: var(--bg);
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  white-space: nowrap;
  opacity: 0;
  pointer-events: none;
  transition: opacity 0.22s var(--ease-soft), transform 0.22s var(--ease-soft);
}

.link-card.is-copied .copy-tip {
  opacity: 1;
  transform: translate(-50%, 0);
}

.link-icon {
  flex: none;
  display: grid;
  place-items: center;
  width: 28px;
  height: 28px;
  border-radius: 9px;
  background: rgba(22, 24, 28, 0.05);
  border: 1px solid rgba(22, 24, 28, 0.05);
  color: var(--ink-70);
  transition: background 0.35s var(--ease-soft), color 0.35s var(--ease-soft);
}

.link-card:hover .link-icon {
  background: rgba(22, 24, 28, 0.075);
  color: var(--ink);
}

.link-icon svg {
  display: block;
  width: 17px;
  height: 17px;
  fill: currentColor;
}

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

.link-label {
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-40);
}

.link-value {
  font-size: 13.5px;
  letter-spacing: -0.01em;
  color: var(--ink-88);
  /* Long handles shouldn't force the grid wider than its track. */
  overflow: hidden;
  text-overflow: ellipsis;
  white-space: nowrap;
}

/* ------------------------------------------------------------------- form */

.contact-form {
  margin-top: 34px;
  padding-top: 34px;
  border-top: 1px solid var(--hairline);
  display: grid;
  gap: 14px;
}

.field-row {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
  gap: 14px;
}

.contact-form label {
  display: flex;
  flex-direction: column;
  gap: 7px;
  font-family: var(--mono);
  font-size: 10.5px;
  letter-spacing: 0.07em;
  text-transform: uppercase;
  color: var(--ink-40);
}

.contact-form input,
.contact-form textarea {
  font-family: var(--sans);
  font-size: 15px;
  letter-spacing: -0.01em;
  color: var(--ink);
  padding: 13px 16px;
  border-radius: 14px;
  background: var(--input);
  border: 1px solid var(--well-border);
  box-shadow: var(--well-shadow);
  backdrop-filter: blur(10px) saturate(150%);
  -webkit-backdrop-filter: blur(10px) saturate(150%);
  transition: background 0.25s var(--ease-soft), border-color 0.25s var(--ease-soft),
    box-shadow 0.25s var(--ease-soft);
  text-transform: none;
}

.contact-form input::placeholder,
.contact-form textarea::placeholder {
  color: var(--ink-36);
}

.contact-form input:hover,
.contact-form textarea:hover {
  background: var(--input-hover);
  border-color: rgba(22, 24, 28, 0.18);
}

/* Focused fields brighten further without turning flat white - the surface
   stays translucent so the colour field still shows through, and the edge
   stays neutral. */
.contact-form input:focus,
.contact-form textarea:focus {
  outline: none;
  background: var(--input-focus);
  border-color: var(--focus-edge);
  box-shadow: 0 0 0 3px var(--focus-halo), 0 2px 10px rgba(22, 24, 28, 0.05),
    inset 0 1px 0 rgba(255, 255, 255, 0.8);
}

.contact-form textarea {
  resize: vertical;
  min-height: 116px;
  line-height: 1.55;
}

/* Honeypot - off-screen rather than display:none so naive bots still fill it. */
.hp {
  position: absolute;
  left: -9999px;
  width: 1px;
  height: 1px;
  overflow: hidden;
}

/* Only ever unhidden when a challenge is outstanding, so the margin can't
   leave a gap in the common case. */
#turnstile-slot:not([hidden]) {
  margin-bottom: 4px;
}

.form-foot {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  gap: 14px;
  justify-content: space-between;
}

.contact-form button {
  font-family: var(--sans);
  border: 0;
  cursor: pointer;
}

.contact-form button[disabled] {
  opacity: 0.55;
  cursor: not-allowed;
}

.form-status {
  font-size: 14px;
  line-height: 1.5;
  color: var(--ink-58);
  min-height: 1.5em;
}

.form-status[data-state='error'] {
  color: oklch(0.55 0.19 25);
}

.form-status[data-state='ok'] {
  color: oklch(0.5 0.14 150);
}

/* ----------------------------------------------------------------- footer */

.footer {
  display: flex;
  flex-wrap: wrap;
  align-items: center;
  justify-content: space-between;
  gap: 12px;
  padding: 40px 4px 0;
  font-size: 13px;
  color: rgba(22, 24, 28, 0.35);
}

.footer .mono {
  font-size: 11px;
  letter-spacing: 0.05em;
  color: var(--ink-30);
}

.footer a {
  color: rgba(22, 24, 28, 0.35);
  border-bottom: 1px solid var(--hairline);
}

.footer a:hover {
  color: var(--accent);
}

/* ------------------------------------------------------------------ reveal */

/* Content is hidden only once JS has added `.reveal` to <html>, immediately
   before it starts observing. No JS, a script error, a blocked file, or an
   IntersectionObserver that never fires all leave the page fully readable -
   main.js also runs a failsafe timer. Never make invisibility the default on
   a page whose whole job is to be read. */
.reveal [data-reveal] {
  opacity: 0;
  transform: translateY(26px);
  transition: opacity 0.85s var(--ease), transform 0.85s var(--ease);
}

.reveal [data-reveal].shown {
  opacity: 1;
  transform: none;
}

/* ------------------------------------------------------------ breakpoints */

@media (max-width: 1023px) {
  .rail {
    display: none;
  }

  .pill-nav {
    display: flex;
  }

  .shell {
    padding-left: 24px;
    padding-right: 24px;
  }

  .hero {
    padding-top: 120px;
  }

  /* Four cards across get too tight once the shell stops growing. */
  .link-grid {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }
}

@media (max-width: 860px) {
  .hero {
    /* svh rather than vh: mobile browsers size vh to the *largest* viewport,
       so a 100vh hero sits taller than the screen until the address bar
       retracts - which reads as the page being slightly scrolled on arrival. */
    min-height: 100svh;
    padding: 116px 0 64px;
    gap: 32px;
  }

  .hero-grid {
    grid-template-columns: 1fr;
    gap: 30px;
  }

  .hero-copy {
    gap: 22px;
  }

  /* Stacked, a full-width portrait ate most of the first screen. From vertical
     tablet down it's a round avatar leading the copy instead - and the tilt it
     was designed around never fires here anyway; main.js gates it to fine
     pointers.

     168px rather than the 132px this started at. Indexing is mobile-first, so
     this is the size at which a search engine decides whether the photo is
     content or a decorative flourish it can ignore - and at 132px it read as
     the latter. Still an avatar, still well under the fold. */
  .portrait {
    order: -1;
    width: 168px;
    aspect-ratio: 1;
  }

  .portrait-card {
    border-radius: 50%;
  }

  /* The subject sits high in the frame, so a centred square crop would clip
     the top of the head. */
  .portrait .slot img {
    object-position: 50% 22%;
  }

  /* Sized for a wide card; at avatar scale it just prints over the face. */
  .portrait .slot-caption {
    display: none;
  }

  .work-card {
    grid-template-columns: 1fr;
    gap: 22px;
  }

  .work-card .slot {
    min-height: 190px;
  }
}

@media (max-width: 560px) {
  .shell {
    padding-left: 18px;
    padding-right: 18px;
    padding-bottom: 90px;
  }

  .work-card,
  .role-card,
  .edu-card {
    padding: 22px;
  }

  /* Wrapping put the chip on the header row for short titles and on its own
     line for long ones, so the dateline jumped around down the stack. There is
     no right-hand column to align to at this width - stack it every time. */
  .role-head,
  .edu-card {
    flex-direction: column;
    align-items: flex-start;
    gap: 12px;
  }

  /* Reclaim indent: at this width the marker gutter was eating ~12% of the
     line, pushing bullets to four and five lines each. */
  .role-bullets li {
    padding-left: 17px;
    font-size: 14.5px;
    line-height: 1.55;
  }

  .role-bullets li::before {
    left: 2px;
  }

  .section-head {
    flex-direction: column;
    align-items: flex-start;
    gap: 8px;
  }

  #work,
  #experience,
  #stack,
  #contact {
    padding-top: 72px;
  }

  /* Two tracks here would clip "me@bensonshen.com" to an ellipsis. */
  .link-grid {
    grid-template-columns: minmax(0, 1fr);
  }
}

/* Must stay below the 560px block: same specificity, so on a 390px screen the
   later rule is the one that wins. */
@media (max-width: 440px) {
  /* Fit all five labels rather than leaving "Contact" half off-screen.
     The nav still scrolls if a label is ever added. */
  .pill-nav a {
    padding: 8px 11px;
    font-size: 13.5px;
  }

  /* Short phones (360x740 and down) pushed the hero's buttons just under the
     fold. Tightening the vertical rhythm pulls them back on screen. */
  .hero {
    padding-top: 94px;
    padding-bottom: 52px;
    gap: 26px;
  }

  .hero-copy {
    gap: 20px;
  }

  .portrait {
    width: 144px;
  }

  .contact-card {
    padding: 30px 22px;
  }
}

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

  .field span {
    animation: none;
  }

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

  .reveal [data-reveal] {
    opacity: 1;
    transform: none;
  }
}

@media print {
  .rail,
  .pill-nav,
  .field,
  .boot,
  .contact-form {
    display: none !important;
  }

  .shell {
    padding: 0;
    max-width: none;
  }

  body {
    background: #fff;
  }
}
