/* =============================================================================
   sections.css — one heading per channel. Everything here obeys the four
   rules in tokens.css.
   ============================================================================= */

/* =============================================================================
   HERO — "MASTER IN"

   Freelance-first: the first screen states the offer, not the biography. The
   headline is the name, the line under it is what you can hire, and the two
   buttons are the two things a visitor might want. Everything else on the
   first screen is console readout.

   The background is ambient, not typographic. The previous version of this
   site had a wall of MOVING TYPE behind the headline and it was the first
   thing that had to go — two sets of words in one eyeline and neither wins.
   What is here instead is faceplate texture: a fixed grid, a slow amber bloom
   that tracks the pointer, and one scan line every twelve seconds. It carries
   no message, so it cannot compete with the one that does.
   ============================================================================= */

/* -----------------------------------------------------------------------------
   AMBIENT FIELD
   Full-viewport-width, not wrap-width: it is the surface the console is
   mounted on, and a texture that stops at a content column reads as a panel
   someone forgot to stretch.
----------------------------------------------------------------------------- */
.hero__bg {
  position: absolute; inset: 0;
  left: 50%; width: 100vw; transform: translateX(-50%);
  overflow: hidden;
  pointer-events: none;
  z-index: 0;
  /* fades out toward the foot so the legend sits on clean background */
  mask-image: linear-gradient(180deg, #000 30%, transparent 92%);
}
.hero__bg > * { position: absolute; inset: 0; display: block; }

.hero__grid {
  background-image:
    linear-gradient(90deg, var(--c-line) 1px, transparent 1px),
    linear-gradient(180deg, var(--c-line) 1px, transparent 1px);
  background-size: clamp(48px, 6vw, 92px) clamp(48px, 6vw, 92px);
  opacity: .5;
  /* a very slow drift — one cell every 40s, so it is felt rather than seen */
  animation: gridDrift 40s linear infinite;
}
@keyframes gridDrift {
  to { background-position: clamp(48px, 6vw, 92px) clamp(48px, 6vw, 92px); }
}

/* app.js writes --mx/--my as 0–100% of the hero box, lerped rather than set
   directly, so the bloom trails the cursor with weight instead of being
   welded to it. */
.hero__bloom {
  background: radial-gradient(
    38vw 38vw at var(--mx, 62%) var(--my, 38%),
    color-mix(in srgb, var(--c-accent) 14%, transparent),
    transparent 68%);
  opacity: .85;
}

.hero__scan {
  background: linear-gradient(180deg,
    transparent,
    color-mix(in srgb, var(--c-fg) 6%, transparent) 46%,
    color-mix(in srgb, var(--c-accent) 10%, transparent) 50%,
    transparent);
  height: 26%;
  inset: -26% 0 auto 0;
  animation: scan 12s var(--e-inout) infinite;
}
@keyframes scan {
  0%       { transform: translateY(0); opacity: 0; }
  8%       { opacity: 1; }
  55%, 100%{ transform: translateY(520%); opacity: 0; }
}

:root[data-theme='light'] .hero__grid { opacity: .8; }
:root[data-theme='light'] .hero__bloom { opacity: .6; }

/* The opening screen fills the viewport exactly — nothing from CH 01 shows
   until you scroll.

   It was 80svh, deliberately, so the next channel's header peeked as an
   invitation to scroll. That is a real technique and it was the wrong call
   here: an opening screen that leaks the next section's header reads as the
   page not fitting rather than as an invitation, and it undercuts the hero as
   a statement. 100svh (not vh) so mobile browser chrome cannot push the foot
   off-screen. min-height, not height, so a short landscape phone lets the
   content grow past the fold instead of clipping it. */
.hero {
  position: relative;
  min-height: 100svh;
  /* Two rows since the top status line moved to the footer: the name column
     stays centred (1fr) and the discipline legend holds the foot. */
  display: grid; grid-template-rows: 1fr auto;
  gap: var(--s-5);
  padding-block: calc(var(--nav-h) + clamp(18px, 3.4vh, 40px)) clamp(20px, 3.4vh, 40px);
}

/* Everything above the field. */
.hero > *:not(.hero__bg) { position: relative; z-index: 1; }

.hero__top { display: flex; align-items: center; gap: var(--s-4); }
.hero__mid { display: flex; flex-direction: column; justify-content: center; }
/* `fit-content` so the h1's BOX is the width of the wordmark rather than the
   width of the column — which is what lets the trace beneath it be measured
   against the name instead of against the page. */
.hero__name {
  font-size: var(--t-mega); font-weight: 700;
  line-height: var(--lh-tight); letter-spacing: var(--tr-mega);
  margin-left: -0.035em;
  width: fit-content; max-width: 100%;
}

/* -----------------------------------------------------------------------------
   THE OPENING SEQUENCE

   The boot curtain lifts and the hero assembles behind it in five beats —
   status line, pitch, trace, buttons, legend — with the name playing in
   per-character between beats one and two. The point is that arriving on the
   site is an EVENT with a shape, rather than a finished page that was already
   sitting there. app.js adds `.lit` to <body> when the curtain starts moving,
   which is what starts all of this.

   Held off `.js` for the usual reason: no scripting, no hidden content.
----------------------------------------------------------------------------- */
.js [data-hero] { opacity: 0; transform: translate3d(0, 22px, 0); }
.js body.lit [data-hero] {
  opacity: 1; transform: none;
  transition: opacity var(--d-4) var(--e-out) var(--hd),
              transform var(--d-5) var(--e-out) var(--hd);
}
.js [data-hero='1'] { --hd: 120ms; }
.js [data-hero='2'] { --hd: 380ms; }
.js [data-hero='3'] { --hd: 480ms; }
.js [data-hero='4'] { --hd: 560ms; }
.js [data-hero='5'] { --hd: 660ms; }

/* -----------------------------------------------------------------------------
   THE TRACE — a signal running under the pitch line.

   It is the one piece of pure decoration on the opening screen, and it earns
   its place by being the thing that keeps the hero alive between decodes: the
   slot resolves and then holds for two and a half seconds, and without this
   the screen is completely still for most of that time.
----------------------------------------------------------------------------- */
.hero__scrub {
  position: relative;
  height: 1px;
  /* Sits directly under the name, and runs exactly as wide as it: app.js
     measures the rendered wordmark and writes --name-w. A hard 520px was a
     number that happened to look right at one window size and at no other,
     and it stopped short of the name it belongs to. */
  max-width: var(--name-w, 520px);
  background: var(--c-line);
  overflow: hidden;
}
.hero__scrub::after {
  content: '';
  position: absolute; top: 0; bottom: 0; left: 0;
  width: 34%;
  background: linear-gradient(90deg, transparent, var(--c-accent), transparent);
  animation: trace 4.6s var(--e-inout) infinite;
}
@keyframes trace {
  0%       { transform: translateX(-100%); }
  55%, 100%{ transform: translateX(330%); }
}

/* -----------------------------------------------------------------------------
   The name plays in once, per character, rising out of its own line.

   Each WORD is the clipping box, not each character and not the whole line:
   clip per character and the letters shave each other's overhangs; clip the
   whole line and it stops wrapping correctly. Per word, descenders survive and
   the heading still breaks where it should.

   One-time on load, deliberately — the slot below it decodes continuously, and
   two things looping in the same eyeline compete.
----------------------------------------------------------------------------- */
.hero__name .nw {
  display: inline-block;
  overflow: hidden;
  vertical-align: bottom;
  padding-bottom: .1em;      /* room for descenders inside the clip */
  margin-bottom: -.1em;
}
.hero__name .nc { display: inline-block; }
.hero__name.split .nc { transform: translateY(110%); opacity: 0; }
.hero__name.split.in .nc {
  transform: none;
  opacity: 1;
  transition: transform 760ms var(--e-out) var(--d, 0ms),
              opacity 300ms linear var(--d, 0ms);
}
/* Was --t-xl across a 26ch measure, which at 1920 rendered a 45px paragraph
   four lines deep directly under the name — two enormous blocks of type
   competing for the same screen. One line, a step smaller, and the emphasis
   carried by the rotating slot instead of by size. */
.hero__pitch {
  margin-top: clamp(16px, 2vw, 26px);
  font-size: clamp(1.3rem, 2vw, 2.35rem);
  font-weight: 400;
  line-height: 1.3;
  letter-spacing: var(--tr-head);
  color: var(--c-muted);
}

/* -----------------------------------------------------------------------------
   THE SLOT — a console readout that scrambles and locks onto each value.

   It is a fixed-width monospaced display, and that is the point: a proportional
   slot has to either stay as wide as "applications" (leaving a hole behind
   every shorter word) or resize on every cycle (making the rest of the sentence
   jump). A readout is *supposed* to be a fixed aperture with the value centred
   in it, so the constant width stops being a compromise and becomes the design.

   Monospace also keeps the scramble rock-steady — random glyphs occupy exactly
   the width of the letters they resolve into, so nothing twitches mid-decode.

   Per rule 1 this is console chrome rather than prose, so mono + uppercase is
   the correct treatment, not an exception to it.
----------------------------------------------------------------------------- */
/* No box. The framed monospace aperture existed only to hold a constant width
   while a single noun swapped inside a sentence that continued after it. Now
   the whole phrase changes and it is the last thing on the line, so there is
   nothing downstream to protect from a width change — the box was solving a
   problem that no longer exists, and without it the line reads as one
   sentence instead of a sentence with a widget dropped into it. */
/* Only the two variable halves carry weight. "that" is a connective — it does
   not change, it is not a claim, and emphasising it made the whole line read
   as one bold slab instead of `noun that claim`.

   tabular-nums keeps the digits in the scramble a fixed width, which stops
   the second half twitching sideways while the first half is still resolving. */
.slot { font-weight: 400; font-variant-numeric: tabular-nums; }
.slot b { color: var(--c-fg); font-weight: 600; }
/* Mid-decode the characters are noise, so they step back to muted until the
   line resolves. */
.slot.decoding b { color: var(--c-muted); }

/* Visually hidden, still announced. */
.sr {
  position: absolute; width: 1px; height: 1px;
  padding: 0; margin: -1px; overflow: hidden;
  clip-path: inset(50%); white-space: nowrap; border: 0;
}
.hero__cta { display: flex; gap: var(--s-3); flex-wrap: wrap; margin-top: clamp(24px, 3vw, 38px); }

/* The channel legend along the foot of the opening screen: what I am on the
   left, where else I am on the right. */
/* The foot legend is chrome, not content — the same order of thing as the nav
   strip at the other end of the screen, and it reads as a pair with it. It sits
   inside `.wrap`, so it is pulled back out by the DIFFERENCE between the two
   gutters, which lands it exactly on the chrome inset at every width without
   needing to know either value. */
.hero__bottom {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-4) var(--s-6); flex-wrap: wrap;
  padding-top: var(--s-4);
  border-top: 1px solid var(--c-line);
  margin-inline: calc(var(--gut-chrome) - var(--gut));
}
/* -----------------------------------------------------------------------------
   THE DISCIPLINES, LIT IN TURN.

   Four labels, one lamp travelling along them — the same idea as the lit
   segment on a meter, applied to words. Each label runs the same animation on
   a cycle four steps long and starts one step later than the last, so exactly
   one is up at any moment and the sequence loops without a controller.

   The lift is to --c-fg with a soft white bloom, NOT to amber: rule 4 keeps
   the accent for state, and "Producer" is not a state, it is a fact that is
   true all four seconds. The lamp is what moves; the meaning does not.

   THE LIT WORD FOCUSES IN. It starts its moment blurred and resolves sharp
   about a third of a second later, so the lamp does not just switch a word on,
   it brings it into focus. Nothing else on the row does anything: the word being
   left behind simply dims, and NOTHING EVER MOVES.

   THAT LAST SENTENCE IS THE WHOLE LESSON, AND IT COST TWO REJECTED BUILDS.
   v1 blurred each word in and out with a 4px slide; the user said it *"does not
   feel like it is moving from one text to another"*. v2 fixed that properly —
   each word runs the same 4-step cycle offset by one step, so a word's local 6%
   is the same instant as its left neighbour's 31%; parking the entry peak at 6%
   and the exit at 30% put the outgoing word smearing right in the very frames
   the incoming one smeared in from the left, exit quieter so the pair read as a
   wave rather than a collision. Measured, two words carried a blur on 13 of 75
   frames. It was a travelling lamp and it was correct.

   It was also *"bad... distracting"*, and the reason is nothing to do with the
   timing. This legend sits under a 176px headline on the OPENING SCREEN, in the
   corner of the eye of someone reading the name and the pitch. DISPLACEMENT
   there is movement you have to actively ignore. A blur that resolves does not
   change where anything is, so it can be noticed without demanding to be
   watched — which is what was actually wanted: *"just motion blur kind of just
   to see"*.

   So: keep the blur, lose the travel. If this is ever revisited, the thing to
   tune is the blur's amount or how long it takes to resolve. DO NOT REINTRODUCE
   `transform` HERE.

   The blur peaks at 2.4px. 3.4 was tried in v1 and turned an 11px mono word into
   a grey bar; below ~2 it is not visible at this size. It runs 3%→14% — clear
   of both ends of the lit phase, so the word is perfectly sharp for the whole
   time it is actually up, and every other word on the row is sharp always.

   The keyframes below are deliberately PARTIAL: a property interpolates between
   the nearest stops that mention it, so the colour track (3→8→20→27) and the
   blur track (3→6→14) run on their own schedules without either having to
   restate the other's values at four places. The colour track is byte-for-byte
   the one that shipped with the landing screen and was signed off; only the blur
   is new.
----------------------------------------------------------------------------- */
.hero__discs { display: flex; gap: var(--s-2) var(--s-5); flex-wrap: wrap; }
.hero__discs span {
  animation: discLamp calc(4 * var(--disc-step, 1.5s)) var(--e-inout) infinite;
  animation-delay: calc(var(--i, 0) * var(--disc-step, 1.5s));
  will-change: auto;
}
.hero__discs span:nth-child(1) { --i: 0; }
.hero__discs span:nth-child(2) { --i: 1; }
.hero__discs span:nth-child(3) { --i: 2; }
.hero__discs span:nth-child(4) { --i: 3; }
@keyframes discLamp {
  0%, 3%     { color: var(--c-muted); text-shadow: none; filter: blur(0); }
  6%         { filter: blur(2.4px); }
  8%         { color: var(--c-fg); text-shadow: 0 0 14px var(--disc-glow); }
  14%        { filter: blur(0); }
  20%        { color: var(--c-fg); text-shadow: 0 0 14px var(--disc-glow); }
  27%, 100%  { color: var(--c-muted); text-shadow: none; filter: blur(0); }
}
/* `--motion` only reaches transitions, so a forever-looping keyframe has to be
   stopped by name, and this one never had been — a gap that predates the blur
   pass and outlived it. The legend still reads; it just stops being lit in turn. */
@media (prefers-reduced-motion: reduce) {
  .hero__discs span { animation: none; filter: none; color: var(--c-muted); }
}
.hero__links { display: flex; gap: var(--s-2) var(--s-5); flex-wrap: wrap; align-items: center; }
.hero__links a { display: inline-flex; align-items: center; gap: 6px; color: var(--c-muted); transition: color var(--d-1); }
.hero__links a:hover { color: var(--c-fg); }
.hero__links svg { opacity: .5; transition: opacity var(--d-1), transform var(--d-2) var(--e-out); }
.hero__links a:hover svg { opacity: 1; transform: translate(2px, -2px); }

@media (max-width: 760px) {
  .hero__cta .btn { flex: 1 1 auto; }
  .hero__bottom { flex-direction: column; align-items: stretch; gap: var(--s-4); }
}

/* -----------------------------------------------------------------------------
   THE HERO ON A PHONE.

   THE PROBLEM WAS NOT THE LAYOUT, IT WAS THAT THE CONTENT WAS TOO SMALL FOR THE
   SCREEN. Measured at 390×844: the name/pitch/buttons came to 217px of content
   centred inside a 606px row, which left ~194px of nothing above it and ~219px
   below — a quarter of the screen empty between the buttons and the legend, and
   the whole thing reading as a screen that had failed to finish loading.

   Redistributing that emptiness would only have moved it. `--t-mega`'s floor is
   2.9rem, so the name came out at 46px — a size chosen so it would survive a
   narrow column, which it does by being small enough to look incidental. It is
   the largest thing on the site and on the device most people will see it on it
   was barely larger than the pull quote three sections down.

   So the name gets a floor of its own here and WRAPS TO TWO LINES, which is
   what a name wants to do on a phone anyway. That is ~60px of the gap spent on
   the one thing the screen is actually for. `initSplit()` clips per WORD, so a
   wrap between them is free; `width: fit-content` then measures the trace under
   the longer line rather than the whole column, which is the behaviour it always
   had, just against a different word.
----------------------------------------------------------------------------- */
@media (max-width: 620px) {
  .hero__name {
    font-size: clamp(3.3rem, 15vw, 4.4rem);
    letter-spacing: -0.03em;
  }

  /* THE PLATFORM LINKS COME OFF THE OPENING SCREEN ENTIRELY.

     Five destinations with arrows, wrapping to two ragged rows, sitting under
     the four disciplines — nine items of small mono type stacked at the foot of
     a phone hero, which is the most valuable screen on the site. Every one of
     them is in the menu's own icon bay, one tap away behind a control that is
     on screen the whole time. Removing them is not losing anything; it is
     declining to print the same five links twice on the first thing anybody
     sees. `display: none` rather than removed from the markup, because on a
     desktop the legend is a single tidy row and it stays exactly as it was. */
  .hero__links { display: none; }

  /* WHICH LETS THE FOUR DISCIPLINES BE ONE LINE, and one line is what they
     always wanted to be. They were a 2×2 grid because at `--t-xs` the four
     labels measure 306.8px and three gaps took them to 354.8 against a 354px
     column — a miss of less than a pixel. With the links gone the legend is the
     only thing on the row, so it can afford a size of its own: the type scales
     on `vw` and the tracking comes in, which buys the ~40px needed at 390 and
     still fits at 320.

     A 2×2 grid was never wrong, it was just two rows pretending to be a block.
     One row of four reads as a legend — which is what it is. */
  .hero__discs {
    display: flex; flex-wrap: nowrap;
    justify-content: space-between;
    gap: 8px;
  }
  /* THE SIZE GOES ON THE SPAN, NOT THE CONTAINER. Each label is `.mono`, which
     sets `font-size: var(--t-xs)` on the element itself — a size on the flex
     parent is inherited and therefore loses to it, so the first version left
     every label at 11.36px and the four of them measured 307px against a 307px
     column at 342: they touched, edge to edge, with no space between the words
     at all. Sized here they actually scale, and the row keeps its gaps. */
  .hero__discs span {
    white-space: nowrap;
    font-size: clamp(8.6px, 2.6vw, 11.36px);
    letter-spacing: .05em;
  }

  .hero__bottom { gap: var(--s-3); }

  /* THE CONTENT SITS LOW, AND THE AIR GOES ABOVE IT.

     Centred in the 1fr row, the block left ~163px empty above and ~188px below —
     near-equal gaps, which is precisely what makes a screen read as adrift
     rather than composed. Anchoring it to the bottom of its row puts the name,
     the pitch and both buttons in the lower half and hands the whole remainder
     to the top, where the faceplate grid and the bloom already live and empty
     space is texture rather than absence. It also brings the two buttons within
     reach of a thumb, which on the device this rule exists for is not a small
     thing. */
  .hero__mid {
    justify-content: flex-end;
    padding-bottom: clamp(56px, 11vh, 104px);
  }

  /* BOTH BUTTONS FULL WIDTH, ALWAYS. `flex: 1 1 auto` lets them share a row the
     moment one fits, which measured as stacked at 360 and 390 and side by side
     at 414 — two phones a hand's width apart getting different layouts for no
     reason the reader can see. Full width is also the shape a thumb expects at
     the bottom of a hero. */
  .hero__cta .btn { flex: 1 1 100%; }
}


/* =============================================================================
   CH 01 — EXPERTISE  (the editorial)

   The lead channel is written as a PIECE, not as a rack: kicker, headline,
   deck, a byline rule, then a measured body column with a key-facts card
   holding the margin beside it. It is the only place on the site where the
   visitor is being introduced to a person rather than shown a list of things,
   and an article is the layout that says so before a word is read.

   WHY THIS IS NOT `.art` (CH 05 Studio). Both are "article-shaped", so they
   have to be different registers or the site repeats itself. `.art` is a photo
   spread — a title sharing a top edge with a contact sheet. This is a long-read:
   one column of prose you actually read top to bottom, subheads numbered like
   the channels around it, a pull quote on the site's own amber cue mark, and a
   sticky sidebar. Same grammar, different instrument.

   THE MEASURE IS THE WHOLE JOB. Body copy sits at ~62ch and nothing is allowed
   to widen it — the aside takes the leftover, and past the breakpoint it drops
   under the prose rather than squeezing it. A 110ch line of prose is the single
   fastest way to make an article look like a wall.
   ============================================================================= */

.ed { display: grid; }

/* ---- the masthead: kicker → headline | deck → byline rule ----------------
   THE HEADLINE AND THE DECK SHARE A ROW ON A WIDE SCREEN. Stacked, the
   headline runs to 15ch and the deck to 46ch, so everything above the byline
   rule lived in the left half and the right half of the masthead was empty —
   a full-width rule under a half-width column, which reads as a layout that
   ran out rather than one that was composed. Side by side they hold the whole
   measure, the deck lands level with the headline's cap line, and the rule
   underneath finally has something spanning it. */
.ed__kicker { display: flex; align-items: center; gap: var(--s-3); flex-wrap: wrap; }
.ed__kicker i { font-style: normal; color: var(--c-line-2); }

/* ← REVERT POINT — "Profile · Software engineering" IS HIDDEN EVERYWHERE.

   It was taken off the phone first, where it sat directly under a sticky head
   plate reading "CH 01 EXPERTISE" in the same mono at the same size and the same
   faint grey, and read as the plate having wrapped. The owner then asked for it
   on the desktop too, and the same argument turns out to hold there: the plate is
   14px above it there as well, and between the two of them they say "this is CH
   01, it is a profile, the subject is software engineering" — which the headline
   ("The engineer behind the console"), the deck's first sentence and `.ch__meta`'s
   own "Profile · 2 min read" have all already said.

   `display: none`, and the markup stays exactly where it is. Restoring it is
   deleting this one rule. It lives here rather than in mobile.css precisely
   because it is NOT a phone rule — mobile.css has no code path to a desktop. */
.ed__kicker { display: none; }

.ed__masthead { display: grid; gap: clamp(18px, 2vw, 28px); margin-top: clamp(14px, 1.6vw, 22px); }
@media (min-width: 1000px) {
  .ed__masthead {
    grid-template-columns: minmax(0, 1.05fr) minmax(0, 1fr);
    column-gap: clamp(48px, 5.5vw, 104px);
    align-items: start;
  }
  /* Optical: the deck's first line sits on the headline's cap line rather than
     on its ascender, which is where a lining-up-by-box would put it. */
  .ed__deck { margin-top: .34em; }
}

.ed__title {
  font-size: clamp(2.05rem, 4.4vw, 3.9rem);
  font-weight: 600;
  line-height: 1.02;
  letter-spacing: var(--tr-mega);
  margin: 0 0 0 -0.03em;             /* optical, as everywhere at this size */
  max-width: 14ch;
  text-wrap: balance;
}

/* The standfirst. Bigger than body, quieter than the headline — it exists to
   get you into the first paragraph. */
.ed__deck {
  font-size: var(--t-lg);
  line-height: 1.4;
  letter-spacing: var(--tr-head);
  color: var(--c-muted);
  max-width: 48ch;
  text-wrap: pretty;
}
.ed__deck b { color: var(--c-fg); font-weight: 600; }

/* The masthead rule. This replaced a mono byline strip (name · role · city ·
   updated) which read as metadata but was really the page repeating itself —
   the name is the wordmark, the role is the kicker directly above it, and the
   city is in the deck's own first sentence. What the strip was actually doing
   was drawing a line under the head, so now it draws a line under the head. */
.ed__rule {
  height: 1px; border: 0;
  margin: clamp(30px, 3.4vw, 46px) 0 0;
  background: var(--c-line);
}

/* ---- the spread ---------------------------------------------------------- */
.ed__grid { display: grid; gap: clamp(34px, 4vw, 56px); margin-top: clamp(50px, 5vw, 80px); }

@media (min-width: 1000px) {
  .ed__grid {
    /* Card widened from clamp(250,23vw,330) — it carries six facts now, and at
       330px "Associate Engineer, Worldline" wrapped to two lines while the
       body column beside it was leaving ~250px of its own width unused. */
    grid-template-columns: minmax(0, 1fr) clamp(300px, 27vw, 420px);
    /* Tightened from clamp(40, 4.4vw, 84): the gutter was doing the same job
       twice, since the prose already stops at its own measure well before the
       card. Two gaps in a row is what made the spread look like it had come
       apart rather than been set. */
    column-gap: clamp(34px, 3.2vw, 58px);
    align-items: start;
  }
}

/* THE MEASURE LIVES ON THE PARAGRAPH, NOT ON THE COLUMN — and that is a real
   bug fixed, not a preference. It was `max-width: 78ch` on `.ed__body`, which
   sets no font-size of its own: `ch` resolves against the element's OWN font,
   so the measure was being counted in body-copy characters while constraining
   text set at `--t-md`. The two diverge as the viewport grows, which is why the
   column came out the same 776px at both 1440 and 1600 no matter what the
   number said. On `.ed__p` the unit finally means what it reads as.

   80ch, up from an effective ~66. The column is ~840px at 1440 and the prose
   was spending ~660 of it, leaving a band of nothing before the card that read
   as a mistake rather than as a gutter. 80 is the long end of comfortable,
   which is why the line-height is 1.66 rather than 1.5 — a longer line needs a
   taller lane to find its way back to the start of the next one. Do not push
   past this without raising the type size to match. */
.ed__body { max-width: none; }
.ed__p {
  color: var(--c-muted);
  font-size: var(--t-md);
  line-height: 1.66;
  margin-top: var(--s-5);
  max-width: 80ch;
  text-wrap: pretty;
}
.ed__p b { color: var(--c-fg); font-weight: 600; }
.ed__p a { color: var(--c-fg); }

/* THE DROP CAP — the one ornament in the piece, and the oldest signal in
   print for "this is where you start reading". Sized in `em` so it scales
   with the fluid body type, and `line-height: .8` because ::first-letter
   inherits the paragraph's 1.66 otherwise and the cap floats three lines
   deep with a hole above it. --c-fg, not amber: rule 4, a cap is not a state. */
.ed__p--cap { margin-top: var(--s-5); }
.ed__p--cap::first-letter {
  float: left;
  font-size: 3.3em;
  line-height: .8;
  font-weight: 600;
  color: var(--c-fg);
  letter-spacing: var(--tr-mega);
  padding-right: .085em;
  padding-top: .04em;
}

/* Numbered subheads. Same index-then-label grammar as the channel heads and
   the process stages, so a reader who has scrolled the rack already knows how
   to read them. */
.ed__h {
  display: flex; align-items: baseline; gap: var(--s-3);
  margin-top: clamp(36px, 6vw, 77px);
  font-size: var(--t-lg);
  font-weight: 600;
  letter-spacing: var(--tr-head);
}
.ed__h .mono { flex: none; }
.ed__h + .ed__p { margin-top: var(--s-4); }
/* The first subhead already has the byline rule and the grid's own top margin
   above it — its 56px would stack on both and open a hole under the masthead,
   and it would also stop the prose starting level with the card beside it. */
.ed__body > .ed__h:first-child { margin-top: 0; }
/* The subheads that sit outside the measured column. They used to take a BIGGER
   top margin than the in-column ones — clamp(48, 5.4vw, 76) — on the theory that
   a full-width block wants more air above it. It does not: "01 Experience" sits
   directly under the masthead rule, which is already a separator with its own
   space above it, so the extra margin read as the head and the piece drifting
   apart. Falls back to `.ed__h`'s own top margin now; only the bottom is
   special, because a full-width plate needs the gap under its label. */
.ed__h--wide { margin-bottom: var(--s-5); }

/* The pull quote, on the same 2px amber cue mark that holds the Statement.
   Deliberately the site's existing device rather than a new one — three uses
   of one idea is a language, three ideas is noise. */
/* Sized DOWN and widened: at 1.62rem over a 26ch measure this ran four deep and
   took as much vertical room as the section it was quoting from — a pull quote
   that is bigger than the argument around it stops being an aside and starts
   being the point. Two lines on a 46ch measure is the whole job. */
.ed__q {
  position: relative;
  margin: clamp(26px, 2.8vw, 38px) 0 0;
  padding-left: clamp(16px, 1.7vw, 22px);
  font-size: clamp(1.02rem, 1.25vw, 1.24rem);
  font-weight: 500;
  line-height: 1.42;
  letter-spacing: var(--tr-head);
  color: var(--c-fg);
  max-width: 46ch;
  text-wrap: pretty;
}
.ed__q::before {
  content: '';
  position: absolute; left: 0; top: .1em; bottom: .1em;
  width: 2px; border-radius: 2px;
  background: var(--c-accent);
}
.ed__q + .ed__h { margin-top: clamp(34px, 6vw, 77px); }

/* ---- the margin card ----------------------------------------------------- */
/* Sticky, because it is reference material: it should still be beside you at
   the end of the piece. Offset clears the nav AND the channel's own sticky
   head plate, which is the thing it would otherwise slide under.

   DENSITY IS THE POINT, and this went the wrong way once. An intermediate
   version answered "give it more space" with ~34px of padding and 22px rows,
   which pushed it to four facts and made a reference card that said LESS while
   taking MORE room. A margin card is a spec plate: it is glanced at, and the
   thing that makes it worth its column is how much it can answer at a glance.
   So the padding came back down, the card got WIDER instead, and the space
   bought six facts rather than air between four.

   `--pad` is a variable because the head rule has to break out of exactly the
   same value; hard-coding it twice is how the rule ends up one pixel off. */
/* HOW THE PINNED CARD IS POSITIONED — three attempts, keep the third.

   `position: sticky` can only name one edge, and a percentage in `top` resolves
   against the containing block rather than the viewport, so "centred in what is
   left of the screen" cannot be written directly.

   1. A fixed offset under the nav left the card hard against the head plate
      with the rest of the screen empty below it.
   2. A full-height sticky RAIL with the card centred inside it is exactly even,
      but the rail was ~850px against a ~1100px column, so it ran out of travel
      and scrolled away a quarter of the way down the piece. Don't reintroduce.
   3. Solving the offset for equal gaps — `top = (100svh - cardH + headBottom)/2`
      — worked, but needed the card's height as a hand-measured `--card-h`
      constant, and both gaps came out around 200px on a laptop: even, but so
      large that the card read as small and adrift in its own column.

   NOW THE CARD IS SIZED TO THE SCREEN INSTEAD OF CENTRED IN IT. The offset is
   `stick + gv` and the height is everything left after taking `gv` off each
   end, so the two gaps are small AND equal by construction. No magic constant
   survives — the height is derived, not measured. */
.ed__aside {
  --pad: clamp(20px, 2vw, 26px);
  /* the head plate: nav + its own 14/14 padding around a line-height-1 label */
  --stick: calc(var(--nav-h) + 44px);
  /* the small margin the card leaves against the screen, top and bottom */
  --gv: clamp(16px, 2.4vh, 30px);

  /* THE CARD FILLS THE SCREEN INSTEAD OF BEING CENTRED IN IT.

     It used to be a 535px card solved onto a centred offset — even gaps, but
     both of them enormous (~200px) on a laptop, so the card read as small and
     adrift in its column. Sizing it to the free height and leaving `--gv` at
     each end makes the gaps small AND equal by construction: the offset is
     `stick + gv` and the height is everything left after both. No `--card-h`
     constant to keep in step any more — the old centring maths needed the
     card's height as a magic number, and this needs nothing.

     `min-height`, not `height`: on a short window the six rows are taller than
     the space and the card must be allowed to grow past it rather than clip. */
  position: sticky;
  top: calc(var(--stick) + var(--gv));
  min-height: calc(100svh - var(--stick) - var(--gv) * 2);
  display: grid;
  /* THE CARD'S PLATE ONLY EXISTS WHILE IT IS PINNED — the plate, not the card.

     THE FACTS NEVER DISAPPEAR — ONLY THE PANEL UNDER THEM DOES. The first go at
     this faded the whole card out, which meant six real answers about the person
     you are reading about vanished for most of the section. What actually wants
     to come and go is the PLATE: unpinned, the card is a bordered box sitting in
     a column competing with the masthead for the first read, and riding down past
     the end of the section like something that has come loose. Pinned, it should
     look mounted. So the background and the border are what ramp, and the type
     stays put and legible from the moment it enters the screen — the panel builds
     itself under the words as it locks on.

     `--fade` is written by app.js from the same scroll position everything else
     on the page is driven from: it ramps 0 → 1 over the last ~180px before the
     card locks to the top of the screen, holds at 1 for the whole of its stick,
     and ramps back to 0 over the ~180px after it releases. Coupling it to scroll
     rather than to a transition is what makes it read as the card being brought
     up on the desk and taken back down — the reader's hand is doing it.

     NO TRANSITION HERE, deliberately, and for the same reason `.ch__rule`'s fill
     has none: the value is rewritten every frame the page moves, so a transition
     would put the panel permanently behind the scroll driving it.

     Defaults to 1 so no-JS, reduced motion and the stacked layout below 1000px
     all get a plain, fully painted panel. */
  /* ONLY THE FILL RAMPS. The border, the radius and the internal hairlines stay
     exactly as they are the whole way through — the card keeps its outline and
     its structure, and what arrives underneath is the panel it is mounted on. */
  background: color-mix(in srgb, var(--c-panel-2) calc(var(--fade, 1) * 100%), transparent);
  border: 1px solid var(--c-line);
  /* The facts take the slack and spread through it; the head stays at the top
     and the résumé button at the foot. Without the `1fr` the extra height just
     pools under the button and nothing looks any different. */
  grid-template-rows: auto 1fr auto;
  padding: var(--pad);
  border-radius: var(--r);
  overflow: hidden;          /* the grid below is inset:0 and must not escape */
  isolation: isolate;
}

/* THE PINNED CARD GETS A FLOOR THAT MOVES.

   Once it sticks, the card is the only thing on screen holding still while the
   page streams past — which is what made it feel dead rather than pinned. So it
   gets the hero's faceplate grid and app.js slides that grid against the scroll
   from the page's single scroll handler (`--gy`, wrapped into the 34px repeat).
   The card holds; the surface under it does not.

   Masked to a corner rather than tiled flat across the whole panel: a full
   even grid behind six rows of text reads as graph paper and fights the labels.
   Fading it out toward the reading edge leaves texture where the eye is not. */
.ed__aside::before {
  content: '';
  position: absolute; inset: 0; z-index: -1;
  background-image:
    linear-gradient(90deg, var(--c-line) 1px, transparent 1px),
    linear-gradient(180deg, var(--c-line) 1px, transparent 1px);
  background-size: 34px 34px;
  background-position: 0 var(--gy, 0px);
  mask-image: radial-gradient(130% 80% at 100% 0%, #000 0%, transparent 68%);
  /* The faceplate texture is part of the FILL, not part of the frame, so it
     comes and goes with the background rather than with the border. */
  opacity: var(--fade, 1);
  pointer-events: none;
}

/* The head rule breaks out of the card's padding so it reads as a divider in a
   plate rather than as an underline under a word. Same move the channel heads
   make against `.wrap`. */
.ed__aside__t {
  padding-bottom: 13px;
  margin-inline: calc(var(--pad) * -1);
  padding-inline: var(--pad);
  border-bottom: 1px solid var(--c-line);
}

/* `space-between` so the rows share the slack the `1fr` row hands them, rather
   than stacking at the top of it and leaving a hole above the button. */
.ed__facts { display: grid; align-content: space-between; margin: 0; }
/* Stacked label-over-value, not two columns: even at 420px a label gutter
   would leave the values on ~20ch of ragged wrapping. */
.ed__fact {
  display: grid; gap: 5px;
  padding-block: 13px;
  border-bottom: 1px solid var(--c-line);
}
.ed__fact dd {
  margin: 0;
  color: var(--c-fg);
  font-size: var(--t-sm);
  line-height: 1.38;
  letter-spacing: var(--tr-body);
}
.ed__aside .btn { width: 100%; margin-top: var(--s-5); }

@media (max-width: 999px) {
  /* Under the spread the card is no longer in a margin, so there is nothing to
     stick to and nothing to centre it against — it becomes a plain panel under
     the prose. app.js reads the computed `position` rather than duplicating
     this breakpoint, so the fade turns itself off here too. */
  .ed__aside { position: static; }
}

/* THE FACTS GO LABEL-LEFT ON A PHONE.

   TWO COLUMNS WAS TRIED FIRST AND MADE IT WORSE, which is worth recording
   because the arithmetic looked obvious: six stacked rows at 338px seemed like
   an easy halving. It is not — at 140px a column the values wrap to THREE lines
   each ("Payments · Backend · Full-stack · AI"), so two columns of three lines
   came to 580px against the 701 it was meant to fix. Measured, not guessed.

   Label-left is what actually shortens it. The mono labels top out at 68px
   ("Core stack"), so a 76px gutter leaves ~250px for the value — one or two
   lines instead of three — and the card reads as a spec strip, which is what
   "At a glance" is asking for. The original comment on `.ed__fact` says a label
   gutter would strand the values on ~20ch, and that was true of the 300px MARGIN
   COLUMN it was written for; below 1000px this is a full-width panel and the
   sum is different. */
@media (max-width: 620px) {
  /* `space-between` exists to share a STICKY card's leftover height between its
     rows, and `min-height: 100svh - …` is what creates that leftover. Neither
     survives the card going static: the panel was still 701px tall for ~316px of
     rows, holding a screen-height reservation it no longer sticks in. Both go,
     and the block is sized by its contents like any other panel. */
  .ed__aside { min-height: 0; }
  .ed__aside,
  .ed__facts { align-content: start; }
  .ed__fact {
    grid-template-columns: 76px minmax(0, 1fr);
    align-items: baseline;
    column-gap: var(--s-3);
    padding-block: 11px;
  }
  .ed__aside .btn { margin-top: var(--s-4); }
}

/* -----------------------------------------------------------------------------
   THE KIT — skills as a patch bay.

   It was six `.spec` rows of comma-separated strings, which is the format a
   CV uses because a CV has no other option. A page does: each row is a labelled
   BANK OF CHIPS, so a reader scanning for one word finds a discrete object
   rather than parsing a sentence. Rule 1 holds — the bank label is mono because
   it says what the row is, the chips are content and stay sentence case.

   A CHIP CARRIES A BRAND MARK ONLY WHERE ONE HONESTLY EXISTS. There is no logo
   for "REST APIs" or "PCI-DSS", and the chip is built so a missing mark is a
   non-event: the icon is a child, not a background, so the label simply sits
   alone and the row stays even.

   REFACTORED AGAIN for hierarchy and for consistency with what sits under it.
   v1 was five full-width rows behind a 14ch mono label gutter, and it had three
   problems: the label — the thing you actually scan by — was the faintest and
   smallest text in the row; the gutter left a fixed dead column down the left;
   and the chips ran to a ragged right edge across a 1200px row, so there was
   no shape to any of it.

   Now it is a PLATE of cells, the same object as the Credentials plate right
   below it and the Freelance rack above — one visual language for "a set of
   related things" instead of three. Label ABOVE its chips, not beside them, so
   the chips get the cell's whole width and every group reads as a block.
   The label is `--c-fg`: same call as the footer columns, the category is the
   loud anchor and the items sit a step back.

   FIVE CELLS IN A TWO-COLUMN GRID leaves an odd one out, so the last cell
   spans the full width — Cloud/DevOps has the most chips and is the one that
   wants the room, and the plate stays a rectangle.

   REFACTORED A THIRD TIME, into a SOLO DESK. The five-cell plate was legible
   but it was still a table — five headings, five blocks, ~550px of page, and
   no way to answer "what does he write the backend in" without reading all of
   it. Now the whole stack is one dense bank with a row of solo buttons over
   it: press one and that category lights while the rest mute. That is what the
   solo row on a mixing desk does, so the interaction IS the concept rather
   than a widget bolted onto it, and it costs about a third of the height.

   MUTED, NOT REMOVED. Filtering by hiding would reflow twenty-six chips on
   every press — the block would jump and you would lose your place. Dimming
   keeps the bank's shape absolutely still, so the eye tracks what lit up
   instead of re-reading a new arrangement.
----------------------------------------------------------------------------- */
/* Same gap under its heading as `.cred` uses — the bank was sitting straight
   under "Skills & tooling" while Credentials had breathing room, so two
   consecutive parts of one column were spaced differently. */
.kit {
  margin-top: clamp(18px, 2vw, 26px);
  border: 1px solid var(--c-line);
  border-radius: var(--r);
  overflow: hidden;
  background: var(--ch-bg);
}

/* The bar is `.js`-gated: a button that cannot do anything is worse than no
   button. Without scripting the inline `.kit__lbl` markers still group the
   bank, so nothing is lost — the desk just becomes a labelled list. */
.kit__bar { display: none; }
.js .kit__bar {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-4) var(--s-5); flex-wrap: wrap;
  padding: clamp(12px, 1.4vw, 16px) clamp(16px, 1.8vw, 22px);
  border-bottom: 1px solid var(--c-line);
  background: var(--c-glass);
}
.solo__set { display: flex; flex-wrap: wrap; gap: 6px; }
.solo {
  padding: 6px 11px;
  border: 1px solid transparent;
  border-radius: var(--r-ctl);
  font-family: var(--f-mono); font-size: var(--t-xs); font-weight: 500;
  letter-spacing: var(--tr-label); text-transform: uppercase;
  line-height: 1;
  color: var(--c-faint);
  transition: color var(--d-2), border-color var(--d-2), background var(--d-2);
}
.solo:hover { color: var(--c-fg); }
/* The lit solo button is the one place amber belongs here — it is state, and
   it is the same "which channel is armed" signal the nav and the head plates
   already use. */
.solo.on {
  color: var(--c-accent-txt);
  border-color: color-mix(in srgb, var(--c-accent) 40%, transparent);
  background: color-mix(in srgb, var(--c-accent) 10%, transparent);
}
.kit__count { color: var(--c-faint); }
.kit__count b { color: var(--c-fg); font-weight: 500; }

.kit__cloud {
  display: flex; flex-wrap: wrap; align-items: center; gap: 8px;
  padding: clamp(18px, 2vw, 26px);
}

/* The inline group markers exist ONLY for the no-script case, where they are
   the whole grouping. With the bar present they are actively wrong: a bare
   "LANGUAGES" sitting in the same wrapping row as the chips reads as a label
   that has fallen inside the bank rather than as a heading over it — at a
   glance it looks like another chip, or like a chip's title has leaked out.
   The bar above already names every category, so with JS they go. */
.kit__lbl {
  color: var(--c-faint);
  padding-right: 4px;
}
.js .kit__lbl { display: none; }

/* SOLO. Everything that is not the armed category drops back; nothing moves.
   `[data-cat='all']` matches nothing here, so the default state is simply the
   whole bank at full strength with no rule doing any work. */
/* Values only — the timing lives on `.chip`. See the note there. */
.kit[data-cat]:not([data-cat='all']) .chip {
  opacity: .26;
  filter: saturate(.15);
}
.kit[data-cat='lang']  .chip[data-c='lang'],
.kit[data-cat='front'] .chip[data-c='front'],
.kit[data-cat='back']  .chip[data-c='back'],
.kit[data-cat='ai']    .chip[data-c='ai'],
.kit[data-cat='cloud'] .chip[data-c='cloud'] {
  opacity: 1;
  filter: none;
}

@media (max-width: 620px) {
  .js .kit__bar { justify-content: flex-start; }
  .kit__count { display: none; }
}

.chip {
  display: inline-flex; align-items: center; gap: 8px;
  padding: 7px 12px;
  border: 1px solid var(--c-line);
  border-radius: var(--r-ctl);
  background: var(--c-glass);
  font-size: var(--t-sm);
  line-height: 1.1;
  color: var(--c-muted);
  /* THE CHIP DECLARES EVERYTHING IT ANIMATES, IN ONE PLACE, AND THE STATE RULES
     BELOW ONLY SET VALUES. `opacity`, `filter` and `transform` are not used
     here — they belong to the solo filter and to the staged entrance
     respectively — but they are listed because a `transition` SHORTHAND in a
     state rule replaces the whole property, and the solo rule
     (`.kit[data-cat]:not([data-cat='all']) .chip`, four classes deep) outranks
     anything the entrance can reasonably write. It used to carry its own
     `transition: opacity, filter` and that silently deleted the entrance's
     transform transition: the chips jumped from scale(.82) to 1 in one frame
     with no error and no visible cause. Measured — `transform` went straight to
     `none` 60ms after `.in` landed, ignoring a 352ms delay.
     Declare timing on the element, values on the states. */
  transition: color var(--d-2), border-color var(--d-2), background var(--d-2),
              opacity var(--d-2) var(--e-out), filter var(--d-2) var(--e-out),
              transform var(--d-3) var(--e-spring) var(--sn, 0ms);
}
.chip:hover { color: var(--c-fg); border-color: var(--c-line-2); background: var(--c-glass-2); }
/* Sized in em so a mark always matches the label beside it, and `flex: none`
   so a long label can never squeeze the logo into an ellipse. */
.chip__i {
  width: 1.05em; height: 1.05em; flex: none;
  fill: currentColor;
  opacity: .82;
  transition: opacity var(--d-2);
}
.chip:hover .chip__i { opacity: 1; }

/* -----------------------------------------------------------------------------
   CREDENTIALS — a row list inside the article column.

   Two changes at once, and they are the same change. It was a full-width 3-up
   plate BELOW the grid, which meant the At-a-glance card ran out of column and
   unpinned while there was still a screen of section to go; and three cards in
   a grid built for three-across is a plate sized for a number it does not
   really have, ~270px of mostly air each at column width.

   As rows inside `.ed__body` the sticky card now runs all the way past them,
   and the list is scannable straight down its left edge in a third of the
   height. `[mark | title]` on one line rather than the mark stacked over it:
   at row scale a 24px glyph sitting above its own heading is a wasted line.

   The link case is an <a> INSIDE the <li> rather than the row itself being an
   anchor — only one of the three has somewhere to go, and a list whose items
   are sometimes links and sometimes not should not change element type row to
   row just to say so.
----------------------------------------------------------------------------- */
.cred { display: grid; margin-top: clamp(18px, 2vw, 26px); }
.cred__row { border-top: 1px solid var(--c-line); }
.cred__row:last-child { border-bottom: 1px solid var(--c-line); }

/* The shared row shape, worn by the <li> when it is inert and by the <a> when
   it is a link — so the two look identical and only one of them reacts. */
.cred__row,
.cred__link {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: var(--s-4);
  padding-block: clamp(14px, 1.6vw, 19px);
  color: inherit;
}
/* When the row IS a link wrapper, the <li> stops being the grid and hands the
   job to the <a> inside it — otherwise there are two nested grids and the
   anchor collapses into a single cell of the outer one. */
.cred__row:has(.cred__link) { display: block; padding-block: 0; }
/* FOUR columns, not three: the linked row carries a trailing arrow the inert
   ones do not, and as a fourth child of a 3-track grid it wrapped onto a second
   row and sat under the mark. A row that sometimes has an extra control needs
   the track declared where that control exists, not on the shared shape. */
.cred__link {
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  padding-inline: 0;
  transition: background var(--d-2);
}
.cred__link:hover { background: var(--c-glass); }

.cred__i { width: 22px; height: 22px; fill: currentColor; color: var(--c-faint); transition: color var(--d-2); }
.cred__link:hover .cred__i { color: var(--c-fg); }

.cred__t { font-size: var(--t-bd); font-weight: 600; letter-spacing: var(--tr-head); }
.cred__d { color: var(--c-muted); font-size: var(--t-sm); margin-top: 3px; max-width: 52ch; }
.cred__y { color: var(--c-faint); white-space: nowrap; }
.cred__go { color: var(--c-faint); transition: color var(--d-2), transform var(--d-2) var(--e-out); }
.cred__link:hover .cred__go { color: var(--c-fg); transform: translate(2px, -2px); }
/* Nothing to click, so nothing that suggests it. */
.cred__row[data-verify='pending'] { cursor: default; }

@media (max-width: 560px) {
  /* The issuer drops under the title rather than squeezing the third column to
     nothing — at 390px `1st · 2024` and a 30ch title cannot share a line.

     THE ARROW IS DROPPED RATHER THAN REPLACED. Forcing it into `grid-column: 2
     / grid-row: 1` put it in the same cell as `.cred__main`, and two items
     colliding in one cell of a two-track grid blew the row's scrollWidth out to
     423px inside a 338px column — a horizontal scrollbar on the whole document,
     from an icon. On a touch screen the row itself is the tap target and a
     decorative arrow earns nothing, so it simply goes. */
  .cred__row, .cred__link { grid-template-columns: auto minmax(0, 1fr); row-gap: 5px; }
  .cred__y { grid-column: 2; }
  .cred__go { display: none; }
}

@media (max-width: 620px) {
  /* The byline can't hold one row on a phone, and a wrapped separator becomes
     a dot dangling off the end of a line. Drop them and let the four facts be
     four lines — the strip still reads as a byline because the rules are what
     make it one. */
  /* Column gap opens up because it is now the ONLY thing separating two facts
     that land on the same line. */
  .ed__byline { gap: 8px var(--s-5); }
  .ed__byline i { display: none; }
}

/* =============================================================================
   CH 04 — STUDIO  (the article)

   The one section on the site laid out as an ARTICLE rather than as a rack: a
   measured column of prose beside a contact sheet, over a three-column spec
   strip. Everything else here is a grid of equal things, so this reads as a
   different register without needing a different visual language.

   THE GRID IS THE DESIGN. The first attempt floated three squares at staggered
   offsets AND drifted each of them at a different rate, and the two fought:
   nothing lined up with anything, and it read as three images that had slipped
   rather than as an arrangement. Now the sheet is rigid — four equal cells,
   one gutter, corners aligned to the prose column beside it — and ALL of the
   movement comes from the sheet drifting as a single plane. Alignment is what
   makes a contact sheet mean anything; it is not the thing to sacrifice for
   motion.
   ============================================================================= */

.art { display: grid; gap: clamp(36px, 4.5vw, 64px); }

@media (min-width: 980px) {
  .art {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1.02fr);
    column-gap: clamp(48px, 6vw, 104px);
    row-gap: clamp(28px, 3.4vw, 48px);
    align-items: start;
  }
  .art__title { grid-column: 1; grid-row: 1; }
  .art__col   { grid-column: 1; grid-row: 2; }
  /* The sheet spans both rows so its top edge starts on the title's cap line —
     that shared baseline is what makes the two halves read as one spread. */
  .art__sheet { grid-column: 2; grid-row: 1 / span 2; }
  .art__spec  { grid-column: 1 / -1; grid-row: 3; }
}

.art__title {
  font-size: clamp(2rem, 4.6vw, 3.9rem);
  font-weight: 600;
  line-height: 1.02;
  letter-spacing: var(--tr-mega);
  margin-left: -0.03em;              /* optical, as everywhere at this size */
  text-wrap: balance;
}

.art__col { max-width: 52ch; }
.art__col p { color: var(--c-muted); margin-top: var(--s-5); }
/* The opening paragraph carries the weight; the rest is body. */
.art__lead {
  font-size: var(--t-lg);
  line-height: 1.4;
  letter-spacing: var(--tr-head);
  color: var(--c-fg);
  margin-top: 0 !important;
  text-wrap: pretty;
}

/* -----------------------------------------------------------------------------
   THE CONTACT SHEET

   Square because a square is the only aspect ratio that does not imply what is
   inside it — a room, a rack and a screen can all sit in one without any of
   them fighting the column beside it.

   `data-par` is on the SHEET and never on a frame: app.js lerps toward the
   target rather than snapping to it, so the whole plate lags the prose by a
   beat, and the frames keep their grid while it does.
----------------------------------------------------------------------------- */
.art__sheet {
  display: grid;
  grid-template-columns: repeat(2, minmax(0, 1fr));
  gap: clamp(12px, 1.5vw, 20px);
  align-content: start;
  transform: translate3d(0, var(--py, 0px), 0);
}
.art__fig { display: grid; gap: 10px; margin: 0; }

.art__img {
  position: relative;
  aspect-ratio: 1;
  border: 1px solid var(--c-line);
  border-radius: var(--r);
  background: var(--c-panel-2);
  overflow: hidden;
  transition: border-color var(--d-2), background var(--d-2);
}
.art__fig:hover .art__img { border-color: var(--c-line-2); background: var(--c-panel); }
/* Drop an <img> in as the frame's first child and it simply takes over. */
.art__img img { width: 100%; height: 100%; object-fit: cover; display: block; }

/* Faceplate texture, so an empty frame reads as a slot rather than a bug. */
.art__img::before {
  content: '';
  position: absolute; inset: 0;
  background-image:
    linear-gradient(90deg, var(--c-line) 1px, transparent 1px),
    linear-gradient(180deg, var(--c-line) 1px, transparent 1px);
  background-size: 26px 26px;
  opacity: .55;
}
.art__img:has(img)::before { display: none; }

/* The proof number, in the corner where a contact sheet puts it. */
.art__no {
  position: absolute; top: 10px; left: 10px; z-index: 1;
  padding: 4px 7px;
  border-radius: 4px;
  background: color-mix(in srgb, var(--c-bg) 72%, transparent);
  backdrop-filter: blur(6px);
  font-family: var(--f-mono); font-style: normal;
  font-size: var(--t-xs); font-weight: 500; line-height: 1;
  letter-spacing: var(--tr-label);
  color: var(--c-muted);
  transition: color var(--d-2);
}
.art__fig:hover .art__no { color: var(--c-fg); }
.art__fig figcaption { color: var(--c-faint); }

/* The fourth cell: a legend, so the sheet is a complete rectangle rather than
   one with a corner missing. */
.art__legend {
  display: flex; flex-direction: column; justify-content: flex-end; gap: 6px;
  padding-bottom: 10px;
}

/* -----------------------------------------------------------------------------
   THE SPEC STRIP — three equal columns on one rule.

   Same grammar as the process stages and the figures: an index, a label, a
   value, each in its own column. It was a two-column list behind a 9ch label
   gutter, which at anything under a very wide screen closed up and read as one
   run-on string.
----------------------------------------------------------------------------- */
.art__spec {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr));
  gap: 1px;
  margin: 0;
  background: var(--c-line);
  border: 1px solid var(--c-line);
  border-radius: var(--r);
  overflow: hidden;
}
.art__cell {
  background: var(--ch-bg);
  padding: clamp(18px, 2.1vw, 26px);
  display: grid; gap: var(--s-3); align-content: start;
  transition: background var(--d-3) var(--e-out);
}
.art__cell:hover { background: var(--c-panel-2); }
.art__cell dt { display: flex; align-items: center; gap: var(--s-3); }
.art__cell dd { margin: 0; color: var(--c-fg); font-size: var(--t-sm); }

@media (max-width: 979px) {
  /* Below the spread, the sheet follows the prose and the drift is dropped —
     on a narrow screen it would only shove the frames against the text. */
  .art__sheet { transform: none; }
}

/* =============================================================================
   CH 03 — FREELANCE IS CURRENTLY OFF THE SITE.  ← REVERT POINT

   Owner, 2026-08-01: *"lets for now display: none the CH 03 Freelance section
   both on mobile and desktop"*. Explicitly BOTH, which is why this lives here in
   the shared stylesheet rather than in mobile.css — everything in that file is
   media-scoped by construction and could not reach a desktop even if it wanted
   to.

   `display: none`, and NOTHING has been deleted: the section, its markup, its
   copy and all the CSS below this block are untouched, so putting the channel
   back is deleting this one rule.

   THE NAV LINKS GO WITH IT. A bar item that scrolls to a hidden anchor is a
   control that does nothing, which is worse than a missing one — so the nav link
   and the phone menu's row are hidden by the same rule. They are matched on
   `href`, so nothing has to be kept in step by hand: restore the section and both
   come back.

   THE FOOTER LINKS ARE **NOT** HIDDEN, AND THAT WAS A CORRECTION.

   They were, at first, by the same `href` match — and it wrecked the desktop
   footer, which the owner reported. Services lost three of its five rows and
   Explore one of its five, so the columns went 5/5/5/5 to 2/4/5/5 and the
   identity block beside them widened by 40px to take up the slack. Hiding a link
   because its destination went away is the right instinct and was the wrong fix
   here: the services are still offered and the channels still exist, so the links
   were REPOINTED in the markup instead (services → `#contact`, and Explore's
   Freelance row replaced by Process). Same text, same widths, same five rows.

   THE ONE THING THIS DELIBERATELY DOES NOT DO, because it is the owner's call and
   was not asked for: **the channel numbering now reads 01 · 02 · 04 · 05 · 06 ·
   07.** Renumbering means touching ~12 `.ch__id`, nav and menu labels and undoing
   every one of them later, and the numbering is a DESKTOP-visible design
   decision.
   ============================================================================= */
#freelance,
.nav__link[href="#freelance"],
.menu__item[href="#freelance"] { display: none; }

/* =============================================================================
   CH 03 — FREELANCE  (rack of cards)

   TWO COLUMNS, NOT THREE. The rack held six cards and ran 3-up on a wide
   screen; it holds four now, and 3-up leaves one card alone on a second row
   with two empty cells beside it — a rack with a corner missing. 2×2 is the
   only arrangement four things have that is still a rectangle.
   ============================================================================= */

/* FOUR PANELS, NOT ONE PLATE QUARTERED. The rack was a single bordered box with
   1px `gap` showing the container's line through it, so the cards shared every
   edge and the whole thing read as one object with cuts in it. Four separate
   offers are four things — and at this size a hairline between them is not a
   join anybody reads as deliberate, it just looks like the grid closed up.
   Real space on both axes, and each card carries its own border and radius, so
   the section says "here are four" before a word of it is read.

   The gap is a `clamp` off the same curve as the rest of the section's spacing
   rather than a fixed number: at 390px the cards are stacked and the gap is the
   only thing between two full-width panels, while at 1440 it is separating two
   columns that already have a hundred-odd pixels of gutter beside them. */
.svc { display: grid; gap: clamp(12px, 1.3vw, 20px); }
@media (min-width: 720px)  { .svc { grid-template-columns: repeat(2, 1fr); } }
.svc__card {
  position: relative;
  background: var(--ch-bg); padding: clamp(22px, 2.4vw, 32px);
  border: 1px solid var(--c-line); border-radius: var(--r);
  /* The arming hairline below is `inset: 0 0 auto`, so it runs the full width of
     the card and would overhang both top corners once the card is rounded. */
  overflow: hidden;
  display: grid; align-content: start; gap: var(--s-4);
  transition: background var(--d-3) var(--e-out), border-color var(--d-3) var(--e-out);
}
.svc__card:hover { border-color: var(--c-line-2); }
/* A hairline arming across the top of the card on hover. Not amber: rule 4
   reserves the accent for state, and pointing at something is not a state. */
.svc__card::before {
  content: ''; position: absolute; inset: 0 0 auto;
  height: 1px; background: var(--c-fg);
  transform: scaleX(0); transform-origin: left;
  transition: transform var(--d-4) var(--e-out);
}
.svc__card:hover { background: var(--c-panel-2); }
.svc__card:hover::before { transform: scaleX(1); }
.svc__head { display: flex; align-items: baseline; gap: var(--s-3); }
.svc__t { font-size: var(--t-lg); font-weight: 600; letter-spacing: var(--tr-head); }
/* At 2-up a card is ~600px wide, so the copy needs its own measure or the
   description sets in one 90ch line and the card stops reading as a card. */
.svc__d { color: var(--c-muted); font-size: var(--t-bd); max-width: 46ch; }
/* An explicit counter, not the built-in `list-item` one: giving an <li> a
   `display` other than list-item stops it incrementing list-item at all, which
   silently rendered every row as "00". */
.svc__list { display: grid; counter-reset: spec; }
.svc__list li {
  display: grid; grid-template-columns: 2.4ch 1fr; gap: var(--s-2);
  padding-block: 8px; border-top: 1px solid var(--c-line);
  color: var(--c-muted); font-size: var(--t-sm);
  counter-increment: spec;
}
/* A mono index, not an accent bullet — rule 4, and it matches every other list */
.svc__list li::before {
  content: counter(spec, decimal-leading-zero);
  font-family: var(--f-mono); font-size: var(--t-xs);
  color: var(--c-faint); line-height: 1.9;
}
/* -----------------------------------------------------------------------------
   THE TERMS STRIP — what the rack's six "Project rate" chips were pretending
   to be. Price, retainer and ownership are not services you pick between, they
   are the terms all four are sold on, so they are stated once underneath the
   rack instead of once per card.

   ONE WRAPPING LINE, not three full-width rows. Each term is four or five
   words, and a row apiece spent ~200px of page giving them the visual weight of
   a service card — they are a footnote to the rack and now they read as one.
----------------------------------------------------------------------------- */
.terms {
  display: flex; flex-wrap: wrap;
  gap: 10px clamp(20px, 3vw, 44px);
  margin-top: clamp(20px, 2.4vw, 30px);
  padding-top: clamp(16px, 1.8vw, 22px);
  border-top: 1px solid var(--c-line);
  color: var(--c-muted);
  font-size: var(--t-sm);
}
/* Label and value ride together so a wrap can never split one from the other —
   "Ownership" alone at the end of a line is a term with no terms. */
.terms__i { display: inline-flex; align-items: baseline; gap: 9px; }
.terms__i .mono { color: var(--c-faint); }

/* THE FILTER BAR AND THE `.trk` TRACK ROWS LIVED HERE.

   Both went when About and Work were merged into one channel. The six project
   rows are not a list any more — they are `.wk`, the index bank that drives the
   showreel's stage — so there is no list left to filter and no row component
   left to style. `.solo` (the button itself) stays where it is: the skills bank
   still uses it, and it was always the shared object.
   ----------------------------------------------------------------------------- */

/* -----------------------------------------------------------------------------
   THE ROLE PLATE — CH 01's Experience.

   It used to be a single `.trk__row`, which is the component the Work section
   uses for a list of six projects. Used once it read as a list that had lost
   the rest of itself: a numbered index for one entry, no panel, no edge, and
   the most important fact on the page set in the plainest thing on the site.

   Now it is the same PLATE object as Credentials and the skills bank further
   down — one border, one radius, an internal hairline — so the section reads
   as a set of related panels rather than as four unrelated widths in a row.
   IDENTITY LEFT, SUBSTANCE RIGHT: who and where on one side, what and with
   what on the other, which is the split a reader is already making.
----------------------------------------------------------------------------- */
.role {
  position: relative;
  display: grid;
  gap: clamp(24px, 3vw, 40px);
  padding: clamp(24px, 2.8vw, 40px);
  border: 1px solid var(--c-line);
  border-radius: var(--r);
  background: var(--c-panel-2);
  color: inherit;
  transition: border-color var(--d-2), background var(--d-3) var(--e-out);
}
.role:hover { border-color: var(--c-line-2); }

/* -----------------------------------------------------------------------------
   THE PLATE ASSEMBLES ITSELF.

   It used to arrive on `data-rv="cut"` — the whole panel, grey and bordered,
   sliding up 34px and fading in. That is how a CARD arrives, and this is not
   really a card: it is the single most important fact in the section, and the
   only thing on the page with a full surface of its own. Sliding it is moving a
   finished object into place. This BUILDS the object where it stands.

   TWO BEATS, AND THE ORDER IS THE WHOLE IDEA:

     1. THE FRAME IS STRUCK. `::after` is a border-only outline revealed by a
        `clip-path` inset opening left to right, so the box is drawn — top and
        bottom edges running out from the left, the right edge arriving last and
        closing it.
     2. THE SURFACE FLOODS IN BEHIND IT. `::before` is the grey, a full-height
        layer sliding in from the left, starting a beat after the frame so the
        outline is legibly empty before anything fills it.

   THE FILL CARRIES AN AMBER LEADING EDGE, which is the reason it is a TRANSLATE
   and not a second clip-path. A clipped layer's gradient is fixed to the
   element, so a hot edge painted into it only lines up with the clip at the very
   end; a layer that MOVES carries its own right edge with it, so the amber rides
   the boundary the whole way across. Same idea as the boot readout, where the
   accent sits in the last stretch before the level's edge — the site's one
   motion idea, on the one surface big enough to show it properly.

   The layer is 132% wide on purpose: at rest it has travelled far enough that
   the amber band is past the right edge and clipped away, so the plate settles
   as plain grey with no accent stranded on it. `overflow: hidden` on `.role` is
   what does that clipping, and it also gives both pseudo layers the radius.

   NO-JS KEEPS THE PLAIN PLATE. The border and background above are real; only
   `.js` hands them over to the two layers, so a browser with scripting off gets
   a finished panel rather than an outline waiting for an event.
----------------------------------------------------------------------------- */
.js .role[data-rv='plate'] {
  overflow: hidden;
  border-color: transparent;
  background: transparent;
}
/* CONTENT OVER BOTH LAYERS — BY `isolation`, NOT BY POSITIONING THE CHILDREN.
   The first version did `> * { position: relative; z-index: 1 }`, which reads
   as harmless and is not: `.role__go` is `position: absolute`, pinned to the
   plate's top-right corner, and re-declaring `position` on it dropped it back
   into flow. The arrow ended up in the wrong place entirely.

   `isolation: isolate` makes the plate its own stacking context, so the two
   layers can sit at `z-index: -1` — behind the content, but unable to escape
   behind the plate itself. Nothing about the children has to change. */
.js .role[data-rv='plate'] { isolation: isolate; }

/* 1 — the fill, sliding in with the accent on its leading edge. */
.js .role[data-rv='plate']::before {
  content: '';
  position: absolute; left: 0; top: 0; bottom: 0;
  width: 132%;
  z-index: -1;
  /* A TONAL SHEEN ON THE LEADING EDGE, NOT AN ACCENT FLASH. The first version
     ran amber up the edge and it was exactly the wrong instinct twice over:
     rule 4 keeps the accent for STATE, and a plate arriving is not a state —
     and at that saturation the entrance announced itself far louder than the
     fact it was delivering.

     What is left is the same move the nav wordmark and the boot readout make,
     with the colour taken out: a band of light travelling through a surface.
     Mixed from `--c-fg`, so it lifts on the dark theme and deepens on the light
     one — a sheen either way, because both tokens swap together. */
  background: linear-gradient(90deg,
    var(--c-panel-2) 0 calc(100% - 220px),
    color-mix(in srgb, var(--c-fg) 3.5%, var(--c-panel-2)) calc(100% - 120px),
    color-mix(in srgb, var(--c-fg) 8%,   var(--c-panel-2)) calc(100% - 34px),
    color-mix(in srgb, var(--c-fg) 11%,  var(--c-panel-2)) 100%);
  transform: translate3d(-100%, 0, 0);
  /* EASE-IN-OUT, NOT `--e-out`, AND THIS IS THE SAME MISTAKE THE NAV MARK MADE.
     An ease-out spends most of its duration already arrived: measured at 320ms
     the frame was 94% drawn and the fill 93% across, so both beats were over
     before either could be watched. A wipe you are meant to SEE cross needs an
     easing with a middle. */
  transition: transform 1180ms cubic-bezier(.52, .02, .28, 1) 430ms;
}
.js .role[data-rv='plate'].in::before { transform: translate3d(0, 0, 0); }

/* 2 — the frame, drawn first and left behind. */
.js .role[data-rv='plate']::after {
  content: '';
  position: absolute; inset: 0;
  z-index: -1;
  border: 1px solid var(--c-line);
  border-radius: var(--r);
  clip-path: inset(0 100% 0 0 round var(--r));
  transition: clip-path 980ms cubic-bezier(.62, .02, .34, 1), border-color var(--d-2);
}
.js .role[data-rv='plate'].in::after { clip-path: inset(0 0 0 0 round var(--r)); }
/* Hover lives on the frame now that the element's own border is transparent. */
.js .role[data-rv='plate'].in:hover::after { border-color: var(--c-line-2); }

/* The internal divider is part of the surface, so it arrives with it rather
   than being visible across an empty outline. */
@media (min-width: 900px) {
  .js .role[data-rv='plate'] .role__body { border-left-color: transparent; }
  .js .role[data-rv='plate'].in .role__body {
    border-left-color: var(--c-line);
    transition: border-left-color var(--d-3) linear 980ms;
  }
}

/* ...AND THE DESCRIPTION ARRIVES WITH THEM, WHICH IT WAS NOT DOING.

   Everything else in this plate has an entrance: the frame draws itself, the
   grey floods in behind it, the divider fades, and both `.role__meta` and
   `.role__tags` carry `data-stage` so their rows and chips stagger in. The
   paragraph had nothing — it was simply already there while the surface
   assembled around it, which is what the owner spotted. One block sitting still
   inside a component whose whole idea is that it BUILDS reads as an element the
   animation forgot, and on a phone it is the largest block in the plate.

   IT KEYS OFF THE PLATE'S OWN `.in`, NOT ITS OWN `data-rv`. Giving it a
   `data-rv` would hand it to the shared observer, which fires on the element's
   own position — so the paragraph would start arriving before the frame that is
   supposed to contain it had finished being drawn. Hanging it off the plate's
   class means there is ONE trigger for the whole component and the order is
   authored here, where the rest of the sequence already lives.

   760ms puts it after the frame has closed (980ms is its full travel, but the
   right edge is the last thing to land) and while the fill is still crossing, so
   the text reads as settling onto a surface that is arriving under it rather
   than as a fifth thing happening once everything has stopped. Slightly ahead of
   the divider at 980, because a paragraph is content and a hairline is chrome.

   The transform is 10px and not the site's usual 14: this one is not entering
   the page, it is settling inside a box that is itself being drawn, and the
   larger travel read as the paragraph sliding around inside the plate. */
.js .role[data-rv='plate'] .role__d {
  opacity: 0;
  transform: translate3d(0, 10px, 0);
}
.js .role[data-rv='plate'].in .role__d {
  opacity: 1;
  transform: none;
  transition: opacity var(--d-3) var(--e-out) 760ms,
              transform var(--d-4) var(--e-out) 760ms;
}

/* Reduced motion gets the finished plate and no build. Both layers are already
   in their rest position; only the transitions are removed, and the fill is
   nudged fully home so the accent edge is off the plate. */
@media (prefers-reduced-motion: reduce) {
  .js .role[data-rv='plate']::before {
    transform: translate3d(0, 0, 0);
    transition: none;
  }
  .js .role[data-rv='plate']::after {
    clip-path: inset(0 0 0 0 round var(--r));
    transition: none;
  }
  .js .role[data-rv='plate'] .role__body { border-left-color: var(--c-line); }
  /* The paragraph is present and still, like everything else here. */
  .js .role[data-rv='plate'] .role__d { opacity: 1; transform: none; transition: none; }
}

@media (min-width: 900px) {
  /* `column-gap: 0` and the gutter is PADDING on each side of the divider
     instead. With a gap the hairline sits hard against the right column's text
     and the space all ends up on one side of it; padding either side puts the
     rule in the middle of its own gutter, which is the only way it reads as a
     divider rather than as the right column's left edge. */
  .role {
    grid-template-columns: minmax(0, .82fr) minmax(0, 1.18fr);
    column-gap: 0;
    align-items: start;
  }
  .role__id { padding-right: clamp(32px, 3.6vw, 64px); }
  /* The divider is a border on the column, not a spare element — it runs the
     exact height of the taller side with nothing to keep in sync, and it
     simply is not there at the stacked breakpoint. */
  .role__body {
    padding-left: clamp(32px, 3.6vw, 64px);
    padding-right: clamp(20px, 2vw, 30px);   /* clear of the corner arrow */
    border-left: 1px solid var(--c-line);
  }
}

/* The employer wordmark as an eyebrow over the role.

   SIZED BY WIDTH, NOT HEIGHT. It is a 194x20 wordmark — nearly 10:1 — so a
   height in `em` like the square tech marks get would either leave it hairline
   thin or blow it across the column. `height: auto` off a fixed width keeps its
   ratio at every viewport.

   RENDERED IN PAGE INK, NOT BRAND TEAL. The original hard-codes #46BEAA, and
   dropping one saturated teal into a palette whose entire point is that amber
   is the only colour in the room would make the employer's logo the loudest
   thing in the section. Monochrome-on-dark is standard practice for a logo used
   as a credential rather than as a brand statement. It sits a step back at rest
   and comes up to full ink with the rest of the plate on hover. */
/* EMPLOYER OVER ROLE — the user's call, made explicitly after seeing it both
   ways. It reads as a masthead: the organisation identifies the entry, the
   title says what he is inside it. Kept at the larger "named fact" size rather
   than dropping back to the small eyebrow it started as, because leading an
   entry with something set at caption scale is what made the first version
   read as a label stuck on the card. */
/* BALANCING THE LOCKUP. With the wordmark on top it is the thing identifying
   the entry, so it cannot be the quieter of the two — but it was: 172px wide is
   only ~18px tall on a 10:1 mark, roughly 12px of cap height, sitting under a
   32px title with 22px caps. The title was winning by nearly two to one and the
   mark read as a caption that had floated above its own heading.

   Fixed from BOTH ends rather than by inflating the logo alone, which at the
   size needed to out-weigh a 32px title would have run the width of the column:
   the mark goes up ~20% AND to full ink, the title comes down ~20%. They now
   read as one lockup — organisation, then role — instead of two things stacked. */
.role__at { display: flex; align-items: center; }
.role__logo {
  width: clamp(178px, 19vw, 268px);
  height: auto;
  fill: currentColor;
  color: var(--c-fg);
  opacity: .92;
  transition: opacity var(--d-2);
}
.role:hover .role__logo { opacity: 1; }

.role__t {
  font-size: clamp(1.02rem, 1.35vw, 1.24rem);
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: var(--tr-head);
  color: var(--c-muted);
  max-width: 18ch;
  margin-top: 20px;
  text-wrap: balance;
  transition: color var(--d-2);
}
.role:hover .role__t { color: var(--c-fg); }

.role__meta { display: grid; margin: clamp(18px, 2vw, 26px) 0 0; }
.role__meta > div {
  display: grid; grid-template-columns: 8ch minmax(0, 1fr);
  gap: var(--s-4);
  align-items: baseline;
  padding-block: 10px;
  border-top: 1px solid var(--c-line);
}
.role__meta dt { color: var(--c-faint); }
/* NOT a flex container. It was, with `gap: 8px`, to space the Onsite chip from
   the city — but flex makes every child a flex item, so the auto-counted
   tenure span became its own item and rendered "3⎵⎵years" with the gap wedged
   between a number and its own unit. The chip is `inline-flex` already, so a
   plain block line and a margin on the chip does the same job without
   restructuring the text around it. */
.role__meta dd { margin: 0; color: var(--c-fg); font-size: var(--t-sm); line-height: 1.4; }
.role__meta dd .tag { margin-left: 9px; vertical-align: 1px; }

/* Tags first, so the margins swap: the chip row now sets the top of the column
   and the paragraph is what needs separating from it. Slightly more space than
   the reverse order used, because a row of bordered chips needs more air under
   it than a paragraph does — the borders read as a hard edge and text set
   straight beneath them looks stuck to the boxes. */
/* Nudged down off the top of the column. Flush at 0 the chips sat on the
   title's CAP line across the divider, which lines up the boxes' tops with the
   letters' tops and reads as slightly high — a chip's optical centre is what
   should meet the title, not its edge. */
.role__tags { display: flex; flex-wrap: wrap; gap: 6px; margin-top: clamp(8px, 1vw, 14px); }
.role__d {
  color: var(--c-muted);
  max-width: 62ch;
  margin-top: clamp(20px, 2.2vw, 28px);
  text-wrap: pretty;
}

.role__go {
  position: absolute; top: clamp(24px, 2.8vw, 40px); right: clamp(24px, 2.8vw, 40px);
  color: var(--c-faint);
  transition: color var(--d-2), transform var(--d-2) var(--e-out);
}
.role:hover .role__go { color: var(--c-fg); transform: translate(3px, -3px); }

/* The role's location, directly under its title. Two revisions got this wrong
   before landing on the obvious answer: first appended to the sector/date
   string on the right (which is `white-space: nowrap`, so it squeezed the
   description column), then split to the bottom-right of the row level with
   the tags — where it was as far from the job title as it could physically be
   while still being in the same row. Location is an attribute of the ROLE, so
   it goes with the role. */
.tag--sm { padding: 3px 8px; }

/* =============================================================================
   CH 05 — MUSIC  (the record crate)

   WHAT THIS REPLACED. A three-cell service strip, one featured record with a
   waveform, an eight-row text discography and Spotify's own purple iframe at the
   bottom: four different listings of one catalogue, none of which could play a
   note, and the only thing that could was a widget in someone else's design
   language sitting inside this one.

   It is a crate now — sleeves, sideways, with the sound under them. Three things
   make that work as design rather than as a gallery:

   1. THE ARTWORK IS THE CONTENT. It is the only place on this site where an
      image is the point rather than a texture, so the sleeves get real size, a
      real shelf and nothing on top of them but a play mark and a progress line.
   2. THE SHELF COSTS ONE SCREEN, WHATEVER IS ON IT. Twenty-six sleeves take the
      same page as six because it scrolls sideways under its own edge mask. The
      catalogue can double without the channel growing.
   3. THE TRANSPORT IS REAL. The lamp is lit only while audio is playing, the
      clock is the element's own currentTime, and the amber crossing the waveform
      is currentTime/duration. Only the SHAPE of the waveform is generated — the
      site's deterministic seed-from-the-title index, as it has always been, now
      finally being used as the scrub track it always looked like.
   ============================================================================= */

/* ---- the shelf, and the two arrows on its edges --------------------------- */
/* The sleeve size and the shelf's own top padding are variables because the edge
   arrows have to land on the middle of the ARTWORK, not the middle of the box —
   the box also contains a title and a meta line, so centring on it puts the
   arrows a good 30px low. `--pad-t + --sleeve / 2` is the art's centre exactly,
   whatever the viewport does to either. */
.rec {
  --sleeve: clamp(190px, 20vw, 300px);
  --pad-t: clamp(18px, 2vw, 26px);

  /* `--p` IS RESET HERE, AND IT IS NOT A TIDY-UP.

     Two unrelated things on this page are called `--p`: playback position, which
     app.js writes onto a sleeve and onto the amber half of the waveform, and the
     CHANNEL'S SCROLL PROGRESS, which onScroll writes onto every `<section>` for
     the fill inside its sticky head. This component is inside `section#music`,
     so with nothing playing the waveform's `var(--p, 0)` fallback never applied
     — it inherited the channel's scroll position and drew the amber to it.

     That has always been true; it was invisible only because the strip was
     hidden at rest and `.cut__bar` is transparent unless its sleeve is armed.
     Making the transport permanent is what put it on screen: an idle waveform
     showing 12% of a preview that does not exist, moving as you scrolled.

     Declaring it on the component stops the inheritance at the boundary. Every
     real write is an inline style on a descendant, which still wins inside its
     own subtree. */
  --p: 0;
}
.rec__shelf { position: relative; }

.rec__step {
  position: absolute;
  top: calc(var(--pad-t) + var(--sleeve) / 2);
  z-index: 3;
  transform: translateY(-50%);
  display: grid; place-items: center;
  width: 38px; height: 38px;
  font-size: 17px; line-height: 1;
  border: 1px solid var(--c-line-2);
  border-radius: 50%;
  /* Opaque, because it sits on top of artwork that could be any colour. */
  background: color-mix(in srgb, var(--c-panel-2) 88%, transparent);
  backdrop-filter: blur(10px);
  color: var(--c-fg);
  cursor: pointer;
  transition: opacity var(--d-2), border-color var(--d-2), background var(--d-2);
}
.rec__step--a { left: 12px; }
.rec__step--b { right: 12px; }
.rec__step:hover { border-color: var(--c-fg); background: var(--c-panel-2); }
/* At the end of its travel it fades out rather than disappearing — an arrow that
   vanishes takes the shelf's shape with it. */
.rec__step[disabled] { opacity: 0; pointer-events: none; }
/* A finger has the whole shelf to swipe; arrows would just cover two sleeves. */
@media (pointer: coarse) { .rec__step { display: none; } }

/* ---- the transport, at rest and in motion ---------------------------------
   The bar that used to live ABOVE the shelf was permanent, said "Hover a sleeve
   to preview", and carried a mode toggle plus a waveform and a clock that
   repeated the progress line already drawn on the sleeve. This is what is left
   once all of that is taken out: the three things a sleeve cannot tell you —
   WHICH record you are hearing (once you have moved the pointer on, the sleeve
   under it is not the one playing), how far through the thirty-second preview
   you are, and one control to stop.

   IT IS ON SCREEN AT REST NOW, AND IT WAS NOT. The strip faded in on the first
   note and out on the last, which sounds like the right instinct — a readout
   with nothing to read out is furniture — but its space was reserved either way,
   so the only thing that instinct actually bought was 46px of empty page under
   the shelf and a set of controls nobody could see until they had already found
   the interaction those controls describe. A desk shows its state; a dark desk
   is a broken one.

   The rest state is a machine idling, not a machine playing nothing: dark lamp,
   a cue instead of a title, the waveform flat on its centre line at zero, and
   0:00 / 0:00 — no loaded clip, so no length to claim. Nothing here reports a
   value it does not have, which is the only rule this component has ever had. */
.rec__np {
  display: flex; align-items: center;
  gap: var(--s-3) var(--s-4);
  min-height: 46px;
  margin-top: clamp(12px, 1.4vw, 18px);
}

/* The lamp is the one thing that must not be lit at rest — it is state, and the
   state is "nothing is playing". `.led` supplies the live look; this dims it. */
.rec__lamp { flex: none; transition: opacity var(--d-2), filter var(--d-2); }
.rec:not(.playing) .rec__lamp { opacity: .3; filter: grayscale(1); }

/* The cue and the title occupy the same slot and cross-fade — one is interface,
   the other is content, and only one of them is ever true. `position: absolute`
   would take the slot out of the flex row and let the waveform slide left as the
   text swapped, so both stay in flow and the hidden one is `visibility: hidden`
   with a zero max-width, which keeps the row from being sized by the copy that
   is not showing. */
.rec__cue,
.rec__now {
  flex: none;
  max-width: 40ch;
  overflow: hidden; white-space: nowrap; text-overflow: ellipsis;
  transition: opacity var(--d-2), color var(--d-2);
}
.rec__cue { color: var(--c-faint); }
.rec__now { color: var(--c-fg); }
.rec:not(.playing) .rec__now,
.rec.playing .rec__cue { max-width: 0; opacity: 0; visibility: hidden; }

.rec__wave {
  position: relative;
  flex: 1 1 auto; min-width: 60px;
  height: 26px;
}
/* THE FLAT LINE IS A REAL ELEMENT, NOT THE BARS SQUASHED. Collapsing the path
   alone leaves a row of one-pixel slivers with gaps between them — a dashed
   line, which reads as a rendering fault rather than as silence. The centre rule
   is drawn underneath and is always there, so at zero the strip shows a level
   meter sitting on its baseline and the bars grow out of it. */
.rec__wave::before {
  content: ''; position: absolute; left: 0; right: 0; top: 50%;
  height: 1px; margin-top: -.5px;
  background: var(--c-line-2);
  transition: opacity var(--d-3) var(--e-out);
}
/* AND IT GOES WHEN THE BARS ARRIVE. The line is the ZERO — it is what the meter
   reads when there is nothing to read. Left drawn underneath a playing waveform
   it stops being the baseline and becomes a hairline ruled straight through the
   signal, still running full width past the playhead into a region that has not
   played yet. One thing says the level at a time. */
.rec.playing .rec__wave::before { opacity: 0; }
/* AT REST THE BARS GO TO ZERO, NOT TO "NEARLY ZERO". A first pass left them at
   7% so the line would still have some tooth to it — but the waveform is 84 bars
   with a gap between each, so 7% of 26px is a row of 5px dashes with 2px of
   height, and what it drew was not a flat waveform, it was a dashed rule. The
   hairline is the rest state; the bars are the signal, and there is no signal. */
.rec__wave > .wave {
  position: absolute; inset: 0; height: 100%;
  transform: scaleY(var(--amp, 0));
  transform-origin: 50% 50%;
  transition: transform var(--d-4) var(--e-spring);
}
.rec.playing .rec__wave > .wave { --amp: 1; }
.rec__wave-a { color: var(--c-line-2); }
.rec__wave-b {
  color: var(--c-accent);
  clip-path: inset(0 calc(100% - var(--p, 0) * 100%) 0 0);
}
@media (max-width: 860px) { .rec__wave { display: none; } }

.rec__time { flex: none; color: var(--c-faint); transition: color var(--d-2); }
.rec.playing .rec__time { color: var(--c-muted); }

/* The actions sit hard right, which is where a transport's controls go and what
   makes the strip read as [what is playing ————— what you can do about it]. */
.rec__acts { display: flex; align-items: center; gap: 6px; flex: none; }

/* STOP, NOT MUTE. It ends what is playing and puts previews back to sleep, so
   the shelf returns to the state it loaded in — which is the only "off" anyone
   actually wants, and it needs no mode to be understood. Icon only: it sits
   beside two labelled links and a third label would make the corner a sentence.
----------------------------------------------------------------------------- */
.rec__stop,
.rec__go {
  display: inline-flex; align-items: center; gap: 7px;
  height: 32px;
  padding: 0 11px;
  border: 1px solid var(--c-line);
  border-radius: var(--r-ctl);
  background: transparent;
  color: var(--c-muted);
  cursor: pointer;
  transition: color var(--d-2), border-color var(--d-2), background var(--d-2);
}
.rec__stop { width: 32px; padding: 0; justify-content: center; }
/* Dimmed rather than removed, for the same reason the shelf arrows fade instead
   of vanishing: a control that disappears takes the strip's shape with it. */
.rec__stop[disabled] { opacity: .34; cursor: default; pointer-events: none; }
.rec__stop:hover, .rec__go:hover {
  color: var(--c-fg);
  border-color: var(--c-line-2);
  background: var(--c-glass);
}
/* Open is the one thing anybody is going to press here, so it is the one that
   carries the accent — rule 4's single primary action, scoped to this strip. */
.rec__go {
  color: var(--c-accent-txt);
  border-color: color-mix(in srgb, var(--c-accent) 34%, transparent);
  background: color-mix(in srgb, var(--c-accent) 8%, transparent);
}
.rec__go:hover {
  color: var(--c-accent-txt);
  border-color: color-mix(in srgb, var(--c-accent) 62%, transparent);
  background: color-mix(in srgb, var(--c-accent) 15%, transparent);
}
.rec__go .ico { transition: transform var(--d-2) var(--e-out); }
.rec__go:hover .ico { transform: translate(2px, -2px); }
/* Apple is the second store, so it is the mark alone — the same record, another
   door, and not a second call to action. */
.rec__go--alt {
  width: 32px; padding: 0; justify-content: center;
  color: var(--c-muted);
  border-color: var(--c-line);
  background: transparent;
}
.rec__go--alt:hover { color: var(--c-fg); border-color: var(--c-line-2); background: var(--c-glass); }

@media (max-width: 700px) {
  .rec__np { flex-wrap: wrap; row-gap: 10px; }
  /* The showing one takes the whole first row; the hidden one still has to
     collapse, which is why these are scoped by state rather than by class —
     `flex: 1 1 100%` on both would put a zero-width element on its own line and
     open a blank row under the readout. */
  .rec.playing .rec__now,
  .rec:not(.playing) .rec__cue { max-width: none; flex: 1 1 100%; }
  .rec__time { margin-left: auto; }
}

/* ---- the shelf ----------------------------------------------------------- */
.rec__crate {
  display: flex;
  gap: clamp(14px, 1.5vw, 22px);
  list-style: none;
  margin: 0;
  padding: var(--pad-t) clamp(14px, 1.4vw, 20px) clamp(20px, 2.2vw, 28px);
  border: 1px solid var(--c-line);
  border-radius: var(--r);
  background: var(--c-panel);
  /* NO SCROLL SNAP AT ALL, and it was tried both ways. Mandatory fought the drag
     the whole way. Proximity looked like the answer and still pulls: a drag that
     ends 144px along gets yanked back to the start, because zero is the nearest
     snap point — the shelf undoing what you just did. It also races the
     momentum, the snap engine pulling toward a sleeve edge while the throw is
     still pushing, and two things steering one scroll position always reads as
     lag. A record crate is a continuous shelf, not a set of pages.

     The `scroll-padding-inline` that existed only to stop snap opening the shelf
     at scrollLeft 20 went with it. */
}

/* NO GRAB CURSOR AT REST. A fist under the pointer everywhere on the shelf says
   the only way to move it is to haul it, which was never true and is certainly
   not true now the wheel drives it — and a cursor that changes the instant you
   enter a region reads as the region taking hold of you. The fist appears only
   once a drag is genuinely under way, where it is describing something that is
   actually happening. */

.rec__crate.dragging { cursor: grabbing; }
.rec__crate.dragging .cut__b { cursor: grabbing; }
/* While a drag is in progress the sleeves must not swallow the pointer or start
   their own hover state — the shelf is being moved, not read. */
.rec__crate.dragging * { pointer-events: none; }

/* THE ARTWORK IS THE POINT OF THIS CHANNEL, so it gets the room the transport
   bar used to take. Sleeves went from clamp(146, 15vw, 208) to this — about four
   across a 1440 shelf instead of six and a half, which is the difference between
   a contact sheet and a record crate. */
.cut {
  flex: none;
  width: var(--sleeve);
}

/* ---- the shelf arriving ---------------------------------------------------
   THE SLEEVES WIPE UP OUT OF THE SHELF, one after another, when the channel is
   scrolled to. `clip-path` and not a translate, deliberately: the crate is a
   horizontal scroller, which means `overflow-y` computes to `auto` as well, so
   anything that starts a few pixels below where it belongs pushes the shelf into
   a vertical scroll for the length of the animation. A clip moves no boxes.

   The cover wipes and the artwork settles out of a slight over-scale at the same
   time, so the picture arrives with a little depth rather than a curtain going
   up on a flat image. The title and the year follow a beat later — the record
   first, its name second, which is the order you would read a crate in.

   Only the first eight are staggered. Past that the reader has not seen them
   yet, and a delay that keeps growing means sleeve twenty is still waiting to
   appear a second and a half after you arrive. */
.js .rec__crate .cut { --rd: 0ms; }
.js .rec__crate .cut:nth-child(2) { --rd: 70ms; }
.js .rec__crate .cut:nth-child(3) { --rd: 140ms; }
.js .rec__crate .cut:nth-child(4) { --rd: 210ms; }
.js .rec__crate .cut:nth-child(5) { --rd: 280ms; }
.js .rec__crate .cut:nth-child(6) { --rd: 350ms; }
.js .rec__crate .cut:nth-child(7) { --rd: 420ms; }
.js .rec__crate .cut:nth-child(n+8) { --rd: 480ms; }

.js .rec__crate .cut__b { clip-path: inset(100% 0 0 0); }
.js .rec__crate .cut__art { scale: 1.2; }
.js .rec__crate .cut__t,
.js .rec__crate .cut__m { opacity: 0; }

.js .rec.lit .cut__b {
  clip-path: inset(0 0 0 0);
  transition: clip-path var(--d-4) var(--e-out) var(--rd),
              border-color var(--d-2), box-shadow var(--d-3) var(--e-out);
}
.js .rec.lit .cut__art {
  scale: 1.1;
  /* `scale` only. A transition on `translate` would put the drift on a 1150ms
     delay-loaded curve — see the note on `.cut__art`. */
  transition: scale var(--d-5) var(--e-out) var(--rd), filter var(--d-3) var(--e-out);
}
.js .rec.lit :is(.cut__t, .cut__m) {
  opacity: 1;
  transition: opacity var(--d-3) var(--e-out) calc(var(--rd) + 160ms), color var(--d-2);
}

/* NOTHING ON THIS SHELF MOVES. The sleeves lifted on hover and lifted further
   while playing for one revision — "album art popup" — and it was rejected. It
   was also the wrong instinct: twenty-six covers on a shelf is already a lot of
   visual information, and making one of them jump out means the whole row
   twitches as a pointer travels along it.

   Every state here is drawn INTO the sleeve instead: the artwork comes up to
   full colour, the frame takes the accent, a NOW PLAYING chip lights in the
   corner and the playhead runs along the foot, while the rest of the shelf steps
   back. All information, no displacement. */
/* An `<a>` now, not a button: pressing a cover opens the record on Spotify and
   hovering it previews. `display: block` so the anchor is the frame itself. */
.cut__b {
  position: relative;
  display: block;
  width: 100%;
  aspect-ratio: 1;
  padding: 0;
  border: 1px solid var(--c-line);
  border-radius: var(--r-ctl);
  background: var(--c-panel);
  overflow: hidden;
  cursor: pointer;
  isolation: isolate;
  box-shadow: 0 0 0 rgb(0 0 0 / 0);
  transition: border-color var(--d-2), box-shadow var(--d-3) var(--e-out);
}
.cut:hover .cut__b { border-color: var(--c-line-2); }
.cut__b:focus-visible { outline: 2px solid var(--c-accent); outline-offset: 2px; }
/* Playing: the frame becomes the record light. A ring and a bloom, both drawn
   outside the border, so the sleeve occupies exactly the same pixels it did. */
.cut.on .cut__b {
  border-color: color-mix(in srgb, var(--c-accent) 55%, var(--c-line-2));
  box-shadow:
    0 0 0 1px color-mix(in srgb, var(--c-accent) 30%, transparent),
    0 4px 30px -4px color-mix(in srgb, var(--c-accent) 30%, transparent);
}

/* THE SLEEVE FLOATS INSIDE ITS FRAME. `--ax` is written by app.js from the
   sleeve's distance off the centre of the shelf, so the art drifts against the
   scroll instead of being welded to it — the same easing idea as the page's
   `data-par` frames, at crate scale. The 1.1 scale is the headroom that drift
   needs; without it the parallax would expose the frame's own edge. */
.cut__art {
  display: block;
  width: 100%; height: 100%;
  object-fit: cover;
  /* SCALE AND TRANSLATE ARE SEPARATE PROPERTIES HERE, AND THAT IS THE WHOLE
     POINT. They used to be one `transform`, which meant the shelf's entrance —
     a 1150ms ease on `transform` — was also animating the pointer-following
     drift, because the drift writes into the same property. Every `--ax` write
     during a sideways scroll was therefore played back over more than a second,
     with up to 480ms of stagger delay in front of it: the artwork lagged the
     shelf badly and arrived long after you had stopped moving.

     As independent properties the entrance can transition `scale` while
     `translate` stays immediate, so the drift is live at the frame it is
     written. Never put a transition on `translate` here. */
  scale: 1.1;
  translate: var(--ax, 0px) 0;
  transition: filter var(--d-3) var(--e-out);
  /* BARELY TOUCHED. This was `saturate(.9) brightness(.86)`, which put a grey
     film over a wall of artwork that people spent money having made. The resting
     state is now a hair below full so that coming UP to full still reads as a
     change, and that is the whole job of it. */
  filter: saturate(.98) brightness(.94);
}
.cut__b:hover .cut__art,
.cut.on .cut__art { filter: none; }
/* While something plays the rest of the shelf steps back — but only just. At
   `brightness(.52)` it was a blackout: twenty-five covers went to mud so that one
   could be lit, which is a spotlight, not a shelf. A few points is enough,
   because the record that is up has an accent frame, a bloom and a lit meter of
   its own. */
.rec.playing .cut:not(.on) .cut__art { filter: saturate(.9) brightness(.78); }

/* The play mark. It sits over a scrim rather than over the art, because a white
   glyph on an unknown cover is a coin toss. */
.cut__play {
  position: absolute; inset: 0;
  display: grid; place-items: center;
  background: linear-gradient(180deg, rgb(0 0 0 / .12), rgb(0 0 0 / .48));
  opacity: 0;
  transition: opacity var(--d-2) var(--e-out), background var(--d-2);
}
/* THE CENTRED OVERLAY IS FOR HOVER ONLY. It exists to make a white triangle
   legible on an unknown cover — that is a "you could press this" affordance, and
   once the record is playing there is nothing left to offer, so it goes and
   stops dimming the artwork it is meant to be celebrating. */
.cut:hover .cut__play, .cut__b:focus-visible .cut__play { opacity: 1; }
.cut.on .cut__play { opacity: 0; }
/* TWO MARKS THAT CROSS-FADE, NOT ONE THAT MORPHS. The play triangle used to
   become the playing indicator by animating its own border widths, margin and
   background at once — four properties describing two completely different
   shapes, which is a smear rather than a transition. They are separate now and
   only their opacity changes.

   ::before is the triangle. Idle-with-a-pointer-on-it. */
.cut__play::before {
  content: '';
  grid-area: 1 / 1;
  width: 0; height: 0;
  margin-left: 4px;
  border-left: 15px solid #fff;
  border-top: 9px solid transparent;
  border-bottom: 9px solid transparent;
  filter: drop-shadow(0 2px 10px rgb(0 0 0 / .5));
  transition: opacity var(--d-2) var(--e-out), transform var(--d-2) var(--e-spring);
}
.cut:hover .cut__play::before { transform: scale(1.1); }

/* THE PLAYING MARK IS A CHIP IN THE CORNER, NOT A GLYPH IN THE MIDDLE. Centred,
   it sits on top of the picture and needs a scrim to be legible; in the corner
   it reads as a lamp on a piece of equipment and the cover is left alone. It is
   also a LEVEL METER rather than a pause icon — a pause glyph describes a
   control you could press, a meter describes a state that is true, which is what
   this is. Rule 5: the movement is the information.

   Three bars from three background gradients on one box, so the chip costs no
   extra elements, and `.cut__bar`'s playhead is right beneath it. */
.cut__play::after {
  content: '';
  position: absolute; left: 10px; top: 10px;
  width: 17px; height: 15px;
  opacity: 0;
  background:
    linear-gradient(var(--c-accent), var(--c-accent)) 0 100% / 3px var(--b1, 40%) no-repeat,
    linear-gradient(var(--c-accent), var(--c-accent)) 50% 100% / 3px var(--b2, 80%) no-repeat,
    linear-gradient(var(--c-accent), var(--c-accent)) 100% 100% / 3px var(--b3, 55%) no-repeat;
  filter: drop-shadow(0 1px 6px rgb(0 0 0 / .85));
}
/* On the ACT, not on the overlay, because the overlay is hidden while playing. */
.cut.on .cut__play {
  opacity: 1;
  background: none;
  pointer-events: none;
}
.cut.on .cut__play::before { opacity: 0; }
.cut.on .cut__play::after {
  opacity: 1;
  animation: cutLevel 1.15s var(--e-inout) infinite;
}
@keyframes cutLevel {
  0%,  100% { --b1: 35%; --b2: 85%; --b3: 55%; }
  25%       { --b1: 90%; --b2: 40%; --b3: 75%; }
  50%       { --b1: 55%; --b2: 65%; --b3: 30%; }
  75%       { --b1: 75%; --b2: 95%; --b3: 85%; }
}
/* Custom properties have to be registered before they can be interpolated;
   without this the bars would jump between keyframes instead of moving. */
@property --b1 { syntax: '<percentage>'; inherits: false; initial-value: 40%; }
@property --b2 { syntax: '<percentage>'; inherits: false; initial-value: 80%; }
@property --b3 { syntax: '<percentage>'; inherits: false; initial-value: 55%; }

/* The playhead across the foot of the sleeve — the same value the transport is
   showing, drawn where the eye already is. */
.cut__bar {
  position: absolute; left: 0; right: 0; bottom: 0;
  height: 4px;
  background: rgb(0 0 0 / .5);
  opacity: 0;
  transition: opacity var(--d-2);
}
.cut.on .cut__bar { opacity: 1; }
.cut__bar i {
  display: block; height: 100%;
  background: var(--c-accent);
  box-shadow: 0 0 10px color-mix(in srgb, var(--c-accent) 70%, transparent);
  transform: scaleX(var(--p, 0)); transform-origin: left;
}

/* THE COVER IS THE LINK, drawn as a bare stretched anchor UNDER everything else.
   It is a sibling of the controls rather than a wrapper around them, because an
   anchor cannot legally contain another anchor — nest them and a press opens two
   tabs. Stacking order does the rest. */
.cut__hit { position: absolute; inset: 0; z-index: 1; border-radius: inherit; }

/* THE STORE BUTTONS LIVE ON THE ARTWORK. They were in the strip under the
   waveform, which is the wrong place: the record you want to open is the cover
   you are looking at, so the buttons belong on it. Bottom-right corner, opposite
   the level meter in the top-left, both out of the middle where the picture is.
   They appear under the pointer and for keyboard focus, and never on touch —
   there the whole cover is already the link. */
.cut__acts {
  position: absolute; z-index: 3;
  right: 9px; bottom: 9px;
  display: flex; gap: 5px;
  opacity: 0;
  transition: opacity var(--d-2) var(--e-out);
}
.cut:hover .cut__acts, .cut:focus-within .cut__acts { opacity: 1; }
.cut__go {
  display: inline-flex; align-items: center; gap: 6px;
  height: 28px; padding: 0 10px;
  border-radius: var(--r-ctl);
  /* Opaque against artwork that could be any colour. */
  background: color-mix(in srgb, var(--c-bg) 80%, transparent);
  backdrop-filter: blur(10px);
  border: 1px solid rgb(255 255 255 / .16);
  color: #fff;
  transition: background var(--d-2), border-color var(--d-2);
}
.cut__go:hover { background: var(--c-accent); border-color: var(--c-accent); color: var(--c-accent-ink); }
.cut__go--alt { width: 28px; padding: 0; justify-content: center; }
.cut__go .plat__i { color: currentColor; }

.cut__t {
  display: block;
  margin-top: 11px;
  font-size: var(--t-bd);
  font-weight: 500;
  line-height: 1.24;
  letter-spacing: var(--tr-body);
  color: var(--c-muted);
  transition: color var(--d-2);
}
.cut__t:hover, .cut.on .cut__t { color: var(--c-fg); }
.cut__m { margin-top: 5px; color: var(--c-faint); }
.cut__m i { font-style: normal; color: var(--c-line-2); }

/* ---- where to go and listen ---------------------------------------------- */
.plat {
  display: grid; gap: 1px;
  margin-top: clamp(22px, 2.6vw, 34px);
  padding: 1px;
  background: var(--c-line);
  border: 1px solid var(--c-line);
  border-radius: var(--r);
  overflow: hidden;
}
@media (min-width: 720px) { .plat { grid-template-columns: repeat(3, 1fr); } }
.plat__b {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: center;
  gap: 3px var(--s-3);
  padding: clamp(15px, 1.6vw, 20px) clamp(16px, 1.6vw, 22px);
  background: var(--c-panel);
  transition: background var(--d-2);
}
.plat__b:hover { background: var(--c-panel-2); }
/* EVERY CELL IS PLACED BY HAND. Two of these span both rows, and an item with a
   definite row span but an automatic column goes through a different pass of the
   auto-placement algorithm — the arrow landed in the name's cell and the name
   was pushed to the far edge. Row-spanning items and auto-placement do not mix;
   name the column. */
.plat__i  { grid-column: 1; grid-row: 1 / 3; color: var(--c-muted); transition: color var(--d-2); }
.plat__b:hover .plat__i { color: var(--c-fg); }
.plat__n  { grid-column: 2; grid-row: 1; font-size: var(--t-bd); font-weight: 500; letter-spacing: var(--tr-body); }
.plat__m  { grid-column: 2; grid-row: 2; color: var(--c-faint); }
.plat__go { grid-column: 3; grid-row: 1 / 3; color: var(--c-faint); transition: color var(--d-2), transform var(--d-2) var(--e-out); }
.plat__b:hover .plat__go { color: var(--c-fg); transform: translate(3px, -3px); }

/* ---- the video bench: a monitor stack ------------------------------------
   One large frame and two smaller ones beside it, which is the shape of every
   desk that has pictures on it. Deliberately NOT the crate's shape — those are
   square sleeves running sideways; these are 16:9 monitors that sit still, and
   two horizontal scrollers in one channel would read as one idea used twice.

   THE RIGHT COLUMN IS SIZED BY THE LEFT ONE. Both `.vid`s in the stack are
   `1fr` rows of a grid that is exactly as tall as the big frame, and their
   artwork fills whatever is left after the title — so the two columns end level
   without a single measured number. `min-height: 0` on the frame is what lets
   an image shrink inside a `1fr` row at all; without it the row is floored at
   the image's intrinsic height and the stack overshoots.
--------------------------------------------------------------------------- */
.bench { margin-top: clamp(40px, 5vw, 68px); }
.bench__head { display: flex; align-items: center; gap: var(--s-4); }
.bench__rule { flex: 1 1 auto; height: 1px; background: var(--c-line); }
.bench__all {
  display: inline-flex; align-items: center; gap: 8px;
  color: var(--c-muted);
  transition: color var(--d-2);
}
.bench__all:hover { color: var(--c-fg); }
.bench__all .plat__i { color: currentColor; }
.bench__all .ico { transition: transform var(--d-2) var(--e-out); }
.bench__all:hover .ico { transform: translate(3px, -3px); }

.bench__grid {
  display: grid;
  gap: clamp(14px, 1.6vw, 22px);
  margin-top: clamp(20px, 2.2vw, 30px);
}
.bench__side { display: grid; gap: clamp(14px, 1.6vw, 22px); }
@media (min-width: 760px) {
  /* TWO ROWS, AND THE STACK SPANS BOTH. Row one is the feature at its natural
     height, row two the subscribe plate at its own; the pair on the right spans
     the lot and splits it in two. Nothing is measured and nothing is circular —
     the left column states the height, the right column takes it. */
  .bench__grid {
    grid-template-columns: minmax(0, 1.62fr) minmax(0, 1fr);
    /* Row two is `1fr` so the mark takes whatever the stack beside it does not. */
    grid-template-rows: auto 1fr;
    align-items: stretch;
  }
  .vid--pgm    { grid-column: 1; grid-row: 1; }
  .subx        { grid-column: 1; grid-row: 2; }
  /* EVERY FRAME IS SPACED THE SAME, and the subscribe mark absorbs the
     difference. Two earlier tries got this wrong from opposite ends: `1fr` rows
     stretched each frame to half the column, which at this width is nearly
     square — and a 16:9 thumbnail fitted inside a square leaves two fat bands of
     backdrop. `space-between` fixed the shape but paid for it in a hole between
     the two frames, which reads as a missing fourth video.

     So the stack takes the SAME gap as everything else and sits at the top, and
     the leftover height goes to the mark instead, which is centred text and can
     be any height at all. Three frames, one gap value, no holes. */
  .bench__side { grid-column: 2; grid-row: 1 / 3; grid-template-rows: auto auto; align-content: start; }
}

.vid { display: block; }
.vid__shot {
  position: relative;
  display: block;
  aspect-ratio: 16 / 9;
  border: 1px solid var(--c-line);
  border-radius: var(--r-ctl);
  overflow: hidden;
  background: var(--c-panel);
}
/* Quieter than the crate on purpose: the catalogue is the headline of this
   channel and the bench is the workshop behind it. Rule 6 — nothing moves, the
   frame just comes up out of the dark. */
/* THE FRAME FILLS ITSELF WITH A BLURRED COPY OF THE PICTURE. Several of these
   uploads are vertical, and YouTube bakes the pillarbox INTO its 16:9 thumbnail
   — the picture is about a third of the file's width, so no crop can reach it
   and `object-fit: cover` just leaves two black bars that read as a broken
   image. Fitting the thumbnail whole and filling the dead space with a blurred,
   over-scaled copy of itself is what every video product does with mismatched
   aspect ratios, and on a monitor it reads as the screen's own glow.
   `scale` past 1 hides the soft edge a blur leaves at the boundary. */
.vid__shot::before {
  content: '';
  position: absolute; inset: 0;
  background: var(--shot) center / cover no-repeat;
  filter: blur(24px) saturate(1.35) brightness(.42);
  scale: 1.3;
}
.vid__shot img {
  position: relative;
  width: 100%; height: 100%;
  /* CONTAIN, not cover: the thumbnail is shown whole and the backdrop takes
     whatever is left over. */
  object-fit: contain;
  filter: saturate(.9) brightness(.72);
  transition: filter var(--d-3) var(--e-out);
}
.vid:hover .vid__shot img { filter: none; }
.vid:hover .vid__shot { border-color: var(--c-line-2); }

/* THE SCAN LINES ARE THE MONITOR. A picture inside a frame is a photograph; a
   picture with a raster on it is a display showing something. They sit at a
   whisper by default and come up under the pointer — the same lift the artwork
   makes — so the frame reads as a screen waking rather than as an effect.
   `background-size` in px on a repeating gradient, so the raster stays the same
   pitch whatever size the monitor is. */
.vid__scan {
  position: absolute; inset: 0; z-index: 2;
  pointer-events: none;
  background: repeating-linear-gradient(180deg,
    rgb(255 255 255 / .05) 0 1px, transparent 1px 3px);
  opacity: .5;
  transition: opacity var(--d-3) var(--e-out);
}
.vid:hover .vid__scan { opacity: 1; }

/* The corner tag: which monitor this is. On the big one it is a tally lamp and
   the word Latest, which is a real fact about the channel rather than a light
   bolted on because consoles have lights. */
.vid__tag {
  position: absolute; left: 10px; top: 10px; z-index: 3;
  display: inline-flex; align-items: center; gap: 7px;
  padding: 5px 9px;
  border-radius: var(--r-ctl);
  background: color-mix(in srgb, var(--c-bg) 74%, transparent);
  backdrop-filter: blur(8px);
  color: #fff;
}
.vid:not(.vid--pgm) .vid__tag { color: rgb(255 255 255 / .62); }

/* The play mark, bottom-right so it is not on top of the tag and not over the
   middle of the picture. */
.vid__shot::after {
  content: '';
  position: absolute; right: 12px; bottom: 12px; z-index: 3;
  width: 0; height: 0;
  border-left: 15px solid #fff;
  border-top: 9px solid transparent;
  border-bottom: 9px solid transparent;
  opacity: .8;
  filter: drop-shadow(0 2px 10px rgb(0 0 0 / .7));
  transition: opacity var(--d-2), transform var(--d-2) var(--e-spring);
}
.vid:hover .vid__shot::after { opacity: 1; transform: scale(1.14); }

.vid__t {
  margin-top: 11px;
  font-size: var(--t-bd);
  font-weight: 500;
  line-height: 1.3;
  letter-spacing: var(--tr-body);
  color: var(--c-muted);
  transition: color var(--d-2);
}
.vid:hover .vid__t { color: var(--c-fg); }
.vid__m { margin-top: 5px; color: var(--c-faint); }
/* The stacked pair carry less text than the feature, so they get less of it. */
.bench__side .vid__t { font-size: var(--t-sm); }

/* ---- the subscribe mark ---------------------------------------------------
   NO PANEL. This began as a bordered plate and it was wrong twice over: the
   bench is already three framed rectangles, so a fourth made the whole section
   read as boxes rather than as screens, and the thing itself is not a card — it
   is a sign. Just type, centred in the space the feature leaves behind.

   Centred is also the only alignment that makes sense here. Everything else in
   the rack is set flush left because it is a list, a readout or a paragraph;
   this is one call to action standing alone in a gap, and left-aligning it would
   read as another column of copy that never arrived.
--------------------------------------------------------------------------- */
.subx {
  display: grid;
  place-content: center;
  justify-items: center;
  text-align: center;
  gap: clamp(12px, 1.4vw, 18px);
  /* NO VERTICAL PADDING. The row supplies the height and `place-content: center`
     puts the type in the middle of it, so padding here does nothing but make the
     block taller than the space it was given — which pushes the whole grid down
     and leaves the video stack ending short of it. Inline padding only, to keep
     the text off the column edges on a phone. */
  padding-inline: var(--s-4);
}

/* Word and arrow on one baseline, so the mark reads as a single object rather
   than a heading with an icon parked under it. The arrow sits OUTSIDE the
   clipped span on purpose — `background-clip: text` plus a transparent text
   fill is a text-painting trick, and an SVG inside it inherits a paint context
   it was never meant to be in. */
/* THE ARROW HANGS OFF THE WORD INSTEAD OF SITTING BESIDE IT, so it cannot pull
   the centring. As a flex sibling the line box was word + gap + arrow, and
   centring THAT put the word about 19px left of the line beneath it — close
   enough to look like a mistake rather than a decision. Taken out of flow, the
   line box is exactly the word, so the word and the sentence share one axis and
   the arrow simply hangs in the margin.

   Nudged with the `translate` property rather than `transform`, so the hover
   offset composes with the -50% centring instead of overwriting it. */
.subx__line { position: relative; display: inline-block; }
.subx__arr {
  position: absolute;
  left: calc(100% + clamp(10px, 1.2vw, 18px));
  top: 50%;
  translate: 0 -50%;
  color: var(--c-faint);
  transition: color var(--d-2), translate var(--d-2) var(--e-out);
}
.subx:hover .subx__arr { color: var(--c-accent-txt); translate: 4px calc(-50% - 4px); }

/* THE WORD. Mono and uppercase, because rule 1 files button text under the
   console face and rule 2 allows caps only there.

   The amber lamp travelling through the letterforms is the site's one motion
   idea — the boot readout filling the name, the nav wordmark, Back-to-top's
   shimmer, and now this. `drop-shadow`, not `text-shadow`: with the fill
   transparent and the paint coming from a clipped background, only a filter can
   see the actual glyphs; a text-shadow would draw around a box of nothing. */
.subx__w {
  font-family: var(--f-mono);
  font-size: clamp(1.5rem, 3.6vw, 3.1rem);
  font-weight: 600;
  letter-spacing: .05em;
  line-height: 1;
  text-transform: uppercase;
  background-image: linear-gradient(100deg,
    var(--c-fg) 0 38%,
    var(--c-accent) 50%,
    var(--c-fg) 62% 100%);
  background-size: 320% 100%;
  background-position: 100% 0;
  background-repeat: no-repeat;
  -webkit-background-clip: text;
          background-clip: text;
  -webkit-text-fill-color: transparent;
  transition: background-position var(--d-5) var(--e-out), filter var(--d-3) var(--e-out);
}
.subx:hover .subx__w,
.subx:focus-visible .subx__w {
  background-position: 0 0;
  filter: drop-shadow(0 0 14px color-mix(in srgb, var(--c-accent) 50%, transparent));
}

.subx__d {
  color: var(--c-muted);
  font-size: var(--t-sm);
  line-height: 1.6;
  max-width: 42ch;
  text-wrap: balance;
}
/* One line: the handle and its arrow are a single label, and letting the arrow
   wrap onto its own row turns a link into a typo. */


@media (prefers-reduced-motion: reduce) {
  /* The drift is the one thing here that is motion for its own sake once the
     shelf is still, so it is pinned flat. Sound is a different axis and is left
     alone — it never starts on its own anyway. */
  /* SPECIFICITY HAS TO MATCH THE REVEAL RULE. `.js .rec.lit .cut__art` is four
     selectors deep, so a bare `.cut__art` here lost and the artwork stayed at
     its 1.1 parallax headroom — a 10% crop for a drift that reduced motion has
     already switched off. Named in full, it wins on order. */
  .cut__art,
  .js .rec.lit .cut__art { scale: 1; translate: none; }
  .rec__crate { scroll-behavior: auto; }
  /* `--motion` only reaches transitions, so a forever-looping keyframe has to be
     stopped by name. The bars hold a level instead of playing one. */
  .cut.on .cut__play::after { animation: none; }
  /* The strip no longer slides in, but the waveform still springs up off its
     baseline — which is the one motion left on it, so it is the one to pin. */
  .rec__wave > .wave { transition: none; }
  /* The raster holds one level rather than reacting. */
  .vid__scan { opacity: .5; }
  /* The shelf is simply there. The reveal is an entrance, which is the exact
     category this setting exists to switch off. */
  .js .rec__crate .cut__b { clip-path: none; }
  .js .rec__crate .cut__art { scale: 1; }
  .js .rec__crate :is(.cut__t, .cut__m) { opacity: 1; }
}

/* =============================================================================
   CH 06 — PROCESS
   ============================================================================= */

.proc { display: grid; gap: 1px; background: var(--c-line); border: 1px solid var(--c-line); border-radius: var(--r); overflow: hidden; grid-template-columns: repeat(auto-fit, minmax(min(100%, 230px), 1fr)); }
.proc__step { background: var(--ch-bg); padding: clamp(20px, 2.4vw, 30px); display: grid; gap: var(--s-3); align-content: start; transition: background var(--d-3) var(--e-out); }
.proc__step:hover { background: var(--c-panel-2); }
.proc__n {
  width: 32px; height: 32px; border: 1px solid var(--c-line-2); border-radius: var(--r-ctl);
  display: grid; place-items: center;
  font-family: var(--f-mono); font-size: var(--t-xs); color: var(--c-muted);
  margin-bottom: var(--s-2);
  transition: border-color var(--d-2), color var(--d-2), background var(--d-2);
}
.proc__step:hover .proc__n { border-color: var(--c-fg); color: var(--c-bg); background: var(--c-fg); }
.proc__t { font-size: var(--t-lg); font-weight: 600; letter-spacing: var(--tr-head); }
.proc__d { color: var(--c-muted); font-size: var(--t-sm); }

/* =============================================================================
   CH 02 — SELECTED WORK  (one plate: an index and the project it is showing)

   WHY THIS WAS REBUILT. The first version of the merge put a six-cell index
   plate directly on top of a stage plate — two bordered racks of the same shape,
   one above the other, and they said the same things twice: the index gave each
   project a number, a title and a "kind · year", and then the card immediately
   repeated all three in its kicker and headline. On top of that the card carried
   a giant figure AND four label/value rows AND a tag row AND a link, so the
   section managed to be crowded at the top and half empty at the bottom at the
   same time. That is what "cluttered" was.

   THE FIX IS ONE OBJECT, NOT TWO, AND EACH FACT IN ONE PLACE.

   `.wkx` is a single plate split by one hairline: the INDEX down the left, the
   PROJECT on the right. Nothing is repeated across the divide — the index owns
   the number, the title and the year; the card owns the kind, the excerpt, the
   headline figure and the link. The card lost its tag chips because the mono
   facts line under it was already saying the same words, and it lost the four
   readout rows because "Kind / Stack / Built for / Source" were the eyebrow, the
   facts line, the excerpt and the link written a second time.

   Result: ~640px of stacked racks became a ~360px plate holding more air and
   less furniture.

   THE HEADLINE FIGURE IS PRESENT ONLY WHERE THERE IS ONE. Two of the six have a
   real number; the other four leave that corner empty rather than filling it
   with a year dressed up as a metric. A readout with nothing to say should be
   blank — that is the same rule the meters and waveforms follow everywhere else.

   IT IS STILL A MOVIE. The index is what a carousel's dots always wanted to be:
   not six anonymous markers but the actual titles, so the transport is also the
   contents page. The armed row carries the cue mark and its dwell bar.

   THREE THINGS KEEP IT HONEST:

   1. IT IS SIX PLAIN BLOCKS FIRST. The acts are ordinary flow content, divided
      by hairlines, and only `.js` collapses them into one cell and renders the
      index. No scripting, or reduced motion, and CH 02 is six project panels in
      a column, each with its own link out.
   2. THE STAGE IS A ONE-CELL GRID, NOT ABSOLUTE POSITIONING, so it is exactly as
      tall as its tallest act at whatever width it is read at.
   3. INACTIVE ACTS ARE `opacity: 0`, NEVER `visibility: hidden` or `display:
      none`, so a screen reader gets all six in order. Their links are parked at
      `tabindex="-1"` by app.js — staying readable and staying tabbable are two
      different things.
   ============================================================================= */

.reel { position: relative; }

/* ---- the plate ----------------------------------------------------------- */
.wkx {
  display: grid;
  border: 1px solid var(--c-line);
  border-radius: var(--r);
  background: var(--c-panel-2);
  overflow: hidden;          /* the act textures are inset:0 and must not escape */
}
@media (min-width: 900px) {
  /* THE INDEX IS TAKEN OUT OF FLOW, and that is what lets it scroll.

     As a grid item it sized its own track: twenty rows made the plate 1040px
     tall instead of scrolling inside 312. The usual answer — `overflow` setting
     an automatic minimum size of zero — only stops an item being SQUASHED below
     its content by a track sized from elsewhere. Here nothing else constrained
     the track, so `auto` sized it to the index's max-content and the plate grew.

     Absolute against the plate, `inset: 0 auto 0 0`, means its height IS the
     card's height, whatever that turns out to be, and anything past it scrolls.
     The lane width is stated once as a variable so the stage's inset and the
     index cannot drift apart. It is sized to the longest title at one line —
     "Nullbyte Ticketing System" — because an index whose rows wrap stops being
     scannable down its left edge, which is the only reason to give it a column.

     `.js` on both, because with no script there is no index and the card should
     not be indented past a lane that is not there. */
  .wkx { --lane: clamp(258px, 25vw, 340px); position: relative; }
  .js .wkx__index {
    position: absolute;
    inset: 0 auto 0 0;
    width: var(--lane);
  }
  .js .wkx > .reel__stage { margin-left: var(--lane); }
}

/* ---- the index, which is also the transport ------------------------------ */
/* `.js`-gated: a bank of buttons that cannot do anything is worse than no bank
   at all, and without scripting the six acts below are already six readable
   panels. */
.wkx__index { display: none; }
.js .wkx__index {
  display: grid;
  list-style: none;
  margin: 0;
  padding: 0;
  border-bottom: 1px solid var(--c-line);
  /* SIX IS NOT THE LIMIT, THE COLUMN IS. Rows keep a real minimum height and
     take an equal share of whatever is left, so a short list fills its bay and a
     long one runs past the bottom and scrolls inside it. The plate does not grow
     either way, which is the point: adding a twentieth project costs no page.

     The grid row is sized by the CARD, not by this — an `overflow` other than
     visible sets a grid item automatic minimum size of zero, so however many
     rows are in here they contribute nothing to the row height and are simply
     scrolled through. That one line is what makes the whole thing work. */
  grid-auto-rows: minmax(var(--wkx-row, 52px), 1fr);
}
.js .wkx__index li { display: grid; }
@media (min-width: 900px) {
  /* One hairline is the whole division. A gap or a second border here would put
     the two halves back into separate boxes, which is what was wrong before.

     `1fr` rows so the six strips SHARE the plate's height rather than stacking
     to their natural size and leaving a band of empty panel under row 06. The
     card is always the taller half, so without this the index looks like a list
     that stopped early instead of like a rack that fills its bay. */
  .js .wkx__index { border-bottom: 0; border-right: 1px solid var(--c-line); }
  /* Nothing else lives in the plate, so the lane is empty space at any width the
     index is not using — the divider is the index's own right border. */
}
.js .wkx__index li + li .wkx__b { border-top: 1px solid var(--c-line); }

.wkx__b {
  position: relative;
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto;
  align-items: baseline;
  gap: var(--s-3);
  width: 100%;
  padding: 14px clamp(15px, 1.4vw, 21px);
  border: 0;
  background: transparent;
  color: inherit;
  font: inherit;
  text-align: left;
  cursor: pointer;
  /* Rule 6: nothing here moves under the pointer. The row lifts its ground and
     brightens its title, and that is the whole state change — six rows that
     shuffle as you read down them is a control that will not sit still. */
  transition: background var(--d-2);
}
/* Hover lifts a little, armed lifts more. Both LIFT: the plate is --c-panel-2
   and the channel behind it is --c-panel, so painting the armed strip in the
   page's own colour would have recessed it — and a strip that is armed should
   read as raised and lit, not as a hole. */
.wkx__b:hover { background: var(--c-glass); }
.wkx__b.on { background: var(--c-glass-2); }
.js .wkx__b { height: 100%; }
.wkx__b:focus-visible { outline: 1px solid var(--c-line-2); outline-offset: -3px; }

/* The cue mark: a bar up the left edge of the armed strip, the way a channel
   lights on a desk. It survived the track rows it was written for because it is
   this site's way of saying "this one". */
.wkx__b::before {
  content: '';
  position: absolute; left: 0; top: 0; bottom: 0;
  width: 2px;
  background: var(--c-accent);
  transform: scaleX(0); transform-origin: left;
  transition: transform var(--d-2) var(--e-out);
}
.wkx__b.on::before { transform: scaleX(1); }

.wkx__n { color: var(--c-faint); transition: color var(--d-2); }
.wkx__b.on .wkx__n { color: var(--c-accent-txt); }   /* rule 4: this is state */

.wkx__t {
  font-size: var(--t-bd);
  font-weight: 500;
  line-height: 1.2;
  letter-spacing: var(--tr-body);
  color: var(--c-muted);
  transition: color var(--d-2);
}
.wkx__b:hover .wkx__t,
.wkx__b.on .wkx__t { color: var(--c-fg); }
.wkx__y { color: var(--c-faint); }

/* THE DWELL, DRAWN ON THE ROW IT BELONGS TO. Same keyframe and the same
   `--dwell` the cut is timed from, so the bar cannot drift out of step with the
   change it is predicting. It restarts because the class comes off and goes
   back on.

   `.reel.manual` kills it outright rather than pausing it: once someone has
   picked a project there is no timer any more, and a progress bar for a clock
   that is not running is a readout telling you something untrue. */
.wkx__fill {
  position: absolute; left: 0; right: 0; bottom: 0;
  height: 2px;
  background: var(--c-accent);
  transform: scaleX(0); transform-origin: left;
}
.wkx__b.on .wkx__fill { animation: reelFill var(--dwell, 5600ms) linear forwards; }
@keyframes reelFill { from { transform: scaleX(0); } to { transform: scaleX(1); } }

/* ONE CLASS, EVERY REASON THE CLOCK IS NOT RUNNING. `paused` is set by the
   reel's own `stop()`, so the bar freezes for all of them at once: a pointer
   resting on the index, the section leaving the viewport, a backgrounded tab.

   It used to be `.held`, which only covered the pointer — so scrolling away
   left the animation running against a stopped timeout, and it finished and sat
   at 100% (it is `forwards`) while the clock still had a full dwell to hand out
   on return. That is the "even after 100% it takes some time to go to next"
   that was reported, and the reason the bar cannot be a second timer that
   merely happens to share a duration. See the clock in app.js. */
.reel.paused .wkx__b.on .wkx__fill { animation-play-state: paused; }
.reel.manual .wkx__fill { animation: none; opacity: 0; }

/* ---- the stage ----------------------------------------------------------- */
/* Default (no JS): the acts are a normal stack, divided by the same hairline the
   index uses, so the plate still reads as one rack rather than six loose cards. */
.reel__stage { position: relative; display: grid; }
.reel__act + .reel__act { border-top: 1px solid var(--c-line); }

.js .reel__stage > * { grid-area: 1 / 1; }
.js .reel__act {
  opacity: 0;
  pointer-events: none;
  border-top: 0;
  transition: opacity var(--d-2) var(--e-out);
}
.js .reel__act.on {
  opacity: 1;
  pointer-events: auto;
  transition: opacity var(--d-3) var(--e-out) 120ms;
}

/* THE CARD'S ANATOMY, and it is four things.

   Eyebrow top-left, headline figure top-right, title under the eyebrow, then
   the excerpt, then a foot rule carrying the facts and the one link out. The
   figure spans the first two rows so the title sets beside it instead of under
   it; the title is held to column one so a long name can never run in behind a
   five-digit number.

   `1fr` on the last row plus `align-self: end` on the foot pins the rule to the
   bottom of the card, which is what keeps six acts of different lengths looking
   like one card changing its contents rather than six cards of different sizes. */
.reel__act {
  position: relative;
  display: grid;
  grid-template-columns: minmax(0, 1fr) auto;
  grid-template-rows: auto auto auto 1fr;
  column-gap: clamp(20px, 2.4vw, 40px);
  padding: clamp(24px, 2.8vw, 42px);
  isolation: isolate;
}
.reel__kicker { grid-area: 1 / 1; }
.reel__stamp  { grid-area: 1 / 2 / 3 / 3; text-align: right; align-self: start; }
.reel__t      { grid-area: 2 / 1; }
.reel__d      { grid-area: 3 / 1 / 4 / -1; }
.reel__foot   { grid-area: 4 / 1 / 5 / -1; align-self: end; }

/* Per-act texture. All of them are the same faceplate idea at different
   frequencies rather than different backgrounds — the act changes its grain,
   not its material. Sits under everything via z-index. */
.reel__bg {
  position: absolute; inset: 0; z-index: -1;
  opacity: 0;
  transition: opacity var(--d-5) var(--e-out);
}
.on .reel__bg { opacity: 1; }

[data-fx='grid'] .reel__bg {
  background-image:
    linear-gradient(90deg, var(--c-line) 1px, transparent 1px),
    linear-gradient(180deg, var(--c-line) 1px, transparent 1px);
  background-size: 46px 46px;
  mask-image: radial-gradient(120% 90% at 88% 6%, #000 0%, transparent 72%);
  transform: scale(1.06);
  transition: opacity var(--d-5) var(--e-out), transform var(--d-5) var(--e-out);
}
.on[data-fx='grid'] .reel__bg { transform: scale(1); }

[data-fx='kinetic'] .reel__bg {
  background-image: repeating-linear-gradient(
    100deg, var(--c-line) 0 1px, transparent 1px 13px);
  mask-image: radial-gradient(100% 80% at 92% 100%, #000 0%, transparent 70%);
}

[data-fx='code'] .reel__bg {
  background-image: repeating-linear-gradient(
    180deg, var(--c-line) 0 1px, transparent 1px 8px);
  /* Cornered like the other two. It used to fade top-to-bottom across the FULL
     width, which was fine on a tall card and reads as a lined notepad behind the
     copy now that the card is ~310px — 8px scan lines running straight through a
     paragraph. Every texture on this stage now stays out of the reading edge and
     lives in the margin the content does not use. */
  mask-image: radial-gradient(85% 130% at 100% 55%, #000 0%, transparent 72%);
}

/* -----------------------------------------------------------------------------
   THE CARD READS THE CURSOR.

   A second copy of whatever texture the act is using, painted brighter and
   masked to a soft circle at the pointer — so the grid, the stripes or the scan
   lines lift under your hand and go dark again as you leave. app.js writes
   --mx/--my per act on `pointermove`, fine pointers only, which is the same
   mechanism the footer's social tiles use.

   IT IS A SECOND LAYER, NOT A CHANGE TO THE FIRST. Re-masking `.reel__bg` would
   fight the act's own entrance mask (each `data-fx` sets one) and composing the
   two needs `mask-composite`, which is more support surface than this is worth.
   Two layers, one mask each.

   --lit is the fade: 0 at rest, 1 while a pointer is inside the act. Without it
   the glow stays wherever the cursor left the card.

   HUGE, SOFT AND BLURRED. A tight circle reads as a torch with an edge the eye
   locks onto; a radius bigger than the card with no hard stop has no edge to
   find. The blur is doing the real work — the mask controls WHERE the layer
   shows and does nothing to the 1px lines themselves, which stayed razor sharp
   and read as a second crisp grid chasing the pointer. Reach for the filter, not
   a softer mask, if this ever needs to go quieter still. */
.reel__lit {
  position: absolute; inset: 0; z-index: -1;
  opacity: 0;
  transition: opacity var(--d-3) var(--e-out);
  pointer-events: none;
  mask-image: radial-gradient(circle 520px at var(--mx, 50%) var(--my, 50%),
    rgb(0 0 0 / .8) 0%, rgb(0 0 0 / .4) 30%, rgb(0 0 0 / .14) 60%, transparent 94%);
  filter: blur(16px);
}
.on .reel__lit { opacity: calc(var(--lit, 0) * .055); }
[data-fx='grid'] .reel__lit {
  background-image:
    linear-gradient(90deg, var(--c-fg) 1px, transparent 1px),
    linear-gradient(180deg, var(--c-fg) 1px, transparent 1px);
  background-size: 46px 46px;
}
[data-fx='kinetic'] .reel__lit {
  background-image: repeating-linear-gradient(100deg, var(--c-fg) 0 1px, transparent 1px 13px);
}
[data-fx='code'] .reel__lit {
  background-image: repeating-linear-gradient(180deg, var(--c-fg) 0 1px, transparent 1px 8px);
}
/* A soft wash under the pointer as well as the lit lines — the texture alone
   reads as a cursor-shaped hole rather than as a light. Its own element because
   it must NOT take the lit layer's circular mask; it is the glow the mask is
   cutting out of. */
.reel__wash {
  position: absolute; inset: 0; z-index: -1;
  opacity: 0;
  pointer-events: none;
  transition: opacity var(--d-3) var(--e-out);
  background: radial-gradient(circle 600px at var(--mx, 50%) var(--my, 50%),
    color-mix(in srgb, var(--c-fg) 1.4%, transparent) 0%,
    color-mix(in srgb, var(--c-fg) .55%, transparent) 48%,
    transparent 92%);
}
.on .reel__wash { opacity: var(--lit, 0); }

/* ---- act content --------------------------------------------------------- */
/* Each part rises on its own beat once the act is up. The three fx layers are
   excluded BY NAME, and leaving two of them out was a real bug: the rule below
   sets `opacity: 1` on everything it matches, and `.reel__wash` / `.reel__lit`
   are children of the act too — so the stagger was overriding
   `.on .reel__lit { opacity: calc(var(--lit) * .055) }` on specificity and
   pinning the cursor layers wide open whether or not a pointer was near the
   card. That is why the effect kept reading as too strong however far its mask
   and blur were pushed. The delays are set by CLASS, not by nth-child, so
   inserting a layer cannot silently renumber the cascade. */
.js .reel__act > *:not(.reel__bg):not(.reel__wash):not(.reel__lit) {
  opacity: 0;
  transform: translate3d(0, 14px, 0);
}
.js .reel__act.on > *:not(.reel__bg):not(.reel__wash):not(.reel__lit) {
  opacity: 1;
  transform: none;
  transition: opacity var(--d-3) var(--e-out) var(--rd, 0ms),
              transform var(--d-4) var(--e-out) var(--rd, 0ms);
}
.js .reel__act > .reel__stamp { --rd: 120ms; }
.js .reel__act > .reel__t     { --rd: 190ms; }
.js .reel__act > .reel__d     { --rd: 260ms; }
.js .reel__act > .reel__foot  { --rd: 330ms; }

.reel__kicker { color: var(--c-faint); }

.reel__t {
  font-size: clamp(1.5rem, 2.5vw, 2.2rem);
  font-weight: 600;
  line-height: 1.08;
  letter-spacing: var(--tr-mega);
  margin: 10px 0 0 -0.03em;
  text-wrap: balance;
}

/* THE HEADLINE FIGURE, and it is only here when the project has one. Four of the
   six leave this corner empty rather than stamping a year in it and letting it
   pass for a metric. Sized as a corner readout, not as a hero slab — it was
   4.4rem and it was the loudest thing in a section whose subject is the work,
   not the number. */
.reel__stamp { display: grid; justify-items: end; gap: 7px; }
.reel__v {
  font-family: var(--f-display);
  font-size: clamp(1.9rem, 3.2vw, 2.9rem);
  font-weight: 700;
  line-height: .9;
  letter-spacing: var(--tr-mega);
}
.reel__u { color: var(--c-muted); max-width: 26ch; text-align: right; }

.reel__d {
  color: var(--c-muted);
  font-size: var(--t-bd);
  line-height: 1.6;
  max-width: 62ch;
  margin-top: clamp(14px, 1.6vw, 20px);
  text-wrap: pretty;
}
.reel__d b { color: var(--c-fg); font-weight: 600; }

/* The foot: what it is made of on the left, where it lives on the right, on one
   rule. It replaced a row of tag chips that was saying the same words two lines
   above the same link. */
.reel__foot {
  display: flex; align-items: center; justify-content: space-between;
  gap: var(--s-4) var(--s-5); flex-wrap: wrap;
  margin-top: clamp(22px, 2.6vw, 34px);
  padding-top: clamp(14px, 1.5vw, 18px);
  border-top: 1px solid var(--c-line);
}
.reel__facts { color: var(--c-faint); }
.reel__facts i { font-style: normal; color: var(--c-line-2); }

/* The one thing on the card that leaves the page. A link, not a button: there is
   no other control on the card now, and a filled button here would make "go to
   GitHub" the primary action of a section about the work itself. Underline on
   hover, arrow nudges; rule 6, so the words never move. */
.reel__src {
  display: inline-flex; align-items: center; gap: 8px;
  flex: none;
  font-family: var(--f-mono);
  font-size: var(--t-xs);
  font-weight: 500;
  letter-spacing: var(--tr-label);
  text-transform: uppercase;
  color: var(--c-muted);
  border-bottom: 1px solid transparent;
  padding-bottom: 3px;
  transition: color var(--d-2), border-color var(--d-2);
}
.reel__src:hover { color: var(--c-fg); border-bottom-color: var(--c-line-2); }
.reel__src .ico { transition: transform var(--d-2) var(--e-out); }
.reel__src:hover .ico { transform: translate(3px, -3px); }

/* ---- the cut ------------------------------------------------------------- */
/* One amber band crossing the stage on every change. This is the "whoosh": a
   single shared transition used for every cut rather than a bespoke one per act,
   which is the same discipline the lamp-through-glyphs effect follows elsewhere.
   `transform`-only, so the whole cut is composited. It lives inside the stage so
   it crosses the project and not the index — the index is not changing, it is
   the thing that did the changing. */
.reel__sweep {
  position: absolute; inset: 0;
  z-index: 3;
  pointer-events: none;
  opacity: 0;
  background: linear-gradient(
    100deg,
    transparent 0 38%,
    color-mix(in srgb, var(--c-accent) 26%, transparent) 47%,
    color-mix(in srgb, var(--c-accent) 60%, transparent) 50%,
    color-mix(in srgb, var(--c-accent) 26%, transparent) 53%,
    transparent 62% 100%);
  transform: translate3d(-115%, 0, 0);
}
.reel.cutting .reel__sweep {
  opacity: 1;
  animation: reelSweep 720ms var(--e-inout);
}
@keyframes reelSweep {
  from { transform: translate3d(-115%, 0, 0); }
  to   { transform: translate3d(115%, 0, 0); }
}

@media (max-width: 899px) {
  /* Stacked, the index sits on top of the project inside the same plate — still
     one object, just folded. The figure drops to its own line above the title
     rather than fighting a wrapped heading for the same row. */
  .reel__act { grid-template-columns: minmax(0, 1fr); grid-template-rows: auto auto auto auto 1fr; }
  .reel__kicker { grid-area: 1 / 1; }
  .reel__stamp  { grid-area: 2 / 1; text-align: left; justify-items: start; margin-top: 14px; }
  .reel__t      { grid-area: 3 / 1; }
  .reel__d      { grid-area: 4 / 1; }
  .reel__foot   { grid-area: 5 / 1; }
  .reel__u { text-align: left; }
}

@media (max-width: 899px) {
  /* Stacked, the index has no card beside it to borrow a height from, so it is
     capped directly. Four-and-a-bit rows visible is enough to read as a list
     that continues without eating the screen before the card is reached. */
  .js .wkx__index { max-height: min(46vh, 260px); }
}

/* …EXCEPT ON A TOUCH SCREEN, WHERE THE CAP COSTS MORE THAN IT SAVES.
   Six rows are 312px against a 260px cap, so the sixth project sat behind an
   internal scroll — and a nested scroller inside a page you are already
   flicking is the worst control on a phone: it swallows the gesture when you
   meant to move the page, and it hides its content behind an affordance that a
   finger cannot see. The whole thing costs 53px. Show all six. */
@media (pointer: coarse) and (max-width: 899px) {
  .js .wkx__index { max-height: none; overflow: visible; }
}

@media (max-width: 620px) {
  .wkx__b { padding: 12px 14px; }
  .wkx__t { font-size: var(--t-sm); }
}

/* -----------------------------------------------------------------------------
   REDUCED MOTION — the reel stops being a reel.

   `--motion` only reaches transitions, and this is built out of a running timer
   and two keyframe animations, so all of them have to be undone by hand. What is
   left is the no-JS layout: six stacked panels, everything visible, nothing on a
   clock. The script checks the same media query and never starts its timer.
----------------------------------------------------------------------------- */
@media (prefers-reduced-motion: reduce) {
  .js .reel__stage > * { grid-area: auto; }
  .js .reel__act {
    opacity: 1;
    pointer-events: auto;
  }
  .js .reel__act + .reel__act { border-top: 1px solid var(--c-line); }
  .js .reel__act > *:not(.reel__bg):not(.reel__wash):not(.reel__lit) { opacity: 1; transform: none; }
  .reel__bg { opacity: 1; }
  .reel__sweep { display: none; }
  /* The index goes with the timer it was displaying: with every project on
     screen at once there is nothing to arm and nothing counting down, so a rack
     of buttons would just be six links to things already in front of you. */
  .js .wkx__index { display: none; }
  /* Every act is on screen, so the cursor layers would light whichever one you
     happened to be over. */
  .reel__lit, .reel__wash { display: none; }
}

/* =============================================================================
   CH 07 — CONTACT  (patch bay)

   No form. A form needs a backend to be honest, and every one of these is a
   channel that already works: tap the number and the phone dials.

   THE BAY CARRIES ONLY THINGS THAT REACH A PERSON. It used to list eight rows,
   five of which were Spotify / YouTube / GitHub / Instagram / SoundCloud —
   already in the hero legend AND in the footer's social bar, so the section
   that should be the most decisive on the page read as a third link dump. The
   profiles belong to the footer; this owns email, phone, WhatsApp, LinkedIn
   and the résumé, and each row got the room it deserves as a result.
   ============================================================================= */

/* Centre-aligned across the two columns. With five rows instead of eight the
   bay is shorter than it was, and top-aligning left the pitch sitting against
   the head with a screen of nothing under the button. */
/* ---- the patch bay --------------------------------------------------------
   Two columns: what to say on the left, where to say it on the right. It was
   stacked for one round and that was worse — correct, but it turned the tightest
   section on the page into three separate bands. Compact is right here.
--------------------------------------------------------------------------- */
.patch { display: grid; gap: var(--s-7); }

@media (min-width: 900px) {
  .patch {
    grid-template-columns: minmax(0, 1fr) minmax(0, 1fr);
    gap: clamp(40px, 5vw, 88px);
    /* START, never CENTER. Centred, the taller column set the height and the bay
       floated up until its first line sat above the headline — the section that
       closes the page opened with an email address. */
    align-items: start;
  }
  /* AND THE RULE LANDS ON THE HEADLINE'S CAP LINE, not on the top of its box.
     A heading's em box starts well above the letters you can actually see, so a
     hairline aligned to the box reads as sitting HIGHER than the words beside
     it — which is exactly what "the links are too much on top" was describing.
     Space Grotesk's cap height is ~0.7em, and the line box adds (1.08 - 1)/2 of
     leading above that, so the visible top of the word is about 0.19em down.
     Derived from `--t-xxl`, so it follows the type instead of being typed in. */
  .patch__rows { margin-top: calc(var(--t-xxl) * 0.19); }
}

/* ONE LINE. It was three label/value rows on hairlines, which is a spec sheet
   dropped into the middle of a paragraph — a lot of furniture for three short
   facts, in the one column that should feel like a person talking. Now it is a
   status strip: lamp, three facts, dot separators, no rules at all. */
.patch__state {
  display: flex; flex-wrap: wrap; align-items: center;
  gap: 8px 12px;
  margin-top: clamp(22px, 2.6vw, 32px);
  color: var(--c-faint);
  line-height: 1.4;
}
.patch__state .led { flex: none; margin-right: 2px; }
/* A drawn dot rather than a typed middot: it never inherits the label's
   letter-spacing and cannot end a wrapped line looking like a typo. */
.patch__dot {
  flex: none;
  width: 3px; height: 3px; border-radius: 50%;
  background: var(--c-line-2);
}
.patch__act { margin-top: clamp(26px, 3vw, 38px); }

/* ---- the rows -------------------------------------------------------------
   The MARK leads and the VALUE is the primary text — the thing you read and act
   on — with the classifier demoted to a quiet tag beside the arrow. A mail glyph
   says "email" faster than the word does, so the icon carries the labelling and
   the word becomes confirmation. `auto 1fr auto auto` anchors the row to both
   edges with the slack inside the value, which is the one part that should
   absorb it.
--------------------------------------------------------------------------- */
.patch__rows { display: grid; border-top: 1px solid var(--c-line); }
.patch__row {
  display: grid;
  grid-template-columns: auto minmax(0, 1fr) auto auto;
  align-items: center;
  gap: var(--s-4);
  padding: clamp(15px, 1.7vw, 20px) clamp(10px, 1.2vw, 14px);
  border-bottom: 1px solid var(--c-line);
  color: var(--c-fg);
  transition: background var(--d-2);
}
/* No padding shift. The row lights, the value brightens and the arrow moves —
   the words themselves stay where they are (rule 6). */
.patch__row:hover { background: var(--c-glass); }

.patch__i {
  width: 19px; height: 19px;
  fill: currentColor;
  color: var(--c-faint);
  transition: color var(--d-2);
}
.patch__row:hover .patch__i { color: var(--c-fg); }

.patch__v {
  font-size: var(--t-md); min-width: 0;
  overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
  color: var(--c-muted);
  transition: color var(--d-2);
}
.patch__row:hover .patch__v { color: var(--c-fg); }
.patch__sub { font-style: normal; color: var(--c-faint); font-size: var(--t-sm); }
.patch__k { color: var(--c-faint); }
.patch__go { color: var(--c-faint); transition: color var(--d-2), transform var(--d-2) var(--e-out); }
.patch__row:hover .patch__go { color: var(--c-fg); transform: translate(3px, -3px); }

@media (max-width: 560px) {
  /* THE LABEL MOVES UNDER THE VALUE RATHER THAN DISAPPEARING. Hiding it was the
     first instinct — the glyph says what the row is — but WhatsApp and Phone
     carry the SAME NUMBER, so without the word the two rows are identical apart
     from a 19px icon. */
  .patch__row {
    grid-template-columns: auto minmax(0, 1fr) auto;
    grid-template-areas: 'i v go' 'i k go';
    gap: 2px var(--s-3);
  }
  .patch__i { grid-area: i; }
  .patch__v { grid-area: v; font-size: var(--t-bd); }
  .patch__k { grid-area: k; }
  .patch__go { grid-area: go; }
}

/* =============================================================================
   THE STATEMENT — between the last channel and the footer.

   Deliberately NOT a channel: it carries the head grammar but no number, and
   that missing CH is exactly how you know it is not part of the rack. It is
   the sleeve note.

   The first version was one big line with a rule and three words under it, and
   it was bland for a specific reason: it had no STRUCTURE and it said nothing
   the page had not already said. This one is built like the rest of the site —
   a head plate, a statement on a cue mark, and three indexed columns — and the
   columns now carry what each role actually does, so the section adds a fact
   instead of repeating the hero's legend in a bigger font.
   ============================================================================= */

.say {
  border-top: 1px solid var(--c-line);
  /* Top padding kept small on purpose: unlike a channel, this head is not
     sticky and is not pulled up through its padding, so any top padding here
     shows as a gap AND stacks on the previous channel's bottom padding. A
     channel's head sits flush to its top edge; this makes the Statement's head
     sit the same way, with the Process channel's bottom space as the divider.
     The generous bottom padding stays — it is the breathing room before the
     footer. */
  padding-block: clamp(18px, 2.4vw, 34px) clamp(64px, 8vw, 128px);
  background: var(--c-bg);
}
.say__head {
  display: flex; align-items: center; gap: var(--s-4);
  padding-bottom: 14px;
  border-bottom: 1px solid var(--c-line);
  /* Same as `.ch .ch__head`: full-bleed rule, chrome inset for the label. */
  margin-inline: calc(var(--gut) * -1);
  padding-inline: var(--gut-chrome);
}

/* The cue mark: the same amber bar that runs up a hovered track row, holding
   the one sentence the site is actually about. */
.say__q {
  position: relative;
  margin: clamp(40px, 5vw, 72px) 0 0;
  padding-left: clamp(20px, 2.4vw, 34px);
  font-family: var(--f-display);
  font-size: clamp(1.75rem, 4.4vw, 3.7rem);
  font-weight: 400;
  line-height: 1.1;
  letter-spacing: var(--tr-mega);
  color: var(--c-muted);
  max-width: 20ch;
  text-wrap: balance;
}
.say__q::before {
  content: '';
  position: absolute; left: 0; top: .12em; bottom: .12em;
  width: 2px; border-radius: 2px;
  background: var(--c-accent);
}
/* Only the claim carries weight; the setup stays quiet, exactly as the hero's
   slot does. Emphasis without state is --c-fg, never the accent. */
.say__q b { color: var(--c-fg); font-weight: 600; }

.say__roles {
  display: grid;
  grid-template-columns: repeat(auto-fit, minmax(min(100%, 240px), 1fr));
  gap: clamp(24px, 3vw, 48px);
  margin-top: clamp(48px, 6vw, 88px);
}
.say__role {
  display: grid; gap: var(--s-3); align-content: start;
  padding-top: var(--s-4);
  border-top: 1px solid var(--c-line-2);
}
.say__role h3 { font-size: var(--t-lg); font-weight: 600; letter-spacing: var(--tr-head); }
.say__role p { color: var(--c-muted); font-size: var(--t-sm); max-width: 34ch; }

/* =============================================================================
   TAP TARGETS — one block, because it is one rule.

   Audited on a 390×844 phone with touch emulation: 101 interactive elements
   measured under 38px in one axis or both. Almost none of them are buttons —
   they are TEXT LINKS, and a text link is exactly as tall as its line box, which
   at `--t-xs` is eleven pixels. On a mouse that is a generous target. On a
   finger it is a miss, and a miss on a link that opens a record or a repo reads
   as the site being broken rather than as the reader being imprecise.

   EVERY FIX HERE IS PADDING OR A MIN-HEIGHT, NEVER A FONT SIZE. The console
   voice is 11.4px mono by design and it renders at 3× on these devices; the
   problem was never that the words were too small to read, it was that the
   HITBOX was too small to hit. Padding also costs nothing visually on a link
   that already sits in its own row.

   `(pointer: coarse)` and not a width query: what decides how big a target has
   to be is what is pointing at it. A narrow desktop window still has a mouse,
   and a 44px control in a 58px bar is a button that has eaten its own strip.

   THIS BLOCK LIVES AT THE END OF THE LAST STYLESHEET AND HAS TO. It started in
   base.css, where three of its rules silently lost: `.rec__stop` is `32px` in
   sections.css, same specificity, later file — so the override was read first
   and thrown away. An override sheet that is not last is not an override.
   ============================================================================= */
@media (pointer: coarse) {
  /* Row-level links: the line box is the target, so give it height directly. */
  .cut__t,
  .reel__src,
  .hero__links a,
  .foot__links a,
  .foot__top,
  .plat__b,
  .ln { min-height: 38px; display: inline-flex; align-items: center; }

  /* `.cut__t` is a block-level title inside a grid cell — inline-flex would
     collapse it to its text and lose the ellipsis it relies on. */
  .cut__t { display: flex; }

  .hero__links a,
  .foot__links a { padding-block: 12px; }

  /* The filter bar. 25px buttons in a row you are meant to press repeatedly. */
  .solo { min-height: 38px; padding-inline: 14px; }

  /* Icon buttons that were sized for a cursor. */
  .cut__go { min-width: 38px; min-height: 38px; }
  .rec__stop { width: 38px; height: 38px; }
  .foot__logo { padding: 6px; }

  /* The brand is the one link in the bar that is not already a 44px control. */
  .nav__brand { min-height: 44px; display: inline-grid; align-content: center; }

  /* Sized in sections.css, so these need to be read after it. */
  .rec__stop { width: 38px; height: 38px; }
  .bench__all { min-height: 38px; display: inline-flex; align-items: center; }
  /* "Terms" is 33px of word. The height is the axis that matters in a horizontal
     row, but a floor on both is one declaration and costs nothing — the dot
     separators sit OUTSIDE the box, so a wider box does not move them. */
  .foot__links a { min-width: 38px; justify-content: center; }
  .nav__brand { min-width: 38px; }
  .foot__logo { min-width: 38px; justify-items: center; }
  .rec__step { width: 44px; height: 44px; }
}
