/* ==========================================================================
   lidiaterecia.com
   Every value comes from tokens.css.

   Layout note: the left rail is persistent; the content sits in a single
   centred column (--column). Headline, prose, rules and images all share
   that measure so the page has one consistent left AND right edge.
   ========================================================================== */

@import url("tokens.css?v=b3a4602f");

/* ----------------------------------------------------------------- Base */

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

html {
  -webkit-text-size-adjust: 100%;
}

body {
  margin: 0;
  background: var(--bg);
  color: var(--text);
  font-family: var(--font-body);
  font-size: var(--step-0);
  /* Fraunces has a wide optical-size axis; auto lets the browser pick the
     right cut for whatever size an element ends up at, so small UI text keeps
     sturdy strokes while large type gets the fine ones. Note that this only
     holds where a rule does not set opsz itself in font-variation-settings —
     the hero trio does exactly that, deliberately. */
  font-optical-sizing: auto;
  /* Rounded terminals everywhere. WONK stays off for prose: the alternates
     that give the greeting its character become noise over a paragraph. */
  font-variation-settings: "SOFT" 100, "WONK" 0;
  line-height: var(--leading-body, 1.6);
  -webkit-font-smoothing: antialiased;
  -moz-osx-font-smoothing: grayscale;
}

/* Headings get the wonk back. One rule rather than nine edits, and it sits
   above them all so any rule that needs to opt out still can. */
.rail__name,
.work__name,
.section-title,
.page-title,
.decision__title,
.next__link,
.prose h2,
.prose-h2 {
  font-variation-settings: "SOFT" 100, "WONK" 1;
}

h1,
h2,
h3,
p,
figure,
ul,
ol,
dl,
dd {
  margin: 0;
}

ul,
ol {
  list-style: none;
  padding: 0;
}

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

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

::selection {
  background: var(--accent-soft);
}

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

.icon {
  width: 16px;
  height: 16px;
  flex: 0 0 auto;
  stroke-width: 1.7;
}

.mono {
  font-family: var(--font-mono);
  font-variant-numeric: tabular-nums;
}

.skip {
  position: absolute;
  left: -9999px;
  top: 0;
  z-index: 100;
  background: var(--text);
  color: var(--bg);
  padding: var(--space-3) var(--space-4);
}

.skip:focus {
  left: 0;
}

/* ---------------------------------------------------------------- Rail */

.rail {
  position: fixed;
  inset-block: 0;
  left: 0;
  width: var(--rail);
  background: var(--bg);
  border-right: var(--hairline) solid var(--rule);
  padding: var(--space-5) var(--space-4) var(--space-4);
  display: flex;
  flex-direction: column;
  z-index: 40;
  /* One inner measure, shared by the brand, every nav group and the footer. */
  --rail-col: 11rem;
}

/* The block is centred; the text inside it is not. Giving all three parts the
   same width is what does the work — centre each one independently and they
   land on three different left edges, which is the ragged look this replaces.
   One shared measure means one shared left edge, sitting in the middle of the
   rail with equal air either side. */
.rail__brand,
.rail__group,
.rail__foot {
  width: var(--rail-col);
  max-width: 100%;
  margin-inline: auto;
}

/* Two rules in the rail, not four and not none.

   Four was the original: one under the brand and one running out of each of
   the three group labels. In a 248px column that chopped a short list into
   four boxed fragments.

   Zero was the overcorrection. With nothing but space, the navigation floated
   — no edge said where the identity stopped and the menu began, so the whole
   column read as one loose stack.

   Two brackets it. A rule under the brand and a rule above the footer, with
   the navigation held between them: identity, then the menu, then the
   controls. The group labels stay ruleless — their own spacing separates
   them, and a heading does not need a line to be read as one. */
.rail__brand {
  display: block;
  padding-bottom: var(--space-5);
  border-bottom: var(--hairline) solid var(--rule);
}

.rail__name {
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
  /* Centred type needs its tracking back. The negative fit was set for a
     left-aligned label, where the eye reads from a fixed edge; centred, the
     tight version reads as cramped rather than tight. */
  letter-spacing: 0;
  line-height: 1.15;
  color: var(--text);
}

/* Header and footer stay pinned; only the nav scrolls as projects are added.
   The mask fades the overflow edge, since the scrollbar is hidden. */
.rail__nav {
  flex: 1 1 auto;
  min-height: 0;
  overflow-y: auto;
  overscroll-behavior: contain;
  margin-top: 28px;
  scrollbar-width: none;
  --fade: 16px;
  /* Fades at the bottom edge only. It used to fade both ends, and the top one
     landed exactly on the word "Work" — the first thing in the list — so the
     label appeared to have a gradient washed through it and read as half
     switched-off. The top of this column never needs the hint anyway: it is
     pinned under the brand rule and there is nothing above it to scroll to.
     The bottom keeps its fade, which is where content can actually run on. */
  mask-image: linear-gradient(
    to bottom,
    #000 0,
    #000 calc(100% - var(--fade)),
    transparent 100%
  );
}

.rail__nav::-webkit-scrollbar {
  width: 5px;
}

.rail__nav::-webkit-scrollbar-thumb {
  background: transparent;
  border-radius: 3px;
}

.rail:hover .rail__nav::-webkit-scrollbar-thumb {
  background: var(--rule);
}

/* Rows read left to right, as a list should. The icon gutter keeps its fixed
   width so every label starts at the same x — that shared inner edge is what
   makes the group scan as a column rather than as four unrelated rows. */
/* 32px was set when every label in here was one short word. "Website & Mobile
   Design" wraps to two lines in an 11rem column, and at that height the pill
   closed on the text with nothing around it. The row now has real vertical
   padding, so a wrapped label sits inside the shape instead of filling it, and
   a one-line row still clears the 40px that a pointer target wants. */
.nav-row {
  display: grid;
  grid-template-columns: 22px 1fr;
  align-items: center;
  gap: 8px;
  min-height: 40px;
  padding: 7px 10px;
}

/* With the rules gone, this gap is the only thing separating one group from
   the next, so it has to be big enough to do that job alone. */
.rail__group + .rail__group {
  margin-top: var(--space-6);
}

.rail__group ul {
  display: grid;
  gap: 1px;
}

/* Signposts, not content. The rule that used to run out to the right of each
   word is gone — three of them in a narrow column read as three table borders,
   and the label already sits far enough below the group above it to be read as
   a heading.

   Three sizes carry the whole rail and they step cleanly: the name at
   --step-1, the links at 0.875rem, these labels at 0.6875rem. The label is the
   smallest thing here, so it takes the tracking — capitals need the air at
   that size — and the lightest colour, because a signpost that competes with
   what it points at is not a signpost. */
.rail__label {
  padding-inline: 8px;
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.12em;
  text-transform: uppercase;
  /* No opacity on top of the muted colour. --text-muted is already the quiet
     step; knocking a further quarter off it was what tipped these labels from
     quiet into unreadable, and stacked with the mask fade above it made the
     first one look like it was dissolving. */
  color: var(--text-muted);
  margin-bottom: 10px;
}

.rail__link {
  position: relative;
  color: var(--text);
  border-radius: 999px;
  transition: color var(--dur-fast) var(--ease),
    background var(--dur-fast) var(--ease);
}

.rail__gutter {
  display: flex;
  align-items: center;
  color: var(--text-muted);
  transition: color var(--dur-fast) var(--ease);
}

.rail__gutter .icon {
  width: 16px;
  height: 16px;
}

/* Small type needs a little tracking and a tight leading to stay crisp.
   The external-link arrow sits inline beside the label, not on its own row. */
.rail__text {
  display: inline-flex;
  align-items: center;
  gap: 5px;
  font-family: var(--font-body);
  font-size: 0.875rem;
  line-height: 1.35;
  letter-spacing: 0.005em;
  color: inherit;
}

/* Hover nudged the row 2px right, which only reads as intentional when rows
   share a left edge — on a centred row it looks like a wobble. A background
   tint carries the same feedback without implying a direction. */
.rail__link:hover {
  background: var(--surface);
}

.rail__link:hover .rail__gutter {
  color: var(--text);
}

/* Likewise the active marker: a 2px bar hung off the left edge belonged to a
   left-aligned list. A filled pill marks a centred row from all sides. */
.rail__link[aria-current="page"] {
  background: var(--nav-active-wash);
  color: var(--nav-active-ink);
  font-weight: 500;
}

.rail__link[aria-current="page"] .rail__gutter {
  color: var(--nav-active-ink);
}

.rail__out {
  width: 11px;
  height: 11px;
  flex: 0 0 auto;
  color: var(--text-muted);
  opacity: 0;
  transition: opacity var(--dur-fast) var(--ease);
}

.rail__link:hover .rail__out {
  opacity: 1;
}

/* The lower bracket. It was removed once for being a heavy boundary above one
   small control — true when it was the only rule left, but it is not on its
   own now: it closes the navigation the way the brand rule opens it, and a
   pair reads as structure where a single line read as a leftover. */
.rail__foot {
  flex: 0 0 auto;
  margin-top: var(--space-6);
  padding-top: var(--space-4);
  border-top: var(--hairline) solid var(--rule);
}

/* space-between held two controls apart; with the language pair gone there is
   only the theme toggle, so it sits on the column's left edge like everything
   else rather than drifting to the middle on its own. */
.rail__toggles {
  display: flex;
  align-items: center;
  justify-content: flex-start;
  gap: var(--space-3);
  padding-inline: 8px;
}

/* An enclosed track with a chip on the active side. The chip used to be two
   backgrounds switching on and off, which is what made it feel stiff — the
   state changed but nothing moved, so the two halves read as separate
   buttons. Now a single thumb slides between them, and the movement is what
   tells you this is one control with a position. */
.seg {
  position: relative;
  display: inline-flex;
  align-items: center;
  gap: 4px;
  padding: 4px;
  border-radius: 999px;
  background: var(--surface);
  border: var(--hairline) solid var(--rule);
  font-family: var(--font-mono);
  font-size: 0.625rem;
  line-height: 1;
}

/* The thumb. Driven off :root[data-theme] rather than a class on the control,
   because that attribute is already stamped by the anti-flash script in
   <head> — so on a dark-theme reload the chip is on the right from the first
   painted frame, with no visible jump into place. */
.seg::before {
  content: "";
  position: absolute;
  z-index: 0;
  /* These four must track .seg's padding (4px) and gap (4px) exactly. They
     were left at the old 3px/2px values when those went up, which put the
     chip a pixel high, a pixel left and a pixel wide — enough to read as
     "not centred" even though the buttons themselves were fine. */
  top: 4px;
  left: 4px;
  /* Padding box, less both paddings, less the gap, halved. */
  width: calc((100% - 12px) / 2);
  height: calc(100% - 8px);
  border-radius: 999px;
  background: var(--bg);
  box-shadow: 0 1px 3px rgba(0, 0, 0, 0.16);
  /* Overshoots slightly before settling — the chip arrives with a bit of
     weight behind it instead of stopping dead, which is the difference
     between a control that moves and one that feels mechanical. */
  transition: transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

:root[data-theme="dark"] .seg::before {
  transform: translateX(calc(100% + 4px));
}

.seg__opt {
  appearance: none;
  background: transparent;
  border: 0;
  border-radius: 999px;
  /* The icons were sitting in a 24px-tall slot, which cropped the sun's rays
     against the chip edge and made the whole control feel pinched. A square
     hit area with real breathing room around a 17px glyph fixes both. */
  width: 34px;
  height: 34px;
  padding: 0;
  cursor: pointer;
  color: var(--text-muted);
  font: inherit;
  letter-spacing: 0.04em;
  /* Above the sliding thumb, which is painted at z-index 0. */
  position: relative;
  z-index: 1;
  display: inline-flex;
  align-items: center;
  justify-content: center;
  transition: color var(--dur-fast) var(--ease);
}

.seg__opt .icon {
  width: 17px;
  height: 17px;
  overflow: visible;
  transition: transform 420ms cubic-bezier(0.34, 1.56, 0.64, 1),
    opacity var(--dur) var(--ease);
}

.seg__opt:hover {
  color: var(--text);
}

/* The inactive side answers the pointer on its own, so the control responds
   before you commit to the click. */
.seg__opt[aria-pressed="false"]:hover .icon {
  transform: scale(1.05) rotate(-12deg);
  opacity: 1;
}

/* The chip is the thumb now, so the active option only changes colour. The
   icon lifts a little as the thumb arrives under it — a quarter turn on the
   sun, which reads as the rays catching, and a plain settle on the moon. */
.seg__opt.is-on,
.seg__opt[aria-pressed="true"] {
  color: var(--text);
  background: transparent;
  box-shadow: none;
}

.seg__opt[data-set-theme="light"][aria-pressed="true"] .icon {
  transform: rotate(90deg) scale(1.06);
}

.seg__opt[data-set-theme="dark"][aria-pressed="true"] .icon {
  transform: scale(1.08);
}

.seg__opt[aria-pressed="false"] .icon {
  transform: scale(0.9);
  opacity: 0.75;
}

/* The separator is gone; the rule is kept out of the markup entirely. */
.seg__sep {
  display: none;
}

/* .rail__copy is gone from the markup; its rule went with it. */

/* --------------------------------------------------------------- Shell */

.shell {
  margin-left: var(--rail);
  min-height: 100vh;
}

.wrap {
  width: 100%;
  max-width: var(--column);
  margin-inline: auto;
  padding: var(--space-7) var(--space-5) var(--space-8);
}

/* -------------------------------------------------------------------- Hero */

/* The opening sits in the middle of the first screen rather than against the
   top edge. min() caps the height in rem as well as vh: on a tall monitor a
   pure vh value would strand the greeting in the middle of an empty field,
   and it would push the Work list clean off the fold — losing the cue that
   the page continues below. */

.hero {
  position: relative;
  display: flex;
  flex-direction: column;
  justify-content: center;
  min-height: min(66vh, 34rem);
}

/* Three soft colour fields that drift behind the greeting.

   Built from radial-gradients rather than blurred shapes: a filter: blur()
   on a real element forces the compositor to re-rasterise every frame, which
   is what makes this effect stutter on laptops. A gradient is painted once
   and only transformed, so it stays on the GPU.

   The inset bleeds well past the column so no edge of the field is ever
   visible — you see the glow, never the box producing it. */
.hero::before {
  content: "";
  position: absolute;
  inset: -30% -45%;
  pointer-events: none;
  background:
    radial-gradient(34% 42% at 24% 30%, var(--glow-1), transparent 68%),
    radial-gradient(30% 38% at 74% 34%, var(--glow-2), transparent 66%),
    radial-gradient(36% 40% at 52% 76%, var(--glow-3), transparent 70%);
  filter: blur(6px);
  animation: hero-aurora 26s var(--ease) infinite alternate;
  /* main.js writes --lean-x / --lean-y as the pointer moves. This uses the
     standalone `translate` property rather than a transform, because the
     animation above already owns transform — declaring it here would cancel
     the drift entirely. The two compose instead. */
  translate: var(--lean-x, 0) var(--lean-y, 0);
  transition: translate 600ms var(--ease);
}

/* The content sits above the field. Without this the ::before paints over
   the greeting, since both are children of the same stacking context and a
   pseudo-element that comes later in paint order wins ties. */
.hero > * {
  position: relative;
  z-index: 1;
}

@keyframes hero-aurora {
  0% {
    transform: translate3d(0, 0, 0) scale(1);
  }
  50% {
    transform: translate3d(2.5%, -2%, 0) scale(1.12);
  }
  100% {
    transform: translate3d(-2%, 2.5%, 0) scale(1.05);
  }
}

/* Motion off, colour kept. The field is part of the design, not decoration
   layered on top of it — removing it entirely would leave a different page. */
@media (prefers-reduced-motion: reduce) {
  .hero::before {
    animation: none;
  }
}

/* Phones: the fixed bottom bar already takes vertical space and the viewport
   is short, so centre less aggressively — enough to lift the greeting off the
   top edge, not enough to bury the work. */
@media (max-width: 768px) {
  .hero {
    min-height: 42vh;
  }
}

/* ------------------------------------------------------------ Identity */

.identity {
  display: flex;
  align-items: center;
  gap: var(--space-3);
  margin-bottom: var(--space-5);
}

.identity__photo {
  width: 48px;
  height: 48px;
  border-radius: var(--radius);
  object-fit: cover;
  background: var(--surface);
  flex: 0 0 auto;
}

.identity__name {
  font-family: var(--font-display);
  font-size: var(--step-0);
  font-weight: 600;
  letter-spacing: -0.01em;
}

.identity__role {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-top: 2px;
}

/* ---------------------------------------------------------- Positioning */

.eyebrow {
  font-size: var(--step-0);
  color: var(--text-muted);
  margin-bottom: var(--space-3);
}

/* The greeting is the one place the serif appears.

   The variation settings are what make it playful rather than formal:
     opsz 144  the display cut — thinner hairlines, more drama at this size
     SOFT 100  fully rounded terminals, so nothing ends in a sharp point
     WONK 1    swaps in the wonky alternates; a few letters lean off-axis

   Tracking sits near zero. The tight negative fit that suits a grotesque
   closes up a serif's sidebearings and jams the serifs into each other. */
.headline {
  font-family: var(--font-serif);
  font-size: var(--step-4);
  font-weight: 600;
  font-variation-settings: "opsz" 144, "SOFT" 100, "WONK" 1;
  line-height: 1.02;
  letter-spacing: -0.005em;
  text-wrap: balance;
  margin-bottom: var(--space-5);
}

/* The name, and the only place the accent appears in prose.

   It is set in the true italic rather than the upright. The underline bar
   that used to sit here is gone: it existed to mark a claim being made, and
   the headline is a greeting now — with the italic and the accent already
   carrying the emphasis, a third signal was one too many. */
.headline em {
  font-style: italic;
  color: var(--accent);
  white-space: nowrap;
  /* inline-block so it can be scaled at all, and an origin below centre so
     it swells upward off the baseline the way a balloon on a string would,
     rather than growing symmetrically through the line below it. */
  display: inline-block;
  transform-origin: 50% 62%;
  /* The recovery: springy, slight overshoot, as it refills. */
  scale: 1;
  transition: scale 520ms cubic-bezier(0.34, 1.56, 0.64, 1);
}

/* Slow and linear-ish — the tension of something filling up. */
.headline em.is-inflating {
  scale: 1.32;
  transition: scale 850ms cubic-bezier(0.33, 0, 0.67, 1);
}

/* The burst itself. 90ms is deliberately faster than the eye tracks, so the
   shards appear to be what tore it open rather than a separate effect. */
.headline em.is-popped {
  scale: 0.82;
  transition: scale 90ms linear;
}

@media (max-width: 30rem) {
  .headline em {
    white-space: normal;
  }
}

/* The greeting animates word by word. Each word is its own inline-block so it
   can be transformed independently; the space between them comes from the
   source, not from margins, so the line still wraps and reflows normally.

   The animation is hung off .reveal.is-in rather than firing on load, because
   the section itself starts at opacity 0 and fades in — starting both at once
   would run the word stagger behind an invisible parent. --delay is the value
   main.js already sets for the section's own stagger, so the words pick up
   where the section fade leaves off. */

.headline__word {
  display: inline-block;
  opacity: 0;
  transform: translateY(0.38em);
}

.js .reveal.is-in .headline__word {
  animation: headline-word var(--dur-slow) var(--ease) both;
  animation-delay: calc(var(--delay, 0ms) + var(--word, 0) * 90ms);
}

/* No JavaScript: nothing ever adds .is-in, so show the words outright. */
html:not(.js) .headline__word {
  opacity: 1;
  transform: none;
}

/* Each word lifts and tips under the pointer. Uses the standalone `translate`
   and `rotate` properties for the same reason as the aurora: the entrance
   animation owns transform, and it retains its final frame through
   animation-fill-mode: both — a hover transform here would simply lose. */
.headline__word {
  transition: translate var(--dur) var(--ease), rotate var(--dur) var(--ease),
    color var(--dur) var(--ease);
}

@media (hover: hover) and (pointer: fine) {
  .headline__word:hover {
    translate: 0 -6px;
    rotate: -2.5deg;
  }
}

@media (prefers-reduced-motion: reduce) {
  .headline__word:hover {
    translate: none;
    rotate: none;
  }
}

@keyframes headline-word {
  from {
    opacity: 0;
    transform: translateY(0.38em);
  }
  to {
    opacity: 1;
    transform: none;
  }
}

/* Matches the selector above rather than the bare class — a media query adds
   no specificity, so a plainer selector here would simply lose. */
@media (prefers-reduced-motion: reduce) {
  .headline__word,
  .js .reveal.is-in .headline__word {
    opacity: 1;
    transform: none;
    animation: none;
  }
}

/* A caption to the greeting now, not a second headline — so it steps down a
   size and picks up the same face in its true italic.

   opsz 24, not the headline's 144. Optical size is not a scale factor: the
   display cut thins the hairlines for large type, and reusing it down here
   would leave the thin strokes dropping out. The text cut thickens them back.

   Latin only. Fraunces carries no CJK glyphs, so the /zh/ line falls through
   to Noto Serif SC — and asking that for italic gets a synthesised slant,
   which is exactly the machine-translated look the site cannot afford. */
.support {
  font-family: var(--font-serif);
  font-style: italic;
  font-weight: 400;
  font-variation-settings: "opsz" 24, "SOFT" 100, "WONK" 1;
  color: var(--text-muted);
  max-width: 48ch;
  font-size: var(--step-0);
  line-height: 1.45;
}

html[lang^="zh"] .support {
  font-family: var(--font-num);
  font-style: normal;
  font-variation-settings: normal;
}

/* Now that this is two words rather than a sentence, it reads as a tag, not
   as prose — so it is set as one. The pill needs a border as well as the tint:
   --accent-soft is only 9% opaque, which all but disappears against the light
   background on its own.

   Sized to the text rather than to a fixed measure. The uppercase transform
   and the extra tracking that were here stretched it well past the width the
   words need — mono capitals are already wide, and 0.05em on top pushed every
   gap open. Lowercase, no tracking, and padding in em so the pill scales with
   the type instead of drifting from it. */
.status {
  display: inline-flex;
  align-items: center;
  /* .hero is a column flex container, so its default align-items: stretch
     pulls this to the full column width — inline-flex alone cannot shrink it
     back, because a flex item's own display is blockified. Opting this one
     item out of the stretch is what keeps the pill the width of its text. */
  align-self: flex-start;
  gap: 0.5em;
  margin-bottom: var(--space-3);
  padding: 0.32em 0.7em 0.32em 0.58em;
  border: var(--hairline) solid var(--status-line);
  border-radius: 999px;
  background: var(--status-wash);
  font-family: var(--font-serif);
  font-style: italic;
  font-weight: 400;
  /* opsz 14 — the smallest type on the page needs the sturdiest cut. */
  font-variation-settings: "opsz" 14, "SOFT" 100, "WONK" 1;
  font-size: var(--step--1);
  line-height: 1.35;
  color: var(--status-ink);
}

/* Same reason as .support: no CJK glyphs in Fraunces, and a synthesised
   slant on Noto Serif SC is the one thing this site cannot look like. */
html[lang^="zh"] .status {
  font-family: var(--font-num);
  font-style: normal;
  font-variation-settings: normal;
}

.status__dot {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: var(--status-dot);
  flex: 0 0 auto;
  position: relative;
}

/* A halo that expands and fades, not a blink. A dot that switches on and off
   pulls the eye back every cycle and never lets it settle; a soft expanding
   ring reads as "live" and then gets out of the way. */
.status__dot::after {
  content: "";
  position: absolute;
  inset: -3px;
  border-radius: 50%;
  background: var(--status-dot);
  animation: status-pulse 2.8s var(--ease) infinite;
}

@keyframes status-pulse {
  0% {
    transform: scale(0.5);
    opacity: 0.35;
  }
  70%,
  100% {
    transform: scale(1.6);
    opacity: 0;
  }
}

@media (prefers-reduced-motion: reduce) {
  .status__dot::after {
    animation: none;
    opacity: 0;
  }
}

/* ------------------------------------------------------------ Sections */

/* Sections are not boxed — the box belongs to each project inside Work, not
   around the parts of the page. The extra air stays, though: that much was
   right, and it is what separates one part from the next now that no line
   does it. */
.section {
  margin-top: var(--space-8);
}


.section__head {
  display: flex;
  align-items: baseline;
  justify-content: space-between;
  gap: var(--space-4);
  margin-bottom: var(--space-4);
}

.section__label {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  font-weight: 400;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
}

/* ---------------------------------------------------------- Work rows */

.work {
  border-top: var(--hairline) solid var(--rule);
}

.work__row {
  border-bottom: var(--hairline) solid var(--rule);
}

/* One hover effect, executed precisely: background shift, arrow 4px,
   thumbnail fades in. No lift, no shadow, no scale. */
.work__link {
  display: grid;
  grid-template-columns: 2.4rem 1fr auto;
  align-items: start;
  gap: var(--space-2) var(--space-4);
  padding: var(--space-5) var(--space-3);
  transition: background var(--dur-fast) var(--ease);
}

.work__link:hover {
  background: var(--hover);
}

.work__framing {
  margin-top: var(--space-2);
  color: var(--text-muted);
  font-size: var(--step--1);
  max-width: 52ch;
}

/* The arrow is pinned to the bottom-right corner of the card, under the
   window. In the reference the card has one row beneath the screenshot —
   name, tag, then the arrow at its right end — so that row is also the last
   thing in the card and the arrow lands in the corner. This card carries a
   framing line under the name as well, so the same arrow at the end of the
   name row sits a line short of the corner. Taking it out of that row and
   pinning it puts it where the reference has it, whatever the framing line
   does underneath.

   Absolute against .work__link, which means it has to be a child of the card
   rather than of the name row — an ancestor with position: relative captures
   it, and the name row had one. Right on --frame-inset, which is the
   screenshot's right edge, and flush with the bottom of the text.

   Third drawing attempt, and the reason the first two were never going to
   work. It was the character ↗, which Fraunces does not carry, so it fell
   through to a system face at the wrong weight and the wrong height. Then it
   was the shared sprite — and #i-arrow-out sets stroke-width="1.5" as a
   presentation attribute on its own <g>, which no rule matching the parent
   <svg> can override. It has been drawing thin at 16px the whole time.

   So the paths are inline in the card now. Nothing between this rule and the
   strokes: the weight, the size and the colour are all set here, and the box
   is large enough that the mark inside it is unmistakable. */
.work__arrow {
  position: absolute;
  right: var(--frame-inset);
  bottom: 0;
  display: grid;
  place-items: center;
  width: 34px;
  height: 34px;
  border-radius: 10px;
  color: color-mix(in srgb, var(--project) 30%, var(--text));
  box-shadow: inset 0 0 0 var(--hairline)
    color-mix(in srgb, var(--project) 55%, var(--rule));
  transition: box-shadow var(--dur) var(--ease), color var(--dur) var(--ease);
}

.work__arrow svg {
  display: block;
  width: 19px;
  height: 19px;
  fill: none;
  stroke: currentColor;
  stroke-width: 2.2;
  stroke-linecap: round;
  stroke-linejoin: round;
  transition: transform var(--dur-fast) var(--ease);
}

/* It points out of the top-right corner, so it travels that way — but only
   the arrow does. The box stays put: a bordered shape sliding off its own
   alignment is the thing that reads as a mistake. */
.work__link:hover .work__arrow svg {
  transform: translate(2px, -2px);
}

.work__row:hover .work__arrow {
  color: var(--project);
  box-shadow: inset 0 0 0 var(--hairline)
    color-mix(in srgb, var(--project) 70%, transparent);
}

/* A draft has nowhere to go. The shape stays, so the row ends where its
   neighbours end, but it does not brighten and the arrow does not move. */
.work__row--draft .work__arrow {
  opacity: 0.4;
}

.work__row--draft:hover .work__arrow svg {
  transform: none;
}

/* The chrome, and the only box in the row. A screenshot floating on the page
   reads as a stray asset; the same screenshot inside a window reads as a
   product someone can open. The bar is not decoration — it is what tells you
   the thing below it is a screen.

   The grey lives here. The window is a solid panel with the screen inset in
   it, so the grey shows as a border on three sides and as the bar along the
   top — which is exactly what it does in the reference. Mixed into --surface
   on light so the panel is opaque, and left translucent on dark, where it
   lifts off #131519 on its own. */
.work-frame {
  position: relative;
  border-radius: 14px;
  padding: 0 var(--frame-inset) var(--frame-inset);
  background: color-mix(in srgb, var(--project) 26%, var(--surface));
  transition: background var(--dur) var(--ease),
    box-shadow var(--dur) var(--ease);
}

/* The shadow arrives with the lift rather than sitting under the window at
   rest — the reference has no resting shadow, the panel is simply a different
   value from the page. */
.work__row:hover .work-frame {
  box-shadow: 0 16px 36px color-mix(in srgb, var(--project) 24%, transparent);
}

/* A single band of light crossing the panel once when the row is touched.
   Not the pointer-tracking radial sheen that was removed — that one dressed a
   surface which no longer exists and followed the cursor around, so it never
   ended. This is one pass, on entry, and then it is gone: the panel catches
   the light as it lifts rather than glowing permanently.

   It runs on transform and opacity only, so it stays on the compositor. */
.work-frame {
  overflow: hidden;
}

.work-frame::after {
  content: "";
  position: absolute;
  inset: -20% -60%;
  pointer-events: none;
  background: linear-gradient(
    100deg,
    transparent 38%,
    rgba(255, 255, 255, 0.5) 50%,
    transparent 62%
  );
  opacity: 0;
  transform: translateX(-70%);
}

.work__row:hover .work-frame::after,
.work__row:focus-within .work-frame::after {
  animation: work-sheen 820ms var(--ease) 1;
}

@keyframes work-sheen {
  0% { transform: translateX(-70%); opacity: 0; }
  18% { opacity: 1; }
  100% { transform: translateX(70%); opacity: 0; }
}

/* White at half strength blows out over a near-black panel; on dark the band
   only has to be a lift, not a flash. */
:root[data-theme="dark"] .work-frame::after {
  background: linear-gradient(
    100deg,
    transparent 38%,
    rgba(255, 255, 255, 0.16) 50%,
    transparent 62%
  );
}

@media (prefers-reduced-motion: reduce) {
  .work__row:hover .work-frame::after,
  .work__row:focus-within .work-frame::after {
    animation: none;
  }
}

/* The bar is a strip of the panel, not a band laid on top of it: it has no
   fill of its own and no rule under it. The screen's own rounded top edge is
   the boundary, the way a title bar and a viewport meet. */
.work-frame__bar {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  height: 30px;
  /* No inset of its own: the bar spans exactly the width of the screen below
     it, so the dots start on the image's left edge and the arrow ends on its
     right one. */
  padding-inline: 0;
  background: transparent;
}

.work-frame__dots {
  display: inline-flex;
  gap: 5px;
  flex: 0 0 auto;
}

.work-frame__dots i {
  width: 7px;
  height: 7px;
  border-radius: 50%;
  background: color-mix(in srgb, var(--project) 35%, transparent);
  transition: background var(--dur) var(--ease);
}

.work-media {
  aspect-ratio: 16 / 10;
  overflow: hidden;
  /* Its own corners, because the frame no longer clips it — 8px against the
     frame's 14px less its 8px padding, so the two curves stay concentric. */
  border-radius: 8px;
}

.work-media img {
  width: 100%;
  height: 100%;
  object-fit: cover;
  object-position: var(--focal, center);
  /* The image scales inside the fixed frame; the frame itself never moves. */
  transition: transform 400ms ease;
}

.work__link:hover .work-media img {
  transform: scale(1.03);
}

.work__link:hover {
  background: var(--surface);
}

.work-media__wait {
  display: grid;
  place-items: center;
  width: 100%;
  height: 100%;
  background: color-mix(in srgb, var(--project) 5%, var(--surface));
  font-family: var(--font-mono);
  font-size: var(--step--1);
  color: var(--text-muted);
}

/* "Live" marker — traceable work is what gets screened for */
.tag {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  padding: 2px 7px;
  border-radius: 999px;
  border: var(--hairline) solid var(--rule);
  color: var(--text-muted);
}

.tag--live {
  color: var(--accent);
  border-color: color-mix(in srgb, var(--accent) 40%, transparent);
  background: var(--accent-soft);
}

/* ----------------------------------------------------------- Experience */

/* Level 1 - section heading in display type, deliberately unlike level 2. */
.section-title {
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
  letter-spacing: -0.015em;
  color: var(--text);
  margin-bottom: var(--space-4);
}

.xp__group + .xp__group {
  margin-top: var(--space-5);
}

/* Level 2 - group label: the smallest, quietest text in the block. */
.xp__label {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: var(--space-2);
}

.xp__item {
  display: flex;
  align-items: baseline;
  gap: var(--space-3);
  padding: 9px 2px;
}

.xp__list > li + li {
  border-top: var(--hairline) solid var(--rule);
}

/* Level 3 - company: important by position and contrast, not by weight. */
.xp__org {
  font-size: var(--step-0);
  font-weight: 500;
  color: var(--text);
}

/* Level 4 - role, gloss and year. */
.xp__gloss,
.xp__role {
  font-size: var(--step--1);
  color: var(--text-muted);
}

.xp__years {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--step--1);
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
  white-space: nowrap;
}

/* -------------------------------------------------------------- Contact */

.contact {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
}

.btn {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  padding: 10px 18px;
  border: var(--hairline) solid var(--rule);
  border-radius: var(--radius);
  font-size: var(--step--1);
  color: var(--text);
  transition: border-color var(--dur-fast) var(--ease),
    background var(--dur-fast) var(--ease);
}

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

/* One filled button and one outlined one. The pair only works if there is a
   clear first choice — two equal buttons make the visitor decide, which is
   the opposite of what a call to action is for. The work is the first choice;
   the resume is for the person who has already decided to keep the tab. */
/* The one filled button on the page, so it is the one that gets built rather
   than merely coloured. Three things separate a flat swatch from something
   that reads as expensive, and none of them is a brighter colour:

     a gradient with somewhere to go — --accent-lift at the top falling to
     --accent at the bottom, about one step of lightness, so the surface has a
     direction to catch rather than sitting dead flat

     a shadow in the button's own hue, not black — a grey drop shadow under a
     violet button reads as dirt on the paper; a violet one reads as the
     button lifting off it

     an inset hairline of white at the top edge, the thing a real moulded
     surface has where it turns to face the light

   Tracking opens a hair too. Fraunces at button size is tight by default, and
   a little air is what stops "Explore projects" reading as a form control. */
.btn--solid {
  background-image: linear-gradient(180deg, var(--accent-lift), var(--accent));
  background-color: var(--accent);
  border-color: transparent;
  color: #fff;
  letter-spacing: 0.012em;
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.22),
    0 1px 2px color-mix(in srgb, var(--accent) 35%, transparent),
    0 6px 18px color-mix(in srgb, var(--accent) 26%, transparent);
  transition: transform var(--dur) var(--ease), box-shadow var(--dur) var(--ease),
    background-image var(--dur) var(--ease);
}

/* Rises a touch further than the outlined button and deepens its own glow.
   The gradient shortens on hover — top and bottom move closer together — which
   makes the face look like it has tilted toward you rather than just moved. */
.btn--solid:hover {
  background-image: linear-gradient(170deg, var(--accent-lift), var(--accent) 78%);
  border-color: transparent;
  transform: translateY(-2px);
  box-shadow:
    inset 0 1px 0 rgba(255, 255, 255, 0.3),
    0 2px 4px color-mix(in srgb, var(--accent) 38%, transparent),
    0 14px 32px color-mix(in srgb, var(--accent) 32%, transparent);
}

.btn--solid:active {
  transform: translateY(0);
  box-shadow:
    inset 0 1px 2px color-mix(in srgb, var(--accent) 60%, #000),
    0 1px 2px color-mix(in srgb, var(--accent) 30%, transparent);
}

@media (prefers-reduced-motion: reduce) {
  .btn--solid,
  .btn--solid:hover,
  .btn--solid:active {
    transform: none;
  }
}

.btn .icon {
  width: 16px;
  height: 16px;
  flex: 0 0 auto;
}

/* The outbound arrow is a footnote on the label, not a second icon competing
   with the platform mark on the left. */
.btn__out {
  width: 12px;
  height: 12px;
  opacity: 0.55;
  margin-left: -2px;
}

.btn:hover .btn__out {
  opacity: 1;
}

/* Sits under the status pill. .hero is a column flex container, so this needs
   the same opt-out of the default stretch that .status takes — otherwise the
   row spans the full column and the buttons drift apart. */
.cta {
  display: flex;
  flex-wrap: wrap;
  align-self: flex-start;
  gap: var(--space-3);
  margin-top: var(--space-5);
}

@media (max-width: 480px) {
  /* Two buttons side by side get too narrow to read at this width; stacked,
     each keeps its full label and a proper hit area. */
  .cta {
    width: 100%;
  }

  .cta .btn {
    flex: 1 1 100%;
    justify-content: center;
  }
}

/* ------------------------------------------------------------- Closing */

/* The page ends on the ask, not on the last project. A rule across the top
   separates it from the work without making it a separate page — this is the
   same conversation, arriving at its point. */
/* The page ends on the ask, not on the last project. A rule across the top
   separates it from the work without making it a separate page — this is the
   same conversation, arriving at its point. */
.closing {
  margin-top: var(--space-8);
  padding-top: var(--space-6);
  border-top: var(--hairline) solid var(--rule);
}

.closing__eyebrow {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  font-weight: 500;
  letter-spacing: 0.11em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: var(--space-3);
}

/* Display size, because this is the second headline on the page and it should
   read like one — a small "Contact" heading turns the ask into a form label. */
.closing__head {
  font-family: var(--font-display);
  font-size: var(--step-3);
  font-weight: 600;
  font-variation-settings: "SOFT" 100, "WONK" 1;
  letter-spacing: -0.015em;
  line-height: 1.1;
  margin-bottom: var(--space-4);
}

.closing__lead {
  max-width: var(--prose);
  color: var(--text-muted);
  margin-bottom: var(--space-5);
}

.closing__actions {
  display: flex;
  flex-wrap: wrap;
  gap: var(--space-3);
  margin-bottom: var(--space-6);
}

/* Three facts, set as a definition list because that is what they are. The
   label column is fixed so the values line up on one left edge; below the
   breakpoint the pair stacks and the label sits above its value. */
.closing__meta {
  display: grid;
  gap: var(--space-3);
  max-width: var(--prose);
  padding-top: var(--space-5);
  border-top: var(--hairline) solid var(--rule);
}

.closing__row {
  display: grid;
  grid-template-columns: 7.5rem 1fr;
  gap: var(--space-3);
  align-items: baseline;
}

.closing__row dt {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.closing__row dd {
  margin: 0;
  font-size: var(--step-0);
  color: var(--text);
}

@media (max-width: 560px) {
  .closing__row {
    grid-template-columns: 1fr;
    gap: 2px;
  }
}

/* The name is the link; the pill is not. Keeping them apart is what stops
   "Brisbane, Australia" from being a second, unlabelled way home. */
.rail__brand-link {
  display: block;
  color: inherit;
}

/* .status was written for the hero's flex column — align-self and a bottom
   margin. Here it is a block in normal flow, so both are reset. */
.rail__brand .status {
  align-self: auto;
  margin-top: 7px;
  margin-bottom: 0;
  /* The rail column is 11rem. The longest label fits, but only just, so the
     pill gives up padding rather than wrapping to two lines. */
  padding-inline: 0.55em 0.6em;
  font-size: 0.75rem;
}

.connect__lead {
  max-width: var(--prose);
  color: var(--text-muted);
  margin-bottom: var(--space-4);
}

/* ---------------------------------------------------------------- Prose */

.prose {
  max-width: var(--prose);
  color: var(--text-muted);
}

.prose p + p {
  margin-top: var(--space-4);
}

.prose h2 {
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
  color: var(--text);
  letter-spacing: -0.015em;
  margin: var(--space-7) 0 var(--space-3);
}

.prose h3 {
  font-size: var(--step-0);
  font-weight: 600;
  color: var(--text);
  margin: var(--space-5) 0 var(--space-2);
}

.prose strong {
  color: var(--text);
  font-weight: 600;
}

.prose a {
  color: var(--text);
  border-bottom: 1px solid var(--accent);
}

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

.prose ul {
  margin-top: var(--space-3);
  display: grid;
  gap: var(--space-2);
}

.prose ul li {
  position: relative;
  padding-left: var(--space-5);
}

.prose ul li::before {
  content: "";
  position: absolute;
  left: 3px;
  top: 0.68em;
  width: 5px;
  height: 5px;
  border-radius: 50%;
  background: var(--accent);
}

/* --------------------------------------------------------- Page headers */

.page-head {
  margin-bottom: var(--space-6);
  padding-bottom: var(--space-5);
  border-bottom: var(--hairline) solid var(--rule);
}

.page-title {
  font-family: var(--font-display);
  font-size: var(--step-3);
  font-weight: 600;
  letter-spacing: -0.02em;
  line-height: 1.12;
  text-wrap: balance;
}

.page-sub {
  margin-top: var(--space-3);
  color: var(--text-muted);
  max-width: var(--prose);
}

.back {
  display: inline-flex;
  align-items: center;
  gap: var(--space-2);
  font-family: var(--font-mono);
  font-size: var(--step--1);
  color: var(--text-muted);
  margin-bottom: var(--space-5);
  transition: gap var(--dur-fast) var(--ease),
    color var(--dur-fast) var(--ease);
}

.back:hover {
  color: var(--text);
  gap: var(--space-3);
}

/* -------------------------------------------------------- Case studies */

.case-meta {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
  gap: var(--space-5);
  padding: var(--space-5) 0;
  border-bottom: var(--hairline) solid var(--rule);
}

.case-meta dt {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: 4px;
}

.case-meta dd {
  font-size: var(--step--1);
}

/* Decision blocks — the body of a case study */
.decision {
  border-top: var(--hairline) solid var(--rule);
  padding: var(--space-6) 0;
}

.decision__n {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.08em;
  color: var(--text-muted);
  margin-bottom: var(--space-2);
}

.decision__title {
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
  letter-spacing: -0.015em;
  margin-bottom: var(--space-4);
  text-wrap: balance;
}

.decision__grid {
  display: grid;
  gap: var(--space-3);
}

.decision__row {
  display: grid;
  grid-template-columns: 7.5rem 1fr;
  gap: var(--space-4);
  align-items: baseline;
}

.decision__key {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
}

.decision__val {
  color: var(--text-muted);
}

.decision__val strong {
  color: var(--text);
  font-weight: 600;
}

@media (max-width: 34rem) {
  .decision__row {
    grid-template-columns: 1fr;
    gap: var(--space-1);
  }
}

.figure {
  margin: var(--space-6) 0;
}

.figure img,
.figure .ph {
  width: 100%;
  border-radius: var(--radius-lg);
  border: var(--hairline) solid var(--rule);
  background: var(--surface);
}

.figure .ph {
  aspect-ratio: 16 / 9;
  display: grid;
  place-items: center;
  padding: var(--space-5);
  text-align: center;
}

.ph__hint {
  font-family: var(--font-mono);
  font-size: var(--step--1);
  color: var(--text-muted);
  max-width: 34ch;
  line-height: 1.6;
}

.figure figcaption {
  margin-top: var(--space-3);
  font-size: var(--step--1);
  color: var(--text-muted);
  max-width: var(--prose);
}

.note {
  border-left: 2px solid var(--accent);
  padding: var(--space-2) 0 var(--space-2) var(--space-5);
  margin-top: var(--space-6);
}

.next {
  margin-top: var(--space-8);
  padding-top: var(--space-5);
  border-top: var(--hairline) solid var(--rule);
}

.next__label {
  font-family: var(--font-mono);
  font-size: 0.6875rem;
  letter-spacing: 0.1em;
  text-transform: uppercase;
  color: var(--text-muted);
  margin-bottom: var(--space-2);
}

.next__link {
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
  letter-spacing: -0.015em;
  display: inline-flex;
  align-items: baseline;
  gap: var(--space-3);
  transition: gap var(--dur-fast) var(--ease);
}

.next__link:hover {
  gap: var(--space-4);
}

/* ------------------------------------------------------------ Award rows */

/* The page header already draws a rule, so the award that follows it must not
   draw a second one directly underneath. (:first-of-type would not work here:
   .page-head is also a div, so it claims that slot.) */
.page-head + .award {
  border-top: 0;
  padding-top: 0;
}

/* Four results down one page, and the placement is the reason a reader stops.
   It used to be set at --step--1 in mono — the same size as the date beneath
   it and the brief beside it — so the page had four identical grey blocks and
   nothing announced which one was first. The placement now carries display
   scale and the accent, and it is the only thing at that size on the row. */
.award {
  border-top: var(--hairline) solid var(--rule);
  padding: var(--space-6) var(--space-2);
  display: grid;
  grid-template-columns: 10rem 1fr;
  gap: var(--space-3) var(--space-5);
  transition: border-color var(--dur) var(--ease);
}

.award:hover {
  border-color: color-mix(in srgb, var(--accent) 45%, var(--rule));
}

.award__place {
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
  font-variation-settings: "SOFT" 100, "WONK" 1;
  line-height: 1.1;
  letter-spacing: -0.015em;
  color: var(--accent);
  text-wrap: balance;
}

.award__meta {
  font-family: var(--font-mono);
  font-size: var(--step--1);
  color: var(--text-muted);
  margin-top: 4px;
}

.award__name {
  font-weight: 600;
}

.award__brief {
  color: var(--text-muted);
  font-size: var(--step--1);
  margin-top: var(--space-2);
  max-width: var(--prose);
}

@media (max-width: 34rem) {
  .award {
    grid-template-columns: 1fr;
  }
}

/* ------------------------------------------------------------- Stack */

.stack__group + .stack__group {
  margin-top: var(--space-6);
}

.tools {
  display: grid;
  grid-template-columns: repeat(auto-fill, minmax(128px, 1fr));
  gap: var(--space-3);
  margin-top: var(--space-4);
}

/* The logos are the content of this page, so they are in colour at rest.

   They used to be grey until hovered — "quiet by default, brand identity only
   where attention lands". That is a good rule on a page where the tools are
   an aside. Here they are the subject, and nine grey glyphs in a grid gave the
   page nothing to look at. Hover now changes the tile, not the fact that the
   mark has a colour at all.

   Each tile carries a wash of its own brand so the grid reads as a set of
   distinct things rather than one grey field, and the mark sits on a plate of
   its own colour instead of floating on the page. */
.tool {
  display: grid;
  place-items: center;
  gap: var(--space-3);
  padding: var(--space-5) var(--space-4);
  border: var(--hairline) solid color-mix(in srgb, var(--brand) 18%, transparent);
  border-radius: var(--radius-lg);
  background: color-mix(in srgb, var(--brand) 5%, var(--surface));
  transition: transform var(--dur) var(--ease), background var(--dur) var(--ease),
    border-color var(--dur) var(--ease), box-shadow var(--dur) var(--ease);
}

/* The plate behind the mark. Round, a stronger tint than the tile, so the icon
   has something to sit on and the tile has a focal point. */
.tool svg {
  width: 30px;
  height: 30px;
  box-sizing: content-box;
  padding: 9px;
  border-radius: 50%;
  fill: currentColor;
  background: color-mix(in srgb, var(--brand) 12%, transparent);
  color: var(--brand);
  transition: transform var(--dur) var(--ease), background var(--dur) var(--ease);
}

.tool__name {
  font-family: var(--font-mono);
  font-size: var(--step--1);
  font-weight: 500;
  color: var(--text);
  text-align: center;
}

.tool:hover,
.tool:focus-visible {
  transform: translateY(-2px);
  background: color-mix(in srgb, var(--brand) 12%, var(--surface));
  border-color: color-mix(in srgb, var(--brand) 42%, transparent);
  box-shadow: 0 10px 24px color-mix(in srgb, var(--brand) 18%, transparent);
}

.tool:hover svg,
.tool:focus-visible svg {
  transform: scale(1.06);
  background: color-mix(in srgb, var(--brand) 20%, transparent);
}

/* On the near-black ground a 5% wash disappears, so the tile takes more tint.
   The marks themselves need no help: every brand here clears 3.5:1 against
   #131519 on its own. */
:root[data-theme="dark"] .tool {
  background: color-mix(in srgb, var(--brand) 10%, var(--surface));
}

:root[data-theme="dark"] .tool:hover,
:root[data-theme="dark"] .tool:focus-visible {
  background: color-mix(in srgb, var(--brand) 20%, var(--surface));
}

@media (prefers-reduced-motion: reduce) {
  .tool:hover,
  .tool:focus-visible,
  .tool:hover svg,
  .tool:focus-visible svg {
    transform: none;
  }
}

/* Contrast repairs, light ground only.
   A logo is a meaningful graphic, so it owes 3:1 against the paper (WCAG
   1.4.11). Six of these brands are pale enough to fail that on #f1f0ec — the
   yellows worst of all, Miro at 1.29:1. Darkening the whole set toward the ink
   would have fixed it and drained every strong colour with it, so only the six
   that fail are moved, each to a darker version of its own hue. Figma, HTML
   and CSS pass untouched and keep their exact brand values.
   Dark theme needs none of this: every mark clears 3.5:1 over #131519. */
/* The attribute values must stay quoted. 31A8FF starts with a digit, which is
   not a valid bare CSS identifier, so unquoted that one selector is thrown out
   by the parser and Photoshop silently keeps its failing blue. */
:root:not([data-theme="dark"]) .tool[style*="FFD02F"] { --brand: #a8801a; } /* Miro        1.29 -> 3.19 */
:root:not([data-theme="dark"]) .tool[style*="F7B500"] { --brand: #a87a00; } /* Sketch      1.59 -> 3.38 */
:root:not([data-theme="dark"]) .tool[style*="FF9A00"] { --brand: #b86a00; } /* Illustrator 1.86 -> 3.61 */
:root:not([data-theme="dark"]) .tool[style*="F7DF1E"] { --brand: #8f7d00; } /* JavaScript  1.19 -> 3.61 */
:root:not([data-theme="dark"]) .tool[style*="FF61F6"] { --brand: #c21fb4; } /* Adobe XD    2.22 -> 4.44 */
:root:not([data-theme="dark"]) .tool[style*="31A8FF"] { --brand: #0e76c4; } /* Photoshop   2.25 -> 4.18 */
:root:not([data-theme="dark"]) .tool[style*="0696D7"] { --brand: #0b7fb4; } /* Fusion 360  2.90 -> 3.91 */

.handoff {
  margin-top: var(--space-7);
  padding-top: var(--space-5);
  border-top: var(--hairline) solid var(--rule);
  color: var(--text-muted);
  font-size: var(--step--1);
}


/* -------------------------------------------------- About page modules */

/* Career path: restates the opening paragraph visually. Flat, typographic. */
/* The career is the one narrative on this page — IT, then graphic and visual,
   then product — so it is drawn as a track rather than three labels separated
   by a 12px dash. A continuous rule runs behind the stages and each one sits
   on a node, which is what makes it read as movement in a direction instead of
   a list that happens to carry years. */
.path {
  position: relative;
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(9rem, 1fr));
  gap: var(--space-5) var(--space-4);
  margin: var(--space-7) 0;
  padding-top: var(--space-6);
}

/* The track. Sits at the node line, behind everything, and stops short of the
   last node so the line does not run off into nothing. */
.path::before {
  content: "";
  position: absolute;
  left: 5px;
  right: 5px;
  top: calc(var(--space-6) - 5px);
  height: var(--hairline);
  background: var(--rule);
}

.path__stage {
  position: relative;
  padding-top: var(--space-5);
}

/* The node. Hollow for the stages already passed, filled for the one in
   progress — the same grammar the status dot uses elsewhere. */
.path__stage::before {
  content: "";
  position: absolute;
  left: 0;
  /* .path pads its top by --space-6 and draws the track 5px above that line,
     so relative to a stage the track sits at -5px. A 9px node centred on it
     starts at -9px. */
  top: -9px;
  width: 9px;
  height: 9px;
  border-radius: 50%;
  background: var(--bg);
  box-shadow: inset 0 0 0 1.5px var(--rule);
}

.path__stage.is-now::before {
  background: var(--accent);
  box-shadow: inset 0 0 0 1.5px var(--accent),
    0 0 0 4px var(--accent-soft);
}

/* The stage name leads at display weight — a career stage is content, not
   metadata, and muting it was what made this row disappear. The year follows
   underneath in the tabular face, small, where a date belongs. */
.path__label {
  display: block;
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
  font-variation-settings: "SOFT" 100, "WONK" 1;
  line-height: 1.15;
  letter-spacing: -0.012em;
  color: var(--text);
  max-width: 12ch;
}

.path__year {
  display: block;
  margin-top: 8px;
  font-family: var(--font-num);
  font-size: var(--step--1);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.06em;
  color: var(--text-muted);
}

.path__stage.is-now .path__year {
  color: var(--accent);
}

/* "How I work" is four positions, not four bullets. Each one already opens on
   a claim in <strong> and then explains it, so the markup was doing the work
   and only the styling was hiding it: a bulleted list sets the claim and the
   explanation at the same size, and the reader gets a paragraph.
   Numbering them makes them countable, lifting the claim to display size makes
   them scannable, and dropping the bullet removes the last thing that said
   "list of features". */
.prose ul.principles {
  display: grid;
  gap: var(--space-6);
  margin-top: var(--space-5);
  counter-reset: principle;
}

.prose ul.principles li {
  counter-increment: principle;
  position: relative;
  padding-left: 3.25rem;
  color: var(--text-muted);
  font-size: var(--step--1);
  line-height: 1.6;
  max-width: var(--prose);
}

/* The bullet that .prose ul draws is replaced, not hidden, so nothing of the
   old treatment survives underneath. */
.prose ul.principles li::before {
  content: counter(principle, decimal-leading-zero);
  position: absolute;
  left: 0;
  top: 0.1em;
  width: auto;
  height: auto;
  border-radius: 0;
  background: none;
  font-family: var(--font-num);
  font-size: var(--step--1);
  font-variant-numeric: tabular-nums;
  letter-spacing: 0.08em;
  color: var(--accent);
}

.prose ul.principles li strong {
  display: block;
  margin-bottom: var(--space-2);
  font-family: var(--font-display);
  font-size: var(--step-1);
  font-weight: 600;
  font-variation-settings: "SOFT" 100, "WONK" 1;
  line-height: 1.2;
  letter-spacing: -0.012em;
  color: var(--text);
}

@media (max-width: 34rem) {
  .prose ul.principles li {
    padding-left: 2.5rem;
  }
}

/* Numbers row: no borders, no cards, no icons — but the numerals carry the
   page. This was the whole problem with About: every element on it sat at
   --step--1 in --text-muted, so there was nothing for the eye to land on and
   the page read as one grey block. Four figures are the strongest thing the
   page has; they get display scale and the display face, and the labels drop
   away beneath them. One anchor per column, and the hierarchy does the rest. */
.figures {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(8rem, 1fr));
  gap: var(--space-5) var(--space-4);
  margin: var(--space-7) 0;
  padding-top: var(--space-5);
  border-top: var(--hairline) solid var(--rule);
}

.fig__n {
  font-family: var(--font-display);
  font-size: var(--step-4);
  font-weight: 600;
  font-variation-settings: "opsz" 60, "SOFT" 100, "WONK" 1;
  font-variant-numeric: lining-nums;
  color: var(--text);
  line-height: 1;
  letter-spacing: -0.02em;
}

/* A hairline between the numeral and its label, the width of the numeral's
   column. It is what turns four numbers in a row into a table of contents for
   the career rather than four loose facts. */
.fig__c {
  margin-top: var(--space-3);
  padding-top: var(--space-2);
  border-top: var(--hairline) solid var(--rule);
  font-family: var(--font-mono);
  font-size: var(--step--1);
  line-height: 1.4;
  color: var(--text-muted);
  max-width: 16ch;
}

/* Background: aligned two-column list, scans in one pass. */
.prose-h2 {
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 600;
  color: var(--text);
  letter-spacing: -0.015em;
  margin-bottom: var(--space-3);
}

.edu {
  border-top: var(--hairline) solid var(--rule);
}

.edu__row {
  display: flex;
  align-items: baseline;
  flex-wrap: wrap;
  gap: var(--space-2) var(--space-3);
  padding: 10px 2px;
  border-bottom: var(--hairline) solid var(--rule);
}

.edu__degree {
  font-size: var(--step-0);
  font-weight: 500;
  color: var(--text);
}

.edu__place {
  font-size: var(--step--1);
  color: var(--text-muted);
}

.edu__when {
  margin-left: auto;
  font-family: var(--font-mono);
  font-size: var(--step--1);
  font-variant-numeric: tabular-nums;
  color: var(--text-muted);
  white-space: nowrap;
}

.edu__note {
  margin-top: var(--space-3);
  font-size: var(--step--1);
  color: var(--text-muted);
}

.edu__note a {
  color: var(--text);
  border-bottom: 1px solid var(--accent);
}

@media (max-width: 34rem) {
  .path {
    display: grid;
    gap: var(--space-3);
  }

  .path__stage + .path__stage::before {
    left: 0;
    top: calc(var(--space-3) * -0.5);
    width: 1px;
    height: var(--space-2);
  }
}

/* ------------------------------------------------------------- Reveal */

.reveal {
  opacity: 0;
  transform: translateY(6px);
}

.js .reveal {
  transition: opacity var(--dur-slow) var(--ease),
    transform var(--dur-slow) var(--ease);
  transition-delay: var(--delay, 0ms);
}

.reveal.is-in,
html:not(.js) .reveal {
  opacity: 1;
  transform: none;
}

/* ------------------------------------------------------- Mobile layout */

.railbar {
  display: none;
}

@media (max-width: 768px) {
  .rail {
    transform: translateX(-100%);
    transition: transform var(--dur) var(--ease);
    box-shadow: var(--shadow);
  }

  body.nav-open .rail {
    transform: none;
  }

  body.nav-open .scrim {
    position: fixed;
    inset: 0;
    background: rgba(0, 0, 0, 0.45);
    z-index: 35;
  }

  .shell {
    margin-left: 0;
    padding-bottom: 56px;
  }

  .wrap {
    padding: var(--space-6) var(--space-4) var(--space-8);
  }

  /* A fixed bottom bar, not a full-screen hamburger overlay. */
  .railbar {
    position: fixed;
    inset-inline: 0;
    bottom: 0;
    z-index: 45;
    height: 56px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    gap: var(--space-3);
    padding: 0 var(--space-4);
    background: var(--bg);
    border-top: var(--hairline) solid var(--rule);
  }

}

@media (min-width: 769px) {
  .scrim {
    display: none;
  }
}

@media (prefers-reduced-motion: reduce) {
  .reveal {
    opacity: 1;
    transform: none;
    transition: none;
  }

  *,
  *::before,
  *::after {
    transition-duration: 0.01ms !important;
    animation-duration: 0.01ms !important;
    scroll-behavior: auto !important;
  }
}

@media print {
  .rail,
  .railbar,
  .scrim {
    display: none;
  }

  .shell {
    margin-left: 0;
  }
}

/* ------------------------------------------------------------- Figures */

/* Figures and codes, not words. These sit in columns that have to line up,
   and Fraunces ships no tabular figures — proportional digits stagger a year
   column, and the two-letter indices would sit at differing widths inside a
   fixed 25px gutter. The only place a second face survives.

   This block lives at the very end on purpose. Each of these classes sets
   its own font-family further up the file; at equal specificity the last
   declaration wins, so anywhere earlier and this rule would silently lose. */
.mono,
.path__year,
.xp__years,
.edu__when,
.decision__n,
.fig__n {
  font-family: var(--font-num);
  font-variation-settings: normal;
  font-variant-numeric: tabular-nums;
}

/* -------------------------------------------------------------- Cursor */

/* Two shapes replace the system pointer: a dot pinned exactly to it, and a
   ring easing along behind. main.js only creates these on a fine pointer with
   motion allowed, and adds .has-cursor at the same time — so the native
   cursor is never hidden unless something is actually there to replace it.
   Everything here hangs off that class for exactly that reason. */

@media (pointer: fine) {
  .has-cursor,
  .has-cursor a,
  .has-cursor button,
  .has-cursor summary {
    cursor: none;
  }

  .cursor__dot,
  .cursor__ring {
    position: fixed;
    top: 0;
    left: 0;
    z-index: 90;
    pointer-events: none;
    border-radius: 50%;
    /* Promote both to their own layer: they move every frame, and without
       this the browser repaints the strip of page underneath them. */
    will-change: transform;
    transition: scale var(--dur) var(--ease), opacity var(--dur) var(--ease),
      background var(--dur) var(--ease), border-color var(--dur) var(--ease);
  }

  .cursor__dot {
    width: 6px;
    height: 6px;
    background: var(--accent);
  }

  .cursor__ring {
    width: 40px;
    height: 40px;
    border: var(--hairline) solid var(--accent);
    opacity: 0.45;
  }

  /* Over anything clickable the dot collapses and the ring opens up — the
     ring becomes the pointer, which reads as the target being surrounded
     rather than merely touched. Scaling uses the standalone `scale` property
     so it composes with the translate main.js writes into `transform`. */
  .cursor-hot .cursor__dot {
    scale: 0;
  }

  .cursor-hot .cursor__ring {
    scale: 1.5;
    opacity: 0.9;
    background: var(--accent-soft);
  }

  .cursor-down .cursor__ring {
    scale: 0.8;
    opacity: 1;
  }

  .cursor-out .cursor__dot,
  .cursor-out .cursor__ring {
    opacity: 0;
  }
}

/* --------------------------------------------------------------- Burst */

/* Shards thrown when the name pops. Created and removed by main.js, which
   only runs this on a fine pointer with motion allowed. Positioned fixed on
   body rather than inside the heading so nothing clips them at its edge. */

.burst {
  position: fixed;
  z-index: 95;
  width: 0;
  height: 0;
  pointer-events: none;
}

/* The shockwave — one ring opening out ahead of the shards. */
.burst::before {
  content: "";
  position: absolute;
  left: 0;
  top: 0;
  width: 12px;
  height: 12px;
  margin: -6px 0 0 -6px;
  border-radius: 50%;
  border: 2px solid var(--accent);
  animation: burst-ring 540ms cubic-bezier(0.2, 0.7, 0.3, 1) forwards;
}

.burst i {
  position: absolute;
  left: 0;
  top: 0;
  width: var(--s, 6px);
  height: var(--s, 6px);
  margin-top: calc(var(--s, 6px) / -2);
  margin-left: calc(var(--s, 6px) / -2);
  border-radius: 50%;
  background: var(--accent);
  /* rotate first, then translate along the rotated x-axis: one shared
     keyframe throws every shard along its own spoke, with only the angle,
     distance and duration differing per element. */
  animation: burst-fly var(--t, 500ms) cubic-bezier(0.17, 0.75, 0.3, 1) forwards;
}

@keyframes burst-fly {
  from {
    transform: rotate(var(--a)) translateX(0) scale(1);
    opacity: 1;
  }
  to {
    transform: rotate(var(--a)) translateX(var(--d)) scale(0.15);
    opacity: 0;
  }
}

@keyframes burst-ring {
  from {
    transform: scale(0.35);
    opacity: 0.85;
  }
  to {
    transform: scale(7);
    opacity: 0;
  }
}

/* ----------------------------------------------------------------- Home */

/* Available to assistive tech, absent from the page. The Work heading still
   exists in the outline — the marquee replaced how it looks, not the fact
   that the list below it needs a name. */
.vh {
  position: absolute;
  width: 1px;
  height: 1px;
  margin: -1px;
  padding: 0;
  overflow: hidden;
  clip-path: inset(50%);
  white-space: nowrap;
  border: 0;
}

/* The band between the greeting and the work. It carries the transition the
   bare "Work" label used to make abruptly. */
.marquee {
  margin-block: var(--space-8) var(--space-7);
  padding-block: var(--space-4);
  border-block: var(--hairline) solid var(--rule);
  overflow: hidden;
  /* Fades both ends, so the words arrive and leave rather than being cut
     against a hard edge. */
  mask-image: linear-gradient(
    90deg,
    transparent 0,
    #000 7%,
    #000 93%,
    transparent 100%
  );
}

/* Two identical groups sitting flush, with no gap on the track itself. That
   is what makes -50% land exactly one group along and the loop seamless — a
   gap here would leave half of it showing as a stutter at the wrap point. */
.marquee__track {
  display: flex;
  width: max-content;
  animation: marquee 30s linear infinite;
}

.marquee__group {
  display: flex;
  align-items: center;
  gap: var(--space-5);
  padding-right: var(--space-5);
}

.marquee:hover .marquee__track {
  animation-play-state: paused;
}

.marquee__group span {
  font-family: var(--font-display);
  font-size: var(--step-2);
  font-weight: 400;
  font-variation-settings: "SOFT" 100, "WONK" 1;
  white-space: nowrap;
  color: var(--text-muted);
}

.marquee__group i {
  font-style: normal;
  font-size: var(--step-0);
  color: var(--accent);
}

@keyframes marquee {
  from {
    transform: translateX(0);
  }
  to {
    transform: translateX(-50%);
  }
}

/* Rows arrive one after another rather than the whole list at once. The
   delay is per row; the trigger is the section's own .is-in, so nothing
   starts until the list is actually on screen. */
.work__row {
  opacity: 0;
  translate: 0 14px;
}

.js .reveal.is-in .work__row {
  animation: work-in 600ms var(--ease) both;
}

.js .reveal.is-in .work__row:nth-child(1) {
  animation-delay: 40ms;
}
.js .reveal.is-in .work__row:nth-child(2) {
  animation-delay: 130ms;
}
.js .reveal.is-in .work__row:nth-child(3) {
  animation-delay: 220ms;
}
.js .reveal.is-in .work__row:nth-child(4) {
  animation-delay: 310ms;
}

html:not(.js) .work__row {
  opacity: 1;
  translate: none;
}

@keyframes work-in {
  from {
    opacity: 0;
    translate: 0 14px;
  }
  to {
    opacity: 1;
    translate: none;
  }
}

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

  .work__row,
  .js .reveal.is-in .work__row {
    opacity: 1;
    translate: none;
    animation: none;
  }
}

/* ---------------------------------------------------------------- Mark */

/* A cluster of sparks above the greeting, in place of a photograph. The
   glyph is the same one the marquee uses as a separator, so the mark reads
   as part of the page rather than an ornament dropped onto it. */

.hero__text {
  min-width: 0;
}

.hero__mark {
  align-self: flex-start;
  margin-bottom: var(--space-4);
  line-height: 0;
}

.hero__mark svg {
  width: clamp(38px, 5vw, 48px);
  height: auto;
  overflow: visible;
  /* One slow turn for the whole cluster. At 26 seconds it is below the
     threshold where the eye tracks motion — you register that something is
     alive without being pulled away from the greeting. */
  animation: mark-spin 26s linear infinite;
}

/* fill-box again: without it each star's origin resolves against the whole
   48x48 viewport, so the two satellites would swell toward the centre
   instead of pulsing in place. */
.mark__star {
  fill: var(--accent);
  transform-box: fill-box;
  transform-origin: center;
  animation: mark-pulse 3.4s var(--ease) infinite;
}

/* Offset starts, so the three never breathe in unison — that synchrony is
   what makes this kind of cluster look mechanical. */
.mark__star--b {
  opacity: 0.72;
  animation-delay: 0.55s;
}

.mark__star--c {
  opacity: 0.5;
  animation-delay: 1.15s;
}

@keyframes mark-spin {
  to {
    rotate: 360deg;
  }
}

@keyframes mark-pulse {
  0%,
  100% {
    scale: 1;
  }
  50% {
    scale: 1.22;
  }
}

@media (prefers-reduced-motion: reduce) {
  .hero__mark svg,
  .mark__star {
    animation: none;
  }
}
/* ---------------------------------------------------------- Work title */

/* Announces the list plainly. It replaces the small mono label that used to
   sit here — that label was a signpost, and at 11px it read as metadata
   rather than as the point of the page. */
.work-title {
  font-family: var(--font-display);
  font-size: var(--step-3);
  font-weight: 600;
  font-variation-settings: "SOFT" 100, "WONK" 1;
  letter-spacing: -0.015em;
  line-height: 1.1;
  margin-bottom: var(--space-5);
}

/* ------------------------------------------------------------ Work cards */

/* Each project is a card rather than a row in a shared list. Every colour
   below is derived from --project — the wash, the outline, the index
   watermark and the lift shadow all move together if it changes.

   All four cards rest on the same --project, and only the card being pointed
   at or pressed swaps it for the accent. The rows each carried their own hue
   before, set inline; colour that is always on says nothing, and it left the
   touched state with nothing of its own to show. */

.work {
  /* The list rule and the dividers between rows both go: cards separated by
     space do not need a line to say where one ends. */
  border-top: 0;
  display: grid;
  gap: var(--space-6);
}

.work__row {
  --project: var(--work-card);
}

/* :focus-within and :active are what carry this on a touch screen, where
   :hover either never fires or fires on tap and then sticks. */
.work__row:hover,
.work__row:focus-within,
.work__row:active {
  --project: var(--work-card-hot);
}

/* There is no card around the window any more. The row used to be a panel
   holding a second panel, and two nested boxes for one screenshot is one box
   too many — the reference sets the window straight on the page and lets the
   ground carry it. So the row keeps only its motion; every surface it used to
   paint now belongs to .work-frame below. */
.work__row {
  border-bottom: 0;
  border-left: 0;
  border-radius: 0;
  background: transparent;
  box-shadow: none;
  transition: transform var(--dur) var(--ease);
}

.work__row:hover {
  transform: translateY(-3px);
  background: transparent;
  box-shadow: none;
}

/* No inset either: the window used to sit in from the card's edge, and with
   the card gone that padding is just the window failing to reach the column
   edges the rest of the page lines up on.

   --frame-inset is how far the screen sits inside the window. It is declared
   here rather than on .work-frame because the meta row and the framing line
   below the window need the same value to end on the same edge the
   screenshot does. */
.work__link {
  --frame-inset: 8px;
  position: relative;
  display: block;
  padding: 0;
  background: transparent;
}

/* Name, platform, arrow — one line under the screen. */
/* Aligned to the screen, not to the card. The arrow is pushed to the end of
   this row, so this padding is what decides where it lands — at 2px it
   overhung the screenshot's right edge by six pixels, which is enough to read
   as the arrow not lining up with anything. */
.work__meta {
  display: flex;
  align-items: center;
  gap: var(--space-2);
  margin-top: var(--space-3);
  padding-inline: var(--frame-inset);
}

.work__name {
  font-family: var(--font-display);
  font-size: var(--step-0);
  font-weight: 600;
  letter-spacing: -0.01em;
  line-height: 1.2;
  color: var(--text);
}

.work__framing {
  margin-top: 6px;
  padding-left: var(--frame-inset);
  /* Keeps the last line clear of the pinned arrow rather than running under
     it — the arrow is out of the flow, so nothing else reserves this. */
  padding-right: calc(var(--frame-inset) + 34px + var(--space-4));
  padding-bottom: 2px;
  font-size: var(--step--1);
  line-height: 1.5;
  color: var(--text-muted);
}

.work__link:hover {
  background: transparent;
}

/* On dark backgrounds these hues sit close to the page, so the window's fill
   is left translucent rather than mixed into --surface: over #131519 that is
   what lifts the panel off the ground the way the reference does. */
:root[data-theme="dark"] .work-frame {
  background: color-mix(in srgb, var(--project) 26%, transparent);
}

:root[data-theme="dark"] .work__row:hover .work-frame {
  background: color-mix(in srgb, var(--project) 38%, transparent);
  box-shadow: 0 16px 36px rgba(0, 0, 0, 0.45);
}

:root[data-theme="dark"] .work__arrow {
  color: color-mix(in srgb, var(--project) 30%, #ffffff);
  box-shadow: inset 0 0 0 var(--hairline)
    color-mix(in srgb, #ffffff 24%, transparent);
}

:root[data-theme="dark"] .work__row:hover .work__arrow {
  box-shadow: inset 0 0 0 var(--hairline)
    color-mix(in srgb, #ffffff 45%, transparent);
}

@media (prefers-reduced-motion: reduce) {
  .work__row,
  .work__row:hover {
    transform: none;
  }
}

/* ------------------------------------------------- Full-bleed and 2-up work */

/* The wrap is a centred 46rem column; anything that wants the whole width has
   to break out of it. Both rules below do that the same way: set an explicit
   width, then pull the element back by half the difference. --wide is named
   once so the width and the pull can never drift apart.

   .shell clips instead of scrolling because 100vw counts the scrollbar and
   the wrap does not — that few-pixel excess would otherwise show up as a
   horizontal scrollbar. `clip` and not `hidden`: hidden makes a scroll
   container and would break any position: sticky inside the shell. */

.shell {
  overflow-x: clip;
}

/* The band runs from the rail's edge to the window's, no longer stopping
   where the prose does. */
.marquee {
  --bleed: calc(100vw - var(--rail));
  width: var(--bleed);
  max-width: none;
  margin-inline: calc(50% - var(--bleed) / 2);
}

@media (min-width: 64rem) {
  /* Two cards abreast need more room than the reading column allows, so the
     Work section alone widens — clamped so it never ends up narrower than
     the column it broke out of, and never wider than 68rem. */
  .section--wide {
    --wide: clamp(
      var(--column),
      calc(100vw - var(--rail) - var(--space-5) * 2),
      68rem
    );
    width: var(--wide);
    max-width: none;
    margin-inline: calc(50% - var(--wide) / 2);
  }

  .work {
    grid-template-columns: repeat(2, minmax(0, 1fr));
  }

  /* Cards in the same row end on the same baseline even when one framing
     paragraph runs a line longer than its neighbour's. */
  .work__link {
    display: flex;
    flex-direction: column;
    height: 100%;
  }

  /* The paragraph takes the slack, so every frame in a row starts and ends at
     the same height whatever the copy does. */
  .work__framing {
    flex: 1 1 auto;
  }
}

@media (max-width: 768px) {
  .marquee {
    --bleed: 100vw;
  }
}

/* ------------------------------------------------------- Card end: platform */

/* The years are gone. What sits in their place answers the question a
   recruiter actually has at a glance — is this a phone or a browser — so it
   is set as a pill in the project's own colour rather than as quiet metadata
   in the muted grey the dates used. */

.work__kind {
  font-family: var(--font-mono);
  font-size: 0.625rem;
  letter-spacing: 0.08em;
  text-transform: uppercase;
  white-space: nowrap;
  padding: 3px 9px;
  border-radius: 999px;
  color: var(--project);
  background: color-mix(in srgb, var(--project) 12%, transparent);
  box-shadow: inset 0 0 0 var(--hairline)
    color-mix(in srgb, var(--project) 35%, transparent);
  transition: background var(--dur) var(--ease),
    box-shadow var(--dur) var(--ease), color var(--dur) var(--ease);
}

.work__row:hover .work__kind {
  background: color-mix(in srgb, var(--project) 22%, transparent);
  box-shadow: inset 0 0 0 var(--hairline)
    color-mix(in srgb, var(--project) 60%, transparent);
}

:root[data-theme="dark"] .work__kind {
  color: color-mix(in srgb, var(--project) 35%, #ffffff);
  background: color-mix(in srgb, #ffffff 8%, transparent);
  box-shadow: inset 0 0 0 var(--hairline)
    color-mix(in srgb, #ffffff 22%, transparent);
}

:root[data-theme="dark"] .work__row:hover .work__kind {
  background: color-mix(in srgb, #ffffff 16%, transparent);
  box-shadow: inset 0 0 0 var(--hairline)
    color-mix(in srgb, #ffffff 40%, transparent);
}

/* --------------------------------------------------- Cursor-tracked sheen */

/* --mx/--my are written by js/main.js on pointer move; the 50%/50% here is
   what the card falls back to before the first move, and permanently on
   touch and keyboard, where the glow simply sits centred. */

.work__row {
  position: relative;
}

/* The pointer-tracking sheen that used to wash across the card is gone with
   the card. It was lighting a surface, and the row paints none — on the page
   background it would have read as a coloured smear following the cursor
   around, which is not something the reference does. The lift and the hue
   change carry the hover now. */
/* ------------------------------------------------------------ Draft card */

/* UpJob has no case study yet, so its card is a <div>, not a link. It keeps
   the colour and the shape of the others but drops every affordance that
   would promise a destination. */

.work__row--draft {
  opacity: 0.72;
}

.work__row--draft:hover {
  transform: none;
}

.work__row--draft .work__link {
  cursor: default;
}

/* --------------------------------------------------------- Odd card out */

@media (min-width: 64rem) {
  /* With an odd number of projects the last card would sit alone in the left
     column against dead space. Spanning it across both reads as a deliberate
     closing note instead of a gap. */
  .work__row:last-child:nth-child(odd) {
    grid-column: 1 / -1;
  }
}

@media (prefers-reduced-motion: reduce) {
  .work__row::before {
    display: none;
  }
}
/* ==========================================================================
   Small screens: the top bar, the drawer, and in-page scrolling.

   Three things were actually broken here, not merely tight. The bar's two
   buttons carried the class .toggle, which no rule in this file ever defined,
   so they rendered as raw browser chrome. The theme button in the bar had no
   data-set-theme attribute, so main.js never bound it and it did nothing when
   pressed. And the bar sat above the drawer in the stack, so opening the menu
   slid the panel underneath its own control.

   The bar is now a header at the top of the screen. It holds the menu button
   and the name, nothing else — the theme control belongs inside the drawer,
   beside the navigation it relates to, where there is room to make it a real
   target.
   ========================================================================== */

/* In-page jumps glide instead of cutting. scroll-padding-top keeps the
   landing point clear of the fixed bar; the reduced-motion block near the top
   of this file already returns this to an instant jump for anyone who asks
   the system for less movement. */
html {
  scroll-behavior: smooth;
  scroll-padding-top: var(--space-5);
}

.railbar__btn {
  display: inline-flex;
  align-items: center;
  gap: 8px;
  min-height: 44px;
  padding: 0 16px 0 12px;
  border: var(--hairline) solid var(--rule);
  border-radius: 999px;
  background: var(--surface);
  color: var(--text);
  font-family: var(--font-body);
  font-size: 0.9375rem;
  font-weight: 500;
  line-height: 1;
  cursor: pointer;
  -webkit-tap-highlight-color: transparent;
  transition: background var(--dur-fast) var(--ease);
}

.railbar__btn .icon {
  width: 18px;
  height: 18px;
}

.railbar__btn:active {
  background: var(--rule);
}

.railbar__name {
  font-family: var(--font-display);
  font-size: 1rem;
  font-weight: 600;
  letter-spacing: -0.01em;
  color: var(--text);
  text-decoration: none;
}

@media (max-width: 768px) {
  /* The fluid scale bottoms out around 13px on a phone, which is where the
     "small and hard to read" came from. Raising only the two body steps
     leaves the display sizes alone, so the headline still fits its line. */
  :root {
    --step--1: 0.875rem;
    --step-0: 1.0625rem;
  }

  html {
    scroll-padding-top: 76px;
  }

  .railbar {
    position: fixed;
    inset-inline: 0;
    top: 0;
    bottom: auto;
    z-index: 45;
    height: auto;
    min-height: 60px;
    padding-block: 8px;
    padding-top: calc(8px + env(safe-area-inset-top, 0px));
    padding-inline: var(--space-4);
    border-top: 0;
    border-bottom: var(--hairline) solid var(--rule);
  }

  .shell {
    padding-top: calc(60px + env(safe-area-inset-top, 0px));
    padding-bottom: 0;
  }

  /* The drawer and its scrim now sit above the bar. */
  .rail {
    z-index: 60;
    width: min(86vw, 20rem);
    padding: var(--space-5);
    padding-top: calc(var(--space-5) + env(safe-area-inset-top, 0px));
    padding-bottom: calc(var(--space-5) + env(safe-area-inset-bottom, 0px));
    /* The desktop rail centres an 11rem column inside a 248px band. In a
       drawer this narrow that centring is what reads as ragged, because each
       block lands on its own left edge. One full-width column, one edge. */
    --rail-col: 100%;
  }

  body.nav-open .scrim {
    z-index: 55;
  }

  .rail__brand,
  .rail__group,
  .rail__foot {
    width: 100%;
    margin-inline: 0;
    text-align: left;
  }

  .rail__name {
    font-size: 1.375rem;
  }

  .rail__nav {
    margin-top: var(--space-4);
  }

  .rail__label {
    font-size: 0.75rem;
    letter-spacing: 0.1em;
    margin-bottom: 4px;
  }

  /* Every row a 48px target. Below that, a thumb misses as often as it hits. */
  .nav-row {
    grid-template-columns: 26px 1fr;
    gap: 12px;
    min-height: 48px;
    padding: 10px 12px;
  }

  .rail__gutter .icon {
    width: 20px;
    height: 20px;
  }

  .rail__text {
    font-size: 1rem;
  }

  /* The theme control, now that it lives here. .seg's own padding and gap are
     left untouched on purpose — the sliding chip is sized off them in calc(),
     and changing either would put it back out of register. Only the two
     buttons grow. */
  .rail__foot {
    padding-top: var(--space-4);
  }

  .rail__toggles {
    padding-inline: 0;
  }

  .seg__opt {
    width: 48px;
    height: 44px;
  }

  .seg__opt .icon {
    width: 20px;
    height: 20px;
  }
}

/* The clipboard confirmation. Sits above the drawer so it is still readable
   when the address is copied from inside the open menu. */
.toast {
  position: fixed;
  left: 50%;
  bottom: calc(var(--space-5) + env(safe-area-inset-bottom, 0px));
  translate: -50% 0;
  z-index: 90;
  max-width: calc(100vw - var(--space-5) * 2);
  padding: 10px 16px;
  border: var(--hairline) solid var(--rule);
  border-radius: 999px;
  background: var(--bg);
  box-shadow: var(--shadow);
  color: var(--text);
  font-family: var(--font-mono);
  font-size: 0.75rem;
  line-height: 1.3;
  text-align: center;
  opacity: 0;
  transform: translateY(8px);
  pointer-events: none;
  transition: opacity var(--dur) var(--ease), transform var(--dur) var(--ease);
}

.toast.is-on {
  opacity: 1;
  transform: none;
}
