/* ============================================================
   johanthegoat.xyz — Alphonso "Johan" Braga
   ============================================================
   Dark, technical, high contrast. Surfaces are real: content
   sits on raised panels with a hairline edge, not on hairlines
   alone. Type is one tight grotesque carrying the display and
   the prose, with a monospace carrying every label, count and
   piece of metadata, which is the thing that makes a page read
   as instrumentation rather than as an article.

   Dark is the default rather than an option. The light theme is
   a complete second palette, not a set of patches, so both are
   defined in full and neither is the other's afterthought.
   ============================================================ */

:root {
  color-scheme: dark;

  /* Surfaces, darkest first. The canvas is not pure black: a few
     points of lightness keep the raised panels distinguishable
     without needing shadows to separate them. */
  --bg:        #08090a;
  --bg-2:      #0e1012;
  --bg-3:      #16191c;
  --text:      #f2f3f5;
  --muted:     #a2a8b0;
  --faint:     #7b828b;
  --line:      #1d2024;
  --line-firm: #2e3238;
  --accent:    #5b9dff;
  --accent-bg: #0f1b33;

  /* The edge highlight. A single hairline of light along the top
     of a raised surface is what stops a dark card reading as a
     hole cut in the page. */
  --edge: rgb(255 255 255 / .07);

  --sans: "Inter Tight", ui-sans-serif, system-ui, -apple-system, "Segoe UI", sans-serif;
  --mono: "JetBrains Mono", ui-monospace, "Cascadia Mono", Menlo, monospace;
  /* Display is the same family, distinguished by weight and tracking rather
     than by a second typeface. */
  --display: var(--sans);

  --pad: clamp(1.15rem, 4.5vw, 4rem);
  --maxw: 1180px;
  --readw: 68ch;
  --radius: 10px;
  --radius-sm: 6px;

  --ease: cubic-bezier(0.2, 0.8, 0.2, 1);

  /* The header's own height, named once so anything that has to clear it
     (the sticky rail, scroll-margin on anchor targets) stays correct if it
     ever changes. */
  --hdr-h: 3.4rem;

  /* ---------------------------------------------------------------
     A modular type scale: a perfect fourth (1.333) at the top of
     the viewport range easing to a minor third (1.2) on small
     screens so the headline cannot bully everything else on a
     phone. Each step is a real clamp between those two ratios
     rather than a number picked by eye.
     --------------------------------------------------------------- */
  --step--2: clamp(0.69rem, 0.67rem + 0.11vw, 0.75rem);
  --step--1: clamp(0.83rem, 0.79rem + 0.20vw, 0.94rem);
  --step-0:  clamp(1.00rem, 0.95rem + 0.24vw, 1.13rem);
  --step-1:  clamp(1.20rem, 1.11rem + 0.43vw, 1.44rem);
  --step-2:  clamp(1.44rem, 1.30rem + 0.71vw, 1.84rem);
  --step-3:  clamp(1.73rem, 1.50rem + 1.13vw, 2.36rem);
  --step-4:  clamp(2.07rem, 1.72rem + 1.76vw, 3.05rem);
  --step-5:  clamp(2.49rem, 1.95rem + 2.68vw, 3.98rem);
  --step-6:  clamp(2.99rem, 2.18rem + 4.02vw, 5.23rem);
  --step-7:  clamp(3.58rem, 2.37rem + 6.05vw, 6.95rem);

  /* Named roles, so components ask for meaning rather than a number. */
  --t-hero:  var(--step-7);
  --t-sec:   var(--step-4);
  --t-item:  var(--step-2);
  --t-body:  var(--step-0);
  --t-small: var(--step--1);
  --t-micro: var(--step--2);

  /* Line heights tighten as type grows, which is how it stays readable
     at both ends instead of loose at the top and cramped at the bottom. */
  --lh-display: 0.98;
  --lh-heading: 1.12;
  --lh-body: 1.62;
  --lh-tight: 1.3;

  /* Tracking follows size in the opposite direction. A grotesque set this
     large needs more negative tracking than a serif did. */
  --tr-display: -0.042em;
  --tr-heading: -0.024em;
  --tr-body: -0.006em;
  --tr-caps: 0.16em;
}

/* There is one theme.

   No `prefers-color-scheme` rule, no `[data-theme]` variants, no toggle. The
   panels, the edge highlights and the accent are drawn for a dark canvas and
   do not have a light reading that is as good. A second palette that nobody
   designed as carefully is not a feature, it is a second design to keep
   correct, and a stored preference for it is a way for a visitor to get
   stranded on a version of the site that no longer exists. */

*, *::before, *::after { box-sizing: border-box; }

html { -webkit-text-size-adjust: 100%; scroll-padding-top: 5.5rem; }

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--sans);
  font-size: var(--t-body);
  line-height: var(--lh-body);
  letter-spacing: var(--tr-body);
  overflow-x: hidden;

  /* One grotesque across the whole page, so the only thing distinguishing a
     headline from a paragraph is weight, size and tracking. That is a harder
     discipline than swapping in a display face, and it is what makes the page
     read as a single system. */
  font-optical-sizing: auto;
  font-kerning: normal;
  font-variant-ligatures: common-ligatures contextual;
  text-rendering: optimizeLegibility;
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* Paragraphs get `pretty`, which spends a little layout time avoiding
   widows and short last lines. Headings get `balance`, which evens the
   ragged edge across lines instead of leaving one word stranded. */
p, li, dd, figcaption, blockquote { text-wrap: pretty; }
h1, h2, h3, h4, .lede, .hero__pitch, .contact__big { text-wrap: balance; }

/* Uppercase mono labels need air; lowercase display needs the opposite. */
.mono, .eyebrow, kbd, .chip, th {
  font-feature-settings: "tnum" 1;
  letter-spacing: var(--tr-caps);
}

/* Numbers that sit in columns or tick upward must not reflow. */
.stat b, .tech__n, .row__aside, .typing__stats b, .pj-facts dd {
  font-variant-numeric: tabular-nums;
}

img, svg, video, canvas { display: block; max-width: 100%; }
a { color: inherit; text-decoration: none; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
::selection { background: var(--accent); color: #fff; }
:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; border-radius: 2px; }

.skip {
  position: fixed; left: -9999px; top: 0; z-index: 999;
  background: var(--text); color: var(--bg); padding: .8rem 1.2rem;
}
.skip:focus { left: .5rem; top: .5rem; }

/* ---------- primitives ---------- */

.wrap { width: 100%; max-width: var(--maxw); margin-inline: auto; padding-inline: var(--pad); }

.mono {
  font-family: var(--mono); font-size: var(--t-micro);
  letter-spacing: .14em; text-transform: uppercase; color: var(--faint);
  font-weight: 400;
}

/* The numbered section rule — the spine of the whole page. */
.sec { padding-block: clamp(3.5rem, 7vw, 6rem); border-top: 1px solid var(--line); }
.sec:first-of-type { border-top: 0; }

.sec__head {
  display: flex; align-items: baseline; justify-content: space-between;
  gap: 1rem 2rem; flex-wrap: wrap;
  margin-bottom: clamp(1.75rem, 3.5vw, 2.75rem);
}
.sec__label { display: flex; align-items: baseline; gap: .7rem; }
.sec__label b { font-family: var(--mono); font-size: var(--t-micro); color: var(--accent); font-weight: 500; letter-spacing: .14em; }
.sec__label h2 {
  font-family: var(--mono); font-size: var(--t-micro); letter-spacing: var(--tr-caps);
  text-transform: uppercase; color: var(--faint); margin: 0; font-weight: 400;
}
.sec__more { font-size: var(--t-small); color: var(--muted); display: inline-flex; align-items: center; gap: .4rem; }
.sec__more:hover { color: var(--accent); }

.lede {
  font-size: var(--t-item);
  line-height: var(--lh-tight);
  letter-spacing: var(--tr-heading);
  max-width: 34ch; margin: 0;
}

.ftr__colophon {
  margin: 1.25rem 0 0;
  padding-top: 1.25rem;
  border-top: 1px solid var(--line);
  font-size: var(--t-small);
  color: var(--faint);
  max-width: 62ch;
}

/* ---------- about ----------
   The bio reads as prose, so it is set at a real reading measure rather than
   the narrow column the rows use. The opening paragraph runs one step larger
   and the rest settle back, which is the whole hierarchy the section needs. */
.about { max-width: var(--readw); }
.about p {
  margin: 0 0 1.1em;
  color: var(--muted);
  line-height: var(--lh-body);
  text-wrap: pretty;
}
.about p:first-child {
  font-size: var(--step-1);
  line-height: var(--lh-tight);
  color: var(--text);
  margin-bottom: 1.4rem;
}
.about p:last-child { margin-bottom: 0; }

/* A reference to a project, inside a sentence. Underlined rather than
   coloured, so four of them in one paragraph do not turn it into a menu. */
.about__ref {
  color: inherit;
  text-decoration: underline;
  /* The rule sits well below the baseline and barely darker than the page,
     because the first paragraph carries four of these and a normal link
     underline turns the sentence into a list of links. */
  text-decoration-color: var(--line-firm);
  text-underline-offset: .22em;
  text-decoration-thickness: 1px;
  transition: color .18s var(--ease), text-decoration-color .18s var(--ease);
}
.about__ref:hover {
  color: var(--accent);
  text-decoration-color: var(--accent);
}

.ext::after { content: "↗"; font-size: .8em; margin-left: .25em; color: var(--faint); }

/* ---------- header ---------- */

.hdr {
  position: sticky; top: 0; z-index: 100;
  background: color-mix(in srgb, var(--bg) 88%, transparent);
  backdrop-filter: saturate(1.6) blur(12px);
  border-bottom: 1px solid var(--line);
}
@supports not (backdrop-filter: blur(1px)) { .hdr { background: var(--bg); } }

.hdr__in { display: flex; align-items: center; gap: 1rem; height: var(--hdr-h); }

.brand { font-weight: 600; letter-spacing: -.015em; font-size: .95rem; display: flex; align-items: baseline; gap: .5rem; flex: none; }
.brand i { font-style: normal; color: var(--faint); font-weight: 400; font-size: var(--t-micro); font-family: var(--mono); letter-spacing: .12em; text-transform: uppercase; }

.hdr__nav { display: flex; align-items: center; gap: 1.35rem; margin-left: auto; }
.hdr__nav a { font-size: var(--t-small); color: var(--muted); transition: color .18s var(--ease); white-space: nowrap; }
.hdr__nav a:hover, .hdr__nav a[aria-current="page"] { color: var(--text); }

.hdr__tools { display: flex; align-items: center; gap: .4rem; flex: none; }

.tool {
  display: inline-flex; align-items: center; gap: .5rem;
  padding: .35rem .6rem .35rem .7rem;
  border: 1px solid var(--line); border-radius: 5px;
  font-size: var(--t-small); color: var(--muted);
  transition: border-color .18s var(--ease), color .18s var(--ease);
}
.tool:hover { color: var(--text); border-color: var(--line-firm); }
kbd {
  font-family: var(--mono); font-size: .62rem; letter-spacing: .04em;
  padding: .15rem .3rem; border: 1px solid var(--line); border-radius: 3px;
  color: var(--faint); background: var(--bg-2); line-height: 1;
}

.icon-btn {
  width: 2rem; height: 2rem; display: grid; place-items: center;
  border: 1px solid var(--line); border-radius: 5px; color: var(--muted);
  transition: border-color .18s var(--ease), color .18s var(--ease);
}
.icon-btn:hover { color: var(--text); border-color: var(--line-firm); }
.icon-btn svg { width: 15px; height: 15px; }


.burger { display: none; }

.tool__icon { display: none; width: 15px; height: 15px; }

@media (max-width: 940px) {
  .hdr__nav { display: none; }
  .burger { display: grid; }
  /* Collapse the labelled tools to a single icon rather than an empty pill. */
  .tool span, .tool kbd { display: none; }
  .tool { padding: .4rem; }
  .tool__icon { display: block; }
  .tool--desk { display: none; }
}

/* mobile sheet */
.sheet {
  position: fixed; inset: 3.4rem 0 0; z-index: 99;
  background: var(--bg); border-top: 1px solid var(--line);
  padding: 1.5rem var(--pad) 3rem; overflow-y: auto;
  display: none; flex-direction: column; gap: .25rem;
}
.sheet[data-open="true"] { display: flex; }
.sheet a { padding: .85rem 0; border-bottom: 1px solid var(--line); font-size: 1.05rem; }
.sheet a:last-of-type { border-bottom: 0; }
.sheet__group { font-family: var(--mono); font-size: var(--t-micro); letter-spacing: .16em; text-transform: uppercase; color: var(--faint); margin-top: 1.25rem; }
body[data-locked="true"] { overflow: hidden; }

/* ---------- hero ---------- */

.hero { padding-block: 0 clamp(2rem, 4vw, 3rem); }
.hero__top { display: flex; align-items: center; gap: 1.15rem; margin-bottom: 1.75rem; }

.avatar {
  width: 62px; height: 62px; border-radius: 50%; flex: none;
  border: 1px solid var(--line); object-fit: cover; background: var(--bg-2);
}
.avatar--fallback {
  display: grid; place-items: center;
  font-family: var(--display); font-size: 1.6rem; color: var(--muted);
}

.hero__name { font-size: 1.05rem; font-weight: 600; letter-spacing: -.015em; margin: 0; }
.hero__where { font-size: var(--t-small); color: var(--faint); margin: .15rem 0 0; display: flex; align-items: center; gap: .45rem; }
.pulse { width: 6px; height: 6px; border-radius: 50%; background: #3ecf7b; flex: none; }

.hero__pitch {
  font-family: var(--display); font-weight: 600;
  font-size: var(--t-hero);
  line-height: var(--lh-display);
  letter-spacing: var(--tr-display);
  /* The capital I has almost no left sidebearing, so flush-left leaves it
     looking indented against the text beneath. Pull it back optically.
     Must come after the shorthand or the shorthand resets it. */
  margin: 0 0 clamp(1.75rem, 3.5vw, 2.5rem);
  margin-left: -0.055em;
  /* Balance rather than a hard clamp, so it never orphans a single word. */
  max-width: 18ch;
  text-wrap: balance;
}
.hero__pitch em { font-style: italic; }
.hero__intro { color: var(--muted); max-width: var(--readw); margin: 0 0 1.75rem; }

/* ---------- the stack, directly under the headline ---------- */

/* Full bleed, sitting directly under the header as the first thing on the
   page. It is edge to edge rather than inside .wrap, so it reads as a band. */
.hstack {
  border-bottom: 1px solid var(--line);
  margin-bottom: clamp(2.5rem, 5vw, 4rem);
  position: relative;
  z-index: 1;
}

/* One lane. The lane clips, the track slides. */
.hstack__lane {
  display: flex;
  overflow: hidden;
  padding-block: 1.1rem;
  /* Fade both ends so names never collide with the edge of the page. */
  mask-image: linear-gradient(90deg, transparent, #000 4%, #000 96%, transparent);
  -webkit-mask-image: linear-gradient(90deg, transparent, #000 4%, #000 96%, transparent);
}
.hstack__track {
  display: flex;
  flex: none;
  align-items: center;
  gap: 1.6rem;
  padding-right: 1.6rem;
  animation: hstack-slide linear infinite;
  animation-duration: calc(var(--lane-speed, 72) * 1s);
  will-change: transform;
}
.hstack__lane:hover .hstack__track { animation-play-state: paused; }
@keyframes hstack-slide { to { transform: translateX(-100%); } }

/* The band is metadata, not a headline. Set in the monospace at label size so
   it reads as a running readout under the header rather than competing with
   the hero underneath it. */
.hstack__item {
  white-space: nowrap;
  font-family: var(--mono);
  font-size: var(--t-micro);
  letter-spacing: .12em;
  text-transform: uppercase;
  line-height: 1.5;
  color: var(--muted);
  display: inline-flex;
  align-items: center;
  gap: 1.6rem;
  transition: color .16s var(--ease);
}
/* A dot between entries, drawn on the item rather than as a separate node. */
.hstack__item::after {
  content: "";
  width: 3px; height: 3px; border-radius: 50%;
  background: var(--line-firm);
  flex: none;
}
.hstack__item:hover { color: var(--accent); }

@media (max-width: 680px) {
  .hstack__lane { padding-block: .9rem; }
  .hstack__track { gap: 1.15rem; padding-right: 1.15rem; }
  .hstack__item { gap: 1.15rem; }
}

/* Motion off: stop the slide and let the lane scroll by hand, so the full list
   stays reachable instead of frozen part-way through. */
@media (prefers-reduced-motion: reduce) {
  .hstack__track { animation: none; }
  .hstack__track[aria-hidden="true"] { display: none; }
  .hstack__lane { overflow-x: auto; mask-image: none; -webkit-mask-image: none; }
}
}

@media (max-width: 680px) {
  .hstack__row { grid-template-columns: 1fr; }
  .hstack__items a { font-size: .85rem; }
}

.socials { display: flex; flex-wrap: wrap; gap: .4rem; }
.socials a {
  font-size: var(--t-small); color: var(--muted);
  padding: .3rem .7rem; border: 1px solid var(--line); border-radius: 100px;
  transition: border-color .18s var(--ease), color .18s var(--ease);
}
.socials a:hover { color: var(--text); border-color: var(--line-firm); }

/* ---------- hero supporting type ---------- */
.hero__sub {
  margin: 1.5rem 0 0; max-width: 46ch;
  font-size: var(--step-1); line-height: var(--lh-tight);
  color: var(--muted); letter-spacing: var(--tr-body);
  text-wrap: pretty;
}
.hero__cta { display: flex; flex-wrap: wrap; gap: .7rem; margin-top: 2rem; }
.hero__cta .btn { padding: .7rem 1.3rem; font-size: var(--t-body); border-radius: var(--radius-sm); }

/* ---------- the product shot ----------

   A browser frame tilted back on the X axis, lit from behind, bleeding off the
   right edge of the viewport. Three things make it read as depth rather than as
   a rotated rectangle:

     perspective on the container, not the element, so the tilt has a real
       vanishing point rather than a flat skew;
     a large blurred radial behind it, which is the light the frame is sitting
       in and the reason its top edge separates from the page;
     the frame's own top-edge highlight, the same one the project panels use.

   The transform is dropped entirely under reduced motion and below the tablet
   breakpoint, where a tilted frame is illegible at that width and the shot is
   better shown flat. */
.showcase {
  position: relative;
  margin: 3.25rem 0 0 max(var(--pad), calc(50vw - var(--maxw) / 2 + var(--pad)));
  perspective: 1800px;
  perspective-origin: 20% 50%;
  pointer-events: none;
  /* The height is capped on the shot rather than here. Clipping the container
     would cut off the glow, which is deliberately drawn outside the frame's
     bounds: it is the light the frame sits in, so it has to spill. */
  isolation: isolate;
}
.showcase__glow {
  position: absolute; inset: -22% -8% -26% -8%; z-index: 0;
  background:
    radial-gradient(55% 55% at 28% 15%, color-mix(in srgb, var(--accent) 42%, transparent), transparent 70%),
    radial-gradient(45% 50% at 78% 65%, color-mix(in srgb, #7c5cff 32%, transparent), transparent 72%);
  filter: blur(70px);
  opacity: 1;
}
.showcase__frame {
  position: relative; z-index: 1;
  border: 1px solid var(--line-firm);
  border-radius: 12px 0 0 12px;
  background: var(--bg-2);
  overflow: hidden;
  transform: rotateX(7deg) rotateY(-13deg) rotateZ(1deg) translateZ(0);
  transform-origin: left center;
  box-shadow: 0 60px 120px -30px rgb(0 0 0 / .85), 0 0 0 1px rgb(255 255 255 / .04);
}
.showcase__frame::before {
  content: ""; position: absolute; inset: 0; z-index: 2; pointer-events: none;
  background: linear-gradient(var(--edge), transparent 3%);
}
.showcase__bar {
  display: flex; align-items: center; gap: .85rem;
  padding: .6rem .9rem;
  border-bottom: 1px solid var(--line);
  background: var(--bg-3);
}
.showcase__dots { display: flex; gap: .4rem; flex: none; }
.showcase__dots i { width: 9px; height: 9px; border-radius: 50%; background: var(--line-firm); }
.showcase__url {
  font-family: var(--mono); font-size: var(--t-micro); color: var(--faint);
  background: var(--bg-2); border: 1px solid var(--line);
  border-radius: 999px; padding: .22rem .8rem;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.showcase__shot { aspect-ratio: 16 / 9; overflow: hidden; max-height: 50vh; }
.showcase__shot img { width: 100%; height: 100%; object-fit: cover; object-position: top center; display: block; }

@media (max-width: 900px) {
  /* Flat, inside the normal measure. A frame tilted this hard is unreadable
     once it is only a few hundred pixels wide. */
  .showcase {
    margin: 2.75rem var(--pad) 0; perspective: none;
    max-height: none;
  }
  .showcase__shot { max-height: none; }
  .showcase__frame { transform: none; border-radius: 10px; }
  .showcase__glow { filter: blur(40px); opacity: .6; }
}

@media (prefers-reduced-motion: reduce) {
  .showcase__frame { transform: none; }
}

/* ---------- the two-column page shell ----------

   Below the hero the home page splits: a sticky rail that stays put, and the
   sections scrolling past it. This is the one structural decision on the page,
   so it is worth saying what it buys. A long single column makes a visitor
   scroll to learn how much is left and scroll back to find the email address.
   The rail answers both without moving.

   It is a progressive enhancement of a layout that already worked. At narrow
   widths the grid collapses to one column and the rail becomes an ordinary
   block of links above the content, in source order, which is also what a
   screen reader gets either way. */
.page {
  display: grid;
  grid-template-columns: 200px minmax(0, 1fr);
  gap: clamp(2rem, 5vw, 5rem);
  align-items: start;
}

/* Sections inside the flow have no wrap of their own, so the column is the
   measure. The first one loses its rule because the hero already drew one. */
.sec--bare { padding-block: clamp(3rem, 6vw, 5rem); }
.page__flow > .sec--bare:first-child { border-top: 0; padding-top: clamp(2rem, 4vw, 3rem); }

/* ---------- rail ---------- */
.rail {
  position: sticky;
  /* Clears the fixed header, then a little air. */
  top: calc(var(--hdr-h) + 2.5rem);
  display: flex; flex-direction: column; gap: 2.5rem;
  /* Never taller than the viewport it is pinned to, or the foot is unreachable. */
  max-height: calc(100vh - var(--hdr-h) - 4rem);
  overflow: auto;
  scrollbar-width: none;
}
.rail::-webkit-scrollbar { display: none; }

.rail__map { display: flex; flex-direction: column; }
.rail__item {
  display: grid; grid-template-columns: 1.9rem 1fr;
  align-items: baseline; gap: .3rem;
  padding: .45rem 0 .45rem .75rem;
  border-left: 2px solid var(--line);
  color: var(--faint);
  font-size: var(--t-small);
  transition: color .2s var(--ease), border-color .2s var(--ease);
}
.rail__item b {
  font-family: var(--mono); font-size: var(--t-micro);
  font-weight: 400; letter-spacing: .1em;
}
.rail__item:hover { color: var(--text); border-left-color: var(--line-firm); }

/* Set by the observer in app.js. Without JavaScript no entry is current and
   every entry is still a working link, which is the point. */
.rail__item[data-current] {
  color: var(--text);
  border-left-color: var(--accent);
}
.rail__item[data-current] b { color: var(--accent); }

.rail__foot {
  display: flex; flex-direction: column; gap: .9rem; align-items: flex-start;
  padding-top: 1.5rem; border-top: 1px solid var(--line);
}
.rail__status {
  margin: 0; font-size: var(--t-micro); color: var(--faint);
  line-height: var(--lh-body);
  display: flex; gap: .45rem;
}
.rail__status .pulse { margin-top: .45em; flex: 0 0 auto; }
.rail__links { display: flex; flex-wrap: wrap; gap: .75rem; }
.rail__links a { font-size: var(--t-micro); color: var(--muted); }
.rail__links a:hover { color: var(--accent); }

/* ---------- segmented control ----------
   Only meaningful once scripting is running: without it both groupings are
   shown in full and there is nothing to switch, so the control stays hidden
   rather than sitting there doing nothing. */
.seg { display: none; }
html.js .seg {
  display: inline-flex; gap: 2px; padding: 3px;
  border: 1px solid var(--line); border-radius: 7px;
  background: var(--bg-2); margin-bottom: .5rem;
}
.seg__btn {
  font: inherit; font-size: var(--t-small); color: var(--muted);
  background: none; border: 0; border-radius: 5px;
  padding: .35rem .75rem; cursor: pointer;
  transition: background .18s var(--ease), color .18s var(--ease);
}
.seg__btn:hover { color: var(--text); }
.seg__btn[aria-pressed="true"] { background: var(--bg); color: var(--text); box-shadow: 0 1px 2px rgb(0 0 0 / .06); }

/* One grouping at a time, but only when there is a control to change it. */
html.js [data-group="status"] { display: none; }
html.js [data-group].is-shown { display: block; }
html.js [data-group]:not(.is-shown) { display: none; }

/* ---------- copy to clipboard ----------
   The address is the label, so there is nothing to decode before clicking, and
   the confirmation replaces the label in place rather than appearing beside it,
   which keeps the row from resizing. */
.copy {
  position: relative;
  font: inherit; font-size: var(--t-micro); font-family: var(--mono);
  color: var(--muted); background: none; cursor: pointer;
  border: 1px solid var(--line); border-radius: 5px;
  padding: .4rem .55rem; text-align: left;
  max-width: 100%; overflow: hidden; text-overflow: ellipsis;
  transition: border-color .2s var(--ease), color .2s var(--ease);
}
.copy:hover { border-color: var(--line-firm); color: var(--text); }
.copy__done {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  background: var(--accent-bg); color: var(--accent);
  opacity: 0; transition: opacity .16s var(--ease);
}
.copy[data-copied] .copy__done { opacity: 1; }

@media (max-width: 900px) {
  .page { grid-template-columns: 1fr; gap: 0; }
  .rail {
    position: static; max-height: none; overflow: visible;
    flex-direction: row; flex-wrap: wrap; gap: 1.25rem 2rem;
    align-items: center; justify-content: space-between;
    padding-block: 1.5rem; border-bottom: 1px solid var(--line);
  }
  /* Horizontal on a phone: the minimap becomes a scrollable strip of numbers
     rather than a tall list nobody asked for above the content.

     `min-width: 0` on both the rail and the strip is what makes the scroll
     actually scroll. A flex item defaults to its content's minimum width, so
     without it the strip forces the rail wider than the column and the whole
     page gains a horizontal scrollbar instead. */
  .rail { min-width: 0; max-width: 100%; }
  .rail__map {
    flex: 1 1 100%; min-width: 0;
    flex-direction: row; gap: .25rem;
    overflow-x: auto; scrollbar-width: none;
    -webkit-overflow-scrolling: touch;
  }
  .rail__map::-webkit-scrollbar { display: none; }
  .rail__item {
    border-left: 0; border-bottom: 2px solid var(--line);
    padding: .3rem .7rem; white-space: nowrap;
    grid-template-columns: auto auto; gap: .4rem;
  }
  .rail__item[data-current] { border-left-color: var(--line); border-bottom-color: var(--accent); }
  .rail__foot { border-top: 0; padding-top: 0; flex-direction: row; align-items: center; gap: 1rem; }
  .rail__status { display: none; }
}

/* ---------- stats ---------- */

.stats { display: grid; grid-template-columns: repeat(auto-fit, minmax(120px, 1fr)); gap: 1px; background: var(--line); border-block: 1px solid var(--line); }
.stat { background: var(--bg); padding: 1.15rem clamp(.75rem, 1.6vw, 1.25rem); }
.stat b { display: block; font-family: var(--display); font-weight: 600; font-size: clamp(1.5rem, 3vw, 2.1rem); line-height: 1.05; letter-spacing: -.02em; }
.stat span { display: block; margin-top: .3rem; font-family: var(--mono); font-size: var(--t-micro); letter-spacing: .12em; text-transform: uppercase; color: var(--faint); }

/* ---------- list rows (blog, experience, index) ---------- */

.rows { border-top: 1px solid var(--line); }
.row {
  display: grid; gap: .2rem 1.5rem; align-items: baseline;
  grid-template-columns: 6.5rem minmax(0, 1fr) auto;
  padding-block: 1.1rem;
  border-bottom: 1px solid var(--line);
  position: relative;
  transition: background .18s var(--ease);
}
.row::before {
  content: ""; position: absolute; left: 0; top: 0; bottom: 0; width: 2px;
  background: var(--accent); transform: scaleY(0); transform-origin: top;
  transition: transform .28s var(--ease);
}
.row:hover { background: var(--bg-2); }
.row:hover::before { transform: scaleY(1); }
.row__link { position: absolute; inset: 0; }
.row__when { font-family: var(--mono); font-size: var(--t-micro); letter-spacing: .1em; text-transform: uppercase; color: var(--faint); padding-top: .3rem; }
.row__body { min-width: 0; }
.row__title {
  font-family: var(--display); font-weight: 600;
  font-size: var(--t-item);
  line-height: var(--lh-heading);
  letter-spacing: var(--tr-heading);
  margin: 0;
}
.row:hover .row__title { color: var(--accent); }
.row__desc { color: var(--muted); font-size: var(--t-small); margin: .3rem 0 0; max-width: 62ch; }
.row__aside { font-family: var(--mono); font-size: var(--t-micro); color: var(--faint); white-space: nowrap; letter-spacing: .08em; text-align: right; padding-top: .3rem; }
.row__tags { display: flex; flex-wrap: wrap; gap: .35rem; margin-top: .5rem; }

@media (max-width: 720px) {
  .row { grid-template-columns: 1fr auto; }
  .row__when { grid-column: 1 / -1; padding-top: 0; }
  .row__aside { align-self: start; }
}

/* Filled rather than outlined. On a dark page an outlined pill reads as an
   empty box; a filled one reads as a token, which is what it is. */
.chip {
  font-family: var(--mono); font-size: .62rem; letter-spacing: .09em; text-transform: uppercase;
  padding: .25rem .5rem; border-radius: 999px;
  background: var(--bg-3); border: 1px solid var(--line); color: var(--muted);
}
.chip--live {
  background: var(--accent-bg);
  border-color: color-mix(in srgb, var(--accent) 34%, transparent);
  color: var(--accent);
}

/* ---------- projects ---------- */

.feat-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(min(100%, 300px), 1fr)); gap: clamp(1.25rem, 2.5vw, 2rem); margin-bottom: clamp(2rem, 4vw, 3rem); }

/* A featured project is a raised panel, not a floating image with a caption.
   Three layers make it read that way on a dark canvas:

     the surface, a shade above the page;
     the edge, one hairline of light along the top, which is what stops a dark
       panel looking like a hole cut in the background;
     the spotlight, a soft radial wash that follows the pointer, drawn from
       --mx/--my which app.js writes on pointermove.

   All three are inert without a pointer: the spotlight defaults to invisible
   and only gains opacity on hover, so touch and keyboard get the panel and
   nothing that depends on a cursor that is not there. */
.feat {
  display: block; position: relative; isolation: isolate;
  background: var(--bg-2);
  border: 1px solid var(--line);
  border-radius: var(--radius);
  padding: .85rem .85rem 1.1rem;
  transition: border-color .3s var(--ease), background .3s var(--ease);
}
.feat::before {
  content: ""; position: absolute; inset: 0; border-radius: inherit;
  padding-top: 1px; pointer-events: none;
  background: linear-gradient(var(--edge), transparent 42%);
  -webkit-mask: linear-gradient(#000 0 0) content-box, linear-gradient(#000 0 0);
  -webkit-mask-composite: xor; mask-composite: exclude;
}
.feat::after {
  content: ""; position: absolute; inset: 0; border-radius: inherit;
  pointer-events: none; opacity: 0; z-index: -1;
  transition: opacity .35s var(--ease);
  background: radial-gradient(
    260px circle at var(--mx, 50%) var(--my, 50%),
    color-mix(in srgb, var(--accent) 16%, transparent), transparent 70%);
}
.feat:hover { border-color: var(--line-firm); background: var(--bg-3); }
.feat:hover::after { opacity: 1; }
.feat:focus-visible { outline: 2px solid var(--accent); outline-offset: 3px; }

.feat__shot {
  aspect-ratio: 16 / 10; overflow: hidden; background: var(--bg-3);
  border: 1px solid var(--line); border-radius: var(--radius-sm);
  margin-bottom: .9rem;
}
.feat__shot img { width: 100%; height: 100%; object-fit: cover; object-position: top center; transition: transform .6s var(--ease); }
.feat:hover .feat__shot img { transform: scale(1.025); }
.feat__meta { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
.feat__name {
  font-family: var(--display); font-weight: 600;
  font-size: var(--step-1);
  line-height: var(--lh-heading);
  letter-spacing: var(--tr-heading);
  margin: 0;
}
.feat:hover .feat__name { color: var(--accent); }
.feat__desc { color: var(--muted); font-size: var(--t-small); margin: .35rem 0 0; }

/* ---------- stack ---------- */

.stack-group { padding-block: 1.35rem; border-bottom: 1px solid var(--line); display: grid; grid-template-columns: 11rem minmax(0, 1fr); gap: 1rem 2rem; align-items: start; }
.stack-group:first-child { border-top: 1px solid var(--line); }
.stack-group h3 { font-family: var(--mono); font-size: var(--t-micro); letter-spacing: .14em; text-transform: uppercase; color: var(--faint); margin: .25rem 0 0; font-weight: 400; }
.stack-items { display: flex; flex-wrap: wrap; gap: .4rem; }
/* Each pill is a link to that technology on the stack page, and carries the
   number of projects using it when there is more than one. The count is set
   in the mono face at a smaller size so it reads as an annotation on the name
   rather than part of it. */
.stack-items a {
  display: inline-flex; align-items: baseline; gap: .4rem;
  font-size: var(--t-small); color: var(--muted);
  padding: .25rem .6rem; border: 1px solid var(--line); border-radius: 4px;
  transition: border-color .18s var(--ease), color .18s var(--ease);
}
.stack-items a:hover { color: var(--text); border-color: var(--line-firm); }
.stack-items a i {
  font-style: normal; font-family: var(--mono);
  font-size: var(--t-micro); color: var(--faint);
}
.stack-items a:hover i { color: var(--accent); }
@media (max-width: 720px) { .stack-group { grid-template-columns: 1fr; } }

/* ---------- empty state for unfilled sections ---------- */

.awaiting {
  border: 1px dashed var(--line-firm); border-radius: 4px;
  padding: 1.15rem 1.25rem; color: var(--faint); font-size: var(--t-small);
}
.awaiting code { font-family: var(--mono); font-size: .8em; color: var(--muted); }

/* ---------- contact ---------- */

.contact { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: clamp(1.5rem, 4vw, 4rem); align-items: start; }
@media (max-width: 780px) { .contact { grid-template-columns: 1fr; } }
.contact__big {
  font-family: var(--display); font-weight: 600;
  font-size: var(--t-sec);
  line-height: var(--lh-heading);
  letter-spacing: var(--tr-heading);
  margin: 0 0 1rem;
}
.contact p { color: var(--muted); margin: 0 0 1.25rem; }
.mailto { font-size: var(--t-item); color: var(--accent); border-bottom: 1px solid color-mix(in srgb, var(--accent) 40%, transparent); }
.mailto:hover { border-bottom-color: var(--accent); }

/* ---------- footer ---------- */

.ftr { border-top: 1px solid var(--line); padding-block: 2rem 2.5rem; }
.ftr__in { display: flex; flex-wrap: wrap; gap: .6rem 2rem; justify-content: space-between; align-items: center; font-size: var(--t-small); color: var(--faint); }
.ftr a:hover { color: var(--text); }

/* ============================================================
   Command palette (⌘K)
   ============================================================ */

.pal-back {
  position: fixed; inset: 0; z-index: 200;
  background: color-mix(in srgb, #000 42%, transparent);
  display: none; align-items: flex-start; justify-content: center;
  padding: clamp(3rem, 12vh, 8rem) 1rem 2rem;
}
.pal-back[data-open="true"] { display: flex; }

.pal {
  width: min(100%, 560px); background: var(--bg); border: 1px solid var(--line-firm);
  border-radius: 8px; overflow: hidden; box-shadow: 0 16px 48px rgba(0,0,0,.18);
  display: flex; flex-direction: column; max-height: min(70vh, 480px);
}
.pal input {
  width: 100%; border: 0; border-bottom: 1px solid var(--line);
  padding: .95rem 1.1rem; font: inherit; font-size: .95rem;
  background: transparent; color: var(--text);
}
.pal input::placeholder { color: var(--faint); }
.pal input:focus { outline: 0; }

.pal__list { overflow-y: auto; padding: .35rem; }
.pal__item {
  display: flex; align-items: baseline; gap: .75rem; width: 100%; text-align: left;
  padding: .55rem .75rem; border-radius: 5px; font-size: var(--t-small);
}
.pal__item[aria-selected="true"] { background: var(--accent-bg); }
.pal__item b { font-weight: 500; color: var(--text); }
.pal__item span { color: var(--faint); font-family: var(--mono); font-size: var(--t-micro); letter-spacing: .1em; text-transform: uppercase; margin-left: auto; white-space: nowrap; }
.pal__none { padding: 1.5rem .75rem; text-align: center; color: var(--faint); font-size: var(--t-small); }
.pal__foot { border-top: 1px solid var(--line); padding: .5rem .85rem; display: flex; gap: 1rem; font-size: var(--t-micro); color: var(--faint); font-family: var(--mono); letter-spacing: .08em; }

/* ============================================================
   Typing test (⌘J)
   ============================================================ */

.typing { max-width: 100%; }
.typing__quote {
  font-family: var(--mono); font-size: clamp(.9rem, 1.4vw, 1.05rem); line-height: 2;
  letter-spacing: .01em; color: var(--faint); word-break: break-word;
  border: 1px solid var(--line); border-radius: 6px; padding: 1.1rem 1.2rem;
  background: var(--bg-2); cursor: text;
}
.typing__quote b { color: var(--text); font-weight: 400; }
.typing__quote i { font-style: normal; color: #ff8a7a; border-bottom: 2px solid #ff8a7a; }
.typing__quote u {
  text-decoration: none; border-left: 2px solid var(--accent);
  margin-left: -2px; animation: caret 1s steps(1) infinite;
}
@keyframes caret { 50% { border-color: transparent } }

.typing__stats { display: flex; gap: 1.75rem; margin-top: 1rem; flex-wrap: wrap; }
.typing__stats div b { display: block; font-family: var(--display); font-size: 1.5rem; font-weight: 400; line-height: 1; }
.typing__stats div span { font-family: var(--mono); font-size: var(--t-micro); letter-spacing: .12em; text-transform: uppercase; color: var(--faint); }
.typing__hint { font-size: var(--t-small); color: var(--faint); margin-top: .9rem; }
.typing input.ghost { position: absolute; opacity: 0; pointer-events: none; }

/* ============================================================
   Article
   ============================================================ */

.art-head { padding-block: clamp(2.5rem, 5vw, 4rem) 1.5rem; }
.art-head h1 {
  font-family: var(--display); font-weight: 600;
  font-size: var(--step-5);
  line-height: var(--lh-heading);
  letter-spacing: var(--tr-display);
  margin: .5rem 0 .75rem; max-width: 24ch;
}
.art-head .lede { color: var(--muted); font-family: var(--sans); font-size: 1.05rem; max-width: var(--readw); }
.art-meta { display: flex; align-items: center; gap: .7rem; margin-top: 1.5rem; font-size: var(--t-small); color: var(--faint); }
.art-meta .avatar { width: 30px; height: 30px; }

.prose {
  max-width: var(--readw);
  font-size: var(--step-1);
  line-height: 1.75;
  letter-spacing: var(--tr-body);
}
.prose > * + * { margin-top: 1.15em; }
.prose h2 {
  font-family: var(--display); font-weight: 600;
  font-size: var(--step-3);
  letter-spacing: var(--tr-heading);
  line-height: var(--lh-heading);
  margin-top: 2.2em;
}
.prose h3 { font-size: 1.15rem; font-weight: 600; margin-top: 1.8em; }
.prose a { color: var(--accent); border-bottom: 1px solid color-mix(in srgb, var(--accent) 35%, transparent); }
.prose a:hover { border-bottom-color: var(--accent); }
.prose ul, .prose ol { padding-left: 1.3em; }
.prose li + li { margin-top: .35em; }
.prose blockquote { border-left: 2px solid var(--line-firm); padding-left: 1.1em; color: var(--muted); font-style: italic; margin-left: 0; }
.prose code { font-family: var(--mono); font-size: .86em; background: var(--bg-3); padding: .12em .35em; border-radius: 3px; }
.prose pre { background: var(--bg-2); border: 1px solid var(--line); border-radius: 6px; padding: 1rem 1.1rem; overflow-x: auto; font-size: .85rem; line-height: 1.6; }
.prose pre code { background: none; padding: 0; font-size: inherit; }
.prose img { border: 1px solid var(--line); border-radius: 4px; margin-block: 1.6em; }
.prose hr { border: 0; border-top: 1px solid var(--line); margin-block: 2.2em; }

/* ---------- pagination ---------- */

.pager { display: flex; align-items: center; justify-content: space-between; gap: 1rem; padding-block: 1.5rem; font-size: var(--t-small); }
.pager span { font-family: var(--mono); font-size: var(--t-micro); letter-spacing: .12em; color: var(--faint); }
.pager a:hover { color: var(--accent); }
.pager .off { color: var(--faint); pointer-events: none; }

/* ---------- project case study ---------- */

.pj-head { padding-block: clamp(2.5rem, 5vw, 4rem) 1.75rem; }
.pj-kicker {
  display: flex; flex-wrap: wrap; gap: .55rem; align-items: baseline;
  color: var(--faint); margin: 0 0 .9rem;
}
.pj-kicker span { opacity: .45; }
.pj-head h1 {
  font-family: var(--display); font-weight: 600;
  font-size: var(--step-5);
  line-height: var(--lh-heading);
  letter-spacing: var(--tr-display);
  margin: 0 0 1rem; max-width: 20ch;
}
.pj-lede {
  font-size: clamp(1.02rem, 1.5vw, 1.2rem); line-height: 1.55;
  color: var(--muted); max-width: var(--readw); margin: 0;
}
.pj-actions { display: flex; flex-wrap: wrap; gap: .5rem; margin-top: 1.75rem; }

.btn {
  display: inline-flex; align-items: center; gap: .45rem;
  padding: .55rem 1.05rem; border: 1px solid var(--line-firm); border-radius: 5px;
  font-size: var(--t-small);
  transition: background .18s var(--ease), color .18s var(--ease), border-color .18s var(--ease);
}
.btn:hover { border-color: var(--text); }
.btn--solid { background: var(--text); color: var(--bg); border-color: var(--text); }
.btn--solid:hover { background: var(--accent); border-color: var(--accent); color: #fff; }

/* full-width cover */
.pj-cover {
  margin: 0 0 clamp(2rem, 4vw, 3.5rem);
  border: 1px solid var(--line); border-radius: 6px;
  overflow: hidden; background-size: cover; background-position: center;
  aspect-ratio: 16 / 10;
}
.pj-cover img { width: 100%; height: 100%; object-fit: cover; object-position: center; }

/* narrative + sidebar */
.pj-cols {
  display: grid; grid-template-columns: minmax(0, 1.65fr) minmax(0, 1fr);
  gap: clamp(1.75rem, 4vw, 4rem); align-items: start;
  padding-bottom: clamp(2rem, 5vw, 3.5rem);
}
@media (max-width: 900px) { .pj-cols { grid-template-columns: 1fr; } }

.pj-step {
  display: grid; grid-template-columns: 3rem minmax(0, 1fr); gap: 0 1.25rem;
  padding-block: clamp(1.25rem, 2.5vw, 2rem);
  border-top: 1px solid var(--line);
}
.pj-story .pj-step:first-child { border-top: 0; padding-top: 0; }
.pj-step__n {
  font-family: var(--mono); font-size: var(--t-micro); letter-spacing: .14em;
  color: var(--accent); margin: .35rem 0 0;
}
.pj-step h2 {
  font-family: var(--sans); font-weight: 600; font-size: .95rem;
  letter-spacing: -.005em; margin: 0 0 .5rem; color: var(--text);
}
.pj-step p {
  margin: 0; color: var(--muted);
  font-size: clamp(1rem, 1.2vw, 1.08rem); line-height: 1.7; max-width: 62ch;
}
@media (max-width: 560px) {
  .pj-step { grid-template-columns: 1fr; }
  .pj-step__n { margin-bottom: .4rem; }
}

/* sidebar */
.pj-side { position: sticky; top: 4.5rem; display: grid; gap: 1.5rem; }
@media (max-width: 900px) { .pj-side { position: static; } }

.pj-facts { display: grid; border-top: 1px solid var(--line); }
.pj-facts > div {
  display: flex; justify-content: space-between; gap: 1rem;
  padding-block: .7rem; border-bottom: 1px solid var(--line);
}
.pj-facts dt {
  font-family: var(--mono); font-size: var(--t-micro); letter-spacing: .14em;
  text-transform: uppercase; color: var(--faint);
}
.pj-facts dd { margin: 0; font-size: var(--t-small); text-align: right; }
.pj-facts a { color: var(--accent); }

.pj-sec h3 {
  font-family: var(--mono); font-size: var(--t-micro); letter-spacing: .16em;
  text-transform: uppercase; color: var(--faint); font-weight: 400;
  margin: 0 0 .7rem;
}
a.chip { transition: border-color .18s var(--ease), color .18s var(--ease); }
a.chip:hover { border-color: var(--accent); color: var(--accent); }

.pj-points { list-style: none; margin: 0; padding: 0; }
.pj-points li {
  position: relative; padding-left: 1rem; padding-block: .35rem;
  font-size: var(--t-small); color: var(--muted);
}
.pj-points li::before {
  content: ""; position: absolute; left: 0; top: .95em;
  width: 5px; height: 1px; background: var(--accent);
}

/* live embed */
.pj-embed {
  border: 1px solid var(--line); border-radius: 6px; overflow: hidden;
  margin-top: clamp(1.75rem, 3.5vw, 2.5rem);
}
.pj-embed__bar {
  display: flex; justify-content: space-between; gap: 1rem;
  padding: .55rem .85rem; border-bottom: 1px solid var(--line);
  background: var(--bg-2); font-family: var(--mono);
  font-size: var(--t-micro); letter-spacing: .1em; color: var(--faint);
}
.pj-embed__bar a:hover { color: var(--accent); }
.pj-embed iframe { width: 100%; height: min(70vh, 660px); border: 0; display: block; background: #000; }

.note {
  border-left: 2px solid var(--accent); background: var(--accent-bg);
  padding: .8rem 1rem; font-size: var(--t-small); color: var(--muted);
  margin-top: 1.25rem; border-radius: 0 4px 4px 0;
}

.nextup { border-top: 1px solid var(--line); padding-block: 2rem; }
.nextup a { display: flex; align-items: baseline; justify-content: space-between; gap: 1rem; }
.nextup b { font-family: var(--display); font-weight: 600; font-size: var(--t-item); letter-spacing: -.015em; }
.nextup a:hover b { color: var(--accent); }

/* ---------- decoder text ----------
   The real text holds the box and the animated layer sits exactly on top of
   it, which is what stops a headline this large from reflowing while it
   resolves. `.decode` is added by script, so with JS off none of this applies
   and the headline is simply itself. */
.decode { position: relative; display: inline-block; }
/* While the overlay exists the real text goes transparent rather than being
   covered, so the hero canvas still shows through the headline instead of
   being masked by an opaque rectangle for the length of the animation. */
.decode--running { color: transparent; }
.decode > .decode__layer {
  position: absolute; inset: 0;
  /* Inherits every type property from the headline, so the two layers cannot
     disagree about metrics. Colour is taken back explicitly, because the
     element it inherits from is transparent while this layer is up. */
  font: inherit; letter-spacing: inherit;
  color: var(--text);
  white-space: pre-wrap;
}

/* ---------- resume ----------
   Denser than the rest of the site on purpose. A resume is read in about
   twenty seconds by someone with forty of them open, so it is set tighter, in
   one column, with the facts doing the work instead of the whitespace. */
.cv .wrap { max-width: 52rem; }

.cv-head {
  display: flex; flex-wrap: wrap; gap: 1.5rem 2.5rem;
  align-items: flex-start; justify-content: space-between;
  padding-bottom: 1.5rem; border-bottom: 1px solid var(--line-firm);
}
.cv-head h1 {
  font-family: var(--display); font-weight: 700;
  font-size: var(--step-4); line-height: 1; letter-spacing: var(--tr-display);
  margin: 0;
}
.cv-head__role { margin: .4rem 0 0; color: var(--muted); font-size: var(--step-0); }
.cv-head__contact { list-style: none; margin: 0; padding: 0; text-align: right; }
.cv-head__contact li { font-size: var(--t-small); color: var(--muted); line-height: 1.7; }
.cv-head__contact a { color: var(--muted); }
.cv-head__contact a:hover { color: var(--accent); }

.cv-summary {
  margin: 1.75rem 0 0; font-size: var(--step-0);
  line-height: var(--lh-body); color: var(--text); max-width: 62ch;
}

.cv-sec { margin-top: 2.5rem; }
.cv-sec > h2 {
  font-family: var(--mono); font-size: var(--t-micro);
  letter-spacing: var(--tr-caps); text-transform: uppercase;
  color: var(--faint); font-weight: 400; margin: 0 0 1rem;
  padding-bottom: .5rem; border-bottom: 1px solid var(--line);
}

.cv-numbers {
  list-style: none; margin: 0; padding: 0;
  display: grid; grid-template-columns: repeat(auto-fit, minmax(7rem, 1fr)); gap: 1rem;
}
.cv-numbers b {
  display: block; font-family: var(--display); font-weight: 600;
  font-size: var(--step-2); line-height: 1; letter-spacing: var(--tr-heading);
}
.cv-numbers span {
  display: block; margin-top: .3rem;
  font-family: var(--mono); font-size: var(--t-micro);
  letter-spacing: .08em; text-transform: uppercase; color: var(--faint);
}

.cv-item { margin-bottom: 1.35rem; }
.cv-item__head { display: flex; flex-wrap: wrap; gap: .3rem .75rem; align-items: baseline; }
.cv-item__head h3 { margin: 0; font-size: var(--step-0); font-weight: 600; letter-spacing: -.012em; }
.cv-item__meta { font-family: var(--mono); font-size: var(--t-micro); color: var(--faint); }
.cv-item__meta a { color: var(--faint); }
.cv-item p { margin: .3rem 0 0; font-size: var(--t-small); color: var(--muted); line-height: 1.55; max-width: 72ch; }
.cv-item__facts { font-family: var(--mono); font-size: var(--t-micro); color: var(--faint) !important; }

.cv-stack { margin: 0 0 .6rem; font-size: var(--t-small); color: var(--muted); line-height: 1.5; }
.cv-stack b {
  display: inline-block; min-width: 11rem; font-weight: 400;
  font-family: var(--mono); font-size: var(--t-micro);
  letter-spacing: .08em; text-transform: uppercase; color: var(--faint);
}
.cv-note { margin: 1rem 0 0; font-size: var(--t-micro); color: var(--faint); }

/* ---------- resume, printed ----------
   The screen version is dark. Paper is not, and printing a dark page either
   wastes a cartridge or comes out as light grey on white because browsers drop
   backgrounds by default. So print gets its own palette: black on white, the
   chrome removed, and nothing that only made sense on a screen. */
@media print {
  @page { margin: 14mm 14mm 12mm; }

  :root {
    --bg: #fff; --bg-2: #fff; --bg-3: #fff;
    --text: #000; --muted: #333; --faint: #555;
    --line: #ccc; --line-firm: #888; --accent: #000;
  }
  html, body { background: #fff !important; color: #000 !important; }

  /* Everything that is navigation, decoration or an invitation to click. */
  .hdr, .ftr, .rail, .hstack, .hero, .peek, .progress, .sheet, .pal-back,
  .skip, #hero-canvas, .cv-print { display: none !important; }

  .sec { padding: 0 !important; border: 0 !important; }
  .cv .wrap { max-width: none; padding: 0; }

  /* A link's text is useless on paper, so the address is printed after it.
     Internal anchors are skipped: "#projects" on a sheet of paper is noise. */
  .cv a[href^="http"]::after,
  .cv a[href^="mailto"]::after {
    content: " (" attr(href) ")";
    font-size: .85em; color: #555; word-break: break-all;
  }
  .cv-head__contact a::after { content: none; }

  .cv-item, .cv-sec { break-inside: avoid; }
  .cv-sec { margin-top: 1.1rem; }
  .cv-summary { margin-top: 1rem; }
}

/* ---------- status board ----------
   A row per project: a state dot, the identity, a latency bar and the number.
   The bar is the point. Twenty-four numbers in a column is a table nobody
   reads; twenty-four bars is a distribution you can see at a glance, and the
   outlier is obvious without anyone having to sort by it. */
.st-summary {
  display: grid; grid-template-columns: repeat(3, 1fr);
  border: 1px solid var(--line); border-radius: var(--radius);
  background: var(--bg-2); margin: 2rem 0 2.5rem; overflow: hidden;
}
.st-summary .stat { padding: 1.1rem 1.25rem; border-left: 1px solid var(--line); }
.st-summary .stat:first-child { border-left: 0; }

.st-list { border-top: 1px solid var(--line); }
.st-row {
  display: grid; align-items: center;
  grid-template-columns: 10px minmax(0, 15rem) minmax(0, 1fr) 5.5rem;
  gap: 1rem;
  padding: .7rem 0;
  border-bottom: 1px solid var(--line);
}

.st-dot {
  width: 7px; height: 7px; border-radius: 50%;
  background: #3ecf7b; flex: none;
  box-shadow: 0 0 0 3px color-mix(in srgb, #3ecf7b 16%, transparent);
}
.st-dot--down { background: #ff6b5e; box-shadow: 0 0 0 3px color-mix(in srgb, #ff6b5e 18%, transparent); }

.st-id { display: flex; flex-direction: column; gap: .1rem; min-width: 0; }
.st-id a { font-size: var(--t-small); color: var(--text); }
.st-id a:hover { color: var(--accent); }
.st-host {
  font-family: var(--mono); font-size: var(--t-micro); color: var(--faint) !important;
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.st-host:hover { color: var(--accent) !important; }

.st-bar { height: 6px; background: var(--bg-3); border-radius: 999px; overflow: hidden; }
.st-bar i {
  display: block; height: 100%; border-radius: inherit;
  background: linear-gradient(90deg, var(--accent), color-mix(in srgb, var(--accent) 45%, #7c5cff));
  transition: width .5s var(--ease);
}
.st-row[data-down] .st-bar i { background: #ff6b5e; }

.st-ms {
  font-family: var(--mono); font-size: var(--t-micro);
  color: var(--muted); text-align: right; white-space: nowrap;
}

@media (max-width: 720px) {
  .st-summary { grid-template-columns: 1fr 1fr; }
  .st-summary .stat:nth-child(3) { grid-column: 1 / -1; border-left: 0; border-top: 1px solid var(--line); }
  /* The bar drops to its own line rather than being squeezed into a column
     too narrow to read a distribution from. */
  .st-row {
    grid-template-columns: 10px minmax(0, 1fr) auto;
    grid-template-areas: "dot id ms" ". bar bar";
    row-gap: .5rem;
  }
  .st-dot { grid-area: dot; }
  .st-id  { grid-area: id; }
  .st-ms  { grid-area: ms; }
  .st-bar { grid-area: bar; }
}

/* ---------- design system page ----------
   Specimen sheets want more air than reading pages and less decoration. Every
   rule here is layout only: the specimens must render in the site's own
   components, or the page is not showing what it claims to show. */
.ds-intro {
  max-width: var(--readw); color: var(--muted);
  line-height: var(--lh-body); margin: 0 0 1rem;
}
.ds-intro code, .ds-table code, .ds-step__meta code {
  font-family: var(--mono); font-size: .85em;
}

.ds-h {
  font-size: var(--t-item); letter-spacing: var(--tr-heading);
  margin: 4.5rem 0 .5rem; padding-top: 1.75rem;
  border-top: 1px solid var(--line);
}
.ds-note {
  color: var(--faint); font-size: var(--t-small);
  max-width: 62ch; margin: 0 0 2rem; line-height: var(--lh-body);
}

/* type scale */
.ds-scale { display: grid; gap: 2rem; }
.ds-step__spec {
  line-height: var(--lh-heading); letter-spacing: var(--tr-heading);
  /* Clipped rather than wrapped: a specimen that reflows stops being a
     comparison between sizes. */
  white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.ds-step__meta {
  display: flex; flex-wrap: wrap; gap: .5rem .9rem; align-items: baseline;
  margin-top: .35rem; font-size: var(--t-micro); color: var(--faint);
}
.ds-step__meta code {
  border: 1px solid var(--line); border-radius: 4px;
  padding: .1rem .35rem; color: var(--muted);
}
.ds-role { color: var(--accent) !important; border-color: var(--line-firm) !important; }

/* colour */
.ds-colours {
  display: grid; gap: 1.5rem 1.25rem;
  grid-template-columns: repeat(auto-fill, minmax(170px, 1fr));
}
.ds-colour code { font-family: var(--mono); font-size: var(--t-micro); display: block; margin-top: .5rem; }
.ds-colour__pair { display: flex; border: 1px solid var(--line); border-radius: 6px; overflow: hidden; }
.ds-colour__chip { flex: 1; height: 56px; }
.ds-colour__hex {
  display: flex; justify-content: space-between; gap: .5rem;
  font-size: var(--t-micro); color: var(--faint); font-family: var(--mono);
}
.ds-colour__hex i { font-style: normal; opacity: .75; }

/* components */
.ds-comps { display: grid; gap: 1.25rem; }
.ds-comp__stage {
  border: 1px solid var(--line); border-radius: 8px;
  padding: 1.5rem; margin-top: .6rem; background: var(--bg-2);
  display: flex; flex-wrap: wrap; gap: .75rem; align-items: center;
}
.ds-comp__stage > .rows, .ds-comp__stage > .stats { flex: 1 1 100%; }

/* the remaining tokens */
.ds-group { margin-bottom: 2.5rem; }
.ds-table { width: 100%; border-collapse: collapse; font-size: var(--t-small); }
.ds-table th, .ds-table td {
  text-align: left; font-weight: 400; padding: .55rem 0;
  border-bottom: 1px solid var(--line); vertical-align: top;
}
.ds-table td { color: var(--muted); font-family: var(--mono); font-size: .9em; }
.ds-table th { width: 40%; }

@media (max-width: 640px) {
  .ds-table th { width: 50%; }
  .ds-colours { grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); }
}

/* ---------- motion ---------- */

.fade { opacity: 0; transform: translateY(10px); }
.fade.in { opacity: 1; transform: none; transition: opacity .55s var(--ease), transform .55s var(--ease); }

@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after { animation-duration: .01ms !important; transition-duration: .01ms !important; }
  .fade { opacity: 1; transform: none; }
  html { scroll-behavior: auto; }
}

/* ============================================================
   Hero canvas, scroll progress, and card depth
   ============================================================ */

.hero { position: relative; overflow: hidden; }
#hero-canvas {
  position: absolute; inset: 0; width: 100%; height: 100%;
  z-index: 0; pointer-events: auto;
  /* Fades out toward the copy so the type always wins. */
  mask-image: linear-gradient(100deg, transparent 0%, #000 42%, #000 100%);
  -webkit-mask-image: linear-gradient(100deg, transparent 0%, #000 42%, #000 100%);
  opacity: .85;
}
.hero .wrap { position: relative; z-index: 1; pointer-events: none; }
.hero .wrap a, .hero .wrap button { pointer-events: auto; }

/* reading progress */
.progress {
  position: fixed; top: 0; left: 0; height: 2px; width: 100%;
  transform: scaleX(0); transform-origin: left;
  background: var(--accent); z-index: 300;
  will-change: transform;
}

/* counters hold their space so nothing reflows as they tick up */
.stat b { font-variant-numeric: tabular-nums; }

/* project cards get real depth on hover without a drop shadow */
.feat__shot { position: relative; }
.feat__shot::after {
  content: ""; position: absolute; inset: 0;
  box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--text) 8%, transparent);
  pointer-events: none;
}
.feat { transition: transform .5s var(--ease), border-color .3s var(--ease), background .3s var(--ease); }
.feat:hover { transform: translateY(-2px); }

/* the art covers are wide and dark; keep the crop centred on their subject */
.feat__shot img.art, .pj-embed img.art { object-position: center; }

@media (prefers-reduced-motion: reduce) {
  #hero-canvas { display: none; }
  .feat:hover { transform: none; }
}

/* ============================================================
   Avatar that turns toward the pointer
   ============================================================
   Eight directions, but continuous rather than eight sprites:
   one real photograph on a 3D-tilted plane, so it leans north,
   north-east, east and so on as the cursor moves around it.
   ============================================================ */

.av {
  width: 62px; height: 62px; flex: none;
  perspective: 320px;
  position: relative;
}
.av__plate {
  width: 100%; height: 100%;
  border-radius: 50%;
  overflow: hidden;
  border: 1px solid var(--line);
  background: var(--bg-2);
  transform-style: preserve-3d;
  transition: transform .35s var(--ease);
  will-change: transform;
}
.av__plate img { width: 100%; height: 100%; object-fit: cover; object-position: 50% 38%; }

/* The lit edge slides around the rim, so the direction reads even
   at 62px where the tilt alone is nearly invisible. */
.av__glow {
  position: absolute; inset: -2px;
  border-radius: 50%;
  pointer-events: none;
  background: radial-gradient(
    circle at var(--gx, 50%) var(--gy, 50%),
    color-mix(in srgb, var(--accent) 55%, transparent) 0%,
    transparent 58%);
  opacity: 0;
  transition: opacity .35s var(--ease);
}
.av.is-tracking .av__glow { opacity: 1; }

.av__ring {
  position: absolute; inset: -5px;
  border-radius: 50%;
  border: 1px solid color-mix(in srgb, var(--accent) 30%, transparent);
  opacity: 0;
  transform: scale(.9);
  transition: opacity .4s var(--ease), transform .4s var(--ease);
  pointer-events: none;
}
.av.is-tracking .av__ring { opacity: 1; transform: scale(1); }

.art-meta .av, .art-meta .av__plate { width: 30px; height: 30px; }

@media (prefers-reduced-motion: reduce) {
  .av__plate { transform: none !important; transition: none; }
  .av__glow, .av__ring { display: none; }
}

/* ============================================================
   Blur-up image loading
   ============================================================
   The box already carries a ~20px version of the real picture as
   its background, so there is never an empty rectangle. The full
   image fades in over it once decoded.
   ============================================================ */

.feat__shot, .pj-embed {
  background-size: cover;
  background-position: top center;
  background-repeat: no-repeat;
}
/* Hidden only once scripting has confirmed it can reveal them again, so a
   no-JS visitor sees every image rather than a page of blurred squares. */
.js img[data-fade] {
  opacity: 0;
  transition: opacity .5s var(--ease);
}
.js img[data-fade].is-loaded { opacity: 1; }

@media (prefers-reduced-motion: reduce) {
  img[data-fade] { transition: none; }
}

/* ============================================================
   Cross-document view transitions
   ============================================================
   Supported browsers cross-fade between pages instead of the
   white flash. Everywhere else this block is simply ignored.
   ============================================================ */

@view-transition { navigation: auto; }

::view-transition-old(root) { animation: vt-out .18s ease both; }
::view-transition-new(root) { animation: vt-in .26s ease both; }
@keyframes vt-out { to { opacity: 0 } }
@keyframes vt-in { from { opacity: 0 } }

@media (prefers-reduced-motion: reduce) {
  ::view-transition-old(root), ::view-transition-new(root) { animation: none; }
}

/* ============================================================
   Cursor-follow preview on the project index
   ============================================================ */

.peek {
  position: fixed; top: 0; left: 0;
  width: min(20vw, 268px); aspect-ratio: 16 / 10;
  z-index: 60; pointer-events: none;
  overflow: hidden; border-radius: 4px;
  border: 1px solid var(--line-firm);
  background: var(--bg-2) center/cover no-repeat;
  opacity: 0;
  transform: translate(-50%, -50%) scale(.94);
  transition: opacity .28s var(--ease), transform .36s var(--ease);
  box-shadow: 0 12px 40px color-mix(in srgb, #000 22%, transparent);
}
.peek.is-on { opacity: 1; transform: translate(-50%, -50%) scale(1); }
.peek img { width: 100%; height: 100%; object-fit: cover; object-position: top center; }

/* Only worth it where there is a real pointer and room to show it. */
@media (max-width: 1100px), (pointer: coarse) { .peek { display: none; } }
@media (prefers-reduced-motion: reduce) { .peek { display: none; } }

/* keyboard focus on a row reads the same as hover */
.row:focus-within { background: var(--bg-2); }
.row__link:focus-visible { outline-offset: -2px; }

/* ============================================================
   Stack page
   ============================================================ */

.tech-group { margin-bottom: clamp(2rem, 4vw, 3rem); }
.tech-group h2 {
  display: flex; align-items: baseline; gap: .6rem;
  margin: 0 0 .25rem; padding-bottom: .6rem;
  border-bottom: 1px solid var(--line-firm);
  color: var(--text); font-weight: 400;
}
.tech-group h2 span { color: var(--faint); }

.tech {
  display: grid;
  grid-template-columns: 11rem minmax(0, 1fr) 2.5rem;
  gap: .5rem 1.5rem; align-items: baseline;
  padding-block: .75rem;
  border-bottom: 1px solid var(--line);
  scroll-margin-top: 5rem;
}
.tech:target { background: var(--accent-bg); }
.tech__name { font-weight: 500; font-size: .95rem; }
.tech__uses { display: flex; flex-wrap: wrap; gap: .3rem .75rem; }
.tech__uses a {
  font-size: var(--t-small); color: var(--muted);
  border-bottom: 1px solid transparent;
}
.tech__uses a:hover { color: var(--accent); border-bottom-color: currentColor; }
.tech__n {
  font-family: var(--mono); font-size: var(--t-micro);
  color: var(--faint); text-align: right; font-variant-numeric: tabular-nums;
}

@media (max-width: 700px) {
  .tech { grid-template-columns: 1fr 2.5rem; }
  .tech__uses { grid-column: 1 / -1; }
}

/* The narrative column already ends on generous padding; the related section
   does not need its own full top padding on top of that. */
.pj-cols + .sec { padding-top: clamp(1rem, 2vw, 1.75rem); }

/* ============================================================
   Marquee
   ============================================================
   One continuous band of the stack, moving. Duplicated once so
   the loop is seamless, paused on hover, and stopped entirely
   for anyone who asked for reduced motion.
   ============================================================ */

.marquee {
  --speed: 48s;
  display: flex;
  overflow: hidden;
  user-select: none;
  border-block: 1px solid var(--line);
  background: var(--bg-2);
  padding-block: clamp(.9rem, 1.8vw, 1.4rem);
}
.marquee__track {
  display: flex;
  flex: none;
  min-width: 100%;
  animation: marquee-slide var(--speed) linear infinite;
}
.marquee:hover .marquee__track { animation-play-state: paused; }
@keyframes marquee-slide { to { transform: translateX(-100%); } }

.marquee__item {
  display: inline-flex;
  align-items: center;
  gap: clamp(1.1rem, 2.2vw, 2.2rem);
  padding-inline: clamp(.55rem, 1.1vw, 1.1rem);
  white-space: nowrap;
  font-family: var(--display);
  font-weight: 400;
  font-size: clamp(1.35rem, 2.6vw, 2.1rem);
  line-height: 1.2;
  letter-spacing: -.02em;
  color: var(--text);
}
.marquee__item::after {
  content: "";
  flex: none;
  width: 5px; height: 5px;
  border-radius: 50%;
  background: var(--accent);
  opacity: .7;
}

/* The quieter second band. */
.marquee--mono { background: var(--bg); --speed: 62s; padding-block: .8rem; }
.marquee--mono .marquee__track { animation-direction: reverse; }
.marquee--mono .marquee__item {
  font-family: var(--mono);
  font-size: var(--t-micro);
  letter-spacing: .2em;
  text-transform: uppercase;
  color: var(--faint);
  gap: clamp(.9rem, 1.8vw, 1.6rem);
}
.marquee--mono .marquee__item::after { width: 3px; height: 3px; }

@media (prefers-reduced-motion: reduce) {
  .marquee__track { animation: none; }
  .marquee { overflow-x: auto; }
}

/* ============================================================
   Container queries
   ============================================================
   Project cards appear in three places at three different widths:
   the home grid, the related row on a case study, and the 404.
   Sizing them to the viewport is the wrong question. Sizing them
   to the space they were actually given is the right one.
   ============================================================ */


/* ---------- the lead project ----------
   One card promoted out of the grid. Everything it changes is scale: a taller
   image, a name at section size, and room for the stack. It shares every other
   rule with the grid cards, so a change to `.feat` reaches both. */
.feat--lead { margin-bottom: clamp(2rem, 4vw, 3.25rem); }
.feat--lead { padding: 1.1rem 1.1rem 1.4rem; }
.feat--lead .feat__shot { aspect-ratio: 16 / 9; margin-bottom: 1.25rem; }
.feat--lead .feat__name { font-size: var(--step-3); letter-spacing: var(--tr-heading); }
.feat--lead .feat__desc { font-size: var(--step-0); max-width: 60ch; }
.feat__tags { display: flex; flex-wrap: wrap; gap: .4rem; margin-top: .9rem; }

.feat-grid { container-type: inline-size; }
.feat { container-type: inline-size; }

@container (max-width: 260px) {
  .feat__name { font-size: var(--step-0); }
  .feat__desc { font-size: var(--t-micro); }
  .feat__meta { flex-direction: column; align-items: flex-start; gap: .35rem; }
}

/* ============================================================
   Scroll-driven reveal
   ============================================================
   Where `animation-timeline` is supported this runs entirely on
   the compositor with no JavaScript and no observer. The existing
   IntersectionObserver stays as the fallback, so both paths end
   at the same visible state.
   ============================================================ */

@supports (animation-timeline: view()) {
  @media (prefers-reduced-motion: no-preference) {
    .fade:not(.in) {
      animation: fade-rise linear both;
      animation-timeline: view();
      animation-range: entry 5% cover 22%;
    }
    @keyframes fade-rise {
      from { opacity: 0; transform: translateY(14px); }
      to   { opacity: 1; transform: none; }
    }
  }
}

/* ============================================================
   Selection, focus and the small stuff
   ============================================================ */

::selection { background: var(--accent); color: #fff; text-shadow: none; }

/* A focus ring that follows the shape of what it is wrapping. */
:focus-visible {
  outline: 2px solid var(--accent);
  outline-offset: 3px;
  border-radius: 3px;
}
.row:focus-within, .feat:focus-within { background: var(--bg-2); }

/* Anchor jumps should not land under the sticky header. */
[id] { scroll-margin-top: 5.5rem; }

/* Fine hairlines stay hairlines on high-density screens. */
@media (min-resolution: 2dppx) {
  :root { --line: color-mix(in srgb, var(--text) 11%, transparent); }
}

/* Aggregated headlines. Quieter than owned content, and marked as external. */
.feed-note {
  margin: 1.25rem 0 0;
  font-size: var(--t-small);
  color: var(--faint);
  max-width: 58ch;
}
#reading .row__when { color: var(--accent); opacity: .85; }

/* ============================================================
   Runtime banner
   ============================================================
   Injected by the control surface, so it must not shift the
   sticky header or cover content when it appears.
   ============================================================ */

.banner {
  display: block;
  padding: .6rem var(--pad);
  font-size: var(--t-small);
  text-align: center;
  background: var(--accent-bg);
  color: var(--text);
  border-bottom: 1px solid var(--line);
  text-decoration: none;
}
a.banner:hover { background: color-mix(in srgb, var(--accent) 18%, transparent); }
.banner--warn { background: color-mix(in srgb, #d97706 16%, transparent); }

/* The header is sticky at top:0; with a banner above it the anchor offset
   needs to grow or headings land underneath. */
.has-banner [id] { scroll-margin-top: 8rem; }

/* Not currently taking work: the dot stops advertising that you are. */
.pulse--closed { background: var(--faint); animation: none; }

/* Counted facts in the case-study sidebar. Quiet, but the first thing a
   scanning reader can verify without taking prose on trust. */
.pj-scope { list-style: none; margin: 0; padding: 0; }
.pj-scope li {
  padding-block: .3rem;
  font-size: var(--t-small);
  color: var(--muted);
  border-top: 1px solid var(--line);
}
.pj-scope li:first-child { border-top: 0; }
.pj-scope b { color: var(--text); font-weight: 500; font-variant-numeric: tabular-nums; }
.pj-scope__yes { color: var(--text); }
.pj-scope__yes::before { content: "✓ "; color: var(--accent); }
