/* Cross-document transitions. Chrome/Edge/Safari 18+ animate; Firefox just navigates. */
@view-transition { navigation: auto; }

/* ══ RESET ═══════════════════════════════════════════════════════ */
*, *::before, *::after { box-sizing: border-box; }
* { margin: 0; }
img { display: block; max-width: 100%; height: auto; }
button { font: inherit; color: inherit; background: none; border: 0; cursor: pointer; }
a { color: inherit; text-decoration: none; }
ol, ul { list-style: none; padding: 0; }

/* page itself never scrolls — .scroll does */
html, body { height: 100%; overflow: hidden; }

body {
  font-family: var(--font-sans);
  font-size: var(--text-body);
  /* Fallback only — every text level names its own leading in BILINGUAL
     TYPOGRAPHY. A ratio, not a grid box: this one has to survive whatever size
     an unnamed element turns out to be. */
  line-height: var(--lh-hud-latin);
  color: var(--fg-primary);
  background: var(--bg-base);
  -webkit-font-smoothing: antialiased;
  transition: background-color var(--dur-slow) var(--ease-out),
              color var(--dur-slow) var(--ease-out);
}

.sr-only, .skip:not(:focus) {
  position: absolute; width: 1px; height: 1px; padding: 0;
  overflow: hidden; clip-path: inset(50%); white-space: nowrap;
}
.skip:focus {
  position: fixed; z-index: 100; inset-block-start: var(--space-4);
  inset-inline-start: var(--space-4); padding: var(--space-3) var(--space-4);
  background: var(--signal); color: var(--fg-on-signal); font-family: var(--font-mono);
}
:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 3px; }

/* Selection is the last place a browser default was still showing through, and
   it was showing through in blue.

   Signal orange is budgeted at under 2% of the page and reserved for live data,
   the progress rail and hover feedback. Selection does not spend that budget:
   it exists only while someone is actively dragging across text, and it is a
   response to what they just did — the same category as hover, which is
   already one of the three. Painting it orange makes the rule more legible,
   not less.

   The pair is the one the token build already measures at 7.05:1, so the text
   stays readable even where the selected run was orange to begin with (a
   category label, say) — the colour is forced, not blended. */
::selection {
  background: var(--signal);
  color: var(--fg-on-signal);
  text-shadow: none;
}

/* Tapping anything on a phone drew the OS default flash — a grey-blue box with
   square corners, over a page that has neither. Same signal, dialled down, so
   the feedback stays but stops looking borrowed. */
:root { -webkit-tap-highlight-color: color-mix(in srgb, var(--signal) 22%, transparent); }

/* height of one fixed HUD row + its padding — full-height sections keep clear of it */
:root {
  --hud-safe: calc(var(--space-4) * 2 + 1.4em);
  /* written by site.js: pointer position and the grid's scroll drift.
     Defaults park the halo off-screen until a pointer actually moves. */
  --scene-fade: 1;
}

/* ══ LANGUAGE VISIBILITY ═════════════════════════════════════════ */
/* One language is present in the DOM but hidden — keeps text selectable,
   readable by screen readers and indexable, unlike CSS-generated content. */
html[data-lang="zh"] [lang="en"],
html[data-lang="en"] [lang="zh"] { display: none; }

.sprite { position: absolute; width: 0; height: 0; overflow: hidden; }

/* language toggle: current language solid, the other dimmed */
#langBtn { display: inline-flex; gap: 4px; align-items: baseline; }
.lang__sep { opacity: 0.35; }
html[data-lang="zh"] .lang__en, html[data-lang="en"] .lang__zh { opacity: 0.4; }

/* ══ BILINGUAL TYPOGRAPHY ════════════════════════════════════════
   Tracking, weight and leading are a function of writing system × type size,
   and they are decided HERE — nowhere else in this stylesheet. Latin display
   is Syne, which runs wide and has to be pulled in hard once set in caps;
   CJK is full-width by construction and wants the opposite sign.

   Two different hooks, and the split is forced by the cascade, not taste:
   · letter-spacing / weight / case hang off the inline [lang] span, because a
     rule that hits the span directly always beats one inherited from its block.
   · line-height hangs off the BLOCK element via html[data-lang], because an
     inline span's line-height loses to the block's own strut — setting it on
     the span silently does nothing.
   Do not re-declare any of these on individual components.              */

/* ── tracking: on the language span ── */
[lang="zh"] { letter-spacing: var(--track-body-cjk); }
[lang="en"] { letter-spacing: var(--track-body-latin); }

.hero__title [lang="en"]                            { letter-spacing: var(--track-display-1-latin); }
.hero__title [lang="zh"]                            { letter-spacing: var(--track-display-1-cjk); }
.detail__title [lang="en"], .beat__role [lang="en"] { letter-spacing: var(--track-display-2-latin); }
.detail__title [lang="zh"], .beat__role [lang="zh"] { letter-spacing: var(--track-display-2-cjk); }
.sectionHead h2 [lang="en"], .node__title [lang="en"],
.detail__note h2 [lang="en"], .detail__say [lang="en"] { letter-spacing: var(--track-title-latin); }
.sectionHead h2 [lang="zh"], .node__title [lang="zh"],
.detail__note h2 [lang="zh"], .detail__say [lang="zh"] { letter-spacing: var(--track-title-cjk); }

/* ── digits in display type ──────────────────────────────────────
   Syne has no lining figures: its digits sit at x-height, so "BASE 24H" came
   out with a 24 a third shorter than the letters around it. It is not an
   OpenType problem — `lnum` and `font-feature-settings: "lnum"` render
   identically to the default, checked against the live face. So the generator
   wraps digit runs in `.num` (build-pages.mjs) and they get Archivo, which has
   lining figures. Only the family changes; letters stay Syne.

   The weight is declared per display context rather than on `.num` itself,
   because `.num` also lands in body copy, where the family is already Archivo
   and the swap is a no-op. Archivo 700 against Syne 800 is the closest the two
   get — 500 read visibly thinner than the caps beside it, so 700 was added to
   the Google Fonts request for this. */
.num { font-family: var(--font-num); }
.hero__title .num, .detail__title .num, .beat__role .num,
.sectionHead h2 .num, .node__title .num, .detail__note h2 .num,
.detail__say .num, .detail__next a .num, .tool__name .num { font-weight: 700; }

/* ── weight + case: on the language span (Syne tops out at 800) ── */
.hero__title [lang="en"], .detail__title [lang="en"], .beat__role [lang="en"],
.sectionHead h2 [lang="en"], .node__title [lang="en"], .detail__note h2 [lang="en"],
.detail__say [lang="en"] {
  font-weight: 800; text-transform: uppercase;
}
/* 钉钉进步体 ships a single weight (400). Asking for 900 makes the browser
   synthesise a fake bold, which smears the typeface's built-in slant. */
.hero__title [lang="zh"], .detail__title [lang="zh"], .beat__role [lang="zh"],
.sectionHead h2 [lang="zh"], .node__title [lang="zh"],
.detail__note h2 [lang="zh"], .detail__say [lang="zh"] { font-weight: 400; }

/* ── leading: on the block, keyed off the document language ── */
/* Every value here is a token. Two tiers, and the split is not stylistic:
   ·  Running text (meta / body / lead) is pinned to the 4px baseline as an
      ABSOLUTE rem box. A ratio times a fluid clamp() can never land on a grid,
      so the size ranges were narrowed until one fixed box reads well at both
      ends — see the leads in tokens.json.
   ·  Labels, headings and display sizes keep tuned ratios. display-1 alone
      runs 44→128px; no single line box serves both ends of that, and headings
      are set optically rather than on the body's rhythm. */
html[data-lang="en"] .hero__title    { line-height: var(--lh-display-1-latin); }
html[data-lang="zh"] .hero__title    { line-height: var(--lh-display-1-cjk); }
html[data-lang="en"] .detail__title,
html[data-lang="en"] .beat__role     { line-height: var(--lh-display-2-latin); }
html[data-lang="zh"] .detail__title,
html[data-lang="zh"] .beat__role     { line-height: var(--lh-display-2-cjk); }
html[data-lang="en"] .sectionHead h2, html[data-lang="en"] .detail__say,
html[data-lang="en"] .node__title,
html[data-lang="en"] .detail__note h2 { line-height: var(--lh-title-latin); }
html[data-lang="zh"] .sectionHead h2, html[data-lang="zh"] .detail__say,
html[data-lang="zh"] .node__title,
html[data-lang="zh"] .detail__note h2 { line-height: var(--lh-title-cjk); }

/* lead tier — the long-form paragraphs */
html[data-lang="en"] .statement p, html[data-lang="en"] .beat__desc,
html[data-lang="en"] .detail__intro  { line-height: var(--lh-lead-latin); }
html[data-lang="zh"] .statement p, html[data-lang="zh"] .beat__desc,
html[data-lang="zh"] .detail__intro  { line-height: var(--lh-lead-cjk); }

/* body tier */
html[data-lang="en"] .node__desc, html[data-lang="en"] .detail__note p,
html[data-lang="en"] .media__pitch   { line-height: var(--lh-body-latin); }
html[data-lang="zh"] .node__desc, html[data-lang="zh"] .detail__note p,
html[data-lang="zh"] .media__pitch   { line-height: var(--lh-body-cjk); }

/* meta tier */
html[data-lang="en"] .hero__bio, html[data-lang="en"] .detail__not,
html[data-lang="en"] .detail__cap     { line-height: var(--lh-meta-latin); }
html[data-lang="zh"] .hero__bio, html[data-lang="zh"] .detail__not,
html[data-lang="zh"] .detail__cap     { line-height: var(--lh-meta-cjk); }

/* label tier — mono, uppercase, almost always one line */
html[data-lang="en"] .tool__lv       { line-height: var(--lh-hud-latin); }
html[data-lang="zh"] .tool__lv       { line-height: var(--lh-hud-cjk); }

/* The About paragraph is the site's only block of display-sized prose: set at
   title size but read as running text. The grid tier tops out at 21px so it
   cannot join it, and the title tier's leading is heading leading — far too
   tight to read a paragraph at. It keeps a prose ratio, declared here with
   everything else rather than buried in the component. */
html[data-lang="en"] .outro__about p { line-height: 1.55; }
html[data-lang="zh"] .outro__about p { line-height: 1.72; }

/* Syne sets ~75% wider than the CJK face at the same size, and the career
   stage is only 8 columns. At full display-2 the English role titles broke to
   3 lines unevenly; this pulls them to a stable 2. Visual weight still holds:
   Latin caps read about 28% shorter than CJK glyphs at the same font-size. */
html[data-lang="en"] .beat__role { font-size: calc(var(--text-display-2) * 0.78); }
/* Same problem one size up: "IDEAS MADE" set in Syne caps at full display-1
   filled the 12-column measure to within 3px, so any wider viewport or longer
   word broke it to a second line. 0.88 buys ~12% slack and frees vertical room
   above the headline for the hero artwork. Below ~700px it wraps to two lines
   on its own — forcing one line there would shrink it to ~33px. */
html[data-lang="en"] .hero__title { font-size: calc(var(--text-display-1) * 0.88); }
/* A detail title can be one unbreakable word — "ILLUSTRATOR" is eleven
   characters of Syne 800 with nowhere to wrap. At 375 it measured 391px in a
   342px column and pushed the page sideways; at 320 it was 98px over. The
   clamp cannot help, because its floor is already reached there. Cap it
   against the viewport instead: 7.6vw tracks the column closely enough to fit
   at every width below ~480, and above that the clamp is the smaller of the
   two and takes over on its own. Chinese needs none of this — the same word
   is four glyphs and the face is ~75% narrower. */
html[data-lang="en"] .detail__title { font-size: min(var(--text-display-2), 7.6vw); }
/* On narrow screens the longer role titles still take 2–3 lines. Balance evens
   the line lengths so the ragged edge reads as intentional rather than broken. */
.beat__role, .detail__title, .hero__title { text-wrap: balance; }

/* the load-bearing word carries the signal colour */
.hero__title em { font-style: normal; color: var(--signal); }

/* ══ FIXED LAYER ═════════════════════════════════════════════════ */

/* 12-column guide lines + horizontal rules, drawn with gradients */
/* HUD — full-bleed frame, top row + bottom row, click-through */
/* difference blending keeps the fixed chrome legible over ANY content beneath it —
   white on a dark photo, black on a light one. Colour expression stays in the
   content layer; the instrument layer is monochrome by design. */
.hud {
  position: fixed; inset: 0; z-index: 50;
  display: flex; flex-direction: column; justify-content: space-between;
  padding: var(--space-4) var(--space-edge);
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase;
  pointer-events: none;
  mix-blend-mode: difference;
  color: #fff;
}
.hud a, .hud button { pointer-events: auto; }
.hud__row { display: flex; justify-content: space-between; align-items: baseline; gap: var(--space-4); }
.hud__logo { font-weight: 500; }
.hud__nav { display: flex; gap: var(--space-6); }
.hud__nav a, .hud__nav button { position: relative; padding-block: 2px; }
.hud__nav a::after, .hud__nav button::after {
  content: ""; position: absolute; inset-inline: 0; inset-block-end: 0; height: 1px;
  background: #fff; transform: scaleX(0); transform-origin: left;
  transition: transform var(--dur-fast) var(--ease-out);
}
.hud__nav a:hover::after, .hud__nav button:hover::after { transform: scaleX(1); }
.key { color: rgba(255, 255, 255, 0.5); margin-inline-start: 2px; }
.hud__cell { color: rgba(255, 255, 255, 0.55); font-variant-numeric: tabular-nums; }
.hud__cell--center { flex: 1; text-align: center; }
.hud__cell--right { color: #fff; }

/* right-edge progress rail */
.rail {
  position: fixed; z-index: 30; inset-inline-end: 0; inset-block-start: 50%;
  transform: translateY(-50%);
  width: 56px; padding-inline: 12px;
  pointer-events: none;
  mix-blend-mode: difference;
  color: #fff;
  /* opacity is written by site.js — it fades out over Hero */
  transition: opacity var(--dur-base) var(--ease-out);
}

/* Two background states, blended by scroll position rather than switched:
   dot lattice (the middle five sections) → nothing at all. Hero and the career
   stage both run bare — one has the entrance clip, the other has warp. Opacity
   is written every scroll event by site.js §11, so each handover is a
   continuous dissolve — no threshold, no snap. */
.dots {
  position: fixed; inset: 0; z-index: 0;
  width: 100%; height: 100%;
  pointer-events: none;
  opacity: 0;                /* driven by site.js */
}

/* ══ SCROLL CONTAINER ════════════════════════════════════════════ */
.scroll {
  position: fixed; inset: 0; z-index: 10;
  overflow-y: auto; overflow-x: hidden; overscroll-behavior: contain;
  scrollbar-width: none;
}
.scroll::-webkit-scrollbar { display: none; }
/* containing block for the full-page sticker layer — without this the layer
   would resolve against .scroll, which is viewport-sized, not content-sized */
.scroll__inner { position: relative; }

/* shared 12-col grid for content sections */
.hero, .statement, .tools, .work, .media, .scene__stage, .outro {
  display: grid;
  grid-template-columns: repeat(var(--grid-cols), 1fr);
  gap: var(--space-gutter);
  padding-inline: var(--space-edge);
}

/* ══ 1. HERO ═════════════════════════════════════════════════════ */
.hero {
  position: relative;
  grid-template-rows: auto 1fr auto;
  min-height: 100svh;
  padding-block: calc(var(--hud-safe) + var(--space-12)) calc(var(--hud-safe) + var(--space-6));
}
.hero__eyebrow {
  position: relative; z-index: 1;
  grid-column: 1 / span 3; grid-row: 1;
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase; color: var(--fg-secondary);
}
/* This block sits in columns 5–7, and the character is centred, so it lands on
   his hair every time — not an edge case, the resting frame of the clip. Sampled
   off the video's own pixels the copy ran 1.62:1 in Chinese and 2.10:1 in
   English against a 4.5 requirement; the lit strands reach rgb(161,94,63) while
   the muted grey text sits at rgb(149,146,141).

   Same remedy the About section already uses — lay page colour underneath the
   words rather than dim the whole clip — but feathered to an ellipse instead of
   a hard edge, because here it covers a small block in open frame where a
   rectangle would read as a panel. */
   Both sides have to move. Sampled against the brightest strand the copy sits
   on, even pure bone text only reaches 4.29 — no colour alone clears 4.5 there,
   so the ground has to come down too. And a scrim alone cannot do it either
   with grey text: it would have to be near-opaque, which reads as a panel
   dropped on his hair. Lifting the text one step and laying a soft ground under
   it lets each do half the work and neither be conspicuous. */
.hero__note::before {
  content: ""; position: absolute; z-index: -1;
  /* wide enough that the copy sits inside the solid core, not in the feather */
  inset: -1.1em -2.6em;
  background: radial-gradient(ellipse at 50% 50%,
    var(--bg-base) 62%, rgb(14 13 12 / 0.66) 80%, transparent 100%);
}
.hero__note {
  position: relative; z-index: 1;
  grid-column: 5 / span 3; grid-row: 1;
  font-family: var(--font-mono); font-size: var(--text-meta);
  /* one step up from muted — see the note above ::before */
  color: var(--fg-secondary);
}
.hero__bio {
  position: relative; z-index: 1;
  grid-column: 9 / span 4; grid-row: 1;
  font-size: var(--text-meta); color: var(--fg-secondary);
}
.hero__bio strong { color: var(--fg-primary); font-weight: 500; }

/* IP artwork — sits behind the headline, drifts with pointer */
.hero__ip, .outro__ip {
  position: absolute; inset: 0; z-index: 0;
  display: grid; place-items: center;
  pointer-events: none;
}
/* The About artwork runs full-bleed for the same reason the hero clip does: a
   bounded rectangle has edges to hide, and here his forearms and the sketchbook
   cross three of them. Feathering an edge a limb crosses dissolves the limb
   halfway down the forearm — it reads as broken, not as emerging from the dark.
   Full-bleed leaves only the top and bottom, and the artwork is encoded on the
   page's own base colour so neither of those shows either. */
/* 0.55 → 0.92 on 2026-08-26: the user found the clip too dark, and on desktop
   raising it is nearly free. The copy sits over the left half, where ::after
   paints page colour back at 52–74% — and page colour over page colour is page
   colour whatever the layer's opacity is. What the lift does cost is the right
   half of the frame, where the lamp comes on around 5–7s; sampled 12 frames
   across the loop, the worst the body copy sees is 9.55:1 against a 4.5 bar.
   The phone has no scrim and is set separately below. */
.outro__ip { opacity: 0.92; overflow: hidden; }
.hero__ip { overflow: hidden; }
/* video and its fallback still are alternatives, not siblings in a stack —
   without this the grid gives them a row each and neither lands centred. */
.hero__ip > *, .outro__ip > * { grid-area: 1 / 1; }
.outro__ip img, .outro__ip video {
  /* Exactly the box, since 2026-08-26. This used to be 56x48px oversized so a
     since-deleted pointer drift had margin to move inside without opening a
     strip of bare page along an edge. With the drift gone the oversize only
     bought a tighter crop, and `will-change: transform` promoted a full-bleed
     video to its own layer for a transform nothing writes any more. */
  width: 100%; height: 100%;
  max-width: none;                 /* the global img reset clamps it back to 100% */
  object-fit: cover; object-position: 50% 50%;
}
/* The copy sits over the left half of the frame, and the laptop screen in the
   clip is a blown-out white that lands right under the ends of the lines —
   measured 2.4:1 against body copy, where the token system holds 4.5:1
   everywhere else. Painting the page colour back over that half takes it to
   7.2:1 without dimming the right of the frame at all: the gradient sits
   INSIDE the faded layer, so page colour at 55% over page colour is exactly
   page colour.

   The stops are in container percentages on purpose. The copy always occupies
   7 of 12 columns, so 52% covers it at every desktop width — anchoring the
   crop instead (object-position) fixed 1440 and still failed at 1280, because
   how far the crop slides depends on the viewport's aspect ratio. */
.outro__ip::after {
  content: ""; position: absolute; inset: 0; z-index: 1;
  pointer-events: none;
  background:
    /* the copy sits over the left half — see the contrast note above */
    linear-gradient(to right,  var(--bg-base) 52%, transparent 74%),
    /* The top edge is the seam with the career section. The clip's own top
       band is empty wall running from 4 to 38 out of 255 depending on where
       the off-frame lamp reaches, so it neither matches the page nor stays
       put — fading the first few percent to the page colour lands it exactly.
       It costs nothing here: there is nothing up there but wall. */
    linear-gradient(to bottom, var(--bg-base) 0%,  transparent 7%);
}

/* The hero clip is 16:9 with the character centred and ~15% clear on each
   side, so it can be cropped to the viewport without cutting him. Its
   background is rendered at the page's own base colour, and the radial mask
   dissolves the rectangle's edges — without it the flat fill would blank out
   the grid lines behind in a visible rectangle. */
.hero__media {
  /* Exactly filling the container, with no overflow for the parent to clip.
     An earlier version bled 2% past the edges to hide the clip's outermost
     pixel columns — but measuring showed those columns are not brighter than
     the rest (15 against 13 turns up all over the frame, it is compression
     noise), so that was a fix for a problem that did not exist. What it did
     create was a masked composited layer being clipped by an ancestor, which
     on some GPUs leaves a lit hairline along the clip. */
  position: absolute; inset: 0;
  width: 100%; height: 100%;
  object-fit: cover; object-position: 50% 44%;
}
/* The bottom fade lives here rather than in a mask on the clip itself, for the
   same reason: painting the page colour over the video keeps the video an
   ordinary layer. His torso runs off the frame, so without the fade it ends on
   a straight cut exactly where the next section starts. */
.hero__ip::after {
  content: ""; position: absolute; inset: 0; z-index: 1;
  pointer-events: none;
  background: linear-gradient(to bottom, transparent 74%, var(--bg-base) 97%);
}
/* the only interactive thing on this layer: clicking replays it with sound */
.hero__video { pointer-events: auto; cursor: pointer; }

.hero__title {
  position: relative; z-index: 1;
  grid-column: 1 / -1; grid-row: 3;
  font-family: var(--font-display);
  font-size: var(--text-display-1);
}
.hero__title span { display: block; }
/* Leading, tracking and the English size step are set in BILINGUAL TYPOGRAPHY,
   which is why the font-size above is not the whole story. */

/* ══ 2. STATEMENT ════════════════════════════════════════════════ */
.statement { padding-block: var(--space-bay); }
.statement p {
  grid-column: 3 / span 8;
  font-size: var(--text-lead); color: var(--fg-secondary);
  text-wrap: pretty;
}

/* ══ SHARED SECTION HEAD ════════════════════════════════════════ */
.sectionHead {
  grid-column: 1 / -1;
  display: flex; align-items: baseline; justify-content: space-between;
  gap: var(--space-6); flex-wrap: wrap;
  padding-block-end: var(--space-4);
  border-block-end: 1px solid var(--line-subtle);
}
.sectionHead h2 {
  font-family: var(--font-display); font-size: var(--text-title);
  font-weight: 800;
}
.sectionHead__note {
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase;
  color: var(--fg-muted);
}

/* ══ 3. TOOLS — one horizontal axis, marks alternating above / below ══ */
.tools { padding-block: var(--space-bay); row-gap: var(--space-12); }

.axis {
  grid-column: 1 / -1;
  position: relative; z-index: 1;
  display: flex; align-items: center;
  height: 460px;
  /* Same shape as .timeline and .hud: a full-width box whose contents only
     occupy a checkerboard of it. Without the z-index a sticker drifting past
     paints over the tool links, and without pointer-events: none the empty
     cells swallow the stickers that live in them. Both halves are needed. */
  pointer-events: none;
}
.tool { pointer-events: auto; }
.axis__line {
  position: absolute; inset-inline: 0; inset-block-start: 50%;
  height: 1px; background: var(--line-subtle);
  /* site.js runs this from 0 to 1 as the section arrives. The default is 1, so
     with no JS, or under reduced motion, the axis is simply already drawn. */
  --draw: 1;
  transform: scaleX(var(--draw));
  transform-origin: left center;
}

/* each tool occupies half the axis height and hangs toward the line,
   so the stem length is a constant and never depends on content height */
.tool {
  --stem: 60px;
  position: relative; flex: 1;
  display: flex; flex-direction: column; align-items: center;
  gap: var(--space-3);
  height: calc(50% - var(--stem));
  text-align: center;
  color: var(--fg-secondary);
}
.tool[data-side="up"]   { align-self: flex-start; justify-content: flex-end; }
.tool[data-side="down"] { align-self: flex-end;   justify-content: flex-start; }

/* stem down to the axis */
.tool::before {
  content: ""; position: absolute; inset-inline-start: 50%;
  width: 1px; height: var(--stem); background: var(--line-subtle);
  transition: background-color var(--dur-base) var(--ease-out);
}
.tool[data-side="up"]::before   { inset-block-start: 100%; }
.tool[data-side="down"]::before { inset-block-end: 100%; }

/* node sitting on the axis */
.tool::after {
  content: ""; position: absolute; inset-inline-start: 50%;
  width: 7px; height: 7px; margin-inline-start: -3px;
  border-radius: 50%; background: var(--fg-muted);
  transition: background-color var(--dur-base) var(--ease-out),
              transform var(--dur-base) var(--ease-out);
}
.tool[data-side="up"]::after   { inset-block-start: calc(100% + var(--stem)); margin-block-start: -3px; }
.tool[data-side="down"]::after { inset-block-end: calc(100% + var(--stem)); margin-block-end: -3px; }

/* The mark IS the app tile now — there is no box around it. A 32px near-black
   squircle inside a --bg-raised panel was a dark shape on a dark shape, and
   five of these six tiles are near-black. Letting the tile be the mark also
   means its own corner radius is right, which no CSS radius would have been:
   macOS icons are superellipses, not rounded rectangles.

   Faded at rest, full colour on hover — the same contract the stickers run on,
   and for the same reason. Illustrator's tile is #FF9A00, close enough to the
   signal orange that six saturated logos would make orange mean "decoration"
   here and "interactive" everywhere else. Faded, they read as a toolbar;
   touched, they light up, and orange goes back to being a response. */
.tool__mark {
  display: grid; place-items: center;
  width: 68px; height: 68px;
  filter: saturate(.55) brightness(.82);
  transition: filter var(--dur-base) var(--ease-out),
              transform var(--dur-base) var(--ease-out);
}
.tool__mark img { width: 100%; height: 100%; display: block; }
/* No pointer, no way to wake them — same fallback the stickers use. */
@media (hover: none) { .tool__mark { filter: saturate(.8) brightness(.92); } }
.tool__body { display: flex; flex-direction: column; gap: 2px; }
.tool__name {
  font-family: var(--font-display); font-size: var(--text-body);
  font-weight: 500; color: var(--fg-primary);
}
/* How long it has been in the box, and what part it plays.

   One tick per year. It is a count, not a score — the unit is a year and the
   scale has a zero, which is what separates this from the percentage bars
   these sections usually get, where 85% is 85% of nothing in particular.

   Row LENGTH is tenure, row BRIGHTNESS is how much the work leans on it, and
   the word says the same thing in case the brightness does not carry. The two
   deliberately disagree: the shortest row on this axis is the brightest, and
   belongs to the tool that moved the most. */
.tool__level { display: flex; flex-direction: column; align-items: center; gap: 5px;
  margin-block-start: 6px; }
.tool__ticks { display: flex; gap: 3px; }
.tool__ticks i { width: 3px; height: 3px; border-radius: 50%; background: var(--fg-muted); }
.tool__level[data-lv="core"]  .tool__ticks i { background: var(--fg-primary); }
.tool__level[data-lv="daily"] .tool__ticks i { background: var(--fg-secondary); }
.tool__lv {
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase;
  color: var(--fg-muted);
}

/* the AI entry carries more weight — it is the one tool most designers lack */
.tool--lead { --stem: 60px; }
/* The one tool on this axis most designers do not have is also the only one
   that keeps its colour at rest. That is the emphasis now that there is no
   border to turn orange — and it costs nothing from the signal budget,
   because on this axis the AI tool IS the signal. */
.tool--lead .tool__mark {
  width: 84px; height: 84px;
  filter: none;
}
.tool--lead::after { background: var(--signal); transform: scale(1.3); }

.tool:hover .tool__mark { filter: none; transform: translateY(-4px); }
/* The lead is already at full colour, so "filter: none" would leave it the one
   tool that does not answer the pointer. Give it the only move it has left. */
.tool--lead:hover .tool__mark { filter: brightness(1.08); }
.tool:hover::before { background: var(--signal); }
.tool:hover::after  { background: var(--signal); transform: scale(1.5); }
.tool[data-side="up"]:hover::after,
.tool[data-side="down"]:hover::after { margin-inline-start: -3px; }

/* ══ 4. WORK — one row of cards on an endless left-to-right reel ══ */
.work { padding-block: var(--space-bay); row-gap: var(--space-8); }

.filters {
  grid-column: 1 / -1;
  display: flex; flex-wrap: wrap; gap: var(--space-2);
  /* full-width row; same reason as .axis above */
  position: relative; z-index: 1; pointer-events: none;
}
.filter { pointer-events: auto; }
.filter {
  padding: 6px 12px;
  border: 1px solid var(--line-subtle); border-radius: 2px;
  background: transparent; color: var(--fg-muted);
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase;
  cursor: pointer;
  transition: color var(--dur-base) var(--ease-out),
              border-color var(--dur-base) var(--ease-out),
              background-color var(--dur-base) var(--ease-out);
}
.filter:hover { color: var(--fg-primary); border-color: var(--line-strong); }
.filter.is-on {
  color: var(--fg-on-signal); background: var(--signal); border-color: var(--signal);
}
.filters__count {
  grid-column: 1 / -1;
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase;
  color: var(--fg-muted);
  min-height: 1.4em;
}

/* The reel — one row of cards looping left to right, for ever.

   It replaced a 6.5-screen vertical timeline, which was 42% of the whole page.
   The timeline used the section's height as its medium: a background year read
   off the nearest node, sides alternating down an axis, a stem per card. None
   of that means anything in a single row, so all of it is gone rather than
   half-kept. */
/* The reel scrolls, so its own pseudo-elements would slide off with the cards.
   This frame does not scroll, and carries the edge fades. */
.reel-frame {
  grid-column: 1 / -1;
  position: relative;
  margin-inline: calc(var(--space-edge) * -1);
}
/* Overlays, not mask-image. The site already made that call once for the hero
   clip: covering up is predictable where cutting out turned out to depend on
   the GPU. Page colour over page colour is exactly page colour, so the cards
   dissolve into the margin instead of stopping at a cut edge. */
.reel-frame::before, .reel-frame::after {
  content: ""; position: absolute; inset-block: 0; z-index: 2;
  width: clamp(3rem, 7vw, 8rem);
  pointer-events: none;
}
.reel-frame::before {
  inset-inline-start: 0;
  background: linear-gradient(to right, var(--bg-base), transparent);
}
.reel-frame::after {
  inset-inline-end: 0;
  background: linear-gradient(to left, var(--bg-base), transparent);
}

.reel {
  grid-column: 1 / -1;
  --card-w: 380px;
  --card-h: 470px;
  --gap: var(--space-6);
  position: relative;
  /* Runs off both edges of the screen — the offset now lives on .reel-frame,
     which also fades those edges so the row reads as continuing past them
     rather than being cut at them. */

  /* Motion is native horizontal scroll driven by site.js, not a CSS animation.
     An animation owns the transform outright, so there is nowhere to add the
     drag offset — and being able to push the row by hand while it is paused is
     the point. Scrolling gives dragging, touch swipe and trackpad flicks for
     free, and makes the phone layout the same mechanism rather than a second
     one. */
  overflow-x: auto; overflow-y: hidden;
  overscroll-behavior-x: contain;
  scrollbar-width: none;
  scroll-behavior: auto;              /* smooth would fight the per-frame writes */

  /* Clear bands above and below the row — the stickers live in them, and a band
     has to be TALLER than a sticker, not merely equal: at exactly the sticker's
     height §12 centres it with no slack, so its box lands flush on the card row
     and reads as touching. Top gets the extra because that is where the two
     work stickers sit. */
  padding-block: var(--space-32) var(--space-24);

  /* Full-bleed box with empty bands top and bottom: exactly the shape that left
     dead zones over the sticker layer three times before (.hero__title,
     .outro__label, .timeline). Same fix .hud and the old timeline used — the
     container stops taking events and the row takes its own back. Scrolling is
     unaffected: the wheel targets a card and bubbles to the nearest scrollable
     ancestor, which is still this box. */
  z-index: 1; pointer-events: none;
}
.reel::-webkit-scrollbar { display: none; }

.reel__track {
  display: flex; width: max-content;
  pointer-events: auto;
}
/* The trailing gap belongs to each set rather than sitting between them. With a
   gap between the sets, one set's width would be half the track minus half a
   gap, and the wrap would jump by that much every lap. */
.reel__set { display: flex; gap: var(--gap); padding-inline-end: var(--gap); }
/* an explicit display beats the hidden attribute's own display:none */
.reel__set[hidden] { display: none; }

/* Grabbing is the affordance for "you can push this", and it has to sit on the
   row rather than on .reel so it does not appear over the sticker bands. */
.reel__track {
  cursor: grab;
  /* A drag across the row would otherwise leave a selection sweeping through
     the card copy — easy to miss while selection was browser-blue, impossible
     to miss now it is orange. The full text lives on the detail page anyway. */
  user-select: none;
}
.reel.is-dragging .reel__track { cursor: grabbing; }
.reel.is-dragging .node { pointer-events: none; }   /* keeps hovers from flickering mid-drag */

/* Filtering routinely leaves one or two cards — 7 works across 5 categories. A track
   narrower than the screen cannot loop without opening a hole in itself, so it
   parks instead. */
.reel.is-static { display: grid; justify-items: center; overflow-x: hidden; }
.reel.is-static .reel__track { cursor: auto; }
.node {
  flex: 0 0 var(--card-w); width: var(--card-w); height: var(--card-h);
  position: relative;
  pointer-events: auto;
  transition: transform var(--dur-base) var(--ease-out);
}
.node[hidden] { display: none; }
.node:hover { transform: translateY(-10px); }

/* Overlapping is gone but the cards still need a surface of their own: on a
   moving row, edgeless art would smear into its neighbours. Radius stays near
   the 2px the filter chips use rather than the soft 16px of the reference. */
.node__link {
  height: 100%;
  display: flex; flex-direction: column;
  background: var(--bg-raised);
  border: 1px solid var(--line-subtle); border-radius: 3px;
  overflow: hidden;
  transition: border-color var(--dur-base) var(--ease-out);
}
.node__link:hover { border-color: var(--line-strong); }
.node__media {
  display: block; overflow: hidden;
  background: var(--bg-raised);
  /* Pinned to a ratio rather than given the leftover space. Letting it flex
     made every card's picture a different height: the mono face carries no
     CJK, so a Chinese category falls back to MiSans and stands ~4px taller
     than "Vibe Coding", and the image absorbed the difference. Line boxes are
     set by the glyphs actually used, so no amount of `lh` on the text fixes
     that — `1lh` is the strut, and clamping to it just clipped the text. Fix
     the picture instead and let the few stray pixels fall in the text block,
     which has margin to spare. */
  flex: 0 0 auto; aspect-ratio: 4 / 3;
}
.node__media img {
  width: 100%; height: 100%; object-fit: cover;
  transition: transform var(--dur-slow) var(--ease-out);
}
.node__meta,
.node__title,
.node__desc { padding-inline: var(--space-4); }
.node__meta {
  display: flex; align-items: baseline; gap: var(--space-2);
  margin-block-start: var(--space-3);
  white-space: nowrap; overflow: hidden;
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase;
  color: var(--fg-muted);
}
.node__year { font-variant-numeric: tabular-nums; }
.node__cat { color: var(--signal); }
/* Title and description are pinned to a line count, not left to their content.
   The image takes whatever the text leaves, so a one-line description would
   hand its card a taller picture than its neighbours and the row of thumbnails
   would run ragged along the bottom. Lines rather than pixels because the two
   languages set at different leading (1.58 / 1.75) — `lh` is the only unit
   that is correct in both. */
.node__title {
  margin-block-start: var(--space-2);
  font-family: var(--font-display); font-size: var(--text-lead);
  font-weight: 800;
  min-height: 2lh;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden;
  transition: color var(--dur-base) var(--ease-out);
}
.node__desc {
  margin-block-start: var(--space-2);
  margin-block-end: var(--space-4);
  color: var(--fg-secondary); max-width: none;
  font-size: var(--text-body);
  min-height: 2lh;
  display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2;
  overflow: hidden;
}
.node__go { display: none; }   /* the whole card is the link now */

.node__link:hover .node__media img { transform: scale(1.04); }
.node__link:hover .node__title { color: var(--signal); }

/* ══ 5. MEDIA — compact proof of ongoing output ══════════════════ */
.media { padding-block: var(--space-bay); row-gap: var(--space-8); }
.media__body {
  grid-column: 1 / -1;
  display: grid; grid-template-columns: 5fr 7fr; gap: var(--space-12);
  align-items: start;
}
.media__handle {
  font-family: var(--font-mono); font-size: var(--text-body);
  color: var(--signal);
}
.media__pitch { margin-block-start: var(--space-3); color: var(--fg-secondary); }
.media__stats {
  display: flex; gap: var(--space-8);
  margin-block-start: var(--space-6);
}
.media__stats dt {
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase;
  color: var(--fg-muted);
}
.media__stats dd {
  margin-block-start: 2px;
  /* Not the display face, and not 800. Two things were wrong here: Syne's
     x-height digits made "496" read as lowercase next to the mono labels, and
     the 万 in "20.9万" falls through to AaHouDiHei, which ships one weight —
     at 800 the browser synthesised a fake bold and smeared it, which is the
     one thing CLAUDE.md's red line 7 forbids. Archivo has lining figures and
     MiSans has a real Medium, so at 500 every glyph in this row is a weight
     that actually exists. The size goes up to hold the presence that dropping
     from 800 costs. */
  font-family: var(--font-num); font-size: calc(var(--text-title) * 1.2);
  font-weight: 500; font-variant-numeric: tabular-nums;
}
.media__go {
  display: inline-flex; align-items: center; gap: var(--space-2);
  margin-block-start: var(--space-8);
  padding: 10px 18px;
  border: 1px solid var(--line-strong); border-radius: 2px;
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase;
  transition: color var(--dur-base) var(--ease-out),
              border-color var(--dur-base) var(--ease-out);
}
.media__go svg { width: 16px; height: 16px; transition: transform var(--dur-base) var(--ease-out); }
.media__go:hover { color: var(--signal); border-color: var(--signal); }
.media__go:hover svg { transform: translateX(4px); }
.media__feed { display: grid; grid-template-columns: repeat(3, 1fr); gap: var(--space-3); }
.media__feed img {
  /* 3:4, because that is the ratio Douyin's CDN already crops a cover to
     (323 x 430). Matching it means `cover` has nothing left to crop — at 4/5
     it took about 3% off each end, which is where a cover's title usually
     sits. Swap the covers, check this line. */
  width: 100%; aspect-ratio: 3 / 4; object-fit: cover;
  background: var(--bg-raised);
  transition: transform var(--dur-slow) var(--ease-out);
}
.media__feed li { overflow: hidden; }
.media__feed li:hover img { transform: scale(1.04); }

/* ══ 6. SCENE — 8 screens of scroll, one sticky stage ════════════ */
.scene {
  position: relative;
  height: 500svh;              /* pure scroll budget — 4 beats */
  background: var(--bg-void);
  transition: background-color var(--dur-slow) var(--ease-out);
}
.scene__stage {
  position: sticky; inset-block-start: 0;
  height: 100svh;
  grid-template-rows: auto 1fr;
  align-content: center;
  padding-block: calc(var(--hud-safe) + var(--space-8));
  overflow: hidden;
}
.warp {
  position: absolute; inset: 0; z-index: 0;
  width: 100%; height: 100%;
  /* The stage slides in from the bottom of the viewport, so without this the
     canvas arrives as a hard-edged rectangle. Fading it against the same
     coverage value the dots use makes the two cross-dissolve. */
  opacity: var(--scene-fade);
}
/* darkens the middle so the career copy reads over the starfield */
.scene__stage::before {
  content: ""; position: absolute; inset: 0; z-index: 0;
  background: radial-gradient(ellipse 62% 54% at 50% 50%,
              var(--bg-void) 0%, var(--bg-void) 38%, transparent 74%);
  opacity: calc(0.86 * var(--scene-fade));
  pointer-events: none;
}
.scene__label {
  position: relative; z-index: 1;
  grid-column: 1 / span 3; grid-row: 1;
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase; color: var(--signal);
}
.scene__track {
  position: relative; z-index: 1;
  grid-column: 3 / span 8; grid-row: 2;
  display: grid; align-content: center;
}
.beat {
  grid-area: 1 / 1;                 /* stack all beats, cross-fade between them */
  opacity: 0; transform: translateY(24px);
  will-change: opacity, transform;
}
.beat.is-active { opacity: 1; transform: none; }
.beat__year {
  display: block; font-family: var(--font-mono); font-size: var(--text-meta);
  color: var(--signal); letter-spacing: var(--track-meta);
  margin-block-end: var(--space-3);
}
.beat__role {
  font-family: var(--font-display); font-weight: 800;
  font-size: var(--text-display-2);
  margin-block-end: var(--space-4);
}
.beat__desc {
  max-width: 42ch; font-size: var(--text-lead);
  color: var(--fg-secondary); text-wrap: pretty;
}

/* ══ 7. OUTRO — About ═══════════════════════════════════════════ */
.outro {
  position: relative;
  grid-template-rows: auto 1fr auto;
  min-height: 100svh;
  padding-block: calc(var(--hud-safe) + var(--space-8)) calc(var(--hud-safe) + var(--space-4));
}
.outro__label {
  position: relative; z-index: 1;
  grid-column: 1 / -1; grid-row: 1;
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase; color: var(--signal);
}
.outro__about {
  position: relative; z-index: 1;
  grid-column: 1 / span 7; grid-row: 2;
  align-self: center;
}
.outro__about p {
  /* long-form copy — belongs on the body face, not the single-weight display one */
  font-family: var(--font-sans); font-weight: 500;
  font-size: var(--text-title);
  text-wrap: pretty;
}
.outro__foot {
  position: relative; z-index: 1;
  grid-column: 1 / -1; grid-row: 3;
  display: flex; justify-content: space-between; align-items: baseline;
  gap: var(--space-4);
  margin-block-start: var(--space-12);
  padding-block-start: var(--space-4);
  border-block-start: 1px solid var(--line-subtle);
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase;
}
.outro__social { display: inline-flex; align-items: center; gap: var(--space-2); }
.outro__social svg { width: 14px; height: 14px; transition: transform var(--dur-base) var(--ease-out); }
.outro__social:hover svg { transform: translateX(4px); }
.outro__mail:hover, .outro__social:hover { color: var(--signal); }

/* Both of these are full-bleed grid boxes whose glyphs only occupy one end, so
   each lays an invisible full-width strip over the sticker layer beneath and
   makes anything under it unpickable — a sticker identical to its neighbours
   but dead to the pointer. Neither is interactive; the only thing given up is
   selecting them. Content that genuinely owns its box (.outro__about, and
   .outro__foot with its two links) is deliberately not in this list. */
.hero__title, .outro__label { pointer-events: none; }

/* ══ STICKERS ════════════════════════════════════════════════════ */
/* Spans the whole scroll content and is the LAST child of .scroll__inner, so
   at z-index 0 it paints over the hero clip (also 0, but earlier in the tree)
   while every text block, at z-index 1, still paints over it. That ordering is
   the whole guarantee that a sticker can never swallow a link — no hit-testing
   needed, the cascade does it.
   Opacity rides --scene-fade so the About stickers dissolve out on exactly the
   curve the warp starfield dissolves in on. */
.stickers {
  position: absolute; inset: 0; z-index: 0;
  pointer-events: none;
  --sticker-scale: 1;
  opacity: calc(1 - var(--scene-fade, 0));
}
/* positions are written by site.js §12 — hidden until then, or all 18 would
   flash stacked in the top-left corner on first paint */
.stickers:not(.is-placed) { visibility: hidden; }

.sticker {
  position: absolute; inset-block-start: 0; inset-inline-start: 0;
  /* Defaults, not decoration: --w and --rot arrive inline per sticker, --px
     and --py are written by site.js. Declaring them here keeps every var()
     resolvable even before the script runs — and keeps the build's
     undefined-variable check honest instead of allowlisted. */
  --w: 120px; --rot: 0deg; --px: 0px; --py: 0px; --fit: 1;
  /* --fit is written by site.js when a sticker's gap is shorter than the
     sticker: rather than let it push into the copy, it gives up some size. */
  width: calc(var(--w) * var(--sticker-scale) * var(--fit, 1));
  height: auto;
  pointer-events: auto;
  cursor: grab;
  user-select: none; -webkit-user-drag: none;
  /* translate/rotate/scale as separate properties, not one transform string:
     site.js only ever writes --px/--py, and the hover scale composes on top
     without either side having to know about the other */
  translate: calc(var(--px) - 50%) calc(var(--py) - 50%);
  rotate: var(--rot);
  /* Desaturated by default because DESIGN.md caps signal orange at under 2% of
     the page — 18 saturated stickers would spend that budget many times over
     and the orange would stop meaning "live". Faded, they read as stickers
     that have been on the wall a while; the colour comes back on touch, which
     puts the orange back to work as a response rather than as decoration. */
  filter: saturate(0.38) brightness(0.74)
          drop-shadow(0 5px 9px rgb(0 0 0 / 0.45));
  transition: filter var(--dur-base) var(--ease-out),
              scale var(--dur-base) var(--ease-out);
}
/* Keyed on hover rather than width because the reason is the input, not the
   screen: with no pointer there is no way to bring a sticker back to life, so
   holding them at the desktop's resting value would strand them permanently
   grey. They sit closer to full colour instead — affordable, since touch only
   ever gets nine of the eighteen. */
@media (hover: none) {
  .sticker {
    filter: saturate(0.66) brightness(0.88)
            drop-shadow(0 5px 9px rgb(0 0 0 / 0.45));
  }
}
/* set by site.js when the gap a sticker was authored into has closed up to
   the point where no size would clear the copy */
.sticker.is-cramped { display: none; }

.sticker:hover, .sticker.is-held {
  filter: saturate(1) brightness(1)
          drop-shadow(0 14px 22px rgb(0 0 0 / 0.55));
  scale: 1.06;
}
.sticker.is-held { cursor: grabbing; transition: filter var(--dur-fast) var(--ease-out); }

/* ══ HANGING PUNCTUATION ════════════════════════════════════════
   site.js §15 measures the first glyph's left bearing and writes it here, so a
   heading opening with 「 lines its ink up with the copy underneath instead of
   starting a half-character in. Default 0: no JS, no shift, nothing broken. */
[data-hang] { --hang: 0px; margin-inline-start: calc(var(--hang) * -1); }

/* ══ CURSOR ══════════════════════════════════════════════════════
   A reticle instead of the arrow. The page already talks in instruments — a
   coordinate readout along the bottom, a progress rail down the side, mono
   uppercase labels throughout — and the arrow was the one piece of generic
   desktop furniture left in it. Drawn as SVG so it carries the bone/line
   palette; the dark halo underneath keeps it legible over the bright hero
   character as well as over the near-black page. `crosshair` is the fallback.

   Known trade-off: a url() cursor is a fixed size, so it does not follow the
   OS "large pointer" accessibility setting the way a keyword cursor does. */
body {
  cursor: url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='22' height='22'><g fill='none' stroke-linecap='round'><path d='M11 2v6M11 14v6M2 11h6M14 11h6' stroke='%23000' stroke-opacity='.55' stroke-width='3'/><path d='M11 2v6M11 14v6M2 11h6M14 11h6' stroke='%23f2f0ed' stroke-width='1.2'/></g></svg>") 11 11, crosshair;
}

/* Convention beats styling. Anything that opens something keeps the hand —
   that is the one cursor meaning everybody already shares — and standing
   blocks of copy keep the I-beam so they still read as selectable. Card text
   is deliberately not in that list: the whole card is a link, and an I-beam
   across half of it would argue with that. */
a, button, [role="button"], label, summary { cursor: pointer; }
.hero__bio, .statement p, .beat__desc,
.media__pitch, .outro__about p, .detail__intro, .detail__note p, .detail__code,
.detail__not { cursor: text; }

/* ══ POINTER LABEL ═══════════════════════════════════════════════
   Sits above everything and takes no input. Deliberately NOT inside .hud —
   that layer blends with `difference`, which would invert the chip against
   whatever is behind it. */
.ptr {
  position: fixed; inset-block-start: 0; inset-inline-start: 0;
  z-index: 60; pointer-events: none;
  /* written per pointermove by site.js §14; declared here so the offset is
     valid on the very first frame, before any pointer has moved */
  --x: 0px; --y: 0px;
  transform: translate3d(calc(var(--x) + 18px), calc(var(--y) + 18px), 0);
  display: flex; align-items: center; gap: var(--space-2);
  padding: 5px 9px;
  background: var(--bg-raised);
  border: 1px solid var(--line-subtle); border-radius: 2px;
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase;
  color: var(--fg-primary); white-space: nowrap;
  opacity: 0; transition: opacity var(--dur-fast) var(--ease-out);
}
/* the only orange here — one glyph, well inside the <2% signal budget */
.ptr b { color: var(--signal); font-weight: 400; }
.ptr.is-on { opacity: 1; }

/* No pointer, no label. The chip is built only when the device can hover, but
   this keeps it honest if that ever changes. */
@media (hover: none) { .ptr { display: none; } }
@media (prefers-reduced-motion: reduce) { .ptr { transition: none; } }

/* a card's first job is to open; the gaps between them are where you grab */
.reel .node { cursor: pointer; }

/* ══ ENTRANCE ANIMATION BASE STATES ══════════════════════════════ */
[data-reveal], [data-reveal-lines] span[lang] > span {
  opacity: 0; transform: translateY(20px);
  will-change: opacity, transform;
}

/* ══ RESPONSIVE ══════════════════════════════════════════════════ */
@media (max-width: 900px) {
  .hero, .statement, .tools, .work, .media, .scene__stage, .outro {
    grid-template-columns: repeat(var(--grid-cols-mobile), 1fr);
  }
  .hero { grid-template-rows: auto auto 1fr auto; }
  .hero__eyebrow { grid-column: 1 / -1; grid-row: 1; }
  .hero__note   { grid-column: 1 / -1; grid-row: 2; margin-block-start: var(--space-6); }
  .hero__bio    { grid-column: 1 / -1; grid-row: 3; margin-block-start: var(--space-4); align-self: start; }
  /* A 16:9 frame covering a portrait viewport crops ~34% off each side, and
     the height fits exactly, so only the horizontal anchor does anything here.
     Anchoring on him puts a head the size of the screen directly behind the
     copy; anchoring left of him lands the empty half of the frame under the
     text and leaves just his arm and the sketchbook in the lower corner.
     Body copy goes from 4.7:1 to 7.5:1, and the scene still reads. */
  .outro__ip img, .outro__ip video { object-position: 38% 50%; }
  /* A 16:9 clip laid into a portrait viewport leaves the character tiny.
     Cropping the box to 4:3 trades the empty sides for a bigger character —
     it still clears him, because he starts 17.7% in from the left edge. */
  .hero__ip { place-items: center; }
  /* Full-bleed would crop 74% of a 16:9 clip on a portrait phone and take the
     peace sign with it. Instead the clip is scaled past the viewport and let
     the container clip it: the side edges land off-screen, so only top and
     bottom remain, and those are feathered.

     They have to be feathered because the browser colour-manages video but not
     CSS colours — the clip decodes to rgb(13,13,11) against a body painted
     rgb(14,13,12), a one-step difference that still renders as a visible box.
     On desktop the clip covers the whole hero, so it has no edges to give it
     away; here it does. */
  /* the desktop overlay would fade the headline band instead of the clip here,
     because on mobile the clip is a bounded box rather than the whole hero */
  .hero__ip::after { display: none; }
  .hero__media {
    position: static;
    width: 132%; height: auto; aspect-ratio: 16 / 9;
    margin-inline: -16%;
    -webkit-mask-image: linear-gradient(to bottom, transparent 0, #000 7%, #000 82%, transparent 100%);
            mask-image: linear-gradient(to bottom, transparent 0, #000 7%, #000 82%, transparent 100%);
  }
  /* A 16:9 room covering a 9:16 screen leaves only a third of the frame's
     width visible, and the copy runs the full width over all of it — so the
     horizontal scrim has nothing to protect and is switched off. What is left
     to trade is opacity: at 0.30 the lit desk pulled body copy to 3.7:1.
     Anchoring the crop further left would clear it (9.5:1) but only by
     showing the empty half of the room, which is no picture at all. 0.22
     keeps the desk in view at 5.0:1 — a suggestion of a lit room rather than
     a scene, which is all a phone has space for. */
  /* The phone has no scrim (next line), so here the opacity IS the copy's
     contrast floor. 0.22 → 0.42, and the ceiling was found by measuring rather
     than by eye: 0.50 came back at 5.12:1 over 6 frames, which is 14% of margin
     on a sample that can miss the worst frame of a 10-second loop. 0.42 across
     12 frames holds 6.44:1. */
  .outro__ip { opacity: 0.42; }
  .outro__ip::after { background: none; }
  .hero__title { grid-row: 4; }
  .outro__about { grid-column: 1 / -1; }
  .statement p  { grid-column: 1 / -1; }
  .scene        { height: 400svh; }
  .scene__label { grid-column: 1 / -1; }
  .scene__track { grid-column: 1 / -1; }
  .outro__foot  { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
  .outro__about p { font-size: var(--text-title); }

  /* TOOLS: the horizontal axis becomes vertical, marks stack down one rail */
  .axis { flex-direction: column; align-items: stretch; height: auto; gap: var(--space-8); }
  .axis__line {
    inset-block: 0; inset-inline: 26px auto; width: 1px; height: auto;
    /* The rail is vertical here, so the draw has to be too — scaleX would
       squeeze its 1px width to nothing and the rail would simply vanish.
       The meaning survives the rotation: the process now runs downward. */
    transform: scaleY(var(--draw));
    transform-origin: center top;
  }
  /* match the desktop rules' specificity ([data-side] = 0,2,1) rather than
     fighting them with !important — these come later, so these win */
  .tool[data-side] {
    align-self: stretch; justify-content: flex-start;
    flex-direction: row; align-items: center;
    height: auto; text-align: start; gap: var(--space-4);
    padding-inline-start: 62px;
  }
  .tool[data-side]::before {
    inset-block: 50% auto; inset-inline: 26px auto;
    width: 36px; height: 1px; margin-block-start: -0.5px;
  }
  .tool[data-side]::after {
    inset-block: 50% auto; inset-inline: 26px auto;
    margin-block-start: -3px; margin-inline-start: -3px; margin-block-end: 0;
  }
  .tool__mark { width: 56px; height: 56px; }
  .tool--lead .tool__mark { width: 64px; height: 64px; }
  .tool__body { align-items: flex-start; }
  .tool__level { align-items: flex-start; }

  /* WORK: a thumb has no hover to stop the reel with, so autoplay would mean
     chasing the card you want. site.js leaves the row parked here and it stays
     a strip you push yourself — same scroller, no drift. */
  .reel {
    padding-block: var(--space-8);
    scroll-snap-type: x mandatory;
    padding-inline: var(--space-edge);
    /* the desktop rule turns events off so the empty bands do not smother the
       stickers beneath; there are no work stickers here and the strip has to
       take the swipe itself */
    pointer-events: auto;
  }
  .reel__track { cursor: auto; }
  .node {
    flex: 0 0 clamp(240px, 78vw, 320px); width: clamp(240px, 78vw, 320px);
    height: auto;
    scroll-snap-align: center;
  }
  .node:hover { transform: none; }

  /* MEDIA: stack intro above the feed */
  .media__body { grid-template-columns: 1fr; gap: var(--space-8); }
  .media__stats { gap: var(--space-6); }

  /* rail moves to bottom-right corner on small screens */
  .rail {
    inset-block-start: auto; inset-block-end: var(--space-4);
    transform: none; width: 40px; padding-inline: 8px;
  }
  .rail svg { width: 24px; height: 120px; }

  /* Dragging fights the scroll on touch, so the layer is decoration only here.
     And eighteen of them at 375px is noise — only the nine carrying a data-mx
     get a mobile position, the rest are not laid out at all. */
  .stickers { --sticker-scale: 0.62; }
  .sticker { pointer-events: none; cursor: auto; }
  .sticker:not([data-mx]) { display: none; }
}

@media (max-width: 640px) {
  .hud { font-size: 10px; }
  .hud__nav { gap: var(--space-4); }
  /* pointer coordinates are meaningless on touch — reclaim the space */
  .hud__cell--center { display: none; }
  .hud__row--bottom { justify-content: space-between; }
  .filter { padding: 5px 9px; }
  .media__feed { grid-template-columns: repeat(3, 1fr); gap: var(--space-2); }
}


/* ══ DETAIL PAGE ═════════════════════════════════════════════════ */
.detail {
  display: grid;
  grid-template-columns: repeat(var(--grid-cols), 1fr);
  gap: 0 var(--space-gutter);
  padding-inline: var(--space-edge);
  padding-block: calc(var(--hud-safe) + var(--space-16)) var(--space-bay);
}
.hud__back { display: inline-flex; align-items: center; gap: var(--space-2); }
.hud__back svg { width: 14px; height: 14px; transform: rotate(180deg); transition: transform var(--dur-base) var(--ease-out); }
.hud__back:hover svg { transform: rotate(180deg) translateX(4px); }

.detail__meta {
  grid-column: 1 / -1;
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase;
  color: var(--fg-muted);
}
.detail__meta span + span::before { content: " · "; }
.detail__title {
  grid-column: 1 / -1;
  margin-block-start: var(--space-4);
  font-family: var(--font-display); font-size: var(--text-display-2);
  font-weight: 800;
}
/* ── the tool page's colour field ─────────────────────────────────
   Each tool page takes on that app's own colour at the top and dies back into
   the page before any text starts. This is the one place a flood of colour has
   a claim on this site: the page's subject IS the brand, so the field is the
   content's own property rather than decoration — the same test every other
   background layer here has to pass.

   Three things the naive version gets wrong, all fixed here:

   ·  A plain `tint → black` ramp interpolates in sRGB and passes through a
      desaturated brown halfway down. Interpolating `in oklab` keeps perceived
      lightness moving evenly and never visits that mud.
   ·  Linear falloff reads as a band with a visible bottom edge. The stops
      below follow an ease-out, so the colour is still full near the icon and
      is already gone by the time it reaches the copy.
   ·  A long ramp across near-blacks bands on 8-bit displays no matter how
      many stops it has — that is quantisation, not resolution. The tiled
      turbulence on top dithers it away at a strength you cannot see directly.

   Everything degrades cleanly: no `color-mix`, no field, page as before. */
/* The reference this came from is mostly air: the icon sits high in the colour
   with a long empty drop before the first line of type. That gap is not
   decoration — it is what keeps every text run out of the bright half of the
   field, which is the only reason the contrast works without touching a single
   text colour. */
.detail--tool { padding-block-start: calc(var(--hud-safe) + var(--space-8)); }
.detail--tool .detail__icon { margin-block-end: var(--space-32); }

/* The HUD is `mix-blend-mode: difference`, which works everywhere else on this
   site because nothing but near-black has ever been under it. Over a colour
   field it collapses — difference against a mid-tone returns a mid-tone, and
   the bar measured 1.35:1 on the 剪映 page.

   The first fix was a strip of page colour under the bar. It worked, and it
   cost the thing the field is for: a black band across the top kills the glow.
   So the bar stops adapting and simply goes bone white here, and the FIELD
   takes the constraint instead — every tint is authored at or below the
   luminance where bone white still clears 4.5 on it, checked in
   build-pages.mjs. Normalising the six tints by luminance is also what makes
   them read as one set rather than as six brands shouting at different
   volumes.
   The bar keeps its own `#fff` rather than dropping to bone white: over a
   coloured field those 0.135 of luminance are the difference between 4.07 and
   4.63, and it is not a change anyone can see — difference-of-white over
   near-black renders as pure white already, which is what the bar has looked
   like on every other page since the day it was written. */
.page--field .hud { mix-blend-mode: normal; }

.detail--tool {
  /* Overridden inline per page from content.json. The default is here so a
     page that somehow ships without one gets a quiet neutral field instead of
     an invalid gradient and no field at all. */
  --tint: var(--fg-muted);
  --icon: clamp(112px, 11vw, 156px);
  --field-h: calc(var(--hud-safe) + var(--space-8) + var(--icon) + var(--space-32));
  position: relative; isolation: isolate;
}
.detail--tool::before {
  content: "";
  position: absolute; z-index: -1;
  inset-block-start: 0; inset-inline: 0;
  /* The field ends exactly where the type starts, and it says so in the same
     terms the layout is built from. A fluid height guessed in vw drifted out
     of step with the content: at the small end it cropped the colour off the
     bottom of the icon, at the large end it left colour under the first line.
     Deriving it means the two can never disagree. */
  height: var(--field-h);
  pointer-events: none;
  background-image:
    url("data:image/svg+xml,<svg xmlns='http://www.w3.org/2000/svg' width='160' height='160'><filter id='n'><feTurbulence type='fractalNoise' baseFrequency='0.9' numOctaves='3' stitchTiles='stitch'/><feColorMatrix type='saturate' values='0'/></filter><rect width='160' height='160' filter='url(%23n)' opacity='0.13'/></svg>"),
    linear-gradient(to bottom in oklab,
      color-mix(in oklab, var(--tint) 100%, var(--bg-base)) 0%,
      color-mix(in oklab, var(--tint) 98%, var(--bg-base)) 14%,
      color-mix(in oklab, var(--tint) 88%, var(--bg-base)) 30%,
      color-mix(in oklab, var(--tint) 66%, var(--bg-base)) 46%,
      color-mix(in oklab, var(--tint) 40%, var(--bg-base)) 60%,
      color-mix(in oklab, var(--tint) 20%, var(--bg-base)) 72%,
      color-mix(in oklab, var(--tint) 7%, var(--bg-base)) 82%,
      color-mix(in oklab, var(--tint) 2%, var(--bg-base)) 90%,
      var(--bg-base) 96%);
  background-size: 160px 160px, auto;
  background-repeat: repeat, no-repeat;
  /* `overlay` rather than a plain layer on top. Painted straight, the dither
     lifts the whole field by a constant amount — including the dark tail —
     so it stopped dead at the element's bottom edge and put a visible step
     there, 0.011 of luminance in one pixel row. Measured, not guessed: the
     ramp itself was smooth the whole way down and the step was the noise's
     own boundary. Overlay scales the noise with what is under it, so it works
     hardest through the mid-tones where banding lives and fades to nothing in
     the near-black where the field ends.

     Strength is set by the top bar, not by the banding: at 0.22 the dither's
     bright specks under an 11px glyph pulled the worst pixel behind it to
     4.30, and the flat colour at the very top has no gradient to band in
     anyway. 0.13 clears 4.5 there and still leaves zero repeated rows down
     the ramp, which is the only place banding could show. */
  background-blend-mode: overlay, normal;
}

/* ── the work page's banner ───────────────────────────────────────
   Two layouts again, split at the same boundary they have always been split
   at. The phone / tablet / short-window branch is the ORIGINAL one, restored
   unchanged: the picture is a band, cropped to fill it, held back by an even
   veil, with the copy set underneath. It was restored because "don't crop it"
   costs something real down there — at 375 the whole 16:9 frame is 211px tall
   and the character in it goes tiny. Cropping to a 300px band is what makes
   him readable on a phone, and the brief that replaced it was a desktop brief.

   The desktop branch is the new one. There the picture is PLACED, not fitted:
   its height follows its own ratio, so nothing is ever cropped — full screen
   or not, whichever the window happens to make it. The veil is gone; what is
   left is a feather at the BOTTOM EDGE only, the last 12% dissolving into the
   page colour, which is where the picture's own composition is already
   near-black. And the type sits directly on the picture in bone white.

   Both branches paint the same two layers — picture, then veil — and differ
   only in the veil's stops and the banner's height. One mechanism, two sets of
   numbers; that is why the split costs eleven lines instead of a second
   implementation.

   Legibility on the desktop branch is no longer geometric, and the numbers say
   it does not have to be. Bone white's worst case on the bare picture is
   2.83:1, the fixed top bar where it crosses the orange letterforms; the title
   band is 3.32:1, which clears the 3:1 that type this size needs. Both
   averages are 8:1 and up — the picture is near-black everywhere except the
   lettering. The remaining collisions are a knowing trade: see
   docs/journal.md, 2026-08-24（二）. */
.detail--work {
  /* Phone: the picture is a band and the copy sits under it. */
  --banner-h: clamp(300px, 34vw, 470px);
  position: relative; isolation: isolate;
  padding-block-start: calc(var(--banner-h) + var(--space-12));
}
/* In the banded layout the pair is an ordinary block in the flow, so it needs
   the full-width column its two children used to claim individually. */
.detail--work .detail__lead { grid-column: 1 / -1; }

/* The picture and the veil are separate layers on purpose: the two ends of
   this site want opposite things from the veil — the phone wants it flat and
   even under a band, the desktop wants nothing at all until the last twelfth —
   and one layer cannot be both without restating the image URL in a media
   query. Same height, same box, so they cannot drift apart. */
.detail--work::before,
.detail--work::after {
  content: "";
  position: absolute; z-index: -1;
  inset-block-start: 0; inset-inline: 0;
  height: var(--banner-h);
  pointer-events: none;
}
.detail--work::before {
  /* Relative to THIS stylesheet (/src/styles/), not to the page. Written as
     "../src/assets/…" it resolved to /src/src/assets/ and 404'd — and the
     contrast pass went green precisely because the image was missing, since a
     banner that never paints only makes the page darker. */
  /* `cover` crops the band here. On the desktop branch the box is already the
     picture's own ratio, so the same `cover` fits it exactly and crops nothing
     — which is why that branch does not have to restate this line. */
  background: url("../assets/work-header.webp") 50% 50% / cover no-repeat;
}
.detail--work::after {
  background-image: linear-gradient(to bottom in oklab,
    color-mix(in oklab, var(--bg-base) 38%, transparent) 0%,
    color-mix(in oklab, var(--bg-base) 30%, transparent) 26%,
    color-mix(in oklab, var(--bg-base) 30%, transparent) 46%,
    color-mix(in oklab, var(--bg-base) 48%, transparent) 64%,
    color-mix(in oklab, var(--bg-base) 82%, transparent) 82%,
    var(--bg-base) 100%);
}

/* Desktop: uncropped, feathered at the bottom edge only, type on the picture.
   The width gate is where it has always been. The height gate the old overlay
   layout needed is gone with the block it was protecting: that version reserved
   400–470px for a whole run of copy, so a short window had nothing left; this
   one puts only the meta line and the title on the picture and clamps them to
   the first screen itself. */
@media (min-width: 1200px) {
  .detail--work {
    /* work-header.webp is 1600 × 901. One pair of numbers drives the whole
       branch — swap the picture, change this line. Multiplication rather than
       division because calc() cannot divide by a ratio. */
    --banner-h: calc(100vw * 901 / 1600);
    --lead-inset: clamp(1.25rem, 4vw, 4rem);
    /* Where the BOTTOM of the type block lands: normally the picture's bottom
       edge less the inset. But the picture is 1.78:1 and a laptop viewport is
       about 1.8:1, so on a wide-enough window the uncropped picture is TALLER
       than the screen and its bottom edge is below the fold before the page
       has been touched. The min() holds the title on the first screen when
       that happens — the one thing an uncropped picture is not allowed to
       cost. It is expressed against the same bottom readout row the old
       overlay layout had to clear, not against a fraction of the height. */
    --lead-bottom: min(
      calc(var(--banner-h) - var(--lead-inset)),
      calc(100svh - var(--hud-safe) - var(--space-8)));
  }
  /* Nothing until the last twelfth, then an ease-out into the page colour.
     Stops rather than a second background-size, so both branches stay one
     full-height gradient over one full-height picture. */
  .detail--work::after {
    background-image: linear-gradient(to bottom in oklab,
      color-mix(in oklab, var(--bg-base) 0%, transparent) 88%,
      color-mix(in oklab, var(--bg-base) 12%, transparent) 91.6%,
      color-mix(in oklab, var(--bg-base) 38%, transparent) 94.7%,
      color-mix(in oklab, var(--bg-base) 72%, transparent) 97.4%,
      var(--bg-base) 100%);
  }
  /* A flex column that reaches down to --lead-bottom and pushes its contents to
     the end keeps the title's last line on one baseline no matter how many
     lines it wraps to. That matters here specifically: the language toggle
     changes the line count live, and anchoring from the top would move the
     title every time 中/EN is pressed. */
  .detail--work .detail__lead {
    /* `inset-inline: 0`, NOT the page's edge padding. The base rule gives this
       block `grid-column: 1 / -1` so it behaves in the banded layout, and a
       definite grid position changes what an absolutely positioned element is
       measured against: the containing block stops being the grid container's
       padding box and becomes its GRID AREA, which already starts inside the
       edge padding. Written as `var(--space-edge)` the padding was applied
       twice and the title sat 56px right of every other line on the page —
       measured, after it shipped that way once. The same change makes `100%`
       below resolve against the real column width, which is what the
       seven-column formula assumes. */
    position: absolute; inset-inline: 0; inset-block-start: 0;
    block-size: var(--lead-bottom);
    /* The same 7-of-12 measure every other run of type on this page uses,
       restated by hand because an absolutely positioned block is not a grid
       item and cannot say `grid-column: 1 / span 7`. It is not decoration:
       without it an English title runs the full 1800px of a wide window and its
       last letters land on the white app icons in the picture — measured at
       1.33:1, white on white. Held to seven columns it stays over the dark
       left third. */
    max-inline-size: calc((100% - 11 * var(--space-gutter)) / 12 * 7 + 6 * var(--space-gutter));
    display: flex; flex-direction: column; justify-content: flex-end;
  }
  /* The only line on these pages that would otherwise stay muted grey over a
     picture. Bone white for the same reason the bar is bone white. */
  .detail--work .detail__meta { color: var(--fg-primary); }
}

/* A tool page is a spec card, not an article: no image of its own, so it opens
   on the app icon at full colour. That icon is also what makes the page
   recognisable as a tool page in the first half second — the two templates
   were otherwise identical, while one of them had a tenth of the content. */
.detail__icon {
  grid-column: 1 / -1;
  width: var(--icon, 140px); height: auto;
  margin-block-end: var(--space-8);
  /* Two shadows, and both are TINTED rather than black. A shadow on a coloured
     surface keeps that surface's hue and loses light — a neutral black one
     sits on the field instead of in it, which is exactly what makes a pasted-on
     icon look pasted on. The tight one seats the tile, the soft one gives it
     the height the reference had. */
  filter:
    drop-shadow(0 2px 3px color-mix(in oklab, var(--tint) 22%, rgb(0 0 0 / .42)))
    drop-shadow(0 16px 30px color-mix(in oklab, var(--tint) 16%, rgb(0 0 0 / .34)));
}

/* The counterweight to the paragraph above it: what he will NOT use it for.
   "Proficient in Photoshop" is something anyone can type; "Photoshop is not
   where marks get drawn" is only sayable by someone who knows the edges. Each
   of these points at another tool, which is what ties the six into a kit. */
.detail__not {
  grid-column: 1 / span 7;
  margin-block-start: var(--space-6);
  padding-block-start: var(--space-4);
  border-block-start: 1px solid var(--line-subtle);
  font-size: var(--text-meta); color: var(--fg-muted);
}
.detail__notLabel {
  display: block; margin-block-end: var(--space-2);
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase;
}

/* A tool page opens on the one judgement worth reading if you read nothing
   else. It used to sit on the axis, where six of them turned the quietest
   section of the site into six paragraphs. Here it gets the size it deserves
   and the paragraph below it stops repeating it. */
.detail__say {
  grid-column: 1 / span 7;
  margin-block-start: var(--space-6);
  font-family: var(--font-display); font-size: var(--text-title);
  color: var(--fg-primary);
  text-wrap: balance;
}
.detail__intro {
  grid-column: 1 / span 7;
  margin-block-start: var(--space-8);
  font-size: var(--text-lead); color: var(--fg-secondary);
}
.detail__say + .detail__intro { margin-block-start: var(--space-6); }
.detail__facts {
  grid-column: 9 / span 4;
  margin-block-start: var(--space-8);
  display: flex; flex-direction: column; gap: var(--space-3);
}
.detail__facts div { display: flex; justify-content: space-between; gap: var(--space-4);
  padding-block-end: var(--space-2); border-block-end: 1px solid var(--line-subtle); }
.detail__facts dt { font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase; color: var(--fg-muted); }
.detail__facts dd { font-size: var(--text-meta); text-align: end; }

/* Case notes — the half of a work page that is argument rather than picture.
   Optional in content.json; most entries stop at the lead paragraph. The
   nested grid re-declares the page's 12 columns so a note can span the full
   width and still put its parts on the same lines as everything above. */
.detail__note {
  grid-column: 1 / -1;
  margin-block-start: var(--space-16);
  display: grid;
  grid-template-columns: repeat(var(--grid-cols), 1fr);
  gap: 0 var(--space-gutter);
}
.detail__note h2 {
  grid-column: 1 / span 7;
  font-family: var(--font-display); font-size: var(--text-title);
  text-wrap: balance;
}
.detail__note p {
  grid-column: 1 / span 7;
  margin-block-start: var(--space-4);
  font-size: var(--text-body); color: var(--fg-secondary);
}
/* One column wider than the prose it follows, because mono lines do not wrap
   the way sentences do — and `overflow-x` for the widths where even that is
   not enough. No line-height of its own: this is exactly the unnamed element
   body's fallback ratio exists for. */
.detail__code {
  grid-column: 1 / span 8;
  margin-block-start: var(--space-6);
  padding: var(--space-4) var(--space-6);
  background: var(--bg-raised);
  overflow-x: auto;
  font-family: var(--font-mono); font-size: var(--text-meta);
  color: var(--fg-secondary);
}
.detail__code .detail__c { color: var(--fg-muted); }

.detail__shots {
  grid-column: 1 / -1;
  margin-block-start: var(--space-24);
  display: flex; flex-direction: column; gap: var(--space-12);
}
.detail__shots figure { background: var(--bg-raised); }
.detail__shots img, .detail__shots video { width: 100%; display: block; }
.detail__shots figcaption {
  margin-block-start: var(--space-3);
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase; color: var(--fg-muted);
}
/* A caption that carries a sentence rather than a serial number drops the
   label treatment — uppercasing a whole sentence shouts, and the mono face
   has no Chinese and would fall back mid-caption. The number keeps it. */
.detail__cap {
  font-family: var(--font-sans); font-size: var(--text-meta);
  text-transform: none; color: var(--fg-muted);
}
.detail__shotNo { font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); }
.detail__shotNo::after { content: " · "; }

.detail__next {
  grid-column: 1 / -1;
  margin-block-start: var(--space-32);
  padding-block-start: var(--space-8);
  border-block-start: 1px solid var(--line-subtle);
  display: flex; align-items: baseline; justify-content: space-between; gap: var(--space-6);
}
.detail__next a { display: inline-flex; align-items: center; gap: var(--space-3);
  font-family: var(--font-display); font-size: var(--text-title); font-weight: 800;
  transition: color var(--dur-base) var(--ease-out); }
.detail__next a:hover { color: var(--signal); }
.detail__next svg { width: 22px; height: 22px; }
.detail__next span.detail__nextLabel {
  font-family: var(--font-mono); font-size: var(--text-hud);
  letter-spacing: var(--track-hud); text-transform: uppercase; color: var(--fg-muted); }

@media (max-width: 900px) {
  .detail { grid-template-columns: repeat(var(--grid-cols-mobile), 1fr); }
  .detail__note { grid-template-columns: repeat(var(--grid-cols-mobile), 1fr);
    margin-block-start: var(--space-12); }
  .detail__intro, .detail__facts, .detail__say, .detail__not,
  .detail__note h2, .detail__note p, .detail__code { grid-column: 1 / -1; }
  /* Set the variable, not the width: --field-h is derived from --icon, and
     overriding the width alone put the field 8px out of step with the icon
     it is supposed to end below. */
  .detail--tool { --icon: 104px; }
  .detail__code { padding: var(--space-4); }
  .detail__facts { margin-block-start: var(--space-6); }
  .detail__shots { margin-block-start: var(--space-16); gap: var(--space-8); }
  .detail__next { flex-direction: column; align-items: flex-start; gap: var(--space-3); }
}

/* ══ PICTURE GUARD ═══════════════════════════════════════════════
   The CSS half of site.js §17 — see the comment there for what this does and
   does not stop. `-webkit-user-drag` is the one that matters: without it,
   dragging a work card starts a native image drag partway through the reel
   gesture and the browser takes the pointer. Prefixed only, because that is
   the only form any engine implements. */
img, video { -webkit-user-drag: none; user-select: none; }

/* ══ REDUCED MOTION ══════════════════════════════════════════════ */
@media (prefers-reduced-motion: reduce) {
  *, *::before, *::after {
    animation-duration: 0.01ms !important; animation-iteration-count: 1 !important;
    transition-duration: 0.01ms !important; scroll-behavior: auto !important;
  }
  [data-reveal], [data-reveal-lines] span[lang] > span { opacity: 1; transform: none; }
  .beat { opacity: 1; transform: none; }
  .beat:not(.is-active) { display: none; }
  .warp { display: none; }
  .dots { display: none; }
  ::view-transition-group(*), ::view-transition-old(*), ::view-transition-new(*) { animation: none !important; }
}
