/* ============================================================
   Mohammed Abdul Rahman — portfolio

   Near-white and near-black, one red, the system font, and surfaces that
   float rather than rules that divide. The warm-paper, Fraunces-and-mono
   editorial identity this replaced was a printed page rendered in a browser;
   this is software.
   ============================================================ */

:root {
  /* Neutrals with no temperature: the page is the absence of colour, and the
     one hue is spent on the thing you can act on. Checked rather than picked --
     see the contrast table in tools/check_contrast.py. */

  /* surfaces */
  --paper:    #fbfbfd;   /* the page */
  --paper-2:  #f5f5f7;   /* a recessed band, where cards sit */
  --paper-3:  #ffffff;   /* a card, raised off the band */

  /* ink */
  --ink:      #1d1d1f;
  --ink-2:    #424245;
  --ink-3:    #6e6e73;

  /* structure. Hairlines survive, but they separate far less than they did:
     most grouping is a surface now, not a line. */
  --rule:     #e8e8ed;
  --rule-2:   #d2d2d7;

  /* One accent, and it means "you can act on this". Nothing decorative is
     ever this colour. #d70015 rather than a brighter red because the accent
     carries link text: at 5.21:1 on the page it clears the same gate the blue
     it replaced did at 5.39, where Apple's #ff3b30 sits at 3.43 and fails. */
  --signal:   #d70015;
  --signal-w: #f9ecef;   /* the accent at 6% over the page, precomputed */
  --accent:   #007a3d;   /* the second categorical, charts only */
  --sun:      #ff9500;   /* a fill, never text */

  /* True black, not a dark grey. Apple's dark bands are #000 so an OLED
     panel actually switches the pixels off, and so the plate has no edge. */
  --night:     #000000;
  --night-2:   #1d1d1f;
  --night-ink: #f5f5f7;
  --night-mut: #86868b;

  /* Depth. Three levels, each a soft wide shadow at low opacity rather than a
     tight dark one -- a big surface reads as thicker than a small one, which
     is what the blur radius encodes. */
  --lift-1: 0 1px 3px rgba(0, 0, 0, .04), 0 4px 16px rgba(0, 0, 0, .04);
  --lift-2: 0 2px 6px rgba(0, 0, 0, .05), 0 12px 32px rgba(0, 0, 0, .07);
  --lift-3: 0 4px 10px rgba(0, 0, 0, .06), 0 24px 56px rgba(0, 0, 0, .10);

  --nav-h:    52px;
  --gutter:   24px;
  --maxw:     1024px;   /* Apple's readable measure, not a full-bleed column */

  /* Radius by role. Pills for anything pressable, because a fully rounded end
     says "press me" at a glance; a generous radius for surfaces; just enough
     on small marks to stop them being pixel-sharp. */
  --r-pill: 980px;
  --r-xl:   18px;    /* large cards */
  --r-lg:   12px;    /* figures, the console, canvases */
  --r-sm:   8px;     /* swatches, inline code */

  /* The system font, everywhere. It already ships optical sizing, tracking
     tables and legibility tuning, it is the face the reader's own OS is set
     in, and dropping the three webfonts took three render-blocking requests
     off the critical path with it. */
  --sans: -apple-system, BlinkMacSystemFont, "SF Pro Text", "SF Pro Display",
          "Segoe UI", Roboto, "Helvetica Neue", Helvetica, Arial, sans-serif;
  --mono: ui-monospace, SFMono-Regular, "SF Mono", Menlo, Consolas,
          "Liberation Mono", monospace;
  --serif: var(--sans);   /* kept so nothing referencing it breaks */

  /* Motion tokens. Built-in CSS easings are too weak for UI; these are the
     curves from Emil Kowalski's animation guidance, not hand-rolled. */
  --ease:        cubic-bezier(0.23, 1, 0.32, 1);   /* strong ease-out, the default */
  --ease-out:    cubic-bezier(0.23, 1, 0.32, 1);
  --ease-in-out: cubic-bezier(0.77, 0, 0.175, 1);  /* on-screen movement */
  --dur-press:  120ms;   /* button feedback */
  --dur-hover:  180ms;   /* hover, colour */
  --dur-move:   240ms;   /* something travelling on screen */
  --dur-reveal: 480ms;   /* first-time scroll reveal, allowed to be slower */
}

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

html { scroll-behavior: smooth; scroll-padding-top: 32px; }
@media (prefers-reduced-motion: reduce) {
  html { scroll-behavior: auto; }
  *, *::before, *::after { animation-duration: .001ms !important; transition-duration: .001ms !important; }
}

body {
  margin: 0;
  background: var(--paper);
  color: var(--ink);
  font-family: var(--sans);
  /* 17px is the size this whole system is drawn at, and -.022em is the
     tracking that size wants -- body copy is the one place people assume
     tracking should be zero, and at 17px in a grotesk it should be slightly
     tight or the words come apart. */
  font-size: 17px;
  line-height: 1.47;
  letter-spacing: -.022em;
  -webkit-font-smoothing: antialiased;
  text-rendering: optimizeLegibility;
}

/* Tracking is size-specific, which for headings means the bigger it gets the
   tighter it goes. Weight carries the emphasis instead of a second family. */
h1, h2, h3 {
  font-family: var(--sans); font-weight: 700; margin: 0;
  line-height: 1.06;
  letter-spacing: -.028em;
}
h2 { letter-spacing: -.024em; }
h3 { font-weight: 600; line-height: 1.16; letter-spacing: -.018em; }
p { margin: 0; }
a { color: inherit; }

::selection { background: var(--signal); color: var(--paper); }

:focus-visible {
  outline: 2px solid var(--signal);
  outline-offset: 3px;
  /* No radius of its own: browsers draw the outline around the element's own
     border-radius, so a pill button gets a pill ring for free. Forcing 1px here
     drew a sharp rectangle around a rounded button. */
}

.skip {
  position: absolute; left: -9999px; top: 0; z-index: 99;
  background: var(--ink); color: var(--paper);
  padding: 12px 20px; font: 500 15px/1 var(--sans); border-radius: var(--r-sm);
}
.skip:focus { left: 12px; top: 12px; }

/* ── shell ─────────────────────────────────────────────────── */
/* One column. The 232px left rail is gone: a permanent sidebar spends a fifth
   of the window on navigation nobody uses twice, and it forces the content
   into a narrow off-centre strip. The nav is a bar over the top now, and the
   content gets the middle of the page. */
.shell { display: block; }

.grid {
  display: grid;
  grid-template-columns: repeat(12, minmax(0, 1fr));
  gap: 0 var(--gutter);
  max-width: var(--maxw);
  margin-inline: auto;
  padding-inline: clamp(22px, 5vw, 40px);
}

/* ── top bar ───────────────────────────────────────────────── */
/* Translucent chrome with the page scrolling underneath it, rather than an
   opaque strip that permanently costs 52px of window. The saturate() is what
   keeps colour alive through the blur instead of going milky. */
.rail {
  position: sticky; top: 0; z-index: 40;
  height: var(--nav-h);
  display: flex; align-items: center; gap: 28px;
  padding: 0 clamp(22px, 5vw, 40px);
  background: rgba(251, 251, 253, .72);
  backdrop-filter: saturate(180%) blur(20px);
  -webkit-backdrop-filter: saturate(180%) blur(20px);
}
/* A scroll edge, not a divider: a fade where the content passes beneath says
   "this is in front" without drawing a line the layout does not have. */
.rail::after {
  content: ""; position: absolute; left: 0; right: 0; top: 100%; height: 12px;
  pointer-events: none;
  background: linear-gradient(to bottom, rgba(0, 0, 0, .05), transparent);
}

.rail__top { flex: none; }
.mark { text-decoration: none; display: inline-flex; align-items: center; gap: 10px; flex: none; }
.mark__initials {
  font: 600 15px/1 var(--sans); letter-spacing: -.01em; color: var(--ink);
}
.mark__rule { display: none; }

.rail__nav { flex: 1; min-width: 0; overflow-x: auto; scrollbar-width: none; }
.rail__nav::-webkit-scrollbar { display: none; }
.rail__nav ol { list-style: none; margin: 0; padding: 0; display: flex; gap: 4px; }

.rail__nav a {
  display: block; white-space: nowrap;
  padding: 7px 11px; border-radius: var(--r-pill);
  text-decoration: none;
  font: 400 13px/1 var(--sans); letter-spacing: -.01em;
  color: var(--ink-2);
  transition: color var(--dur-hover) var(--ease), background var(--dur-hover) var(--ease);
}
/* The numbers were a print affectation. A nav item should say where it goes. */
.rail__nav .num { display: none; }
.rail__nav .lbl { font: inherit; letter-spacing: inherit; }
.rail__nav a[aria-current="true"] { color: var(--ink); background: rgba(0, 0, 0, .06); }

.rail__foot { display: flex; gap: 2px; flex: none; }
.icolink {
  display: inline-flex; align-items: center; gap: 7px;
  font: 400 13px/1 var(--sans); letter-spacing: -.01em;
  color: var(--ink-2); text-decoration: none;
  padding: 7px 11px; border-radius: var(--r-pill);
  transition: color var(--dur-hover) var(--ease), background var(--dur-hover) var(--ease);
}
.icolink span { display: none; }   /* the mark is the label at this size */

/* ── icons ─────────────────────────────────────────────────── */
.ico { width: 18px; height: 18px; flex: none; display: block; }
.ico--sm { width: 14px; height: 14px; }
.ico--link { width: 15px; height: 15px; }

/* ── hero ──────────────────────────────────────────────────── */
/* Centred, and everything in one column. The old hero put the name in an
   eight-column block with a four-column table of facts alongside it, which is
   a magazine spread -- two things competing at the top of the page. One
   centred column has an order: name, sentence, the things you can press. */
.hero { padding: clamp(56px, 11vh, 116px) 0 clamp(48px, 8vh, 88px); text-align: center; }

.hero__eyebrow {
  grid-column: 1 / -1;
  font: 600 15px/1.3 var(--sans); letter-spacing: -.01em; text-transform: none;
  color: var(--signal);
  margin-bottom: 12px;
}

/* The size ceiling is high on purpose. This is the one piece of display type
   on the page and it should be unambiguously the first thing read; -.03em is
   the tracking that size wants, since letters read too far apart as they grow. */
.hero__name {
  grid-column: 1 / -1;
  font-size: clamp(2.75rem, 7.4vw, 5.5rem);
  font-weight: 700;
  line-height: 1.05;
  letter-spacing: -.03em;
}

.hero__lede {
  grid-column: 1 / -1;
  margin: 22px auto 0;
  font-size: clamp(1.0625rem, 1.6vw, 1.3125rem);
  line-height: 1.42;
  letter-spacing: -.018em;
  color: var(--ink-2);
  max-width: 34ch;
}

/* The facts read as a quiet caption row under the actions rather than as a
   second table beside the name. */
.hero__meta {
  grid-column: 1 / -1;
  margin: 44px 0 0;
  /* A grid, not wrapping flex: four items in a flex row broke 3 + 1 and the
     lone one centred under the gap, which reads as a mistake. A grid keeps the
     columns even at every step -- 4, then 2, then 1, all of which divide 4. */
  display: grid; grid-template-columns: repeat(4, minmax(0, 1fr)); gap: 22px 24px;
}
.hero__meta > div { padding: 0; border: 0; }
.hero__meta dt {
  font: 400 12px/1.4 var(--sans); letter-spacing: -.005em; text-transform: none;
  color: var(--ink-3);
}
.hero__meta dd {
  margin: 2px 0 0; font-size: 14px; font-weight: 500; color: var(--ink);
  display: flex; align-items: center; justify-content: center; gap: 6px;
}
.hero__meta .ico { color: var(--ink-3); }

.hero__actions {
  grid-column: 1 / -1;
  display: flex; flex-wrap: wrap; justify-content: center; gap: 12px;
  margin-top: 42px;
}

.btn {
  display: inline-flex; align-items: center; gap: 10px;
  padding: 12px 22px;
  border: 1px solid var(--signal);
  background: transparent;
  /* The blue is the accent and the accent means "actionable", so a button is
     blue whether or not it is the filled one. Grey-bordered buttons made the
     primary action fight three lookalikes. */
  color: var(--signal);
  font: 400 17px/1.24 var(--sans); letter-spacing: -.022em;
  text-decoration: none;
  border-radius: var(--r-pill);
  transition: background var(--dur-hover) var(--ease),
              border-color var(--dur-hover) var(--ease),
              color var(--dur-hover) var(--ease),
              opacity var(--dur-hover) var(--ease);
}
.btn .ico { color: currentColor; }
.btn:hover { background: var(--signal-w); }
.btn--solid { background: var(--signal); border-color: var(--signal); color: #fff; }
.btn--solid:hover { background: #b3000f; border-color: #b3000f; color: #fff; opacity: 1; }

/* ── sections ──────────────────────────────────────────────── */
/* No rule between sections. A band of a different surface, or simply the
   space, is what separates them -- a full-width hairline every 700px is the
   editorial habit this design dropped. The Work section gets the recessed
   band, because that is where the cards sit and a card needs something to be
   raised off. */
.sec { padding: clamp(72px, 10vh, 128px) 0; }
.sec--band { background: var(--paper-2); }
#work { background: var(--paper-2); }
.sec--end { padding-bottom: 0; }

.sec__label { grid-column: 1 / 4; position: sticky; top: 44px; align-self: start; }
.sec__label .tag {
  display: block;
  font: 600 13px/1 var(--sans); letter-spacing: -.01em; color: var(--signal);
  margin-bottom: 14px;
}
.sec__label h2 { font-size: 1.75rem; line-height: 1.15; }
.sec__note {
  margin-top: 12px; font-size: .875rem; color: var(--ink-3); max-width: 22ch;
}

.sec__body { grid-column: 4 / 13; }

.lead { font-size: 1.0625rem; color: var(--ink-2); max-width: 62ch; margin-bottom: 44px; }

/* ── projects ──────────────────────────────────────────────── */
/* Cards on a band, not rows separated by rules. A rule says "these are two
   entries in a list"; a raised surface says "this is a thing", which is what
   each of these is. The lift is small at rest and grows on hover, so the card
   answers a pointer by coming toward it. */
.projects { list-style: none; margin: 0; padding: 0; display: grid; gap: 16px; }

.proj {
  display: grid;
  grid-template-columns: 32px minmax(0, 1fr) 172px;
  gap: 0 24px;
  padding: 28px 28px 30px;
  background: var(--paper-3);
  border-radius: var(--r-xl);
  box-shadow: var(--lift-1);
  position: relative;
  transition: box-shadow var(--dur-move) var(--ease),
              transform var(--dur-move) var(--ease);
}
@media (hover: hover) and (pointer: fine) {
  .proj:hover { box-shadow: var(--lift-2); transform: translateY(-2px); }
}

.proj__idx {
  font: 400 15px/1 var(--sans); letter-spacing: -.01em; color: var(--ink-3);
  padding-top: 7px;
}

.proj__title { font-size: 1.5rem; line-height: 1.2; }
.proj__title a {
  text-decoration: none;
  display: inline-flex; align-items: baseline; gap: 8px;
  background-image: linear-gradient(var(--signal), var(--signal));
  background-size: 0% 1px; background-repeat: no-repeat; background-position: 0 100%;
  transition: background-size .28s var(--ease), color .18s var(--ease);
}
.proj__title a:hover { color: var(--signal); background-size: 100% 1px; }
.proj__title .ico--link { transition: transform .22s var(--ease); align-self: center; }
.proj__title a:hover .ico--link { transform: translate(2px, -2px); }

.proj__desc { margin-top: 12px; color: var(--ink-2); font-size: 16px; line-height: 1.45; max-width: 62ch; }

.chips { list-style: none; display: flex; flex-wrap: wrap; gap: 6px; margin: 16px 0 0; padding: 0; }
.chips li {
  font: 400 13px/1 var(--sans); letter-spacing: -.01em;
  color: var(--ink-3);
  border: 1px solid var(--rule-2);
  padding: 6px 11px;
  border-radius: var(--r-pill);
}

.demo {
  display: inline-flex; align-items: center; gap: 7px;
  margin-top: 16px;
  font: 400 15px/1 var(--sans); letter-spacing: -.015em;
  color: var(--signal); text-decoration: none;
  border-bottom: 1px solid transparent;
  padding-bottom: 2px;
  transition: border-color .18s var(--ease);
}
.demo:hover { border-color: var(--signal); }

.proj__stats { margin: 4px 0 0; }
.proj__stats > div { padding: 7px 0; border-top: 1px solid var(--rule); }
.proj__stats > div:first-child { border-top: 0; padding-top: 0; }
.proj__stats dt {
  font: 400 12px/1.4 var(--sans); letter-spacing: -.005em; text-transform: none;
  color: var(--ink-3);
}
.proj__stats dd {
  margin: 2px 0 0;
  font-size: 1.5rem; font-weight: 600; line-height: 1.15;
  letter-spacing: -.022em;
  font-variant-numeric: tabular-nums;
}
.proj__stats .unit { font-family: var(--mono); font-size: .8125rem; margin-left: 2px; color: var(--ink-3); }

/* ── benchmark ─────────────────────────────────────────────── */
.bench { margin: 0 0 48px; }
.bench__cap { margin-bottom: 16px; }
.bench__cap h3 { font-size: 1.1875rem; }
.bench__cap p { font: 400 14px/1.45 var(--sans); letter-spacing: -.016em; color: var(--ink-3); margin-top: 5px; }

.bench__table { width: 100%; border-collapse: collapse; font-variant-numeric: tabular-nums; }
.bench__table th, .bench__table td { text-align: left; padding: 11px 14px 11px 0; }
.bench__table thead th {
  font: 500 12px/1.4 var(--sans); letter-spacing: -.005em; text-transform: none;
  color: var(--ink-3);
  border-bottom: 1px solid var(--rule-2);
  padding-bottom: 8px;
}
.bench__table tbody th {
  font: 500 14px/1.4 var(--sans); letter-spacing: -.016em; color: var(--ink);
}
.bench__table tbody th .sub {
  display: block; font-weight: 400; font-size: 10.5px; color: var(--ink-3); letter-spacing: .04em;
}
.bench__table tbody td { font: 400 13px/1.4 var(--mono); color: var(--ink-2); }
.bench__table tbody tr { border-bottom: 1px solid var(--rule); }
.bench__table tbody tr:hover { background: var(--paper-3); }
.bench__table tr.is-mine { background: var(--signal-w); }
.bench__table tr.is-mine:hover { background: var(--signal-w); }
.bench__table tr.is-mine th, .bench__table tr.is-mine td { color: var(--ink); }
.bench__table tbody td a { font-size: 12px; }
.bench__table b { color: var(--signal); font-weight: 600; }
.ta-r { text-align: right !important; }

.viz-h { width: 200px; }
.viz { padding-right: 0 !important; }
.bar { display: block; height: 7px; border-radius: 1px; width: var(--w); min-width: 3px; }
.bar + .bar { margin-top: 3px; }
.bar--py  { background: var(--accent); }
.bar--cpp { background: var(--signal); }

.legend { list-style: none; display: flex; gap: 16px; margin: 10px 0 0; padding: 0; }
.legend li {
  display: inline-flex; align-items: center; gap: 7px;
  font: 400 13px/1 var(--sans); letter-spacing: -.01em; color: var(--ink-2);
}
.sw { width: 11px; height: 11px; border-radius: 3px; display: block; }
.sw--py  { background: var(--accent); }
.sw--cpp { background: var(--signal); }

.bench__note { margin-top: 14px; font-size: .875rem; color: var(--ink-2); max-width: 66ch; }
.bench__meta {
  display: flex; align-items: flex-start; gap: 9px; flex-wrap: wrap;
  margin-top: 8px; padding-top: 20px; border-top: 1px solid var(--rule);
  font: 400 12px/1.7 var(--mono); color: var(--ink-3);
}
.bench__meta .ico { color: var(--signal); margin-top: 3px; }

code {
  font: 400 .875em/1 var(--mono);
  background: var(--signal-w); color: var(--ink);
  padding: 2px 6px; border-radius: var(--r-sm);
}

a.inline {
  color: var(--signal); text-decoration: none;
  border-bottom: 1px solid var(--signal-w);
  transition: border-color .18s var(--ease);
}
a.inline:hover { border-color: var(--signal); }

/* ── stack ─────────────────────────────────────────────────── */
.stack { margin: 0; }
.stack > div {
  display: grid; grid-template-columns: 148px minmax(0, 1fr); gap: 24px;
  padding: 15px 0; border-top: 1px solid var(--rule);
}
.stack > div:last-child { border-bottom: 1px solid var(--rule); }
.stack dt {
  font: 500 13px/1.5 var(--sans); letter-spacing: -.01em; text-transform: none;
  color: var(--signal);
}
.stack dd { margin: 0; font-size: .9375rem; color: var(--ink-2); }

/* ── path ──────────────────────────────────────────────────── */
.path { list-style: none; margin: 0; padding: 0; }
.path > li {
  display: grid; grid-template-columns: 104px minmax(0, 1fr); gap: 24px;
  padding: 26px 0; border-top: 1px solid var(--rule);
}
.path > li:last-child { border-bottom: 1px solid var(--rule); }
.path__when {
  font: 400 15px/1.5 var(--sans); letter-spacing: -.015em; color: var(--ink-3);
  font-variant-numeric: tabular-nums;
}
.path__what h3 { font-size: 1.125rem; line-height: 1.35; }
.path__what .at { color: var(--signal); }
.path__what p { margin-top: 8px; font-size: .9375rem; color: var(--ink-2); max-width: 64ch; }
.path__roles {
  font: 400 15px/1.5 var(--sans) !important; letter-spacing: -.015em;
  color: var(--ink-3) !important; margin-top: 6px !important;
}

/* ── contact ───────────────────────────────────────────────── */
.closing { font-size: 1.125rem; color: var(--ink); max-width: 52ch; margin-bottom: 36px; }

.contact { list-style: none; margin: 0; padding: 0; }
.contact li { border-top: 1px solid var(--rule); }
.contact li:last-child { border-bottom: 1px solid var(--rule); }
.contact a {
  display: grid; grid-template-columns: 20px minmax(0, 1fr) 20px; align-items: center; gap: 14px;
  padding: 17px 4px;
  text-decoration: none;
  font: 400 17px/1 var(--sans); letter-spacing: -.022em;
  color: var(--ink);
  transition: padding .22s var(--ease), color .18s var(--ease), background .18s var(--ease);
}
.contact a:hover { color: var(--signal); background: var(--paper-3); padding-left: 14px; }
.contact .ico--link {
  opacity: 0; transform: translate(-4px, 4px);
  transition: opacity var(--dur-hover) var(--ease), transform var(--dur-hover) var(--ease);
}
.contact a:hover .ico--link { opacity: 1; transform: none; }

/* ── footer ────────────────────────────────────────────────── */
.foot { margin-top: clamp(64px, 9vh, 100px); border-top: 1px solid var(--rule); }
.foot .grid { padding-block: 26px; }
.foot__l { grid-column: 1 / 7; }
.foot__r { grid-column: 7 / 13; text-align: right; color: var(--ink-3); }
.foot p { font: 400 13px/1 var(--sans); letter-spacing: -.01em; color: var(--ink-3); }

/* The visit count sits on its own row under the name, and is hidden until a
   real number arrives -- see visits.js. A counter that is down leaves the
   footer exactly as it was rather than parking a 0 under the byline.
   Tabular figures so the count-up animates in place instead of nudging the
   line left and right as digit widths change. */
.foot__count {
  grid-column: 1 / 13;
  margin-top: 14px;
  text-align: right;
  font-variant-numeric: tabular-nums;
}
.foot__count[hidden] { display: none; }
.foot__count [data-count] { font-family: var(--mono); color: var(--ink-2); }

/* ── say something ─────────────────────────────────────────────
   The comment form, and the prompt that offers it. Both are injected by
   feedback.js, so neither exists for a reader without JavaScript -- better
   than a form that looks real and quietly does nothing. */
.say {
  max-width: var(--maxw);
  margin: clamp(48px, 7vh, 76px) auto 0;
  padding: clamp(22px, 3vw, 30px);
  margin-inline: auto;
  width: calc(100% - clamp(44px, 10vw, 80px));
  background: var(--paper-3);
  border: 1px solid var(--rule);
  border-radius: var(--r-xl);
  box-shadow: var(--lift-1);
}
.say__h { font: 600 21px/1.2 var(--sans); letter-spacing: -.022em; color: var(--ink); }
.say__p { margin-top: 8px; font: 400 14.5px/1.5 var(--sans); color: var(--ink-3); max-width: 56ch; }
.say__l {
  display: block; margin: 18px 0 6px;
  font: 500 12.5px/1 var(--sans); letter-spacing: -.01em; color: var(--ink-2);
}
.say__l span { font-weight: 400; color: var(--ink-3); }
.say__t, .say__i {
  width: 100%; font: 400 15px/1.5 var(--sans); color: var(--ink);
  background: var(--paper-2); border: 1px solid var(--rule-2);
  border-radius: var(--r-lg); padding: 11px 13px;
  -webkit-appearance: none; appearance: none;
}
.say__t { resize: vertical; min-height: 96px; }
.say__t:focus-visible, .say__i:focus-visible {
  outline: none; border-color: var(--signal);
  box-shadow: 0 0 0 3px var(--signal-w);
}
.say__row { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 0 16px; }
/* Off-screen rather than display:none -- some bots skip anything hidden
   outright, and this only works if it looks fillable to them. */
.say__hp { position: absolute; left: -9999px; width: 1px; height: 1px; overflow: hidden; }
.say__foot { display: flex; align-items: center; gap: 14px; margin-top: 20px; }
.say__b {
  font: 500 15px/1 var(--sans); color: #fff; background: var(--ink);
  border: 0; border-radius: var(--r-pill); padding: 11px 22px; cursor: pointer;
}
.say__b:hover { opacity: .88; }
.say__b:disabled { opacity: .5; cursor: default; }
.say__msg { font: 400 13.5px/1 var(--sans); color: var(--ink-3); }

/* A bar along the bottom, not a sheet over the middle: the page stays
   readable and scrollable underneath it, and nothing steals focus. */
.nudge {
  position: fixed; left: 50%; bottom: max(16px, env(safe-area-inset-bottom));
  z-index: 40; width: min(560px, calc(100vw - 32px));
  display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between;
  gap: 12px 18px; padding: 14px 16px;
  background: rgba(255, 255, 255, .82);
  -webkit-backdrop-filter: saturate(180%) blur(20px);
  backdrop-filter: saturate(180%) blur(20px);
  border: 1px solid var(--rule-2); border-radius: var(--r-xl);
  box-shadow: var(--lift-3);
  transform: translate(-50%, calc(100% + 24px));
  transition: transform .3s cubic-bezier(.22, 1, .36, 1), opacity .3s;
  opacity: 0;
}
.nudge.is-up { transform: translate(-50%, 0); opacity: 1; }
.nudge__p { font: 400 14.5px/1.4 var(--sans); letter-spacing: -.01em; color: var(--ink); flex: 1 1 220px; }
.nudge__a { display: flex; gap: 8px; flex: none; }
.nudge__b {
  font: 500 13.5px/1 var(--sans); cursor: pointer; white-space: nowrap;
  border-radius: var(--r-pill); padding: 9px 14px;
  background: transparent; border: 1px solid var(--rule-2); color: var(--ink-2);
}
.nudge__b:hover { background: var(--paper-2); }
.nudge__b--go { background: var(--ink); border-color: var(--ink); color: #fff; }
.nudge__b--go:hover { background: var(--ink); opacity: .88; }
@media (prefers-reduced-motion: reduce) {
  .nudge { transition: none; }
}
@media (max-width: 560px) {
  .say__row { grid-template-columns: minmax(0, 1fr); }
  .nudge { flex-direction: column; align-items: stretch; }
  .nudge__a { justify-content: flex-end; }
}

/* ── responsive ────────────────────────────────────────────── */
@media (max-width: 1080px) {
  /* The hero is one centred column at every width now, so the old per-element
     column spans have nothing left to do. Only the facts grid steps down. */
  .hero__meta { grid-template-columns: repeat(2, minmax(0, 1fr)); }
  .sec__label { grid-column: 1 / 13; position: static; margin-bottom: 32px; }
  .sec__note { max-width: 44ch; }
  .sec__body { grid-column: 1 / 13; }
  .proj { grid-template-columns: 34px minmax(0, 1fr) 150px; }
}

@media (max-width: 860px) {
  :root { --gutter: 18px; }
  .hero__meta { grid-template-columns: 1fr; }
  /* The bar is already the bar at every width, so this breakpoint only has to
     tighten it -- the whole rail-to-topbar block that used to live here is the
     base rule now. */
  .rail { gap: 14px; padding-inline: clamp(18px, 5vw, 40px); }
  .rail__nav a { padding: 7px 9px; font-size: 12.5px; }

  .proj { grid-template-columns: 26px minmax(0, 1fr); }
  .proj__stats { grid-column: 2; display: flex; flex-wrap: wrap; gap: 0 32px; margin-top: 18px; }
  .proj__stats > div { border-top: 0; padding: 0; }
  .proj__stats dd { font-size: 1.1875rem; }

  .stack > div, .path > li { grid-template-columns: 1fr; gap: 6px; }
  .viz-h, .viz { display: none; }
  .foot__l, .foot__r, .foot__count { grid-column: 1 / 13; text-align: left; }
  .foot__r { margin-top: 6px; }
  /* Once the byline stacks, a right-aligned count is the only thing on the
     footer's other edge, which reads as a stray rather than a column. */
  .foot__count { margin-top: 10px; }
}

@media (max-width: 560px) {
  .bench__table thead th:nth-child(3), .bench__table tbody td:nth-child(3) { display: none; }
  .hero__actions .btn span { font-size: 12px; }
}

/* ── interactive callout ───────────────────────────────────── */
.tryit {
  display: grid; grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center; gap: 18px;
  padding: 18px 20px; margin-bottom: 8px;
  text-decoration: none; color: var(--ink);
  border: 1px solid var(--signal); border-radius: var(--r-lg);
  background: var(--signal-w);
  transition: background .18s var(--ease), padding .22s var(--ease);
}
.tryit:hover { background: var(--paper-3); padding-left: 28px; }
.tryit__tag {
  font: 500 12px/1 var(--sans); letter-spacing: -.005em; text-transform: none;
  color: var(--signal); border: 1px solid var(--signal); border-radius: var(--r-pill);
  padding: 6px 8px; white-space: nowrap;
}
.tryit__body { font-size: .9375rem; line-height: 1.55; color: var(--ink-2); }
.tryit__body b { color: var(--ink); }
.tryit .ico--link { color: var(--signal); transition: transform .22s var(--ease); }
.tryit:hover .ico--link { transform: translate(3px, -3px); }
@media (max-width: 620px) {
  .tryit { grid-template-columns: 1fr; gap: 10px; }
}

/* ── motion polish ────────────────────────────────────────────────────
   Built against Emil Kowalski's animation guidance. Three rules drove
   everything here: transform and opacity only, nothing over 300ms unless
   it is a first-time reveal, and every hover gated behind a real pointer
   so a tap on a phone does not fire a stuck hover state.
   ------------------------------------------------------------------- */

/* Press feedback. Buttons that do not answer a press feel dead, and this
   was missing everywhere -- the audit found zero :active states. */
.btn:active,
.icolink:active { transform: scale(0.985); }
.btn, .icolink { transition: transform var(--dur-press) var(--ease), color var(--dur-hover) var(--ease), background var(--dur-hover) var(--ease), border-color var(--dur-hover) var(--ease); }

/* Hover gating. Touch fires a false hover on tap and it sticks until the
   next tap elsewhere. */
@media (hover: hover) and (pointer: fine) {
  .tryit:hover .ico--link { transform: translate(3px, -3px); }
  .proj__title a:hover .ico--link { transform: translate(3px, -3px); }
  .contact a:hover .ico--link { opacity: 1; transform: translate(0, 0); }
}

/* First-time reveal. Sections lift in once as they arrive; 480ms is past
   the 300ms UI ceiling on purpose -- this is a marketing reveal seen once
   per visit, not an interaction. Staggered 60ms so they do not all land
   together, which is what makes a page feel assembled rather than dumped. */
[data-reveal] {
  opacity: 0;
  transform: translateY(14px);
  transition: opacity var(--dur-reveal) var(--ease), transform var(--dur-reveal) var(--ease);
  transition-delay: var(--reveal-delay, 0ms);
  will-change: opacity, transform;
}
[data-reveal].is-in { opacity: 1; transform: none; }
/* No JS, no reveal -- never leave content invisible behind a script. */
.no-js [data-reveal] { opacity: 1; transform: none; }

/* Reduced motion means fewer and gentler, not zero: the reveal still
   fades so the page does not snap, but nothing travels. */
@media (prefers-reduced-motion: reduce) {
  [data-reveal] { transform: none; transition: opacity 200ms var(--ease); }
  [data-reveal].is-in { transform: none; }
  .btn:active, .icolink:active { transform: none; }
  .runner__back:hover { gap: 8px; }
}

/* ── the other two accessibility preferences ──────────────────────────
   Reduced motion was honoured and these two were not, which is half an
   answer: the same reader who turns motion down is the one who turns
   transparency or contrast up. Both have real targets on this page. */

/* The mobile rail is the one translucent surface here -- a blurred bar
   with content scrolling under it. Asked for less transparency, it
   becomes an opaque bar with a hairline, which is the same structure
   without the legibility cost of text over moving content. */
@media (prefers-reduced-transparency: reduce) {
  .rail {
    background: var(--paper);
    backdrop-filter: none;
    border-bottom: 1px solid var(--rule-2);
  }
  /* The scroll edge is a translucency effect. Once the bar is opaque there is
     nothing showing through to soften, and a hairline is the honest edge. */
  .rail::after { display: none; }
}

/* More contrast means defined edges, not louder colour. The hairlines
   this page is built from are deliberately quiet; at this setting they
   need to be visible rather than felt. */
@media (prefers-contrast: more) {
  :root {
    --rule:   #b0b0b8;
    --rule-2: #8e8e93;
    --ink-3:  #4b4b50;   /* 4.9:1 -> 8.0:1 on the page */
    --signal: #9c000c;   /* 5.2:1 -> 8.4:1, so links clear AAA */
  }
  .btn, .tryit, .chips li, .proj__stats > div { border-color: var(--rule-2); }
  /* A card is separated by a shadow, which more-contrast readers may not see
     at all. Give it the edge the shadow was standing in for. */
  .proj, .bench, .tryit { border: 1px solid var(--rule-2); }
  .seg__btn.is-on, .tbtn:focus-visible { outline: 1px solid currentColor; }
}

/* The cards float on a shadow. Asked for less transparency, a reader is also
   asking for less of that kind of soft depth cue, so the surfaces get a real
   edge instead. */
@media (prefers-reduced-transparency: reduce) {
  .proj, .bench { box-shadow: none; border: 1px solid var(--rule-2); }
}

/* ── print ───────────────────────────────────────────────────────────
   A portfolio gets saved to PDF by people who want to read it later or
   pass it on, and this printed as a rail full of nav, an interactive
   callout to a page that cannot be printed, and every section still
   invisible behind an IntersectionObserver that never fires because
   print has no viewport to scroll. */
@media print {
  /* The reveal is the important one: without this the printed page is
     mostly blank, because [data-reveal] starts at opacity 0 and nothing
     ever adds .is-in. */
  [data-reveal] { opacity: 1 !important; transform: none !important; }

  :root { --paper: #fff; --paper-2: #fff; --paper-3: #fff; }
  * { background: transparent !important; box-shadow: none !important; }
  body { color: #000; }

  /* Navigation, the skip link and the interactive callout are all things
     you cannot act on from paper. */
  .rail, .skip, .tryit, .viz, .viz-h { display: none !important; }
  .shell { display: block; }

  /* A link is useless in print unless it says where it goes. Internal
     anchors and mailto stay silent -- printing "#work" helps nobody. */
  .proj__title a[href^="http"]::after,
  .contact a[href^="http"]::after {
    content: " (" attr(href) ")";
    font: 400 9pt/1 var(--mono);
    color: #444;
    word-break: break-all;
  }

  .sec, .proj, figure, .bench { break-inside: avoid; }
  h1, h2, h3 { break-after: avoid; }
  .hero { padding-top: 0; }
  a { text-decoration: underline; }
}


/* ============================================================
   Scroll-driven motion.

   All of this runs on `animation-timeline`, which the compositor drives off
   the scroll position with no JavaScript on the scroll path at all. That is
   the whole reason it is allowed to be this expressive: the page can be
   grinding through a Pyodide episode and none of it drops a frame, because
   none of it is on the main thread.

   Everything below is inside three gates, and the order matters:

     @supports  -- browsers without scroll timelines keep the JS reveal in
                   main.js, which still works and still cannot strand content
     reduced-motion -- nothing travels, nothing scales; the fade stays
     print      -- style.css already forces every reveal target visible with
                   !important, which beats an animation

   A positional timeline also removes a whole class of bug. A JS reveal has to
   be told when an element arrived; a view() timeline *is* the arrival, so a
   deep link or a jump to the bottom lands mid-timeline at exactly the right
   progress rather than at opacity 0.
   ============================================================ */

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {

    /* The reveal, with some presence. 44 px and a 4% scale rather than the
       14 px lift the transition did -- far enough to read as arriving, short
       enough not to look like a slide deck. `entry 5% / 60%` means a block is
       fully itself well before it reaches the middle of the screen, so you are
       never reading something that is still moving. */
    [data-reveal] {
      opacity: 1;
      transform: none;
      transition: none;
      animation: reveal-rise linear both;
      animation-timeline: view();
      animation-range: entry 5% entry 60%;
    }
    @keyframes reveal-rise {
      from { opacity: 0; transform: translateY(44px) scale(.96); }
      to   { opacity: 1; transform: translateY(0) scale(1); }
    }

    /* Stagger comes free. Every element carries its own timeline keyed to its
       own position, so a row of cards resolves in the order you meet them
       without a single hand-set delay -- and it stays correct at any scroll
       speed, in either direction. */

    /* The hero hands the page over as you leave it, rather than just scrolling
       off. `exit` is the range where the element is on its way out. */
    .hero__name, .hero__lede, .hero__meta, .hero__actions {
      animation: hero-part linear both;
      animation-timeline: view();
      animation-range: exit 0% exit 90%;
    }
    @keyframes hero-part {
      from { opacity: 1; transform: none; }
      to   { opacity: 0; transform: translateY(-30px) scale(.97); }
    }

    /* Benchmark bars draw themselves as the figure arrives. scaleX off the
       left edge, so it is a transform rather than a width and never touches
       layout -- a width animation on a table cell would reflow the row on
       every frame. */
    .bar {
      transform-origin: 0 50%;
      animation: bar-draw linear both;
      animation-timeline: view();
      animation-range: entry 20% entry 75%;
    }
    @keyframes bar-draw {
      from { transform: scaleX(0); }
      to   { transform: scaleX(1); }
    }

    /* The section number tracks its own section: it arrives with weight and
       settles. Only on the index, where .tag exists. */
    /* Lift and fade, and deliberately no scale.

       Two attempts here were wrong and the overflow sweep caught both. The
       first animated `letter-spacing`, which is layout rather than compositing
       -- the rule at the top of this block exists precisely to forbid that --
       and it widened the tag to 1,274 px on a 1024 px viewport, taking the
       whole document sideways. The second kept a 1.3x scale, which is no
       better: `.tag` is `display: block` inside a `grid-column: 1 / 4` label,
       so it is a ~1,200 px box that happens to contain two digits, and scaling
       it scales the box, not the number. */
    .sec__label .tag {
      animation: tag-settle linear both;
      animation-timeline: view();
      animation-range: entry 10% entry 70%;
    }
    @keyframes tag-settle {
      from { opacity: 0; transform: translateY(20px); }
      to   { opacity: 1; transform: none; }
    }
  }
}

/* A read-position bar. scroll(root) is the document's own scroll timeline, so
   this is two pixels of accent that cost nothing and tell you exactly how much
   of a long page is left -- which this page needed more than it needed another
   effect. Above the sticky chrome at z-index 40. */
@supports (animation-timeline: scroll()) {
  body::after {
    content: "";
    position: fixed; left: 0; right: 0; top: 0; height: 2px; z-index: 60;
    background: var(--signal);
    transform-origin: 0 50%;
    animation: read-progress linear both;
    animation-timeline: scroll(root block);
    pointer-events: none;
  }
  @keyframes read-progress {
    from { transform: scaleX(0); }
    to   { transform: scaleX(1); }
  }
  @media print { body::after { display: none; } }
}
